登录
OAmaster
动态规划

动态规划 · 0/1 背包与完全背包

从 LC 416 出发,看背包问题在「物品能选几次」上的两个分支,以及为什么 1D 压缩要看遍历顺序。

Medium预计阅读 50 分钟更新于 2026-05-19

1. 核心原理

背包 DP 处理的是一类有限资源下的选取问题:给定若干物品(带重量与价值)和一个容量上限 W,问"最大价值是多少 / 能否恰好装满 / 有多少种装法"。物品的"可选次数"决定了具体形态——只能选一次的是 0/1 背包,可以选任意次的是完全背包,限定上限 k[i] 次的是多重背包,按互斥分组挑一的是分组背包。

朴素思路是把所有子集枚举一遍,再筛容量与目标。子集数 2^nn = 20 以上立刻不可行;即便剪枝,"重量 + 价值"两维约束让一般的搜索难以收敛。背包问题的核心 invariant 是最优子结构:考虑第 i 个物品时的最优值,只依赖于"前 i - 1 个物品 + 剩余容量"的最优值——这把指数级搜索压成了多项式级 DP。

状态定义

f[i][w] 为"考虑前 i 个物品、容量上限为 w 时的最优值(最大价值 / 可行性 / 方案数)"。这是背包 DP 最通用的二维定义。维度 i 表示物品下标的"前缀",w 表示当前剩余可装容量。状态空间 O(n × W),每个状态转移 O(1),整体 O(n × W)

转移方程:选或不选

对第 i 个物品 (weight = w_i, value = v_i),有两种决策:

  • 不选f[i][w] = f[i - 1][w],状态直接继承
  • (前提 w ≥ w_i):f[i][w] = f[i - 1][w - w_i] + v_i,从"剩余容量为 w - w_i、未考虑第 i 个物品"的状态扩展

两者取最优(求最大价值时取 max,求方案数时取 +,求可行性时取 or)。这就是 0/1 背包的标准转移。完全背包把第二项的 f[i - 1] 改成 f[i],表示"选了第 i 个之后还能继续选它"。

空间压缩与遍历方向

二维表里 f[i] 只依赖 f[i - 1],所以可以滚动到一维数组 f[w]。压缩后 f[w] = f[w](不选)vs f[w - w_i] + v_i(选)。此时遍历方向决定语义:

  • 0/1 背包w 必须倒序枚举(从 Ww_i)。倒序时,更新 f[w] 用到的 f[w - w_i] 还保留着"上一行"的值,第 i 个物品不会被重复使用。
  • 完全背包w 必须正序枚举(从 w_iW)。正序时,f[w - w_i] 已经被本轮(即考虑过第 i 个物品后)更新过,第 i 个物品可以被反复选入。

这条"倒序 vs 正序"是背包 DP 最容易写错的地方。判断标准只看一句话:物品能选几次。

四类背包一览

类型物品能选几次1D 遍历方向代表题
0/1 背包至多 1 次倒序 枚举 wLC 416 / 474 / 494
完全背包任意次(含 0)正序 枚举 wLC 322 / 518 / 377 / 139 / 279
多重背包至多 k[i]二进制拆分后回 0/1(倒序)见 §2 末尾
分组背包每组至多选 1 件外层枚举组、内层倒序见 §2 末尾

三种问法对应三种 DP 形态

同一份骨架,按"问法"换初值与转移符号即可:

问法dp 类型转移符号初值
"能否凑出"(boolean)dp[w] = dp[w] or dp[w - w_i]ordp[0] = true,其余 false
"最少件数"(min)dp[w] = min(dp[w], dp[w - w_i] + 1)min + 1dp[0] = 0,其余 INF
"方案数"(count)dp[w] = dp[w] + dp[w - w_i]+dp[0] = 1,其余 0

§4 的三道例题就是这三种问法的填空题。


2. 通用模板

下面给三类问法 × 两类背包的最小骨架。状态都用一维数组 f,"问法"决定初值与转移符号,"背包类型"决定 w 的遍历方向。模板里只覆盖 0/1 与完全两种主流形态;多重背包二进制拆分后回到 0/1,分组背包在外层套一个"组"循环。

