理论基础
什么是贪心: 贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
贪心算法没有固定套路,贪心算法大致一般分为如下四步:
-
将问题分解为若干个子问题
-
找出适合的贪心策略
-
求解每一个子问题的最优解
-
将局部最优解堆叠成全局最优解
简单例题
分发饼干
力扣原题
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i
,都有一个胃口值 g[i]
,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j
,都有一个尺寸 s[j]
。如果 s[j] >= g[i]
,我们可以将这个饼干 j
分配给孩子 i
,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
示例 1:
输入: g = [1,2,3], s = [1,1]
输出: 1
解释: 你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。虽然你有两块小饼干,由于他们的尺寸都是 1,你只能让胃口值是 1 的孩子满足。所以应该输出1。
示例 2:
输入: g = [1,2], s = [1,2,3]
输出: 2
解释: 你有两个孩子和三块小饼干,2 个孩子的胃口值分别是1,2。你拥有的饼干数量和尺寸都足以让所有孩子满足。所以你应该输出 2。
思路
- 先将饼干大小s[i]与孩子的胃口g[i]进行排序
- 每一轮保证当前胃口最小的孩子得到饼干吃
- 直到最大的饼干大小都无法满足当前孩子的胃口
代码实现
class Solution {
public:
// g[i] 最小胃口 s[i] 饼干大小 s[i] >= g[i]
int findContentChildren(vector<int>& g, vector<int>& s) {
int res = 0;
int startIdx = 0; //记录发到第几块饼干了
sort(g.begin(), g.end());
sort(s.begin(), s.end());
for(int i = 0; i < g.size(); i++)
{
for(int j = startIdx; j < s.size(); j++)
{
if(s[j] >= g[i])
{
res++;
startIdx = j+1; //从下一块饼干开始发
break;
}
}
}
return res;
}
};
K次取反后最大化的数组和
给定一个整数数组 nums
和一个整数 k
,按以下方法修改该数组:
- 选择某个下标
i
并将nums[i]
替换为-nums[i]
。 - 重复这个过程恰好
k
次。可以多次选择同一个下标i
。
以这种方式修改数组后,返回数组 可能的最大和
核心思想
- 局部最优: 让绝对值大的负数变为正数,当前数值达到最大
- 整体最优: 整个数组和达到最大。
将数组按照绝对值大小从大到小排序,从前向后遍历,遇到负数将其变为正数,同时K–;结束后如果K还大于0,那么反复转变数值最小的元素,将K用完。
代码实现
class Solution {
static bool cmp(int a, int b)
{
return abs(a) > abs(b);
}
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
int sum = 0;
sort(nums.begin(), nums.end(),cmp); //按绝对值从大到小排序
// 把尽可能多的负数转为正数
for(int i = 0; i < nums.size();i++)
{
if(nums[i] < 0 && k > 0)
{
nums[i] = nums[i] * -1;
k--;
}
}
if(k % 2) // 若还需要转奇数次,把最小的数转一次
nums[nums.size()- 1] = nums[nums.size()- 1] * -1;
for(int num:nums)
sum += num;
return sum;
}
};
柠檬水找零
力扣链接
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元,一开始手头无钱。
示例 1:
输入:bills = [5,5,5,10,20]
输出:true
示例 2:
输入:bills = [5,5,10,10,20]
输出:false
核心思想
贪心:
局部最优: 遇到账单20,优先消耗美元10,完成本次找零。
全局最优: 完成全部账单的找零。
因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!
代码实现
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
int money5 = 0;
int money10 = 0;
for(int i = 0; i < bills.size();i++)
{
if(bills[i] == 5)
money5++;
else if(bills[i] == 10)
{
money10++;
if(money5 > 0)
money5--;
else
return false;
}
//收到20 有10块的先找10块的
else if(bills[i] == 20)
{
if(money5 > 0 && money10 > 0)
{
money5--;
money10--;
}
else if(money5 >= 3)
money5 -= 3;
else
return false;
}
}
return true;
}
};
买卖股票的最佳时机 Ⅱ
给你一个整数数组 prices
,其中 prices[i]
表示某支股票第 i
天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售,返回 你能获得的 最大 利润 。
示例 1:
输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3。最大总利润为 4 + 3 = 7 。
示例 2:
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4。最大总利润为 4 。
贪心思想
这道题目可能我们只会想,选一个低的买入,再选个高的卖,再选一个低的买入…循环反复,其实最终利润是可以分解的。
假如第 0 天买入,第 3 天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
此时就是把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑!
局部最优:收集每天的正利润,全局最优:求得最大利润。
代码实现
class Solution {
public:
int maxProfit(vector<int>& prices) {
int res = 0;
int profit;
for(int i = 1; i < prices.size(); i++)
{
profit = prices[i] - prices[i-1]; // 当天利润
if(profit > 0)
res += profit;
}
return res;
}
};
单调递增的数字
给定一个非负整数 N,找出小于或等于 N 的最大的整数,同时这个整数需要满足其各个位数上的数字是单调递增。
暴力求解
暴力的思想即是通过逆序遍历N,判断每一个数字是否符合单调递增的规律,若符合,则直接返回。
bool judge(int num)
{
int max = 10;
while (num)
{
int t = num % 10; // 依次取每一位
if (max >= t) // 单调递增
max = t;
else
return false;
num = num / 10;
}
return true;
}
贪心求解
- 拿一个两位的数字举例,例如98,一旦出现
strNum[i - 1] > strNum[i
]的情况(非单调递增),首先想让strNum[i - 1]--
,然后strNum[i]
给为9,即得到小于98的最大的单调递增整数。 - 从后向前遍历,可以重复利用上次比较得出的结果,从后向前遍历332的数值变化为:332 -> 329 -> 299
- 最后代码实现的时候,也需要一些技巧,例如用一个flag来标记从哪里起(从第一次出现
strNum[i - 1] > strNum[i
] )后面开始赋值9。
代码实现
int monotoneIncreasingDigits(int n)
{
string strNum = to_string(n);
int flag = strNum.size(); // 标记才哪开始起全部赋值为9
for(int i = strNum.size()-1; i > 0; i--)
{
if(strNum[i-1] > strNum[i]) // 第一次出现非递增情况
{
strNum[i-1]--;
flag = i; // 记录下标,后面全部赋值为9
}
}
for(int i = flag; i < strNum.size(); i++)
strNum[i] = '9';
return stoi(strNum);
}
摆动序列
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
- 例如, [1, 7, 4, 9, 2, 5] 是一个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。
- 相反,[1, 4, 7, 2, 5] 和 [1, 7, 4, 5, 5] 不是 摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。给你一个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的长度 。
思路
-
本题是典型的查找变号 / 波峰波谷问题,但存在诸多特殊情况的细节问题
-
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
-
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。、
-
但本题要考虑三种特殊平坡情况:
(1)情况一:上下坡中有平坡
(2)情况二:数组首尾两端
(3)情况三:单调坡中有平坡
(1)情况一:上下坡中有平坡
如图,可以统一规则,保留平坡最右边的数字,即删除左边的三个 2:
-
在图中,当
i
指向第一个 2 的时候,prediff > 0 && curdiff = 0
,当i
指向最后一个 2 的时候prediff = 0 && curdiff < 0
。 -
如果我们采用,删左面三个 2 的规则,那么 当
prediff = 0 && curdiff < 0
也要记录一个峰值,因为他是把之前相同的元素都删掉留下的峰值。 -
所以我们记录峰值的条件应该是:
(preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)
,为什么这里允许prediff == 0
,就是为了 上面我说的这种情况。
(2)情况二:数组首尾两端
- 可以假设,数组最前面还有一个数字,那这个数字和第一个数字相同,即使得
prediff = 0
,curdiff < 0 或者 >0 也记为波谷。 - 那么为了规则统一,针对序列
[2,5]
,可以假设为[2,2,5]
,这样它就有坡度了即preDiff = 0
,如图:
针对以上情形,result 初始为 1(默认最右面有一个峰值),此时 curDiff > 0 && preDiff <= 0,那么 result++(计算了左面的峰值),最后得到的 result 就是 2(峰值个数为 2 即摆动序列长度为 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;
}
return result;
}
};
(3)情况三:单调坡中有平坡
在上述代码中,忽略了一种情况,即 如果在一个单调坡度上有平坡,例如[1,2,2,2,3,4],如图:
- 图中,我们可以看出,上述代码在三个地方记录峰值,但其实结果因为是 2,因为 单调中的平坡 不能算峰值(即摆动),之所以版本一会出问题,是因为我们实时更新了 prediff。
- 我们只需要在 这个坡度 摆动变化的时候,更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判。
代码最终实现
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int res = 1; // 最长子序列 默认最右边有坡度
int prediff = 0; // 保存上一轮结果正负
int curdiff = 0; // 暂存当前结果正负
bool flag = false;
// 1个
if(nums.size() == 1)
return 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)
{
res++;
prediff = curdiff;
}
}
return res;
}
};
两个纬度权衡问题
这种双边题目一定是要确定一边之后,再确定另一边,如果两边一起考虑一定会顾此失彼。
分发糖果
n
个孩子站成一排。给你一个整数数组 ratings
表示每个孩子的评分,你需要按照以下要求,给这些孩子分发糖果:
- 每个孩子至少分配到 1 个糖果。
- 相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
示例 1:
输入:ratings = [1,0,2]
输出:5
解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。
示例 2:
输入:ratings = [1,2,2]
输出:4
解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。
核心思想
-
这种双边题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
-
局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
-
如果
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; }
-
再确定左孩子大于右孩子的情况,为什么需要从后向前遍历?
-
如果 ratings[i] > ratings[i + 1],此时candyVec[i]有两个选择了,一个是candyVec[i + 1] + 1(从右边这个加1得到的糖果数量),一个是candyVec[i](之前比较右孩子大于左孩子得到的糖果数量),那么又要贪心了,局部最优:取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,保证第i个小孩的糖果数量既大于左边的也大于右边的,遍历代码如下;
// 从后向前 for (int i = ratings.size() - 2; i >= 0; i--) { if (ratings[i] > ratings[i + 1] ) { candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1); } }
代码实现
class Solution {
public:
// 每个孩子至少分配到 1 个糖果。
// 相邻两个孩子评分更高的孩子会获得更多的糖果。
int candy(vector<int>& ratings) {
int sum = 0;
vector<int> candyVec(ratings.size(),1);
// 右孩子比左孩子大
for(int i = 0; i < ratings.size() - 1; i++)
{
if(ratings[i] < ratings[i+1])
candyVec[i+1] = candyVec[i] + 1;
}
// 左孩子比右孩子大
for(int i = ratings.size() - 1; i > 0; i--)
{
if(ratings[i-1] > ratings[i])
candyVec[i-1] = max(candyVec[i-1], candyVec[i] + 1);
}
for(int num: candyVec)
sum += num;
return sum;
}
};
根据身高重建队列
假设有打乱顺序的一群人站成一个队列,数组 people
表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki]
表示第 i
个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj]
是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例 1:
输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
示例 2:
输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]]
输出:[[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]
核心思想
- 本题有两个维度,h和k,遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度,如果两个维度一起考虑一定会顾此失彼
- 如果按照k来从小到大排序,排完之后,k与h均不符合条件;若按照身高h来排序,身高一定是从大到小排,让高个子在前面。此时先确定身高维度,前面的节点一定都比本节点高!
- 按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。
局部最优: 优先按身高高的people的k来插入。插入操作过后的people满足队列属性
全局最优: 最后都做完插入操作,整个队列满足题目队列属性
代码实现
class Solution {
public:
static bool cmp(vector<int> &a, vector<int> &b)
{
if(a[0] == b[0])
return a[1] < b[1];
else
return a[0] > b[0];
}
vector<vector<int>> reconstructQueue(vector<vector<int>>& people)
{
vector<vector<int>> queue;
// 按身高从到到低排序
sort(people.begin(), people.end(), cmp);
for(int i = 0; i < people.size(); i++)
{
int insertPos = people[i][1];
queue.insert(queue.begin() + insertPos, people[i]);
}
return queue;
}
};