背包问题

一句话

背包问题的本质是:每个物品都问一次“选还是不选”,并把容量当成状态维度。

🎯 什么时候想到背包

看到这些描述时,优先怀疑是背包:

  • 有一堆物品 / 数字 / 硬币。
  • 每个物品有“消耗”和“收益”。
  • 总容量、总金额、总和有上限。
  • 问:
    • 最大价值
    • 最少数量
    • 是否能凑出
    • 有多少种方案

典型题面换皮:

  • 容量限制
  • 预算限制
  • 目标和
  • 硬币拼金额
  • 选若干元素使和最接近目标

🧠 先把背包想成买东西

  • 背包容量 W:你的预算或空间
  • 物品重量 w[i]:要花掉多少预算
  • 物品价值 v[i]:带来多少收益

处理每个物品时,永远在做同一个决策:

flowchart TD
    A["处理第 i 个物品"] --> B{"容量 j 放得下吗"}
    B -- 否 --> C["只能不选"]
    B -- 是 --> D["比较:不选 vs 选"]
    D --> E["不选:保留旧答案"]
    D --> F["选:空出 w[i] 再加 v[i]"]

🧩 背包最核心的状态定义

最经典的写法:

dp[i][j] = 只看前 i 个物品、容量为 j 时的最优答案

如果做一维优化,则常写成:

dp[j] = 当前处理到某一轮时,容量 j 的最优答案

📦 四大背包类型

类型每个物品能选几次关键区别
0-1 背包0 次或 1 次不能重复选
完全背包无限次可以重复选
多重背包有数量上限可以二进制拆分
分组背包每组最多选一个组内互斥

✍️ 0-1 背包为什么要逆序

状态转移

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

一维优化后写成:

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

这里的 dp[j-w[i]] 必须是“上一轮”的值,不能被当前物品污染。

所以容量 j 要从大到小遍历。

逆序示意图

flowchart LR
    A["容量 W"] --> B["..."]
    B --> C["容量 j"]
    C --> D["容量 j-w[i]"]
    D --> E["容量 0"]

逆序时,j-w[i] 还没被当前物品更新,所以不会一轮内重复选它。

结论

0-1 背包 一维写法必须逆序,否则同一件物品会被重复使用。

Go 代码:0-1 背包

func knapsack01(capacity int, weights, values []int) int {
    dp := make([]int, capacity+1)
 
    for i := 0; i < len(weights); i++ {
        for j := capacity; j >= weights[i]; j-- {
            candidate := dp[j-weights[i]] + values[i]
            if candidate > dp[j] {
                dp[j] = candidate
            }
        }
    }
 
    return dp[capacity]
}

✍️ 手推一个 0-1 背包

物品如下:

物品重量价值
A115
B320
C430

容量 W = 4。

状态表

容量 j01234
初始00000
处理 A 后015151515
处理 B 后015152035
处理 C 后015152035

最后答案是 35,选的是 A + B。

✍️ 完全背包为什么要正序

完全背包允许同一种物品重复使用。

所以我们反而希望:

dp[j-w[i]]

已经吸收过“当前物品”的贡献,这样当前物品才能继续被选。

状态转移

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

注意第二项是 dp[i],不是 dp[i-1]。

这说明当前物品还能继续接着用,所以一维优化时要正序遍历。

正序示意图

flowchart LR
    A["容量 0"] --> B["容量 w[i]"]
    B --> C["容量 j-w[i]"]
    C --> D["容量 j"]
    D --> E["容量 W"]

结论

完全背包 一维写法必须正序,否则就失去了“重复使用当前物品”的能力。

Go 代码:完全背包

func knapsackComplete(capacity int, weights, values []int) int {
    dp := make([]int, capacity+1)
 
    for i := 0; i < len(weights); i++ {
        for j := weights[i]; j <= capacity; j++ {
            candidate := dp[j-weights[i]] + values[i]
            if candidate > dp[j] {
                dp[j] = candidate
            }
        }
    }
 
    return dp[capacity]
}

🎯 一张表看懂遍历方向

类型一维遍历方向原因
0-1 背包j 从大到小防止同一物品在本轮重复使用
完全背包j 从小到大允许同一物品在本轮继续使用

🧪 背包题的四种常见问法

1. 求最大值

candidate := dp[j-w] + v
if candidate > dp[j] {
    dp[j] = candidate
}

2. 求最小值

例如最少硬币数:

candidate := dp[j-coin] + 1
if candidate < dp[j] {
    dp[j] = candidate
}

3. 求方案数

dp[j] += dp[j-w]

4. 求可行性

dp[j] = dp[j] || dp[j-w]

💻 高频变形模板

分割等和子集

func canPartition(nums []int) bool {
    total := 0
    for _, num := range nums {
        total += num
    }
    if total%2 != 0 {
        return false
    }
 
    target := total / 2
    dp := make([]bool, target+1)
    dp[0] = true
 
    for _, num := range nums {
        for j := target; j >= num; j-- {
            dp[j] = dp[j] || dp[j-num]
        }
    }
 
    return dp[target]
}

零钱兑换

func coinChange(coins []int, amount int) int {
    const inf = int(1e9)
    dp := make([]int, amount+1)
    for i := 1; i <= amount; i++ {
        dp[i] = inf
    }
 
    for _, coin := range coins {
        for j := coin; j <= amount; j++ {
            candidate := dp[j-coin] + 1
            if candidate < dp[j] {
                dp[j] = candidate
            }
        }
    }
 
    if dp[amount] == inf {
        return -1
    }
    return dp[amount]
}

零钱兑换 II

func change(amount int, coins []int) int {
    dp := make([]int, amount+1)
    dp[0] = 1
 
    for _, coin := range coins {
        for j := coin; j <= amount; j++ {
            dp[j] += dp[j-coin]
        }
    }
 
    return dp[amount]
}

⚠️ 组合和排列不要混

如果题目问“组合数”,循环顺序通常是:

for _, coin := range coins {
    for j := coin; j <= amount; j++ {
        // ...
    }
}

如果题目问“排列数”,循环顺序通常会交换:

for j := 1; j <= amount; j++ {
    for _, coin := range coins {
        // ...
    }
}

因为:

  • 组合不关心顺序
  • 排列关心顺序

⚠️ 背包问题最常见错误

  • 把 0-1 背包写成正序遍历。
  • 把完全背包写成逆序遍历。
  • 题目问“方案数”,你却用 max。
  • 题目问“最少个数”,你却把 dp 初始化成 0。
  • 没想清楚到底是“组合”还是“排列”。

🧪 提交前检查单

做完先检查

  • 每个物品能选几次?
  • dp[j] 表示最大值、最小值、方案数还是可行性?
  • 容量循环应该正序还是逆序?
  • dp[0] 应该初始化成 0、1、inf 还是 -inf?

📚 推荐练习

0-1 背包

完全背包

综合

相关主题


返回:动态规划 | 算法学习导航