演算法概念
從費氏數列看起
純遞迴算 會展成指數大的樹—— 就重複算 好幾次、 數十次。每個小問題都從頭算一遍,蠢得很。
動態規劃(DP)就是來解這種「子問題重複出現」的:
把每個子問題算一次,答案記起來,之後直接查表。
費氏數列改 DP:從 開始,由小往大填到 ,每格只算一次。複雜度 。
什麼時候能用 DP?
兩個條件缺一不可:
- 最佳子結構:大問題的最佳解,可以用子問題的最佳解組合出來。
- 重疊子問題:同一個子問題在遞迴中會被用到多次。
沒重疊用分治;沒最佳子結構只能暴搜。
寫 DP 的五步驟
- 定義狀態: 代表什麼?要能對應到原問題。
- 寫轉移方程: 怎麼由更小的 算出來?
- 找邊界:最小的 case 是什麼?
- 決定填表順序:要保證用到的子問題已經算好。
- 找答案位置:最終答案在表的哪一格?
實作風格:
- Top-down(遞迴 + 快取):直觀,容易從定義照寫
- Bottom-up(從小填到大):通常更省記憶體、可壓縮維度
以下範例多用 bottom-up。
各適用情境
DAG 最短路徑
白話版
有向無環圖(DAG,沒有環的有向圖)找最短路徑——這是 DP 最乾淨的舞台。
要算「從起點 到 的最短距離」,先想最後一步:v 的所有入邊 中,挑一條讓總長最短的。
直覺:「先到 v 的某個前一站 u,再走 那條邊」。試遍所有可能的 u,取最短。
邊界:(自己到自己)、其他 。
順序問題:算 要用到 。怎麼保證 u 已經算好?→ 用拓樸順序。
拓樸排序在做什麼?
每個節點是一個任務,箭頭 表示「做 v 之前要先做完 u」。拓樸排序就是把所有任務排成一列,滿足所有依賴關係。
招式:找入度為 0 的點(沒前置)、放進清單、刪掉它的出邊、重複,直到全部取完。
舉例:穿襪子、穿褲子 → 穿鞋子 → 出門。穿襪子和穿褲子順序自由(都入度 0);穿鞋子要等兩個都完成;出門最後。
為什麼 DAG 最短路徑要按拓樸順序算? 因為轉移要用到入邊起點的 dist,拓樸順序保證它們都已經算過了——不會出現「想用的數字還沒算出來」。
複雜度
拓樸排序 、掃每條邊一次 。總共 。每條邊只看一次——拓樸順序保證之後不會再被縮短。
範例
5 節點 A–E,邊:
| 邊 | 權 | 邊 | 權 |
|---|---|---|---|
| 3 | 1 | ||
| 6 | 5 | ||
| 2 | 2 | ||
| 4 |
起點 ,拓樸序 。按順序算:
| 處理 | 入邊 | 計算 | |
|---|---|---|---|
| 起點 | — | 0 | |
| 3 | |||
| 5 | |||
| 6 | |||
| 8 |
Python 實作
from collections import defaultdict, deque
def dag_shortest_path(V, edges, s):
graph = defaultdict(list)
in_deg = {v: 0 for v in V}
for u, v, w in edges:
graph[u].append((v, w))
in_deg[v] += 1
order = []
queue = deque([v for v in V if in_deg[v] == 0])
while queue:
u = queue.popleft()
order.append(u)
for v, _ in graph[u]:
in_deg[v] -= 1
if in_deg[v] == 0:
queue.append(v)
dist = {v: float('inf') for v in V}
dist[s] = 0
for u in order:
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
return dist多階圖
白話版
多階圖(Multistage Graph)是 DAG 的特例:節點分成 個 stage,邊只從 stage 連到 stage 。像是流水線、時間步規劃。
因為 stage 是天然的拓樸順序,可以直接從終點往回推(backward DP):
意思:「在 v,要往下一站走,哪條路加上之後到終點的距離最短?」
邊界 。從 stage 往前推到 stage 1。
複雜度
每條邊看一次 。空間可壓到 (單一 stage 最大節點數)——只要保留下一個 stage 的 值。
範例
7 節點分 4 個 stage:
| Stage | 節點 |
|---|---|
| 1 | |
| 2 | |
| 3 | |
| 4 |
邊:
| 邊 | 權 | 邊 | 權 |
|---|---|---|---|
| 2 | 3 | ||
| 4 | 2 | ||
| 5 | 6 | ||
| 4 | 2 | ||
| 3 |
從 stage 4 往回:
| Stage | 節點 | 計算 | |
|---|---|---|---|
| 4 | 終點 | 0 | |
| 3 | 4 | ||
| 3 | 2 | ||
| 3 | 3 | ||
| 2 | 5 | ||
| 2 | 4 | ||
| 1 | 7 |
最短路徑長度 7,路徑 。
Python 實作
def multistage_shortest(stages, edges):
# stages: list of lists, stages[0]=[s], stages[-1]=[t]
graph = {}
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
t = stages[-1][0]
f = {t: 0}
for stage in reversed(stages[:-1]):
for v in stage:
f[v] = min(w + f[u] for u, w in graph.get(v, []))
return f[stages[0][0]]最長遞增子數列(LIS)
白話版
給整數序列,找最長的「遞增子數列」——不必連續、順序不變。
例:序列 的一個遞增子數列是 ,長度 4。
關鍵狀態:「以 結尾的最長遞增子數列長度」叫做 。「以 結尾」這個限定很重要,否則轉移很難寫。
轉移:在 之前找一個 ( 且 ,也就是它可以接在 後面繼續遞增),挑最長的接下去:
沒這種 就 (自己一個)。答案 。
嚴格 vs 非嚴格
- 嚴格遞增:(不允許等於)
- 非嚴格遞增:(允許等於)
差別只在轉移時的比較符號。題目沒明說時要看上下文判斷——李家同《演算法》課本用非嚴格。
例:序列 :
- 嚴格版 LIS = 或 (2 條)
- 非嚴格版多了 、(共 4 條)
複雜度
樸素 DP:每個 掃前面所有 → 。
進階版用 patience sorting(DP + 二分搜)可壓到 :維護遞增陣列 tails,每個新元素二分找第一個 它的位置覆蓋掉(沒有就 append)。最後 tails 長度就是答案。
範例
(嚴格版):
| 前面比 小的 | 用到的 | |||
|---|---|---|---|---|
| 0 | 3 | — | — | 1 |
| 1 | 1 | 無 | — | 1 |
| 2 | 4 | 3, 1 | 2 | |
| 3 | 1 | 無 | — | 1 |
| 4 | 5 | 3, 1, 4, 1 | 3 | |
| 5 | 9 | 3, 1, 4, 1, 5 | 4 | |
| 6 | 2 | 1, 1 | 2 | |
| 7 | 6 | 3, 1, 4, 1, 5, 2 | 4 |
,答案 ,對應 。
Python 實作(兩種)
def lis_n2(arr):
dp = [1] * len(arr)
for i in range(len(arr)):
for j in range(i):
if arr[j] < arr[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp) if dp else 0
from bisect import bisect_left
def lis_nlogn(arr):
tails = []
for x in arr:
i = bisect_left(tails, x)
if i == len(tails):
tails.append(x)
else:
tails[i] = x
return len(tails)最長共同子序列(LCS)
白話版
「子序列」= 從字串挑出一些字元(順序不變、可以跳過),組成的新字串。例如 ABCDE 的子序列可以是 ACE、BD、空字串等等,但 EA 不是(順序錯了)。
給兩字串 、,LCS 就是最長、同時是兩者子序列的字串。
例: AGCAT、 GAC。共同子序列有 A、AC、GA 等等,最長是 AC 或 GA,長度 2。
實務應用:DNA 比對(兩物種同源程度)、diff / git 差異比對、編輯距離。
轉移方程
狀態 = 取前 個字元、 取前 個字元的 LCS 長度。
Case 1:末字元相同(,注意是 0-indexed)
這個字元一定能進 LCS → 答案 = 「再退一步的 LCS」+ 1:
Case 2:末字元不同
至少要丟掉其中一個結尾字元,看哪邊丟掉後 LCS 較長:
邊界 (空字串的 LCS 是空)。
複雜度
填 表、每格 → 。空間 ,若只要長度可壓到 。
範例
AGCAT、 GAC:
| 0 | 1 (G) | 2 (A) | 3 (C) | |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 1 (A) | 0 | 0 | 1 | 1 |
| 2 (G) | 0 | 1 | 1 | 1 |
| 3 (C) | 0 | 1 | 1 | 2 |
| 4 (A) | 0 | 1 | 2 | 2 |
| 5 (T) | 0 | 1 | 2 | 2 |
幾格細節:
- : A 對上 A →
- : C 對上 C →
- : A 對上 A →
答案 。
還原 LCS:從右下 往左上走——遇到 走對角 並紀錄字元;否則往值較大的那邊(上或左)走。追出 GA 或 AC。
Python 實作
def lcs(X, Y):
m, n = len(X), len(Y)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i - 1] == Y[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]一般最短路徑(Bellman-Ford)
白話版
DAG 的拓樸序法在有環圖就行不通了(環會卡死「依賴關係」)。Bellman-Ford 處理有環、甚至含負權邊的一般圖。
正權邊用 Dijkstra 更快;這邊聚焦能處理負邊的 Bellman-Ford。
維護一張表 = 從起點 到 目前已知的最短距離。對每條邊 (權重 )做檢查:
白話:「經過 u 再走這條邊到 v,比現在的記錄還短嗎?若是,就改寫」。
把所有邊都檢查一次叫一輪。重複跑直到沒任何邊能再縮短(最多 輪保證收斂)。
這個「檢查並縮短」的動作,教科書叫 relax(鬆弛),但名字滿誤導的、不必糾結。實際就是上面那個 if 條件。
為什麼 Dijkstra 不行? Dijkstra 每輪挑「目前 dist 最小的」當「已確定」、之後不改。但有負邊時,已確定的點可能還能被「繞遠路後減扣」變更短——這個假設破掉了。Bellman-Ford 因為「每輪所有邊都重檢查」抓得到這種修正。
DP 視角
正式的 DP 狀態: = 從 到 、最多使用 條邊的最短距離。轉移:
任何不含環的最短路徑最多 條邊 → 跑 輪保證收斂。實作上不用真的開二維表,每輪就用同一張 更新即可。
偵測負權環:跑完 輪後再跑一輪,若還能縮短就有可達的負權環。
複雜度
每輪掃 條邊、 輪 → 。比 Dijkstra()慢,但能處理負權邊。
範例
5 節點,含負權邊:
| 邊 | 權 | 邊 | 權 |
|---|---|---|---|
| 6 | 9 | ||
| 7 | |||
| 8 | 2 | ||
| 5 | 7 | ||
起點 。跑 4 輪():
| 輪 | A | B | C | D | E | 說明 |
|---|---|---|---|---|---|---|
| 0 | 0 | ∞ | ∞ | ∞ | ∞ | 初始 |
| 1 | 0 | 6 | 7 | 4 | 2 | 、、、 |
| 2 | 0 | 2 | 7 | 4 | 2 | (經 回 ) |
| 3 | 0 | 2 | 7 | 4 | ||
| 4 | 0 | 2 | 7 | 4 | 穩定 |
最終 。
層次直覺
按與起點的「最少邊數」幫點分層:
- 第 1 層:起點本身
- 第 2 層:起點直接相連的鄰居
- 第 3 層:再走一步可達的
- …
第 輪結束後,最多用 條邊可達的所有點,dist 都已是目前最佳。所以 輪能涵蓋最多 層、保證收斂。
實際上有時更早收斂——若某輪沒任何邊縮短了 dist,可提早結束。常見實作就加個 flag 偵測。
Python 實作(含負權環偵測)
def bellman_ford(V, edges, s):
dist = {v: float('inf') for v in V}
dist[s] = 0
for _ in range(len(V) - 1):
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
for u, v, w in edges:
if dist[u] + w < dist[v]:
raise ValueError('negative cycle')
return dist所有節點對最短路徑(Floyd-Warshall)
白話版
要一次算出所有節點對之間的最短路徑。跑 次 Bellman-Ford 是 很貴;Floyd-Warshall 用不同的 DP 維度, 一次解掉。
巧妙觀察:「從 到 的最短路徑,要嘛經過某個中繼點 ,要嘛不經過。」
逐步把中繼點集合放寬。狀態 = 從 到 、只允許用編號 的節點當中繼的最短距離。轉移:
兩個選擇:
- 不走 :維持原本
- 走 : 加上
取兩者較小。
三層迴圈的最外層必須是 ,順序錯就錯!直覺:要先把「中繼點到 為止」的所有 對都算完,才能擴展到「中繼點到 」。 在內層會讓某些 dp 還沒準備好就被使用。
複雜度
三層迴圈各 次 → 。空間 。適合稠密圖;稀疏圖()跑 次 Dijkstra 可能更快。
範例
4 節點(0–3),初始直接邊權重:
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 3 | ∞ | 7 |
| 1 | 8 | 0 | 2 | ∞ |
| 2 | 5 | ∞ | 0 | 1 |
| 3 | 2 | ∞ | ∞ | 0 |
(允許經過 0):、、。
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 3 | ∞ | 7 |
| 1 | 8 | 0 | 2 | 15 |
| 2 | 5 | 8 | 0 | 1 |
| 3 | 2 | 5 | ∞ | 0 |
(再允許經過 1):、。
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 3 | 5 | 7 |
| 1 | 8 | 0 | 2 | 15 |
| 2 | 5 | 8 | 0 | 1 |
| 3 | 2 | 5 | 7 | 0 |
(再允許經過 2):、。
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 3 | 5 | 6 |
| 1 | 8 | 0 | 2 | 3 |
| 2 | 5 | 8 | 0 | 1 |
| 3 | 2 | 5 | 7 | 0 |
(再允許經過 3):、、。
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 0 | 3 | 5 | 6 |
| 1 | 5 | 0 | 2 | 3 |
| 2 | 3 | 6 | 0 | 1 |
| 3 | 2 | 5 | 7 | 0 |
最終矩陣。
Python 實作
def floyd_warshall(V, edges):
INF = float('inf')
dist = [[INF] * V for _ in range(V)]
for i in range(V):
dist[i][i] = 0
for u, v, w in edges:
dist[u][v] = w
for k in range(V):
for i in range(V):
for j in range(V):
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist0/1 背包
白話版
件物品,第 件重 、價值 。背包容量 。每件物品只能拿或不拿(不能切、不能多拿)。最大化總價值。
對每件物品都只有兩個選擇:拿 或 不拿。問題拆成「處理完第 件、剩 容量時的最大價值」就能 DP。
狀態 = 只考慮前 件、背包容量 時的最大價值。轉移:
- (裝不下,只能不拿):
- 否則拿或不拿取較大:
直覺:對第 件物品,「不拿」就是前 件在 容量下的最大值;「拿」就是先騰出 的位置(前 件用剩下 )再加上 。
複雜度
填 表 → 。空間壓縮到 (只需上一列)。
為什麼不是真正的多項式(偽多項式 / pseudo-polynomial)
看起來像多項式,但 和 代表的「東西」不一樣:
- 是物品數——就是輸入長度
- 是背包容量——是輸入裡一個數字的大小
電腦裡一個數字 要用 個 bit 存。 多給 1 個 bit(譬如從 變 )執行時間就翻倍——以輸入長度(bit 數)來看, 實際上是 ,對 bit 數 是指數級。
這種「對數值本身是多項式、對輸入長度是指數」的複雜度叫 pseudo-polynomial(偽多項式)。所以 0/1 背包被歸為 NP-hard:只有在 不太大時 DP 才實用。
簡單判斷:複雜度裡如果出現「輸入裡某個數字本身」(而不是數字的個數),就要警覺它可能是偽多項式。
範例
,5 件物品:
| 物品 | ||
|---|---|---|
| 1 | 2 | 3 |
| 2 | 3 | 4 |
| 3 | 4 | 5 |
| 4 | 5 | 6 |
| 5 | 9 | 10 |
填表:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
| 2 | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 | 7 | 7 |
| 3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 | 12 | 12 |
| 4 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 12 | 13 |
| 5 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 12 | 13 |
幾格細節:
- :物品 2 重 3。不拿 、拿 。取 7。
- :物品 3 重 4。不拿 、拿 。取 12。
- :物品 5 重 9。不拿 、拿 。取 13。
最大總價值 13。
還原物品:從 往回——
- → 物品 5 沒拿
- → 物品 4 有拿,移到
- → 物品 3 沒拿
- → 物品 2 有拿,移到
- → 物品 1 有拿
拿物品 1、2、4,重 ,價值 ✓
Python 實作(空間壓縮版)
def knapsack_01(weights, values, W):
dp = [0] * (W + 1)
for w, v in zip(weights, values):
for j in range(W, w - 1, -1): # 反向!避免同物品被拿多次
dp[j] = max(dp[j], dp[j - w] + v)
return dp[W]反向走容量是關鍵:正向走會讓同一個物品被拿多次(變成完全背包)。
常見考法
| # | 題型 | 重點 |
|---|---|---|
| 1 | LIS 手跑 DP | dp[i] = 1 + max{dp[j] : j<i, a[j]<a[i]};要還原得記 parent |
| 2 | LCS 填表 + 還原 | 末字元相同走對角 +1;否則取上/左較大;追回時走等號的方向 |
| 3 | 0/1 Knapsack 填表 + 還原 | 拿/不拿取 max;空間壓縮要反向走容量 |
| 4 | DAG vs Bellman-Ford 差異 | DAG 拓樸序一次掃完;有環需要 輪 |
| 5 | Floyd-Warshall 三層迴圈順序 | 最外層必須是中繼 ;順序錯就錯 |
| 6 | 判斷該用哪個演算法 | 正權用 Dijkstra;有負權無負環用 Bellman-Ford;全對用 Floyd |
| 7 | 轉移方程寫出來 | 多半題目只問狀態定義與轉移方程 |
| 8 | 為什麼 Knapsack 是 NP-hard | 的位數是 ;以輸入大小看 |
複雜度推導:
- Bellman-Ford : 輪 × 每輪 條邊
- Floyd-Warshall :三層迴圈各 次
- LCS : 表、每格
- LIS :;patience 可降
- 0/1 Knapsack : 表
考試常踩的陷阱:
- LIS 不是子字串:子序列不要求連續。
- LCS 末字元相同只能 +1 一次:不能雙向各退一格。
- 0/1 Knapsack 空間壓縮要反向:正向會讓物品被重複拿。
- Bellman-Ford 第 輪偵測負權環:正常 輪收斂;還能縮短就有負環。
- Floyd-Warshall 必須在最外層: 在內層會錯,因為要先把 的所有配對更新完才能擴展。
下一篇 NP 完備理論——即使 Knapsack 有 DP 解,也逃不過「問題本身就難」的宿命。
Latest Updates
- 2026.05.26 Content updated
- 2026.04.12 Content updated
