ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

完全背包问题怎么判断?三个指标教你快速识别题型

完全背包问题怎么判断?三个指标教你快速识别题型

看到这个题目我第一反应是点头,因为“判断某题是否该用完全背包”这件事,很多人在初学背包问题的时候,其实不是不会写模板,而是根本认不出“这道题原来是个背包”。我自己带过不少同学,最常听到的一句话就是:“我知道完全背包怎么写,但拿到题就是想不到用它。”这太真实了。你仔细想想,01背包和完全背包,代码就差一个遍历方向,但它们的适用场景和判断逻辑,差别其实非常大。如果不把判断方法讲清楚,刷一百道题也还是只能套模板,换个皮就认不出来了。

这篇文章不打算重复那些到处都能搜到的算法定义,而是想站在“拿到一道题,怎么在五分钟内决定它到底是不是完全背包”这个角度,把判断指标、排除项、模板改造和真实场景串起来讲一遍,适合正在刷动态规划题库、准备机试或面试、以及一切被背包问题绕晕的同学参考。

1. 判断之前,先搞清楚完全背包到底在做什么

1.1 为什么“选不选”和“选几次”是两个完全不同的世界

先把最底层的逻辑说透。动态规划题里所有的背包问题,本质上都是同一种决策模型:给定一个容量,一堆有重量有代价的物品,让你从这堆物品里做出选择,让某种目标最优。01背包和完全背包的区别,表面上看只是“每件物品能不能重复选”,但这个差别带来的建模难度是质的。

01背包的典型状态转移是:

f[j] = max(f[j], f[j - w[i]] + v[i])

注意,这里在计算第 i 件物品时,必须倒序遍历容量 j。因为每件物品只能用一次,f[j - w[i]] 必须保证它还是在没有使用第 i 件物品时的状态,如果正序遍历,小的容量 j - w[i] 已经被第 i 件物品更新过了,就会出现“一件物品被用了多次”的效果,这恰好就是完全背包想要的。

所以很多教程会说,“01背包倒序,完全背包正序”,这没错,但如果你只记住了遍历方向,是没法判断一道题归类的。你得从题意本身出发,先问自己一个问题——这道题我拿到的每件物品,允许我重复使用吗?

举个例子。题目说“从一个物品列表中挑出若干件,使总重量恰好等于背包容量,求最大价值”,这种表述,每件物品前面有个“限购一件”的含义,这是01背包。但如果题目变成了“你有若干种面额的硬币,每种面额无限供应,问凑出某个金额最少需要几枚硬币”,那“无限供应”四个字直接指向完全背包。别小看这个区分,很多混淆都从这里开始。

1.2 完全背包的状态定义与递推公式,值得背进肌肉记忆

判断一道题是不是完全背包,光会背公式不够,你得理解公式为什么长那样。完全背包的状态定义通常写作:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

数组的含义是:容量不超过 j(或恰好为 j,视初始化而定)时能获得的最大价值。

它和01背包的递推公式一模一样,只是在代码实现时,容量维度用正序遍历。正序的意义在于,当我们在算容量 j 时,容量更小的 dp[j - weight[i]] 已经在当前这一轮被更新过了,它代表着“已经使用过这件物品若干次之后”的最优状态,所以还能再加一次当前物品。这个概念如果你没想明白,一会儿刷题就会搞混,而判断题型时恰恰用得上。

然后,不同约束下的完全背包会演化出至少三种状态转移:最大值、最小值、方案数。这三个才是判断时容易出错的重灾区。最大值直接套 max;最小值初始化成无穷大,套 min;方案数则不用比较大小,直接把方法数累加。这就带来一个很好的判断思路——先看题干求的是什么,再决定套哪一类模板。

到这里为止,核心定义已经清楚了。下面重点展开判断指标。

2. 从题干里找“无限取用”的信号:三个判断指标

2.1 指标一:物品数量和每件物品的可用次数

这是最高优先级的指标。读题的时候,圈出所有描述物品供应的句子,重点出现“无限件”“不限量”“每种可重复选择”“每张/每种无限供应”这类字眼,基本可以直接判定完全背包。

我自己习惯的做法是做一个快速标记:把题干里所有关于物品供应的条件列出来。如果题目明确说“每件物品最多用一次”,是01背的范畴;如果说“第 i 种物品最多使用 Ci 次”,那是多重背包;只有当语言环境包含“无限”“任意多次”“数量不限”时,才进入完全背包候选区。

有一种隐蔽情形得多说一句:有些题没有直接在句子里写“无限”,而是通过场景暗示。比如“给定一个正整数数组 nums 和一个目标数 target,求组合成 target 的方案数”,题目没说每个数只能用一次,只是因为组合数学里通常默认可取任意多次。碰到这种只给了“正整数”“组合”“凑成目标”而没有明确次数限制的,要考虑完全背包这一分支。

