algorithm dynamic programming LIS LCS knapsack bellman-ford floyd-warshall

[Algorithm] 動態規劃

演算法概念

從費氏數列看起

純遞迴算 F(n)=F(n1)+F(n2)F(n) = F(n-1) + F(n-2) 會展成指數大的樹——F(10)F(10) 就重複算 F(5)F(5) 好幾次、F(3)F(3) 數十次。每個小問題都從頭算一遍,蠢得很。

動態規劃(DP)就是來解這種「子問題重複出現」的:

把每個子問題算一次,答案記起來,之後直接查表。

費氏數列改 DP:從 F(0),F(1)F(0), F(1) 開始,由小往大填到 F(n)F(n),每格只算一次。複雜度 O(2n)O(n)O(2^n) \to O(n)

什麼時候能用 DP?

兩個條件缺一不可:

  1. 最佳子結構:大問題的最佳解,可以用子問題的最佳解組合出來。
  2. 重疊子問題:同一個子問題在遞迴中會被用到多次。

沒重疊用分治;沒最佳子結構只能暴搜。

寫 DP 的五步驟

  1. 定義狀態dp[i]\text{dp}[i] 代表什麼?要能對應到原問題。
  2. 寫轉移方程dp[i]\text{dp}[i] 怎麼由更小的 dp[?]\text{dp}[?] 算出來?
  3. 找邊界:最小的 case 是什麼?
  4. 決定填表順序:要保證用到的子問題已經算好。
  5. 找答案位置:最終答案在表的哪一格?

實作風格:

  • Top-down(遞迴 + 快取):直觀,容易從定義照寫
  • Bottom-up(從小填到大):通常更省記憶體、可壓縮維度

以下範例多用 bottom-up。

各適用情境

DAG 最短路徑

白話版

有向無環圖(DAG,沒有環的有向圖)找最短路徑——這是 DP 最乾淨的舞台。

要算「從起點 ssvv 的最短距離」,先想最後一步:v 的所有入邊 uvu \to v 中,挑一條讓總長最短的。

dist(v)=minuv(dist(u)+w(u,v))\text{dist}(v) = \min_{u \to v}\bigl(\text{dist}(u) + w(u, v)\bigr)

直覺:「先到 v 的某個前一站 u,再走 uvu \to v 那條邊」。試遍所有可能的 u,取最短。

邊界dist(s)=0\text{dist}(s) = 0(自己到自己)、其他 \infty

順序問題:算 dist(v)\text{dist}(v) 要用到 dist(u)\text{dist}(u)。怎麼保證 u 已經算好?→ 用拓樸順序

拓樸排序在做什麼?

每個節點是一個任務,箭頭 uvu \to v 表示「做 v 之前要先做完 u」。拓樸排序就是把所有任務排成一列,滿足所有依賴關係。

招式:找入度為 0 的點(沒前置)、放進清單、刪掉它的出邊、重複,直到全部取完。

舉例:穿襪子、穿褲子 → 穿鞋子 → 出門。穿襪子和穿褲子順序自由(都入度 0);穿鞋子要等兩個都完成;出門最後。

為什麼 DAG 最短路徑要按拓樸順序算? 因為轉移要用到入邊起點的 dist,拓樸順序保證它們都已經算過了——不會出現「想用的數字還沒算出來」。

複雜度

拓樸排序 O(V+E)O(V + E)、掃每條邊一次 O(V+E)O(V + E)。總共 O(V+E)O(V + E)。每條邊只看一次——拓樸順序保證之後不會再被縮短。

範例

5 節點 A–E,邊:

ABA \to B3CDC \to D1
ACA \to C6CEC \to E5
BCB \to C2DED \to E2
BDB \to D4

起點 s=As = A,拓樸序 A,B,C,D,EA, B, C, D, E。按順序算:

處理入邊計算dist\text{dist}
AA起點0
BBABA \to B0+30 + 33
CCAC,BCA \to C, B \to Cmin(0+6,3+2)\min(0 + 6, 3 + 2)5
DDBD,CDB \to D, C \to Dmin(3+4,5+1)\min(3 + 4, 5 + 1)6
EECE,DEC \to E, D \to Emin(5+5,6+2)\min(5 + 5, 6 + 2)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 的特例:節點分成 kk 個 stage,邊只從 stage ii 連到 stage i+1i+1。像是流水線、時間步規劃。

因為 stage 是天然的拓樸順序,可以直接從終點往回推(backward DP):

f(v)=minvu(w(v,u)+f(u))f(v) = \min_{v \to u}\bigl(w(v, u) + f(u)\bigr)

意思:「在 v,要往下一站走,哪條路加上之後到終點的距離最短?」

邊界 f(t)=0f(t) = 0。從 stage kk 往前推到 stage 1。

複雜度

每條邊看一次 O(V+E)O(V + E)。空間可壓到 O(nmax)O(n_{\max})(單一 stage 最大節點數)——只要保留下一個 stage 的 ff 值。

範例

7 節點分 4 個 stage:

Stage節點
1ss
2A,BA, B
3C,D,EC, D, E
4tt

邊:

sAs \to A2ADA \to D3
sBs \to B4BDB \to D2
ACA \to C5BEB \to E6
CtC \to t4DtD \to t2
EtE \to t3

從 stage 4 往回:

Stage節點計算ff
4tt終點0
3CC4+04 + 04
3DD2+02 + 02
3EE3+03 + 03
2AAmin(5+4,3+2)\min(5 + 4, 3 + 2)5
2BBmin(2+2,6+3)\min(2 + 2, 6 + 3)4
1ssmin(2+5,4+4)\min(2 + 5, 4 + 4)7

最短路徑長度 7,路徑 sADts \to A \to D \to t

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)

白話版

給整數序列,找最長的「遞增子數列」——不必連續、順序不變

例:序列 [3,1,4,1,5,9,2,6][3, 1, 4, 1, 5, 9, 2, 6] 的一個遞增子數列是 [1,4,5,9][1, 4, 5, 9],長度 4。

關鍵狀態:「a[i]a[i] 結尾的最長遞增子數列長度」叫做 dp[i]\text{dp}[i]。「以 a[i]a[i] 結尾」這個限定很重要,否則轉移很難寫。

轉移:在 a[i]a[i] 之前找一個 a[j]a[j]j<ij < ia[j]<a[i]a[j] < a[i],也就是它可以接在 a[j]a[j] 後面繼續遞增),挑最長的接下去:

dp[i]=1+max{dp[j]:j<i,  a[j]<a[i]}\text{dp}[i] = 1 + \max\{\text{dp}[j] : j < i,\; a[j] < a[i]\}

沒這種 jjdp[i]=1\text{dp}[i] = 1(自己一個)。答案 max(dp)\max(\text{dp})

嚴格 vs 非嚴格

  • 嚴格遞增:a[j]<a[i]a[j] < a[i](不允許等於)
  • 非嚴格遞增:a[j]a[i]a[j] \leq a[i](允許等於)

差別只在轉移時的比較符號。題目沒明說時要看上下文判斷——李家同《演算法》課本用非嚴格

例:序列 [5,2,1,2,3,6,9,7][5, 2, 1, 2, 3, 6, 9, 7]

  • 嚴格版 LIS = [1,2,3,6,9][1, 2, 3, 6, 9][1,2,3,6,7][1, 2, 3, 6, 7](2 條)
  • 非嚴格版多了 [2,2,3,6,9][2, 2, 3, 6, 9][2,2,3,6,7][2, 2, 3, 6, 7](共 4 條)

複雜度

樸素 DP:每個 ii 掃前面所有 jjO(n2)O(n^2)

進階版用 patience sorting(DP + 二分搜)可壓到 O(nlogn)O(n \log n):維護遞增陣列 tails,每個新元素二分找第一個 \geq 它的位置覆蓋掉(沒有就 append)。最後 tails 長度就是答案。

範例

a=[3,1,4,1,5,9,2,6]a = [3, 1, 4, 1, 5, 9, 2, 6](嚴格版):

