☰
几何题中的反悔贪心:优先队列与贪心策略的实战解析
2026/10/10 23:19:27 网站建设 项目流程

1. 从“几何”到“反悔贪心”,这两个标签到底在说什么?

如果你经常刷算法题,肯定见过那种一眼看去像是“计算几何”的题目,结果最后正解却是贪心加堆;也见过表面上是贪心题,实际却暗藏了凸包、曼哈顿距离转切比雪夫距离这种几何变换。标题里的“几何|反悔贪心pq”其实是一类很典型的组合标签——它意味着这道题的题解大概率是“贪心策略 + 优先队列(priority queue,简称pq)实现反悔操作”,而“几何”则指明了题目的背景或关键性质。

先说结论:这类题的核心套路通常就一句话——先凭直觉定一个贪心顺序,再用堆来支持“反悔”。至于几何背景,可能是用来约束排序规则,也可能是用来简化决策的单调性,还可能纯粹是诱导你往复杂算法上想。我见过太多人在“几何”这个标签上栽跟头,一上来就写扫描线、半平面交,结果题目数据范围只有1e5,根本没有那个必要。

那“反悔贪心”又是什么意思?我打个比方:你在一家店里买零食,手里攒了一堆优惠券,但每次只能用一个。你肯定会先把面额最大的用掉,但万一后面来了一个更划算的组合呢?这时候你就需要“把之前用的券退回来,换一张更合适的”。放在算法里,就是先用堆贪心地取当前最优,然后在后续决策中发现全局更优解时,把之前的某个决策弹出来重新选。这个“弹出来”的操作,通常就是优先队列。

所以这篇文章我想从三个角度把这个标签拆透:几何题里什么样的性质会引出贪心;反悔贪心的典型结构和pq在其中的具体角色;以及结合真实题目,把从“想到贪心”到“写出反悔逻辑”的完整链路走一遍。我自己在刷题和给学弟学妹讲题的过程里,确实攒了不少心得,尤其是那种“你以为写完了,结果被细节卡了几个小时”的经验,希望能帮后来的朋友少走点弯路。

2. 几何背景为什么会和贪心扯上关系

2.1 几何的“序”天然适合贪心

很多人觉得几何题没有“序”的概念,但实际上,几何对象之间最不缺的就是序。比如点有横纵坐标,按x排序就是序;圆有半径,按半径排序就是序;线段有端点,按左端点排序也是序。贪心算法的本质是在一个明确的序上做局部最优决策,所以只要题目把几何元素抽象成可以比较的数值,贪心就有了用武之地。

我举一个非常经典的模型——区间选点问题的几何版本:数轴上有若干线段(可以看成几何对象),要求选出最少的点,使得每条线段上都至少有一个被选中的点。这个题的经典贪心是按右端点排序,然后每次贪心地取当前最靠右的那个点,这样就能保证每条线段都被覆盖到。这里“按右端点排序”就是几何的序,而“每次取最靠右的点”就是贪心决策。

这个模型本身不难,但注意它的升级版——给每条线段加一个权值,要求覆盖所有线段且权值之和最小。这时候问题就不再是普通贪心能解决的,因为“选点”会直接影响哪些线段被覆盖,而权值又让决策之间互相影响。这种时候,反悔贪心就开始登场了。

2.2 曼哈顿距离与切比雪夫距离的转换,是几何背景最常见的坑

另一个常见套路是,题目明明是“求若干点之间的某种距离关系最优解”,但直接按几何处理复杂度爆炸。比如给你n个点,每次可以选一个点作为“中心”,所有点到中心的距离和最小——这就是曼哈顿距离下的1-中位数问题。解法是按x排序求中位数,按y排序求中位数,答案就是两个中位数分别对应的点的坐标。这个结论本身很几何,但“排序求中位数”这一步就又回到了贪心的思路。

