字符串算法专题之:字符串相似度和字符串编辑距离
字符串编辑距离,又称Levenshtein距离(也叫做Edit Distance),是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符和删除一个字符。我们认为三种编辑操作的任何一种执行一次则编辑次数加1.
俄罗斯科学家Vladimir Levenshtein在1965年提出这个概念。
字符串相似度,参阅《编程之美》第3.3节给出的定义:两个字符串的相似度等于“字符串编辑距离+1”的倒数。可见求字符串相似度的关键还是求字符串编辑距离。
此问题不容易直接下手,因为有三种允许的操作类型,我们很难判断哪个字符该删除,哪个字符该修改才能获得最小的编辑距离。这时候就需要考虑该问题繁杂的表象下存在的的一些规律和不变量,寻找问题的子问题以及原问题和子问题的关系。
1、编辑距离的上界是较长字符串的长度,下界是两个字符串长度之差;
2、如果一个字符串长度为0,则编辑距离为另一字符串的长度;
进一步挖掘该问题和子问题的关系,会发现:
第一个递归算法
如果有两个串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的子串起始索引为ABgein,结束索引为AEnd,字符串b的子串起始索引为BBegin,结束索引为BEnd。leva,b(0,|a|,0,|b|)表示a,b的编辑距离,则:
COST = (a[ABgein] == b[BBegin])? 0 : 1)
根据以上分析,可以具体代码实现如下:
#include<iostream> #include<cstdlib> #include<string> using namespace std; int minValue(int v1, int v2, int v3) { return min(v1, min(v2,v3)); } int calculateStringDistance(string strA, int pABegin, int pAEnd, string strB, int pBBegin, int pBEnd) { if(pABegin>pAEnd) { if(pBBegin > pBEnd) return 0; else return pBEnd - pBBegin + 1; } if(pBBegin > pBEnd) { if(pABegin > pAEnd) return 0; else return pAEnd - pABegin + 1; } if(strA[pABegin] == strB[pBBegin]) { return calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin+1, pBEnd); } else { int t1 = calculateStringDistance(strA, pABegin, pAEnd, strB, pBBegin+1, pBEnd); int t2 = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin, pBEnd); int t3 = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin+1, pBEnd); return minValue(t1, t2, t3) + 1; } } int main() { string a = "kitten"; string b = "sitting"; cout << "Edit Distance is : " <<calculateStringDistance(a, 0, a.length()-1, b, 0, b.length()-1); return 0; }
此方法为编程之美提供的。
第二个递归算法
字符串a,b的编辑距离定义为leva,b(|a|,|b|),则:
其中[ai ≠ bj]表示当两者不等时值为1,两者相等时值为0.
这种方法来自维基百科,从思路上和编程之美递归方法是一致的,区别在于编程之美的方法是从前往后分析,维基百科方法是从后往前分析。
给出具体代码:
int LevenshteinDistance(string s, string t) { int len_s = s.length(); int len_t = t.length(); int cost; /* test for degenerate cases of empty strings */ if (len_s == 0) return len_t; if (len_t == 0) return len_s; /* test if last characters of the strings match */ if (s[len_s-1] == t[len_t-1]) cost = 0; else cost = 1; /* return minimum of delete char from s, delete char from t, and delete char from both */ return minValue(LevenshteinDistance(s.substr(0,len_s-1), t) + 1, LevenshteinDistance(s, t.substr(0,len_t-1)) + 1, LevenshteinDistance(s.substr(0,len_s-1), t.substr(0,len_t-1)) + cost); }
递归算法的问题
递归算法思考容易,代码编写简单,在这个问题里,上述两种算法都存在严重的性能问题。通过分析代码和思考递归运行过程,不难发现存在大量的子问题重复计算,这些无用的计算白白浪费了计算时间和程序栈,当字符串较长递归层次较深的时候会出现内存溢出。对于重复子问题的情况常见的解决方法就是制作备忘录,首次遇到该子问题是进行计算并将计算结果保存,再次遇到该子问题时直接取出保持的结果。
自底向上矩阵表示法
递归是一种自顶向下的解决方法,如果想采用备忘录记录中间结果往往采用自底向上的方法,使用矩阵作为记录媒介,设字符串a的长度为la,b的长度为lb,则使用d[lb+1][la+1]的二维数组来记录信息,d[i][j]表示a[0..j-1]子串和b[0..i-1]子串的编辑距离。根据上述分析可知。d[i][0] = i, d[0][j] = j,其中一个字符串长度为0是,编辑距离为另一个字符串的长度。其他情况下d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost),cost=0(a[j-1] == b[i-1])或者1(不等)。
矩阵表示如下:
图1表示初始情况,设置d[i][0] = i, d[0][j] = j,
图2表示程序执行两次循环之后的结果;
图3是程序执行结束后的矩阵,右下角的值即为编辑距离,可见kitten和sitting的编辑距离为3.
代码如下:
int LevenshteinDistanceMatrix(string s, string t) { // for all i and j, d[i,j] will hold the Levenshtein distance between // the first i characters of s and the first j characters of t; // note that d has (m+1)*(n+1) values int len_s = s.length(); int len_t = t.length(); int cost; int d[len_t+1][len_s+1]; //The init is not necessary for (int i=0; i<len_t+1; i++) { for (int j=0; j<len_s+1; j++) d[i][j] = 0; } // source prefixes can be transformed into empty string by // dropping all characters for (int i=0; i<len_t+1; i++) { d[i][0] = i; } // target prefixes can be reached from empty source prefix // by inserting every characters for (int j=0; j<len_s+1; j++) { d[0][j] = j; } for (int i=1; i<len_t+1; i++) { for (int j=1; j<len_s+1; j++) { if (t[i-1] == s[j-1]) cost = 0; else cost = 1; d[i][j] = minValue ( d[i-1][j] + 1, // a deletion d[i][j-1] + 1, // an insertion d[i-1][j-1] + cost // a substitution ); } } for (int j=0; j<len_s+1; j++) { if ( j == 0) cout << " "; else cout << s[j-1] << " "; } cout << endl; for (int i=0; i<len_t+1; i++) { if ( i == 0) cout << " "; else cout << t[i-1] << " "; for (int j=0; j<len_s+1; j++) { cout << d[i][j] << " "; } cout << endl; } cout << endl; return d[len_t][len_s]; }
进一步优化
分析矩阵法的执行过程,会发现每次计算的时候只需要本行和上一行的结果,而和再之前的行无关,如果第i行已经完成计算,则第i-1行就不再需要了,所以可以只采用两行的矩阵来实现,当字符串长度大的时候可以有效的节省存储空间。读者可自行修改上述代码实现。
题目变化
此时的编辑距离把插入一个字符、删除一个字符以及替换一个字符都当作一次操作,实际上替换操作可以看作是先删除旧字符再插入新字符,这样替换操作变成了两次操作,此时如何实现呢。
如果真正理解了该问题的实现思路,题目的这个变化很容易满足,只需要改动上述代码里的一个值,没错,就是cost,把cost = 1改为cost = 2即可。