5道经典贪心算法题详解:从入门到进阶
贪心算法是算法面试中最常考的思想之一,核心逻辑是在每一步都做出当前最优的选择,从而期望最终得到全局最优解。不同于动态规划需要回溯所有可能性,贪心只关注眼前的最优,因此效率极高,但需要严格证明其正确性才能使用。
本文精选了LeetCode中5道经典的贪心算法题,从简单到中等,覆盖不同应用场景,每道题都包含题目解析、贪心思路推导、完整C++代码、复杂度分析,帮你彻底吃透贪心思想。
文章目录
- 5道经典贪心算法题详解:从入门到进阶
-
- [一、860. 柠檬水找零(简单)](#一、860. 柠檬水找零(简单))
- [二、2208. 将数组和减半的最少操作次数(中等)](#二、2208. 将数组和减半的最少操作次数(中等))
- [三、179. 最大数(中等)](#三、179. 最大数(中等))
- [四、376. 摆动序列(中等)](#四、376. 摆动序列(中等))
- [五、300. 最长递增子序列(中等)](#五、300. 最长递增子序列(中等))
- 贪心算法核心总结
一、860. 柠檬水找零(简单)
题目描述
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
给你一个整数数组 bills,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true,否则返回 false。
示例1:
输入:bills = [5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。
示例2:
输入:bills = [5,5,10,10,20]
输出:false
解释:
前 2 位顾客那里,我们按顺序收取 2 张 5 美元的钞票。
对于接下来的 2 位顾客,我们收取一张 10 美元的钞票,然后返还 5 美元。
对于最后一位顾客,我们无法退回 15 美元,因为我们现在只有两张 10 美元的钞票。
由于不是每位顾客都得到了正确的找零,所以答案是 false。
贪心思路分析
这道题是贪心算法的入门级题目,核心逻辑非常直观:
- 顾客只会支付3种面额:5、10、20,柠檬水售价固定5元,因此找零只有两种情况:
- 支付5元:无需找零,直接收下,5元钞票数量+1
- 支付10元:需要找零5元,优先用1张5元找零(5元是通用找零货币,10元只能用于20元找零),5元数量-1,10元数量+1
- 支付20元:需要找零15元,有两种找零方式:
- 优先用「1张10元 + 1张5元」找零(10元只能用于20元找零,5元更通用,优先消耗10元)
- 若没有10元,再用「3张5元」找零
- 贪心的核心:优先保留5元钞票,因为5元是唯一能同时给10元和20元找零的货币,10元只能给20元找零,因此找零20元时优先用10元,最大化5元的留存。
完整C++代码
cpp
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
// 记录5元和10元的数量(20元不需要记录,因为不会用来找零)
int five = 0, ten = 0;
for (int bill : bills) {
if (bill == 5) {
// 支付5元,无需找零
five++;
} else if (bill == 10) {
// 支付10元,需要找零5元
if (five == 0) return false;
five--;
ten++;
} else {
// 支付20元,需要找零15元
if (ten > 0 && five > 0) {
// 优先用10+5找零
ten--;
five--;
} else if (five >= 3) {
// 没有10元,用3张5元
five -= 3;
} else {
// 无法找零
return false;
}
}
}
return true;
}
};
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),其中 n n n 是
bills数组的长度,只需要遍历一次数组。 - 空间复杂度 : O ( 1 ) O(1) O(1),仅用两个变量记录钞票数量,与输入规模无关。
二、2208. 将数组和减半的最少操作次数(中等)
题目描述
给你一个正整数数组 nums。每一次操作中,你可以从 nums 中选择任意一个 数并将它减小到恰好一半 (注意,在后续操作中你可以对减半过的数继续执行操作)。
请你返回将 nums 数组和至少减少一半 的最少操作数。
示例1:
输入:nums = [5,19,8,1]
输出:3
解释:初始 nums 的和为 5 + 19 + 8 + 1 = 33。
以下是将数组和减少至少一半的一种方法:
选择数字 19 并减小为 9.5。
选择数字 9.5 并减小为 4.75。
选择数字 8 并减小为 4。
最终数组为 [5, 4.75, 4, 1],和为 5 + 4.75 + 4 + 1 = 14.75。
nums 的和减小了 33 - 14.75 = 18.25,减小的部分超过了初始和的一半,18.25 >= 33/2 = 16.5。
我们需要 3 个操作实现题目要求,所以返回 3。
示例2:
输入:nums = [3,8,20]
输出:3
解释:初始 nums 的和为 3 + 8 + 20 = 31。
以下是将数组和减少至少一半的一种方法:
选择数字 20 并减小为 10。
选择数字 10 并减小为 5。
选择数字 3 并减小为 1.5。
最终数组为 [1.5, 8, 5],和为 1.5 + 8 + 5 = 14.5。
nums 的和减小了 31 - 14.5 = 16.5,等于初始和的一半,所以返回 3。
贪心思路分析
要让操作次数最少,核心逻辑是每次操作都让数组和减少最多,也就是每次都选择当前数组中最大的数进行减半:
- 首先计算数组的初始总和
sum,目标是让总和减少至少sum / 2。 - 用**大顶堆(优先队列)**维护数组中的所有数,每次取出堆顶的最大值,将其减半,然后把减半后的值放回堆中。
- 每操作一次,记录减少的数值,直到减少的总和 >=
sum / 2,返回操作次数。 - 贪心正确性证明:每次选择最大的数减半,能在单次操作中获得最大的和减少量,因此用最少的操作次数达到目标,是全局最优解。
完整C++代码
cpp
class Solution {
public:
int halveArray(vector<int>& nums) {
// 计算初始总和
double sum = 0;
// 大顶堆,默认就是大顶堆
priority_queue<double> pq;
for (int num : nums) {
sum += num;
pq.push(num);
}
// 目标:减少至少 sum / 2
double target = sum / 2;
// 已经减少的总和
double reduce = 0;
int res = 0;
while (reduce < target) {
// 取出当前最大值
double max_val = pq.top();
pq.pop();
// 减半
double half = max_val / 2;
// 记录减少的量
reduce += half;
// 把减半后的值放回堆中
pq.push(half);
// 操作次数+1
res++;
}
return res;
}
};
复杂度分析
- 时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),其中 n n n 是数组长度。建堆时间 O ( n ) O(n) O(n),每次堆操作时间 O ( log n ) O(\log n) O(logn),最多操作 O ( n ) O(n) O(n) 次,因此总时间 O ( n log n ) O(n \log n) O(nlogn)。
- 空间复杂度 : O ( n ) O(n) O(n),堆的大小为 n n n。
三、179. 最大数(中等)
题目描述
给定一组非负整数 nums,重新排列每个数的顺序(每个数不可拆分)使之组成一个最大的整数。
注意:输出结果可能非常大,所以你需要返回一个字符串而不是整数。
示例1:
输入:nums = [10,2]
输出:"210"
示例2:
输入:nums = [3,30,34,5,9]
输出:"9534330"
贪心思路分析
这道题的核心是自定义排序规则 ,贪心的逻辑是:
对于两个数 a 和 b,我们需要比较 a + b 和 b + a 哪个更大,从而决定 a 和 b 的顺序:
- 如果
a + b > b + a,则a应该排在b前面 - 如果
a + b < b + a,则b应该排在a前面 - 相等则顺序无关
例如:
- 比较
3和30:330>303,因此3排在30前面 - 比较
9和5:95>59,因此9排在5前面
排序完成后,将所有数拼接成字符串即可。需要注意特殊情况:如果数组全为0,需要返回 "0" 而不是 "000..."。
完整C++代码
cpp
class Solution {
public:
string largestNumber(vector<int>& nums) {
// 将整数转为字符串,方便拼接比较
vector<string> strs;
for (int num : nums) {
strs.push_back(to_string(num));
}
// 自定义排序规则:a+b > b+a 则a排在前面
sort(strs.begin(), strs.end(), [](sslocal://flow/file_open?url=const+string%26+a%2C+const+string%26+b&flow_extra=eyJsaW5rX3R5cGUiOiJjb2RlX2ludGVycHJldGVyIn0=) {
return a + b > b + a;
});
// 拼接结果
string res;
for (string& s : strs) {
res += s;
}
// 处理全0的情况:如果第一个字符是0,说明全是0,返回"0"
if (res[0] == '0') {
return "0";
}
return res;
}
};
复杂度分析
- 时间复杂度 : O ( n log n ⋅ m ) O(n \log n \cdot m) O(nlogn⋅m),其中 n n n 是数组长度, m m m 是数字的平均长度。排序时间 O ( n log n ) O(n \log n) O(nlogn),每次比较需要拼接两个字符串,时间 O ( m ) O(m) O(m)。
- 空间复杂度 : O ( n ⋅ m ) O(n \cdot m) O(n⋅m),存储字符串数组的空间。
四、376. 摆动序列(中等)
题目描述
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
- 例如,
[1, 7, 4, 9, 2, 5]是一个摆动序列,因为差值(6, -3, 5, -7, 3)是正负交替出现的。 - 相反,
[1, 4, 7, 2, 5]和[1, 7, 4, 5, 5]不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。
给你一个整数数组 nums,返回 nums 中作为摆动序列的最长子序列的长度。
示例1:
输入:nums = [1,7,4,9,2,5]
输出:6
解释:整个序列均为摆动序列,各元素之间的差值为 (6, -3, 5, -7, 3)。
示例2:
输入:nums = [1,17,5,10,13,15,10,5,16,8]
输出:7
解释:这个序列包含几个长度为 7 摆动序列。
其中一个是 [1, 17, 10, 13, 10, 16, 8],各元素之间的差值为 (16, -7, 3, -3, 6, -8)。
示例3:
输入:nums = [1,2,3,4,5,6,7,8,9]
输出:2
贪心思路分析
这道题的贪心思路非常巧妙,核心是只保留「波峰」和「波谷」,删除中间的单调元素:
- 我们需要维护两个状态:
up:以当前元素为结尾的最长上升摆动子序列长度(最后一步是上升)down:以当前元素为结尾的最长下降摆动子序列长度(最后一步是下降)
- 遍历数组,对于每个元素
nums[i]:- 如果
nums[i] > nums[i-1]:说明当前是上升,那么最长上升子序列长度 = 前一个下降子序列长度 + 1(up = down + 1),下降子序列长度不变 - 如果
nums[i] < nums[i-1]:说明当前是下降,那么最长下降子序列长度 = 前一个上升子序列长度 + 1(down = up + 1),上升子序列长度不变 - 如果
nums[i] == nums[i-1]:两个状态都不变
- 如果
- 最终
max(up, down)就是最长摆动子序列的长度。 - 贪心正确性:每次遇到上升/下降时,只更新对应状态,相当于只保留波峰和波谷,删除中间的单调元素,从而得到最长的摆动序列。
完整C++代码
cpp
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int n = nums.size();
if (n <= 1) return n;
// up:以当前元素为结尾的最长上升摆动子序列长度
// down:以当前元素为结尾的最长下降摆动子序列长度
int up = 1, down = 1;
for (int i = 1; i < n; i++) {
if (nums[i] > nums[i-1]) {
// 上升,更新up
up = down + 1;
} else if (nums[i] < nums[i-1]) {
// 下降,更新down
down = up + 1;
}
// 相等,不更新
}
return max(up, down);
}
};
复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n),只需要遍历一次数组。
- 空间复杂度 : O ( 1 ) O(1) O(1),仅用两个变量维护状态。
五、300. 最长递增子序列(中等)
题目描述
给你一个整数数组 nums,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。
示例2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
贪心思路分析
最长递增子序列有两种解法:动态规划( O ( n 2 ) O(n^2) O(n2))和贪心+二分查找( O ( n log n ) O(n \log n) O(nlogn)),这里介绍贪心优化的解法:
- 维护一个数组
tails,其中tails[i]表示长度为i+1的最长递增子序列的最小末尾元素。 - 遍历数组
nums,对于每个元素num:- 如果
num大于tails的最后一个元素,说明可以直接接在后面,tails.push_back(num) - 否则,在
tails中找到第一个大于等于num的元素,用num替换它(贪心:用更小的末尾元素,为后续元素留出更多空间,从而得到更长的子序列)
- 如果
- 最终
tails的长度就是最长递增子序列的长度。 - 贪心正确性:
tails数组始终保持严格递增,因此可以用二分查找快速找到替换位置,每次替换都让tails的末尾元素尽可能小,从而最大化后续可扩展的空间,最终得到最长子序列。
完整C++代码
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for (int num : nums) {
// 二分查找第一个大于等于num的位置
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) {
// 可以直接接在后面
tails.push_back(num);
} else {
// 替换该位置的元素
*it = num;
}
}
return tails.size();
}
};
复杂度分析
- 时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),遍历数组 O ( n ) O(n) O(n),每次二分查找 O ( log n ) O(\log n) O(logn)。
- 空间复杂度 : O ( n ) O(n) O(n),
tails数组的长度最多为 n n n。
贪心算法核心总结
| 题目 | 核心贪心思想 | 关键技巧 |
|---|---|---|
| 柠檬水找零 | 优先保留通用货币(5元),最大化找零灵活性 | 分类讨论找零逻辑 |
| 将数组和减半 | 每次选择最大数减半,单次操作获得最大和减少量 | 大顶堆维护最大值 |
| 最大数 | 自定义排序规则,比较a+b和b+a的大小 | 字符串拼接+自定义排序 |
| 摆动序列 | 只保留波峰和波谷,删除中间单调元素 | 双状态维护(up/down) |
| 最长递增子序列 | 维护最小末尾元素,为后续元素留空间 | 贪心+二分查找优化 |
贪心算法的适用场景
贪心算法不是万能的,只有满足以下两个条件时才能使用:
- 最优子结构:全局最优解可以由局部最优解推导而来
- 无后效性:当前的选择不会影响未来的选择