動態規劃的高度套路

動態規劃(百度百科)

動態規劃(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看到大神的一個比喻:你的原問題是考出最高的成績,那麼你的子問題就是把語文考最高分,數學考最高分…爲了每門課考到最高,你要把每門課相對應的選擇題分數拿到最高,填空題分數拿到最高…當然,最終你的每門課都是滿分,這就是最高的總成績。

得到了正確的結果:最高的總成績就是總分。因爲整個過程符合最優子結構,”每門科目考到最高“這些子問題互相獨立,互不干擾。

但是,如果加一個條件:你的語文成績和數學成績會互相制約,此消彼長。這樣的話你能考到的最高總成績就達不到總分了,按照剛纔的思路就得不到正確的結果。因爲子問題不獨立,所以最優子結構會被破壞。

最後就是如果有寫不對的地方可以評論或者私信我,想和我一起討論數據結構的也可以評論和私聊我,本人非常樂意學習和進步

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章