算法競賽專題解析(11):DP概述和常見DP面試題


本系列是這本算法教材的擴展資料:《算法競賽入門到進階》(京東 當當 ) 清華大學出版社
如有建議,請聯系:(1)QQ 群,567554289;(2)作者QQ,15512356

  動態規划(DP)是一種算法技術,它將大問題分解為更簡單的子問題,對整體問題的最優解決方案取決於子問題的最優解決方案。

1 DP概述

1.1 DP問題的特征

  下面以斐波那契數為例說明DP的概念。斐波那契數列的每個數字是前面兩個數字的和,前幾個數是1、1、2、3、5、8。計算第n個斐波那契數,用遞推公式進行計算:
$$fib(n) = fib(n-1) + fib(n-2) $$
  用遞歸編程,代碼如下。

int fib (int n){
    if (n == 1 || n == 2)  
        return 1;
    return (fib (n -1) + fib (n -2));
}

  為了解決總體問題\(fib(n)\),將其分解為兩個較小的子問題\(fib(n-1)\)\(fib(n-2)\)。這就是DP的應用場景。
  有一些問題有2個特征:重疊子問題、最優子結構。用DP可以高效率地處理具有這2個特征的問題。
  (1)重疊子問題
  首先,子問題是原大問題的小版本,計算步驟完全一樣;其次,計算大問題的時候,需要多次重復計算小問題。這就是“重疊子問題”。以斐波那契數為例,用遞歸計算\(fib(5)\),分解為以下子問題:

圖1 計算斐波那契數

  其中\(fib(3)\)計算了2次,其實只算1次就夠了。
  一個子問題的多次計算,耗費了大量時間。用DP處理重疊子問題,每個子問題只需要計算一次,從而避免了重復計算,這就是DP效率高的原因。具體的做法是:首先分析得到最優子結構,然后用遞推或者記憶化遞歸進行編程,從而實現了高效的計算。
  需要注意的是,DP在獲得時間高效率的同時,可能耗費更多的空間,即“時間效率高,空間耗費大”。滾動數組是優化空間效率的一個辦法。
  (2)最優子結構
  最優子結構的意思是:首先,大問題的最優解包含小問題的最優解;其次,可以通過小問題的最優解推導出大問題的最優解。在斐波那契問題中,把數列的計算構造成\(fib(n) = fib(n-1) + fib(n-2)\),即把原來為\(n\)的大問題,減小為\(n-1\)\(n-2\)的小問題,這是斐波那契數的最優子結構。
  在DP的概念中,還常常提到“無后效性”,即一個狀態只取決於推導它的前面的狀態,和后續的狀態無關。從最優子結構的概念可以看出,它是滿足無后效性的;所以,可以把無后效性看成最優子結構的另外一種解釋。
  這里用斐波那契數列舉例說明DP的概念,可能過於簡單,不足以說明DP的特征。建議讀者用后文的經典問題“0/1背包”,重新理解DP的特征。

1.2 DP的兩種實現

  處理DP中的大問題和小問題,有兩種思路:自頂向下(先大問題再小問題)、自下而上(先小問題再大問題)。
  編碼實現DP時,自頂向下用帶記憶化的遞歸,自下而上用遞推。兩種方法的復雜度是一樣的,每個子問題都計算一遍,而且只計算一遍。
  (1)自頂向下與記憶化遞歸
  先考慮大問題,再縮小到小問題,遞歸很直接地體現了這種思路。為避免遞歸時重復計算子問題,可以在子問題得到解決時,就保存結果,再次需要這個結果時,直接返回保存的結果就行了。這種存儲已經解決的子問題的結果的技術稱為“記憶化(Memoization)”。
  以斐波那契數為例,記憶化代碼如下:

int memoize[maxn];                  //保存結果
int fib (int n){
    if (n == 1 || n == 2)  
        return 1;
    if(memoize[n] != 0)             //直接返回保存的結果,不再遞歸
        return memoize[n];    
    memoize[n]= fib (n - 1) + fib (n - 2);     //遞歸計算結果,並記憶
    return memoize[n];
}

  (2)自下而上與制表遞推
  這種方法與遞歸的自頂向下相反,避免了遞歸的編程方法。這種“自下而上”的方法,先解決子問題,再遞推到大問題。通常通過填寫多維表格來完成。根據表中的結果,逐步計算出大問題的解決方案。
  用制表法計算斐波那契數,維護一個一維表\(dp[]\),記錄自下而上的計算結果,更大的數是前面兩個數的和。
  代碼如下:

