一文带你掌握贪心算法

贪心算法是比较难的一个算法,他的难点在于每题的贪心策略都是不一样的,在学习其他算法(如二分查找,动态规划,递归回溯时)我们只要有了某一个类型的例子,就可以举一反三写出很多题目,但是贪心算法不是这样的,虽然你写了20道题,但是可能会发现第21道题用前面20道题的方法都做不出来,在学习贪心算法时,需要多积累,遇到一道题写不出来是很正常的,可以多学习别人的经验,比如说田忌赛马其实就是贪心的策略,但是并不是人人都能想到的,在遇到一个不会的策略,我们就学习这个策略,往后再遇到同样类型的题,就可以利用前面的经验

1.什么是贪心算法

贪心算法也可以称为贪心策略,他是一种解决问题的策略,这个策略是:每次都去找局部最优解,最终得到全局最优解。我们可以把这句话拆分成三步:

  • 1.把解决问题的过程分为若干步。
  • 2.解决每一步时,都选择当前看起来“最优的”解法(贪心)
  • 3.希望得到全局最优解

这里的希望指的是:通过贪心策略(每次选择当前最优解)得到的结果并不一定是正确的,即局部最优不能代表全局最优,有可能是你的贪心策略不对,也有可能是这题无法使用贪心算法。

2.贪心算法的特点

1.贪心策略的提出

  • (1).贪心策略的提出是没有标准以及模板的(这也是贪心最难的地方)
  • (2).可能每一道题的贪心策略都是不一样的。

2.贪心策略的正确性

因为你的贪心策略可能是一个错误的策略,所以我们应该要知道一个贪心策略为什么是正确的,我们需要去证明(通常是数学中的那些证明方法)。

3. 例题

在讲解例题过程中,我会直接告诉你贪心策略是什么,此时不要去纠结为什么贪心策略是这个,前面也说过了,贪心学的主要是经验。

3.1 柠檬水找零

题目链接:860. 柠檬水找零 - 力扣(LeetCode)

贪心策略:

每一杯柠檬水的价格为5美元,如果用户给5美元,直接收下;如果用户给10美元,那么就要找5美元;如果用户给20美元,那么我们可以给一张10美元和一张5美元,还可以一次给三张5美元。

给20美元的情况有两种,那么这里就要进行贪心了(10+5策略比较好,还是3*5比较好)。在上面分析中可以发现5美元的作用是最大的。

那么在遇到需要找零的情况下,我们应该尽可能多的保留5美元,以便后续进行找零。

 题解:

class Solution 
{
public:
    bool lemonadeChange(vector<int>& bills) 
    {
        int five = 0, ten = 0;
        //20元不需要找出去,所以不用统计
        for (auto money : bills)
        {
            if (money == 5)
                ++five;
            else if (money == 10)
            {
                if (five == 0)
                    return false;
                else 
                    --five, ++ ten;
            }
            else 
            {
                if (ten >= 1 && five >= 1) //贪心
                    --ten, --five;
                else if (five >= 3)
                    five -= 3;
                else 
                    return false;
            }
        }       
        return true;
    }
};

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

题目链接:2208. 将数组和减半的最少操作次数 - 力扣(LeetCode)

贪心策略:

要使数组和减半最快,最好的方法就是每次选择数组中值最大的那个数进行减半,直到数组和减少到一半。

要每次都找到最大的那个数,可以使用一个优先级队列(大根堆)。

题解:

class Solution 
{
public:
    int halveArray(vector<int>& nums) 
    {
        priority_queue<double> heap;
        //计算nums的数组和
        double sum = 0; 
        for (auto e : nums)
        {
            heap.push(e);
            sum += e;
        }
        sum /= 2;
        int step = 0; //操作次数

        while (sum > 0)
        {
            double top = heap.top();
            heap.pop();
            heap.push(top / 2);
            sum -= top / 2;
            ++step;
        }
        return step;
    }
};

3.3 最大数

题目链接:179. 最大数 - 力扣(LeetCode)

贪心策略:

这题有点像排序题,排完序后要求所有数组合的是一个最大数。排序的本质都是确定元素的先后顺序,如排升序那就是小的在前大的在后,排降序就是大的在前小的在后。

这里的排序也是一个道理,我们需要找到一种策略,使排完序后整个数组组成的数最大。

假如有两个数[10, 2],那么这两个数只有两种拼接方法:102或者210,而210>102,我们就知道了应该把2放在10的前面;同样如果有两个数a和b,也只有两种拼接方法:ab或者ba,如果ab>ba,那么a就应该放在前面,如果ba>ab,那就把b放在前面。

