動態規劃入門

學動態規劃自然要從數字三角形開始起步,那麼我們就先從數字三角形開始。


數字三角形題目:有一個由非負整數組成的三角形,第一行只有一個數,除了最下行之外的每個數的左下方和右下方各有一個數,如下圖所示:

1

3 2

4 10 1

4 3 2 20

從第一行的數開始,每次可以往下或往右下走一格,直到走到最下行,把沿途經過的數全部加起來。如何走才能使這個和最大?


知道回溯法麼(請參看:八皇后與回溯法),你會發現這是一個動態的決策問題:每次有兩種選擇——向左或是向右,每一步決策又影響到後面的決策,如果用貪心,你會發現由局部最優解得不到全局最優解,所以本題要麼用回溯法(但是回溯法的效率太低,特別是基數大的時候,讓人無法忍受),要麼就是用動態規劃。

我們用d(i,j)表示從格子(i,j)出發到達底層能取得的最大和,用a(i,j)表示當前格子的權值,從格子(i,j)出發有兩種決策,如果往左走,則到(i+1,j)後需要求從(i+1,j)往後走能得到的最大和,即d(i+1,j);如果往右走,則需要求從d(i+1,j+1)開始出發能取得的最大和,即d(i+1,j+1)。由於可以在這兩個決策中自由選擇,所以應該選擇d(i+1,j)和d(i+1,j+1)中較大的一個。至此,我們得出了動態規劃解題中最重要的狀態轉移方程:

d(i,j)=a(i,j)+  max(d(i+1,j),d(i+1,j+1))

如果往左走,那麼最好的情況等於(i,j)格子裏的值a(i,j)與d(i+1,j)之和,如果從格子(i+1,j)出發到底層這部分的和都不是左右決策中最大的,那麼加上a(i,j)之後的值也不會是最大的。這個性質稱之爲最優子結構,也可以描述成“全局最優解包含局部最優解”。


那麼我們看第一種方法(遞歸法)

  1. #include<iostream>  
  2. #define MAXN 100  
  3. using namespace std;  
  4. int a[MAXN][MAXN],n;  
  5. int d(int i,int j)  
  6. {  
  7.     if(i==n)  
  8.         return a[i][j];  
  9.     else  
  10.         return a[i][j] += max(d(i+1,j),d(i+1,j+1));<span style=“white-space:pre”> </span>//遞歸調用取左右決策中的最大值  
  11. }  
  12. int main()  
  13. {  
  14.     cin>>n;  
  15.     for(int i(1);i<=n;i++)  
  16.         for(int j(1);j<=i;j++)  
  17.             cin>>a[i][j];  
  18.     cout<<d(1,1)<<endl;  
  19.     return 0;  
  20. }  
#include<iostream>




#define MAXN 100 using namespace std; int a[MAXN][MAXN],n; int d(int i,int j) { if(i==n) return a[i][j]; else return a[i][j] += max(d(i+1,j),d(i+1,j+1));<span style="white-space:pre"> </span>//遞歸調用取左右決策中的最大值 } int main() { cin>>n; for(int i(1);i<=n;i++) for(int j(1);j<=i;j++) cin>>a[i][j]; cout<<d(1,1)<<endl; return 0; }

這種方法簡單易懂,但是效率太低,因爲包含了大量的重複計算。


第二種方法(遞推法)

  1. #include<iostream>  
  2. #define MAXN 100  
  3. using namespace std;  
  4. int a[MAXN][MAXN],d[MAXN][MAXN];  
  5. int main()  
  6. {  
  7.     int n;  
  8.     cin>>n;  
  9.     for(int i(1);i<=n;i++)  
  10.         for(int j(1);j<=i;j++)  
  11.             cin>>a[i][j];  
  12.     for(int i(1);i<=n;i++)  
  13.         d[n][i]=a[n][i];<span style=”white-space:pre”>    </span>//邊界處理  
  14.     for(int i(n-1);i>=1;i–)  
  15.         for(int j(1);j<=i;j++)  
  16.             d[i][j]=a[i][j]+max(d[i+1][j],d[i+1][j+1]);  
  17.     cout<<d[1][1]<<endl;  
  18.     return 0;  
  19. }  