const int maxn = 255;
int dp[maxn];
int fib (int n){
    dp[1] = dp[2] =1;
    for (int i = 3;i<=n;i++)
        dp[i] = dp[i-1] +dp[i-2];
    return dp[n];
}

  在DP編程時,大多使用制表遞推的編程方法。超過4維(\(dp[][][][]\))的表格也是常見的。

2 經典DP面試問題

  本節介紹了10個經典的DP面試問題[1],並且以第一個“0/1背包問題”為例,詳細解釋與DP有關的內容:
  (1)dp的設計;
  (2)dp方程的推導;
  (3)記憶化和遞推編碼;
  (4)具體方案的輸出;
  (5)滾動數組。DP使用的空間可以用滾動數組優化。DP的狀態方程,常常是二維和二維以上,占用了太多的空間。滾動數組是減少空間的技術,例如它可以把二維狀態方程的\(O(n^2)\)空間復雜度,優化到\(O(n)\),更高維的數組也可以優化。

2.1 0/1背包問題(0/1 Knapsack Problem)

  問題描述:給定\(n\)種物品和一個背包,物品\(i\)的重量是\(w_i\),價值為\(v_i\),背包的總容量為\(C\)。把物品裝入背包時,第\(i\)種物品只有兩種選擇:裝入背包或不裝入背包,稱為0/1背包。如何選擇裝入背包的物品,使得裝入背包中的物品的總價值最大?
  設\(x_i\)表示物品\(i\)裝入背包的情況:\(x_i=0\)時,不裝入背包;\(x_i=1\)時,裝入背包。有以下約束條件和目標函數:
  約束條件:\(\sum_{i=1}^nw_ix_i \leq C\)   \(x_i\in\{0,1\},1 \leq i \leq n\)
  目標函數:\(max \sum_{i=1}^nv_ix_i\)

  下面以0/1背包問題為例,詳細解釋DP相關知識點。首先看一個例題。


hdu 2602 Bone Collector http://acm.hdu.edu.cn/showproblem.php?pid=2602
問題描述:“骨頭收集者”帶着體積C的背包去撿骨頭,已知每個骨頭的體積和價值,求能裝進背包的最大價值。N <= 1000,C<= 1000。
輸入:第1行是測試數量;后面每3行是1個測試,其中第1行是骨頭數量N和背包體積C,第2行是每個骨頭的價值,第3行是每個骨頭的體積。
輸出
最大價值。
樣例輸入
1
5 10
1 2 3 4 5
5 4 3 2 1
樣例輸出
14


(1)dp的設計
  引進一個\((N+1)×(C+1)\)大小的二維數組\(dp\)\(dp[i][j]\)表示把前\(i\)個物品(從第1個到第\(i\)個)裝入容量為\(j\)的背包中獲得的最大價值。
  可以把每個\(dp[i][j]\)都看成一個背包,最后的\(dp[N][C]\)就是答案:把\(N\)個物品裝進容量\(C\)的背包。
(2)dp轉移方程
  現在自下而上計算dp,假設現在遞推到\(dp[i][j]\),它表示把前\(i\)個物品裝進容量為\(j\)的背包。分2種情況:
  1)第\(i\)個物品的體積比容量\(j\)還大,不能裝進容量\(j\)的背包。那么直接繼承前\(i-1\)個物品裝進容量\(j\)的背包的情況即可:\(dp[i][j] = dp[i-1][j]\)
  2)第\(i\)個物品的體積比容量\(j\)小,能裝進背包。又可以分為2種情況:裝或者不裝第\(i\)個。
  (a)裝第\(i\)個。從前\(i-1\)個物品的情況下推廣而來,前\(i-1\)個物品是\(dp[i-1][j]\)。第\(i\)個物品裝進背包后,背包容量減少\(bone[i].volum\),價值增加\(bone[i].value\)。所以有:

\[dp[i][j] = dp[i-1][j-bone[i].volum] + bone[i].value \]

  (b)不裝第\(i\)個。那么:\(dp[i][j] = dp[i-1][j]\)
  取(a)和(b)的最大值,狀態轉移方程是:
$$dp[i][j] = max(dp[i-1][j], dp[i-1][j-bone[i].volum] + bone[i].value)$$
  總結上述分析,0/1背包問題的重疊子問題是\(dp[i][j]\),最優子結構是\(dp[i][j]\)的狀態轉移方程。
  算法的時間復雜度:算法需要計算二維矩陣dp,二維矩陣大小是\(N×C\),每一項計算時間是\(O(1)\),總復雜度是\(O(N×C)\)。算法的空間復雜度是\(O(N×C)\)
  0/1背包問題的簡化版。一般物品有體積(或者重量)、價值這2個屬性,求滿足體積約束條件下的最大價值。如果再簡單一點,只有一個體積屬性,求能放到背包的最多物品,那么,只要把體積看成價值,求最大體積就好了。狀態方程變為:
$$dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - volum[i]] + volum[i])$$
(3)詳解dp的轉移過程
  初學者可能對上面的描述仍不太清楚,下面用一個例子詳細說明:有4個物品,其體積分別是{2, 3, 6, 5},價值分別為{6, 3, 5, 4},背包的容量為9。
  填dp表的過程,按照只裝第1個物品、只放前2個、只放前3個......的順序,一直到裝完,這就是從小問題擴展到大問題的過程。

圖2 dp矩陣

  步驟1:只裝第1個物品。
  由於物品1的體積是2,所以背包容量小於2的,都放不進去,得\(dp[1][0]=dp[1][1]=0\)
  物品1的體積等於背包容量,能裝進去,背包價值等於物品1的價值,\(dp[1][2]=6\)
  容量大於2的背包,多余的容量用不到,所以價值和容量2的背包一樣。

圖3 裝第1個物品

  步驟2:只裝前2個物品。
  如果物品2體積比背包容量大,那么不能裝物品2,情況和只裝第1個一樣。見下圖中的\(dp[2][0]=dp[2][1]=0,dp[2][2]=6\)
  下面填\(dp[2][3]\)。物品2體積等於背包容量,那么可以裝物品2,也可以不裝:
  (a)如果裝物品2(體積是3),那么可以變成一個更小的問題,即只把物品1裝到(容量 - 3)的背包中。

圖4 裝第2個物品

  (b)如果不裝物品2,那么相當於只把物品1裝到背包中。

圖5 不裝第2個物品

  取(a)和(b)的最大值,得\(dp[2][3] = max\{3,6\} = 6\)
  后續步驟:繼續以上過程,最后得到下圖(圖中的箭頭是幾個例子):

圖6 完成dp矩陣

  最后的答案是\(dp[4][9]\):把4個物品裝到容量為9的背包,最大價值是11。
(4)輸出背包方案
  現在回頭看具體裝了哪些物品。需要倒過來觀察:
  \(dp[4][9]=max{dp[3][4]+4,dp[3][9]} = dp[3][9]\),說明沒有裝物品4,用\(x_4=0\)表示;
  \(dp[3][9]=max{dp[2][3]+5,dp[2][9]} = dp[2][3]+5 = 11\),說明裝了物品3,\(x_3=1\)
  \(dp[2][3]=max{dp[1][0]+3,dp[1][3]} = dp[1][3]\),說明沒有裝物品2,\(x_2=0\)
  \(dp[1][3]=max{dp[0][1]+6,dp[0][3]} = dp[0][1]+6 = 6\),說明裝了物品1,\(x_1=1\)
  圖中的實線箭頭指出了方案的路徑。

圖7 背包方案

(5)記憶化代碼和遞推代碼
  下面的代碼分別用自下而上的遞推和自上而下的記憶化遞歸實現。
  1)遞推代碼。

#include<bits/stdc++.h>
using namespace std;
struct BONE{
	int value, volum;
}bone[1011];
int dp[1011][1011];
int solve(int n, int c){
    for(int i=1; i<=n; i++)
        for(int j=0; j<=c; j++){
     	  if(bone[i].volum > j)        //第i個物品比背包還大,裝不了
              dp[i][j] = dp[i-1][j];
           else                        //第i個物品可以裝
	          dp[i][j] = max(dp[i-1][j], dp[i-1][j-bone[i].volum]+bone[i].value);
    }
    return dp[n][c];
}
int main(){    
	int T; cin>>T;
	while(T--){
		int N,C; cin >> N >> C;
		for(int i=1;i<=N;i++)	cin>>bone[i].value;
		for(int i=1;i<=N;i++)	cin>>bone[i].volum;
        memset(dp,0,sizeof(dp));
		cout << solve(N, C) << endl;
	}
	return 0;
}

2)記憶化代碼。只改動了上面代碼中的solve()。

