算法系列之四:字符串的相似度

我们把两个字符串的相似度定义为:将一个字符串转换成另外一个字符串的代价(转换的方法可能不唯一),转换的代价越高则说明两个字符串的相似度越低。比如两个字符串:“SNOWY”和“SUNNY”,下面给出两种将“SNOWY”转换成“SUNNY”的方法:


变换1:

S - N O W Y

S U N N - Y

Cost = 3 (插入U、替换O、删除W)


变换2:

- S N O W - Y

S U N - - N Y

Cost = 5 (插入S、替换S、删除O、删除W、插入N)

分析问题

我们可以把这种相似度理解为:把一个字符串(source)通过“插入、删除和替换”这样的编辑操作变成另外一个字符串(target)所需要的最少编辑次数,也就是两个字符串之间的编辑距离(edit distance)。能否给出一个算法,求解任意两个字符串之间的编辑距离?

从题目给出的例子可知,将一个字符串经由插入、删除或替换等操作转换成另外一个字符串的方法不止一种,所需要做的编辑次数也不相同,如果有某种方法能够用最小的修改次数完成转换,这种方法的编辑次数就是我们要求的编辑距离。很显然,这是个求最优解的问题。

提到最优解问题,首先可以考虑使用贪婪法,但是本题显然是一个多阶段决策类型的最优解问题,对source字符串做最小的修改变换到target字符串,需要在处理过程中的每个阶段都选择修改最小的方式,但是本题中每个阶段之间都不是孤立的,受到前面已经确定的决策和后面可选的决策共同影响,无法通过对每一次决策的最优决策简单堆叠出最后的最优结果,因此,排除贪婪法。

动态规划法(Dynamic Programming)

对于多阶段决策类型的问题,应该优先考虑动态规划法(Dynamic Programming, DP)。动态规划法是解决多阶段决策最优化问题常用的一种思想方法[1],也是所有解题方法中最抽象的一种方法。使用动态规划法解决问题的关键有两点,一点是定义子问题的最优子结构【注解1】,另一点是确定子问题最优解的堆叠方式。定义最优子结构就是分解子问题,可以用递推的方式,也可以用递归的方式,基本原则就是将问题分成M个子问题,同时确定每个子问题的最优解与其它N(N小于M)个子问题之间的关系。子问题最优解的堆叠方式是指最优决策序列和它的子序列的递推关系,包括子问题最优解的递推关系和边界值两部分。对于一个问题,如果能够找最优子结构的定义方式(包括子问题之间的关系)和子问题最优解的堆叠方式,并且每个子问题最优解都满足无后效性【注解2】,则该问题就可以尝试用动态规划法解决这个问题。

以本题为例,假设source字符串有n个字符,target字符串有m个字符,如果将问题定义为求解将source的1-n个字符转换为target的1-m个字符所需要的最少编辑次数(最小编辑距离),则其子问题就可以定义为将source的1-i个字符转换为target的1-j个字符所需要的最少编辑次数,这就是本问题的最优子结构。我们用d[i, j]表示source[1..i]到target[1..j]之间的最小编辑距离,则计算d[i, j]的递推关系可以这样计算出来:

如果source[i] 等于target[j],则: 

d[i, j] = d[i, j] + 0 (递推式 1)

如果source[i] 不等于target[j],则根据插入、删除和替换三个策略,分别计算出使用三种策略得到的编辑距离,然后取最小的一个:
d[i, j] = min(d[i, j - 1] + 1,d[i - 1, j] + 1,d[i - 1, j - 1] + 1 ) (递推式 2)

d[i, j - 1] + 1 表示对source[i]执行插入操作后计算最小编辑距离d[i - 1, j] + 1 表示对source[i]执行删除操作后计算最小编辑距离d[i - 1, j - 1] + 1表示对source[i]替换成target[i]操作后计算最小编辑距离
d[i, j]的边界值就是当target为空字符串(m = 0)或source为空字符串(n = 0)时所计算出的编辑距离:
m = 0,对于所有 i:d[i, 0] = i
n = 0,对于所有 j:d[0, j] = j

 根据前面分析的最优子结构、最优解的递推关系以及边界值,写出用动态规划法求解最小编辑距离的算法就很容易了,以下代码就是计算两个字符串的最小编辑距离的算法实现:
30/*注意:source和target字符串的长度不能超过d矩阵的限制*/
31int EditDistance(const std::string& source, const std::string& target)
32{
33 std::string::size_type i,j;
34  int d[MAX_STRING_LEN][MAX_STRING_LEN] = {  0 };
35
36  for(i =  0; i <= source.length(); i++)
37 d[i][0] = i;
38  for(j =  0; j <= target.length(); j++)
39 d[0][j] = j;
40
41  for(i =  1; i <= source.length(); i++)
42  {
43  for(j =  1; j <= target.length(); j++)
44  {
45  if((source[i - 1] == target[j  - 1]))
46  {
47 d[i][j] = d[i  - 1][j  - 1];  //不需要编辑操作
48  }
49  else
50  {
51  int edIns = d[i][j - 1] + 1; //source 插入字符
52  int edDel = d[i - 1][j] + 1; //source 删除字符
53  int edRep = d[i - 1][j - 1] + 1; //source 替换字符
54
55 d[i][j] = std::min(std::min(edIns, edDel), edRep);
56  }
57  }
58  }
59
60  return d[source.length()][target.length()];
61}

