動態規劃


動態規劃的設計思想

動態規劃(DP)[1]通過分解成子問題解決了給定複雜的問題,並存儲子問題的結果,以避免再次計算相同的結果。我們通過下面這個問題來說明這兩個重要屬性:重疊子問題最優子結構

重疊子問題

像分而治之,動態規劃也把問題分解爲子問題。動態規劃主要用於:當相同的子問題的解決方案被重複利用。在動態規劃中,子問題解決方案被存儲在一個表中,以便這些不必重新計算。因此,如果這個問題是沒有共同的(重疊)子問題, 動態規劃是沒有用的。例如,二分查找不具有共同的子問題。下面是一個斐波那契函數的遞歸函數,有些子問題被調用了很多次。

/* simple recursive program for Fibonacci numbers */
int fib(int n)
{
   if ( n <= 1 )
      return n;
   return fib(n-1) + fib(n-2);
}

執行 fib(5) 的遞歸樹

                fib(5)
                     /             \
               fib(4)                fib(3)
             /      \                /     \
         fib(3)      fib(2)         fib(2)    fib(1)
        /     \        /    \       /    \
  fib(2)   fib(1)  fib(1) fib(0) fib(1) fib(0)
  /    \
fib(1) fib(0)

我們可以看到,函數f(3)被稱執行2次。如果我們將存儲f(3)的值,然後避免再次計算的話,我們會重新使用舊的存儲值。有以下兩種不同的方式來存儲這些值,以便這些值可以被重複使用。

  • 記憶化(自上而下)
  • 打表(自下而上)

記憶化(自上而下)

記憶化存儲其實是對遞歸程序小的修改,作爲真正的DP程序的過渡。我們初始化一個數組中查找所有初始值爲零。每當我們需要解決一個子問題,我們先來看看這個數組(查找表)是否有答案。如果預先計算的值是有那麼我們就返回該值,否則,我們計算該值並把結果在數組(查找表),以便它可以在以後重複使用。

下面是記憶化存儲程序:

/* Memoized version for nth Fibonacci number */
#include<stdio.h>
#define NIL -1
#define MAX 100

int lookup[MAX];

/* Function to initialize NIL values in lookup table */
void _initialize()
{
  int i;
  for (i = 0; i < MAX; i++)
    lookup[i] = NIL;
}

/* function for nth Fibonacci number */
int fib(int n)
{
   if(lookup[n] == NIL)
   {
    if ( n <= 1 )
      lookup[n] = n;
    else
      lookup[n] = fib(n-1) + fib(n-2);
   }

   return lookup[n];
}

int main ()
{
  int n = 40;
  _initialize();
  printf("Fibonacci number is %d ", fib(n));
  getchar();
  return 0;
}

打表(自下而上)

下面我們給出自下而上的打表方式,並返回表中的最後一項。

/* tabulated version */
#include<stdio.h>
int fib(int n)
{
  int f[n+1];
  int i;
  f[0] = 0;   f[1] = 1; 
  for (i = 2; i <= n; i++)
      f[i] = f[i-1] + f[i-2];

  return f[n];
}

int main ()
{
  int n = 9;
  printf("Fibonacci number is %d ", fib(n));
  getchar();
  return 0;
}

這兩種方法都能存儲子問題解決方案。在第一個版本中,記憶化存儲只在查找表存儲需要的答案。而第二個版本,所有子問題都會被存儲到查找表中,不管是否是必須的。比如LCS問題的記憶化存儲版本,並不會存儲不必要的子問題答案。

最優子結構[2]

