动态规划求编辑距离

这两天在写一个简单的单词拼写检查器(Spell checker),本来求编辑距离只是其中的一个子问题,现在把它罗列出来,是因为鉴于看到一些书,把本来不是很难的问题讲得很复杂,而且不知道是不是一些作者,为了显得自己水平之高,几乎没有任何的推导,只有一堆结果罗列。当然这些都是题外话了,但是,书者,传道授业解惑也,若是使读者迷惑不能自拔,不知是读者愚钝,还是书之不书。

现在我们开始切入正题。先来说说什么是编辑距离,编辑距离是一种字符串之间相似程度的计算方法。按照Damerau给出的定义,即两个字符串之间的编辑距离等于使一个字符串变成另外一个字符串而进行的(1)插入、(2)删除、(3)替换或(4)相邻字符交换位置而进行操作的最少次数。用ed来表示编辑距离。

比如:ed("recoginze", "recognize") == 1(需要交换两个相邻字符"i"和"n"的位置)

ed("sailn", "failing") == 3(需要将"s"换成"f",在字母"l"后边插入"i","n"后面插入"g")。

有的时候,变换并不唯一,重要的是要求出这一些变换路径中最短的数量。

关于编辑距离的求法,普遍的采用的是动态规划方法。但是,目前网上的资料中提及的编辑路径并不准确,缺少了第(4)步的处理。详细的同学们可以查阅这两篇文章——【串和序列处理 2】字符串编辑距离算法(Java)和动态规划求编辑距离——算法解题报告(C++)。

下面给出维基上对动态规划的定义:

动态规划是一种在数学和计算机科学中使用的,用于求解包含重叠子问题的最优化问题的方法。其基本思想是,将原问题分解为相似的子问题,在求解的过程中通过子问题的解求出原问题的解。

其实就是把一个复杂的最优解问题分解成一系列较为简单的最优解问题,再将较为简单的最优解问题一步步分解,直到能够一眼看出为止。

我们拿"sailn"和"failing"这两个字符串作例子。首先我们定义这样一个函数——edit(i, j),它表示字符串1的长度为i的子串到字符串2的长度为j的子串的编辑距离。

首先我们作出初始化edit(0, j) = j(字符串1子串长度为0,字符串2子串有多少个字符,就作多少次增加操作;于是同理,edit(i, 0) = i。)

这里,我们要注意到对于操作(4),即交换相邻字符串的操作,我们要把某个字符通过这个操作到另一个位置上,我们最多只能执行一次操作,即只能移动到邻位上。原因是什么呢?这是因为,移动两次的话,就没有优势了,它的操作等于两次替换操作的操作数。大于2次的时候,移动操作会更差。所以,我们要进行操作(4),最多只发生一次操作。

我们可以得出这样一段动态规划公式:

  1. 如果i == 0 且 j == 0,edit(i, j) = 0
  2. 如果i == 0 且 j > 0,edit(i, j) = j
  3. 如果i > 0 且j == 0,edit(i, j) = i(2、3点之前已经陈述)
  4. 如果0 < i ≤ 1  且 0 < j ≤ 1 ,edit(i, j) == min{ edit(i-1, j) + 1, edit(i, j-1) + 1, edit(i-1, j-1) + f(i, j) },这里当字符串1的第i个字符不等于字符串2的第j个字符时,f(i, j) = 1;否则,f(i, j) = 0。
  5. 如果i > 1且 j > 1时,这个时候可能出现操作(4),由之前的推导,我们只能交换一次,否则就没有意义。这个时候在比较最小值中可能加入edit(i-2, j-2) +1,什么时候加入呢?假设i-2长度的字符串1子串和j-2长度的字符串2子串已经得出最优解,这个时候如果s1[i-1] == s2[j] 并且s1[i] == s2[j-1],这时就在比较值中加入edit(i-2, j-2) + 1(这个1是交换一次的操作)

我们来把这个过程演绎一遍。我们首先给出这样一个矩阵:

 
  0 f a i l i n g
0                
s                
a                
i                
l                
n                

在经过初始化之后,矩阵变成这样:

  0 f a i l i n g
0 0 1 2 3 4 5 6 7
s 1              
a 2              
i 3              
l 4              
n 5              

现在要计算edit(1, 1),而edit(0, 1) + 1 == 2,edit(1, 0) + 1 == 2,edit(0, 0) + f(1, 1) == 0 + 1 == 1,他们其中最小的为1,因此edit(1, 1) == 1。按照此法计算,直到计算到edit(2, 2),矩阵为这样:

  0 f a i l i n g
0 0 1 2 3 4 5 6 7
s 1 1 2 3 4 5 6 7
a 2 2            
i 3              
l 4              
n 5              

