前言
和分治法一樣,動態規劃(dynamic programing)是通過組合子問題的解而解決整個問題的。注意這裡的programing翻譯成立規劃而不是編程。維基百科上寫道
This is also usually done in a tabular form by iteratively generating solutions to bigger and bigger subproblems by using the solutions to small subproblems.
這說明動規的關鍵是table,是子問題。而且動規的子問題是相互關聯的。而分治演算法是將問題劃分成相對獨立的子問題,遞迴的解決所有子問題,
然後合并子問題成為最終的結果。在這個過程中,分治法會做很多不必要的工作,即重複地求解公用子問題。
動規對每個子問題之求解一遍,並且將其結果儲存在表中,從而避免了子問題被重複計算。
適用範圍
1.動態規劃通常情況下應用於最佳化問題,這類問題一般有很多個可行的解,每個解有一個值,而我們希望從中找到最優的答案。
2.該問題必須符合無後效性。即目前狀態是曆史的完全總結,過程的演變不再受此前各種狀態及決策的影響。
簡單動態規劃問題
最大子數組和問題
題目
一個有N個整數元素的一位元組(A[0], A[1],...,A[n-1], A[n]),這個數組當然有很多子數組,那麼數組之和的最大值是什麼呢?
這是一道很簡單的題目,但是要想寫出時間複雜度為O(n)的最優解法還是需要仔細推敲下的。
例如有數組 int A[5] = {-1, 2, 3, -4, 2};
合格子數組為 2,3 即答案為 5;
再明確一下題意
1.子數組必須是連續的。
2.不需要返回子數組的具體位置。
3.數組中包含:正整數,零,負整數。
例如
數組: {1, -2, 3, 5, -3, 2} 傳回值為 8
數組: {0, -2, 3, 5, -1, 2} 傳回值為 9
數組: {-9, -2, -3, -5, -6} 傳回值為 -2 注意子數組不可為空
首先我們看看最直接的窮舉法
int MaxSubString(int* A, int n){ int max = min; //初始值為負無窮大 int sum; for(int i = 0; i < n; i++) { sum = 0; for(int j = i; j < n; j++) { sum += A[j]; if(sum > max) max = sum; } } return max;}
這種方法最直接,當也是最耗時的,他的時間複雜度為O(n^2);
問題分析
可以最佳化嗎?答案是肯定的,可以考慮數組的第一個元素,以及最大的一段數組(A[i], ..., A[j]),和A[0]的關係,有一下幾種情況:
1. 當0 = i = j 時,元素A[0]本身構成和最大的一段;
2. 當0 = i < j 時,和最大的一段以A[0]開始;
3. 當0 < i 時, 元素A[0]和最大的一段沒有關係。
從上面3中情況可以看出。可以將一個大問題(N個元素數組)轉化為一個較小的問題(N-1個元素的數組)。假設已經知道(A[1], ...,A[n-1])中和最大的一段數組之和為All[1],並且已經知道
(A[1],...,A[n-1])中包含A[1]的和最大的一段數組為Start[1]。那麼不難看出 (A[0], ..., A[n])中問題的解All[0] = max{ A[0], A[0] + start[1], All[1] }。通過這樣的分析,可以看出這個問題
無有效性,可以用動態規劃來解決。
解決方案
int MaxSubString(int* A, int n){ int Start = A[n - 1]; int All = A[n - 1]; for(int i = n - 2; i >= 0; i--) //從後向前遍曆,反之亦可。 { Start = max( A[i], A[i] + Start); All = max(Start, All); } return All[0]; //All[0] 中存放結果}
我們通過動規演算法解決該問題不僅效率很高(時間複雜度為O(n)),而且極其簡便。
01背包問題
題目
這題非常有名,只要是電腦專業的應該都有聽說過。有N件物品和一個容量為V的背包。第i件物品的體積是c[i],價值是v[i]。求解將哪些物品裝入背包可使價值總和最大。
我們把題目具體下, 有5個商品,背包的體積為10,他們的體積為 c[5] = {3,5,2,7,4}; 價值為 v[5] = {2,4,1,6,5};
問題分析
這是最基礎的背包問題,特點是:每種物品僅有一件,可以選擇放或不放。可以將背包問題的求解看作是進行一系列的決策過程,即決定哪些物品應該放入背包,哪些不放入背包。
如果一個問題的最優解包含了物品n,即Xn = 1,那麼其餘X1, X2, .....,Xn-1 一定構成子問題1,2,.....,n-1在容量C - cn時的最優解。如果這個最優解不包含物品n,即Xn = 0;
那麼其餘 X1, X2.....Xn-1一定構成了子問題 1,2,....n-1在容量C時的最優解。 //請各位仔細品味這幾句話
根據以上分析最優解的結構遞迴定義問題的最優解 f[i][v] = max{ f[i-1][v] , f[i-1][v - c[i]] + v[i]}
解決方案
#include<iostream>#define max(a,b) ((a) > (b) ? a : b)int c[5] = {3,5,2,7,4};int v[5] = {2,4,1,6,5};int f[6][10] = {0};//f[i][v] = max{ f[i-1][v] , f[i-1][v - c[i]] + w[i]}int main(){for(int i = 1; i < 6; i++)for(int j = 1; j < 10 ;j++){if(c[i] > j)//如果背包的容量,放不下c[i],則不選c[i]f[i][j] = f[i-1][j]; else{f[i][j] = max(f[i-1][j], f[i-1][j - c[i]] + v[i]);//轉移方程式}}std::cout<<f[5][9];return 0;}
01背包問題是最基本的動態規劃問題,也是最經典,最易懂的。所以請讀者仔細推敲這段代碼。它包含了背包問題中設計狀態、方程的最基本思想。
矩陣連乘問題
題目
給定n個矩陣{A1,A2,...,An},其中Ai與Ai+1是可乘的,i = 1,2, ...n-1。考慮這n個矩陣的乘積。由於競爭乘法滿足結合律,故計算矩陣的連乘有許多不同的計算次序。
這種計算次序可以用加括弧的方式確定。若一個矩陣連乘的計算次序完全確定,這是就說該連乘已完全加括弧。
例如,矩陣連乘A1 *A2 *A3 *A4 可以有5種完全加括弧的方式:(A1 *(A2 *(A3 *A4 ))), (A1 *((A2 *A3) *A4)),((A1 *A2 )*(A3 *A4)),
(((A1 *A2 )*A3 )*A4)。每種加括弧的方式確定了一個計算的次序。不同的計算次序與矩陣連乘的計算量有密切的關係。關於矩陣如何相乘這裡我就不贅述了請看about matrix 。
考慮3個矩陣{A1,A2,A3}連乘的例子,假設這3個矩陣的維數分別為 10×100, 100×5, 5×50。若按照((A1*A2)*A3)計算,則計算次數為10×100×5 + 10×5×50 = 7500
若按(A1*(A2*A3))計算,則計算次數為 100×5×50 + 10×100×50 = 75000。第1種方法的計算次數是後者的10倍!由此可以看出,不同的加括弧方式確定不同的計算次序對
矩陣乘法的運算量影響是巨大的。
矩陣連乘為題定義如下:給定n個矩陣{A1,A2,...,An},矩陣A1的維數為pi-1×pi, i = 1,2, ..., n,如何給矩陣連乘A1*A2*....*An完全加上括弧使用矩陣乘法中計算次數最少。
問題分析
若用窮舉法,能夠證明需要指數時間來求解。但是時間代價高昂。現在考慮用動態規劃來求解連乘問題。
為方便起見用Ai...j表示矩陣乘法Ai*Ai+1*....Aj的結果。其中i<j。那麼Ai*Ai+1*.....Aj一定在Ak與Ak+1之間被分裂。i <= k < j。問題Ai*Ai+1 ... Aj完全加括弧的開銷等於計算矩陣
Ai...k 與計算 Ak+1...j的開銷,再加上他們的結果相乘的開銷。問題的最優子結構可以描述如下:假定問題Ai*Ai+1*...Aj被完全加括弧的最優方式是在Ak與Ak+1之間被分裂,那麼分裂
之後,最優解Ai*Ai+1*....Aj中的子鏈Ai*Ai+1....Ak一定是問題Ai*Ai+1*...*Ak的最優加括弧方式。同樣,最優解Ak+1*Ak+2*...Aj的子鏈一定是問題Ak+1*Ak+2*...Aj最優加括弧方式。
根據上面分析,設m[i,j]表示計算Ai...j所需的最小計算次數 m[i,j] = min{m[i,k]+m[k+1,j]+pi-1pKpj }
解決方案
#include<iostream>void main(){ int m[8][8], min; int r[8] = {10, 20, 50, 1, 100, 4, 20, 2}; /* 矩陣維數 */ /* 初始化 */ memset(m,0,sizeof(m)); /* 每此增量加一 */ for (int l = 1; l < 7; l++) { /* 對於差值為 l 的兩個元素 */ for (int i = 1; i <= 7 - l; i++) { j = i + l; /* 求其最小組合方式 */ min = m[i][i] + m[i+1][j] + r[i-1] * r[i] * r[j]; middle[i][j] = i; for (int k = i + 1; k < j; k++) { if (min > m[i][k] + m[k+1][j] + r[i-1] * r[k] * r[j]) { min = m[i][k] + m[k+1][j] + r[i-1] *r[k]* r[j]; middle[i][j] = k; } } m[i][j] = min; } } std::cout<<m[1][N];}
由以上代碼可以很容易看出演算法的時間複雜度為O(n^3)。即便如此也比窮舉法的指數級時間複雜度快。
尾聲
動態規劃絕對不是一兩篇文章可以講清楚的。當然也不是通過一兩道題目可以完全學會。學習的關鍵是用動規的思想去想問題,去設計狀態轉移方程式。
動態規劃還有很多變形,如狀態壓縮,樹形等等。這些題目雖然很難,但是工作學習之餘,一邊聽歌,一邊推敲。也是人生一大快事