qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

背包表只改了循环方向,为什么一件物品突然能拿两次?

容量4、重量2、价值3的小例子:从大到小读取上一轮,从小到大可能读到这一轮刚写出的结果。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 每一格表示不超过这个容量时能拿到的最大价值
  4. 先保留两行,就能看清更新应该读谁
  5. 从大到小更新,较小容量还保留上一轮
  6. 从小到大更新,较小容量已经可能包含当前物品
  7. 省掉一行,不代表任何演示程序都只占一行内存
  8. 完整程序:手算之外,再让独立方法核对
  9. 把每次读取标成旧行还是新行
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
0/1背包
每件列出的物品只能不拿或拿一次,在容量限制下挑最大总价值。
动态规划空间压缩(滚动数组)
把原本按物品分层的多行结果复用到较少的存储里。
打开完整术语小抄 ↗

桌上只有一件物品,重量2,价值3,背包容量4。最多能拿到的价值显然是3。可是同一个更新公式,仅把容量循环从大到小改为从小到大,最后竟然写出了6。

这并非加法出了错,而是数组开始重复使用当前这件物品。把动态规划压缩成一行以后,读取哪个时间点的值,就成了循环方向必须负责的事情。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。容量4、重量2、价值3的小例子:从大到小读取上一轮,从小到大可能读到这一轮刚写出的结果。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

同一行背包表,向左更新还是向右更新?

正在准备实验控件…

已执行更新
0 / 3
当前最大容量格
0
每件最多一次的最终值
结束后对照

容量下标:0 | 1 | 2 | 3 | 4

当前 dp:0 | 0 | 0 | 0 | 0

初始各格为 0。点击“更新下一格”,跟踪读到了旧值还是本轮刚写入的值。

目标是容量不超过上限、每件物品最多取一次。正向更新演示的是允许每类物品重复取用的另一种库存规则;所有重量为正整数,价值为非负整数。

每一格表示不超过这个容量时能拿到的最大价值

dp[c]表示总重量不超过c的最大价值,所以空背包允许出现,初始所有格都是0。这与“必须恰好装满c”不同;后者通常要把不可达状态单独标出来,不能直接沿用全零初值。

先看只有一件物品的情况:重量2、价值3、容量4。容量0、1放不下它;容量2、3、4都只能放这件一次。因此合理的整行是0、0、3、3、3。容量更大不等于物品会自动复制。

先保留两行,就能看清更新应该读谁

定义F(i,c)为只使用前i件物品、容量不超过c的最好价值。处理第i件、重量w、价值v时,不拿它得到F(i−1,c);拿它则得到F(i−1,c−w)+v。只有c≥w时第二项才存在,最终在两项之间取较大值。

这两个分支都读取“前i−1件”的结果。特别是拿当前物品之后,剩余容量也只能从更早的物品里挑,否则就会把第i件再次拿进来。MIT讲义的二维递推表达的正是这种跨物品层的依赖;下面的一行方向解释是对它的具体展开。

从大到小更新,较小容量还保留上一轮

压缩为一行后,公式写成dp[c]=max(dp[c],dp[c−w]+v)。如果c从4往2走,更新dp[4]时,dp[2]还没有处理当前物品,所以读到0,写成3。之后dp[3]也写成3,最后dp[2]写成3。

由于w>0,c−w总比c小。下降遍历确保读取的较小下标还没被当前这一轮覆盖,于是它仍对应上一件物品处理完后的那一行。把行号省掉了,但依赖的时间顺序没有省掉。

从小到大更新,较小容量已经可能包含当前物品

若从2往4走,先写dp[2]=3。等走到dp[4],再读dp[2]时它已变成3,于是写出3+3=6。同一件物品的价值沿较小容量格被再次带入,库存规则已经改变。

在本例的正重量、非负价值、允许空选条件下,正向更新正好对应每类物品可无限次使用的背包。它不是在所有问题里都错误,而是解了另一种数量约束。某些输入两种规则得到相同数值,也不能据此认定方向无关紧要。

两件重量相同的真实物品仍可以是两件不同物品:0/1限制针对每个输入位置最多一次,不能先按重量去重。本文不处理零重量;在无限数量模型里,零重量且正价值会让最大价值没有有限上界。

省掉一行,不代表任何演示程序都只占一行内存

不保存轨迹的标准一行更新需要O(nC)时间、O(C)工作空间,n为件数、C为整数容量。C是数值本身;如果容量用很少的二进制位表示却很大,O(nC)并不是按输入位数计算的普通多项式界。

本文Python为了打印和检查,还记录每次更新,轨迹额外占O(nC)空间。浏览器为了前后翻步保存整行快照,轨迹空间可到O(nC²)。这两者是教学记录开销,不能拿演示实现直接宣称总空间只有O(C)。容量和物品数都做了小范围限制。

完整程序:手算之外,再让独立方法核对

保存下面全部代码为 demo.py,执行 python demo.py。本次在 Windows / CPython 3.14.0 实跑,只使用标准库;同时通过 Python 3.10 语法解析,未在 Python 3.10 解释器中运行。

枚举长度0到3的物品序列,重量取1到3、价值取0到3,再枚举容量0到8,共16965组。下降更新对照全部物品子集;上升更新对照各类物品取用次数的独立枚举。另拒绝8类无效输入。

输出来自本次真实执行。检查使用显式 AssertionError,python -O 不会把检查删掉。小范围穷举支持实现核对,算法的一般理由仍由前面的依赖关系说明。