2.2 指标二:容量为什么会“涨”或者“回退”

这是个很多人没注意但非常实用的判断角度。仔细想想,01背包的容量遍历是从大到小,因为每件物品只允许拿一次,当前容量不能跨过“已拿过当前物品”的状态去叠加。而完全背包的容量遍历从小到大,允许容量递增的过程中反复使用同一件物品。

所以看一道题的容量维变化,也可以反向推出模型。如果状态转移里你需要“多次重复叠加同一物品带来的增量”,比如“每次可以走 1 步或 2 步或 3 步,问走到第 n 级台阶有多少种走法”,这本质上是一个“可重复使用物品”的场景——每次走法可以在不同轮次反复出现,这已经是完全背包风格了。

再比如经典的“零钱兑换”问题:你要凑 amount,硬币面值数组 coin,每次都从同一组面值里面取,可以取任意多次。这里的“容量” amount 会随着一次次取硬币而“回退”(当前总额减去面值),而这个回退后的状态自身已经包含了当前硬币的多次使用信息。这种自我引用的特征,其实就是完全背包容量正序更新的数学镜像。

2.3 指标三:是从上到下还是从下到上的选择方向

这个判断指标可能听起来抽象,但我把它总结为一种思维习惯。你拿到一道完全背包题时,可以想象构造一个物品序列:每一步选择都是在“同一堆物品”里做选取,选取后进入一个新的子问题;而这个子问题面对的物品集合和原来完全一样。这就是“从下到上”的思维——我在处理大容量时,小容量已经用同一套物品尝试过,我的子问题结构是自相似的。

而01背包的经典问题是“物品序列是有序的,i 状态不能再回头使用之前的物品”,哪怕状态转移还是一条公式,它的计算顺序却只能从大到小,因为每一步是在消减物品集合,不是消减可用次数。

我遇到复杂场景时,会自己做一个“代入实验”:假设我现在容量已经用了 k 次同一件物品,下一步还允不允许再用一件?如果题意允许,那么它就是完全背包。如果不允许,那就是01或多重。这个实验能滤掉绝大多数模棱两可的题目。

3. 容易和完全背包混淆的题型,以及各自的判断依据

3.1 01背包与完全背包的边界场景

最典型的边界就是:题目不说“只能用一次”,但其实隐含只能用一次。比如“从 n 件物品中选若干个,每个物品只有一个”,这种一般出现在物理物品上,如装箱问题、资源分配问题。题干里可能写的是“每个物品最多拿一个”,而不是“每个物品只能拿一次”,这时你没注意“个”和“次”的区别,就会误判成完全背包。

还有一种情况是“只能用一次但没说”,比如“数组中的每个数字最多使用一次”,这种限定词必须圈出来,它把无限取用直接否决了。我的判断顺序永远都是:先找次数限制词,再找目标量的方向(最大/最小/方案数),最后再看容量维度的更新方式,三步走完,基本不踩坑。

3.2 顺序敏感的排列问题:爬楼梯类为什么要单独处理

爬楼梯、数字组合的排列数问题,其实是一个“伪完全背包”。表面上看,你有若干种走法,可以无限重复,很像完全背包;但它对顺序极其敏感。走 1 步再走 2 步,和走 2 步再走 1 步,在爬楼梯问题里是两种方案。如果按常规完全背包的组合数写法,只会有一种方案被计入,少了另一条路径。

判断方法很简单:题目问“有多少种排列方式”而不是“有多少种组合方式”。排列问题里,两个步骤的先后顺序不同算不同方案,这时的循环方式通常要外层遍历容量,内层遍历物品,也就是容量在外面、物品种类在里面,这样每个位置都能用所有物品去填,天然处理了顺序问题。而纯组合完全背包,必须外层物品、内层容量,避免排列重复。

所以严格来说,爬楼梯一类依然用的是完全背包式的无限取用模型,但排序方式变了。它不是判断错,而是实现细节上容易写错,后面实战部分会再展开。

3.3 组合数类:为什么必须外层物品内层容量

如果一道题满足无限取用、要求方案数但不关心顺序,那么它就是最标准的一类完全背包方案数问题。典型的例子是“给定若干种面额硬币,不限数量,问有多少种组合方式凑成目标金额”。这里的组合数不区分顺序,1+2 和 2+1 被视作同一种。

实现上有一个铁律:外层先遍历物品,内层再遍历容量,且容量正序。这样每个物品只在它自己的轮次里去更新所有容量,后面换下一个物品的时候,前面物品的影响已经被固定,不会重复计算不同顺序。你要是把循环次序写反了,统计算法数时会把排列和组合混在你的结果里。

这一点也是判断题型的得力工具——题目一旦说了“铁无序不区分顺序”,那么完全背包组合数模板直接下车;如果语气里暗示“先后算不同,步骤顺序有意义”,就得切换成排列写法。平时用这个问题去检验题目,比咬文嚼字要有效得多。

