Longest Common Subsequence
2つの文字列 text1 と text2 が与えられます。文字列の部分列とは、いくつかの文字を元の順序のまま残し、それ以外を削除したものです。残す文字は隣り合っている必要はありません。両方の文字列の部分列である最長の文字列の長さを返してください。共通する文字がない場合は 0 を返してください。
関数
- text1string
- 最初の文字列
- text2string
- 2つ目の文字列
- 戻り値integer
- 最長共通部分列の長さ
制約
1 ≤ text1.length ≤ 10001 ≤ text2.length ≤ 1000- どちらの文字列も、小文字の英字のみを含みます。
例
- 入力
- text1 = "stone"text2 = "longest"
- 出力
- 3
- 説明
- o、n、e は両方の単語でこの順に現れるため、長さ 3 の共通部分列は
oneです。longestでは文字 s と t は最後に現れますが、stoneでは最初に現れるため、それらを使う共通部分列はstだけで、より短くなります。
- 入力
- text1 = "pear"text2 = "reap"
- 出力
- 2
- 説明
eaは両方の単語に現れます。2つの単語ではpとrがeaの反対側にあるため、どちらもそれに加わることはできません。したがって、答えは2です。
- 入力
- text1 = "cat"text2 = "dog"
- 出力
- 0
- 説明
- 2つの単語には共通する文字がないため、共通部分列は長さ0の空のものだけです。
提出時に隠しテスト+19件
発展問題
最長共通部分列の長さだけでなく、そのうちの1つ自体を返すことはできますか?
ヒント
1つずつ開いてください。開くたびに少しずつ答えに近づきます。
それぞれの文字列の最後の文字を見てください。2つの文字が等しい場合と異なる場合で、答えについてそれぞれ何が言えますか?
文字が一致する場合はそれらをペアにし、残りは両方の文字列からその文字を取り除いた、同じ問題になります。一致しない場合は、少なくともどちらか一方は使われないため、それぞれを取り除いて試し、より良い答えを選びます。
同じ接頭辞のペアが何度も出てきます。接頭辞の長さの各ペア
(i, j)に対する答えを表に格納し、答えが 0 である空の接頭辞から始めて、行ごとに埋め、最後のセルから答えを読み取ります。
解説
文字を貪欲に照合しても、うまくいきません。1つの文字がもう一方の文字列の複数の位置と一致することがあり、最初の一致がより良い選択肢を妨げることがあります。たとえば、cab の c を abc の末尾の c と組み合わせると、a と b に対応する文字が残りませんが、これを飛ばせば ab が見つかります。この問題を解く鍵は、2つの接頭辞に対する答えが、それぞれ少し短い接頭辞に対する答えだけで決まることです。(n+1) × (m+1) 個の数を並べた表を使えば、すべての組み合わせを一度ずつ計算でき、各行で参照するのは1つ上の行だけなので、2行あれば十分です。
再帰を使って先頭の文字を比較する
正しいが、最大のテストでは終わらない
考え方
lcs(i, j)を、接尾辞text1[i:]とtext2[j:]に対する答えとします。それぞれの先頭の文字を見てみましょう。等しければ、その2文字を組にします。この組を使わない最長共通部分列でも、最初の組をこの組に置き換えて、長さを短くせずに済みます。したがって、答えは1 + lcs(i+1, j+1)です。
文字が異なる場合、両方を使うことはできません。それぞれをもう一方の文字列の後ろにある文字としか対応づけられず、組が交差してしまうためです。そこで、どちらか一方を削除できます。答えはmax(lcs(i+1, j), lcs(i, j+1))です。どちらかの接尾辞が空なら、共通するものはないので、答えは0です。
不一致のたびに2つの呼び出しが始まるため、処理が遅くなります。2つの文字列に共通する文字がない場合、どちらかの文字列が尽きるまで、すべての呼び出しで不一致になります。そして、呼び出しの数は2つの文字列を交互に並べる方法の数のように増加します。20文字の文字列2つでは、呼び出し回数は約2.8 × 10^11回です。大きなテストでは、それぞれの文字列に1000文字あります。それでも、異なる組(i, j)は(n+1) × (m+1)個しかないため、ほとんどの呼び出しは以前の呼び出しを繰り返しています。
アルゴリズム
iとjから始まる接尾辞について、lcs(i, j)を記述します。iまたはjが対応する文字列の末尾を過ぎている場合は、0 を返します。text1[i] == text2[j]の場合は、1 + lcs(i+1, j+1)を返します。- そうでない場合は、
max(lcs(i+1, j), lcs(i, j+1))を返します。 - 答えは
lcs(0, 0)です。
def longestCommonSubsequence(text1, text2):
def lcs(i, j):
# The longest common subsequence of text1[i:] and text2[j:]
if i == len(text1) or j == len(text2):
return 0
if text1[i] == text2[j]:
return 1 + lcs(i + 1, j + 1)
return max(lcs(i + 1, j), lcs(i, j + 1))
return lcs(0, 0)接頭辞の表を埋める
考え方
状態。 dp[i][j]を、text1の最初のi文字とtext2の最初のj文字の最長共通部分列とします。接頭辞を使うことで、インデックス0を空文字列として扱えます。
漸化式。 2つの接頭辞の最後の文字、text1[i-1]とtext2[j-1]を比較します。等しければ、それらを組み合わせます。dp[i][j] = dp[i-1][j-1] + 1。異なる場合は、どちらか一方を除きます。dp[i][j] = max(dp[i-1][j], dp[i][j-1])。これは、末尾から考えた再帰と同じ考え方です。基本ケース: 空の接頭辞はどの文字列とも共通部分を持たないため、0行目と0列目は0です。順序: 各セルは上のセル、左のセル、左上斜めのセルを参照するため、行ごとに左から右へ埋めていけば、参照先は常に計算済みです。答えはdp[n][m]です。
pearとreapの場合、peaに対応する行は[0, 0, 1, 2, 2]です。reaに対応するセルが2なのは、aとaが一致するためです。したがって、peとreに対応するセルの値1に1を加えます。最後のセルでは、pearとreapを比較し、rとpは異なるため、隣接する2つのセルのうち大きい方の値である2を取ります。
表のセル数は(n+1) × (m+1)で、それぞれの計算にかかる時間は一定です。1000文字の文字列2つの場合、約10^6ステップです。再帰をメモ化したバージョンも同じセルを埋めますが、再帰呼び出しの深さは最大でn + mになり、Pythonなどの言語ではデフォルトのコールスタックをオーバーフローさせます。
アルゴリズム
(n+1) × (m+1)個のゼロからなるテーブルdpを作成します。iを1からnまで、jを1からmまで動かし、text1[i-1]とtext2[j-1]を比較します。- 一致した場合は、
dp[i][j] = dp[i-1][j-1] + 1を設定します。 - それ以外の場合は、
dp[i][j] = max(dp[i-1][j], dp[i][j-1])を設定します。 dp[n][m]を返します。
def longestCommonSubsequence(text1, text2):
n, m = len(text1), len(text2)
# dp[i][j]: the longest common subsequence of text1[:i] and text2[:j].
# Row 0 and column 0 stay 0: an empty prefix has nothing in common.
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[n][m]2 行だけを残す
考え方
表の行 i は、行 i-1 とその行のそれ以前のセルだけを読み取ります。行の処理が終わると、それより上の行が再び読み取られることはありません。そのため、完成した行用の prev と、埋めている行用の cur の2つの配列を使い、各行の後にそれらを入れ替えます。漸化式と順序はまったく同じままです。
2つの文字列の最長共通部分列は、どちらの文字列が先かを問わないため、それらを入れ替えて、行が短い方の文字列に沿って進むようにできます。すると各行が保持する数値は min(n, m) + 1 個になります。最大の入力でも、100万個のセルの代わりに1001個で済み、処理量は同じ 10^6 ステップです。
各行の最初の要素は、短い方の文字列の空の接頭辞を表すため、0のままにする必要があります。答えは、最後に完成した行の最後の要素です。
アルゴリズム
text2がtext1より長い場合は、入れ替えます。prevとcurを作成します。それぞれm + 1個のゼロを含みます。ここで、mは短い方の長さです。text1の各文字について、上の行の値としてprevを参照し、表と同じ規則でcur[1..m]を埋めます。prevとcurを入れ替えます。prev[m]を返します。
def longestCommonSubsequence(text1, text2):
if len(text2) > len(text1):
text1, text2 = text2, text1 # keep the rows as short as the shorter string
m = len(text2)
# prev[j]: the answer for the previous prefix of text1 and text2[:j]
prev = [0] * (m + 1)
for ch in text1:
cur = [0] * (m + 1)
for j in range(1, m + 1):
if ch == text2[j - 1]:
cur[j] = prev[j - 1] + 1
else:
cur[j] = max(prev[j], cur[j - 1])
prev = cur
return prev[m]
落とし穴と境界ケース
漸化式は短く、バグの多くは1つずれていたり、誤った位置に一致を加えたりするものです。
- テーブルのインデックスと文字列のインデックスを混同すること。セル
dp[i][j]ではtext1[i-1]とtext2[j-1]を比較します。これは、行0が空の接頭辞を表すためです。 - 一致したとき、
dp[i-1][j-1]ではなく、max(dp[i-1][j], dp[i][j-1])に1を加えること。これでは同じ文字を2回使う場合があります。aaとaの場合、1ではなく2が返されてしまいます。 - 2つのポインターで貪欲に一致させること。
cabとabcでは2つの c が対応付けられて結果は1になりますが、abなら2になります。 - 読み取り中の行に書き込むこと。2行を使う場合、上の行の値はすべて
prevから取得し、cur[0]は0のままにする必要があります。 - 誤って最長共通部分文字列を解いてしまうこと。部分列では文字を飛ばせますが、部分文字列では飛ばせません。
- 長さ1000文字の文字列に対して、再帰をメモ化すること。呼び出しの深さは2000に達し、Pythonのデフォルトの上限である1000を超えます。
よくある質問4
最長共通部分列の時間計算量はどれくらいですか?
テーブルを使う解法の実行時間はO(n × m)です。ここで、nとmは2つの文字列の長さです。接頭辞の各ペアにつき1つのセルを埋めます。テーブル全体を使う場合のメモリ使用量はO(n × m)ですが、2行だけを使えばO(min(n, m))になります。テーブルを使わない単純な再帰は指数時間になります。
最長共通部分列と最長共通部分文字列の違いは何ですか?
部分列は順序を保てば文字を飛ばしてもかまいませんが、部分文字列は隣り合う文字のまとまりです。stone と longest の最長共通部分列は one (3) ですが、最長共通部分文字列は on (2) です。部分文字列の場合も似た表を使いますが、一致しないときは隣の値をコピーする代わりにセルを 0 にリセットします。
最長共通部分列そのものをどのように出力しますか?
dp[n][m]から逆にたどります。現在のセルにある2つの文字が一致する場合、その文字は答えに含まれるので、記録して左上に斜めに進みます。一致しない場合は、上または左の隣接セルのうち、値が大きい方へ進みます。最後に、記録した文字を逆順にします。2行バージョンでは前の行を破棄しているため、これを行うことはできません。LCSはdiffツールや編集距離とどのように関連していますか?
ファイルの2つのバージョンの差分では、行の最長共通部分列を見つけ、それ以外の行はすべて追加または削除として表示します。同様に、一方の文字列をもう一方の文字列に変換するために必要な挿入と削除の最小数は n + m - 2 × LCS です。編集距離では文字の置換も許されるため、各セルに3つ目の選択肢を持つ独自の表を使います。
似た問題
同じ考え方を使う問題です。2〜3問解くとパターンが身につきます。
Python
def longestCommonSubsequence(text1, text2):
# ここにコードを書いてくださいケース1
ケース2
ケース3
入力
text1 = "stone" text2 = "longest"
期待値
3