数据结构:贪心算法

数据结构:贪心算法

基础概念

什么是贪心?

贪心就是每一次都选择局部最优,最终达到全局最优

举一个简单的例子,如果你每次从一堆钱中拿走一张钞票,你只能拿10次,那么怎么拿才能拿到最多的钞票?

一堆钱:5张100元,4张50元,10张10元,20张一元;从中选择10张,让总金额最大;

策略:每次选择所有钱中最大金额的纸币;

结果:前五次选择都选择100元面额,下一次选择时,最大金额纸币变成了50元,所以选择10元的。最终我们选择了5张100元,4张50元,1张10元。

这里我们就通过了局部最优(每一次选择钱堆中最大金额的纸币)推出了全局最优(10次拿到最多的钞票)

这里就有一个问题,**局部最优一定能推出全局最优吗?**答案当然是不一定。

还是举一个钱的例子,比如我们有三种金额的纸币,分别为1元、3元、4元,此时我们要找零,要求找零用到的纸币数量最少

选择的贪心策略先尽可能选择纸币最大的找出,因为对于1张3元比3张1元一定好,一张4元比4张3元一定好。如果要找零的金额是6元,依照贪心策略:

  1. 总金额6元,尽可能找最大金额的纸币,所以选择4元纸币,余下金额为2元;
  2. 总金额2元,尽可能找最大金额的纸币,只能选择1元,余下金额为1元;
  3. 总金额1元,选择1元纸币;

结果为4、1、1,一共3张纸币。可是如果选择两张3元纸币其实结果更优。

**所以局部最优推不出全局最优?也不一定。**其实上面的例子中,改一改贪心策略可能结果就会不一样,我们将局部进行分类,分为6种局部,列出让每种局部最优的分配方法:

  1. 如果待分配金额为1,则1张1元;
  2. 如果待分配金额为2,则2张1元;
  3. 如果待分配金额为3,则1张3元;
  4. 如果待分配金额为4,则1张4元;
  5. 如果待分配金额为5,则1张4元,1张1元;
  6. 如果待分配金额为6,则2张3元;

将全局分成多个局部,每个局部按照不同的情况讨论;比如要找零23元,则分成3个6元局部和1个5元局部,每个6元局部最优分配方式为2张,每个5元局部最优分配方式为2张,所以一共需要 3 ∗ 2 + 1 ∗ 2 = 8 3*2+1*2 = 8 32+12=8张;

这么说,难道只要我们改变局部的划分方式,分别讨论不同局部的最优策略,就一定能通过局部最优推出全局最优吗?

再看一种情况:在10个数字中,找到5个数字,使得这5个数字之和与余下的5个数字之和的差的绝对值最小。比如在1、2、3、4、5、6、7、8、9、10中,找到的5个数字是1、3、6、8、9和为27,余下五个数字之和为28,差的绝对值为1,此时最小;

假设这10个数字已经从大到小有序,我们可以想象有两个桶,每一次都从10个数字中选中1个数字加入某一个桶中,最终两个桶中各有5个数字,而且两个桶中各自数字之和的差的绝对值最小。

那么贪心策略就是:加入一个数字到桶中,尽可能让两个桶中数字之和均衡;进一步来说,就是每次加入数字时,新加入的数字都放入数字之和较小的那个桶,如果放入较大的那个桶,两个桶的差距会越来越大,这不是局部最优,局部最优就是放入之后两个桶的差距尽可能小

10个数字:20、9、8、7、6、5、4、3、2、1
桶1:20  6  4  2  1   总和为33
桶2: 9  8  7  5  3   总和为32
差为1,确实全局最优

上述情况确实实现了局部最优推出全局最优;

再换1组测试案例:(开始桶为空,每次选择一个未加入桶的最大数字加入到较小的桶中)

10个数字:35、27、15、15、15、15、5、1、1、1
桶1:35  15  15  1  1   总和为67
桶2:27  15  15  5  1   总和为63
差为4,如果按照35、27、1、1、1一组,余下15、15、15、15、5一组,则差为0,此时才是最优

上述局部最优没有推出全局最优的原因是开始时35和27分在两个桶中确实是局部最优,但是全局最优却必须将35和27都分在一个桶中;即我们一开始的局部最优已经不可能达到全局最优了!