#include<iostream>




#define MAXN 100 using namespace std; int a[MAXN][MAXN],d[MAXN][MAXN]; int main() { int n; cin>>n; for(int i(1);i<=n;i++) for(int j(1);j<=i;j++) cin>>a[i][j]; for(int i(1);i<=n;i++) d[n][i]=a[n][i];<span style="white-space:pre"> </span>//邊界處理 for(int i(n-1);i>=1;i--) for(int j(1);j<=i;j++) d[i][j]=a[i][j]+max(d[i+1][j],d[i+1][j+1]); cout<<d[1][1]<<endl; return 0; } 遞推法的時間複雜度是O(N^2)。


爲了提高程序運行效率,我們考慮把計算過的數據存儲下來,這是典型的空間換時間算法

第三種方法(記憶化搜索&遞歸)

  1. #include<iostream>  
  2. #define MAXN 100  
  3. using namespace std;  
  4. int a[MAXN][MAXN],d[MAXN][MAXN],n;  
  5. int memy(int i,int j)  
  6. {  
  7.     if(d[i][j]>0) return d[i][j];<span style=“white-space:pre”>    </span>//如果計算過d[i][j],直接返回d[i][j]  
  8.     else  
  9.     {  
  10.         if(i==n) return a[i][j];<span style=“white-space:pre”>    </span>  
  11.         else return d[i][j]=a[i][j]+max(memy(i+1,j),memy(i+1,j+1));  
  12.     }  
  13. }  
  14. int main()  
  15. {  
  16.     cin>>n;  
  17.     for(int i(1);i<=n;i++)  
  18.         for(int j(1);j<=i;j++)  
  19.             cin>>a[i][j];  
  20.     memset(d,0,sizeof(d));<span style=“white-space:pre”>  </span>//把數組d全部初始化爲-1,以便在遞歸中判斷是否被計算過  
  21.     cout<<memy(1,1)<<endl;  
  22.     return 0;  
  23. }  
#include<iostream>




#define MAXN 100 using namespace std; int a[MAXN][MAXN],d[MAXN][MAXN],n; int memy(int i,int j) { if(d[i][j]>0) return d[i][j];<span style="white-space:pre"> </span>//如果計算過d[i][j],直接返回d[i][j] else { if(i==n) return a[i][j];<span style="white-space:pre"> </span> else return d[i][j]=a[i][j]+max(memy(i+1,j),memy(i+1,j+1)); } } int main() { cin>>n; for(int i(1);i<=n;i++) for(int j(1);j<=i;j++) cin>>a[i][j]; memset(d,0,sizeof(d));<span style="white-space:pre"> </span>//把數組d全部初始化爲-1,以便在遞歸中判斷是否被計算過 cout<<memy(1,1)<<endl; return 0; } 這樣可以保證每個結點只訪問一次。

知道了數字三角形之後,我們來看看另一個經典問題——01揹包

但是一開始我們不看01揹包,先看一個類似問題的生動形象的故事(轉自別人的blog,我認爲寫得非常好,對DP入門有很大幫助)。

<1>初始動態規劃