def knapsack_01(weights, values, W):
    """0/1 背包:求最大价值。每件物品至多选一次"""
    f = [0] * (W + 1)
    for i in range(len(weights)):
        # 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
        for w in range(W, weights[i] - 1, -1):
            f[w] = max(f[w], f[w - weights[i]] + values[i])
    return f[W]


def knapsack_complete(weights, values, W):
    """完全背包:求最大价值。每件物品可选任意次"""
    f = [0] * (W + 1)
    for i in range(len(weights)):
        # 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
        for w in range(weights[i], W + 1):
            f[w] = max(f[w], f[w - weights[i]] + values[i])
    return f[W]
// 0/1 背包:求最大价值
static int knapsack01(int[] weights, int[] values, int W) {
    int[] f = new int[W + 1];
    for (int i = 0; i < weights.length; i++) {
        // 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
        for (int w = W; w >= weights[i]; w--) {
            f[w] = Math.max(f[w], f[w - weights[i]] + values[i]);
        }
    }
    return f[W];
}

// 完全背包:求最大价值
static int knapsackComplete(int[] weights, int[] values, int W) {
    int[] f = new int[W + 1];
    for (int i = 0; i < weights.length; i++) {
        // 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
        for (int w = weights[i]; w <= W; w++) {
            f[w] = Math.max(f[w], f[w - weights[i]] + values[i]);
        }
    }
    return f[W];
}
// 0/1 背包:求最大价值
int knapsack01(const vector<int>& weights, const vector<int>& values, int W) {
    vector<int> f(W + 1, 0);
    for (int i = 0; i < (int)weights.size(); i++) {
        // 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
        for (int w = W; w >= weights[i]; w--) {
            f[w] = max(f[w], f[w - weights[i]] + values[i]);
        }
    }
    return f[W];
}

// 完全背包:求最大价值
int knapsackComplete(const vector<int>& weights, const vector<int>& values, int W) {
    vector<int> f(W + 1, 0);
    for (int i = 0; i < (int)weights.size(); i++) {
        // 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
        for (int w = weights[i]; w <= W; w++) {
            f[w] = max(f[w], f[w - weights[i]] + values[i]);
        }
    }
    return f[W];
}

模板里要注意四点:

  • 问法决定初值与符号:求最大价值用 max + 初值全 0;求"能否凑出"用 or + 初值 f[0] = true 其余 false;求方案数用 + + 初值 f[0] = 1 其余 0;求最少件数用 min + 初值 f[0] = 0 其余 INF
  • W 的下界:内层循环只到 weights[i],再小就装不下当前物品,跳过。
  • INF 的取值:求最少件数时不要用 Integer.MAX_VALUEINF + 1 会溢出。用 amount + 1W + 1 等"题目上界 + 1"即可。
  • 多重背包与分组背包:多重背包对单种物品 (w, v, k) 做二进制拆分(拆成 1, 2, 4, ..., 2^p, 余数 几堆),每堆当一个独立物品塞进 0/1 背包;分组背包外层枚举"组",组内枚举本组要选哪一件,仍然倒序。

多重背包:二进制拆分回 0/1

每种物品有 k[i] 件可选(不是 1 件、也不是无限)。朴素做法把每件都当独立物品塞进 0/1 背包,O(n · W · sum(k[i])),当 k[i] 很大时会爆。经典技巧是二进制拆分:把 k[i] 件物品拆成 1, 2, 4, 8, ..., 2^m, (k[i] - 2^(m+1) + 1) 这几堆,每堆当一个"新物品"(重量、价值都是单件的对应倍数),再跑标准 0/1 背包。

# 二进制拆分 O(n · W · log k_max)
items = []                                # 拆分后的新物品列表
for w, v, k in zip(weights, values, counts):
    p = 1
    while p <= k:
        items.append((w * p, v * p))
        k -= p
        p *= 2
    if k > 0:
        items.append((w * k, v * k))      # 剩余件数也算一堆

# 然后就是标准 0/1 背包
for w, v in items:
    for j in range(W, w - 1, -1):
        dp[j] = max(dp[j], dp[j - w] + v)