这个时候,edit(2, 1) + 1 == 3,edit(1, 2) + 1 == 3,edit(1, 1) + f(2, 2) == 1 + 0 == 1,而此时s1[2] == 'a' 而 s2[1] == 'f'‘,不满足条件,所以,交换相邻字符的操作不纳入比较最小数中计算。接着往下计算,得出最后矩阵为:

  0 f a i l i n g
0 0 1 2 3 4 5 6 7
s 1 1 2 3 4 5 6 7
a 2 2 1 2 3 4 5 6
i 3 3 2 1 2 3 4 5
l 4 4 3 2 1 2 3 4
n 5 5 4 3 2 2 2 3

可以看到edit(len(s1), len(s2)) == 3,验证了先前的结论。

接下来就给出Python实现的代码:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

def ed(s1, s2):
    '''
    >>> ed('eeba', 'abac')
    3
    >>> ed('abc', 'cba')
    2
    >>> ed('cbc', 'eba')
    2
    >>> ed('recoginze', 'recognize')
    1
    >>> ed('sailn', 'failing')
    3
    >>> ed('ab', 'ba')
    1
    '''
    # 动态规划求编辑距离
    # param s1: 字符串1
    # param s2: 字符串2
    
    len1 = len(s1)
    len2 = len(s2)
    
    # 初始化矩阵
    matrix = [[i+j for j in range(len2 + 1)] for i in range(len1 + 1)]
    
    for row in range(len1):
        for col in range(len2):
            comp = [matrix[row+1][col]+1, matrix[row][col+1]+1]
            
            if s1[row] == s2[col]:
                comp.append(matrix[row][col])
            else:
                comp.append(matrix[row][col]+1)
            
            # 对相邻字符交换位置的处理判断
            if row > 0 and col > 0:
                if s1[row] == s2[col-1] and s1[row-1] == s2[col]:
                    comp.append(matrix[row-1][col-1]+1)
                    
            matrix[row+1][col+1] = min(comp)
            
    return matrix[len1][len2]

重要的是这段代码:

if row > 0 and col > 0:
    if s1[row] == s2[col-1] and s1[row-1] == s2[col]:
        comp.append(matrix[row-1][col-1]+1)

同学们要用其他语言实现,只需要实现以上判断,来进行操作(4)。

注意到ed函数的docstring出现了类似命令行的句子,这是为了方便进行doctest测试。要测试全部数据,只需加上以下几句话:

if __name__ == '__main__':
    import doctest
    doctest.testmod()

赞这篇文章

分享到

13个评论

  1. kisonlee

    你好
    最后的
    if row > 0 and col > 0:
    if s1[row] == s2[col-1] and s1[row-1] == s2[col]:
    comp.append(matrix[row-1][col-1]+1)
    是不是应该改为
    if row > 0 and col > 0:
    if s1[row] == s2[col-1] and s1[row-1] == s2[col]:
    comp.append(matrix[row-2][col-2]+1)

  2. @秦续业 作者

    不是的,因为第0列和第0行不要计算。
    你注意看matrix[row+1][col+1] = min(comp),所以计算实际上是从1...len(s1)-1,1...len(s2)-1的。

  3. kisonlee

    另外,其实你提到的第4步,只是一种定义编辑距离中提到,也有不包括交换这个原子操作的,两种计算方式各有利弊,请参考http://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance

  4. @秦续业 作者

    这一点我也发现了,当时我看到手头上讲NLP的书是包含的,所以就加了进去。
    我看过你的博客,好像挺专注于NLP的,对于马尔可夫链最近也在看一些东西,希望能和你多交流:)

  5. kisonlee

    互相学习,对了,你这个矩阵表格怎么做出来的,呵呵,我平时就用gtalk,可以加我,呵呵

  6. @秦续业 作者

    我整个blog都是自己实现的,所以这个表格就是HTML的table...不是什么工具生成的

  7. Zhe

    第33行的代码好像有问题:
    if s1[row] == s2[col]:
    应该是
    if s1[row+1] == s2[col+1]:

    因为substitution操作是判断当前字符,而你代码里循环用的index都是提前1位的

给作者留言

关于作者

残阳似血(@秦续业),程序猿一枚,把梦想揣进口袋的挨踢工作者。现加入阿里云,研究僧毕业于上海交通大学软件学院ADC实验室。熟悉分布式数据分析(DataFrame并行化框架)、基于图模型的分布式数据库和并行计算、Dpark/Spark以及Python web开发(Django、tornado)等。

博客分类

点击排行

标签云

扫描访问

主题

残阳似血的微博