int solve(int i, int j){        //前i個物品,放進容量j的背包
    if (dp[i][j] != 0)           //記憶化
        return dp[i][j];    
	if(i == 0) return 0;	
	int res;
	if(bone[i].volum > j)            //第i個物品比背包還大,裝不了
        res =  solve(i-1,j);    
	else                           //第i個物品可以裝
        res =  max(solve(i-1,j), solve(i-1,j-bone[i].volum)+bone[i].value);    
    return dp[i][j] = res;
}

(6)滾動數組
  上述代碼使用了二維矩陣\(dp[][]\),有可能超過題目許可的空間限制。此時可以用滾動數組減少空間,也就是把\(dp[][]\)替換成一維的\(dp[]\)。觀察二維表\(dp[][]\),可以發現,每一行是從上面一行算出來的,只跟上面一行有關系,跟更前面的行沒有關系,那么用新的一行覆蓋原來的一行(滾動)就好了。

int dp[1011];                                    //替換 int dp[1011][1011];
int solve(int n, int c){
    for(int i=1; i<=n; i++)
        for(int j=c; j>=bone[i].volum; j--)  //反過來循環
            dp[j] = max(dp[j],dp[j-bone[i].volum]+bone[i].value);
    return dp[c];
}

  注意j應該反過來循環,即從后面往前面覆蓋。請讀者思考原因。
  經過滾動數組的優化,空間復雜度從\(O(N×C)\)減少為\(O(C)\)
  滾動數組也有缺點。它覆蓋了中間轉移狀態,只留下了最后的狀態,所以損失了很多信息,導致無法輸出背包的方案。
  二維以上的dp數組也常常能優化。比如求\(dp[t][][]\),如果它只和\(dp[t-1][][]\)有關,不需\(dp[t-2][][]\)\(dp[t-3][][]\)等,那么可以把數組縮小為\(dp[2][][]\)。后面的很多問題都可以用滾動數組優化。

2.2 最長公共子序列(Longest Common Subsequence,LCS)

  一個給定序列的子序列,是在該序列中刪去若干元素后得到的序列。例如:X = {\(A, B, C, B, D, A, B\)},它的子序列有{\(A, B, C, B, A\)}、{\(A, B, D\)}、{\(B, C, D, B\)}等。子序列和子串是不同的概念,子串的元素在原序列中是連續的。
  給定兩個序列\(X\)\(Y\),當另一序列\(Z\)既是\(X\)的子序列又是\(Y\)的子序列時,稱\(Z\)是序列\(X\)\(Y\)的公共子序列。最長公共子序列是長度最長的子序列。
  問題描述:給定兩個序列\(X\)\(Y\),找出\(X\)\(Y\)的一個最長公共子序列。
  用暴力法找最長公共子序列,需要先找出\(X\)的所有子序列,然后驗證是否\(Y\)的子序列。如果\(X\)\(m\)個元素,那么\(X\)\(2^m\)個子序列;\(Y\)\(n\)個元素;總復雜度大於\(O(n2^m)\)
  用動態規划求LCS,復雜度是\(O(nm)\)
  用\(dp[i][j]\)表示序列\(X_i\)(表示\(x_1,...,x_i\)這個序列,即\(X\)的前\(i\)個元素組成的序列;這里用小寫的\(x\)表示元素,用大寫的\(X\)表示序列)和\(Y_j\)(表示\(y_1,...,y_j\)這個序列,即\(Y\)的前\(j\)個元素)的最長公共子序列的長度。\(dp[n][m]\)就是答案。
  分解為2種情況:
  (1)當\(x_i = y_j\)時,找出\(X_{i-1}\)\(Y_{j-1}\)的最長公共子序列,然后在其尾部加上\(x_i\)即可得到\(X_i\)\(Y_j\)的最長公共子序列。
  (2)當\(x_i ≠ y_j\)時,求解兩個子問題:\(X_{i-1}\)\(Y_j\)的最長公共子序列;\(X_i\)\(Y_{j-1}\)的最長公共子序列。取其中的最大值。
  狀態轉移方程是:
    \(dp[i][j] = dp[i-1][j-1] + 1\)          \(x_i = y_j\)
    \(dp[i][j] = max\{dp[i][j-1], dp[i-1][j]\}\)    \(x_i ≠ y_j\)
  習題:hdu 1159 Common Subsequence http://acm.hdu.edu.cn/showproblem.php?pid=1159

