经典动态规划问题 | 背包问题详解


1. 场景引入:打包行李

想象你要去旅行,有一个背包,它的承重有限(比如只能装 4 公斤)。
你面前有几种物品,每个物品都有它的重量价值(比如重要性、价格)。

目标: 在不超过背包承重的前提下,让包里的物品总价值最大


2. 0/1 背包问题(最基础版本)

规则: 每种物品只有一个。你要么拿走它(1),要么不拿(0),不能把物品切开,也不能拿多个。

2.1 核心逻辑:选还是不选?

对于每一个物品,我们只有两个选择:

  1. 不选它: 背包容量不变,价值不变,接着看下一个物品。
  2. 选它: 背包容量减少(减去该物品重量),价值增加(加上该物品价值),接着看下一个物品。

我们要做的就是:在这两种选择中,挑一个价值更大的。

2.2 动态规划表格(DP Table)

我们用一个表格来记录“中间结果”。
定义 dp[i][w] 表示:面对前 i 个物品,当背包容量为 w 时,能获得的最大价值。

  • i 范围:0 到 物品总数
  • w 范围:0 到 背包最大容量

状态转移方程(核心公式):
对于第 i 个物品(重量 wt,价值 val):

  1. 如果背包放不下它 (w < wt):
    • 只能不选。dp[i][w] = dp[i-1][w]
  2. 如果背包放得下它 (w >= wt):
    • 我们要比较“不选”和“选”哪个更好:
    • 不选: 价值等于前 i-1 个物品在容量 w 下的最大价值。 -> dp[i-1][w]
    • 选: 价值等于 当前物品价值 + 前 i-1 个物品在剩余容量 (w-wt) 下的最大价值。 -> val + dp[i-1][w-wt]
    • 取最大值: dp[i][w] = max(不选,选)

2.3 Python 代码实现 (二维数组版)

这是最容易理解的版本,完全对应上面的表格。

def knapsack_01(weights, values, capacity):
    n = len(weights)
    # 1. 初始化 dp 表格 (n+1 行,capacity+1 列)
    # dp[i][w] 表示前 i 个物品,容量为 w 时的最大价值
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    # 2. 开始填表
    for i in range(1, n + 1):          # 遍历每个物品 (从 1 到 n)
        w_i = weights[i-1]             # 当前物品重量 (注意索引是 i-1)
        v_i = values[i-1]              # 当前物品价值
        
        for w in range(capacity + 1):  # 遍历每个容量 (从 0 到 capacity)
            # 情况 1: 背包容量不够,装不下当前物品
            if w < w_i:
                dp[i][w] = dp[i-1][w]
            # 情况 2: 背包容量够,决策“选”还是“不选”
            else:
                # 不选:价值等于上一行同容量的值
                not_take = dp[i-1][w]
                # 选:当前价值 + 剩余容量的最优解
                take = v_i + dp[i-1][w - w_i]
                
                # 取最大值
                dp[i][w] = max(not_take, take)
                
    # 3. 返回右下角的值,即最终答案
    return dp[n][capacity]

# 测试
weights = [1, 3, 4]
values = [15, 20, 30]
cap = 4
print(knapsack_01(weights, values, cap))  # 输出 35

2.4 空间优化 (一维数组版)

注意: 这一步是为了节省内存,逻辑一样,但代码更简洁。
观察上面的公式 dp[i][w] 只依赖于 dp[i-1]... 也就是说,我们只需要上一行的数据。
我们可以把二维数组压缩成一维数组 dp[w]

关键点: 内层循环必须倒序遍历容量!

  • 为什么倒序? 因为如果我们正序遍历,计算 dp[w] 时可能会用到已经更新过的 dp[w-w_i](这意味着当前物品被用了两次),这违反了 0/1 背包“每个物品只能用一次”的规则。倒序可以保证用到的是“上一轮”的数据。
def knapsack_01_optimized(weights, values, capacity):
    n = len(weights)
    # 初始化一维数组,dp[w] 表示容量为 w 时的最大价值
    dp = [0] * (capacity + 1)
    
    for i in range(n):          # 遍历每个物品
        w_i = weights[i]
        v_i = values[i]
        
        # 【关键】必须倒序遍历容量
        # 从 capacity 到 w_i,确保每个物品只被计算一次
        for w in range(capacity, w_i - 1, -1):
            dp[w] = max(dp[w], dp[w - w_i] + v_i)
            
    return dp[capacity]

3. 完全背包问题(变种):每种物品无限多个

规则变化: 每种物品无限多个。你可以拿 1 个 A,也可以拿 5 个 A,只要背包装得下。

3.1 区别在哪里?

  • 0/1 背包: 物品只能选一次。所以内层循环要倒序(防止重复选)。
  • 完全背包: 物品可以选多次。所以内层循环要正序(允许重复选)。

为什么正序就可以重复选?
当你计算 dp[w] 时,如果用到了 dp[w - w_i],而 dp[w - w_i] 是在本轮循环中刚刚更新过的(因为正序),那就说明 dp[w - w_i] 里可能已经包含了一个当前物品 i
那么 dp[w] = dp[w - w_i] + v_i 就相当于放了两个物品 i。这正是完全背包想要的。

3.2 Python 代码 (完全背包)

def knapsack_complete(weights, values, capacity):
    n = len(weights)
    # 1. 初始化 dp 表格 (n+1 行,capacity+1 列)
    # dp[i][w] 表示前 i 个物品,容量为 w 时的最大价值
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    # 2. 开始填表
    for i in range(1, n + 1):          # 遍历每个物品 (从 1 到 n)
        w_i = weights[i-1]             # 当前物品重量 (注意索引是 i-1)
        v_i = values[i-1]              # 当前物品价值
        
        for w in range(capacity + 1):  # 遍历每个容量 (从 0 到 capacity)
            # 情况 1: 背包容量不够,装不下当前物品
            if w < w_i:
                dp[i][w] = dp[i-1][w]
            # 情况 2: 背包容量够,决策“选”还是“不选”
            else:
                # 不选:价值等于上一行同容量的值
                not_take = dp[i-1][w]
                # 选:当前价值 + 剩余容量的最优解
                take = v_i + dp[i][w - w_i]  # 01 背包问题这里是 dp[i-1][w - w_i]
                
                # 取最大值
                dp[i][w] = max(not_take, take)
                
    # 3. 返回右下角的值,即最终答案
    return dp[n][capacity]

3.3 空间优化 (一维数组版)

对比上面的 0/1 背包优化代码,只改了一行(循环方向)。

def knapsack_complete(weights, values, capacity):
    n = len(weights)
    dp = [0] * (capacity + 1)
    
    for i in range(n):          # 遍历每个物品
        w_i = weights[i]
        v_i = values[i]
        
        # 【关键】完全背包需要正序遍历容量
        # 允许在同一轮循环中重复使用当前物品
        for w in range(w_i, capacity + 1):
            dp[w] = max(dp[w], dp[w - w_i] + v_i)
            
    return dp[capacity]

4. 总结与对比

特性 0/1 背包 完全背包
物品数量 每个只有 1 个 每个有无限个
核心决策 选 或 不选 选 0 个、1 个、2 个...
一维 DP 循环方向 倒序 (从大到小) 正序 (从小到大)
原因 防止同一物品被重复计算 允许同一物品被重复计算
状态转移 dp[w] = max(dp[w], dp[w-wt] + val) 公式一样,但遍历顺序不同


posted @ 2026-03-10 20:08  MoonOut  阅读(61)  评论(0)    收藏  举报