【贪心算法】(二)贪心算法区间问题及进阶习题

贪心算法区间问题及进阶习题

  • 贪心算法解决区间问题
    • 跳跃问题
      • [1. 跳跃游戏](#1. 跳跃游戏)
      • [2. 跳跃游戏 Ⅱ](#2. 跳跃游戏 Ⅱ)
    • 重叠区间问题
      • [3. 用最少数量的箭引爆气球](#3. 用最少数量的箭引爆气球)
      • [4. 无重叠区间](#4. 无重叠区间)
      • [5. 划分字母区间](#5. 划分字母区间)
      • [6. 合并区间](#6. 合并区间)
  • 其他问题
    • [7. 最大子序和](#7. 最大子序和)
    • [8. 加油站](#8. 加油站)
    • [9. 监控二叉树](#9. 监控二叉树)

贪心算法解决区间问题

跳跃问题

**  对于跳跃问题这一类问题,核心在于把我跳跃过后的覆盖范围,与具体怎么跳,跳几格关系不大。**

1. 跳跃游戏

力扣链接

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置 可以跳跃的最大长度

判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false

示例 1:
输入: nums = [2,3,1,1,4]
输出: true
解释: 可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

示例 2:
输入: nums = [3,2,1,0,4]
输出: false
解释: 无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。

核心思想

  • 对于当前位置元素,跳几步无所谓,关键在于可跳的覆盖范围,即不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围

  • 那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点,每移动一个单位,就更新最大覆盖范围

  • 贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围)

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

i 每次移动只能在 cover 的范围内移动,每移动一个元素,cover 得到该元素数值(新的覆盖范围)的补充,让 i 继续移动下去。

而 cover 每次只取 max(该元素数值补充后的范围, cover 本身范围)。

如果 cover 大于等于了终点下标,直接 return true 就可以了

程序实现

cpp 复制代码
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;
    }
};

2. 跳跃游戏 Ⅱ

力扣链接

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

  • 0 <= j <= nums[i]
  • i + j < n

返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]

示例 1:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。 从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

示例 2:
输入: nums = [2,3,0,1,4]
输出: 2

核心思想

  • 本题与上述相似,但难不少,还是要看最大覆盖范围

  • 贪心的思路, 局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最少步数。

  • 从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!

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

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

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

程序实现

从图中可以看出来,就是移动下标达到了当前覆盖的最远距离下标时,步数就要加一,来增加覆盖距离。最后的步数就是最少步数。

这里还是有个特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时

  • 如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。
  • 如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。
cpp 复制代码
class Solution {
public:
    int jump(vector<int>& nums) {
		if(nums.size() == 1)
			return 0;
		int res = 0;		// 结果:跳跃次数
		int cur = 0;		// 当前覆盖范围
		int next = 0;		// 下一步覆盖范围
		for(int i = 0; i < nums.size(); i++)
		{
			next = max(next, i + nums[i]);		    // 更新下一步覆盖最远距离下标
			if(i == cur)						    // 遇到当前覆盖最远距离下标
			{
				if(cur != nums.size()-1)		    // 如果还未到终点
				{
					res++;                          // 需要走下一步
					cur = next;                     // 更新当前覆盖最远距离下标(相当于加油了)
					if(next >= nums.size() - 1)	    // 当前覆盖最远距到达集合终点,不用做res++操作了,直接结束
						break;
				}
			}
		}
		return res;
    }
};

重叠区间问题

对于重叠区间而言,存在一样的模板:

  • 首先对内部区间进行排序,使临近的的区间靠在一起(左对齐排序)

  • 当前区间左边界:points[i][0],当前区间右边界 points[i][1]

  • 上个区间左边界:points[i-1][0],上个区间右边界 points[i-1][1]

  • 区间重叠:当前区间左边界 < 上个区间右边界(视题目情况取 == ),更新当前区间的右边界。

    cpp 复制代码
    if(points[i][0] <  points[i-1][1])	// 重叠
    	points[i][1] = min(points[i-1][1], points[i][1]);

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

力扣链接

墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstartxend之间的气球。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstartxend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。给你一个数组 points ,返回引爆所有气球所必须射出的 最小 弓箭数

核心思想

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

  • 对数据根据左边界进行排序,使其相邻的数据尽可能的靠在一起,重叠部分一起射

  • 区域不重叠: 新的气球区域左边界 > 旧气球的右边界,即 points[i][0] > points[i-1][1],弓箭数+1,res++

  • 区域重叠: 新的区域左边界 <= 旧的右边界,points[i][0] <= points[i-1][1],则需要更新重叠区域的右边界,是两个区域右边界的最小值,min(points[i][0], points[i-1][1])

代码实现

cpp 复制代码
class Solution {
public:
	static bool cmp(vector<int> &point1, vector<int> &point2)
	{
		return point1[0] < point2[0];
	}
    int findMinArrowShots(vector<vector<int>>& points) {
    	if (points.size() == 0) 
			return 0;
			
		 int result = 1; // points 不为空至少需要一支箭
    	// 对气球起点从小到大排序
		sort(points.begin(), points.end(), cmp);
		for(int i = 1; i < points.size(); i++)
		{
			if(points[i][0] > points[i-1][1])	// 气球i和气球i-1不挨着 注意不是 >=
				result++;						// 需要一支箭
			else								// 气球i和气球i-1挨着
				points[i][1] = min(points[i-1][1], points[i][1]);	// 更新重叠气球最小右边界
		}
		return result;
    }
};

4. 无重叠区间

力扣链接

给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。

注意: 可以认为区间的终点总是大于它的起点,且区间 [1,2] 和 [2,3] 不重叠。

核心思想

  • 本题是一个典型的区间重叠问题,根据上述区间问题,只需对区间进行左排序,再统计重叠区间个数即可

代码实现

cpp 复制代码
class Solution {
public:
	static bool cmp(vector<int> &point1, vector<int> &point2)
	{
		if(point1[0] == point2[0])
			return point1[1] < point2[1];
		return point1[0] < point2[0];
	}
	int eraseOverlapIntervals(vector<vector<int>>& intervals) 
	{
		int res = 0;
		sort(intervals.begin(), intervals.end(), cmp);
		int end = intervals[0][1];
		for(int i = 1; i < intervals.size(); i++)
		{
			if(intervals[i][0] >= end)	// 无重叠
				end = intervals[i][1];
			else		
			{
				end = min(end,intervals[i][1]);		//有重叠 更新重叠右边界
				res++;
			}
		}
		return res;
	}
};

5. 划分字母区间

力扣链接

字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。

示例:

输入:S = "ababcbacadefegdehijhklij"

输出:[9,7,8] 解释: 划分结果为 "ababcbaca", "defegde", "hijhklij"。 每个字母最多出现在一个片段中。 像 "ababcbacadefegde", "hijhklij" 的划分是错误的,因为划分的片段数较少。

核心思想

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

可以分为如下两步:

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

程序实现

cpp 复制代码
class Solution {
public:
    vector<int> partitionLabels(string s) 
	{
		vector<int> res;
		// 统计当前区间的左右下标
		int left = 0;
		int right = 0;		
		int hash[27] = {0};			// 记录每个字母最远出现的位置
		for(int i = 0; i < s.size(); i++)
			hash[s[i] - 'a'] = i;
		for(int i = 0; i < s.size(); i++)
		{
			right = max(right, hash[s[i] - 'a']);	// 更新右边界最远下标
			if(i == right)							// 遍历到有边界最远下标
			{
				res.push_back(right - left + 1);
				left = right + 1;
			}
		}
		return res;			
    }
};

6. 合并区间

力扣链接

给出一个区间的集合,请合并所有重叠的区间。

示例 :

输入: intervals = [[1,3],[2,6],[8,10],[15,18]]

输出: [[1,6],[8,10],[15,18]]

核心思想

  • 本题的本质还是判断重叠区间问题
  • 如果没有重叠就把原区间加入到 result 数组中,若有重叠就更新结果集中上一个区间的右边界值,达到合并的效果。

代码实现

cpp 复制代码
class Solution {
public:
	static bool cmp(vector<int> &point1, vector<int> &point2)
	{
		if(point1[0] == point2[0])
			return point1[1] < point2[1];
		return point1[0] < point2[0];
	}
	vector<vector<int>> merge(vector<vector<int>>& intervals) 
	{
		vector<vector<int>> res;
		// 区间集合为空直接返回
		if (intervals.size() == 0) 
			return res; 
		//排序 相邻的靠在一起
		sort(intervals.begin(), intervals.end(), cmp);
		// 第一个区间就可以放进结果集里,后面如果重叠,在result上直接合并
		res.push_back(intervals[0]);
		int end = intervals[0][1];
		for(int i = 1; i < intervals.size(); i++)
		{
			if(intervals[i][0] <= res.back()[1])	// 有重叠
				// 合并 更新结果集区间的右边界 细节max 存在新的右边界比旧的小的情况
				res.back()[1] = max(res.back()[1], intervals[i][1]);	
			else
				res.push_back(intervals[i]);			// 无重叠 放入区间
		}
		return res;
	}
};

其他问题

7. 最大子序和

力扣原题

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

子数组是数组中的一个连续部分

示例 :

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]

输出:6

解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

思路

  • 局部最优的情况下,并记录最大的"连续和",可以推出全局最优。
  • 遍历 nums,从头开始用sum累积,如果sum一旦加上 nums[i]变为负数,应该舍弃之前的累加和,从下一个数开始重新累加
  • 贪心:累加和到负数就舍弃

代码实现

cpp 复制代码
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
    	int max = INT32_MIN;
    	int sum = 0;
    	for(int i = 0; i < nums.size(); i++)
    	{
    		sum += nums[i];
			if(sum > max)
				max = sum;
			if(sum <= 0)
    			sum = 0;
		}
		return max;
	}
};

8. 加油站

力扣链接

在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。

你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。

给定两个整数数组 gascost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。

暴力求解

  • 暴力的方法很明显就是O(n^2)的,遍历每一个加油站为起点的情况,模拟一圈。

  • 如果跑了一圈,中途没有断油,而且最后油量大于等于0,说明这个起点是ok的。

  • 暴力的方法思路比较简单,但代码写起来也不是很容易,关键是要模拟跑一圈的过程。

  • for循环适合模拟从头到尾的遍历,而while循环适合模拟环形遍历,要善于使用while!

cpp 复制代码
// 暴力求解 模拟每次加油过程 1个测试用例通不过,时间复杂度过大
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) 
{
	for(int i = 0; i < gas.size(); i++)			// 从第一个点开始
	{
		int rest = gas[i] - cost[i];			// 起点剩余的油
		int idx = (i + 1) % gas.size();			// 下一站
		while(rest > 0 && idx != i)				// 还没进入下一圈
		{
			rest = rest + gas[idx] - cost[idx];	//计算下一站的剩余
			idx = (idx + 1) % cost.size();		// 更新下一站的站点
			if(rest < 0)
				break;
		}
		if(rest >= 0 && idx == i)
			return i;							//一圈结束,还有油
	}
	return -1;
}

核心思想

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

  • 每个加油站的剩余量rest[i] = gas[i] - cost[i],i 从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,那么起始位置从i+1算起,再从0计算curSum

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

代码实现

cpp 复制代码
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) 
{
	int rest;
	int restTotal = 0;				// 一圈总剩余
	int curSum = 0;					// 跑过路的总剩余
	int start = 0;	
	for(int i = 0; i < gas.size(); i++)
	{
		rest = gas[i] - cost[i];
		curSum += gas[i] - cost[i];
		restTotal += rest;
		if(curSum < 0)
		{
			curSum = 0;
			start = i + 1;
		}
	}
	if(restTotal < 0)	return -1;		//说明怎么走都不可能跑一圈了
	else 				return start;
}

