文章目录
前言
背包问题是计算机科学中经典的组合优化问题,广泛应用于资源分配、装载调度等场景。其中,0/1背包问题要求每件物品只能完整选取或不选,目标是使装入容量有限的背包中的物品总价值最大。求解0/1背包的常用方法包括动态规划、回溯法以及贪心法。本文聚焦于贪心法------一种基于"性价比"排序的启发式策略,详细分析其算法步骤、代码实现、复杂度以及局限性。通过具体测试用例和反例,阐明贪心法为何无法保证0/1背包的全局最优解,并与动态规划进行多维度对比,帮助读者理解贪心算法的适用边界与设计思想。
一、问题背景及测试用例
(一)问题描述
你有一个容量为 C 的背包,还有 n 件物品。每件物品 i 有重量 w i w_i wi 和价值 v i v_i vi
0/1背包:每件物品只能整个拿走(1)或不拿(0),不能分割。
问:如何选择物品,使得背包内总价值最大?
(二)测试用例
| 物品编号 | 重量 w i w_i wi | 价值 v i v_i vi | 价值/重量比(性价比) |
|---|---|---|---|
| 1 | 2 | 6 | 3.00 |
| 2 | 2 | 3 | 1.50 |
| 3 | 6 | 5 | 0.833 |
| 4 | 5 | 4 | 0.80 |
| 5 | 4 | 6 | 1.50 |
- 背包容量:C=10
- 常规用例:上面5个物品
- 边界用例1:背包容量为0 → 什么也装不了,总价值0
- 边界用例2:只有一个物品,重量≤容量 → 直接拿或不拿
二、算法分析
(一)贪心法
1.概念
贪心法是一种在每一步选择中都采取当前状态下最好/最优的选择,从而希望导致全局最优的算法策略 。它不从整体上考虑,只做局部最优决策,并且一旦做出选择就不再回溯。
2.基本步骤
- 1.建立数学模型:把问题转化为具有最优子结构的优化问题。
- 2.分解为子问题:将原问题分解为一系列的单步决策。
- 3.贪心选择:对每个子问题做出当前看起来最好的选择。
3.性质
- 贪心选择性质:每一步的局部最优选择最终能导致全局最优解。
- 最优子结构:问题的最优解包含其子问题的最优解。
4.典型例子
分数背包问题(可以分割)
活动安排问题(每次选结束时间最早的)
哈夫曼编码(每次选频率最小的两个合并)
找零钱问题(部分货币体系下用贪心可得最优)
注意:0/1背包问题不满足贪心选择性质,所以贪心法只能得到近似解,这也是我们接下来要重点分析的。
(二)核心思想
每次优先选择"单位重量价值最高"的物品,如果放得下就放,否则跳过。
(三)贪心选择性质
-
局部最优选法能推导出全局最优。
在分数背包中,这个性质成立:先装性价比最高的,可以装到满,一定最优。
-
但在0/1背包中,由于物品不可分割,可能为了腾空间装一个"性价比略低但更轻"的物品组合,反而得到更高总价值。
(四)计算公式(排序依据)
1.公式
对每个物品 i i i 定义性价比(单位重量价值):
ratio i = v i w i \text{ratio}_i = \frac{v_i}{w_i} ratioi=wivi
2.贪心策略
- 将所有物品按 r a t i o i ratio_i ratioi 从大到小排序。
- 初始化总重量 c u r W = 0 curW = 0 curW=0,总价值 $curV = 0。
- 按排序后的顺序依次考察每个物品:
- 如果 c u r W + w i ≤ C curW + w_i ≤ C curW+wi≤C,就放入( c u r W + = w i curW += w_i curW+=wi, c u r V + = v i curV += v_i curV+=vi)
- 否则跳过该物品,继续看下一个
对比分数背包的贪心:在0/1背包中,跳过的物品就彻底放弃;而在分数背包中,如果放不下整个物品,可以装入一部分,直到背包满。
(五)为什么贪心法对0/1背包可能出错?
用一个极简例子说明:
-
背包容量 C=4
-
物品A:重量3,价值5(性价比 ≈1.67)
-
物品B:重量2,价值3(性价比 1.50)
-
物品C:重量2,价值3(性价比 1.50)
-
贪心法会先选A(性价比最高),然后剩余容量1,放不下B或C → 总价值=5。但最优解是选B+C,总重量4,总价值=6。
-
原因:贪心选了"占地方的大块头",浪费了空间。
三、代码实现
(一)实现步骤
1.输入处理
读取物品个数、容量、每个物品的重量和价值。
2.构造结构体:
每个物品包含重量、价值、性价比和原始编号(若需要输出哪些物品)。
java
// 物品结构体
struct Item{
int w, v, id; // 重量,价值,编号(1-based)
double ratio; // 性价比 = v/w
};
3.排序
按性价比降序排序(稳定排序无所谓)。
java
sort(items.begin(), items.end(), [](Item &a, Item &b){
return a.ratio > b.ratio;
4.贪心选择
遍历排序后的物品,能放就放。
java
//2. 贪心算法
int curW = 0, totalV = 0; // curW: 当前已装重量, totalV: 当前总价值
vector<int> selected; // 记录选中的物品编号
for (int i = 0; i < items.size(); i++) {
if (curW + items[i].w <= capacity) {
curW += items[i].w;
totalV += items[i].v;
selected.push_back(items[i].id);
}
}
5.输出结果
总价值、总重量、选中了哪些物品。
⚠️ 容易写错的地方:
1.排序函数 :比较两个物品的性价比,注意浮点比较误差,可以用交叉乘法避免浮点。
2.边界 :背包容量0时直接返回0。
3.记录选中的物品:可以用布尔数组或vector存储下标
(二)具体代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 物品结构体
struct Item{
int w, v, id; // 重量,价值,编号(1-based)
double ratio; // 性价比 = v/w
};
//贪心法求解0/1背包问题
int greedyKnapsack(vector<Item> &items, int capacity){
// 1.按性价比排序
//1.1 计算每个物品性价比
for (int i = 0; i < items.size(); i++) {
items[i].ratio = (double)items[i].v / items[i].w;
}
// for (auto &it : items) {
// it.ratio = (double)it.v / it.w;
// }
//1.2 按性价比排序
sort(items.begin(), items.end(), [](Item &a, Item &b){
return a.ratio > b.ratio;
});
//2. 贪心算法
int curW = 0, totalV = 0; // curW: 当前已装重量, totalV: 当前总价值
vector<int> selected; // 记录选中的物品编号
for (int i = 0; i < items.size(); i++) {
if (curW + items[i].w <= capacity) {
curW += items[i].w;
totalV += items[i].v;
selected.push_back(items[i].id);
}
}
//3.输出信息
cout << "选中物品编号: ";
for (int id : selected) cout << id << " ";
cout << "\n总重量: " << curW << " / " << capacity << endl;
return totalV;
}
int main(){
int capacity = 10;
vector<Item> items= {
{2, 6, 1,0.0},
{2, 3, 2,0.0},
{6, 5, 3,0.0},
{5, 4, 4,0.0},
{4, 6, 5,0.0},
};
int result = greedyKnapsack(items, capacity);
cout << "总价值: " << result << endl;
return 0;
}
四、复杂度分析
(一)时间复杂度
设物品数量为 n n n。
- 排序 : O ( n log n ) O(n \log n) O(nlogn)(使用快速排序或归并排序)
- 遍历物品 : O ( n ) O(n) O(n)(一次循环判断是否能放)
因此总时间复杂度为:
T ( n ) = O ( n log n ) + O ( n ) = O ( n log n ) T(n) = O(n \log n) + O(n) = O(n \log n) T(n)=O(nlogn)+O(n)=O(nlogn)
不同情况分析
- 最好情况 :已经按性价比排好序(实际上还需扫描 O ( n ) O(n) O(n))
⇒ O ( n + n ) = O ( n ) \Rightarrow O(n + n) = O(n) ⇒O(n+n)=O(n),但通常我们还是说 O ( n log n ) O(n \log n) O(nlogn)。 - 最坏情况 :排序花费 O ( n log n ) O(n \log n) O(nlogn),没有额外开销。
- 平均情况 : O ( n log n ) O(n \log n) O(nlogn)。
对于背包问题来说,贪心法比动态规划的 O ( n ⋅ C ) O(n \cdot C) O(n⋅C)( C C C 为容量)要快得多,特别是当 C C C 很大时。
(二)空间复杂度
- 存储物品数组 : O ( n ) O(n) O(n)(每个物品存储重量、价值、性价比、索引)
- 排序过程 : O ( log n ) O(\log n) O(logn) 递归栈空间(如果使用快排)
- 辅助变量:常数
总空间复杂度:
S ( n ) = O ( n ) S(n) = O(n) S(n)=O(n)
如果直接在原数组上排序,且不复制物品,也是 O ( n ) O(n) O(n)。
五、适用问题类型
(一)适合用贪心法求解的情况
- 分数背包问题(物品可分割)→ 贪心法可得全局最优解
- 0/1背包的近似解:当要求快速得到一个"不错"的解,且不要求绝对最优时(例如实时系统、近似计算)
- 某些特殊约束的0/1背包:如果所有物品的性价比都差不多,或者容量非常大,贪心误差较小
(二)不适合(或需要谨慎)的情况
- 要求绝对最优解的0/1背包 → 请用动态规划或回溯法
- 物品重量差异巨大,且容量较小时 → 贪心法很容易掉进局部陷阱
- 物品数量很小但需要精确解 → 枚举或DP更可靠
六、贪心法与动态规划法的对比
(一)核心总结
1.核心思想
按"性价比"从高到低尝试放入,能放就放。
2.核心公式:
ratio i = v i w i \text{ratio}_i = \frac{ v_i} {w_i} ratioi=wivi
3.优点:
- 实现简单
- 运行速度快( O ( n log n ) O(n \log n) O(nlogn))
- 容易理解
4.缺点:
- 对于0/1背包问题,不能保证全局最优解
5.在算法版图中的位置:
是贪心算法思想的入门案例,也是与动态规划对比的经典反面教材(指"直接套用"会导致错误)。
(二)局限性
-
背包可能无法被填满,造成空间浪费
贪心法跳过放不下的大物品,但最优解可能刚好需要它来填充剩余空间。
-
没有回溯机制,决策一旦锁定就不可逆
贪心法一旦选了某物品就不会撤销,而最优解可能需要放弃一个"占地方"的高性价比物品,换取一组整体价值更高的组合。
(三)详细多维度对比
| 对比维度 | 贪心法 (Greedy) | 动态规划 (Dynamic Programming) |
|---|---|---|
| 问题适用性 | 适用于满足贪心选择性质的问题(如分数背包、活动选择、哈夫曼编码) | 适用于有重叠子问题和最优子结构的问题 |
| 0/1背包能否最优 | ❌ 不能保证最优解,仅得到近似解 | ✅ 能保证最优解 |
| 核心思想 | 每一步选当前"最好"的,从不回头 | 记录所有可能的子问题最优解,择优组合 |
| 求解方向 | 自顶向下:从原问题逐步推进 | 自底向上(典型):从小子问题开始,逐步构建大问题的解 |
| 状态记录 | 只记录当前背包状态,不需要历史信息 | 需要 dp 数组,记录每种容量下的最优价值 |
| 时间复杂度 | O(n log n)(主要开销在排序) | O(n × C),C为背包容量 |
| 空间复杂度 | O(n) | 优化后 O©,未优化 O(n × C) |
| 决策依赖 | 只依赖当前状态,不依赖子问题的解 | 每一步选择都依赖于子问题的解 |
| 核心"公式" | r a t i o i = v i / w i ratio_i = v_i / w_i ratioi=vi/wi(排序依据) | d p i j = m a x ( d p i − 1 j , d p i − 1 j − w i + v i ) dpij = max(dpi-1j, dpi-1j-w_i + v_i) dpij=max(dpi−1j,dpi−1j−wi+vi) |
| 是否需要考虑所有组合 | 否 | 是 |
| 是否有回溯机制 | 无 | 有(通过状态转移隐式回溯) |
| 什么情况"崩" | 物品不可分割且数量少时崩(如上述反例) | 容量 C 极大时,n×C 过大,可能内存溢出或时间爆炸 |
七、总结
本文围绕贪心法求解0/1背包问题展开,核心要点如下:
-
贪心策略 :按物品的单位重量价值(性价比 v i / w i v_i / w_i vi/wi)从高到低排序,依次尝试放入,能放则放。
-
时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),主要开销来自排序,远优于动态规划的 O ( n ⋅ C ) O(n \cdot C) O(n⋅C)(当容量 C C C 较大时优势明显)。
-
空间复杂度 : O ( n ) O(n) O(n),只需存储物品数组及少量辅助变量。
-
主要局限性:
- 可能因选择"大块头"高性价比物品而浪费剩余空间,错过更优的轻量组合。
- 决策不可回溯,一旦选定就无法撤销,无法保证全局最优。
-
适用场景:适用于分数背包(可分割)或需要快速近似解的0/1背包;不适用于要求精确最优解的0/1背包。
-
与动态规划的对比 :动态规划通过状态转移保证最优解,但时间和空间依赖容量 C C C;贪心法效率高但可能不优,是理解"贪心选择性质"的经典反面案例。
通过本文的分析与代码示例,读者可以清晰掌握贪心法在0/1背包问题中的实现细节、性能特点及其与最优算法的差距,从而在实际问题中合理选择算法策略。