2.3 最長上升子序列(Longest Increasing Subsequence,LIS)

  問題描述:給定一個長度為\(n\)的數組,找出一個最長的單調遞增子序列。
  例如一個長度為6的序列\(A=\{5, 6, 7, 4, 2, 8, 3\}\),它最長的單調遞增子序列為{\(5, 6, 7, 8\)},長度為4。
  定義狀態\(dp[i]\),表示以第\(i\)個數為結尾的最長遞增子序列的長度,那么:

\[dp[i] = max\{dp[j]\}+1 \qquad 0< j < i, A_j < A_i \]

  最后答案是\(max\{dp[i]\}\)
  復雜度:\(j\)\(0\) ~ \(i\)之間滑動,復雜度是\(O(n)\)\(i\)的變動范圍也是\(O(n)\)的;總復雜度\(O(n^2)\)
  DP並不是LIS問題的最優解法,有復雜度\(O(nlogn)\)的非DP解法[參考《算法競賽入門到進階》“7.1.4 最長遞增子序列”的詳細講解。]。
  習題:hdu 1257 最少攔截系統 http://acm.hdu.edu.cn/showproblem.php?pid=1257

2.4 編輯距離(Edit Distance)

  問題描述:給定兩個單詞\(word1\)\(word2\),計算出將\(word1\)轉換為\(word2\)所需的最小操作數。一個單詞允許進行以下3種操作:(1)插入一個字符;(2)刪除一個字符;(3)替換一個字符。
  把長度為\(m\)\(word1\)存儲在數組\(word1[1]\) ~ \(word1[m]\),長度為n的\(word2\)存儲在\(word2[1]\) ~ \(word2[n]\)。不用\(word1[0]\)\(word2[0]\)
  定義二維數組 \(dp\)\(dp[i][j]\)表示從 \(word1\) 的前\(i\)個字符轉換到 \(word2\) 的前j個字符所需要的操作步驟,\(dp[m][n]\)就是答案。下圖是\(word1=”abcf”\)\(word2=”bcfe”\)\(dp\)轉移矩陣。

圖8 編輯距離的dp矩陣

  狀態轉移方程:
  (1)若\(word1[i] = word2[j]\),則\(dp[i][j] = dp[i-1][j-1]\)。例如圖中\(dp[2][1]\)處的箭頭。
  (2)其他情況:\(dp[i][j] = min\{dp[i-1][j-1], dp[i-1][j], dp[i][j-1]\} + 1\)。例如圖中\(dp[4][2]\)處的箭頭。\(dp[i][j]\)是它左、左上、上的三個值中的最小值加1,分別對應以下操作:
  1)\(dp[i-1][j]+1\),插入,在\(word2\)的最后插入\(word1\)的最后字符;
  2)\(dp[i][j-1]+1\),刪除,將\(word2\)的最后字符刪除;
  3)\(dp[i-1][j-1]+1\),替換,將\(word2\)的最后一個字符替換為\(word1\)的最后一個字符。
  復雜度:\(O(mn)\)
  習題:力扣72 編輯距離https://leetcode-cn.com/problems/edit-distance/

2.5 最小划分(Minimum Partition)

  問題描述:給出一個正整數數組,把它分成S1、S2兩部分,使S1的數字和與S2的數字和的差的絕對值最小。最小划分的特例是S1和S2的數字和相等,即差為0。
  例如:數組\([1, 6, 11, 5]\),最小划分是\(S1 = [1, 5, 6]\)\(S2 = [11]\)\(S1\)的數字和減去\(S2\)的數字和,絕對值是\(|11 - 12| = 1\)
  最小划分問題可以轉化為0/1背包問題。求出數組的和\(sum\),把問題轉化成:背包的容量為\(sum/2\),把數組的每個數字看成物品的體積,求出背包最多可以放\(res\)體積的物品,返回結果\(|res-(sum-res)|\)
  習題:lintcode 724 Minimum Partition
https://www.lintcode.com/problem/minimum-partition/description

2.6 行走問題(Ways to Cover a Distance)

  問題描述:給定一個整數\(n\)表示距離,一個人每次能走1、2、3步,問走到n步,有多少種走法。例如\(n\) = 3,有4種走法:{1, 1, 1}、{1, 2}、{2, 1}、{3}。
  和爬樓梯問題差不多。爬樓梯問題是每次能走1級或2級,問走到第n級有多少種走法。爬樓梯的解實際上是一個斐波那契數列。
  定義行走問題的狀態\(dp[i]\)為走到第\(i\)步的走法數量,那么有:
  \(dp[0] = 1,dp[1] = 1,dp[2] = 2\)
  當i > 2時:\(dp[i] = dp[i-1] + dp[i-2] + dp[i-3]\)