9. 监控二叉树

力扣链接

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

示例

核心思路

  • 我们发现示例中摄像头都没有放在叶子节点上 ,这是很重要的一个线索,因为如果把摄像头放在叶子节点上,就浪费了一层的覆盖。所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。

  • 头节点也都没有放,但头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的,因此选择从叶子节点向上遍历。

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

  • 两个难点分析:

    (1)二叉树的遍历

    (2)如何隔两个节点放一个摄像头

  • 从下往上看,可以使用二叉树后续遍历的顺序

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

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

对于每个节点都有无覆盖,有摄像头和有覆盖三种状态之一,分别用0, 1,2表示这三种状态。

cpp 复制代码
0:该节点无覆盖
1:本节点有摄像头
2:本节点有覆盖

终止条件:

遇到空节点,空节点究竟是哪一种状态呢? 空节点为有覆盖状态,父节点(叶子节点)则为无覆盖状态,这样就可以在叶子节点的父节点放摄像头了,那么则递归的终止条件程序如下:

cpp 复制代码
// 空节点,该节点有覆盖
if (cur == NULL) 
	return 2;

单层逻辑处理

  • 情况1:左右节点都有覆盖: 此时中间节点应该就是无覆盖,如下图

  • 情况2:左右节点至少有一个无覆盖: 则中间节点(父节点)应该放摄像头

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

  • 情况3:左右节点至少有一个有摄像头: 那么其父节点就应该是有覆盖的状态

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

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

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

    cpp 复制代码
    nt minCameraCover(TreeNode* root) {
        result = 0;
        if (traversal(root) == 0) { // root 无覆盖
            result++;
        }
        return result;
    }

