0Pricing
DSA Interview Prep · 课时

完全背包与零钱兑换 II

通过正向遍历容量允许重复使用物品,并使用这一变体解决零钱兑换 II(统计方案数)和切割钢条问题。

完全背包与零钱兑换 II 是 CoddyKit 上的免费 DSA Interview Prep 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 DSA Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 DSA Interview Prep 课程共包含 4 节课。

完全背包概念

在完全背包中,每个物品可以选择任意多次(不同于每个物品最多使用一次的 0/1 背包)。状态定义相同——dp[c] = 容量为 c 时可以达到的最大价值——但遍历方向发生了变化。由于物品可以重复使用,更新 dp[c] 时需要允许再次使用当前物品,因此要从左到右(正向)遍历容量。

正向遍历实现重复使用

回顾一下,在 0/1 背包中,我们从右到左遍历以防止重复使用。在完全背包中则相反:要从左到右遍历。计算 dp[c] 时,dp[c-w] 已经在当前遍历中更新过,这意味着物品 i 可能已经被包含。正是我们想要的效果:物品 i 可以再次添加到一个已经包含物品 i 的解中。

def unbounded_knapsack(weights, values, W):
    dp = [0] * (W + 1)
    
    for i in range(len(weights)):
        w, v = weights[i], values[i]
        for c in range(w, W + 1):  # iterate LEFT TO RIGHT
            dp[c] = max(dp[c], dp[c - w] + v)
    
    return dp[W]

weights = [1, 3, 4, 5]
values  = [1, 4, 5, 7]
print(unbounded_knapsack(weights, values, 7))  # 9

硬币兑换 II:计算方案数

硬币兑换 II要求:给定硬币面额和一个金额,计算凑出该金额的不同方案数(每种硬币都可以无限次使用)。这是完全背包的一种变体,不再是最大化价值,而是计算 combinations。将 dp[c] 定义为凑出金额 c 的方案数。基本情况:dp[0] = 1(凑出 0 的方法只有一种:什么都不取)。

硬币兑换 II 实现

对于每种硬币,从左到右遍历金额并累加:dp[c] += dp[c - coin]。基本情况 dp[0] = 1 为计数提供初始值。请注意,外层循环遍历硬币,内层循环遍历金额——这样自然得到combinations 计数(而不是 permutations),因为每种硬币面额都只作为外层遍历的一次处理对象。

def change(amount, coins):
    dp = [0] * (amount + 1)
    dp[0] = 1  # one way to make amount 0
    
    for coin in coins:
        for c in range(coin, amount + 1):
            dp[c] += dp[c - coin]
    
    return dp[amount]

print(change(5, [1, 2, 5]))   # 4
print(change(3, [2]))          # 0
print(change(10, [10]))        # 1

combinations 与 permutations 的区别

循环的顺序至关重要。如果将金额放在外层循环、硬币放在内层循环,计算的就是permutations(顺序有影响)。对于 amount=5、硬币为 [1,2] 的情况,1+2+2 和 2+1+2 会被分别计数。如果将硬币放在外层循环,计算的就是combinations(顺序没有影响):1+2+2 和 2+1+2 被视为同一种方案。硬币兑换 II 要求计算 combinations,因此硬币应作为外层循环。

# Count COMBINATIONS (order does not matter) — coin outer loop
def combinations(amount, coins):
    dp = [0] * (amount + 1)
    dp[0] = 1
    for coin in coins:          # coin outer
        for c in range(coin, amount + 1):
            dp[c] += dp[c - coin]
    return dp[amount]

# Count PERMUTATIONS (order matters) — amount outer loop
def permutations(amount, coins):
    dp = [0] * (amount + 1)
    dp[0] = 1
    for c in range(1, amount + 1):  # amount outer
        for coin in coins:
            if c >= coin:
                dp[c] += dp[c - coin]
    return dp[amount]

print(combinations(5, [1,2,5]))   # 4
print(permutations(5, [1,2,5]))   # 13

钢条切割问题

这是另一个经典的完全背包问题:给定长度为 n 的钢条,以及长度从 1 到 n 的每种钢条价格,找出最优切割方案,使收益最大。每段长度为 l 的钢条可以按 price[l] 出售,并且各段可以重复使用(钢条可以切成多段相同长度的部分)。这可以直接映射为完全背包,其中 W = n,物品就是不同的切割长度。

