编程之美3.3—计算字符串的相似度

题目:

许多程序会大量使用字符串。对于不同的字符串,我们希望能够有办法判断其相似程序。我们定义一套操作方法来把两个不相同的字符串变得相同,具体的操作方法为:

  1.修改一个字符(如把“a”替换为“b”);

  2.增加一个字符(如把“abdd”变为“aebdd”);

  3.删除一个字符(如把“travelling”变为“traveling”);

比如,对于“abcdefg”和“abcdef”两个字符串来说,我们认为可以通过增加/减少一个“g”的方式来达到目的。上面的两种方案,都仅需要一 次 。把这个操作所需要的次数定义为两个字符串的距离,而相似度等于“距离+1”的倒数。也就是说,“abcdefg”和“abcdef”的距离为1,相似度 为1/2=0.5。

给定任意两个字符串,你是否能写出一个算法来计算它们的相似度呢?

解法一:递归求解

基本思想:

不难看出,两个字符串的距离肯定不超过它们的长度之和(我们可以通过删除操作把两个串都转化为空串)。虽然这个结论对结果没有帮助,但至少可以知道,任意两个字符串的距离都是有限的。

我们还是就住集中考虑如何才能把这个问题转化成规模较小的同样的子问题。如果有两个串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]变成相字符串。

这样,很快就可以完成一个递归程序。

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;
     }
}

解法二:动规求解

基本思想:

上面的递归程序,有什么地方需要改进呢?问题在于:在递归的过程中,有些数据被重复计算了。

   我们知道适合采用动态规划方法的最优化问题中的两个要素:最优子结构和重叠子问题。另外,还有一种方法称为备忘录(memoization),可以充分利用重叠子问题的性质。

  下面简述一下动态规划的基本思想。和分治法一样,动态规划是通过组合子问题的解而解决整个问题的。我们知道,分治算法是指将问题划分 成一睦独立的子问题,递归 地求解各子问题,然后合并子问题的解而得到原问题的解。与此不同,动态规划适用于子问题不是独立 的情况,也就是各子问题包含公共的子子问题。在这种情况 下,若用分治法则会做许多不必要的工作,即重复地求解公共的子子问题。动态规划算法对每个子子问题只求解一次,将其结果保存在一张表中,从而避免每次遇到各个子问题时重新计算答案。

  动态规划通常应用于最优化问题。此类问题可能有很多种可行解,每个解有一个值,而我们希望找出一个具有最优(最大或最小)值的解。称这样的解为该问题的“一个”最优解(而不是“确定的”最优解),因为可能存在多个取最优值的解。

  动态规划算法的设计可以分为如下4个步骤:

  1)描述最优解的结构。

2)递归定义最优解的值。

3)按自底向上的方式计算最优解的值。

4)由计算出的结果构造一个最优解。

  第1~3步构成问题的动态规划解的基础。第4步在只要求计算最优解的值时可以略去。如果的确做了第4步,则有时要在第3步的计算中记录一些附加信息,使构造一个最优解变得容易。

  该问题明显完全符合动态规划的两个要素,即最优子结构和重叠子问题特性。该问题的最优指的是两个字符串的最短距离,子问题的重叠性可以从原书中的那个递归算法中看出。

  下面再来详细说说什么是重叠子问题。适用于动态规划求解的最优化问题必须具有的第二个要素是子问题的空间要“很小”,也就是用来解原问题的递归算法可以反复地解同样的子问题,而不是总在产生新的子问题。典型地,不同的子问题数是输入规模的一个多项式。当一个递归算法不断地调用同一问题时,我们说该最优问题包含重叠子问题。相反地,适合用分治法解决的问题只往往在递归的每一步都产生全新的问题。动态规划算法总是充分利用重叠子问题,即通过每个子问题只解一次,把解保存在一个需要时就可以查看的表中,而每次查表的时间为常数。

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

如果strA[i] 等于strB[j],则:

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