如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質(即滿足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。

例如,最短路徑問題有以下最優子結構性質:如果一個節點x是到源節點ü的最短路徑,同時又是到目的節點V的最短路徑,則最短路徑從u到v是結合最短路徑:u到x和x到v。解決任意兩點間的最短路徑的算法的Floyd-Warshall算法[3]和貝爾曼-福特[4]是動態規劃的典型例子。

另一方面最長路徑問題不具有最優子結構性質。這裏的最長路徑是指兩個節點之間最長簡單路徑(路徑不循環)。

考慮下算法導論上面的例子:

這裏寫圖片描述
有兩條最長的路徑與Q到T:Q – > R – > T和Q – > S-> T。不像最短路徑,這些路徑最長不具有最優子屬性。例如,最長路徑q-> r-> t不是由q->r 和 r->t的組合 ,因爲最長的路徑從q至r爲q-> s-> t->r


動態規劃算法的設計要素

這裏用一個矩陣鏈乘問題爲例說明動態規劃算法的設計要素。

例如:給定n個矩陣{A1,A2,,An} ,其中AiAi+1 是可乘的,i=1,2,,n1 。考察這n個矩陣的連乘積A1A2An 。由於矩陣乘法滿足結合律,故計算矩陣的連乘積可以有許多不同的計算次序,這種計算次序可以用加括號的方式來確定。若一個矩陣連乘積的計算次序完全確定,則可以依此次序反覆調用2個矩陣相乘的標準算法(有改進的方法,這裏不考慮)計算出矩陣連乘積。若A是一個p×q矩陣,B是一個q×r矩陣,則計算其乘積C=AB的標準算法中,需要進行pqr次數乘。

例如,如果我們有四個矩陣A,B,C和D,我們將有:

(AB)C=A(BC=(AC)B=....

不同組合得到的運算次數是不同的,例如A爲 10 × 30 , B爲 30 × 5 , C 爲 5 × 60 ,那麼,如果採用第一種次序,執行的基本運算次數是:
(AB)C = (10×30×5) + (10×5×60) = 1500 + 3000 = 4500

而採用第二種次序,執行的基本運算次數是:
A(BC) = (30×5×60) + (10×30×60) = 9000 + 18000 = 27000

很明顯第一種運算更爲高效。

問題:給定一個數組P[]表示矩陣的鏈,使得第i個矩陣Ai 的維數爲 p[i-1] x p[i].。我們需要寫一個函數MatrixChainOrder()返回這個矩陣連相乘最小的運算次數。

示例:

輸入:P [] = {40,20,30,10,30}   
輸出:26000  
有4個矩陣維數爲 40X20,20X30,30×10和10X30。
運算次數最少的計算方式爲:
(A(BC))D  - > 20 * 30 * 10 +40 * 20 * 10 +40 * 10 * 30

輸入:P[] = {10,20,30,40,30} 
輸出:30000
有4個矩陣維數爲 10×20,20X30,30X40和40X30。 
運算次數最少的計算方式爲:
  ((AB)C)D  - > 10 * 20 * 30 +10 * 30 * 40 +10 * 40 * 30

最優子結構:

一個簡單的解決辦法是把括號放在所有可能的地方,計算每個位置的成本,並返回最小值。對於一個長度爲n的鏈,我們有n-1種方法放置第一組括號。

例如,如果給定的鏈是4個矩陣。讓矩陣連爲ABCD,則有3種方式放第一組括號:A(BCD),(AB)CD和(ABC)D。

所以,當我們把一組括號,我們把問題分解成更小的尺寸的子問題。因此,這個問題具有最優子結構性質,可以使用遞歸容易解決。

2)重疊子問題
以下是遞歸的實現,只需用到上面的最優子結構性質。

//直接的遞歸解決
#include<stdio.h>
#include<limits.h>
//矩陣 Ai 的維數爲 p[i-1] x p[i] ( i = 1..n )
int MatrixChainOrder(int p[], int i, int j)
{
    if(i == j)
        return 0;
    int k;
    int min = INT_MAX;
    int count;

    // 在第一個和最後一個矩陣直接放置括號
    //遞歸計算每個括號,並返回最小的值
    for (k = i; k <j; k++)
    {
        count = MatrixChainOrder(p, i, k) +
                MatrixChainOrder(p, k+1, j) +
                p[i-1]*p[k]*p[j];

        if (count < min)
            min = count;
    }

    return min;
}

// 測試
int main()
{
    int arr[] = {1, 2, 3, 4, 3};
    int n = sizeof(arr)/sizeof(arr[0]);
    printf("Minimum number of multiplications is %d ", 
                          MatrixChainOrder(arr, 1, n-1));

    getchar();
    return 0;
}

上面直接的遞歸方法的複雜性是指數級。當然可以用記憶化存儲優化。應當指出的是,上述函數反覆計算相同的子問題。請參閱下面的遞歸樹的大小4的矩陣鏈。函數MatrixChainOrder(3,4)被調用兩次。我們可以看到,有許多子問題被多次調用。

這裏寫圖片描述

動態規劃解決方案
以下是C / C + +實現,使用動態規劃矩陣鏈乘法問題。

#include<stdio.h>
#include<limits.h>

int MatrixChainOrder(int p[], int n)
{

    /* 第0行第0列其實沒用到 */
    int m[n][n];

    int i, j, k, L, q;

    //單個矩陣相乘,所需數乘次數爲0
    for (i = 1; i < n; i++)
        m[i][i] = 0;

     //以下兩個循環是關鍵之一,以6個矩陣爲例(爲描述方便,m[i][j]用ij代替)
     //需按照如下次序計算
     //01 12 23 34 45
     //02 13 24 35
     //03 14 25
     //04 15
     //05
     //下面行的計算結果將會直接用到上面的結果。例如要計算14,就會用到12,24;或者13,34等等
    for (L=2; L<n; L++)   
    {
        for (i=1; i<=n-L+1; i++)
        {
            j = i+L-1;
            m[i][j] = INT_MAX;
            for (k=i; k<=j-1; k++)
            {
                q = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
                if (q < m[i][j])
                    m[i][j] = q;
            }
        }
    }

    return m[1][n-1];
}

int main()
{
    int arr[] = {1, 2, 3, 4};
    int size = sizeof(arr)/sizeof(arr[0]);

    printf("Minimum number of multiplications is %d ",
                       MatrixChainOrder(arr, size));

    getchar();
    return 0;
}

時間複雜度: O(n3)
空間複雜度: O(n2)


動態規劃算法典型的應用

最長遞增子序列LIS

最長遞增子序列LIS[5]的問題可以使用動態規劃要解決的問題,例如,最長遞增子序列(LIS)的問題是要找到一個給定序列的最長子序列的長度,使得子序列中的所有元素被排序的順序增加。例如,{10,22,9,33,21,50,41,60,80} LIS的長度是6和 LIS爲{10,22,33,50,60,80}。

1) 最優子結構

對於長度爲N的數組A[N]={a0,a1,a2,,an1} ,假設假設我們想求以aj結尾的最大遞增子序列長度,設爲L[j],那麼L[j] = max(L[i]) + 1, where i < j && a[i] < a[j], 也就是i的範圍是0到j – 1。這樣,想求aj結尾的最大遞增子序列的長度,我們就需要遍歷j之前的所有位置i(0到j-1),找出a[i] < a[j],計算這些i中,能產生最大L[i]的i,之後就可以求出L[j]。之後我對每一個A[N]中的元素都計算以他們各自結尾的最大遞增子序列的長度,這些長度的最大值,就是我們要求的問題——數組A的最大遞增子序列。

2) 重疊子問題

以下是簡單的遞歸實現LIS問題(先不說性能和好壞,後面討論)。這個實現我們遵循上面提到的遞歸結構。使用 max_ending_here 返回 每一個LIS結尾的元素,結果LIS是使用指針變量返回。

/* LIS 簡單的遞歸實現 */
#include<stdio.h>
#include<stdlib.h>