有一個國家,所有的國民都非常老實憨厚,某天他們在自己的國家發現了十座金礦,並且這十座金礦在地圖上排成一條直線,國王知道這個消息後非常高興,他希望能夠把這些金子都挖出來造福國民,首先他把這些金礦按照在地圖上的位置從西至東進行編號,依次爲0、1、2、3、4、5、6、7、8、9,然後他命令他的手下去對每一座金礦進行勘測,以便知道挖取每一座金礦需要多少人力以及每座金礦能夠挖出多少金子,然後動員國民都來挖金子。

 

       題目補充1:挖每一座金礦需要的人數是固定的,多一個人少一個人都不行。國王知道每個金礦各需要多少人手,金礦i需要的人數爲peopleNeeded[i]。

       題目補充2:每一座金礦所挖出來的金子數是固定的,當第i座金礦有peopleNeeded[i]人去挖的話,就一定能恰好挖出gold[i]個金子。否則一個金子都挖不出來。

       題目補充3:開採一座金礦的人完成開採工作後,他們不會再次去開採其它金礦,因此一個人最多隻能使用一次。

       題目補充4:國王在全國範圍內僅招募到了10000名願意爲了國家去挖金子的人,因此這些人可能不夠把所有的金子都挖出來,但是國王希望挖到的金子越多越好。

       題目補充5:這個國家的每一個人都很老實(包括國王),不會私吞任何金子,也不會弄虛作假,不會說謊話。

       題目補充6:有很多人拿到這個題後的第一反應就是對每一個金礦求出平均每個人能挖出多少金子,然後從高到低進行選擇,這裏要強調這種方法是錯的,如果你也是這樣想的,請考慮揹包模型,當有一個揹包的容量爲10,共有3個物品,體積分別是3、3、5,價值分別是6、6、9,那麼你的方法取到的是前兩個物品,總價值是12,但明顯最大值是後兩個物品組成的15。

       題目補充7:我們只需要知道最多可以挖出多少金子即可,而不用關心哪些金礦挖哪些金礦不挖。

 

       那麼,國王究竟如何知道在只有10000個人的情況下最多能挖出多少金子呢?國王是如何思考這個問題的呢?

 

       國王首先來到了第9個金礦的所在地(注意,第9個就是最後一個,因爲是從0開始編號的,最西邊的那個金礦是第0個),他的臣子告訴他,如果要挖取第9個金礦的話就需要1500個人,並且第9個金礦可以挖出8888個金子。聽到這裏國王哈哈大笑起來,因爲原先他以爲要知道十個金礦在僅有10000個人的情況下最多能挖出多少金子是一件很難思考的問題,但是,就在剛纔聽完他的臣子所說的那句話時,國王已經知道總共最多能挖出多少金子了,國王是如何在不瞭解其它金礦的情況下知道最多能挖出多少金子的呢?他的臣子們也不知道這個謎,因此他的臣子們就問他了:“最聰明的國王陛下,我們都沒有告訴您其它金礦的情況,您是如何知道最終答案的呢?”

 

       得意的國王笑了笑,然後把他最得意的“左、右手”叫到跟前,說到:“我並不需要考慮最終要挖哪些金礦才能得到最多的金子,我只需要考慮我面前的這座金礦就可以了,對於我面前的這座金礦不外乎僅有兩種選擇,要麼挖,要麼不挖,對吧?”

 

       “當然,當然”大臣們回答倒。

      

       國王繼續說道:“如果我挖取第9座金礦的話那麼我現在就能獲得8888個金子,而我將用去1500個人,那麼我還剩下8500個人。我親愛的左部下,如果你告訴我當我把所有剩下的8500個人和所有剩下的其它金礦都交給你去開採你最多能給我挖出多少金子的話,那麼我不就知道了在第9個金礦一定開採的情況下所能得到的最大金幣數嗎?”

      

       國王的左部下聽後回答道:“國王陛下,您的意思是如果我能用8500個人在其它金礦最多開採出x個金幣的話,那您一共就能夠獲得 x + 8888個金子,對嗎?”

      

       “是啊,是啊……如果第9座金礦一定開採的話……”大臣們點頭說到。

      

       國王笑着繼續對着他的右部下說到:“親愛的右部下,也許我並不打算開採這第9座金礦,那麼我依然擁有10000個人,如果我把這10000個人和剩下的金礦都給你的話,你最多能給我挖出多少個金子呢?”

      

       國王的右部下聰明地說道:“尊敬的國王陛下,我明白您的意思了,如果我回答最多能購開採出y個金幣的話,那您就可以在y和x+8888之間選擇一個較大者,而這個較大者就是最終我們能獲得的最大金幣數,您看我這樣理解對嗎?”

      

       國王笑得更燦爛了,問他的左部下:“那麼親愛的左部下,我給你8500個人和其餘金礦的話你能告訴我最多能挖出多少金子嗎?”

 

       “請您放心,這個問題難不倒我”。左部下向國王打包票說到。

 

       國王高興地繼續問他的右部下:“那右部下你呢,如果我給你10000個人和其餘金礦的話你能告訴我最多能挖出多少金子嗎?”

 

       “當然能了!交給我吧!”右部下同左部下一樣自信地回答道。

 

       “那就拜託給你們兩位了,現在我要回到我那舒適的王宮裏去享受了,我期待着你們的答覆。”國王說完就開始動身回去等消息了,他是多麼地相信他的兩個大臣能夠給他一個準確的答覆,因爲國王其實知道他的兩位大臣要比他聰明得多。

 

       故事發展到這裏,你是否在想國王的這兩個大臣又是如何找到讓國王滿意的答案的呢?他們爲什麼能夠如此自信呢?事實上他們的確比國王要聰明一些,因爲他們從國王的身上學到了一點,就是這一點讓他們充滿了自信。

 

       國王走後,國王的左、右部下來到了第8座金礦,早已在那裏等待他們的金礦勘測兵向兩位大臣報道:“聰明的兩位大臣,您們好,第8座金礦需要1000個人才能開採,可以獲得7000個金子”。

 

       因爲國王僅給他的左部下8500個人,所以國王的左部下叫來了兩個人,對着其中一個人問到:“如果我給你7500個人和除了第8、第9的其它所有金礦的話,你能告訴我你最多能挖出多少金子嗎?”

 

       然後國王的左部下繼續問另一個人:“如果我給你8500個人和除了第8、第9的其它所有金礦的話,你能告訴我你最多能挖出多少金子嗎?”

 

       國王的左部下在心裏想着:“如果他們倆都能回答我的問題的話,那國王交給我的問題不就解決了嗎?哈哈哈!”

 

       因爲國王給了他的右部下10000個人,所以國王的右部下同樣也叫來了兩個人,對着其中一個人問:“如果我給你9000個人和除了第8、第9的其它所有金礦的話,你能告訴我你最多能挖出多少金子嗎?”

 

       然後國王的右部下繼續問他叫來的另一個人:“如果我給你10000個人和除了第8、第9的其它所有金礦的話,你能告訴我你最多能挖出多少金子嗎?”

 

       此時,國王的右部下同左部下一樣,他們都在爲自己如此聰明而感到滿足。

      

       當然,這四個被叫來的人同樣自信地回答沒有問題,因爲他們同樣地從這兩個大臣身上學到了相同的一點,而兩位自認爲自己一樣很聰明的大臣得意地笑着回到了他們的府邸,等着別人回答他們提出來的問題,現在你知道了這兩個大臣是如何解決國王交待給他們的問題了嗎?

 

       那麼你認爲被大臣叫去的那四個人又是怎麼完成大臣交給他們的問題的呢?答案當然是他們找到了另外八個人!

 

       沒用多少功夫,這個問題已經在全國傳開了,更多人的人找到了更更多的人來解決這個問題,而有些人卻不需要去另外找兩個人幫他,哪些人不需要別人的幫助就可以回答他們的問題呢?

 

       很明顯,當被問到給你z個人和僅有第0座金礦時最多能挖出多少金子時,就不需要別人的幫助,因爲你知道,如果z大於等於挖取第0座金礦所需要的人數的話,那麼挖出來的最多金子數就是第0座金礦能夠挖出來的金子數,如果這z個人不夠開採第0座金礦,那麼能挖出來的最多金子數就是0,因爲這唯一的金礦不夠人力去開採。讓我們爲這些不需要別人的幫助就可以準確地得出答案的人們鼓掌吧,這就是傳說中的底層勞動人民!

 

       故事講到這裏先暫停一下,我們現在重新來分析一下這個故事,讓我們對動態規劃有個理性認識。

 

       子問題的概念:

       國王需要根據兩個大臣的答案以及第9座金礦的信息才能判斷出最多能夠開採出多少金子。爲了解決自己面臨的問題,他需要給別人製造另外兩個問題,這兩個問題就是子問題。

 

       思考動態規劃的第一點—-最優子結構:

       國王相信,只要他的兩個大臣能夠回答出正確的答案(對於考慮能夠開採出的金子數,最多的也就是最優的同時也就是正確的),再加上他的聰明的判斷就一定能得到最終的正確答案。我們把這種子問題最優時母問題通過優化選擇後一定最優的情況叫做“最優子結構”。

 

       思考動態規劃的第二點—-子問題重疊:

       實際上國王也好,大臣也好,所有人面對的都是同樣的問題,即給你一定數量的人,給你一定數量的金礦,讓你求出能夠開採出來的最多金子數。我們把這種母問題與子問題本質上是同一個問題的情況稱爲“子問題重疊”。然而問題中出現的不同點往往就是被子問題之間傳遞的參數,比如這裏的人數和金礦數。

      

       思考動態規劃的第三點—-邊界:

       想想如果不存在前面我們提到的那些底層勞動者的話這個問題能解決嗎?永遠都不可能!我們把這種子問題在一定時候就不再需要提出子子問題的情況叫做邊界,沒有邊界就會出現死循環。

 

       思考動態規劃的第四點—-子問題獨立:

       要知道,當國王的兩個大臣在思考他們自己的問題時他們是不會關心對方是如何計算怎樣開採金礦的,因爲他們知道,國王只會選擇兩個人中的一個作爲最後方案,另一個人的方案並不會得到實施,因此一個人的決定對另一個人的決定是沒有影響的。我們把這種一個母問題在對子問題選擇時,當前被選擇的子問題兩兩互不影響的情況叫做“子問題獨立”。

 

       這就是動態規劃,具有“最優子結構”、“子問題重疊”、“邊界”和“子問題獨立”,當你發現你正在思考的問題具備這四個性質的話,那麼恭喜你,你基本上已經找到了動態規劃的方法。

 

       有了上面的這幾點,我們就可以寫出動態規劃的轉移方程式,現在我們來寫出對應這個問題的方程式,如果用gold[mineNum]表示第mineNum個金礦能夠挖出的金子數,用peopleNeeded[mineNum]表示挖第mineNum個金礦需要的人數,用函數f(people,mineNum)表示當有people個人和編號爲0、1、2、3、……、mineNum的金礦時能夠得到的最大金子數的話,f(people,mineNum)等於什麼呢?或者說f(people,mineNum)的轉移方程是怎樣的呢?

 

       答案是:

  當mineNum = 0且people >= peopleNeeded[mineNum]時 f(people,mineNum) = gold[mineNum]

        當mineNum = 0且people < peopleNeeded[mineNum]時 f(people,mineNum) = 0

      當mineNum != 0時 f(people,mineNum) = f(people-peopleNeeded[mineNum], mineNum-1) + gold[mineNum]與f(people, mineNum-1)中的較大者,前兩個式子對應動態規劃的“邊界”,後一個式子對應動態規劃的“最優子結構”請讀者弄明白後再繼續往下看。