展开收起代码 · 小实验python · 92 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""One storage row, two loop directions, two different inventory rules."""
from itertools import product


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def validate(items, capacity):
    if type(capacity) is not int or not 0 <= capacity <= 30:
        raise ValueError("integer capacity in 0..30 required")
    if not isinstance(items, (list, tuple)) or len(items) > 12:
        raise ValueError("at most 12 items required")
    for item in items:
        if not isinstance(item, (list, tuple)) or len(item) != 2:
            raise ValueError("each item is (weight, value)")
        weight, value = item
        if type(weight) is not int or not 1 <= weight <= 30:
            raise ValueError("positive integer weight in 1..30 required")
        if type(value) is not int or not 0 <= value <= 100:
            raise ValueError("integer value in 0..100 required")


def knapsack(items, capacity, ascending=False):
    validate(items, capacity)
    if type(ascending) is not bool:
        raise ValueError("ascending must be bool")
    dp = [0] * (capacity + 1)
    trace = []
    for index, (weight, value) in enumerate(items):
        positions = range(weight, capacity + 1) if ascending else range(capacity, weight - 1, -1)
        for c in positions:
            read = dp[c - weight]
            before = dp[c]
            dp[c] = max(before, read + value)
            trace.append((index, c, c - weight, read, before, dp[c]))
    return dp, trace


def subset_reference(items, capacity):
    best = 0
    for mask in range(1 << len(items)):
        chosen = [item for i, item in enumerate(items) if mask & (1 << i)]
        if sum(weight for weight, _ in chosen) <= capacity:
            best = max(best, sum(value for _, value in chosen))
    return best


def unlimited_reference(items, capacity, index=0):
    # Independent enumeration of the number of copies, without a DP array.
    if index == len(items):
        return 0
    weight, value = items[index]
    return max(copies * value + unlimited_reference(items, capacity - copies * weight, index + 1)
               for copies in range(capacity // weight + 1))


def main():
    items, capacity = [(2, 3)], 4
    descending, _ = knapsack(items, capacity)
    ascending, steps = knapsack(items, capacity, True)
    print(f"one_item={items}; capacity={capacity}")
    print(f"descending={descending}; answer={descending[-1]}")
    print(f"ascending={ascending}; answer={ascending[-1]}")
    print(f"ascending_last_update: capacity={steps[-1][1]}; reads_dp[{steps[-1][2]}]={steps[-1][3]}")
    require((descending[-1], ascending[-1]) == (3, 6), "reuse witness")
    count = 0
    alphabet = tuple(product(range(1, 4), range(4)))
    for size in range(4):
        for items in product(alphabet, repeat=size):
            for capacity in range(9):
                once, _ = knapsack(items, capacity)
                reusable, _ = knapsack(items, capacity, True)
                require(once[-1] == subset_reference(items, capacity), "0/1 subset oracle")
                require(reusable[-1] == unlimited_reference(items, capacity), "unlimited copy oracle")
                count += 1
    invalid = [([(0, 3)], 4), ([(2, -1)], 4), ([(True, 3)], 4), ([(2, 3)], True),
               ([(2, 3)], -1), ([(2, 3)], 31), ([(2, 3.5)], 4), ([(2,)], 4)]
    for items, capacity in invalid:
        try:
            knapsack(items, capacity)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"cases={count}; independent_oracles=subsets+copy_counts; invalid_inputs={len(invalid)}")
    print("PASS: descending uses each item once; ascending allows repeated copies")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。容量4、重量2、价值3的小例子:从大到小读取上一轮,从小到大可能读到这一轮刚写出的结果。

运行后对照结果
one_item=[(2, 3)]; capacity=4
descending=[0, 0, 3, 3, 3]; answer=3
ascending=[0, 0, 3, 3, 6]; answer=6
ascending_last_update: capacity=4; reads_dp[2]=3
cases=16965; independent_oracles=subsets+copy_counts; invalid_inputs=8
PASS: descending uses each item once; ascending allows repeated copies

把每次读取标成旧行还是新行

练习不预填结果:使用两件物品(重量3,价值4)、(重量4,价值6),容量8。各画一张下降与上升更新表,在每次读取旁标出“上一轮的值”还是“本轮已经改过的值”。

然后用所有子集核对每件最多一次的答案。再改为允许重复取用,列出每类物品取几件。最后尝试一个两种规则碰巧相同的容量,说明为什么相同数值不能替代依赖分析。

记住这些要点

  • 0/1背包的拿取分支应读取上一件物品处理完后的状态。
  • 一行数组下降更新保留旧依赖,上升更新允许复用当前物品。
  • 同一个公式可以因读取顺序不同而对应不同库存规则。
  • 不超过容量和恰好装满容量,初始状态不能混用。

别把例子用过头

  • 只背“背包要倒着写”,却不检查所读取状态属于哪一轮。
  • 把两个方向偶尔相同的输出当作算法等价。
  • 把轨迹演示的总空间说成标准核心更新的O(C)。

自己试一小步

容量8、物品(3,4)和(4,6)时,如何从读取顺序判断当前物品是否被重复使用?

  1. 分别手写两个方向的容量更新。
  2. 给每次读取标记其所属物品轮次。
  3. 用子集与取用次数两种独立方法核对。

做到这些,就算完成

  • 先声明每件一次还是每类可重复。
  • 容量含义是不超过上限。
  • 练习不提前给出最终价值。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • MIT 6.006 Lecture 20:Knapsack 与伪多项式复杂度 (新标签页)↗MIT CSAIL / 6.006 Fall 2009 · 英文

    Knapsack二维物品层递推、整数容量下O(nS)时间,以及容量数值和输入位数的区别;一行循环方向由本文依照递推展开。

    已阅读全文提取并查看PDF第5页图像,重点为第5页背包定义与递推、第6页pseudopolynomial说明;未声称观看课程视频。