/* 要利用遞歸調用,此函數必須返回兩件事情:
   1) Length of LIS ending with element arr[n-1]. We use max_ending_here for this purpose
   2) Overall maximum as the LIS may end with an element before arr[n-1]  max_ref is used this purpose.
The value of LIS of full array of size n is stored in *max_ref which is our final result
*/
int _lis( int arr[], int n, int *max_ref)
{
    /* Base case */
    if(n == 1)
        return 1;

    int res, max_ending_here = 1; // 以arr[n-1]結尾的 LIS的長度

    /* Recursively get all LIS ending with arr[0], arr[1] ... ar[n-2]. If 
       arr[i-1] is smaller than arr[n-1], and max ending with arr[n-1] needs
       to be updated, then update it */
    for(int i = 1; i < n; i++)
    {
        res = _lis(arr, i, max_ref);
        if (arr[i-1] < arr[n-1] && res + 1 > max_ending_here)
            max_ending_here = res + 1;
    }

    // Compare max_ending_here with the overall max. And update the
    // overall max if needed
    if (*max_ref < max_ending_here)
       *max_ref = max_ending_here;

    // Return length of LIS ending with arr[n-1]
    return max_ending_here;
}

// The wrapper function for _lis()
int lis(int arr[], int n)
{
    // The max variable holds the result
    int max = 1;

    // The function _lis() stores its result in max
    _lis( arr, n, &max );

    // returns max
    return max;
}

/* 測試上面的函數 */
int main()
{
    int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
    int n = sizeof(arr)/sizeof(arr[0]);
    printf("Length of LIS is %d\n",  lis( arr, n ));
    getchar();
    return 0;
}

根據上面的實現方式,以下是遞歸樹大小4的調用。LIS(N)爲我們返回arr[]數組的LIS長度。

                 lis(4)           
                 /       |      \
         lis(3)      lis(2)    lis(1)  
        /     \        /         
  lis(2)  lis(1)   lis(1) 
  /    
lis(1)

我們可以看到,有些重複的子問題被多次計算。所以我們可以使用memoization (記憶化存儲)的或打表 來避免同一子問題的重新計算。以下是打表方式實現的LIS。

/* LIS 的動態規劃方式實現*/
#include<stdio.h>
#include<stdlib.h>
/* lis() returns the length of the longest increasing subsequence in 
    arr[] of size n */
int lis( int arr[], int n )
{
   int *lis, i, j, max = 0;
   lis = (int*) malloc ( sizeof( int ) * n );

   /* Initialize LIS values for all indexes */
   for ( i = 0; i < n; i++ )
      lis[i] = 1;

   /* Compute optimized LIS values in bottom up manner */
   for ( i = 1; i < n; i++ )
      for ( j = 0; j < i; j++ )
         if ( arr[i] > arr[j] && lis[i] < lis[j] + 1)
            lis[i] = lis[j] + 1;

   /* Pick maximum of all LIS values */
   for ( i = 0; i < n; i++ )
      if ( max < lis[i] )
         max = lis[i];

   /* Free memory to avoid memory leak */
   free( lis );

   return max;
}

/* 測試程序 */
int main()
{
  int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
  int n = sizeof(arr)/sizeof(arr[0]);
  printf("Length of LIS is %d\n", lis( arr, n ) );

  getchar();
  return 0;
}

注意,上面動態的DP解決方案的時間複雜度爲On2 ,其實較好的解決方案是O(nlogn)

最長公共子序列LCS

LCS問題[6] [7]描述:給定兩個序列,找出在兩個序列中同時出現的最長子序列的長度。一個子序列是出現在相對順序的序列,但不一定是連續的。例如,“ABC”,“ABG”,“BDF”,“AEG”,“acefg“,..等都是”ABCDEFG“ 序列。因此,長度爲n的字符串有2n 個不同的可能的序列。

注意:

最長公共子串(Longest CommonSubstring)和最長公共子序列(LongestCommon Subsequence, LCS)的區別:子串(Substring)是串的一個連續的部分,子序列(Subsequence)則是從不改變序列的順序,而從序列中去掉任意的元素而獲得的新序列;更簡略地說,前者(子串)的字符的位置必須連續,後者(子序列LCS)則不必。比如字符串acdfg同akdfc的最長公共子串爲df,而他們的最長公共子序列是adf。LCS可以使用動態規劃法解決。

這是一個典型的計算機科學問題,基礎差異(即輸出兩個文件之間的差異文件比較程序),並在生物信息學有較多應用。例如:輸入序列“ABCDGH”和“AEDFHR” 的LCS是“ADH”長度爲3;輸入序列“AGGTAB”和“GXTXAYB”的LCS是“GTAB”長度爲4。

這個問題的直觀的解決方案是同時生成給定序列的所有子序列,找到最長匹配的子序列。此解決方案的複雜性是指數的。讓我們來看看如何這個問題 (擁有動態規劃(DP)問題的兩個重要特性):

1)最優子結構:

設輸入序列是X[0..m1]Y[0..n1] ,長度分別爲mn 。和設序列L(X[0..m1]Y[0..n1] 是這兩個序列的LCS的長度。

以下爲LX[0..M1]Y[0..N1] 的遞歸定義:

  • 如果兩個序列的最後一個元素匹配(即X [M-1] == Y [N-1])
    LX[0..M1]Y[0..N1]=1+LX[0..M2]Y[0..N1]
  • 如果兩個序列的最後字符不匹配(即X [M-1]!= Y [N-1])
    LX[0..M1]Y[0..N1]=MAXLX[0..M2]Y[0..N1]LX[0..M1]Y[0..N2]

例:
1)考慮輸入字符串“AGGTAB”和“GXTXAYB”。最後一個字符匹配的字符串。這樣的LCS的長度可以寫成:
L(“AGGTAB”, “GXTXAYB”) = 1 + L(“AGGTA”, “GXTXAY”)

2)考慮輸入字符串“ABCDGH”和“AEDFHR。最後字符不爲字符串相匹配。這樣的LCS的長度可以寫成:
L(“ABCDGH”, “AEDFHR”) = MAX ( L(“ABCDG”, “AEDFHR”), L(“ABCDGH”, “AEDFH”) )

因此,LCS問題有最優子結構性質!

2)重疊子問題:

以下是直接的遞歸實現, 遵循上面提到的遞歸結構。

/* 簡單的遞歸實現LCS問題 */
#include<stdio.h>
#include<stdlib.h>

int max(int a, int b);

