計算字符串的相似度--編程之美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步的計算中記錄一些附加信息,使構造一個最優解變得容易。

  該問題明顯完全符合動態規劃的兩個要素,即最優子結構和重疊子問題特性。該問題的最優指的是兩個字符串的最短距離,子問題的重疊性可以從原書中的那個遞歸算法中看出。

  下面再來詳細說說什麼是重疊子問題。適用於動態規劃求解的最優化問題必須具有的第二個要素是子問題的空間要“很小”,也就是用來解原問題的遞歸算法可以反覆地解同樣的子問題,而不是總在產生新的子問題。典型地,不同的子問題數是輸入規模的一個多項式。當一個遞歸算法不斷地調用同一問題時,我們說該最優問題包含重疊子問題。相反地,適合用分治法解決的問題只往往在遞歸的每一步都產生全新的問題。動態規劃算法總是充分利用重疊子問題,即通過每個子問題只解一次,把解保存在一個需要時就可以查看的表中,而每次查表的時間爲常數。

根據以上的分析,我寫了如下的動態規劃算法:

/*DP Algorithm   * A loop method using dynamic programming.   * Calculate from bottom to top.   */ int calculateStringDistance(string strA, string strB) {      int lenA = (int)strA.length();      int lenB = (int)strB.length();      int c[lenA+1][lenB+1]; // Record the distance of all begin points of each string //初始化方式與揹包問題有點不同      for(int i = 0; i < lenA; i++) c[i][lenB] = lenA - i;      for(int j = 0; j < lenB; j++) c[lenA][j] = lenB - j;      c[lenA][lenB] = 0;      for(int i = lenA-1; i >= 0; i--)          for(int j = lenB-1; j >= 0; j--)          {              if(strB[j] == strA[i])                  c[i][j] = c[i+1][j+1];              else                  c[i][j] = minValue(c[i][j+1], c[i+1][j], c[i+1][j+1]) + 1;          }      return c[0][0]; }
字符串"abdd"和字符串"aebdd"求距離的動態規劃規劃過程如下表:
最後再說說“備忘錄”法。其實它算是動態規劃的一種變形,它既具有通常的動態規劃方法的效率,又採用了一種自頂向下的策略。其思想就是備忘原問題的自然但低效的遞歸算法。像在通常的動態規劃中一樣,維護一個記錄了子問題解的表,但有關填表動作的控制結構更像遞歸算法。

  加了備忘的遞歸算法爲每一個子問題的解在表中記錄一個表項。開始時,每個表項最初都包含一個特殊的值,以表示該表項有待填入。當在遞歸算法的執行中第一次遇到一個子問題時,就計算它的解並填入表中。以後每次遇到該子問題時,只要查看並返回先前填入的值即可。

  下面是原文遞歸算法的做備忘錄版本,並通過布爾變量memoize來控制是否使用備忘錄,以及布爾變量debug來控制是否打印調用過程。有興趣的讀都可以通過這兩個布爾變量的控制來對比一下備忘錄版本與非備忘錄版本的複雜度。

  備忘錄版
#include <iostream> #define M 100 using namespace std; const bool debug = false; // Whether to print debug info const bool memoize = true; // Whether to use memoization unsigned int cnt = 0; // Line number for the debug info int memoizedDistance[M][M]; // Matrix for memoiztion int minValue(int a, int b, int c) {      if(a < b && a < c) return a;      else if(b < a && b < c) return b;      else return c; } /*  20  * A recursive method which can be decorated by memoization.   * Calculate from top to bottom. */ int calculateStringDistance(string strA, int pABegin, int pAEnd, string strB, int pBBegin, int pBEnd) {      if(memoize && memoizedDistance[pABegin][pBBegin] >= 0)          return memoizedDistance[pABegin][pBBegin];      if(pABegin > pAEnd)      {          if(pBBegin > pBEnd)          {              if(memoize)                  memoizedDistance[pABegin][pBBegin] = 0;              if(debug)                  cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=0" << endl;              return 0;          }          else          {              int temp = pBEnd - pBBegin + 1;              if(memoize)                  memoizedDistance[pABegin][pBBegin] = temp;              if(debug)                  cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;              return temp;          }      }      if(pBBegin > pBEnd)      {          if(pABegin > pAEnd)          {              if(memoize)                  memoizedDistance[pABegin][pBBegin] = 0;              if(debug)                  cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=0" << endl;              return 0;          }          else          {              int temp = pAEnd - pABegin + 1;              if(memoize)                  memoizedDistance[pABegin][pBBegin] = temp;              if(debug)                  cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;              return temp;          }      }      if(strA[pABegin] == strB[pBBegin])      {          int temp = calculateStringDistance(strA, pABegin+1, pAEnd, strB, pBBegin+1, pBEnd);          if(memoize)              memoizedDistance[pABegin][pBBegin] = temp;           if(debug)              cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;          return temp;      }      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);          int temp = minValue(t1, t2, t3) + 1;          if(memoize)              memoizedDistance[pABegin][pBBegin] = temp;        if(debug)              cout << cnt++ << ": m(" << pABegin << "," << pBBegin << ")=" << temp << endl;          return temp;      } } int main() {      if(memoize)      {          // initialize the matrix : memoizedDistance[][]          for(int i = 0; i < M; i++)              for(int j = 0; j < M; j++)                  memoizedDistance[i][j] = -1; // -1 means unfilled cell yet      }      string strA = "abcdfef";      string strB = "a";      cout << endl << "Similarity = "              << 1.0 / (1 + calculateStringDistance(strA, 0, (int)strA.length()-1, strB, 0, (int) strB.length()-1))              << endl;      return 0; }

總結 : 可以計算出,如果不用動態規劃或是做備忘錄,最壞情況下複雜度約爲:lenA!*lenB!。使用動態規劃的複雜度爲O((lenA+1)*(lenB+1))。遞歸併做備忘錄的方法最壞情況下複雜度爲O((lenA+1)*(lenB+1))。

  在實際應用中,如果所有的子問題都至少要被計算一次,則一個自底向上的動態規劃算法通常要比一個自頂向下的做備忘錄算法好出一個常數因子,因爲前者無需遞歸的代價,而且維護表格的開銷也小些。此外,在有些問題中,還可以用動態規劃算法中的表存取模式來進一步減少時間或空間上的需求。或者,如果子問題空間中的某些子問題根本沒有必要求解,做備忘錄方法有着只解那些肯定要求解的子問題的優點,對於本問題就是這樣。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章