总言
主要内容:编程题举例,学习理解贪心策略解题思想。
文章目录
0、基本介绍
1)、是什么?
贪心算法,顾名思义,是一种在每一步选择中都采取当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。它常被形象地描述为“贪婪+鼠目寸光”,因为贪心算法在每一步只考虑当前的最优解,而不考虑未来的影响或全局的最优性。
2)、贪心算法的特点
1、贪心策略的提出:
①、贪心策略的提出并没有固定的标准或模板。
②、不同的问题可能需要不同的贪心策略,因此贪心算法的应用往往依赖于对问题的深入理解和分析。
2、贪心策略的正确性:
①、并非所有的问题都能通过贪心策略得到全局最优解。有时,贪心策略可能是一个错误的方法,导致无法得到正确的结果。
②、因此,对于贪心策略的正确性,我们需要进行严格的证明。常用的证明方法包括数学中见过的所有证明方法,如反证法、归纳法等。
3)、学习贪心的方向
1、理解并吸收贪心策略: 在学习贪心算法的前期,重点应放在理解和吸收各种贪心策略上。通过大量的练习和案例分析,将贪心策略作为一种经验来积累,以便在遇到类似问题时能够迅速识别并应用合适的贪心策略。(心态放平)
2、学会证明贪心策略的正确性: 证明贪心策略的正确性是学习贪心算法的重要一环。需要掌握各种数学证明方法,并能够灵活运用这些方法来证明贪心策略的正确性。(因人而异,了解学习证明只是为了知根知底,不做强求)
1、柠檬水找零(easy)
题源:链接。
1.1、贪心
1.1.1、题解
1)、思路分析
分析题目,对bills
,我们会遇到三种面值的金额:
1)、5美元钞票: 不需要找零,直接收下即可。
2)、10美元钞票: 需要找零5美元。因此,我们需要在找零时有至少 1张“5美元” 钞票。
3)、20美元钞票: 需要找零15美元。①可以通过 1张“10美元” 和 1张“5美元” 完成,②也可以通过 3张“5美元” 来完成。
1、哪里体现了贪心?
可以发现,情况1),情况2),都是固定的策略,只有一种操作,因此不用过多分析。而情况3)却可以选择多种操作,因此,这就需要我们考虑如何选择,才是更优的策略。这就是需要用到“贪心”的体现。
2、那么对于20美元的找零,10+5和5+5+5,该选择哪一种策略更优呢?
要知道,10美元只能给20美元找零,而5美元可以给10美元和20美元找零,这就说明,5美元的用途是更大的,那么我们应该把用途更大的5美元保留下来,尽可能优先使用大面额的钞票找零。
一个举例:
2)、题解
解题思路: 基于上述分析,可以选择贪心策略解题。使用两个变量 five 和 ten,分别记录手头拥有的5美元和10美元钞票的数量,初始都为0(20美元不用记录,因为不参与找零)。遍历账单数组 bills,对每个账单进行处理。
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
int ten = 0, five = 0;
for(auto bill : bills)
{
if(bill == 5)//无需找零
++five;
else if(bill == 10)// 判断手头是否有5美元,再决定是否找零
{
if(five == 0) return false;
--five; ++ten;
}
else// 说明遇到的是支付了20美元的情况:使用贪心
{
if(ten && five) // a、先选择 10+5 的这种找零策略
{
--ten; --five;
}
else if(five >= 3)// b、再选择 5+5+5 的这种找零策略
five -= 3;
else // 手头的零钱数量不够
return false;
}
}
return true;//如果所有账单都处理完毕且都能找零,则返回 true。
}
};
1.1.2、证明:交换论证法
1)、介绍交换论证法
交换论证法是通过假设存在一个最优解,然后尝试通过交换最优解中的某些元素或步骤来得到一个与贪心解更接近的解,同时不改变最优解的全局最优性。如果在交换过程中发现贪心解与最优解在某种情况下是等价的,或者贪心解能够导致一个不更差的结果,那么就可以推断出贪心算法是正确的。
在本题中:
假设最优解:假设存在一个最优解,即一个能够正确找零的零钱使用方案。这个方案可能包含多种使用5美元和10美元找零的组合。
定义交换操作:我们定义一个交换操作,即在一个找零方案中,将使用3张5美元找零20美元的操作替换为使用1张10美元和1张5美元找零20美元的操作(假设这种替换是可行的,即手头有足够的10美元和5美元)。
证明交换不导致更差结果:
1)、交换后,找零的总金额不变,因为1张10美元和1张5美元与3张5美元的总金额都是15美元。
2)、交换后,手头剩余的零钱种类和数量不会减少,因为替换操作没有消耗额外的零钱,且保留了更多的5美元钞票(因为使用了1张10美元而不是3张5美元)。
3)、由于保留了更多的5美元钞票,后续交易中找零的灵活性增加,不会因为缺少5美元而无法找零。
逐步逼近贪心解:从任意一个最优解出发,通过不断执行上述交换操作,我们可以逐步将找零方案转变为贪心算法所得出的解。在每一步交换中,我们都保证了找零的总金额不变,且手头剩余的零钱种类和数量不会减少。
有限步结束:由于账单数组是有限的,且每一步交换都朝着贪心解的方向进行,因此交换过程将在有限步内结束。最终,我们将得到一个与贪心解等价的找零方案。
得出结论:由于通过交换论证法,我们证明了从任意一个最优解出发,通过有限步的交换操作,最终可以得到一个与贪心解等价的找零方案,且在这个过程中找零的总金额和手头剩余的零钱种类和数量都没有减少。因此,我们可以得出结论:贪心算法在柠檬水找零问题中是正确的。
2、将数组和减半的最少操作次数(medium)
题源:链接。
1.2、贪心
1.2.1、题解
1)、思路分析
分析此题: 本题要求我们通过对数组中的元素进行操作,使得数组的总和至少减少一半(即减少到原总和的sum/2
或更小),并求出达成此目标所需的最小操作次数。
可以使用贪心的体现: 由于数组中有众多元素,那么我们可以有多种选数的方式。但为了以最小的次数达成要求,最优的选择策略,当然是每次操作都都选择当前数组中的最大值进行减半。因为较大的数减半后对总和的减少贡献更大,这样可以更快地接近目标值。
辅助容器: 为了方便我们获取数组中的最大元素,可以借助大根堆存储数组操作前后的元素。
2)、题解
时间复杂度:
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),其中 n 是数组 nums 的长度。将数组和减半最多不超过 n 次操作,每次操作需要
O
(
l
o
g
n
)
O(logn)
O(logn)。
空间复杂度:
O
(
n
)
O(n)
O(n)。保存优先队列需要
O
(
n
)
O(n)
O(n) 的空间。
class Solution {
public:
int halveArray(vector<int>& nums) {
double sum = 0; // 统计数组和
for (auto n : nums)
sum += n;
priority_queue<double> que(nums.begin(), nums.end()); // 借助大根堆存储数组元素
sum /= 2.0; // 目标值:题目要求数组和至少要减少一半
int count = 0;// 统计操作次数
while(sum > 0)// 当sum变为0,或者<0,说明数组已经减少了至少一半
{
double tmp = que.top() / 2.0;
que.pop();
sum -= tmp;
que.push(tmp);
++count;
}
return count;
}
};
1.2.2、证明:交换论证法
1)、解释
2)、文字表述
假设我们有一个正整数数组 nums,并且我们有一个贪心算法,它每次选择当前数组中的最大值进行减半操作。我们称这个贪心算法得到的解为 G,其操作次数为 op_G。
现在,假设存在另一个算法 A,它在某一步没有选择当前最大值进行减半,而是选择了其他值 x(x 不是当前最大值)。我们称这个算法在这一步得到的中间状态为 S_A,并且假设它在这一步之后继续执行,最终得到的解为 A,其操作次数为 op_A。
我们的目标是证明 :op_G <= op_A。
定义与初始状态:
初始状态:nums 数组。
贪心算法 G:每次选择当前最大值减半。
算法 A:在某一步选择了非最大值 x 进行减半,得到状态 S_A。
交换操作:
在状态 S_A 中,假设 x 被减半为 x/2,而当前最大值 max_val 仍然保持不变。
我们现在构造一个新的操作序列,其中我们首先选择 max_val 进行减半,得到一个新的中间状态 S_G’。
然后,在 S_G’ 中,我们选择减半后的 max_val/2(如果它仍然存在)或其他任何值进行进一步操作,直到我们达到一个与 S_A 类似但可能不同的状态 S_G’',其中 x/2 也存在(可能是经过一系列其他操作后得到的)。
比较操作次数:
由于 max_val 是当时数组中的最大值,减半 max_val 对总和的减少贡献最大。
因此,在 S_G’ 状态中,即使我们接下来不直接操作 x,由于已经减少了更大的值 max_val,我们可能只需要更少的操作就能达到或超过 S_A 的效果。
换句话说,从 S_G’ 到 S_G’'(或达到目标值)的操作次数可能少于从 S_A 到最终解 A 的操作次数。
归纳与结论:
我们可以对算法 A 中的每一步都应用上述交换操作,逐步将其转换为贪心算法 G 的等价操作序列(或更优的操作序列)。
最终,我们将证明,无论算法 A 如何选择非最大值进行减半,总可以通过一系列交换操作得到一个不劣于贪心算法 G 的解。
因此,贪心算法 G 是最优的,即 op_G <= op_A 对所有可能的算法 A 都成立。
3、最大数(medium)
题源:链接。
3.1、贪心
3.1.1、题解
1)、思路分析
根据题目,对于每个整数,不能拆分,只能作为一个整体进行排列。我们的核心目标就是:寻找一种排列方式,使得拼接后的字符串数值最大。
因此,本质就是寻找一种排序规则, 从而确定元素的先后顺序。
有了上述的排序规则后,我们只需要按照这个规则进行排序,将排序后的结果拼接成最大的整数。
为了方便拼接数字,这里,先对给定的数组做优化:将所有的数字当成字符串处理(字符串的拼接操作会比整型数值的拼接方便得多),
贪心体现在哪里?
贪心策略体现在如何定义一个排序规则,使得排序后的结果最优。
排序规则: 对于两个整数 x 和 y,我们比较 xy 和 yx 的字典序(即字符串比较)。如果 xy 大于 yx,则 x 应该排在 y 前面;否则,y 应该排在 x 前面。
贪心思想: 局部最优解(每一步都选择当前最优的排列方式)能够引导到全局最优解(最终得到最大的拼接字符串)。
2)、题解
把数转化成字符串,然后拼接字符串,按照字典序比较即可。
class Solution {
public:
string largestNumber(vector<int>& nums) {
// 1、把整型数字转换为字符串
vector<string> strs;
for(auto n : nums)
strs.push_back(to_string(n));
// 2、排序:sort
sort(strs.begin(),strs.end(),[](string& s1,string& s2)
{
// 比较拼接后的两数:ab > ba,则a在前
return s1+s2 > s2+s1;
});
// 3、取数拼接返回值
string ret;
for(auto s : strs)
ret+=s;
return ret[0] == '0' ? "0": ret;// 注意判断返回值
}
};
4、摆动序列(medium)
题源:链接。
4.1、动态规划
见链接。
4.2、贪心
4.2.1、题解
1)、思路分析
摆动序列是指连续数字之间的差严格地在正数和负数之间交替的序列。
换句话说,如果我们将序列中的相邻元素进行两两比较,得到的差值序列应该满足以下条件:
差值序列中的元素交替为正数和负数。
序列不能包含连续的相同差值(即不能有连续的零差值)。
我们将整个序列的趋势显示出来。贪心算法的核心思想是每一步都做出在当前看来最好的选择,从而希望这样的局部最优选择能够导致全局最优解。
运用到本题,贪心的思想是:在整个数组中尽可能多的挑选点(元素),如此,才能让摆动序列的长度尽可能的长。
因此,我们就需要思考,如何挑点?
对后续,第三、第四、……等等点的选择,也是如此分情况讨论。由此,我们可以发现,对于某一个位置来说:
如果接下来呈现上升趋势的话,我们让其上升到波峰的位置;
如果接下来呈现下降趋势的话,我们让其下降到波谷的位置。
因此,如果把整个数组放在"折线图"中,就能统计出所有的波峰以及波谷的个数,也就是摆动序列的最长长度。
因此,接下来要思考的问题就是,如何知道某一个点,是波峰、波谷?
波峰、波谷左右两段的差值是负数。我们可以根据这一特性进行判断。
但需要处理特殊情况,即相邻两个元素相同时(序列段持平,差值为 0)。这种情况,可分为两大类:
因此,我们需要把上图右侧这种情况的波峰、波谷也考虑进入摆动序列中。写法有很多,以下提供一种思路:
使用 left 和 right 两个变量,记录相邻元素之间的差值(即趋势)的符号。
left
变量:用于存储当前元素与前一个元素之间的差值(即当前考察位置的“左边”趋势)。
right
变量:用于存储当前元素与下一个元素之间的差值(即当前考察位置的“右边”趋势。
2)、题解
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int n = nums.size();
if(n < 2) return n;// 处理特殊情况
int left = 0; // 用于记录 i 左边的趋势
int ret = 1;// 因为i、i+1比较的原因,最后一个元素位置是没做判断的(但它也符合要求)
for(int i = 0; i < n-1; ++i)
{
int right = nums[i+1] - nums[i];// 用于记录 i 右边的趋势
if(right == 0) continue;// 持平时的处理
if(right*left <=0) ++ret;// 左右趋势不相等时:波峰or波谷
left = right;
}
return ret;
}
};
5、 最长递增子序列(medium)
题源:链接。
5.1、动态规划、记忆搜索化
5.2、贪心
5.2.1、题解
1)、思路分析
本题中,贪心解法是在 “动态规划的解法” + “二分查找的思想” 的基础上,进一步发展和融合而来的。因此,在学习本题之前,建议先具备上述两个算法基础(二分查找相关算法链接)。
回顾一下dp的解法,dp[i]表示,以 i 位置为结尾的的所有子序列中,最长递增子序列的长度。然后,我们推导得状态方程:
dp[i] = max(dp[i], dp[j]+1),其中,0 <= j <= i - 1 && nums[j]< nums[i]。
仔细思考这一过程,会发现,在考虑最长递增子序列的长度的时候,其实并不用关注序列具体长什么样,我们只是关心最后一个元素是谁,这样我们就可以判断 nums[i] 是否可以拼接到原序列的后面。
贪心优化: 我们使用一个数组,来存放长度为x的递增子序列中的最后一个元素。即:下标为 i 的位置,对应长度为 i +1 的序列。
由上述分析,我们再来总结一下这里贪心的使用:对数组,下标为 i 的位置,对应长度为 i +1 的序列
1)、数组中存什么:所有长度为 x 的递增子序列中,最后一个元素的最小值(注意理解,这里存的是最小值);
2)、对于新来的元素 i ,存哪里:遍历数组, j < i 时,往后走,当第一次出现 j >= i 时,替换掉此处位置的元素。
观察上述步骤会发现,使用贪心策略时,对每一个元素 i ,我们都要遍历数组找它合适的位置,所以时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),和原先使用动态规划的解法相比并无优化,反而因为这个贪心策略难以想到,会显得复杂。
这是因为我们尚未引入二分查找进行优化。实际上,在这一个贪心策略中,所得到的数组是严格保持单调递增的,因此,我们在找 i 存放的位置时,是可以使用二分查找的。
证明:
处理边界情况: 由于二分查找判断的是[left,right]之间的位置,但根据这里贪心的策略,有可能存在 x > right 的情况, i > ,因此我们需要将 x 插入在数组vector.back()之后。这里要做特殊处理。
2)、题解
使用贪心+二分查找:时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),空间复杂度为
O
(
n
)
O(n)
O(n)
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> dp;//辅助数组:i 存放长度为 i + 1 的子序列的结尾元素的最小值
dp.push_back(nums[0]);// 首个元素:毫无疑问,不用比较,直接放
for(int i = 1; i < n; ++i)// 遍历数组,根据贪心策略,二分查找位置
{
if(nums[i] > dp.back())// 边界情况:比最后一个元素都大(直接插数组后面,形成一个新的更的序列)
dp.push_back(nums[i]);
else
{
// 对数组dp,查找左边界的二分查找
int left = 0, right = dp.size()-1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(dp[mid] < nums[i])
left = mid + 1;
else
right = mid;
}
//找到位置
dp[left] = nums[i];
}
}
return dp.size();
}
};
6、递增的三元子序列(medium)
题源:链接。
6.1、贪心
6.1.1、题解
1)、思路分析
观察分析此题会发现,此题就是“最长递增子序列”的简化版,只需要找到长度为3的子序列即可。因此,完全可以使用“最长递增子序列”中的题解来解此题。
1)、使用动态规划: 设置一个dp数组,其中dp[i]表示以nums[i]结尾的最长递增子序列的长度。在遍历完整个数组后,我们只需检查dp数组中是否存在值大于等于3的元素,即可判断是否存在满足条件的递增子序列。但是,由于动态规划的时间复杂度为 O ( n 2 ) O(n^2) O(n2),本题数据量太大,因此存在超时的可能。
2)、使用贪心:
①、可以直接照搬5.2.1中的“贪心+二分”的题解。可以借鉴“贪心+二分”的解题思路,维护一个有序数组来记录当前找到的最长递增子序列。每次插入新元素时,通过二分查找确定其插入位置,并更新数组。如果数组长度首次超过3,则直接返回true。
②、甚至,我们可以直接使用“贪心”,不必加入二分查找进行优化。(因为贪心就是在更新数值,那么找出首次长度为3的子序列存在即可,不必保证当前位置元素最小)
③、继续简化,我们甚至不用辅助数组,只需要两个变量记录长度为1、长度为2时的值,即可使用贪心求解。
方法:
一个举例:
在这种情况下,每个元素最多被比较两次(一次与存储长度为1的变量进行比较,一次与存储长度为2的变量比较)。对于n个数,时间复杂度为
O
(
2
n
)
O(2n)
O(2n),也就是
O
(
n
)
O(n)
O(n)。
2)、题解
直接使用两个变量的写法:
class Solution {
public:
bool increasingTriplet(vector<int>& nums) {
int n = nums.size();
if(n <= 2) return false;//数组长度不构成三元组
int a = nums[0];
int b = INT_MAX;
for(int i = 1; i < n; ++i)//时间复杂度为o(n),空间复杂度为O(1)
{
if(nums[i] > b) return true; // x > b,构成长度为3的子序列
else if(nums[i] > a) b = nums[i]; // b >= x > a,替换掉 b
else a = nums[i];// x <= a,替换掉 a
}
return false;
}
};
7、 最长连续递增序列(easy)
题源:链接。
7.1、贪心
7.1.1、题解
1)、思路分析
思路暴力解法:从左到右遍历数组.
①当 nums[right] > nums[right-1]
时,这意味着当前元素比前一个元素大,满足连续递增的条件,因此可以将 right 指针向右移动一位(right++
)。
②当首次遇到 nums[right] <= nums[right-1]
的情况时,表明当前位置不再满足连续递增的条件,此时我们获得了一段连续递增的子序列(right - left
)。
③找到一段连续序列后,在暴力解法中,我们可能会让 left 指针向右移动一位(即 left++
),并将 right 重置为 left+1 的位置,然后重新开始遍历。
但这种方法存在效率问题,因为在 [left, right-1]
区间之后的每个 left++ 操作所得的新连续递增子序列,其长度很可能会逐渐减小。
实际上,我们不用逐一检查这些小区间,因为我们要找的是最长的连续递增子序列。因此,我们对这一过程进行优化:
当发现当前位置不再满足连续递增的条件时,我们直接将 left 设置为 right(即 left = right),然后从当前位置重新开始寻找新的连续递增子序列。
这样做的目的是跳过那些显然不会构成最长连续递增子序列的较短区间,直接从当前位置开始尝试寻找更长的连续递增子序列。这就是贪心的体现。
2)、题解
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int n = nums.size();
int left = 0, right = 1;
int ret = 1;//用于记录返回值:根据题目,数组长度至少为1
while(right < n)
{
while(right < n && nums[right] > nums[right -1]) ++right;//当前子数组一直连续递增
ret = max(ret, right - left);// 来到此处,说明“连续递增”被打断。统计本次连续子数组的长度:注意这里长度计算的方式
left = right;// 贪心体现:直接跳过一连串元素,让left从right位置开始重新统计
right = left + 1;//注意更新right的位置(这种写法的要求,否则会陷入死循环)
}
return ret;
}
};
8、 买卖股票的最佳时机(easy)
题源:链接。
8.1、暴力
1)、思路分析
暴力解法的基本思路是尝试所有可能的买入和卖出组合,找到能够获取的最大利润。对于数组 prices,其长度为 n,我们需要对每一对 (i, j)(其中 i < j)计算 prices[j] - prices[i],并跟踪记录其中的最大值。
总的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
2)、题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int maxProfit = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
maxProfit = max(maxProfit, prices[i] - prices[j]);
}
}
return maxProfit;
}
};
8.2、贪心(在暴力基础上优化)
8.2.1、题解
1)、思路分析
优化的关键在于,对于任意一天 i,我们只需要知道在 i 之前的最小价格,然后用 prices[i] 减去这个最小价格就可以得到在 i 天卖出的最大利润。
因为只需要遍历数组一次,时间复杂度为
O
(
n
)
O(n)
O(n)。
2)、题解
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int prevmin = INT_MAX;// 用于记录[0,i-1]前缀最小值
int ret = 0;//统计最后结果
for(int i = 0; i < n; ++i)
{
ret = max(ret, prices[i] - prevmin);// i位置买入时的最大利润
prevmin = min(prevmin,prices[i]);// 更新[0,i]之间的最小值
}
return ret;
}
};
9、买卖股票的最佳时机Ⅱ(medium)
题源:链接。
9.1、贪心
9.1.1、题解
1)、思路分析
分析题目,本题中,股票买卖不限交易次数。我们很容易想到把股票涨跌趋势画成折线图,显然,最佳的买卖策略(贪心),当然是:最低点买入、最高点卖出。(股票持平时:可以不用管,此时买卖利润为0)
这里存在两种写法:
1)、双指针写法:寻找股票价格连续增长的区间段(当然,把股票持平阶段算进来也行),这一段区间,就是一次股票买卖获利的过程。把这样的所有增长区间计算得的利润累加起来,就是最终利润。
2)、逐日判断股票涨跌趋势:将总天数的股票交易拆分为一系列相邻天数之间的交易,即每天只考虑与相邻一天的交易利润:增长、持平、下降,把所有增长时的利润累加即可。
2)、题解
写法一:
class Solution {
public:
int maxProfit(vector<int>& prices) { // 使用双指针的写法
int n = prices.size();
int left = 0, right = 0;
int ret = 0;// 记录最终利润
while(right+1 < n)
{
while(right+1 < n && prices[right] <= prices[right+1]) right++;// 股票增长或者持平
ret += prices[right] - prices[left];
left = right+1;
right++;
}
return ret;
}
};
写法二:
class Solution {
public:
int maxProfit(vector<int>& prices) { // 把交易“拆分为每一天”的写法
int n = prices.size();
int ret = 0; // 记录最终利润
for (int i = 0; i + 1 < n; ++i) {
if (prices[i] < prices[i + 1])
ret += prices[i + 1] - prices[i];
}
return ret;
}
};
10、 K次取反后最大化的数组和(easy)
题源:链接。
10.1、贪心
10.1.1、题解
1)、思路分析
分析题目可知,要使得修改操作恰好为k次。目标是最大化数组的和。对于数组中的元素,我们可以分为两大类情况来考虑:
1)、0 或 正数(非负数):正数在大多数情况下最好不要修改,因为修改它们(即使变成负数再变回来)也不会增加数组的和。然而,在某些特殊情况下(比如,没有负数时),我们可能会选择修改正数(即使选择,也只会选择其中最小非负数)。
2)、负数:负数显然是我们希望修改的主要对象,因为将它们变成正数可以显著增加数组的和。但是,我们也需要根据k的值,来决定修改哪些负数以及修改多少次(但显而易见的,修改的负数越小越好)。
此外,由于可以对同一个数进行多次修改,我们需要考虑以下几点:
1)、修改偶数次,数值不变(因为正负相抵消)。
2)、修改奇数次,数值变号(从正变负或从负变正)。
虽然我们可以肉眼值观判断得到最佳修改策略,但我们要以代码的形式写出,由于给定数组是乱序的,因此,如何找数也是我们要考虑的问题。
根据上述分析,这里,我们设整个数组中负数的个数为m个,分情况讨论操作:
1)、若 m > k
:将前k个最小的负数全部变成正数。这样可以最大化地增加数组的和,同时不超出k次修改的限制。
2)、若 m = k
:所有负数都可以被变成正数,从而最大化数组的和。
3)、若 m < k
:在这种情况下,我们首先将所有负数变成正数。然后,根据剩余的修改次数k-m的奇偶性,进一步操作:
①如果 k - m
是偶数,由于修改偶数次不改变数值,我们可以忽略剩余的修改次数。
②如果 k - m
是奇数,挑选当前修改后的数组中的最小的数,变成负数。(因为数组中的最小值,可以是由负数转为正数后得到,要把这种情况考虑进去。由于m < k 的情况下,所有负数均会转为正数。要找最小值,一个做法是,对原数组求绝对值,记录此时的最小值)
2)、题解
写法不一:
1)、借助了小根堆,先改数据再统计。
class Solution {
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> pq(nums.begin(), nums.end()); // 小堆,辅助取数
while (k) {
int curmin = pq.top(); // 1、取当前最小数
pq.pop();
if (curmin < 0) // 2、当前最小数为负数,将其改为正数,重新放入
{
pq.push(-curmin);
--k; // 记得减少修改次数
}
else // 3、当前最小数都为正数了,说明此时没有负数存在,根据剩余k的奇偶性判断一次即可。
{
if (k % 2 == 0)// 此时k为偶数,不用操作。但需要注意,把取出的数原封不动的放回去
pq.push(curmin);
else // 此时k为奇数,修改一次符号,然后重新放入
pq.push(-curmin);
break; // 全为非负数的情况,只需要判断一次即可。
}
}
// 统计
int sum = 0;
while (pq.size()) {
sum += pq.top();
pq.pop();
}
// 返回
return sum;
}
};
2)、借助排序,分情况直接统计。
class Solution {
public:
int largestSumAfterKNegations(vector<int>& nums, int k) {
// 1、统计出负数的个数、顺带找一下元素全为正数时的最小非负数
int m = 0, min_n = INT_MAX;
int n = nums.size();
for(auto x : nums)
{
if(x < 0) ++m;
min_n = min(min_n, abs(x));// 求绝对值最小的数(用于后续 k > m,求最大和的情况)
}
// 2、分情况讨论
int sum = 0;// 记录返回值
sort(nums.begin(),nums.end());// 排个序,方便取数
if(m > k)// 2.1、负数个数远大于修改次数时
{
for(int i = 0; i < k; ++i)// 对前k个负数(可将其修改为正数),统计绝对值
sum += -nums[i];
for(int i = k; i < n; ++i)// 对后续元素,直接统计值
sum += nums[i];
}
else// 2.2、修改次数 >= 负数个数时
{
for(auto x : nums)// 所有负数均可修改为正数,因此统计绝对值
sum += abs(x);
if((k - m) % 2)// 若剩余修改次数为奇数:对此时数组中的最小数反复执行修改操作(最终只是一次修改操作的效果)
sum -= min_n*2;// 注意理解这里的写法,上述abs进行了一次累加,这里需要减去那里的累加值,并减去本次修改操作。
}
// 3、返回
return sum;
}
};
11、按身高排序(easy)
题源:链接。
11.1、常用技巧学习(使用下标排序)
11.1.1、题解
1)、思路分析
本题并非贪心算法的应用题型,其核心解法主要涉及到排序技巧。在这里,我们需要掌握一种常见的排序方法——下标排序。
由题目描述可知,names 数组和 heights 数组的下标是一一对应的,但这两个数组本身并没有直接的关联。因此,我们不能直接对 heights 数组进行排序,因为排序会改变 heights 数组中元素的顺序,而 names 数组中的元素顺序则不会随之改变。
为了解决这个问题,我们需要思考如何排序。本题容易想到的解法:
1)、创建二元组数组: 我们可以创建一个由 <身高, 名字>
二元组组成的数组(例如 vector<pair<int, string>>
)。然后,我们根据身高(即二元组的 first
元素)对这个数组进行排序。排序完成后,我们可以遍历这个数组,并提取出名字(即二元组的 second
元素),从而得到按身高降序排列的名字数组。
2)、利用哈希表存储映射关系: 使用哈希表(例如 map<int, string>
)来存储 <身高, 名字>
的映射关系。我们可以按照身高作为键(key)来插入元素,根据需求实现一个降序排列的逻辑,这样哈希表就会按照键的降序自动排列。然后,我们可以遍历哈希表,并提取出值(value),即名字,从而得到按身高降序排列的名字数组。(PS:C++ STL中的map默认是按照键的升序排列的)
3)、对下标排序(常用技巧): 由于 names 数组和 heights 数组的下标是一一对应的,我们可以创建一个下标数组,该数组存储的是 heights[i]
和 names[i]
中的下标值 i
。然后,我们根据 heights[i]
的值对这个下标数组进行排序。排序完成后,我们就可以通过排序后下标数组中存储的下标 i ,依次找到与之对应的名字names[i]
,从而得到按身高降序排列的名字数组。
2)、题解
使用下标排序的写法:重写排序时的比较方法即可。
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
int n = heights.size();
vector<int> index(n);// 创建下标数组并为其初始化
for(int i = 0; i < n; ++i)
index[i] = i;
sort(index.begin(),index.end(),[&](int a, int b)
{
return heights[a] > heights[b];// 按身高降序
});
vector<string> ret;
for(auto id :index)// 遍历下标数组,提取结果
{
ret.push_back(names[id]);
}
return ret;
}
};
使用std::map:
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
int n = heights.size();
map<int,string,greater<int>> hash;
for(int i = 0; i < n; ++i)
{
hash[heights[i]] = names[i];
}
vector<string> ret;
for(auto [height, name]: hash)
ret.push_back(name);
return ret;
}
};
12、优势洗牌(田忌赛马)(medium)
题源:链接。
12.1、贪心
12.1.1、题解
1)、思路分析
在探讨如何最大化 nums1 相对于 nums2 的优势时,我们可以借鉴田忌赛马这一经典博弈论问题中的策略。
在田忌赛马的故事中,田忌拥有下等马、中等马和上等马,而齐王同样拥有这三类马。由于同等级的马中,齐王的马优胜于田忌的马,因此屡战屡败。对此,孙膑向田忌献计:
下等马消耗策略: 田忌的下等马无法胜过齐王的下等马,因此他选择让自己的下等马去对阵齐王的上等马。虽然这一场必输,但田忌可以消耗掉齐王的一个最强战力。
中等马制胜策略: 接下来,田忌选择让自己的中等马对阵齐王的下等马。由于齐王的下等马较弱,田忌的中等马有很大机会获胜。
上等马决胜策略: 最后,田忌用上等马对阵齐王的中等马。由于田忌的上等马本身较强,且齐王的中等马已经不如田忌的上等马,因此田忌有很大机会在这一场获胜。
将田忌赛马的策略应用到数组 nums1 和 nums2 的匹配中,我们可以得到以下贪心策略:
①、劣势消耗策略: 当 nums1 中当前最差的元素无法胜过 nums2 中当前最差的元素时,让 nums1 中的这个劣势元素去对阵 nums2 中的一个优势元素(通过牺牲一局,消耗对方最强战力)。
②、优势保持策略: 当 nums1 当前最差的元素能够与对方最差的元素相抗衡,甚至有可能获胜时,应毫不犹豫地选择让这两元素匹配(不必消耗nums1中更优的元素,可以留着以备后用)。
排序(贪心前的准备工作): 为了找到能够清晰地识别出各自的“上等马”、“中等马”和“下等马”(即,两数组各自的“优势”和“劣势”元素),我们需要先对两数组进行排序。但根据示例可知,最后输出结果中,要保持原先 nums2 数组的输出顺序,因此:
对nums1,可直接排序,无影响。对nums2,利用11题中学习的下标排序技巧进行排序。
2)、题解
class Solution {
public:
vector<int> advantageCount(vector<int>& nums1, vector<int>& nums2) {
// 创建下标数组并初始化:对应nums2元素下标
int n = nums1.size();
vector<int> index2(n);
for(int i = 0; i < n; ++i) index2[i] = i;
// 排序:升序排序
sort(nums1.begin(),nums1.end());
sort(index2.begin(),index2.end(),[&](int& a, int& b)
{
return nums2[a] < nums2[b];
});
// 比较:田忌赛马
vector<int> ret(n);// 记录返回值
int right = n-1;// 指向index2、nums2的右端下标
int left = 0;// 指向index2、nums2的左端下标
for(int i = 0; i < n; ++i)// 遍历nums1,与nums2比较
{
if(nums1[i] <= nums2[index2[left]])// 比不过:让nums1中的极小抵消nums2极大
{
ret[index2[right]] = nums1[i];
--right;
}
else// 比得过:直接填
{
ret[index2[left]] = nums1[i];
++left;
}
}
return ret;
}
};
简化比较部分:
vector<int> ret(n);// 记录返回值
int right = n-1;// 指向index2、nums2的右端下标
int left = 0;// 指向index2、nums2的左端下标
for(auto x : nums1)// 遍历nums1,与nums2比较
{
if(x <= nums2[index2[left]]) ret[index2[right--]] = x;// 比不过:让nums1中的极小抵消nums2极大
else ret[index2[left++]] = x;// 比得过:直接填
}
13、最长回文串(easy)
题源:链接。
13.1、贪心
13.1.1、题解
1)、思路分析
可以采用贪心策略,核心思想是尽可能地利用字符串中的字符来构建回文结构。
①、对偶数次数的字符: 当某个字符在字符串 s 中出现偶数次时,我们可以将该字符的全部数量都用于构造回文串。
②、对奇数次数的字符: 当某个字符在字符串 s 中出现奇数次时,我们可以利用 该字符的数量减去一个(即偶数个) 来构造回文串的对称部分。剩下的一个字符则 可以作为回文串的中心字符(如果需要的话)。
③、中心字符的确定: 在遍历字符串 s 并统计每个字符的数量后,我们需要检查是否有字符出现了奇数次。如果有,我们可以选择其中一个作为回文串的中心字符。
2)、题解
class Solution {
public:
int longestPalindrome(string s) {
// 统计这些字符出现的次数:这里直接用数组模拟哈希
int hash[128] = {0};
for(auto& ch : s) hash[ch]++;
// 统计回文串长度
int ret = 0;
for(auto& x: hash) ret += (x/ 2 * 2);
// 返回
return ret < s.size() ? ++ret : ret;
}
};
14、增减字符串匹配(easy)
题源:链接。
14.1、贪心
14.1.1、题解
1)、思路分析
分析题目,根据给定字符s
,对[0,s.size()]
之间的数字重新排列。因此,我们可以创建一个数组 result
来存储最终的排列结果。贪心策略如下:
对字符串中的每个字符ch
:
①、如果ch == 'I'
(表示当前数应该小于下一个数),为了确保下一个上升的数有更多的选择范围,我们选择当前未使用的最小数。
②、如果 ch == 'D'
(表示当前数应该大于下一个数):为了确保下一个下降的数有更多的选择范围,我们选择当前未使用的最大数。
2)、题解
可以采用双指针进行填数:创建两个指针 left 和 right,分别指向[0,s.size()]
区间中,当前未使用的最小数和最大数。
class Solution {
public:
vector<int> diStringMatch(string s) {
int left = 0, right = s.size();// 用于选择[0,n]之间的数
vector<int> ret;// 用于记录排列
for(auto ch : s)// 用于遍历s字符串
{
if(ch == 'D')// 遇到下降趋势
ret.push_back(right--);
else // 遇到上升趋势
ret.push_back(left++);
}
ret.push_back(left);// 最后还会剩余一个数:left、right相等
return ret;
}
};
15、分发饼干(easy)
题源:链接。
15.1、贪心
15.1.1、题解
1)、思路分析
能够发现,此题的思想和田忌赛马基本相似。
1)、由于数组是乱序,我们需要对两个数组进行排序:一个是孩子的胃口值数组 g
,按从小到大排序;另一个是饼干的尺寸数组 s
,同样按从小到大排序。排序的目的是为了让我们能够按顺序处理最小的胃口和最小的饼干,从而优化资源的分配。
接下来,我们按照孩子的胃口从小到大的顺序进行遍历,为他们逐一挑选合适的饼干。具体策略如下:
①当前饼干满足条件时,直接分配:
在遍历过程中,我们检查当前最小的饼干 s[j]
是否能够满足当前胃口最小的孩子 g[i]
。
如果满足(即 s[j] >= g[i]
),则直接将这块饼干分配给孩子 i
。这是因为,既然最小的饼干都能满足当前孩子的需求,那么为了最大化满足孩子的数量,我们应该避免使用更大的饼干,从而留下更多的饼干资源供后续孩子使用。
②当前饼干不满足条件时,跳过,寻找下一块饼干:
如果当前饼干 s[j]
无法满足当前孩子 g[i]
的胃口(即 s[j] < g[i]
),则我们放弃这块饼干,并继续检查下一块饼干。
之所以选择跳过这块饼干,是因为它连最小的胃口都无法满足,那么对于后续胃口更大的孩子来说,这块饼干更是无法满足他们的需求。因此,为了避免资源的浪费,我们直接选择跳过这块饼干,寻找更适合的饼干进行分配。
2)、题解
贪心的思路不变,写法不唯一(根据孩子胃口找饼干,或者根据饼干的尺寸找满足的孩子,都行)。
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
// 排序,方便将饼干尺寸和孩子胃口一一对应
sort(g.begin(),g.end());
sort(s.begin(),s.end());
int i = 0;// 记录当前满足的孩子个数/当前待投喂的孩子
for(int j = 0; j < s.size(); ++j)// 遍历饼干尺寸
{
//(注意这里饼干数量和孩子数量不一定相等)
if(i < g.size() && s[j] >= g[i]) ++i;// 说明当前i指向的饼干尺寸,能够满足第j个孩子的胃口
}
return i;
}
};
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
// 排序,方便将饼干尺寸和孩子胃口一一对应
sort(g.begin(),g.end());
sort(s.begin(),s.end());
int ret = 0;// 用于统计返回的孩子数量
int m = g.size(), n = s.size();
for(int i = 0, j = 0; i < m && j < n; ++i, ++j)
{
while(j < n && s[j] < g[i]) ++j;// 遍历饼干,直到当前饼干值能够满足一个孩子
if(j < n) ++ret;// 有可能把所有饼干遍历完,均无法满足,所在这里要加一个判断条件
// 能够满足, 则从下一个饼干开始,进入下一个孩子胃口的匹配。
}
return ret;
}
};