Dynamic Programming

来源:互联网 发布:青年网络公开课叶海林 编辑:程序博客网 时间:2024/05/16 03:35
第一次听说动态规划(Dynamic programming)这个名词的人都会觉得它有点怪,其实它的本名应该是多阶段决策过程(multistage decision process)。根据Richard Bellman的自传,他在RAND Corporation研究这个问题的时候,是这么考虑的:当时RAND受雇于美国空军,而空军的事实上的头是Wilson,此人对research这个词过敏,只要有人在他面前提到这个词,他就会涨红脸,变得暴躁易怒。相应地,他也讨厌mathematical这个经常用在research之前的词。因此,Bellman决定找一个跟数学不沾边的词来掩盖他研究的问题的数学本质。他考虑了planning, decision making, thinking等几个词,最后选定用programming, 再加上一个从物理里面借用来的形容词dynamic, 构成了一个新词dynamic programming。

In mathematics and computer science, dynamic programming is a method of solving complex problems by breaking them down into simpler steps. It is applicable to problems that exhibit the properties of overlapping subproblems which are only slightly smaller[1] and optimal substructure (described below). When applicable, the method takes much less time than naive methods.

Top-down dynamic programming simply means storing the results of certain calculations, which are then re-used later because the same calculation is a sub-problem in a larger calculation. Bottom-up dynamic programming involves formulating a complex calculation as a recursive series of simpler calculations。

 

 

Intermediate

    现在让我们看看如何解决2维DP问题。
问题:
   一个拥有N*M个单元的格子,每一个里面都有着一定数量的苹果,数量已经给定。现在你位于左上角。每一步你可以向下走或者向右走。问
你能够收集到最多多少个苹果。

    这个问题可以向其他DP问题一样来解决;几乎没有差别。首先我们需要找出一个状态。首先我们需要注意到到某个单元里最多有两种方法-要么从它的左边过来(如果当前格子不是在第一列的话),要么从上方过来(如果它不是在最上面的行的话)。为了找到该单元的最优方案,我们首先需要找到到达它的单元的最优方案。从上面的叙述中,我们可以很容易的得到转移关系:
S[i][j]=A[i][j]+max(S[i-1][j],if i>0; S[i][j-1],if j>0) (这里的i代表行,j代表列。左上角的坐标为{0,0};A[i][j]为i,j单元格中的
苹果数量)。

    S[i][j]需要在每行中从左到右计算,并且从上到下计算每一行,或者从上到下计算每一列然后从左到右处理每一列。

伪代码:

For i = 0 to N - 1
   For j 
= 0 to M - 1
   S[i][j] 
= A[i][j] +
      max(S[i][j
-1], if j>0 ; S[i-1][j], if i>0 ; 0)

Output S[n
-1][m-1]

下面是TopCoder比赛中的一些题目,作为练习之用:

  • AvoidRoads - 2003 TCO Semifinals 4
  • ChessMetric - 2003 TCCC Round 4

Dynamic Programming

程度★★ 難度★★★

Dynamic Programming

中文譯作「動態規劃」,英文常常縮寫成DP。在數學領域中,programming是指「最佳化(optimization)」的意思,例如求極大值、求極小值。dynamic是指「動態」的意思。顧名思義,Dynamic Programming是一個以動態的方式來進行最佳化的方法。

DP = Divide and Conquer + Memoization

DP可視做是Divide and Conquer的延伸版本。當運用Divide and Conquer所遞迴分割出來的子問題都非常相像的時候,並且當同樣的子問題一而再、再而三出現的時候──就運用Memoization儲存全部子問題的答案,節省重複計算相同問題的時間,以空間來換取時間。

由於全部子問題的答案都儲存在記憶體的緣故,計算答案的過程,就是反覆不斷地在各塊記憶體中讀取數據、計算數據、儲存數據,動感地達成最佳化──動態規劃之名由此而來。

出現過的子問題會被儲存

DP有一個特點,是當原問題題算好之後,其實也一併將所有出現過的子問題都算好了,其答案都可直接從表格存取。此後當重複提問類似問題時,若提問到的是這些子問題,就可直接從表格取得答案,不需再計算。

子問題可視做狀態,可嘗試建立狀態空間

當子問題之間非常相像的時候,不妨把子問題視作狀態。我們可以嘗試直接建立狀態空間,接下來才來觀察狀態們(子問題們)之間的關係,有助於找出原問題的遞迴分割方式。

時間複雜度、空間複雜度

由於問題的答案都在表格中,記憶體足夠的情況下,存取一個問題的答案通常只需O(1)。如果一個問題可以分成O(d)個子問題(一個問題的答案需要由O(d)個更小的問題計算而得),而全部的子問題共有O(n)個,時間複雜度就是O(n * d * 1)。

空間複雜度則必須以子問題們的出現期間來決定,在計算過程當中,已確定不再出現的子問題,就不必再儲存於表格中,記憶體就可以釋放,或者回收再利用,導致瞬間的記憶體用量不會太多,整體的空間複雜度就會降低。若只是單純的要儲存全部子問題,空間複雜度就是O(n)。

用Dynamic Programming設計演算法時的步驟

1. 利用Divide and Conquer把原問題遞迴地分成許多更小的問題。(recurrence)甲、子問題與原問題的求解方式皆類似。(optimal sub-structure)乙、子問題會一而再、再而三的出現。(overlapping sub-problems)2. 確認每個問題需要哪些子問題來計算答案,並確認總共有哪些子問題。(state space)3. 決定各個問題的計算先後次序。(computational sequence)4. 安排好各個問題的答案,要存放在表格的哪個位置。(lookup table)5. 實做程式,主要有兩種方式:甲、Top-down, Recursive.乙、Bottom-up, Iterative.

第一點。先找到原問題和其子問題們之間的關係,寫出遞迴公式。如此一來,便可利用遞迴公式,用子子問題的答案,求出子問題的答案;用子問題的答案,求出原問題的答案。

第二點,確認可能出現的子問題全部共有哪些,這樣才能知道要計算哪些子問題,才能知道共會花多少時間、多少記憶體。

第三點。有了遞迴公式和表格結構之後,就必須安排出一套計算的順序。大問題的答案,總是以小問題的答案來求得的,所以,小問題的答案是必須先算的,否則大問題的答案從何而來呢?