iia[i]a[i]前面比 a[i]a[i] 小的用到的 dp[j]\text{dp}[j]dp[i]\text{dp}[i]
031
111
243, 1max(1,1)+1\max(1, 1) + 12
311
453, 1, 4, 1max(1,1,2,1)+1\max(1, 1, 2, 1) + 13
593, 1, 4, 1, 5max(1,1,2,1,3)+1\max(1, 1, 2, 1, 3) + 14
621, 1max(1,1)+1\max(1, 1) + 12
763, 1, 4, 1, 5, 2max(1,1,2,1,3,2)+1\max(1, 1, 2, 1, 3, 2) + 14

dp=[1,1,2,1,3,4,2,4]\text{dp} = [1, 1, 2, 1, 3, 4, 2, 4],答案 =4= 4,對應 [1,4,5,9][1, 4, 5, 9]

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 的子序列可以是 ACEBD、空字串等等,但 EA 不是(順序錯了)。

給兩字串 XXYYLCS 就是最長、同時是兩者子序列的字串。

例:X=X = AGCATY=Y = GAC。共同子序列有 AACGA 等等,最長是 ACGA,長度 2。

實務應用:DNA 比對(兩物種同源程度)、diff / git 差異比對、編輯距離。

轉移方程

狀態 dp[i][j]\text{dp}[i][j] = XX 取前 ii 個字元、YY 取前 jj 個字元的 LCS 長度。

Case 1:末字元相同X[i1]=Y[j1]X[i-1] = Y[j-1],注意是 0-indexed)

這個字元一定能進 LCS → 答案 = 「再退一步的 LCS」+ 1:

dp[i][j]=dp[i1][j1]+1\text{dp}[i][j] = \text{dp}[i-1][j-1] + 1

Case 2:末字元不同

至少要丟掉其中一個結尾字元,看哪邊丟掉後 LCS 較長:

dp[i][j]=max(dp[i1][j],  dp[i][j1])\text{dp}[i][j] = \max\bigl(\text{dp}[i-1][j],\; \text{dp}[i][j-1]\bigr)

邊界 dp[0][j]=dp[i][0]=0\text{dp}[0][j] = \text{dp}[i][0] = 0(空字串的 LCS 是空)。

複雜度

(m+1)(n+1)(m+1)(n+1) 表、每格 O(1)O(1)O(mn)O(mn)。空間 O(mn)O(mn),若只要長度可壓到 O(min(m,n))O(\min(m, n))

範例

X=X = AGCATY=Y = GAC

i\ji \backslash j01 (G)2 (A)3 (C)
00000
1 (A)0011
2 (G)0111
3 (C)0112
4 (A)0122
5 (T)0122

幾格細節:

  • (1,2)(1, 2)X[0]=X[0] = A 對上 Y[1]=Y[1] = A → dp[0][1]+1=1\text{dp}[0][1] + 1 = 1
  • (3,3)(3, 3)X[2]=X[2] = C 對上 Y[2]=Y[2] = C → dp[2][2]+1=2\text{dp}[2][2] + 1 = 2
  • (4,2)(4, 2)X[3]=X[3] = A 對上 Y[1]=Y[1] = A → dp[3][1]+1=2\text{dp}[3][1] + 1 = 2

答案 dp[5][3]=2\text{dp}[5][3] = 2

還原 LCS:從右下 (m,n)(m, n) 往左上走——遇到 X[i1]=Y[j1]X[i-1] = Y[j-1] 走對角 (1,1)(-1, -1) 並紀錄字元;否則往值較大的那邊(上或左)走。追出 GAAC

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。

維護一張表 dist[v]\text{dist}[v] = 從起點 ssvv目前已知的最短距離。對每條邊 uvu \to v(權重 ww)做檢查:

若 dist[u]+w<dist[v],則更新 dist[v]=dist[u]+w\text{若 } \text{dist}[u] + w < \text{dist}[v],\text{則更新 } \text{dist}[v] = \text{dist}[u] + w

白話:「經過 u 再走這條邊到 v,比現在的記錄還短嗎?若是,就改寫」。

