最近看了一下動態規劃,發現其實還是很容易的,不知道爲什麼當時上課的時候覺得很難。腦子瓦特了。網上找了一些資料:
幽默的解釋,感性的理解動態規劃:http://blog.csdn.net/woshioosm/article/details/7438834
一、基本概念
動態規劃過程是:每次決策依賴於當前狀態,又隨即引起狀態的轉移。一個決策序列就是在變化的狀態中產生出來的,所以,這種多階段最優化決策解決問題的過程就稱爲動態規劃。
二、基本思想與策略
基本思想與分治法類似,也是將待求解的問題分解爲若干個子問題(階段),按順序求解子階段,前一子問題的解,爲後一子問題的求解提供了有用的信息。在求解任一子問題時,列出各種可能的局部解,通過決策保留那些有可能達到最優的局部解,丟棄其他局部解。依次解決各子問題,最後一個子問題就是初始問題的解。
由於動態規劃解決的問題多數有重疊子問題這個特點,爲減少重複計算,對每一個子問題只解一次,將其不同階段的不同狀態保存在一個二維數組中。
與分治法最大的差別是:適合於用動態規劃法求解的問題,經分解後得到的子問題往往不是互相獨立的(即下一個子階段的求解是建立在上一個子階段的解的基礎上,進行進一步的求解)。
三、適用的情況
能採用動態規劃求解的問題的一般要具有3個性質:
(1) 最優化原理:如果問題的最優解所包含的子問題的解也是最優的,就稱該問題具有最優子結構,即滿足最優化原理。
(2) 無後效性:即某階段狀態一旦確定,就不受這個狀態以後決策的影響。也就是說,某狀態以後的過程不會影響以前的狀態,只與當前狀態有關。
(3)有重疊子問題:即子問題之間是不獨立的,一個子問題在下一階段決策中可能被多次使用到。(該性質並不是動態規劃適用的必要條件,但是如果沒有這條性質,動態規劃算法同其他算法相比就不具備優勢)
四、求解的基本步驟
動態規劃所處理的問題是一個多階段決策問題,一般由初始狀態開始,通過對中間階段決策的選擇,達到結束狀態。這些決策形成了一個決策序列,同時確定了完成整個過程的一條活動路線(通常是求最優的活動路線)。如圖所示。動態規劃的設計都有着一定的模式,一般要經歷以下幾個步驟。
初始狀態→│決策1│→│決策2│→…→│決策n│→結束狀態
圖1 動態規劃決策過程示意圖
(1)劃分階段:按照問題的時間或空間特徵,把問題分爲若干個階段。在劃分階段時,注意劃分後的階段一定要是有序的或者是可排序的,否則問題就無法求解。
(2)確定狀態和狀態變量:將問題發展到各個階段時所處於的各種客觀情況用不同的狀態表示出來。當然,狀態的選擇要滿足無後效性。
(3)確定決策並寫出狀態轉移方程:因爲決策和狀態轉移有着天然的聯繫,狀態轉移就是根據上一階段的狀態和決策來導出本階段的狀態。所以如果確定了決策,狀態轉移方程也就可寫出。但事實上常常是反過來做,根據相鄰兩個階段的狀態之間的關係來確定決策方法和狀態轉移方程。
(4)尋找邊界條件:給出的狀態轉移方程是一個遞推式,需要一個遞推的終止條件或邊界條件。
一般,只要解決問題的階段、狀態和狀態轉移決策確定了,就可以寫出狀態轉移方程(包括邊界條件)。
實際應用中可以按以下幾個簡化的步驟進行設計:
(1)分析最優解的性質,並刻畫其結構特徵。
(2)遞歸的定義最優解。
(3)以自底向上或自頂向下的記憶化方式(備忘錄法)計算出最優值
(4)根據計算最優值時得到的信息,構造問題的最優解
五、算法實現的說明
動態規劃的主要難點在於理論上的設計,也就是上面4個步驟的確定,一旦設計完成,實現部分就會非常簡單。
使用動態規劃求解問題,最重要的就是確定動態規劃三要素:
(1)問題的階段 (2)每個階段的狀態
(3)從前一個階段轉化到後一個階段之間的遞推關係。
遞推關係必須是從次小的問題開始到較大的問題之間的轉化,從這個角度來說,動態規劃往往可以用遞歸程序來實現,不過因爲遞推可以充分利用前面保存的子問題的解來減少重複計算,所以對於大規模問題來說,有遞歸不可比擬的優勢,這也是動態規劃算法的核心之處。
確定了動態規劃的這三要素,整個求解過程就可以用一個最優決策表來描述,最優決策表是一個二維表,其中行表示決策的階段,列表示問題狀態,表格需要填寫的數據一般對應此問題的在某個階段某個狀態下的最優值(如最短路徑,最長公共子序列,最大價值等),填表的過程就是根據遞推關係,從1行1列開始,以行或者列優先的順序,依次填寫表格,最後根據整個表格的數據通過簡單的取捨或者運算求得問題的最優解。
f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}
f(n,m)通常表示爲一個nxm的數組,保存f(n,m)子問題的最優值,然後自底向上的求解,減少遞歸帶來的重複計算問題。需要注意的是計算順序的問題。
六、動態規劃算法基本框架
七、練習
7.1 矩陣連乘問題
一個給定的矩陣序列A1A2...An計算連乘乘積,有不同的結合方法,並且在結合時,矩陣的相對位置不能改變,只能相鄰結合。根據矩陣乘法的公式,10*100和100*5的矩陣相乘需要做10*100*5次標量乘法。那麼對於維數分別爲10*100、100*5、5*50的矩陣A、B、C,用(A*B)*C來計算需要10*100*5 + 10*5*500 =7500次標量乘法;而A*(B*C)則需要100*5*50+10*100*50=75000次標量乘法。
那麼對於由n個矩陣構成的鏈<A1,A2,...,An>,對i-1,2,...n,矩陣Ai的維數爲pi-1*pi,對乘積A1A2...An求出最小化標量乘法的加括號方案。
數學基礎:
1. [n][m]矩陣乘[m][k]矩陣,乘積次數爲 n*m*k,乘的結果是一個n x k的矩陣
2. 這個矩陣列,前一個矩陣的列數等於後一個矩陣的行數。Ai x... x Ak 的結果是一個 p[i - 1]p[k]的矩陣。
輸入矩陣的數目和一系列可以連乘的矩陣,輸出最佳計算次數
樣例輸入:6 30 35 15 5 10 20 25
樣例輸出:
先分析轉移方程:
其中k是一個在[i, j)之間遊走的值,表示從第i個乘到第k個,加上括號,第k+1個乘到第j個,加上括號,這兩部分左邊乘的結果是一個 p(i-1) x p(k)矩陣,右邊的結果是一個p(k) x p(j) 的矩陣,這兩個矩陣再乘,又需要 p(i - 1) x p (k) x p(j) 次運算。
代碼實現時需要注意的問題:計算順序!!!
因爲你要保證在計算m[i][j]查找m[i][k]和m[k+1][j]的時候,m[i][k]和m[k+1][j]已經計算出來了。
#include<iostream>
using namespace std;
const int MAX = 100;
//p用來記錄矩陣的行列,main函數中有說明
//m[i][j]用來記錄第i個矩陣至第j個矩陣的最優解
//s[][]用來記錄從哪裏斷開的纔可得到該最優解
int p[MAX + 1], m[MAX][MAX], s[MAX][MAX];
int n;//矩陣個數
void matrixChain() {
// 對角線初始化爲0
for (int i = 1; i <= n; i++)m[i][i] = 0;
for (int r = 2; r <= n; r++)//對角線循環,r是連乘矩陣的個數
for (int i = 1; i <= n - r + 1; i++) {//行循環
int j = r + i - 1;//列的控制
//找m[i][j]的最小值,先初始化一下,令k=i
m[i][j] = m[i][i] + m[i + 1][j] + p[i - 1] * p[i] * p[j];
s[i][j] = i;
//k從i+1到j-1循環找m[i][j]的最小值
for (int k = i + 1; k<j; k++) {
int temp = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j];
if (temp<m[i][j]) {
m[i][j] = temp;
//s[][]用來記錄在子序列i-j段中,在k位置處
//斷開能得到最優解
s[i][j] = k;
}
}
}
}
//根據s[][]記錄的各個子段的最優解,將其輸出
void traceback(int i, int j) {
if (i == j)return;
traceback(i, s[i][j]);
traceback(s[i][j] + 1, j);
cout << "Multiply A" << i << "," << s[i][j] << "and A" << s[i][j] + 1 << "," << j << endl;
}
int main() {
cin >> n;
for (int i = 0; i <= n; i++)cin >> p[i];
//測試數據可以設爲六個矩陣分別爲
//A1[30*35],A2[35*15],A3[15*5],A4[5*10],A5[10*20],A6[20*25]
//則p[0-6]={30,35,15,5,10,20,25}
//輸入:6 30 35 15 5 10 20 25
matrixChain();
traceback(1, n);
//最終解值爲m[1][n];
cout << m[1][n] << endl;
return 0;
}
7.2 發工資問題
–問題描述
作爲老師,最盼望的日子就是每月的10號了,因爲這一天是發工資的日子,養家餬口就靠它了,但是對於學校財務處的工作人員來說,這一天則是很忙碌的一天,財務處的小胡老師最近就在考慮一個問題:如果每個老師的工資額都知道,最少需要準備多少張人民幣,才能在給每位老師發工資的時候都不用老師找零呢?這裏假設老師的工資都是正整數,單位元,人民幣一共有100元、50元、10元、5元、2元和1元六種。
–輸入
輸入數據包含多個測試實例,每個測試實例的第一行是一個整數n(n<100),表示老師的人數,然後是n個老師的工資。n=0表示輸入的結束,不做處理。
–輸入樣例
3
1 2 3
0
–輸出樣例
4
這個問題的關鍵在於計算每個老師的工資最少需要多少張人民幣,加起來就行了。實際上問題是求給定的工資數,有六種人民幣100, 50, 10, 5, 2,最少需要幾張加起來爲工資額。轉移方程也很簡單,用f(m) = min(f(m - v[i] +1)其中f(m)表示m錢數時需要的最小值。計算順序是從低向上,肯定是先計算小值。
#include <iostream>
using namespace std;
#define INF 100000
int main()
{
int n;
int t[100];
int m[6] = { 100, 50, 10, 5, 2, 1 };
int f[1000] = {0}; // 1000塊以內的每種錢的最優解
for (int i = 1; i <= 1000; i++) // 注意計算順序
{
int min = INF;
for (int j = 0; j < 6; j++)
{
if (i - m[j] >= 0) // 邊界
{
int count = f[i - m[j]] + 1;
if (count < min)
min = count;
}
}
f[i] = min;
}
while (cin >> n)
{
if (n == 0) break;
int count = 0;
for (int i = 0; i < n; i++)
{
cin >> t[i];
count += f[t[i]];
}
cout << count;
}
getchar();
}
當然,用常規想法也是可以做的,比如對於一定錢數,肯定是先用100的,再用50的,依次下去,反正有1塊的來填坑。有貪心的思想。
#include<iostream>
using namespace std;
int f(int n)
{
int a,b,c,d,e,f;
a=n/100;
b=(n-100*a)/50;
c=(n-100*a-50*b)/10;
d=(n-100*a-50*b-10*c)/5;
e=(n-100*a-50*b-10*c-5*d)/2;
f=n-100*a-50*b-10*c-5*d-2*e;
return (a+b+c+d+e+f);
}
int main()
{
int n;
int a[100];
while(cin>>n)
{
if(n==0) break;
int t=0;
for(int i=0;i<n;i++)
cin>>a[i];
for(int i=0;i<n;i++)
t=t+f(a[i]);
cout<<t<<endl;
}
return 0;
}
7.3 買書
如果單獨購買其中一卷,那麼可以打9.5折。
如果同時購買兩卷不同的,那麼可以打9折。
如果同時購買三卷不同的,那麼可以打8.5折。
如果小明希望購買第1卷x本,第2卷y本,第3卷z本,那麼至少需要多少錢呢?(x、y、z爲三個已知整數)。
輸入:x, y, z
輸出需要的最少金錢
這個問題最好的買法肯定是一次一次買,一次可以買一本、兩本或者三本。
轉移方程
f(x, y, z) = min (
f( x - 1, y, z) + 60*0.95 ,
f(x, y - 1, z) + 60*0.95,
f(x, y, z) + 60*0.95,
f(x - 1, y - 1, z) + 120 * 0.9,
f(x - 1, y, z- 1) + 120 * 0.9,
f(x, y - 1, z - 1) + 120 * 0.9,
f( x - 1,y -1 ,z -1 ) + 180 * 0.85
)
f(x, y, z)表示購買x本1卷,y本2卷,z本3卷的最優解。共有x * y * z 個問題,每個問題又有7中選擇,所以時間複雜度是O(x ×y×z×7) => 忽視7 複雜度爲O(x×y×z)。
#include <iostream>
using namespace std;
#define MAX 20
#define INF 10000
int f[MAX][MAX][MAX] = { { INF },{ INF },{ INF }}; // 購買x本1卷,y本2卷,z本3卷的最優解
int main()
{
// 先初始化
for (int x = 0; x < MAX; x++)
for (int y = 0; y < MAX; y++)
for (int z = 0; z < MAX; z++)
f[x][y][z] = INF;
f[0][0][0] = 0;
f[1][0][0] = 60 * 0.95;
f[0][1][0] = 60 * 0.95;
f[0][0][1] = 60 * 0.95;
for(int x = 0; x < MAX; x++)
for(int y = 0; y < MAX; y++)
for (int z = 0; z < MAX; z++)
{
#define T(X, V)\
do{\
if(X)\
{\
if(V < f[x][y][z])\
f[x][y][z]= V;\
}\
}while(0)
T(x - 1 >= 0, f[x - 1][y][z] + 60* 0.95);
T(y - 1 >= 0, f[x][y -1 ][z] + 60 * 0.95);
T(z - 1 >= 0, f[x][y][z-1] + 60 * 0.95);
T(x - 1 >= 0 && y - 1 >=0, f[x - 1][y - 1][z] + 120*0.9);
T(x - 1 >= 0 && z - 1 >= 0, f[x - 1][y ][z - 1] + 120 * 0.9);
T(y - 1 >= 0 && z - 1 >= 0, f[x][y - 1][z - 1] + 120 * 0.9);
T(y - 1 >= 0 && z - 1 >= 0 && x - 1 >= 0, f[x - 1][y - 1][z - 1] + 180 * 0.85);
}
for (;;)
{
int x, y, z;
cin >> x >> y >> z;
cout << f[x][y][z] << endl;
}
}
7.4 最長公共子序列Longest Common Subsequence,lcs
難度:1
對於序列S和T,求它們的最長公共子序列。例如X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A}則它們的lcs是{B,C,B,A}和{B,D,A,B}。求出一個即可。對於兩個序列X[0] .. X[i], Y[0] ... Y[j], i,j分別表示兩個序列的最後一個元素的索引。我們可以用一個二維數組記錄序列Xi,Yi的LCS的長度。當i == 0 || j == 0時,公共子序列長度爲0,這是邊界。有以下轉移方程:
c[i][j] => (
if(i ==0 || j == 0) return 0;
if(X[i] == Y[j]) return c[i - 1][j - 1] + 1;
if(X[i] != Y[j]) max( c[i -1][j] , c[i][j-1] );
)
然後用一個數組b[i][j] 是由哪個子問題得到的最優解,用來構造最長公共子序列。
以下用string來表達序列。
#include <string>
#include <iostream>
using namespace std;
#define SIZE 20
// 注意i和j是從1開始的
void LCS(const string& str, int b[][SIZE] , int i, int j)
{
// 遞歸終點
if (i == 0 || j == 0) return;
if (b[i][j] == 1)
{
LCS(str, b, i - 1, j - 1);
cout << str[i - 1];
}
else if (b[i][j] == 2)
{
LCS(str, b, i -1, j);
}
else if (b[i][j] == 3)
{
LCS(str, b, i, j - 1);
}
}
int main()
{
for (;;)
{
int c[SIZE][SIZE] = {}; // 最優解
int b[SIZE][SIZE] = {}; // 構造最長公共子序列
string x;
string y;
cin >> x >> y;
// 邊界,兩者中一方是空的,則公共子序列是空的
// 注意c和d並不是從0開始的,0留給了邊界
for (int i = 0; i < SIZE; i++) c[0][i] = 0;
for (int i = 0; i < SIZE; i++) c[i][0] = 0;
for (int i = 1; i <= x.size(); i++) // 從最短的開始
{
for (int j = 1; j <= y.size(); j++)
{
#define max(x,y) (x >y ?x:y)
if (x[i - 1] != y[j - 1]) // 注意這裏
{
if (c[i - 1][j] > c[i][j - 1])
{
c[i][j] = c[i - 1][j];
b[i][j] = 2;// 標記爲2
}
else if (c[i - 1][j] < c[i][j - 1])
{
c[i][j] = c[i][j - 1];
b[i][j] = 3;// 標記爲3
}
}
else if (x[i - 1] == y[j - 1])
{
c[i][j] = c[i - 1][j - 1] + 1;
b[i][j] = 1; // 標記爲1
}
}
}
LCS(x, b, x.size(), y.size());
}
}
7.5 最長遞增子序列
設長度爲N的數組爲{a0,a1, a2, ...an-1),則假定以aj結尾的數組序列的最長遞增子序列長度爲L(j),則L(j)={ max(L(i))+1, i<j且a[i]<a[j] }。也就是說,我們需要遍歷在j之前的所有位置i(從0到j-1),找出滿足條件a[i]<a[j]的L(i),求出max(L(i))+1即爲L(j)的值。最後,我們遍歷所有的L(j)(從0到N-1),找出最大值即爲最大遞增子序列。時間複雜度爲O(N^2)。
例如給定的數組爲{5,6,7,1,2,8},則L(0)=1, L(1)=2, L(2)=3, L(3)=1, L(4)=2, L(5)=4。所以該數組最長遞增子序列長度爲4,序列爲{5,6,7,8}。算法代碼如下:
#include <iostream>
using namespace std;
#define len(a) (sizeof(a) / sizeof(a[0])) //數組長度
int lis(int arr[], int len)
{
int longest[len];
for (int i=0; i<len; i++)
longest[i] = 1;
for (int j=1; j<len; j++) {
for (int i=0; i<j; i++) {
if (arr[j]>arr[i] && longest[j]<longest[i]+1){ //注意longest[j]<longest[i]+1這個條件,不能省略。
longest[j] = longest[i] + 1; //計算以arr[j]結尾的序列的最長遞增子序列長度
}
}
}
int max = 0;
for (int j=0; j<len; j++) {
cout << "longest[" << j << "]=" << longest[j] << endl;
if (longest[j] > max) max = longest[j]; //從longest[j]中找出最大值
}
return max;
}
int main()
{
int arr[] = {1, 4, 5, 6, 2, 3, 8}; //測試數組
int ret = lis(arr, len(arr));
cout << "max increment substring len=" << ret << endl;
return 0;
}
7.6 0-1揹包
一個賊在偷竊一家商店時發現了n件物品,其中第i件值vi元,重wi磅。他希望偷走的東西總和越值錢越好,但是他的揹包只能放下W磅。請求解如何放能偷走最大價值的物品,這裏vi、wi、W都是整數。
解法:
如果每個物品都允許切割並只帶走其一部分,則演變爲部分揹包問題,可以用貪心法求解。0-1揹包問題經常作爲貪心法不可解決的實例(可通過舉反例來理解),但可以通過動態規劃求解。
爲了找出子結構的形式,粗略地分析發現,對前k件物品形成最優解時,需要決策第k+1件是否要裝入揹包。但是此時剩餘容量未知,不能做出決策。因此把剩餘容量也考慮進來,形成的狀態由已決策的物品數目和剩餘容量兩者構成。這樣,所有狀態可以放入一個n*(W+1)的矩陣c中,其值爲當前包中物品總價值,這時有
#include <stdio.h>
#include <stdlib.h>
int package_dp(int *v,int *w,int n,int total) {
int i,j,tmp1,tmp2;
int **c = (int **)malloc((n+1)*sizeof(int *));
for(i=0;i<n+1;i++)
c[i]=(int *)malloc((total+1)*sizeof(int));
for(i=0,j=0;j<total;j++)
c[i][j] = 0;
for(i=1;i<=n;i++) {
c[i][0] = 0;
for(j=1;j<=total;j++) {
if(w[i]>j)
c[i][j] = c[i-1][j];
else {
tmp1 = v[i]+c[i-1][j-w[i]];
tmp2 = c[i-1][j];
c[i][j]=(tmp1>tmp2?tmp1:tmp2);
}
}
}
printf("c[%d][%d]:%d\n",n,total,c[n][total]);
return 0;
}
int main() {
int v[] = {0,10,25,40,20,10};
int w[] = {0,40,50,70,40,20};
int total = 120;
package_dp(v,w,sizeof(v)/sizeof(int)-1,total);
return 0;
}