/* Returns length of LCS for X[0..m-1], Y[0..n-1] */
int lcs( char *X, char *Y, int m, int n )
{
   if (m == 0 || n == 0)
     return 0;
   if (X[m-1] == Y[n-1])
     return 1 + lcs(X, Y, m-1, n-1);
   else
     return max(lcs(X, Y, m, n-1), lcs(X, Y, m-1, n));
}

/* Utility function to get max of 2 integers */
int max(int a, int b)
{
    return (a > b)? a : b;
}

/* 測試上面的函數 */
int main()
{
  char X[] = "AGGTAB";
  char Y[] = "GXTXAYB";

  int m = strlen(X);
  int n = strlen(Y);

  printf("Length of LCS is %d\n", lcs( X, Y, m, n ) );

  getchar();
  return 0;
}

上面直接的遞歸方法的時間複雜度爲O2n .(在最壞的情況下。X和Y不匹配的所有字符即LCS的長度爲0)。按照到上述的實現,下面是對輸入字符串AXYTAYZX 的部分遞歸樹:

                    lcs("AXYT", "AYZX")
                       /                 \
         lcs("AXY", "AYZX")            lcs("AXYT", "AYZ")
         /            \                  /               \
lcs("AX", "AYZX") lcs("AXY", "AYZ")   lcs("AXY", "AYZ") lcs("AXYT", "AY")

在上述部分遞歸樹,LCS(“AXY”,“AYZ”)被調用兩次。如果我們繪製完整的遞歸樹,那麼我們可以看到,我們可以看到很多重複的調用。所以這個問題有重疊的子結構性質,可使用memoization的或打表來避免重新計算。下面是用動態規劃(打表)解決LCS問題:

/ *動態規劃實現的LCS問題* /
#include<stdio.h>
#include<stdlib.h>

int max(int a, int b);

/* Returns length of LCS for X[0..m-1], Y[0..n-1] */
int lcs( char *X, char *Y, int m, int n )
{
   int L[m+1][n+1];
   int i, j;

   /* Following steps build L[m+1][n+1] in bottom up fashion. Note 
      that L[i][j] contains length of LCS of X[0..i-1] and Y[0..j-1] */
   for (i=0; i<=m; i++)
   {
     for (j=0; j<=n; j++)
     {
       if (i == 0 || j == 0)
         L[i][j] = 0;

       else if (X[i-1] == Y[j-1])
         L[i][j] = L[i-1][j-1] + 1;

       else
         L[i][j] = max(L[i-1][j], L[i][j-1]);
     }
   }

   /* L[m][n] contains length of LCS for X[0..n-1] and Y[0..m-1] */
   return L[m][n];
}

/* Utility function to get max of 2 integers */
int max(int a, int b)
{
    return (a > b)? a : b;
}

/*測試上面的函數 */
int main()
{
  char X[] = "AGGTAB";
  char Y[] = "GXTXAYB";

  int m = strlen(X);
  int n = strlen(Y);

  printf("Length of LCS is %d\n", lcs( X, Y, m, n ) );

  getchar();
  return 0;
}

最長迴文子序列

問題[8]:給一個字符串,找出它的最長的迴文子序列的長度。例如,如果給定的序列是“BBABCBCAB”,則輸出應該是7,“BABCBAB”是在它的最長迴文子序列。 “BBBBB”和“BBCBB”也都是該字符串的迴文子序列,但不是最長的。注意和最長迴文子串的區別(參考:最長迴文串)!這裏說的子序列,類似最長公共子序列LCS( Longest Common Subsequence)問題,可以是不連續的。這就是LPS(Longest Palindromic Subsequence)問題。

最直接的解決方法是:生成給定字符串的所有子序列,並找出最長的迴文序列,這個方法的複雜度是指數級的。下面來分析怎麼用動態規劃解決。

1)最優子結構

假設 X[0 … n-1] 是給定的序列,長度爲n. 讓 L(0,n-1) 表示 序列 X[0 … n-1] 的最長迴文子序列的長度。

  • 如果X的最後一個元素和第一個元素是相同的,這時:L(0,n1)=L(1,n2)+2 , 還以 “BBABCBCAB” 爲例,第一個和最後一個相同,因此 L(1,n-2) 就表示紅色的部分。
  • 如果不相同:L(0, n-1) = MAX ( L(1, n-1) , L(0, n-2) )。 以”BABCBCA” 爲例,L(1,n-1)即爲去掉第一個元素的子序列,L(0, n-2)爲去掉最後一個元素。

有了上面的公式,可以很容易的寫出下面的遞歸程序:

#include<stdio.h>
#include<string.h>
int lps(char *seq, int i, int j)
{
   //一個元素即爲1
   if (i == j)
     return 1;
   if(i > j) return 0; //因爲只計算序列 seq[i ... j]

   // 如果首尾相同
   if (seq[i] == seq[j])
      return lps (seq, i+1, j-1) + 2;

   // 首尾不同
   return max( lps(seq, i, j-1), lps(seq, i+1, j) );
}

/* 測試 */
int main()
{
    char seq[] = "acmerandacm";
    int n = strlen(seq);
    printf ("The lnegth of the LPS is %d", lps(seq, 0, n-1));
    getchar();
    return 0;
}

Output: The lnegth of the LPS is 5 (即爲: amama)

2) 重疊子問題

畫出上面程序的遞歸樹(部分),已一個長度爲6 的字符串爲例:

        L(0, 5)
          /          \  
      L(1,5)          L(0,4)
    /      \          /      \
L(2,5)    L(1,4)  L(1,4)  L(0,3)

可見有許多重複的計算,例如L(1,4)。該問題符合動態規劃的兩個主要性質: 重疊子問題 和 最優子結構 。下面通過動態規劃的方法解決,通過自下而上的方式打表,存儲子問題的最優解。