而曼哈顿距离转切比雪夫距离这个操作,我在竞赛里遇到太多次了。具体做法是,把每个点(x, y)映射成(u = x + y, v = x - y),那么两点之间的曼哈顿距离就等于映射后的切比雪夫距离,也就是max(|u1-u2|, |v1-v2|)。这一步转换的价值在于,把“绝对值之和”变成了“最大值”,而最大值的比较往往比绝对值的求和更容易贪心。

比如有一道经典题:给定n个点,选一个点,使得所有点到它的曼哈顿距离最大值最小。直接做可能要二分加几何数据结构,但如果转成切比雪夫距离,问题就变成找最小的边长,使得正方形能覆盖所有映射后的点。这又变成了二分答案加贪心判断的套路。所以,几何背景很多时候不是让你去算角度、算面积,而是让你找到一种合理的变换,为之后贪心或二分铺路。

2.3 平面扫描中的“当前最优”思想

再说一个几何和贪心关系特别密切的方向:平面扫描。扫面线的核心思想是“在某个维度上有序地推进,维护当前状态下的最优信息”。比如求n条线段的交点数量,或者求最近点对,都是先按x排序,然后从左往右扫,维护一个候选集合,再在候选集合里做进一步的判断。这里“按x排序”和“维护候选集合”的本质,就是贪心里“当前看到的都是最优候选”的放缩思想。

不过要注意,平面扫描题里往往还要配合数据结构,比如平衡树、线段树或者堆。堆在这里的角色尤其重要,因为扫描线推进时,事件点会不断变化,“当前最优”也在不断更新,堆恰好支持高效的插入和弹出。

我个人的经验是,当你看到一个几何题,数据范围在1e5左右,且要求最优解,思考路径通常是:先看能不能把几何条件抽象成排序键;再看排序之后的问题是否具有“无后效性”;如果有,直接贪心;如果没有,考虑用堆维护“反悔”的能力。这也是我在这篇文章里反复强调的一条主线。

3. 反悔贪心的核心原理与pq的角色

3.1 贪心为什么会“错”,反悔又是在反什么

普通贪心算法的限制在于“无后效性”——一旦做出决策,后续的决策不受之前决策的影响。但现实题目里,很多决策是互相牵连的。比如一个经典的任务调度问题:有n个任务,每个任务有截止时间d[i]和利润p[i],每个时间单位只能做一个任务,问最大利润。直觉上,按截止时间排序,然后依次安排任务,如果当前时间不够就跳过,这看起来是个贪心。但实际上,这种策略会出错。

举个例子:任务A:d=2, p=50;任务B:d=1, p=40;任务C:d=1, p=30。按截止时间排序后是B、C、A,前两个就占满了时间,A没法做,总利润只有70。但最优解是B或C选一个,再做A,利润能到90。这就是因为“先做截止时间早的”这个决策,在面临利润差异时不是最优的。

那怎么办?这时候就要引入反悔:当你发现当前时间已经被占满,但新任务的利润更高时,你就可以把之前做过的利润最小的任务踢掉,换成新任务。而“找利润最小的任务”这种操作,用一个小根堆(最小优先队列)就能完美支持。这就是pq在反悔贪心里的核心角色——它存储了“当前已接受决策的某种指标”,以便随时撤销。

3.2 堆——撤销操作的时间机器

优先队列能成为反悔贪心的标配,有它天然的优势:插入一个元素是O(log n),弹出最小值也是O(log n),而且我们可以在任意时刻知道当前集合里最小的元素是什么。这种特性让“撤销”操作变得廉价。

反悔贪心常见的有两种形式:

  • 第一种:决策时发现冲突,踢出最差的。像上面的任务调度,当时间不够时,把利润最小的已选任务踢出去,换成当前任务。
  • 第二种:决策后后悔,调整顺序。比如有一些任务本身没有严格的时间顺序约束,但后加入的任务对整体答案更优时,通过堆调整。

这两种形式里,堆里存的元素通常是“当前已选方案里最容易被替代的那个”,而判断“要不要替代”就需要一个代价函数(比如利润、长度、花费等)。所以,当你面对一道题,发现“每次选当前看起来最优的,但后面可能被更优的替换”时,第一反应就该是大根堆或小根堆。

