7.2 動態規劃救背包問題#
概念說明#
第 5.2 節已介紹 0/1 背包的基本動態規劃(DP)。本節深入探討 DP 為什麼能「救」背包問題,以及工程上的關鍵改良。核心洞察有二:
- 偽多項式的本質:背包的困難來自「數值大」,而非「物品多」。DP 的複雜度 O(nW) 中,W 是數值——它以二進位表示只佔 log W 個位元。當 W 與 n 同階或更小時,DP 是貨真價實的多項式演算法。這解釋了為什麼 NP-hard 不等於「所有實例都無解法」:難度集中在數字巨大的稀疏實例上。
- 兩種狀態設計:以「容量」為狀態得到 O(nW);以「價值」為狀態(dp[v] = 達到價值 v 的最小重量)則得到 O(n·ΣV)。兩者可擇優:容量小用前者、價值總和小用後者。這個對偶性是 DP 設計的重要思維——狀態空間選在哪個維度,決定了解法的實際效率。
工程改良還包括:位元平行化(用整數的位元運算一次更新 64 個狀態,Python 大整數天然支援)、滾動陣列省空間、以及「子集和支配性」剪枝(若物品 A 重量與價值都不差於 B,捨棄 B)。實務上,物品數上千、容量數十萬的背包實例,用位元平行 DP 可在秒級解出。
演算法步驟#
- 標準一維 DP:
dp[j]= 容量 j 的最大價值,倒序更新。 - 位元平行版(子集和型):把 dp 陣列編碼成一個大整數,第 j 位代表「和 j 可達」。每個物品 s 的轉移就是整體左移 s 位再 OR。
- 以價值為狀態的版本:
minw[v]= 恰好達到價值 v 的最小重量,轉移 minw[v] = min(minw[v], minw[v - vi] + wi)。 - 選擇策略:若 W < ΣV 用容量版,否則用價值版。
- 需要解還原時,記錄每個狀態的決策來源,回溯輸出物品清單。
Python 程式碼範例#
def knapsack_dp(weights, values, W):
"""標準 O(nW) 一維 DP"""
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]
def subset_sum_bitset(nums, t):
"""位元平行的子集和:O(n * t / 64)"""
bits = 1 # 第 0 位 = 1(和 0 可達)
mask = (1 << (t + 1)) - 1 # 只保留 t 位以內
for s in nums:
bits = (bits | (bits << s)) & mask
return (bits >> t) & 1 == 1
def knapsack_by_value(weights, values, W):
"""以價值為狀態:O(n * ΣV),回傳最大價值"""
total_v = sum(values)
INF = float('inf')
minw = [INF] * (total_v + 1)
minw[0] = 0
for w, v in zip(weights, values):
for u in range(total_v, v - 1, -1):
if minw[u - v] + w <= W and \
minw[u - v] + w < minw[u]:
minw[u] = minw[u - v] + w
return max(v for v in range(total_v + 1)
if minw[v] <= W)
import random, time
weights = [random.randint(1, 1000) for _ in range(200)]
values = [random.randint(1, 1000) for _ in range(200)]
W = 50000
t0 = time.time()
print("容量版 DP:", knapsack_dp(weights, values, W),
f"{time.time()-t0:.3f}s")
t0 = time.time()
print("價值版 DP:", knapsack_by_value(weights, values, W),
f"{time.time()-t0:.3f}s")
print("位元平行子集和可達 12345:",
subset_sum_bitset(weights, 12345))
複雜度分析#
- 容量版:O(nW) 時間、O(W) 空間。
- 價值版:O(nΣV) 時間、O(ΣV) 空間;當價值總和遠小於 W 時勝出。
- 位元平行版:Python 大整數的左移與 OR 是以「字組(word)」為單位的 C 層運算,實際速度約比純迴圈快數十倍,複雜度 O(n·t/wordsize)。
- 理論意義:背包存在 O(nW) 演算法 ⟹ 背包是「最弱的 NP-hard 問題」之一;事實上已證明除非 ETH 失效,不存在 O(W^(1-ε)·poly(n)) 的演算法(強指數時間下界),說明偽多項式已近乎最優。
練習#
- 實作「以價值為狀態」版本的解還原:輸出達到最大價值的物品清單。
- 用
timeit實測純迴圈子集和 vs. 位元平行版在 t = 10⁶、n = 100 的速度比,並解釋為何位元版快。 - 設計一組「最壞情況」資料,使容量版 DP 的 W 很大但物品數很少,說明什麼樣的實例應改用分支限界而非 DP。