4. 判断完毕之后:完整解题流程与模板改造

4.1 一维数组的完全背包模板,以及正序遍历的理解

一旦你判断出是完全背包,接下来就是把题目翻译成一段靠谱的代码。我写一个最常见的一维完全背包模板(求最大值):

# dp[j]:容量为 j 时能装下的最大价值 dp = [0] * (capacity + 1) for i in range(len(weights)): for j in range(weights[i], capacity + 1): dp[j] = max(dp[j], dp[j - weights[i]] + values[i]) return dp[capacity]

正序遍历就是为了让当前物品可以被重复使用。稍微注意一点:内层循环从 weights[i] 开始,不是从 0 开始,因为比物品重量小的容量根本塞不进这个物品,省去无意义更新。这句代码看着简单,但很多初学者容易把起点写成 0,后面容量循环倒是无所谓,就是慢。

求最小值时,把 dp 初始化成一个比所有可能结果都大的数,例如 float("inf"),dp[0] 保持 0。这样转移时 min 才有得比较,否则全被 0 干扰,整个数组直接废了。求方案数时,dp[0] 初始为 1,表示凑成 0 只有一种方案(什么都不选),其余为 0,更新用加法。

对应到完全背包,这是“组合数版本”的模板:

dp = [0] * (target + 1) dp[0] = 1 for coin in coins: for j in range(coin, target + 1): dp[j] += dp[j - coin] return dp[target]

这个代码有一点很容易让人疑惑:内层正序遍历不会导致重复吗?实际上不是重复,是“复用”,这正是完全背包所需的。组合数场景下允许同一面额多次出现,正序遍历能保证这一点。

4.2 场景A:恰好装满求最值时的边界初始化

判断一个题是不是“恰好装满”,就看它的答案状态是否有“恰好”和“不超过”的区别。如果题目说“凑齐目标金额”“铺满背包容量”“刚好达到某个数值”,那 dp[0] 必须设为 0,其他 dp[j] 初始为负无穷(最大值问题)或正无穷(最小值问题)。原因很简单:只有容量 0 是天然“恰好装满”且价值为 0 的合法起点,其他容量本身不可达,必须先标记为无效状态,等后续转移去填充。

如果你不区分“恰好”和“不超过”,直接用 dp[j] = 0 初始化,最后算最大价值时会发现所有容量都像是“可以拼出来”一样,把本不能到达的容量也当成了答案来源,经典翻车。

举个例子。零钱兑换问题,求最少硬币数凑成 amount,dp 数组初始化成 amount+1(一个比最优解更大的占位值),dp[0]=0,最后如果 dp[amount] 仍然是 amount+1,说明凑不出来,返回 -1。这个边界判断,核心一句话:初始化表达的是“从无到有”的有效起点,不能图省事全设 0。

4.3 场景B:容量很大的时候怎么压缩、剪枝、优化

完全背包的二维状态里,容量维度如果特别大(比如 10^9),直接开一维数组都放不下。这时有两个常见出路:第一,如果物品种类少但容量巨大,考虑贪心预处理,比如“尽量多用性价比高的物品”,但要注意贪心只有特定条件下成立,并非通用。第二,如果物品重量范围小,可以用“取模优化”或“单调队列”做多重背包式优化,但完全背包天然不需要二进制拆分这种手段,却仍然可以用一个基础优化——把物品排序并去掉那些“又被别人完全碾压”的差物品。

所谓“碾压”是指:两件物品 a 和 b,如果 a 的重量大于等于 b 的重量,但 a 的价值还小于等于 b 的价值,那 a 就是一个无论如何都不占优的选项,可以删掉。这种去重能显著压缩物品种类,尤其是随机生成数据的题目,效果立竿见影。

另外一个绝大多数刷题人都容易忽视的点是:完全背包最内层循环不一定需要跑到 capacity,你可以按物品重量分组。比如在计数问题里,如果硬币面值之间存在公因数,可以先除以最大公约数,把容量和面值都缩小,大幅减少数组长度。这个思路在笔试里特别实用,能救几个超时的题。

5. 实战对照:几道经典题型像“病例分析”一样拆给你看

5.1 病例一:硬币找零类

题干通常长这样:“有各种面额的硬币,每种数量无限,问凑成目标金额所需的最少硬币数。”看到“无限”两个字,标准完全背包判断成功。目标量是最小值,所以模板走 min 路线。

实际操作的时候,我会把这个题拆成三个字:面(面额)、值(金额)、度(硬币数量)。dp[j] 表示金额 j 的最优硬币数,转移就是 dp[j] = min(dp[j], dp[j - coin] + 1)。因为每选择一枚硬币,相当于使用一次物品,价值是 1。这里硬币是“物品”,但价值不是硬币的金额,而是“枚数”,这也是判断时的一个坑——很多初学者会误把硬币面额当作价值,那就全乱套了。

