综述
分发饼干:leetcode455
摆动序列:leetcode376
最大子数组和:leetcode53
买卖股票的最佳时机 II:leetcode122
跳跃游戏:leetcode55
跳跃游戏 II:leetcode45
K 次取反后最大化的数组和:leetcode1005
加油站:leetcode134
分发糖果:leetcode135
柠檬水找零:leetcode860
根据身高重建队列:leetcode406
用最少数量的箭引爆气球:leetcode452
无重叠区间:leetcode435
划分字母区间:leetcode763
合并区间:leetcode56
单调递增的数字:leetcode738
监控二叉树:leetcode968
引言
贪心的本质是选择每一阶段的局部最优,从而达到全局最优
贪心算法并没有固定的套路,所以建议直接刷题
刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。如果用不了贪心,就考虑动态规划
贪心算法一般分为如下四步:
将问题分解为若干个子问题
找出适合的贪心策略
求解每一个子问题的最优解
将局部最优解堆叠成全局最优解
这四部有点理论,刷题中有时候不会根据这四步来,所以直接刷题,刷题时只需要考虑局部最优是什么,全局最优是什么即可
分发饼干
题目
题解
一眼贪心算法,局部最优:给大胃口先分配大饼干,外层遍历胃口g,内层遍历饼干s(这里的内循环并不是真的 for 循环,而是满足一定条件进行++或- -),如下图
如果是小胃口先分配小饼干,外层遍历胃口g,内层遍历饼干s,那么会出错,如下图:
或者 局部最优:将小饼干分配给小胃口,外层遍历饼干s,内层遍历胃口g
同样的,局部最优:将大饼干分配给大胃口,外层遍历饼干s,内层遍历胃口g,就是错的
第一种情况的代码:
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(s.begin(), s.end());//先排序
sort(g.begin(), g.end());//先排序
int j = s.size() - 1;
int res = 0;
for (int i = g.size() - 1; i >= 0; i--) {//外层胃口,倒序
if (j >= 0 && s[j] >= g[i]) {//每层饼干,倒序
res++;
j--;
}
}
return res;
}
};
摆动序列
题目
题解
贪心
这道题就是贪心直接上,尽量有更多的波峰和波谷,(数组的两端也可以称之为波峰和波谷,比如[1,3,2,4] 中1是波峰,4是波谷)
但是需要注意两个情况:
- 波动段有平台,需要在前面加等号
- 过渡段有平台,需要在波动时才更新 prediff
代码:
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
if (nums.size() == 1) return 1;
int prediff = nums[1] - nums[0];
int curdiff = 0;
int res = (prediff == 0 ? 1 : 2);
for (int i = 2; i < nums.size(); i++) {
curdiff = nums[i] - nums[i - 1];
if ((prediff <= 0 && curdiff > 0) || (prediff >= 0 && curdiff < 0)) {//加等号是因为波动段有平台
res++;
prediff = curdiff;//只有波动时才更新prediff,防止过度段有平台
}
}
return res;
}
};
动态规划
这道题用贪心很容易想到,但是很难一下子就做出来,这时候其实可以想想用动态规划
其实本题就是求 波峰的数量 和 波谷的数量 的最大值。(数组的两端也可以称之为波峰和波谷,比如[1,3,2,4] 中1是波峰,4是波谷)
记 上升摆动子序列为 整体是摆动的,但是最后一段是上升的
记 下降摆动子序列为 整体是摆动的,但是最后一段是下降的
那么定义一个二维数组 dp,第一行是上升摆动子序列的长度,第二行是下降摆动子序列的长度
所以 dp[i][0] 表示 以 nums[0] ~ nums[i] 的上升摆动子序列的长度
dp[i][1] 表示 以 nums[0] ~ nums[i] 的下降摆动子序列的长度
所以递推公式:
nums[i] > nums[i - 1] 时,
上升摆动子序列 的长度会是 下降摆动子序列的长度+1,并且和之前的上升摆动子序列的长度对比
下降摆动子序列 的长度不变
nums[i] < nums[i - 1] 时,
上升摆动子序列 的长度不变
下降摆动子序列 的长度是 上升摆动子序列的长度+1,并且和和之前的下降摆动子序列的长度对比
nums[i] = nums[i - 1] 时,
上升摆动子序列 的长度不变
下降摆动子序列 的长度不变
因此递推公式:
if (nums[i] > nums[i - 1]) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + 1);
dp[i][1] = dp[i - 1][1];
} else if (nums[i] < nums[i - 1]) {
dp[i][0] = dp[i - 1][0];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + 1);
} else {
dp[i][0] = dp[i - 1][0];
dp[i][1] = dp[i - 1][1];
}
初始化:dp[0][0] = 1; dp[0][1] = 1;
,指的是当只有一个元素时,上升摆动子序列 和 下降摆动子序列 长度都是1
代码:
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
//dp数组第一行是上升摆动子序列,第二行是下降摆动子序列
vector<vector<int>> dp(nums.size(), vector<int>(2, 1));
for (int i = 1; i < nums.size(); i++) {
if (nums[i] > nums[i - 1]) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + 1);
dp[i][1] = dp[i - 1][1];
} else if (nums[i] < nums[i - 1]) {
dp[i][0] = dp[i - 1][0];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + 1);
} else {
dp[i][0] = dp[i - 1][0];
dp[i][1] = dp[i - 1][1];
}
}
return max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
}
};
最大子数组和
题目
题解
一眼贪心,只要之前的和是正数,那么加上 nums[i] 可能会更大
只要之前的和是负数,那么加上 nums[i] 一定更小,所以直接从 nums[i] 开始算
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int add = 0;
int res = INT_MIN;
for (int i = 0; i < nums.size(); i++) {
if (add < 0) add = 0;
add += nums[i];
res = max(res, add);
}
return res;
}
};
买卖股票的最佳时机 II
题目
题解
贪心,前一天价格 比 后一天少,就可以前一天买,后一天卖
否则就不操作
class Solution {
public:
int maxProfit(vector<int>& prices) {
int res = 0;
for (int i = 0; i < prices.size() - 1; i++) {
if (prices[i] < prices[i + 1]) res += (prices[i + 1] - prices[i]);
}
return res;
}
};
跳跃游戏
题目
题解
别管怎么跳,只要跳跃覆盖范围可以覆盖到终点即可
class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0;
for (int i = 0; i <= cover; i++) {
cover = max(cover, i + nums[i]);
if (cover >= nums.size() - 1) return true;//覆盖最后一个元素 nums.size() - 1
}
return false;
}
};
跳跃游戏 II
题目
题解
第一步到达的范围是 curDistance
第二步到达的范围是 nextDistacne,需要一直更新 nextDistacne
如果 i 遍历到了 curDistance,说明需要再加一步了,再加一步之后的 curDistance 就是 nextDistacne
class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() == 1) return 0;
int curDistance = 0;
int nextDistance = 0;
int res = 0;
for (int i = 0; i < nums.size(); i++) {
nextDistance = max(nextDistance, i + nums[i]);
if (i == curDistance) {
res++;
curDistance = nextDistance;
if (nextDistance >= nums.size() - 1) return res;//由于上来在curDistacne=0时res就+1了,所以返回时不用再+1了
}
}
return 0;
}
};
更简单的是用动态规划
dp[i] 表示 到达 i 所需的最小步数
那么遍历到 i 时,从 i 到 i + nums[i] 中的步数都可以由 dp[i] + 1 得到,但是需要取最小,所以递推公式:for (int j = i; j <= i + nums[i] && j < nums.size(); j++) dp[j] = min(dp[j], dp[i] + 1);
class Solution {
public:
int jump(vector<int>& nums) {
vector<int> dp(nums.size(), INT_MAX);
dp[0] = 0;
for (int i = 0; i < nums.size(); i++) {
//只要是覆盖范围内的步数都可以由dp[i]+1得到,但是要取最小
for (int j = i; j <= i + nums[i] && j < nums.size(); j++) dp[j] = min(dp[j], dp[i] + 1);
}
return dp[nums.size() - 1];
}
};
动态规划简单易懂,但是时间复杂度是 o(n^2),而贪心算法时间复杂度是 o(n)
加油站
题目
题解
每个加油站的剩余量rest[i]为gas[i] - cost[i]
i 从0开始累加 rest[i],和记为 curSum,一旦 curSum 小于零,说明 [0, i] 区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从 i+1 算起,再从0计算curSum
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int curSum = 0;
int totalSum = 0;
int start = 0;
for (int i = 0; i < gas.size(); i++) {
curSum += gas[i] - cost[i];
totalSum += gas[i] - cost[i];
if (curSum < 0) { // 当前累加rest[i]和 curSum一旦小于0
start = i + 1; // 起始位置更新为i+1
curSum = 0; // curSum从0开始
}
}
if (totalSum < 0) return -1; // 说明怎么走都不可能跑一圈了
return start;
}
};
分发糖果
题目
题解
一定要先确定一遍再确定另一边,如果同时考虑,会顾此失彼
一次是从左到右遍历,只比较右边孩子评分比左边大的情况,遇到则比左边的大1个,这样保证了右边比左边大的情况是成立的。
一次是从右到左遍历,只比较左边孩子评分比右边大的情况,遇到则比右边的大1个,这样保证了左边比右边大的情况是成立的。
那么既符合 左边 > 右边 又符合 右边 > 左边,那么应该去两者的最大值
class Solution {
public:
int candy(vector<int>& ratings) {
vector<int> leftRate(ratings.size(), 1);
vector<int> rightRate(ratings.size(), 1);
//从前向后,找到右边比左边大的,然后=左边的+1
for (int i = 1; i < ratings.size(); i++) {
if (ratings[i] > ratings[i - 1]) rightRate[i] = rightRate[i - 1] + 1;
}
//从后向前,找到左边比右边大的,然后=右边+1
for (int i = ratings.size() - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) leftRate[i] = leftRate[i + 1] + 1;
leftRate[i] = max(leftRate[i], rightRate[i]);//两边要同时满足,需要取max
}
//上一个从后向前遍历时未遍历最后一个元素,因此最后一个元素需要取max
leftRate[ratings.size() - 1] = max(leftRate[ratings.size() - 1], rightRate[ratings.size() - 1]);
int sum = 0;
for (int ele : leftRate) sum += ele;
return sum;
}
};
根据身高重建队列
题目
题解
这道题也有两个维度,一个是 h,一个是 k,所以应该先处理一个维度,再处理另一个维度
先对身高进行排序,从大到小排序,这样身高的排序维度就处理完了
然后根据 k 进行插入,对于每一个 people[i], 它的 ki 就是前面的比它高的数量,因此,插入到第 ki 的位置即可。
因为本来就是身高从大到小排列的,那么小的身高插入到大身高前面,也不会影响大身高前面的比他身高大的数量,也就是说和大身高的 ki 仍然相等
代码:
class Solution {
public:
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);
vector<vector<int>> que;
for (int i = 0; i < people.size(); i++) {
int position = people[i][1];
que.insert(que.begin() + position, people[i]);
}
return que;
}
};
时间复杂度:O(nlog n + n^2)
空间复杂度:O(n)
这种的话是 vector 频繁的插入,十分影响效率(因为底部扩容)。对于插入频繁的情况,用 list 会更高效:
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)
虽然两个的时间复杂度是一样的,但是执行时间差别很大
用最少数量的箭引爆气球
题目
题解
从此题开始,接下来的三道题都是和 “重复区间” 相关
局部最优:当气球出现重叠,一起射,所用弓箭最少。全局最优:把所有气球射爆所用弓箭最少
为了让气球尽可能的重叠,需要对数组进行排序
其实按照左边界排序和有边界排序都可以
如果面试时发现代码不能通过,尝试换一下排序的方式或许可以(比如从左边界排序改为有边界排序)
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
sort(points.begin(), points.end(), pointSort);
int res = 1;
int arrow = points[0][1];
for (int i = 1; i < points.size(); i++) {
//认为箭就是区间的end,由于points经过了Xend的排序,因此每次射出去如果在相应气球的区间,则能穿破,不在区间则再申请一支箭
if (arrow < points[i][0] || arrow > points[i][1]) {
arrow = points[i][1];
res++;
}
}
return res;
}
private:
//根据Xend从小到大排序
static bool pointSort(vector<int>& p1, vector<int>& p2) {
if (p1[1] == p2[1]) return p1[0] < p2[0];
return p1[1] < p2[1];
}
};
这是按照右边界从小到大排序的,如果右边界相等,按照左边界从小到大排序
时间复杂度:O(nlog n),因为有一个快排
空间复杂度:O(1),有一个快排,最差情况(倒序)时,需要n次递归调用。如果加上系统栈空间的话需要O(n)的栈空间
无重叠区间
题目
题解
“重复区间” 问题,需要排序
此处按照右边界进行排序
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), intervalSort);
int border = intervals[0][1];//记录边界
int res = 0;
for (int i = 1; i < intervals.size(); i++) {
if (border > intervals[i][0]) res++;//如果边界 > 新区间的起始位置,说明区间重叠,那么将此区间删除
else border = intervals[i][1];//如果边界 <= 新区间的起始位置,说明不重叠,更新边界
}
return res;
}
private:
static bool intervalSort(vector<int>& p1, vector<int>& p2) {
if (p1[1] == p2[1]) return p1[0] < p2[0];
return p1[1] < p2[1];
}
};
时间复杂度:O(nlog n) ,有一个快排
空间复杂度:O(n)
合并区间
题目
题解
“重复区间” 问题,需要排序
此处按照左边界进行排序
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), mergeSort);
int start = intervals[0][0];
int end = intervals[0][1];
vector<vector<int>> res;
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] > end) {//说明不重叠,那么就将结果加入res中,并更新start和end
res.push_back({start, end});
start = intervals[i][0];
end = intervals[i][1];
} else {//说明重叠,更新start和end
start = start;//这个可以不要
end = max(end, intervals[i][1]);
}
}
//看最后一个start 和 end 是否被插入进去了
if (res.empty() || end != res[res.size() - 1][1]) res.push_back({start, end});
return res;
}
private:
static bool mergeSort(vector<int>& a, vector<int>& b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] < b[0];
}
};
时间复杂度: O(nlogn)
空间复杂度:O(n)
划分字母区间
题目
题解
如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了
所以需要
统计每一个字符最后出现的位置
从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点
class Solution {
public:
vector<int> partitionLabels(string s) {
unordered_map<char, int> umap;
//找每个字符的最远位置下标
for (int i = 0; i < s.size(); i++) {
umap[s[i]] = i;
}
vector<int> res;
int startIndex = 0;//子串的开始位置
int endIndex = 0;//遍历的子串中出现的字符的最远位置下标
for (int i = 0; i < s.size(); i++) {
endIndex = max(endIndex, umap[s[i]]);
if (i == endIndex) {
res.push_back(endIndex - startIndex + 1);
startIndex = endIndex + 1;
}
}
return res;
}
};
时间复杂度:O(n)
空间复杂度:O(n)
当然可以把unordered_map 换成 int hash[27];
每个字母可以这样取:hash[s[i] - 'a'] = ...;
这样的话空间复杂度是 o(1)
单调递增的数字
题目
题解
这道题是贪心算法题,但是中间有很多操作很技巧,非常值得一做
对于数字876,需要从后往前遍历,发现后一位“6”小于前一位“7”,那么后一位变成9,前一位减1,即869
继续遍历,发现后一位“6”小于前一位“8”,那么后一位变成9,前一位减1,即799
所以整体过程是从后往前遍历,找到最前面的 “后一位小于前一位” 的那个下标,然后将其后面的所有数字换成9即可
class Solution {
public:
int monotoneIncreasingDigits(int n) {
//将n转换成string,方便对位数操作
string nums = to_string(n);
//贪心算法,遇到高位比地位小的,那低位变9,高位--。一直找到最高位,赋值给flag,将flag之后的全部置为9
int flag = nums.size();
for (int i = nums.size() - 1; i > 0; i--) {
if (nums[i] < nums[i - 1]) {
flag = i;
nums[i - 1]--;
}
}
//通过nums中flag之后的数字置为9
for (int i = flag; i < nums.size(); i++) {
nums[i] = '9';
}
return stoi(nums);
}
};
时间复杂度:O(n)
空间复杂度:O(n)
这道题有个很技巧的操作就是将 n 转成 字符串string,这样操作很方便,后续可以再将其转成int
监控二叉树
题目
题解
每个节点三种状态,0是未覆盖,1是覆盖,2是装摄像头
尽量不往叶子节点放摄像头,而是在叶子节点的父节点放摄像头,所以需要后续遍历,直接跳过叶子节点,给父节点放摄像头。如果是前序遍历是控制不了的
由于叶子节点不放摄像头,叶子节点的父节点放摄像头,所以空节点认为是覆盖状态,因为:
空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
class Solution {
public:
//0是未覆盖,1是覆盖,2是装摄像头
int minCameraCover(TreeNode* root) {
if (traversal(root) == 0) res++;
return res;
}
private:
int res = 0;
int traversal(TreeNode* root) {
if (root == nullptr) return 1;//空间点认为是覆盖了,这样就是叶子节点不会装摄像头,而是从叶子节点的父节点开始装摄像头
int leftSate = traversal(root->left);
int rightSate = traversal(root->right);
//几种状态,可以动手画画
if (leftSate == 0 || rightSate == 0) {
res++;
return 2;
} else if (leftSate == 2 || rightSate == 2) {
return 1;
} else return 0;
}
};
时间复杂度: O(n),需要遍历二叉树上的每个节点
空间复杂度: O(n)