Day2【算法进阶】贪心算法
【算法进阶】从 C 到 C++:深度解析“贪心算法”与 STL 实战细节
前言:为什么学算法要先学“贪心”?
作为一名计算机科学专业的学生,尤其是未来志在神经网络(Neural Networks)方向的同学,贪心算法(Greedy Algorithm) 是你必须跨越的第一座大山。
为什么?因为深度学习的核心——梯度下降(Gradient Descent),本质上就是一种贪心策略:在每一步参数更新时,都沿着当前梯度下降最快的方向走,试图找到全局损失函数的最低点。
本文将基于 C 语言基础,结合 C++ 的强大工具(STL),深度剖析三类经典贪心模型,并着重解决代码实现中的逻辑陷阱。
第一章:贪心算法的灵魂——区间选点问题
1.1 核心思想
贪心算法的核心在于**“目光短浅”**。
- 定义:在每一步选择中,只采取当前看起来最优的策略(局部最优),而不考虑这一步对未来的长远影响。
- 关键:通过无数个“局部最优”的累积,最终达到“全局最优”。
- 注意:贪心并不适用于所有问题(如下棋),但在区间调度、资源分配等问题上是标准解法。
1.2 例题:区间选点
题目:给定 N 个闭区间 [a_i, b_i],选最少的点,使得每个区间内至少包含一个点。
策略深度解析:
我们需要对区间进行排序。但怎么排?
- 按开始时间排?❌(如果一个长区间覆盖了后面很多短区间,选开始点并不划算)。
- 按区间长度排?❌(短区间可能分布在互不相干的位置)。
- 按结束时间(右端点)从小到大排?✅
为什么是右端点?
想象你在处理任务。如果你优先处理结束最早的任务,并且在它的最后一刻(右端点)去选点,那么这个点的位置最靠后。
点越靠后,它“够得着”下一个区间开始部分的概率就越大。 这就是局部最优:让当前的付出产生最大的潜在覆盖范围。
1.3 C++ 代码实现细节
相比 C 语言的手写 qsort,C++ 提供了更优雅的方案:
struct Interval { int l, r; };
// 比较器:告诉 sort 函数按右端点(r) 升序排列
bool cmp(Interval a, Interval b) {
return a.r < b.r;
}
// 核心逻辑
sort(v.begin(), v.end(), cmp); // 1. 排序
int cnt = 0;
int end = -2e9; // 初始化为一个极小值
for(auto &x : v) { // C++ range-based for 循环
// 如果当前区间的左端点 > 上一个选点的覆盖范围
// 说明上一个点“够不着”这个区间了,必须选新点
if(x.l > end) {
cnt++; // 选一个新点
end = x.r; // 贪心:新点选在当前区间的尽头
}
}
第二章:流式数据的极值维护——股票买卖问题
2.1 题目逻辑
给定一个价格数组,只能买一次、卖一次,求最大利润。
例如:[7, 1, 5, 3, 6, 4]。
最大利润是 6 - 1 = 5。
2.2 贪心策略
我们在遍历数组时(模拟时间流逝),脑子里只需要想两件事:
- 抄底:这个价格是不是我见过的最低价?如果是,记下来。
- 套现:如果现在卖出,能赚多少?是不是比以前算出的最大利润还高?
2.3 💡 深度疑难解析:互斥逻辑 (if ... else if)
这是初学者最容易纠结的地方。请看代码:
int min_price = INT_MAX; // 历史最低价
int max_profit = 0; // 历史最大利润
for (int price : prices) {
// 逻辑 A:尝试更新最低价
if (price < min_price) {
min_price = price;
}
// 逻辑 B:尝试更新最大利润
else if (price - min_price > max_profit) {
max_profit = price - min_price;
}
}
🤔 你的疑问:
“这两个条件是只要满足就可以走吗?还是只能走二者其中一个?用 else if 会不会漏掉情况?”
👨🏫 导师解答:
这里必须(或者说最好)使用 else if,意味着如果满足了 A,就绝对不走 B。原因极其深刻:
场景模拟:假设今天是第 i 天,股价暴跌,创下了历史新低(满足 price < min_price)。
推导:既然 price 就是新的 min_price,那么如果我们强行计算当天的利润:
\text{Profit} = \text{Current Price} - \text{New Min Price} = 0
结论:利润为 0。而我们的 max_profit 初始化至少是 0。0 永远不可能大于 max_profit(除非你在做亏本生意,但本题不考虑)。
性能优化:既然我们从逻辑上确定了“创新低的那天绝不可能创新高”,那么用 else if 跳过第二次判断,能减少 CPU 的无效计算。
一句话总结:你不可能在同一天既“买在最低点”又“卖出赚大钱”。
第三章:字符串操作与数学直觉——最大奇数问题
3.1 题目逻辑
在字符串 num 中找到最大的奇数子字符串。
例如:"35426" -> 最大奇数是 "35"(而不是 5 或 3)。
3.2 贪心策略
- 数学规律:一个数是不是奇数,只看最后一位。
- 大小规律:一个数想要最大,高位(左边)保留得越多越好。
- 结合:我们不需要切掉左边的数字,只需要从右边开始切,直到切到某个位置是奇数为止。保留这个位置及其左边的所有数字。
3.3 💡 深度疑难解析:下标与长度 (Index vs Length)
string largestOddNumber(string num) {
int n = num.length();
// 倒序遍历:从最后一位往前找
for (int i = n - 1; i >= 0; i--) {
int digit = num[i] - '0'; // 字符转整数
if (digit % 2 != 0) {
// 找到奇数了!直接截取
return num.substr(0, i + 1);
}
}
return "";
}
🤔 你的疑问:
“为什么 substr 的第二个参数是 i + 1?为什么不是 i?”
👨🏫 导师解答:
这是编程中经典的**“从 0 开始计数 (Zero-based Indexing)”** 带来的认知偏差。
- API 定义:string.substr(起始位置, 截取数量)。
- 场景图解: 假设字符串是 "354..."。 我们找到了字符 '5',它的下标是 i = 1。 我们想要得到的子串是 "35"。
- 字符 '3':是第 1 个字,下标 0。
- 字符 '5':是第 2 个字,下标 1。
- 极端反证法: 如果 i = 0(字符串第一个字就是奇数),我们想截取这个字。 如果用 num.substr(0, i) -> num.substr(0, 0) -> 截取 0 个字符 -> 结果是空串 ""。(错误!) 必须用 num.substr(0, i+1) -> num.substr(0, 1) -> 截取 1 个字符 -> 结果是 "3"。(正确!)
第四章:哈夫曼树与 C++ 神器——谈判问题
4.1 题目逻辑
合并 N 个部落,每次合并两个,代价是两数之和。求最小总代价。
这是经典的 Huffman Coding 变种。
4.2 贪心策略
为了省钱,必须让小的数先合并,因为它们会在后续的合并中被反复加多次。
大的数要后合并,尽量少加几次。
4.3 C++ 实现:为什么要用优先队列?
如果用数组,每次合并完产生一个新数字,都要重新 sort 一遍,复杂度高达 O(N^2)。
我们需要一个数据结构,能自动维护顺序,永远让最小的数浮在最上面。
4.4 💡 深度疑难解析:STL 优先队列的语法解构
这行代码是 C++ 算法竞赛中含金量最高的一行:
// 定义一个小根堆(Min-Heap)
priority_queue<int, vector<int>, greater<int>> pq;
👨🏫 导师解答:为什么要写这么长?
C++ 的设计哲学是“极致的灵活”,所以它把所有可配置项都暴露给了你:
- priority_queue:这是容器适配器的名字。
- int (参数 1):数据类型。表示里面存的是整数。
- vector<int> (参数 2):底层容器。
- 你可能以为堆是树,应该用指针连起来?
- 错! 为了追求极致的内存访问速度,完全二叉树通常是拍扁了放在数组 (vector) 里的。
- 节点 i 的左孩子下标是 2i+1。这种紧凑的内存布局对 CPU 缓存极度友好。
- greater<int> (参数 3):比较规则。
- 核心考点:默认的 priority_queue 是大根堆(less<int>),也就是堆顶是最大值。
- 当我们想求最小代价时,我们需要小根堆。
- greater<int> 的意思是:让更小的元素具有更高的优先级(排在前面)。
4.5 完整代码逻辑 (Time: O(N \log N))
// 1. 扔进堆里 (自动排序)
for(int x : tribes) pq.push(x);
long long cost = 0;
// 2. 只要还有多于1个部落,就继续合并
while(pq.size() > 1) {
// 取出最小的
int a = pq.top(); pq.pop();
// 取出第二小的
int b = pq.top(); pq.pop();
// 合并
int new_tribe = a + b;
cost += new_tribe;
// 把新部落扔回去,堆会自动把它放到合适的位置
pq.push(new_tribe);
}
结语:从“写出代码”到“理解代码”
这篇博客不仅仅是解题,更是从 C 语言的视角去理解 C++ 的工具哲学:
- 逻辑严密性:如 else if 的互斥思考。
- 数学敏感度:如 i+1 的下标偏移。
- 工具掌控力:如 priority_queue 的底层模板参数。
对于未来从事 AI 研究的你,这些不仅仅是算法题,更是数据处理思维的基石。继续加油!
更多推荐

所有评论(0)