局部最优能否推出全局最优取决于问题的性质。一般来说,当问题满足贪心选择性质和最优子结构性质时,局部最优解就可以推出全局最优解。判断一个问题是否适合采用贪心算法来求解,一般可以从以下几个角度入手:

  1. 贪心选择性质:验证每一步的局部最优解是否能够推出全局最优解。
  2. 最优子结构性质:分解问题,看问题是否可以划分为子问题,且子问题的最优解能够推出原问题的最优解。
  3. 贪心算法的证明:有时候需要通过数学归纳法或反证法来证明贪心算法的正确性。
  4. 反例分析:寻找反例来证明贪心算法行不通。

在实际使用中,我们不可能对于每一个问题都做数学性分析,所以一般判断能不能用贪心都是先模拟判断,看能不能找到明显的反例(即局部最优推出的全局解并不是全局最优);

并且局部最优策略应该根据局部的不同情况而做出不同的策略。比如之前的找零钱,如果将局部最优策略设置为无论什么局部都坚持先尝试4元纸币,再尝试3元纸币,最后尝试1元纸币的策略,最终得到的结果不一定是全局最优;但是当我们将局部细分为6个局部,对于不同的局部采用不同的策略,最终得到的结果又是全局最优了。

所以当问题分为多个子问题时,不同子问题的局部最优策略可以不同


贪心的一般解题步骤

贪心算法一般没有固定的套路,因为不同问题的局部最优策略不同,并且相同问题的不同局部的最优策略也可能不同,但是可以归纳出一个理论化的步骤来。

  1. 将问题分解为若干个子问题;
  2. 对于不同的子问题,设置不同的最优策略;
  3. 根据局部最优策略,求解每一个局部子问题;
  4. 由各个局部解推出全局解;

总结为一句话就是:找到局部最优,然后局部最优推出全局最优;


例题

分发饼干

力扣题目链接

class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());
        sort(s.begin(), s.end());
        
        // 遍历g数组,每找到一个孩子就在s中看有没有合适胃口的饼干;
        int result = 0;
        // for (int i = 0; i < g.size(); i++) {
        //     for (int j = 0; j < s.size(); j++) {
        //         if (s[j] >= g[i]) {
        //             result++;
        //             s[j] = 0;
        //             break;
        //         }
        //     }
        // }
        
        // 大饼干应该优先满足胃口大的孩子,这就是局部最优,所以遍历顺序应该改变一下;
        // for (int i = g.size() - 1; i >= 0; i--) { // 优先找胃口大的孩子;
        //     for (int j = s.size() - 1; j >= 0; j--) { // 优先找大饼干;
        //         if (s[j] >= g[i]) {
        //             result++;
        //             s[j] = 0;
        //             break;
        //         }
        //     }
        // }
        // 还是两个for循环,并不好;
        
        int index = s.size() - 1; // 饼干数组的下标,从大饼干开始分配
        for (int i = g.size() - 1; i >= 0; i--) { // 遍历胃口(从胃口大的开始遍历)
            if (index >= 0 && s[index] >= g[i]) { // 如果饼干够分,则分配
                result++;
                index--; // 用index,使用过的饼干就不用设置为0然后还要被遍历到了;
            }
        }
        // 饼干的遍历没有用for循环,而是用index自减控制,将时间复杂度从O(n*n)变成了O(n*logn)
        return result;
    }
};

注意三次优化的过程:

  • 第一次:直接暴力循环,不使用贪心策略,每遍历一个孩子就看能不能满足;
  • 第二次:使用贪心策略,优先将大饼干分给大胃口的孩子,但是复杂度并没有降低;
  • 第三次:使用贪心策略,优先将大饼干分给大胃口的孩子,并且在分饼干时使用指针标记而不是循环,复杂度降低;

第一次到第二次是暴力循环优化为贪心策略,虽然时间复杂度没有变化,但是实际执行时间一定下降很多;第二次到第三次是使用指针优化(很常见的优化手段,经常使用二分法、双指针等优化暴力循环),虽然实际执行时间可能没有优化多少,但是时间复杂度下降了。

局部:从大胃口的孩子开始遍历,每遍历到一个孩子就是一个局部;

局部最优:给孩子发一个尽可能大的饼干,而不是多个小饼干;(尽量让一个饼干就能满足一个孩子)

全局最优:吃饱的孩子的数量;


摆动序列