穷举法(枚举法)

除了贪婪法和动态规划法,穷举法也是求解最优解的常用方法。穷举法比动态规划法容易理解,穷举法的原理就是对问题域的整个解空间进行搜索,通过比较所有可能的解,从中选出最优解。穷举法的本意其实是为了求解某个问题的所有合法的解,最优解可以理解为只是搜索过程中的一个副产品。根据题目的不同,穷举法的实现也不一样,如果问题的解空间是线性结构,则可以使用循环方法,如果问题的解空间是树状结构,则可以使用递归方法。本问题的解空间显然不是线性结构,因此考虑使用递归方法对所有解进行穷举。递归算法需要解决两个问题,一个是如何将问题递归地分解为子问题,另一个问题是如何确定递归终止条件。

对于本文的问题,可以这样对递归分解子问题:位置i表示source和target字符串中共同的字符位置,对于每一个i位置,计算从i位置开始的子串的编辑距离,计算方法是比较source[i]和target[i]的值,如果相等,则表示这个位置需要的编辑次数是0,i位置开始的子串的编辑距离就等于source和target字符串从i + 1位置开始的子串的编辑距离。如果不相等,则对source字符串i位置的字符分别尝试插入、删除和替换三种编辑方式计算新的子串的编辑距离,然后加上1就是从i位置开始的子串的编辑距离。用插入、删除和替换三种方式计算新的子串的开始位置需要分别调整,如果是在i位置插入字符,则source字符串的i位置不变化,target字符串的i位置移到i + 1位置继续。如果是在i位置删除字符,则source字符串的i位置移到i + 1位置,target字符串的i位置不变。如果是在i位置替换字符,则source和target字符串的i位置都移到i + 1位置。

有了子问题的递归分解,还需要确定递归计算的终止条件。本问题的终止条件很简单,由上面的递归子问题定义可知,当source和target字符串中的某一个为空时即可终止递归。当达到递归终止条件时,编辑距离的值就是source和target字符串中非空的那一个剩余子串的长度,这一点比较容易理解,如果source最后剩余的子串不为空,则意味着需要删除这些字符才能和target一样,如果target最后剩余的子串不为空,则意味着需要对source插入这些字符才能和target一样,所以最后的编辑距离就是剩余子串的长度。

根据以上对递归子问题的分解方法和递归终止条件的分析,可以很容易地写出使用递归方法求解本问题的算法实现。递归算法通常效率不高,但是与人类解决问题的思维方式一致,通常代码简洁,容易理解,本文给出的算法实现只用了9行代码:


14int EditDistance(const std::string& source, const std::string& target)
15{
16  if(source.empty() || target.empty())
17  return std::abs(source.length() - target.length());
18
19  if(source[0] == target[0])
20  return EditDistance(source.substr(1), target.substr(1));
21
22  int edIns = EditDistance(source, target.substr(1)) + 1; //source 插入字符
23  int edDel = EditDistance(source.substr(1), target) + 1; //source 删除字符
24  int edRep = EditDistance(source.substr(1), target.substr(1)) + 1; //source 替换字符
25
26  return std::min(std::min(edIns, edDel), edRep);
27}


总结

就时间复杂度而言,动态规划法的时间复杂度是O(n2),穷举算法的时间复杂度在最好的情况下是O(n),也就是每次都走(source[0] == target[0])分支的情况,最差的情况当然是O(3n)。这种时间复杂度是指数型的算法,基本上是不可用的算法,只存在理论上的价值,实际工程中是不会适用这种时间复杂度的算法。

就空间复杂度而言,动态规划法的空间复杂度是O(mn),但是这个空间复杂度在很大程度上是可以优化的,优化的程度因算法而异。以本文的算法为例,从(递推式 2)可以看到,d[i, j]的结果只需要知道i,i - 1,以及j,j – 1位置上的结果就可以递推计算出来,其它位置上的信息完全可以在计算完成后释放,并不需要从头到尾占用m x n的空间。类似的优化只是数据组织上的一些小技巧,本文就不再赘述,有兴趣的读者可以自己对EditDistance()函数进行改造。


注解:

【1】最优子结构:对于多阶段决策问题,如果每一个阶段的最优决策序列的子序列也是最优的,且决策序列具有“无后效性”,就可以将此决策方法理解为最优子结构。


【2】无后效性:动态规划法的最优解通常是由一系列最优决策组成的决策序列,最优子结构就是这些最优决策序列中的一个子序列,对于每个子序列再做最优决策会产生新的最优决策(子)序列,如果某个决策只受当前最优决策子序列的影响,而不受当前决策可能产生的新的最优决策子序列的影响,则可以理解这个最优决策具有无后效性。