1. 程式人生 > 程式設計 >動態規劃套路詳解

動態規劃套路詳解

前言

前一篇部落格總結了動態規劃,但是對於我這初學者,還是很多地方不能理解,所以我就在網上找到了一個大神的講解,確實很棒。轉載過來。

原文連結在下面參考資料

1. 動態規劃套路詳解

下面通過對斐波那契數列和這道湊零錢問題詳解動態規劃。如果只想看本題的答案,請直接翻到最後檢視

動態規劃演演算法似乎是一種很高深莫測的演演算法,你會在一些面試或演演算法書籍的高階技巧部分看到相關內容,什麼狀態轉移方程,重疊子問題,最優子結構等高大上的詞彙也可能讓你望而卻步。

而且,當你去看用動態規劃解決某個問題的程式碼時,你會覺得這樣解決問題竟然如此巧妙,但卻難以理解,你可能驚訝於人家是怎麼想到這種解法的。

實際上,動態規劃是一種常見的“演演算法設計技巧”,並沒有什麼高深莫測,至於各種高大上的術語,那是嚇唬別人用的,只要你親自體驗幾把,這些名詞的含義其實顯而易見,再簡單不過了。

至於為什麼最終的解法看起來如此精妙,是因為動態規劃遵循一套固定的流程:遞迴的暴力解法 -> 帶備忘錄的遞迴解法 -> 非遞迴的動態規劃解法。這個過程是層層遞進的解決問題的過程,你如果沒有前面的鋪墊,直接看最終的非遞迴動態規劃解法,當然會覺得牛逼而不可及了。

當然,見的多了,思考多了,是可以一步寫出非遞迴的動態規劃解法的。任何技巧都需要練習,我們先遵循這個流程走,演演算法設計也就這些套路,除此之外,真的沒啥高深的。

以下,先通過兩個個比較簡單的例子:斐波那契和湊零錢問題,揭開動態規劃的神祕面紗,描述上述三個流程。後續還會寫幾篇文章探討如何使用動態規劃技巧解決比較複雜的經典問題。

首先,第一個快被舉爛了的例子,斐波那契數列。請讀者不要嫌棄這個例子簡單,因為簡單的例子才能讓你把精力充分集中在演演算法背後的通用思想和技巧上,而不會被那些隱晦的細節問題搞的莫名其妙。後續,困難的例子有的是。

步驟一、暴力的遞迴演演算法

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}
複製程式碼

這個不用多說了,學校老師講遞迴的時候似乎都是拿這個舉例。我們也知道這樣寫程式碼雖然簡潔易懂,但是十分低效,低效在哪裡?假設 n = 20,請畫出遞迴樹。

遞迴樹

PS:但凡遇到需要遞迴的問題,最好都畫出遞迴樹,這對你分析演演算法的複雜度,尋找演演算法低效的原因都有巨大幫助。

這個遞迴樹怎麼理解?就是說想要計算原問題 f(20),我就得先計算出子問題 f(19) 和 f(18),然後要計算 f(19),我就要先算出子問題 f(18) 和 f(17),以此類推。最後遇到 f(1) 或者 f(2) 的時候,結果已知,就能直接返回結果,遞迴樹不再向下生長了。

遞迴演演算法的時間複雜度怎麼計算?子問題個數乘以解決一個子問題需要的時間。

子問題個數,即遞迴樹中節點的總數。顯然二叉樹節點總數為指數級別,所以子問題個數為 O(2^n)。

解決一個子問題的時間,在本演演算法中,沒有迴圈,只有 f(n - 1) + f(n - 2) 一個加法操作,時間為 O(1)。

所以,這個演演算法的時間複雜度為 O(2^n),指數級別,爆炸。

觀察遞迴樹,很明顯發現了演演算法低效的原因:存在大量重複計算,比如 f(18) 被計算了兩次,而且你可以看到,以 f(18) 為根的這個遞迴樹體量巨大,多算一遍,會耗費巨大的時間。更何況,還不止 f(18) 這一個節點被重複計算,所以這個演演算法及其低效。

這就是動態規劃問題的第一個性質:重疊子問題。下面,我們想辦法解決這個問題。

步驟二、帶備忘錄的遞迴解法

