🎒 背包问题 — 01背包与完全背包

背包问题是DP的王冠——理解它,就理解了资源分配问题的本质

📖 背包问题全景

背包问题是DP最经典的模型族:给定容量为W的背包和n个物品,选择物品使总价值最大(或满足特定条件)。核心区别在于物品的选取方式。背包问题是理解"资源分配"类问题的钥匙。

背包问题分类: 背包问题 +-- 01背包: 每个物品最多选1次 | +-- 模板: dp[j] = max(dp[j], dp[j-w]+v) 逆序遍历! +-- 完全背包: 每个物品可以选无限次 | +-- 模板: dp[j] = max(dp[j], dp[j-w]+v) 正序遍历! +-- 多重背包: 每个物品有数量上限 | +-- 二进制拆分 --> 转化为01背包 +-- 分组背包: 物品分组,每组最多选1个 +-- 加一层分组循环 核心区别就在遍历顺序: 01背包: for j in W->w[i]: 逆序(避免重复选取) 完全背包: for j in w[i]->W: 正序(允许重复选取)

1. 01背包核心模板

# 01背包:n个物品,容量W,重量w[i],价值v[i]
# 一维优化: 逆序遍历容量
def knapsack_01(n, W, w, v):
    dp = [0] * (W + 1)
    for i in range(n):
        for j in range(W, w[i] - 1, -1):  # 逆序!
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    return dp[W]

# 二维版本(更直观)
def knapsack_01_2d(n, W, w, v):
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(W + 1):
            dp[i][j] = dp[i-1][j]  # 不选第i个
            if j >= w[i-1]:
                dp[i][j] = max(dp[i][j], dp[i-1][j-w[i-1]] + v[i-1])
    return dp[n][W]

2. 完全背包核心模板

# 完全背包:每种物品可无限选取
def knapsack_complete(n, W, w, v):
    dp = [0] * (W + 1)
    for i in range(n):
        for j in range(w[i], W + 1):  # 正序!
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    return dp[W]
01背包逆序遍历是重中之重!正序遍历会导致同一物品被多次选取(变成完全背包)。这是面试中最常考的细节。理解原因:逆序保证dp[j-w[i]]是上一层(未选当前物品)的值,正序则可能用到已选当前物品的值。

🔬 题目一:分割等和子集(LC 416)

给定正整数数组,判断能否分成两个子集使得两个子集的元素和相等。

思路分析

转化为01背包:
1. 总和为sum,需要找子集使和=sum/2
2. 等价于:容量为sum/2的背包,物品重量=数值,能否恰好装满
3. dp[j] = 容量j能否被凑出(布尔型背包)
4. 每个数只用一次 --> 01背包,逆序遍历

代码实现

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  # 容量0一定可以凑出
        for num in nums:
            for j in range(target, num - 1, -1):  # 逆序!
                dp[j] = dp[j] or dp[j - num]
        return dp[target]

复杂度分析

时间: O(n * target) 空间: O(target)

LC 416是01背包的"恰好装满"变体。与"最大价值"不同,这里dp存的是布尔值。还有一个常见变体:求具体方案数(LC 494 Target Sum)。

🔬 题目二:零钱兑换(LC 322)

给定不同面额的硬币和总金额,计算凑成总金额所需的最少硬币数。每种硬币可无限使用。

思路分析

完全背包变形:
1. dp[j] = 凑出金额j所需的最少硬币数
2. 每种硬币可无限用 --> 完全背包,正序遍历
3. 转移:dp[j] = min(dp[j], dp[j - coin] + 1)
4. 初始:dp[0] = 0,其余 = 无穷大
5. 最终dp[amount]若仍为无穷大则返回-1

代码实现

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        dp = [float('inf')] * (amount + 1)
        dp[0] = 0
        for coin in coins:
            for j in range(coin, amount + 1):  # 正序!完全背包
                dp[j] = min(dp[j], dp[j - coin] + 1)
        return dp[amount] if dp[amount] != float('inf') else -1

复杂度分析

时间: O(n * amount) 空间: O(amount)

🔬 题目三:零钱兑换II(LC 518)

给定不同面额的硬币和总金额,计算凑成总金额的组合数。每种硬币可无限使用。

思路分析

完全背包求方案数:
1. dp[j] = 凑出金额j的组合数
2. 转移:dp[j] += dp[j - coin]
3. 初始:dp[0] = 1(凑出金额0有一种方案:不选)
4. 注意:先遍历硬币再遍历金额 --> 保证是组合数(不区分顺序)

代码实现

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        dp = [0] * (amount + 1)
        dp[0] = 1
        for coin in coins:
            for j in range(coin, amount + 1):
                dp[j] += dp[j - coin]
        return dp[amount]

复杂度分析

