0-1 背包的 PTAS 算法

01 背包是一个很朴素的问题:给一批物品,每个物品有重量和价值,背包容量有限,每个物品只能选或不选,问怎么让总价值最大。设第 \(i\) 个物品的重量为 \(w_i\),价值为 \(v_i\),容量为 \(W\)。目标是在 \(\sum w_i x_i \le W\)\(x_i \in {0,1}\) 的约束下最大化 \(\sum v_i x_i\)。这就是 0/1 的含义:每个物品不是拿一部分,而是完整拿或完全不拿。

精确 DP 的伪多项式问题

最常见的动态规划按容量做状态:\(\text{dp}[c]\) 表示容量不超过 \(c\) 时能获得的最大价值。转移是 \(\text{dp}[c]=\max(\text{dp}[c], \text{dp}[c-w_i]+v_i)\),为了保证每个物品最多用一次,容量要从大到小遍历。

def knapsack_exact_by_weight(items, W):
    dp = [0] * (W + 1)

    for w, v in items:
        for c in range(W, w - 1, -1):
            dp[c] = max(dp[c], dp[c - w] + v)

    return dp[W]

这个算法的时间复杂度是 \(O(nW)\)。如果 \(W\) 只有几万,它非常好用;如果 \(W\)\(10^{12}\),哪怕物品只有几十个,也基本不可用。

因为,\(O(nW)\) 不是通常意义下关于输入规模的多项式时间。因为输入里写下 \(W\) 只需要 \(O(\log W)\) 位,而算法时间却和 \(W\) 本身成正比。这类算法叫伪多项式算法。它不是坏算法,只是它依赖数值大小,而不只是依赖输入长度。

同样也可以按价值做 DP:令 \(\text{dp}[p]\)表示恰好达到价值 \(p\) 所需的最小重量。最后找最大的 \(p\),使得 \(\text{dp}[p]\le W\)。复杂度是 \(O(n\sum v_i)\)。但如果价值数字很大,它会遇到同样的问题。

于是近似算法的入口出现了:如果价值刻度太细,我们能不能把刻度变粗一点?

把价值刻度磨钝一点

如果价值的数值太大,或者精度太高。常见做法是缩放价值。设最大单件价值为 \(V_{\max}\),给定误差参数 \(\epsilon\),取 \(K=\epsilon V_{\max}/n\),把每个价值 \(v_i\) 替换成缩放后的整数 \(a_i=\lfloor v_i/K \rfloor\)。然后在缩放价值上跑“按价值的 DP”。

这一步的直觉很简单:原来的价值可能是 982341、127381、431902 这种大数;缩放以后,变成了 982000、127000、431000。它们被投影到更粗的网格上,数组就可以小得多。我们不再区分特别细的价值差异,只保留足够影响近似质量的部分。

def knapsack_fptas(items, W, eps):
    # items: list[(weight, value)], values are assumed non-negative integers
    # return approximate best value, not the selected item set

    if not items:
        return 0
    if eps <= 0:
        raise ValueError("eps must be positive")

    vmax = max(v for _, v in items)
    if vmax == 0:
        return 0

    n = len(items)
    K = eps * vmax / n

    scaled = []
    for w, v in items:
        scaled.append((w, int(v // K), v))

    total_scaled_value = sum(a for _, a, _ in scaled)
    INF = W + 1
    dp = [INF] * (total_scaled_value + 1)
    dp[0] = 0

    for w, a, _ in scaled:
        for p in range(total_scaled_value - a, -1, -1):
            if dp[p] + w < dp[p + a]:
                dp[p + a] = dp[p] + w

    best_scaled = 0
    for p, min_weight in enumerate(dp):
        if min_weight <= W:
            best_scaled = p

    # If only the approximate value is needed, one should reconstruct the
    # selected set. Here we keep the function minimal and return the scaled score.
    return best_scaled * K

这段代码只返回近似价值,不返回具体选了哪些物品。要额外记录路径:可以使用二维 choice[i][p],也可以在一维 DP 上记录前驱。

为什么这里缩放价值而不是缩放重量?因为重量约束是硬约束。缩放重量如果处理不好,很容易得到一个在缩放世界里可行、在真实世界里超出一点点重量的方案。缩放价值则安全得多:DP 过程中一直使用真实重量,最后得到的解一定不会超过容量,损失只发生在目标函数上。每个物品的价值被减小了一点点。

误差与精度

缩放以后,每个物品的价值都会向下取整。对第 \(i\) 个物品,有 \(K a_i \le v_i < K(a_i+1)\)。也就是说,每个物品最多损失不到 \(K\) 的价值刻度。一个解最多包含 \(n\) 个物品,所以任意一个解的总价值损失不到 \(nK\)

\(O\) 是最优解,\(A\) 是缩放价值下 DP 找到的解。因为 \(A\) 在缩放价值上不比 \(O\) 差,所以有 \(a(A)\ge a(O)\)。从原始价值看,\(v(A)\ge K a(A)\ge K a(O)>v(O)-nK\)。而 \(nK=\epsilon V_{\max}\),又因为最优解至少可以选择那个最大价值物品,所以 \(V_{\max}\le \text{OPT}\)。于是得到 \(v(A)>(1-\epsilon)\text{OPT}\)

这个推导里没有使用什么神秘技巧,核心只是控制向下取整造成的累计误差。每个物品损失一点,最多损失 \(n\) 次;再把单次损失设成 \(\epsilon V_{\max}/n\),总损失就被压到 \(\epsilon V_{\max}\) 以内。

复杂度也随之变小。缩放后每个 \(a_i\) 大约不超过 \(n/\epsilon\),所以缩放后的总价值是 \(O(n^2/\epsilon)\)。按价值 DP 的时间复杂度变成 \(O(n^3/\epsilon)\),空间复杂度是 \(O(n^2/\epsilon)\)

PTAS vs FPTAS

PTAS 是 polynomial-time approximation scheme。对最大化问题来说,它接受一个误差参数 \(\epsilon\),在多项式时间内给出至少 \((1-\epsilon)\text{OPT}\) 的解。相比那些只有x - 近似方法的 NP 问题,有 PTAS 方案的问题至少可以逼近最优解,只是想要越接近,要花的时间就越多。这里的“多项式时间”有一个隐含条件:当 \(\epsilon\) 固定时,算法关于输入规模是多项式的。

这个定义允许一些看起来不太友好的复杂度,例如 \(O(n^{1/\epsilon})\)。当 \(\epsilon=0.1\) 时,这可能还是一个天文数字;但从定义上看,只要把 \(\epsilon\) 当常数,它仍然是多项式。

而 FPTAS 更强。它要求时间复杂度不仅关于 \(n\) 是多项式,关于 \(1/\epsilon\) 也必须是多项式。比如 \(O(n^3/\epsilon)\) 可以接受,\(O(n^{1/\epsilon})\) 就不行。

我们发现,上面的价值缩放算法不仅是 PTAS,而且是 FPTAS。这个差别不只是名字更漂亮。它意味着当你想把误差迅速降低时,运行时间也仍会按某个可控的多项式增长,因为误差自己也是多项式复杂度的一部分,而不是指数式爆炸。

posted @ 2026-06-27 20:58  Ofnoname  阅读(4)  评论(0)    收藏  举报