5.2 病例二:无限供应的组合计数

“从给定数组中选出若干数字(可重复),使它们的和等于 target,求组合方案数。”这种题如果不区分顺序,就是纯完全背包的组合数版本。进入代码前建议先给“数字排序”这一步加上,因为物品正序遍历时,如果数组无序,不会出错,但你要是心血来潮加了个剪枝或者提前返回,排序能让你少很多边界思考。

实现时外层遍历物品,内层正序遍历容量。有一个极其常见的错误:把循环顺序写成外层容量、内层物品,跑出来结果变成了排列数。笔试里看不到具体样例时,这个错误很小,藏在结果差异里,非常容易忽略。我的经验是,如果提交后发现组合数比答案大好几倍,先检查循环顺序,多半就是这里出了问题。

5.3 病例三:带顺序的完全背包

带顺序的典型场景就是爬楼梯变体:“每次可以爬 1、2 或 3 个台阶,问爬到 n 级台阶有多少种爬法。”数字不一定连续,这就是一个顺序敏感的完全背包排列问题。

这题的循环方式恰好与组合数相反:外层循环容量(台阶数),内层循环物品种类(步长)。因为每到一个容量位置,它前面所有可能的最后一步都来自同一个物品种类池,并且顺序不同被天然区分。这个细节对初学者的冲击最大,因为如果不理解“完全背包不关心顺序”,你会一直疑惑为什么同一个背包问题,两段代码长得截然不同。

我把这三个病例放在一起对比,你要记住的其实是:完全背包只是“无限取用”这个大框架,在这个框架下,最大值、最小值、计数、组合、排列各是一套不同的实现细节。判断题型只是第一步,紧跟着要判断的还有目标量的属性和顺序敏感性,这样才真正做到精确解题。

6. 我在实操中判断错的那些题,最后都错在哪

最后说几个我自己甚至带过的同学反复踩的坑,都是从实战中总结出来的,比任何理论都值得留意。

第一个坑是“误把多重背包当完全背包”。有些题文中没有明确写限量,但本质上每件物品有固定数量上限,比如“每种零件最多库存 5 个”,这时候完全背包纯属送死。我自己现在读完题第一件事不是想状态转移,而是先数一数题干里出现多少次“每”字修饰次数。凡是有次数上限的,一律先去多重背包部分做判断。

第二个坑是过度依赖“正整数”三个字。不少题只给正整数数组,很多教程也默认正整数就能用完全背包,但实际上有些题暗含“每个元素只能使用一次”的约束,比如“从数组中选若干元素,每个只能用一次,求是否能凑成目标值”。你如果只看到正整数、目标和、凑成,就开开心心写了完全背包,结果一堆重复使用导致的错误答案。我自己的铁律是:题目没说“可重复”,那就先按只能用一次来做假设,再看有没有反证。

第三个坑是求方案数时初始化全设 0。dp[0] 的 1 是全部方案数的种子,漏掉这个 1,整棵计数树直接没有根。如果你发现答案少得离谱,或者输出全是 0,十有八九是 dp[0] 初始值错了。别笑,这个真的特别常见,尤其是从求最大值模板临时改成求方案数时,很容易忘了把 dp[0] 从 0 改成 1。

第四个坑是“完全背包不排序也能做”的错误认知。在一些组合计数题中,给硬币排序不是必需的,但如果你后续做了剪枝或提前终止,为了保证正确性,还是建议先排序。我做这类题已经形成肌肉记忆:拿起硬币数组,先 sort 一遍,排序不会改变答案,但会降低你思维负担。

还有一个思考习惯想分享:当你判断“这题是不是完全背包”犹豫不决时,试着把题目里的状态定义写成一句话,写在草稿纸上。比如“我可以无限次从 A 类物品里选取,去填满容量 B,求……”如果这句话读起来通顺自然,那完全背包基本没错。如果句子读起来不像“无限次”,反而像“每个东西碰一次”,赶紧掉头。这个方法听上去笨,但比一眼看穿所有套路更适合真实考试环境,因为考试写题的时候,你的大脑需要的是一个快速、无歧义、可复用的决策模型。

完全背包的判断,说白了就是抓住“无限重复使用”这个核心标签,然后再通过目标方向和顺序敏感度去选模板。做题多了你会发现,真正的难点从来不是把模板写出来,而是拿到新题能准确判断它属于哪个模板家族。希望大家从这篇分享开始,多做一些“题目分类训练”:别一上来就敲代码,先花三十秒判断这是哪一类背包、要不要处理顺序、边界怎么初始化。练上二三十道以后,你会在拿到题的一瞬间就有直觉,那才是真学会。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进