int lpsDp(char * str,int n){
    int dp[n][n], tmp;
    memset(dp,0,sizeof(dp));
    for(int i=0; i<n; i++) dp[i][i] = 1;
    // i 表示 當前長度爲 i+1的 子序列
    for(int i=1; i<n; i++){
        tmp = 0;
        //考慮所有連續的長度爲i+1的子串. 該串爲 str[j, j+i]
        for(int j=0; j+i<n; j++){
            //如果首尾相同
            if(str[j] == str[j+i]){
                tmp = dp[j+1][j+i-1] + 2;
            }else{
                tmp = max(dp[j+1][j+i],dp[j][j+i-1]);
            }
            dp[j][j+i] = tmp;
        }
    }
    //返回串 str[0][n-1] 的結果
    return dp[0][n-1];
}

該算法的時間複雜度爲O(n2) 。其實這個問題和 最長公共子序列 問題有些相似之處,我們可以對LCS算法做些修改,來解決此問題:

  • 1) 對給定的字符串逆序 存儲在另一個數組 rev[] 中
  • 2) 再求這兩個 字符串的 LCS的長度

時間複雜度也爲O(n2)

最小編輯距離(Edit Distance)

問題:給定一個長度爲m和n的兩個字符串,設有以下幾種操作:替換(R),插入(I)和刪除(D)且都是相同的操作。尋找到轉換一個字符串插入到另一個需要修改的最小(操作)數量。

PS:最短編輯距離算法右許多實際應用,參考Lucene[9]的 API。另一個例子,對一個字典應用,顯示最接近給定單詞\正確拼寫單詞的所有單詞。

找遞歸函數

這個案例的子問題是什麼呢?考慮尋找的他們的前綴子串的編輯距離,讓我們表示他們爲[1...i][1....j] , 1<i<m1<j<n .顯然,這是解決最終問題的子問題,記爲Eij 。我們的目標是找到Emn 和最小的編輯距離。

我們可以用三種方式:(i,) , (,j)(i,j)右對齊兩個前綴字符串。連字符符號 表示沒有字符。

看一個例子或許會更清楚:假設給定的字符串是 SUNDAY 和 SATURDAY。如果i=2 , j=4 ,即前綴字符串分別是SU和SATU(假定字符串索引從1開始)。這兩個字串最右邊的字符可以用三種不同的方式對齊:

  1. (i,j) : 對齊字符U和U。他們是相等的,沒有修改的必要。我們仍然留下其中i=1j=3 ,即問題E1,3
  2. (i, -) : 對第一個字符串右對齊,第二字符串最右爲空字符。我們需要一個刪除(D)操作。我們還留下其中i = 1和j = 4的 子問題 E(i-1,j)。
  3. (-, j) : 對第二個字符串右對齊,第一個字符串最右爲空字符。在這裏,我們需要一個插入(I)操作。我們還留下了子問題i=2j=3Eij1

對於這三種操作,我可以得到最少的操作爲:

E(i,j)=min([E(i1,j)+D],[E(i,j1)+I],[E(i1,j1)+R])

其中,E(i1,j1)+R 表示:如果 i,j 字符不一樣.到這裏還沒有做完。什麼將是基本情況?

當兩個字符串的大小爲0,其操作距離爲0。當其中一個字符串的長度是零,需要的操作距離就是另一個字符串的長度. 即:

E00=0Ei0=iE0j=j

爲基本情況。這樣就可以完成遞歸程序了。

動態規劃解法

我們先計算出上面遞歸表達式的時間複雜度:T(m,n)=T(m1,n1)+T(m,n1)+T(m1,n)+C . Tmn 的複雜性,可以通過連續替代方法或結二元齊次方程計算。結果是指數級的複雜度。這是顯而易見的,從遞歸樹可以看出這將是一次又一次地解決子問題。

我們對重複子問題的結果打表存儲,並在有需要時(自下而上)查找。動態規劃的解法時間複雜度爲 O(mn) 正是我們打表的時間.通常情況下,DIR 操作的成本是不一樣的。在這種情況下,該問題可以表示爲一個有向無環圖DAG 與各邊的權重,並且找到最短路徑給出編輯距離。

實現代碼如下:

// 動態規劃實現 最小編輯距離
#include<stdio.h>
#include<stdlib.h>
#include<string.h>

// 測試字符串
#define STRING_X "SUNDAY"
#define STRING_Y "SATURDAY"

#define SENTINEL (-1)
#define EDIT_COST (1)

inline
int min(int a, int b) {
   return a < b ? a : b;
}

// Returns Minimum among a, b, c
int Minimum(int a, int b, int c)
{
    return min(min(a, b), c);
}

// Strings of size m and n are passed.
// Construct the Table for X[0...m, m+1], Y[0...n, n+1]
int EditDistanceDP(char X[], char Y[])
{
    // Cost of alignment
    int cost = 0;
    int leftCell, topCell, cornerCell;

    int m = strlen(X)+1;
    int n = strlen(Y)+1;

    // T[m][n]
    int *T = (int *)malloc(m * n * sizeof(int));

    // Initialize table
    for(int i = 0; i < m; i++)
        for(int j = 0; j < n; j++)
            *(T + i * n + j) = SENTINEL;

    // Set up base cases
    // T[i][0] = i
    for(int i = 0; i < m; i++)
        *(T + i * n) = i;

    // T[0][j] = j
    for(int j = 0; j < n; j++)
        *(T + j) = j;

    // Build the T in top-down fashion
    for(int i = 1; i < m; i++)
    {
        for(int j = 1; j < n; j++)
        {
            // T[i][j-1]
            leftCell = *(T + i*n + j-1);
            leftCell += EDIT_COST; // deletion

            // T[i-1][j]
            topCell = *(T + (i-1)*n + j);
            topCell += EDIT_COST; // insertion

            // Top-left (corner) cell
            // T[i-1][j-1]
            cornerCell = *(T + (i-1)*n + (j-1) );

            // edit[(i-1), (j-1)] = 0 if X[i] == Y[j], 1 otherwise
            cornerCell += (X[i-1] != Y[j-1]); // may be replace

            // Minimum cost of current cell
            // Fill in the next cell T[i][j]
            *(T + (i)*n + (j)) = Minimum(leftCell, topCell, cornerCell);
        }
    }

    // 結果存儲在 T[m][n]
    cost = *(T + m*n - 1);
    free(T);
    return cost;
}

