当前位置:   article > 正文

贪心算法--Leetcode刷刷刷

贪心算法--Leetcode刷刷刷

来自代码随想录的刷题路线:代码随想录

贪心算法

贪心算法大纲

理论基础

什么是贪心?

贪心的本质是选择每一阶段的局部最优,从而达到全局最优

例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?

指定每次拿最大的,最终结果就是拿走最大数额的钱。

每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。

贪心的套路(什么时候用贪心)

贪心没有套路,说白了就是常识性推导加上举反例

靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。

有同学问了如何验证可不可以用贪心算法呢?

最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧

面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了

贪心一般解题步骤

想清楚 局部最优 是什么,如果推导出全局最优,其实就够了。

455. 分发饼干

比较简单,先排序再双指针即可。主要是用来接触局部最优推导全局最优的想法。

想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心

小饼干先喂饱小胃口,或者大胃口先吃大饼干。

class Solution {
public:
    //每个小孩一块饼干、应该努力使得饼干尺寸接近小孩的胃口
    //先给两个数组排个序,按照递增顺序看饼干满不满足小孩胃口
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());
        sort(s.begin(), s.end());
        
        int canContentNum = 0;  //能满足的小孩数量

        //双指针法
        int i = 0, j = 0;
        while(i < g.size() && j < s.size()){
            if(s[j] >= g[i]){
                canContentNum++;
                i++;
                j++;
            }
            else{
                j++;
            }
        }

        return canContentNum;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(),g.end());
        sort(s.begin(),s.end());
        int index = 0;
        for(int i = 0; i < s.size(); i++) { // 饼干
            if(index < g.size() && g[index] <= s[i]){ // 胃口
                index++;
            }
        }
        return index;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(1)

376. 摆动序列

方法1:只统计变化的次数

这个是力扣评论区的方法,比贪心跟dp都好理解,且代码更简单

子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。

摆动的意思是连续的两个数的差值是正负交替的。所以其实:变化的次数+1就是最长的摆动序列大小,因为同趋势或者平坡都可直接删掉。

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if (nums.size() <= 1) return nums.size();

        int changeNum = 0;  //统计变化的次数
        int direction = 0;	//记录当前的变化方向

        for(int i = 1; i < nums.size(); i++){
            if(nums[i] == nums[i - 1])  continue;   //没有变化相当于删掉当前元素
			
            //上坡
            else if(nums[i] > nums[i - 1]){
                if(direction == 1)  continue;   //之前也是上坡 相当于没有变化

                //当上一个是平坡或者相反时都视为变化
                direction = 1;
                changeNum++; 
            }
            //下坡
            else if(nums[i] < nums[i - 1]){
                if(direction == -1)  continue;   //之前也是下坡 没有变化
                direction = -1;
                changeNum++; 
            }
        }

        return changeNum + 1;   //变化次数+1就是序列的长度
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

方法2:记录局部峰值求最大摆动

376.摆动序列

本题异常情况的本质,就是要考虑平坡, 平坡分两种,一个是 上下中间有平坡,一个是单调有平坡,如图:

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if (nums.size() <= 1) return nums.size();
        int curDiff = 0; // 当前一对差值
        int preDiff = 0; // 前一对差值
        int result = 1;  // 记录峰值个数,序列默认序列最右边有一个峰值
        for (int i = 0; i < nums.size() - 1; i++) {
            curDiff = nums[i + 1] - nums[i];
            // 出现峰值
            if ((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)) {
                result++;
                preDiff = curDiff; // 注意这里,只在摆动变化的时候更新prediff
            }
        }
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

53. 最大子数组和

暴力解法

暴力解法的思路,第一层 for 就是设置起始位置,第二层 for 循环遍历数组寻找最大值

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int result = INT32_MIN;
        int count = 0;
        for(int i = 0; i < nums.size(); i++){
            count = 0;  //每一层的count都得重新计算
            for(int j = i; j < nums.size(); j++){   // 每次从起始位置i开始遍历寻找最大值
                count += nums[j]; 
                if(count > result)  result = count;
            }
        }
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

有案例超时

贪心解法

局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。

全局最优:选取最大“连续和”