力扣题目链接

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if (nums.size() < 1) return nums.size();
        int result = 1; // 记录峰值数,峰值数就是摆动序列长度,默认最右边有一个峰值;
        int curDiff = 0; // 当前一对差值;
        int preDiff = 0; // 上一对差值;
        for (int i = 0; i < nums.size() - 1; i++) {
            // 计算当前一对的差值,看是增加还是减少;
            curDiff = nums[i + 1] - nums[i];
            // 判断峰值是否出现:
            // 比较preDiff和curDiff的正负是否相同;
            if ((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)) {
                result++;
                // 同时要准备更新preDiff的值;在发现峰值才更新,如果没发现峰值不更新;
                preDiff = curDiff;
            }
        }
        return result;
    }
};

(注意平坡的处理策略,即特殊局部处理策略要考虑进去)

局部最优:局部出现峰值(当前遍历的数左边和右边的数都小于该数或者当前遍历的数左边和右边的数均大于该数)

全局最优:摆动子序列最长;

(看上去简单的不像是贪心,贪心本来就不像一种方法,而是一种常识,一种感觉,就像你在钱堆中总是拿最大的钞票一样,感觉到了,拦也拦不住)


最大子数组之和

力扣题目链接

class Solution {
public:
    // 贪心算法:找到局部最优和全局最优;
    // 最小局部看:-2,1在起点时,一定从1开始算,加上-2只会让累加和变小;
    // 局部最优:当前连续和为负数时立刻放弃,从下一个元素重新计算连续和,因为负数加上下一个元素连续和只会越来越小;
    // 全局最优:选取最大的连续和
    int maxSubArray(vector<int>& nums) {
        int result = INT32_MIN;
        int count = 0;
        for (int i = 0; i < nums.size(); i++) {
            count += nums[i]; // 连续和累加;
            if (count > result) result = count; // 记录最大的连续和;
            if (count <= 0) count = 0; // 重置连续和的起始位置;
        }
        return result;
    }
};

53.最大子序和

局部:指的是从选定的子数组起点到当前位置的连续子数组。(局部不能认为是当前指针指向的元素,如果认为是当前指针指向的元素,求的实际是连续正整数序列而不是最大连续子数组)

局部最优:如果局部累加大于0,说明前面的局部对后面是有用的,需要保留;如果小于0,说明前面的局部不能累加,起点移动到当前位置的下一个位置,重新选择子数组;

不断记录连续和,如果连续和大于0,则说明有用,保留,继续累加;如果连续和小于0,说明对后面的累加有反作用,舍弃,连续和初始化为0;

全局:指的是整个数组的连续子数组的和。也就是整个数组中的某一段连续子数组的和。

全局最优:指的是在所有可能的连续子数组和中选择最大的值。也就是在所有的局部连续子数组和中选择最大的值作为全局连续子数组和。


买卖股票的最佳时机II

力扣题目链接

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        bool have = false;
        int sum = 0;
        int buyprice = 0;
        for (int i = 0; i < prices.size() - 1; i++) {
            if (prices[i] > prices[i + 1]) { // 股票跌;
                if (have) {
                    sum += prices[i] - buyprice; // 如果持有则卖出;
                    have = false; // 更改状态为未持有;
                }
            } else if (prices[i] < prices[i + 1]) { // 股票涨
                if (!have) {
                    buyprice = prices[i]; // 如果未持有则购买;
                    have = true; // 更改状态为持有;
                }
            }  
        }
        if (have) { // 最后一天还在涨;
            sum += prices[prices.size() - 1] - buyprice;
        }
        return sum;
    }
};

局部最优:在涨之前买,在跌之前卖,收集每天的正利润;

全局最优:获得最大利润,并且尽可能完成多次交易;


跳跃游戏

力扣题目链接

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int maxArrive = 0; // 当前最大可到达位置;
        for (int i = 0; i < nums.size() && i <= maxArrive; i++) { // 其实i < nums.size()没有必要;
            // 不是数组中任意i都可以被遍历,只有可到达范围内的i可以被遍历;
            if (nums[i] + i >= maxArrive) { // >=可以通过[0]测试样例;
                maxArrive = nums[i] + i; // 更新最大可到达位置;
                if (maxArrive >= nums.size() - 1) return true;
            }
        }
        return false;
    }
};

题目关键点:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是经过哪个点,怎么跳的。

一次跳几步无所谓,关键在于可跳的覆盖范围;所以使用一个变量maxArrive来标注当前最大可跳位置;将问题转化为跳跃覆盖范围究竟可不可以覆盖到终点!即maxArrive能不能大于nums.size()-1

每移动一个单位,就判断是否要更新maxArrive,每更新maxArrive就判断是否已经可以覆盖到终点;

局部最优:每次取最大跳跃步数(取最大覆盖范围);

