最長共通部分列
概要
$2$ つの数列または文字列に共通する部分列を作るとき、その長さを最大化する問題。
動的計画法を用いて解ける。
ABCでは主にE問題以降で登場する。
典型問題例
- Educational DP Contest F - LCS
- $2$ つの文字列の最長共通部分列そのものを求める基本問題。
コード例
最長共通部分列を求める通常の方法
例えば、次の $2$ つの数列があったとする。
A = {1, 2, 3, 4, 5}
B = {1, 4, 3, 5, 2}
この場合、
C = {1, 4, 5}
という数列は、$A$ からも $B$ からも一部の要素を元の順序を保って選ぶことで作られる。
このような $C$ を、$A$ と $B$ の共通部分列という。
共通部分列の中で最も長いものを、最長共通部分列という。
最長共通部分列は、二次元の動的計画法を用いて求められる。
次のようなDPテーブルを用意する。
dp[i][j] = A の先頭 i 個と B の先頭 j 個の最長共通部分列の長さ
片方の要素数が $0$ 個なら、共通部分列の長さも $0$ である。
そのため、初期値は次のようになる。
$$
dp[0][j]=0,\qquad dp[i][0]=0
$$
A[i] と B[j] が等しい場合、その値を共通部分列の末尾に追加できる。
異なる場合は、どちらか一方を使わない場合の大きい方を採用する。
したがって、遷移は次のようになる。
$$
dp[i+1][j+1]
=
\begin{cases}
dp[i][j]+1 & (A[i]=B[j]) \\
\max(dp[i][j+1],\ dp[i+1][j]) & (A[i]\neq B[j])
\end{cases}
$$
これに従ってDPテーブルを埋めると、次のようになる。
| $B\backslash A$ | ― | $1$ | $2$ | $3$ | $4$ | $5$ |
|---|---|---|---|---|---|---|
| ― | $0$ | $0$ | $0$ | $0$ | $0$ | $0$ |
| $1$ | $0$ | $1$ | $1$ | $1$ | $1$ | $1$ |
| $4$ | $0$ | $1$ | $1$ | $1$ | $2$ | $2$ |
| $3$ | $0$ | $1$ | $1$ | $2$ | $2$ | $2$ |
| $5$ | $0$ | $1$ | $1$ | $2$ | $2$ | $3$ |
| $2$ | $0$ | $1$ | $2$ | $2$ | $2$ | $3$ |
右下の dp[5][5] から、最長共通部分列の長さは $3$ とわかる。
実際の最長共通部分列を復元する場合は、DPテーブルの右下からバックトレースする。
現在見ている要素が A[i-1] と B[j-1] のとき、次のように移動する。
A[i-1]とB[j-1]が同じなら、その値を記録して左上へ進む。- 異なるなら、上と左のうち、DPテーブルの値が現在地と等しい方へ進む。
- 上と左の値がどちらも現在地と等しいなら、どちらへ進んでもよい。
これを $i=0$ または $j=0$ になるまで繰り返すと、最長共通部分列が後ろから順に得られる。
最後に反転すればよい。
この例では、
{1, 4, 5}
を復元できる。
途中で同じ値のマスから別の方向へ進むと、
{1, 3, 5}
を復元する場合もある。
最長共通部分列は $1$ つとは限らないが、どれか $1$ つを求めればよい問題では、どちらも正解である。
C++での実装例は、次のようになる。
vector<int> lcs(const vector<int>& s, const vector<int>& t) {
int m = ssize(s);
int n = ssize(t);
vector<int> result;
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
for (int i=1; i<=m; i++) {
for (int j=1; j<=n; j++) {
if (s[i-1]==t[j-1]) {
dp[i][j] = dp[i-1][j-1]+1;
} else {
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
}
}
}
int i = m;
int j = n;
while (i>0&&j>0) {
if (s[i-1]==t[j-1]) {
i--;
j--;
result.emplace_back(s[i]);
} else if (dp[i-1][j]==dp[i][j]) {
i--;
} else if (dp[i][j-1]==dp[i][j]) {
j--;
}
}
reverse(result.begin(),result.end());
return result;
}
DPテーブルには $(N+1)(M+1)$ 個の状態があり、各状態を $O(1)$ で更新する。
復元のための移動回数は最大で $N+M$ 回である。
- 時間計算量:$O(NM)$
- 空間計算量:$O(NM)$
最長増加部分列へ帰着する方法
最長共通部分列を、最長増加部分列のアルゴリズムへ帰着して求める方法もある。
例えば、次の $2$ つの数列の最長共通部分列を考える。
A = {1, 3, 3, 2, 1}
B = {1, 2, 3, 1, 2}
$B$ の各要素について、同じ値が $A$ の何番目にあるかを後ろから列挙する。
(後ろからなのは、$B$ の同じ $1$ 要素に対して、$A$ の複数の位置を同時に選んでしまうことを防ぐため)
B[0]=1 に対応する位置を追加すると、次のようになる。
vec = {4, 0}
B[1]=2 に対応する位置を追加する。
vec = {4, 0, 3}
B[2]=3 に対応する位置を追加する。
vec = {4, 0, 3, 2, 1}
同様に、残りの要素についても追加する。
vec = {4, 0, 3, 2, 1, 4, 0, 3}
この vec の最長増加部分列を求めると、例えば次の列が得られる。
{0, 1, 3}
これは $A$ から選ぶ位置を表す。
対応する値を $A$ から取り出すと、
{1, 3, 2}
となり、$A$ と $B$ の最長共通部分列が得られる。
C++での実装例は、次のようになる。
lis は、最長増加部分列のページで紹介した関数とする。
vector<int> lcs(const vector<int>& s, const vector<int>& t) {
int m = ssize(s);
int n = ssize(t);
map<int,vector<int>> mp;
for (int i=m-1; i>=0; i--) mp[s[i]].emplace_back(i);
vector<int> vec;
for (int j=0; j<n; j++) for (int i : mp[t[j]]) vec.emplace_back(i);
vector<int> vec2 = lis(vec);
vector<int> result(vec2.size());
for (int i=0; i<ssize(vec2); i++) result[i] = s[vec2[i]];
return result;
}
値が広く分布していて、一致する組が少ない場合には、通常の $O(NM)$ の動的計画法より高速である。
特に双方が同じ要素からなる順列の場合は、$O(N\log N)$ で求められる。
一方、全ての要素が同じ場合、vec の要素数は $NM$ 個になる。
この場合、最長増加部分列の計算に $O(NM\log(NM))$ かかるため、通常の動的計画法より遅くなる。
注意点
部分列は連続していなくてもよい
部分列は、元の順序を保って要素を選んだ列であり、選んだ要素が元の数列内で連続している必要はない。
連続する部分だけを選ぶ最長共通部分文字列とは別の問題である。
関連アルゴリズム
動的計画法
少し小さい問題の答えを利用して、より大きい問題を順に解くアルゴリズム。
最長共通部分列では、$2$ つの列の先頭から使う要素数を状態として持つ。
バックトレース
終了状態から逆向きにたどり、具体的な選び方などを復元する方法。
最長共通部分列そのものを復元するために使う。
最長増加部分列
数列の一部を元の順序を保ったまま選び、狭義単調増加する部分列の長さを最大化する問題。
値が一致する位置を並べた数列の最長増加部分列を求めることで、最長共通部分列を求められる。