我們會利用較小問題的答案,計算出較大問題的答案──因此,一個好的安排方式,不但會使程式碼容易撰寫,還可有效節省記憶體空間,甚至重複利用記憶體空間。

第四點。實作DP的程式時,會建立一個表格,在表格存入所有大小問題的答案。安排好每個問題的答案在表格的哪個位置,這樣計算時才能知道該在哪裡取值。這個表格其實就是所謂的lookup table。

當撰寫程式碼的時候,應小心下面幾項問題:

初始值:記得先將最小、最先被計算的子問題,將其答案預先算好,內建於程式碼中,並存入表格。一道遞迴公式必須擁有初始值,才有辦法計算其他項。

表格界限:切勿存取超出表格界限的地方。計算過程中,一旦子問題的答案出錯,就會如骨牌效應般一個影響一個,造成很難除錯。

計算順序:切勿存取還沒計算過答案的子問題,原因同前項所述。

第五點。留待下述範例解釋。

一、recurrence

以下以著名的爬樓梯問題來做為範例,其遞迴公式如下:

f(n) = { 1                , if n = 0 { 1                , if n = 1 { f(n-1) + f(n-2)  , if n >= 2註:為了寫作程式方便,遞迴公式中設計了n=0的情形。

為了讓程式碼比較好寫,設計遞迴公式時,可以嘗試在不與原本公式起衝突的情況下,額外增加一些邊界條件。

二、state space

全部的子問題共有六個,從「爬完零階」到「爬完五階」。每個子問題的答案都是由前兩階的答案求得,除了「爬完零階」與「爬完一階」以外。

三、computational sequence

必須最先計算,也是最小的子問題是「爬完零階」與「爬完一階」,它們都是寫程式時就能夠內建答案的子問題。

剩下的子問題,都必須先算好階數少一階、階數少二階這兩個子問題,所以必須由階數較小的子問題開始算。

必須最後計算,也是最大的子問題是原問題「爬完五階」。

整體的計算順序是:「爬完零階」、「爬完一階」、「爬完兩階」、「爬完三階」、「爬完四階」、「爬完五階」。

四、lookup table

可以簡單用一條六格的一維陣列做為lookup table,每一格都對應到一個問題的答案。

 
  1. int table[6];

然後設定好最小的子問題的答案,「爬完零階」與「爬完一階」。

 
  1. table[0] = 1;
  2. table[1] = 1;

五、實做程式

直接用遞迴實作,而不使用記憶體儲存各個問題的答案,是最直接的方式,也是最慢的方式。時間複雜度是O(f(n))。有些問題一而再、再而三的出現,不斷呼叫同樣的函式,效率不彰。很多剛接觸DP的新手都會犯這種錯誤。

 
  1. int f(int n)
  2. {
  3.     if (n == 0 || n == 1)
  4.         return 1;
  5.     else
  6.         return f(n-1) + f(n-2);
  7. }

正確的DP,是一邊計算,一邊將計算出來的數值存入表格當中,以後便不必再重算。這裡整理了兩種實做方式:

1. Top-down, Recursive.2. Bottom-up, Iterative.

這兩種方式各有優缺點,以下說明這兩種方式有何不同。

五之一、Top-down, Recursive

第一種。採用Divide and Conquer的遞迴實作方式,以Divide階段分割原問題,並以Merge階段來計算出原問題的答案。每當計算出一個問題的答案後,就馬上儲存在表格裡面,並記錄該問題已計算。

 
  1. int table[6];       // 表格,儲存全部問題的答案。
  2. bool solve[6];      // 記錄問題是否已計算
  3. int f(int n)
  4. {
  5.     if (n == 0 || n == 1)
  6.         return 1;
  7.     if (solve[n])
  8.         return table[n];
  9.     table[n] = f(n-1) + f(n-2); // 將答案存入表格
  10.     solve[n] = true;            // 紀錄已計算
  11.     return table[n];
  12. }
  13. int main()
  14. {
  15.     for (int i=0i<6i++) solve[i] = false;
  16.     cout << "爬完五階的踏法有" << f(5) << "種";
  17.     return 0;
  18. }
  19. // 算完的子問題可以重複利用
  20. int main()
  21. {
  22.     for (int i=0i<6i++) solve[i] = false;
  23.     for (int n; (cin >> n) && (n >= 0 && n <= 5); )
  24.         cout << "爬完" << n << "階的踏法有" << f(n) << "種";
  25.     return 0;
  26. }
 
  1. int table[6];   // 可以將solve陣列跟table合併,將程式碼簡化。
  2. int f(int n)
  3. {
  4.     if (n == 0 || n == 1)
  5.         return 1;
  6.     if (table[n] != 0)      // 直接用 0 來代表沒算過
  7.         return table[n];
  8.     return table[n] = f(n-1) + f(n-2);
  9. }
  10. int main()
  11. {
  12.     for (int i=0i<6i++) table[i] = 0;
  13.     cout << "爬完五階的踏法有" << f(5) << "種";
  14.     return 0;
  15. }
  16. // 算完的子問題可以重複利用
  17. int main()
  18. {
  19.     for (int i=0i<6i++) table[i] = 0;
  20.     for (int n; (cin >> n) && (n >= 0 && n <= 5); )
  21.         cout << "爬完" << n << "階的踏法有" << f(n) << "種";
  22.     return 0;
  23. }

這個方式的好處是不必斤斤計較計算順序,因為程式碼中的遞迴結構會迫使最小的子問題先被計算。這個方式的另一個好處是只計算必要的子問題,而不必計算所有可能的子問題(計算整個狀態空間)。

這個方式的壞處是程式碼採用遞迴結構,不斷呼叫函式,執行效率較差。這個方式的另一個壞處是無法自由地控制計算順序,因而無法妥善運用記憶體,浪費了可回收再利用的記憶體。

五之二、Bottom-up, Iterative

第二種。訂定一個計算順序,然後由最小的子問題先計算,其特色是程式碼通常只有幾個迴圈。這個方式的好處與壞處恰與前一個方式互補。

 
  1. int table[6];
  2. // 姑且稱之「往回取值」,較常見的作法。
  3. int f(int n)
  4. {
  5.     for (int i=0i<6i++) table[i] = 0;
  6.     table[0] = table[1] = 1;
  7.     for (int i = 2i <= ni++)
  8.         table[i] = table[i-1] + table[i-2];
  9.     return table[n];
  10. }
  11. // 姑且稱之「往後補值」,較少見的作法。
  12. int f(int n)
  13. {
  14.     for (int i=0i<6i++) table[i] = 0;
  15.     table[0] = 1;
  16.     for (int i = 0i <= ni++) {
  17.         if (i+1 <= ntable[i+1] += table[i];
  18.         if (i+2 <= ntable[i+2] += table[i];
  19.     }
  20.     return table[n];
  21. }
  22. int main()
  23. {
  24.     cout << "爬完五階的踏法有" << f(5) << "種";
  25.     return 0;
  26. }
  27. // 算完的子問題可以重複利用
  28. int main()
  29. {
  30.     f(5);
  31.     for (int n; (cin >> n) && (n >= 0 && n <= 5); )
  32.         cout << "爬完" << n << "階的踏法有" << table[n] << "種";
  33.     return 0;
  34. }

總結

現在已經有許多問題已經發掘出DP的解法,這些問題通常以遞迴公式和表格結構的樣式,做為主要的分類依據。有賴前人辛苦耕耘,近年來已漸漸整理出幾個經典的題型了。學習這些題型,可以增廣解題的思考方向。大家在學習之餘,也不妨順便開創一些新題型,可供後人學習!這裡列出一些常見的簡易題型,供各位牛刀小試。

UVa 369 485 495 623 10334 10446 10520

Staircase Walk

程度★★ 難度★

Staircase Walk

有一個長方形的方格棋盤,從左上角開始,欲走至右下角,每次只能往右走一格或者往下走一格。請問有幾種不同走法?

這個問題可以很容易的找到遞迴公式。對某個位置的方格來說,只可能是從左走來或者從上走來,將這兩種情形分開,便得到遞迴公式:

c(i, j) = { 0                         , if i < 0 or j < 0 { 1                         , if i = 0 or j = 0 { c(i-1, j) + c(i, j-1)     , if i > 0 and j > 0c(i, j):從格子 (0, 0) 走到格子 (i, j) 的走法種類數。

除了遞迴公式之外,這個問題也有一般公式解:http://mathworld.wolfram.com/StaircaseWalk.html

時間複雜度分析:令X和Y分別是棋盤的長和寬。每個子問題用O(1)時間(用兩個子問題)就可算得,共有X*Y個子問題,所以算出所有子問題要用O(XY)時間。

空間複雜度分析:共有X*Y個子問題,所以要用O(XY)空間,簡單來說就是開一個二維陣列啦!如果不需要紀錄所有子問題的答案,只想算出其中一個子問題,那只需要一條一維陣列就可以了,也就是O(min(X, Y))空間。

程式碼就不提供囉!各位可以自己試試看!

如果某些格子上有障礙物呢?其實也很簡單,如果某格有障礙物,就把此格的c(i, j)設為零就可以了。

如果也可以往右下斜角走呢?那麼遞迴公式就再修改一下,多加一項c(i-1, j-1)就行了。

如果可以往上下左右走呢?那麼就可以不斷繞圈子,走法就成了無限多種了。寫成遞迴公式的話,就會產生無窮遞迴,永遠也不會結束。

如果也可以往右上斜角走呢?因為不會產生無窮遞迴,所以這是可以解的!

UVa 10599

Maximum Subarray

程度★★ 難度★★

Maximum Subarray
(Maximum Consecutive Sum)
(Maximum Consecutive Subsequence)

問題:從一串數列中取一連串數字求其總和。找出最大的總和。最糟糕的情況就是什麼數字都不取,總和為零。

如果用窮舉法的話,窮舉所有可能的起點、終點,並計算總和,時間複雜度就是O(N^3)。

這是一個非常簡單的問題,只要依序累加數字、做點判斷,就可以找到答案。累加數字求總和就是種Dynamic Programming!

更詳細的資料,可參考「名題精選百則」這本書。現在直接來看程式碼吧。

計算Maximum Subarray之元素總和

 
  1. int a[10] = {12, -63, -24, -132, -4};
  2. int maximum_subarray()
  3. {
  4.     int max_sum = 0sum = 0;
  5.     
  6.     for (int i=0i<10; ++i)
  7.     {
  8.         sum += a[i];    // 隨時計算總和
  9.         if (sum < 0sum = 0;   // 零總比負數好
  10.         if (sum > max_summax_sum = sum;   // 隨時紀錄最大值
  11.     }
  12.     
  13.     return max_sum;
  14. }

時間複雜度是O(N)。

找出Maximum Subarray的位置

當然也可以找出那串數字。如果有很多串,下面這支程式碼只會找出比較早出現的那一串。

 
  1. int a[10] = {12, -63, -24, -132, -4};
  2. int maximum_subarray()
  3. {
  4.     int max_sum = 0sum = 0;
  5.     int start = 0end = 0temp_start = 0;
  6.     
  7.     for (int i=0i<10; ++i)
  8.     {
  9.         sum += a[i];
  10.         
  11.         if (sum < 0)
  12.         {
  13.             sum = 0;
  14.             temp_start = i+1;   // 現在情況很糟,故設定起點在下一個數字
  15.         }
  16.         if (sum > max_sum)
  17.         {
  18.             max_sum = sum;
  19.             start = temp_startend=i;
  20.         }
  21.     }
  22.     
  23.     if (start > end)
  24.         cout << "什麼數字都不取" << endl;
  25.     else
  26.         cout << "從" << start << "到" << end << endl;
  27.     return max_sum;
  28. }

計算Maximum Subarray之元素總和(至少取一個數字)

 
  1. int a[10] = {12, -63, -24, -132, -4};
  2. int maximum_subarray()
  3. {
  4.     int max_sum = -1e9sum = 0;
  5.     
  6.     for (int i=0i<10; ++i)
  7.     {
  8.         if (sum > 0sum += a[i];   // 先前的數字若不為負數才加
  9.         else sum = a[i];            // 不加數字總會比加上負數好
  10.         
  11.         if (sum > max_summax_sum = sum;   // 隨時紀錄最大值
  12.     }
  13.     
  14.     return max_sum;
  15. }

推廣

這個問題還可推廣到2D、3D。時間複雜度分別是O(N^3)、O(N^5)。以下直接提供3D的情形。

 
  1. int A = 20B = 20C = 20// 三個維度的個別寬度
  2. int a[20][20][20];
  3. int max1D(int a[])
  4. {
  5.     int ans = -1e9;
  6.     int b = 0;
  7.     for (int i=0i<C; ++i)
  8.     {
  9.         if (b >= 0b += a[i];
  10.         else b = a[i];
  11.         ans = max(ansb);
  12.     }
  13.     return ans;
  14. }
  15. int max2D(int a[][20])
  16. {
  17.     int ans = -1e9;
  18.     int b[20];
  19.     
  20.     for (int i=0i<B; ++i)
  21.     {
  22.         memset(b0sizeof(b));
  23.         for (int j=ij<B; ++j)
  24.         {
  25.             for (int k=0k<C; ++k)
  26.                 b[k] += a[j][k];
  27.             ans = max(ansmax1D(b));
  28.         }
  29.     }
  30.     return ans;
  31. }
  32. int max3D(int a[][20][20])
  33. {
  34.     int ans = -1e9;
  35.     int b[20][20];
  36.     
  37.     for (int i=0i<A; ++i)
  38.     {
  39.         memset(b0sizeof(b));
  40.         for (int j=ij<A; ++j)
  41.         {
  42.             for (int k=0k<B; ++k)
  43.                 for (int l=0l<C; ++l)
  44.                     b[k][l] += a[j][k][l];
  45.             ans = max(ansmax2D(b));
  46.         }
  47.     }
  48.     return ans;
  49. }
  50. int main()
  51. {
  52.     cout << max3D(a) << endl;
  53.     return 0;
  54. }

最後,來一點練習題目吧。

UVa 507 10684 108 836 10827 10755

0/1 Knapsack Problem之一

程度★★ 難度★★★

Knapsack Problem

問題:將一群物品儘量塞進背包裡,令背包裡物品總價值最高。僅考慮背包只有負重限制(似乎太天真了一點)。

關於背包問題,世界上已經有很多研究成果了。這裡向大家提供一本專述背包問題的論著,作者精心整理了背包問題的相關問題和研究成果,並免費提供電子檔給大家,大家請記得要滿懷感激的看此書:http://www.or.deis.unibo.it/knapsack.html。

UVa 233 10898

0/1 Knapsack Problem

背包問題是很經典的問題,亦引申出許多變形和應用。這裡我們只介紹最基本易懂的其中一種變形:0/1 knapsack problem。

文言的說法是:每種類型的物品只會放進背包零個或一個。通俗的說法是:每個物品都是不同類型的,每種物品都只有一個。

這個問題原本是個NP-Complete問題。當數值範圍不大時,能以DP處理之。

分割問題的方法

當我們把一件物品放進背包裡時,會讓總價值變高,並且讓背包變重。對某一件物品來說,我們可以選擇放或不放;然後移去這件物品所帶來的影響,將問題縮小成子問題。遞迴式便可設計為:

c(n, w) = max( c(n-1, w), c(n-1, w-W[n]) + C[n] )               ^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^               不放 -> 0         放 -> 1n:第1個到第n個物品要放進背包內。w:背包負重上限。c(n, w):第1個到第n個物品儘量塞入負重限制為w的背包時,其總價值的最大值。W[n]:第n個物品的重量。C[n]:第n個物品的價值。

考慮一下東西放不進去時的情形,以及沒有東西時的情形。

c(n, w) = { 0                                        , if n = 0 and w >= 0 { max( c(n-1, w), c(n-1, w-W[n]) + C[n] )  , if n > 0 and w-W[n] >= 0 { c(n-1, w)                                , if n > 0 and w-W[n] < 0

物品一開始的先後順序是無所謂的,最後得出的答案都會一樣。

計算出背包裡物品總價值的最大值(Bottom-up)

只要計算所有小問題,那麼大問題的答案必然可以推得出來。建立二維表格後,依序計算每個小問題吧!

 
  1. // 物品總數上限。
  2. const int MaxN = 100;
  3. // 背包負重上限。有時也會利用所有物品的總重量作為此值。
  4. const int MaxW = 100000;
  5. // 物品的價值與重量。
  6. int C[MaxN], W[MaxN];
  7. // DP所需的陣列,需初始化為零。
  8. int c[MaxN + 1][MaxW + 1];
  9. void knapsack(int nint w)             // n 為物品個數,w 為背包負重上限
  10. {
  11.     memset(c0sizeof(c));            // 初始化為零
  12.     for (int i = 0i < n; ++i)         // 每個物品都試試看
  13.         for (int j = 0j <= w; ++j)    // 每個重量都試試看
  14.             if (j - W[i] < 0)
  15.                 c[i+1][j] = c[i][j];    // 負重能力不足,故只能不放
  16.             else
  17.                 c[i+1][j] = maxc[i][j], c[i][j - W[i]] + C[i] );
  18.     cout << "最高的價值為" << c[n][w] << endl;
  19. }

因為計算時只需要用到上方和左上方的格子,所以其實只需要一條陣列就夠了。不過計算次序需要改為由陣列後端開始,才不會覆蓋掉需要拿來計算的陣列格子。

 
  1. const int MaxN = 100MaxW = 100000;
  2. int C[MaxN], W[MaxN];
  3. int c[MaxW + 1];                        // 只需要一條陣列就夠了
  4. void knapsack(int nint w)             // n 為物品個數,w 為背包負重上限
  5. {
  6.     memset(c0sizeof(c));            // 初始化為零
  7.     for (int i = 0i < n; ++i)
  8.         for (int j = wj - W[i] >= 0; --j// 由後往前
  9.             c[j] = maxc[j], c[j - W[i]] + C[i] );
  10.     cout << "最高的價值為" << c[w] << endl;
  11. }

程式碼也可以寫成這樣,讓人容易理解。

 
  1. const int MaxN = 100MaxW = 100000;
  2. struct Item {int costweight;} items[MaxN];    // 設計成物件
  3. int c[MaxW + 1];
  4. void knapsack(int nint w)
  5. {
  6.     memset(c0sizeof(c));
  7.     for (int i = 0i < n; ++i)
  8.     {
  9.         int weight = items[i].weightcost = items[i].cost;
  10.         for (int j = wj - weight >= 0; --j)
  11.             c[j] = maxc[j], c[j - weight] + cost );
  12.     }
  13.     cout << "最高的價值為" << c[w] << endl;
  14. }

計算出背包裡物品總價值的最大值(Top-down)

方才所採用的方法會計算到所有可能的小問題,然而並不是所有小問題都需要計算,故採用Top-down的方式會比較快。

 
  1. const int MaxN = 100;
  2. const int MaxW = 100000;
  3. int C[MaxN], W[MaxN];
  4. int c[MaxN + 1][MaxW + 1];
  5. int knapsack(int nint w)          // n 為物品個數,w 為背包負重上限
  6. {
  7.     if (w < 0return -1e9;         // 負重能力不足,故只能不放
  8.     if (n == 0return 0;           // 沒有物品時,就沒有cost
  9.     if (c[n][w]) return c[n][w];    // memoization,直接讀取記憶體的答案
  10.     return c[n][w] =
  11.         max(knapsack(n-1w-W[n]) + C[n], knapsack(n-1w));
  12. }

找出此時背包裡最多可剩下多少空間、最少只用了多少空間

從表格的右方開始往左搜尋即可。當發現最佳的cost將要變小時,表示該處為最節省空間的地方。然而這不是很好的作法。

 
  1. const int MaxN = 100MaxW = 100000;
  2. int C[MaxN], W[MaxN];
  3. int c[MaxW + 1];
  4. void knapsack(int nint w)
  5. {
  6.     memset(c0sizeof(c));
  7.     for (int i = 0i < n; ++i)
  8.         for (int j = wj - W[i] >= 0; --j)
  9.             c[j] = maxc[j], c[j - W[i]] + C[i] );
  10.     cout << "最高的價值為" << c[w] << endl;
  11.     while (w-1 >= 0 && c[w-1] == c[w]) w--; // 往左搜尋邊界
  12.     cout << "使用的空間最少可為" << w << endl;
  13. }

找出此時背包裡放了哪些物品

另外再建立一個二維陣列,紀錄每一格的數值是由哪個子問題所算得的。每個問題只會有放或不放兩種情形,所以只要記錄放或不放便可以了。這段程式碼只能找出其中一種配置物品的方式。

這裡要注意的是,由於尋找放入的物品時必須要從表格的右下角逆推,如此得到的物品順序並不會是字典順序最小的一組。因此下面的程式碼會將所有物品依照相反順序進行DP的計算,然後再逆推、求放入的物品時,就會剛好是字典順序了。

 
  1. const int MaxN = 100MaxW = 100000;
  2. int C[MaxN], W[MaxN];
  3. int c[MaxW + 1];
  4. bool path[MaxN][MaxW + 1];  // 紀錄放還是不放,false為不放,true為放
  5. void knapsack(int nint w)
  6. {
  7.     memset(c0sizeof(c));
  8.     memset(path0sizof(path));       // 初始化為 false
  9.     for (int i = n-1i >= 0; --i)      // 改為由後往前
  10.         for (int j = wj >= W[i]; --j)
  11.             if (c[j - W[i]] + C[i] > c[j])
  12.             {
  13.                 c[j] = c[j - W[i]] + C[i];
  14.                 path[i][j] = true;      // 放入
  15.             }
  16.     cout << "最高的價值為" << c[w] << endl;
  17.     for (int i = 0j = wi < N; ++i)  // 往回逆推,求放入的物品。
  18.         if (path[i][j])                 // 背包有該物品
  19.         {
  20.             cout << "背包裡有第" << i << "個物品" << endl;
  21.             j -= W[i];
  22.         }
  23. }

找出所有的配置物品的方式

方才的程式碼只能找出一種配置背包內物品的方式。若要找出所有方式,那就要寫個遞迴了吧?就我所知,目前尚未存在有效率的方法,能夠求出所有的配置方式。

 
  1. const int MaxN = 100MaxW = 100000;
  2. int C[10], W[10];
  3. int c[MaxW + 1];
  4. int path[MaxN][MaxW + 1];   // 0為不放,1為放,2為可放可不放
  5. void knapsack(int nint w)
  6. {
  7.     memset(c0sizeof(c));
  8.     memset(path0sizof(path));
  9.     for (int i = n-1i >= 0; --i)
  10.         for (int j = wj >= W[i]; --j)
  11.             if (c[j - W[i]] + C[i] > c[j])          // 放了會比較好
  12.             {
  13.                 c[j] = c[j - W[i]] + C[i];
  14.                 path[i][j] = 1;
  15.             }
  16.             else if (c[j - W[i]] + C[i] == c[j])    // 可放可不放
  17.             {
  18.                 path[i][j] = 2;
  19.             }
  20.             else if (c[j - W[j]] + C[i] < c[j])     // 不放比較好
  21.             {
  22. //              c[j] = c[j];
  23.                 path[i][j] = 0;
  24.             }
  25.     cout << "最高的價值為" << c[w] << endl;
  26. }
  27. // 從表格右下角開始往回逆推
  28. void find_path(int nint w)
  29. {
  30.     if (n < 0return;          // 找完了
  31.     if (path[n][w] == 1)        // 背包有該物品
  32.     {
  33.         cout << "背包裡有第" << i << "個物品" << endl;
  34.         find_path(n-1w-W[i]);
  35.     }
  36.     else if (path[n][w] == 2)   // 可有可無
  37.     {
  38.         cout << "背包裡有第" << i << "個物品" << endl;
  39.         find_path(n-1w-W[i]);
  40.         find_path(n-1w);
  41.     }
  42.     else if (path[n][w] == 0)   // 背包沒有該物品
  43.     {
  44.         find_path(n-1w);
  45.     }
  46. }

塞入最少個物品、最多個物品

若只是要找出物品塞最少個或最多個的配置方式,則可以重新設計recurrence為:

c(n, w, t) = max( c(n-1, w-W[n], t-1) + C[n] , c(n-1, w, t) )t:放入的物品個數。

建立三維的表格,並算出每一格的答案後,接著窮舉所有可能的t,觀察那些格子、相互比較cost之後,便可以得到最佳解。程式碼就不寫了。

Greedy

當每個物品的重量相互之間皆為倍數關係,優先放入價值與重量比值較高的物品,可以讓總價值最高。但是我不確定是不是一定要呈倍數關係,才能有greedy演算法。(可參考本站文件「Dynamic Programming─Money Changing Problem」的Change-Making Problem: Cashier's Algorithm章節)

計算出背包裡物品總價值的最小值

剛剛所討論是讓背包裡物品總價值最高的方法;至於要讓背包裡物品總價值最低的話,那就是什麼東西都不要放進背包了吧。

整理了一些類題。

UVa 431 624 990 10130 10819

0/1 Knapsack Problem之二

程度★★ 難度★

另一種分割問題的方法

想像物品放入背包時是照物品編號順序來放。由於每一種物品都可能是最後一個放入背包的物品,遞迴式可設計為:

c(n, w) = max(0,                           都不放c(0, w-W[0]) + C[0],         最後是放第1個物品(index 0)c(1, w-W[1]) + C[1],         最後是放第2個物品(index 1)... ,                        ...c(n-1, w-W[n-1]) + C[n-1] )  最後是放第n個物品(index n-1)n:第1個到第n個物品要放進背包內。w:背包負重上限。c(n, w):n個物品儘量塞入負重限制為w的背包時,其總價值的最大值。W[n]:第n個物品的重量。C[n]:第n個物品的價值。

計算出背包裡物品總價值的最大值(Top-down)

隨意寫一下。如果寫錯要跟我講呀。

 
  1. // 物品的價值、重量,以及DP會用到的表格。
  2. int C[100], W[100], c[100 + 1][100000 + 1];
  3. int knapsack(int nint w)          // n 為物品個數,w 為背包負重上限
  4. {
  5.     if (w < 0return -1e9;         // 負重能力不足,故只能不放
  6.     if (n == 0return 0;           // 沒有物品時,就沒有cost
  7.     if (c[n][w]) return c[n][w];    // memoization,直接讀取記憶體的答案
  8.     int v = 0;
  9.     for (i=0i<n; ++i)             // 每個物品都試試看
  10.         v = max(vknapsack(iw-W[i]) + C[i]);
  11.     return c[n][w] = v;
  12. }

Matrix Chain Multiplication

程度★★ 難度★

Matrix Chain Multiplication

矩陣乘法具有結合率。在一連串的矩陣乘法中,可以從中任取兩個相鄰的矩陣相乘,先行結合成一個新矩陣,也不會改變所有矩陣相乘之後的結果。

在一連串的矩陣乘法中,無論從何處開始相乘其結果都不變,然而計算速度卻有差異。兩個矩陣大小為a x b及b x c,其相乘需要O(a*b*c)的時間(當然還可以更快,但此處不討論),那麼一連串的矩陣乘法,需要多少時間呢?

分割問題的方法

這個問題在許多地方都找得到資料,故只略述。從最後一次相乘的角度來看,原來的一連串矩陣,可從最後一次相乘的地方分開,便能將原問題化作兩串矩陣相乘,然後再合併起來。分割問題的方法類似於本站文件「Divide and Conquer─Fast Exponentiation」,但在本問題中,並非固定地對半分,而是同時考慮所有可能的分法。

當然也可以印出矩陣相乘的順序。只要另外再用一個陣列來紀錄每次相乘的位置就行了。

另一種方法

現今已能在O(NlogN)時間內解決Matrix Chain Multiplication,是我從網路論壇上聽聞到的:http://historical.ncstrl.org/litesite-data/stan/CS-TR-81-875.pdf。

UVa 348 442

Optimal Binary Search Tree

程度★★ 難度★★

Optimal Binary Search Tree

問題說明:略!總之就是所有鍵值的「深度」乘上「權重(出現頻率)」的總和要最小。

Optimal Binary Search Tree的相似產物

這裡整理了三種很相像的樹,都是令所有鍵值的「深度」乘上「權重(出現頻率)」的總和最小。

Optimal Binary Search Tree:樹上所有點都是鍵值,且鍵值須照大小順序安排。可用Dynamic Programming解決,時間複雜度為O(N^3),可優化至O(N^2)。

Optimal Alphabetic Binary Code Tree:只有葉子是鍵值,且鍵值前後順序需固定。可用Greedy解決,時間複雜度為O(NlogN)。

Optimal Binary Code Tree(Huffman Tree):只有葉子是鍵值,且鍵值順序隨意。可用Greedy解決,時間複雜度為O(NlogN)。

分割問題的方法

和Matrix Chain Multiplication相同,窮舉所有可以當作root的鍵值,並以root將原來的樹分作左右兩棵子樹,便縮小了問題。

實作

下面的程式碼將陣列邊界左右各加一格,如此可省去一些判斷陣列邊界的麻煩。

 
  1. // 鍵值排序後,各自對應的出現頻率。最左邊和最右邊是額外加上的空格子。
  2. int freq[5+2] = {04612250};
  3. int sum[5+2][5+2];      // 連續區間和
  4. int c[5+2][5+2];        // 實行DP所用的陣列。初始化為零。
  5. int path[5+2][5+2];     // 用來紀錄左右子樹的分割點
  6. void OptimalBinarySearchTree()
  7. {
  8.     /* 先計算連續區間和。之後就會輕鬆多了。 */
  9.     
  10.     for (int i=0i<5; ++i)         // 區間的寬度
  11.         for (int j=1j+i<=5; ++j)  // 區間的起點
  12.             sum[j][j+i] = freq[j] + sum[j+1][j+i];
  13.     /* 計算最佳的二元搜尋樹。 */
  14.     
  15.     for (int i=0i<5; ++i)         // 區間的寬度
  16.         for (int j=1j+i<=5; ++j)  // 區間的起點
  17.         {
  18.             int c[j][j+i] = 1e9;
  19.             for (int k=jk<=j+i; ++k)  // 分割點
  20.                 if (c[j][k-1] + c[k+1][j+i] + sum[j][j+i] < c[j][j+i])
  21.                 {
  22.                     c[j][j+i] = c[j][k-1] + c[k+1][j+i] + sum[j][j+i];
  23.                     path[j][j+i] = k;
  24.                 }
  25.         }
  26.         
  27.     cout << "最佳的二元搜尋樹的平均搜尋成本為" << c[1][5];
  28. }

由於第二層迴圈實行時,sum[j][j+i]都維持定值,也不影響最大值的判斷,故可將之移到迴圈外面去。加法次數減少,會稍微快一點。

 
  1.         for (int j=1j+i<=5; ++j)
  2.         {
  3.             int c[j][j+i] = 1e9;
  4.             for (int k=jk<=j+i; ++k)
  5.                 if (c[j][k-1] + c[k+1][j+i] < c[j][j+i])
  6.                 {
  7.                     c[j][j+i] = c[j][k-1] + c[k+1][j+i];    // 移走
  8.                     path[j][j+i] = k;
  9.                 }
  10.             c[j][j+i] += sum[j][j+i];   // 移到這裡來
  11.         }

迴圈判斷式中,關於區間起點的範圍,與其用減法計算區點起點的界限,不如簡單想成區間終點不超過陣列邊界。

 
  1.     for (int i=0i<5; ++i)         // 區間的寬度
  2.         for (int j=1j<=5-i; ++j)  // 用減法計算區間起點的界限
  3.             // 不確定j+i會不會超過邊界
  4.             sum[j][j+i] = freq[j] + sum[j+1][j+i];
 
  1.     for (int i=0i<5; ++i)
  2.         for (int j=1j+i<=5; ++j)  // 區間終點不超過陣列邊界
  3.             // 對應到此處的index,可確定j+i不會超過陣列邊界。
  4.             sum[j][j+i] = freq[j] + sum[j+1][j+i];

印出Optimal Binary Search Tree

嗯哼。為了不剝奪各位寫程式的樂趣,所以這裡就不提了。

時間複雜度

所有的子問題共有O(N^2)個,每個子問題需要窮舉O(N)種分割點,故時間複雜度為O(N^3)。

優化

當recurrence具有特殊的遞增或遞減性質時,便可有辦法加速。【待補凸四邊形不等式】

每次計算一個子問題時,總是要窮舉所有的分割點。然而有些分割點很明顯地是錯誤的,尤其是靠近區間邊界的那些分割點,實在不太可能將兩顆子樹分的夠均勻、令總和最小。

另外這個問題還有一個特性。相近的子問題,其分割點的位置也很相近。例如子問題[a,b]的分割點,應該會與子問題[a-1,b]、[a+1,b]的分割點差不多,因為分割出來的左右子樹頂多也只差了一個鍵值,考量到左右子樹要均勻才行,可推論總成本、子樹的形狀、分割點的位置應該都是差不多的。

事實上,細心的觀察者們,可以發現子問題[a,b]的分割點,必定位於更小的子問題[a+1,b]和[a,b-1]的分割點之間。如此一來,每次計算一個子問題時,就不必窮舉所有的分割點,只要找更小的子問題[a+1,b]和[a,b-1]的分割點之間的分割點即可,為常數個(請各位自行觀察一下)。

由於檢查分割點的次數變成常數次,所以時間複雜度為O(1)。計算O(N^2)個子問題時,時間複雜度就是O(N^2)了。

 
  1.             for (int k=path[j+1][j+i]; k<=path[j][j+i-1]; ++k)  // 分割點

UVa 10304

Bitmask in Dynamic Programming問題特輯

程度★★ 難度★★

Bitmask

bitmask是一串二進位數字,每一個位元分別代表一件東西,1代表開啟,0代表關閉。例如現在有十個燈泡,編號設定為零到九,其中第零個、第一個、第四個、第八個燈泡是亮的,剩下來的燈泡是暗的,我們可以用一個10 bit的二進位數字0100001001,來表示這十個燈泡現在的亮暗狀態。

如果想替各種狀態做進一步的記錄,我們可以建立一個大小為2^10的陣列,便囊括了所有可能的狀態。這個陣列的每一格,就代表一種燈泡開關的狀態,可以進行記錄。

 
  1. int array[1<<10];
  2. array[521] = 想記錄的數字;
  3. /* 100001001(2進位) = 521(10進位) */

Maximum Cardinality Matching

大意:給一張圖,相鄰的兩點可匹配在一起,求這張圖的最大匹配方式及匹配數目。

此題有多項式時間的解法,不過很難實作。用DP雖然慢了些,但簡單多了,只要把一對匹配在一起的點拿掉,就可以得到遞迴公式:

c[s] = max ( c[s-{i}-{j}] + adj[i,j] )c[s+{i}+{j}] = max ( c[s] + adj[i,j] )i、j:點。s:點集合。c[s]:s當中的點,所構成的最大匹配數。adj[i,j]:adjacency matrix。邊ij暢通就是1,不暢通就是0。

實作時,可利用bitmask做為s的資料結構,匹配過的點都標成1,未匹配的點都標成0。

這個方法的時間複雜度為O(2^N * N^2),空間複雜度為O(2^N)。

這個方法需要大量記憶體,所以無法計算N很大的情況,何況編譯器也不准我們開太大的陣列,N=28就是極限了。這個方法同時也需要大量時間,以現在的個人電腦來說,N=17就已經要花上幾分鐘才能求出答案了。

 
  1. // top-down DP
  2. const int N = 10;   // 點的數目
  3. int dp[1<<N];   // lookup table for memoization
  4. bool ok[1<<N];  // 記錄lookup table是否已存值
  5. int adj[N][N];  // adjacency matrix。暢通為1,否則為0。
  6. bool MCM(int s)
  7. {
  8.     if (s == 0return true;
  9.     if (ok[s]) return dp[s];
  10.     for (int i=0i<N; ++i)
  11.         for (int j=i+1j<N; ++j)
  12.             if (s & ((1<<i) | (1<<j)))
  13.             {
  14.                 // ss = s - {i} - {j};
  15.                 int ss = s ^ (1<<i) ^ (1<<j);
  16.                 dp[s] >?= MCM(ss) + adj[i][j];
  17.             }
  18.     ok[s] = true;
  19.     return dp[s];
  20. }
  21. int MCM()
  22. {
  23.     memset(dp0sizeof(dp));
  24.     memset(okfalsesizeof(ok));
  25.     return MCM((1<<N)-1);
  26. }

這個演算法可以再修正,讓時間複雜度成為O(2^N * N),各位可以試試看。

Minimum Weight Matching / Maximum Weight Matching

大意:給一張圖,相鄰的兩點可匹配在一起,求這張圖的所有最大匹配當中,權重最小(大)的匹配方式及權重。

此題亦有多項式時間的解法,但是很難實作。DP解法同前,稍微改一下遞迴公式的運算符號就行了。程式碼就不寫了。

w[s] = min ( w[s-{i}-{j}] + adj[i,j] )w[s+{i}+{j}] = min ( w[s] + adj[i,j] )i、j:點。s:點集合。w[s]:s當中的點,所構成的最大匹配的最小權重。adj[i,j]:adjacency matrix。邊ij的權重。

UVa 10888 10911 11439

Chinese Postman Problem(CPP)

大意:給定一張圖,郵差想走過圖上每一條邊去寄信,最後回到原點。求最短的走法及長度。

此題會用到Minimum Weight Matching,故在此列出。此題不論是無向圖或是有向圖,都有多項式時間的解法,可是很難實作。

UVa 10296 11156

判斷一張圖上存不存在Hamilton Path

大意:是否存在一條通過圖上所有點的路徑,若有則找出走法。

此題目前無多項式時間的解法。最容易的解法是使用backtracking,窮舉圖上所有點的各種排列方式,一種排列方式當作是一條路徑,並判斷該路徑是不是Hamilton Path。

把一條路徑的最後一條邊拆掉的話,就可以形成遞迴公式。注意到路徑的端點,當端點不同,結果會不同,所以需要另外一個維度來記錄路徑的端點:

path[s,j] = or_all ( path[s-{j},i] && adj[i,j] )path[s+{j},j] = or_all ( path[s,i] && adj[i,j] )i、j:點。s:點集合。path[s,j]:經過s當中所有點且最後一點是j的路徑,如果暢通就是true,不暢通就是false。adj[i,j]:adjacency matrix。邊ij暢通就是true,不暢通就是false。
 
  1. // top-down DP
  2. const int N = 10;   // 點的數目
  3. bool dp[1<<N][N];   // lookup table for memoization
  4. bool ok[1<<N][N];   // 記錄lookup table是否已存值
  5. bool adj[N][N];     // adjacency matrix。暢通為true,否則為false。
  6. bool path(int sint jint s_size)
  7. {
  8.     if (s_size == 1return true;   // s只有一個位元是1
  9.     if (ok[s][j]) return dp[s][j];
  10.     for (int i=0i<N; ++i)
  11.         if (i != j && s & (1 << i))
  12.             dp[s][j] ||= (path(s ^ (1 << j), is_size - 1) && adj[i][j]);
  13.     ok[s][j] = true;
  14.     return dp[s][j];
  15. }
  16. bool Hamilton_Path()
  17. {
  18.     memset(dpfalsesizeof(dp));
  19.     memset(okfalsesizeof(ok));
  20.     for (int i=0i<N; ++i)
  21.         if (path((1<<N)-1iN))
  22.             return true;
  23.     return false;
  24. }

計算一張圖的Hamilton Path的數量

解法同前,稍微改一下遞迴公式的運算符號就行了。程式碼就不寫了。

c[s,j] = sigma ( c[s-{j},i] * adj[i,j] )c[s+{j},j] = sigma ( c[s,i] * adj[i,j] )i、j:點。s:點集合。c[s,j]:經過s當中所有點且最後一點是j的路徑數量。adj[i,j]:adjacency matrix。邊ij暢通就是1,不暢通就是0。

Travelling Salesman Problem(TSP)

TSP是一個經典的NP-Complete問題。大意是:一個周遊各國的商人,他想去所有不同的城市買賣東西。商人為了節省車馬費,打算從其中一個城市出發,各個地方剛好經過一次之後,回到原城市。所有城市之間都有路,請規劃出距離最短的路線,以及算出距離。這個問題其實也就是找一個權重最小的Hamilton Circuit。

解法同前,稍微改一下遞迴公式運算符號就行了:

dist[s,j] = min ( dist[s-{j},i] + adj[i,j] )dist[s+{j},j] = min ( dist[s,i] + adj[i,j] )i、j:點。s:點集合。dist[s,j]:經過s當中所有點且最後一點是j的路徑長度。adj[i,j]:adjacency matrix。邊ij的長度。

上面的遞迴公式只能求出權重最小的Hamilton Path。在TSP當中,不管從哪一點出發,求出來的答案都一樣,反正都會繞一圈經過每個點,所以我們令商人從第0點出發,當找到一條Hamilton Path之後,再額外加上一條回到第0點的邊,就能產生一個Hamilton Circuit了。

 
  1. // fake bottom-up DP,同樣的子問題可能會算許多遍。
  2. int dp[1<<N][N];    // lookup table for memoization
  3. int adj[N][N];  // adjacency matrix。兩點間的距離。
  4. int ans;        // TSP的答案
  5. void TSP(int sint iint s_size)
  6. {
  7.     if (s_size == N)
  8.     {
  9.         // 最後再加上一條回到原點的邊,形成環狀路線。
  10.         if (dp[s][i] + adj[i][0] < ans)
  11.             ans = dp[s][i] + adj[i][0];
  12.         return;
  13.     }
  14.     for (int j=1j<N; ++j// 只走第0點以外的點
  15.         if (!(s & (1 << j)))
  16.         {
  17.             // ss = s + {j};
  18.             int ss = s | (1 << j);
  19.             if (!dp[ss][j] || dp[s][i] + adj[i][j] < dp[ss][j])
  20.             {
  21.                 dp[ss][j] = dp[s][i] + adj[i][j];
  22.                 TSP(ssjs_size + 1);
  23.             }
  24.         }
  25. }
  26. void TSP()
  27. {
  28.     memset(dp0sizeof(dp));
  29.     ans = 1e9;
  30.     TSP(000);   // 令商人從第0點出發。
  31.     cout << ans << endl;
  32. }

另外這裡再附上一個不正確的程式碼。表格的用途是這樣的:dp[目前走過的地點數][已走過的地點]。這個方法會在計算dp[N][N個1組成的二進位數字]的時候產生錯誤(N為地點數目)。

 
  1. void TSP(int dint nowint mask)
  2. {
  3.     if (d == N)
  4.     {
  5.         if (dp[d][mask] + dd(now0) < ans)
  6.             ans = dp[d][mask] + dd(now0);
  7.         return;
  8.     }
  9.     for (int i=1mm=1i<=N; ++imm<<=1)
  10.         if (!(mask & mm))
  11.             if (!dp[d+1][mask | mm] || dp[d][mask] + dd(nowi) < dp[d+1][mask | mm])
  12.             {
  13.                 dp[d+1][mask | mm] = dp[d][mask] + dd(nowi);
  14.                 TSP(d+1imask | mm);
  15.             }
  16. }

UVa 216 10068 10496 10818 10937 10944 10605 10890

Subset Sum Problem / Partition Problem

參照Money Changing Problem之湊得某個價位的錢幣用量有哪幾種

 

 

 

 

 

 

http://www.cnblogs.com/drizzlecrj/archive/2007/10/26/939159.html

http://www.csie.ntnu.edu.tw/~u91029/DynamicProgramming.html

原创粉丝点击