Home
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

4

Local AI

--

Uedu Code

--

Taipei AQI 57 · Taichung AQI 74 · Tainan AQI 63 · Kaohsiung AQI 64
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 21

動態規劃入門

記憶化與表格:費氏數列、爬樓梯、方格路徑
Interaction實作 30 minute(s)

Learning Objectives

  • 從費氏數列的遞迴樹看出重疊子問題,說明直接遞迴為什麼很慢
  • 用記憶化(由上而下)和表格法(由下而上)把費氏數列改成 O(n)
  • 說明動態規劃適用的兩個條件:重疊子問題與最佳子結構
  • 依「定義狀態、寫出轉移式、設定初始值、決定計算順序」四個步驟,解爬樓梯、方格路徑與找零問題
  • 用額外記錄的陣列,還原最佳解實際的做法

同一個問題算了好多遍

費氏數列(Fibonacci)的定義是:fib(0) = 0、fib(1) = 1,之後每一項是前兩項的和:0, 1, 1, 2, 3, 5, 8, 13, … 照定義直接寫成遞迴(第 05 課)只要三行:

def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

程式正確,但非常慢:fib(40) 要呼叫自己超過 3 億次。原因在遞迴樹裡:算 fib(6) 要算 fib(5) 和 fib(4), 而 fib(5) 自己又要再算一次 fib(4)。同一個子問題被算了一遍又一遍,呼叫次數大約隨 n 以指數成長。

這一課的主角動態規劃(dynamic programming,DP)就是解決這件事的方法:每個子問題只算一次,把答案記下來,之後直接查。

直接遞迴:fib(6) 呼叫了幾次

看遞迴樹長出來的過程。fib(4) 出現 2 次、fib(3) 出現 3 次、fib(2) 出現 5 次,每一次都從頭重算。算 fib(6) 總共呼叫了 25 次,面板下方會顯示目前的呼叫次數。

記憶化:算過的就記下來

改法很簡單:準備一個陣列 memo,memo[n] 存 fib(n) 的答案,還沒算過的放 -1。

  1. 進入 fib(n) 時,先查 memo[n]:已經算過就直接回傳
  2. 沒算過才真的去算,算完存進 memo[n] 再回傳

這種「遞迴+查表」的寫法叫記憶化(memoization),也叫由上而下(top-down)的動態規劃:從大問題往下拆,遇到算過的子問題就不再往下。

每個 fib(k) 只會真正計算一次,所以 fib(n) 只需要 O(n) 次計算,用 O(n) 的空間存答案。

記憶化:重複的呼叫消失了

遞迴樹先沿著左邊一路往下到 fib(1)、fib(0),回程時把答案一格一格填進 memo。之後右邊的 fib(4)、fib(3)… 一查表就回傳,不再長出子樹。總共只呼叫 11 次(直接遞迴是 25 次)。

什麼問題適合用動態規劃

「動態規劃」這個名字是 Richard Bellman 在 1950 年代取的,當時他在 RAND 研究多階段的決策問題。 這裡的 programming 指的是「規劃、排出計畫表」,不是寫程式;把子問題的答案一格一格填進表格,正是這個意思。

一個問題適合用動態規劃,通常有兩個特徵:

特徵 意思 費氏數列
重疊子問題(overlapping subproblems) 拆出來的子問題會重複出現 fib(4)、fib(3) 被算很多次
最佳子結構(optimal substructure) 大問題的答案可以由子問題的答案組合出來 fib(n) = fib(n - 1) + fib(n - 2)

和前面學過的方法比較:

  • 分治法(第 07 課的合併排序)也拆子問題,但子問題不重疊(左半、右半各排一次),所以不需要記下來
  • 貪婪法(第 20 課)每一步只選一個方向、不回頭;動態規劃把每個選擇的結果都算出來再比較,所以能處理貪婪法會失敗的問題

想一想:重疊子問題與記憶化

1.

下面的程式會印出什麼?

calls = 0

def fib(n):
    global calls
    calls += 1
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

fib(6)
print(calls)
2.

費氏數列加上記憶化之後,算 fib(n) 的時間複雜度是多少?

3.

合併排序也把問題拆成子問題,為什麼它不需要記憶化?

第 1 次作答

表格法:從最小的子問題往上填

記憶化是從大問題往下拆。另一種寫法反過來:從最小的子問題開始,依序把表格填滿,填到要的那一格就是答案。 這叫表格法(tabulation),也叫由下而上(bottom-up)的動態規劃,不需要遞迴。

用爬樓梯當例子:每次可以跨 1 階或 2 階,走到第 n 階有幾種走法?