正确性:任意 0 ≤ x ≤ k[i] 都能用拆出来的几堆"二进制位组合"凑出来。比如 k = 13 拆成 [1, 2, 4, 6],选 11 件 = 1 + 4 + 6,选 7 件 = 1 + 2 + 4,都能办到。复杂度 O(n · W · log k_max),应对 k = 10⁹ 也游刃有余。更高级的单调队列优化能把单组物品压到 O(W)、整体 O(n · W),面试基本不要求会写,了解即可。

分组背包:组内互斥选一

物品被分成若干组,每组里最多选一件。骨架是"外层枚举组、组内枚举本组要选哪一件、w 仍倒序":

# 分组背包:groups[g] = [(w, v), ...]
for g in range(num_groups):
    for w in range(W, -1, -1):                # 倒序:保证本组只算一件
        for wi, vi in groups[g]:
            if w >= wi:
                dp[w] = max(dp[w], dp[w - wi] + vi)

关键是"w 倒序"在外层、"组内枚举"在最内层——内层连续比较"本组的每件",只更新一次 dp[w],保证本组至多取一件。

记住三种背包的核心区别一句话:0/1 倒序、完全正序、多重二进制拆分回 0/1


3. 母题精讲

LeetCode 416. 分割等和子集

链接:LeetCode 416

题意:给一个只含正整数的非空数组 nums,判断能否把它分成两个子集,使两个子集的元素和相等。

数据范围:

  • nums.length:1 到 200
  • nums[i]:1 到 100

示例:

输入:nums = [1, 5, 11, 5]
输出:true
解释:可以分成 [1, 5, 5] 和 [11],两边和都是 11。

输入:nums = [1, 2, 3, 5]
输出:false

暴力解:枚举所有子集

转化:设 total = sum(nums)。要分成两个和相等的子集,等价于找一个子集,其和等于 total / 2。如果 total 是奇数,直接返回 false。

最朴素的写法是递归 include/exclude——对每个 nums[i] 决定"放进子集"或"不放",枚举所有 2ⁿ 种组合:

class Solution:
    def canPartition_brute(self, nums: list[int]) -> bool:
        total = sum(nums)
        if total % 2:
            return False
        target = total // 2

        def dfs(i, cur):
            if cur == target:
                return True
            if i == len(nums) or cur > target:
                return False
            return dfs(i + 1, cur + nums[i]) or dfs(i + 1, cur)

        return dfs(0, 0)

时间 O(2ⁿ)n = 2002²⁰⁰ —— 完全不可行。即使加 memoization,状态数是 O(n × target),target 最大 200 × 100 / 2 = 10⁴,状态数 2 × 10⁶,可以接受。

关键观察:DP 状态空间是 O(n × sum)

dfs(i, cur) 用一个 2D 表存下来——dp[i][j] 表示"用前 i 个物品能否凑出和 j"。

dp[i][j] = dp[i-1][j]                    # 不选 nums[i-1]
        or dp[i-1][j - nums[i-1]]        # 选 nums[i-1],前提是 j >= nums[i-1]

边界:dp[0][0] = true(0 个物品凑出 0 是可以的),dp[0][j > 0] = false

这就是经典的 0/1 背包——每个物品最多选一次。下面看完整 DP 表怎么填出来。

LC 416 上 nums=[1,5,11,5] 的 0/1 背包 DP 表 (target=11)

dp[i][j] = 用前 i 个物品能否凑出和 j。红 = true,蓝 = false

Step 1 / 5初始:dp[0][0]=true,dp[0][j>0]=false (0 个物品只能凑 0)
index
0123
value
1
5
11
5
target
dp[i][j] = truedp[i][j] = false目标 dp[n][target]尚未填
01/05

复杂度从 O(2ⁿ) 砍到 O(n × target) = 2 × 10⁶,毫秒级。下面把这个套路拆开看。


4. 例题(三道)

下面三道按"问法递增"排:boolean → min → count。每道带 follow-up 链。

4.1 LC 416 分割等和子集

回到本章开头那道题。total = sum(nums) 奇数直接返回 false;偶数时跑 0/1 背包,target = total / 2

class Solution:
    def canPartition(self, nums: list[int]) -> bool:
        total = sum(nums)
        if total % 2:
            return False
        target = total // 2
        dp = [False] * (target + 1)
        dp[0] = True
        for x in nums:
            for j in range(target, x - 1, -1):    # 倒序
                dp[j] = dp[j] or dp[j - x]
            if dp[target]:                          # 提前结束
                return True
        return dp[target]
