数日前に顔の質問のガチョウのフィールドを見て、アルゴリズムは、計画の最もダイナミックな部分で、最後の質問は、この問題を議論するために記事を書くために特別に今日距離計算エディタの機能を、書くことです。
それは非常に困難に見えるが、意外にも解決策は非常に単純でしたが、それは、より実用的なアルゴリズムは稀であるので、個人的にこの問題から編集したい私は、(はい、私はアルゴリズムの問題の多くは非常に実用的ではないことを認めます)。以下のトピックではまず見て:
なぜ、この困難な問題が明らかにので、人々が何をすべきか知っているように困難である、恐怖。
数日前、私は日常生活の中で、この方法を使用するので、なぜそれが、あまりにも実用的です。記事の数が過失に起因する公衆の前にありますが、私は流暢のロジックのこの部分を変更することを決定し、コンテンツの一部を書き見当違い。しかし、国民はわずか20の単語記事番号を修正することができ、かつ唯一の支持体は、追加、削除、操作(正確に同じ問題を持つ編集距離)を置き換える、私は最適解を得るためのアルゴリズムを使用するので、それだけで完全に16個のステップを取りました変更。
別の例は、文字列に背の高いアプリケーション、点A、G、Cの配列、T用組成物によるDNA配列決定なぞらえることができるされています。編集距離はより類似これら二つのDNAは、多分、多分古代DNAの所有者が自分の近親であることを示す、DNA、小さい編集距離の2つの配列間の類似性を測定することができます。
身近には、以下の、編集距離を計算する方法を詳細に説明し、私はこの記事があなたが収穫できるようになると信じています。
まず、アイデア
編集距離の問題は私たちに2つの文字列を与えることであるs1
とs2
、3つだけの操作で、私たちは聞かせてs1
なるs2
操作の最小数を求め、。明確にするために、それがあるかどうかs1
になるs2
か、その逆、結果はそれほど後にするには、同じでs1
なるs2
例。
前述の「最長共通サブシーケンス」とは、前記動的プログラミング二つの文字列の問題を解決するため、通常、2つのポインタとするi,j
ステップフォワード次いで、最後の二つの文字列を指している、問題の大きさを減少させます。
設けられた二つの文字列を入れて、「RAD」と「りんご」、されs1
にs2
、アルゴリズムは、によって行われます。
我々は編集距離を計算することができるように、このGIFプロセスを覚えておいてください。キーは右の操作を作成する方法である、彼女は後で話します。
GIFの上に、それだけではなく、3つの動作見つけることができますによると、実際には、第4の動作があり、(スキップ)何もしないことです。例えば、このような状況:
すでに同じこれら2つの文字があるので、編集するための最短距離で、明らかにあなたがそれらのいずれかの操作を持つべきではない、直接前進i,j
します。
ケースは非常に簡単にハンドルがあり、されてj
完了しs2
た場合、i
完了していないs1
場合、削除のみに使用することができs1
短縮s2
。例えば、このような状況:
同様に、もしi
終了s1
時間がj
取られていないs2
、それだけで挿入するために使用することができますs2
文字の残りの部分が完全に挿入されますs1
。我々が表示されますので、両方のケースでのアルゴリズムである基本ケース。
起動するように、じっと、コードにアイデアを変換する方法で、以下の詳細な表情。
第二に、コードの詳細
まずソート何以前のアイデアアウト:
ベースケースは、i
完了s1
またはj
終了s2
、他の文字列の長さの残りの部分に直接戻すことができます。
子文字の各ペアについてs1[i]
とs2[j]
4つのアクションを持つことができます。
if s1[i] == s2[j]:
啥都别做(skip)
i, j 同时向前移动
else:
三选一:
插入(insert)
删除(delete)
替换(replace)
このフレームワークを持って、問題が解決されました。読者はどのように選択することが最終的には、この「3選挙」を求めることができますか?操作を終わる非常に単純な、全体のテストを再度、最小編集距離、それが出て見つけることです。これは、再帰的な技術、理解するのが難しいビット、コードを見てする必要があります:
def minDistance(s1, s2) -> int:
def dp(i, j):
# base case
if i == -1: return j + 1
if j == -1: return i + 1
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
else:
return min(
dp(i, j - 1) + 1, # 插入
dp(i - 1, j) + 1, # 删除
dp(i - 1, j - 1) + 1 # 替换
)
# i,j 初始化指向最后一个索引
return dp(len(s1) - 1, len(s2) - 1)
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
记住这个定义之后,先来看这段代码:
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
# 解释:
# 本来就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小编辑距离等于
# s1[0..i-1] 和 s2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)
如果 s1[i]!=s2[j]
,就要对三个操作递归了,稍微需要点思考:
dp(i, j - 1) + 1, # 插入
# 解释:
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
dp(i - 1, j) + 1, # 删除
# 解释:
# 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一
dp(i - 1, j - 1) + 1 # 替换
# 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
怎么能一眼看出存在重叠子问题呢?前文「动态规划之正则表达式」有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
def dp(i, j):
dp(i - 1, j - 1) #1
dp(i, j - 1) #2
dp(i - 1, j) #3
对于子问题 dp(i-1, j-1)
,如何通过原问题 dp(i, j)
得到呢?有不止一条路径,比如 dp(i, j) -> #1
和 dp(i, j) -> #2 -> #3
。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
三、动态规划优化
对于重叠子问题呢,前文「动态规划详解」详细介绍过,优化方法无非是备忘录或者 DP table。
备忘录很好加,原来的代码稍加修改即可:
def minDistance(s1, s2) -> int:
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1)
主要说下 DP table 的解法:
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样:
有了之前递归解法的铺垫,应该很容易理解。dp[..][0]
和 dp[0][..]
对应 base case,dp[i][j]
的含义和之前的 dp 函数类似:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp[i-1][j-1]
# 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp 函数的 base case 是 i,j
等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解:
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// base case
for (int i = 1; i <= m; i++)
dp[i][0] = i;
for (int j = 1; j <= n; j++)
dp[0][j] = j;
// 自底向上求解
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
if (s1.charAt(i-1) == s2.charAt(j-1))
dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i-1][j-1] + 1
);
// 储存着整个 s1 和 s2 的最小编辑距离
return dp[m][n];
}
int min(int a, int b, int c) {
return Math.min(a, Math.min(b, c));
}
三、扩展延伸
一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table:
还有一个细节,既然每个 dp[i][j]
只和它附近的三个状态有关,空间复杂度是可以压缩成 \(O(min(M, N))\) 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。
你可能还会问,这里只求出了最小的编辑距离,那具体的操作是什么?你之前举的修改公众号文章的例子,只有一个最小编辑距离肯定不够,还得知道具体怎么修改才行。
这个其实很简单,代码稍加修改,给 dp 数组增加额外的信息即可:
// int[][] dp;
Node[][] dp;
class Node {
int val;
int choice;
// 0 代表啥都不做
// 1 代表插入
// 2 代表删除
// 3 代表替换
}
val
属性就是之前的 dp 数组的数值,choice
属性代表操作。在做最优选择时,顺便把操作记录下来,然后就从结果反推具体操作。
我们的最终结果不是 dp[m][n]
吗,这里的 val
存着最小编辑距离,choice
存着最后一个操作,比如说是插入操作,那么就可以左移一格:
重复此过程,可以一步步回到起点 dp[0][0]
,形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。
以上就是编辑距离算法的全部内容,如果本文对你有帮助,欢迎关注我的公众号 labuladong,致力于把算法问题讲清楚~
我最近精心制作了一份电子书《labuladong的算法小抄》,分为【动态规划】【数据结构】【算法思维】【高频面试】四个章节,共 60 多篇原创文章,绝对精品!限时开放下载,在我的公众号 labuladong 后台回复关键词【pdf】即可免费下载!
欢迎关注我的公众号 labuladong,技术公众号的清流,坚持原创,致力于把问题讲清楚!