Levenshtein编辑距离算法的改进—剪枝优化
我们在先前的一篇博客中已经阐明了Levenshtein编辑距离算法,首先介绍算法的思想,后来介绍了根据跳转列表生成所有编辑方案的方法,并通过附带的代码来实现这些方法。本文中我们继续探讨Levenshtein编辑距离算法,引入了剪枝思想来更高效的生成编辑距离状态表。
首先我们给出一张编辑距离表,如下所示:
Edit-Dist | <\b> | k | i | t | t | e | n |
---|---|---|---|---|---|---|---|
<\b> | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
s | 1 | 1 | 2 | 3 | 4 | 5 | 6 |
i | 2 | 2 | 1 | 2 | 3 | 4 | 5 |
t | 3 | 3 | 2 | 1 | 2 | 3 | 4 |
t | 4 | 4 | 3 | 2 | 1 | 2 | 3 |
i | 5 | 5 | 4 | 3 | 2 | 2 | 3 |
n | 6 | 6 | 5 | 4 | 3 | 3 | 2 |
g | 7 | 7 | 6 | 5 | 4 | 4 | 3 |
我们通过观察表格不难发现,表格位于右上角和左下角附近的元素是不是都较大?这是因为右上角附近的元素生成了较多的删除操作,而左下角的元素生成了较多的插入操作。我们将一个字符串编辑为另一个字符串时,通过一个“替换”操作可以实现“先插入,后删除”或“先删除,后插入”这两种操作,因此替换操作是能够节省编辑步数的。读者可以从表格中发现,右上角和左下角附近的值往往在向右下角前进时会被其他元素所替代,即它们迭代过程中往往都不能成为最小元素。因此我们介绍一种Levenshtein编辑距离算法的剪枝操作,以便在计算步数的时候不必考虑右上角、左下角附近的元素。
读者不难想出,两个字符串的最小编辑距离有一个最大值—就是两个字符串中较长字符串的长度,无论字符串的内容如何变化,最小编辑距离都不会大于较长字符串长度的。因此,我们对编辑距离表中那些能够使最终编辑步数大于最大长度的编辑状态采取跳过措施。那么哪些位置的元素能够导致最终产生大于最大值的编辑距离呢?我们给出判断是否符合这一条件的计算公式:
m i n d i s t = ∣ 2 ∗ ( j − i ) + l e n ( t a r g e t ) − l e n ( s o u r c e ) ∣ mindist = |2*(j - i) +len(target) - len(source)| mindist=∣2∗(j−i)+len(target)−len(source)∣
该公式的意义是:包含该位置操作的编辑步骤的最小编辑距离。其中i、j分别为表格的行列下标。判断是否跳过的方式为:当计算结果mindist大于较长的字符串时,该位置可以不必计算编辑距离。我们来举个例子,比如对于上述表格,给出下标[1][5](下标从数字部分算起),则我们计算穿过该位置的最小编辑距离过程如下:将该位置用一条左上-右下方向的斜线贯穿其方格对角线,该线穿过的所有单元格呈斜向排列,由于计算的是最小编辑距离,因此我们假设这些斜向排列的单元格的操作都为“跳过”,就不必将它们计入总数;我们需要计算的是该线穿过的第一行的单元格及左边的所有单元格(不包括0),以及穿过的最后一列单元格下边的所有单元格,如下图所示:
我们需要计算的是蓝色方框中的单元格数量,每个单元格代表一次编辑操作,横向代表“删除操作”,纵向代表“插入操作。图中有9个单元格,则我们认为通过橘红色位置(即位置[1][5])的编辑步数最小为9步。在代码中我们使用上文给到的公式便能够方便地计算出每个单元格的“最小步数”值。由于[1][5]的“最小步数”值大于两个字符串较大的长度8,因此该位置可以不必计算编辑距离,也不必参与到后面编辑距离的计算。
我们用紫色区域标示出上述表格的所有可不必计算距离的单元格,如下所示:
不难看出,可不必计算距离的单元格在表格中都成较小的上下三角矩阵分布,当两个序列长度相差较小时,这两个半矩阵也会增大。当两个序列等长时,半矩阵的直角边长会达到序列的一半,继而可不必计算的元素会占到所有元素的1/4.
我们给出剪枝后的算法实现代码,并使用“我不是药神”和“我是谁”测试输出结果,如下所示:
def edit_distance_reduced(self, source, target):
if len(target) == 0 and len(source) == 0:
return 0
row = []
jump_row = []
maxnum = max(len(source), len(target))
for i in range(len(target) + 1):
for j in range(len(source) + 1):
if abs(2*(j - i) +len(target) - len(source)) > maxnum :
row.append(maxnum+1)
jump_row.append(['-'])
continue
if i == 0:
row.append(j)
if j == 0 :
jump_row.append(['-'])
else:
jump_row.append(['→'])
continue
elif j == 0:
row.append(i)
jump_row.append(['↓'])
continue
row.append(0)
jump_row.append([])
self.Matrix.append(row)
self.jump.append(jump_row)
jump_row = []
row = []
for i in range(1, len(target) + 1):
for j in range(1, len(source) + 1):
if self.Matrix[i][j] == maxnum + 1 :
continue
if target[i - 1] == source[j - 1]:
edit = 0
else:
edit = 1
self.Matrix[i][j] = min(self.Matrix[i - 1][j] + 1, self.Matrix[i][j - 1] + 1,
self.Matrix[i - 1][j - 1] + edit)
if self.Matrix[i][j] == self.Matrix[i - 1][j] + 1:
self.jump[i][j].append('↓')
if self.Matrix[i][j] == self.Matrix[i][j - 1] + 1:
self.jump[i][j].append('→')
if self.Matrix[i][j] == self.Matrix[i - 1][j - 1] and self.source[j - 1] == self.target[i - 1]:
self.jump[i][j].append('↘')
if self.Matrix[i][j] == self.Matrix[i - 1][j - 1] + 1 and self.source[j - 1] != self.target[i - 1]:
self.jump[i][j].append('↘')
for i in range(len(target) + 1):
print(self.Matrix[i])
for i in range(len(target) + 1):
print(self.jump[i])
注:代码既可以接受字符串输入,也能接受列表。
输出的状态矩阵中,我们把可以跳过的位置用一个极大值表示(图中用的是较长序列+1),跳转矩阵中则用 ‘-’ 表示。最终的最小距离和方案也与未加剪枝的结果输出相同,表明我们的思想基本正确。