2.7 矩陣最長遞增路徑(Longest Path In Matrix)

  問題描述:給定一個矩陣,找一條最長路徑,要求路徑上的數字遞增。矩陣的每個點,可以往上、下、左、右四個方向移動,不能沿對角線方向移動。
  例如矩陣:
      \(\begin{matrix} 9 & 9 & 3 \\ 7 & 5 & 7 \\ 3 & 1 & 1\end{matrix}\)
  它的一個最長遞增路徑是[1, 3, 7, 9],長度是4。
  下面給出2種解法:
  (1)暴力DFS。設矩陣有m×n個點,以每個點為起點做DFS搜索遞增路徑,在所有遞增路徑中找出最長的路徑。每個DFS都是指數時間復雜度的,復雜度非常高。
  (2)記憶化搜索。在暴力DFS的基礎上,用記憶化進行優化。把每個點用DFS得到的最長遞增路徑記下來,后面再搜到這個點時,直接返回結果。由於每個點只計算一次,每個邊也只計算一次,雖然做了m×n次DFS搜索,但是總復雜度仍然是O(V+E)= O(mn)的,其中V是點數,E是邊數。這也算是動態規划的方法。
  習題: 力扣329 矩陣中的最長遞增路徑
https://leetcode-cn.com/problems/longest-increasing-path-in-a-matrix/

2.8 子集和問題 (Subset Sum Problem)

  問題描述:給定一個非負整數的集合S,一個值M,問S中是否有一個子集,子集和等於M。
  例如:S[] = {6, 2, 9, 8, 3, 7}, M = 5,存在一個子集{2, 3},子集和等於5。
  用暴力法求解,即檢查所有的子集。子集共有有\(2^n\)個,為什么?用二進制幫忙理解:一個元素被選中,標記為1;沒有選中,標記為0;空集是n個0,所有元素都被選中是n個1,從n個0到n個1,共有\(2^n\)個。
  用DP求解,定義二維數組 \(dp\)。當\(dp[i][j]\)等於1時,表示S的前\(i\)個元素存在一個子集和等於\(j\)。題目的答案就是dp[n][M]。
  用S[1]~S[n]記錄集合S的n個元素。
  狀態轉移方程,分析如下:
  (1)若S[i] > j,則S[i]不能放在子集中,有\(dp[i][j] = dp[i-1][j]\)
  (2)若S[i] <= j, 有兩種選擇:
  不把S[i]放在子集中,則\(dp[i][j] = dp[i-1][j]\)
  把S[i]放在子集中,則\(dp[i][j]= dp[i-1][j-S[i]]\)
  這2種選擇,只要其中一個為1,那么\(dp[i][j]\)就為1。
  讀者可以用下面的圖例進行驗證。

圖9 子集和問題的dp矩陣

  如果已經確定問題有解,即\(dp[n][M]=1\),如何輸出子集內的元素?按推導轉移方程的思路,從\(dp[n][M]\)開始,沿着\(dp\)矩陣倒推回去即可。

2.9 最優游戲策略(Optimal Strategy for a Game)

  問題描述:有\(n\)堆硬幣排成一行,它們的價值分別是\(v_1, v_2, ..., v_n\)\(n\)為偶數;兩人交替拿硬幣,每次只能在剩下的硬幣中,拿走第一堆或最后一堆硬幣。如果你是先手,你能拿到的最大價值是多少?
  例如:{8, 15, 3, 7},先手這樣拿可以獲勝:(1)先手拿7;(2)對手拿8;(3)先手拿15;(4)對手拿3,結束。先手拿到的最大價值是7 + 15 = 22。
  這一題不能用貪心法。比如在樣例中,如果先手第一次拿8,那么對手接下來肯定拿15,先手失敗。
  定義二維\(dp\)\(dp[i][j]\)表示從第\(i\)堆到\(j\)堆硬幣區間內,先手能拿到的最大值。
  在硬幣區間\([i, j]\),先手有兩個選擇:
  1)拿\(i\)。接着對手也有2個選擇,拿\(i+1\)\(j\):拿\(i+1\),剩下\([i+2, j]\);拿\(j\),剩下\([i+1, j-1]\)。在這2個選擇中,對手必然選那個對先手不利的。
  2)拿\(j\)。接着對手也有2個選擇,拿\(i\)\(j-1\):拿\(i\),剩下\([i+1, j-1]\);拿\(j-1\),剩下\([i, j-2]\)
  得到dp轉移方程[2]
    \(dp[i][j]=Max(V[i]+min(dp[i+2][j],dp[i+1][j-1]), V[j]+min(dp[i+1][j-1], dp[i][j-2]))\)
    \(dp[i][j] = V[i]\)         if \(j == i\)
    \(dp[i][j] = max(V[i], V[j])\)  if \(j == i+1\)