全局最优:最后得到整体最大覆盖范围,看是否能到终点;

虽然复盘时用到了贪心算法,但开始时可能根本没想到贪心;


跳跃游戏II

力扣题目链接

class Solution {
public:
// 把数组分成一个个区域看,找区域中的最大可达范围的点作为跳跃节点;
// 例如[2, 3, 1, 1, 4],选中节点2,就确定下一片区域为[3, 1];
// 在[3, 1]中找到最大可达范围的节点作为跳跃节点,比如3 + 1 = 4, 1 + 2 = 3,4比3大,选中值为3的节点跳跃;
// 从值为3的节点跳跃,最大可达范围包含终点,所以结束,路径为2->3->4;
    int jump(vector<int>& nums) {
        int cover = 0; // 可达区域;
        int covercopy = 0;
        int result = 0;
        if (nums.size() == 1) return result;
        for (int i = 0; i <= cover; i++) {
            // 在可达区域里选择可以跳最远的跳;(局部最优)
            covercopy = max(nums[i] + i, covercopy); // 更新最大可达区域;
            if (covercopy >= nums.size() - 1) { // 要先于i == cover判断;
                result++;
                return result;
            }
            if (i == cover) { // 上一个值所辐射的区域全部遍历完成后才更新cover;
                cover = covercopy; // 找到下一片搜索的区域的范围;
                result++;
            }
        }
        return result;
    }
};

45.跳跃游戏II

局部就是上一个跳跃节点的可达范围内的所有节点;例如[2, 1, 3]中,第一个跳跃节点为2,则下一个的局部为[1, 3];下下个局部为[4, 4],即可以到达终点,一共跳了2次;

最好节点:我们希望跳跃次数越少越好,所以最好节点一定是nums[i] + i最大的节点,即可达范围最大的节点(即一次跳得越远越好);

选中一个跳跃节点,然后遍历下一个局部,找到局部中nums[i]+i最大的节点作为下一个跳跃节点,直到可以跳到终点;

什么时候步数加一?遍历局部的时候不用,知道遍历完局部更新下一跳的最大可达范围时才用步数加一;此外,如果遍历局部时发现已经可以一次跳出,则也要步数加一后直接返回;

和跳跃游戏相比,我们在获得一个更大的可达范围之后,并不更新,而是继续遍历局部,只有局部全部遍历完成后才更新;


K次取反后最大化的数组和

力扣题目链接

class Solution {
public:
    int largestSumAfterKNegations(vector<int>& nums, int k) {
        sort(nums.begin(), nums.end());
        for (int i = 0; i < k; i++) { // 取最小的数反转k次;
            nums[0] = 0 - nums[0];
            // 冒泡-nums[0],使有序;
            sort(nums.begin(), nums.end());
        }
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        return sum;
    }
};

局部最优:让绝对值大的负数变为正数,当前数值达到最大;只找数值最小的正整数进行反转,当前数值和可以达到最大(例如正整数数组{5, 3, 1},反转1 得到-1 比 反转5得到的-5 大多了)

整体最优:整个数组和达到最大。


加油站

力扣题目链接

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int begin = -1;; // 记录能否走通;
        // 计算所有gas[i] - cost[i],看是否可以出发,大于等于0才有可能出发;
        vector<int> vec(gas.size(), 0);
        int sum = 0;
        for (int i = 0; i < gas.size(); i++) {
            vec[i] = gas[i] - cost[i];
            sum += vec[i];
        }
        // 如果vec中所有元素之和小于0,则说明无论从哪出发都无法到达;
        if (sum < 0) return begin;

        // 可以到达,找到起点;
        sum = 0;
        begin = 0; // 从0开始尝试;
        for (int i = 0; i < vec.size(); i++) {
            sum += vec[i];
            if (sum < 0) {
                sum = 0;
                begin = i + 1;
            }
        }
        return begin;     
    }
};

解题方法:

  • 首先遍历全部数组元素,获得vec数组,vec[i] = gas[i] - cost[i],同时统计vec数组所有元素之和;

  • 如果所有元素之和小于0,则不可能环路一周返回;

  • 如果所有元素之和大于0,则需要寻找起点;

  • 从0开始,记录vec数组之和,如果数组之和第一次小于0,即0~i之和小于0,则0~i不可能作为起点;

  • 继续从i开始,sum归0,直到到达末尾,找到累加和一直大于0的起点;

局部最优:当前累加和一旦小于0,起始位置至少要是i+1,因为从i之前开始一定不行。(从i之前的任意位置开始,到达isum都小于0,即已经无法到达i了)

