動態規劃
前幾天被阿里校招筆試一道裝箱問題的編程題嚇懵逼了,遂決定好好看看動態規劃的東西,結合在牛客網上的課程,總結一下基礎動態規劃的知識。
動態規劃的關鍵點在於解決冗餘和記憶化搜索。當遇到一道需要暴力搜索方法解決的問題時,都可以考慮使用動態規劃的方法解決。
動態規劃的推導過程
動態規劃的大致過程可以表示爲:暴力搜索方法->記憶化搜索方法->動態規劃->狀態繼續化簡後的動態規劃方法
首先,動態規劃方法不是空穴來風,他是程序員在解決實際問題中總結出來的經驗。
舉個例子,我們有一道題目,來自牛客網:
有數組penny,penny中所有的值都爲正數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數aim(小於等於1000)代表要找的錢數,求換錢有多少種方法。
給定數組penny及它的大小(小於等於50),同時給定一個整數aim,請返回有多少種方法可以湊成aim。
首先,考慮使用最直觀的暴力搜索方法:
public int coins1(int[] arr, int aim) {
if(arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process1(arr, 0, aim);
}
// arr表示penny數組,index表示剩餘penny數組索引起點,aim表示目標錢數
// 這個函數的意思表示通過arr[index]——arr[arr.length-1]的鈔票來拼湊aim
process1(int[] arr, int index, int aim) {
int res = 0;
if(index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for(int i = 0; arr[index] * i <= aim; i++) {
res += process1(arr, index + 1, aim - arr[index] * i);
}
}
}
可以看到,暴力搜索使用標準的遞歸方法求解,簡單明瞭。但是,可以發現,在遞歸的時候,許多子集會進行重複的計算,例如,假設arr={5, 10, 25, 1}, aim=1000
,在處理已經使用0張5元,1張10元
和2張5元、0張10元
的情況下,會重複計算process1(arr, 2, 990)
。因此,暴力搜索還有很大的優化空間。
接下來,優化暴力搜索帶來的重複計算量,使用一個二維數組存儲每次中間計算結果,去處重複計算。
public int coins2(int[] arr, int aim) {
if(arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int[][] map = new int[arr.length + 1][aim + 1];
return process2(arr, 0, aim, map);
}
// map存儲所有狀態結果
public int process2(int[] arr, int index, int aim, int[][] map) {
int res = 0;
if(index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
int mapValue = 0;
for(int i = 0; arr[index] * i <= aim; i++) {
mapValue = map[index + 1][aim - arr[index] * i];
if(mapValue != 0) {
res += mapValue == -1 ? 0 : mapValue;
} else {
res += process2(arr, index + 1, aim - arr[index] * i, map);
}
}
}
map[index][aim] = res == 0 ? -1 : res;
return res;
}
這樣,在每次計入遞歸過程的時候,都在表中查詢,如果已經計算過了便不再計算。但是,凡是能夠通過遞歸調用完成的計算,都可以通過嚴格規定計算順序,通過非遞歸的方式替代,並且將節省大量的遞歸計算開銷,這種方法就是動態規劃方法。
還是考慮上面的案例,首先,我們需要建立一張狀態表,如果arr
長度爲N,生成行數爲N,列數爲aim+1
的矩陣dp
。dp[i][j]
的含義是在使用arr[0...i]
貨幣的情況下,組成錢數爲j
有多少種方法。如下:
從上圖dp[i][j]
的求解過程來看,它需要枚舉上一行的數據,遍歷dp[i-1][j-k*arr[i]
,其中0<=k*arr[i]<=aim
。 所需的時間複雜度爲
顯然,這樣太過複雜,看下圖,會發現上面的枚舉的式子可以進一步改寫爲dp[i][j] = dp[i][j-arr[i]] + dp[i-1][j]
,這樣,之前的動態規劃的複雜度經過化簡後可以達到
public int coins3(int[] arr, int aim) {
int[][] dp = new int[n][aim+1];
for(int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for(int j = 0; j < aim+1; j++) {
if(j % penny[0] == 0)
dp[0][j] = 1;
}
for(int i = 1; i < n; i++) {
for(int j = 1; j < aim+1; j++) {
if(j - penny[i] >= 0)
dp[i][j] = dp[i-1][j] + dp[i][j-penny[i]];
else
dp[i][j] = dp[i-1][j];
}
}
return dp[n-1][aim];
}
如上,是在時間複雜度上進行了優化,個狀態之間的關係就非常簡潔明瞭了。但是,我們還可以在空間複雜度上進行進一步優化,將二維狀態矩陣使用一維矩陣替代。可以看到,動態規劃在對求解順序上有着非常嚴格的要求,上述狀態求解就是從矩陣左上角依次從上到下,從左到右求解的過程。後面的狀態依賴於前端的狀態。因此,如果我們不需要存儲中間的結果,只需要得到最終結果,那麼完全可以捨棄對每一行狀態的存儲。但是,仍然需要嚴格遵守求解順序。程序可以優化如下:
public int coins4(int[] penny, int n, int aim) {
int[] dp = new int[aim+1];
dp[0] = 1;
for(int i = 0; i < n; i++) {
for(int j = penny[i]; j < aim+1; j++) {
dp[j] += dp[j - penny[i]];
}
}
return dp[aim];
}
程序的空間複雜度由
例題
題目全部來自於牛客網課程,代碼爲自己編寫。
臺階問題
有n級臺階,一個人每次上一級或者兩級,問有多少種走完n級臺階的方法。爲了防止溢出,請將結果Mod 1000000007。
測試樣例:
1
返回:1
public int countWays(int n) {
// write code here
int[] dp = new int[n+1];
dp[0] = 0;
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i < n+1; i++) {
dp[i] = (dp[i-1] + dp[i-2])%1000000007;
}
return dp[n];
}
矩陣最小路徑和
有一個矩陣map,它每個格子有一個權值。從左上角的格子開始每次只能向右或者向下走,最後到達右下角的位置,路徑上所有的數字累加起來就是路徑和,返回所有的路徑中最小的路徑和。
給定一個矩陣map及它的行數n和列數m,請返回最小路徑和。保證行列數均小於等於100.
測試樣例:
[[1,2,3],[1,1,1]],2,3
返回:4
public int getMin(int[][] map, int n, int m) {
// write code here
int[][] dp = new int[n][m];
dp[0][0] = map[0][0];
for(int i = 1; i < n; i++)
dp[i][0] += dp[i-1][0] + map[i][0];
for(int j = 1; j < m; j++)
dp[0][j] = dp[0][j-1] + map[0][j];
for(int i = 1; i < n; i++) {
for(int j = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j-1], dp[i-1][j]) + map[i][j];
}
}
return dp[n-1][m-1];
}
LIS(最長上升子序列)
這是一個經典的LIS(即最長上升子序列)問題,請設計一個儘量優的解法求出序列的最長上升子序列的長度。
給定一個序列A及它的長度n(長度小於等於500),請返回LIS的長度。
測試樣例:
[1,4,2,5,3],5
返回:3
public int getLIS(int[] A, int n) {
// write code here
int[] dp = new int[n];
int max = 0;
for(int i = 0; i < n; i++) {
dp[i] = 1;
}
for(int i = 1; i < n; i++) {
for(int j = 0; j < i; j++) {
if(A[i] > A[j] && dp[i] < dp[j]+1)
dp[i] = dp[j] + 1;
}
}
for(int i = 0; i < n; i++) {
max = dp[i] > max ? dp[i] : max;
}
return max;
}
LCS(最長公共子序列)
給定兩個字符串A和B,返回兩個字符串的最長公共子序列的長度。例如,A=”1A2C3D4B56”,B=”B1D23CA45B6A”,”123456”或者”12C4B6”都是最長公共子序列。
給定兩個字符串A和B,同時給定兩個串的長度n和m,請返回最長公共子序列的長度。保證兩串長度均小於等於300。
測試樣例:
“1A2C3D4B56”,10,”B1D23CA45B6A”,12
返回:6
char[] a = A.toCharArray();
char[] b = B.toCharArray();
int[][] dp = new int[n][m];
boolean finda = false, findb = false;
for(int i = 0; i < n; i++) {
if(a[i] == b[0])
finda = true;
if(finda)
dp[i][0] = 1;
}
for(int i = 0; i < m; i++) {
if(b[i] == a[0])
findb = true;
if(findb)
dp[0][i] = 1;
}
for(int i = 1; i < n; i++) {
for(int j = 1; j < m; j++) {
if(a[i] == b[j]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[n-1][m-1];
01揹包問題
一個揹包有一定的承重cap,有N件物品,每件都有自己的價值,記錄在數組v中,也都有自己的重量,記錄在數組w中,每件物品只能選擇要裝入揹包還是不裝入揹包,要求在不超過揹包承重的前提下,選出物品的總價值最大。
給定物品的重量w價值v及物品數n和承重cap。請返回最大總價值。
測試樣例:
[1,2,3],[1,2,3],3,6
返回:6
public int maxValue(int[] w, int[] v, int n, int cap) {
int[] dp = new int[cap+1];
for(int i = 0; i < n; i++) {
for(int j = cap; j >=w[i]; j--) {
dp[j] = Math.max(dp[j-w[i]] + v[i], dp[j]);
}
}
return dp[cap];
}
最優編輯問題
對於兩個字符串A和B,我們需要進行插入、刪除和修改操作將A串變爲B串,定義c0,c1,c2分別爲三種操作的代價,請設計一個高效算法,求出將A串變爲B串所需要的最少代價。
給定兩個字符串A和B,及它們的長度和三種操作代價,請返回將A串變爲B串所需要的最小代價。保證兩串長度均小於等於300,且三種代價值均小於等於100。
測試樣例:
“abc”,3,”adc”,3,5,3,100
返回:8
public int findMinCost(String A, int n, String B, int m, int c0, int c1, int c2) {
// write code here
int[][] dp = new int[n][m];
char[] a = A.toCharArray();
char[] b = B.toCharArray();
boolean findb = false, finda = false;
for(int i = 0; i < n; i++) {
if(a[i] == b[0])
findb = true;
if(findb)
dp[i][0] = c1*i;
else
dp[i][0] = Math.min(c1*(i+1)+c0, c1*i+c2);
}
for(int j = 0; j < m; j++) {
if(a[0] == b[j])
finda = true;
if(finda)
dp[0][j] = c0*j;
else
dp[0][j] = Math.min(c0*(j+1)+c1, c0*j+c2);
}
for(int i = 1; i < n; i++) {
for(int j = 1; j < m; j++) {
if(a[i] == b[j])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = Math.min(Math.min(dp[i-1][j]+c1, dp[i][j-1]+c0),
Math.min(dp[i-1][j-1]+c2, dp[i-1][j-1]+c0+c1));
}
}
return dp[n-1][m-1];
}