<2>動態規劃的優點

現在我假設讀者你已經搞清楚了爲什麼動態規劃是正確的方法,但是我們爲什麼需要使用動態規劃呢?請先繼續欣賞這個故事:

 

       國王得知他的兩個手下使用了和他相同的方法去解決交代給他們的問題後,不但沒有認爲他的兩個大臣在偷懶,反而很高興,因爲他知道,他的大臣必然會找更多的人一起解決這個問題,而更多的人會找更更多的人,這樣他這個聰明的方法就會在不經意間流傳開來,而全國人民都會知道這個聰明的方法是他們偉大的國王想出來的,你說國王能不高興嗎?

 

       但是國王也有一些擔憂,因爲他實在不知道這個“工程”要動用到多少人來完成,如果幫助他解決這個問題的人太多的話那麼就太勞民傷財了。“會不會影響到今年的收成呢?”國王在心裏想着這個問題,於是他請來了整個國家裏唯一的兩個數學天才,一個叫做小天,另一個叫做小才。

 

       國王問小天:“小天啊,我發覺這個問題有點嚴重,我知道其實這可以簡單的看成一個組合問題,也就是從十個金礦中選取若干個金礦進行開採,看看哪種組合得到的金子最多,也許用組合方法會更好一些。你能告訴我一共有多少種組合情況嗎?”

 

       “國王陛下,如果用組合方法的話一共要考慮2的10次方種情況,也就是1024種情況。”小天思考了一會回答到。

 

       “嗯……,如果每一種情況我交給一個人去計算能得到的金子數的話,那我也要1024個人,其實還是挺多的。”國王好像再次感覺到了自己的方法是正確的。

 

       國王心理期待着小才能夠給它一個更好的答案,問到:“小才啊,那麼你能告訴我用我的那個方法總共需要多少人嗎?其實,我也計算過,好像需要的人數是1+2+4+8+16+32+64+……,畢竟每一個人的確都需要找另外兩個人來幫助他們……”

 

       不辜負國王的期待,小才微笑着說到:“親愛的國王陛下,其實我們並不需要那麼多人,因爲有很多問題其實是相同的,而我們只需要爲每一個不同的問題使用一個人力便可。”

 

       國王高興的問到:“此話如何講?”

 

       “打個比方,如果有一個人需要知道1000個人和3個金礦可以開採出多少金子,同時另一個人也需要知道1000個人和3個金礦可以開採出多少金子的話,那麼他們可以去詢問相同的一個人,而不用各自找不同的人浪費人力了。”

      

       國王思考着說到:“嗯,很有道理,如果問題是一樣的話那麼就不需要去詢問兩個不同的人了,也就是說一個不同的問題僅需要一個人力,那麼一共有多少個不同的問題呢?”   

 

       “因爲每個問題的人數可以從0取到10000,而金礦數可以從0取到10,所以最多大約有10000 * 10 等於100000個不同的問題。” 小才一邊算着一邊回答。

 

       “什麼?十萬個問題?十萬個人力?”國王有點失望。

 

       “請國王放心,事實上我們需要的人力遠遠小於這個數的,因爲不是每一個問題都會遇到,也許我們僅需要一、兩百個人力就可以解決這個問題了,這主要和各個金礦所需要的人數有關。” 小才立刻回答到。

 

       故事的最後,自然是國王再一次向他的臣民們證明了他是這個國家裏最聰明的人,現在我們通過故事的第二部分來考慮動態規劃的另外兩個思考點。

 

       思考動態規劃的第五點—-做備忘錄:

       正如上面所說的一樣,當我們遇到相同的問題時,我們可以問同一個人。講的通俗一點就是,我們可以把問題的解放在一個變量中,如果再次遇到這個問題就直接從變量中獲得答案,因此每一個問題僅會計算一遍,如果不做備忘的話,動態規劃就沒有任何優勢可言了。             

 

       思考動態規劃的第六點—-時間分析:

       正如上面所說,如果我們用窮舉的方法,至少需要2^n個常數時間,因爲總共有2^n種情況需要考慮,如果在揹包問題中,包的容量爲1000,物品數爲100,那麼需要考慮2^100種情況,這個數大約爲10的30次方。

 

       而如果用動態規劃,最多大概只有1000*100 = 100000個不同的問題,這和10的30次方比起來優勢是很明顯的。而實際情況並不會出現那麼多不同的問題,比如在金礦模型中,如果所有的金礦所需人口都是1000個人,那麼問題總數大約只有100個。

 

       非正式地,我們可以很容易得到動態規劃所需時間,如果共有questionCount個相同的子問題,而每一個問題需要面對chooseCount種選擇時,我們所需時間就爲questionCount * chooseCount個常數。在金礦模型中,子問題最多有大概people * n 個(其中people是用於開採金礦的總人數,n是金礦的總數),因此questionCount = people * n,而就像國王需要考慮是採用左部下的結果還是採用右部下的結果一樣,每個問題面對兩個選擇,因此chooseCount = 2,所以程序運行時間爲 T = O(questionCount * chooseCount) =O(people * n),別忘了實際上需要的時間小於這個值,根據所遇到的具體情況有所不同。

 

       這就是動態規劃的魔力,它減少了大量的計算,因此我們需要動態規劃!

 


