贪心算法区间问题及进阶习题
- 贪心算法解决区间问题
- 其他问题
-
- [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]
-
区间重叠:当前区间左边界 < 上个区间右边界(视题目情况取 == ),更新当前区间的右边界。
cppif(points[i][0] < points[i-1][1]) // 重叠 points[i][1] = min(points[i-1][1], points[i][1]);
3. 用最少数量的箭引爆气球
墙面上的气球记录在整数数组 points
,其中points[i] = [xstart, xend]
表示水平直径在 xstart
和 xend
之间的气球。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart
,xend
, 且满足 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]
升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组 gas
和 cost
,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -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)如何隔两个节点放一个摄像头
-
从下往上看,可以使用二叉树后续遍历的顺序
cppint 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++,代码如下:
cppnt 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;
}
};