如果strA[i] 不等于strB[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 表示对strA[i]执行插入操作后计算最小编辑距离

d[i - 1, j] + 1 表示对strA[i]执行删除操作后计算最小编辑距离

d[i - 1, j - 1] + 1表示对strA[i]替换成strB[i]操作后计算最小编辑距离

d[i, j]的边界值就是当strB为空字符串(m = 0)或strA为空字符串(n = 0)时所计算出的编辑距离:

m = 0,对于所有 i:d[i, 0] = i

n = 0,对于所有 j:d[0, j] = j

根据前面分析的最优子结构、最优解的递推关系以及边界值,写出用动态规划法求解最小编辑距离的算法就很容易了,以下代码就是计算两个字符串的最小编辑距离的算法实现:

/*注意:strA和strB字符串的长度不能超过d矩阵的限制*/
int EditDistance(string strA, string strB)
{
        int i,j;
        int d[MAX_STRING_LEN][MAX_STRING_LEN] = { 0 };

        for(i = 0; i <= strA.length(); i++)
             d[i][0] = i;
        for(j = 0; j <= strB.length(); j++)
             d[0][j] = j;

        for(i = 1; i <= strA.length(); i++)
        {
            for(j = 1; j <= strB.length(); j++)
            {
                 if((strA[i - 1] == strB[j - 1]))
                 {
                     d[i][j] = d[i - 1][j - 1]; //不需要编辑操作
                 }
                 else
                 {
                     int edIns = d[i][j - 1] + 1; //strA插入字符
                     int edDel = d[i - 1][j] + 1; //strA删除字符
                     int edRep = d[i - 1][j - 1] + 1; //strA替换字符

                    d[i][j] = min(edIns, edDel, edRep);
                 }
             }
         }

        return d[source.length()][target.length()];
}

解法三:备忘录求解

基本思想:

解法一上面的递归程序,有什么地方需要改进呢?问题在于:在递归的过程中,有些数据被重复计算了。

备忘录其实它算是动态规划的一种变形,它既具有通常的动态规划方法的效率,又采用了一种自顶向下的策略。其思想就是备忘原问题的自然但低效的递归算法。像在通常的动态规划中一样,维护一个记录了子问题解的表,但有关填表动作的控制结构更像递归算法。

  加了备忘的递归算法为每一个子问题的解在表中记录一个表项。开始时,每个表项最初都包含一个特殊的值,以表示该表项有待填入。当在递归算法的执行中第一次遇到一个子问题时,就计算它的解并填入表中。以后每次遇到该子问题时,只要查看并返回先前填入的值即可。

  下面是原文递归算法的做备忘录版本,并通过布尔变量memoize来控制是否使用备忘录,以及布尔变量debug来控制是否打印调用过程。有兴趣的读都可以通过这两个布尔变量的控制来对比一下备忘录版本与非备忘录版本的复杂度。

1 #include <iostream>

2 #define M 100

3

4 using namespace std;

5

6 const bool debug = false; // Whether to print debug info

7 const bool memoize = true; // Whether to use memoization

8 unsigned int cnt = 0; // Line number for the debug info

9

10 int memoizedDistance[M][M]; // Matrix for memoiztion

11

12 int minValue(int a, int b, int c)

13 {

14     if(a < b && a < c) return a;

15     else if(b < a && b < c) return b;

16     else return c;

17 }

18

19 /*

20  * A recursive method which can be decorated by memoization.

21  * Calculate from top to bottom.

22  */

23 int calculateStringDistance(string strA, int pABegin, int pAEnd, string strB, int pBBegin, int pBEnd)

24 {

25     if(memoize && memoizedDistance[pABegin][pBBegin] >= 0)

26         return memoizedDistance[pABegin][pBBegin];

27

28     if(pABegin > pAEnd)

29     {

30         if(pBBegin > pBEnd)

31         {

32             if(memoize)

33                 memoizedDistance[pABegin][pBBegin] = 0;

34             if(debug)

35                 cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=0" << endl;

36             return 0;

37         }

38         else

39         {

40             int temp = pBEnd - pBBegin + 1;

41             if(memoize)

42                 memoizedDistance[pABegin][pBBegin] = temp;

43             if(debug)

44                 cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;

45             return temp;

46         }

47     }

48

49     if(pBBegin > pBEnd)

50     {

51         if(pABegin > pAEnd)

52         {

53             if(memoize)

54                 memoizedDistance[pABegin][pBBegin] = 0;

55             if(debug)

56                 cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=0" << endl;

57             return 0;

58         }

59         else

60         {

61             int temp = pAEnd - pABegin + 1;

62             if(memoize)

63                 memoizedDistance[pABegin][pBBegin] = temp;

64             if(debug)

65                 cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;

66             return temp;

67         }

68     }

69

70     if(strA[pABegin] == strB[pBBegin])

71     {

72         int temp = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin+1, pBEnd);

73         if(memoize)

74             memoizedDistance[pABegin][pBBegin] = temp;

75          if(debug)

76             cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;

77         return temp;

78     }

79     else

80     {

81         int t1 = calculateStringDistance(strA, pABegin, pAEnd, strB, pBBegin+1, pBEnd);

82         int t2 = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin, pBEnd);

83         int t3 = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin+1, pBEnd);