<3>動態規劃的思考

那麼什麼是動態規劃呢?我個人覺得,如果一個解決問題的方法滿足上面六個思考點中的前四個,那麼這個方法就屬於動態規劃。而在思考動態規劃方法時,後兩點同樣也是需要考慮的。

 

       面對問題要尋找動態規劃的方法,首先要清楚一點,動態規劃不是算法,它是一種方法,它是在一件事情發生的過程中尋找最優值的方法,因此,我們需要對這件事情所發生的過程進行考慮。而通常我們從過程的最後一步開始考慮,而不是先考慮過程的開始。

 

       打個比方,上面的挖金礦問題,我們可以認爲整個開採過程是從西至東進行開採的(也就是從第0座開始),那麼總有面對最後一座金礦的時候(第9座),對這座金礦不外乎兩個選擇,開採與不開採,在最後一步確定時再去確定倒數第二步,直到考慮第0座金礦(過程的開始)。

 

       而過程的開始,也就是考慮的最後一步,就是邊界。

 

       因此在遇到一個問題想用動態規劃的方法去解決時,不妨先思考一下這個過程是怎樣的,然後考慮過程的最後一步是如何選擇的,通常我們需要自己去構造一個過程,比如後面的練習。



<4>動態規劃解題思路

 那麼遇到問題如何用動態規劃去解決呢?根據上面的分析我們可以按照下面的步驟去考慮:

 

       1、構造問題所對應的過程。

       2、思考過程的最後一個步驟,看看有哪些選擇情況。

       3、找到最後一步的子問題,確保符合“子問題重疊”,把子問題中不相同的地方設置爲參數。

       4、使得子問題符合“最優子結構”。

       5、找到邊界,考慮邊界的各種處理方式。

       6、確保滿足“子問題獨立”,一般而言,如果我們是在多個子問題中選擇一個作爲實施方案,而不會同時實施多個方案,那麼子問題就是獨立的。

       7、考慮如何做備忘錄。

       8、分析所需時間是否滿足要求。

       9、寫出轉移方程式。


