赞
踩
来自代码随想录的刷题路线:代码随想录
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。
贪心没有套路,说白了就是常识性推导加上举反例。
靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。
有同学问了如何验证可不可以用贪心算法呢?
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。
面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了
想清楚 局部最优 是什么,如果推导出全局最优,其实就够了。
比较简单,先排序再双指针即可。主要是用来接触局部最优推导全局最优的想法。
想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心。
小饼干先喂饱小胃口,或者大胃口先吃大饼干。
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;
}
};
这个是力扣评论区的方法,比贪心跟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就是序列的长度 } };
本题异常情况的本质,就是要考虑平坡, 平坡分两种,一个是 上下中间有平坡,一个是单调有平坡,如图:
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; } };
暴力解法的思路,第一层 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;
}
};
有案例超时
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。
全局最优:选取最大“连续和”
局部最优的情况下,并记录最大的“连续和”,可以推出全局最优。
从代码角度上来讲:遍历 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; } };
收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。
局部最优:收集每天的正利润,全局最优:求得最大利润。
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;
}
};
这道题目关键点在于:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。
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; } };
理解本题的关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,这个范围内最少步数一定可以跳到,不用管具体是怎么跳的,不纠结于一步究竟跳几个单位。
这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。
如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。
如图:
图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)
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; } };
思考过程:
将正的变成负的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 * 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; } };
如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量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; } };
最后一个案例超时
先给每个孩子分一个糖果,然后遍历数组。
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; } };
是==而不是=
有如下三种情况:
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; } };
遇到两个维度都需要考虑的时候,先确定好其中一个维度,再根据确定好的维度去分析另一个维度。不要一起分析,不然容易顾此失彼。
先按身高来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; } };
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()); } };
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的普通数组,并且把原元素拷贝过去,释放原数组内存,注意图中底层数组的内存起始地址已经变了。
找重叠区间的 如果不需要保持原数组顺序,往往都需要重新排序一波
把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组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; } };
注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,
所以代码中
if (points[i][0] > points[i - 1][1])
而不能是>=
跟上一题非常类似,就是判断区间是否重叠:
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; } };
在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
可以分为如下两步:
如图:
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; } };
可以直接用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; } };
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); //字符串改成数字 } };
这道题目首先要想,如何放置,才能让摄像头最小的呢?
从题目中示例,其实可以得到启发,我们发现题目示例中的摄像头都没有放在叶子节点上!
这是很重要的一个线索,摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。
所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。
那么为什么不从头结点开始看起呢,为啥要从叶子节点看呢?
因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。
所以我们要从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少!
此时,大体思路就是从底到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。
此时这道题目还有两个难点:
在二叉树中如何从低向上推导呢?
可以使用后序遍历也就是左右中的顺序,这样就可以在回溯的过程中从下到上进行推导了。
注意我们取左孩子的返回值,右孩子的返回值,用于后面推导中间节点的状态
此时需要状态转移的公式,大家不要和动态的状态转移公式混到一起,本题状态转移没有择优的过程,就是单纯的状态转移!
来看看这个状态应该如何转移,先来看看每个节点可能有几种状态:
有如下三种,我们分别有三个数字来表示:
一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。
因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,因为这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了。
接下来就是递推关系。
那么递归的终止条件应该是遇到了空节点,此时应该返回2(有覆盖)。
代码如下:
// 空节点,该节点有覆盖
if (cur == NULL) return 2;
递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。
主要有如下四类情况:
左孩子有覆盖,右孩子有覆盖,那么此时中间节点应该就是无覆盖的状态了。
如图:
代码如下:
// 左右节点都有覆盖
if (left == 2 && right == 2) return 0;
如果是以下情况,则中间节点(父节点)应该放摄像头:
这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。
此时摄像头的数量要加一,并且return 1,代表中间节点放摄像头。
代码如下:
if (left == 0 || right == 0) {
result++;
return 1;
}
如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)
代码如下:
if (left == 1 || right == 1) return 2;
从这个代码中,可以看出,如果left == 1, right == 0 怎么办?其实这种条件在情况2中已经判断过了,如图:
这种情况也是大多数同学容易迷惑的情况。
以上都处理完了,递归结束之后,可能头结点 还有一个无覆盖的情况,如图:
所以递归结束之后,还要判断根节点,如果没有覆盖,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; } };
本题的难点首先是要想到贪心的思路,然后就是遍历和状态推导。
在二叉树上进行状态推导,其实难度就上了一个台阶了,需要对二叉树的操作非常娴熟。
这道题目是名副其实的hard,大家感受感受。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。