全局最优:找到可以跑一圈的起始位置。

暴力方法:以每一个加油站为起点,模拟一圈,时间复杂度为 O ( n 2 ) O(n^2) O(n2),贪心算法将时间复杂度降低为 O ( n ) O(n) O(n)


分发糖果

力扣题目链接

class Solution {
public:
    int candy(vector<int>& ratings) {
        // 不用模拟回溯的视角,即不先分一个,如果后面不够再回来多分,如果模拟流程控制很麻烦;
        // 在一个更高的纬度看,如果从左到右分数1、2、3、4升序,则糖数无论如何至少是1、2、3、4
        // 找到每个位置至少要分的糖数,并且分别是从左到右至少分的糖数和从右到左至少分的糖数;
        // left中存的:为了满足左边的邻居,每个位置至少分的糖数;
        // right中存的:为了满足右边邻居,每个位置至少分的糖数;
        // max(left, right):即满足左边邻居又满足右边邻居的最少糖数;
        int sum = 0; 
        // 消耗的内存空间大,要O(n)级别;时间复杂度也是O(n);
        vector<int> left(ratings.size(), 1);
        vector<int> right(ratings.size(), 1);
        // 从左到右看,只修改升序;
        for (int i = 1; i < ratings.size(); i++) {
            if (ratings[i] > ratings[i - 1]) {
                left[i] = left[i - 1] + 1;
            }
        }
        // 从右向左看,只修改升序;
        for (int i = ratings.size() - 2; i >= 0; i--) {
            if (ratings[i] > ratings[i + 1]) {
                right[i] = right[i + 1] + 1;
            }
        }
        // 取两个数组的max;
        for (int i = 0; i < ratings.size(); i++) {
            sum += max(left[i], right[i]);
        }
        return sum;
    }
};

从左到右遍历,如果得分比左边邻居大,则给的糖果要比左边邻居多,由于为了糖果最少,所以多一个即可满足;

但是如果得分比左边邻居小,为了糖果最少,则不一定比左边邻居少一个,为了糖果最少,应该只分一个

可是如果只分一个,右边的得分更少的情况下不够分糖果,如果采用回溯,回去多分,则流程控制很麻烦,回去哪,多分多少,都是很复杂的。所以先不回去。

如果只看每个人至少得到的糖果数,则从左到右中,比左边邻居得分高则比左边邻居多分1个,得分小则直接只分1个(因为是至少分的糖果数,目前没必要非找到最终的糖果数)

从右向左看(从后往前遍历),我们又可以得到一组每个人至少分的糖果数;

两个至少分的糖果数取最大值,得到最终结果(这一步比较难想,既然左边遍历只能确定升序序列的糖果数,右边遍历只能确定将序序列的糖果数,则两次遍历后统一则得到最终糖果数)

这道题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼

采用了两次贪心的策略:

  • 一次是从左到右遍历,只比较右边孩子评分比左边大的情况。(顺序遍历贪心
  • 一次是从右到左遍历,只比较左边孩子评分比右边大的情况。(逆序遍历贪心

这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。

相邻有左相邻和右相邻,一次性考虑顾此失彼,只有分成两次遍历分别贪心局部最优,才能简洁明了实现;


柠檬水找零

力扣题目链接

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        // 找零具有优先级,5元是万能的,所以优先保留5元;其次保留10元;
        int five = 0, ten = 0, twenty = 0;
        for (int bill : bills) { // 注意这种遍历方式;
            if (bill == 5) five++;
            if (bill == 10) {
                if (five <= 0) return false;
                ten++;
                five--;
            }
            if (bill == 20) { // 有优先级考虑
                if (five > 0 && ten > 0) { // 优先找一张10元一张5元;
                    five--;
                    ten--;
                    twenty++;
                } else if (five >= 3) {
                    five = five - 3;
                    twenty++;
                } else return false;
            }
        }
        return true;
    }
};

局部最优:收到20元时尽量找零10元;

全局最优:可以找零完成,没有不够的情况;

局部贪心的策略(不同局部选择不同策略):

  • 情况一:账单是5,直接收下;
  • 情况二:账单是10,消耗一个5,增加一个10;
  • 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5;

根据身高重建队列

力扣题目链接