至此,應該對動態規劃有個全局的直觀的瞭解。動態規劃問題一般都包括四點,最優子結構,子問題重疊,邊界和子問題獨立,而解決動態規劃問題的最關鍵要素就是找到狀態轉移方程,如數字三角形的狀態轉移方程就是d(i,j)= a(i,j)+ max(d(i+1,j),d(i+1,j+1)),有了狀態轉移方程我們就可以很輕鬆的寫出程序。

下面我們看看01揹包問題:有n種物品,每種只有一個。第i種物品的體積爲Vi,重量爲Wi。選一些物品裝到一個容量爲C的揹包,使得揹包中的物品在總體積不超過C的前提下重量儘量大。1<=n<=100,1<=Vi<=C<=10000,1<=Wi<=10^6。


這又是一個多階段決策問題,每一次決策都影響後面的決策,所以不能用常規的貪心算法去做,只能用我們的動態規劃,我們這麼定義,用f(i,j)表示把前i個物品裝到容量爲j的揹包中的最優解,所以我們可以得出狀態轉移方程爲f(i,j)= max(f(i - 1,j),f(i - 1,j - Vi)+ Wi),別忘了定義邊界,當i=0時爲0,j<0時爲負無窮,最終的最優解就是f(n,C),我們給出代碼。

  1. #include<iostream>  
  2. #define MAXN 110  
  3. using namespace std;  
  4. int main()  
  5. {  
  6.     int C,n,V[MAXN],W[MAXN],f[MAXN][MAXN];  
  7.     cin>>C;  
  8.     cin>>n;  
  9.     for(int i(1);i<=n;i++)  
  10.         cin>>V[i];  
  11.     for(int i(1);i<=n;i++)  
  12.         cin>>W[i];  
  13.     for(int i(1);i<=n;i++)  
  14.         for(int j(0);j<=C;j++)  
  15.         {  
  16.             f[i][j]=(i==1 ? 0 : f[i-1][j]);<span style=”white-space:pre”> </span>//初始化邊界以及讓本次裝次結果暫時等於上次裝入結果  
  17.             if(j>=V[i]) f[i][j]=max(f[i][j],f[i-1][j-V[i]]+W[i]);<span style=“white-space:pre”>    </span>//如果能裝下第i個物品則做出最好的決策  
  18.         }  
  19.     cout<<f[n][C]<<endl;  
  20.     return 0;  
  21. }   