代码实现

cpp 复制代码
class Solution {
public:
	int result;
	int traversal(TreeNode* cur) {
		// 状态0:无覆盖
		// 状态1:有摄像头
		// 状态2:有覆盖
		
		if(cur == nullptr)		// 空节点 有覆盖状态 使得父节点为无覆盖状态
			return 2;
			
		// 后续遍历
		int left  = traversal(cur->left);		// 左
		int right = traversal(cur->right);		// 右
		
		// 中
		// 情况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;
	}

	int minCameraCover(TreeNode* root) {
		result = 0;
		// 情况4: 根节点无覆盖
		if(traversal(root) == 0)
			result++;
		return result;
    }
};
相关推荐
蜀黍@猿17 分钟前
C/C++基础错题归纳
c++
古希腊掌管学习的神19 分钟前
[搜广推]王树森推荐系统笔记——曝光过滤 & Bloom Filter
算法·推荐算法
qystca20 分钟前
洛谷 P1706 全排列问题 C语言
算法
浊酒南街26 分钟前
决策树(理论知识1)
算法·决策树·机器学习
雨中rain32 分钟前
Linux -- 从抢票逻辑理解线程互斥
linux·运维·c++
就爱学编程34 分钟前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法
学术头条38 分钟前
清华、智谱团队:探索 RLHF 的 scaling laws
人工智能·深度学习·算法·机器学习·语言模型·计算语言学
Schwertlilien1 小时前
图像处理-Ch4-频率域处理
算法
IT猿手1 小时前
最新高性能多目标优化算法:多目标麋鹿优化算法(MOEHO)求解TP1-TP10及工程应用---盘式制动器设计,提供完整MATLAB代码
开发语言·深度学习·算法·机器学习·matlab·多目标算法
__lost1 小时前
MATLAB直接推导函数的导函数和积分形式(具体方法和用例)
数学·算法·matlab·微积分·高等数学