class Solution {
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(), people.end(), [](const vector<int>& u, const vector<int>& v) {
            return u[0] < v[0] || (u[0] == v[0] && u[1] > v[1]);
        });
        int n = people.size();
        vector<vector<int>> ans(n);
        for (const vector<int>& person: people) {
            // 前面有person[1]个比该人身高高的,所以该人的位置至少为person[1]+1
            int spaces = person[1] + 1; 
            for (int i = 0; i < n; ++i) {
                if (ans[i].empty()) { // 前面有空位,则说明该空位会再后面加入一个比此人高的人,所以spaces可以减少
                    --spaces;
                    if (!spaces) { // 前面留了spaces个空位,即满足了第二个维度
                        ans[i] = person;
                        break;
                    }
                }
            }
        }
        return ans;
    }
};

本题排序的依据有两个维度,一个是身高h,一个是排在他前面的人的个数k;和之前的分发糖果一样,分发糖果也有两个维度,一个从左向右看的维度,一个从右向左看的维度。对于这种有两个维度的情况,要想“如何先确定一个维度,然后再按照另一个维度排序”。

遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。如果两个维度一起考虑一定会顾此失彼。

按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。

局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性;

全局最优:最后都做完插入操作,整个队列满足题目队列属性;

(可以将数组改成链表,效率会提高不少)


用最少数量的箭引爆气球

力扣题目链接

class Solution {
public:
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        if (a[1] == b[1]) return a[0] < b[0];
        return a[1] < b[1];
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        // 根据xend排序;
        sort(points.begin(), points.end(), cmp);
        int result = 0;
        for (int i = 0; i < points.size(); i++) {
            if (points[i][1] != -1) {
                // 取它当射击的横坐标;
                // 看射中了几个;
                int x = points[i][1];
                points[i][1] = -1; // 标记为已经射中;
                result++;
                while (i + 1 < points.size() && x >= points[i + 1][0] && x <= points[i + 1][1]) { // 看该箭还能射中几个气球,即x是否在下一个气球的起点和终点之间
                    points[i + 1][1] = -1;
                    // result++;
                    i++;
                    if (i >= points.size()) break;
                }
            }
        }
        return result;
    }
};
452.用最少数量的箭引爆气球

解题思路:

  • 对气球结束位置进行排序;
  • 选择气球结束位置最小的当成发射点,看可以射中几个气球;
  • 被射中的气球标记,不参与后续排序;
  • 继续找未被射中的气球中结束位置最小的作为射击点;

贪心思想:尽可能射气球中结束位置最早的地方;(既可以保证射中气球,又能尽量多射中气球)


无重叠区间

力扣题目链接

class Solution {
public:
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        // 按照起点从小到大的顺序排序;
        if (a[0] == b[0]) return a[1] < b[1]; // 起点相同,按照终点从小到大排序;
        return a[0] < b[0];
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        // 先按照起点排序;
        sort(intervals.begin(), intervals.end(), cmp);
        int result = 0;
        // 遍历排序后的区间集合;
        for (int i = 0; i < intervals.size() - 1; i++) {
            // 判断重叠:
            // 下一个区间的起点是否在本区间之中:(不用判断终点,按照起点排序的)
            int begin = intervals[i][0];
            int end = intervals[i][1];
            int nextbegin = intervals[i + 1][0];
            int nextend = intervals[i + 1][1];
            bool nextbeginIn = nextbegin >= begin && nextbegin < end; // 左闭右开;
            if (nextbeginIn) { 
                // 已经重叠,删除哪一个?贪心:删除后结束的(越后结束越可能发生重叠)
                result++; // 不管删除哪个,都要删除一个;
                // 比较结束位置:
                if (end < nextend) {
                    // 删除下一个区间:可以直接将下一个区间改为本区间大小;这样本区间还可以参与比较;
                    intervals[i + 1][0] = begin;
                    intervals[i + 1][1] = end; 
                } 
                // else {
                //     // 删除本区间;可以不做修改,因为我们后面本区间不参与比较了;
                // }
            } 
        }
        return result;
    }
};

解题方法:

  • 排序,根据起点从小到大排序;
  • 遍历数组,判断相邻的两个区间是否重叠;具体而言就是看下一个区间的begin是否属于本区间的[begin, end)
  • 重叠之后要删除一个区间,利用贪心的思想:局部看一个区间end得越晚,则越可能和后面的区间重叠,所以选择end大的区间删除;
  • 删除之后的处理,由于我们一直只比较相邻区间,所以删除本区间可以不做处理,删除下一个区间的本质是本区间和下下个区间比较,所以只用将下一个区间的起点终点都修改为本区间的起点终点;即可实现在下次循环时正确的区间比较;