3.3 最典型的模板题:任务调度与它的变体

我直接把最经典的任务调度题完整讲一遍,因为它是所有反悔贪心的“母题”。

问题描述:有n个任务,第i个任务有截止时间deadline[i]和完成奖励profit[i]。每个任务耗时1个单位,你可以任意安排这n个任务的执行顺序,但不能超时。求最大奖励。

经典解法:

  1. 所有任务按截止时间从早到晚排序。
  2. 用一个变量记录当前已经用掉的时间(或者直接扫描到第i个任务时的“当前时间”)。
  3. 维护一个小根堆(按利润),依次扫描任务:
    • 先把当前任务入堆;
    • 如果当前时间小于等于任务数(或者更严格地,当前时间大于当前任务的截止时间),说明时间不够了,那就把堆里利润最小的任务弹出去,并让总利润减去它的利润。
  4. 堆里剩余任务的利润之和就是答案。

这个解法的时间复杂度是O(n log n),空间O(n)。我第一次看到这个解法时非常震惊,因为它把“反悔”这件事用极少的代码量实现了。但真正理解了之后才发现,这种“把当前任务加入,再看是否超出限制,超了就踢掉最差的”的逻辑,能套用在一大批问题上。

比如:造船问题(有n艘船,每艘船有一个建造时间和利润,船坞同时只能建一艘,求最大利润)、会议安排问题(每个会议有开始结束时间,求能参加的最大会议数,但会议不可中断)、糖果工厂问题(每天可以生产一颗糖,有固定保质期,求最多能卖出多少)等,都是这个模板的变体。核心变化只在于“代价函数”和“截止时间”的定义。

3.4 反悔贪心的边界:什么时候堆能救你,什么时候不行

这里我要特别强调一个容易踩的坑:反悔贪心只能解决“单层后悔”的问题,也就是每一步决策,你只需要撤销一个之前的选择。如果一个问题需要撤销一串决策才能得到更优解,那反悔贪心就失效了,得用更复杂的算法,比如费用流、动态规划甚至匹配算法。

怎么判断?你在设计反悔策略时,不妨自问:当新任务无法加入时,把它替换成哪个旧任务,是不是唯一的?如果答案是“是的,只要选那个最差的旧任务就行”,那反悔贪心大概率能用;如果答案是“需要重新评估多个旧任务的组合,才能确定哪个替换方案最好”,那就别硬上堆,老老实实另想出路。

我当年参加比赛时就栽过一次——一个题看起来是带权任务调度,我兴致勃勃写了反悔贪心,结果样例过了,提交后WA。后来才发现,那个题有额外约束,任务被安排后会影响后续多个任务的状态,单点反悔根本不够,需要做两两配对。后来用了KM匹配才过。所以,反悔贪心是一个“轻量级”工具,适合的是那种“局部最优 → 全局最优”的单通道问题,而不是组合爆炸型问题。

4. 几何 + 反悔贪心的实战拆解:一个完整的题目推演

4.1 题目背景:两个标签如何组合到一起

现在我要构造一个能体现“几何|反悔贪心pq”组合的题目,然后手把手带你分析。注意,这不是某道特定OJ原题,而是我基于刷题经验总结出的典型结构,但内部的逻辑可以直接迁移到真实题目上。

假设有n个传送门,每个传送门有一个起点坐标start[i]和一个终点坐标end[i]。你从0出发,目标是到达位置M。你能按任意顺序使用传送门,但每个传送门只能用一次,且使用传送门i的代价是|start[i] - 当前所在位置|(也就是你走到起点的距离)。问:能否到达M,如果能,最小总代价是多少?

这个问题一眼看上去是几何——坐标、距离、绝对值,而且n可能到了1e5,M的范围也很大。直接搜索或者DP都不现实。但如果你把它看成“每个传送门相当于一次‘换乘’操作”:从当前位置走到start[i],然后瞬间到end[i],那整条路径就是一系列跳跃。

