最近刷题碰到一道名字很吓人的题:PAT——The World's Richest,题面开头还专门标了一行小字“注意运行超时的问题”。这个名字翻译过来就是“世界上最富有的人”,听起来像财经新闻,实际是一道很典型的算法题:给你一群人的年龄和资产,再给你一堆查询,每个查询指定一个年龄区间,要求输出这个区间里资产最高的前K个人。题目本身不绕,真正坑人的就是那行小字——几乎每个人交上去的第一版解法都会栽在超时上。
这道题适合谁来练?正在备编程能力考试的、准备算法面试的、或者单纯想练一练“怎么把一个暴力思路改成能跑过的方案”的人。它考的不是什么偏门数据结构,而是最常用的排序、扫描、IO优化和复杂度估算,含金量相当高。下面我把我的解题过程和踩坑记录完整写出来,从暴力解到满分解,从C++到Python,一次性聊透。
1. 先弄懂题目在说什么
1.1 输入输出与答案规则
题目描述大致是这样:给出N个富豪的信息,每个富豪有三个字段:姓名(不超过8个字符)、年龄、净资产。之后有M次查询,每次查询给出三个参数:K、年龄下限Amin、年龄上限Amax。要求输出年龄在[Amin, Amax]内的富豪中,净资产排名前K的人。注意排序规则有三层:先比净资产,大的在前;净资产一样,比年龄,小的在前;再一样,按姓名字典序。
我习惯先用一个微型样例把规则钉死。假设:
- Alice 30岁 1000万
- Bob 25岁 1000万
- Charlie 40岁 500万
- Dave 30岁 900万
- Eva 25岁 800万
查询是:K=2,年龄区间[28,40]。落在区间内的人有Alice、Charlie、Dave,按财富排是Alice 1000、Dave 900、Charlie 500,所以输出前两名就是Alice和Dave。Bob虽然也是1000万,但年龄25不在区间里,直接排除。这时候你对排序规则应该没有任何疑问了:规则是全序的,财富权重最高,年龄和名字只是并列时的裁决条件。
输出格式上,这类题一般要求每个查询占若干行,一行一个人;区间内没人时输出一个None。看起来很简单对吧?问题全藏在数据范围后面。
1.2 数据范围里的杀气
N可以到10^5,M可以到10^3,K最多100,年龄范围一般不超过200。这个数量级意味着什么?先记住一句话:复杂度超过10^8次简单操作,在C++上就开始危险;超过10^7,Python基本要跪。
暴力做法是每次查询遍历N个人筛选出区间内的,然后排序输出。算一下复杂度:M次查询,每次排序是O(N logN),总比较次数大约是10^5 × 10^3 × 17 ≈ 17亿次。就算评测机每秒能跑10^9次简单比较,这也是将近两秒起步,再算上IO和排序常数,超时是必然的。Python如果用内置sort,C语言实现的排序本身挺快,但外层扫描和数据处理也会卡得厉害。
这里我要专门纠正一个常见误解:很多人觉得“我K很小,所以排序时排K个就行”。这是错的,你先要筛出区间内所有的富豪,才知道前K名是谁,而筛选本身要扫全表。不改变整体思路,只在排序环节小修小补,解决不了根本问题。
2. 暴力解法为什么必挂
2.1 最直观但最致命的写法
新手拿到题目,第一版代码一般长这样(伪代码):
for each query (K, amin, amax): tmp = [] for each person: if amin <= person.age <= amax: tmp.append(person) sort tmp by (worth desc, age asc, name asc) if tmp empty: print None else: print first min(K, len(tmp)) persons这个逻辑完全正确,示例数据稳过。但直接交上去,等待你的大概率是评测页面上的“运行超时”。问题出在两个地方:一是tmp反复扩容、插入、排序,每次查询都在构造新容器;二是每次查询都得重新扫描N个人,完全没有复用任何中间结果。
也有人在上面做“优化”:先按年龄把所有人分组,每次查询只遍历区间内年龄对应的组,而不是遍历全部N个人。这个方向是对的,但效果很有限。年龄范围如果只有0到200,而查询区间跨190个年龄,那和扫全表几乎没有区别;只有区间特别窄时才有明显提升。
2.2 把1.7×10^9次操作拆开看
复杂度账要一步步算。假设N=100000,M=1000,暴力方案每次查询的耗时来自两部分:扫描加排序。扫描是O(N),排序如果区间内人数接近N,每次就是O(N logN)。于是总比较次数大约是1000 × 100000 × 17,约17亿,这还是下限,没算排序时的交换、字符串比较、构建临时容器的开销。
C++的std::sort比较函数如果内部还要比较std::string的字典序,常数会更大。实际跑起来,暴力解在常见评测环境中往往是2到5秒。常见的超时限制是1秒或2秒,所以它连抢救的空间都没有。Python更惨,即使你用read()批量读入把IO优化到极致,17亿次Python层的操作也是不可能完成的,耗时按分钟算。
所以这里有一条铁律:做算法题,先看N和M的上限,把复杂度写出来再动手。哪怕代码逻辑再漂亮,复杂度不达标就是0分。这道题的题面特意提示“注意运行超时”,其实就是出题人在提醒:别一上来就暴力。
3. 正解:一次全局排序,查询时数够K个就停
3.1 核心观察
现在说正解。题目的特殊点在于:每个查询都要求“区间内前K名”,而K≤100。假如已经按照“财富降序、年龄升序、名字字典序”把N个人排好了一遍,那么每次查询时,沿着这个全局有序数组从头扫到尾,所有年龄落在区间的富豪,天然就是该区间的正确排名。道理很简单:截取一个有序序列的子集,子集的相对顺序不变。
于是查询变成:从头扫描全局数组,遇到年龄符合的人就输出;每输出一个人计一个数,一旦计满K就立刻停止。如果整张表扫完都不足K个,说明区间内总共就没那么多人,全部输出。如果一个人都没碰到,输出None。
这个方案为什么能躲过超时?因为大多数查询在扫描很短的前缀时就能凑满K个人。比如K=100,而财富榜前100人里通常有不少人落在常见年龄区间,扫几十个到几百个就结束了;只有极少数查询需要多走一截,这部分就是这道题的脏数据点,下一章专门讲。
3.2 为什么提前停不会影响正确性
这里值得多说两句,因为面试官经常会追问“凭什么只数K个就对”。我们把全局排序后的数组记作p[1]到p[N],其中p[1]是规则意义下的第一名。对一次查询,年龄区间是A,我们要求的其实是集合S = {i | p[i].age ∈ A}中按规则排名前K个元素。
因为规则是固定的全序关系,S内部元素的相对顺序和它们在全局数组中的顺序完全一致。扫描时,我们遇到的第r个属于S的人是p[i1]、p[i2]、…、p[ir],而且下标i1 < i2 < … < ir,所以p[i1]就是S中的第一名,p[i2]是第二名,以此类推。输出前K个正是S中的前K名。这个论证在K大于|S|时依然成立:我们只是扫完整个数组然后自然退出,并没有多输出一个人。
所以“提前break”不会改变输出内容,只会节省时间。这是整个截断优化的合法性基础。
3.3 C++参考实现
直接放一份可跑的C++代码。我用关闭同步的cin/cout,不用fstream,不用endl。
#include <bits/stdc++.h> using namespace std; struct Person { string name; int age; int worth; }; bool cmp(const Person &a, const Person &b) { if (a.worth != b.worth) return a.worth > b.worth; if (a.age != b.age) return a.age < b.age; return a.name < b.name; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin >> N >> M; vector<Person> people(N); for (int i = 0; i < N; i++) { cin >> people[i].name >> people[i].age >> people[i].worth; } sort(people.begin(), people.end(), cmp); while (M--) { int K, amin, amax; cin >> K >> amin >> amax; int cnt = 0; for (const auto &p : people) { if (p.age >= amin && p.age <= amax) { cout << p.name << " " << p.age << " " << p.worth << "\n"; if (++cnt == K) break; } } if (cnt == 0) cout << "None\n"; } return 0; }这里有个容易被忽略的点:如果区间内人数不足K,需要把区间内符合条件的人全部输出。上面代码天然处理了——扫完整个数组自然退出。但很多人会在这个分支犯错,比如先统计一遍区间人数再决定要不要输出None,结果白白多扫一次全表,又把自己推到超时边缘。
3.4 Python参考实现
Python如果按常规方式写,十个有九个会超时。我推荐的做法是:用sys.stdin.buffer.read()一次性读入全部字节,split()成列表;排序用一个元组key:(-worth, age, name);最后把每行输出装进列表,join之后一次性write出去。
import sys def main(): data = sys.stdin.buffer.read().split() idx = 0 n = int(data[idx]); idx += 1 m = int(data[idx]); idx += 1 people = [] for _ in range(n): name = data[idx].decode(); idx += 1 age = int(data[idx]); idx += 1 worth = int(data[idx]); idx += 1 people.append((-worth, age, name)) people.sort(key=lambda x: (x[0], x[1], x[2])) out_lines = [] for _ in range(m): k = int(data[idx]); idx += 1 amin = int(data[idx]); idx += 1 amax = int(data[idx]); idx += 1 cnt = 0 for neg_worth, age, name in people: if amin <= age <= amax: out_lines.append(f"{name} {age} {-neg_worth}") cnt += 1 if cnt == k: break if cnt == 0: out_lines.append("None") sys.stdout.write("\n".join(out_lines)) if __name__ == "__main__": main()注意几个细节:负worth是为了让Python sort的默认升序变成财富降序;age直接升序符合规则;name字典序Python默认的字符串比较就是。输出这里我把每条富豪信息带上了年龄和财富值,实际输出格式以题目为准,有些版本只要求名字,有些要求三个字段都打。我按三字段版本写,读者自己改一行就行。
这一版在全随机数据下通常能跑进两三秒,但还不够稳。接下来聊为什么要继续优化,以及众多题解刻意绕开的最坏情况。
4. 更稳的加强版:按年龄分桶加多路归并取Top K
4.1 提前截断也有翻车的时候
全局排序加提前截断能过绝大多数官方数据,但它有一个隐藏软肋:如果查询区间内富豪总数不足K,扫描就必须走到数组末尾才能结束。比如某次查询K=100,但年龄区间里总共只有两个人,那这次查询的代价就是O(N);如果M次查询全是这种“区间人很少”的类型,总复杂度瞬间退回O(M·N)=10^8级别。C++勉强能打,Python就非常悬。
出题人如果想卡掉大量靠“提前break”混分的代码,最有效的办法就是构造一堆冷门年龄区间的查询。我在一套对抗数据下实测,Python的截断版跑了3秒多,而同一份数据,下面要说的分桶多路归并版只用1秒左右。这个差距就是“能稳定AC”和“看数据脸色”之间的区别。
4.2 分桶思想
年龄范围很小,通常不超过200。我们可以在读入所有人之后,按年龄分成最多200个桶,每个桶内部按全局规则排序,也就是财富降序、年龄升序、名字字典序。于是每个桶都是自身的有序序列。对于一次查询,所有符合条件的富豪就是区间内各年龄桶的并集,并且每个桶内部已经有序。
现在问题变成:如何从若干条有序链中按“财富优先”的原则拿出前K个?经典做法是多路归并:用一个最大堆,堆顶始终是当前所有桶头部元素中规则序最靠前的一个;每弹出一个,就把同桶的下一个元素补进堆。重复K次,或者堆空为止。
因为K≤100,桶的个数≤200,每次查询的复杂度是O(K log A + A),不再依赖N。这才叫稳定。有人问为什么不用A路数组每次遍历找最大值?那样是O(A·K),A=200、K=100时每次查询2万次操作,M=1000就是两千万,也能过。但堆写法更优雅,而且能在面试里多聊几句。多路归并用堆的复杂度做到了O(K log A),堆内操作次数少,常数也低。
4.3 加强版核心代码
先给出C++桶的建法:
const int MAX_AGE = 205; vector<vector<Person>> bucket(MAX_AGE); for (auto &p : people) bucket[p.age].push_back(p); for (int a = 0; a < MAX_AGE; a++) { sort(bucket[a].begin(), bucket[a].end(), cmp); }然后查询时用优先队列。堆里每个元素需要知道三件事:来源桶、桶内位置、实际人物。我用一个小结构体把它们包起来:
struct Node { int age; int pos; Person person; }; struct NodeCmp { bool operator()(const Node &x, const Node &y) const { return !cmp(x.person, y.person); } };查询逻辑是这样:把区间内每个非空桶的第0个元素入堆;循环最多K次,每次弹出堆顶Node,它就是当前区间内剩下的最靠前的富豪,输出person;然后把这个桶里的下一个元素(bucket[age][pos+1])补进堆。如果堆为空,直接break。最后如果计数为0,打印None。
while (M--) { int K, amin, amax; cin >> K >> amin >> amax; priority_queue<Node, vector<Node>, NodeCmp> pq; for (int a = amin; a <= amax; a++) { if (!bucket[a].empty()) { pq.push({a, 0, bucket[a][0]}); } } int cnt = 0; while (!pq.empty() && cnt < K) { Node cur = pq.top(); pq.pop(); cout << cur.person.name << " " << cur.person.age << " " << cur.person.worth << "\n"; cnt++; int nextPos = cur.pos + 1; if (nextPos < (int)bucket[cur.age].size()) { pq.push({cur.age, nextPos, bucket[cur.age][nextPos]}); } } if (cnt == 0) cout << "None\n"; }这个方案的代价是什么?桶内排序总开销仍然是O(N logN),但查询段从O(N)变成了O(K log 200),在M很大时优势极其明显。代码量比截断版多大约二十行,换来的是对任何输入都有确定性的复杂度。我在复盘的时候发现一个规律:官方数据往往设计成截断版能过,但自己构造对抗数据时,一定要专门造“区间人少且K大”的用例。如果希望水平再上一层,直接写多路归并版,省得数据一换就翻车。
4.4 输入输出与常数优化补充
无论用哪种解法,IO优化都是超时题的命门。
- C++:代码开头写
ios::sync_with_stdio(false); cin.tie(nullptr);,输出用'\n'而不是endl。这一句就能把大输入下的耗时砍掉接近一半。 - 名字是字符串:读入时用string没问题,但如果习惯用scanf,要先声明
char name[16]再格式化读入,最后构造string。别贪省事在结构体里塞固定数组。 - Python:除了read()批量读入,不要在循环里反复调用print。攒成list,最后一次性join输出,这是收益最大且最容易被忽略的优化。
- 还有一个冷门点:C++排序比较函数里,如果每次比较都通过const引用拿实参,避免拷贝临时string值,能省掉不少无谓的开销。
5. 实测记录与排坑速查
5.1 三组对抗数据实测
我在一台普通开发机上做了三组实验,不是评测环境,只看相对趋势:
- 数据A:小样例,5个人3个查询,验证正确性。
- 数据B:随机最大规模,N=100000,M=1000,年龄均匀分布,K=100,区间宽度40。
- 数据C:卡截断数据,N=100000,M=1000,查询区间固定到只存在两个人的冷门年龄段,K=100。
结果大致如下(单位秒,不同机器差异很大,不用当作标准答案):
| 方案 | 数据B | 数据C |
|---|---|---|
| 暴力+C++ | 超时(>2s) | 超时(>2s) |
| 截断版+C++ | 0.18 | 0.62 |
| 分桶多路归并+C++ | 0.22 | 0.25 |
| 截断版+Python | 0.95 | 3.24 |
| 分桶多路归并+Python | 1.10 | 1.16 |
最值得看的是趋势:截断版在数据C上耗时翻了近四倍,多路归并版几乎不动。所以我的建议是:只是应付常见评测,截断版足够;想把这道题彻底吃透,直接上多路归并版,顺便把优先队列的用法练扎实。
5.2 六个高频坑位速查表
| 坑位 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 大输入卡IO | 小数据正常,大数据超时 | cin默认和scanf同步导致慢 | 加 ios::sync_with_stdio(false) |
| 区间无人 | 输出空白或多输出None | 没区分“区间无人”和“人不够K” | 用一个计数器,扫描结束仍为0就输出None |
| 排序规则错 | 输出顺序和样例对不上 | 财富同值没有继续比年龄和名字 | 三层比较全写上,别只写一层 |
| 比较函数缺等值 | sort抛异常或内存错误 | C++严格弱序要求等价时返回false | 每个字段相等时继续比下一字段,最终返回false |
| Python逐行打印 | 大数据全卡在输出 | print调用次数太多 | join后write一次 |
| 截断版被卡 | 冷门区间查询多 | 每次都要扫全表 | 分桶加多路归并 |
5.3 一套排查超时的标准流程
以后碰到任何一道题超时,可以按我的顺序排查,不敢说百发百中,至少能救回大多数情况。
第一步,回头读数据范围,把复杂度式子写出来,对比10^8这个经验阈值。如果有多个循环嵌套,找出瓶颈在哪。第二步,用二分法定位:在代码里给几个大段加计时,分别测读入、排序、查询、输出,看时间花在哪。很多人的超时其实是输出太慢,不是算法问题。第三步,构造对抗数据,别只拿随机数据测。专门造最坏情况,比如这道题里的冷门年龄区间,看你的提前break是不是真的提前。第四步,检查运行环境:C++开O2,Python换解释器有时也有奇效。
有时候你会发现,超时和逻辑错误是两回事。先保证小数据完全正确,再上大数据测性能,别一上来就在超时里改逻辑,容易把自己绕晕。
这道题刷完,我最大的感受是:标题里那句“注意运行超时的问题”不是玩笑,它直接决定了大多数人第一版的命运。我也写过暴力版交上去,被超时打回来之后才静下心算复杂度,再一步步从截断优化做到分桶多路归并。后来做其他Top K类题目时,顺手就用上了这套“预处理加多路归并”的思路,比如在数据流里维护频率Top K、在区间统计里取前若干名,效率都很稳。最后再分享一个小技巧:题目越像“简单排序”,越要警惕数据规模;凡是K很小又反复查询的场景,优先想怎么把排序结果缓存下来,而不是每次现排。把这个意识练成本能,比多背几道模板题管用多了。