字符串算法专题之:字符串相似度和字符串编辑距离

字符串编辑距离,又称Levenshtein距离(也叫做Edit Distance),是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符和删除一个字符。我们认为三种编辑操作的任何一种执行一次则编辑次数加1.

例如将kitten一字转成sitting:
sitten (k→s)
sittin (e→i)
sitting (→g)

俄罗斯科学家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即可。

 

 

 
posted @ 2013-07-07 22:05  苍穹逸影  阅读(620)  评论(0编辑  收藏  举报