動態規劃算法
動態規劃(dynamic programming)是運籌學的一個分支,是求解決策過程(decision process)最優化的數學方法。
一、前序知識
- 最優化原理:
簡單來說就是一個最優策略的子策略也是必須是最優的,而所有子問題的局部最優解將導致整個問題的全局最優。如果一個問題能滿足最優化原理,就稱其具有最優子結構性質
一、基本思想
動態規劃算法通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,我們希望找到具有最優值的解。動態規劃算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規劃求解的問題,經分解得到子問題往往不是互相獨立的。若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重複計算了很多次。如果我們能夠保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重複計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。不管該子問題以後是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規劃法的基本思路。具體的動態規劃算法多種多樣,但它們具有相同的填表格式。
二、適用情況
- 最優子結構性質:如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質(即滿足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。
- 無後效性:即子問題的解一旦確定,就不再改變,不受在這之後、包含它的更大的問題的求解決策影響。
- 子問題重疊性質:子問題重疊性質是指在用遞歸算法自頂向下對問題進行求解時,每次產生的子問題並不總是新問題,有些子問題會被重複計算多次。動態規劃算法正是利用了這種子問題的重疊性質,對每一個子問題只計算一次,然後將其計算結果保存在一個表格中,當再次需要計算已經計算過的子問題時,只是在表格中簡單地查看一下結果,從而獲得較高的效率。(重疊子問題)
三、一般解題思路
- 將原問題轉化爲子問題:
- 子問題與原問題形式相同或類似,只是問題規模變小了,從而變簡單了;
- 子問題一旦求出就要保存下來,保證每個子問題只求解一遍
- 確定狀態:
- 狀態:和子問題相關的各個變量的一組取值 ,稱之爲一個“狀態”。一個“狀態”對應於一個或多個子問題,所謂某“狀態的值”,就是這個“狀態”所對應的子問題的解。
- 狀態空間:所有“狀態”的集合,構成問題的“狀態空間”,“狀態空間”的大小,與用動態規劃解決問題的時間複雜度直接相關。
- 確定一些初始狀態(邊界狀態)
可以理解爲遞歸的終止條件 - 確定狀態轉移方程:
狀態的轉移可以用遞推公式表示,此地推公式也可以被稱作“狀態轉移方程”。
形式類似如下:
四、經典案例
1、0/1揹包問題
0-1 揹包問題: 給定 n 種物品和一個容量爲 C 的揹包,物品 i 的重量是 wi,其價值爲 vi
問:應該如何選擇裝入揹包的物品,使得裝入揹包中的物品的總價值最大?。
解題步驟:
- 將原問題轉化爲子問題:
子問題:
當揹包容量爲 0 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
當揹包容量爲 1 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
當揹包容量爲 2 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
。。。。。。
當揹包容量爲 8 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
一共有 (c+1) * (n+1) 個子問題。(c:揹包容量,n:物品數量) - 確定狀態:
定義狀態,對於 0/1 揹包問題,可以設計一個二維數組dp[n][c]來存放子問題的狀態, 而每一個子問題的狀態爲 d[i][j] 表示 從物品 1 ~ i 物品中選擇放入容量爲 j 的揹包得到的最優解。 - 確定一些初始狀態(邊界狀態):
當不放入物品時,價值爲0:dp[0][0~c] = 0
當揹包容量爲0時,價值爲0:dp[0~n][0] = 0 - 確定狀態轉移方程:
該問題的狀態轉移方程即:分析得出 dp[i][j] 的計算方法-
j < w[i] 的情況,這時候揹包容量不足以放下第 i 件物品,只能選擇不拿
dp[ i ][ j ] = dp[ i-1 ][ j ] -
j >= w[i] 的情況,這時揹包容量可以放下第 i 件物品,我們就要考慮拿這件物品是否能獲取更大的價值。
- 如果拿取:dp[ i ][ j ] = dp[ i-1 ][ j-w[ i ] ] + v[ i ]。 這裏的 dp[ i-1 ][ j-w[ i ]]是從 1 ~ i-1 件物品選擇,揹包容量爲 j-w[i] 時的最大價值。
- 如果不拿:dp[ i ][ j ] = dp[ i-1 ][ j ]
通過比較這兩種情況哪種價值最大,來判斷是否要放入第 i 個物品,即:
-
if(j >= w[i])
dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
else
dp[i][j] = dp[i-1][j];
實戰:
假如有1個揹包,揹包容量是8。有4個物品,重量分別爲:{2,3,4,5},價值爲:{3,4,5,6}
要求在不超過揹包容量的情況下,使揹包裝載物品的價值最大。
dp[n][c]數組計算結果如下:當揹包容量爲8,從4個物品中選,最優解爲 dp[4][8] = 10
物品編號 i \ 揹包容量 j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 0 | 3 | 4 | 7 | 7 | 7 | 7 | |
3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
4 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 |
計算過程:以表格中的藍色的4,即 dp[2][3] 進行分析:
dp[2][3]: 當不裝入2物品時,dp[2][3] = dp[1][3] = 3;當裝入2物品時,揹包容量爲3,去除2物品的重量,揹包容量還剩0,即 dp[2][3] = dp[1][0] + w[2] = 0 + 4
選兩者的最大值4,即dp[2][3] = 4,填入dp[][]表。
通過這一種方式,我們可以得到揹包問題中的最優解,但是並不能知道在最優解的情況下,揹包中放入了哪些物品。
爲了想要知道在最優解時揹包中放了哪些物品,我們可以採取回溯的方法。從表格的右下角(動態規劃的終點)開始回溯,利用性質 dp[i][j] == dp[i-1][lj] 表示第 i 個物品沒有放入揹包,不相等則表示放入揹包。
- 回溯
dp[4][8] != dp[3][8]:第 4 個物品放入揹包。第4個物品放入揹包,揹包的容量爲 8-5=3,這時候回溯到 dp[3][3]。
dp[3][3] == dp[2][3]:第 3 個物品沒有放入揹包。這時候可以回溯到dp[2][3]
dp[2][3] != dp[1][3]:第 2 個物品放入揹包。第2個物品放入揹包,揹包的容量爲 3-3=0,這時候回溯到 dp[1][0]。
dp[1][0] == dp[0][0]:第 1 個物品沒有放入揹包
最終可以得出在最優解爲10時,此時揹包中放入的物品爲:物品{2,4}
算法實現
package indi.pentiumcm.thought;
import java.util.ArrayList;
import java.util.List;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.thought
* @className: DpBack
* @author: pentiumCM
* @email: [email protected]
* @date: 2020/3/25 23:38
* @describe: 動態規劃 - 0/1揹包問題
*/
public class DpBack {
/**
* 0/1揹包算法實現
*
* @param backCap 揹包的容量
* @param weights 物品的重量數組
* @param values 物品的價值數組
* @return
*/
public void backPro(int backCap, int[] weights, int[] values) {
// 物品個數
int goodNum = weights.length;
// 1. 確定狀態
// dp[i][j] 表示前i件物品放入重量爲j的揹包時的最大價值
int[][] dp = new int[goodNum + 1][backCap + 1];
// 2. 確定一些初始狀態:dp[0][0~backCap]=0,dp[0~goodNum][0]=0
for (int i = 0; i <= backCap; i++) {
dp[0][i] = 0;
}
for (int i = 0; i <= goodNum; i++) {
dp[i][0] = 0;
}
// 3.確定狀態轉移方程:dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
for (int i = 1; i <= goodNum; i++) {
// j 從 1 遍歷到揹包容量
for (int j = 1; j <= backCap; j++) {
if (weights[i - 1] <= j) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
// 回溯,看揹包裏面放了哪些物品
int tempCap = backCap;
List<Integer> backList = new ArrayList<>();
for (int i = goodNum; i > 0; i--) {
if (dp[i][tempCap] != dp[i - 1][tempCap]) {
backList.add(i);
tempCap -= weights[i - 1];
}
}
System.out.print("揹包中放了物品:");
for (int i = backList.size() - 1; i >= 0; i--) {
System.out.print(" " + backList.get(i));
}
System.out.print("\n" + "獲得的最大價值爲: " + dp[goodNum][backCap]);
}
public static void main(String[] args) {
// 揹包容量
int backCap = 8;
// 物品的重量
int[] weights = {2, 3, 4, 5};
// 物品的價值
int[] values = {3, 4, 5, 6};
new DpBack().backPro(backCap, weights, values);
}
}
運行結果:
2、旅行商問題
旅行商問題(TSP問題):
是指旅行家要旅行n個城市,要求各個城市經歷且僅經歷一次然後回到出發城市,並要求所走的路程最短。
示例:
其中0,1,2,3,4代表五個城市,例如從城市0出發,經過城市1,2,3,4,最後回到城市0。
此模型可抽象爲圖,城市之間的距離可用鄰接矩陣 C 表示,如下圖所示:
解題步驟:
-
將原問題轉化爲子問題:
原問題:
從0出發,經過{1,2,3,4}這幾個城市,然後回到0,使得路程最短。
子問題:- 從頂點 0 出發,到1,然後再從1出發,經過{2,3,4}這幾個城市,然後回到0,使得路程最短。
- 從頂點 0 出發,到1,然後再從1出發到2,經過{3,4}幾個城市,最後回到0,使得路程最短。
。。。。。。
- 從頂點 0 出發,到1,然後再從1出發到2,經過{3,4}幾個城市,最後回到0,使得路程最短。
- 從頂點 0 出發,到2,然後再從2出發,經過{1,3,4}這幾個城市,然後回到0,使得路程最短。
- 。。。。。。
- 從頂點 0 出發,到3,然後再從3出發,經過{1,2,4}這幾個城市,然後回到0,使得路程最短。
- 。。。。。。
- 從頂點 0 出發,到4,然後再從4出發,經過{1,2,3}這幾個城市,然後回到0,使得路程最短。
- 。。。。。。
- 從頂點 0 出發,到1,然後再從1出發,經過{2,3,4}這幾個城市,然後回到0,使得路程最短。
-
確定狀態:
假設從頂點s出發,令d(i, V)表示從頂點 i 出發經過V(點的集合)中各個頂點一次且僅一次,最後回到出發點s的最短路徑長度。 -
確定一些初始狀態(邊界狀態):
當V爲空集,那麼d(i, V)爲 d(i,{∅}),表示直接從 i 回到 s 了,此時d(i,V) = Cis(Cis表示頂點 i 和 s 之間的距離)
// 初始化dp表的第一列,d(i,{∅})
for(int i =0;i <n;i++){
dp[i][0] = C[i][0];
}
- 確定狀態轉移方程:
當V不爲空,那麼就是對子問題的最優求解。須在V這個城市集合中,嘗試每一個,並求出最優解。
d(i,V) = min{ Cik + d(i,V-k) } (Cik:相鄰城市 i 和 k的距離,d(i,V-k):子問題)
所以狀態轉移方程爲:
過程步驟:
假設從城市 0 出發,分別要經過{1,2,3,4},最終回到城市0。所以原問題爲:d(0,{1,2,3,4})。
以上是選擇了路徑爲:0 → 1 → 2 → 3 → 4 → 0
d(0,{1,2,3,4}) = min{ d(1,{2,3,4}) + C01,d(2,{1,3,4}) + C02,d(3,{1,2,4}) + C03,d(4,{1,2,3}) + C04 }
d(1,{2,3,4}) = min{ d(2,{3,4}) + C12,d(3,{2,4}) + C13,d(4,{2,3}) + C14 }
d(2,{3,4}) = min{ d(3,{4}) + C23,d(4,{3}) + C24 }
d(3,{4}) = min{ d(4,{∅}) + C34 }
d(4,{∅}) = C40
dp表結構:
設置一個二維的動態規劃表dp:
先確定一下dp表的大小,有n個城市,從0開始編號,那麼dp表的行數就是n,列數就是2(n-1),集合{1,2,3,4}的子集個數。在求解的時候,第一列的值對應這從鄰接矩陣可以導出,後面的列可以有前面的列和鄰接矩陣導出。
其次,爲了編程方便,建立{ }二進制編碼轉換:{1,3,4}表示爲二進制的1101,十進制的13,對於規則爲:其中集合裏面有的數對應的二進制中位數寫成1,沒有的寫成0。
- 對於第y個城市,他的二進制表達爲,1 << (y-1)
- 對於數x,要看它的第 i 位是不是1,那麼可以通過判斷布爾表達式 (((x >> (i - 1) ) & 1) == 1的真值來實現。
- 符號{1,2,3,4}: 表示經過{1,2,3,4}這幾個城市,然後回到0。那麼題目就是求dp[0][{1,2,3,4}],即dp[0][15]。
(d[i][j]:i 表示某一步的起點城市,j 爲需要經過城市集合的十進制,如{1,2,3}爲二進制的111,對應的十進制爲7。) - 編程過程中的狀態轉移:
dp[ i ][ j ] = C[ i ][ k ] + dp[ k ][ j ^ (1 << (k - 1)) ],解釋如下,等式左邊表示問題:dp[ i ][ j ]從 i 出發經過 j 對應的頂點集。等式右邊表示將等式左邊的問題拆分爲子問題:從 i 出發到 j 對應的點集中的 頂點 k,然後從頂點 k 經過 剩餘的點集。
j ^ (1 << (k - 1)): 這個其實就是 j 對應的點集中去除 頂點 K 之後的點集。相信很多朋友一下子理解不了,我舉個例子來解釋一下,當 j = 7時,二進制爲111,對應的頂點集爲{1,2,3},我們選出頂點 3,則剩下的點集爲{1,2},這個過程:j 對應爲 111,3 對應爲 100 = 1 << (3-1),將 j 的 111 和 3 的100 進行異或便可求出剩餘的點集,也可以從減法的角度理解: 111(2) - 100(2) = 11(2)
{∅} | {1} | {2} | {1,2,3} | {1,2,3,4} | |
---|---|---|---|---|---|
二進制 | 0 | 1 | 10 | 111 | 1111 |
十進制 | 0 | 1 | 2 | 7 | 15 |
所以求出的動態規劃表就是:
{∅} | {1} | {2} | {1,2} | {3} | {1,3} | {2,3} | {1,2,3} | {4} | {1,4} | {2,4} | {1,2,4} | {3,4} | {1,3,4} | {2,3,4} | {1,2,3,4} | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
索引 i \ j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
0 | ∞ (C00) | 6 (C01+C10) | ∞(C02+C20) | ∞ | 16 | 21 | ∞ | 18 | 18 | 17 | ∞ | 18 | 37 | 32 | 24 | 23 |
1 | 3 (C10) | ∞(C11+C10) | ∞ | ∞ | 18 | ∞ | 15 | ∞ | 14 | ∞ | 15 | ∞ | 33 | ∞ | 20 | ∞ |
2 | ∞ C(20) | 6(C21+C10) | ∞ | ∞ | 12 | 17 | ∞ | ∞ | 12 | 11 | ∞ | ∞ | 31 | 26 | ∞ | ∞ |
3 | 8 (C30) | 13(C31+C10) | ∞ | 10 (min{10+∞, 4+6}) | ∞ | ∞ | ∞ | ∞ | 29 | 24 | 16 | 15 | ∞ | ∞ | ∞ | ∞ |
4 | 9 (C40) | 8(C41+C10) | ∞ | 9 (min{3+∞, 3+6}) | 28 | 23 | 15 | 20 | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ |
子問題 | d(i,{∅}) = Ci0 | d(i,{1}) = Ci1+d(1,{∅}) = Ci1 + C10 | d(i,{2}) = Ci2+d(2,{∅}) = Ci2 + C20 | d(i,{1,2})=min{ Ci1+d(1,{2}) ,Ci2+d(2,{1}) } | d(i,{3}) = Ci3+d(3,{∅}) = Ci3+C30 | d(i,{1,3})=min{ Ci1+d(1,{3}) ,Ci3+d(3,{1}) } |
同樣,這種方式只能獲取到最優值,無法獲取獲取到最優值情況中的路徑,爲了獲取到最優路徑,同樣可以採用回溯的方法。
- 回溯:
如:dp[0]{1,2,3,4} = dp[0][15] = min{ C01 + dp[1]{2,3,4},C02 + dp[2]{1,3,4},C03 + dp[3]{1,2,4},C04 + dp[4]{1,2,3} } = C01 + dp[1]{2,3,4}。可得這一路徑爲:0 -> 1,接下來從 dp[1]{2,3,4} 開始回溯,直到所有城市頂點被遍歷完。
算法實現:
package indi.pentiumcm.thought;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.lang.Double.POSITIVE_INFINITY;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.thought
* @className: DpTSP
* @author: pentiumCM
* @email: [email protected]
* @date: 2020/3/28 22:05
* @describe: 動態規劃 - 旅行商問題
*/
public class DpTSP {
// 無窮大
static int max = (int) POSITIVE_INFINITY / 2;
/**
* 旅行商問題算法實現
*
* @param distances 各個頂點之間距離的鄰接矩陣的二維數組
*/
public void tsp(int[][] distances) {
int cityNum = distances.length;
int colNum = 1 << cityNum - 1;
// 1. 構建動態規劃dp表
int[][] dp = new int[cityNum][colNum];
// 2. 確定一些初始狀態:初始化dp表的第一列,即d(i,{∅}) = Ci0
for (int i = 0; i < cityNum; i++) {
// 每個城市回到起點的距離
dp[i][0] = distances[i][0];
}
// 3. 確定狀態轉移方程,d[i][j]:i - 城市,j - {1,2,3,4}等
for (int j = 1; j < colNum; j++) {
for (int i = 0; i < cityNum; i++) {
dp[i][j] = max;
// {j}中的頂點包含起點i ,就continue
if (((j >> (i - 1)) & 1) == 1) {
continue;
}
// 遍歷{ }裏面的點集
for (int k = 1; k < cityNum; k++) {
// 排除{V}中不包含的頂點,如j = 5(二進制101)時,{}爲{1,3},這時候並不含有2,4,需要排除掉,直接continue
if (((j >> (k - 1)) & 1) == 0) {
continue;
}
// 從i出發到 k,然後從k出發經過{V-k}
if (dp[i][j] > distances[i][k] + dp[k][j ^ (1 << (k - 1))]) {
dp[i][j] = distances[i][k] + dp[k][j ^ (1 << (k - 1))];
}
}
}
}
int minDistance = dp[0][colNum - 1];
// 回溯,求最短路徑的流行
List<Integer> path = new ArrayList<>();
// 存放剩餘未走的城市頂點,key-value:城市頂點標號
Map<Integer, Integer> remPath = new HashMap<>();
for (int i = 1; i < cityNum; i++) {
remPath.put(i, i);
}
// 將起點先存入列表
path.add(0);
int choseCity = 0;
for (int j = cityNum - 1; j > 0; j--) {
int col2 = 0;
for (Integer cityIndex : remPath.keySet()) {
col2 += 1 << remPath.get(cityIndex) - 1;
}
for (int i = 1; i < cityNum; i++) {
choseCity = path.get(path.size() - 1);
int cDis = distances[choseCity][i];
if (dp[i][col2 ^ (1 << (i - 1))] + cDis == minDistance) {
path.add(i);
remPath.remove(i);
minDistance -= cDis;
break;
}
}
}
path.add(0);
System.out.print("選擇的最佳路徑爲:");
for (int i = 0; i < path.size(); i++) {
System.out.print(" " + path.get(i));
}
System.out.print("\n" + "最佳路徑長度爲:" + dp[0][colNum - 1]);
}
public static void main(String[] args) {
// 各個城市之間的距離的鄰接矩陣
int[][] distances = {
{max, 3, max, 8, 9},
{3, max, 3, 10, 5},
{max, 3, max, 4, 3},
{8, 10, 4, max, 20},
{9, 5, 3, 20, max}
};
new DpTSP().tsp(distances);
}
}
運行結果:
3. 最大和子串問題
- 問題描述
給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
示例:
輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。
解題套路一致,分析過程在代碼註釋中,如下↓:
package indi.pentiumcm.leetcode;
import java.util.Arrays;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.leetcode
* @className: Q15
* @author: pentiumCM
* @email: [email protected]
* @date: 2020/4/1 10:55
* @describe: 53. 最大子序和 - 動態規劃解決
*/
public class Q15 {
public void maxSubArray(int[] nums) {
// 1. 原問題劃分爲子問題
// 原問題:當序列長度爲 n 時,求最大和
// 子問題:當序列長度爲 n -1 時,求最大和
// 子問題:當序列長度爲 n -2 時,求最大和
// 2. 定義狀態:dp[i]爲 遍歷到第 i 個元素時,子串的最大和
int[] dp = new int[nums.length];
// 3. 初始狀態 dp[0] = nums[0]
dp[0] = nums[0];
// 4. 狀態轉移方程:dp[i] = max{dp[i-1] + nums[i], nums[i]}
for (int i = 1; i < nums.length; i++) {
int maxSum = dp[i - 1] + nums[i] > nums[i] ? dp[i - 1] + nums[i] : nums[i];
dp[i] = maxSum;
}
// 遍歷dp[],dp[]最大的值即爲連續子串的最大和
int maxSum = dp[0];
int maxIndex = 0;
for (int i = 0; i < dp.length; i++) {
if (dp[i] > maxSum) {
maxSum = dp[i];
maxIndex = i;
}
}
System.out.println("最大子串和:" + dp[maxIndex]);
// 回溯, 求最大連續子串的元素
System.out.print("最大連續子串元素:");
for (int i = maxIndex; i >= 0; i--) {
if (maxSum == nums[i]) {
System.out.print(" " + nums[i]);
break;
} else {
maxSum -= nums[i];
System.out.print(" " + nums[i]);
}
}
}
public static void main(String[] args) {
int[] arr = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
new Q15().maxSubArray(arr);
}
}