動態規劃:第 1 節:理解「重複子問題」(自己的草稿,內容不嚴謹,與之前有重複,不用看)

從這一章開始,我們將向大家開啓「算法」領域另一個重要的話題「動態規劃」。

首先,「動態規劃」這個名字可能聽起來有點讓琢磨不透,感覺它像是在「運籌學」這個領域裏的方法,這一點其實沒有錯,「動態規劃」的方法廣泛運用於各行各業,當然包括「運籌學」等「最優化領域」。

但我們初學「動態規劃」的時候,可以暫時忽略這個「動態規劃」有點讓人捉摸不透的名字。從一個最簡單的例子去理解「動態規劃」的基本思想。

首先我們解釋「規劃」這個詞,在《算法導論》這本書裏,對「規劃」的解釋是「表格」,這一點定義我覺得是非常準確的,因爲可以用「動態規劃」解決的問題,就是讓我們在求解問題的過程中,記錄每一步求解的結果。

下面我們解釋「動態」,我沒有在維基百科以及一些經典的書籍上找到「動態」的解釋,我自己是這樣理解「動態」這個詞。「動態」這是與求解「動態規劃」問題的兩個思路相關的。

「動態規劃」告訴我們求解一個問題,可以不直接求解這個問題,而是去思考這個問題最開始(規模最小的時候)的時候是什麼樣子,然後通過遞推的方式,一步一步得到結果,直到問題得到解決,這是一種「自下而上」的思想。

而我們熟悉的「遞歸」方法,是一種「自上而下」的思想。這兩種思想在絕大多數情況下,都能夠幫助我們解決問題。而「動態」告訴我們「自上而下」「自下而上」都可以解決這一類問題。在這裏給大家一個提示,在我們這門課程裏介紹的絕大多數「動態規劃」的問題,都可以使用「自底向上」的思路解決,樹形 dp 等情況除外。

對於可以使用「動態規劃」解決的問題,主要有下面三個特點:

1、重複子問題;

也叫「重複子問題」,從「斐波拉契數列」求解的問題中,我們知道,如果遞歸地去這個問題,會遇到很多「重複子問題」。這些子問題不應該被重複計算。

2、最優子結構;

求解子問題得到的最優解,組成了規模更大的原問題的最優解,這樣的動態規劃問題,我們稱之爲具有「最優子結構」。

動態規劃問題通常應用的場景是:我們直接求解這個問題感覺難度較大,但是我們把這個問題拆分爲規模更小的問題的時候,這個問題的解通常也就能夠找到,這樣的解決問題的實現通常都要藉助遞歸來實現。

3、無後效性。

  • 在推導後面階段的狀態的時候,我們只關心前面階段的狀態值,不關心這個狀態是怎麼一步一步推導出來的。

  • 某階段狀態一旦確定,就不受之後階段的決策影響。

我們將通過具體的例子來解釋可以使用「動態規劃」方法解決的問題的這 3 個特點。

我們先來看一個最最簡單的問題:「斐波拉契數列」。

「力扣」第 509 題:斐波那契數

方法一:使用遞歸

分析:雖然可以通過,但是認爲是錯的,因爲進行了大量的重複計算。因此時間複雜度是認爲指數級別。(這個結論比較粗糙,由於我們是算法基礎課程,就不帶着代價去研究這個細節了。)

Java 代碼:

class Solution {
    public int fib(int N) {
        if (N < 2) {
            return N;
        }
        return fib(N - 1) + fib(N - 2);
    }
}

在這裏插入圖片描述

解決的辦法是使用一個數組作爲「緩存」,在遇到同樣的問題的時候,先查表。

  • 如果已經計算過,就不再計算;
  • 如果還沒有計算過,就遞歸去計算一次。

Java 代碼:

import java.util.Arrays;

public class Solution {

    public int fib(int N) {
        if (N < 2) {
            return N;
        }

        // 0 要佔一個位置,所以設置 N + 1 個位置
        int[] memo = new int[N + 1];
        Arrays.fill(memo, -1);
        return fib(N, memo);
    }

    public int fib(int n, int[] memo) {
        if (n == 0) {
            return 0;
        }

        if (n == 1) {
            return 1;
        }

        if (memo[n] == -1) {
            memo[n] = fib(n - 1) + fib(n - 2);
        }
        return memo[n];
    }
}

方法二:動態規劃

上面「遞歸」求解的過程是「自底向上」的過程,而「動態規劃」告訴我們一種求解問題的思路:「自底向上」,事實上,我們人在計算的時候,更多會這樣去計算。

  • 「自上而下」和 「自底向上」的解法通常都可以稱爲「動態規劃」;
  • 如果沒有學習過「動態規劃」,通過「遞歸」求解,應該需要知道做了大量重複計算,因此需要加入緩存,這種做法叫「記憶化遞歸」或者「記憶化搜索」;
  • 而使用「自底向上」的思路可以解決在入門階段的絕大多數「動態規劃」問題,我們就是去想一下,這個問題最開始的時候是什麼樣子,而不是直接去解決這個問題,請大家在練習的過程中逐漸體會這個思路。

注意:並不是所有的「動態規劃」問題都可以「自底向上」去做,但是初學的時候,大家可以直接適應這種解法,因爲「自上而下」的寫法就是「遞歸」的寫法,我們已經相對熟悉。

Java 代碼:

public class Solution {

    public int fib(int N) {
        if (N < 2) {
            return N;
        }
        int[] dp = new int[N + 1];

        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i < N + 1; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
}

這一小節,希望大家能夠體會「動態規劃」的一個思路,「自底向上」,並且理解使用「動態規劃」解決問題的一個特徵:「重複子問題」。

因爲有「重複子問題」,我們在「自底向上」求解的過程中,通過先解決更小規模的問題,在處理更大規模的問題的時候,直接使用了更小規模問題的結果,進而原問題得到了解決。

練習

1、「力扣」第 70 題:爬樓梯

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