总言
主要内容:编程题举例,学习理解贪心策略解题思想。
文章目录
16、最优除法(medium)
题源:链接。
16.1、贪心
1)、思路分析
一般思路流程:暴力 + 递归(枚举所有情况) → 记忆搜索化(优化)→ 动态规划(转化)
贪心: 但实际上我们可以结合一点数学知识,很容易就能得出最优的解法。
题目明确指出nums[i] >= 2
,这个条件非常重要,因为它决定了除法运算的性质。
在除法运算中:
如果被除数(分子)保持不变,而除数(分母)增大,则商(结果)会减小。
反之,如果除数(分母)减小(通过连续除法),则商(结果)会增大。
因此,我们可以按照以下逻辑添加括号,以此最大化表达式的值:
1)、最大化分子: 由于 nums 数组中的每个元素都是正整数,并且我们希望结果尽可能大,因此应该让数组中的第一个元素(即 nums[0])单独作为分子。这是因为,如果我们将它与其他元素组合在一起作为分子的一部分,由于 nums[i] >= 2,结果肯定会比 nums[0] 本身小。
对 a/b/c/……/x:
a/(b/c/……/x) 的结果,比(a/b/……)/x 的大,因为后者减小了分子。
2)、最小化分母: 接下来,我们希望将剩余的所有元素都组合在一起作为分母的一部分,以最小化分母的值。这可以通过将它们连续相除来实现(例如 nums[1] / nums[2] / nums[3] / …)。这样做可以使得整个表达式的值最大化,因为分母越小,结果越大。
从除法角度:
a/(b/c/……/x),(b/c/……/x)越除越小,无限趋近于0,那么结果自然越来越大
从分数角度:对分母做除法,等同于将数放到分子上,nums[i] >= 2,结果自然大。
a*c*……*x
------------
b
证明(反证法): 对
{
a
、
b
、
c
、
d
、
e
、
f
}
\{a、b、c、d、e、f\}
{a、b、c、d、e、f},假设存在一种解法,使得
a
c
d
b
e
f
>
a
c
d
e
f
b
\frac{acd}{bef} > \frac{acdef}{b}
befacd>bacdef
通分后有:
a
c
d
b
e
f
、
a
c
d
e
2
f
2
b
e
f
\frac{acd}{bef} 、 \frac{acde^2f^2}{bef}
befacd、befacde2f2
两边比较抵消得:
1
、
e
2
f
2
1、e^2f^2
1、e2f2
由于
n
u
m
s
[
i
]
>
=
2
nums[i]>=2
nums[i]>=2,所以
1
<
e
2
f
2
1< e^2f^2
1<e2f2 恒成立,与假设矛盾。
3)、添加括号: 由上述分析,我们应该将第一个元素单独作为分子,并将剩余的元素全部用括号括起来并连续相除作为分母。这样做既满足了题目的要求(添加了括号),又确保了表达式的值最大化。
简化括号后,最优的表达式形式应该是:
nums[0] / (nums[1] / nums[2] / ... / nums[n-1])
需要注意特殊情况:
1)、单独一个数时:a
2)、两个数时:a / b
2)、题解
class Solution {
public:
string optimalDivision(vector<int>& nums) {
int n = nums.size();
string ret(to_string(nums[0]));// 题目至少给定一个数
if(n == 1) return ret;
if(n == 2)// 两个数的情况:a / b
{
ret += '/' + to_string(nums[1]);
return ret;
}
// 其它情况: a /( b / c / d / …… / z)
ret += "/(";
ret += to_string(nums[1]);
for(int i = 2; i < n; ++i)
{
ret += '/' + to_string(nums[i]);
}
ret +=')';
return ret;
}
};
17、 跳跃游戏Ⅱ(medium)
题源:链接。
17.1、动态规划
分析此题,从左到右单向跳跃,很容易想到使用动态规划求解,而且这还是一个线性dp。dp[i]
:表示从0
位置开始,到达i
位置时的最小跳跃次数。
使用动态规划解题,时间复杂度为 O ( n 2 ) O(n^2) O(n2)
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
// 1、创建dp表并初始化
vector<int> dp(n,INT_MAX);
dp[0] = 0;
// 2、填值:从左到右
for(int i = 1; i < n; ++i)
{
for(int j = i-1; j >=0; --j)
{
if(j + nums[j] >= i)// 能从j位置跳到i位置
dp[i] = min(dp[i],dp[j]+1);
}
}
// 3、返回
return dp[n-1];
}
};
17.2、贪心(基于层序遍历的思想)
1)、思路分析
2)、题解
时间复杂度为:
O
(
n
)
O(n)
O(n)
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
if(n == 1) return 0;// 只有一个数时,无需起跳
int left = 0, right = 0;// 用于记录起跳区间
int step = 0;// 用于记录起跳次数
while(left <= right)
{
if(right >= n-1) // 说明已经能跳到n-1这个位置了
return step;
// 遍历,找下一段跳跃区间的右端点位置
int next_right = 0;
for(int i = left; i <= right; ++i)
next_right = max(next_right,i+nums[i]);
// 更新区间数值
left = right+1;
right = next_right;
++step;
}
return -1;// 说明不存在这种跳跃方式
}
};
为什么循环条件是while(left <= right)
?虽然本题做了说明,保证生成的测试用例可以到达 nums[n - 1]
,但在18题中,存在跳跃不到最后一个位置的情况:
18、跳跃游戏(medium)
题源:链接。
18.1、贪心
1)、思路分析
思路和17题一样,区别在于返回值。
2)、题解
class Solution {
public:
bool canJump(vector<int>& nums) {
int n = nums.size();
int left = 0, right = 0;// 用于记录跳跃区间
while(left <= right)
{
if(right >= n - 1) return true;// 能跳跃到最后一个位置
// 找下一次跳跃的区间右端点
int next_right = 0;
for(int i = left; i <= right; ++i)
next_right = max(next_right, i + nums[i]);
// 更新区间段:
left = right + 1;
right = next_right;
}
return false;// 说明到不了最后一个位置
}
};
19、加油站(medium)
题源:链接。
19.1、模拟(暴力解法)
1)、思路分析
实则此题可以看作是一个模拟题。
以暴力解法来分析:依次枚举所有的起点,从每个起点开始,模拟⼀遍加油的流程,判断从当前位置出发,是否能够成功完成行驶。
如何实现暴力解法?可以使用一个变量step
(取值在[0,n-1
]之间),记录从i
位置出发往后走了几步。由于这是一个环路问题,我们需要利用取模运算(i+step)%n
,来确保在遍历过程中能够正确地循环回到起点。通过这种方式,可以对每一个可能的起点进行逐一尝试和验证。
2)、题解
相对来说,时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int n = cost.size();
// 暴力+模拟:
for(int i = 0; i < n; ++i)// 依次枚举所有起点
{
int remain = 0;// 用于记录油量
for(int step = 0; step < n; ++step)// 从i位置往后走step步
{
int index = (i + step) % n;// 获取i往后走step步所到达的下标位置
remain = remain + gas[index] - cost[index];
if(remain < 0) break;// 说明从 i 位置出发无法走完一环
}
if(remain >= 0 )return i;// 出内层循环的情况有二,只有满足该条件时,说明走完一回合
}
return -1;// 无解
}
};
19.2、贪心优化(找规律)
优化分析:
代码如下:实则改动部分很小。时间复杂度优化到 O ( n ) O(n) O(n)
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int n = cost.size();
for(int i = 0; i < n; ++i)// 依次枚举所有起点
{
int remain = 0;// 用于记录油量
int step = 0;
for(; step < n; ++step)// 从i位置往后走step步
{
int index = (i + step) % n;// 获取i往后走step步所到达的下标位置
remain = remain + gas[index] - cost[index];
if(remain < 0) break;// 说明从 i 位置出发无法走完一环
}
if(remain >= 0 )return i;// 出内层循环的情况有二,只有满足该条件时,说明走完一回合
i = i + step;// 优化
}
return -1;// 无解
}
};
20、单调递增的数字(medium)
题源:链接。
20.1、暴力
1)、思路分析
先来思考暴力解法,可以从给定的整数 n 开始,从大到小枚举 [n, 0] 区间内的每一个数字。对于每一个枚举到的数字,判断其是否是单调递增的,如此,即可找出首个单调递增的数字。
这里需要思考的是,如何判断一个数字是单调递增的?
2)、题解
写法一:将数转换为字符串,再判断单调递增
class Solution {
public:
int monotoneIncreasingDigits(int n) {
if (n < 10) return n; // 只有个位数的情况,直接满足
for(int i = n; i >= 0; --i)
{
if(fun(i)) return i;
}
return -1;
}
bool fun(int n)// 将数转换为字符串,再判断单调递增
{
string str = to_string(n);
for(int i = 0; i+1 < str.size(); ++i)
{
if(str[i] > str[i+1]) return false;
}
return true;
}
};
写法二:直接使用%10、/10的方法。
bool fun(int n)// 使用 % 10 、/ 10 的方法判断
{
int prev = n % 10;
while(n/=10)
{
int cur = n % 10;
if(cur > prev) return false;
prev = cur;
}
return true;
}
20.2、贪心
1)、思路分析
贪心的解法实则就是根据“数的性质”找规律。
保持高位单调递增: 如果一个数的高位(从左到右的靠前位置)已经是单调递增的,那么我们没有必要去修改这些位。因为修改它们可能会导致整个数变小,从而可能不再是小于或等于 n 的最大单调递增数字。
寻找递减位置: 从左到右遍历这个数的每一位,找到第一个出现递减的位置。这个位置意味着其前一位的数字大于后一位的数字,违反了单调递增的规则。
修改递减位置及其后续: 一旦找到了递减的位置,我们需要从这个位置开始向前推,找到与该数值相同的首个位置,①将该位置的数减1,②将这个位置之后的所有数字都修改为 9。这样做是为了在保持数尽可能大的同时,确保整个数是单调递增的。
2)、题解
class Solution {
public:
int monotoneIncreasingDigits(int n) {
if (n < 10) return n; // 只有个位数的情况,直接满足
string str = to_string(n); // 把数转换为字符串,方便找位数
int len = str.size();
// 从左往右,找第一个递减的位置
int i = 0;
while (i + 1 < len && str[i] <= str[i + 1]) ++i;
// 来到此处,情况有二:
if (i + 1 >= len) return n; // a、特殊情况:找到字符尾部(1234全递增的情况)
while (i - 1 >= 0 && str[i] == str[i - 1]) --i; // b、找到首个递减位置:从这个位置往前推,找到相同元素的最左区域
// 来到此处,说明找到了正确位置,开始修改值
--str[i];
for (int j = i + 1; j < len; ++j)
str[j] = '9';
// 返回
return stoi(str);
}
};
21、坏了的计算器(medium)
题源:链接。
21.1、贪心
1)、思路分析
在决定是执行双倍操作还是递减操作时,我们需要基于后续的结果进行反向推导,这使得问题变得复杂。我们不能仅仅因为双倍操作带来的数值变化比递减操作大,就优先选择双倍操作,然后再选择递减操作。
这里,设startValue = begin
,target = end
。
在解题时会发现,正向思考比较困难(虽然也能解题)。这是因为,在考虑当前begin
是选择 ×2
操作,还是选择 -1
操作时,往往需要基于后续的结果进行反向推导。也就是说,我们不能仅仅因为乘法的操作带来的数值变化比减法的操作大,就直接贪心地优选选择乘除操作,再选择加减操作。
由于这是数学运算,×2、-1
操作恰好对应÷2、+1
操作。因此正难则反,本题可以逆向来考虑,且这道题采用逆向思维更优。
为什么逆向思考更优?
1)、逻辑简化:
①、正向思考中,当 begin<end 时,要实现操作数最小,则需要将 begin 逼近 end 的某个分数值(1/2值、1/4值、1/8值、…),再进行×2操作。这里,难点就在于要判断要逼近的是1/2值还是1/4值还是其他值,逻辑复杂。
②、逆向思考中:当 end>begin 时,end只管÷2,到了end<begin时,再+1逼近。
③、说白了就是,正向思维采用的是先小跨度的-1操作,再大跨度的2操作;逆向思维采用的是先大跨度的/2操作,再小跨度的-1操作。然而事实上往往是先大后小的解决问题思维在实现起来会比较简单。
2)、操作优化:
①、正向思考时,对于×2、-1,两种操作奇数偶数都可选择。
②、而逆向思考时,对于÷2、+1,虽然偶数仍旧可以选择+1、÷2操作,但奇数只能选择 +1 操作。为什么?因为题目背景是计算器,给定的begin、end是整数,对一个奇数÷2,会得到一个小数,无法从end转换到begin值(注意,这要与C++中的除法后结果取整区分开。本题计算器考虑的是实际中的数学运算)。
③、因此,相比于正向思考,这种逆向思考使得操作更加明确和唯一。
基于上述,逆向分析此题的贪心策略。要从end
到达 begin
:
1)、当end <= begin
的时候,只能执行 +1
操作;(所需的操作步数可以通过 begin - end
快速得出)
2)、当end > begin
的时候,需要根据 end
的奇偶性来决定操作:
a、如果 end 是奇数,我们只能执行+1
操作,将其变为偶数。
b、如果 end 是偶数,我们优先执行÷2
操作,以尽快减小 end 的值
这样,每次操作都是唯一且确定的,从而保证了操作数的最小化。
2)、题解
class Solution {
public:
int brokenCalc(int begin, int end) {
int step = 0; // 记录操作步数
// 正难则反: end -> begin
while (end > begin) {
if (end % 2)
end += 1; // 奇数
else
end /= 2;
++step;
}
return step + begin - end;// end <= begin 的情况
}
};
22、合并区间(medium)
题源:链接。
22.1、贪心
1)、思路分析
区间类型的问题,是经典的贪心类题。关于此类题的一般解题思路是: ①对给定区间排序;②根据排序后的结果,总结规律,找解题策略,或先蒙一个解题策略,再总结规律。
1)、如何对区间进行排序? 一般有两种方式。
①基于区间左端点排序:保证排序后的区间集,左端点从小到大(不关心右端点)
②基于区间右端点排序:保证排序后的区间集,右端点从小到大(不关心左端点)
通常情况下,大多数区间问题这两种排序方式都能解题,只不过是解题策略需要依照排序方式进行灵活变动。
本题中,我们以左端点进行排序(C++中,可以使用sort函数排序,默认就是左端点排序,比较方便)
2)、排序后,如何合并区间?
说明①:实际上,排序后的区间具有以下性质:能够合并的区间,在给定数组中,都是连续的存放的。(也就意味着,当从左到右进行区间合并时,首次遇到不能合并的区间,此后所有区间都不能与当前区间进行合并)
说明②:合并区间的实质是求各个区间的并集。本题中,题目明确告知此意图,在某些问题中,题目可能不会直接说明要求。因此解题的关键在于,能否分析出题目要求(让我们求并集,或者求交集,等等)。
具体的合并操作如下:
以左端点排序进行区间合并,设选中区间为[left,right],待合并区间为[begin,end]。观察可以发现:
①、若两个区间能合并,则有right <= begin(是否取=看题目条件,本题示例2中,[1,4],[4,5]被视为重叠区间)。合并后,区间左端点不变, 右端点取max(right,end)
②、若两个区间不能合并,根据上述说明的连续性质,此后的区间均不能与当前区间合并,因此我们找全了一个完整的重叠区间,将其加入结果集,,并继续找下一个重叠区间。
2)、题解
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
// 1、先排序:这里以左端点进行排序
sort(intervals.begin(),intervals.end());
vector<vector<int>> ret;// 记录返回值
// 2、合并区间
int left = intervals[0][0], right = intervals[0][1];
for(int i = 1; i < intervals.size(); ++i)
{
int begin = intervals[i][0], end = intervals[i][1];
if(right >= begin)// 有重叠部分,需要合并
right = max(right,end);// 合并区间左端点不变,找并集的右端点
else// 没重叠部分,找全一个重叠区间
{
ret.push_back({left,right});// 加入结果集中
left = begin;// 更新
right = end;
}
}
ret.push_back({left,right});// 最后一个区间没加入结果集中
// 3、返回
return ret;
}
};
23、无重叠区间(medium)
题源:链接。
23.1、贪心
1)、思路分析
可以发现,此题和上一题同属于区间类题。解题思路具有一定的共通性,区别在于:
①、本题中,只在一点上接触的区间是不重叠的。
②、题目要找的是“使剩余区间互不重叠时,需要移除区间的最小数量”
如何理解这里的“移除区间的最小数量”?(这里我们举例理解)
如何操作,才能使我们“移除最少的区间,保留更多的区间”?
当两个区间重叠时,为了保留更多的区间,我们应该移除区间范围较大的那个。
区间范围的大小可以通过比较右端点来确定,因为右端点更大的区间通常会覆盖更多的空间,从而增加与其他区间重叠的可能性。
因此,在发生重叠进行区间移除时,我们都应该优先选择保留右端点较小的区间,以便为后续区间留下更多的空间。(这就是贪心的地方。)
2)、题解
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
// 1、排序:以左端点为基准
sort(intervals.begin(),intervals.end());
// 2、移除区间
int count = 0;// 记录删除次数
int right = intervals[0][1];// 本题实际只需要右端点即可
for(int i = 1; i < intervals.size(); ++i)
{
int begin = intervals[i][0],end = intervals[i][1];
if(right > begin)// 有重叠部分
{
right = min(right,end);// 取小删大(贪心点)
++count;// 删除次数增加
}
else// 无重叠,进行更新
right = end;
}
// 3、返回
return count;
}
};
24、用最少数量的箭引爆气球(medium)
题源:链接。
24.1、贪心
1)、思路分析
先理解题目意思(图源力扣评论区):分析题目可知,虽然故事背景是射气球,但此题本质上就是区间类问题。每个气球由一个区间表示,即 [xstart, xend]
,表示气球的直径在 xstart
和 xend
之间。
(像上述22、23那样直接表明题意的,可称之为母题,其中的解题思想经验可以被借鉴学习)
分析出区间问题后,一般按照两步走即可。
题目要求使用“最少的弓箭数量”,从贪心的角度来讲,这表明,对于一支箭,都应该尽可能地引爆更多的气球。为了做到这一点,我们需要找到那些两两之间互相重叠的气球区间,并在这些重叠的部分射箭。(注意理解这里“互相重叠”的含义)
如何求出互相重叠的区间?(求交集)。
1)、对气球区间按照左端点进行排序。
2)、排序后,从左到右遍历这些区间,并尝试合并相邻的重叠区间。合并两个区间的规则是:
①、新的左端点是两个区间左端点的最大值(由于我们已经按左端点排序,所以新的左端点总是当前区间的左端点。也就是说,左端点不会影响我们的合并结果,可以忽略)
②、新的右端点是两个区间右端点的最小值。
那么,我们只用使用一个变量,记录射箭数量即可。
2)、题解
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
// 1、排序
sort(points.begin(),points.end());
// 2、求交集,找无重叠区间的数量
int count = 0;
int left = points[0][0], right = points[0][1];
for(int i = 1; i < points.size(); ++i)
{
int begin = points[i][0], end = points[i][1];
if(begin <= right)// 存在重叠区域
{
right = min(right,end);// 仅需更新右端点
}
else// 不重叠,记作一次射箭,更新
{
++count;
right = end;// 仅需更新右端点
}
}
// 3、返回
return count+1;// 注意出循环时少记一次(还需再射出一支箭)
}
};
25、整数替换(medium)
题源:链接。
25.1、递归+记忆搜索化
1)、思路分析
①、直接根据题意模拟(递归):本题的核心在于寻找将任意正整数n
转换为1
所需的最小替换次数。这个转换过程可以通过两种基本操作来实现:
如果n是偶数,则将其除以2;
如果n是奇数,则可以选择将其加1或减1。
由于这个过程具有递归性质,即每次操作后得到的数(无论是偶数减半还是奇数加减一)都可能需要进一步的操作才能变为1,因此自然而然地引出了递归的解题思路。
②、引入记忆搜索化: 直接的递归实现可能会面临重复计算的问题。例如,在处理n=8时,我们需要知道将4变为1的最小次数;而在处理n=4时,我们同样需要这个信息。这种重复的子问题使得直接的递归效率不高,因为相同的计算会被重复多次。
为了解决这个问题,可以使用记忆化搜索优化递归,利用一个额外的数据结构(通常是哈希表或数组)来存储已经计算过的子问题的结果。当递归过程中再次遇到相同的子问题时,可以直接从存储的结果中读取,而无需重新计算。
2)、题解
PS:在本题中,不加记忆搜索化,直接使用递归解题,也能通过(因为偶数/2操作,一次减半数据)
class Solution {
public:
unordered_map<long long,long long> hash;// 记忆搜索化的备忘录:key表示数n,value表示操作次数
int integerReplacement(long long n) {
if(hash.count(n)) return hash[n];// 备忘录中有值,直接返回
// 备忘录中无值,模拟:
if(n == 1)// 递归结束条件
{
hash[1] = 0;
return 0;
}
if(n % 2)// 奇数的情况
hash[n] = min(integerReplacement(n - 1),integerReplacement(n + 1)) + 1;// 此处n+1会溢出
else // 偶数的情况
hash[n] = integerReplacement(n / 2) + 1;
return hash[n];
}
};
25.2、贪心
1)、思路分析
分析题目可知:①对于偶数,只有一种操作(/2
);②对于奇数,有两种操作(+1
、-1
)。我们的任何选择,应该让这个数尽可能快的变成1,因此对奇数如何操作才能使决策最优,就是贪心需要解决的问题。
这里,我们需要深入到数的二进制位分析,二进制表示的数有如下几个特点:
1、偶数:在二进制表示中,最后一位为0;
2、奇数:在二进制表示中,最后一位为1;
3、/2 操作:在二进制表示中,统一右移一位
我们需要重点分析奇数的情况,因为偶数只能进行一种操作(/2
),而奇数有两种选择(+1
、-1
)。
对一个奇数,其二进制表示,结尾可以是 0bxxx01 ,或者 0bxxx11 。
当 n == 1
时:不需要进行任何操作,返回 0
当 n == 3
时:最优操作是先将 3 减 1 变成 2,再除以 2 变成 1,共需 2 次操作。
当 n > 1
且 n % 4 == 1
时:此时 n 的二进制表示以 ...01
结尾。最优策略是选择-1
,这样可以去掉末尾的 1,使得接下来的除法操作能更快地使 n 减小到 1。
当 n > 3
且 n % 4 == 3
时:此时 n 的二进制表示以 ...11
结尾。最优策略是选择+1
,这样可以将末尾的连续 1 转换成 0(或者通过进位影响前面的位),从而更快地通过除法操作使 n 减小到 1。
2)、题解
要思考出这种贪心策略,需要对数的性质与运用融会贯通(从上述题解可知,其解题思路是深入到二进制位进行分析的)
class Solution {
public:
int integerReplacement(long long n) {
long long count = 0; // 统计操作次数
while (n > 1) // 分类讨论
{
// 判断奇偶数
if (n % 2) // 奇数:分情况选择最优决策
{
if(n == 3) n -= 1;// 注意这里,要先把这种情况挑出来,不然 n % 4 == 3 会进入 n += 1的决策
else if(n % 4 == 1)// 说明是"01"的情况
n -= 1;
else // 说明是"11"的情况
n += 1;
}
else // 偶数:只能执行/操作
n /= 2;
++count;// 完成一次操作,次数+1
}
return count;
}
};
在判断奇数的操作时,还可以更简化一些,直接执行两步操作:
class Solution {
public:
int integerReplacement(int n) {
int count = 0; // 统计操作次数
while (n > 1) // 分类讨论
{
// 判断奇偶数
if (n % 2) // 奇数:分情况选择最优决策
{
if (n == 3) // 注意这里,要先把这种情况挑出来:执行-1、/2 操作
n = 1; // 直接改值
else if (n % 4 == 1) // 说明是"01"的情况:执行-1、/2 操作
n /= 2; // 本应该是 n = (n - 1) / 2,但因为/运算向零取值,故可直接写为 n = n / 2
else // 说明是"11"的情况:执行+1、/2 操作
n = n / 2 + 1; // 本该是 n = (n +1)/2,因为+1数据会溢出,由于是奇数,这里这样写也行
count +=2; // 上述操作均走了两步,统一把count放在条件判断外处理(也可以放到每个条件中)
}
else // 偶数:只能执行/操作
{
n /= 2;
++count; // 完成一次操作,次数+1
}
}
return count;
}
};
说明一下C++中,表达式 n = (n + 1) / 2
和 n = n / 2 + 1
,这两个表达式在 n 为偶数时,显然结果不同。但在 n 为奇数时,它们的结果会相同,因为整数除法的特性导致 (n + 1) / 2 总是产生 (n / 2) + 0.5 的整数部分(即 n / 2 的结果),然后 n / 2 + 1 直接在 n / 2 的结果上加 1。
假设 n = 5(奇数):
n = (5 + 1) / 2 结果为 3(因为 (5 + 1) = 6,6 / 2 = 3)。
n = 5 / 2 + 1 结果也为 3(因为 5 / 2 = 2,2 + 1 = 3)。
假设 n = 4(偶数):
n = (4 + 1) / 2 结果为 2(因为 (4 + 1) = 5,5 / 2 = 2)。
n = 4 / 2 + 1 结果为 3(因为 4 / 2 = 2,2 + 1 = 3)。
26、俄罗斯套娃信封问题(hard)
题源:链接。
26.1、动态规划(常规解法/通用解法)
1)、前请说明
说明1: 本题在排序之后,问题就转换为了“最长递增子序列”的模型。此处“动态规划”和“贪心+二分”的解题思路,就是建立在那道题的基础上的,建议先温故学习一下当时的解题方法。
说明2: 动态规划的解法时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),在本题中会超时,但这种解法具有普遍性,在其它类似题型中可以使用(面试题 08.13. 堆箱子)。
2)、思路分析
本题中,由于数组元素乱序,且能否套娃由信封的宽度和高度两个条件共同决定,直接求解,找信封会比较困难。因此,我们先对数组元素按照左端点(信封宽度)进行排序,这样一来,在判断第 i
个信封能否装下前面[0,i-1]
中的信封时,我们只需关注高度是否满足递增的条件,因为宽度已经保证了是递增的。
由此,可用动态规划解题(思路和最长递增子序列那题一致)
1、确定状态表示: dp[i]
,表示以 i
位置的信封为结尾的所有套娃序列中,最长的套娃序列的长度(最大套娃信封数量)。
2、推导状态转移方程:
当envelopes[i][0] > envelopes[j][0] && envelopes[i][1] > envelopes[j][1]时,有:
dp[i] = max(dp[j] + 1),其中,0 <= j < i
3、初始化: dp[i] = 1
,表示单独一个信封时,数量为1。
4、填表顺序: 从左到右。
5、返回值: 整个dp表中的最⼤值。
3)、题解
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
// 0、预处理:排序
sort(envelopes.begin(),envelopes.end());
// 1、创建dp表并初始化
int n = envelopes.size();
vector<int> dp(n,1);
// 2、填表
int ret = 1;// 返回值
for(int i = 1; i < n; ++i)
{
for(int j = 0; j < i; ++j)
{
if(envelopes[i][0] > envelopes[j][0] && envelopes[i][1] > envelopes[j][1])
dp[i] = max(dp[i],dp[j] + 1);
}
ret = max(ret,dp[i]);
}
// 3、返回
return ret;
}
};
26.2、贪心+二分查找
1)、思路分析
“最长递增子序列”题可以将动态规划改为用“贪心+二分”解题,同理,本题也一样(建议先回顾当时解题的思路)。
相比于动态规划中直接使用“左端点”进行排序,在贪心解法中,需要重写排序。
原因说明:
1)、若只以左端点(信封的宽度)排序,那么当面对宽度相同但高度不同的信封时,我们就无法做出有效的选择。
比如,排序后得[2,3]、[4,1]、[7,8]、[10,12]、[11,3]
,数组的左端点是严格单调递增的。此时完全可以忽略左端点,对右端点仿照“最长递增子序列”中“贪心+二分”的思路进行解题。题目就转化成了:在 {3、1、8、12、3}
中,挑一个最长递增子序列。
2)、但本题中,左右端点均可能出现相同元素值,这时候,若仅仅只以“左端点”排序,是行不通的。
比如,排序后得 [2,3]、[2,4]、[2,6]、[2,7]、[2,9]
。忽视左端点,在右端点序列{3、4、6、7、9}
中,挑一个最长递增子序列,这会得到错误的结果,因为此时左端点是不满足套娃信封的条件的。
3)、因此,我们不能只以左端点排序,而需要重新考虑排序。那么可以如何排序呢?
A、左端点不同的时候:按照“左端点从小到大”排序;
B、左端点相同的时候:按照“右端点从大到小”排序。
按照这个规则,上述[2,3]、[2,4]、[2,6]、[2,7]、[2,9]
,排序后得 [2,9]、[2,7]、[2,6]、[2,4]、[2,3]
。这样一来,按照“贪心+二分”思路,在右端点序列{9、7、6、4、3}
挑一个最长递增子序列,选出的最长子序列长度就是正确的。
余下的,就是“最长递增子序列”中,“贪心+二分”的解题思想,这里不再赘叙。
2)、题解
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
// 0、预处理:重写排序
sort(envelopes.begin(),envelopes.end(),[&](vector<int>& v1, vector<int>& v2)
{
// 当左端点不同时,按照左端点从小到大顺序
// 当左端点相同时,按照右端点从大到小排序
return v1[0] != v2[0] ? v1[0] < v2[0] : v1[1] > v2[1];
});
// 2、贪心 + 二分
vector<int> ret;// 辅助数组:用于记录右端点,即高度
ret.push_back(envelopes[0][1]);// 先将首个元素的右端点存入
for(int i = 1; i < envelopes.size(); ++i)// 遍历信封,找合适的高度(右端点),挑选出最长序列
{
int cur_h = envelopes[i][1];// 当前信封的高度(待判断元素)
if(cur_h > ret.back())
ret.push_back(cur_h);
else // 二分查找,找合适位置
{
int left = 0, right = ret.size();
while(left < right)
{
int mid = left + (right - left )/ 2;
if(cur_h > ret[mid]) left = mid + 1;
else right = mid;
}
ret[right] = cur_h;// 找到位置,放值
}
}
// 3、返回
return ret.size();
}
};
27、可被三整除的最大和(medium)
题源:链接。
27.1、贪心
1)、思路分析
正难则反: 直接考虑一个一个数累加判断比较麻烦。我们可以先求出数组中所有元素的总和,然后根据总和的余数情况,贪心地删除一些数来使剩余的和能被三整除。
分类讨论:
设累加和为sum
,用x
标记数组中%3 == 1
的元素,用y
标记数组中%3 == 2
的元素。根据 sum 的余数进行分类:
1、如果 sum % 3 == 0
,则所有元素的和已经满足能被三整除的条件,此时无需删除任何数,直接返回 sum。
2、如果 sum % 3 == 1
,这意味着我们需要减少 sum 的值,使其变为能被三整除的数。此时有两种选择:
①、删除一个余数为 1 的数(1 % 3 = 1
,贪心的最优选择是,删除 x 中最小的数,记为 x1
)。
②、删除两个余数为 2 的数(2+2 = 4,4 % 3 = 1
,贪心的最优选择是,删除 y 中最小和次小的数,记为 y1
和 y2
)。
选择这两种情况中的最大值,即 max(sum - x1, sum - y1 - y2)
。
3、如果 sum % 3 == 2
,同样需要减少 sum 的值。此时有两种选择:
①、删除一个余数为 2 的数( 2 % 3 = 2
,贪心的最优选择是,删除 y 中最小的数,记为 y1
)。
②、删除两个余数为 1 的数(1+1 = 2,2 % 3 = 2
,贪心的最优选择是,删除 x 中最小和次小的数,记为 x1
和 x2
)。
选择这两种情况中的最大值,即 max(sum - y1, sum - x1 - x2)
。
可以看到,上述两者情况均需要找%3 == 1
、%3 == 2
的最小值和次小值,那么,如何求一堆数中的最小值以及次小值?(同理,如何求一堆数中的最大值和次大值?)
方法一: 在C++中,可以使用sort排序后求解。这种方式,时间复杂度是
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)
方法二: 设最小数为x1
, 次小数为x2
,遍历数组元素求解。这种方式,时间复杂度为
O
(
n
)
O(n)
O(n)
①若 x < x1
,则有 x2 = x1, x1 = x
(注意这里赋值顺序不能颠倒)。
②若 x1 <= x <= x2
(等号可取可不取),则有 x2 = x
。
2)、题解
class Solution {
public:
int maxSumDivThree(vector<int>& nums) {
int sum = 0;// 统计元素和
const int INF = 0x3f3f3f3f;// 定义无穷大:因为下述要做运算,直接使用INT_MAX,容易溢出
int x1 = INF, x2 = INF;// 记录 % 3 == 1 的数中的最小值和次小值
int y1 = INF, y2 = INF;// 记录 % 3 == 2 的数中的最小值和次小值
// 遍历一遍数组:
for(auto n : nums)
{
sum += n;
if(n % 3 == 1)
{
if(n < x1)
x2 = x1, x1 = n;
else if(n < x2)
x2 = n;
}
if(n % 3 == 2)
{
if(n < y1)
y2 = y1,y1 = n;
else if(n < y2)
y2 = n;
}
}
// 分情况讨论:
if(sum % 3 == 0)
return sum;
else if(sum % 3 == 1)
return max(sum - x1, sum - y1 - y2);
else if(sum % 3 == 2)
return max(sum - y1, sum - x1 - x2);
return 0;
}
};
28、 距离相等的条形码(medium)
题源:链接。
28.1、贪心+模拟
1)、思路分析
根据题目,要使任意两个相邻的数不能相等,关键在于将相同的数间隔放置。因此,贪心策略为:
a、每次处理一批相同的数。
b、摆放的时候,每次隔一个格子。
c、优先处理出现次数最多的那个数,剩下的数的处理顺序无所谓。
以下为该策略的证明:
2)、题解
时间复杂度整体为
O
(
n
)
O(n)
O(n)
class Solution {
public:
vector<int> rearrangeBarcodes(vector<int>& barcodes) {
unordered_map<int,int> hash;// 用于记录数及其出现次数
int max_value = 0;// 用于标记出现最多的那个数
int max_count = 0;// 用于标记出现最多的那个数的次数
// 遍历数组一遍,统计
for(auto x : barcodes)
{
hash[x]++;
if(hash[x] > max_count)// 有更大频次的数出现,更新标记值
{
max_count = hash[x];
max_value = x;
}
}
// 填数
int n = barcodes.size();
vector<int> ret(n,0);
int pos = 0;// 用于标记待填入的下标位置
// a、先填偶数位(注意:题目有解,保证了 max_count < (n+1)/2 )
for(int i = 0; i < max_count; ++i)// 一共放max_count次
{
ret[pos] = max_value;
pos += 2;// 下一个位置
}
hash.erase(max_value);// 删除摆放过的这个最大数
// b、填剩余数
for(auto& [value,count] : hash)
{
for(int i = 0; i < count; ++i)
{
if(pos >= n)// 偶数位摆放完,将pos重置到1,从奇数位开始填数
pos = 1;
ret[pos] = value;
pos += 2;
}
}
// 返回
return ret;
}
};
29、 重构字符串(medium)
题源:链接
29.1、贪心
1)、思路分析
分析题目可知,此题与上一道题思路一致。只是区别在于,本题不保证有解,因此,我们统计出“出现次数最多的字符”的数量时,需要判断一下是否满足 count < (n+1)/2
的条件,若不满足,就是无解的情况,直接返回空串即可。
2)、题解
使用unordered_map作为哈希表:
class Solution {
public:
string reorganizeString(string s) {
// 1、遍历字符串,统计
unordered_map<char, int> hash; // key:字符, value:该字符出现的次数
char max_ch = ' '; // 标记出现次数最多的那个字符
int max_count = 0;// 标记出现次数最多的那个字符的出现频次
for(auto ch : s)
{
if(max_count < ++hash[ch])// 说明出现了更大频次的字符,需要更新标记位
{
max_count = hash[ch];
max_ch = ch;
}
}
// 2、判断是否有解
int n = s.size();
if(max_count > (n + 1) / 2) return "";// 无解
// 3、摆放字符:隔一个位置,放置一个字符
string ret(n,' ');// 返回值
int pos = 0;// 标记放置字符的下标
// 3.1、放置出现次数最多的字符
for(int i = 0; i < max_count; ++i)
{
ret[pos] = max_ch;
pos += 2;
}
hash.erase(max_ch);
// 3.2、放置剩余字符
for(auto& [ch, count] : hash)
{
for(int i = 0; i < count; ++i)
{
if(pos >= n) pos = 1;
ret[pos] = ch;
pos += 2;
}
}
// 4、返回
return ret;
}
};
使用数组作为哈希表的写法:只做了很小的改动。
class Solution {
public:
string reorganizeString(string s) {
// 1、遍历字符串,统计
int hash[26]; // s 只包含小写字母,直接使用数组作为哈希表
char max_ch = ' '; // 标记出现次数最多的那个字符
int max_count = 0;// 标记出现次数最多的那个字符的出现频次
for(auto ch : s)
{
if(max_count < ++hash[ch - 'a'])// 说明出现了更大频次的字符,需要更新标记位
{
max_count = hash[ch - 'a'];
max_ch = ch;
}
}
// 2、判断是否有解
int n = s.size();
if(max_count > (n + 1) / 2) return "";// 无解
// 3、摆放字符:隔一个位置,放置一个字符
string ret(n,' ');// 返回值
int pos = 0;// 标记放置字符的下标
// 3.1、放置出现次数最多的字符
for(int i = 0; i < max_count; ++i)
{
ret[pos] = max_ch;
pos += 2;
}
hash[max_ch - 'a'] = 0;
// 3.2、放置剩余字符
for(int j = 0; j < 26; ++j)// 遍历26个字符
{
for(int i = 0; i < hash[j]; ++i)// 若当前字符存在数,则放置
{
if(pos >= n) pos = 1;
ret[pos] = j + 'a';
pos += 2;
}
}
// 4、返回
return ret;
}
};