贪心算法:
理论基础
什么是贪心?
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。
贪心的套路(什么时候用贪心)
贪心没有套路,说白了就是常识性推导加上举反例。
靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。
有同学问了如何验证可不可以用贪心算法呢?
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。
面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了
贪心一般解题步骤
想清楚 局部最优 是什么,如果推导出全局最优,其实就够了。
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;
}
};
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;
}
};
- 时间复杂度: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就是序列的长度
}
};
方法2:记录局部峰值求最大摆动
本题异常情况的本质,就是要考虑平坡, 平坡分两种,一个是 上下中间有平坡,一个是单调有平坡,如图:
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;
}
};
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;
}
};
- 时间复杂度: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;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(1)
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;
}
};
55. 跳跃游戏
这道题目关键点在于:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。
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;
}
};
自己做的:
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;
}
};
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;
}
};
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;
}
};
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;
}
- 时间复杂度: O(k * n)
- 空间复杂度: O(1)
也可以先快排再取反第一个元素,这样的话时间复杂度就是O(k * nlogn),比较大
下面是卡哥的方法,个人感觉比较难理解
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;
}
};
- 时间复杂度: 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。
如图:
那么为什么一旦[0,i] 区间和为负数,起始位置就可以是i+1呢,i+1后面就不会出现更大的负数?
如果出现更大的负数,就是更新i,那么起始位置又变成新的i+1了。
那有没有可能 [0,i] 区间 选某一个作为起点,累加到 i这里 curSum是不会小于零呢? 如图:
如果 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;
}
};
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;
}
};
贪心:
采用了两次贪心的策略:
- 一次是从左到右遍历,只比较右边孩子评分比左边大的情况。
- 一次是从右到左遍历,只比较左边孩子评分比右边大的情况。
这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。
这道题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
先确定右边评分大于左边的情况(也就是从前向后遍历)
如果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;
}
如图:
再确定左孩子大于右孩子的情况(从后向前遍历)
遍历顺序这里有同学可能会有疑问,为什么不能从前向后遍历呢?
因为 rating[5]与rating[4]的比较 要利用上 rating[5]与rating[6]的比较结果,所以 要从后向前遍历。
如果从前向后遍历,rating[5]与rating[4]的比较 就不能用上 rating[5]与rating[6]的比较结果了 。如图:
所以确定左孩子大于右孩子的情况一定要从后向前遍历!
如果 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]的糖果多。
如图:
整体代码如下:
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;
}
};
- 时间复杂度: 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;
}
};
- 时间复杂度: O(n)
- 空间复杂度: O(1)
406. 根据身高重建队列
遇到两个维度都需要考虑的时候,先确定好其中一个维度,再根据确定好的维度去分析另一个维度。不要一起分析,不然容易顾此失彼。
-
先按身高来h排序,当h相同时,k大的在后。这样前面的人一定比后面的人高。
-
确定身高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]]
代码如下:
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;
}
};
- 时间复杂度: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());
}
};
- 时间复杂度:O(nlog n + n^2)
- 空间复杂度:O(n)
vector的底层实现
vector的底层实现也是普通数组。
vector的大小有两个维度一个是size一个是capicity,size就是我们平时用来遍历vector时候用的,例如:
for (int i = 0; i < vec.size(); i++) {
}
而capicity是vector底层数组(就是普通数组)的大小,capicity可不一定就是size。
当insert数据的时候,如果已经大于capicity,capicity会成倍扩容,但对外暴漏的size其实仅仅是+1。
那么既然vector底层实现是普通数组,怎么扩容的?
就是重新申请一个二倍于原数组大小的数组,然后把数据都拷贝过去,并释放原数组内存。(对,就是这么原始粗暴的方法!)
举一个例子,如图:
原vector中的size和capicity相同都是3,初始化为1 2 3,此时要push_back一个元素4。
那么底层其实就要申请一个大小为6的普通数组,并且把原元素拷贝过去,释放原数组内存,注意图中底层数组的内存起始地址已经变了。
452. 用最少数量的箭引爆气球
找重叠区间的 如果不需要保持原数组顺序,往往都需要重新排序一波
把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组remove气球,只要记录一下箭的数量就可以了。
为了让气球尽可能的重叠,需要对数组进行排序。
那么按照气球起始位置排序,还是按照气球终止位置排序呢?
其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。
既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。
从前向后遍历遇到重叠的气球了怎么办?
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭。
以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)
可以看出首先第一组重叠气球,一定是需要一个箭,气球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;
}
};
- 时间复杂度:O(nlog n),因为有一个快排
- 空间复杂度:O(1),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间
注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,
所以代码中
if (points[i][0] > points[i - 1][1])
而不能是>=
435. 无重叠区间
跟上一题非常类似,就是判断区间是否重叠:
- 先按照左边界从小到大进行排序
- 遍历数组,用preEnd来记录上个元素的终点,看当前元素的起点与preEnd是否重叠
- 不重叠则刷新preEnd为当前元素的终点
- 重叠就使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;
}
};
- 时间复杂度:O(nlog n) ,有一个快排
- 空间复杂度:O(n),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间
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;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(1),使用的hash数组是固定大小
56. 合并区间
可以直接用result.back()[1]访问最后一个元素的y值
用最少数量的箭引爆气球、无重叠区间都是一个套路。
都是判断区间重叠,区别就是判断区间重叠后的逻辑,本题是判断区间重叠后要进行区间合并。
所以一样的套路,先排序,让所有的相邻区间尽可能的重叠在一起,按左边界,或者右边界排序都可以,处理逻辑稍有不同。
按照左边界从小到大排序之后,如果 intervals[i][0] <= intervals[i - 1][1]
即intervals[i]的左边界 <= intervals[i - 1]的右边界,则一定有重叠。(本题相邻区间也算重贴,所以是<=)
这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了)
知道如何判断重复之后,剩下的就是合并了,如何去模拟合并区间呢?
其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到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;
}
};
- 时间复杂度: 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); //字符串改成数字
}
};
- 时间复杂度:O(n),n 为数字长度
- 空间复杂度:O(n),需要一个字符串,转化为字符串操作更方便
968. 监控二叉树
思路
这道题目首先要想,如何放置,才能让摄像头最小的呢?
从题目中示例,其实可以得到启发,我们发现题目示例中的摄像头都没有放在叶子节点上!
这是很重要的一个线索,摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。
所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。
那么为什么不从头结点开始看起呢,为啥要从叶子节点看呢?
因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。
所以我们要从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少!
此时,大体思路就是从底到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。
此时这道题目还有两个难点:
- 二叉树的遍历
- 如何隔两个节点放一个摄像头
#确定遍历顺序
在二叉树中如何从低向上推导呢?
可以使用后序遍历也就是左右中的顺序,这样就可以在回溯的过程中从下到上进行推导了。
注意我们取左孩子的返回值,右孩子的返回值,用于后面推导中间节点的状态
#如何隔两个节点放一个摄像头
此时需要状态转移的公式,大家不要和动态的状态转移公式混到一起,本题状态转移没有择优的过程,就是单纯的状态转移!
来看看这个状态应该如何转移,先来看看每个节点可能有几种状态:
有如下三种,我们分别有三个数字来表示:
- 0:该节点无覆盖
- 1:本节点有摄像头
- 2:本节点有覆盖
一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。
因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,因为这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了。
接下来就是递推关系。
那么递归的终止条件应该是遇到了空节点,此时应该返回2(有覆盖)。
代码如下:
// 空节点,该节点有覆盖
if (cur == NULL) return 2;
递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。
主要有如下四类情况:
- 情况1:左右节点都有覆盖
左孩子有覆盖,右孩子有覆盖,那么此时中间节点应该就是无覆盖的状态了。
如图:
代码如下:
// 左右节点都有覆盖
if (left == 2 && right == 2) return 0;
- 情况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;
}
- 情况3:左右节点至少有一个有摄像头
如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)
- left == 1 && right == 2 左节点有摄像头,右节点有覆盖
- left == 2 && right == 1 左节点有覆盖,右节点有摄像头
- left == 1 && right == 1 左右节点都有摄像头
代码如下:
if (left == 1 || right == 1) return 2;
从这个代码中,可以看出,如果left == 1, right == 0 怎么办?其实这种条件在情况2中已经判断过了,如图:
这种情况也是大多数同学容易迷惑的情况。
- 情况4:头结点没有覆盖
以上都处理完了,递归结束之后,可能头结点 还有一个无覆盖的情况,如图:
所以递归结束之后,还要判断根节点,如果没有覆盖,result++,代码如下:
if (traversal(root) == 0) { // root 无覆盖
result++;
}
以上四种情况我们分析完了,整体代码如下:
/**
* 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;
}
};
- 时间复杂度: O(n),需要遍历二叉树上的每个节点
- 空间复杂度: O(n)
本题的难点首先是要想到贪心的思路,然后就是遍历和状态推导。
在二叉树上进行状态推导,其实难度就上了一个台阶了,需要对二叉树的操作非常娴熟。
这道题目是名副其实的hard,大家感受感受。