局部最优的情况下,并记录最大的“连续和”,可以推出全局最优

从代码角度上来讲:遍历 nums,从头开始用 count 累积,如果 count 一旦加上 nums[i]变为负数,那么就应该从 nums[i+1]开始从 0 累积 count 了,因为已经变为负数的 count,只会拖累总和。

这相当于是暴力解法中的不断调整最大子序和区间的起始位置

这里也会有疑惑,那 4 + -1 之后 不就变小了吗? 会不会错过 4 成为最大连续和的可能性?

其实并不会,因为还有一个变量 result 一直在更新 最大的连续和,只要有更大的连续和出现,result 就更新了,那么 result 已经把 4 更新了,后面 连续和变成 3,也不会对最后结果有影响。

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int temp = 0;   //累计值
        int result = INT32_MIN;

        for(int i = 0; i < nums.size(); i++){
            temp += nums[i];
            result = max(result, temp); //记录出现过的最大值

            //如果积累的子数组和小于0,那么往后的话也只会拖累总和,所以应该从下一个数重新开始算
            if(temp < 0){
                temp = 0;
            }
        }
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

122. 买卖股票的最佳时机 II

收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间

局部最优:收集每天的正利润,全局最优:求得最大利润

122.买卖股票的最佳时机II

class Solution {
public:
    //收集正利润的区间,就是股票买卖的区间
    int maxProfit(vector<int>& prices) {
        int profit = 0;
        for(int i = 1; i < prices.size(); i++){
            int temp = prices[i] - prices[i - 1];
            if(temp > 0){
                profit += temp;
            }
        }
        return profit;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

55. 跳跃游戏

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

img

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int cover = 0;
        if (nums.size() == 1) return true; // 只有一个元素,就是能达到
        for (int i = 0; i <= cover; i++) { // 注意这里是小于等于cover,很妙
            cover = max(i + nums[i], cover);
            if (cover >= nums.size() - 1) return true; // 说明可以覆盖到终点了
        }
        return false;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

自己做的:

class Solution {
public:
    //找出每个元素的可跳跃范围值,别管怎么跳,反正能跳到
    bool canJump(vector<int>& nums) {
        if(nums.size() <= 1)    return true;

        int curMaxJump = 0;
        for(int i = 0; i < nums.size(); i++){
            curMaxJump = max(nums[i] + i, curMaxJump);   //最大跳跃范围

            if(curMaxJump >= nums.size() - 1)   
                return true;

            if(i == curMaxJump){    //如果当遍历到最大跳跃值了还没有到达终点那就肯定到不了了
                if(curMaxJump < nums.size())    
                    return false;
            }
        }

        return false;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

45. 跳跃游戏 II

理解本题的关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,这个范围内最少步数一定可以跳到,不用管具体是怎么跳的,不纠结于一步究竟跳几个单位。

这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖

如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。

如图:

45.跳跃游戏II

图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)

class Solution {
public:
    int jump(vector<int>& nums) {
        if (nums.size() == 1) return 0;
        int curDistance = 0;    // 当前覆盖最远距离下标
        int ans = 0;            // 记录走的最大步数
        int nextDistance = 0;   // 下一步覆盖最远距离下标
        for (int i = 0; i < nums.size(); i++) {
            nextDistance = max(nums[i] + i, nextDistance);  // 更新下一步覆盖最远距离下标
            if (i == curDistance) {                         // 遇到当前覆盖最远距离下标(每一步都走到最大值才计算步数)
                ans++;                                  // 需要走下一步
                curDistance = nextDistance;             // 更新当前覆盖最远距离下标(相当于加油了)
                if (nextDistance >= nums.size() - 1) break;  // 当前覆盖最远距到达集合终点,不用做ans++操作了,直接结束
            }
        }
        return ans;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
class Solution {
public:
    //返回最小的跳跃次数
    int jump(vector<int>& nums) {
        if(nums.size() <= 1)    return 0;

        int curMaxJump = 0;
        int nextMaxJump = 0;
        int count = 1;  //肯定起码得走一步

        for(int i = 0; i < nums.size(); i++){
            nextMaxJump = max(nums[i] + i, nextMaxJump);   //最大跳跃范围

            if (nextMaxJump >= nums.size() - 1)   //如果已经覆盖终点则直接返回值
                break;

            if (i == curMaxJump)   //如果到了当前最大值且没到终点,就需要加油
            {
                count++;    
                curMaxJump = nextMaxJump;
            }
        }

        return count;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

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

解法1

思考过程:

将正的变成负的k次,求最大和。说明每次修改的得是最小值(可以多次选择同一个下标)

贪心的思路,局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。

int largestSumAfterKNegations(vector<int>& nums, int k) {
    int min;
    int minIndex;
    int sum = 0;
    while (k--) {
        min = INT32_MAX;
        for (int i = 0; i < nums.size(); i++) {
            if (nums[i] < min) {
                min = nums[i];
                minIndex = i;
            }
        }
        nums[minIndex] *= -1;
    }
    for (int i = 0; i < nums.size(); i++) {
        sum += nums[i];
    }

    return sum;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 时间复杂度: O(k * n)
  • 空间复杂度: O(1)

也可以先快排再取反第一个元素,这样的话时间复杂度就是O(k * nlogn),比较大

image-20231112174431565

下面是卡哥的方法,个人感觉比较难理解

class Solution {
static bool cmp(int a, int b) {
    return abs(a) > abs(b);
}
public:
    int largestSumAfterKNegations(vector<int>& A, int K) {
        sort(A.begin(), A.end(), cmp);       // 第一步
        for (int i = 0; i < A.size(); i++) { // 第二步
            if (A[i] < 0 && K > 0) {
                A[i] *= -1;
                K--;
            }
        }
        if (K % 2 == 1) A[A.size() - 1] *= -1; // 第三步
        int result = 0;
        for (int a : A) result += a;        // 第四步
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 时间复杂度: O(nlogn)
  • 空间复杂度: O(1)

134. 加油站

如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。

每个加油站的剩余量rest[i]为gas[i] - cost[i]。

i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。

如图:

img

那么为什么一旦[0,i] 区间和为负数,起始位置就可以是i+1呢,i+1后面就不会出现更大的负数?

如果出现更大的负数,就是更新i,那么起始位置又变成新的i+1了。

那有没有可能 [0,i] 区间 选某一个作为起点,累加到 i这里 curSum是不会小于零呢? 如图:

img

如果 curSum<0 说明 区间和1 + 区间和2 < 0, 那么 假设从上图中的位置开始计数curSum不会小于0的话,就是 区间和2>0。

区间和1 + 区间和2 < 0 同时 区间和2>0,只能说明区间和1 < 0, 那么就会从假设的箭头初就开始从新选择其实位置了。

那么局部最优:当前累加rest[i]的和curSum一旦小于0,起始位置至少要是i+1,因为从i之前开始一定不行。全局最优:找到可以跑一圈的起始位置

局部最优可以推出全局最优,找不出反例,试试贪心!

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        vector<int> remain(gas.size());
        int curSum = 0;
        int totalSum = 0;
        int startIndex = 0;

        //计算油量是否能跑完全程
        for (int i = 0; i < gas.size(); i++) {
            totalSum += gas[i] - cost[i];
        }
        if(totalSum < 0)    return -1;

        //下面的逻辑就是一定能跑完全程
        //所以只需要找剩余油量和能大于0的站点,肯定就是答案,无需再绕一圈(因为一定能跑完全程)
        for (int i = 0; i < gas.size(); i++) {
            curSum += gas[i] - cost[i];

            if(curSum < 0){     //说明前面的站点都不能走完全程,直接从下一个站点开始
                startIndex = i + 1;
                curSum = 0;
            }
        }

        return startIndex;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

135. 分发糖果

暴力:

最后一个案例超时

先给每个孩子分一个糖果,然后遍历数组。

  • 当当前孩子比前一个孩子评分高时,增加当前的孩子的糖果数和糖果总数
  • 当前一个孩子比当前孩子评分高且两者糖果数相同(都是1,说明上一个孩子在与上上个孩子的比较中失败)时,不仅要增加前一个孩子的糖果和糖果总数,还有往前遍历看看增加之后合不合规。
class Solution {
public:
    int candy(vector<int>& ratings) {
        int candySum = ratings.size();
        vector<int> candyNum(ratings.size(), 1);

        for(int i = 1; i < ratings.size(); i++){
            if(ratings[i] > ratings[i - 1]){
                candyNum[i] = candyNum[i - 1] + 1;
                candySum += candyNum[i - 1];
            }
            else if(ratings[i] < ratings[i - 1] && candyNum[i] == candyNum[i - 1]){
                candySum++;
                candyNum[i - 1]++;

                //检查增加糖果后,与前面的孩子之间是否符合规则
                int j = i - 1;
                while(j > 0){
                    if(candyNum[j] == candyNum[j - 1]){
                        if(ratings[j] > ratings[j - 1]){
                            candyNum[j]++;
                            candySum++;
                        }
                        else if(ratings[j] < ratings[j - 1]){
                            candyNum[j - 1]++;
                            candySum++;
                        }
                    }
                    j--;
                }
            }
        }

        return candySum;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

贪心:

采用了两次贪心的策略:

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

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

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

先确定右边评分大于左边的情况(也就是从前向后遍历)

如果ratings[i] > ratings[i - 1] 那么[i]的糖 一定要比[i - 1]的糖多一个,所以贪心:candyVec[i] = candyVec[i - 1] + 1

代码如下:

// 从前向后
for (int i = 1; i < ratings.size(); i++) {
    if (ratings[i] > ratings[i - 1]) candyVec[i] = candyVec[i - 1] + 1;
}
  • 1
  • 2
  • 3
  • 4

如图:

135.分发糖果

再确定左孩子大于右孩子的情况(从后向前遍历)

遍历顺序这里有同学可能会有疑问,为什么不能从前向后遍历呢?

因为 rating[5]与rating[4]的比较 要利用上 rating[5]与rating[6]的比较结果,所以 要从后向前遍历。

如果从前向后遍历,rating[5]与rating[4]的比较 就不能用上 rating[5]与rating[6]的比较结果了 。如图:

img

所以确定左孩子大于右孩子的情况一定要从后向前遍历!

如果 ratings[i] > ratings[i + 1],此时candyVec[i](第i个小孩的糖果数量)就有两个选择了,一个是candyVec[i + 1] + 1(从右边这个加1得到的糖果数量),一个是candyVec[i](之前比较右孩子大于左孩子得到的糖果数量)。

那么又要贪心了,局部最优:取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,保证第i个小孩的糖果数量既大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。

局部最优可以推出全局最优。

所以就取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,candyVec[i]只有取最大的才能既保持对左边candyVec[i - 1]的糖果多,也比右边candyVec[i + 1]的糖果多

如图:

135.分发糖果1

整体代码如下:

class Solution {
public:
    //先从前到后遍历一遍,若比上个孩子分高就加个糖
    //因为可能会出现前后未满足题目的第二个条件,所以还得从后往前遍历一遍
    int candy(vector<int>& ratings) {
        vector<int> candyVec(ratings.size(), 1);    //每人至少一个糖

        for(int i = 1; i < ratings.size(); i++){    //第0个如果较大,会在第二次for(从后往前遍历时逐步更改)
            if(ratings[i] > ratings[i - 1])
                candyVec[i] = candyVec[i - 1] + 1;
        }

        //起到修正检查的作用
        for(int i = ratings.size() - 2; i >= 0; i--){
            if(ratings[i] > ratings[i + 1]){
                //赋值为较大的一个
                candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1);
            }
        }

        int result = 0;

        for(int i = 0; i < ratings.size(); i++){
            result += candyVec[i];
        }

        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 时间复杂度: O(n)
  • 空间复杂度: O(n)

860. 柠檬水找零

是==而不是=

有如下三种情况:

  • 情况一:账单是5,直接收下。
  • 情况二:账单是10,消耗一个5,增加一个10
  • 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5
class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        int five = 0;
        int ten = 0;
        //无需统计20的钱数,因为它是最大的,找零也找不出去
        
        for(int i = 0; i < bills.size(); i++){
            if(bills[i] == 5){
                five++;
            }
            else if(bills[i] == 10){
                if(five == 0)   return false;

                five--;
                ten++;
            }
            // 优先消耗10美元,因为5美元的找零用处更大,能多留着就多留着
            else if(bills[i] == 20){
                if(five == 0) return false;
                else if(five >= 1 && ten >= 1){
                    five--;
                    ten--;
                }
                else if(five >= 3){
                    five -= 3;
                }
                else    return false;
            }
        }

        return true;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

406. 根据身高重建队列

遇到两个维度都需要考虑的时候,先确定好其中一个维度,再根据确定好的维度去分析另一个维度。不要一起分析,不然容易顾此失彼

  1. 先按身高来h排序,当h相同时,k大的在后。这样前面的人一定比后面的人高。

  2. 确定身高h的维度后,遍历数组,由k的大小来决定插入的位置。

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

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

回归本题,整个插入过程如下:

排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2][4,4]]

插入的过程:
插入[7,0][[7,0]]
插入[7,1][[7,0],[7,1]]
插入[6,1][[7,0],[6,1],[7,1]]		//虽然先插入的[7, 1]被后插入的[6, 1]挪到了后面,但还是符合题目要求的,因为在[7, 1]后面插入的人一定比它矮,所以并不影响它的k值
插入[5,0][[5,0],[7,0],[6,1],[7,1]]
插入[5,2][[5,0],[7,0],[5,2],[6,1],[7,1]]
插入[4,4][[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

代码如下:

class Solution {
public:
    //按身高这个维度来排序,所以前面的人一定比后面的人高
    //确定身高h的维度后,由k的大小来决定插入的位置
    static bool cmd(vector<int>& a, vector<int>& b){
        //当h一样时,k小的排在前面
        if(a[0] == b[0])    return a[1] < b[1];

        //h大的排在前面
        return a[0] > b[0];
    }
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(), people.end(), cmd);    //cmd的意思是自定义一个规则去排序这个数组(cmd一定得是static)
        
        vector<vector<int>> que;    //新定义一个数组来完成k维度的插入操作,不能在原数组中操作,因为这样的话其实已经修改了k的位置

        //遍历数组用k的大小来决定插入位置
        for(int i = 0; i < people.size(); i++){
            //确定插入位置
            int position = people[i][1];    //意思就是第i个人的第二个元素(k)的大小
            que.insert(que.begin() + position, people[i]);  
        }

        return que;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 时间复杂度:O(nlog n + n^2) //快排是nlogn;for下的insert是n,所以是n^2
  • 空间复杂度:O(n)

vector<vector<int>> que; 是一个二维向量,但它的大小并不是n^2。它的大小取决于people容器的大小,而不是people容器中的元素的个数的平方。

在插入操作中,我们将people容器中的每个元素按照指定的位置插入到que容器中。由于que容器的大小与people容器相同,因此que的大小也是n,而不是n^2。

因此,que的空间复杂度为O(n),而不是O(n^2)。

但使用vector是非常费时的,C++中vector(可以理解是一个动态数组,底层是普通数组实现的)如果插入元素大于预先普通数组大小,vector底部会有一个扩容的操作,即申请两倍于原先普通数组的大小,然后把数据拷贝到另一个更大的数组上。

所以使用vector(动态数组)来insert,是费时的,插入再拷贝的话,单纯一个插入的操作就是O(n2)了,甚至可能拷贝好几次,就不止O(n2)了。

改成链表之后,C++代码如下:

// 版本二
class Solution {
public:
    // 身高从大到小排(身高相同k小的站前面)
    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];
    }
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort (people.begin(), people.end(), cmp);
        list<vector<int>> que; // list底层是链表实现,插入效率比vector高的多
        for (int i = 0; i < people.size(); i++) {
            int position = people[i][1]; // 插入到下标为position的位置
            std::list<vector<int>>::iterator it = que.begin();
            while (position--) { // 寻找在插入位置
                it++;
            }
            que.insert(it, people[i]);
        }
        return vector<vector<int>>(que.begin(), que.end());
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 时间复杂度:O(nlog n + n^2)
  • 空间复杂度:O(n)

vector的底层实现

vector的底层实现也是普通数组

vector的大小有两个维度一个是size一个是capicity,size就是我们平时用来遍历vector时候用的,例如:

for (int i = 0; i < vec.size(); i++) {

}
  • 1
  • 2
  • 3

而capicity是vector底层数组(就是普通数组)的大小,capicity可不一定就是size。

当insert数据的时候,如果已经大于capicity,capicity会成倍扩容但对外暴漏的size其实仅仅是+1

那么既然vector底层实现是普通数组,怎么扩容的?

就是重新申请一个二倍于原数组大小的数组,然后把数据都拷贝过去,并释放原数组内存。(对,就是这么原始粗暴的方法!)

举一个例子,如图: vector原理

原vector中的size和capicity相同都是3,初始化为1 2 3,此时要push_back一个元素4。

那么底层其实就要申请一个大小为6的普通数组,并且把原元素拷贝过去,释放原数组内存,注意图中底层数组的内存起始地址已经变了

452. 用最少数量的箭引爆气球

找重叠区间的 如果不需要保持原数组顺序,往往都需要重新排序一波

把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组remove气球,只要记录一下箭的数量就可以了。

为了让气球尽可能的重叠,需要对数组进行排序

那么按照气球起始位置排序,还是按照气球终止位置排序呢?

其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。

既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。

从前向后遍历遇到重叠的气球了怎么办?

如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭

以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)

452.用最少数量的箭引爆气球

可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了。

class Solution {
public:
    //先按起点的大小排序,从前往后遍历数组,比较当前气球与前一个气球有没有重叠
    //不重叠的话就需要加一箭,重叠的话就更新最小的右边界(因为下一箭如果想要跟已经重叠的气球重叠的话,就得在最小右边界之前)

    static bool cmp(vector<int>& a, vector<int>& b){
        if (a[0] == b[0]) return a[1] < b[1];
        return a[0] < b[0]; //小的元素排在前面     
    }

    int findMinArrowShots(vector<vector<int>>& points) {
        if(points.size() == 0)  return 0;
        
        sort(points.begin(), points.end(), cmp);

        int result = 1;     //当数组不为空时至少需要一箭才能射完
        
        int minRightEdge = points[0][1];    //初始最小右边界为第一个元素的终点
        for(int i = 1; i < points.size(); i++){
            //若当前气球起点大于上个气球终点,两球不重叠
            if(points[i][0] > minRightEdge){
                minRightEdge = points[i][1];    //持续更新
                result++;
            }
            else{       //重叠则更新最小右边界为较小数
                minRightEdge = min(points[i][1], minRightEdge);
            }
        }

        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 时间复杂度:O(nlog n),因为有一个快排
  • 空间复杂度:O(1),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,

所以代码中 if (points[i][0] > points[i - 1][1]) 而不能是>=

435. 无重叠区间

跟上一题非常类似,就是判断区间是否重叠:

  1. 先按照左边界从小到大进行排序
  2. 遍历数组,用preEnd来记录上个元素的终点,看当前元素的起点与preEnd是否重叠
  3. 不重叠则刷新preEnd为当前元素的终点
  4. 重叠就使preEnd为当前元素和preEnd的较小值,这样可以使得删除的重叠区间最少
class Solution {
public:
    static bool cmp(vector<int>& a, vector<int>& b){
        if(a[0] == b[0])    return a[1] < b[1];
        
        return a[0] < b[0];     //根据起点由小到大进行排序
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if(intervals.size() <= 0)   return 0;

        sort(intervals.begin(), intervals.end(), cmp);

        int preEnd = intervals[0][1];   //初始上一个终点
        int result = 0;

        for(int i = 1; i < intervals.size(); i++){
            //当前元素的起点大于上一个的终点,则不重叠
            if(intervals[i][0] >= preEnd){
                preEnd = intervals[i][1];
            }
            //重叠则移除终点较大的数
            else{
                preEnd = min(preEnd, intervals[i][1]);
                result++;
            } 
        }

        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 时间复杂度:O(nlog n) ,有一个快排
  • 空间复杂度:O(n),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

763. 划分字母区间

在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。

可以分为如下两步:

  • 统计每一个字符最后出现的位置
  • 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点

如图:

763.划分字母区间

class Solution {
public:
    vector<int> partitionLabels(string s) {
        //两次遍历:第一次遍历获得每个字母的最远出现下标
        //第二次获取分割字符串的分界线
        int hash[26] = {0};     //存放26个字符出现的最远下标
        for(int i = 0; i < s.size(); i++){
            hash[s[i] - 'a'] = i;   //该字母的当前最远下标是i
        }

        vector<int> result;
        int left = 0, right = 0;

        for(int i = 0; i < s.size(); i++){
            right = max(right, hash[s[i] - 'a']);   //更新当前区间的最远下标

            //到达分界线
            if(i == right){
                result.push_back(right - left + 1); //装的是分割长度
                left = i + 1;
            }
        }

        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 时间复杂度:O(n)
  • 空间复杂度:O(1),使用的hash数组是固定大小

56. 合并区间

可以直接用result.back()[1]访问最后一个元素的y值

用最少数量的箭引爆气球、无重叠区间都是一个套路。

都是判断区间重叠,区别就是判断区间重叠后的逻辑,本题是判断区间重叠后要进行区间合并。

所以一样的套路,先排序,让所有的相邻区间尽可能的重叠在一起,按左边界,或者右边界排序都可以,处理逻辑稍有不同。

按照左边界从小到大排序之后,如果 intervals[i][0] <= intervals[i - 1][1] 即intervals[i]的左边界 <= intervals[i - 1]的右边界,则一定有重叠。(本题相邻区间也算重贴,所以是<=)

这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了

56.合并区间

知道如何判断重复之后,剩下的就是合并了,如何去模拟合并区间呢?

其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到result数组里就可以了。如果没有合并就把原区间加入到result数组。

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        vector<vector<int>> result;
        if (intervals.size() == 0) return result; // 区间集合为空直接返回
        // 排序的参数使用了lambda表达式
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});

        // 第一个区间就可以放进结果集里,后面如果重叠,在result上直接合并
        result.push_back(intervals[0]); 

        for (int i = 1; i < intervals.size(); i++) {
            if (result.back()[1] >= intervals[i][0]) { // 发现重叠区间
                // 合并区间,只更新右边界就好,因为result.back()的左边界一定是最小值,因为我们按照左边界排序的 直接修改装进结果集里的右边界,不用弹出再修改装入
                result.back()[1] = max(result.back()[1], intervals[i][1]); 
            } else {
                result.push_back(intervals[i]); // 区间不重叠 
            }
        }
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 时间复杂度: O(nlogn)
  • 空间复杂度: O(logn),排序需要的空间开销

738. 单调递增的数字

stoi(str); //将字符串转换成数字

是从前向后遍历还是从后向前遍历呢?

举个例子,数字:332,从前向后遍历的话,那么就把变成了329,此时2又小于了第一位的3了,真正的结果应该是299。

那么从后向前遍历,就可以重复利用上次比较得出的结果了,从后向前遍历332的数值变化为:332 -> 329 -> 299

确定了遍历顺序之后,那么此时局部最优就可以推出全局,找不出反例,试试贪心。

class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        string str = to_string(n);

        //flag用来标记从哪个下标开始使得往后的数都是9
        int flag = str.size();      //初始化为数组长度可防止第二个for的i未被赋值

        for(int i = str.size() - 1; i > 0; i--){
            //非单调递增
            if(str[i - 1] > str[i]){
                flag = i;
                str[i - 1]--;
            }
        }
        for(int i = flag; i < str.size(); i++){
            str[i] = '9';   //flag往后的数都是9
        }

        return stoi(str); //字符串改成数字
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 时间复杂度:O(n),n 为数字长度
  • 空间复杂度:O(n),需要一个字符串,转化为字符串操作更方便

968. 监控二叉树

代码随想录–监控二叉树

思路

这道题目首先要想,如何放置,才能让摄像头最小的呢?

从题目中示例,其实可以得到启发,我们发现题目示例中的摄像头都没有放在叶子节点上!

这是很重要的一个线索,摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。

所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。

那么为什么不从头结点开始看起呢,为啥要从叶子节点看呢?

因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。

所以我们要从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少!

此时,大体思路就是从底到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。

此时这道题目还有两个难点:

  1. 二叉树的遍历
  2. 如何隔两个节点放一个摄像头

#确定遍历顺序

在二叉树中如何从低向上推导呢?

可以使用后序遍历也就是左右中的顺序,这样就可以在回溯的过程中从下到上进行推导了。

注意我们取左孩子的返回值,右孩子的返回值,用于后面推导中间节点的状态

#如何隔两个节点放一个摄像头

此时需要状态转移的公式,大家不要和动态的状态转移公式混到一起,本题状态转移没有择优的过程,就是单纯的状态转移!

来看看这个状态应该如何转移,先来看看每个节点可能有几种状态:

有如下三种,我们分别有三个数字来表示:

  • 0:该节点无覆盖
  • 1:本节点有摄像头
  • 2:本节点有覆盖

一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。

因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢?

回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。

那么空节点不能是无覆盖的状态,因为这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。

所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了。

接下来就是递推关系。

那么递归的终止条件应该是遇到了空节点,此时应该返回2(有覆盖)。

代码如下:

// 空节点,该节点有覆盖
if (cur == NULL) return 2;
  • 1
  • 2

递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。

主要有如下四类情况:

  • 情况1:左右节点都有覆盖

左孩子有覆盖,右孩子有覆盖,那么此时中间节点应该就是无覆盖的状态了。

如图:

968.监控二叉树2

代码如下:

// 左右节点都有覆盖
if (left == 2 && right == 2) return 0;
  • 1
  • 2
  • 情况2:左右节点至少有一个无覆盖的情况

如果是以下情况,则中间节点(父节点)应该放摄像头:

  • left == 0 && right == 0 左右节点无覆盖
  • left == 1 && right == 0 左节点有摄像头,右节点无覆盖
  • left == 0 && right == 1 左节点有无覆盖,右节点摄像头
  • left == 0 && right == 2 左节点无覆盖,右节点覆盖
  • left == 2 && right == 0 左节点覆盖,右节点无覆盖

这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。

此时摄像头的数量要加一,并且return 1,代表中间节点放摄像头。

代码如下:

if (left == 0 || right == 0) {
    result++;
    return 1;
}
  • 1
  • 2
  • 3
  • 4
  • 情况3:左右节点至少有一个有摄像头

如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)

  • left == 1 && right == 2 左节点有摄像头,右节点有覆盖
  • left == 2 && right == 1 左节点有覆盖,右节点有摄像头
  • left == 1 && right == 1 左右节点都有摄像头

代码如下:

if (left == 1 || right == 1) return 2;
  • 1

从这个代码中,可以看出,如果left == 1, right == 0 怎么办?其实这种条件在情况2中已经判断过了,如图:

968.监控二叉树1

这种情况也是大多数同学容易迷惑的情况。

  • 情况4:头结点没有覆盖

以上都处理完了,递归结束之后,可能头结点 还有一个无覆盖的情况,如图:

968.监控二叉树3

所以递归结束之后,还要判断根节点,如果没有覆盖,result++,代码如下:

if (traversal(root) == 0) { // root 无覆盖
    result++;
}
  • 1
  • 2
  • 3

以上四种情况我们分析完了,整体代码如下:

/**
 * 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);  // 右

        //情况判断的顺序很重要,这也是hard处所在
        // 情况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;

        // 以上代码我没有使用else,主要是为了把各个分支条件展现出来,这样代码有助于读者理解
        // 这个 return -1 逻辑不会走到这里。
        return -1;
    }

public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        // 情况4:有可能根节点没被覆盖,这是个小坑
        if (traversal(root) == 0) { // root 无覆盖
            result++;
        }
        return result;
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 时间复杂度: O(n),需要遍历二叉树上的每个节点
  • 空间复杂度: O(n)

本题的难点首先是要想到贪心的思路,然后就是遍历和状态推导。

在二叉树上进行状态推导,其实难度就上了一个台阶了,需要对二叉树的操作非常娴熟。

这道题目是名副其实的hard,大家感受感受。

贪心总结

贪心总结.png

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/254208
推荐阅读
相关标签
  

闽ICP备14008679号