跳轉到

6.2 從子集合加總到分割與背包#

概念說明#

分割問題(PARTITION):給定一組正整數,問能否分成兩部分使兩部分的總和相等。這是 Karp 清單中最簡單敘述的 NP-complete 問題,也是化約鏈上的重要中繼站。它與 SUBSET-SUM 的關係一眼可見:令總和為 σ,則「可分割」⟺「存在子集和為 σ/2」。因此 SUBSET-SUM 可在多項式時間內化約到 PARTITION——構造出「等價於求 σ' / 2」的實例即可。

具體化約:給 SUBSET-SUM 實例 (S, t),令 σ = sum(S)。構造 S' = S ∪ {σ - 2t}(若 σ - 2t ≤ 0 則先放大所有數字)。此時 S' 總和為 2σ - 2t... 為保持簡潔,常用技巧是先將所有數字乘上 2 再加入一個大數平衡;標準做法是加入數字 σ - 2t 並調整:S' 的總和為 2(σ - t),選中子集總和必為 σ - t,加上 σ - 2t 是否被選,恰可推出原問題的子集和是否為 t。化約的細節正是本章要動手驗證的重點。

PARTITION 到背包:分割問題是背包的特例——令每個物品重量等於價值 wᵢ = vᵢ、容量 W = σ/2,則「能否恰好裝滿」⟺「能否分割」。這是一個一行的化約,也再次說明:特例往往比一般問題「更有名」。

這條化約鏈 3-SAT → SUBSET-SUM → PARTITION → 背包 展示了 NP-complete 問題之間「等價但形式各異」的網絡結構:同一個計算難題,可以穿上數字、物品、集合等不同外衣。

演算法步驟#

SUBSET-SUM → PARTITION 的化約:

  1. 輸入 SUBSET-SUM 實例 (S, t),σ = sum(S)。
  2. 若 t > σ 或 t < 0,直接回報 NO(特例處理)。
  3. 將每個元素放大:S₁ = {2s : s ∈ S},t₁ = 2t。
  4. 加入新元素 c = 2(σ - t):S' = S₁ ∪ {c},總和為 2σ + 2(σ - t) ... 採用標準構造使總和為偶數且一半對應 t₁。
  5. 對 S' 跑分割判定(DP:子集和能否達到總和之半)。
  6. 若分割可行且 c 不在「對應子集」中,還原出原問題的解。

PARTITION → 背包的化約:

  1. 輸入數字集合 A,σ = sum(A)。
  2. 建立背包實例:n 個物品,wᵢ = vᵢ = aᵢ,容量 W = σ/2,價值門檻 V = σ/2。
  3. 跑背包判定:最大價值 ≥ V ⟺ 能恰好裝滿 ⟺ 可分割。

Python 程式碼範例#

def can_partition(A):
    """分割判定:DP,O(n * sum/2)"""
    total = sum(A)
    if total % 2:
        return False
    half = total // 2
    dp = [False] * (half + 1)
    dp[0] = True
    for a in A:
        for j in range(half, a - 1, -1):
            dp[j] = dp[j] or dp[j - a]
    return dp[half]

def subset_sum_to_partition(S, t):
    """SUBSET-SUM -> PARTITION 化約"""
    sigma = sum(S)
    if not (0 <= t <= sigma):
        return False
    doubled = [2 * s for s in S] + [2 * (sigma - 2 * t) + sigma]
    # 構造總和為偶數的實例;以小實例列舉驗證等價性
    return can_partition([2 * s for s in S] +
                         [2 * sigma - 4 * t, sigma])

def partition_to_knapsack(A):
    """PARTITION -> 背包判定化約"""
    sigma = sum(A)
    if sigma % 2:
        return False
    W = V = sigma // 2
    dp = [0] * (W + 1)
    for a in A:
        for j in range(W, a - 1, -1):
            dp[j] = max(dp[j], dp[j - a] + a)
    return dp[W] >= V

# 等價性驗證:小實例暴力比對
from itertools import combinations
def subset_sum_brute(S, t):
    return any(sum(c) == t for r in range(len(S) + 1)
               for c in combinations(S, r))

S, t = [3, 9, 14, 20], 23
print(subset_sum_brute(S, t), can_partition(
    [2 * s for s in S] + [2 * sum(S) - 4 * t, sum(S)]))
print(partition_to_knapsack([3, 1, 1, 2, 2, 1]))  # True

複雜度分析#

  • 分割判定 DP:O(n·σ) 時間、O(σ) 空間,偽多項式(σ 是數值)。
  • 化約構造本身:僅 O(n) 次算術運算,是「線性時間翻譯」的典範。
  • PARTITION 的特殊性:雖然 NP-complete,但實務上隨機實例幾乎總是可分割(數字夠多時),這提醒我們 NP-complete ≠ 實例都很難。
  • 化約鏈的方向性:每一節箭頭都是多項式時間、保持解對應,這是 NP-hard 傳遞的直接體現。

練習#

  1. 完整實作 SUBSET-SUM → PARTITION 的解還原:給定分割的兩側,輸出原 SUBSET-SUM 的子集。
  2. 證明或反駁:若 PARTITION 有 O(n log n) 演算法,則 SUBSET-SUM 也有。(提示:注意化約產生的數字大小。)
  3. 生成 n = 10、數字範圍 1~10 的隨機分割實例 100 個,統計可分割比例;再改用數字範圍 1~1000,比較並解釋差異。