// 遞歸方法實現
int EditDistanceRecursion( char *X, char *Y, int m, int n )
{
    // 基本情況
    if( m == 0 && n == 0 )
        return 0;

    if( m == 0 )
        return n;

    if( n == 0 )
        return m;

    // Recurse
    int left = EditDistanceRecursion(X, Y, m-1, n) + 1;
    int right = EditDistanceRecursion(X, Y, m, n-1) + 1;
    int corner = EditDistanceRecursion(X, Y, m-1, n-1) + (X[m-1] != Y[n-1]);

    return Minimum(left, right, corner);
}

int main()
{
    char X[] = STRING_X; // vertical
    char Y[] = STRING_Y; // horizontal

    printf("Minimum edits required to convert %s into %s is %d\n",
           X, Y, EditDistanceDP(X, Y) );
    printf("Minimum edits required to convert %s into %s is %d by recursion\n",
           X, Y, EditDistanceRecursion(X, Y, strlen(X), strlen(Y)));

    return 0;
}

給出了動態規劃實現 和 遞歸實現。大家可以比較他們的效率差異。

硬幣找零問題

問題[10] [11]:假設有m種面值不同的硬幣,個個面值存於數組S={S1S2Sm} 中,現在用這些硬幣來找錢,各種硬幣的使用個數不限。 求對於給定的錢數N ,我們最多有幾種不同的找錢方式。硬幣的順序並不重要。

例如,對於N = 4,S = {1,2,3},有四種方案:{1,1,1,1},{1,1,2},{2,2},{1, 3}。所以輸出應該是4。對於N = 10,S = {2,5, 3,6},有五種解決辦法:{2,2,2,2,2},{2,2,3,3},{2,2,6 },{2,3,5}和{5,5}。所以輸出應該是5。

1)最優子結構

要算總數的解決方案,我們可以把所有的一整套解決方案在兩組 (其實這個方法在組合數學中經常用到,要麼包含某個元素要麼不包含,用於遞推公式等等,)。

  1. 解決方案不包含 第m種硬幣(或Sm )。
  2. 解決方案包含至少一個 第m種硬幣。讓數(S [] , M, N)是該函數來計算解的數目,則它可以表示爲計數的總和(S [], M-1, N)和計數S[]MNSm

因此,這個問題具有最優子結構性質的問題。

2) 重疊子問題

下面是一個簡單的遞歸實現硬幣找零問題。遵循上面提到的遞歸結構。

#include<stdio.h>
int count( int S[], int m, int n )
{
    // 如果n爲0,就找到了一個方案
    if (n == 0)
        return 1;
    if (n < 0)
        return 0;
    // 沒有硬幣可用了,也返回0
    if (m <=0 )
        return 0;
    // 按照上面的遞歸函數
    return count( S, m - 1, n ) + count( S, m, n-S[m-1] );
}

// 測試
int main()
{
    int i, j;
    int arr[] = {1, 2, 3};
    int m = sizeof(arr)/sizeof(arr[0]);
    printf("%d ", count(arr, m, 4));
    getchar();
    return 0;
}

應當指出的是,上述函數反覆計算相同的子問題。見下面的遞歸樹爲S = {1,2,3},且n = 5。
的函數C({1},3)被調用兩次。如果我們繪製完整的樹,那麼我們可以看到,有許多子問題被多次調用。

C() --> count()
                              C({1,2,3}, 5)                     
                           /                \
                         /                   \              
             C({1,2,3}, 2)                 C({1,2}, 5)
            /     \                        /         \
           /        \                     /           \
C({1,2,3}, -1)  C({1,2}, 2)        C({1,2}, 3)    C({1}, 5)
               /     \            /    \            /     \
             /        \          /      \          /       \
    C({1,2},0)  C({1},2)   C({1,2},1) C({1},3)    C({1}, 4)  C({}, 5)
                   / \      / \       / \        /     \    
                  /   \    /   \     /   \      /       \ 
                .      .  .     .   .     .   C({1}, 3) C({}, 4)
                                               /  \
                                              /    \  
                                             .      .

所以,硬幣找零問題具有符合動態規劃的兩個重要屬性。像其他典型的動態規劃(DP)的問題,可通過自下而上的方式打表,存儲相同的子問題。當然上面的遞歸程序也可以改寫成記憶化存儲的方式來提高效率。

下面是動態規劃的程序:

#include<stdio.h>

int count( int S[], int m, int n )
{
    int i, j, x, y;

    // 通過自下而上的方式打表我們需要n+1行
    // 最基本的情況是n=0
    int table[n+1][m];

    // 初始化n=0的情況 (參考上面的遞歸程序)
    for (i=0; i<m; i++)
        table[0][i] = 1;

    for (i = 1; i < n+1; i++)
    {
        for (j = 0; j < m; j++)
        {
            // 包括 S[j] 的方案數
            x = (i-S[j] >= 0)? table[i - S[j]][j]: 0;

            // 不包括 S[j] 的方案數
            y = (j >= 1)? table[i][j-1]: 0;

            table[i][j] = x + y;
        }
    }
    return table[n][m-1];
}

// 測試
int main()
{
    int arr[] = {1, 2, 3};
    int m = sizeof(arr)/sizeof(arr[0]);
    int n = 4;
    printf(" %d ", count(arr, m, n));
    return 0;
}

時間複雜度:O(mn)

以下爲上面程序的優化版本。這裏所需要的輔助空間爲O(n)。因爲我們在打表時,本行只和上一行有關,類似01揹包問題。

int count( int S[], int m, int n )
{
    int table[n+1];
    memset(table, 0, sizeof(table));
    //初始化基本情況
    table[0] = 1;

    for(int i=0; i<m; i++)
        for(int j=S[i]; j<=n; j++)
            table[j] += table[j-S[i]];

    return table[n];
}

0-1揹包問題

問題[12]:在M 件物品取出若干件放在空間爲W 的揹包裏,每件物品的體積爲W1W2Wn ,與之相對應的價值爲P1,P2Pn 。求出獲得最大價值的方案。

