跟着编程之美学算法——字符串相似度
计算字符串的相似度,这个问题是《编程之美》中的一个经典问题,问题的解决其实应该使用动态规划来解决,在《编程之美》中其实已经给出了该问题的子问题,不过最终用了递归的方法来实现,这使得算法有许许多多的重复计算,算法的复杂度达到了指数级。本文对该问题重新分析,用动态规划的方法来实现,是复杂度降为O(m*n)(其中m、n分别为两个字符串的长度)。
对于一个动态规划问题,那么解决这个问题的第一步就是找到子问题。这里利用《编程之美》中的分析方法:
不难看出,两个字符串的距离肯定不超过它们的长度之和(我们可以通过删除操作把两个串都转化为空串)。虽然这个结论对结果没有帮助,但至少可以知道,任意两个字符串的距离都是有限的。
我们还是就住集中考虑如何才能把这个问题转化成规模较小的同样的子问题。如果有两个串A=xabcdae和B=xfdfa,它们的第一个字符是相同的,只要计算A[2,...,7]=abcdae和B[2,...,5]=fdfa的距离就可以了。但是如果两个串的第一个字符不相同,那么可以进行如下的操作(lenA和lenB分别是A串和B串的长度)。
1.删除A串的第一个字符,然后计算A[2,...,lenA]和B[1,...,lenB]的距离。
2.删除B串的第一个字符,然后计算A[1,...,lenA]和B[2,...,lenB]的距离。
3.修改A串的第一个字符为B串的第一个字符,然后计算A[2,...,lenA]和B[2,...,lenB]的距离。
4.修改B串的第一个字符为A串的第一个字符,然后计算A[2,...,lenA]和B[2,...,lenB]的距离。
5.增加B串的第一个字符到A串的第一个字符之前,然后计算A[1,...,lenA]和B[2,...,lenB]的距离。
6.增加A串的第一个字符到B串的第一个字符之前,然后计算A[2,...,lenA]和B[1,...,lenB]的距离。
在这个题目中,我们并不在乎两个字符串变得相等之后的字符串是怎样的。所以,可以将上面的6个操作合并为:
1.一步操作之后,再将A[2,...,lenA]和B[1,...,lenB]变成相字符串。
2.一步操作之后,再将A[2,...,lenA]和B[2,...,lenB]变成相字符串。
3.一步操作之后,再将A[1,...,lenA]和B[2,...,lenB]变成相字符串。
总结:
如果两个字符串A,B,若第一个字母相同,那么,其距离d就为distance(A[2-n],B[2-m])。
若首字母不相同,那么距离d就为min(distance(A[1-n],B[2-m]), distance(A[2-n],B[1-m]), distance(A[2-n],B[2-m]))+1。
这样,很快就可以完成一个递归程序。这个程序也就是《编程之美》给我们的程序,如下:
1 #include <iostream> 2 3 using namespace std; 4 5 int Min(int a, int b, int c) 6 { 7 int min = a; 8 if(a > b) 9 min = b; 10 if(min > c) 11 min = c; 12 return min; 13 } 14 15 int GetDistance_Recursion(char *str1, char *str2) 16 { 17 if(str1 == NULL || str2 == NULL) 18 return 0; 19 20 if(*str1 == '\0') 21 { 22 if(*str2 == '\0') 23 return 0; 24 else 25 return strlen(str2); 26 } 27 28 if(*str2 == '\0') 29 { 30 if(*str1 == '\0') 31 return 0; 32 else 33 return strlen(str1); 34 } 35 36 if(*str1 == *str2) 37 return GetDistance_Recursion(str1+1, str2+1); 38 else 39 { 40 int dis1 = GetDistance_Recursion(str1+1, str2); 41 int dis2 = GetDistance_Recursion(str1, str2+1); 42 int dis3 = GetDistance_Recursion(str1+1, str2+1); 43 return Min(dis1, dis2, dis3) + 1; 44 } 45 } 46 47 int GetDistance_DP(char *str1, char *str2) 48 { 49 } 50 51 int main() 52 { 53 cout<<GetDistance_Recursion("xabcdae", "xfdfa")<<endl; 54 cout<<GetDistance_Recursion("abcdo", "abedf")<<endl; 55 return 0; 56 }
以上就是该问题的递归实现,这样的实现确实在复杂度,跟实用性上比较差。
复杂度上,因为递归过程中有很多重复的计算,该算法的时间复杂度达到了指数级;实用性上,因为是递归实现,造成该算法会由于系统对堆栈的限制,达到上限后,程序会崩溃。所以上面的算法一定要改进。改进的方向就是把上面分析的子问题按照动态规划的方法,自底向上完成整个问题的求解。
A = {a0, a1, a2, ..., am}与B = {b0, b1, b2, ..., bn}表示两个待计算相似度的字符串。用c[i][j]来表示{a0, ..., ai}与{b0, ..., bj}部分的字符串的编辑距离。按照上面的子问题分析,如果想计算c[i][j],那么先看ai是否等于bj,如果相等,那么c[i][j] = c[i-1][j-1];如果不相等,那么c[i][j] = max(c[i-1][j], c[i][j-1], c[i-1][j-1]) + 1。
仔细看上面的分析,其实这个子问题完全可以自底向上求解,最后一步就是分析出c[i][j]的边界条件,也就是c[i][0]和c[0][j]的值。很直观的我们就可以知道,c[i][0] = i,c[0][j] = j(任意字符串与空字符串的编辑距离都是该字符串本身的长度)。这样就可以自底向上解决问题。代码如下:
1 #include <iostream> 2 3 using namespace std; 4 5 int Min(int a, int b, int c) 6 { 7 int min = a; 8 if(a > b) 9 min = b; 10 if(min > c) 11 min = c; 12 return min; 13 } 14 15 int GetDistance_DP(char *str1, char *str2) 16 { 17 if(str1 == NULL || str2 == NULL) 18 return 0; 19 20 int nLen1 = strlen(str1); 21 int nLen2 = strlen(str2); 22 23 if(nLen1 == 0) 24 return nLen2; 25 if(nLen2 == 0) 26 return nLen1; 27 28 int c[nLen1+1][nLen2+1]; 29 30 for(int i = 0; i <= nLen1; i++) 31 c[i][0] = i; 32 33 for(int i = 0; i <= nLen2; i++) 34 c[0][i] = i; 35 36 for(int i = 0; i <= nLen1; i++) 37 { 38 for(int j = 0; j <= nLen2; j++) 39 { 40 if(str1[i] == str2[j]) 41 { 42 c[i][j] = c[i-1][j-1]; 43 } 44 else 45 { 46 c[i][j] = Min(c[i-1][j], c[i][j-1], c[i-1][j-1]) + 1; 47 } 48 } 49 } 50 int result = c[nLen1][nLen2]; 51 return result; 52 } 53 54 int main() 55 { 56 cout<<GetDistance_DP("xabcdae", "xfdfa")<<endl; 57 return 0; 58 }
另类解法:
求两个字符串的编辑距离,也可以通过两个字符串的最长公共子序列的方法来求解:
首先求两个字符串的最长公共子序列,方法如:http://www.cnblogs.com/liyukuneed/archive/2013/05/22/3090597.html
比如要计算A、B两个字符串的编辑距离,同时已经得到A、B的最长公共子序列为len,A的长度为lenA,B的长度为lenB。
这样,A与B的编辑距离 = max(lenA - len, lenB - len)。