目录
贪心入门概念:
贪心算法的原理其实概念很简单,就是一个解决问题的策略,从局部的最优解推导出全局的最优解,就是贪心的思路。
贪心的步骤一般如下:
1 将解决问题的过程分为若干步;
2 解决每一步的时候都选择在当前看起来“最优”的解法;
3 “希望”能够依此得到全局最优解。
为什么说是“希望”得到全局最优解呢?因为我们从局部最优解推导过来的全局解并不一定是全局的最优解。
为了快速入门贪心,我们可以用以下几个示例来学习贪心的策略:
1 找零问题:给定纸币的种类, 要求用最少数量的纸币张数找零出指定金额。
比如我们给定[20,10,5,1]四类纸币面额,要求我们凑出28元,那么我们如何思考最优解呢?我们先将问题拆分成多个步骤,其实每一个步骤都需要选择一张纸币,那么我们要最终的纸币张数最少,我们就需要一张纸币起到的作用最大,那么我们每一次选择纸币面额都去选择最大的面额,比如第一次选择,需要凑出28元,我们选择20元的面额一张,此时还剩下8元需要找零,第二次选择一张5元的纸币,以此类推,最终选择了1张20元,1张5元和3张1元。这种场景下,我们从局部推导出来的解就是一个全局的最优解。
但是假如我们给定[10,6,5,1]这四种面额,去凑齐18元找零,如果还是按照上面的贪心策略来做,就会得到:1张10元,1张5元和3张1元,一共5张纸币, 但是其实本题的最优解是使用3张6元面额的纸币就够了。 所以我们使用局部最优策略得到的解并不一定是全局最优的。
2 最小路径和:给定一个二位网格图,每一步只能向右或向下,找出从左上角走到右下角的最小的路径和。
我们将问题划分为多个步骤的话,那么其实每一步就是选择向下走还是向右走,那么每一步都贪心来思考,我们其实在(x,y)位置的时候,会选择board[x+1][y] , board[x][y+1] 这两个中较小的位置来作为下一步,这就是一种局部贪心的策略,但是这样推导出来的解并不一定是全局的最优解,比如在这样的而一个二维网格图中:
3 背包问题:给定一个背包容积为V,给定一系列物品的体积vi和价值wi,求出背包能装的最大的价值
如果使用贪心策略来做的话,我们要使得固定体积内得价值最大,那么就要使得单位体积内的价值最大,因此我们需要预处理出每个物品的单位体积的价值,每次选择单位体积价值最大的物品放进去(如果体积足够的话)。
当然这种贪心策略也不一定能够得到全局的最优解。
贪心的特点
从上面的几个例题我们也能知道,贪心策略是作用在局部的,也就是每一个步骤来进行贪心选择最优解,他并没有全局的眼光,所以每一个步骤的最优解并不一定能得到全局的最优解。
同时在每一步选择最优解的时候,我们称之为贪心策略,贪心策略是没有模板的,我们需要根据每一个问题的场景来提出对应的贪心策略。
同时我们提出的贪心策略可能是“错误”的,这也就意味着贪心策略的正确性是需要证明的,而贪心策略的正确证明其实才是整个贪心算法的最重要的一点。 因为贪心策略其实我们可以根据经验提出很多,同时证明一个贪心策略是错误的我们只需要使用一个反例就行了,但是要证明贪心策略的正确性,是很复杂的,常见的证明方法有:
1 数学证明法
使用数学方法来证明, 比如利用各种不等式,微分,积分等知识来证明贪心解就是最优解。
2 等效法
我们通过证明贪心解和最优解完全等效, 就能够证明贪心策略的正确性。
比如上面的找零问题,面额有[20,10,5,1]。
假设我们贪心求出的解,有A张20,B张10,C张5,D张1,而最优解有a张20,b张10,c张5,d张1。
在证明正确性之前,我们能够证明 : B < 2 ,因为如果B大于2,那么我们其实会选择一张20的而不是两张10的; C < 2 , 因为如果C>2,每2张5元的我们会选择用10元的而不是两种5元;D < 5 同理。
我们只需要证明 a == A && B == b && C == c && D == d就能证明我们的贪心解和最优解是一样的。
首先对于 A == a ,如果A > a,那么证明最优解中,这些贪心解中多出的20元就需要用其他纸币去凑,但是根据我们前面的到的BCD的关系,他们也适用于bcd,也就是说10元,5元和1元的纸币我们最多只能够凑出19元,根本凑不出20元,那么就能够证明 A !> a 。
同理a !> A,这是从我们的贪心策略能够推导出来的,因为贪心策略中是能选20就选20 ,那么A>=a。
根据 A>=a&&A!>a我们就能得出A==a。
同理我们也能推导出 B==b ,C==c,D==d。这样我们就能用数学方法证明贪心策略的正确性。
3 交换论证法
比如我们的得到的贪心解是 : abcde。
而最优解是 bcdea。假设题目场景与返回的解的顺序无关。
那么我们在一一对比贪心解和最优解是否一样的时候,假设对比到第一个位置,我们发现a!=b,但是我们能够直到在贪心解中一定会出现b,那么我们将贪心解的a和b交换位置。 以此类推,能够通过交换来达到贪心解和最优解的等效。
学习贪心的时候,其实对于我们来说,贪心策略和正确性的证明都是很困难的,不过我们不需要过分担心,在初学贪心的时候,可能遇到的每一个题我们都想不出一种正确的贪心策略,这时候我们可以学习他人的贪心策略,如果有兴趣也可以学习正确性的证明,我们需要在做题的过程中将他人的贪心策略吸收,后续遇到类似的题目就可以复用这些贪心策略。
当然如果我们想要更深入的学习贪心算法,那么贪心策略的正确性的证明还是迈不过去的坎。
在本文中,我们注重于学习各种贪心策略,而不会着重讲解正确性的证明。
1 柠檬水找零
题目解析:我们需要进行柠檬水的找零,每一杯柠檬水价格为5,顾客可能给我们5,10,和20三种面额的钱,我们需要对每一个顾客进行找零,如果能够完成所有顾客而当找零,返回true,不能就返回false。
首先我们在想贪心策略的时候,要将整个问题拆解成多个相似的步骤,我们只需要在其中某一个步骤进行思考就行了。 比如我们这里的每一步就是对每一个客人进行找零,在对第i个客人进行找零的时候,使用贪心的思想,在局部的贪心策略中不需要关注后续的客人能否找零。
由于顾客只会给我们三种面额,5不需要找零,10只能找零一张5元,而20需要找零15元,此时有两种做法:1、找零3张5元;2、找零1张10元和1张5元。那么我们使用哪一种策略更优呢?
由于5元的面额我们既要在10元中用到,也要在20元用到,而10元面额只会在20的找零中用到,所以我们会尽可能的保留5元的纸币,也就是说我们会优先考虑第2中策略来对20进行找零。
证明方法:交换论证法
由于对5元和对10元的找零策略没有贪心的空间,所以我们只需要讨论对20元找零的所有解就行了。
假设贪心解是:abcde,而最优解是:ebcda,此时贪心解和最优解的顺序不同,可以通过交换顺序等效。
也有可能: 贪心解abcdef,最优解是:abcdxy,比如对其中几个20元的找零思路不同,那么说明不同的几个20元找零时,贪心解用的是: 10+5 的组合,而最优解用的是: 5+5+5 的组合。此时虽然两种找零策略不同,但是都能够完成找零工作,由于贪心解用到了10元的纸币,而此时最优解用的是5+5的纸币,那么说明这两种面额此时都是足够的,那么我们完全也可以把贪心解的10元面额换成两个5元面额,来使得贪心解和最优解等效。或者将最优解的两张5元替换成一张10元可贪心解等效。
由于本题20元面额收完之后不会再用到,所以我们只需要存储所拥有的5元和10元的数量就行了,代码如下:
class Solution {
public:
int cnt1 ,cnt2; //5元和10元的数量
bool lemonadeChange(vector<int>& bills) {
for(auto x : bills){
if(x == 5) ++cnt1;
else if(x == 10) --cnt1,++cnt2;
else{
if(cnt2 > 0) --cnt2,--cnt1; //如果有10元就优先使用10元
else cnt1 -= 3;//没有10元就只能使用三张5元
}
// if(cnt1 < 0 || cnt2 < 0) return false;
if(cnt1 < 0) return false; //由于我们只有在有10元的时候才会使用,而使用5元没有判断,只有5元的数量会花超
}
return true;
}
};
2 将数组和减半的最少操作次数
https://leetcode.cn/problems/minimum-operations-to-halve-array-sum/
题目解析:给定一个数组nums,我们每一次可以选择数组的一个数将其减半,可以对一个数多次操作。求出最少需要多少次减半操作才能使数组和减半。
每一次需要从nums中选出一个数进行减半操作,要使得操作的次数尽可能小,那么我们就需要让每一次操作减去的数尽可能大,那么我们的贪心策略就是每一次操作选择当前剩余元素中最大的数进行减半,直到减去的值大于等于原数组和的一半。
正确性:假设贪心解为 :abcdef ,最优解为 abcdfe 或者 abcdeh
当贪心解和最优解只是顺序不同时可以交换顺序来达到等效,而如果最优解和贪心解在某些位置操作的元素不一样,我们可以先对最优解进行降序排序,由于贪心解是按照从大到小的顺序来操作元素的,那么如果最优解和贪心解的某一个元素不一样,那么说明此时最优解选择的元素一定是小于贪心解所选择的元素的,而选择较小的元素进行减半都能够达到目标,那么自然在贪心解中选择较大的元素也可以达到目的,或者说我们完全可以将最优解的该元素替换成贪心解中更大的元素,最优解的正确性依旧成立。
而我们每一次操作都需要选择当前剩余的最大的元素进行减半,那么可以使用堆来维护所有的剩余元素。
代码如下:
class Solution {
public:
double EXP = 1e-10; //误差
int halveArray(vector<int>& nums) {
priority_queue<double> pq;
double sum = 0 , sub = 0;
for(auto x : nums){
sum += x;
pq.push((double)x);
}
int res = 0;
while(sub < sum && !abs(sub - sum) <= EXP){ //规定结果的误差在10^-10内都算有效
++res;
double x = pq.top();
pq.pop();
sub += x; //当我们操作的数的总和大于等于sum时,那么减掉的数自然就大于等于sum的一半了
pq.push(x/2);
}
return res;
}
};
3 最大数
题目解析:给定一个非负整数数组,要求我们将数组中的数组合在一起,例如:1和2组合可以变成12和21。 输出能够组合出来的最大的数。
如果nums中包括0,那么我们直到这些0一定不能作为前置0,作为前置0的时候一定会使得结果小于最优解。
在不包含前置0的情况下,我们按任意顺序组成的数字的位数都是相同的,那么高位越大,结果就越大。
比如:3和12组成的数字,我们会选择3作为高位,组成312 ,而不是123。
对于两个数字,a和b,如果ab > ba ,那么我们就需要让a排在b的前面。 如果ab==ba,那么这两个数字的顺序无所谓,如果 ab < ba,那么需要让b排在a的前面。那么这就是一种贪心的策略。
这种贪心策略如何实现呢? 我们可以通过定义排序规则,通过排序来确定最终顺序。
这个策略能够作为排序规则需要三个性质:
1、完全性,两个元素一定能够确定大小关系,要么大于,要么等于,要么小于,而不会出现模糊的大小关系。
2、反对称性:如果a>=b && b>=a ,那么 a==b;
3、传递性: a > b && b > c ,那么需要能够推导出 a > c;
而为了方便比较,也就是将 a和b组成ab和ba的形式,我们可以将数字转换成字符串来处理。
代码如下:
class Solution {
public:
string largestNumber(vector<int>& nums) {
vector<string> str;
for(int x : nums) str.push_back(to_string(x));
sort(str.begin(),str.end(),[](const string& a, const string& b){
return a+b > b+a; //升序
});
string res;
for(auto& s : str) res += s;
if(res[0] == '0') res = "0"; //说明最大的数就是0,此时需要处理前置0
return res;
}
};
注意比较规则不能使用 >= ,在本题会报错,我们直接使用大于也是一样的效果,因为如果相等交不交换都一样。
4 摆动序列
题目解析:给定一个数组nums,要求我们找到最长的摆动序列的长度。摆动序列我们在动态规划中有所接触了,就是子序列中的相邻元素的关系在大于和小于关系交替出现,而这样的序列我们画出折线图其实就是一个摆动的折线,一上一下交替进行。
我们可以将nums 的数据在图中画出来,以便查找规律来总结贪心策略。
假设nums = [1,7,4,9,2,5] ,我们能够发现在图中所有的点都构成一上一下的关系,所以整个数组就是最长的摆动序列。
再比如:nums = [1,17,5,10,13,15,10,5,16,8]
在这个折线图中如何找到最长的摆动序列呢?很简单,我们从第一个点开始,如果第一条线是下降趋势的,那么我们先找到下降趋势的最低点,如果是上升趋势的,那么我们先找到上升趋势的最高点,来作为摆动序列的第二个点。这就是我们的贪心策略。
就比如在 [5,10,13,15]这一段上升区间内,我们会选择最大的点也就是15,我们找的点越大,那么后续的值小于该点的概率就越大,那么就越有可能构建更长的摆动序列。
比如有 [5,10,13,15,14] ,如果我们选择10或者13作为上升区间的终点的话,那么后续的14就无法拼接到摆动序列后面,但是如果选择最大的15,我们就能够将14拼接到15后面组成摆动序列。同时如果后面是一个较小的值,比如x,如果我们选择10,能和x构成下降趋势的话,那么比10更大的15也一定能够和x构成摆动序列。
给定的nums也有可能会出现连续的相等区间,也就是一条和x轴平行的线,此时我们不需要关系这段区间,而是根据前面不相等区间的趋势来看,比如 [10,9,9,9,11],那么我们会将因为10和最前面的9构成下降趋势,我们会进行记录,而连续相等的9我们不会修改记录的趋势,直到9和11我们才会比较这两个元素得出是上升趋势,那么此时需要将下降趋势的最后一个位置纳入到摆动序列中。
那么我们转换一下题意,可以理解为在nums的折线图中,找到波峰和波谷的数量,因为我们按照上述贪心策略找到的点,如果将折现看成直线的话,其实就是出现在极值处。
代码如下:
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
//转换为找波峰和波谷的数量
int flag = 0; //1表示上升趋势,2表示下降趋势
int res = 1 , n = nums.size();
if(n < 2) return n;
for(int i = 1 ; i < n ; ++i){
//不妨我们直接跳过相等区间
if(nums[i] == nums[i-1]) continue;
else if(nums[i] > nums[i-1]){
if(flag == 2){ //说明前面是下降趋势,而到了i位置变成上升趋势,此时可以把nums[i-1]选上
res++;
}
flag = 1;
}
// else if(nums[i] < nums[i-1]){
else{
if(flag == 1){//说明前面是上升趋势,而到了i位置变成下降趋势,此时可以将nums[i-1]选上
res++;
}
flag = 2;
}
}
//走出循环的时候,我们已经将前面的点都算上的,但是还有最后一段趋势的点没有算上
//比如最后一段是上升/下降,我们需要把最后一个点算上 [1,2,3,3,3,3,3]/[1,2,3,3,2,2,2,2,2]
//但是如果nums全是相等元素,那么此时flag==0,此时我们不需要计算最后一个点,[1,1,1,1,1,1]
if(flag != 0) res++;
return res;
}
};
5 最长递增子序列
https://leetcode.cn/problems/longest-increasing-subsequence/
题目解析:给定一个数组nums,要求我们找出nums中最长严格递增子序列的长度。
我们使用过动态规划的思路来解决这个问题,使用dp[i]表示以nums[i]为结尾的最长递增子序列的长度,这时候我们直到以每一个元素为结尾的最长递增子序列的长度,但是其实我们可以对这个动态规划的思路进行贪心优化,当遍历到nums[i]时,如果前面有多个dp[i]相等,多个元素为结尾的最长递增子序列长度相等,那么我们其实只需要记录这些nums[i]的最小值,而不需要关心其他的较大值。那么这样一来,每一个长度的递增子序列我们只需要记录一个最小的结尾值就行了。
模拟一遍该过程:nums = [10,9,2,5,3,7,101,18]
遍历:
初始: len = [INT_MIN]。len[i] 表示长度为i的递增子序列的最小的结尾元素,预填入一个INT_MIN,表示任意一个数都可以跟在INT_MIN后面构成一个长度为1的递增子序列。
i == 0 : nums[i] = 10 , 从后往前遍历len数组,10 > len[0] ,那么10可以跟在INT_MIN的后面构成递增关系,变成长度为1的递增子序列,此时由于还没有已记录的长度为1的递增子序列,那么10可以表示成长度为1的递增子序列的最小结尾元素,len[1] = 10; 此时 len = [INT_MIN,10]
i == 1:nums[i] = 9 ,从后往前遍历len数组,9 < len[1],无法跟在现有的长度为1的递增子序列后面构成长度为2的递增子序列。9 > len[0] ,那么9可以跟在长度为0的递增自诩路i额后面构成递增关系,变成长度为1的递增子序列,此时我们需要对比 9 和 len[1] ,来更新所有长度为1的子序列的最小结尾元素, len[1] = min(len[1] , nums[i]),那么此时 len[1] = 9。此时len = [INT_MIN,9]
i == 2:nums[i] = 2,还是按照一样的思路,从后往前遍历len,len[1] > 2 , 所以2无法构成更长的递增子序列。 2 > len[0] ,2可以跟在长度为0的递增子序列后面构成长度为1的递增子序列,此时len[1] = min(2,len[1]) 。此时 len = [INT_MIN,2]
i == 3:nums[i] = 5 。从后往前遍历len数组,len[1] < 5,那么说明5可以跟在长度为1的递增子序列后面构成长度为2的递增自序列,len.push_back(5)。 len[0] < 5 ,此时5也可以跟在长度为0的递增子序列后面构成长度为1的递增子序列,len[1] = min(len[1],5),此时len = [INT_MIN,2,5]。
依此类推,我们就能够记录所有存在的递增子序列的长度所对应的最小的结尾的元素。记录最小的结尾元素就是我们的贪心策略,因为只要我们的值大于该最小的结尾元素,就能够拼接到该递增序列后构成新的递增序列。
细节: 我们对比的是nums[i] 和 len[j] ,但是更新的缺失 len[j+1],因为是将nums[i]拼接在了长度为j的子序列后面,构成的是长度为j+1的子序列。\
这样做的话,我们再计算nums[i]是否能够拼接到nums[j]后面时,动态规划需要遍历i次,而贪心策略则只需要遍历len.size()次,而len.size() <= i。
代码如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> len;
len.push_back(INT_MIN);
for(auto x:nums){
//首先对比最长的递增子序列的结尾元素,看能不能构成更长的长度
if(x > len.back()) len.push_back(x);
for(int j = len.size() - 2 ; j >= 0 ;--j){ //而后续对比的时候,不需要对比len的最后一个元素,所以从sz-2开始
if(x > len[j]) len[j+1] = min(len[j+1],x);
}
}
return len.size() - 1; //len[i] 表示长度为i,那么最大的下标就是最大的长度
}
};
本题还可以继续优化: 去掉len[0] 的INT_MIN,len数组中只记录每个长度的最小结尾元素,基于len数组的形成过程,len数组一定是非递减的数组,那么我们只需要在len数组中第一个大于等于nums[i]的元素len[j],然后更新len[j] = nums[i]就行了。为什么呢?首先len的[0,j)区间都是小于nums[i]的,他们后面都能够跟nums[i]组成新的序列,但是由于他们都比nums[i]小,所以不会更新,到最后,由于len[j-1] < nums[i],而len[j] >= nums[i] ,所以需要更新。 而后面的lem的[j,sz-1]都大于nums[i],无法拼接nums[i]形成新的严格递增序列。 在二分之前需要特殊处理一下nums[i]大于len.back()的情况,此时需要新增位置。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> len;
len.push_back(nums[0]);
//二分优化
for(auto x:nums){
if(x > len.back()){
len.push_back(x);
continue;
}
//找len中大于等于x的左边界
int l = 0 , r = len.size() - 1;
while(l < r){
int mid = l + (r - l ) / 2;
if(len[mid] >= x) r = mid;
else l = mid + 1;
}
len[l] = x;
}
return len.size();
}
};
6 递增的三元子序列
https://leetcode.cn/problems/increasing-triplet-subsequence/
题目解析:给定一个整数数组nums,要求我们求出是否有长度为3的严格递增数组。
那么本题其实就是上一个题的简化版本,我们可以直接用上一个题的思路,最后判断一下len.size()是否大于等于3就行了。
代码如下:
class Solution {
public:
bool increasingTriplet(vector<int>& nums) {
vector<int> len;
len.push_back(nums[0]);
for(auto x : nums){
if(x > len.back()){
len.push_back(x);
continue;
}
int l = 0 , r = len.size() - 1 ;
while(l < r){
int mid = l + (r - l) / 2;
if(len[mid] >= x) r = mid;
else l = mid + 1;
}
len[l] = x;
}
return len.size() >= 3;
}
};
7 最长连续递增序列
https://leetcode.cn/problems/longest-continuous-increasing-subsequence/
题目解析:给定一个整数数组nums,要求我们找出最长的连续递增子序列的长度,其实就是找到最长的递增子数组的长度。
暴力解法我们会去枚举以每一个元素为起点的递增子数组,此时需要两层循环。
而使用贪心优化,对于 nums[i],我们找到他的递增子数组的结尾为 j ,那么[i,j]区间内,以x(x>=i&&x<=j)为起点的最长递增子数组都是这个区间的一个子区间,那么我们其实只需要记录一下以i为起点的递增子数组的长度就行了,后续的(i,j]为起点都不需要枚举了。
代码如下:
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int n = nums.size(),res = 1;
for(int i = 0 ; i < n ; ++i){
int j = i + 1;
for(; j < n && nums[j] > nums[j-1]; ++j);
//此时[i,j)为递增区间
res = max(res , j - i);
//贪心
i = j - 1; //i下一次以j为起点进行枚举
}
return res;
}
};
8 买卖股票的最佳时机
https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/
题目解析:给定一个数组prices表示每一天股票的价格,我们需要在某一天买入一个股票,然后在未来的某一天卖出股票,也就是只能完成一笔交易,返回能获取的最大价值。
当我们在第i天买入股票后,我们会选择在哪一天卖出呢??当然是第i天之后的价格最高的一天,这样我们才能获取到最大的差价也就是利润,但是这也有一个前提就是后面的最高价格要比当前价格高,否则不管怎么卖都是亏本的。
那么如何快速找到第i天之后的最高价格呢? 我们可以从后往前遍历数组,记录最大的后缀价格。
代码如下:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size() , res = 0;
int maxp = prices[n-1];
for(int i = n - 2 ; i >= 0 ; --i){
res = max(res , maxp - prices[i]);
maxp = max(prices[i] ,maxp);
}
return res;
}
};
9 买卖股票的最佳时机 II
https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/
题目解析:给定每天的股票价格,我们可以交易任意多次,但是手上最多同时持有一个股票,返回能够获取的最大利润。
如果我们将价格化成折线图,那么我们就很容易能够得到,最大利润就是在每一个波谷买入,然后在波峰卖出。但是由于本题不会限制交易次数,我们可以将波谷到波峰这段上升的折现分成多段,每一天如果价格比前一天高,那么我们就在前一天买入今天卖出。
代码如下:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size() , res = 0;
for(int i = 1 ; i < n ; ++i){
if(prices[i] > prices[i-1]) res += prices[i] - prices[i-1];
}
return res;
}
};
10 K 次取反后最大化的数组和
https://leetcode.cn/problems/maximize-sum-of-array-after-k-negations/
题目解析:给定一个整数数组,我们可以对数组的某一个元素进行取反操作,我们需要返回在进行了k次取反操作之后的最大的数组和。
要使数组和最大,我们该如何规划这些取反操作呢? 首先,如果数组中有负数,我们肯定是优先对负数进行取反,同时再对负数取反的时候也是尽可能先对绝对值大的负数取反。
如果我们将所有负数都转换为正数之后,还剩下x次取反操作没有用,那么此时如何做呢?因为我们当前已经是最大的数组和了,再进行取反操作要么数组和不变,要么就会减少了。如果剩下的操作次数x是偶数,那么我们可以对某一个数进行x次取反操作,偶数次取反之后还是原数据,那么数组和不变。如果x是奇数,那么我们将其中的偶数次都对同一个数操作之后,最后剩下一次取反操作我们可以将其用在绝对值最小的那个数上,这样对数组总和的影响最小。
代码如下:
class Solution {
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
//用堆来存储负数
priority_queue<int> pq;
int minabs = abs(nums[0]) , sum = 0;
for(auto x : nums){
sum += x;
minabs = min(minabs,abs(x)); //找到最小的绝对值以便后续剩余奇数次操作时使用
if(x < 0) pq.push(-x);
}
while(pq.size() && k > 0){ //进行k次负数的取反操作
sum += 2*pq.top();
pq.pop();
--k;
}
if(k % 2 == 1) sum -= 2 * minabs;
return sum;
}
};
11 按身高排序
题目解析:给定一个name数组表示每个人的名字,以及一个heights数组对应每个人的身高,要求我们按照身高降序排序返回对应的名字。
本题就是一个简单的排序问题,我们需要按照题目意思确定排序规则,题目明确了是需要按照身高来对名字进行降序排序。 而本题的难点就在于,我们要对名字数组进行排序,但是当名字数组的顺序发生变化时,我们无法找到对应名字的身高了,因为他们的下标相等的关系已经被破坏了。
最简单的我们可以创建一个新的数组,vector<pair<string,int>> 将名字与身高绑定在一起,排序的时候根据身高进行排序就行了。
第二种方法就是利用身高的唯一性,我们可以使用哈希表映射每一个身高对应的名字,然后直接对身高进行降序排序之后找到对应身高的名字就行了,。
还有一种方法就是: 下标数组。 我们不对name数组本身排序,而是对每一个name对应的原始下标按照身高进行排序。下标数组适用于排序,但是不改变数据原始位置的情况,而是对下标数组进行排序。
我们首先需要创建一个下标数组,比如例1 的names = ["Mary","John","Emma"],下标数组就是index = [0,1,2] ,他们对应的身高是heights = [180,165,170] , 那么我们如何对下标数组排序呢? 我们的下标数组存储的虽然是下标,但是这些下标可以找到对应的人name[index[i]],也可以找到该下标对应的人的身高heights[index[i]]。 而题目要求我们按照身高进行降序排序,那么我们这里的下标就是一个身高的意义,index[i]实际代表的是 heights[index[i]] ,我们排序的时候根据heights[index[i]]的大小关系排序就行了。
代码如下:
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
int n = names.size();
vector<int> index(n);
for(int i = 1 ; i < n ; ++i) index[i] = i;
sort(index.begin(),index.end(),[&heights](const int& i1 , const int& i2){
return heights[i1] > heights[i2];
});
//下标数组按照身高降序排序之后,每一个下标去对应原始name数组的名字
vector<string> res(n);
for(int i = 0 ; i < n ;++i){
res[i] = names[index[i]];
}
return res;
}
};
12 优势洗牌
题目解析:给定两个数组,我们可以对nums1进行任意排列,使得尽可能多的下标满足 nums2[i] > nums1[i],返回其中一种nums1的排列。
本题其实就是类似于田忌赛马的问题,齐王和田忌都有上等马中等马和下等马,齐王的上等马比田忌的上等马好,齐王的中等马比田忌的中等马好,但是比不上田忌的上等马,齐王的下等马比田忌的下等马好,但是比不上田忌的中等马,我们可以让田忌的中等马去赢下齐王的下等马,用田忌的上等马去赢下齐王的中等马,而下等马则输给齐王的上等马,那么田忌还是能够三局两胜。
田忌赛马的本质其实就是一种废物利用,田忌的下等马既然是必输的,那么我们就让他去消耗掉齐王的最厉害的马。要使得赢得数量尽可能多,那么我们就需要将齐王的最弱的那一些马赢了,而齐王的强壮的马则使用我们的弱的马去输掉。
对于本题也是一样的,我们可以将nums1和nums2排升序,排完序之后,我们从小到大遍历nums1,当nums1[i]能赢下当前nums2[l]的最弱的数据的时候,那么让nums1[i] 去对决nums2[l],如果赢不了,那么就让nums1[i]去与nums2当前最大的数nums2[r]去比较,废物利用。
这样遍历完之后,nums1的前半段都能赢下nums2,后半段虽然都输给了nums2,但是我们已经将能赢的都赢下了。
但是现在的问题就是:当我们对nums2排序之后,数据的位置就发生变化了,不再是原始的nums2的顺序了,而题目要求返回的是nums1去跟nums2的原始数据一一对决。
那么在本题中,对nums1排序可以直接排,但是对nums2排序的时候,为了不影响原始数据的位置,我们需要使用下标数组来排序。
代码如下:
class Solution {
public:
vector<int> advantageCount(vector<int>& nums1, vector<int>& nums2) {
int n = nums1.size();
sort(nums1.begin(),nums1.end());
//nums2排下标数组
vector<int> index(n);
for(int i = 1 ; i < n ; ++i) index[i] = i;
sort(index.begin(),index.end(),[&nums2](const int& i1 ,const int& i2){
return nums2[i1] < nums2[i2];
});
//排完序之后进行田忌赛马
vector<int> res(n);
int l = 0 ,r = n-1; //l表示nums2剩下的的最弱的,也就是 nums2[index[l]],r表示nums2剩余的最强的,也即是nums2[index[r]]
for(auto& x : nums1){
if(x <= nums2[index[l]]) res[index[r--]] = x; //去对决最强的nums2[index[r]]
else res[index[l++]] = x; //能赢下最弱的。去打最弱的 nums2[index[l]]
}
return res;
}
};
13 最长回文串
题目解析:给定一个字符串,我们可以从字符串中挑选任意字符,来构成回文串,返回能够构成的最长回文串的长度。
回文串的构造其实很简单,只需要在原始回文串的两端各加上一个相同的字符就行了。
那么我们可以先遍历一遍字符串,将所有的字母的个数统计一下,而对于每一个字符,如果他的个数为x>2,那么我们可以将 x/2个该字符添加到当前回文串的左边,将x/2个字符添加到当前回文串的右边,仍然满足回文串的性质。
最后需要注意的是,回文串长度可以为奇数,最中间的那个字符不需要相等关系,那么我们再拼接完长度为偶数的回文串之后,如果还有字符剩下,那么可以将一个字符加入到回文串的最中间,使得长度再加一。
代码如下:
class Solution {
public:
int cnt[128]; //记录每一个字符的个数
int longestPalindrome(string s) {
for(auto ch : s) cnt[ch]++;
int n = s.size() , res = 0;
for(int i = 'a' ; i <= 'z' ; ++i){
if(cnt[i] >= 2) res += cnt[i]/2*2; //将偶数个字符添加到回文串
}
for(int i = 'A' ; i <= 'Z' ; ++i){
if(cnt[i] >= 2) res += cnt[i]/2*2;
}
if(res < n) ++res;
return res;
}
};
14 增减字符串匹配
题目解析:给定一个长度为n的匹配字符串,要求我们使用0~n的数字字符组成一个数组,相邻元素的关系满足匹配字符串的关系。比如 s[i] == 'D' ,那么 res[i+1] < res[i] ,也就是需要是下降的,如果s[i] == 'I',那么需要满足res[i] < res[i+1],上升趋势。
那么我们如何进行贪心呢?当我们遇到一段连续的 'I'的时候。由于要连续递增,那么我们在这段区间的第一个元素要选择当前剩余的最小的元素,由于这段连续的 'I' 区间的前面一定是一个'D'或者没有元素,我们在该位置选择I一定是可行的。
同理,当我们遇到一段连续的'D'区间的时候,我们第一个元素选择当前的最大的元素。该位置前面要么是连续的'I'递增区间,要么是没有元素。那么为什么当前最大的元素一定大于上一个递增区间的最大的元素呢? 因为当我们从前往后按照这样的规则填下来之后,由于递增的区间我们是从最小的一部分去选,当填到递减区间的时候,剩余的元素都是比该递增区间大的元素,自然可以满足。
代码如下:
class Solution {
public:
vector<int> diStringMatch(string s) {
int n = s.size() , minn = 0 , maxn = n;
vector<int> res(n+1);
//从前往后找
for(int i = 0; i < n ;++i){
if(s[i] == 'D'){
//找完这段连续D区间
int j = i + 1;
for(; j < n ; ++j) if(s[j]!='D') break;
//此时[i,j)是递减区间,我们选择当前的最大值开始填
for(int x = i; x < j ; ++x){
res[x] = maxn--;
}
i = j - 1;//下一个从s[j]开始找递增的区间
}
//否则就是找递增区间
else{
int j = i + 1;
for(;j < n ; ++j) if(s[j] != 'I') break;
//[i,j)是一段递增区间,从最小的值开始填
for(int x = i ; x < j ; ++x) res[x] = minn++;
i = j-1;
}
}
//这样一趟填下来,我们只填了[0,n-1)区间,还剩下最后一个位置没填
//最后一个位置有两种情况,一种是s[n-1] =='I',那么说明这是一段递增区间的结尾,而我们剩下的数一定是这段递增区间的最大值+1,而相反如果是'D',那么剩下的数一定是该递减区间的最小值-1
//还剩下最后一个位置就填剩下的元素就行
res[n] = minn;
return res;
}
};
解法2:
我们用字符串来表示构建的数组,
比如:res = "01234",s = "IDID",我们只需要将下降的区间进行逆置一下就行了,比如s的 [1,1]区间是下降的,那么对应res的[1,2]区间需要是降序的,逆置一下res的[1,2]区间, 变成了 res = "02134",然后s的[3,3]区间是下降的,那么对应res的[3,4]区间是下降的。我们逆置一下res的[3,4]区间,就变成了 res = "02143"。这样就能够构建出我们所需的满足匹配字符串的序列。
上面res为字符串时,只能用于n为0~9时的序列的构建,如果我们将res使用数组来代替,那么就可以进行n为任意长度(不超内存)的序列的构建了。
为什么这样能行呢? 其实跟贪心的思路一样,相当于我们在遇到连续的’D‘的区间的时候,还是选取一段比前面的’I’区间大的区间,但是我们逆序排序,在这个思路中选择的比'I‘区间大的区间则刚好是连续递增区间的后面一段。
比如:s = 'IIIIDDDIID',第一段长度为4的区间我们选择[0,3]来升序放置,而后一段长度为3的区间我们会选择3个元素[4,6]来逆置放置,依次类推。
代码如下:
class Solution {
public:
vector<int> diStringMatch(string s) {
int n = s.size();
vector<int>res(n+1);
for(int i = 0 ; i <= n ; ++i)res[i] = i;
for(int i = 0 ; i < n ; ++i){
if(s[i] == 'D'){
//找连续递减区间
int j = i + 1;
for(;j < n ; ++j) if(s[j] == 'I') break;
//[i,j)为连续D区间,对应的[i,j]都需要递减
reverse(res.begin() + i , res.begin()+j+1);
i = j;
}
}
return res;
}
};
15 分发饼干
题目解析:给定孩子的胃口数组 g,以及饼干的大小数组s,每个孩子最多给一块饼干,同时只有该饼干的大小大于等于孩子的胃口大小时,该孩子才能够满足,返回我们最多能够满足的孩子的数量。
本题其实还是一个田忌赛马的问题,为了尽可能满足多的孩子,那么我们会优先去满足哪些胃口小的孩子。
我们将孩子的胃口和饼干的大小升序排序。 从小到大遍历饼干的大小,如果当前饼干能够满足最小胃口的孩子,那么就给他,如果满足不了,那么这块饼干也没用了,跳过,去拿下一块饼干比较。
代码如下:
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(),g.end());
sort(s.begin(),s.end());
int j = 0 , res = 0 , n = g.size();
for(auto x : s){
//当前的最小的饼干
if(j == n) break ;//说明所有的孩子都满足了
if(x >= g[j]) res++ , j++; //能够满足胃口最小的孩子
}
return res;
}
};
16 最优除法
题目解析:给定一个整数数组,数组中的每个元素都被用于一个只包含除法和括号的运算式中,我们需要在 n0/n1/n2/n3.... nm 表达式中手动添加任意对括号,使得整个表达式的运算结果最大。
对于一个除法的表达式,比如 a/b/c/d ,我们其实可以将除法的临时结果使用分数保存,a/b/c/d = a/bcd ,再添加完任意的括号之后,比如我们在(c/d)添加括号,就变成了 a/b/(c/d) = ad/bc。举这个例子是为了表明,我们的分数之间的除法其实是可以转换为乘法的, a/b = a*1/b,那么根据这个性质,其实不管我们怎么添加括号,最终结果一定是一个 x/y 的表达式,x为nums中任意个数的乘积,y为nums中其余数的乘积。
但是根据我们的初始表达式 n0/n1/n2.../nm ,不管我们怎么添加括号,n0 一定是在最终表达式的分子上,同时n1一定是在最终表达式的分母上,或许我们又可以转换为 n0/n1 * (x/y) 。
要使得最终结果最大,那么就需要让x尽可能大而y尽可能小,那么我们能否让除了n0和n1之外的所有的数都在分母上呢? 能。因为题目给定了nums中所有元素都是大于等于2的,不会存在负数和小数,我们只需要让除了n0和n1之外的所有元素都出现在分子,就能够得到最大的结果。
比如 [1,2,3,4,5,6,7] ,我们能否通过添加括号让[3,4,5,6,7]都在分子上呢?1/(2/3/4/5/6/7),这其实就相当于 n0/(n1/n2/n3../nm),而括号中的表达式根据上面的 a/b/c/d = a/bcd ,就可以转换为 n0/(n1/(n2*n3*...*nm)) = (n0*n2*n3*n4*...*nm) / n1。
那么我们就只需要在 n1前面加上一个(,同时在最后一个元素后面加上一个)就行了,然后把 / 也添加在字符串中。
特殊情况就是当nums只有两个元素时,括号是多余的。
代码如下:
class Solution {
public:
string optimalDivision(vector<int>& nums) {
string res;
int n = nums.size();
res += to_string(nums[0]);
if(n == 1);
else if(n == 2)res += "/" + to_string(nums[1]);
else{
res += "/(";
for(int i = 1 ; i < n ; ++i) res += to_string(nums[i]) + "/";
res.back() = ')'; //把最后多出来的 / 替换为 )
}
return res;
}
};
17 跳跃游戏 II
题目解析:给定一个数组,每一个位置的值nums[0]表示在这个位置起跳一步最多可以跳多远,要求我们求出从下标0跳到下标n-1所需的最少的步数。
题目保证可以到达n-1,那么其实我们可以把0当成起点,n-1当成终点,来做bfs或者dfs,但是如果直接暴力dfs的话,会有相当多重复遍历的路径,除非我们使用记忆化搜索来进行路径去重。 所以本题我们使用bfs更加方便。
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
queue<int> q;
vector<bool> vis(n);
int res = 0;
vis[0] = true;
q.push(0);
while(q.size()){
//当前q中的节点能通过res步走到,那么通过q中的节点再走一步
int cnt = q.size();
while(cnt--){
int x = q.front();
if(x == n-1) return res;
q.pop();
for(int i = 1 ; i <= nums[x] ; ++i){
if(x + i < n && !vis[x + i]) {
q.push(x+i); //说明本条是通往x+i的最短路径
vis[i+x] = true;
}
}
}
res++;
}
return res;
}
};
同时本题也可以是一个经典的动态规划问题,定义dp[i]为到达i位置的最少步数就行了,动态规划就不写了,很简单。
在本题中,其实层序遍历的过程就是从左往右的,所以我们并不需要队列来模拟,只需要一个dis数组就行了,代码如下:
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
vector<int> dis(n,INT_MAX);
dis[0] = 0;
for(int i = 0 ; i < n ; ++i){
for(int x = 1; x <= nums[i] && x + i < n; ++x){
if(dis[x+i] > dis[i] + 1) dis[x+i] = dis[i] + 1;
}
}
return dis[n-1];
}
};
这种优化之后的层序遍历在代码上其实和动态规划的写法基本一致。
18 跳跃游戏
题目解析:本题和上一题类似,但是不保证一定能跳到终点,需要我们自己判断。
借鉴上一题的优化后的bfs来做就行了。 代码如下:
class Solution {
public:
bool canJump(vector<int>& nums) {
int n = nums.size();
vector<bool> can(n,false);
can[0] = true;
for(int i = 0 ; i < n ; ++i){
if(!can[i]) continue; //无法到达i
//走到这里说明能到达i,那么自然能通过i到达后面的点
for(int x = 1 ; x <= nums[i] && x + i < n; ++x){
can[x+i] = true;
}
}
return can[n-1];
}
};
同时本题还有一种更简单的做法就是:如果我们能够到达x位置,那么是不是意味着[0,x]都可到达?
比如我们从0位置开始,最远能够到达 nums[0]+0,意味着[0,nums[0]]这些位置都可以到达,然后我们去遍历nums[1]~nums[nums[0]] 这些点能够到达的最远位置maxn,那么又可以延伸到 [0,maxn]都可到达,那么我们只需要这样遍历完nums或者遍历到maxn,如果maxn大于等于n-1说明能够到达n-1。
代码如下:
class Solution {
public:
bool canJump(vector<int>& nums) {
int maxn = 0, n = nums.size();
for(int i = 0 ; i <= maxn && i < n ; ++i){
maxn = max(maxn,i + nums[i]); //能到达的最远距离
}
return maxn >= n-1;
}
};
19 加油站
题目解析:有一条环路,环路上有n个加油站,gas[i] 表示第i个加油站可以加的油量,而cost[i]则表示从加油站i到加油站i+1需要消耗的油量。 我们需要判断能否以某一个加油站为起点,沿着环路走一圈,如果能,返回起点加油站的编号,不能就返回-1.
本题其实有两个遍历,可加油量和消耗量,假设我们到达第i个加油站时,剩余油量为 x,此时x一定是大于等于0的 ,那么我们在加油站i加 gas[i] 的油,同时走到第i+1个加油站需要cost[i] 的油,那么到达第i+1个加油站时的剩余油量为 x + gas[i] - cost[i] ,但是这个剩余油量可能会小于0,如果小于0,则说明无法到达第i+1个加油站。
如果我们暴力来解决的话,那么直接枚举每一个加油站为起点,然后看能否走一圈,但是这样会超时,我们可以进行以下优化:
假设我们从第i个加油站出发,能够走到第j个加油站,那么说明,我们从第i个加油站出发时,到达[i,j]这些加油站时,剩余油量都是大于等于0的。但是如果我们无法到达[i,z] ,此时既然已经枚举到了z,那么说明z-1是可以到达的,而无法从z-1到达z。 那么我们继续枚举i+1为起点看能否到达加油站z。
由于我们前面枚举了i为起点能够到达i+1,那么从i到达i+1时的剩余油量一定是x >= 0 的,也就是说从i出发前往z的总油量一定是大于等于从i+1出发前往z的总油量的,而从i出发都无法到达z,那么从i+1出发也一定无法到达z。
所以当我们枚举起点i无法到达加油站z时,[i+1,z-1]这些加油站为起点也一定无法到达z,那么下一次直接从z加油站为起点判断能否绕一圈。
代码如下:
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int n = gas.size() , i = 0;
//枚举起点
while(i < n){
//从i出发最远能够到达哪个加油站
int res = 0 , x = 0; //res剩余油量,x为能走的步数
for(; x < n && res >= 0; ++x) {
res += gas[(i+x)%n] - cost[(i+x)%n]; //走到i+x+1加油站时剩余的油量,也就是说能走x+1步
}
if(res >= 0) return i; //说明以i为起点,走了n-1步了,绕了一圈还有剩余的油
else i += x; //无法走到i+x,也就是只能走到[i,i+x],下一次直接枚举从i+x出发
}
return -1;
}
};
20 单调递增的数字
题目解析:给定n,返回小于等于n的最大的单调递增的数字。 单调递增的数字意思就是,对于每一对相邻的数位,左边的高位的数字小于等于右边的低位的数字。
比如 n = 1231,那么最大的单调递增数字就是1111,比如n=1100,那么最大的单调递增数字就是1229。
其实小于等于n的数字有两类,一类是和n位数相同的,一类是位数小于n的,那么我们肯定是优先考虑位数和n相同的那一类数字。
假设n的每一位是 : numN[ ] ,而我们要找的数字的每一位是 numRes[ ] ,而既要位数和n相同,同时小于等于n,那么对于numRes,我们从前往后对比res和n的每一位,如果numN[i] == x , 那么我们要使得res尽可能达,res的第i位要填多少呢?肯定是尽可能大的填,我们就填x。
比如n = 1231 ,我们首先会将res填成 numRes = [1,2,3, ] ,当遍历到n的最后一位时,numN[i] = 0 < numRes[i-1] ,那么此时我们的res的第i位就不能填n的第i位了,因为这样一来res就不是单调递增数字了,那么我们怎么做呢?此时需要向前借位,也就是让前面的数变小一点。
我们首先向前遍历到 3,能否在3借位呢?能,因为在3借1位之后变成2,2>=numRes[1]
而当我们将3改成2之后,后续在本位res就已经小于n了,那么后续的位不管再大,整体也是小于等于n的,那么我们可以直接将后续的所有位都改为9.
于是我们就改成了 numRes = [1,2,2,9]。
又比如 n = 1100 ,我们首先填numRes = [1,1, ,],填到第3位时我们发现无法填0,那么向前借位,最终借到了最高位,将最高位改为0之后,后续都改为9,就变成了 numRes = [0,9,9,9]。
为了方便操作,我们可以先将n转化为字符串进行操作。
代码如下:
class Solution {
public:
int monotoneIncreasingDigits(int n) {
string res = to_string(n);
int len = res.size();
for(int i = 1 ; i < len ; ++i){ //直接从第1位开始看
if(res[i] < res[i-1]){
//说明比前一位小,需要借位
char ch = res[i];
int j = i - 1;
for(;j > 0 ; --j){ //看第j位能否修改成ch
if(res[j-1] < res[j]) break; //说明在第j位借1之后还是单调递增
}
res[j++]--; //在第j位借位,只需要第j位减1就行了
for(;j < len ; ++j) res[j] = '9';
break;
}
}
return stoi(res);
}
};
21 坏了的计算器
题目解析:给定一个计算器,只能执行乘2和减1两种操作,求出从startValue到target的最少的操作次数。
如果我们直接按照题目意思来模拟的话,从贪心的角度来看,我们需要让startValue尽可能快的接近或者超过target,那么我们会优先使用乘2操作,当startValue大于target之后就使用减法得到target。
但是这种贪心并不是最优解,比如 :2 - > 6 ,如果我们使用贪心来做,那么会先乘2次变成8,再减2两次变成6,此时一共需要4次操作,但是最优解是 2->4->3->6,此时只需要三次操作。
为什么贪心策略不是最优解呢? 因为当我们得出startValue大于target之后,此时来进行减1操作,如果减1的次数大于2,那么一定是不如在本次乘2之前进行一次减1,减完1之后再去乘2的,如果减1的次数大于4,那么就不如在倒数第二次乘2操作之前先进行一次减1操作再来进行两次乘2操作。
但是我们可以进行逆向思维,我们求出最快由target得到startValue的方法,就相当于求出了最快由startValue得到target的方法。不过此时我们的操作就变成了除2操作和加1操作,同时除2操作的前提是偶数。
那么此时如何进行贪心呢?我们要让target尽快变成startValue,那么优先选择除法,但是除法的前提是偶数,如果不是偶数,那么只能选择加法。
那么如何证明先除后加比先加后除快呢(或者说如何证明先将target除到小于startValue再去执行加法会比一边执行加法一边进行除法快呢)? 很简单,比如从x除法,我们需要得到x/2+1,先除后加只需要两次操作,而先加后除需要3次操作,因为除之后的加法,一次加1可以等价于除之前的两次加法,这样一来我们就能得到最少的操作次数。
代码如下:
class Solution {
public:
int brokenCalc(int startValue, int target) {
int res = 0;
while(target > startValue){
res++;
if(target % 2) target++;
else target /= 2;
}
//此时target小于等于startValue,还需要执行 startValue - target 次加一操作
res += startValue - target;
return res;
}
};
22 合并区间
题目解析:给定一个n*2 二维数组表示n个区间,要求我们合并其中重叠的区间,合并完之后返回。
比如给定三个区间 [1,4] , [4,7] 和 [2,3] ,我们就需要合并为[1,7] 来返回。
如果给定的是: [1,2] [3,4] , 这两个区间没有重叠部分,不需要合并,返回[1,2],[3,4]。
那么现在的问题就变成了如何进行有重叠部分的区间的合并。
什么情况下两个区间有重叠部分呢?假设两个区间 [a,b] 和 [c,d] ,如果 c <= b 或者 a <= d ,那么这两个区间就是有交集的。
而如果我们能够确保区间 [a,b] 和 [c,d] 中 a<=c ,那么我们只需要判断 b >= c 就能够确定这两个区间是有重叠部分了,那么合并之后的区间是: [a,max(b,d)] 。 合并之后,我们会接着去判断下一个区间 [e,f]是否和 [a,max(b,d)] 有重叠。
那么现在的问题就是我们如果暴力来判断的话,就需要两层for循环来枚举每一对区间是否有重叠部分,这样时间复杂度过高,如何优化呢?
很简单,我们可以先对所有区间以区间的左端点进行升序排序,这样一来,所有有重叠部分的区间就都是连续的了。
因为当我们求出了当前合并区间的右端点为 x , 下一个区间的左端点一定是越小越好,假设下一个区间的左端点为 y > x ,那么说明下一个区间与当前区间没有交集,后面的所有区间左端点都在下一个区间的右边,那么自然也不会和当前区间有交集,也就说明本区间已经合并完成了。
代码如下:
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
sort(intervals.begin(),intervals.end()); //以左端点进行升序排序
vector<vector<int>> res;
int begin , end , n = intervals.size();
if(n == 1) return intervals;
for(int i = 0 ; i < n;){
//找与当前区间有重叠部分的区间
begin = intervals[i][0] , end = intervals[i][1];
int j = i + 1;
for(;j < n; ++j){
if(intervals[j][0] > end) break;
end = max(end , intervals[j][1]);
}
//走到这里说明intervals[j]与当前区间没有交集了,当前区间已经合并完,或者j==n了
res.push_back({begin,end});
i = j; //
}
return res;
}
};
23 无重叠区间
题目解析:给定多个区间,我们需要移除某些区间,使得剩余的区间没有重叠部分,返回最少需要移除多少个区间。
首先本体的区间重叠和上一题有不同的地方,上一题的区间只要有一个点从重叠了那么就算重叠,而本题只有一个点重叠不算重叠。
还是一样的,我们需要现将所有区间以左端点进行排序,使得所有可能存在重叠部分的区间相邻,然后再去考虑删除区间。
对于两个区间 [a,b] [c,d] 其中 a <= c ,如果我们发现 c < d , 那么此时这两个区间有重叠,我们需要移除其中一个来避免重叠,选择哪个区间进行移除呢? 移除右端点较大的区间。从贪心的角度来讲,既然[a,b]和[c,d]需要移除一个,同时也要尽可能避免剩下的那个区间和后面的区间不会有重叠,那么就需要保证剩下的那个区间的右端点尽可能小,所以我们需要移除右端点较大的区间。
代码如下:
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
sort(intervals.begin(),intervals.end());//左端点升序排序
int res = 0;
int end = INT_MIN, n = intervals.size();
for(int i = 0 ; i < n ; ++i){
if(intervals[i][0] < end){
//有重叠,此时需要删除右端点大的区间
end = min(end , intervals[i][1]);
res++;
}
else {
end =intervals[i][1]; //去判断后续区间是否有重叠
}
}
return res;
}
};
24 用最少数量的箭引爆气球
https://leetcode.cn/problems/minimum-number-of-arrows-to-burst-balloons/
题目解析:本题给定一些气球所处的位置,我们只需要关注x坐标,后续我们射箭的时候是沿着 x轴的垂直方向射箭。只要满足我们射箭的位置 x = a ,a在气球的 [xstart,xend] 内部,这根箭就能引爆气球。求出最少需要射多少次箭才能引爆所有气球。
我们需要射箭的次数尽可能小,那么一次射箭就需要尽可能引爆更多的气球,那么什么情况下我们一根箭能够引爆多个气球呢? 假设两个气球的直接范围为 [a,b] 和 [c,d] ,那么只要这两个气球在x轴看有交集,那么我们就能在这个交集内射一根箭同时引爆这两个气球。假设 a<=c,那么如果 c<=b,就说明这两个气球有交集,那么交集在哪呢? [c,min(d,b)]。
那么如何判断一根箭能否引爆3个气球呢? 比如 [a,b] [c,d] [e,f] (a<=c<=e),如果前两个气球有交集[c,min(b,d)],那么第三个气球如果和 [c,min(b,d)] 有交集,就说明可以一次性引爆这三个气球。
本题其实就是类似于求有重叠区间的交集。同样我们还是需要将有重叠部分的区间放在一起,以左端点升序排序。代码如下:
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
sort(points.begin(),points.end());
int end, n = points.size() , res = 0;
for(int i = 0 ; i < n ;){
//求出一次最多能够引爆的数量
end = points[i][1];
int j = i + 1;
for(;j < n ; ++j){
if(points[j][0] <= end){
end = min(end , points[j][1]);
}
else break;
}
res++;
i = j;
}
return res;
}
};
25 整数替换
题目解析:给定一个正整数n,如果n为偶数我们需要对其进行/2操作,如果n为奇数,可以对其进行加一或者减一操作。返回将n变为1的最少的操作次数。
本题我们可以使用动态规划来解决,也可以使用dfs+记忆化来解决。记忆化搜索的代码如下:
class Solution {
public:
unordered_map<long long , int> hash; //记忆化hash[x]记录将x变为1的最少操作次数
int integerReplacement(int n) {
return dfs(n);
}
int dfs(long long n){
if(n == 1) return 0;
if(hash.count(n))return hash[n];
if(n % 2){
hash[n] = 1 + min(dfs(n+1) , dfs(n-1));
}
else{
hash[n] = 1 + dfs(n/2);
}
return hash[n];
}
};
本题也可以使用贪心策略来做,我们可以思考一下哪一种操作能够更快使n变为1呢? 当然是/2操作,但是/2操作的前提是n为偶数,偶数进行除2的操作,从二进制角度来看,其实就是右移一位的操作。而当n为奇数时,有加一和减一两种操作,哪一种操作更优呢?更优其实就是要让后面能够进行更多的/2操作。
我们可以拿出n的二进制位来看,如果n为奇数,那么n的二进制序列的最低位一定是1,而我们不管是加一还是减一,都是让最后一位变为0,好让我们能够执行偶数的二进制的右移一位操作(除2)的操作。我们可以拿出最后两位来看,如果最后两位是 01 ,那么此时我们如果执行+1操作,那么就变成了10,那么本次加一之后,下一次执行除2操作,除2之后又变成了奇数,又需要进行加一或者减一的操作,。 而如果我们进行减一操作,那么就变成了00,然后执行除2操作,右移一位之后,最低为仍然是0,那么我们可以接着进行除2操作,就可以省去了一次减一或者加一的操作。而如果n的最后两位为01,那么此时我们执行减一操作更加好。
其实我们也可以想到,我们需要执行的/2的操作次数其实和n的二进制的最高位的1有关系,除2操作的总次数其实就是最高位1的位置,那么我们就需要尽量让中间过程的加一和减一的操作次数少一点。
代码如下:
class Solution {
public:
int integerReplacement(int n) {
int res = 0;
while(n != 1){
if(n % 2 == 0){
n = n/2;
res++;
}
else if(n % 4 == 1){ // 01
n = n-1;
res++;
}
else { //11
n = n/2+1; //n++可能溢出,可以直接走两步 n = (n + 1) / 2 = n/2 + 1; 因为此时n是奇数
res += 2;
}
}
return res;
}
};
此时我们会发现一种特殊情况就是n==3的时候,+1是不如减1操作的,因为此时更高高位已经没有1了,减一之后只需要执行一次除2操作就变成了1,所以n==3的时候特殊判断一下。
class Solution {
public:
int integerReplacement(int n) {
int res = 0;
while(n != 1){
if(n == 3){ res += 2; return res;}
if(n % 2 == 0){
n = n/2;
res++;
}
else if(n % 4 == 1){ // 01
n = n-1;
res++;
}
else { //11
n = n/2+1; //n++可能溢出,可以直接走两步 n = (n + 1) / 2 = n/2 + 1; 因为此时n是奇数
res += 2;
}
}
return res;
}
};
26 俄罗斯套娃信封问题
题目解析:给定一组信封的大小(长和宽),对于两个信封,如果一个信封的长和宽都小于另一个信封,那么该信封可以放进另一个信封里面去。要求我们返回这些信封最多能够有多少个套在一起。
本题的常规解法还是动态规划,动态规划之前需要先对信封以长度或者宽度进行升序排序,dp[i]表示以第i个信封作为最外层信封时,最多套娃的信封数量。 再求dp[i]的时候,我们需要遍历[0,i-1]找出能够装进i中的信封j,dp[i] = max(dp[j]+1),时间复杂度为O(N^2),代码如下:
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
sort(envelopes.begin(),envelopes.end());
int n = envelopes.size() , res = 0;
vector<int> dp(n,1);
for(int i = 1; i < n ; ++i){
for(int j = 0 ; j < i ; ++j){
if(envelopes[i][1] > envelopes[j][1] && envelopes[i][0] > envelopes[j][0]) dp[i] = max(dp[j]+1 , dp[i]);
}
res = max(res,dp[i]);
}
return res;
}
};
这种暴力的动态规划会超时,那么如何快速求出dp[i]呢?我们在排序的时候,是根据长来进行升序排序的,假设不存在相同的长,那么我们其实可以把高单独拿出来,作为一个序列,[a,b,c,d] ,由于这个序列的信封的长已经是严格递增了,那么只需要高也严格递增,那么就是能够进行套娃的。比如 (1,2) (2,3) (3,2) (4,6) ,把高拿出来就是 [2,3,2,6],那么我们要求最多能够套娃的数量,其实就是再求一个高度的严格递增序列的最大的长度。 比如上面的最长递增序列就是 [2,3,6],所以最多能够套娃3个信封。
但是本题的长可能相同,此时我们默认的排序规则就是 长相同的情况下按照高的升序排序,那么我们求得最长递增子序列中,就有可能会包含长度相等的信封,此时是不对的。如何避免长度相同的信封出现在一个递增子序列中呢? 很简单,我们排序的时候,让长度相同的信封,按照高度的降序排序,那么我们在选择递增子序列的时候,就不会选到两个长度相同的信封了,因为只要长度相同,两两之间的高度都是递减的。
同时,我们做动态规划的时候就只需要解决最长递增子序列就行了。
代码如下:
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
sort(envelopes.begin(),envelopes.end(),[](const vector<int>& v1 , const vector<int>& v2){
return v1[0] < v2[0] || (v1[0] == v2[0] && v1[1] > v2[1]);
});
int n = envelopes.size() , res = 0;
vector<int> dp(n,1);
//变成了求最长递增子序列
for(int i = 0 ; i < n ; ++i){
for(int j = 0 ; j < i ; ++j){
if(envelopes[j][1] < envelopes[i][1]) dp[i] = max(dp[i] , dp[j] + 1);
}
res = max(res,dp[i]);
}
return res;
}
};
但是这样下来,本质还是一个两层循环的动态规划,时间复杂度还是O(N^2)。而我们可以参考前面做过的最长递增子序列的方法来进行二分的优化。
我们在动态规划求 dp[i] 的时候,同时维护一个len数组,len[j] 表示在前i个信封中,能够套娃j个信封的最外层信封的最小的高度,那么这样一来我们就可以通过二分查找,来优化掉第二层O(N)的循环,二分查找的思路参考本文的第5题。其实这里的len我们并不一定要套着题目来理解,直接理解为在高度的序列中,长度为j的严格递增子序列的最小的末尾元素,此时len数组一定是递增的,我们可以通过二分查找找到len数组中大于等于第i个信封的高度的最小长度,假设为 len[x] ,那么我们可以将len[x] 更新为 envelopes[i][1]。
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
sort(envelopes.begin(),envelopes.end(),[](const vector<int>& v1 , const vector<int>& v2){
return v1[0] < v2[0] || (v1[0] == v2[0] && v1[1] > v2[1]);
});
int n = envelopes.size() , res = 0;
vector<int> len;
len.push_back(envelopes[0][1]);
//变成了求最长递增子序列
for(int i = 1 ; i < n ; ++i){
//特殊判断一下需要增加新长度的时候
int h = envelopes[i][1];
if(h > len.back()) len.push_back(h);
else{
//找len中大于等于h的第一个位置
int l = 0 , r = len.size()-1;
while(l < r){
int mid = l + (r - l) / 2;
if(len[mid] >= h) r = mid;
else l = mid + 1;
}
len[l] = h;
}
}
return len.size();
}
};
27 可被三整除的最大和
https://leetcode.cn/problems/greatest-sum-divisible-by-three/
题目解析:给定一个数组,可以删除元素,要求我们求出数组和为3的整数倍的最大和。
如果整个数组和为3的整数倍,那么不需要删除元素,如果不为3的倍数,那么就需要删除一些元素。要使得剩余元素的和最大,那么就要让删除元素的和尽可能小。我们需要从删除的元素的角度来解决本题,所谓正难则反。
本题需要用到同余定理,也就是 a%p - b%p = (a-b)%p (a>b).
我们可以把nums的所有元素划分为三类 , {S1,S2,S3},S1包含所有能被三整除的元素,S2为所有模3结果为1的元素,S3为所有模三结果为2的元素。
求出所有元素的和为sum,如果sum%3==0,那么不需要删除元素。
如果 sum % 3 == 1 ,那么此时有两种删除的策略,删除S2中最小的元素,或者删除S3中最小的两个元素。
如果sum % 3 == 1,此时也有两种删除策略,删除S3中最小的元素或者删除S2中最小的两个元素。
我们需要用到S2和S3的最小和次小元素,可以在求和的过程中记录以下。
代码如下:
class Solution {
public:
int maxSumDivThree(vector<int>& nums) {
int sum = 0 , a1 = INT_MAX , a2 = INT_MAX ,b1 = INT_MAX , b2 = INT_MAX;
for(auto x : nums){
sum += x;
if(x % 3 == 1){
if(x <= a1) a2 = a1 , a1 = x;
else if(x < a2) a2 = x;
}
else if(x % 3 == 2){
if(x <= b1) b2 = b1 , b1 = x;
else if(x < b2) b2 = x;
}
}
if(sum % 3 == 1){
//两种策略
int del= INT_MAX; //
if(a1 != INT_MAX) del = a1; //S1需要有一个元素
if(b2 != INT_MAX) del = min(del , b1 + b2); //S2需要有两个元素
sum -= del;
}
else if(sum % 3 == 2){
int del = INT_MAX;
if(a2 != INT_MAX) del = a1 + a2;
if(b1 != INT_MAX) del = min(del , b1);
sum -= del;
}
return sum;
}
};
贪心是求这类问题比较快的方法,同时我们也可以使用动态规划来求,动态规划是解决这一类问题的通用方法,定义状态表示为: dp[i][j] 表示在前i个元素中选,使得和 sum % m == j 的最大和。而考虑dp[i][j] 的时候可以考虑第i个元素的选和不选,如果不选,则需要找到前i-1个元素中选 余数为(j - nums[i] % m + m )%m的最大和。具体代码就不在这里实现了。
28 距离相等的条形码
题目解析:给定一个数组,数组中的元素的值表示一个条形码,可能存在多个相同的条形码,我们需要对所有的条形码进行排序,使得任意两个相邻的条形码不相同。
其实就是需要我们对元素进行排序,使得相邻元素不相等。
对于这一类问题有一种很取巧的方式,就是我们先对所有元素进行计数,然后依次放每一类元素,首先放奇数下标,然后放偶数下标。
更具体的,我们先把所有的x拿出来,从下标0开始放,依次放0,2,4,6... i,直到把所有的x放完,然后再拿出下一个元素y,从i+2继续在偶数下标处放i,以此类推直到放完偶数位置,然后再从奇数下标开始放。
但是这样做有一种特殊情况就是 : 其中一个元素出现了超过元素总数的一半,(n+1)/2,n为奇数此时我们其实是可以使得所有元素相邻不相等的,但是可能由于该元素不是第一个也不是最后一个放的,那么这个元素就会横跨奇数和偶数的下标,同时由于数量过多,到最后一个奇数位置,是和该元素的偶数起始位置相邻的,那么就出错了。就比如 : [1,2,2,2,3],如果我们先放1,变成 [1,_,_,_,_] ,然后放2,就会变成:[1,2,_,2,2,_] ,这样就会导致2相邻了,但是其实我们是可以使得相邻不相同的,[2,1,2,3,2] 。
所以我们加一个限制就是,最先放数量出现最多的元素,这样一来就能保证相邻不相等了。
代码如下:
class Solution {
public:
vector<int> rearrangeBarcodes(vector<int>& barcodes) {
unordered_map<int,int> hash;
int maxnum = barcodes[0];
for(auto x : barcodes) {
hash[x]++;
if(x != maxnum && hash[x] > hash[maxnum]) maxnum = x;
}
//然后开始放元素
//先把maxnum放完
int cnt = hash[maxnum];
hash.erase(maxnum);
int i = 0 , n = barcodes.size();
for(; i < n && cnt--; i += 2){
barcodes[i] = maxnum;
}
//放完maxnum开始放剩余元素
for(auto& [x,cnt] : hash){
while(cnt--){
if(i >= n) i = 1;//偶数放完放奇数
barcodes[i] = x;
i+=2;
}
}
return barcodes;
}
};
29 重构字符串
题目解析:给定一个字符串,对其进行重新排列,使得所有相邻元素不相等。
本题和上一题类似,但是不保证有解,什么时候无解呢? 出现次数最多的元素比 (size()+1)/2还大,如果总数为偶数个,那么数量大于总数的一半就无解,如果总数为奇数个,那么大于一半加一就无解。
其余的代码和上一题一样,就是多了一个无解的判断。
代码如下:
class Solution {
public:
string reorganizeString(string s) {
unordered_map<char,int> hash;
char maxch = s[0];
for(auto ch : s){
hash[ch]++;
if(ch != maxch && hash[ch] > hash[maxch]) maxch = ch;
}
int n = s.size(),i = 0,cnt = hash[maxch];
if(cnt > (n+1)/2) return ""; //无解
while(cnt--) {
s[i] = maxch;
i += 2;
}
hash.erase(maxch);
for(auto& [ch , cnt] : hash){
while(cnt--){
if(i >= n) i = 1;
s[i] = ch;
i += 2;
}
}
return s;
}
};
总结
贪心算法对于初学者或者不是深入研究算法的工程师而言,主要考察的是做题量以及贪心策略,平时刷题的量多了,那么很多做过的题目的贪心策略我们都可以移植到其他题目上,但是如果做题比较少,初次接触算法,那么贪心算法的学习难度就比较大。 对于贪心算法的提升,最简单快速的方法就是多刷题。