1、動態規劃
動態規劃算法通常用於求解具有某種最優性質的問題。這類問題通常會有許多可行解,每個解對應一個值,而我們希望在可行解中找到最優解。
(1) 基本思想
動態規劃的基本思想是將待求問題分解爲若干個子問題,先求解子問題,再從子問題得到原問題的解。與分治法不同,動態規劃適合於分解後的子問題非獨立的情況。動態規劃用一個表記錄已解決子問題的答案,可以避免大量的重複計算。
在LeetCode做題時碰到很多”自上而下“型的動態規劃算法。這種算法的基本思路與“自下而上”的動態規劃是一致的,區別在於分析問題的角度與得出答案的方式。自上而下型從原問題出發,逐級向下計算所需子問題的解,通常使用遞歸;自下而上型從最小的子問題出發,逐級向上構造較大問題的解,通常使用迭代或遞推。
(2) 基本步驟
- 找出最優解的性質,並刻畫其結構特徵;
- 遞歸地定義最優值(動態規劃方程);
- 以自下而上的方式計算出最優值;
- 根據計算最優值過程得到的信息,構造最優解。
(3) 問題特徵
- 最優子結構:問題的最優解包含了其子問題的最優解。
- 重疊子問題:每次產生的子問題並不總是新問題。
2、動態規劃典型問題
(1) 矩陣連乘問題
● 問題描述
普通方法下,矩陣連續相乘的運算次數與矩陣相乘的順序有關。
假設有n個矩陣,爲A1A2…An,其中Ai維數爲 Pi-1 × Pi;試確定這n個矩陣相乘所需的最少相乘次數。
● 問題分析
以 m[i,j] 表示對從Ai到Aj的最少相乘次數,那麼該次數必然可以分解爲兩個部分相乘次數之和,再加上兩個部分合併的代價。設k爲將矩陣Ai到Aj的切分位置,其中i ≤ k ≤ j。遍歷所有的k並找到最少的相乘次數,就是m[i,j]的值。而每一個k所切分出的Ai到Ak與Ak+1到Aj,也應當是這部分的最少相乘次數m[i,k]與m[k+1,j]。
由此,該問題具有如上的最優子結構性質,根據該性質可以對問題求解。
● 建立方程
由上面的分析,可以寫出動態規劃方程:
● 計算最優值
根據動態規劃方程,可以設計對應算法計算最優值。
大致步驟爲:首先將i=j,也即單個矩陣的情況最優值設爲0;然後逐漸向上計算多個矩陣的情況,最後得到n個矩陣的情況。
對於n個矩陣A1A2…An,其最優值爲m[1][n]。
//p儲存矩陣的維數;m存儲矩陣Ai到Aj的最小乘積次數;s存儲最小乘積次數對應的切斷點。
void minMetrixChain(int n,int* p,int** m,int** s) // 動態規劃,自下而上求所有子集的乘積最小次數
{
for(int i = 0;i <= n;i++){
m[i][i] = 0; //令對角線元素爲0
}
for(int r = 1;r <= n - 1;r++){ //i與j的差,最大爲n-1
for(int i = 1;i <= n - r;i++){ //i從1開始求m和s的元素
int j = i + r;
m[i][j] = m[i+1][j] + p[i-1]*p[i]*p[j]; //初值,切在i位置,即第一項單獨計算,後面r-i項一起算
s[i][j] = i; //初值記錄s
for(int k = i + 1;k <j;k++){ //切在k位置,i~k爲一組,k+1~j爲一組
int t = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
if(t < m[i][j]){
m[i][j] = t;
s[i][j] = k;
}
}
}
}
}
● 構造最優解
上一步計算最優值的過程已經得到了構造最優解的所需信息。由此設計算法構造最優解:
//s中儲存了分割點的信息
void traceBack(int i,int j,int **s)
{
if(i == j)
return;
traceBack(i,s[i][j],s);
traceBack(s[i][j]+1,j,s);
cout <<"Multiply A" << i << "," << s[i][j] << " and A" << s[i][j] + 1 << "," << j << endl;
}
(2) 最長公共子序列(LCS)
● 問題描述
子序列:對於X1X2…Xn,有一個序列Xi…Xj的下標嚴格遞增,則稱後一序列爲前一序列的子序列。
子序列不要求連續,但要求子序列內的元素順序必須在原序列中有序。
試求兩個序列X=x1x2…xm和Y=y1y2…yn的最長公共子序列。這一問題又稱爲LCS問題。
● 問題分析
設Z=z1z2…zk是X和Y的最長公共子序列,則LCS具有如下最優子結構性質:
- 若xm=yn,則Zk-1是Xm-1和Yn-1的最長公共子序列;
- 若xm≠yn,且zk≠xm,則Z是Xm-1和Y的最長公共子序列;
- 若xm≠yn,且zk≠yn,則Z是X和Yn-1的最長公共子序列。
● 建立方程
設c[i,j]爲Xi與Yj的最長公共子序列長度,根據上面的性質,可以得到動態規劃方程:
● 計算最優值
根據動態規劃方程,可以設計算法計算最優值。
大致步驟爲:令c[i][0]和c[0][j]均爲0,再根據方程,用兩循環嵌套逐步向上計算LCS值。
void longestCommonString(int *X,int n,int *Y,int m,int **lcsNum,int **lcsWay)
{
//初始化將空序列與任意序列的最大子序列設爲無
for(int i = 1;i <= n;i++)
lcsNum[i][0] = 0;
for(int i = 1;i <= m;i++)
lcsNum[0][i] = 0;
//根據遞推公式構造
for(int i = 1;i <=n;i++){
for(int j = 1;j <= m;j++){
if(X[i] == Y[j]){
lcsNum[i][j] = lcsNum[i-1][j-1] + 1;
lcsWay[i][j] = 1;
}
else if(lcsNum[i-1][j] > lcsNum[i][j-1]){
lcsNum[i][j] = lcsNum[i-1][j];
lcsWay[i][j] = 2;
}
else{
lcsNum[i][j] = lcsNum[i][j-1];
lcsWay[i][j] = 3;
}
}
}
}
● 構造最優解
上面計算最優值的過程已經得到了構造最優解的所需信息。
//lcsWay存有LCS的構造路徑。
void printLCS(int n,int m,int **lcsNum,int **lcsWay,int *X)
{
if(n == 0 || m == 0) return;
if(lcsWay[n][m] == 1){
printLCS(n-1,m-1,lcsNum,lcsWay,X);
cout << X[n];
}
else if(lcsWay[n][m] == 2)
printLCS(n-1,m,lcsNum,lcsWay,X);
else if(lcsWay[n][m] == 3)
printLCS(n,m-1,lcsNum,lcsWay,X);
}
(3) 0-1揹包問題
● 問題描述
給定一個物品集合s ={1,2,3,…,n},物品i的重量是wi,價值是vi,揹包的容量爲W。在限定的重量內,怎麼裝物品才能使物品的總價最大。
當物品允許拆分時,稱爲揹包問題,適用貪心算法。
當物品不允許拆分時,稱爲0-1揹包問題,需要使用動態規劃。
下面討論0-1揹包問題的求解。
● 問題分析
用數學語言描述該問題,就是找到一個s的子集s‘滿足:
前式稱爲目標函數,後式稱爲約束方程。
● 建立方程
設 p[i,j] 表示在可選物品爲s’={i,i+1…n}和剩餘容量爲j的情況下,對應的最大價值即最優值。
對於任意的p[i,j],都有如下關係:
- 當wi>j時,無法裝入物品i,因此最優值一定爲p[i+1,j];
- 當wi<j時,可以裝入物品,此時最優值可能爲p[i+1,j-wi]+vi,需要取較大值。
由此得到動態規劃方程:
● 計算最優值
由動態規劃方程,可以設計算法計算最優值:
//n爲物品總數,c爲最大容量,w儲存物品重量、v儲存物品價值、p用於遞推
void knapSack(int n,int c,int *w,int *v,int **p)
{
//jMax表示物品能否存入的邊界情況;防止出現c < w[i]的情況造成越界
int jMax = min(w[n]-1,c);
//對小於w[n]的j
for(int j = 0;j <= jMax;j++)
p[n][j] = 0;
//對大於等於w[n]的j
for(int j = w[n];j <= c;j++)
p[n][j] = v[n];
//由動態規劃方程遞推
for(int i = n - 1;i > 0;i--){
jMax = min(w[i]-1,c);
for(int j = 0;j <= jMax;j++)
p[i][j] = p[i+1][j];
for(int j = w[i];j <= c;j++)
p[i][j] = max(p[i+1][j],p[i+1][j-w[i]]+v[i]);
}
}
● 構造最優解
計算最優值的過程已經獲得了構造最優解的所需信息。
//x儲存物品的選擇情況;0爲未選擇,1爲已選擇
void traceBack(int c,int n,int* w,int *v,int **p,int *x)
{
for(int i = 1;i < n;i++){
if(p[i][c] == p[i+1][c])
x[i] = 0;
else{
x[i] = 1;
c -= w[i];
}
}
x[n] = p[n][c] == 0 ? 0 : 1;
}
動態規劃在不同的問題中,難度也有變化。在刷題時,發現難題的動態規劃簡直看答案都看不明白。這個算法還需要多多體會、多多學習。