注意:在本題中,所有的體積值均爲整數。01的意思是,每個物品都是一個整體,要麼整個都要,要麼都不要。

1)最優子結構

考慮所有物品的子集合,考慮第n個物品都有兩種情況: 一種情況: 包括在最優方案中 ;二 種情況:不在最優方案中。因此,能獲得的最大價值,即爲以下兩個值中較大的那個

  • 1) 在剩下 n1 個物品中(剩餘 W 重量可用)的情況能得到的最大價值 (即排除了 第n 個物品)
  • 2) 第n 個物品的價值加上剩下 剩下的 n1 個物品(剩餘Wwn 的重量)能得到的最大價值。(即包含了第n個物品)

如果第n個物品的重量,超過了當前的剩餘重量W,那麼只能選情況1), 排除第n個物品。

2) 重疊子問題

下面是一個遞歸的實現,按照上面的最優子結構。

/* 樸素的遞歸實現  0-1 揹包 */
#include<stdio.h>

int max(int a, int b) { return (a > b)? a : b; }

// 返回  前n個物品在容量爲W時,能得到的最大價值
int knapSack(int W, int wt[], int val[], int n)
{
   // 沒有物品了
   if (n == 0 || W == 0)
       return 0;

   // 如果當前第n個物品超重了,就排除在外
   if (wt[n-1] > W)
       return knapSack(W, wt, val, n-1);

   //返回兩種情況下最大的那個 (1) 包括第n個物品 (2) 不包括第n個物品
   else return max( val[n-1] + knapSack(W-wt[n-1], wt, val, n-1),
                    knapSack(W, wt, val, n-1)
                  );
}

// 測試
int main()
{
    int val[] = {60, 100, 120};
    int wt[] = {10, 20, 30};
    int  W = 50;
    int n = sizeof(val)/sizeof(val[0]);
    printf("%d", knapSack(W, wt, val, n));
    return 0;
}

這種方法其實就是搜索了所有的情況,但是有很多重複的計算。時間複雜度是指數級的 O(2n)

在下面的遞歸樹中 K() 代表 knapSack().  
輸入數據如下:
wt[] = {1, 1, 1}, W = 2, val[] = {10, 20, 30}

                       K(3, 2)         ---------> K(n, W)
                   /            \ 
                 /                \               
            K(2,2)                  K(2,1)
          /       \                  /    \ 
        /           \              /        \
       K(1,2)      K(1,1)        K(1,1)     K(1,0)
       /  \         /   \          /   \
     /      \     /       \      /       \
K(0,2)  K(0,1)  K(0,1)  K(0,0)  K(0,1)   K(0,0)

可見相同的子問題被計算多次。01揹包滿足動態規劃算法的兩個基本屬性(重疊子問題和最優子結構)。可以通過自下而上的打表,存儲中間結果,來避免重複計算。動態規劃解法如下:

#include<stdio.h>
int max(int a, int b) { return (a > b)? a : b; }

int knapSack(int W, int wt[], int val[], int n)
{
   int i, w;
   int dp[n+1][W+1];

   for (i = 0; i <= n; i++)
   {
       for (w = 0; w <= W; w++)
       {
           if (i==0 || w==0)
               dp[i][w] = 0;
           else if (wt[i-1] <= w)
                 dp[i][w] = max(val[i-1] + dp[i-1][w-wt[i-1]],  dp[i-1][w]);
           else
                 dp[i][w] = dp[i-1][w];
       }
   }
   return dp[n][W];
}

int main()
{
    int val[] = {80, 100, 150};
    int wt[] = {10, 20, 30};
    int  W = 50;
    int n = sizeof(val)/sizeof(val[0]);
    printf("%d", knapSack(W, wt, val, n));
    return 0;
}

空間複雜度和時間複雜度都爲O(Wn) . 由於打表的過程中,計算的當前行只依賴上一行,空間複雜度可以優化爲O(W) .

劃分問題

劃分問題是指有一個集合,判斷是否可以把這個結合劃分爲總和相等的兩個集合。例如:arr[] = {1, 5, 11, 5},Output: true.這個數組可以劃分爲: {1, 5, 5} 和 {11}. arr[] = {1, 5, 3}Output: false.無法劃分爲總和相等的兩部分

如果劃分後的兩個集合總和相等,則原集合的總和肯定爲偶數,假設爲總和爲sum。問題即爲是否有子集合的總和爲sum/2.

遞歸解決

設函數isSubsetSum(arr, n, sum/2) 返回true如果存在arr的一個子集合的總和爲 sum/2
isSubsetSum函數爲分爲下面兩個子問題

  • 1) 不考慮最後一個元素。問題遞歸到 isSubsetSum(arr, n-1. sum/2)
  • 2) 考慮最後一個元素。問題遞歸到 isSubsetSum(arr, n-1. sum/2-arr[n])

上面兩種情況有一個返回TRUE,即可

isSubsetSum (arr, n, sum/2) = isSubsetSum (arr, n-1, sum/2) ||
isSubsetSum (arr, n-1, sum/2 – arr[n-1])

代碼如下所示:

#include <iostream>
#include <stdio.h>

bool isSubsetSum (int arr[], int n, int sum)
{
   // 基本情況
   if (sum == 0)
     return true;
   if (n == 0 && sum != 0)
     return false;

   // 如果最後一個元素比sum大,就不考慮該元素
   if (arr[n-1] > sum)
     return isSubsetSum (arr, n-1, sum);

  //分別判斷包括最後一個元素 和 不包括最後一個元素
   return isSubsetSum (arr, n-1, sum) || isSubsetSum (arr, n-1, sum-arr[n-1]);
}

bool findPartiion (int arr[], int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++)
       sum += arr[i];

    // 奇數不可能劃分
    if (sum%2 != 0)
       return false;

    return isSubsetSum (arr, n, sum/2);
}

// 測試
int main()
{
  int arr[] = {3, 1, 5, 9, 12};
  int n = sizeof(arr)/sizeof(arr[0]);
  if (findPartiion(arr, n) == true)
     printf("Can be divided into two subsets of equal sum");
  else
     printf("Can not be divided into two subsets of equal sum");
  return 0;
}