84         int temp = minValue(t1, t2, t3) + 1;

85         if(memoize)

86             memoizedDistance[pABegin][pBBegin] = temp;

87         if(debug)

88             cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;

89         return temp;

90     }

91 }

92

93 int main()

94 {

95     if(memoize)

96     {

97         // initialize the matrix : memoizedDistance[][]

98         for(int i = 0; i < M; i++)

99             for(int j = 0; j < M; j++)

100                 memoizedDistance[i][j] = -1; // -1 means unfilled cell yet

101     }

102

103     string strA = "abcdfef";

104     string strB = "a";

105

106     cout << endl << "Similarity = "

107             << 1.0 / (1 + calculateStringDistance(strA, 0, (int)strA.length()-1, strB, 0, (int)strB.length()-1))

108             << endl;

109

110     return 0;

111 }

总结 :

可以计算出,如果不用动态规划或是做备忘录,最坏情况下复杂度约为:lenA!*lenB!。使用动态规划的复杂度为O((lenA+1)*(lenB+1))。递归并做备忘录的方法最坏情况下复杂度为O((lenA+1)*(lenB+1))。

在实际应用中,如果所有的子问题都至少要被计算一次,则一个自底向上的动态规划算法通常要比一个自顶向下的做备忘录算法好出一个常数因子,因为前者无需递归的代价,而且维护表格的开销也小些。此外,在有些问题中,还可以用动态规划算法中的表存取模式来进一步减少时间或空间上的需求。或者,如果子问题空间中的某些子问题根本没有必要求解,做备忘录方法有着只解那些肯定要求解的子问题的优点,对于本问题就是这样。

时间: 2024-10-07 07:04:41

编程之美3.3—计算字符串的相似度的相关文章

编程之美3.3 计算两个字符串的相似度

      假如有两个字符串分别是:abcd,bbcd,那么,这两个字符串不相同的字符个数是1,即第一个字符时不相同的,定义字符串的相似度为 1 / (x + 1),其中,x 就是不相同的字符个数.       我们可以有三种方法比较两个字符串中不相同字符的个数:       1.去掉第一个字符串中不相同的那个字符,并同时再去比较下一个字符       2.去掉第二个字符串中不相同的那个字符,并同时再去比较下一个字符       3.同时去掉字符串中不相同的那个字符,并同时再去比较下一个字符  

编程之美2.13 子数组最大乘积

问题描述: 给定一个长度为N的整数数组,只允许用乘法,不能用除法,计算任意(N-1)个数的组合乘积中最大的一组,并写出算法的时间复杂度. 解法: 1.暴力解法------O(n^2) 2.前后缀法------O(n) 3.统计法--------O(n) 具体思路和代码: 1.暴力解法: 思路:利用两层循环,依次删掉一个,其余的做乘法,计算出最大的. 代码: 1 int s1(int A[], int n) 2 { 3 int s = 1; 4 int max; 5 for(int i = 1;

编程之美2.14 求数组的子数组之和的最大值

问题描述: 一个有N个整数元素的一维数组(A[0], A[1], A[2],...,A[n-1]),这个数组当然有很多子数组,那么子数组之和的最大值是什么呢? 解法: 1. 暴力解法-------O(N^3) 2. 改进版暴力解法-------O(N^2) *3. 分治算法-------O(NlogN)(暂时未去实现) 4. 数组间关系法-------O(N) 具体思路和代码: 1.暴力解法 思路:Sum[i,...,j]为数组第i个元素到第j个元素的和,遍历所有可能的Sum[i,...,j].

编程之美2.17 数组循环移位

问题描述: 设计一个算法,把一个含有N元素的数组循环左移或者右移K位. 解决方法: 1. 暴力解法------O(KN) 2. 颠倒位置------O(N) 具体思路和代码: 1. 暴力解法------O(KN) 思路:循环K次,每次移动一位 代码: 1 //右移 2 void s1(int A[], int n, int k) 3 { 4 k = k % n; 5 for(int i = 0; i < k; i++) 6 { 7 int t = A[n-1]; 8 for(int j = n-

编程之美leetcode之编辑距离

Edit Distance Given two words word1 and word2, find the minimum number of steps required to convert word1 to word2. (each operation is counted as 1 step.) You have the following 3 operations permitted on a word: a) Insert a character b) Delete a char

编程之美2.17之数组循环移位

题目描述:设计一个算法,把一个含有N个元素的数组循环右移K位,要求算法的时间复杂度位O(Log2N),且只允许使用两个附加变量. 什么意思呢,就是说如果输入序列为:abcd1234,右移2位即变为34abcd12.唯一的要求就是使用两个附加变量. 其实这道题编程珠玑上面也出现过,书中给出的一种符合题意的解法是巧妙地进行翻转.以把abcd1234右移4位为例: 第一步:翻转1234,abcd1234---->abcd4321 第二步:翻转abcd,abcd4321---->dcba4321 第三

编程之美2.3: 寻找发帖水王

题目:传说,Tango有一大"水王",他不但喜欢发帖,还会回复其他ID发的帖子,发帖数目超过帖子总数的一半,如果你有一个当前论坛上所有帖子的列表,其中帖子作者的ID也在表中,你能快速找到这个传说中的Tango水王吗? 解题思路:由于水王的发帖数目超过一半,当每次删除两个不同ID的帖子时,水王占得帖子数目仍然大于剩下帖子的一半,重复整个过程,将ID列表中的ID总数降低,转化为更小的问题,从而得到最后水王的ID. #include <iostream> #include <

编程之美2.1 求二进制中1的个数

最近一段的时间,一直在看编程之美之类的算法书籍,刚开始看编程之美,感觉到难度太大,有时候也不愿意去翻动这本书,不过,经过一段时间的修炼,我也彻底的喜欢上这本书了, 书中的算法涉及到很多方面,树,链表,位运算,数组,hash表应用等等. 由于最近事情也忙得差不多了,我重新写了一遍编程之美中的算法,在这里记录下来,以便以后阅读方便. 第一道题从2.1写起,这道题目难度不是很大,首先,给出这个题目的函数声明: /*2.1 求二进制中1的个数*/ int DutCountOf1InBin_1(unsig

Java 编程之美:并发极速赛车平台出租编程高级篇

借用 Java 并发极速赛车平台出租haozbbs.comQ1446595067 编程实践中的话:编写正确的程序并不容易,而编写正常的并发程序就更难了. 相比于顺序执行的情况,多线程的线程安全问题是微妙而且出乎意料的,因为在没有进行适当同步的情况下多线程中各个操作的顺序是不可预期的. 并发编程相比 Java 中其他知识点学习起来门槛相对较高,学习起来比较费劲,从而导致很多人望而却步: 而无论是职场面试和高并发高流量的系统的实现却都还离不开并发编程,从而导致能够真正掌握并发编程的人才成为市场比较迫