#include<iostream>




#define MAXN 110 using namespace std; int main() { int C,n,V[MAXN],W[MAXN],f[MAXN][MAXN]; cin>>C; cin>>n; for(int i(1);i<=n;i++) cin>>V[i]; for(int i(1);i<=n;i++) cin>>W[i]; for(int i(1);i<=n;i++) for(int j(0);j<=C;j++) { f[i][j]=(i==1 ? 0 : f[i-1][j]);<span style="white-space:pre"> </span>//初始化邊界以及讓本次裝次結果暫時等於上次裝入結果 if(j>=V[i]) f[i][j]=max(f[i][j],f[i-1][j-V[i]]+W[i]);<span style="white-space:pre"> </span>//如果能裝下第i個物品則做出最好的決策 } cout<<f[n][C]<<endl; return 0; }

把程序和圖結合起來看,01揹包問題就會十分簡單易懂,什麼描述都不如一張圖來得爽快直接。




我們還可以邊讀入數據邊計算,而不必把V和W保存下來,只需要改動一個地方即可。

  1. #include<iostream>  
  2. #define MAXN 110  
  3. using namespace std;  
  4. int main()  
  5. {  
  6.     int C,n,V,W,f[MAXN][MAXN];  
  7.     cin>>C;  
  8.     cin>>n;  
  9.     for(int i(1);i<=n;i++)  
  10.     {  
  11.         cin>>V>>W;<span style=”white-space:pre”>  </span>//邊讀入數據邊計算,節省了空間  
  12.         for(int j(0);j<=C;j++)  
  13.         {  
  14.             f[i][j]=(i==1 ? 0 : f[i-1][j]);  
  15.             if(j>=V) f[i][j]=max(f[i][j],f[i-1][j-V]+W);  
  16.         }  
  17.     }  
  18.     cout<<f[n][C]<<endl;      
  19.     return 0;  
  20. }   
