文章目录
-
- 在数学与几何的迷宫中,洞悉最优路径
- [一、 前言:贪心的高阶形态](#一、 前言:贪心的高阶形态)
- [二、 坏了的计算器:逆向思维的降维打击 (Medium)](#二、 坏了的计算器:逆向思维的降维打击 (Medium))
-
- [2.1 题目描述](#2.1 题目描述)
- [2.2 贪心策略与数学证明](#2.2 贪心策略与数学证明)
- [2.3 C++ 代码实战](#2.3 C++ 代码实战)
- [三、 合并区间:贪心求并集 (Medium)](#三、 合并区间:贪心求并集 (Medium))
-
- [3.1 题目描述](#3.1 题目描述)
- [3.2 贪心策略与几何证明](#3.2 贪心策略与几何证明)
- [3.3 C++ 代码实战](#3.3 C++ 代码实战)
- [四、 无重叠区间:保留生存空间 (Medium)](#四、 无重叠区间:保留生存空间 (Medium))
-
- [4.1 题目描述](#4.1 题目描述)
- [4.2 贪心策略与留白证明(面试极易错)](#4.2 贪心策略与留白证明(面试极易错))
- [4.3 C++ 代码实战](#4.3 C++ 代码实战)
- [五、 用最少数量的箭引爆气球 (Medium)](#五、 用最少数量的箭引爆气球 (Medium))
-
- [5.1 题目描述](#5.1 题目描述)
- [5.2 贪心策略与交集下界证明](#5.2 贪心策略与交集下界证明)
- [5.3 C++ 代码实战](#5.3 C++ 代码实战)
- [六、 整数替换:二进制掩码的最优消除 (Medium)](#六、 整数替换:二进制掩码的最优消除 (Medium))
-
- [6.1 题目描述](#6.1 题目描述)
- [6.2 贪心策略与二进制证明](#6.2 贪心策略与二进制证明)
- [6.3 C++ 代码实战](#6.3 C++ 代码实战)
- [七、 总结](#七、 总结)
在数学与几何的迷宫中,洞悉最优路径
一、 前言:贪心的高阶形态
💬 开篇 :经过前面几篇的淬炼,我们已经掌握了单调区间、极值分类以及双指针贪心。今天,我们将步入贪心算法最经典、也是面试最常考的两大领域:逆向推导 与 区间重叠。
🚀 核心破局点:
- 正难则反 :当正向操作的抉择树呈现指数级爆炸时,逆向操作往往具有数学上的唯一确定性。
- 区间的吞噬与避让:面对一堆错综复杂的区间,排序是第一把利器;而决定是去"吞噬(求并集)"还是"避让(求交集最小右端点)",则需要严格的贪心逻辑。
💡 学习指南 :
本篇精选了 5 道高频题目。在"无重叠区间"和"合并区间"中,我将重点为你辨析为什么一个是求最大右端点,另一个是求最小右端点。摒弃死记硬背,让我们用数学证明来主宰代码!
二、 坏了的计算器:逆向思维的降维打击 (Medium)
2.1 题目描述
题目链接 :991. 坏了的计算器
描述 :
初始数字为
startValue,目标为target。你只能执行两种操作:
- 乘 2(双倍)
- 减 1(递减)
求将其变为target的最少操作次数。
2.2 贪心策略与数学证明
贪心策略(正难则反) :
正向思维(start -> target)时,我们在任何一个奇数或偶数节点,都可以选择 *2 或 -1,这棵状态树是无限发散的。
逆向思维(target -> start) 时,操作变成了 /2 和 +1。此时策略具备了唯一确定性:
- 如果当前
target是奇数,只能 执行+1操作使其变偶数。 - 如果当前
target是偶数,必定 执行/2操作。 - 当
target <= start时,只能无脑+1,操作次数直接加上差值。
严格数学证明 :
假设当前目标为偶数 T T T。我们在逆向时,究竟是选择直接 /2,还是选择 +1, +1, /2?
- 方案A(先除) :操作为
/2再+1,结果变为 T 2 + 1 \frac{T}{2} + 1 2T+1,耗费 2 步。 - 方案B(先加) :操作为
+1再+1,然后/2,结果变为 T + 2 2 = T 2 + 1 \frac{T+2}{2} = \frac{T}{2} + 1 2T+2=2T+1,耗费 3 步。
结论:在达到相同数值结果的前提下,方案A(遇到偶数立即除以 2)永远比方案B少走 1 步!因此,只要是偶数,毫不犹豫地除以 2 是绝对的全局最优解。
2.3 C++ 代码实战
cpp
class Solution {
public:
int brokenCalc(int startValue, int target) {
int ret = 0;
// 逆向推导:从 target 回退到 startValue
while (target > startValue) {
if (target % 2 == 0) {
// 如果是偶数,最优策略绝对是除以 2
target /= 2;
} else {
// 如果是奇数,只能加 1 变成偶数
target += 1;
}
ret++;
}
// 当 target <= startValue 时,只能靠一步步加 1 弥补差距
return ret + (startValue - target);
}
};
三、 合并区间:贪心求并集 (Medium)
3.1 题目描述
题目链接 :56. 合并区间
描述 :
给定若干个区间,合并所有重叠的区间,返回一个不重叠的区间数组。
3.2 贪心策略与几何证明
贪心策略:
- 先将所有区间按照左端点从小到大排序。
- 遍历区间,维护当前合并块的左右边界
[left, right]。 - 若新区间的左端点 ≤ \le ≤ 当前
right,说明有重叠,此时贪心地拓展右边界:right = max(right, 新区间的右端点)。 - 若新区间的左端点 > > > 当前
right,说明断开了,将旧块存入结果,开启新块。
严格证明 :
排序后,对于任意三个区间 I 1 , I 2 , I 3 I_1, I_2, I_3 I1,I2,I3,其左端点满足 L 1 ≤ L 2 ≤ L 3 L_1 \le L_2 \le L_3 L1≤L2≤L3。
如果 I 1 I_1 I1 与 I 2 I_2 I2 重叠(即 L 2 ≤ R 1 L_2 \le R_1 L2≤R1),那么它们合并后的新右端点为 R n e w = max ( R 1 , R 2 ) R_{new} = \max(R_1, R_2) Rnew=max(R1,R2)。
接下来考察 I 3 I_3 I3:由于已经排序,我们确信 L 3 L_3 L3 绝不可能跑到 L 1 L_1 L1 前面去。所以 I 3 I_3 I3 是否能融入前面的大团体,仅仅取决于 L 3 L_3 L3 是否 ≤ R n e w \le R_{new} ≤Rnew。
这证明了,我们只需在一条直线上贪心地维护"目前所能延伸到的最远右端点",无需回头去管左端点,就能不漏掉任何一个重叠块。
3.3 C++ 代码实战
cpp
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
// 1. 按照左端点进行升序排序
sort(intervals.begin(), intervals.end());
vector<vector<int>> ret;
// 2. 初始化第一个合并块的边界
int left = intervals[0][0];
int right = intervals[0][1];
for (int i = 1; i < intervals.size(); i++) {
int a = intervals[i][0];
int b = intervals[i][1];
if (a <= right) {
// 有重叠,贪心求并集:谁的右边界大就听谁的
right = max(right, b);
} else {
// 彻底断开,结算上一个块
ret.push_back({left, right});
// 开启新的征程
left = a;
right = b;
}
}
// 循环结束时,最后一个块还在手里,记得交上去
ret.push_back({left, right});
return ret;
}
};
四、 无重叠区间:保留生存空间 (Medium)
4.1 题目描述
题目链接 :435. 无重叠区间
描述 :
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
4.2 贪心策略与留白证明(面试极易错)
这题与上一题极其相似,但核心逻辑完全相反 !
上一题求合并,我们希望右边界 right 越大 越好,以吞噬更多区间。
这题求保留最多不重叠区间,发生冲突时,我们希望右边界 right 越小越好!
贪心策略:
- 同样按左端点排序。
- 若遇到重叠区间,贪心地保留右端点较小的那个 (即令
right = min(right, 当前右边界)),并把另一个大的删掉(计数器 +1)。
严格数学证明(替换论证) :
假设区间 A A A 和区间 B B B 发生重叠,且 R A < R B R_A < R_B RA<RB。由于必须删掉一个,我们删谁?
假设存在某种最优解 S o p t S_{opt} Sopt,它保留了跨度极广的 B B B,而删除了 A A A。
由于 R A < R B R_A < R_B RA<RB, A A A 占据的时间段是 B B B 占据时间段的"更早结束版"。
如果我们在 S o p t S_{opt} Sopt 中强行用 A A A 替换掉 B B B。因为 A A A 结束得比 B B B 更早,它为后续区间留出了更大的空闲时间(更大的生存空间) 。
原本能跟在 B B B 后面的合法区间,必定也能跟在 A A A 后面。替换后绝不会导致已有后续区间的冲突。
结论:保留右端点小的区间,是绝对的占优策略。
生活常识辅助 :
今天有两个会议时间冲突了,一个开到中午 12 点,一个要开到晚上 8 点。你为了下午能安排更多别的会议,肯定果断取消那个开到晚上 8 点的冗长会议。
4.3 C++ 代码实战
cpp
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) return 0;
// 1. 依然按照左端点排序
sort(intervals.begin(), intervals.end());
int ret = 0; // 记录被无情裁掉的区间数量
int right = intervals[0][1]; // 记录当前安全区的右边界
for (int i = 1; i < intervals.size(); i++) {
int a = intervals[i][0];
int b = intervals[i][1];
if (a < right) {
// 冲突了!必须干掉一个。
ret++;
// 贪心核心:干掉右端点大的,保留右端点小的!为后面的兄弟腾出空间!
right = min(right, b);
} else {
// 没有冲突,天下太平,边界平移到当前区间的末尾
right = b;
}
}
return ret;
}
};
五、 用最少数量的箭引爆气球 (Medium)
5.1 题目描述
题目链接 :452. 用最少数量的箭引爆气球
描述 :
气球在 X 轴上占据一定区间。一支箭从某处垂直射上天,能射爆该位置经过的所有气球。求引爆所有气球最少的箭数。
5.2 贪心策略与交集下界证明
这题其实就是求交集 。
我们要发挥一支箭的极致性价比,就必须让它穿过尽可能多气球的"公共交集"。
贪心策略:
- 按左端点排序。
- 维护一组气球的公共交集右边界
right。 - 只要新气球的左端点 ≤ \le ≤
right,说明它可以被上一支箭顺带射爆,此时更新公共交集:right = min(right, 新气球右端点)。 - 如果新气球的左端点 > > >
right,说明彻底够不着了,必须消耗一支新箭!
严格数学证明 :
设当前正在考量的一组重叠气球的公共交集为 [ L c o m m o n , R c o m m o n ] [L_{common}, R_{common}] [Lcommon,Rcommon]。
一支箭要想射爆这一组所有气球,射击点 X X X 必须满足 X ≤ R c o m m o n X \le R_{common} X≤Rcommon。
当加入一个新气球 I k = [ L k , R k ] I_k = [L_k, R_k] Ik=[Lk,Rk] 时,如果 L k ≤ R c o m m o n L_k \le R_{common} Lk≤Rcommon,说明它与当前组有交集,新的公共交集必然被进一步压缩,右边界更新为 min ( R c o m m o n , R k ) \min(R_{common}, R_k) min(Rcommon,Rk)。
这确保了只要我们还没超出最小的那个右边界,我们就依然有"一箭多雕"的余地。一旦新气球的起点跨过了这个极限边界,就从几何物理上证明了不存在任何一个 X X X 能同时贯穿它们,必须追加一支箭。
5.3 C++ 代码实战
cpp
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.empty()) return 0;
// 1. 按照左端点排序
sort(points.begin(), points.end());
int ret = 1; // 至少需要 1 支箭
int right = points[0][1]; // 记录当前能"一箭多雕"的极限右边界
for (int i = 1; i < points.size(); i++) {
int a = points[i][0];
int b = points[i][1];
if (a <= right) {
// 有重叠,说明这只箭还能顺带射爆这个新气球!
// 但是!为了确保箭能射中所有涉及的气球,公共射击区域变窄了
right = min(right, b);
} else {
// 够不着了,之前的气球必须射爆,消耗一支新箭,并更新射击边界
ret++;
right = b;
}
}
return ret;
}
};
六、 整数替换:二进制掩码的最优消除 (Medium)
6.1 题目描述
题目链接 :397. 整数替换
描述 :
偶数:变为 n / 2 n / 2 n/2。
奇数:变为 n + 1 n + 1 n+1 或 n − 1 n - 1 n−1。
求变到 1 的最小次数。
6.2 贪心策略与二进制证明
这题如果用递归加备忘录也能做,但贪心法 可以做到极致的 O ( log n ) O(\log n) O(logn) 且不需要额外空间。
贪心策略 :
目标是让数变小,除以 2(右移一位)降维最快。所以我们要尽可能创造偶数,确切地说,我们要创造能被 4 整除的偶数 (即二进制末尾为 00),因为这样能连除两次!
对于奇数,它的二进制末尾必然是 1:
- 情形一:末尾是
01(即 n n % 4 == 1 n)
我们减 1,末尾变成00,接下来可以直接右移两次。 - 情形二:末尾是
11(即 n n % 4 == 3 n)
我们加 1,发生进位!末尾11变成100(甚至带来连锁进位消去更多的 1)。这显然比减 1 变成10更划算。 - 特例:数字 3
3 的二进制是11。如果加 1 变 4,然后 2 然后 1(需 3 步)。如果减 1 变 2,然后 1(只需 2 步)。所以 3 是唯一的例外,应直接减 1。
严格数学证明 :
对于后缀为 ...01 的奇数:
- 若 + 1 ⟹ ... 10 +1 \implies \dots 10 +1⟹...10,除以 2 后得 ... 1 \dots 1 ...1(又是奇数)。
- 若 − 1 ⟹ ... 00 -1 \implies \dots 00 −1⟹...00,除以 2 后得 ... 0 \dots 0 ...0(还是偶数,可继续除以 2)。
显然 − 1 -1 −1 占优。
对于后缀为...11的奇数: - 若 + 1 ⟹ ... 00 +1 \implies \dots 00 +1⟹...00 甚至引发更高位进位(将一连串的 1 化为 0),极大加速了位数下降。
- 若 − 1 ⟹ ... 10 -1 \implies \dots 10 −1⟹...10,除以 2 后得 ... 1 \dots 1 ...1。
显然 + 1 +1 +1 占优(除边界值 3 以外)。
6.3 C++ 代码实战
(注意:当 n 为 2 31 − 1 2^{31}-1 231−1 时,加 1 会发生整型溢出,需要用 long long 强转,或者除法时做变通)
cpp
class Solution {
public:
int integerReplacement(int n) {
long long num = n; // 防止极限用例 2147483647 加 1 导致溢出
int ret = 0;
while (num > 1) {
if (num % 2 == 0) {
// 偶数无脑除以 2
num /= 2;
ret++;
} else {
// 奇数分类讨论
if (num == 3) {
// 特判 3
num = 1;
ret += 2; // 3 -> 2 -> 1
} else if (num % 4 == 1) {
// 二进制末尾为 01,减 1 更优
num /= 2; // 相当于 num-1 后除以 2
ret += 2; // 包含了减 1 和除以 2 两个步骤
} else {
// 二进制末尾为 11,加 1 引发进位更优
num = num / 2 + 1; // 相当于 num+1 后除以 2
ret += 2;
}
}
}
return ret;
}
};
七、 总结
💬 复盘 :区间覆盖和数学位运算,是贪心算法中最能体现代码精简之美的领域。
我们总结出了两套绝对经典的结论:
- 区间求并集(合并) :贪心拓展,维护最大右端点。
- 区间求交集/求留白(无重叠、引爆气球) :贪心收缩,维护最小右端点。
下一篇,我们将迎来贪心专题的最终章(Part 6) 。
我们将迎战俄罗斯套娃信封、被三整除的最大和、距离相等的条形码等终极 Boss 题,见证二维排序降维打击和哈希构造的绝妙手法。千万别错过这收官之战! 🚀