C++ 数据结构与算法(十)(贪心算法)

贪心算法

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

如何验证可不可以用贪心算法呢?

贪心没有套路,说白了就是常识性推导加上举反例
手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。

一般步骤:

  1. 将问题分解为若干个子问题
  2. 找出适合的贪心策略
  3. 求解每一个子问题的最优解
  4. 将局部最优解堆叠成全局最优解

455. 分发饼干 ●

排序+贪心

  • 大饼干喂饱大胃口

大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的

这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
C++ 数据结构与算法(十)(贪心算法)_第1张图片

class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());   // 胃口排序
        sort(s.begin(), s.end());   // 饼干尺寸排序
        int ans = 0;
        int indexS = s.size()-1;    // 从后往前遍历
        for(int i = g.size()-1; i >= 0; --i){	// for 遍历胃口
            if(indexS < 0) break;   
            if(s[indexS] >= g[i]){  // 贪心,大饼干喂饱大胃口
                ++ans;
                --indexS;  // 该饼干满足该胃口,饼干、胃口往左移,否则跳过该胃口,饼干不移动
            }
        }
        return ans;
    }
};
  • 小饼干喂饱小胃口
class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());	// 胃口排序
        sort(s.begin(), s.end());	// 饼干尺寸排序
        int ans = 0;
        int indexG = 0;				// 从前往后遍历
        for(int i = 0; i < s.size(); ++i){	// for 遍历饼干
            if(indexG == g.size()) break;   
            if(s[i] >= g[indexG]){	// 贪心,小饼干喂饱小胃口
                ++ans;
                ++indexG;	// 该饼干满足该胃口,饼干、胃口往右移,否则跳过该饼干,胃口不移动
            }
        }
        return ans;
    }
};

376. 摆动序列 ●●

