贪心算法1

文章目录

  • 简介
  • [860. 柠檬水找零](#860. 柠檬水找零)
  • [2208. 将数组和减半的最少操作次数](#2208. 将数组和减半的最少操作次数)
  • [179. 最大数](#179. 最大数)
  • [376. 摆动序列](#376. 摆动序列)
  • [300. 最长递增子序列](#300. 最长递增子序列)

简介

  1. 什么是贪心算法?

    贪心 = 鼠目寸光(形象描述 "局部最优" 的特点)。贪心算法的核心逻辑:将解决问题的过程拆分为多个子步骤,每一步都选择当前视角下的最优解,"希望" 通过局部最优推导全局最优。

  2. 贪心算法的特点

  • 贪心策略的提出没有固定模式,不同题目可能需要完全不同的贪心逻辑。
  • 贪心策略的正确性需要严格证明(贪心可能是错误的方法,正确的贪心必须经过验证)。常用证明方法:数学中的各类证明手段(反证法、归纳法等)。

典型例子

  • 例一:找零问题场景:用面值 [20, 10, 5, 1] 凑出 46,求最少硬币数。

证明:找零问题(贪心策略的正确性)

找零场景:用面值 [20, 10, 5, 1] 凑任意金额,贪心策略为 "每次选最大的可行面值"。

证明思路(反证法 + 最优解性质推导):

1.定义最优解与贪心解:

  • 设最优解的硬币组合为 [A, B, C, D](A=20 的数量,B=10 的数量,C=5 的数量,D=1 的数量)。
  • 设贪心解的硬币组合为 [a, b, c, d](贪心策略:每次取最大可行面值,因此 a 是 "当前金额下最多能取的 20 数量",b 是 "剩余金额下最多能取的 10 数量",以此类推)。

2.分析最优解的性质:为了 "硬币数最少",大面值应尽可能多取(否则用小面值凑会导致硬币数增加)。因此:

  • B ≤ 1(若 B ≥ 2,则 2 个 10 可换成 1 个 20,硬币数更少,与 "最优" 矛盾)。
  • C ≤ 1(2 个 5 可换成 1 个 10,硬币数更少)。
  • D ≤ 4(5 个 1 可换成 1 个 5,硬币数更少)

3.对比贪心解与最优解:

  • 对于 20 的数量 a 和 A:
    贪心取 20 元的数量 a 是 "当前最多能取的 20 元个数",所以 a>=A
    再判断,如果a>A ,说明说明BCD能凑出至少1个A。结合前面B最大为1、...全部加起来才19。无法凑出A。所以a只能等于A。

同理可得B=b C=c,那最后的D肯定=d。

最终,贪心解和最优解的硬币组合完全一致,证明贪心策略正确。

860. 柠檬水找零

题目链接

分析:

//贪心策略:找零的时候先找10,再找5。确保手里有足够的零钱找零

//分析:1.根据题意,遍历完bills都能找零成功,返回true,如果存在不成功则返回false。

//2.可以通过一个哈希表去映射手中零钱的张数

//3.一开始没有零钱的时候,如果收取的钱是10、20,直接返回false。

cpp 复制代码
class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        //贪心策略:找零的时候先找10,再找5。确保手里有足够的零钱找零
        //分析:1.根据题意,遍历完bills都能找零成功,返回true,如果存在不成功则返回false。
        //2.可以通过一个哈希表去映射手中零钱的张数。
        //3.一开始没有零钱的时候,如果收取的钱是10、20,直接返回false。

        unordered_map<int,int>hash;

        for(auto e:bills)
        {
            if(e==5)hash[e]++;
            else if(e==10)
            {
                //找钱
                hash[e]++;
                if(hash[5])hash[5]--;
                else return false;
            }
            else{
                //收入20可以不用统计,因为不会找出去,用不到
                if(hash[10]&&hash[5])hash[10]--,hash[5]--;
                else if(!hash[10]&&hash[5]>=3)hash[5]-=3;
                else return false;
            }
        }
        return true;

    }
};

证明:

这道题主要的问题就是对10块、20块的找零问题。

对于10块,无论你怎么找,都是找出去5块,没什么区别。

主要是20块,你可以找出去10+5 或者5+5+5。但是吧,10块只能是针对20时找出,5块可以对10、20找。通用性更强。所以从感觉上很好判断最优解肯定是针对20找10的策略。

定义 "最优解" 与 "贪心解" 的差异

假设存在一个最优解(能成功找零的零钱使用序列),其中处理某张 20 元 时,没有优先使用 10 元找零(即:明明有 10 元,却用了 3 张 5 元找零)。

交换操作与可行性分析

对于上述 "非贪心" 的最优解,我们对「处理该 20 元」的步骤进行交换:

  • 原操作:用 3 张 5 元 找零 → 消耗 five -= 3。
  • 交换后操作:用 1 张 10 元 + 1 张 5 元 找零 → 消耗 ten -= 1, five -= 1。
    交换后,找零的总价值不变(都是 15 元),且:
  • 5 元的剩余量:交换后比交换前 多 2 张(原消耗 3 张,现消耗 1 张)。
  • 10 元的剩余量:交换后比交换前 少 1 张。

交换对后续的影响

由于 5 元的 "通用性更强"(可用于 10 元、20 元的找零),而 10 元 "仅能用于 20 元的找零",因此:

  • 交换后,5 元剩余更多 → 后续遇到 10 元顾客(需要 1 张 5 元找零)时,更不容易因 5 元不足而失败。
  • 10 元仅减少 1 张 → 对后续 20 元顾客的影响很小(若后续有 20 元顾客,仍可优先用剩余 10 元找零)。

结论

通过交换,"非贪心的最优解" 可被转化为 "贪心解",且转化后的解不会降低找零可行性(甚至更优)。因此,贪心策略是正确的 ------ 它能覆盖所有可行的找零场景,且是最优策略的一种。

2208. 将数组和减半的最少操作次数

题目链接


根据题意:将数组和减半的最小操作次数。

贪心策略:很好想,每次把数组中最大的数减半就好了。

---》然后就转换到了找到数组中最大的数。

可以将数组的元素(包括减半后的数)全部放到一个大根堆里,每次取堆顶即可

cpp 复制代码
class Solution {
public:
    int halveArray(vector<int>& nums) {
        priority_queue<double>heap;
        double sum=0;
        for(auto e:nums)
        {
            sum+=e;
            heap.push(e);
        }

        int res=0;
        double count=0;
        while(count<sum/2)
        {
        
           double x= heap.top();
           heap.pop();
           x/=2;
           count+=x;
           heap.push(x);
           res++;
        }
        return res;

    }
};

证明:

和上一题类似,交换论证法。

这种简单题的论证其实和推理的过程差不多。

因为我们的贪心解是每次找最大,然后减半。(单个最大的减半对整体减半的效率是最高的,找不出反证)

179. 最大数

题目链接

题意:排序,把数组排成最大的数

分析:

排序规则:第一位数较大的排前面(第一位数相同看第二位...以此类推)确保高位的数是最大的。---》总体就是最大的

思路:把数据转化成字符串然后按照字典序比较(按 "字符 / 元素顺序逐个比较")

这里的贪心策略就是排序策略。

还有两个特殊情况处理,放代码注释里了

cpp 复制代码
class Solution {
public:
    string largestNumber(vector<int>& nums) {
        int n=nums.size();
        vector<string>ret(n);
        int i=0;
        for(auto e:nums)ret[i++]=to_string(e);

        sort(ret.begin(),ret.end(),[](string& a,string& b){return (a+b)>(b+a);});
        //(a+b)>(b+a)确保拼接后更大,如330>303
        string res;
        //特殊情况处理,如果多个0拼接,结果返回0
        if(ret[0]=="0")return "0";
        for(auto s:ret)res+=s;

        return res;
    }
};

证明

  1. 明确贪心策略
    对于任意两个数字字符串 a 和 b,若 a+b 的字典序 大于 b+a,则 a 应排在 b 前面(即 a 比 b 更 "适合" 靠前)。目标是证明:按此规则排序后,所有字符串拼接的结果是 "最大可能的数"。
  2. 证明核心:比较规则满足 "全序关系"
    排序的前提是比较规则必须是 "全序关系"(能严格定义元素的先后顺序,且无矛盾),需满足 3 个性质:

(1)完全性

对任意两个字符串 a 和 b,a+b 与 b+a 的字典序一定可比较:

  • 要么 a+b > b+a(a 在前),
  • 要么 a+b < b+a(b 在前),
  • 要么 a+b = b+a(顺序不影响结果)。
    因字符串的字典序比较是 "确定的"(逐字符比较,直到分出大小),故完全性成立。

(2)反对称性

若 a+b ≥ b+a 且 b+a ≥ a+b,则 a+b = b+a。此时 a 和 b 谁前谁后,拼接结果完全相同(例如 a="22", b="2",a+b="222" 与 b+a="222" 相等),故 "顺序无关",满足反对称性。

(3)传递性(最关键)

需证明:若 a 应在 b 前(a+b ≥ b+a),且 b 应在 c 前(b+c ≥ c+b),则 a 一定应在 c 前(a+c ≥ c+a)。

转化为数值推导:设 a 的数值为 A(如 "3" 对应 3),长度为 len_a(如 "3" 长度为 1),则 a+b 的数值为 A×10^len_b + B(B 是 b 的数值,len_b 是 b 的长度)。

  • 由 a+b ≥ b+a 得:A×10^len_b + B ≥ B×10^len_a + A,整理为:A×(10^len_b - 1) ≥ B×(10^len_a - 1) (式①)
  • 由 b+c ≥ c+b 得:B×10^len_c + C ≥ C×10^len_b + B,整理为:B×(10^len_c - 1) ≥ C×(10^len_b - 1) (式②)

将式①和式②两边分别相乘(因所有项均为正数,不等号方向不变):A×B×(10^len_b - 1)×(10^len_c - 1) ≥ B×C×(10^len_a - 1)×(10^len_b - 1)

约去两边相同的正数项(B×(10^len_b - 1)),得:A×(10^len_c - 1) ≥ C×(10^len_a - 1)

整理后即为:A×10^len_c + C ≥ C×10^len_a + A,即 a+c ≥ c+a。因此,传递性成立。

376. 摆动序列

题目链接


题目需求是要找到最长的摆动序列。

我们的贪心策略就是:

1.确保选的点的前后是摆动的(先增后减/先减后增)

2.选的点要尽可能靠前(确保后续有更多的点够我们选择)

总结起来就是我们需要选这个摆动线段的波峰/波谷+首尾两个点

这里有几类特殊的情况:

中间有部分的点是没有波动的,可以把这些平的(没有变化的)点去掉,左边就可以看作是递增/递减。右边就能看作是波谷/波峰。

接下来我们需要去判断什么时候是波峰/波谷:

很容易想到,如果是波峰。波谷 那么(右边-当前)*(当前-左边)<0。但是遇到中间是没有波动的特殊情况是无法判断的。如果我们把=0当作特殊情况省略,那特殊情况中的波峰/波谷也会被省略。

所以不能简单通过 点前后的数组差的乘积来判断。可以通过left 记录点左边的递增/递减状态(递增>0递减<0)right记录点 右边的状态。

然后遍历的时候,后续点的right继承前序点的left。如果遇到right=0,说明遇到没有波动的点,直接跳过即可。

一开始选择第一个点时无法确定左边的状态就取0.

cpp 复制代码
class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        int res=0;
        int left=0,right=0;

        //统计波峰/波谷点
        for(int i=0;i<nums.size()-1;i++)
        {
            right=nums[i+1]-nums[i];
            if(right==0)continue;
            if(right*left<=0)res++;
            left=right;//状态继承
        }

        res++;//最后一个点
        return res;

    }
};

证明:

用反证法证明 "贪心统计的峰谷数量就是最长摆动序列的长度":

假设存在一个更长的摆动序列 T,其长度超过贪心策略统计的结果 S。

  • 摆动序列的本质是 "趋势交替":若序列中存在两个相邻的 "上升段",中间必然需要一个 "下降段" 来衔接(反之亦然)。而 "峰"(上升转下降的点)和 "谷"(下降转上升的点)是趋势交替的核心节点。
  • 若 T 比 S 长,说明 T 包含了一些 "非峰 / 谷" 的节点。但这些非极值点无法打破 "趋势交替" 的限制 ------ 比如,两个峰之间的所有点,只有 "谷" 能让趋势从上升转下降;若用中间非谷的点替代谷,要么破坏趋势交替,要么无法延长序列。
  • 因此,任何非峰 / 谷的节点都无法为摆动序列 "贡献新的交替",最长序列的节点只能是峰或谷。

300. 最长递增子序列

题目链接

这题前面的动态规划有,下面贪心的思路是在动态规划的局限性上出发的。

原始 DP 解法中,定义 dp[i] 为以 nums[i] 结尾的最长递增子序列长度,状态转移为:(dp[i] = max(dp[j] + 1) (j < i 且nums[j] < nums[i]))

时间复杂度为 (O(n^2))

贪心优化的核心思想:"让子序列增长尽可能慢"

要得到最长的递增子序列,需让子序列的 "增长节奏尽可能慢"------ 即:对于相同长度的递增子序列,最后一个元素越小,后续元素就越容易满足 "递增" 条件,从而更可能延长子序列。

举个例子:长度为 3 的递增子序列,若末尾是 5,比末尾是 8 更优(后续只要遇到 >5 的元素就能延长,而 >8 的元素范围更小)。

定义辅助数组 tails

维护数组 tails,其中 tails[k] 表示:长度为 (k+1) 的递增子序列的最后一个元素的最小值。

举个例子:假设数组nums = [2, 5, 3, 7],我们来看tails的构建过程:

  • 长度为 1 的递增子序列:[2]、[5]、[3]、[7],它们的最后一个元素是2,5,3,7,最小值是2 → 所以tails[0] = 2(k=0对应长度 1)。
  • 长度为 2 的递增子序列:[2,5]、[2,3]、[2,7]、[5,7]、[3,7],它们的最后一个元素是5,3,7,7,7,最小值是3 → 所以tails[1] = 3(k=1对应长度 2)。
  • 长度为 3 的递增子序列:[2,5,7]、[2,3,7],最后一个元素是7,7,最小值是7 → 所以tails[2] =7(k=2对应长度 3)。
    此时tails = [2,3,7],显然是严格递增的。

tails的长度就是要求的最长递增子序列的长度。

由于tails是递增的,所以我们可以通过二分去优化求出tails的过程。让时间复杂度降低到N*logN

遍历 + 二分的执行逻辑

遍历原数组 nums 中的每个元素 num,用二分查找在 tails 中找到第一个 大于等于 num 的位置 pos,然后:

  • 若 num 比 tails 中所有元素都大:说明 num 能延长当前最长的递增子序列,将 num 追加到 tails 末尾。
  • 否则:用 num 替换 tails[pos](让长度为 (pos+1) 的递增子序列的末尾元素更小,为后续元素留出更多 "延长空间")。

最后返回tails的长度

cpp 复制代码
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n=nums.size();
        vector<int>ret;
        ret.push_back(nums[0]);

        for(int i=1;i<n;i++)
        {
            if(nums[i]>ret.back())ret.push_back(nums[i]);
            else
            {
                //二分
                int left=0,right=ret.size()-1;
                while(left<right)
                {
                    int mid=(left+right)>>1;
                    if(ret[mid]<nums[i])left=mid+1;
                    else right=mid;
                }
                ret[left]=nums[i];
            }
        }
        return ret.size();
    }
};
相关推荐
小猪咪piggy3 小时前
【算法】day8 二分查找+前缀和
算法
Word码4 小时前
[排序算法]希尔排序
c语言·数据结构·算法·排序算法
前端小刘哥4 小时前
解析视频直播点播平台EasyDSS在视频点播领域的技术架构与性能优势
算法
QT 小鲜肉4 小时前
【数据结构与算法基础】05. 栈详解(C++ 实战)
开发语言·数据结构·c++·笔记·学习·算法·学习方法
lingran__4 小时前
算法沉淀第七天(AtCoder Beginner Contest 428 和 小训练赛)
c++·算法
前端小刘哥4 小时前
新版视频直播点播平台EasyDSS,打通远程教研与教师培训新通路
算法
2401_840105204 小时前
P1049 装箱问题 题解(四种方法)附DP和DFS的对比
c++·算法·深度优先·动态规划
kobe_t4 小时前
数据安全系列7:常用的非对称算法浅析
算法
靠近彗星4 小时前
3.4特殊矩阵的压缩存储
数据结构·人工智能·算法