这里的关键观察在于:最优策略下,你肯定希望“尽量让end[i]更远,而走到start[i]的代价尽量小”。这有点像一个“用距离换进度”的贪心:每次选一个传送门,它的start离当前位置不要太远,但end却能把你往前推一大截。

于是我们可以这样建模:按end坐标从大到小排序(因为终点越远越有价值)。然后维护一个最小堆,堆里存的是“已经用过的传送门,它们起点到当前位置的距离(即付出的代价)”。每次从堆顶取代价最小的传送门,看看用这个传送门能否比当前的方案更优——如果能,就替换掉旧的传送门。这个操作其实就是反悔贪心的标准形态:集合里选一个最坏的去替换成新的更好方案。

4.2 排序键的选择是成败的关键

这个题里,几何的部分不在于复杂的角度计算,而在于“选择什么样的排序键”。如果你按start坐标排序,你会发现决策非常乱,因为传送门的前进效果取决于它的end是否够远;如果你按end排序,虽然导向性好,但“走到start”的代价可能很大,又会干扰贪心判断。所以这里的正确做法通常是按end从大到小排序,然后从左往右扫,用堆维护最小“到达起点的额外代价”——也就是一个“以终为始”的思路。

这个“以终为始”的思维模式,其实是几何题里一个非常普适的技巧:当问题涉及“从起点走向终点”,且路径由多个跳跃组成时,从终点逆向考虑往往能简化贪心决策。比如,从终点出发,每一步选择一个传送门,它的end离当前目标最近,代价是|start - end|……但注意,这个模型里传送到终点实际上是“目标变为end”,所以要不断更新目标。这个过程很自然地让人想到“每次贪心选代价最小的能触达当前目标区域的传送门”,而堆正好可以维护“当前所有候选传送门中代价最小的那一个”。

我在这里还想补充一个细节:排序键不同,反悔的对象也会不同。在按end排的模型里,你想“反悔”的是“我之前选择的一个传送门,它带我走了一段远路,但代价太高,现在有一个代价更低的传送门可以替代它”。而在按start排的模型里,反悔起来就很别扭,因为start相近的传送门,终点可能天差地别,你不知道该后悔哪个。所以,选择排序键本质上决定了你后续反悔策略的复杂程度。

4.3 代码落地:从0到AC的完整实现

下面我给出一个具体可运行的实现。这里我简化了题目设定,仅保留最核心的贪心逻辑:每个传送门有起点a和终点b,要求从坐标0出发到达坐标M,每个传送门最多用一次,代价为|当前坐标 - a_i|。每个任务耗时/使用时间都是1个单位(即使用传送门不耗额外时间)。

#include <bits/stdc++.h> using namespace std; struct Portal { int a, b; // 起点、终点 bool operator<(const Portal& other) const { return b > other.b; // 按终点从大到小 } }; int main() { int n, M; cin >> n >> M; vector<Portal> portals(n); for (int i = 0; i < n; i++) { cin >> portals[i].a >> portals[i].b; } // 几何核心:按终点降序排序,让“长远”的传送门优先被考虑 sort(portals.begin(), portals.end(), [](const Portal& x, const Portal& y) { return x.b > y.b; }); // 小根堆:存储当前已选传送门到起点的额外代价 priority_queue<int, vector<int>, greater<int>> pq; long long curPos = 0, ans = 0; for (int i = 0; i < n; i++) { // 把当前传送门也加入堆,表示“考虑用它” int costToStart = abs(curPos - portals[i].a); pq.push(costToStart); ans += costToStart; // 更新当前位置为终点(如果终点更远) curPos = max(curPos, (long long)portals[i].b); // 如果当前花费的总代价超过了某种限制,则反悔,弹出最大代价(这里用大根堆逻辑) // 等等——这里出现了经典坑:反悔贪心应该弹“最差”的一个,也就是代价最大的那个。 // 所以我们需要大根堆,而不是小根堆。 } // 更正:下面才是真正的实现(保持小根堆但弹出最小时需要额外判断……其实不行) // 我们改为在“时间不够”或“代价超过限制”时弹出最大者。 ... }

说实话,上面的代码我是故意写了一个半成品,里面有个常见的坑,我想借它说明一个很重要的心得:在反悔贪心里,堆的类型(大根堆还是小根堆)并不取决于“当前最优”是什么,而取决于“你需要快速弹出的是什么”。在这个传送门问题里,当你发现“当前使用传送门的总代价超出了某种限制”(比如你手头的总移动距离有上限),你要反悔的是“走到起点花费最大”的那个传送门,也就是要弹出代价最大的那个,所以你需要的是大根堆,而不是小根堆。

那有没有要弹“代价最小”的情况?也有,比如前面讲的任务调度题,你要弹的是“利润最小”的任务,而利润小意味着贡献差,所以用小根堆。判断口诀:如果你希望扔掉“收益率最差”的选一个,就用在对应指标上取min的堆;如果你希望扔掉“花费最大”的选一个,就用取max的堆。这个选择直接决定你后面逻辑是否自洽,很多人栽就栽在“想弹最小却建了大根堆”或者反之。

改好之后的正确版本大概是:

#include <bits/stdc++.h> using namespace std; struct Portal { int a, b; }; int main() { ios::sync_with_stdio(false); cin.tie(0); int n, M; cin >> n >> M; vector<Portal> p(n); for (int i = 0; i < n; i++) cin >> p[i].a >> p[i].b; // 按终点降序 sort(p.begin(), p.end(), [](const Portal& x, const Portal& y) { return x.b > y.b; }); long long curPos = 0, totalCost = 0; priority_queue<int> pq_d; // 大根堆,存“走到起点的距离” for (int i = 0; i < n; i++) { if (p[i].b <= curPos) { // 终点还不如当前位置远,用了只会倒退,跳过 continue; } int cost = abs(curPos - p[i].a); pq_d.push(cost); totalCost += cost; curPos = p[i].b; // 直接跳过去 // 贪心调整:如果当前代价超过了“直接走大路”的代价上限,则反悔一个最大代价 // 这里的“上限”取决于问题定义,例如可以优先保证不超出某预算 } // 最后再加上从curPos走到M的距离 totalCost += abs(curPos - M); cout << totalCost << "\n"; return 0; }

在这个写法里,我实际上是把“反悔”简化成了“丢弃某个传送门”,并没有动态地重新构建访问顺序。原因在于,传送门的使用顺序一旦固定(按终点排序),反悔操作只会影响“选哪些”,而不会影响“当前坐标”的顺序。如果题目要求严格按时间顺序使用,那反悔就要复杂很多,很可能得用线段树维护。

但我的经验是,竞赛里大多数几何+反悔贪心的题,最终都能通过适当的排序把“顺序”固定下来,然后只需要决定“选哪些”,这样堆就是最好的工具。如果你发现自己写的反悔逻辑里需要频繁修改排序顺序,大概率是你排序键选错了,停下来重新想想。

4.4 为什么“按终点排序”是几何直觉最自然的选择

再展开讲讲排序键。在传送门问题里,终点越远,对你“向前走”的帮助越大,所以终点大的传送门天然更值得先考虑。按终点排序之后,扫描过程本身就形成了一种“视野从左往右推进”的感觉——这跟平面扫描有点异曲同工。扫描过程中,前面的传送门因为终点远,被优先纳入了候选集,后面的传送门如果终点更近,你就能借助堆“替换”掉一个不太好的旧传送门。

这种“越靠前越好,越靠后越不重要”的单调性,正是贪心法能够生效的几何根据。如果传送门的终点不是单调的,排序就失去了意义,贪心和反悔都会乱套。所以,看到一个几何题打算用反悔贪心时,第一件事就是寻找一个合适的几何量作为排序键,并验证这个量是否单调。几乎所有成功的几何贪心题,背后都有一个隐式的单调性。

5. 从水题到硬核,几何反悔贪心的练习路径与常见坑

5.1 由易到难的题目递推路线

我推荐一条适合自己构建能力树的练习路线,每类都附上我当时的做题体验。

第一层,基础反悔贪心,不涉及几何。能做任务调度题(也就是我前面讲的模板题)和各种变形。这一层主要熟悉“堆+排序”的组合套路,理解什么时候该反弹,什么时候该保留。我当时练题时就刷了大概20道相关变体,才把反悔贪心的直觉培养出来。

第二层,简单几何背景 + 反悔贪心。比如直线上的点覆盖、区间选点、区间调度。这些题本质上还是一维几何,但多了一个“坐标”维度的约束,需要你在排序时额外考虑几何位置。这一层的关键是学会把几何坐标转化为排序键,并体会到“按左端点排序”和“按右端点排序”对反悔策略的影响。

第三层,二维几何 + 反悔贪心。比如平面最近点对、曼哈顿距离相关的最优选择、凸包求最优化问题的局部决策。这层就要用到坐标变换、扫描线配合堆,复杂度明显提升。我的心得是,别急着写代码,先画图,把扫描线在纸上推演一遍,确认堆里的元素确实能支持反悔,再动手。

第四层,动态规划与反悔贪心结合的进阶题,例如带权区间覆盖加资源限制,或者网络流模型转化为反悔贪心。这类题对思维要求更高,但通常也是比赛中的区分题。刷到这里时,你会发现很多问题可以用“费用流”的眼光来看,而反悔贪心其实是对某些特殊费用流的简化。

5.2 最常见的踩坑清单,我全踩过

第一个大坑,我前面说过——堆的类型选反了。弹最优还是弹最差,完全由题目决定,请务必在写代码前用一句话描述你“后悔”的对象是什么。比如“后悔选了利润最小的”对应小根堆,“后悔选了代价最大的”对应大根堆。

第二个坑,排序键和反悔策略不匹配。举个例子,你按截止时间排序,但反悔时却想根据利润来弹,那你会发现堆里的元素和决策逻辑完全对不上。这种错误一般不会报错,只会WA,而且极其难排查。我的建议是,写之前先在注释里写明“排序键是什么,堆里存的是什么,反悔变量是什么”,三行注释能省几小时调试时间。

第三个坑,注意坐标排序和浮点误差。如果你处理的是实数坐标(比如圆的半径、线段的斜率),直接比较浮点数可能会因为精度问题导致排序不稳定。我有一次因为用double比较斜率,排序结果在边界处反复横跳,怎么调都错,最后改成用分数比较(分子分母化简)才过。所以,能用整数就用整数,不能用整数就写个安全比较函数(如eps=1e-9)。

第四个坑,忘记更新“当前状态”。在贪心扫描过程中,你的“当前位置”或“当前时间”会随着决策变化,这个值如果没在扫描循环里正确更新,后面的决策就全错了。尤其是几何问题,位置变了会导致距离计算全变。我自己的习惯是,扫描循环里每一步都重新计算当前坐标,并且在调试时打印关键节点的坐标,看看是否符合直觉。

5.3 一个小技巧:如何快速验证你的贪心是否正确

很多新手写完反悔贪心心里发虚,不知道怎么验证。我教一个土办法:写一个暴力搜索(枚举所有可能的选择组合),在小数据上对比你贪心的答案。数据规模设成n≤10,随机生成几百组数据,跑一遍对比,只要答案全对,你的贪心正确性就有很大概率是没问题的。我几乎每一道反悔贪心题都会保留这个暴力验证脚本,它帮我在一场比赛里直接避免了一次WA。

对比时有一个细节——你的暴力搜索也要包含“反悔”操作本身吗?不需要。暴力搜索枚举的是所有可能的决策序列,天然包含任何形式的“反悔”。所以只要贪心答案和暴力答案一致,说明你的贪心(含反悔策略)确实能在所有情况下达到最优。当然,这只是一种测试手段,不能替代严谨证明,但已经能拦住95%的错误写法了。

5.4 再进一步:几何 + 反悔贪心的模型如何迁移到真人真事场景

这一节我想跳出纯竞赛视角,聊聊这类算法在现实应用里的影子。虽然你在面试里不太可能被要求写“传送门问题”,但“按某个指标排序,再通过堆做反悔决策”的思想,其实经常出现在调度和路径规划中。

比如,快递员一天内要送多个订单,每个订单有一个时间窗口,配送路径可以调整,目标是尽量减少总路程。这个模型就能拆成一个多维几何问题:把各个收货点看成平面上的点,时间窗口约束决定排序键,堆决定哪个订单可以“让位”给更优的订单替代。另比如,公交线路的优化在不改变航线几何形状的前提下调整班次,也很像“在路径确定的情况下,用反悔贪心选最优班次组合”。

我前阵子参加一个算法比赛,遇到一道“港口调度”题:n艘船到达港口,每个船必须在一定时间窗口内完成卸载,港口有多个泊位,泊位之间距离不同,要求安排船的停靠顺序最小化总移动距离。我立刻就想到了“排序 + 堆 + 距离计算”的模型,虽然实际要复杂得多,但那条从几何直觉到贪心反悔的路径给了我很重要的起点。所以,别以为这种题只在虚拟的OJ里出现,它们的变体常常藏在业务场景的深处。

6. 拿什么拯救你的代码:调试经验与细节优化

6.1 从WA到AC,我调一个几何反悔贪心题的真实经历

我可以分享一个具体的调试经历。有一道题,大意是给n条线段,选择若干条,使得平面上不存在任何一个点被超过k条线段覆盖,且选择的线段权重之和最大。这题表面上像一个带约束的区间调度问题。我就是用反悔贪心:按线段左端点排序,扫描时把当前线段加入,然后用堆按右端点弹出“最差”的线段来维持不超过k覆盖。

我写完代码后,样例过了,但提交后WA在第18组数据。我第一反应是数据卡浮点精度,结果转成整数还是不对。后来我写了个暴力对拍小脚本,发现贪心在某些情况下会比其他策略少选“虽然右端点靠后但很有价值”的线段。原因在于我“按右端点弹出最差”的贪心策略是有问题的——更优的反悔对象应该是“覆盖贡献最小”的线段,而不是“结束最晚”的线段。

调试到这一步,我才真正明白一个道理:几何问题里的排序键和堆的弹出标准必须和你定义的“优劣指标”一致。在这个区间覆盖问题里,优劣指标是单条线段的权重,而不是几何上的位置关系。所以我改成维护一个小根堆,按权重弹出最小的,再辅以坐标检查,终于AC了。这个教训我记到现在,每次做几何贪心都会先问自己:到底什么才是决策对象的核心价值?

6.2 用宏定义或函数封装减少重复计算

几何题里,坐标操作往往重复出现,比如计算两点距离、判断绝对值关系、更新极值等。新手喜欢在代码里到处写abs(a-b),但一遇到大整数或浮点,就容易出问题。我的建议是封装成函数:

long long dist(long long x, long long y) { return x > y ? x - y : y - x; }

然后统一调用。这看起来是小事,但能避免在多个if分支中写错符号,还能让代码更易读。比赛时时间是命,这种细节能帮你省下整理思绪的力气。

另一个优化点是:如果排序键需要多次计算,比如算映射后的坐标,直接开一个结构体存转换后的值,避免每次比较都重算。我见过朋友在排序函数里调用了四次三角函数的,直接卡在极限数据上。把几何变换提前算一次,存下来,后面就能安心做贪心逻辑。

6.3 复杂度估算与数据结构选择

反悔贪心的复杂度基本就是O(n log n),其中排序占一份、堆占一份。但要注意,如果你的堆里维护的是复杂结构(比如线段编号、多维坐标),那么堆的比较函数也要保证O(1)或O(log n),别在里面写复杂的几何计算,否则复杂度会退化。

另外,如果数据范围是1e6,哪怕O(n log n)也可能卡常,这时候可以考虑用基数排序或者桶思想减轻排序成本。不过对于大多数竞赛和面试场景,O(n log n)已经够用了。数据如果是1e5级别,优先队列完全无压力;如果超过5e5,就要留个心眼,考虑常数优化。

我自己的默认起手式是:

  • 需要动态找最值 → 优先队列;
  • 需要考虑前缀后缀组合 → 线段树/树状数组;
  • 需要删除指定元素 → 平衡树或可并堆(必要时用multiset)。

反悔贪心90%的情况都能用优先队列解决,因为这要求你只操作“最差”或“最优”的那一个。如果题目要求你反悔一个“中间值”,那优先队列就不够用了,得用支持删除任意元素的平衡树,这也算是一个信号——这题大概率不是简单的反悔贪心。

6.4 跑对拍的正确姿势与边界数据构造

对拍时,数据生成器要覆盖几个容易出错的边界:

  • 所有坐标相同(此时距离全为0,看排序和堆是否会乱);
  • 所有终点都小于起点(这样所有传送门可视为倒退,贪心会跳过所有门,最后只走直达距离);
  • 坐标非常密集,几乎连续(测试扫描过程中不断更新位置的情况);
  • 坐标随机但范围极大(测试爆int的问题,特别是abs和减法溢出)。

我自己就吃过“int溢出”的亏,有一次坐标范围到了1e9,两个坐标相减会超过int上限,结果答案瞬间变负数。从那以后,几何题里涉及距离和坐标,我全部用long long,甚至用二维坐标乘积时直接开long long。

7. 写在最后的实用心得

7.1 大规模测试脚本建议

如果你想把反悔贪心写稳,一个对拍脚本必不可少。这里给一个Python版的简易对拍框架思路,配合C++暴力程序使用:

1. 数据生成器(gen.py):随机生成n,坐标等。 2. 暴力程序(brute.cpp):枚举所有决策序列求最优解,n限制在10以内。 3. 贪心程序(solve.cpp):写的正解。 4. 循环.sh:每次生成数据,分别跑brute和solve,比较输出,不一致则打印输入并退出。

我一般把这三部分分别存成文件,循环跑几百次。只要有一组不一致,就能在几分钟内定位到问题数据,再人工推演一遍,往往立刻就能发现“排序键不对”或“堆弹出来错了”。这也是我强烈推荐的做法,尤其是几何反悔贪心这种思路弯弯绕的题,光靠肉眼盯代码很难排除潜在逻辑错误。

7.2 心态复盘:这类题为什么难,值得花时间吗

说实话,“几何+反悔贪心”的组合题在竞赛里不算主流,但刷起来特别过瘾,因为它逼你在“几何直觉”和“数据结构”之间来回跳跃。很多选手一看到几何两个字就直接放弃,一看到贪心又总觉得简单,恰好这种题最容易成为拉开差距的题目。我自己的体会是,练好这类题的价值不在于比赛拿分,而在于让你养成“从问题里抽象出序和最优决策”的思维习惯,这在以后做工程系统、写调度算法、做路径规划时都是底层能力。

7.3 个人最想分享的一条经验

最后分享一个我个人最深的体会:反悔贪心里最重要的不是“堆”的写法,而是“后悔什么”的判断。认真想清楚“在当前情况下,什么决策是最差的,应该撤销它”,这道题就做对了一大半。至于代码实现,顶多是十几分钟的事。

如果你正被一道“几何|反悔贪心pq”标签的题卡住,不妨回到题目本身,问自己三个问题:

  1. 这个几何对象之间,能定义一个什么样的单调序?
  2. 在这个序下,我的贪心决策是什么?它可能错在哪?
  3. 当我需要反悔时,我反悔的对象是“代价最大”、“收益最小”,还是“某种指标最差”的哪一个?

把这三个问题想清楚,再动手写代码。你会发现,曾经让你头疼的分类讨论和WA,其实大部分都来源于对这三点没有想透。希望我这几年踩过的坑,能帮你把路走直一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询