動態規劃(百度百科)
動態規劃(dynamic programming)是運籌學的一個分支,是求解決策過程(decision process)最優化的數學方法。20世紀50年代初美國數學家R.E.Bellman等人在研究多階段決策過程(multistep decision process)的優化問題時,提出了著名的最優化原理(principle of optimality),把多階段過程轉化爲一系列單階段問題,利用各階段之間的關係,逐個求解,創立了解決這類過程優化問題的新方法——動態規劃。1957年出版了他的名著《Dynamic Programming》,這是該領域的第一本著作。
我對動態規劃這個算法的理解
當初大二接觸到數據結構這門課,學習了動態規劃這種算法,老師一直說很難。加上自己當初上網查都是狀態轉移方程、重疊子問題、最優子結構。真的是讓我無能爲力,無從下手。每次解動態規劃老想着狀態轉移方程怎麼得到的,所以讓我望而止步。最後知道了動態規劃都是高度套路的之後,其實也是挺好理解的了。
動態規劃的高度套路
動態規劃統一都是由暴力遞歸–>找到有重複計算的子問題–>動態規劃。
要是你要問,狀態轉移方程怎麼來的,改暴力遞歸來的。動態規劃就是這個一個高度套路。
示例1(斐波那契數列)
大家應該都知道這個數列,f(1)=1,f(2)=1,f(n)=f(n-1)+f(n-2)。
暴力遞歸寫法
public int fib(int N) {
if(N==1 || N==2) return 1;
return fib(N-1) + fib(N-2);
}
這個解法的遞歸展開圖是這樣的(以N=10舉例):
這個時候發現自頂向下的遞歸展開圖有一些計算過程都是重複的,比如fib(8),fib(7)等,所以這樣的暴力遞歸是可以改成動態規劃的。就是改成自底向上的解法
改動態規劃的步驟
首先找到暴力遞歸的base case。就是fib(1)=1,fib(2)=1然後任意一個N都可以由fib(N)=fib(N-1)+fib(N-2)求出
代碼(動態規劃)
public int dp(int N) {
if (N == 1 || N == 2) {
return 1;
}
int[] dp = new int[N];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i < N; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[N - 1];
}
當然這個還可以優化成空間複雜度爲O(1)的解法,但不在本文的討論範圍內。我們只講暴力遞歸能改動態規劃的套路。
示例2(最小路徑和)
給定一個包含非負整數的M*N的矩陣,從左上角走到右下角的過程,使路徑上的數字總和爲最小。每次只能向下或者向右移動一步。
輸入:
[
[1, 3, 1],
[1, 5, 1],
[4, 2, 1]
]
輸出:7
解釋:因爲路徑1->3->1->1->1的總和最小。
先寫暴力遞歸解法
public int minPathSum(int[][] matrix, int i, int j) {
//base case走到最後一格了
if (i == matrix.length - 1 && j == matrix[0].length - 1) {
return matrix[i][j];
}
//最後一行,只能向右走
if (i == matrix.length - 1) {
return matrix[i][j] + minPathSum(matrix, i, j + 1);
}
//最後一列,只能向下走
if (j == matrix[0].length - 1) {
return matrix[i][j] + minPathSum(matrix, i + 1, j);
}
//其他情況
int right = minPathSum(matrix, i, j + 1);
int down = minPathSum(matrix, i + 1, j);
return matrix[i][j] + Math.min(right, down);
}
這個時候的自頂向下的遞歸展開圖是這樣的(以3*3爲例):
此時f(0,0)=Math.min(f(0,1), f(1,0)),
然後f(0,1)=Math.min(f(0,2), f(1,1)),
然後f(1,0)=Math.min(f(1,1),f(2,0))。此時f(1,1)就重複計算了,一直遞歸下去就會一直重複計算
這個時候發現自頂向下的遞歸展開圖又有一些計算過程使重複的,可以改成非遞歸的動態規劃自底向上的解法
改動態規劃的步驟
- 先找到base case,就是最右角的值,matrix[i][j]。就是你不管用什麼方法走,最後都是走到matrix[i][j],即右下角那個地方,所以那個地方就是最小值
- 然後確定了右下角的值,就可以求出最後一行和最後一列的值
- 接下來就是matrix[i][j]的值會等於它右邊和下邊的值的最小值
這就相當於你知道了它的base case(右下角的值),填一張二維數組的dp表,然後dp[0][0]就是左上角到右下角的最短路徑
代碼(動態規劃)
public int minPathSum(int[][] grid) {
int[][] dp = new int[grid.length][grid[0].length];
for (int i = dp.length - 1; i >= 0; i--) {
for (int j = dp[0].length - 1; j >= 0; j--) {
if (i == dp.length - 1 && j == dp[0].length - 1) {
dp[i][j] = grid[i][j];
} else if (i == dp.length - 1 && j != dp[0].length - 1) {
dp[i][j] = grid[i][j] + dp[i][j + 1];
} else if (j == dp[0].length - 1 && i != dp.length - 1) {
dp[i][j] = grid[i][j] + dp[i + 1][j];
} else {
dp[i][j] = Math.min(dp[i + 1][j], dp[i][j + 1]) + grid[i][j];
}
}
}
return dp[0][0];
}
同樣的這道題改成動態規劃後可以優化成空間複雜度爲O(1)的解法,但是本文就不討論後續的優化,只討論怎麼把暴力遞歸改成動態規劃解法。
總結
其實遞歸的本質就是“窮舉”,然後它不知道怎麼優化,它只會自頂向下無限展開,直到base case停下。然後動態規劃相比於它就是自底向上計算,不會重複計算,相當於“聰明的窮舉”。然後就是暴力遞歸改動態規劃是需要有計算重複過程的時候,並且此時的狀態只與以後的狀態有關,和之前的狀態無光,這種稱爲無後效性。這種可以改爲動態規劃。
PS:遞歸算法如漢諾塔問題、N皇后問題都不能改爲動態規劃,因爲它們此時的狀態與之前的狀態是有聯繫的。因爲漢諾塔需要打印出每一步的過程,而且沒有重複計算,所以不能改動態規劃。然後N皇后每一步下的棋都會影響到下一步,所以也不能改動態規劃。
在leetcode看到大神的一個比喻:你的原問題是考出最高的成績,那麼你的子問題就是把語文考最高分,數學考最高分…爲了每門課考到最高,你要把每門課相對應的選擇題分數拿到最高,填空題分數拿到最高…當然,最終你的每門課都是滿分,這就是最高的總成績。
得到了正確的結果:最高的總成績就是總分。因爲整個過程符合最優子結構,”每門科目考到最高“這些子問題互相獨立,互不干擾。
但是,如果加一個條件:你的語文成績和數學成績會互相制約,此消彼長。這樣的話你能考到的最高總成績就達不到總分了,按照剛纔的思路就得不到正確的結果。因爲子問題不獨立,所以最優子結構會被破壞。
最後就是如果有寫不對的地方可以評論或者私信我,想和我一起討論數據結構的也可以評論和私聊我,本人非常樂意學習和進步