設計動態規劃通常有四個步驟:

  1. 定義狀態:dp[i] 是走到第 i 階的走法數
  2. 寫出轉移式:走到第 i 階的最後一步,不是從 i - 1 階跨 1 階,就是從 i - 2 階跨 2 階,所以 dp[i] = dp[i - 1] + dp[i - 2]
  3. 設定初始值:dp[0] = 1(站在地面,什麼都不做也算一種)、dp[1] = 1
  4. 決定計算順序:dp[i] 要用到比較小的格子,所以 i 由小到大算

轉移式和費氏數列一模一樣,只是初始值不同。

爬樓梯:dp 陣列一格一格填

每一輪 dp[i] 都由它左邊的兩格相加。表格從左往右填,用到的格子一定已經算好。走到第 7 階共有 21 種走法。

方格路徑:二維的表格

一個 4 列 5 行的方格,從左上角走到右下角,每一步只能往右或往下,有幾條不同的路?其中第 1 列第 2 行(從 0 算起)是障礙物,不能經過。

  1. 狀態:dp[i][j] 是從左上角走到格子 (i, j) 的路徑數
  2. 轉移式:走進 (i, j) 的最後一步,不是從上面 (i - 1, j) 下來,就是從左邊 (i, j - 1) 過來,所以 dp[i][j] = dp[i - 1][j] + dp[i][j - 1];障礙物的格子是 0
  3. 初始值:起點 dp[0][0] = 1;第 0 列沒有上面、第 0 行沒有左邊,那一項當作 0
  4. 計算順序:一列一列、由左到右,算 (i, j) 時上面和左邊都已經算好

沒有障礙物時答案是 35(7 步裡選 3 步往下);有了障礙物,經過它的路都不能算。

方格路徑:一列一列填表

框起來的是正在計算的格子,它的值等於上面一格加左邊一格。第 1 列第 2 行是障礙物,一直是 0,所以它右邊和下面的格子少了從它過來的路。右下角的答案是 17。

找零錢:最少要幾枚

第 20 課看到,面額 1、3、4 要湊 6 元時,貪婪法先拿 4,結果用了 3 枚,最少其實是 2 枚(3 + 3)。動態規劃不先做決定,而是把每種可能都比較:

  1. 狀態:dp[x] 是湊出 x 元最少要幾枚
  2. 轉移式:湊 x 元的最後一枚可能是 1、3 或 4 元。如果最後一枚是 c 元,前面就要先湊好 x - c 元,所以 dp[x] = min(dp[x - c] + 1),對每個 c ≤ x 取最小
  3. 初始值:dp[0] = 0;其他先設成一個不可能的大數(這裡用 amount + 1,因為最多也只要 amount 枚 1 元),表示「還湊不出來」
  4. 計算順序:x 由小到大

另外用 used[x] 記下湊 x 元時最後用的那枚硬幣。算完之後從 used[6] 開始往回走,就能還原出實際用了哪些硬幣,而不只是知道枚數。

時間複雜度是 O(金額 × 面額種類數)。

找零錢的動態規劃

每個 x 都試三種「最後一枚」,▲ x-c 指出它要參考的格子。看 x = 6:用 1 元要 dp[5] + 1 = 3 枚,用 3 元只要 dp[3] + 1 = 2 枚,用 4 元要 dp[2] + 1 = 3 枚,所以 dp[6] = 2。最後從 used[6] = 3 往回走,還原出 3 + 3。

想一想:填表格

1.

3 列 3 行、沒有障礙物的方格,從左上角走到右下角(只能往右或往下)有幾條路?

2.

面額 1、5、6,用本課的動態規劃湊 10 元,最少要幾枚?(貪婪法會用 6 + 1 + 1 + 1 + 1 共 5 枚)

3.

找零錢的表格法裡,為什麼 x 要由小到大算?

第 1 次作答

實作:一次可以跨 1、2 或 3 階

把爬樓梯改成每次可以跨 1、2 或 3 階。請完成 dp[i] 的轉移式,算出 4、6、10 階各有幾種走法。

你的輸出
預期輸出

重點整理

  • 動態規劃:每個子問題只算一次,把答案記下來;適用於有重疊子問題和最佳子結構的問題
  • 記憶化(由上而下):遞迴+查表;表格法(由下而上):從最小的子問題往上填,不用遞迴
  • 費氏數列直接遞迴的呼叫次數隨 n 指數成長,動態規劃只要 O(n)
  • 設計四步驟:定義狀態、寫出轉移式、設定初始值、決定計算順序
  • 爬樓梯 dp[i] = dp[i - 1] + dp[i - 2];方格路徑 dp[i][j] = dp[i - 1][j] + dp[i][j - 1];找零 dp[x] = min(dp[x - c] + 1)
  • 另外記下每一格「是從哪個選擇來的」,就能還原出最佳解的實際做法

下一課:動態規劃經典問題。0/1 背包、最長共同子序列、編輯距離,都是用二維表格解的問題。

這一課完成了!
Next lesson:動態規劃經典問題
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。