5道经典贪心算法题详解:从入门到进阶

5道经典贪心算法题详解:从入门到进阶

贪心算法是算法面试中最常考的思想之一,核心逻辑是在每一步都做出当前最优的选择,从而期望最终得到全局最优解。不同于动态规划需要回溯所有可能性,贪心只关注眼前的最优,因此效率极高,但需要严格证明其正确性才能使用。

本文精选了LeetCode中5道经典的贪心算法题,从简单到中等,覆盖不同应用场景,每道题都包含题目解析、贪心思路推导、完整C++代码、复杂度分析,帮你彻底吃透贪心思想。


文章目录

一、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。

贪心思路分析

这道题是贪心算法的入门级题目,核心逻辑非常直观:

  1. 顾客只会支付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元」找零
  2. 贪心的核心:优先保留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。

贪心思路分析

要让操作次数最少,核心逻辑是每次操作都让数组和减少最多,也就是每次都选择当前数组中最大的数进行减半:

  1. 首先计算数组的初始总和 sum,目标是让总和减少至少 sum / 2
  2. 用**大顶堆(优先队列)**维护数组中的所有数,每次取出堆顶的最大值,将其减半,然后把减半后的值放回堆中。
  3. 每操作一次,记录减少的数值,直到减少的总和 >= sum / 2,返回操作次数。
  4. 贪心正确性证明:每次选择最大的数减半,能在单次操作中获得最大的和减少量,因此用最少的操作次数达到目标,是全局最优解。

完整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"

贪心思路分析

这道题的核心是自定义排序规则 ,贪心的逻辑是:

对于两个数 ab,我们需要比较 a + bb + a 哪个更大,从而决定 ab 的顺序:

  • 如果 a + b > b + a,则 a 应该排在 b 前面
  • 如果 a + b < b + a,则 b 应该排在 a 前面
  • 相等则顺序无关

例如:

  • 比较 330330 > 303,因此 3 排在 30 前面
  • 比较 9595 > 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

贪心思路分析

这道题的贪心思路非常巧妙,核心是只保留「波峰」和「波谷」,删除中间的单调元素

  1. 我们需要维护两个状态:
    • up:以当前元素为结尾的最长上升摆动子序列长度(最后一步是上升)
    • down:以当前元素为结尾的最长下降摆动子序列长度(最后一步是下降)
  2. 遍历数组,对于每个元素 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]:两个状态都不变
  3. 最终 max(up, down) 就是最长摆动子序列的长度。
  4. 贪心正确性:每次遇到上升/下降时,只更新对应状态,相当于只保留波峰和波谷,删除中间的单调元素,从而得到最长的摆动序列。

完整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)),这里介绍贪心优化的解法:

  1. 维护一个数组 tails,其中 tails[i] 表示长度为 i+1 的最长递增子序列的最小末尾元素
  2. 遍历数组 nums,对于每个元素 num
    • 如果 num 大于 tails 的最后一个元素,说明可以直接接在后面,tails.push_back(num)
    • 否则,在 tails 中找到第一个大于等于 num 的元素,用 num 替换它(贪心:用更小的末尾元素,为后续元素留出更多空间,从而得到更长的子序列)
  3. 最终 tails 的长度就是最长递增子序列的长度。
  4. 贪心正确性: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)
最长递增子序列 维护最小末尾元素,为后续元素留空间 贪心+二分查找优化

贪心算法的适用场景

贪心算法不是万能的,只有满足以下两个条件时才能使用:

  1. 最优子结构:全局最优解可以由局部最优解推导而来
  2. 无后效性:当前的选择不会影响未来的选择
相关推荐
枫叶林FYL2 小时前
【自然语言处理 NLP】8.3 长文本推理评估与针在大海堆任务
人工智能·算法
智者知已应修善业2 小时前
【51单片机1,左边4个LED灯先闪烁2次后,右边4个LED灯再闪烁2次:2,接着所用灯一起闪烁3次,接着重复步骤1,如此循环。】2023-5-19
c++·经验分享·笔记·算法·51单片机
米啦啦.2 小时前
红黑树,,
数据结构·红黑树
xyq20242 小时前
Java 变量命名规则
开发语言
天启HTTP2 小时前
HTTP代理和隧道代理的底层区别与适用场景分析
开发语言·网络协议·tcp/ip·php
小白学大数据2 小时前
告别复杂 XPath:DeepSeek+Python 爬虫快速实践
开发语言·爬虫·python·selenium
xiaoye-duck2 小时前
《算法题讲解指南:优选算法-队列+宽搜》--70.N叉树的层序遍历,71.二叉树的锯齿形层序遍历,72.二叉树的最大宽度,73.在每个树行中找最大值
数据结构·c++·算法·队列
代码改善世界2 小时前
【C++初阶】双向循环链表:List底层结构的完整实现剖析
c++·链表·list
汀、人工智能2 小时前
[特殊字符] 第98课:数据流中位数
数据结构·算法·数据库架构··数据流·数据流中位数