動態規劃問題為什麼要畫表格?

lucifer發表於2020-08-27
本文是我的 91 演算法第一期的部分講義內容。 91 演算法第一期已經接近尾聲,二期的具體時間關注我的公眾號即可,一旦開放,會第一時間在公眾號《力扣加加》通知大家。

動態規劃可以理解為是查表的遞迴(記憶化)。那麼什麼是遞迴?什麼是查表(記憶化)?

遞迴

定義: 遞迴是指在函式的定義中使用函式自身的方法。

演算法中使用遞迴可以很簡單地完成一些用迴圈實現的功能,比如二叉樹的左中右序遍歷。遞迴在演算法中有非常廣泛的使用,包括現在日趨流行的函數語言程式設計。

純粹的函數語言程式設計中沒有迴圈,只有遞迴。

有意義的遞迴演算法會把問題分解成規模縮小的同類子問題,當子問題縮寫到尋常的時候,我們可以知道它的解。然後我們建立遞迴函式之間的聯絡即可解決原問題,這也是我們使用遞迴的意義。準確來說, 遞迴併不是演算法,它是和迭代對應的一種程式設計方法。只不過,我們通常藉助遞迴去分解問題而已。

一個問題要使用遞迴來解決必須有遞迴終止條件(演算法的有窮性),也就是順遞迴會逐步縮小規模到尋常。

雖然以下程式碼也是遞迴,但由於其無法結束,因此不是一個有效的演算法:

def f(n):
  return n + f(n - 1)

更多的情況應該是:

def f(n):
  if n == 1: return 1
  return n + f(n - 1)

練習遞迴

一個簡單練習遞迴的方式是將你寫的迭代全部改成遞迴形式。比如你寫了一個程式,功能是“將一個字串逆序輸出”,那麼使用迭代將其寫出來會非常容易,那麼你是否可以使用遞迴寫出來呢?通過這樣的練習,可以讓你逐步適應使用遞迴來寫程式。

如果你已經對遞迴比較熟悉了,那麼我們繼續往下看。

遞迴中的重複計算

遞迴中可能存在這麼多的重複計算,為了消除這種重複計算,一種簡單的方式就是記憶化遞迴。即一邊遞迴一邊使用“記錄表”(比如雜湊表或者陣列)記錄我們已經計算過的情況,當下次再次碰到的時候,如果之前已經計算了,那麼直接返回即可,這樣就避免了重複計算。而動態規劃中 DP 陣列其實和這裡“記錄表”的作用是一樣的

遞迴的時間複雜度分析

敬請期待我的新書。

小結

使用遞迴函式的優點是邏輯簡單清晰,缺點是過深的呼叫會導致棧溢位。這裡我列舉了幾道演算法題目,這幾道演算法題目都可以用遞迴輕鬆寫出來:

  • 遞迴實現 sum
  • 二叉樹的遍歷
  • 走樓梯問題
  • 漢諾塔問題
  • 楊輝三角

當你已經適應了遞迴的時候,那就讓我們繼續學習動態規劃吧!

動態規劃

如果你已經熟悉了遞迴的技巧,那麼使用遞迴解決問題非常符合人的直覺,程式碼寫起來也比較簡單。這個時候我們來關注另一個問題 - 重複計算 。我們可以通過分析(可以嘗試畫一個遞迴樹),可以看出遞迴在縮小問題規模的同時是否可能會重複計算279.perfect-squares 中 我通過遞迴的方式來解決這個問題,同時內部維護了一個快取來儲存計算過的運算,這麼做可以減少很多運算。 這其實和動態規劃有著異曲同工的地方。

小提示:如果你發現並沒有重複計算,那麼就沒有必要用記憶化遞迴或者動態規劃了。

因此動態規劃就是列舉所以可能。不過相比暴力列舉,動態規劃不會有重複計算。因此如何保證列舉時不重不漏是關鍵點之一。 遞迴由於使用了函式呼叫棧來儲存資料,因此如果棧變得很大,那麼會容易爆棧。

爆棧

我們結合求和問題來講解一下,題目是給定一個陣列,求出陣列中所有項的和,要求使用遞迴實現。

程式碼:

function sum(nums) {
  if (nums.length === 0) return 0;
  if (nums.length === 1) return nums[0];

  return nums[0] + sum(nums.slice(1));
}

我們用遞迴樹來直觀地看一下。

dynamic-programming-1

這種做法本身沒有問題,但是每次執行一個函式都有一定的開銷,拿 JS 引擎執行 JS 來說,每次函式執行都會進行入棧操作,並進行預處理和執行過程,所以記憶體會有額外的開銷,資料量大的時候很容易造成爆棧。

瀏覽器中的 JS 引擎對於程式碼執行棧的長度是有限制的,超過會爆棧,丟擲異常。

重複計算

我們再舉一個重複計算的例子,問題描述:

一個人爬樓梯,每次只能爬 1 個或 2 個臺階,假設有 n 個臺階,那麼這個人有多少種不同的爬樓梯方法?

由於上第 n 級臺階一定是從 n - 1 或者 n - 2 來的,因此 上第 n 級臺階的數目就是 上 n - 1 級臺階的數目加上 n - 1 級臺階的數目

遞迴程式碼:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

我們繼續用一個遞迴樹來直觀感受以下:

dynamic-programming-2

紅色表示重複的計算

可以看出這裡面有很多重複計算,我們可以使用一個 hashtable 去快取中間計算結果,從而省去不必要的計算。

那麼動態規劃是怎麼解決這個問題呢? 答案也是“查表”,不過區別於遞迴使用函式呼叫棧,動態規劃通常使用的是 dp 陣列,陣列的索引通常是問題規模,值通常是遞迴函式的返回值。遞迴是從問題的結果倒推,直到問題的規模縮小到尋常。 動態規劃是從尋常入手, 逐步擴大規模到最優子結構。

如果上面的爬樓梯問題,使用動態規劃,程式碼是這樣的:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

不會也沒關係,我們將遞迴的程式碼稍微改造一下。其實就是將函式的名字改一下:

function dp(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return dp(n - 1) + dp(n - 2);
}
dp[n] 和 dp(n) 對比看,這樣是不是有點理解了呢? 只不過遞迴用呼叫棧列舉狀態, 而動態規劃使用迭代列舉狀態。

動態規劃的查表過程如果畫成圖,就是這樣的:

dynamic-programming-3

虛線代表的是查表過程

這道題目是動態規劃中最簡單的問題了,因為設計到單個因素的變化,如果涉及到多個因素,就比較複雜了,比如著名的揹包問題,挖金礦問題等。

對於單個因素的,我們最多隻需要一個一維陣列即可,對於如揹包問題我們需要二維陣列等更高緯度。

爬樓梯我們並沒有必要使用一維陣列,而是藉助兩個變數來實現的,空間複雜度是 O(1)。程式碼:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;

  let a = 1;
  let b = 2;
  let temp;

  for (let i = 3; i <= n; i++) {
    temp = a + b;
    a = b;
    b = temp;
  }

  return temp;
}

之所以能這麼做,是因為爬樓梯問題的狀態轉移方程中當前狀態只和前兩個狀態有關,因此只需要儲存這兩個即可。 動態規劃問題有很多這種討巧的方式,這個技巧叫做滾動陣列。

再次強調一下:

  • 如果說遞迴是從問題的結果倒推,直到問題的規模縮小到尋常。 那麼動態規劃就是從尋常入手, 逐步擴大規模到最優子結構。
  • 記憶化遞迴和動態規劃沒有本質不同。都是列舉狀態,並根據狀態直接的聯絡逐步推導求解。
  • 動態規劃效能通常更好。 一方面是遞迴的棧開銷,一方面是滾動陣列的技巧。

動態規劃的三個要素

  1. 狀態轉移方程
  2. 臨界條件
  3. 列舉狀態
可以看出,用遞迴解決也是一樣的思路

在上面講解的爬樓梯問題中,如果我們用 f(n) 表示爬 n 級臺階有多少種方法的話,那麼:

f(1) 與 f(2) 就是【邊界】
f(n) = f(n-1) + f(n-2) 就是【狀態轉移公式】

我用動態規劃的形式表示一下:

dp[0] 與 dp[1] 就是【邊界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【狀態轉移方程】

可以看出兩者是多麼的相似。

實際上臨界條件相對簡單,大家只有多刷幾道題,裡面就有感覺。困難的是找到狀態轉移方程和列舉狀態。這兩個核心點的都建立在已經抽象好了狀態的基礎上。比如爬樓梯的問題,如果我們用 f(n) 表示爬 n 級臺階有多少種方法的話,那麼 f(1), f(2), ... 就是各個獨立的狀態

不過狀態的定義都有特點的套路。 比如一個字串的狀態,通常是 dp[i] 表示字串 s 以 i 結尾的 ....。 比如兩個字串的狀態,通常是 dpi 表示字串 s1 以 i 結尾,s2 以 j 結尾的 ....。

當然狀態轉移方程可能不止一個, 不同的轉移方程對應的效率也可能大相徑庭,這個就是比較玄學的話題了,需要大家在做題的過程中領悟。

搞定了狀態的定義,那麼我們來看下狀態轉移方程。

狀態轉移方程

爬樓梯問題由於上第 n 級臺階一定是從 n - 1 或者 n - 2 來的,因此 上第 n 級臺階的數目就是 上 n - 1 級臺階的數目加上 n - 1 級臺階的數目

上面的這個理解是核心, 它就是我們的狀態轉移方程,用程式碼表示就是 f(n) = f(n - 1) + f(n - 2)

實際操作的過程,有可能題目和爬樓梯一樣直觀,我們不難想到。也可能隱藏很深或者維度過高。 如果你實在想不到,可以嘗試畫圖開啟思路,這也是我剛學習動態規劃時候的方法。當你做題量上去了,你的題感就會來,那個時候就可以不用畫圖了。

狀態轉移方程實在是沒有什麼靈丹妙藥,不同的題目有不同的解法。狀態轉移方程同時也是解決動態規劃問題中最最困難和關鍵的點,大家一定要多多練習,提高題感。接下來,我們來看下不那麼困難,但是新手疑問比較多的問題 - 如何列舉狀態

如何列舉狀態

前面說了如何列舉狀態,才能不重不漏是列舉狀態的關鍵所在。

  • 如果是一維狀態,那麼我們使用一層迴圈可以搞定。
  • 如果是兩維狀態,那麼我們使用兩層迴圈可以搞定。
  • 。。。

這樣可以保證不重不漏。

但是實際操作的過程有很多細節比如:

  • 一維狀態我是先列舉左邊的還是右邊的?(從左到右遍歷還是從右到左遍歷)
  • 二維狀態我是先列舉左上邊的還是右上的,還是左下的還是右下的?
  • 裡層迴圈和外層迴圈的位置關係(可以互換麼)
  • 。。。

其實這個東西和很多因素有關,很難總結出一個規律,而且我認為也完全沒有必要去總結規律。不過這裡我還是總結了一個關鍵點,那就是:

  • 如果你沒有使用滾動陣列的技巧,那麼遍歷順序取決於狀態轉移方程。比如:
for i in range(1, n + 1):
  dp[i] = dp[i - 1] + 1;

那麼我們就需要從左到右遍歷,原因很簡單,因為 dp[i] 依賴於 dp[i - 1],因此計算 dp[i] 的時候, dp[i - 1] 需要已經計算好了。

二維的也是一樣的,大家可以試試。
  • 如果你使用了滾動陣列的技巧,則怎麼遍歷都可以,但是不同的遍歷意義通常不不同的。比如我將二維的壓縮到了一維:
for i in range(1, n + 1):
  for j in range(1, n + 1):
    dp[j] = dp[j - 1] + 1;

這樣是可以的。 dp[j - 1] 實際上指的是壓縮前的 dpi