2.10 矩陣鏈乘法(Matrix Chain Multiplication)

  背景知識
  (1)矩陣乘法。如果矩陣A、B能相乘,那么A的列數等於B的行數。設A是m行n列(記為m×n),B是n×u,那么乘積AB的行和列是m×u的,矩陣乘法AB需要做\(m×n×u\)次乘法計算。(注意本小節的"×"符號有2個意思,分別表示矩陣和乘法。)
  (2)矩陣乘法的結合律:\((AB)C = A(BC)\)。括號體現了計算的先后順序。
  (3)在不同的括號下,矩陣乘法需要的乘法操作次數不同。以矩陣A、B、C的乘法為例,設A的行和列是m×n的,B是n×u,C是u×v,下面的兩種計算方法,需要的乘法次數分別是:
     \((AB)C\),計算次數是 \(m×n×u + m×u×v\)
     \(A(BC)\),計算次數是 \(m×n×v + n×u×v\)
  兩者的差是\(|m×n×(u-v)+u×v×(m-n)|\),它可能是一個巨大的值。如果能知道哪一個括號方案是最優的,就能夠大大減少計算量。
  矩陣鏈乘法問題:給定一個數組\(P[]\),其中\(p[i-1]×p[i]\)表示矩陣\(A_i\),輸出最少的乘法次數,並輸出此時的括號方案。
  例如\(p[]\) = {40, 20, 30, 10, 30},它表示4個矩陣:40×20,20×30,30×10,10×30。4個矩陣相乘,當括號方案是\((A(BC))D\)時,有最少乘法次數26000。
  如果讀者學過區間DP,就會發現這是一個典型的區間DP問題。設鏈乘的矩陣是\(A_iA_{i+1}…A_j\),即區間\([i, j]\),那么按結合率,可以把它分成2個子區間\([i, k]、[k+1,j]\),分別鏈乘,有:
    \(A_iA_{i+1}…A_j = (A_i...A_k)(A_{k+1}...A_j)\)
  必定有一個\(k\),使得乘法次數最少,記這個\(k\)\(k_{i,j}\)。並且記\(A_{i,j}\)為此時\(A_iA_{i+1}…A_j\)通過加括號后得到的一個最優方案,它被\(k_{i,j}\)分開。
  那么子鏈\(A_iA_{i+1}…A_k\)的方案\(A_{i,k}\)、子鏈\(A_{k+1}A_{k+2}…A_j\)的方案\(A_{k+1, j}\)也都是最優括號子方案。
  這樣就形成了遞推關系:
    \(A_{i,j} = min\{A_{i,k} + A_{k+1,j} + p_{i-1}p_kp_j\}\)
  用二維矩陣\(dp[i][j]\)來表示\(A_{i,j}\),得到轉移方程為:
$$dp[i][j]= \begin{cases} 0, & \text {i=j} \ { min{dp[i][k]+dp[k+1][j]+p[i-1]p[k]p[j]} }, & \text{i≤k<j} \end{cases}$$

  \(dp[1][n]\)就是答案,即最少乘法次數。
  \(dp[i][j]\)的編碼實現,可以套用區間DP模板,遍歷\(i、j、k\),復雜度是\(O(n^3)\)
  區間DP常常可以用四邊形不等式優化,但是這一題不行,因為它不符合四邊形不等式優化所需要的單調性條件。
  習題: poj 1651 Multiplication Puzzle http://poj.org/problem?id=1651


  1. 問題列表來自:https://www.geeksforgeeks.org/top-20-dynamic-programming-interview-questions/ ↩︎

  2. 還有一種DP方案,參考:https://www.geeksforgeeks.org/optimal-strategy-for-a-game-set-2/ ↩︎


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM