​【算法进阶】从 C 到 C++:深度解析“贪心算法”与 STL 实战细节

​前言:为什么学算法要先学“贪心”?

​作为一名计算机科学专业的学生,尤其是未来志在神经网络(Neural Networks)方向的同学,贪心算法(Greedy Algorithm) 是你必须跨越的第一座大山。

​为什么?因为深度学习的核心——梯度下降(Gradient Descent),本质上就是一种贪心策略:在每一步参数更新时,都沿着当前梯度下降最快的方向走,试图找到全局损失函数的最低点。

​本文将基于 C 语言基础,结合 C++ 的强大工具(STL),深度剖析三类经典贪心模型,并着重解决代码实现中的逻辑陷阱

​第一章:贪心算法的灵魂——区间选点问题

​1.1 核心思想

​贪心算法的核心在于**“目光短浅”**。

  • 定义:在每一步选择中,只采取当前看起来最优的策略(局部最优),而不考虑这一步对未来的长远影响。
  • 关键:通过无数个“局部最优”的累积,最终达到“全局最优”。
  • 注意:贪心并不适用于所有问题(如下棋),但在区间调度、资源分配等问题上是标准解法。

​1.2 例题:区间选点

题目:给定 N 个闭区间 [a_i, b_i],选最少的点,使得每个区间内至少包含一个点。

策略深度解析

我们需要对区间进行排序。但怎么排?

  1. ​按开始时间排?❌(如果一个长区间覆盖了后面很多短区间,选开始点并不划算)。
  2. ​按区间长度排?❌(短区间可能分布在互不相干的位置)。
  3. 按结束时间(右端点)从小到大排?✅

为什么是右端点?

想象你在处理任务。如果你优先处理结束最早的任务,并且在它的最后一刻(右端点)去选点,那么这个点的位置最靠后。

点越靠后,它“够得着”下一个区间开始部分的概率就越大。 这就是局部最优:让当前的付出产生最大的潜在覆盖范围。

​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 贪心策略

​我们在遍历数组时(模拟时间流逝),脑子里只需要想两件事:

  1. 抄底:这个价格是不是我见过的最低价?如果是,记下来。
  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。
    ​我们需要截取 2 个字。 而当前的下标 i 是 1。 所以数学关系是:数量 = 下标 + 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++ 的设计哲学是“极致的灵活”,所以它把所有可配置项都暴露给了你:

  1. priority_queue:这是容器适配器的名字。
  2. int (参数 1)数据类型。表示里面存的是整数。
  3. vector<int> (参数 2)底层容器
    • ​你可能以为堆是树,应该用指针连起来?
    • 错! 为了追求极致的内存访问速度,完全二叉树通常是拍扁了放在数组 (vector) 里的。
    • ​节点 i 的左孩子下标是 2i+1。这种紧凑的内存布局对 CPU 缓存极度友好。
  4. 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++ 的工具哲学:

  1. 逻辑严密性:如 else if 的互斥思考。
  2. 数学敏感度:如 i+1 的下标偏移。
  3. 工具掌控力:如 priority_queue 的底层模板参数。

​对于未来从事 AI 研究的你,这些不仅仅是算法题,更是数据处理思维的基石。继续加油!

 

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