0Pricing
DSA Interview Prep · レッスン

編集距離(Levenshtein距離)

挿入・削除・置換操作に対する編集距離の漸化式を導き、長さの異なる文字列の組み合わせについてDPテーブルを埋めます。

「編集距離(Levenshtein距離)」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン3/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

編集距離の問題

編集距離(レーベンシュタイン距離、LeetCode 72)では、ある文字列を別の文字列に変換するために必要な挿入、削除、置換の操作回数の最小値を求めます。たとえば、'horse'を'ros'に変換するには、'h'→'r'を置換(horse→rorse)、'r'を削除(rorse→rose)、'e'を削除(rose→ros)するため、3回の操作が必要です。編集距離は、スペルチェッカー、DNAアラインメント、あいまいマッチングの基礎となっています。

# Allowed operations:
# Insert: 'abc' → 'abXc' (insert X)
# Delete: 'abc' → 'ac' (delete b)
# Replace: 'abc' → 'aXc' (replace b with X)

# horse → ros: 3 operations
# 1. horse → rorse (replace h with r)
# 2. rorse → rose  (delete r at index 1)
# 3. rose  → ros   (delete e)
print('Edit distance horse→ros: 3')
print('Edit distance intention→execution: 5')

DPの状態と漸化式

dp[i][j]を、word1[:i]とword2[:j]の間の最小編集距離と定義します。word1[i-1] == word2[j-1]の場合は操作が不要なので、dp[i][j] = dp[i-1][j-1]です。それ以外の場合は、3つの操作の最小値を取ります。挿入はdp[i][j-1] + 1、削除はdp[i-1][j] + 1、置換はdp[i-1][j-1] + 1です。基本ケースは、dp[i][0] = i(word1をすべて削除)とdp[0][j] = j(word2をすべて挿入)です。

def edit_distance(word1, word2):
    m, n = len(word1), len(word2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    # Base cases
    for i in range(m+1): dp[i][0] = i  # delete all of word1
    for j in range(n+1): dp[0][j] = j  # insert all of word2
    for i in range(1, m+1):
        for j in range(1, n+1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]  # no cost
            else:
                dp[i][j] = 1 + min(
                    dp[i][j-1],    # insert
                    dp[i-1][j],    # delete
                    dp[i-1][j-1]   # replace
                )
    return dp[m][n]

print(edit_distance('horse', 'ros'))          # 3
print(edit_distance('intention', 'execution')) # 5

3つの操作を理解する

3つの操作は、DPテーブル上の移動に直接対応しています。置換 dp[i-1][j-1]+1 — 両方の文字を対応させますが、コストを1支払います。word1からの削除 dp[i-1][j]+1 — word1から文字を削除します(テーブル上で上に移動)。word1への挿入 dp[i][j-1]+1 — word2の文字に対応する文字を挿入します(左に移動)。3つの値の最小値が最適な編集経路を与えます。

# Visualise the DP table for 'cat' → 'cut'
# dp[i][j] = min edits for word1[:i] vs word2[:j]

word1, word2 = 'cat', 'cut'
m, n = len(word1), len(word2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
    for j in range(1, n+1):
        if word1[i-1]==word2[j-1]: dp[i][j]=dp[i-1][j-1]
        else: dp[i][j]=1+min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1])
print('  ', ' '.join(' '+word2))
for i, row in enumerate(dp):
    print((' ' if i==0 else word1[i-1]), row)

O(n)への空間最適化

編集距離で必要なのは、現在の行と直前の行だけです。サイズがn+1の1次元配列を使用し、各セルを更新する前にdiagonalの値(dp[i-1][j-1])を別に保持します。左から右へ処理します。temp = dp[j](古い値はdp[i-1][j])として保存し、dp[j](削除)、dp[j-1](挿入)、diagonal(置換)を使ってdp[j]を更新します。

def edit_distance_1d(word1, word2):
    m, n = len(word1), len(word2)
    dp = list(range(n + 1))  # initial row: 0,1,2,...,n
    for i in range(1, m + 1):
        diag = dp[0]       # dp[i-1][0]
        dp[0] = i          # dp[i][0] = i
        for j in range(1, n + 1):
            temp = dp[j]   # dp[i-1][j] before overwrite
            if word1[i-1] == word2[j-1]:
                dp[j] = diag
            else:
                dp[j] = 1 + min(dp[j],     # delete
                                dp[j-1],   # insert
                                diag)      # replace
            diag = temp
    return dp[n]

print(edit_distance_1d('horse', 'ros'))          # 3
print(edit_distance_1d('intention', 'execution')) # 5

編集操作の復元

実際の編集系列を復元するには、(m, n)からDPテーブルをバックトラックします。各セルで、word1[i-1] == word2[j-1]の場合は斜めに移動します(操作なし)。それ以外の場合は、3つの隣接セルのうち最小値を与えたものを特定し、対応する操作を記録します。これにより編集スクリプトが逆順に得られるため、最終的な答えでは反転します。

def edit_ops(word1, word2):
    m, n = len(word1), len(word2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0]=i
    for j in range(n+1): dp[0][j]=j
    for i in range(1,m+1):
        for j in range(1,n+1):
            if word1[i-1]==word2[j-1]: dp[i][j]=dp[i-1][j-1]
            else: dp[i][j]=1+min(dp[i][j-1],dp[i-1][j],dp[i-1][j-1])
    ops, i, j = [], m, n
    while i>0 or j>0:
        if i>0 and j>0 and word1[i-1]==word2[j-1]:
            i-=1; j-=1
        elif j>0 and (i==0 or dp[i][j-1]<=dp[i-1][j] and dp[i][j-1]<=dp[i-1][j-1]):
            ops.append(f'Insert {word2[j-1]} at pos {i}'); j-=1
        elif i>0 and (j==0 or dp[i-1][j]<=dp[i][j-1] and dp[i-1][j]<=dp[i-1][j-1]):
            ops.append(f'Delete {word1[i-1]} at pos {i-1}'); i-=1
        else:
            ops.append(f'Replace {word1[i-1]} with {word2[j-1]}'); i-=1; j-=1
    return list(reversed(ops))

for op in edit_ops('horse', 'ros'): print(op)

1回の編集距離の判定

面接でよく出る、より簡単な問題として、2つの文字列がちょうど1回の編集操作分だけ異なるかを判定するものがあります。これはDPを使わずにO(n)で解けます。両方の文字列を同時に走査します。不一致が見つかったら、3つの操作(s1の文字を1つスキップ、s2の文字を1つスキップ、両方を1つずつスキップ)をすべて試し、残りの部分が同一か確認します。不一致が2回発生したらFalseを返します。距離が1以下かどうかだけを知りたい場合、この貪欲法によって完全なO(mn) DPを避けられます。

def is_one_edit_distance(s, t):
    m, n = len(s), len(t)
    if abs(m - n) > 1: return False
    if m > n: return is_one_edit_distance(t, s)  # ensure m <= n
    for i in range(m):
        if s[i] != t[i]:
            if m == n:
                return s[i+1:] == t[i+1:]   # replace
            else:
                return s[i:] == t[i+1:]     # insert into s (delete from t)
    return m + 1 == n  # all matched, lengths differ by 1

print(is_one_edit_distance('ab', 'acb'))   # True (insert c)
print(is_one_edit_distance('ab', 'ab'))    # False (zero edits)
print(is_one_edit_distance('ab', 'abc'))   # True (append c)
print(is_one_edit_distance('ab', 'xyz'))   # False

編集距離とLCSの比較

すべての操作を許可した編集距離とLCSは、文字列の類似性を相補的に捉える方法です。編集距離は差異を数え、LCSは類似性を数えます。挿入と削除だけを許可し(置換は不可)、編集距離はm + n - 2×LCSとなります。置換を許可する場合、DPは少し異なります。一致時は対角方向の値dp[i-1][j-1]をそのまま使い(コスト0)、置換時はdp[i-1][j-1]+1を使います。どちらのアルゴリズムもO(mn)時間で実行されます。

def lcs_len(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1,m+1):
        for j in range(1,n+1):
            if s1[i-1]==s2[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[m][n]

def edit_insert_delete_only(s1, s2):
    return len(s1) + len(s2) - 2 * lcs_len(s1, s2)

print(edit_insert_delete_only('sea', 'eat'))  # 2
print(edit_distance('sea', 'eat'))            # 2 (same here: replace not needed)

あいまい文字列マッチング

編集距離は、実際のあいまいマッチングを支えています。スペルチェッカーは、入力された単語から編集距離1または2以内にある修正候補を提案します。大規模に処理する際の課題は、O(mn × dict_size)回の比較を避けることです。解決策には、BK木(編集距離用の距離木)、n-gramインデックス、Bitapのような近似文字列マッチングアルゴリズムがあります。基礎となるDPを理解すると、これらの高水準なツールの効率について考えやすくなります。

def spell_suggest(typed, dictionary, max_dist=2):
    '''Return words in dictionary within max_dist edits of typed.'''
    suggestions = []
    for word in dictionary:
        if abs(len(typed) - len(word)) <= max_dist:
            if edit_distance(typed, word) <= max_dist:
                suggestions.append(word)
    return suggestions

def edit_distance(w1, w2):
    dp = list(range(len(w2)+1))
    for i,c1 in enumerate(w1,1):
        prev = i
        for j,c2 in enumerate(w2,1):
            temp = dp[j]
            dp[j] = prev if c1==c2 else 1+min(dp[j],prev,dp[j-1])
            prev = temp
    return dp[len(w2)]

dictionary = ['horse', 'worse', 'house', 'morse', 'nurse']
print(spell_suggest('harse', dictionary))  # horse, worse, house, morse

重み付き編集距離

アプリケーションによっては、操作ごとに異なるコストを設定します。たとえば、隣接する文字の入れ替え(よくあるタイプミス)は、完全な置換よりも低いコストにする場合があります。Damerau-Levenshtein距離は、4つ目の操作として入れ替えを追加したものです。DPでは、word1[i-1]==word2[j-2]かつword1[i-2]==word2[j-1]の場合に、dp[i-2][j-2]+1も確認するよう拡張します。これにより、キーボード入力のタイプミスをより正確にモデル化できます。

def damerau_levenshtein(s, t):
    m, n = len(s), len(t)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0]=i
    for j in range(n+1): dp[0][j]=j
    for i in range(1,m+1):
        for j in range(1,n+1):
            cost = 0 if s[i-1]==t[j-1] else 1
            dp[i][j] = min(
                dp[i-1][j]+1,     # delete
                dp[i][j-1]+1,     # insert
                dp[i-1][j-1]+cost # replace
            )
            # Transposition
            if i>1 and j>1 and s[i-1]==t[j-2] and s[i-2]==t[j-1]:
                dp[i][j] = min(dp[i][j], dp[i-2][j-2]+1)
    return dp[m][n]

print(damerau_levenshtein('CA', 'ABC'))   # 2
print(damerau_levenshtein('ab', 'ba'))    # 1 (transposition)

DNA配列アラインメント

バイオインフォマティクスでは、DNA配列アラインメントに編集距離の派生手法を使用します。Needleman-Wunschアルゴリズムは、LCSや編集距離と密接に関係するグローバルアラインメントDPです。一致には+1、不一致には-1、ギャップ(挿入・削除)にはペナルティを与えます。Smith-Watermanの派生手法はローカルアラインメントを行います(最もよく一致する部分文字列を探します)。どちらも同じテーブル充填構造を持つO(mn)のDPアルゴリズムです。

def needleman_wunsch(seq1, seq2, match=1, mismatch=-1, gap=-1):
    m, n = len(seq1), len(seq2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0] = i * gap
    for j in range(n+1): dp[0][j] = j * gap
    for i in range(1,m+1):
        for j in range(1,n+1):
            score = match if seq1[i-1]==seq2[j-1] else mismatch
            dp[i][j] = max(
                dp[i-1][j-1] + score,  # align
                dp[i-1][j] + gap,      # gap in seq2
                dp[i][j-1] + gap       # gap in seq1
            )
    return dp[m][n]

print(needleman_wunsch('GATTACA', 'GCATGCU'))  # alignment score

編集距離に取り組む面接での方法

面接で編集距離を問われた場合は、次のように進めます。(1) 許可される操作(挿入・削除・置換)を確認します。(2) DPの状態を明確に定義します。(3) 3つのケースと漸化式を明示的に書き出します。(4) 基本ケースとしてdp[i][0]=iとdp[0][j]=jを示します。(5) O(n)空間への最適化に言及します。(6) 時間があれば、'cat'→'cut'(置換1回)のような小さな例をたどって検証します。標準的な計算量は、時間がO(mn)、空間がO(mn)からO(n)です。

# Clean interview solution
def min_distance(word1, word2):
    m, n = len(word1), len(word2)
    # O(n) space with rolling row
    dp = list(range(n + 1))
    for i in range(1, m + 1):
        diag = dp[0]   # dp[i-1][0]
        dp[0] = i
        for j in range(1, n + 1):
            temp = dp[j]
            if word1[i-1] == word2[j-1]:
                dp[j] = diag
            else:
                dp[j] = 1 + min(dp[j], dp[j-1], diag)
            diag = temp
    return dp[n]

# Time: O(mn), Space: O(n)
print(min_distance('horse', 'ros'))          # 3
print(min_distance('intention', 'execution')) # 5
print(min_distance('', 'abc'))               # 3
print(min_distance('abc', ''))               # 3

クイックチェック

このレッスンのData Structures & Algorithms — Coding Interview Prepに関する理解度を確認します。

レッスンのまとめ

このレッスンでは、編集距離 dp[i][j] = min(dp[i][j-1]+1, dp[i-1][j]+1, dp[i-1][j-1]+cost) は、一致時のcost=0、それ以外では1となること、基本ケース dp[i][0]=i と dp[0][j]=j は、空文字列への変換または空文字列からの変換を表すこと、そしてO(n)空間への最適化では、diagonal変数を使ったローリング1次元配列を使用することを学びました。次は、同じローリング配列のテクニックを適用し、2D DPテーブルの空間をO(mn)からO(min(m,n))へ削減します。

よくある質問

「編集距離(Levenshtein距離)」レッスンは無料ですか?

はい。「編集距離(Levenshtein距離)」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「編集距離(Levenshtein距離)」で何を学びますか?

挿入・削除・置換操作に対する編集距離の漸化式を導き、長さの異なる文字列の組み合わせについてDPテーブルを埋めます。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン3/4です。

「編集距離(Levenshtein距離)」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. グリッド上のUnique Pathsと最小経路和
  2. 最長共通部分列
  3. 編集距離(Levenshtein距離)
  4. 2D DPの空間最適化
← DSA Interview Prepに戻る