所有邊都檢查一次叫一輪。重複跑直到沒任何邊能再縮短(最多 V1|V| - 1 輪保證收斂)。

這個「檢查並縮短」的動作,教科書叫 relax(鬆弛),但名字滿誤導的、不必糾結。實際就是上面那個 if 條件。

為什麼 Dijkstra 不行? Dijkstra 每輪挑「目前 dist 最小的」當「已確定」、之後不改。但有負邊時,已確定的點可能還能被「繞遠路後減扣」變更短——這個假設破掉了。Bellman-Ford 因為「每輪所有邊都重檢查」抓得到這種修正。

DP 視角

正式的 DP 狀態:distk(v)\text{dist}_k(v) = 從 ssvv最多使用 kk 條邊的最短距離。轉移:

distk(v)=min(distk1(v),    minuv(distk1(u)+w(u,v)))\text{dist}_k(v) = \min\Bigl(\text{dist}_{k-1}(v),\;\; \min_{u \to v}\bigl(\text{dist}_{k-1}(u) + w(u, v)\bigr)\Bigr)

任何不含環的最短路徑最多 V1|V| - 1 條邊 → 跑 V1|V| - 1 輪保證收斂。實作上不用真的開二維表,每輪就用同一張 dist\text{dist} 更新即可。

偵測負權環:跑完 V1|V| - 1 輪後再跑一輪,若還能縮短就有可達的負權環。

複雜度

每輪掃 EE 條邊、V1V - 1 輪 → O(VE)O(V \cdot E)。比 Dijkstra(O((V+E)logV)O((V + E) \log V))慢,但能處理負權邊。

範例

5 節點,含負權邊:

ABA \to B6CEC \to E9
ACA \to C7DBD \to B2-2
BCB \to C8EAE \to A2
BDB \to D5EDE \to D7
BEB \to E4-4
CDC \to D3-3

起點 s=As = A。跑 4 輪(V1|V| - 1):

ABCDE說明
00初始
106742B6B \leftarrow 6C7C \leftarrow 7D73=4D \leftarrow 7 - 3 = 4E64=2E \leftarrow 6 - 4 = 2
202742B42=2B \leftarrow 4 - 2 = 2(經 DDBB
302742-2E24=2E \leftarrow 2 - 4 = -2
402742-2穩定

最終 A=0,B=2,C=7,D=4,E=2A = 0, B = 2, C = 7, D = 4, E = -2

層次直覺

按與起點的「最少邊數」幫點分層:

  • 第 1 層:起點本身
  • 第 2 層:起點直接相連的鄰居
  • 第 3 層:再走一步可達的

kk 輪結束後,最多用 kk 條邊可達的所有點,dist 都已是目前最佳。所以 V1|V| - 1 輪能涵蓋最多 V|V| 層、保證收斂。

實際上有時更早收斂——若某輪沒任何邊縮短了 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)

白話版

要一次算出所有節點對之間的最短路徑。跑 VV 次 Bellman-Ford 是 O(V2E)O(V^2 E) 很貴;Floyd-Warshall 用不同的 DP 維度,O(V3)O(V^3) 一次解掉。

巧妙觀察:「從 iijj 的最短路徑,要嘛經過某個中繼點 kk,要嘛不經過。」

逐步把中繼點集合放寬。狀態 dpk[i][j]\text{dp}_k[i][j] = 從 iijj只允許用編號 k\leq k 的節點當中繼的最短距離。轉移:

dpk[i][j]=min(dpk1[i][j],    dpk1[i][k]+dpk1[k][j])\text{dp}_k[i][j] = \min\bigl(\text{dp}_{k-1}[i][j],\;\; \text{dp}_{k-1}[i][k] + \text{dp}_{k-1}[k][j]\bigr)

兩個選擇:

  • 不走 kk:維持原本 dpk1[i][j]\text{dp}_{k-1}[i][j]
  • kkiki \to k 加上 kjk \to j

取兩者較小。

三層迴圈的最外層必須是 kk,順序錯就錯!直覺:要先把「中繼點到 kk 為止」的所有 (i,j)(i, j) 對都算完,才能擴展到「中繼點到 k+1k+1」。kk 在內層會讓某些 dp 還沒準備好就被使用。

