動態規划算法適用於解最優化問題。通常可按以下4個步驟設計:
(1)找出最優解的性質,並刻畫其結構特征;
(2)遞歸地定義最優值;
(3)以自底向上的方式計算出最優值;
(4)根據計算最優值時得到的信息,構造最優解。
動態規划算法的基本要素:
(1)最優子結構
當問題的最優解包含了其子問題的最優解時,稱該問題具有最優子結構性質。問題的最優子結構性質提供了該問題可用動態規划算法求解的重要線索。
(2)重疊子問題
可用動態規划算法求解的問題應具備的另一個基本要素是子問題的重疊性質。在用遞歸算法自頂向下解此問題時,每次產生的子問題並不總是新問題,有些子問題被反復計算多次。動態規划算法正是利用了這種子問題的重疊性質,對每一個子問題只解一次,而后將其解保存在一個表格里中,當再次需要解此子問題時,只是簡單地用常數時間查看一下結果。通常,不同的子問題個數隨問題的大小呈多項式增長。因此,用動態規划算法只需要多項式時間,從而獲得較高的解題效率。
備忘錄方法
備忘錄方法是動態規划算法的變形。與動態規划算法一樣,備忘錄方法用表格保存已解決的子問題的答案,在下次需要解此子問題時,只要簡單地查看該子問題的解答,而不必重新計算。與動態規划不同的是,備忘錄方法的遞歸方式是自頂向下的,而動態規划算法是自底向上遞歸的。
一般來講,當一個問題的所有子問題都至少要解一次時,用動態規划比備忘錄方法好。此時,動態規划沒有任何多余的計算。當子問題空間中的部分子問題不必求解時,用備忘錄方法則較有利,因為從其控制結構可以看出,該方法只解那些確實需要求解的子問題。
【最長公共子序列】
窮舉搜索法是最容易想到的算法,對X的所有子序列,檢查它是否也是Y的子序列,從而確定它是否為X和Y的公共子序列。並且在檢查過程中記錄最長的公共子序列。X的所有子序列都檢查過后即可求出X和Y的最長公共子序列。若X共有m個元素組成,它共有2m個不同的子序列,從而窮舉搜索法需要指數時間。
下面按照動態規划算法設計的步驟來設計解此問題:
1.最長公共子序列的結構
事實上,最長公共子序列問題具有最優子結構性質。
設X={x1,x2,...,xm}和Y={y1,y2,...,yn}的最長公共子序列為Z={z1,z2,...,zk},則
(1)若xm=yn,則zk=xm=yn,且Zk-1是Xm-1和Yn-1的最長公共子序列;
(2)若xm≠yn,且zk≠xm,則Z是Xm-1和Y的最長公共子序列;
(3)若xm≠yn,且zk≠yn,則Z是X和Yn-1的最長公共子序列。
由此可見,兩個序列的最長公共子序列包含了這兩個序列的前綴的最長公共子序列。
2.子問題的遞歸結構
用c[i][j]記錄序列Xi和Yj的最長公共子序列的長度。由最優子結構性質可建立遞歸關系如下:
3.計算最優值
void LCSLength( int m, int n,char * x,char * y,int ** c,int ** b) {//c[i][j]存儲Xi和Yj最長公共子序列長度,b[i][j]記錄c[i][j]的值是由哪一個子問題解得的 int i,j; for( i = 1; i <= m; i++) c[i][0]=0; for( j = 1; j <= n; j++) c[0][j]=0; for( i = 1; i <= m; i++) for( j = 1; j <= n; j++){ if( x[i] == y[j]) { c[i][j] = c[i-1][j-1] + 1; b[i][j] = 1; } else if( c[i-1][j] > c[i][j-1]) { c[i][j] = c[i-1][j]; b[i][j] = 2; } else { c[i][j] = c[i][j-1]; b[i][j] = 3; } } }
由於每個數組的單元的計算耗時為O(1)時間,算法LCSLength耗時O(mn)。
4.構造最長公共子序列
根據LCSLength算法中b的內容打印最長公共子序列。
void LCS( int i, int j, char * x, char * b ) { if( i == 0 || j == 0) return ; if( b[i][j] == 1) { LCS(i-1,j-1,x,b); cout<<x[i]; } else if( b[i][j] == 2) LCS(i-1,j,x,b); else LCS(i,j-1,x,b); }
在算法LCS中,每一次遞歸調用使i或j減1,因此算法的計算時間為O(m+n)。
【最大子段和】
給定由n和整數組成的序列a1,a2,...,an,求該序列形如∑ak的子段和的最大值。
1.簡單算法
void MaxSum( int n, int * a, int & besti, int & bestj) { int sum = 0; for( int i = 1; i <= n; i++) for( int j = i; j <= n; j++){ int thissum = 0; for( int k = i; k <= j; k++) thissum+=a[k]; if( thissum > sum){ sum = thissum; besti = i; bestj = j; } } return sum; }
三個for循環可以看出,它需要的計算時間是O(n3)。第三個for循環可以省去,避免重復計算,改進后的算法如下:
void MaxSum( int n, int * a, int & besti, int & bestj) { int sum = 0; for( int i = 1; i <= n; i++){ int thissum = 0; for( int j = i; j <= n; j++){ thissum+=a[j]; if( thissum > sum){ sum = thissum; besti = i; bestj = j; } } } return sum; }
改進后只需要O(n2)的計算時間。
2.分治算法
從問題的解得結構可以看出,它適合用分治法求解。
如果將所給序列a[1:n]分為兩個兩段a[1:n/2]和a[n/2+1:n],分別求出這兩段的最大子段和,則a[1:n]的最大子段和有三種情形:
(1)a[1:n]的最大子段和與a[1:n/2]的最大子段和相同;
(2)a[1:n]的最大子段和與a[n/2+1:n]的最大子段和相同;
(3)a[1:n]的最大子段和為∑ak,且1≤i≤n/2,n/2+1≤j≤n。
對於(3)的情況,在a[1:n/2]中計算出s1[i:n/2]的最大和,並在a[n/2+1:n]中計算出s2[n/2+1:j]的最大和。則s1+s2就是1情況(3)的最優值。
void MaxSubSum( int * a, int left, int right) { int sum = 0; if( left == right ) sum = a[left] > 0 ? a[left] : 0; else { int center = (left + right) / 2; int leftsum = MaxSubSum( a,left,center); int rughtsum = MaxSubSum(a,center+1,right); int s1 = 0; int lefts = 0; for( int i = center; i >= left; i--){ lefts += a[i]; if( lefts > s1) s1 = lefts; } int s2 = 0; int rights = 0; for( int i = center+1; i <= right; i++){ rights += a[i]; if( rights > s2) s2 = rights; } sum = s1 + s2; if( sum < leftsum) sum = leftsum; if( sum < rightsum) sum = rightsum; } return sum; }
解此遞歸方程可知,所需時間為O(nlogn)。
3.動態規划算法
b[j]表示包含a[j]及其之前的任意個元素的最大子段和。b[j]與b[j-1]的關系表示如下:
b[j]=max{b[j-1]+a[j],a[j]},1≤j≤n
void MaxSum( int n, int * a) { int sum = 0; for( int i = 1; i < n; i++ ) { if( b > 0 ) b += a[i]; else b = a[i]; if( b > sum ) sum = b; } return sum; }
上述算法顯然需要O(n)計算時間和O(n)空間。
4.最大子段和問題與動態規划算法的推廣
(1)最大矩陣和問題:給定一個m行n列的整數矩陣A,試求矩陣A的一個子矩陣,使其各元素之和最大。
如果直接用枚舉的方法解最大子矩陣和問題,需要O(m2n2)時間。
用動態規划算法設計:
b[j]=∑a[i][j],且i1≤i≤i2,則t(i1,i2)=max∑b[j],且j1≤j≤j2。
int MaxSumTwo( int m, int n, int * a) { int sum = 0; int * b = new int [n+1]; for( int i = 1; i <= m; i++ ){ for( int k = 1; k <= n; k++ ) b[k] = 0; for( int j = i; j <= m; j++){ for( int k = 1; k <= n; k++) b[k] += a[j][k]; int max = MaxSum(n,b); if( max > sum) sum = max; } } return sum; }
MaxSumTwo算法需要O(m2n)時間。
(2)最大m子段和問題:給定由n個整數組成的序列,以及一個正整數m,要求確定序列的m個不想交子段,使這m個子段的總和達到最大。
設b(i,j)表示數組a的前j項中i個子段和的最大值,且第i個子段含a[j],則所求的最優值顯然為max b(m,j)。與最大子段和問題類似,計算b(i,j)的遞歸式為:
b(i,j)=max{b(i,j-1)+a[j],max b(i-1,t)+a[j]}
其中b(i,j-1)+a[j]中a[j]包含在第i個子段內;max b[i-1,j]+a[j]中a[j]為單獨的一個子段,即第i個子段只有一個元素。
void MaxSum( int m, int n, int * a) { if( n < m || m < 1) return 0; int * * b = new int * [m+1]; for( int i = 0; i <= m; i ++ ) b[i] = new int [n+1]; for( int i = 0; i <= m; i ++) b[i][0] = 0; for( int j = o; j <= n; j ++ ) b[0][j] = 0; for( int i = 0; i <= m; i ++) for( int j = 0; j <= n; j ++) if( j > i){ b[i][j] = b[i][j-1] +a[j]; for( int k = i-1; k < j; k ++) if( b[i][j] < b[i-1][k] + a[j]) b[i][j] = b[i-1][k] + a[j]; } else b[i][j] = b[i-1][j-1] + a[j]; int sum = 0; for( int j = m;j <= n; j ++) if( sum < b[m][j]) sum = b[m][j]; return sum; }
上述算法顯然需要O(mn2)計算時間和O(mn)空間。
計算b[i][j]時只用到數組b的第i-1和i行的值,可以減少需要的空間;max b(i-1,t)的值可以在計算第i-1行時預先計算並保存起來,節省了時間。
void MaxSum( int m, int n, int * a) { if( n < m || m < 1) return 0; int * b = new int * [n+1]; int * c = new int * [n+1]; b[0] = 0; c[1] = 0; for( int i = 1; i <= m; i ++ ){ b[i] = b[i-1] + a[i]; c[i-1] = b[i]; int max = b[i]; for( int j = i; j <= i+n-m; j ++){ b[j] = b[j-1] > c[j-1] ? b[j-1]+a[j] : c[j-1] +a[j]; c[j-1] = max; if( max < b[j] ) max = b[j]; } c[i+n-m] = max; } int sum = 0; for( int j = m;j <= n; j ++) if( sum < b[m][j]) sum = b[m][j]; return sum; }
【0-1背包】
問題描述:給定n種物品和一個背包。物品i的重量是wi,其價值是vi,背包的容量是c。問應如何選擇裝入背包中的物品,使得裝入背包中的物品的總價值最大?
0-1背包問題是一個特殊的整數規划問題。
1.最優子結構性質
設(y1,y2,...,yn)是所給0-1背包問題的最優解,則(y2,y3,...yn)是c-w1問題的最優解。因若不然,設(z2,z3,...zn)是c-w1的一個最優解,而(y2,y3,...yn)不是它的最優解。由此可以說明(y1,z2,...,zn)是所給問題的一個更優解,從而(y1,y2,...,yn)不是所給問題的最優解。此為矛盾。
2.遞歸關系
m(i,j)是背包容量為j,可選擇物品為i,i+1,...,n是0-1背包問題的最優值。遞歸式如下:
m(i,j)分為兩種情況,容量不夠直接用m(i+1,j)填寫,容量夠時需要比較放入和不放入的價值大小決定是否裝入。
3.算法描述
當wi為正整數時,用二維數組m[][]來存儲m(i,j)的相應值。算法如下:
template< class Type > void Knapsack( Type v, int w, int c, int n, Type * * m ) { int jMax = min( w[n]-1, c ); for( int j = 0; j <= jMax; j++ ) m[n][j] = 0; for( int j = w[m]; j <= c; j++ ) m[n][j] = v[n]; for( int i = n -1; i >1; i-- ){ jMax = min( w[i]-1, c ); for( int j = 0; j <= jMax; j++ ) m[i][j] = m[i+1][j]; for( int j = w[m]; j <= c; j++ ) m[i][j] = max(m[i+1][j],m[i+1][j-w[i]]+v[i]); } m[1][c] = m[2][c]; if( c >= w[1] ) m[1][c] = max( m[1][c],m[2][c-w[1]]+v[1]); } template< class Type> void Traceback( Type ** m, int w, int c, int n, int x ) { for( int i = 1; i < n; i++ ) if( m[i][c]==m[i+1][c]) x[i] = 0; else { x[i] = 1; c -= w[i]; } x[n] = ( m[n][c]) ? 1 : 0; }
按上述算法Knapsack計算后,m[1][c]給出所要求的0-1背包問題的最優值。相應的最優解可由算法Traceback計算得出。
4.計算復雜度分析
從計算m(i,j)的遞歸式容易看出,Knapsack算法需要O(cn)計算時間,如果c很大時,算法需要的計算時間就會變得很多。這一點需要克服,還有一點,就是物品的重量不一定是整數。
改進的動態規划算法(今天先寫到這,明天繼續更新)。