方法2:根据end排序,然后遍历,看各个区间的end可以贯穿几个区间;被贯穿的区间不再参与遍历;最后统计参与遍历的数量,就是非交叉区间的最大个数;总区间数减去非交叉区间数就是所求结果;

image.png

划分字母区间

力扣题目链接

class Solution {
public:
    vector<int> partitionLabels(string s) {
        // 统计每个字母出现的最后位置;
        int hash[27] = {0};
        for (int i = 0; i < s.size(); i++) {
            hash[s[i] - 'a'] = i;
        }
        vector<int> result;
        for (int i = 0; i < s.size(); i++) {
            int right = hash[s[i] - 'a'];
            for (int j = i; j <= right; j++) {
                if (hash[s[j] - 'a'] > right) right = hash[s[j] - 'a'];
            }
            result.push_back(right - i + 1);
            i = right;
        }
        return result;
    }
};
image.png

解题方法:

  • 首先需要统计每个字母最后出现位置,如果是遍历时一边处理一边统计,有些复杂,所以提前处理,由于字母一共只有27种情况,所以只用设置一个数组大小为27,即可实现哈希(哈希记录每个字母最后出现的位置);
  • 遍历字符串,每次更新right即一组中的右边界,使得组中字母(起点到right为一组)最后出现位置的最大值恰是右边界大小;

(没有感受到贪心,找不出局部最优推出全局最优的过程。就是用最远出现距离模拟了圈字符的行为)

方法2:将每个字符的起始位置和结束位置记录为区间,之后找到无重叠子区间,就和无重叠子区间和用最少的箭引爆气球一样了;


合并区间

力扣题目链接

class Solution {
public:
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0]; // 起点前的排序前;
    }
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        // 先按照起点进行排序;
        sort(intervals.begin(), intervals.end(), cmp);
        // 然后遍历,比较相邻两个区间是否可以合并;
        vector<vector<int>> result;
        vector<int> end = {-1, -1}; // 设置一个守卫,无法和任何区间合并,保证最后一次遍历时是无法合并的,既无论最后一个区间是否可以合并,都可以输出;
        intervals.push_back(end);
        for (int i = 0; i < intervals.size() - 1; i++) {
            // 比较i和i+1是否可以合并区间:
            int begin = intervals[i][0];
            int end = intervals[i][1];
            int nextbegin = intervals[i + 1][0];
            int nextend = intervals[i + 1][1];
            bool addi =  nextbegin <= end && begin <= nextbegin; // 保证nextbegin落在[begin, end)区间;
            if (addi) { // 有重叠部分就一定要合并;(只修改最近的,至于之前的不做修改)
                intervals[i + 1][0] = min(begin, nextbegin);
                intervals[i + 1][1] = max(end, nextend);
            } else { // 无重叠部分,合并结束,写入结果;
                result.push_back(intervals[i]);
            }
        }
        return result;
    }
};

思路:首先对数组进行排序,按照起点排序;之后遍历数组,并判断相邻两个区间是否可以合并(即后一个区间的起点是否在本区间内),可以合并则合并,并修改合并后的区间;

解题方法:

  • 排序,按照区间的第一个元素从小到大排序;
  • 设置一个守卫,即在有序的数组最后添加[-1, -1],守卫无法和任何区间合并,可以帮助我们在遍历最后一个区间之后输出结果;
  • 遍历数组,同时合并区间,无法合并下一个区间时保存一个合并后的区间的结果;
  • 也可以不设置守卫,在result保存过后修改边界;设置守卫的好处就是区间一旦进入result之后就是无需修改合并好的区间;

单调递增的数字

力扣题目链接

class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        // 提取出各位数字;
        vector<int> num;
        while (n > 0) { // 拆分出各位
            num.push_back(n % 10);
            n = n / 10;
        }
        // 分别判断
        for (int i = num.size() - 1; i > 0; i--) {
            if (num[i] <= num[i - 1]) continue; // 看是否各位数字递增
            else { // 不递增,则不符合,要求继续减少数字大小
                num[i]--; // 该位数字减一
                // i之后的其他位数字全变成9(贪心,1300不行,之后找1299最大)
                int j = i - 1;
                while (j >= 0) num[j--] = 9;
                // num[i - 1] = 9;
                i = num.size(); // 回去第一个数字重新判断;
            }
        }
        int result = 0;
        for (int i = 0; i < num.size(); i++) {
            result += num[i] * pow(10, i); // 恢复为整型数字而不是数组
        }
        return result;
    }
};
image.png