時間複雜度:最快情況爲 O(2n) ,即每個元素有選或不選的兩種選擇

動態規劃

如果所有元素的總和sum不是特別大時可以用動態規劃來解決。問題可以轉化爲 是否有子集合的總和爲sum/2.
這裏通過自下向上打表的方法來記錄子問題的解, part[i][j] 表示對於子集合 {arr[0], arr[1], ..arr[j-1]} 其總和是否爲i.

其實這個問題和01揹包問題是一樣的。揹包的最大容量爲sum/2,如果最大價值可以達到sum/2則返回TRUE。

bool findPartiion (int arr[], int n)
{
    int sum = 0;
    int i, j;

    for (i = 0; i < n; i++)
      sum += arr[i];

    if (sum%2 != 0)  
       return false;

    bool part[sum/2+1][n+1];

    for (i = 0; i <= n; i++)
      part[0][i] = true;

    for (i = 1; i <= sum/2; i++)
      part[i][0] = false;     

     for (i = 1; i <= sum/2; i++)  
     {
       for (j = 1; j <= n; j++)  
       {
         part[i][j] = part[i][j-1];
         if (i >= arr[j-1])
           part[i][j] = part[i][j] || part[i - arr[j-1]][j-1];
       }        
     }    

    /** //測試打表數據
     for (i = 0; i <= sum/2; i++)  
     {
       for (j = 0; j <= n; j++)  
          printf ("%4d", part[i][j]);
       printf("\n");
     } */

     return part[sum/2][n];
}

時間複雜度爲 O(sum*n)

二項式係數

以下是常見的二項式係數的定義:

  • 1) 一個二項式係數C(n,k) 可以被定義爲(1+X)n 的展開式中 Xk 的係數。
  • 2) 二項式係數對組合數學很重要,因它的意義是從n件物件中,不分先後地選取k件的方法總數,因此也叫做組合數.

問題[13]: 寫一個函數,它接受兩個參數n和k,返回二項式係數C(n,k)。例如,你的函數應該返回6 當n = 4 k = 2時,返回10當 n = 5 k = 2時。

1) 最優子結構

C(n,k) 的值可以遞歸地使用以下標準公式計算,這個應該是:
C(n,k)=C(n1,k1)+C(n1,k)
C(n,0)=C(n,n)=1

2) 重疊子問題

下面是一個直接用上面的公式寫的遞歸程序解決:

// 直接遞歸實現
#include<stdio.h>

// 返回二項式係數的值 C(n, k)
int binomialCoeff(int n, int k)
{
  // 基本情況
  if (k==0 || k==n)
    return 1;

  // Recur
  return  binomialCoeff(n-1, k-1) + binomialCoeff(n-1, k);
}

/* 測試程序 */
int main()
{
    int n = 5, k = 2;
    printf("Value of C(%d, %d) is %d ", n, k, binomialCoeff(n, k));
    return 0;
}

應該指出的是,上面的程序一次又一次計算相同的子問題。看到下面的遞歸樹n = 5 k = 2。函數C(3,1)執行兩次。對於較大的n 值,將會有許多共同的子問題。

                    C(5,    2)
             /                      \
    C(4, 1)                           C(4, 2)
     /   \                          /           \
C(3, 0)   C(3, 1)             C(3, 1)               C(3, 2)
         /    \               /     \               /     \
  C(2, 0)    C(2, 1)      C(2, 0) C(2, 1)          C(2, 1)  C(2, 2)
            /        \              /   \            /    \
        C(1, 0)  C(1, 1)      C(1, 0)  C(1, 1)   C(1, 0)  C(1, 1)

很明顯,這個問題可以用動態規劃來解決,因爲包含了動態規劃的兩個基本屬性(見重疊子問題和最優子結構)

和經典的動態規劃解決辦法一樣,這裏通過自下而上的構建數組 C[][] 保存子問題的值。

#include<stdio.h>
int min(int a, int b);
// 返回二項式係數 C(n, k)
int binomialCoeff(int n, int k)
{
    int C[n+1][k+1];
    int i, j;

    // 通過自下而上的方式打表
    for (i = 0; i <= n; i++)
    {
        for (j = 0; j <= min(i, k); j++)
        {
            if (j == 0 || j == i)
                C[i][j] = 1;
             else
                C[i][j] = C[i-1][j-1] + C[i-1][j];
        }
    }
    return C[n][k];
}
int min(int a, int b)
{
    return (a<b)? a: b;
}

/* 測試程序*/
int main()
{
    int n = 5, k = 2;
    printf ("Value of C(%d, %d) is %d ", n, k, binomialCoeff(n, k) );
    return 0;
}

時間複雜度: O(n*k)
空間複雜度: O(n*k)

其實,空間複雜度可以優化到 O(k). 但是實際應用中,還是直接用二維數組打表使用比較多。

// 空間優化
int binomialCoeff(int n, int k)
{
    int* C = (int*)calloc(k+1, sizeof(int));
    int i, j, res;

    C[0] = 1;

    for(i = 1; i <= n; i++)
    {
        for(j = min(i, k); j > 0; j--)
            C[j] = C[j] + C[j-1];
    }
    res = C[k];  // 在釋放內存前存儲結果
    free(C); 
    return res;
}

參考資料

  1. 算法導論CLRS
  2. Optimal substructureFrom Wikipedia, the free encyclopedia .
  3. Floyd–Marshall algorithm From Wikipedia, the free encyclopaedia.
  4. Bellman–Ford algorithmFrom Wikipedia, the free encyclopaedia.
  5. Longest Increasing Subsequence
  6. Longest Common Subsequence
  7. Dyanamic programming :Longset common subsequence
  8. Long palindromic Subsequence
  9. Lucene From Wikipedia, the free encyclopedia
  10. Coin Change from geeksforgeeks
  11. Coin Change from algorithms.com
  12. 0-1 knapsack problem
  13. Binomial coefficient
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章