🎒 动态规划进阶 — 背包变体与区间DP

多重背包、分组背包与区间DP——从一维决策到二维合并的思维跃迁

📖 为什么需要DP进阶?

第19课我们学过了0-1背包和完全背包,但面试中背包问题远不止这两种。多重背包、分组背包、二维费用背包是进阶面试的高频变体。而区间DP则是另一种完全不同的思维模式——它不是逐个物品决策,而是考虑如何合并相邻区间

DP进阶路线图: 第19课: 0-1背包 / 完全背包 (逐个决策) ↓ 本课: ├── 背包变体 │ ├── 多重背包 (每个物品有数量上限) │ ├── 分组背包 (每组只能选一个) │ └── 二维费用背包 (两个维度的容量) │ └── 区间DP ├── 矩阵链乘法 ├── 戳气球 (LC 312) └── 合并石子 核心区别: 背包DP: dp[i] = 对第i个物品做决策 (线性) 区间DP: dp[i][j] = 合并区间[i,j]的最优值 (合并)

🎯 题目一:零钱兑换 (LC 322) — 完全背包复习与深化

题目描述:给定不同面额的硬币 coins 和一个总金额 amount,计算凑成总金额所需的最少硬币数。每种硬币可以重复使用。

完全背包视角: coins = [1, 2, 5], amount = 11 物品 = 硬币, 重量 = 面额, 价值 = 1 (每枚硬币计数1) 容量 = amount 目标: 装满容量, 价值最小 dp[j] = 凑出金额j所需的最少硬币数 dp[0] = 0 (金额0需要0枚) dp[j] = min(dp[j], dp[j - coin] + 1) for each coin 填表过程: dp[0]=0, dp[1]=1, dp[2]=1, dp[3]=2 dp[4]=2, dp[5]=1, dp[6]=2, dp[7]=2 dp[8]=3, dp[9]=3, dp[10]=2, dp[11]=3 最优解: 5+5+1 = 3枚

代码实现

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)

完全背包的内层循环从 coinamount(正序),这与0-1背包的倒序不同。正序允许同一物品被多次选取。这是背包DP最核心的区别。

🎯 题目二:目标和 (LC 494) — 0-1背包的巧妙转化

题目描述:给定一个非负整数数组 nums 和一个目标值 target,给每个数前面添加 +-,使得结果等于 target,求有多少种方法。

数学转化: nums = [1,1,1,1,1], target = 3 设 +号的数之和为 P, -号的数之和为 N P - N = target P + N = sum(nums) = 5 两式相加: 2P = target + sum(nums) P = (target + sum) / 2 = (3+5)/2 = 4 问题转化为: 从nums中选若干数,使和恰好为4 → 0-1背包: 装满容量4的方案数! dp[j] = 凑出和为j的方案数 dp[0] = 1 (空集也算一种) 对每个数num: dp[j] += dp[j - num] (倒序遍历!)

代码实现

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        total = sum(nums)
        # 边界检查
        if abs(target) > total or (target + total) % 2 != 0:
            return 0
        bag = (target + total) // 2
        
        dp = [0] * (bag + 1)
        dp[0] = 1
        for num in nums:
            for j in range(bag, num - 1, -1):  # 倒序! 0-1背包
                dp[j] += dp[j - num]
        return dp[bag]

复杂度分析

时间复杂度:O(n × bag),空间复杂度:O(bag)

0-1背包求方案数时,内层循环仍然是倒序!正序会导致同一个数被重复选取。另外注意边界:abs(target) > total(target+total)为奇数 时直接返回0。

🎯 题目三:戳气球 (LC 312) — 区间DP经典

题目描述:n 个气球,编号为 0n-1,每个气球上标有一个数字。戳破气球 i 可以获得 nums[left] × nums[i] × nums[right] 个硬币。求戳破所有气球能获得的最大硬币数。

区间DP思路 — LC 312 戳气球: nums = [3,1,5,8] 关键洞察: 戳的顺序决定收益 → 枚举顺序是关键 但正向思考(先戳哪个)很难,因为戳完后相邻关系会变! 逆向思维: 最后戳哪个? 假设最后戳的是k,则k的左右邻居是边界: 收益 = nums[left] × nums[k] × nums[right] 加入虚拟边界: nums = [1, 3, 1, 5, 8, 1] ^ ^ 左边界 右边界 dp[i][j] = 戳破开区间(i,j)内所有气球的最大收益 枚举最后戳的k (i < k < j): dp[i][j] = max(dp[i][k] + dp[k][j] + nums[i]*nums[k]*nums[j]) 填表顺序: 区间长度从短到长 len=2: dp[0][2]=3, dp[1][3]=15, dp[2][4]=40, dp[3][5]=40 len=3: dp[0][3]=max(dp[0][1]+dp[1][3]+1*3*5, ...) = 30 ... 最终答案: dp[0][n+1]

思路分析