解题方法:

  • 拆分各位数字;
  • 按照流程图进行遍历和比较;

贪心:尽可能让数字大,尽可能让高位数字大,所以一开始从自身开始判断,尽量保留原来的高位数字,改变低位数字让数字各位递增,如果发现哪个位置不符合递增,则该位置减1,该位置右边的位置都变成9,因为1300不符合要求之后,最大的下一个要判断的数字应该是12xx,而1299最大,尽可能大才好;


监控二叉树

力扣题目链接

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
private:
    int result; // 全局变量,保存摄像数量;
    // 返回:0代表未被覆盖,1代表摄像节点,2代表被覆盖;
    int traversal (TreeNode* cur) { // 递归函数;(二叉树的后序遍历)
        // 使用后序遍历,左右中;
        if (cur == NULL) return 2; // 递归终止条件,遍历到空节点,并且空节点默认为被覆盖状态;
        int left = traversal(cur->left); // 左;
        int right = traversal(cur->right); // 右;

        // 注意3种情况的顺序不能乱;
        // 情况1:左右节点都被覆盖了,无需在本节点设置摄像节点;
        if (left == 2 && right == 2) return 0;
        // 情况2:左右节点中至少有一个未被覆盖,本节点需要设置为摄像节点;
        if (left == 0 || right == 0) {
            result++; // 总摄像节点数增加;
            return 1;
        }
        // 情况3:左右节点中有至少一个摄像节点,本节点被覆盖;
        if (left == 1 || right == 1) return 2;
        return -1;
    }
public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        if (traversal(root) == 0) { // 如果根节点没有被覆盖
            result++;
        }
        return result;
    }
};

思路:叶子节点不能设置为监控节点,最优的情况是一个节点监控它的左右孩子和父母,此时为局部最优;

解题思路:

  • 局部最优:让叶子节点的父节点安装摄像头,此时局部摄像头最少;
  • 全局最优:全部摄像头最少;
  • 选择遍历的顺序:后序遍历;
  • 3种情况:
    • 节点无覆盖,返回0;
    • 节点有摄像头,返回1;
    • 节点有覆盖,返回2;
  • 注意空节点的处理,空节点默认是有覆盖的;
  • 根据左右孩子是否有覆盖和是否有摄像头来判断本节点的状态;

状态机,将节点分为3种状态,分别是节点需要覆盖但是无覆盖,节点已覆盖但不是摄像头,节点是摄像头节点三种;而对于第一类状态要求在节点上加上摄像头转化为第三种状态。

贪心:叶子节点不应该被安装摄像头,因为叶子节点安装摄像头至多只能照2个节点,覆盖范围天然就小;所以应该在叶子节点的父节点安装摄像头;从叶子节点的父节点开始往上遍历,所以使用后序遍历(从下往上,后序遍历也就是左右中的顺序,这样就可以在回溯的过程中从下到上进行推导了,后序遍历先找到最左边元素,然后向根方向回溯);

后序遍历递归实现:

int traversal(TreeNode* cur) {
    // 空节点,该节点有覆盖
    if (终止条件) return ;
    int left = traversal(cur->left);    // 左
    int right = traversal(cur->right);  // 右
    逻辑处理                            // 中
        return ;
}

对于根部节点需要在遍历之外单独判断:

968.监控二叉树3

总结

贪心法没有固定套路,在做题时,要有贪心的思想,如果不能用贪心做出了也正常,甚至用模拟做出来发现是贪心也正常;

注意“两个维度权衡问题”的思想:

  • 在分发糖果中,顺序遍历是一个维度,逆序遍历又是一个维度,然后最终结果取顺序遍历和逆序遍历的结果中较大值;
  • 在根据身高重建队列中,也又两个维度,分别是身高和排在前面的人的数量;先处理身高,将身高按顺序排序,然后遍历处理第二个维度;
  • 切莫瞻前顾后,既要还要,一次性处理多个维度,最后导致顾此失彼;确定一个维度的结果之后,再确定另一个维度;

对于处理区间重叠、区间覆盖等区间问题,常用贪心来处理,一般来说,是看区间的右端点是否在其他区间内部,或者区间的左右端点是否都在其他区间内部,一般还要先对区间进行排序(按照右端点或者左端点);

区分模拟题和贪心算法题目:如果能找到局部最优,并且举不出局部最优推不出全局最优的反例,则可以尝试贪心;如果没有局部最优的概念,则使用模拟;

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OutlierLi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值