贪心算法
核心思想:使用目光短浅的方法也可以完成任务。只需要解决每个阶段的某一个子问题即可。
注意:贪心的套路就在于没有套路。
①凭直觉猜贪心的公式
买卖股票的最佳时机 II
(贪心)
贪心思想就是尽可能多地进行交易,并且每一次都是在这只股票涨到最高峰的时候卖出(第二天就要跌了)。
如果不是最大峰卖出,那么一定会有钱没有赚到;如果是跌了之后再卖出,则一定会比没有跌之前卖出赚的少。所以一定要最高峰卖出,并且卖完之后还要再买。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
int ans = 0;
for (int i = 0; i < len; i ++) {
int j = i + 1;
while (j < len && prices[j - 1] <= prices[j]) j ++;
ans += prices[j - 1] - prices[i];
i = j - 1;
}
return ans;
}
};
(动规)
优化每一天买卖结果和前一天的买卖结果是有联系的,所以可以使用动态规划。
1.状态定义:
定义状态的依据是题目的限制条件:1.前面i天的的买卖情况。 2.不能同时多次交易,所以需要定义一维空间表示持股的状态。
dp[i][j]
表示今天持股状态为j(j=0,则表示不持股,j=1表示今天持股)前i天内可以赚的最多钱数。
2.递推公式
今天不持股则前i天可以赚的最多钱数可以由两个状态推出:昨天不持股,昨天持股但是今天卖出。即dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
同理:今天持股则前i天可以赚的最多钱数可以由两个状态推出:昨天持股,昨天不持股但是今天买入,即dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i])
3.初始化
由递推公式可知:需要处理i == 0
的情况。
dp[0][0] = 0
第1天不持股,但是值卖出了0元。dp[0][1]= -pirces[0]
第一天就买入,则钱数为-prices[0]
4.遍历顺序
由递推公式可知:从前往后遍历即可
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<vector<int>> dp(len, vector<int>(2, 0));
dp[0][0] = 0; // 第一天不持股卖出
dp[0][1] = -prices[0]; // 第一天持股买入
for (int i = 1; i < len; i ++) {
// 今天不持股 = max(昨天不持股, 昨天持股今天卖掉)
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
// 今天持股 = max(昨天持股, 昨天不持股今天买入)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
return dp[len - 1][0];
}
};
(动规一维空间优化)
有递推公式可知,dp[i][j]
只会由dp[i - 1][j]
推出来,所以可以直接利用上面已从记录好的空间。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<int> dp(2, 0);
dp[0] = 0; // 第一天不持股卖出
dp[1] = -prices[0]; // 第一天持股买入
for (int i = 1; i < len; i ++) {
int tmp0 = dp[0], tmp1 = dp[1];
// 今天不持股 = max(昨天不持股, 昨天持股今天卖掉)
dp[0] = max(tmp0, tmp1 + prices[i]);
// 今天持股 = max(昨天持股, 昨天不持股今天买入)
dp[1] = max(tmp1, tmp0 - prices[i]);
}
return dp[0];
}
};
数组拆分 I
(贪心)
每一次都要选取一个数对,其中保留数对中较小的那个元素,去除较大的那个元素。所以每一次尽可能地将保留的数变大,将去除的数变小。
每一次都要选取小的数,所以要将数组中的大的数尽可能的保留下来以备后面当做小的数使用。这也就要求我们每一次去掉的数都要尽可能的小。
根据上面的思路:让数组先排序,然后每次都从前往后选取两个数,这样每一次都将去掉的大的数字的成本降到最低。
class Solution {
public:
int arrayPairSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
int ans = 0;
int len = nums.size();
for (int i = 0; i < len; i ++) {
if (i % 2 == 0) ans += nums[i];
}
return ans;
}
};
②贪心和动规的区别
分发糖果
本题不能够同时的兼顾一个孩子两侧的孩子:
原因是:因为需要从头开始遍历的给孩子发糖果,在给第i
个孩子发糖果的时候并没有给第i + 1
的孩子发糖果呢。如果第i
个孩子的评分更高的话,就要比第i - 1
个孩子和第i + 1
个孩子得到的糖果数量都多,但是此时第i - 1
个孩子的糖果已经发了,而第i + 1
个孩子的糖果还没有发,所以无法给第i
个孩子发糖果。
所以就根据这一点,我们有两种解决方法:
1.从左边和从右边分别的计算一次,然后去一个最大值,这样一个孩子的糖果数既可以大于左边有可以大于右边
2.就是硬要从头算一次也可以,但是因为每一次算第i
个孩子的时候,都要知道第i + 1
个孩子的糖果数,如果不知道就默认为1,然后当计算第i + 1
个孩子的糖果数增加了,那么刚刚计算的第i
个孩子的糖果数也增加就可以了。
(贪心)
题目中要求一个孩子得到的糖果数有两个要求:(在评分更高的孩子上)
1.比左边的孩子得到的糖果数要多
2.比右边的孩子得到的糖果数要多
class Solution {
public:
int candy(vector<int>& ratings) {
int len = ratings.size();
vector<int> cnt(len, 1);
for (int i = 1; i < len; i ++) {
if (ratings[i] > ratings[i - 1])
cnt[i] = cnt[i - 1] + 1;
}
for (int i = len - 2; i >= 0; i --) {
if (ratings[i] > ratings[i + 1])
cnt[i] = max(cnt[i], cnt[i + 1] + 1);
}
int sum = 0;
for (int n : cnt) {
sum += n;
}
return sum;
}
};
(贪心)
第二种方法就是从头往后遍历给孩子发糖果,遇到第i
个孩子(这个孩子的评分更高)发糖果的时候,要给他比两侧的孩子的糖果数更多的糖果,但是此时又不知道第i + 1
个孩子的糖果数,所以就默认第i + 1
个孩子的糖果数为1。如果后面发现第i + 1
个孩子的评分比第i + 2
个孩子的评分高,需要糖果数加1,这时不仅要给第i + 1
个孩子糖果数加1,在某些情况下也要给第i
个孩子的糖果数加1,以保证第i
个孩子的糖果数比第i + 1
个孩子的糖果数要多。
某些情况就是:本身就比第i + 1
个孩子增加过的糖果数还多,就不需要给第i
个孩子糖果数增加1了。只有当第i + 1
个孩子的糖果数比第i
个孩子的糖果数一样的时候,这时就需要将第i
个孩子的糖果数加1,这样才能保证第i
个孩子的糖果数量比左右两个孩子的糖果数都多。
根据上面的语言描述可以画出数学图像。
如果评分一直都是一个比一个高那糖果数一定是单调递增的,遇到第一个评分不会更高的孩子开始糖果数就开始呈现单调递减的趋势。而特殊情况就是递增的长度和递减的长度一样的时候,说明第i + 1
个孩子的糖果数由于递减的糖果数的增加,现在糖果数已经和第i
个孩子的糖果数一样了,那么此时第i
个孩子的糖果数不看成递增数列中的数了,而是看成递减数列中的数。
class Solution {
public:
int candy(vector<int>& ratings) {
int len = ratings.size();
int up = 1, down = 0; // up为上升序列的长度,down为下降序列的长度
int prev = 1; // 累加的糖果数
int ans = 1;
for (int i = 1; i < len; i ++) {
if (ratings[i] >= ratings[i - 1]) {
down = 0;
prev = ratings[i] == ratings[i - 1] ? 1 : prev + 1;
ans += prev;
up = prev;
} else {
down ++;
if (down == up)
down ++;
ans += down;
prev = 1;
}
}
return ans;
}
};
最大子序和
(动规)
因为需要求出连续的子数组的和,所以这里就有了限制的条件:需要是连续子数组中的和。并且前后子问题是有联系的,所以可以使用动规。
-
状态定义
dp[i]
表示以i
位置为结尾的子数组中最大子数组的和为dp[i]
-
递推公式
dp[i]
至少为nums[i]
。而且以i位置为结尾的最大子数组和可以由以i - 1位置为结尾的最大子数组和+num[i]
递推出来。两者取一个最大值即可。即dp[i] = max(nums[i], nums[i] + dp[i - 1])
-
初始化
由递推公式可知,
dp[i]
由dp[i - 1]
状态递推而来,所以需要初始化dp[0]
,dp[0] = nums[0]
-
遍历顺序
由递推公式可知,从前往后遍历即可。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int len = nums.size();
vector<int> dp(len, 0);
dp[0] = nums[0];
int ans = nums[0];
for (int i = 1; i < len; i ++) {
dp[i] = max(nums[i], nums[i] + dp[i - 1]);
ans = max(ans, dp[i]);
}
return ans;
}
};
(贪心)
这题还可以使用贪心的做法。只有当以i - 1
为结尾的子数组的和大于0的时候,才可以使得以i
为结尾的子数组的和变大,这样才可以得到更优解。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int sum = 0; // 前面数组中的和
int ans = INT_MIN;
for (int num: nums) {
if (sum >= 0) {
sum += num;
} else {
sum = num;
}
ans = max(ans, sum);
}
return ans;
}
};
摆动序列
(动规)
要求出摆动序列的最长长度,这里就增加了限制条件:1.需要是数组中的序列 2.序列一定是摆动序列 这两个限制状态的条件,至于最长这个条件是递推过程中状态转移时的条件,并不是限制状态的条件。
1.状态定义
dp[i][j]
表示在j
这个状态下(j == 0
表示当前为下降状态,j == 1
表示当前为上升状态)前i
数中最长摆动序列的长度为dp[i][j]
2.递推公式
如果当前是下降状态即dp[i][0]
则可以由两个状态推出:第一个是前i - 1
数中的下降状态推出(图中②),第二个是前i - 1
数中的上升状态加1(1表示当前位置上的数就是上升的数字)(图中③)。
同理,如果当前是上升状态,即dp[i][1]
则可以由两个状态推出:第一个是前i - 1
个数中上升状态推出(图中④),第二个是前i - 1
个数中的下降状态加1(1表示当前位置上的数就是下降的数字)(图中①)。
如果两个数字相等,那么直接将前面一个相同数字的状态照抄下来即可(图中⑤)。
3.初始化
因为每一个数都可以看做是摆动序列,所以所有的数都初始化为1。由递推公式可知dp[0][0]
和dp[0][1]
都需要自己初始化,但是也为1,所以可以全部写在vector初始化中。
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int len = nums.size();
vector<vector<int>> dp(len, vector<int>(2, 1));
dp[0][0] = dp[0][1] = 1;
for (int i = 1; i < len; i ++) {
if (nums[i] > nums[i - 1]) {
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + 1);
} else if (nums[i] < nums[i - 1]) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + 1);
} else {
dp[i][0] = dp[i - 1][0];
dp[i][1] = dp[i - 1][1];
}
}
return max(dp[len - 1][0], dp[len - 1][1]);
}
};
(动规优化空间)
根据递推公式可知,dp[i][j]
是由dp[i - 1][j]
所递推出来的,所以可以直接省掉一维空间,直接使用桂东数组即可。但是要注意每一次可能会覆盖上一次的值,所以可以使用两个临时变量保存上一次的值。
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int len = nums.size();
vector<int> dp(2, 1);
for (int i = 1; i < len; i ++) {
int tmp0 = dp[0], tmp1 = dp[1];
if (nums[i] > nums[i - 1]) {
dp[1] = max(tmp1, tmp0 + 1);
} else if (nums[i] < nums[i - 1]) {
dp[0] = max(tmp0, tmp1 + 1);
}
}
return max(dp[0], dp[1]);
}
};
(贪心)
经过观察可以发现,只要每一次都去摆动序列的极值点,这样就可以最大限度的伸展摆动序列的摆动幅度。这样中间就可以取得更多的点。所以就可以取得更多的数值。而且两端的点可以默认是峰值。
反证:如果不取摆动幅度最大的点(也就是极值点)的话,那么摆动序列的摆动幅度一定会减少(至少峰值那个极值点就没有算进来)。所以中间的可以容纳的点就更少,那么可能形成的摆动序列就会变得更少。因此不如取极值点要好。
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
// 去重
nums.erase(unique(nums.begin(), nums.end()), nums.end());
int len = nums.size();
if (len <= 2) return len;
int ans = 2; // 默认将开头和结尾算进来,这样后面循环好计算
for (int i = 1; i < len - 1; i ++) {
int prev = nums[i - 1], now = nums[i], next = nums[i + 1];
if ((prev < now && now > next) || (prev > now && now < next)) ans ++;
}
return ans;
}
};
(贪心2)
这样写法的思想和上面的思想是一样的,但是写法稍有不同。
这样写法是在比较当前摆动的幅度的差值和上一次摆动幅度的差值的情况,说白了就是比较摆动的落差情况。只用当前后两次摆动有落差的时候,才能形成摆动序列。
这里因为每一次都是上一次的落差和当前的落差比较,所以第一次就会比较尴尬(没有前面一次落差),所以默认将第一个点放入摆动序列当中。
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int len = nums.size();
int prev_diff = 0; // 前一次落差
int cur_diff = 0; // 当前落差
int ans = 1;
for (int i = 1; i < len; i ++) {
cur_diff = nums[i] - nums[i - 1];
// 这里prev_diff取=,也是因为第二个数字比较的时候,前一次落差一定为0
if ((cur_diff > 0 && prev_diff <= 0)||(cur_diff < 0 && prev_diff >= 0)) {
ans ++;
prev_diff = cur_diff;
}
}
return ans;
}
};
③区间调度问题
无重叠区间
这里先用一个生活中的例子引入:到底怎么安排时间才可以在1小时内玩最多的游戏局数?
话说小明刚刚前面考试完,妈妈奖励小明可以玩游戏,但是只能玩一个小时。而小明看到最近王者上面新出了一个活动:玩得局数越多,奖励越丰厚。所以他估么着怎么样才可以在一个小时之内完更多的局数。
他翻看了以前的对战记录并统计了对战时间,最后他灵机一动,发现既然不可以同时地开两盘对战同时打,那么只要更早的结束结束一场对战,这样就可以剩下更多的时间打下一场对战。于是小明开开心心地点开了3v3然后到点就投降(O(∩_∩)O哈哈~)。
(贪心1左端点排序)
根据上面的故事,再将每一段区间看成是一段时间轴,那么原题目移除最少的重叠区间个数就换成了求出不重叠区间的个数。也就是小明可以最多在不同时开两盘的情况下,怎么才能玩最多的局数。
题解就是:使得区间结束越早越好。
所以就有了两种解法,第一种就是按区间的左端点排序,遇到区间的右端点比当前区间的右端点更早结束的时候就更新右端点成为更小的右端点,这样就可以更早地结束区间。
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
int len = intervals.size();
int ans = 0;
sort(intervals.begin(), intervals.end());
int right = intervals[0][1];
for (int i = 1; i < len; i ++) {
if (intervals[i][0] < right) {
right = min(intervals[i][1], right);
} else {
right = intervals[i][1];
ans ++;
}
}
return len - ans;
}
};
(贪心2右端点排序)
第二种方法就是直接按右端点排序,其实就是按区间的结束时间排序。这样的话,如果直接从头遍历到尾就是区间结束的顺序。
按右端点排序之后,只要从头往后遍历,发现一个区间的开始时间在上一个区间的结束区间之后就可以紧跟上就可以了,因为是按右端点排序的,所以当前区间的结束时间一定比其他后面的区间的结束时间要早。
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
int len = intervals.size();
sort(intervals.begin(), intervals.end(), [](vector<int>& a, vector<int>& b) {
return a[1] < b[1];
});
int ans = 0;
long right = LONG_MIN;
for (int i = 0; i < len; i ++) {
if (intervals[i][0] >= right) {
ans ++;
right = intervals[i][1];
}
}
return len - ans;
}
};
总结:本题需要求出重叠区间的最小值转换成了求出不重叠区间的最大值。当需要求出最多的无重叠区间的个数的时候,可以使用这样贪心的策略来求解。
用最少数量的箭引爆气球
本题可以转换成区间重叠问题,拿气球的直径为区间的范围。那么在多个有重叠部分的区间中只需要一个弓箭就可以全部射爆,所以只要求出所有区间中的不重叠区间的个数即可。
这里还有两个点需要注意的是:
1.当两个区间只要一个点重叠的时候,也算是区间重叠。
2.区间的范围在[INT_MIN, INT_MAX]
,所以需要将一开始的区间的右边界(最小值)初始化为LONG_MIN
才可以。
(贪心1左端点排序)
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
int len = points.size();
int ans = 0;
long right = LONG_MIN;
sort(points.begin(), points.end());
for (int i = 0; i < len; i ++) {
if (points[i][0] <= right) {
right = fmin(right, points[i][1]);
} else {
ans ++;
right = points[i][1];
}
}
return ans;
}
};
(贪心2右端点排序)
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
int len = points.size();
int ans = 0;
sort(points.begin(), points.end(), [](vector<int>& a, vector<int>& b){
return a[1] < b[1];
});
long right = LONG_MIN;
for (int i = 0; i < len; i ++) {
if (points[i][0] > right) {
ans ++;
right = points[i][1];
}
}
return ans;
}
};
总结:本题需要求出不重叠区间的最大值,直接使用区间贪心。
会议室 II
本题乍一眼觉得很像上面几道区间分配的问题,但实际上却是不一样的。
比如说上面这幅图,如果区间这样分配的话,按计算无重叠区间算,应该是需要4间会议室。按重叠区间算的话应该是需要1间会议室。但是实际上需要2间会议室,一间会议室给1号,一间会议室给2,3,4,5循环使用。本题不同于之前的重叠区间问题或者是戳气球问题的是,一间会议室可以循环的使用。也就是多个不重叠的区间只能算成一段区间。
这个问题就是经典的上下车问题。就是在求出在同一时刻上,最多需要有几个会议室。
(贪心)
第一种方法就是遇到开始时间需要会议室ans+1,遇到该会议室结束的时候ans-1。开始开会的时间按照各个会议室开始的时间排序。使用一个变量ans记录同时开会的会议室的间数。
class Solution {
public:
using PII = pair<int, int>;
int minMeetingRooms(vector<vector<int>>& intervals) {
int len = intervals.size();
vector<PII> meetings;
for (auto& interval : intervals) {
meetings.push_back({interval[0], 1}); // 开始会议的时间,会议室数量+1
meetings.push_back({interval[1], -1}); // 结束会议的时间,会议室数量-1
}
sort(meetings.begin(), meetings.end()); // 按照时间顺序排序
int ans = 0;
int max_cnt = 0;
for (auto meeting : meetings) {
max_cnt += meeting.second; // 同时在使用会议室的数量
ans = max(ans, max_cnt); // 会议室数量的最大值
}
return ans;
}
};
(优先队列 + 贪心)
还有一种方法就是手动的计算会议室的间数。可以使用小根堆,将会议室的结束时间放到小根堆中,每一次都将新一间会议室开会的时间和上一个会议室结束的时间比较。如果上一个会议已经结束那么新的会议就在旧会议室开,如果上一个会议没有结束将记录会议室的间数+1。
class Solution {
public:
int minMeetingRooms(vector<vector<int>>& intervals) {
int len = intervals.size();
sort(intervals.begin(), intervals.end()); // 按时间顺序排序
priority_queue<int, vector<int>, greater<int>> q; // 记录会议结束的时间
int ans = 0; // 同时在开会的会议室的刷领
for (int i = 0; i < len; i ++) {
// 如果前一趟会议已经结束,则添加新会会议的结束时间
// 否则会议室的数量就要+1,并添加该会议室的结束时间
while (!q.empty() && intervals[i][0] >= q.top()) {
q.pop();
}
q.push(intervals[i][1]);
ans = fmax(ans, q.size());
}
return ans;
}
};
④跳跃问题
跳跃游戏
本题其实算地是跳跃的范围,如果数组的最后一个位置在跳跃的范围内,那么说明就可以到达,否则不可以。
(贪心1)
第一种方法是从头往后计算跳跃的最远距离,如果最远的距离已经超过了数组的最后一维,说明可以达到数组的最后一位,否则就继续往后跳。其中如果遇到不能再前进的地方,即i > reach
的话,说明不能再往前跳了,就返回false
。
class Solution {
public:
bool canJump(vector<int>& nums) {
int len = nums.size();
int reach = 0;
for (int i = 0; i < len; i ++) {
if (i > reach) return false;
reach = max(reach, i + nums[i]);
if (reach >= len - 1) return true;
}
return false;
}
};
(贪心2)
第二种方法就是从后往前倒推,计算可以到达最后一个位置的位置在哪里,然后这个更新的位置就可以看做是最后一个可以到达数组最后的位置。
class Solution {
public:
bool canJump(vector<int>& nums) {
int len = nums.size();
int last = len - 1;
for (int i = len - 2; i >= 0; i --) {
if (nums[i] + i >= last) {
last = i;
}
}
return last == 0;
}
};
跳跃游戏 II
(广搜)
第一种最朴素最暴力的方法就是BFS,一步一步往后移动。
因为是求出到达数组中的最后一个的最小步数,所以可以用BFS搜索出来。每次都将[q.front() ~ nums[q.front()]]
之间的所有数字入队,直到找到最后一个数字。其中可以使用广搜的常用手段,unordered_set
做优化。可以避免重复元素重复入队。
class Solution {
public:
int jump(vector<int>& nums) {
int len = nums.size();
queue<int> q;
unordered_set<int> vis;
vis.insert(0);
q.push(0);
int depth = 0;
while (!q.empty()) {
int size = q.size();
while (size --) {
auto top = q.front();
q.pop();
if (top == len - 1) return depth;
for (int i = top; i <= top + nums[top]; i ++) {
if (vis.count(i)) continue;
if (i == len - 1) return depth + 1;
q.push(i);
vis.insert(i);
}
}
depth ++;
}
return depth;
}
};
(动规)
题目要求出到达数组中最后一个位置的跳跃次数,那么就可以拆解为成子问题:从前面某一个位置可以跳到最后一个位置。所以可以使用动态规划。
这里的限制条件就是在数组中跳跃,所以只需要一维空间即可。
1.状态定义
dp[i]
表示跳到第i个位置最少需要dp[i]
步。
2.递推公式
跳到第i
个位置需要的最少步数,可以从前i - 1
步中找,如果前i - 1
步有可以直接跳到i
位置甚至超过i
位置的位置,到达第i
个位置就是这样位置在跳一步,即dp[i] = dp[j] + 1
,在前i - 1
个位置中选择一个最少的步数即可,即if (j + nums[j] >= i) dp[i] = min(dp[i], dp[j] + 1)
3.初始化
因为最后需要求出最小值,所以一开始dp数组中的所有数为了不影响计算都要初始化为INF
。其中第一个位置不需要步数就可以到达,所以dp[0] = 0
4.遍历顺序
从前往后遍历即可
class Solution {
public:
int jump(vector<int>& nums) {
const int INF = 0x3f3f3f3f;
int len = nums.size();
vector<int> dp(len, INF);
dp[0] = 0;
for (int i = 1; i < len; i ++) {
for (int j = 0; j < i; j ++) {
if (j + nums[j] >= i) {
dp[i] = min(dp[i], dp[j] + 1);
break;
}
}
}
return dp[len - 1];
}
};
(贪心)
本题的贪心方法比较难想出来。
如果贪心地想,每一次一定是跳跃地越远越好,这样跳跃的次数才能越少。所以每一次都要在当前的跳跃范围之内,找出下一次跳跃的范围,如果跳跃的范围已经包含了数组的最后一个位置,说明就已经可以到达数组的最后 一个位置量了。
贪心的亮点在于:**每一次需要在本次的跳跃范围之内,找出下一次跳跃范围的最大值。**这样就可以最贪心的找到步数最少的解。假设下一次跳跃的范围比可计算出的最大的范围要少(假设原本最大范围为[i, j]
, 现在只是[i, j - 1]
),那么跳到j
位置的步数就会比原来跳跃的步数至少多1步,所以每一次跳跃的范围一定要最大。
注意:只要循环len - 1
次即可,因为最后如果cur == len - 1
就不用再跳跃一次了。而且当len == 1
的时候,正好不会进入循环直接return 0
。
class Solution {
public:
int jump(vector<int>& nums) {
int len = nums.size();
int ans = 0;
int cur = 0, next = 0;
for (int i = 0; i < len - 1; i ++) { // 注意这里只要到len-1就可以了
next = max(next, i + nums[i]);
if (i == cur) {
cur = next;
ans ++;
}
}
return ans;
}
};
或者如果循环len
次,也可以加两个特判
class Solution {
public:
int jump(vector<int>& nums) {
int len = nums.size();
if (len == 1) return 0; // 1.如果只有一个元素,可以不用跳跃
int cur = 0, next = 0;
int ans = 0;
for (int i = 0; i < len; i ++) {
next = max(next, i + nums[i]);
if (i == cur) {
cur = next;
ans ++;
// 2.当cur已经找到最后一个数的时候,可以return ans
if (cur >= len - 1) return ans;
}
}
return ans;
}
};
⑤数字相关贪心
单调递增的数字
为了获得单调递增的数字,所以我们希望i
位置上的数字一定要比i + 1
位置上的数字要小,这样才可以得到递增的数字。
但是往往会出现递减的序列,这时就可以贪心地将这些递减的序列都去掉,因为只要出现了递减的序列,那么从递减的位置开始,后面的序列就都不可能是递增的了,所以必须要去掉。同时还需要将开始递减位置上的数字-1,后面的位置都用‘9’
填充,这样就可以使得在1 ~ n
中选取的数字最大。
注意:还有一种特殊的情况就可以序列既不上升也不下降,如2331
这样的数字。当遇到最后两位是递减的时候,需要将3
换成2
,将1
换成9
。但是此时数字为2329
。前面的数字又不满足递增的要求了。因此本题有两种方式可以实现贪心。
(贪心1)
第一种方法是从前往后遍历,为了出现序列既不上升也不下降的情况,所以需要继续需要不上升的最后一个位置,然后找到递减的序列之后,从不上升的位置开始填充‘9'
。
注意:如果使用num[i]
和num[i - 1]
比较序列的增减性,就会少考虑一位数。如11
,如果发现从第2位开始就不增了,就会直接跳出循环,会被重置成‘9’
。
所以为了从第一位开始考虑可以使用一个变量记录数字中每一位的最大值,这样就可以从第一位开始考虑了。
class Solution {
public:
int monotoneIncreasingDigits(int n) {
string num = to_string(n);
int len = num.size();
int index = 0;
int max_num = -1;
for (int i = 0; i < len - 1; i ++) {
if (max_num < num[i]) {
max_num = num[i];
index = i;
}
if (num[i] > num[i + 1]) {
num[index] --;
for (int i = index + 1; i < len; i ++) {
num[i] = '9';
}
}
}
return stoi(num);
}
};
(贪心2)
第二种方法就干脆直接从后面往前面递推,这样只要发现递增序列就可以重置,然后此时第i
个位置已经-1,可以往前继续判断。
注意:这里不可以在判断num[i] < num[i - 1]
直接让num[i] = ‘9’
这是因为有可能一个数字从最后一位开始就不上升不下降,但是前面有一个位置是下降的。如5333
,当从后往前遍历的时候,直接跳过了后面的2个3,但是遇到53这样递减的序列之后,就要将后面的数字都重置为9
了,而不是换成4933
。
所以可以使用一个变量标记一下递减的位置,最后从这个位置开始后面的数字全部重置为‘9'
即可。
class Solution {
public:
int monotoneIncreasingDigits(int n) {
string num = to_string(n);
int len = num.size();
int flag = 0;
for (int i = len - 1; i >= 1; i --) {
if (num[i - 1] > num[i]) {
num[i - 1] --;
flag = i;
}
}
if (flag == 0) return n;
for (int i = flag; i < len; i ++) {
num[i] = '9';
}
return stoi(num);
}
};
总结:不论是从前往后还是从后往前都需要考虑既不上升也不下降的序列,只要出现递减的序列就要将预期关联的所以持平序列都重置。
移掉K位数字
(贪心 + 单调栈)
贪心思路:如果想要剩下的数字变得最小,就要将权值位高上的大数字移除掉。
反证:
如果num[i] > nums[i + 1]
,就一定移除掉num[i]
,因为num[i]
的权值更高,并且数字更高。如果不换成num[i + 1]
,那么数字一定会更到。如:21
,必须移除掉2
,才能变成1
,否则移除1
就变成2
了,数字变大。
如果num[i] < num[i + 1]
,那么一定不可以移除num[i]
,因为num[i]
权值更小,并且数字更小,如果换成num[i + 1]
,数字就会变大。
如果num[i] == num[i + 1]
,无法判断,可以让后面更小的数字选择移除或者不移除该位置上的相同数字。
所以本题就是利用了单调栈的思想,维护一个单调递增的栈。本质就是利用右边权值小的位置上的数字将左边权值大的位置上的数字移除掉k个。
注意:
1.如果不足k个的话(假设还有n个),因为维护的是单调递增的栈,所以此时栈中的数字呈现递增状态,此时只需要将后缀的n个数字删除即可。(这也是一个局部的小贪心,因为删除大的数字,一定要比删除小的数字,再让后面大的数字权值变大之后的数字要小。
2.前导零是不能出现在字符串当中的,所以需要将前导零去掉
class Solution {
public:
string removeKdigits(string num, int k) {
int len = num.size();
if (k >= len) return "0";
// 使用小的数字将大的数字移除
string ans;
for (int i = 0; i < len; i ++) {
while (k && ans.size() && ans.back() > num[i]) {
k --;
ans.pop_back();
}
ans += num[i];
}
// 移除后缀的数字
if (k > 0) ans = ans.substr(0, ans.size() - k);
// 移除前导零
int index = 0;
while (index < ans.size() && ans[index] == '0') index ++;
return index == ans.size() ? "0" : ans.substr(index);
}
};
总结:通过贪心地分析之后,发现本题可以使用单调栈来维护。和单调栈不同的是,这里还有一个限制条件就是删除的字符不能超过k个。
四道简单贪心题
分发饼干
(贪心)
本题是一道简单的贪心问题。一开始处于本能的反应一定是将饼干分给对应胃口的孩子,这样才不会浪费饼干。
所以为了实现这个目的,需要先将饼干和孩子的胃口值排序,这样就可以最大程度上使得饼干和胃口值匹配,不浪费饼干。
然后遍历饼干和胃口值判断if (g[i] <= s[j])
,如果为真,说明这个饼干可以满足孩子的要求,所以就可以判断下一个孩子的胃口和对应的饼干。如果为假,说明这个饼干不能满足孩子的胃口。但是由于饼干和胃口值已经是拍好序的,所以这个饼干是当前剩下饼干中最小的,这个孩子也是剩下孩子中胃口最小的,所以这个饼干不满足这个胃口最小的孩子,也就一定不满足后面胃口值更大的孩子了,只能就后面更大的饼干给这个孩子。
这就是本题的贪心策略。通过排序使得饼干和胃口值之间有了单调性,使得在判断为假的时候,可以直接舍弃掉后面所有的不满足这个条件的所有情况。
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int ans = 0;
int i = 0, j = 0;
while (i < g.size() && j < s.size()) {
if (g[i] <= s[j]) {
i ++;
j ++;
ans ++;
} else { // 如果该饼干不满足第i个孩子的胃口,那么该饼干也不能满足后面的孩子的胃口
j ++;
}
}
return ans;
}
};
卡车上的最大单元数
(贪心)
本体也是一个很明显的贪心问题。如果想要使得装载的单元总数最多,那么就要先装载单元数量最多的箱子。这样证明也很简单:每一次装载更多的单元的箱子一定要比装载更少单元的箱子会使得最后的单元总数更大。
所以可以先按箱子的单元数量排降序,然后按顺序取truckSize
个箱子,这样就可以使得最后的单元数量最多。
class Solution {
public:
int maximumUnits(vector<vector<int>>& boxTypes, int truckSize) {
sort(boxTypes.begin(), boxTypes.end(), [](vector<int>& a, vector<int>& b){
return a[1] > b[1];
});
int ans = 0;
for (auto box : boxTypes) {
int cnt = min(box[0], truckSize);
ans += box[1] * cnt;
truckSize -= cnt;
if (truckSize == 0) break;
}
return ans;
}
};
玩筹码
(贪心)
本题很有意思,就是相邻的数字之间需要花一个代价才可以移动,但是相隔一个数的数字之间不需要花费代价就可以移动。(重点)就是说奇数之间转化是不需要花费代价的,偶数之间也是不需要花费代价的。再换言之,只要是奇数就可以认为是在一个位置上的,因为奇数之间不需要花费代价。同理所有的偶数也可以认为是在同一个位置上的。于是本题就转化为一个位置上的奇数和一个位置上的偶数之间的移动。
即解法就是算出奇数和偶数的个数,哪一个数量越少,就移动哪一个,这样就可以花费更小的代价。这就是本题的贪心策略。
class Solution {
public:
int minCostToMoveChips(vector<int>& position) {
int odd = 0, even = 0;
for (int pos : position) {
if (pos & 1) {
odd ++;
} else {
even ++;
}
}
return min(odd, even);
}
};
两地调度
(贪心)
本题的贪心其实感觉起来一下子想不出来,这是因为同时有两个变量(去A城市的成本和去B城市的成本)同时在改变,所以同时需要考虑两个变量,因此很难兼顾。
如果本题换成,先让所有的人都去A城市,再选取n/2个人去B城市,如何使得成本最低?
此时的变量就变成了一个变量(去B城市成本的最小值),这是只要贪心地想选取n/2个去B城市成本最低的人就可以了。
class Solution {
public:
int twoCitySchedCost(vector<vector<int>>& costs) {
// 所有人都先去A城市
int sum = 0;
for (auto cost: costs) {
sum += cost[0];
}
// 按从A城市到B城市的成本排序
sort(costs.begin(), costs.end(), [](vector<int>& a, vector<int>& b){
return a[1] - a[0] < b[1] - b[0];
});
// 派n/2个人取B城市
int len = costs.size();
for (int i = 0; i < len / 2; i ++) {
sum += costs[1] - costs[0]; // 从A城市到B城市的费用
}
return sum;
}
};
总结:本题难就难在思维的转换上,如果下次做题出现了两个变量,可以试着固定一个变量的变化,计算两个变量。
使括号有效的最少添加
(栈)
一般表达式或者括号匹配问题都可以使用栈来解决。
如果遇到左括号就加入栈中;如果遇到右括号需要判断是否有与之匹配的左括号,如果有就将左括号抵消掉,否则因为后面的括号也不会和这个右括号匹配,所以就直接放入栈中即可。
class Solution {
public:
int minAddToMakeValid(string s) {
stack<char> sk;
for (auto ch : s) {
if (!sk.empty() && sk.top() == '(' && ch == ')') {
sk.pop();
continue;
}
sk.push(ch);
}
return sk.size();
}
};
(贪心)
第二种方法就是使用括号匹配法,来判断需要什么样的括号。
1.如果是(
,则需要)
来匹配它,所以right ++
。而且因为左括号的右边可能还会有右括号,所以暂时不用处理左括号多余的情况。
2.如果是)
,则需要(
来匹配它,这时候就有两种情况了。
2.1.第一种就是这个右括号的左边需要这个右括号来匹配它,这时刚刚需要的右括号right
数量就可以减少一个。
2.2.第二种就是这个右括号是多余的,这个括号的左边没有做左括号需要匹配,也就是right == 0
,这时就需要左括号数量加一,即left ++
。
注意:和左括号匹配不同的是,右括号只能和左边的括号匹配,所以如果右括号没有可以匹配的括号的话,left
一定加一,不会再被抵消掉。
class Solution {
public:
int minAddToMakeValid(string s) {
int right = 0, left = 0;
for (char ch: s) {
if (ch == '(') {
right ++;
} else {
if (right > 0) {
right --;
} else { // 左边已经不需要右括号了,就需要用左括号匹配多余的右括号
left ++;
}
}
}
}
};
交换字符使得字符串相同
(贪心 + 数学)
由于需要用最少的交换次数使得字符串相等,所以原来两个字符串相同的部分就不用交换了(局部的贪心)。
下面就是要考虑两个字符串对应位置上的不同字符的交换情况。
总结一下只有两种情况:
1.两组交换的字符相同,即两个相同的xy
或者yx
交换。这种情况只需要交换一次就可以使得两组字符相同了。
2.两组交换的字符不相同,即xy
和yx
交换。这种情况需要先交换字符使得两组字符相同,然后再交换一次,总共两次。
根据上面的数学推导,可知:
1.交换字符的组数一定需要是偶数,否则不可能来两两交换可以直接return -1
。
2.在有相同两组字符的时候,优先交换两组相同的交换字符(贪心),这样就可以只交换一次,比交换两组不同的字符交换的次数要少。
解法:统计不同字符中一组字符的x和y的数量。如果x % 2 != 0
,说明在交换完相同字符之后,还有两个不相同的字符需要交换,ans = x / 2 + y / 2 + 2
;如果x % 2 == 0
,说明所有的字符交换组都是两两相同可以互相交换的,ans = x / 2 + y / 2
。
class Solution {
public:
int minimumSwap(string s1, string s2) {
int xy = 0, yx = 0;
int len = s1.size();
for (int i = 0; i < len; i ++) {
if (s1[i] == 'x' && s2[i] == 'y') xy ++;
if (s1[i] == 'y' && s2[i] == 'x') yx ++;
}
if ((xy + yx) & 1) return -1;
int ans = 0;
if (xy % 2 == 0) { // 去不都是相同的字符组
ans = xy / 2 + yx / 2;
} else { // 存在一个不相同的字符组
ans = xy / 2 + yx / 2 + 2;
}
return ans;
}
};
给定行和列的和求可行矩阵
(贪心)
本题比较难想,正是因为看起开有很多的二维矩阵都可以,所以就没有一个固定的标准。
所以这里可以每一次都在一个位置上取得该位置上可能取得的最大值(贪心),这样其余位置上的数字就可以直接取0了,这样就可以最大程度上的呈现出一个固定的公式。
class Solution {
public:
vector<vector<int>> restoreMatrix(vector<int>& rowSum, vector<int>& colSum) {
int row = rowSum.size(), col = colSum.size();
vector<vector<int>> ans(row, vector<int>(col, 0));
for (int i = 0; i < row; i ++) {
for (int j = 0; j < col; j ++) {
ans[i][j] = min(rowSum[i], colSum[j]);
rowSum[i] -= ans[i][j];
colSum[j] -= ans[i][j];
}
}
return ans;
}
};