前言

        0-1 背包问题是动态规划领域最经典的问题,也是学习动态规划必须掌握的核心题型。本文将基于你编写的 C++ 代码,从递归求解、非递归求解到最优方案路径回溯,一步一步深度解析 0-1 背包的实现原理,包含完整可运行代码、逐行解析、表格打印调试,新手也能彻底吃透!

本文代码特点:

  • 包含递归实现(自上而下记忆化搜索)
  • 包含非递归实现(自底向上填表,标准动态规划)
  • 包含最优方案回溯(输出哪些物品被选中)
  • 打印 DP 表格,直观观察状态转移过程
  • 数组下标从 1 开始,符合算法教材习惯,更容易理解

一、0-1 背包问题描述

        有 n 个物品和一个最大承重为 c 的背包。每个物品:

  • 重量:W[i]
  • 价值:V[i]
  • 每个物品只能选 1 次或不选(0-1 含义)

        目标:在不超过背包承重的前提下,装入物品的总价值最大,并输出选择了哪些物品。

示例数据

  • 物品数量 n=5
  • 背包容量 c=10
  • 重量数组 W = [0,2,2,6,5,4]
  • 价值数组 V = [0,6,3,5,4,6]

二、核心动态规划思想

1. DP 状态定义

        m[i][j]:表示前 i 个物品,放入容量为 j 的背包中,能获得的最大价值。

2. 状态转移方程

        对第 i 个物品,有两种选择:

  1. 不选:最大价值 = 前 i-1 个物品,容量 j 的最大值m[i][j] = m[i-1][j]
  2. 选:前提是背包能装下,最大价值 = 当前物品价值 + 前 i-1 个物品、剩余容量的最大值m[i][j] = m[i-1][j-W[i]] + V[i]

        最终取最大值:

m[i][j] = max(m[i-1][j], m[i-1][j-W[i]] + V[i]);

3. 边界条件

  • 没有物品(i=0):价值为 0
  • 背包容量为 0(j=0):价值为 0

三、完整代码与逐行深度解析

1. 头文件与工具函数

#include<stdio.h>
#include<iostream>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
#include<limits.h>
#include<float.h>
#include<stack>
#include<queue>
#include<vector>
#include<algorithm>
using namespace std;

2. DP 表格打印函数(调试神器)

        用于打印二维 DP 数组,直观观察每一步状态转移:

// 打印二维DP表格,行:物品i,列:背包容量j
void Print2Vec(const std::vector<std::vector<int> >& m)
{
	int row = m.size();
	int col = m[0].size();
	printf("   ");
    // 打印顶部列号(背包容量)
	for (int i = 0; i < col; ++i)
	{
		printf("%5d", i);
	}
	printf("\n");
	for (int i = 0; i < row; ++i)
	{
		printf("%3d", i); // 打印行号(物品编号)
		for (int j = 0; j < col; ++j)
		{
			printf("%5d", m[i][j]);
		}
		printf("\n");
	}
	printf("\n--------------------------------------\n");
}

四、递归实现(自上而下记忆化搜索)

1. 从尾部切(标准递归,推荐)Knapsack2

        从最后一个物品开始,递归向前决策:

// 递归:从尾部切(i从n→1)
int Knapsack2(const int* W, const int* V, int i, int j,
	std::vector<std::vector<int> >& m)
{
    // 递归出口:只有第1个物品
	if (i == 1)
	{
		m[i][j] = j >= W[i] ? V[i] : 0;
	}
    // 记忆化:已经计算过,直接返回
	else if (m[i][j] > 0)
	{
		return m[i][j];
	}
    // 装不下当前物品,只能不选
	else if (j < W[i])
	{
		m[i][j] = Knapsack2(W, V, i - 1, j, m);
	}
    // 装得下,选或不选取最大值
	else
	{
		int t1 = Knapsack2(W, V, i - 1, j, m);       // 不选第i个
		int t2 = Knapsack2(W, V, i - 1, j - W[i], m) + V[i]; // 选第i个
		m[i][j] = (t1 > t2) ? t1 : t2;
	}
	return m[i][j];
}
代码解析
  • 递归出口:处理到第一个物品,能装就赋值价值
  • 记忆化搜索:避免重复计算,提升效率
  • 装不下:直接继承上一个物品的结果
  • 装得下:比较选与不选的价值,取最大

五、非递归实现(自底向上填表,标准 DP)

        这是最常用、最高效的解法,从第一个物品开始,逐步填满 DP 表。

NiceKnapsack2(标准动态规划)