複雜度

三層迴圈各 VV 次 → O(V3)O(V^3)。空間 O(V2)O(V^2)。適合稠密圖;稀疏圖(EV2E \ll V^2)跑 VV 次 Dijkstra 可能更快。

範例

4 節點(0–3),初始直接邊權重:

0123
0037
1802
2501
320

k=0k = 0(允許經過 0):(1,3):8+7=15(1, 3): 8 + 7 = 15(2,1):5+3=8(2, 1): 5 + 3 = 8(3,1):2+3=5(3, 1): 2 + 3 = 5

0123
0037
180215
25801
3250

k=1k = 1(再允許經過 1):(0,2):3+2=5(0, 2): 3 + 2 = 5(3,2):5+2=7(3, 2): 5 + 2 = 7

0123
00357
180215
25801
32570

k=2k = 2(再允許經過 2):(0,3):5+1=6<7(0, 3): 5 + 1 = 6 < 7(1,3):2+1=3<15(1, 3): 2 + 1 = 3 < 15

0123
00356
18023
25801
32570

k=3k = 3(再允許經過 3):(1,0):3+2=5<8(1, 0): 3 + 2 = 5 < 8(2,0):1+2=3<5(2, 0): 1 + 2 = 3 < 5(2,1):1+5=6<8(2, 1): 1 + 5 = 6 < 8

0123
00356
15023
23601
32570

最終矩陣。

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 dist

0/1 背包

白話版

nn 件物品,第 ii 件重 wiw_i、價值 viv_i。背包容量 WW。每件物品只能拿或不拿(不能切、不能多拿)。最大化總價值。

對每件物品都只有兩個選擇:不拿。問題拆成「處理完第 ii 件、剩 ww 容量時的最大價值」就能 DP。

狀態 dp[i][w]\text{dp}[i][w] = 只考慮前 ii 件、背包容量 ww 時的最大價值。轉移:

  • wi>ww_i > w(裝不下,只能不拿):dp[i][w]=dp[i1][w]\text{dp}[i][w] = \text{dp}[i-1][w]
  • 否則拿或不拿取較大:dp[i][w]=max(dp[i1][w],  dp[i1][wwi]+vi)\text{dp}[i][w] = \max\bigl(\text{dp}[i-1][w],\; \text{dp}[i-1][w - w_i] + v_i\bigr)

直覺:對第 ii 件物品,「不拿」就是前 i1i-1 件在 ww 容量下的最大值;「拿」就是先騰出 wiw_i 的位置(前 i1i-1 件用剩下 wwiw - w_i)再加上 viv_i

複雜度

n(W+1)n \cdot (W + 1) 表 → O(nW)O(nW)。空間壓縮到 O(W)O(W)(只需上一列)。

O(nW)O(nW) 為什麼不是真正的多項式(偽多項式 / pseudo-polynomial)

O(nW)O(nW) 看起來像多項式,但 WWnn 代表的「東西」不一樣:

  • nn 是物品數——就是輸入長度
  • WW 是背包容量——是輸入裡一個數字的大小

電腦裡一個數字 WW 要用 log2W\lceil \log_2 W \rceil 個 bit 存。WW 多給 1 個 bit(譬如從 2302^{30}2312^{31})執行時間就翻倍——以輸入長度(bit 數)來看,O(nW)O(nW) 實際上是 O(n2b)O(n \cdot 2^b),對 bit 數 bb 是指數級

這種「對數值本身是多項式、對輸入長度是指數」的複雜度叫 pseudo-polynomial(偽多項式)。所以 0/1 背包被歸為 NP-hard:只有在 WW 不太大時 DP 才實用。

簡單判斷:複雜度裡如果出現「輸入裡某個數字本身」(而不是數字的個數),就要警覺它可能是偽多項式。

範例

W=10W = 10,5 件物品:

物品wwvv
123
234
345
456
5910

填表:

i\wi \backslash w012345678910
000000000000
100333333333
200344777777
30034578991212
400345789101213
500345789101213