而:

for i in range(1, n + 1):
  #  倒著遍歷
  for j in range(n, 0, -1):
    dp[j] = dp[j - 1] + 1;

這樣也是可以的。 但是 dp[j - 1] 實際上指的是壓縮前的 dpi - 1。因此實際中採用怎麼樣的遍歷手段取決於題目。我特意寫了一個 【完全揹包問題】套路題(1449. 數位成本和為目標值的最大數字 文章,通過一個具體的例子告訴大家不同的遍歷有什麼實際不同,強烈建議大家看看,並順手給個三連。

  • 關於裡外迴圈的問題,其實和上面原理類似。

這個比較微妙,大家可以參考這篇文章理解一下 0518.coin-change-2

小結

關於如何確定臨界條件通常是比較簡單的,多做幾個題就可以快速掌握。

關於如何確定狀態轉移方程,這個其實比較困難。 不過所幸的是,這些套路性比較強, 比如一個字串的狀態,通常是 dp[i] 表示字串 s 以 i 結尾的 ....。 比如兩個字串的狀態,通常是 dpi 表示字串 s1 以 i 結尾,s2 以 j 結尾的 ....。 這樣遇到新的題目可以往上套, 實在套不出那就先老實畫圖,不斷觀察,提高題感。

關於如何列舉狀態,如果沒有滾動陣列, 那麼根據轉移方程決定如何列舉即可。 如果用了滾動陣列,那麼要注意壓縮後和壓縮前的 dp 對應關係即可。

動態規劃為什麼要畫表格

動態規劃問題要畫表格,但是有的人不知道為什麼要畫,就覺得這個是必然的,必要要畫表格才是動態規劃。

其實動態規劃本質上是將大問題轉化為小問題,然後大問題的解是和小問題有關聯的,換句話說大問題可以由小問題進行計算得到。這一點是和用遞迴解決一樣的, 但是動態規劃是一種類似查表的方法來縮短時間複雜度和空間複雜度。

畫表格的目的就是去不斷推導,完成狀態轉移, 表格中的每一個 cell 都是一個小問題, 我們填表的過程其實就是在解決問題的過程,

我們先解決規模為尋常的情況,然後根據這個結果逐步推導,通常情況下,表格的右下角是問題的最大的規模,也就是我們想要求解的規模。

比如我們用動態規劃解決揹包問題, 其實就是在不斷根據之前的小問題A[i - 1][j] A[i -1][w - wj]來詢問:

  • 應該選擇它
  • 還是不選擇它

至於判斷的標準很簡單,就是價值最大,因此我們要做的就是對於選擇和不選擇兩種情況分別求價值,然後取最大,最後更新 cell 即可。

其實大部分的動態規劃問題套路都是“選擇”或者“不選擇”,也就是說是一種“選擇題”。 並且大多數動態規劃題目還伴隨著空間的優化(滾動陣列),這是動態規劃相對於傳統的記憶化遞迴優勢的地方。除了這點優勢,就是上文提到的使用動態規劃可以減少遞迴產生的函式呼叫棧,因此效能上更好。

相關問題

總結

本篇文章總結了演算法中比較常用的兩個方法 - 遞迴和動態規劃。遞迴的話可以拿樹的題目練手,動態規劃的話則將我上面推薦的刷完,再考慮去刷力扣的動態規劃標籤即可。

大家前期學習動態規劃的時候,可以先嚐試使用記憶化遞迴解決。然後將其改造為動態規劃,這樣多練習幾次就會有感覺。之後大家可以練習一下滾動陣列,這個技巧很有用,並且相對來說比較簡單。 比較動態規劃的難點在於列舉所以狀態(無重複)尋找狀態轉移方程

如果你只能記住一句話,那麼請記住:遞迴是從問題的結果倒推,直到問題的規模縮小到尋常。 動態規劃是從尋常入手, 逐步擴大規模到最優子結構。

另外,大家可以去 LeetCode 探索中的 遞迴 I 中進行互動式學習。

相關文章