时间: O(n * amount) 空间: O(amount)

组合数 vs 排列数:先遍历物品再遍历容量 --> 组合数(不区分顺序);先遍历容量再遍历物品 --> 排列数(区分顺序,如LC 377)。这个细节在面试中经常被追问!

面试实战技巧

在实际面试中,算法题不仅仅是写代码,更考察沟通、分析和解决问题的能力。以下是一些实战技巧:

1. 解题步骤模板

5分钟分析:理解题意 - 确认边界 - 想暴力解 - 优化 - 写代码

1. 先复述题目,确认理解无误
2. 问清边界条件:空输入?负数?溢出?
3. 先说暴力解法(证明你理解问题)
4. 分析瓶颈,提出优化思路
5. 边写代码边解释思路

2. 常见陷阱

通用陷阱:
1. 索引越界:特别注意循环范围和数组边界
2. 初始化遗漏:DP的初始条件是否正确?
3. 遍历方向:依赖关系决定遍历顺序
4. 整数溢出:Python不溢出但其他语言注意
5. 边界条件:空数组、单元素、全相同

3. 代码模板

# 通用解题模板
class Solution:
    def solve(self, input_data):
        # 1. 边界检查
        if not input_data:
            return default_value
        # 2. 初始化
        result = initial_value
        state = initial_state
        # 3. 主循环
        for item in input_data:
            state = transition(state, item)
            result = max/min/update(result, state)
        # 4. 返回结果
        return result
面试中的代码不是越短越好,而是越清晰越好。适当的注释、合理的变量名、分步骤的实现,比一行式解法更能展示工程素养。
成就解锁:背包达人 — 掌握01背包与完全背包的核心模板与变形
LeetCode AC验证:LC 416 Partition Equal Subset Sum ✅ | LC 322 Coin Change ✅ | LC 518 Coin Change II ✅

📝 课后练习

  1. LC 494 目标和(01背包求方案数)
  2. LC 377 组合总和IV(完全背包排列数)
  3. LC 1049 最后一块石头的重量II
  4. LC 1449 数位成本和为目标值的最大数字

🔑 本课要点回顾

📚 扩展阅读

思考题:如果每种硬币有数量上限,该用什么背包模型?

⚡ 进阶专题:常见变体与扩展

每道经典题目都有丰富的变体。掌握核心模板后,灵活应对变体才是面试的真正挑战。以下列举本课相关的高频变体和扩展思路。

1. 常见题目变体

条件变化型:在原题基础上增加约束条件,如:数组中有负数、元素有上限、需要考虑溢出等。核心思路:不改变DP框架,只在转移时增加条件判断。

维度扩展型:从一维DP扩展到二维或三维。如:从线性到环形(LC 213)、从单串到双串(LCS)、从无约束到有约束。核心思路:增加状态维度来编码额外信息。

目标变化型:从求最值变为求方案数、求具体方案、判定可行性。核心思路:dp存的值从max/min变为count/boolean,转移从取最值变为累加。

2. 面试中的沟通技巧

# 面试沟通模板
# 1. 先说思路(1-2分钟)
"这道题我想到两种解法,先说更直观的一种..."

# 2. 分析复杂度(30秒)
"时间复杂度O(n^2),空间可以优化到O(n)"

# 3. 边写边说
"这里我定义dp[i]表示...,转移方程是..."

# 4. 主动验证
"我用示例走一遍:dp[0]=..., dp[1]=..."

# 5. 讨论优化
"空间还可以进一步优化,因为dp[i]只依赖..."

3. 代码调试检查清单

检查项常见错误
初始条件dp[0]是否正确?空输入是否处理?
遍历范围循环从0还是1开始?是否越界?
状态转移依赖的状态是否已计算?
返回值是dp[n]还是max(dp)?
边界情况单元素、全相同、空数组
面试中主动验证是最被低估的技巧。用示例手动走一遍DP表,既能发现bug,又能展示你的严谨性。很多面试官就是看你能否主动发现和修正错误。

⚡ 背包问题速查表

背包问题的变体众多,以下速查表帮助你快速定位题目类型和解法。

问题背包类型dp含义遍历方向
分割等和子集01背包dp[j]=能否凑出j逆序
目标和01背包dp[j]=凑出j的方案数逆序
最后一块石头II01背包dp[j]=能否凑出j逆序
零钱兑换完全背包dp[j]=最少硬币数正序
零钱兑换II完全背包dp[j]=组合数正序
组合总和IV完全背包dp[j]=排列数先容量后物品
完全平方数完全背包dp[j]=最少个数正序
单词拆分完全背包dp[j]=能否拼出正序
记住口诀:01背包逆序保安全,完全背包正序可重复。方案数用累加,最值用max/min,判定用or。先物后容=组合,先容后物=排列。