明確了問題,其實就已經把問題解決了一半。即然耗時的原因是重複計算,那麼我們可以造一個「備忘錄」,每次算出某個子問題的答案後別急著返回,先記到「備忘錄」裡再返回;每次遇到一個子問題先去「備忘錄」裡查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。

一般使用一個陣列充當這個「備忘錄」,當然你也可以使用雜湊表(字典),思想都是一樣的。

int fib(int N) {
    if (N < 1) return 0;
    // 備忘錄全初始化為 0
    vector<int> memo(N + 1,0);
    return helper(memo,N);
}
int helper(vector<int>& memo,int n) {
    if (n == 1 || n == 2) return 1;
    if (memo[n] != 0) return memo[n];
    // 未被計算過
    memo[n] = helper(memo,n - 1) + helper(memo,n - 2);
    return memo[n];
}
複製程式碼

現在,畫出遞迴樹,你就知道「備忘錄」到底做了什麼。

備忘錄

實際上,帶「備忘錄」的遞迴演演算法,把一棵存在巨量冗餘的遞迴樹通過「剪枝」,改造成了一幅不存在冗餘的遞迴圖,極大減少了子問題(即遞迴圖中節點)的個數。

遞迴演演算法的時間複雜度怎麼算?子問題個數乘以解決一個子問題需要的時間。

子問題個數,即圖中節點的總數,由於本演演算法不存在冗餘計算,子問題就是 f(1),f(2),f(3) ... f(20),數量和輸入規模 n = 20 成正比,所以子問題個數為 O(n)。

解決一個子問題的時間,同上,沒有什麼迴圈,時間為 O(1)。

所以,本演演算法的時間複雜度是 O(n)。比起暴力演演算法,是降維打擊。

至此,帶備忘錄的遞迴解法的效率已經和動態規劃一樣了。實際上,這種解法和動態規劃的思想已經差不多了,只不過這種方法叫做「自頂向下」,動態規劃叫做「自底向上」。

啥叫「自頂向下」?注意我們剛才畫的遞迴樹(或者說圖),是從上向下延伸,都是從一個規模較大的原問題比如說 f(20),向下逐漸分解規模,直到 f(1) 和 f(2) 觸底,然後逐層返回答案,這就叫「自頂向下」。

啥叫「自底向上」?反過來,我們直接從最底下,最簡單,問題規模最小的 f(1) 和 f(2) 開始往上推,直到推到我們想要的答案 f(20),這就是動態規劃的思路,這也是為什麼動態規劃一般都脫離了遞迴,而是由迴圈迭代完成計算。

步驟三、動態規劃

有了上一步「備忘錄」的啟發,我們可以把這個「備忘錄」獨立出來成為一張表,就叫做 DP table 吧,在這張表上完成「自底向上」的推算豈不美哉!

int fib(int N) {
    vector<int> dp(N + 1,0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}
複製程式碼

畫個圖就很好理解了,而且你發現這個 DP table 特別像之前那個「剪枝」後的結果,只是反過來算而已。實際上,帶備忘錄的遞迴解法中的「備忘錄」,最終完成後就是這個 DP table,所以說這兩種解法其實是差不多的,大部分情況下,效率也基本相同。

這裡,引出 「動態轉移方程」 這個名詞,實際上就是描述問題結構的數學形式:

為啥叫「狀態轉移方程」?為了聽起來高階。你把 f(n) 想做一個狀態 n,這個狀態 n 是由狀態 n - 1 和狀態 n - 2 相加轉移而來,這就叫狀態轉移,僅此而已。

你會發現,上面的幾種解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及對備忘錄或 DP table 的初始化操作,都是圍繞這個方程式的不同表現形式。可見列出「狀態轉移方程」的重要性,它是解決問題的核心。很容易發現,其實狀態轉移方程直接代表著暴力解法。

千萬不要看不起暴力解,動態規劃問題最困難的就是寫出狀態轉移方程,即這個暴力解。優化方法無非是用備忘錄或者 DP table,再無奧妙可言。

這個例子的最後,講一個細節優化。細心的讀者會發現,根據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實並不需要那麼長的一個 DP table 來儲存所有的狀態,只要想辦法儲存之前的兩個狀態就行了。所以,可以進一步優化,把空間複雜度降為 O(1):

int fib(int n) {
    if (n < 2) return n;
    int prev = 0,curr = 1;
    for (int i = 0; i < n - 1; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}
複製程式碼

有人會問,動態規劃的另一個重要特性「最優子結構」,怎麼沒有涉及?下面會涉及。斐波那契數列的例子嚴格來說不算動態規劃,以上旨在演示演演算法設計螺旋上升的過程。當問題中要求求一個最優解或在程式碼中看到迴圈和 max、min 等函式時,十有八九,需要動態規劃大顯身手。

湊零錢問題

下面,看第二個例子,湊零錢問題,有了上面的詳細鋪墊,這個問題會很快解決。

題目:給你 k 種面值的硬幣,面值分別為 c1,c2 ... ck,再給一個總金額 n,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,則回答 -1 。

比如說,k = 3,面值分別為 1,2,5,總金額 n = 11,那麼最少需要 3 枚硬幣,即 11 = 5 + 5 + 1 。下面走流程。

一、暴力解法 首先是最困難的一步,寫出狀態轉移方程,這個問題比較好寫:

其實,這個方程就用到了「最優子結構」性質:原問題的解由子問題的最優解構成。即 f(11) 由 f(10),f(9),f(6) 的最優解轉移而來。

記住,要符合「最優子結構」,子問題間必須互相獨立。啥叫相互獨立?你肯定不想看數學證明,我用一個直觀的例子來講解。

比如說,你的原問題是考出最高的總成績,那麼你的子問題就是要把語文考到最高,數學考到最高...... 為了每門課考到最高,你要把每門課相應的選擇題分數拿到最高,填空題分數拿到最高...... 當然,最終就是你每門課都是滿分,這就是最高的總成績。

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

但是,如果加一個條件:你的語文成績和數學成績會互相制約,此消彼長。這樣的話,顯然你能考到的最高總成績就達不到總分了,按剛才那個思路就會得到錯誤的結果。因為子問題並不獨立,語文數學成績無法同時最優,所以最優子結構被破壞。關注我的眾公號 labuladong 看更多精彩演演算法文章

回到湊零錢問題,顯然子問題之間沒有相互制約,而是互相獨立的。所以這個狀態轉移方程是可以得到正確答案的。

之後就沒啥難點了,按照方程寫暴力遞迴演演算法即可。

int coinChange(vector<int>& coins,int amount) {
    if (amount == 0) return 0;
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金額不可達
        if (amount - coin < 0) continue;
        int subProb = coinChange(coins,amount - coin);
        // 子問題無解
        if (subProb == -1) continue;
        ans = min(ans,subProb + 1);
    }
    return ans == INT_MAX ? -1 : ans;
}
複製程式碼

畫出遞迴樹:

時間複雜度分析:子問題總數 x 每個子問題的時間。子問題總數為遞迴樹節點個數,這個比較難看出來,是 O(n^k),總之是指數級別的。每個子問題中含有一個 for 迴圈,複雜度為 O(k)。所以總時間複雜度為 O(k*n^k),指數級別。

二、帶備忘錄的遞迴演演算法

int coinChange(vector<int>& coins,int amount) {
    // 備忘錄初始化為 -2
    vector<int> memo(amount + 1,-2);
    return helper(coins,amount,memo);
}

int helper(vector<int>& coins,int amount,vector<int>& memo) {
    if (amount == 0) return 0;
    if (memo[amount] != -2) return memo[amount];
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金額不可達
        if (amount - coin < 0) continue;
        int subProb = helper(coins,amount - coin,memo);
        // 子問題無解
        if (subProb == -1) continue;
        ans = min(ans,subProb + 1);
    }
    // 記錄本輪答案
    memo[amount] = (ans == INT_MAX) ? -1 : ans;
    return memo[amount];
}
複製程式碼

不畫圖了,很顯然「備忘錄」大大減小了子問題數目,完全消除了子問題的冗餘,所以子問題總數不會超過金額數 n,即子問題數目為 O(n)。處理一個子問題的時間不變,仍是 O(k),所以總的時間複雜度是 O(kn)。

三、動態規劃

int coinChange(vector<int>& coins,int amount) {
    vector<int> dp(amount + 1,amount + 1);
    dp[0] = 0;
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins)
            if (coin <= i)
                dp[i] = min(dp[i],dp[i - coin] + 1);
    }
    return dp[amount] > amount ? -1 : dp[amount];
}
複製程式碼

最後總結 如果你不太瞭解動態規劃,還能看到這裡,真得給你鼓掌,相信你已經掌握了這個演演算法的設計技巧。

計算機解決問題其實沒有任何奇技淫巧,它唯一的解決辦法就是窮舉,窮舉所有可能性。演演算法設計無非就是先思考“如何窮舉”,然後再追求“如何聰明地窮舉”。

列出動態轉移方程,就是在解決“如何窮舉”的問題。之所以說它難,一是因為很多窮舉需要遞迴實現,二是因為有的問題本身的解空間複雜,不那麼容易窮舉完整。

備忘錄、DP table 就是在追求“如何聰明地窮舉”。用空間換時間的思路,是降低時間複雜度的不二法門,除此之外,試問,還能玩出啥花活?

2. 解決博弈問題的動態規劃通用思路

但是智力題終究是智力題,真正的演演算法問題肯定不會是投機取巧能搞定的。所以,本文就借石頭遊戲來講講「假設兩個人都足夠聰明,最後誰會獲勝」這一類問題該如何用動態規劃演演算法解決。

博弈類問題的套路都差不多,下文舉例講解,其核心思路是在二維 dp 的基礎上使用元組分別儲存兩個人的博弈結果。掌握了這個技巧以後,別人再問你什麼倆海盜分寶石,倆人拿硬幣的問題,你就告訴別人:我懶得想,直接給你寫個演演算法算一下得了。

我們「石頭遊戲」改的更具有一般性:

你和你的朋友面前有一排石頭堆,用一個陣列 piles 表示,piles[i] 表示第 i 堆石子有多少個。你們輪流拿石頭,一次拿一堆,但是隻能拿走最左邊或者最右邊的石頭堆。所有石頭被拿完後,誰擁有的石頭多,誰獲勝。

石頭的堆數可以是任意正整數,石頭的總數也可以是任意正整數,這樣就能打破先手必勝的局面了。比如有三堆石頭 piles = [1,100,3],先手不管拿 1 還是 3,能夠決定勝負的 100 都會被後手拿走,後手會獲勝。

假設兩人都很聰明,請你設計一個演演算法,返回先手和後手的最後得分(石頭總數)之差。比如上面那個例子,先手能獲得 4 分,後手會獲得 100 分,你的演演算法應該返回 -96。

這樣推廣之後,這個問題算是一道 Hard 的動態規劃問題了。博弈問題的難點在於,兩個人要輪流進行選擇,而且都賊精明,應該如何程式設計表示這個過程呢?

還是強調多次的套路,首先明確 dp 陣列的含義,然後和股票買賣系列問題類似,只要找到「狀態」和「選擇」,一切就水到渠成了。

一、定義 dp 陣列的含義

定義 dp 陣列的含義是很有技術含量的,同一問題可能有多種定義方法,不同的定義會引出不同的狀態轉移方程,不過只要邏輯沒有問題,最終都能得到相同的答案。

我建議不要迷戀那些看起來很牛逼,程式碼很短小的奇技淫巧,最好是穩一點,採取可解釋性最好,最容易推廣的設計思路。本文就給出一種博弈問題的通用設計框架。

介紹 dp 陣列的含義之前,我們先看一下 dp 陣列最終的樣子:

下文講解時,認為元組是包含 first 和 second 屬性的一個類,而且為了節省篇幅,將這兩個屬性簡寫為 fir 和 sec。比如按上圖的資料,我們說 dp[1][3].fir = 10,dp[0][1].sec = 3。

先回答幾個讀者可能提出的問題:

這個二維 dp table 中儲存的是元組,怎麼程式設計表示呢?這個 dp table 有一半根本沒用上,怎麼優化?很簡單,都不要管,先把解題的思路想明白了再談也不遲。

以下是對 dp 陣列含義的解釋:

dp[i][j].fir 表示,對於 piles[i...j] 這部分石頭堆,先手能獲得的最高分數。
dp[i][j].sec 表示,對於 piles[i...j] 這部分石頭堆,後手能獲得的最高分數。

舉例理解一下,假設 piles = [3,9,1,2],索引從 0 開始
dp[0][1].fir = 9 意味著:面對石頭堆 [3,9],先手最終能夠獲得 9 分。
dp[1][3].sec = 2 意味著:面對石頭堆 [9,2],後手最終能夠獲得 2 分。
複製程式碼

我們想求的答案是先手和後手最終分數之差,按照這個定義也就是 dp[0][n-1].fir - dp[0][n-1].secdp[0][n−1].fir−dp[0][n−1].sec,即面對整個 piles,先手的最優得分和後手的最優得分之差。

二、狀態轉移方程

寫狀態轉移方程很簡單,首先要找到所有「狀態」和每個狀態可以做的「選擇」,然後擇優。

根據前面對 dp 陣列的定義,狀態顯然有三個:開始的索引 i,結束的索引 j,當前輪到的人。

dp[i][j][fir or sec]
其中:
0 <= i < piles.length
i <= j < piles.length
複製程式碼

對於這個問題的每個狀態,可以做的選擇有兩個:選擇最左邊的那堆石頭,或者選擇最右邊的那堆石頭。 我們可以這樣窮舉所有狀態:

n = piles.length
for 0 <= i < n:
    for j <= i < n:
        for who in {fir,sec}:
            dp[i][j][who] = max(left,right)
複製程式碼

上面的偽碼是動態規劃的一個大致的框架,股票系列問題中也有類似的偽碼。這道題的難點在於,兩人是交替進行選擇的,也就是說先手的選擇會對後手有影響,這怎麼表達出來呢?

根據我們對 dp 陣列的定義,很容易解決這個難點,寫出狀態轉移方程:

dp[i][j].fir = max(piles[i] + dp[i+1][j].sec,piles[j] + dp[i][j-1].sec)
dp[i][j].fir = max(選擇最左邊的石頭堆,選擇最右邊的石頭堆)
 # 解釋:我作為先手,面對 piles[i...j] 時,有兩種選擇:
 # 要麼我選擇最左邊的那一堆石頭,然後面對 piles[i+1...j]
 # 但是此時輪到對方,相當於我變成了後手;
 # 要麼我選擇最右邊的那一堆石頭,然後面對 piles[i...j-1]
 # 但是此時輪到對方,相當於我變成了後手。

if 先手選擇左邊:
dp[i][j].sec = dp[i+1][j].fir
if 先手選擇右邊:
dp[i][j].sec = dp[i][j-1].fir
# 解釋:我作為後手,要等先手先選擇,有兩種情況:
# 如果先手選擇了最左邊那堆,給我剩下了 piles[i+1...j]
# 此時輪到我,我變成了先手;
# 如果先手選擇了最右邊那堆,給我剩下了 piles[i...j-1]
# 此時輪到我,我變成了先手。
複製程式碼

根據 dp 陣列的定義,我們也可以找出 base case,也就是最簡單的情況:

dp[i][j].fir = piles[i]
dp[i][j].sec = 0
其中 0 <= i == j < n
# 解釋:i 和 j 相等就是說面前只有一堆石頭 piles[i]
# 那麼顯然先手的得分為 piles[i]
# 後手沒有石頭拿了,得分為 0
複製程式碼

這裡需要注意一點,我們發現 base case 是斜著的,而且我們推算 dp[i][j] 時需要用到 dp[i+1][j] 和 dp[i][j-1]:

所以說演演算法不能簡單的一行一行遍歷 dp 陣列,而要斜著遍歷陣列:

說實話,斜著遍歷二維陣列說起來容易,你還真不一定能想出來怎麼實現,不信你思考一下?這麼巧妙的狀態轉移方程都列出來了,要是不會寫程式碼實現,那真的很尷尬了。。。

三、程式碼實現

如何實現這個 fir 和 sec 元組呢,你可以用 python,自帶元組型別;或者使用 C++ 的 pair 容器;或者用一個三維陣列 dp[n][n][2],最後一個維度就相當於元組;或者我們自己寫一個 Pair 類:

class Pair {
    int fir,sec;
    Pair(int fir,int sec) {
        this.fir = fir;
        this.sec = sec;
    }
}
複製程式碼

然後直接把我們的狀態轉移方程翻譯成程式碼即可,可以注意一下斜著遍歷陣列的技巧:

/* 返回遊戲最後先手和後手的得分之差 */
int stoneGame(int[] piles) {
    int n = piles.length;
    // 初始化 dp 陣列
    Pair[][] dp = new Pair[n][n];
    for (int i = 0; i < n; i++) 
        for (int j = i; j < n; j++)
            dp[i][j] = new Pair(0,0);
    // 填入 base case
    for (int i = 0; i < n; i++) {
        dp[i][i].fir = piles[i];
        dp[i][i].sec = 0;
    }
    // 斜著遍歷陣列
    for (int l = 2; l <= n; l++) {
        for (int i = 0; i <= n - l; i++) {
            int j = l + i - 1;
            // 先手選擇最左邊或最右邊的分數
            int left = piles[i] + dp[i+1][j].sec;
            int right = piles[j] + dp[i][j-1].sec;
            // 套用狀態轉移方程
            if (left > right) {
                dp[i][j].fir = left;
                dp[i][j].sec = dp[i+1][j].fir;
            } else {
                dp[i][j].fir = right;
                dp[i][j].sec = dp[i][j-1].fir;
            }
        }
    }
    Pair res = dp[0][n-1];
    return res.fir - res.sec;
}
複製程式碼

動態規劃解法,如果沒有狀態轉移方程指導,絕對是一頭霧水,但是根據前面的詳細解釋,讀者應該可以清晰理解這一大段程式碼的含義。

而且,注意到計算 dp[i][j] 只依賴其左邊和下邊的元素,所以說肯定有優化空間,轉換成一維 dp,想象一下把二維平面壓扁,也就是投影到一維。但是,一維 dp 比較複雜,可解釋性很差,大家就不必浪費這個時間去理解了。

四、最後總結

本文給出瞭解決博弈問題的動態規劃解法。博弈問題的前提一般都是在兩個聰明人之間進行,程式設計描述這種遊戲的一般方法是二維 dp 陣列,陣列中通過元組分別表示兩人的最優決策。

之所以這樣設計,是因為先手在做出選擇之後,就成了後手,後手在對方做完選擇後,就變成了先手。這種角色轉換使得我們可以重用之前的結果,典型的動態規劃標誌。

讀到這裡的朋友應該能理解演演算法解決博弈問題的套路了。學習演演算法,一定要注重演演算法的模板框架,而不是一些看起來牛逼的思路,也不要奢求上來就寫一個最優的解法。不要捨不得多用空間,不要過早嘗試優化,不要懼怕多維陣列。dp 陣列就是儲存資訊避免重複計算的,隨便用,直到咱滿意為止。

3. 動態規劃設計方法:歸納思想

瞭解了動態規劃的套路,也不會寫狀態轉移方程,沒有思路,怎麼辦?本文就藉助「最長遞增子序列」來講一種設計動態規劃的通用技巧:數學歸納思想。

最長遞增子序列(Longest Increasing Subsequence,簡寫 LIS)是比較經典的一個問題,比較容易想到的是動態規劃解法,時間複雜度 O(N^2),我們借這個問題來由淺入深講解如何寫動態規劃。比較難想到的是利用二分查詢,時間複雜度是 O(NlogN),我們通過一種簡單的紙牌遊戲來輔助理解這種巧妙的解法。

先看一下題目,很容易理解:

注意「子序列」和「子串」這兩個名詞的區別,子串一定是連續的,而子序列不一定是連續的。下面先來一步一步設計動態規劃演演算法解決這個問題。

動態規劃解法 動態規劃的核心設計思想是數學歸納法

相信大家對數學歸納法都不陌生,高中就學過,而且思路很簡單。比如我們想證明一個數學結論,那麼我們先假設這個結論在 k<nk<n 時成立,然後想辦法證明 k=nk=n 的時候此結論也成立。如果能夠證明出來,那麼就說明這個結論對於 k 等於任何數都成立。

類似的,我們設計動態規劃演演算法,不是需要一個 dp 陣列嗎?我們可以假設 dp[0...i-1]dp[0...i−1] 都已經被算出來了,然後問自己:怎麼通過這些結果算出 dp[i]?

直接拿最長遞增子序列這個問題舉例你就明白了。不過,首先要定義清楚 dp 陣列的含義,即 dp[i] 的值到底代表著什麼?

我們的定義是這樣的:dp[i] 表示以 nums[i] 這個數結尾的最長遞增子序列的長度。

舉兩個例子:

演演算法演進的過程是這樣的,:

根據這個定義,我們的最終結果(子序列的最大長度)應該是 dp 陣列中的最大值。

int res = 0;
for (int i = 0; i < dp.size(); i++) {
    res = Math.max(res,dp[i]);
}
return res;
複製程式碼

讀者也許會問,剛才這個過程中每個 dp[i] 的結果是我們肉眼看出來的,我們應該怎麼設計演演算法邏輯來正確計算每個 dp[i] 呢?

這就是動態規劃的重頭戲了,要思考如何進行狀態轉移,這裡就可以使用數學歸納的思想:

我們已經知道了 dp[0...4]dp[0...4] 的所有結果,我們如何通過這些已知結果推出 dp[5]dp[5] 呢?

根據剛才我們對 dp 陣列的定義,現在想求 dp[5] 的值,也就是想求以 nums[5] 為結尾的最長遞增子序列。

nums[5] = 3,既然是遞增子序列,我們只要找到前面那些結尾比 3 小的子序列,然後把 3 接到最後,就可以形成一個新的遞增子序列,而且這個新的子序列長度加一。

當然,可能形成很多種新的子序列,但是我們只要最長的,把最長子序列的長度作為 dp[5] 的值即可。

for (int j = 0; j < i; j++) {
    if (nums[i] > nums[j]) 
        dp[i] = Math.max(dp[i],dp[j] + 1);
}
複製程式碼

這段程式碼的邏輯就可以算出 dp[5]。到這裡,這道演演算法題我們就基本做完了。讀者也許會問,我們剛才只是算了 dp[5] 呀,dp[4],dp[3] 這些怎麼算呢?

類似數學歸納法,你已經可以算出 dp[5] 了,其他的就都可以算出來:

for (int i = 0; i < nums.length; i++) {
    for (int j = 0; j < i; j++) {
        if (nums[i] > nums[j]) 
            dp[i] = Math.max(dp[i],dp[j] + 1);
    }
}
複製程式碼

還有一個細節問題,dp 陣列應該全部初始化為 1,因為子序列最少也要包含自己,所以長度最小為 1。下面我們看一下完整程式碼:

public int lengthOfLIS(int[] nums) {
    int[] dp = new int[nums.length];
    // dp 陣列全都初始化為 1
    Arrays.fill(dp,1);
    for (int i = 0; i < nums.length; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j]) 
                dp[i] = Math.max(dp[i],dp[j] + 1);
        }
    }
    
    int res = 0;
    for (int i = 0; i < dp.length; i++) {
        res = Math.max(res,dp[i]);
    }
    return res;
}
複製程式碼

至此,這道題就解決了,時間複雜度 O(N^2)。總結一下動態規劃的設計流程:

首先明確 dp 陣列所存資料的含義。這步很重要,如果不得當或者不夠清晰,會阻礙之後的步驟。

然後根據 dp 陣列的定義,運用數學歸納法的思想,假設 dp[0...i-1]dp[0...i−1] 都已知,想辦法求出 dp[i]dp[i],一旦這一步完成,整個題目基本就解決了。

但如果無法完成這一步,很可能就是 dp 陣列的定義不夠恰當,需要重新定義 dp 陣列的含義;或者可能是 dp 陣列儲存的資訊還不夠,不足以推出下一步的答案,需要把 dp 陣列擴大成二維陣列甚至三維陣列。

最後想一想問題的 base case 是什麼,以此來初始化 dp 陣列,以保證演演算法正確執行。

小結&參考資料

小結

此篇部落格為轉載總結,為了更加深入理解動態規劃。 感謝原作者,以下為連結。

參考資料