// 非递归:自底向上填表(标准0-1背包实现)
int NiceKnapsack2(const int* W, const int* V, int n, int c,
	std::vector<std::vector<int> >& m)
{
    // 边界初始化
	for (int j = 0; j <= c; ++j) { m[0][j] = 0; } // 0个物品价值为0
	for (int i = 0; i <= n; ++i) { m[i][0] = 0; } // 0容量价值为0

    // 遍历每个物品
	for (int i = 1; i <= n; ++i)
	{
        // 遍历每个背包容量
		for (int j = 1; j <= c; ++j)
		{
            // 装不下:不选当前物品
			if (j < W[i])
			{
				m[i][j] = m[i - 1][j];
			}
            // 装得下:max(不选,选)
			else
			{
				m[i][j] = std::max(m[i - 1][j], m[i - 1][j - W[i]] + V[i]);
			}
		}
	}
    // 答案:n个物品,c容量的最大价值
	return m[n][c];
}
代码解析
  1. 初始化:第 0 行、第 0 列全部为 0(边界条件)
  2. 双层循环:
    • 外层:遍历所有物品
    • 内层:遍历所有背包容量
  3. 状态转移:严格按照公式,装不下则继承,装得下则取最大值
  4. 返回值:表格右下角即为答案

六、最优方案回溯(输出选了哪些物品)

计算出最大价值后,反向推导哪些物品被选中:

// 回溯:输出物品选择结果 X[i]=1表示选,0表示不选
void TraceBack2(const std::vector < std::vector<int> >& m, int* W, int n, int c,
	std::vector<int>& X)
{
    // 从最后一个物品向前回溯
	for (int i = n; i > 0; --i)
	{
        // 价值不同 → 选了第i个物品
		if (m[i][c] != m[i - 1][c])
		{
			X[i] = 1;
			c -= W[i]; // 剩余容量减去当前物品重量
		}
	}
}

解析

  • 对比 m[i][c] 和 m[i-1][c]
  • 不相等:说明选了第 i 个物品
  • 相等:说明没选
  • 选了之后,更新剩余背包容量

七、主函数测试

int main()
{
	const int n = 5;    // 物品数
	const int c = 10;   // 背包容量
    // 下标从1开始,符合教材习惯
	int W[n + 1] = { 0,2,2,6,5,4 };  // 重量
	int V[n + 1] = { 0,6,3,5,4,6 };  // 价值

    // DP表
	std::vector<std::vector<int> > m(n + 1, std::vector<int>(c + 1, 0));
    // 存储选择结果
	std::vector<int> X(n + 1, false);

    // 计算最大价值
	int maxVal = NiceKnapsack2(W, V, n, c, m);
	cout << "最大价值:" << maxVal << endl;

    // 打印DP表格
	Print2Vec(m);

    // 回溯最优路径
	TraceBack2(m, W, n, c, X);

    // 输出选择结果
	for (int i = 1; i <= n; ++i)
	{
		printf("物品{%d} -> 选择:%d \n", i, X[i]);
	}
	printf("\n-----------------\n");
	return 0;
}

八、运行结果

最大价值:15
   0    1    2    3    4    5    6    7    8    9   10
0   0    0    0    0    0    0    0    0    0    0    0
1   0    0    6    6    6    6    6    6    6    6    6
2   0    0    6    6    9    9    9    9    9    9    9
3   0    0    6    6    9    9    9    9    9   11   11
4   0    0    6    6    9    9    9   10   10   11   13
5   0    0    6    6    9    9   12   12   15   15   15

物品1 -> 选择:1
物品2 -> 选择:1
物品3 -> 选择:0
物品4 -> 选择:0
物品5 -> 选择:1

✅ 结果分析:

  • 最大价值:15
  • 选中物品:1、2、5
  • 总重量:2+2+4=8 ≤ 10,总价值:6+3+6=15,最优解!

九、补充:另一种递归与非递归实现

        上面的方式时从尾部切入的方法,还有从头切的递归Knapsack1和非递归NiceKnapsack1,思路完全一致,只是遍历方向相反(从 1→n),理解其中一种即可举一反三。

1. 从头切递归实现:Knapsack1

        该函数从第一个物品开始,递归向后决策,直到最后一个物品返回结果,同样采用记忆化搜索避免重复计算。