如果连续数字之间的严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
`
子序列 可以通过从原始序列中删除(也可以不删除)元素来获得,剩下的元素保持其原始顺序
``
给你一个整数数组 nums ,返回 nums 中 最长摆动子序列的长度

输入: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)。

1. 贪心算法

C++ 数据结构与算法(十)(贪心算法)_第2张图片
局部最优删除单调坡度中间段的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。

整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。

实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了。

  • 时间复杂度: O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1)
class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        int size = nums.size();
        if(size <= 1) return size;
        int ans = 1;
        int pre_diff = nums[0] - nums[1];	// 前两个数的差值的相反数,0还是0
        for(int i = 1; i < size; ++i){		// 从下标1开始遍历
            int curr_diff = nums[i] - nums[i-1];	// 当前差值
            // 判断摆动序列,pre = 0只能在开始时才可能出现
            if(pre_diff * curr_diff < 0 || (pre_diff == 0 && curr_diff != 0)){		
               ++ans; 
               pre_diff = curr_diff;
            }	// 非摆动序列,则跳过不统计,相当于删除操作
        }
        return ans;
    }
};

上面写法将第一个差值取相反数进行判断,
下面写法则设置一个虚拟初始差值为0进行判断,思路更清晰。
C++ 数据结构与算法(十)(贪心算法)_第3张图片

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 ((curDiff > 0 && preDiff <= 0) || (preDiff >= 0 && curDiff < 0)) {
                result++;
                preDiff = curDiff;
            }
        }
        return result;
    }
};

2. 动态规划

53. 最大子数组和 ●

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

1. 暴力遍历

第一层 for 就是设置起始位置,第二层 fo r循环遍历数组寻找最大值,超出时间限制

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        if(nums.size() == 0) return 0;
        int ans = INT32_MIN;
        int count = 0;
        for(int i = 0; i < nums.size(); ++i){       // 第一层遍历,以nums[i]为起点
            int count = 0;  
            for(int j = i; j < nums.size(); ++j){   // 第二层遍历,以nums[i]为起点的连续数组和
                count += nums[j];
                ans = count > ans ? count : ans; 
            }
        }
        return ans;
    }
};

2. 贪心算法

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

这相当于是暴力解法中的不断调整最大子序和区间的起始位置
区间的终止位置,其实就是如果count取到最大值了,及时记录下来,变相的算是调整了终止位置。

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

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

  • 时间复杂度: O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1)
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        if(nums.size() == 0) return 0;
        int ans = INT32_MIN;	// 取nums[0]亦可
        int count = 0;
        for(int i = 0; i < nums.size(); ++i){
            count += nums[i];
            if(count > ans) ans = count; // 取区间累计的最大值(相当于不断确定最大子序终止位置)
            if(count <= 0) count = 0;	// 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
        }
        return ans;
    }
};

3. 动态规划

  1. dp[i]:表示以nums[i]结尾的最大连续数组和;
  2. dp[i] = max(nums[i], dp[i-1] + nums[i]); 两种选择:以 nums[i] 结尾 或 重新开始 (dp[i-1] < 0)
  3. dp[0] = nums[0];
  4. 从前往后遍历,遍历时用ans变量保存最大和
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, 0);           // dp[i]表示以nums[i]结尾的最大连续数组和
        dp[0] = nums[0];
        int ans = nums[0];
        for(int i = 1; i < n; ++i){     // 从前往后遍历
            dp[i] = max(nums[i], dp[i-1] + nums[i]);
            ans = max(dp[i], ans);      // 保存最大值
        }
        return ans;
    }
};
  • 空间复杂度优化 O ( 1 ) O(1) O(1)
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        int pre = nums[0];
        int ans = nums[0];
        for(int i = 1; i < n; ++i){     // 从前往后遍历
            pre = max(nums[i], pre + nums[i]);
            ans = max(pre, ans);      // 保存最大值
        }
        return ans;
    }
};

4. 分治

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

给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。
在每一天,你可能会决定购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以购买它,然后在 同一天 出售。
返回 你能获得的 最大 利润

输入: prices = [7,1,5,3,6,4]
输出: 7;1->5 + 3->6 = 4 + 3 = 7.

1. 贪心算法(有收益的情况下才持有)

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int ans = 0;
        int bought = 0;
        int cost = 0;
        for(int i = 0; i < prices.size()-1; ++i){
            if(prices[i+1] >= prices[i] && bought == 0){        // 正收益且未维持时,在上一次购入股票
                cost = prices[i];
                bought = 1;
            }else if(prices[i+1] < prices[i] && bought == 1){   // 负收益且已持有,在上一次卖掉
                ans += prices[i] - cost;
                bought = 0;
            }
        }
        if(bought == 1) ans += prices.back() - cost;    // 到最后一天依然持有,则要算上该次利润
        return ans;
    }
};
  • 精简代码
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int ans = 0;
        for(int i = 0; i < prices.size()-1; ++i){
            ans += max(prices[i+1]-prices[i], 0);
        }
        return ans;
    }
};

2. 动态规划

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        vector<int> dp(n ,0);
        for(int i = 1; i < n; ++i){
            dp[i] = max(dp[i-1], dp[i-1] + prices[i] - prices[i-1]);

        }
        return dp[n-1];
    }
};

55. 跳跃游戏 ●●

给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度
判断你是否能够到达最后一个下标

在每一个位置跳几步不重要,重要的是最远能到达的位置,这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

C++ 数据结构与算法(十)(贪心算法)_第4张图片
局部最优解:每次取最大跳跃步数(取最大覆盖范围),
整体最优解:最后得到整体最大覆盖范围,看是否能到终点。

class Solution {
public:
    bool canJump(vector<int>& nums) {
        if(nums.size() == 1) return true;
        int nextMax = 0;	// 代表当前最大能覆盖的下标值
        for(int i = 0; i <= nextMax; ++i){				// i 遍历限制在nextMax之内
            nextMax = max(i + nums[i], nextMax);		// 不断更新可以到达的最大下标nextMax
            if(nextMax >= nums.size()-1) return true;	// 判断是否能够覆盖到最后的值
        }
        return false;
    }
};

45. 跳跃游戏 II ●●

给你一个非负整数数组 nums ,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置
假设你总是可以到达数组的最后一个位置

以最小的步数增加最大的覆盖范围,即不断更新下一步能到的最远距离,直到覆盖范围覆盖了终点。

1. 在循环中判断

C++ 数据结构与算法(十)(贪心算法)_第5张图片
移动下标达到了当前覆盖的最远距离下标且不能到达终点时,步数就要加一,来增加覆盖距离。最后的步数就是最少步数。

除了用currMax表示当前步数下能到达的最远距离,还要用nextMax来不断更新下一步能到的最远距离。

class Solution {
public:
    int jump(vector<int>& nums) {
        int nextMax = 0;
        int ans = 0;
        int currMax = 0;
        for(int i = 0; i < nums.size(); ++i){
            nextMax = max(nextMax, i + nums[i]);// 下一步能到的最远距离
            if(i == currMax){   
                if(currMax < nums.size()-1){    // 已经走到该步的最远距离且还没到终点,需要走下一步
                    ++ans;                      // 走下一步
                    currMax = nextMax;          // 更新能到达的最远距离
                    if(nextMax >= nums.size()-1) break; // 最远距离覆盖终点,返回
                }else break;                    // 已经走到该步的最远距离并到终点,不需要走下一步
            }  
        }
        return ans;
    }
};

2. 到达该步最大距离,不判断,直接走下一步,直到到达终点前一个位置

移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不考虑是不是终点的情况。

想要达到这样的效果,只要让移动下标,最大只能移动到nums.size - 2的地方就可以了。

因为当移动下标指向nums.size - 2时,有两种情况:

  1. 如果移动下标等于当前覆盖最大距离下标, 需要再走一步(即ans++),因为最后一步一定是可以到的终点。(题目假设总是可以到达数组的最后一个位置)
  2. 如果移动下标不等于当前覆盖最大距离下标,说明当前覆盖最远距离就可以直接达到终点了,不需要再走一步。
C++ 数据结构与算法(十)(贪心算法)_第6张图片
C++ 数据结构与算法(十)(贪心算法)_第7张图片
class Solution {
public:
    int jump(vector<int>& nums) {
        int nextMax = 0;
        int ans = 0;
        int currMax = 0;
        for(int i = 0; i < nums.size()-1; ++i){ // 逐个遍历到【终点前一个位置】
            nextMax = max(nextMax, i + nums[i]);// 下一步能到的最远距离
            if(i == currMax){                   // 到达该步的最远距离
                ++ans;                          // 即走下一步
                currMax = nextMax;              // 更新能到达的最远距离
            }   // 如果currMax大于nums.size()-2,即该步覆盖了终点,则不会执行if内语句,不走下一步,直接跳出for循环
        }
        return ans;
    }
};

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

给你一个整数数组 nums 和一个整数 k ,按以下方法修改该数组:
选择某个下标 i 并将 nums[i] 替换为 -nums[i] 。
重复这个过程恰好 k 次。可以多次选择同一个下标 i 。
以这种方式修改数组后,返回数组 可能的最大和 。

局部最优:每次反转最小数;
整体最优:整个数组和达到最大。

class Solution {
public:
    int largestSumAfterKNegations(vector<int>& nums, int k) {
        sort(nums.begin(), nums.end()); // 排序
        int ans = 0;
        for(int i = 0; i < nums.size(); ++i){
            if(nums[i] < 0 && k > 0){   
                nums[i] *= -1;          // 在k次范围内把负数先反转掉
                --k;
            }
            ans += nums[i];
        }
        // k < 0 时,反转完成,不处理
        // 【优化:用一个变量记录一下取反之后最小的正整数 可以省去第二次排序】
        if(k > 0){                  // 全部变为正数,且还剩余反转次数
            sort(nums.begin(), nums.end());
            if(k % 2 != 0){         // k为偶数时,两次反转相互抵消;
                ans -= 2*nums[0];   // k为奇数时,反转最小值,计算总和。
            }
        }
        return ans;
    }
};

134. 加油站 ●●

在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空
给定两个整数数组 gas 和 cost ,如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。

1. 暴力解法

  • 时间复杂度: O ( n 2 ) O(n^2) O(n2),超出时间限制。
  • 空间复杂度: O ( 1 ) O(1) O(1)
class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        vector<int> rest(gas.size(), 0);
        for(int i = 0; i < gas.size(); ++i){
            rest[i] = gas[i] - cost[i];			// 记录所有油站的剩余油量
        }
        for(int i = 0; i < gas.size(); ++i){
            if(gas[i] > 0 && rest[i] >= 0){		// 油站油量>0 且 剩余>=0 则可以是起点
                int currRest = 0;
                for(int j = i; j < gas.size() + i; ++j){
                    currRest += rest[j % gas.size()];
                    if(currRest < 0) break;		// 剩余量<0 退出
                }
                if(currRest >= 0) return i;		// 剩余量>=0 返回
            }
        }
        return -1;
    }
};

2. 全局贪心

直接从全局进行贪心选择,首先从起点0开始遍历,分三种情况如下:

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int min = INT_MAX;  // 剩余总油量最低值
        int currSum = 0;    // 一个循环的油量剩余值
        
        for (int i = 0; i < gas.size(); i++) {
            int rest = gas[i] - cost[i]; // 该站剩余油量
            currSum += rest;
            if(currSum < min) min = currSum;
        }
		// 1、循环油量剩余值小于零,无法走完
        if(currSum < 0) return -1;  
        // 2、总油量够,且最低剩余油量够,则返回起点0
        if(min >= 0) return 0;      
        // 3、0为起点时不满足,从后往前遍历,当最低剩余油量能够满足时则返回起点i
        for(int i = gas.size()-1; i > 0; --i){
            int rest = gas[i] - cost[i]; // 该站剩余油量
            min += rest;
            if(min >= 0) return i;
        }
        return -1;
    }
};
  • 另一个思路:
    总油量满足条件的前提下,前缀和最小的那个点为终点,即其下一个点为起点,因为在总油量满足的前提下,最小前缀和之后的油量和必定大于0,且能够满足整个周期。
class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int sum = 0;            // 全程剩余油量
        int minSum = INT_MAX;   // 最小前缀和
        int minIndex = 0;       // 最小前缀和节点,终点
        for(int i = 0; i < gas.size(); ++i){
            int rest = gas[i] - cost[i];
            sum += rest;
            if(sum < minSum){
                minSum = sum;
                minIndex = i;
            }
        }   
        if(sum < 0) return -1;  // 总油量不满足条件
        return (minIndex + 1) % gas.size(); // 起点为0时,取模
    }
};

3. 局部贪心

C++ 数据结构与算法(十)(贪心算法)_第8张图片
i从0开始累加rest[i],和记为currSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,起始位置从i+1算起,再从0计算currSum。

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

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

而且 j 之前出现了多少负数,j 后面就会出现多少正数,因为耗油总和是大于零的前提我们已经确定了一定可以跑完全程totalSum >= 0)。

局部最优:当前累加rest[i]的和currSum一旦小于0,则更新起始位置为下一个站i+1开始。
全局最优:找到可以跑一圈的起始位置。

  • 时间复杂度: O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1)
class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int currSum = 0;
        int totalSum = 0;
        int start = 0;
        for (int i = 0; i < gas.size(); i++) {
            int rest = gas[i] - cost[i]; // 该站剩余油量
            currSum += rest;    // 刷新起点后的总剩余油量
            totalSum += rest;   // 全周期的总剩余油量
            if(currSum < 0){    // 总剩余油量小于零,刷新下一个起点
                currSum = 0;
                start = i + 1;
            }
        }
        if(totalSum < 0) return -1; // 全周期的总剩余油量不满足,返回-1
        return start;
    }
};

135. 分发糖果 ●●●

n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
每个孩子至少 1 个糖果
相邻两个孩子评分更高的孩子会获得更多的糖果
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目

输入:ratings = [1, 2, 2]
输出:4(nums = [1, 2, 1])

1. 两次贪心遍历

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

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

这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果
C++ 数据结构与算法(十)(贪心算法)_第9张图片

class Solution {
public:
    int candy(vector<int>& ratings) {
        vector<int> nums(ratings.size(), 1);    // 对应糖果数组
        // 从左往右遍历,比较与左边孩子的评分
        for(int i = 1; i < ratings.size(); ++i){    
            if(ratings[i] > ratings[i-1]){
                nums[i] = nums[i-1] + 1;
            }
        }
        // 从右往左遍历,比较与右边孩子的评分
        for(int i = ratings.size() - 2; i >= 0; --i){  
            // 注意当同时满足nums[i] <= nums[i+1]才更新该孩子的糖果数 
            if(ratings[i] > ratings[i+1] && nums[i] <= nums[i+1]){  
                nums[i] = nums[i+1] + 1;
            }
        }  
        int sum = 0;
        for(int i : nums) sum += i;
        return sum;
    }
};

860. 柠檬水找零 ●

在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        int fiveCount = 0;          // 剩余5元数量
        int tenCount = 0;           // 剩余10元数量
        for(int num : bills){
            if(num == 5){           // 5元直接收账
                ++fiveCount;
            }else if(num == 10){    // 10元找零5元
                ++tenCount;
                --fiveCount;
            }else{
                if(tenCount){       // 20元优先找零10元+5元
                    --tenCount;
                }else{
                    fiveCount -= 2;
                }
                --fiveCount;
            }
            if(fiveCount < 0) return false; // 无法找零
        }
        return true;
    }
};

406. 根据身高重建队列 ●●

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。
每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

本题有两个维度 h 和 k,看到这种题目一定要想如何确定一个维度,然后在按照另一个维度重新排列。

如果按照 k 来从小到大排序,排完之后,会发现 k 的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。

那么按照身高h来排序呢,身高从大到小排,让高个子在前面。(身高相同的话则 k 小的站前面)

此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高

然后按照顺序,以 k 为下标重新插入队列,后序插入节点也不会影响前面已经插入的节点,因为后插入的身高小于已插入的身高,最终按照 k 的规则完成了队列。

局部最优:优先按身高高的people的 k 来插入。插入操作过后的people满足队列属性
全局最优:最后都做完插入操作,整个队列满足题目队列属性

排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]],按 k 插入的过程为:

  1. 插入[7,0]:[[7,0]]
  2. 插入[7,1]:[[7,0],[7,1]]
  3. 插入[6,1]:[[7,0],[6,1],[7,1]]
  4. 插入[5,0]:[[5,0],[7,0],[6,1],[7,1]]
  5. 插入[5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
  6. 插入[4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
  • 时间复杂度: O ( n l o g n + n 2 ) O(nlog n + n^2) O(nlogn+n2)
  • 空间复杂度: O ( n ) O(n) O(n)
class Solution {
public:
    static bool comp(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(), comp);   // 排序,身高由大到小,前面人数由小到大
        vector<vector<int>> ans;
        for(auto person : people){                  // 按顺序,在相应位置插入
            ans.insert(ans.begin() + person[1], person);
        }
        return ans;
    }
};

遍历过程中,vector 的插入操作消耗较大,可利用链表 list 进行优化。

class Solution {
public:
    static bool comp(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(), comp);   // 排序,身高由大到小,前面人数由小到大
        list<vector<int>> ans;						// list底层是链表实现,插入效率比vector高的多
        for(auto person : people){                  // 按顺序,在相应位置插入
            int pos = person[1];
            std::list<vector<int>>::iterator iter = ans.begin();	// 迭代器
            while(pos--){
                ++iter;		// 寻找插入位置
            }
            ans.insert(iter, person);
        }
        return vector<vector<int>>(ans.begin(), ans.end());
    }
};

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

有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend之间的气球。你不知道气球的确切 y 坐标
一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。
给你一个数组 points ,返回引爆所有气球所必须射出的 最小 弓箭数

局部最优:当气球出现重叠,一起射,所用弓箭最少。
全局最优:把所有气球射爆所用弓箭最少。

将气球按照左边界从小到大进行排序,遍历寻找重叠的气球,并更新重叠气球最小右边界

  • 时间复杂度: O ( n log ⁡ n ) O(n\log n) O(nlogn),因为有一个快排
  • 空间复杂度: O ( 1 ) O(1) O(1)
    C++ 数据结构与算法(十)(贪心算法)_第10张图片
class Solution {
public:
    static bool cmp(vector<int> a, vector<int> b){
        return a[0] < b[0];         // 按照xstart从小到大排序
    }
    
    int findMinArrowShots(vector<vector<int>>& points) {
        sort(points.begin(), points.end(), cmp);
        int ans = 1;            
        int arrow = points[0][1];   // 第一支箭
        cout << arrow << endl;
        for(int i = 1; i < points.size(); ++i){
            if(points[i][0] > arrow){   // 当前新气球的左边界不在重叠气球范围内
                ++ans;                  // 则增加一支箭
                arrow = points[i][1];
                cout << arrow << endl;
            }else{                      // 当前新气球的左边界在重叠气球的最小右边界范围内
                arrow = min(arrow, points[i][1]);   // 更新该组重叠气球的最小右边界
            }
        }
        return ans;
    }
};

435. 无重叠区间 ●●

给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。 返回 需要移除区间的最小数量,使剩余区间互不重叠 。

C++ 数据结构与算法(十)(贪心算法)_第11张图片
按照右边界排序,从左向右记录非交叉区间的个数。

局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉。
全局最优:选取最多的非交叉区间。

  • 时间复杂度: O ( n l o g n ) O(nlog n) O(nlogn),有一个快排
  • 空间复杂度:O(1)
class Solution {
public:
	// 参数要加 & 引用调用,否则超出时间限制
    static bool cmp(const vector<int>& a, const vector<int>& b){
        return a[1] < b[1];
    }

    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);
        int ans = 0;
        int n = intervals.size();
        int Right = intervals[0][1];
        for(int i = 1; i < n; i++){
            if(intervals[i][0] < Right){ // 重叠
                ans++;      // 移除一个右边界更大的,因此右边界不变
            }else{
                Right = intervals[i][1];// 不重叠,更新右边界
            }
        }
        return ans;
    }
};

763. 划分字母区间 ●●

字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
输入:S = “abaedfe”
输出:[3, 4]

  • 用一个数组 pos 记录字母所在的区间位置,从左往右遍历,当出现重复字母时,合并中间的字母个数,并更新其中字母的区间位置。
class Solution {
public:
    vector<int> partitionLabels(string s) {
        vector<int> pos(26, -1);    // 字母s[i]在第几个区间,下标从0开始
        vector<int> ans;
        for(int i = 0; i < s.length(); ++i){
            if(pos[s[i]-'a'] == -1){               // 新的字母出现
                ans.emplace_back(1);                // 区间数+1
                pos[s[i]-'a'] = ans.size() - 1;    // 记录该字母所在区间位置
            }else if(pos[s[i]-'a'] == ans.size() - 1){
                ++ans[pos[s[i]-'a']];              // 该字母在最后一个区间
            }else{                                  // 该字母在前面区间,需合并处理
                for(int j = pos[s[i]-'a'] + 1; j < ans.size(); ++j){
                    ans[pos[s[i]-'a']] += ans[j];  // 将后面区间的字母数累加
                }
                ans.resize(pos[s[i]-'a'] + 1);     // 区间数重置大小
                ++ans[pos[s[i]-'a']];
                for(int j = 0; j < 26; ++j){        // 更新所有字母的所在区间位置
                    if(pos[j] >= pos[s[i]-'a']){   // 若字母的区间位置>当前字母所在的区间位置
                        pos[j] = pos[s[i]-'a'];   // 则会被合并
                    }
                }
            }
        }
        return ans;
    }
}; 
  • 一次遍历记录所有字母的最远位置,第二次遍历更新区间的最远位置(右边界),当区间右边界为当前下标时,进行一次分割操作,并更新左边界。
    C++ 数据结构与算法(十)(贪心算法)_第12张图片
class Solution {
public:
    vector<int> partitionLabels(string s) {
        vector<int> pos(26, -1);    // 字母s[i]在第几个区间,下标从0开始
        vector<int> ans;
        for(int i = 0; i < s.length(); ++i){
            pos[s[i]-'a'] = i;      // 记录所有字母的最远位置       
        }
        int left = 0;   // 左边界
        int right = 0;  // 右边界
        for(int i = 0; i < s.length(); ++i){
            right = max(pos[s[i]-'a'], right);  // 更新区间的最远位置(右边界)
            if(right == i){         // 当区间右边界为当前下标时,为分割点
                ans.emplace_back(right - left + 1);
                left = i + 1;   // 更新左边界
            }
        }
        return ans;
    }
}; 

56. 合并区间 ●●

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

按照左边界排序,排序后
局部最优:每次合并都取最大的右边界,这样就可以合并更多的区间了;
整体最优:合并所有重叠的区间。
C++ 数据结构与算法(十)(贪心算法)_第13张图片

  • 时间复杂度: O ( n log ⁡ n ) O(n\log n) O(nlogn) ,有一个快排
  • 空间复杂度: O ( 1 ) O(1) O(1)
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);  // 按左边界正序排序
        int left = intervals[0][0];
        int right = intervals[0][1];
        vector<vector<int>> ans;
        ans.emplace_back(vector<int> {left, right});    // 记录第一个数组区间
        for(int i = 1; i < intervals.size(); ++i){
            if(intervals[i][0] <= right){               // 重叠
                if(intervals[i][1] > right){            
                    right = intervals[i][1];            // 更新右边界
                    ans.back()[1] = right;
                }
            }else{
                left = intervals[i][0];                 // 不重叠
                right = intervals[i][1];                // 记录新区间
                ans.emplace_back(vector<int> {left, right});
            }
        }
        return ans;
    }
};

738. 单调递增的数字 ●●

当且仅当每个相邻位数上的数字 x 和 y 满足 x <= y 时,我们称这个整数是单调递增的。
给定一个整数 n ,返回 小于或等于 n 的最大数字,且数字呈 单调递增 。

1. 暴力遍历(超时)

class Solution {
public:
    bool isIncrease(int n){		// 判断是否为递增数
        int backNum = 10;
        do{
            if(n % 10 > backNum){
                return false;
            }
            backNum = n % 10;
            n = n / 10;
        }while(n / 10 != 0 || n % 10 != 0);
        return true;
    }
    
    int monotoneIncreasingDigits(int n) {
        for(int i = n; i >=0; --i){
            if(isIncrease(i)) return i;
        }
        return 0;
    }
};

贪心

局部最优:遇到 str[i - 1] > str[i] 的情况,让str[i - 1]–,然后str[i]给为9,可以保证这两位变成最大单调递增整数。

全局最优:得到小于等于N的最大单调递增的整数。

从后往前遍历,当str[i - 1] > str[i]时,更新需要更改为 9 的起始位置 pos,并将str[i - 1]--

  • 时间复杂度: O ( n ) O(n) O(n),n 为数字长度
  • 空间复杂度: O ( n ) O(n) O(n),需要一个字符串,转化为字符串操作更方便
class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        string str = to_string(n);
        int pos = str.length();
        for(int i = str.length() - 1; i >= 1; --i){
            if(str[i-1] > str[i]){
                pos = i;
                str[i-1] -= 1;
            }
        }
        for(int i = pos; i < str.length(); ++i){
            str[i] = '9';
        }

        return stoi(str);
    }
};

714. 买卖股票的最佳时机含手续费 ●●

给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。

1. 贪心

将手续费放在买入时进行计算costPrice,初始化为 costPrice = prices[0] + fee;

从第二天开始遍历,

  1. 如果 prices[i] + fee < costPrice,那么更新 costPrice,选择此时买入(假买);
  2. 如果 prices[i] > costPrice,能够获利,此时我们直接计算此时卖出的利润,同时更新 costPrice = prices[i],提供反悔操作(假卖),看成手上持有成本为 prices[i] (继续持有,不含手续费)的股票。如果下一天股票价格继续上涨,我们则再获得 prices[i+1]−prices[i] 的利润,相当于第 i 天继续持有,而在下一天卖出的利润;
  3. 如果prices[i] 落在区间 [cost−fee, cost] 内,它的价格没有低到我们放弃手上的股票去选择它,也没有高到我们可以通过卖出获得收益,因此我们不进行任何操作。

核心思想:假买假卖;

在下一次卖出之前,记录的costPrice都不是真正的买入;

而当我们卖出一支股票(结算利润)时,我们就立即获得了以相同价格并且免除手续费买入一支股票的权利,在下一次买入之前,也不是真正的卖出

在遍历完整个数组 prices 之后,我们就得到了最大的总收益。

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        int costPrice = prices[0] + fee;
        int ans = 0;
        for(int i = 1; i < prices.size(); ++i){ // 对每一天的股票进行监控操作

            if(prices[i] + fee < costPrice){
                 costPrice = prices[i] + fee;    // 成本价(包括手续费)更低,此时买入
             }
             
             // prices[i] 落在区间 [costPrice − fee, costPrice] 内,不操作
            if(prices[i] <= costPrice && prices[i] + fee >= costPrice) continue;

            // 获得利润,计算卖出ans,同时costPrice更新,若有更低价,则卖出成立,否则为继续持有
            if(prices[i] > costPrice){
                ans += prices[i] - costPrice;   // 结算当天利润
                costPrice = prices[i];          // 提供反悔操作,即根据之后的价格可选择继续持有而不是真正卖出
             }
        }
        return ans;
    }
};

2. 动态规划

将手续费在股票卖出时进行计算

  1. 一维滚动数组,pre[0]表示未持有 i 时的利润,pre[1]表示持有 i 时的利润;
  2. 持有和未持有的两个状态根据前一天的结果进行计算,
    ---- 未持有 i : i-1 未持有,i 不动; i-1 持有,i 卖出(包含手续费)
    pre[0] = max(pre[0], pre[1] + prices[i] - fee);
    ---- 持有 i : i-1 持有,i 不动; i-1 未持有,j 买入
    pre[1] = max(pre[1], pre0 - prices[i]);
  3. pre[0] = 0;
    pre[1] = -prices[0];
  4. 从前往后遍历
  5. 最后输出最后一天未持有的利润金额。
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        vector<int> pre(2, 0);
        pre[1] = -prices[0];
        for(int i = 1; i < prices.size(); ++i){
            int pre0 = pre[0];  // 暂存pre[0]
            // 未持有 i: i-1 未持有,i 不动;  i-1 持有,i 卖出(包含手续费)
            pre[0] = max(pre[0], pre[1] + prices[i] - fee);
            // 持有 i:  i-1 持有,i 不动;   i-1 未持有,i 买入
            pre[1] = max(pre[1], pre0 - prices[i]);     // 可多次买卖
        }
        return pre[0];	// max(pre[0], pre[1]);
    }
};

968. 监控二叉树 ●●●

给定一个二叉树,我们在树的节点上安装摄像头。
节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。
计算监控树的所有节点所需的最小摄像头数量。

示例中的摄像头都没有放在叶子节点上,

摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。

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

此外我们应从下往上遍历,因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。

局部最优:让叶子节点的父节点安摄像头,所用摄像头最少;
整体最优:全部摄像头数量所用最少!

此时,大体思路就是从低到上,先给叶子节点父节点放个摄像头,然后从左、右子树的状态,推导出父节点的状态,隔两个节点放一个摄像头,直至到二叉树头结点。

如何判断某个节点是否需要摄像头?

此时需要进行状态转移(相比动态规划的状态转移无择优过程),节点有三种状态,用数字表示为:
0 :无覆盖,
1 :有摄像头,
2 :无摄像头但覆盖。

在遍历节点时主要有四种情况,

  1. 左右节点都有覆盖 2,那么此时该节点应返回无覆盖状态 0 ,等待上一个节点的覆盖;
  2. 左右节点至少有一个无覆盖 0,那么此时该节点应该放置摄像头,返回 1
  3. 左右节点至少有一个有摄像头 1,那么此时该节点应返回覆盖状态 2;
  4. 根结点没有被覆盖 0,则放置摄像头,ans++。

根据以上规则,在遍历到空节点时,应该将空节点的状态返回为 覆盖状态 2,否则叶子节点将被放置摄像头(空节点为0),或叶子节点的父节点将不放置摄像头(空节点为1)。
C++ 数据结构与算法(十)(贪心算法)_第14张图片

class Solution {
public:
    int traversal(TreeNode* root, int& ans) {   // 状态返回值:0表示无覆盖,1表示有摄像头,2表示无摄像头但覆盖
        if(root == nullptr) return 2;           // 空节点需要被覆盖
        int left = traversal(root->left, ans);  // 左节点状态
        int right = traversal(root->right, ans);// 右节点状态

        if(left == 0 || right == 0){         
            ++ans;
            return 1;   // 该节点放置摄像头,覆盖子节点
        }
        if(left == 2 && right == 2){	// 包含叶子节点的情况
            return 0;	// 左右子节点被覆盖,置0,等待父节点的覆盖
        }
        if(left == 1 || right == 1){
            return 2;	// 被子节点覆盖
        }
        return -1;	// 以上判断未使用else,但已包含所有情况,不会执行到该行
    }

    int minCameraCover(TreeNode* root) {
        int ans = 0;
        if(traversal(root, ans) == 0){  
            ++ans;		// 根结点未被覆盖
        }
        return ans;
    }
};

你可能感兴趣的:(数据结构与算法,#,贪心算法,c++,数据结构,贪心算法)