動態規划算法與分治法類似,其基本思想也是將待求解問題分解成若⼲個⼦問題。但是經分解得到的⼦問題往往不是互相獨⽴的
動態規划策略通常⽤於求解最優化問題。
– 在這類問題中,可能會有許多可⾏解。每⼀個解都對應於⼀個值,我們希望找到具有最優值的那個解,即最優解。
– 動態
• 在⼀定條件下,當前階段的狀態和下⼀階段的狀態之間的轉移。
– 規划
• 建⽴狀態轉移⽅程(或稱各階段間的遞推關系式),將各個階段的狀態以表格式⽅法存儲。
• 表格式⽅法:⽤⼀個表來記錄所有已解決的⼦問題的解
過程:
• 階段 stage
– 將所給問題的過程,按時間或空間特征分解成若⼲相互聯系的階段,以便按次序去求每階段的解。
• 狀態 state
– 各階段開始時的客觀條件叫做狀態。
• 決策 decision
– 當各階段的狀態確定以后,就可以做出不同的決定,從⽽確定下⼀階段的狀態,這種決定稱為決策。
• 狀態轉移 transition
– 根據上⼀階段的狀態和決策來導出本階段的狀態。
在分治法求解時,有些問題被重復計算了許多次
如果能夠保存已解決的⼦問題的答案,⽽在需要時再找出已求得的答案,就可以避免⼤量重復計算,從⽽得到多項式時間算法。
基本要素
• 最優⼦結構(optimal substructure)
– 原問題的最優解包含了⼦問題的最優解。
– 該性質使我們能夠以⾃底向上的⽅式遞歸地從⼦問題的最優解逐步構造出原問題的最優解。
• 重疊⼦問題(overlapping subproblem)
– 有些⼦問題被反復計算多次(前⼀階段的狀態帶到當前階段,當前階段的狀態帶到下⼀階段)。
– 通過表格式⽅法來記錄已解決的⼦問題的答案
0-1背包問題
• 給定n種物品和⼀個背包。物品i的重量是wi,其價值為vi,背包的容量為c。問應如何選擇裝⼊背包的物品,使得裝⼊背包中物品的總價值最⼤?
– 0-1:在選擇裝⼊背包的物品時,對每種物品i只有兩種選擇,即裝⼊背包或不裝⼊背包。不能將物品i裝⼊背包多次,也不能只裝⼊部分的物品i。
– 找出⼀個n元0-1向量(x1, x2, …, xn),使得n最大
(1)證明問題具有最優解
– 證明:設(y1,y2,…,yn)是給定n種物品在背包載重為c時的⼀個最優解,則(y2,…,yn)是除了第⼀個物品之外的n-1個物品在背包載重為c-w1y1時的⼀個最優解。
– 反證法:如若不然,設(z2,…,zn)是上述⼦問題的⼀個最優解,⽽(y2,…,yn)不是。(y1, y2, …,yn)不是
(2) 設m(i,j)是背包容量為j,可選擇物品為1, 2, …, i時0-1背包問題的最優值。假設j是整數,從1開始逐⼀遞減地划分
• 遞歸定義最優值
• 方法一:設m(i,j)是背包容量為j,(當前階段)可選擇物品為i, i+1,…,n時0-1背包問題的最優值。
• ⽅法⼆:設m(i,j)是背包容量為j,(當前階段)可選擇物品為1,2,…,i時0-1背包問題的最優值。
動態規划問題的理解二:
轉自http://blog.csdn.net/baidu_28312631/article/details/47418773
動態規划相信大家都知道,動態規划算法也是新手在剛接觸算法設計時很苦惱的問題,有時候覺得難以理解,但是真正理解之后,就會覺得動態規划其實並沒有想象中那么難。網上也有很多關於講解動態規划的文章,大多都是敘述概念,講解原理,讓人覺得晦澀難懂,即使一時間看懂了,發現當自己做題的時候又會覺得無所適從。我覺得,理解算法最重要的還是在於練習,只有通過自己練習,才可以更快地提升。話不多說,接下來,下面我就通過一個例子來一步一步講解動態規划是怎樣使用的,只有知道怎樣使用,才能更好地理解,而不是一味地對概念和原理進行反復琢磨。
首先,我們看一下這道題(此題目來源於北大POJ):
數字三角形(POJ1163)
在上面的數字三角形中尋找一條從頂部到底邊的路徑,使得路徑上所經過的數字之和最大。路徑上的每一步都只能往左下或 右下走。只需要求出這個最大和即可,不必給出具體路徑。 三角形的行數大於1小於等於100,數字為 0 - 99
輸入格式:
5 //表示三角形的行數 接下來輸入三角形
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
要求輸出最大和
接下來,我們來分析一下解題思路:
首先,肯定得用二維數組來存放數字三角形
然后我們用D( r, j) 來表示第r行第 j 個數字(r,j從1開始算)
我們用MaxSum(r, j)表示從D(r,j)到底邊的各條路徑中,最佳路徑的數字之和。
因此,此題的最終問題就變成了求 MaxSum(1,1)
當我們看到這個題目的時候,首先想到的就是可以用簡單的遞歸來解題:
D(r, j)出發,下一步只能走D(r+1,j)或者D(r+1, j+1)。故對於N行的三角形,我們可以寫出如下的遞歸式:
- if ( r == N)
- MaxSum(r,j) = D(r,j)
- else
- MaxSum( r, j) = Max{ MaxSum(r+1,j), MaxSum(r+1,j+1) } + D(r,j)
根據上面這個簡單的遞歸式,我們就可以很輕松地寫出完整的遞歸代碼:
- #include <iostream>
- #include <algorithm>
- #define MAX 101
- using namespace std;
- int D[MAX][MAX];
- int n;
- int MaxSum(int i, int j){
- if(i==n)
- return D[i][j];
- int x = MaxSum(i+1,j);
- int y = MaxSum(i+1,j+1);
- return max(x,y)+D[i][j];
- }
- int main(){
- int i,j;
- cin >> n;
- for(i=1;i<=n;i++)
- for(j=1;j<=i;j++)
- cin >> D[i][j];
- cout << MaxSum(1,1) << endl;
- }
對於如上這段遞歸的代碼,當我提交到POJ時,會顯示如下結果:
對的,代碼運行超時了,為什么會超時呢?
答案很簡單,因為我們重復計算了,當我們在進行遞歸時,計算機幫我們計算的過程如下圖:
就拿第三行數字1來說,當我們計算從第2行的數字3開始的MaxSum時會計算出從1開始的MaxSum,當我們計算從第二行的數字8開始的MaxSum的時候又會計算一次從1開始的MaxSum,也就是說有重復計算。這樣就浪費了大量的時間。也就是說如果采用遞規的方法,深度遍歷每條路徑,存在大量重復計算。則時間復雜度為 2的n次方,對於 n = 100 行,肯定超時。
接下來,我們就要考慮如何進行改進,我們自然而然就可以想到如果每算出一個MaxSum(r,j)就保存起來,下次用到其值的時候直接取用,則可免去重復計算。那么可以用n方的時間復雜度完成計算。因為三角形的數字總數是 n(n+1)/2
根據這個思路,我們就可以將上面的代碼進行改進,使之成為記憶遞歸型的動態規划程序:
- #include <iostream>
- #include <algorithm>
- using namespace std;
- #define MAX 101
- int D[MAX][MAX];
- int n;
- int maxSum[MAX][MAX];
- int MaxSum(int i, int j){
- if( maxSum[i][j] != -1 )
- return maxSum[i][j];
- if(i==n)
- maxSum[i][j] = D[i][j];
- else{
- int x = MaxSum(i+1,j);
- int y = MaxSum(i+1,j+1);
- maxSum[i][j] = max(x,y)+ D[i][j];
- }
- return maxSum[i][j];
- }
- int main(){
- int i,j;
- cin >> n;
- for(i=1;i<=n;i++)
- for(j=1;j<=i;j++) {
- cin >> D[i][j];
- maxSum[i][j] = -1;
- }
- cout << MaxSum(1,1) << endl;
- }
當我們提交如上代碼時,結果就是一次AC
雖然在短時間內就AC了。但是,我們並不能滿足於這樣的代碼,因為遞歸總是需要使用大量堆棧上的空間,很容易造成棧溢出,我們現在就要考慮如何把遞歸轉換為遞推,讓我們一步一步來完成這個過程。
我們首先需要計算的是最后一行,因此可以把最后一行直接寫出,如下圖:
現在開始分析倒數第二行的每一個數,現分析數字2,2可以和最后一行4相加,也可以和最后一行的5相加,但是很顯然和5相加要更大一點,結果為7,我們此時就可以將7保存起來,然后分析數字7,7可以和最后一行的5相加,也可以和最后一行的2相加,很顯然和5相加更大,結果為12,因此我們將12保存起來。以此類推。。我們可以得到下面這張圖:
然后按同樣的道理分析倒數第三行和倒數第四行,最后分析第一行,我們可以依次得到如下結果:
上面的推導過程相信大家不難理解,理解之后我們就可以寫出如下的遞推型動態規划程序:
- #include <iostream>
- #include <algorithm>
- using namespace std;
- #define MAX 101
- int D[MAX][MAX];
- int n;
- int maxSum[MAX][MAX];
- int main(){
- int i,j;
- cin >> n;
- for(i=1;i<=n;i++)
- for(j=1;j<=i;j++)
- cin >> D[i][j];
- for( int i = 1;i <= n; ++ i )
- maxSum[n][i] = D[n][i];
- for( int i = n-1; i>= 1; --i )
- for( int j = 1; j <= i; ++j )
- maxSum[i][j] = max(maxSum[i+1][j],maxSum[i+1][j+1]) + D[i][j];
- cout << maxSum[1][1] << endl;
- }
我們的代碼僅僅是這樣就夠了嗎?當然不是,我們仍然可以繼續優化,而這個優化當然是對於空間進行優化,其實完全沒必要用二維maxSum數組存儲每一個MaxSum(r,j),只要從底層一行行向上遞推,那么只要一維數組maxSum[100]即可,即只要存儲一行的MaxSum值就可以。
對於空間優化后的具體遞推過程如下:
接下里的步驟就按上圖的過程一步一步推導就可以了。進一步考慮,我們甚至可以連maxSum數組都可以不要,直接用D的第n行直接替代maxSum即可。但是這里需要強調的是:雖然節省空間,但是時間復雜度還是不變的。
依照上面的方式,我們可以寫出如下代碼:
- #include <iostream>
- #include <algorithm>
- using namespace std;
- #define MAX 101
- int D[MAX][MAX];
- int n;
- int * maxSum;
- int main(){
- int i,j;
- cin >> n;
- for(i=1;i<=n;i++)
- for(j=1;j<=i;j++)
- cin >> D[i][j];
- maxSum = D[n]; //maxSum指向第n行
- for( int i = n-1; i>= 1; --i )
- for( int j = 1; j <= i; ++j )
- maxSum[j] = max(maxSum[j],maxSum[j+1]) + D[i][j];
- cout << maxSum[1] << endl;
- }
接下來,我們就進行一下總結:
遞歸到動規的一般轉化方法
遞歸函數有n個參數,就定義一個n維的數組,數組的下標是遞歸函數參數的取值范圍,數組元素的值是遞歸函數的返回值,這樣就可以從邊界值開始, 逐步填充數組,相當於計算遞歸函數值的逆過程。
動規解題的一般思路
1. 將原問題分解為子問題
- 把原問題分解為若干個子問題,子問題和原問題形式相同或類似,只不過規模變小了。子問題都解決,原問題即解決(數字三角形例)。
- 子問題的解一旦求出就會被保存,所以每個子問題只需求 解一次。
2.確定狀態
- 在用動態規划解題時,我們往往將和子問題相關的各個變量的一組取值,稱之為一個“狀 態”。一個“狀態”對應於一個或多個子問題, 所謂某個“狀態”下的“值”,就是這個“狀 態”所對應的子問題的解。
- 所有“狀態”的集合,構成問題的“狀態空間”。“狀態空間”的大小,與用動態規划解決問題的時間復雜度直接相關。 在數字三角形的例子里,一共有N×(N+1)/2個數字,所以這個問題的狀態空間里一共就有N×(N+1)/2個狀態。
整個問題的時間復雜度是狀態數目乘以計算每個狀態所需時間。在數字三角形里每個“狀態”只需要經過一次,且在每個狀態上作計算所花的時間都是和N無關的常數。
3.確定一些初始狀態(邊界狀態)的值
以“數字三角形”為例,初始狀態就是底邊數字,值就是底邊數字值。
4. 確定狀態轉移方程
定義出什么是“狀態”,以及在該“狀態”下的“值”后,就要找出不同的狀態之間如何遷移――即如何從一個或多個“值”已知的 “狀態”,求出另一個“狀態”的“值”(遞推型)。狀態的遷移可以用遞推公式表示,此遞推公式也可被稱作“狀態轉移方程”。
數字三角形的狀態轉移方程:
能用動規解決的問題的特點
1) 問題具有最優子結構性質。如果問題的最優解所包含的 子問題的解也是最優的,我們就稱該問題具有最優子結 構性質。
2) 無后效性。當前的若干個狀態值一旦確定,則此后過程的演變就只和這若干個狀態的值有關,和之前是采取哪種手段或經過哪條路徑演變到當前的這若干個狀態,沒有關系。