class Solution {
    public boolean canPartition(int[] nums) {
        int total = 0;
        for (int x : nums) total += x;
        if ((total & 1) == 1) return false;
        int target = total >> 1;

        boolean[] dp = new boolean[target + 1];
        dp[0] = true;
        for (int x : nums) {
            // 倒序:避免本轮 dp[j-x] 被同轮先更新过的值污染
            for (int j = target; j >= x; j--) {
                if (dp[j - x]) dp[j] = true;
            }
            if (dp[target]) return true;
        }
        return dp[target];
    }
}

Java 工业级细节(total & 1) == 1total % 2 == 1 略快(位运算 vs 取模);total >> 1 同理。对 LC 数据范围 n ≤ 200, x ≤ 100,total ≤ 20000,int 充分。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int total = 0;
        for (int x : nums) total += x;
        if ((total & 1) == 1) return false;
        int target = total >> 1;

        vector<bool> dp(target + 1, false);
        dp[0] = true;
        for (int x : nums) {
            // 倒序:避免本轮 dp[j-x] 被同轮先更新过的值污染
            for (int j = target; j >= x; j--) {
                if (dp[j - x]) dp[j] = true;
            }
            if (dp[target]) return true;
        }
        return dp[target];
    }
};

复杂度 O(n × target)200 × 10⁴ = 2 × 10⁶,毫秒级。

Follow-up:上面是"能否凑出"的 boolean 版本。如果题目改成"凑出 amount 至少需要几个硬币(每种硬币可以用任意多次)",问法从 boolean 变 min、物品从 0/1 变成完全 ——这就是 LC 322。


4.2 LC 322 零钱兑换

链接:LeetCode 322

题意:给一个面额数组 coins 和金额 amount,每种面额硬币可以用无限多个。求凑出 amount 至少需要几个硬币;凑不出返回 -1。

数据范围:

  • coins.length:1 到 12
  • coins[i]:1 到 2³¹ − 1
  • amount:0 到 10⁴

示例:

输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

完全背包 + min 问法。把 dp[j] 初始化成 INF(凑不出),dp[0] = 0

class Solution:
    def coinChange(self, coins: list[int], amount: int) -> int:
        INF = amount + 1
        dp = [INF] * (amount + 1)
        dp[0] = 0
        for c in coins:
            for j in range(c, amount + 1):           # 正序
                dp[j] = min(dp[j], dp[j - c] + 1)
        return dp[amount] if dp[amount] <= amount else -1
class Solution {
    public int coinChange(int[] coins, int amount) {
        int INF = amount + 1;                          // 不能用 Integer.MAX_VALUE
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, INF);
        dp[0] = 0;
        for (int c : coins) {
            for (int j = c; j <= amount; j++) {
                dp[j] = Math.min(dp[j], dp[j - c] + 1);
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }
}
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int INF = amount + 1;                          // 不能用 INT_MAX
        vector<int> dp(amount + 1, INF);
        dp[0] = 0;
        for (int c : coins) {
            for (int j = c; j <= amount; j++) {
                dp[j] = min(dp[j], dp[j - c] + 1);
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }
};

复杂度 O(len(coins) × amount)12 × 10⁴ = 1.2 × 10⁵

注意 INFamount + 1 而不是 Integer.MAX_VALUE:Python 用 amount + 1 是为了避免 float('inf') + 1 这种特例;Java / C++ / Go 必须用 amount + 1,否则 INF + 1 会溢出 int 范围导致 min 算出负数得到错误答案——这是 LC 322 三语言都要避开的工程坑。

Follow-up:把"最少几个硬币"换成"有几种凑法",问法从 min 换成 count——这就是 LC 518。


4.3 LC 518 零钱兑换 II

链接:LeetCode 518

题意:给一个面额数组 coins(每种无限多)和金额 amount。计算凑出 amount 的硬币组合数。

数据范围:

  • coins.length:1 到 300
  • coins[i]:1 到 5000
  • amount:0 到 5000
  • 题目保证答案在 32 位整数范围内

示例:

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:4 种凑法:
  5 = 5
  5 = 2 + 2 + 1
  5 = 2 + 1 + 1 + 1
  5 = 1 + 1 + 1 + 1 + 1

完全背包 + count 问法。dp[0] = 1(凑出 0 的方法只有"什么都不选"一种):

class Solution:
    def change(self, amount: int, coins: list[int]) -> int:
        dp = [0] * (amount + 1)
        dp[0] = 1
        for c in coins:                              # 外层是硬币
            for j in range(c, amount + 1):           # 内层是金额,正序
                dp[j] += dp[j - c]
        return dp[amount]

下面演示 coins = [1, 2, 5], amount = 5dp 数组每过一枚硬币的演化(颜色:金 = 该轮新更新的格子;红 = dp[0] = 1 起点;蓝 = 已稳定的格子):

LC 518 — dp 数组随每枚硬币逐轮更新

组合数问法:外层硬币、内层金额正序、dp[0] = 1

Step 1 / 5初始:dp = [1,0,0,0,0,0],只有 dp[0] = 1(空选法)
index
012345
value
1
0
0
0
0
0
dp[0] = 1 起点本轮(当前硬币)新更新的格子已稳定(前几轮的结果)尚未更新
01/05

注意外层是 coins、内层是 amount——交换循环顺序会算出"排列数"而不是"组合数",这是 §5 重点拆解的坑。

复杂度 O(len(coins) × amount)

关键陷阱:外层循环必须是硬币、内层是金额,不能颠倒。

如果颠倒成"外层金额、内层硬币",得到的是排列数(不同顺序算不同方案),不是题目要的组合数。这就是 LC 377 组合总和 IV 的区别——LC 377 把顺序算不同,所以它的外层是金额、内层是硬币。


5. 边界、易错与复杂度

0/1 背包必须倒序,完全背包必须正序

这是本章最容易出错的点。判断标准是"一件物品要不要被本轮重复使用":

  • 倒序枚举 jdp[j - w] 还是上一轮的值(不含本物品),所以本物品至多被选一次 = 0/1 背包
  • 正序枚举 jdp[j - w] 可能已经更新过(含本物品),所以本物品可以被多选 = 完全背包

写代码前先在草稿上回答"这道题物品能选几次",再决定方向。

组合数 vs 排列数:循环嵌套顺序

dp[j] += dp[j - c] 的循环顺序决定结果是组合还是排列:

  • 外硬币、内金额 → 组合(LC 518,每种硬币只在外层被"引入"一次)
  • 外金额、内硬币 → 排列(LC 377,每个金额都遍历所有硬币意味着不同顺序被计数)

记不住的话,每次写完跑一遍 coins=[1,2], amount=3:组合数应该是 2 ([1,1,1][1,2]),排列数应该是 3 ([1,1,1] / [1,2] / [2,1])。

初值的三种含义

问法dp[0] 初值其他位置初值
booleantruefalse
min0INF(或 amount + 1
count10

dp[0] 永远表示"凑出 0 的合法状态"——不选任何物品是合法的,所以 boolean 是 true、最少件数是 0、方案数是 1(只有空选这一种)。

INF 的选择

min 问法用 INF 占位时,避免用 Integer.MAX_VALUE:在 dp[j - c] + 1 时会溢出。用题目数据范围 + 1(如 amount + 1)就够大且不溢出。

多维 0/1 背包

LC 474 一和零是二维容量:背包有"0 的数量"和"1 的数量"两个维度。模板扩展成 dp[c0][c1],倒序枚举两个维度即可。复杂度 O(n × m × len)

复杂度速查

时间空间
LC 416O(n × sum / 2)O(sum / 2)
LC 322O(len × amount)O(amount)
LC 518O(len × amount)O(amount)

Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新


7. 小结

背包问题的核心选择:物品能选几次决定 1D 压缩的遍历方向(0/1 倒序、完全正序);问法(能否 / 最少 / 方案数)决定初值 + 递推符号。组合数和排列数的差异藏在循环嵌套顺序上。这套模板覆盖 80% 的背包题。


对应灵神题单:背包入门 / 0-1 背包 / 完全背包 / 多维背包

进一步阅读:LeetCode 动态规划题单 下背包标签

On this page