所以这里的贪心策略就是:有两个数a和b,选择ab或者ba中较大的进行排序

题解:

  • 1.因为库中已经实现了排序的函数,我们只需要修改排序中的比较方法。
  • 2.将a和b进行拼接时,可以转化成字符串再拼接,用字符串的字典序比较即可,所以我们可以一开始就把整个数组都转化成字符串类型。
  • 3.排完序的结果,可能会存在很多前导0,此时只需要返回一个0,所以我们可以判断第一个数是否为0,如果第一个数为0,那么后面全都是0,此时就只返回一个0
class Solution 
{
public:
    string largestNumber(vector<int>& nums) 
    {
        vector<string> strs;
        for (auto e : nums)
            strs.push_back(to_string(e));

        sort(strs.begin(), strs.end(), [](const string& str1, const string& str2){
            return str1 + str2 > str2 + str1;
        });

        string ret;
        for (auto e : strs)
            ret += e;

        if (ret[0] == '0')
            return "0";
        else 
            return ret;        
    }
};

3.4 摆动序列

题目链接:376. 摆动序列 - 力扣(LeetCode)

贪心策略:

为了方便观察,我们可以把数组中所有的点放到一个折线图中,如下图所示。

从宏观角度上(不看中间点,只看整体趋势)来看,上图就是一个摆动序列,那么现在我们要找到最长的摆动序列,所以我们要尽可能多的从上图中挑选一些点出来。

因为要尽可能多,所以第一个点我们直接选(绿色代表选中这个点)

在选择完第一个点之后,第二个点的选择有以下三种情况:1.在第一个点和极值点之间的点 2.极值点 3.极值点后面的点。

我们先来考虑一下,选上面哪种情况是最优的。

  • 1.选择情况1中的点没有选情况2的点好,假设按上图来看,现在是上升的趋势,那么下一次就要下降了,并且情况1中的点的值是小于情况2的点的,也就是说情况1中可选择下降的点一定是少于情况2的。
  • 2.选择情况3中的点,一定会在中间漏掉某些拐点,所以这种情况是不如情况2的。

综上,当我们确定第一个点时,第二个点最优的情况就是极值点,那么在确定第二个点去找第三个点时也是同理,所以,我们最终的贪心策略就是:每次都选择极值点(包括极大值和极小值)

题解:

在写题目之前,我们需要思考下面的问题。

如何得出一个点是极值点?

如果当前数为nums[i],那么我们可以计算nums[i] - nums[i-1] 和 nums[i+1] - nums[i],如果前者为正后者为负,则为极大值点,如果前者为负后者为正,则为极小值。

但是可能会存在水平的情况,一共有下面四种情况。

左边两种是不存在极大值和极小值的,因为整体趋势就是一直上升或者下降。右边两种情况存在极大值和极小值。

右边两种情况是需要考虑水平点,会存在与左右两边的差值为0的情况,需要特殊判断,所以我们可以将中间的点全部删除,只考虑左右两边的点,如下图所示。

如何编写代码

使用一个全局变量left,left用于指明当前点左边的变化趋势,当left > 0,左边单调递增;left < 0,左边单调递减;left = 0,表示左边既可以递增也可以递减(数组中第一个值左边既可以递增也可以递减)

此时我们只需要计算当前点和右边点的差值即可 right = nums[i+1] - nums[i]。

如果left * right <= 0,说明左右变化趋势不一样,即当前点的极值,如果遇到right = 0的情况,也就是右边点的值和当前点的值相同,那么我们直接跳过即可。

class Solution 
{
public:
    int wiggleMaxLength(vector<int>& nums) 
    {
        //贪心策略:每次选择极值点作为摆动序列
        int left = 0; //左边点的变化趋势
        int ret = 0; //最终结果

        for (int i = 0; i < nums.size() - 1; i++)
        {
            //计算与右边点的差值
            int right = nums[i+1] - nums[i];
            if (right == 0)
                continue;
            if (left * right <= 0)
                ++ret;
            left = right;  //更新left的值
        }        
        ++ret;   //最后一个结点可直接加入摆动序列中
        return ret;
    }
};

3.5 最长递增子序列

题目链接:300. 最长递增子序列 - 力扣(LeetCode)

在学习这道题之前,建议先去学习一下动态规划解决这道题的思路,因为贪心的策略是基于动态规划的,笔者在前面也写过相关的文章,建议大家先去学习一下动态规划的解法:一文带你掌握动态规划(二)-CSDN博客

除了动态规划,这道题贪心策略也有二分的思想,所以不熟悉二分的同学,也可以先去学习一下二分。一文带你彻底掌握二分查找-CSDN博客

算法原理:

先来回顾一下动态规划的状态表示,dp[i]表示:以 i 位置的元素为结尾的所有子序列中,最长递增子序列的长度。

而状态表示为dp[i] = max(dp[j] + 1) (j < i && nums[j] < nums[i])

也就是说动态规划的求解思路就是用nums[i]往前找,找到一个 j 位置,使得nums[i]能够拼接到nums[j]后面即可。也就是说我们在考虑最长递增子序列的长度时,并不关心这个序列长什么样子,只关心最后一个元素是谁。

贪心优化:

假设有以下序列,现在要求这个序列的最长递增子序列

在遍历第一个数的时候,我们让7单独形成一个长度为1的序列,上面说过我们只需要关心最后一个元素是什么即可,所以我们只保存7。

在遍历到3的时候,我们发现3是接不到7的后面的,所以我们就让3单独形成一个长度为1的序列,所以现在长度为1的序列中就有7和3,贪心的策略就在这里体现了:长度较大的那个值就不用存了,因为能拼接在7的后面也一定能拼接到3的后面,所以在长度为1的序列中我们只需要保存3就可以了。

接下来我们就继续向后扫描,此时扫描到8,那么就有两种策略:1.单独形成一个长度为1的序列 2.拼接到3的后面,因为我们要找的是最长递增子序列,所以第2种情况一定是优于第一种的。

接下来就要扫描4了,4是可以拼接在3后面,但是不能拼接到8后面,所以我们可以把8去掉,长度为2的只保存4。

接下来的是7,7可以拼接到3后面,也可以拼接掉4后面,所以就形成了长度为3的序列。

当扫描到2的时,2无法拼接到3的后面,但因为需要保存最小的元素,所以我们把3干掉,保存2。

14可以放在2后面,也可以放到4后面,也可以放到7后面,所以就能形成一个长度为4的序列

最后一个16也是同理,所以整个数组扫描完毕之后,就能发现一个长度为5的序列,所以最长递增子序列的长度就是5,最长的就是[3,4,7,14,16]

总结:

  • 1.存什么:保存所有长度为x的递增子序列中,最后一个元素的最小值。
  • 2.存哪里:所有大于等于nums[i]的最小值的位置。

但是要找到大于等于nums[i]的最小值时,需要遍历整个数组,那么就要O(N)的时间,此时可以使用二分查找进行优化,在上图分析中,我们可以发现整个数组是单调递增的,所以是满足二分条件的。

我们要查找大于等于nums[i]的最小值的位置,也就是说需要查找的是左端点的位置。

题解:

class Solution 
{
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        //ret.size()表示最长递增子序列的长度
        //ret[i]表示长度为i-1的最长递增子序序列最后一个元素中的最小值
        vector<int> ret;
        ret.push_back(nums[0]);

        for (int i = 1; i < nums.size(); i++)
        {
            //如果比最后一个元素还大,则直接尾插
            if (nums[i] > ret.back())
            {
                ret.push_back(nums[i]);
                continue;
            }
            //一定能在ret数组中所有大于等于nums[i]的最小值的位置。
            //二分查找,找到要修改的位置
            int left = 0;
            int right = ret.size() - 1;
            while (left < right)
            {
         
### Dijkstra算法概述 Dijkstra算法一种用于解决单源最短路径问题的经典贪心算法[^1]。该算法能够找到加权图中从起始顶点到其他所有顶点的最短路径。 #### 算法特点 - 只适用于边权重非负的情况。 - 对于稀疏图效率较高,适合处理大规模数据集。 - 时间复杂度为O((V+E)log V),其中V表示顶点数量,E代表边的数量。 #### 工作机制描述 初始化阶段设置起点的距离为0,其余各点设为无穷大。随后按照如下方式迭代更新: - 从未访问过的节点集合里选取当前距离最小的一个作为考察对象; - 遍历此节点相邻接的所有未被标记过的邻居结点; - 如果经过当前节点到达某邻近节点的新路径长度小于已记录值,则替换旧值并保存前驱信息; 当全部节点均已完成探索或目标终点已被触及之时终止循环操作流程。 ```python import heapq def dijkstra(graph, start): queue = [] distances = {node: float('infinity') for node in graph} predecessors = {node: None for node in graph} distances[start] = 0 heapq.heappush(queue, (0, start)) while queue: current_distance, current_node = heapq.heappop(queue) if current_distance > distances[current_node]: continue for neighbor, weight in graph[current_node].items(): distance = current_distance + weight if distance < distances[neighbor]: distances[neighbor] = distance predecessors[neighbor] = current_node heapq.heappush(queue, (distance, neighbor)) return distances, predecessors ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值