def rod_cutting(prices, n):
    # prices[i] = price of rod of length i+1
    dp = [0] * (n + 1)
    
    for length in range(1, n + 1):   # each cut length
        price = prices[length - 1]
        for c in range(length, n + 1):
            dp[c] = max(dp[c], dp[c - length] + price)
    
    return dp[n]

prices = [1, 5, 8, 9, 10, 17, 17, 20]
print(rod_cutting(prices, 8))  # 22

硬币兑换 I:最少硬币数

硬币兑换 I(一个不同的问题)要求使用最少数量的硬币凑出目标金额。这里 dp[c] = 凑出金额 c 所需的最少硬币数。递推式为:dp[c] = min(dp[c], dp[c - coin] + 1)。除 dp[0] = 0 外,将所有条目初始化为 inf。这同样是完全背包问题(硬币可以重复使用),因此要从左到右遍历。若 dp[amount] 有限,则返回它;否则返回 -1。

def coinChange(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    
    for coin in coins:
        for c in range(coin, amount + 1):
            dp[c] = min(dp[c], dp[c - coin] + 1)
    
    return dp[amount] if dp[amount] != float('inf') else -1

print(coinChange([1,5,6,9], 11))  # 2 (5+6 or other combos)
print(coinChange([2], 3))          # -1

关键区别:最大值、最小值与计数

完全背包的三种变体对 dp[c-coin] 使用不同的操作:最大化价值:dp[c] = max(dp[c], dp[c-w] + v);初始化为 0。最小化成本:dp[c] = min(dp[c], dp[c-coin] + 1);初始化为 inf,dp[0]=0。计算方案数:dp[c] += dp[c-coin];初始化为 0,dp[0]=1。在面试题中识别出适用的变体,就等于成功了一半。

复杂度与面试技巧

所有完全背包变体的时间复杂度都是 O(n × W),空间复杂度都是 O(W),其中 n 是物品类型的数量,W 是目标金额。对于硬币问题,n 是硬币面额的数量。面试时,请说明变体类型(最大值、最小值或计数),写出一维 DP,并明确外层循环遍历的是硬币还是金额——出题者知道,这一区别能够检验您对 DP 的深入理解。

辨别完全背包与 0/1 背包

可以根据以下信号判断适用的变体:可以无限次重复使用 → 完全背包(正向遍历);每个物品恰好使用一次 → 0/1 背包(逆向遍历);题目说明“可以使用任意次数”“供应无限”或“允许重复使用” → 完全背包。示例:硬币兑换、钢条切割、整数拆分都属于完全背包。子集和、分割、0/1 背包属于 0/1 背包。判断错误会导致难以调试的错误答案。

整数拆分与其他变体

整数拆分(LeetCode 343):将整数 n 拆分为至少 2 个正整数,使它们的乘积最大化。这是一个完全背包问题,其中“物品”是从 2 到 n-1 的整数。将 dp[i] 定义为总和为 i 的整数的最大乘积。对于从 2 到 i 的每个物品 j,dp[i] = max(dp[i], max(j, dp[j]) * max(i-j, dp[i-j]))。这说明完全背包模式可以推广到硬币场景之外。

def integerBreak(n):
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        for j in range(1, i):
            dp[i] = max(dp[i], max(j, dp[j]) * max(i-j, dp[i-j]))
    return dp[n]

print(integerBreak(10))  # 36 (3+3+4 = 3*3*4 = 36)

快速检查

测试您对本课数据结构 & 算法——编程面试准备相关概念的理解。

课程回顾

本课您学习了:完全背包从左到右遍历容量,以允许重复使用物品,硬币兑换 II 通过将硬币放在外层循环来计算 combinations,以及三种变体——最大化、最小化和计数——的区别仅在于 DP 操作和初始化方式不同。接下来我们将使用 0/1 背包解决分割等和子集问题。

常见问题解答

「完全背包与零钱兑换 II」课时是免费的吗?

是的 — 「完全背包与零钱兑换 II」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 DSA Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 DSA Interview Prep 课程共包含 4 节课。

「完全背包与零钱兑换 II」这节课中我会学到什么?

通过正向遍历容量允许重复使用物品,并使用这一变体解决零钱兑换 II(统计方案数)和切割钢条问题。 你通过在浏览器中直接运行的动手代码来练习 DSA Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 DSA Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 DSA Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。

「完全背包与零钱兑换 II」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 DSA Interview Prep 课中编写并运行代码吗?

能。每节 DSA Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 0/1 背包与空间优化
  2. 完全背包与零钱兑换 II
  3. 等和子集分割
  4. 带正负号的目标和
← 返回 DSA Interview Prep