白話算法之【動態規劃入門】

什麼是動態規劃?

        動態規劃(Dynamic Programming,所以我們簡稱動態規劃爲DP)運籌學的一個分支,是求解決策過程(decision process)最優化的數學方法。20世紀50年代初美國數學家R.E.Bellman等人在研究多階段決策過程(multistep decision process)的優化問題時,提出了著名的最優化原理(principle of optimality),把多階段過程轉化爲一系列單階段問題,利用各階段之間的關係,逐個求解,創立了解決這類過程優化問題的新方法——動態規劃。1957年出版了他的名著《Dynamic Programming》,這是該領域的第一本著作。

       動態規劃算法通常基於一個遞推公式及一個或多個初始狀態。當前子問題的解將由上一次子問題的解推出。使用動態規劃來解題只需要多項式時間複雜度,因此它比回溯法、暴力法等要快許多。

       說了這麼多術語,想必大家都很頭疼,現在讓我們通過一個例子來了解一下DP的基本原理。

首先,我們要找到某個狀態的最優解,然後在它的幫助下,找到下一個狀態的最優解。這句話暫時理解不了沒關係,請看下面的例子:

如果我們有面值爲1元、3元和5元的硬幣若干枚,如何用最少的硬幣湊夠11元? 

我們憑直觀感覺告訴自己,先選面值最大,因此最多選25元的硬幣,現在是10元了,還差一元,接下來我們挑選第二大的3元硬幣,發現不行(10+3=13超了),因此我們繼續選第三大的硬幣也就是1元硬幣,選一個就可以(10+1=11),所以總共用了3枚硬幣湊夠了11元。這就是貪心法,每次選最大的。但是我們將面值改爲2元,3元和5元的硬幣,再用貪心法就不行了。爲什麼呢?按照貪心思路,我們同樣先取2枚最大5元硬幣,現在10元了,還差一元,接下來選第二大的,發現不行,再選第三大的,還是不行,這時用貪心方法永遠湊不出11元,但是你仔細看看,其實我們可以湊出11元的,23元硬幣和1枚五元硬幣就行了,這是人經過思考判斷出來了的,但是怎麼讓計算機算出來呢?這就要用動態規劃的思想:

首先我們思考一個問題,如何用最少的硬幣湊夠i(i<11)?爲什麼要這麼問呢?兩個原因:1.當我們遇到一個大問題時,總是習慣把問題的規模變小,這樣便於分析討論。 2.這個規模變小後的問題和原來的問題是同質的,除了規模變小,其它的都是一樣的,本質上它還是同一個問題(規模變小後的問題其實是原問題的子問題)