幾格細節:

  • (2,5)(2, 5):物品 2 重 3。不拿 =3= 3、拿 =dp[1][2]+4=3+4=7= \text{dp}[1][2] + 4 = 3 + 4 = 7。取 7。
  • (3,9)(3, 9):物品 3 重 4。不拿 =7= 7、拿 =dp[2][5]+5=7+5=12= \text{dp}[2][5] + 5 = 7 + 5 = 12。取 12。
  • (5,10)(5, 10):物品 5 重 9。不拿 =13= 13、拿 =dp[4][1]+10=0+10=10= \text{dp}[4][1] + 10 = 0 + 10 = 10。取 13。

最大總價值 13。

還原物品:從 (5,10)(5, 10) 往回——

  • (5,10)=13=dp[4][10](5, 10) = 13 = \text{dp}[4][10] → 物品 5 沒拿
  • (4,10)=13dp[3][10]=12(4, 10) = 13 \neq \text{dp}[3][10] = 12 → 物品 4 有拿,移到 (3,5)(3, 5)
  • (3,5)=7=dp[2][5](3, 5) = 7 = \text{dp}[2][5] → 物品 3 沒拿
  • (2,5)=7dp[1][5]=3(2, 5) = 7 \neq \text{dp}[1][5] = 3 → 物品 2 有拿,移到 (1,2)(1, 2)
  • (1,2)=3dp[0][2]=0(1, 2) = 3 \neq \text{dp}[0][2] = 0 → 物品 1 有拿

拿物品 1、2、4,重 2+3+5=102 + 3 + 5 = 10,價值 3+4+6=133 + 4 + 6 = 13

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]

反向走容量是關鍵:正向走會讓同一個物品被拿多次(變成完全背包)。

常見考法

#題型重點
1LIS 手跑 O(n2)O(n^2) DPdp[i] = 1 + max{dp[j] : j<i, a[j]<a[i]};要還原得記 parent
2LCS 填表 + 還原末字元相同走對角 +1;否則取上/左較大;追回時走等號的方向
30/1 Knapsack 填表 + 還原拿/不拿取 max;空間壓縮要反向走容量
4DAG vs Bellman-Ford 差異DAG 拓樸序一次掃完;有環需要 V1V - 1
5Floyd-Warshall 三層迴圈順序最外層必須是中繼 kk;順序錯就錯
6判斷該用哪個演算法正權用 Dijkstra;有負權無負環用 Bellman-Ford;全對用 Floyd
7轉移方程寫出來多半題目只問狀態定義與轉移方程
8為什麼 Knapsack 是 NP-hardWW位數logW\log W;以輸入大小看 O(nW)=O(n2b)O(nW) = O(n \cdot 2^b)

複雜度推導:

  • Bellman-Ford O(VE)O(VE)V1V - 1 輪 × 每輪 EE 條邊
  • Floyd-Warshall O(V3)O(V^3):三層迴圈各 VV
  • LCS O(mn)O(mn)(m+1)(n+1)(m+1)(n+1) 表、每格 O(1)O(1)
  • LIS O(n2)O(n^2)i=0n1i=n(n1)2\sum_{i=0}^{n-1} i = \frac{n(n-1)}{2};patience 可降 O(nlogn)O(n \log n)
  • 0/1 Knapsack O(nW)O(nW)n(W+1)n \cdot (W+1)

考試常踩的陷阱

  1. LIS 不是子字串:子序列不要求連續。
  2. LCS 末字元相同只能 +1 一次:不能雙向各退一格。
  3. 0/1 Knapsack 空間壓縮要反向:正向會讓物品被重複拿。
  4. Bellman-Ford 第 VV 輪偵測負權環:正常 V1V - 1 輪收斂;還能縮短就有負環。
  5. Floyd-Warshall kk 必須在最外層:kk 在內層會錯,因為要先把 kk 的所有配對更新完才能擴展。

下一篇 NP 完備理論——即使 Knapsack 有 DP 解,也逃不過「問題本身就難」的宿命。

Latest Updates

  • 2026.05.26 Content updated
  • 2026.04.12 Content updated