// 递归:从头切(i从1→n)
int Knapsack1(const int* W, const int* V, int i, int j, int n,
	std::vector<std::vector<int> >& m)
{
	// 递归出口:已经到最后一个物品
	if (i == n) 
	{
		m[i][j] = j >= W[i] ? V[i] : 0;
	}
	// 记忆化:当前状态已经计算过,直接返回结果
	else if (m[i][j] > 0)
	{
		return m[i][j];
	}
	// 背包容量装不下当前物品,只能不选,递归处理下一个物品
	else if (j < W[i])
	{
		m[i][j] = Knapsack1(W, V, i + 1, j, n, m);
	}
	// 背包容量装得下当前物品,选与不选取最大值
	else // j>=W[i];
	{
		// 不选第i个物品,直接处理i+1
		int t1 = Knapsack1(W, V, i + 1, j, n, m);
		// 选第i个物品,剩余容量j-W[i],加上当前物品价值
		int t2 = Knapsack1(W, V, i + 1, j - W[i], n, m) + V[i];
		if (t1 > t2)
		{
			m[i][j] = t1;
		}
		else
		{
			m[i][j] = t2;
		}
	}
	return m[i][j];
}
代码解析
  1. 递归出口:i == n 代表处理到最后一个物品,能装下就赋值价值,否则为 0;
  2. 记忆化优化:判断 m[i][j]>0,已经计算过的状态直接返回,大幅提升效率;
  3. 装不下当前物品:j < W[i],只能不选,递归处理下一个物品 i+1;
  4. 装得下当前物品:分别计算选和不选的价值,取最大值赋值给 m[i][j]。

2. 从头切最优路径回溯:TraceBack1

        配合 Knapsack1/NiceKnapsack1 使用,反向推导选中的物品,逻辑与 TraceBack2 一致。

// 配合从头切的DP表,回溯最优选择路径
void TraceBack1(const std::vector<std::vector<int> >& m, const int* W, int n, int c,
	std::vector<int>& X)//装了哪个
{
	for (int i = 1; i < n; ++i)
	{
		// 价值不相等,说明选了第i个物品
		if (m[i][c] != m[i + 1][c])
		{
			X[i] = 1;
			c -= W[i];
		}
		else
		{
			X[i] = 0;
		}
	}
	// 处理最后一个物品
	if (m[n][c] != 0)
	{
		X[n] = 1;
	}
}
代码解析
  1. 循环遍历物品 i,对比 m[i][c] 和 m[i+1][c];
  2. 不相等 → 选中第 i 个物品,标记 X[i]=1,并减去物品重量;
  3. 最后单独判断最后一个物品,完成路径回溯。

3. 从头切非递归实现:NiceKnapsack1

        自底向上填表,从最后一个物品往第一个物品逆序填充 DP 表,每一步都打印 DP 表格,方便调试观察。

// 非递归:从头切(逆序填表,从最后一个物品开始)
int NiceKnapsack1(const int* W, const int* V, int n, int c,
	std::vector<std::vector<int> >& m)//非递归
{
	// 初始化最后一行(第n个物品)
	for (int j = 1; j <= c; ++j) 
	{
		m[n][j] = (j >= W[n]) ? V[n] : 0;
	}
	// 打印初始化后的DP表
	Print2Vec(m);

	// 从n-1逆序遍历到1,填充DP表
	for (int i = n - 1; i > 0; --i)
	{
		for (int j = 1; j <= c; ++j)
		{
			// 装不下,继承下一个物品的结果
			if (j < W[i]) 
				m[i][j] = m[i + 1][j];
			// 装得下,max(不选,选)
			else
			{
				m[i][j] = std::max(m[i + 1][j], m[i + 1][j - W[i]] + V[i]);
			}
		}
		// 每填充一行打印一次,观察状态转移
		Print2Vec(m);
	}
	// 最终答案:第一个物品,最大容量
	return m[1][c];
}
代码解析
  1. 初始化:先填充 DP 表的最后一行(最后一个物品);
  2. 逆序循环:i 从 n-1 遍历到 1,自下而上填充表格;
  3. 状态转移:装不下则继承 i+1 的结果,装得下则取最大值;
  4. 打印调试:每填充一行打印一次 DP 表,清晰观察状态变化;
  5. 返回值:m[1][c] 即为最大价值。

十、0-1 背包核心总结

  1. 状态定义:m[i][j] 前 i 个物品、容量 j 的最大价值
  2. 转移方程:
    m[i][j] = max(m[i-1][j], m[i-1][j-W[i]]+V[i]);
    
  3. 实现方式:
    • 递归:自上而下,记忆化搜索
    • 非递归:自底向上填表,效率更高
  4. 路径回溯:对比 DP 表,反向推导选中的物品
  5. 复杂度:时间 O (nc),空间 O (nc)

十一、总结

        本文完整实现了 0-1 背包的递归、非递归、路径回溯三大核心功能,代码规范、注释详细、附带表格打印调试,非常适合 C++ 新手学习动态规划。

0-1 背包是动态规划的基石,掌握本题后,可轻松拓展学习:

  • 完全背包(物品可重复选)
  • 多重背包(物品有数量限制)
  • 分组背包

        建议大家把代码复制运行,结合打印的 DP 表格一步步调试,彻底理解状态转移的过程!

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