逆向思维:不思考"先戳哪个",而是思考"最后戳哪个"。最后戳的气球k,其左右邻居就是当前区间的边界i和j,收益为 nums[i] × nums[k] × nums[j]。这样左边 [i,k] 和右边 [k,j] 是独立的子问题!
状态定义:dp[i][j] = 戳破开区间 (i,j) 内所有气球的最大收益。i和j是不被戳的边界。
状态转移:枚举 (i,j) 内最后被戳的气球 k:
dp[i][j] = max(dp[i][k] + dp[k][j] + nums[i]×nums[k]×nums[j]),其中 i < k < j

代码实现

class Solution:
    def maxCoins(self, nums: List[int]) -> int:
        n = len(nums)
        # 加入虚拟边界
        arr = [1] + nums + [1]
        m = n + 2
        
        dp = [[0] * m for _ in range(m)]
        
        # 区间长度从2开始(至少要有两个边界)
        for length in range(2, m):
            for i in range(m - length):
                j = i + length
                # 枚举最后戳的气球
                for k in range(i + 1, j):
                    dp[i][j] = max(dp[i][j],
                        dp[i][k] + dp[k][j] + arr[i] * arr[k] * arr[j])
        
        return dp[0][m - 1]

复杂度分析

时间复杂度:O(n³),空间复杂度:O(n²)

区间DP的三个关键:(1) 状态定义是 dp[i][j] 表示区间 [i,j] 上的最优值;(2) 枚举区间内的分割点 k,将问题分为 [i,k] 和 [k,j];(3) 填表顺序:区间长度从短到长。这个模式适用于矩阵链乘法、合并石子、最长回文子串等所有区间DP问题。

🔬 背包DP vs 区间DP 对比

┌──────────────────────────────────────────────────────┐ │ 背包DP │ 区间DP │ ├──────────────────────────────────────────────────────┤ │ 状态: dp[i][j] 前i个物品容量j │ 状态: dp[i][j] 区间[i,j] │ │ 决策: 对物品i选或不选 │ 决策: 枚举分割点k │ │ 转移: dp[i][j] ← dp[i-1][...] │ 转移: dp[i][j] ← dp[i][k]+dp[k][j]│ │ 遍历: 逐个物品遍历 │ 遍历: 区间长度从短到长 │ │ 核心: 线性决策 │ 核心: 合并子区间 │ ├──────────────────────────────────────────────────────┤ │ 0-1背包: 内层倒序 │ 戳气球: 最后戳哪个 │ │ 完全背包: 内层正序 │ 矩阵链乘: 在哪分割 │ │ 多重背包: 二进制拆分优化 │ 合并石子: 在哪合并 │ └──────────────────────────────────────────────────────┘

多重背包简介

每种物品有数量上限 s[i],既不是0-1(只能选1个),也不是完全(无限选)。核心优化是二进制拆分:将 s[i] 个物品拆成 1, 2, 4, ..., 2^k, remainder 组,然后当成0-1背包处理。这样复杂度从 O(n × V × s) 降到 O(n × V × log s)。

# 多重背包 — 二进制拆分
def multi_knapsack(weights, values, counts, capacity):
    dp = [0] * (capacity + 1)
    for w, v, c in zip(weights, values, counts):
        # 二进制拆分
        k = 1
        while c > 0:
            take = min(k, c)
            # 按0-1背包处理(倒序)
            for j in range(capacity, take * w - 1, -1):
                dp[j] = max(dp[j], dp[j - take * w] + take * v)
            c -= take
            k *= 2
    return dp[capacity]

分组背包简介

物品被分为若干组,每组最多选一个。状态转移时对每组整体考虑:对于每个容量 j,枚举该组中选哪个物品。

# 分组背包
def group_knapsack(groups, capacity):
    # groups[i] = [(w1,v1), (w2,v2), ...]  第i组的物品
    dp = [0] * (capacity + 1)
    for group in groups:
        for j in range(capacity, -1, -1):  # 倒序(每组只能选一个)
            for w, v in group:
                if j >= w:
                    dp[j] = max(dp[j], dp[j - w] + v)
    return dp[capacity]

📝 练习题单

  1. LC 322 零钱兑换(完全背包)✅
  2. LC 494 目标和(0-1背包转化)✅
  3. LC 312 戳气球(区间DP)✅
  4. LC 518 零钱兑换 II(完全背包方案数)
  5. LC 1049 最后一块石头的重量 II(0-1背包转化)
  6. LC 813 最大平均值和的分组(分组背包)
  7. LC 1039 多边形三角剖分最低分(区间DP)
  8. LC 1000 合并石头的最低成本(区间DP变体)
  9. LC 474 一和零(二维费用背包)
  10. LC 1449 数位成本和为目标值的最大数字(背包方案重建)

🔑 关键知识点总结

成就解锁:DP进阶学者 — 掌握背包变体(多重/分组/二维费用)与区间DP的核心模式,能独立推导戳气球等Hard题!🎉
LeetCode AC验证:LC 322 Coin Change ✅ | LC 494 Target Sum ✅ | LC 312 Burst Balloons ✅
思考题:戳气球问题中,为什么正向思考(先戳哪个)很难,而逆向思考(最后戳哪个)就能轻松解决?这个"逆向思维"还能应用到哪些问题?提示:思考"谁先消失导致依赖关系改变"这类问题的通用策略。