好了,讓我們從最小的i開始吧。當i=0,即我們需要多少個硬幣來湊夠0元。由於135都大於0,即沒有比0小的幣值,因此湊夠0元我們最少需要0個硬幣。 (這個分析很傻是不是?彆着急,這個思路有利於我們理清動態規劃究竟在做些什麼。這時候我們發現用一個標記來表示這句湊夠0元我們最少需要0個硬幣。會比較方便,如果一直用純文字來表述,不出一會兒你就會覺得很繞了。那麼,我們用d(i)=j來表示湊夠i元最少需要j個硬幣。於是我們已經得到了d(0)=0,表示湊夠0元最小需要0個硬幣。當i=1時,只有面值爲1元的硬幣可用,因此我們拿起一個面值爲1的硬幣,接下來只需要湊夠0元即可,而這個是已經知道答案的,即d(0)=0。所以,d(1)=d(1-1)+1=d(0)+1=0+1=1。當i=2時,仍然只有面值爲1的硬幣可用,於是我拿起一個面值爲1的硬幣,接下來我只需要再湊夠2-1=1元即可(記得要用最小的硬幣數量),而這個答案也已經知道了。所以d(2)=d(2-1)+1=d(1)+1=1+1=2。一直到這裏,你都可能會覺得,好無聊,感覺像做小學生的題目似的。因爲我們一直都只能操作面值爲1的硬幣!耐心點,讓我們看看i=3時的情況。當i=3時,我們能用的硬幣就有兩種了:1元的和3元的( 5元的仍然沒用,因爲你需要湊的數目是3元!5元太多了親)。既然能用的硬幣有兩種,我就有兩種方案。如果我拿了一個1元的硬幣,我的目標就變爲了:湊夠3-1=2元需要的最少硬幣數量。即d(3)=d(3-1)+1=d(2)+1=2+1=3。這個方案說的是,我拿31元的硬幣;第二種方案是我拿起一個3元的硬幣,我的目標就變成:湊夠3-3=0元需要的最少硬幣數量。即d(3)=d(3-3)+1=d(0)+1=0+1=1. 這個方案說的是,我拿13元的硬幣。好了,這兩種方案哪種更優呢?記得我們可是要用最少的硬幣數量來湊夠3元的。所以,選擇d(3)=1,怎麼來的呢?具體是這樣得到的:d(3)=min{d(3-1)+1, d(3-3)+1}

OK,碼了這麼多字講具體的東西,讓我們來點抽象的。從以上的文字中,我們要抽出動態規劃裏非常重要的兩個概念:狀態和狀態轉移方程。

上文中d(i)表示湊夠i元需要的最少硬幣數量,我們將它定義爲該問題的"狀態",這個狀態是怎麼找出來的呢?根據子問題定義狀態。你找到子問題,狀態也就浮出水面了。最終我們要求解的問題,可以用這個狀態來表示:d(11),即湊夠11元最少需要多少個硬幣。那狀態轉移方程是什麼呢?既然我們用d(i)表示狀態,那麼狀態轉移方程自然包含d(i),上文中包含狀態d(i)的方程是:d(3)=min{d(3-1)+1, d(3-3)+1}。沒錯,它就是狀態轉移方程,描述狀態之間是如何轉移的。當然,我們要對它抽象一下,

d(i)=min{ d(i-vj)+1 },其中i-vj >=0vj表示第j個硬幣的面值;

有了狀態和狀態轉移方程,這個問題基本上也就解決了。當然了,Talk is cheap,show me the code!

  1. int main()  
  2. {  
  3.     int a[3] = {1,3,5},sum = 11,cent = 0,dp[12];  
  4.     dp[0] = 0;  
  5.     for(int i = 1; i <= sum; i++) dp[i] = i;//我們假設存在1元的硬幣那麼i元最多只需要i枚1元硬幣,當然最好設置dp[i]等於無窮大  
  6.    
  7.     for(int i = 1; i <= sum; i++){  
  8.         for(int j = 0; j < 3; j++){  
  9.             if(i >= a[j] && dp[i - a[j]] + 1 < dp[i]){  
  10.                 dp[i] = dp[i- a[j] ] + 1;  
  11.             }  
  12.         }  
  13.     }  
  14.     cout<<dp[sum]<<endl;  
  15.     return 0;  
  16. }  


 

下圖是當i011時的解:


 

從上圖可以得出,要湊夠11元至少需要3枚硬幣。

此外,通過追蹤我們是如何從前一個狀態值得到當前狀態值的,可以找到每一次我們用的是什麼面值的硬幣。比如,從上面的圖我們可以看出,最終結果d(11)=d(10)+1(面值爲1),而d(10)=d(5)+1(面值爲5),最後d(5)=d(0)+1 (面值爲5)。所以我們湊夠11元最少需要的3枚硬幣是:1元、5元、5元。

 

    通過硬幣問題我們初識DP的原理,其實可以說貪心問題是DP問題的特例,現在我們通過幾道題目加深對DP問題的理解

數塔問題是動態規劃經典的題目,下面來初步講解下

將一個由N行數字組成的三角形,如圖所以,設計一個算法,計算出三角形的由頂至底的一條路徑,使該路徑經過的數字總和最大。

學弟學妹們你們之前學過DFSBFS,第一眼看過去這題應該用DFS解決,沒錯,DFS也可以,但是我們觀察下n行總共有(1 + 2 + 3 + 4+...+n) = 1+n*n/2個節點,在遞歸求解的過程中很多節點被重複訪問了,這就導致時間大大增加,必然超時

比如用遞歸的話,18這個節點被訪問了兩次

 

但是如果用DP的話這個節點可以只訪問一次

 

好了,現在我們用DP解決這道問題

 

將上圖轉化一下:


假設上圖用map[][]數組保存。

f[i][j]表示從頂點(1, 1)到頂點(i, j)的最大值。

則可以得到狀態轉移方程:

f[i][j] = max(f[i+1][j], f[i+1][j+1]) + map[i][j]

此題既適合自頂而下的方法做,也適合自底而上的方法,

當用自頂而下的方法做時,最後要在在最後一列數中找出最大值,

而用自底而上的方法做時,f[1][1]即爲最大值。

所以我們將圖2根據狀態轉移方程可以得到圖3


最大值是30.

代碼如下:

  1. 1 #include <cstdio>    
  2. 2. #include <iostream>    
  3. 3. #include <algorithm>    
  4. 4. #include <cstring>    
  5. 5. using namespace std;    
  6. 6. int a[2000][2000];    
  7. 7. int main()    
  8. 8. {    
  9. 9.     int t,n,i,j;    
  10. 10.     while(~scanf("%d",&n))    
  11. 11.     {   
  12. 12.         for(i=0; i<n; i++)    
  13. 13.             for(j=0; j<=i; j++)    
  14. 14.                 scanf("%d",&a[i][j]);    
  15. 15.         for(i=n-1; i>0; i--)    
  16. 16.             for(j=0; j<i; j++)    
  17. 17.                 a[i-1][j]+=max(a[i][j],a[i][j+1]);    
  18. 18.         printf("%d\n",a[0][0]);    
  19. 19.     }    
  20. 20.     return 0;    
  21. 21. }    


 

上面討論了兩個非常簡單的例子。現在讓我們來看看對於更復雜的問題,如何找到狀態之間的轉移方式(即找到狀態轉移方程)。爲此我們要引入一個新詞叫遞推關係來將狀態聯繫起來(說的還是狀態轉移方程)

OK,上例子,看看它是如何工作的。

一個序列有N個數:A[1],A[2],…,A[N],求出最長非降子序列的長度。 (DP基本都會講到的一個問題LISlongest increasing subsequence)

正如上面我們講的,面對這樣一個問題,我們首先要定義一個狀態來代表它的子問題,並且找到它的解。注意,大部分情況下,某個狀態只與它前面出現的狀態有關,而獨立於後面的狀態。

讓我們沿用入門一節裏那道簡單題的思路來一步步找到狀態狀態轉移方程。假如我們考慮求A[1],A[2],…,A[i]的最長非降子序列的長度,其中i<N,那麼上面的問題變成了原問題的一個子問題(問題規模變小了,你可以讓i=1,2,3等來分析然後我們定義d(i),表示前i個數中以A[i]結尾的最長非降子序列的長度。OK,對照入門中的簡單題,你應該可以估計到這個d(i)就是我們要找的狀態。如果我們把d(1)d(N)都計算出來,那麼最終我們要找的答案就是這裏面最大的那個。狀態找到了,下一步找出狀態轉移方程。

爲了方便理解我們是如何找到狀態轉移方程的,我先把下面的例子提到前面來講。如果我們要求的這N個數的序列是:

534867

根據上面找到的狀態,我們可以得到:(下文的最長非降子序列都用LIS表示)

· 1個數的LIS長度d(1)=1(序列:5)

· 2個數的LIS長度d(2)=1(序列:33前面沒有比3小的)

· 3個數的LIS長度d(3)=2(序列:344前面有個比它小的3,所以d(3)=d(2)+1)

· 4個數的LIS長度d(4)=3(序列:3488前面比它小的有3個數,所以 d(4)=max{d(1),d(2),d(3)}+1=3)

OK,分析到這,我覺得狀態轉移方程已經很明顯了,如果我們已經求出了d(1)d(i-1),那麼d(i)可以用下面的狀態轉移方程得到:

d(i) = max{1, d(j)+1},其中j<i,A[j]<=A[i]

用大白話解釋就是,想要求d(i),就把i前面的各個子序列中,最後一個數不大於A[i]的序列長度加1,然後取出最大的長度即爲d(i)。當然了,有可能i前面的各個子序列中最後一個數都大於A[i],那麼d(i)=1,即它自身成爲一個長度爲1的子序列。

分析完了,上圖:(第二列表示前i個數中LIS的長度,第三列表示,LIS中到達當前這個數的上一個數的下標,根據這個可以求出LIS序列)


 

代碼:

  1. 1. #include <cstdio>    
  2. 2. #include <iostream>    
  3. 3. #include <algorithm>    
  4. 4. #include <cstring>    
  5. 5. usingnamespace std;    
  6. 6.      
  7. 7. int main()    
  8. 8. {    
  9. 9.     int dp[2000],a[2000],n;    
  10. 10.     while(cin>>n)    
  11. 11.     {    
  12. 12.         memset(dp,0,sizeof(dp));    
  13. 13.         intres = 0;    
  14. 14.         for(inti = 0; i < n; i++) cin>>a[i];    
  15. 15.      
  16. 16.         for(inti = 0; i < n; i++)    
  17. 17.         {    
  18. 18.             dp[i] = 1;    
  19. 19.             for(intj = 0; j < i; j++)    
  20. 20.             {    
  21. 21.                 if(a[j] < a[i])    
  22. 22.                 dp[i] = max(dp[i],dp[j] + 1);    
  23. 23.             }    
  24. 24.      
  25. 25.           res = max(res,dp[i]);    
  26. 26.         }    
  27. 27.      
  28. 28.         cout<<res<<endl;    
  29. 29.     }    
  30. 30.     return0;    
  31. 31. }  


該算法的時間複雜度是O(n2 ),並不是最優的解法。還有一種很巧妙的算法可以將時間複雜度降到O(nlogn),網上已經有各種文章介紹它,這裏就不再贅述。此題還可以用排序+LCS”來解,感興趣的話可自行GoogleBaidu

 

最後講一下最長上升公共子序列問題:

問題描述

    什麼是最長公共子序列呢?好比一個數列S,如果分別是兩個或多個已知數列的子序列,且是所有符合此條件序列中最長的,則S 稱爲已知序列的最長公共子序列。

    舉個例子,如:有兩條隨機序列,如 1 3 4 5 5 and 2 4 5 5 7 6,則它們的最長公共子序列便是:4 5 5

LCS問題的解決思路

· 

窮舉法   

· 

    解最長公共子序列問題時最容易想到的算法是窮舉搜索法,即對X的每一個子序列,檢查它是否也是Y的子序列,從而確定它是否爲XY的公共子序列,並且在檢查過程中選出最長的公共子序列。XY的所有子序列都檢查過後即可求出XY的最長公共子序列。X的一個子序列相應於下標序列{1, 2, …, m}的一個子序列,因此,X共有2m個不同子序列(Y亦如此,如爲2^n),從而窮舉搜索法需要指數時間(2^m * 2^n)。

· 動態規劃算法

    事實上,最長公共子序列問題也有最優子結構性質。

:

Xi=x1xi﹥即X序列的前i個字符 (1≤i≤m)(前綴)

Yj=y1yj﹥即Y序列的前j個字符 (1≤j≤n)(前綴)

假定Z=z1zk∈LCS(X , Y)

· 

xm=yn(最後一個字符相同),則不難用反證法證明:該字符必是XY的任一最長公共子序列Z(設長度爲k)的最後一個字符,即有zk = xm = yn 且顯然有Zk-1∈LCS(Xm-1 , Yn-1)Z的前綴Zk-1Xm-1Yn-1的最長公共子序列。此時,問題化歸成求Xm-1Yn-1LCSLCS(X , Y)的長度等於LCS(Xm-1 , Yn-1)的長度加1)。

· 

· 

xm≠yn,則亦不難用反證法證明:要麼Z∈LCS(Xm-1, Y),要麼Z∈LCS(X , Yn-1)。由於zk≠xmzk≠yn其中至少有一個必成立,若zk≠xm則有Z∈LCS(Xm-1 , Y),類似的,若zk≠yn 則有Z∈LCS(X , Yn-1)。此時,問題化歸成求Xm-1YLCSXYn-1LCSLCS(X , Y)的長度爲:max{LCS(Xm-1 , Y)的長度, LCS(X , Yn-1)的長度}

· 

    由於上述當xm≠yn的情況中,求LCS(Xm-1 , Y)的長度與LCS(X , Yn-1)的長度,這兩個問題不是相互獨立的:兩者都需要求LCS(Xm-1Yn-1)的長度。另外兩個序列的LCS中包含了兩個序列的前綴的LCS,故問題具有最優子結構性質考慮用動態規劃法。

    也就是說,解決這個LCS問題,你要求三個方面的東西:1、LCSXm-1Yn-1+12、LCSXm-1Y),LCSXYn-1);3、max{LCSXm-1Y),LCSXYn-1}

    行文至此,其實對這個LCS的動態規劃解法已敘述殆盡,不過,爲了成書的某種必要性,下面,我試着再多加詳細闡述這個問題。

第三節、動態規劃算法解LCS問題

3.1、最長公共子序列的結構

    最長公共子序列的結構有如下表示:

    設序列X=<x1, x2, …, xm>Y=<y1, y2, …, yn>的一個最長公共子序列Z=<z1, z2, …, zk>,則:

1. xm=yn,則zk=xm=ynZk-1Xm-1Yn-1的最長公共子序列;

2. xm≠ynzk≠xZXm-1Y的最長公共子序列;

3. xm≠ynzk≠yn ,則ZXYn-1的最長公共子序列。

    其中Xm-1=<x1, x2, …, xm-1>Yn-1=<y1, y2, …, yn-1>Zk-1=<z1, z2, …, zk-1>

32.子問題的遞歸結構

    由最長公共子序列問題的最優子結構性質可知,要找出X=<x1, x2, …, xm>Y=<y1, y2, …, yn>的最長公共子序列,可按以下方式遞歸地進行:當xm=yn時,找出Xm-1和Yn-1的最長公共子序列,然後在其尾部加上xm(=yn)即可得XY的一個最長公共子序列。當xm≠yn時,必須解兩個子問題,即找出Xm-1和Y的一個最長公共子序列及XYn-1的一個最長公共子序列。這兩個公共子序列中較長者即爲XY的一個最長公共子序列。

    由此遞歸結構容易看到最長公共子序列問題具有子問題重疊性質。例如,在計算XY的最長公共子序列時,可能要計算出XYn-1及Xm-1和Y的最長公共子序列。而這兩個子問題都包含一個公共子問題,即計算Xm-1和Yn-1的最長公共子序列。

    與矩陣連乘積最優計算次序問題類似,我們來建立子問題的最優值的遞歸關係。用c[i,j]記錄序列Xi和Yj的最長公共子序列的長度。其中Xi=<x1, x2, …, xi>Yj=<y1, y2, …, yj>。當i=0j=0時,空序列是Xi和Yj的最長公共子序列,故c[i,j]=0。其他情況下,由定理可建立遞歸關係如下:

 

代碼如下:

  1. 1. #include <cstdio>    
  2. 2. #include <iostream>    
  3. 3. #include <algorithm>    
  4. 4. #include <cstring>    
  5. 5. using namespace std;    
  6. 6.      
  7. 7. int main()    
  8. 8. {    
  9. 9.     string str1,str2;    
  10. 10.     int dp[200][200];    
  11. 11.     while(cin>>str1>>str2)    
  12. 12.     {    
  13. 13.         memset(dp,0,sizeof(dp));    
  14. 14.      
  15. 15.         int la = str1.length();    
  16. 16.         int lb = str2.length();    
  17. 17.      
  18. 18.         for(int i = 1; i <= la; i++)    
  19. 19.             for(int j = 1; j <= lb; j++)    
  20. 20.         {    
  21. 21.             if(str1[i - 1] == str2[j - 1])    
  22. 22.             {    
  23. 23.                 dp[i][j] = dp[i-1][j-1]+1;    
  24. 24.             }    
  25. 25.             else dp[i][j] = max(dp[i-1][j],dp[i][j-1]);    
  26. 26.         }    
  27. 27.         cout<<dp[la][lb]<<endl;    
  28. 28.     }    
  29. 29.     return 0;  


 

講到這想必對DP問題有一個大概的認識了吧?乘熱打鐵,我們去HDU刷幾道簡單題練練手感!

 

HDU2191

HDU1159

HDU1432

HDU2084

DP問題是ACM裏面最難的,因爲太考思維能力了,只有將狀態轉移方程推出來才能解決問題,DP問題也是面試的時候最容易考到的,希望大家好好學DP,至少在面試的時候不吃虧。

DP問題還有比較難的,分爲數字DP,插頭DP,狀態壓縮DP,概率DP,組合DP,樹狀DP等等都是非常難理解的,但是也很有趣,有興趣的可以找資料學習


最近李建中老師的算法課上完了,他的課大部分都是在講算法的數學證明,這對算法本身的理解並沒有花大手筆,於是我結合自己ACM一年多的算法經驗和一堆收集的資料,準備寫專題【白話數據結構和算法】,大家多多關注,一起加油~~~~

發佈了40 篇原創文章 · 獲贊 127 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章