#include<iostream>




#define MAXN 110 using namespace std; int main() { int C,n,V,W,f[MAXN][MAXN]; cin>>C; cin>>n; for(int i(1);i<=n;i++) { cin>>V>>W;<span style="white-space:pre"> </span>//邊讀入數據邊計算,節省了空間 for(int j(0);j<=C;j++) { f[i][j]=(i==1 ? 0 : f[i-1][j]); if(j>=V) f[i][j]=max(f[i][j],f[i-1][j-V]+W); } } cout<<f[n][C]<<endl; return 0; }



如果繼續優化空間,我們可以用一維數組來存儲計算信息。

  1. #include<iostream>  
  2. #define MAXN 110  
  3. using namespace std;  
  4. int main()  
  5. {  
  6.     int C,n,V,W,f[MAXN];  
  7.     memset(f,0,sizeof(f));<span style=“white-space:pre”>  </span>//初始化一維數組全部變爲0  
  8.     cin>>C;  
  9.     cin>>n;  
  10.     for(int i(1);i<=n;i++)  
  11.     {  
  12.         cin>>V>>W;  
  13.         for(int j(C);j>=0;j–)<span style=“white-space:pre”>   </span>//這裏是逆序枚舉,非常關鍵  
  14.         {  
  15.             if(j>=V) f[j]=max(f[j],f[j-V]+W);  
  16.         }  
  17.     }  
  18.     cout<<f[C]<<endl;     
  19.     return 0;  
  20. }   
#include<iostream>




#define MAXN 110 using namespace std; int main() { int C,n,V,W,f[MAXN]; memset(f,0,sizeof(f));<span style="white-space:pre"> </span>//初始化一維數組全部變爲0 cin>>C; cin>>n; for(int i(1);i<=n;i++) { cin>>V>>W; for(int j(C);j>=0;j--)<span style="white-space:pre"> </span>//這裏是逆序枚舉,非常關鍵 { if(j>=V) f[j]=max(f[j],f[j-V]+W); } } cout<<f[C]<<endl; return 0; }

本文部分地方引用了《揹包九講》、《算法競賽入門經典》。
發佈了10 篇原創文章 · 獲贊 46 · 訪問量 57萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章