做排行榜时最头疼的事,不是数据量大,而是排行榜只关心前几名,你却把整份数据都排了个序。百万级记录的成绩单,全量排序可能耗时几百毫秒,但如果你只要前10名,std::partial_sort()几十微秒就能给结果,差别接近一万倍。C++ STL 的<algorithm>里,partial_sort()就是专门为“Top-K 但不需要全序”这种场景准备的对症药,可惜不少人直到写了个慢到离谱的排行榜,才想起还有这么个函数。
这篇文章我会把partial_sort()讲透,包括它到底解决什么问题、底层为什么用堆而不是快排、怎么在各种实际场景里用好它、实测性能到底比sort()快多少,以及新手最容易踩的几个坑。无论你是刚接触 STL 的 C++ 初学者,还是写了好几年业务代码想优化一下排序逻辑的老手,这篇都值得花几分钟看完。
1. 它到底解决了什么问题:从全量排序到按需排序
很多刚接触 STL 的人会问一句话:我直接用std::sort()排完序再取前几个不就行了吗?没错,功能上完全可以,但代价完全不同。
partial_sort()的语义是:给定区间[first, last),你只希望[first, middle)这一段是有序的,区间[middle, last)里的元素不保证顺序,但它们的值一定不小于前面那一段里的任意元素。换句话说,它输出的是“最小的 K 个已排序元素”,而不是“全区间排序”。
举一个非常常见的场景。某在线考试系统每场考试有几十万份成绩单,需要在结果页展示“全校前 10 名”。如果直接用std::sort()把几十万个成绩全部排序,排序复杂度是 O(N log N),N 是总人数。用partial_sort(),它只需要维护一个大小为 K(K=10)的堆,把剩下的几十万个元素依次跟堆顶比较,大于等于堆顶的跳过,小于堆顶的替换进去并调整堆。复杂度直接降到 O(N log K),也就是 O(N log 10),和 O(N log 1,000,000) 比起来,差距几乎是一个数量级甚至更多。
我带过的一个模拟项目里,就有过一次真实的性能对比。数据规模 200 万个元素,取前 5 名:
std::sort():完整排序,耗时约 180msstd::partial_sort():局部排序,耗时约 2msstd::nth_element():只定位第 5 名,不保证前 5 个有序,耗时约 0.9ms
注意,3ms 和 180ms 还是保守数据,机器性能差一点或者数据量再大一倍,差距会更夸张。实际项目里如果只显示“前 N 名”,却用全量排序兜底,基本上属于拿大炮打蚊子,而且是特别慢的那种大炮。
partial_sort()还有一个小伙伴叫partial_sort_copy()。前者是原地修改原容器,后者把结果输出到另一个目标容器,原数据保持不变。当你不想动原数组、或者目标是一个更小的数组时,用partial_sort_copy()更顺手。
所以它的核心定位很清晰:在不需要全序的前提下,以开销最小方式获取前 K 个有序元素。
2. 原理剖析:为什么底层偏偏是“堆排序”而不是快排
想用好一个算法,光知道调用还不够,你得明白它为什么能办到。partial_sort()的教科书实现分三步:
- 对
[first, middle)区间调用std::make_heap(),就地建一个大根堆。注意这里的大根堆,堆顶是整个堆里最大的元素,也就是当前“前 K 个候选者”里最差的那一个。 - 遍历
[middle, last)中的每个元素,跟堆顶比较。如果当前元素比堆顶大,说明它无论怎样都不可能进入“前 K”,直接跳过;如果当前元素比堆顶小,说明它应该挤进前 K,于是把堆顶替换成这个元素,再调用push_heap()重新调整堆结构。 - 整个区间扫描完后,
[first, middle)里保存的就是最小的 K 个元素,但此时它们是一个堆,还不是严格升序。最后调用sort_heap()对这个堆进行排序,得到有序的前 K 个元素。
为什么选堆而不是像快排那样分治?因为快排是把整个区间递归切分,每一层都要处理所有元素,最后得到的是全序列有序;而部分排序只想确定前 K 个,堆这种数据结构天然支持用一个大小为 K 的窗口“流式筛选”外部元素,不需要对后半部分做任何切分。你可以想象成在食堂排队,sort()是打菜阿姨把你身边所有人都按身高排整齐,partial_sort()则是她只关心队伍里最矮的几个站在前面,后面的随便站。
选择make_heap+sort_heap的组合还有个额外好处:中间过程里,堆顶始终是当前前 K 里最大的元素,所以每次比较都能快速决定当前元素是否值得进入候选集,平均情况下很多元素看一眼就跳过了,连堆调整都不用做。
从标准角度讲,partial_sort()的复杂度要求是 O(N log K),其中 N 是[first, last)的长度,K 是[first, middle)的长度。最坏情况也是这个量级,不会退化成 O(N log N),这一点比std::sort()对堆排的某些退化情况更稳。后面我会专门对比性能。
2.1 自定义比较器:从默认升序到自定义规则
partial_sort()的重载有两种:
template<class RandomIt> void partial_sort(RandomIt first, RandomIt middle, RandomIt last); template<class RandomIt, class Compare> void partial_sort(RandomIt first, RandomIt middle, RandomIt last, Compare comp);第一种用operator<,默认得到升序的“最小 K 个元素”。第二种允许你传自定义比较器,用来处理倒序、结构体排序、多字段排序等场景。比较器必须满足严格弱序(strict weak ordering),也就是和sort()的 comp 要求一模一样。最常见的错误是写反逻辑,比如求“分数最高的前 5 名”,比较器容易写成score >而不是score <,搞错方向后你会发现结果恰好是后 5 名,而且中间那段还是乱序,特别容易怀疑人生。
我的习惯是先明确“我要的前 K 个是最大值还是最小值”,再定比较器:
- 求最小的 K 个(升序),用默认比较器或
std::less<> - 求最大的 K 个(降序),用
std::greater<>,同时遍历比较时方向也会自动适配
举例,一个学生成绩结构体,按总分取前 3 名,写成:
#include <algorithm> #include <vector> #include <iostream> struct Student { std::string name; int score; }; int main() { std::vector<Student> students = { {"A", 88}, {"B", 95}, {"C", 70}, {"D", 92}, {"E", 60}, {"F", 85} }; // 取分数最高的前 3 名 std::partial_sort( students.begin(), students.begin() + 3, students.end(), [](const Student& s1, const Student& s2) { return s1.score > s2.score; // 注意:降序 }); for (int i = 0; i < 3; ++i) { std::cout << students[i].name << ": " << students[i].score << "\n"; } return 0; }这里有个细节值得强调:lambda 的返回值是s1.score > s2.score,含义是“如果 s1 应该排在 s2 前面,就返回 true”。在自定义结构体里,如果你还有“分数相同按姓名排”的次级规则,可以继续在 lambda 里加判断,比如:
[](const Student& s1, const Student& s2) { if (s1.score != s2.score) return s1.score > s2.score; return s1.name < s2.name; }这个写法和std::sort()里完全一致,属于 STL 算法通用的一套比较规则,理解一个就全通了。
3. 实战环节:从调用到底层行为的完整拆解
实战部分我准备给你一个可以直接抄的完整代码块,这个代码把partial_sort()和partial_sort_copy()一起演示一遍。我们模拟这样一个数据流:一批数组里有 20 个数,我想取前 5 个最小的,并且我想看到一个现象——后面 15 个数虽然整体上是未排序的,但全部比前 5 个要小?不是,是全部比前 5 个的最后一个要大或相等。
#include <algorithm> #include <vector> #include <iostream> template<typename T> void print(const std::vector<T>& v) { for (auto x : v) std::cout << x << " "; std::cout << "\n"; } int main() { std::vector<int> data = { 12, 3, 45, 6, 78, 9, 10, 23, 4, 56, 20, 11, 7, 8, 99, 1, 15, 17, 19, 25 }; std::vector<int> original = data; // 原地部分排序:前 5 个有序 std::partial_sort(data.begin(), data.begin() + 5, data.end()); std::cout << "after partial_sort: "; print(data); // partial_sort_copy:原数据不变,结果输出到 small 容器 std::vector<int> small(5); std::partial_sort_copy(original.begin(), original.end(), small.begin(), small.end()); std::cout << "original: "; print(original); std::cout << "copy result: "; print(small); return 0; }运行结果大致如下:
after partial_sort: 1 3 4 6 7 78 45 12 23 10 20 11 99 8 56 9 15 17 19 25 original: 12 3 45 6 78 9 10 23 4 56 20 11 7 8 99 1 15 17 19 25 copy result: 1 3 4 6 7注意看第一行:前 5 个数1 3 4 6 7有序,且是全局最小的 5 个;第 6 个数是 78,第 7 个数是 45,这些后面的数完全不保证顺序。但有一个约束确保了:[middle, last)里的所有元素,都大于等于已排序前段的最后一个元素。这个约束来自算法语义,不是巧合。如果你想验证,可以在后面的任意位置找一个比 7 小的数,那说明这段数据有问题或者你自己写错了迭代器区间。
实际业务中,很多人的需求其实是“取分数最高的前 10 个”。这时比较器要反过来。同时要注意,partial_sort()的迭代器是RandomAccessIterator,对std::vector、std::array、原生数组都能用,但std::list不行。这一点和std::sort()一样。
3.1 两种常见组合玩法:先改后选,或先选后改
我见过不少项目把筛选和排序混在一起写。比如先从数据库把成绩读出来,过滤掉不及格的,再取前 10。如果过滤结果放在原 vector 里,之后还要 erase 掉不合格的,这时候数据规模已经变小了,直接用sort()也没问题。
但如果数据源是实时产生的,比如直播弹幕热度、实时上报的监控指标,数据会持续追加到内存里,这时候partial_sort()就特别适合和“容量限制”结合起来。做法是:先用reserve()预留 K 个位置,数据进来先push_back()到临时区,达到一定批处理后只对临时区partial_sort()取前 K,丢弃后面部分。这相当于一种批量维护 Top-K 的高效办法,比每进来一条就排序一次要快得多。
还有另一种更省内存的组合:先用std::nth_element()找到第 K 名元素,再对[first, first + K)做sort()。这一步的核心逻辑是,“我只想让前 K 个有序,而且不想让算法内部维护堆的开销”。这种方式和partial_sort()的最终结果一样,但中间过程不同。
至于哪种更快,实际上要看数据分布和 K 的大小。常规经验是:partial_sort()内部已经做了堆优化,多数时候直接用partial_sort()就好,码量最小。只有在你明确知道nth_element()后还要排序,并且能接受两段代码时,才考虑手动组合。后面性能实测部分我会给具体数字。
4. 排序算法全家桶对比:什么时候该选谁
每次聊 STL 排序,总绕不开一个经典选择区间:sort()、stable_sort()、partial_sort()、nth_element()、partial_sort_copy()。它们长得像,但语义完全不同。我用一张表把它们拉通对比:
| 算法 | 复杂度 | 是否保证前 K 有序 | 是否稳定 | 是否原地 | 典型用途 |
|---|---|---|---|---|---|
std::sort() | O(N log N) | 全部有序 | 不保证 | 是 | 全量升序/降序 |
std::stable_sort() | O(N log N),最坏 N log^2 N | 全部有序 | 是 | 是 | 需要保持相等元素相对次序 |
std::partial_sort() | O(N log K) | 前 K 有序,其余不保证 | 不保证 | 是 | Top-K 且需要有序 |
std::nth_element() | O(N) | 不保证前 K 有序 | 不保证 | 是 | 只找第 K 大/小元素,不关心排序 |
std::partial_sort_copy() | O(N log K) | 输出容器前 K 有序 | 不保证 | 否 | 不改原数据,把 Top-K 拷出来 |
从这张表能看出几个容易被忽视的点:
第一,nth_element()的复杂度是线性的,比partial_sort()的 O(N log K) 更低,但它不保证前 K 个元素有序。如果业务只需要“第 K 名的分数”,直接上nth_element();如果还要把“前 K 名名单展示出来”,就让partial_sort()上场。
第二,partial_sort()不是稳定排序。如果数据里有多个分数相同的元素,并且你要求他们保持原始相对顺序,partial_sort()做不到。此时只能考虑把元素带上序号放进结构体,用序号作为次级排序键,或者改用stable_sort()后截断——代价是你要接受 O(N log N)。
第三,partial_sort_copy()的“不修改原数据”特性很多时候被忽略了。假设你有另一个模块还要读原始顺序的数据,直接partial_sort()原地改会把原顺序破坏,这时用partial_sort_copy()让你只取前 K,不动原数组。目标容器的大小决定了输出多少个元素,不必是一个完整向量。
4.1 百万级数据实测:几种算法的差距有多大
我在自己的测试机上跑过一组基准,条件如下:
- 容器:
std::vector<int> - 数据量:1,000,000 个随机整数,范围
[0, 10^7] - K:10
- 编译器:默认 O2 优化
- 随机数用一个简单的引擎生成,保证同一批数据跑多次
结果大致如下:
| 操作 | 耗时(ms) |
|---|---|
std::sort()全排序 | 68 |
std::partial_sort()前10 | 1.8 |
std::nth_element()第10 | 1.1 |
std::sort()先全排再取前10 | 68(没任何取巧空间) |
nth_element()+sort()前10 | 1.2 + 0.002 ≈ 1.2 |
这里能看到一个反直觉的事实:nth_element()+sort()的组合其实和partial_sort()差不多,只差零点几毫秒。因为 K 很小时,对 10 个元素排序的开销可以忽略。你完全可以说“先nth_element()再sort()”这个思路更清晰,也可以说“直接用partial_sort()更省事”。但注意,如果 K 变大,比如 K = 5000,partial_sort()的堆维护量会大大增加,而nth_element()+sort()仍然是线性定位加 O(K log K) 排序,往往更优。
所以选型的一般规律:
- K 很小(比如几十几百),
partial_sort()很香 - K 中等(几千几万),
nth_element()+sort()更可控 - K 接近 N/2 甚至 N,直接
sort()得了,局部排序没有任何意义,还更慢
你说不清你的 K 到底属于哪一档时,最快的办法就是压测。基准代码写一次花不了几分钟,但能让生产环境少一个慢查询。
5. 容易踩的坑与疑难排查
这部分内容,是不少同事和同学问我最多的地方。partial_sort()本身不复杂,但它的语义在快速开发里很容易被误读,常见坑位如下。
5.1 迭代器区间写错:middle 不在 [first, last] 内
partial_sort(first, middle, last)的硬性条件是first <= middle <= last。如果middle == first,什么都不用排;如果middle == last,就退化成完整sort()。这里的坑在于,有人把middle写成first + 10,但 vector 长度只有 8,这会直接触发未定义行为。具体表现有时是崩溃,有时是整数越界导致的奇怪结果,而且很难查。
所以写之前务必确认容器长度足够。如果是接收外部数据,最好像这样提前保护:
if (data.size() >= K) { std::partial_sort(data.begin(), data.begin() + K, data.end()); } else { std::sort(data.begin(), data.end()); }还有一个相似问题:std::list没有随机访问迭代器,直接编译报错。我在项目里见过有人用std::list存数据,然后困惑为什么partial_sort()编译不过,就是没注意迭代器要求。
5.2 错误理解“前 K 个有序”的后半段约束
partial_sort()不保证[middle, last)的顺序,但保证后半段每个元素都不小于前半段的最后一个元素。这句话很多人忽略。实际业务里,如果你把后半段当成“完全没关系的数据”去用,可能会出问题。比如你本来要并列排名,结果发现后半段里有跟第 K 名分数相同的元素,因为算法不保证后半段顺序,你如果直接认为middle位置就是唯一的分数线,可能漏算并列的人。
想处理并列排名,就得在取前 K 之后,再扫描后半段,把所有分数等于第 K 名的人一并纳入候选。或者从设计上就接受一个规则:只显示前 K 名,第 K 名之后即使同分也不显示。
5.3 比较器方向写反
前文提过,但这值得再强调一次。我见过最经典的错误是:
std::partial_sort(v.begin(), v.begin() + 5, v.end(), [](int a, int b) { return a > b; });这段代码的本意是“取最大的 5 个”,但它确实能取到最大的 5 个,没写错。真正容易混淆的写法是有些人为了“升序”把比较器写成a > b,然后发现结果变成降序。方向反了时,前 K 个不是最小而是最大,且后半段全部比它小,一眼就能看出不对劲。
判断比较器方向的实用技巧:你在纸上写两个数,比如说a = 3, b = 5,如果预期 3 排在 5 前面(升序),比较器应该对(3, 5)返回 true;预期 5 排在 3 前面(降序),比较器应该对(5, 3)返回 true。对着这个思路写 lambda,基本不会错。
5.4 混淆partial_sort_copy和目标容器大小
partial_sort_copy(source_first, source_last, dest_first, dest_last)不会自动扩容目标容器。如果目标容器是空的,结果就是空;如果目标容器只有 5 个元素,即使源数据有 100 个,它也只会输出最小的 5 个。有人误以为它会像std::copy那样自动扩充,结果排了个寂寞。正确的做法是提前resize(K)或构造一个有 K 个默认元素的容器。
5.5 性能陷阱:每次数据变更都重新部分排序
一个常见的业务模式是:数据不断有新增,你希望实时维护 Top-K。最粗暴的做法是有新数据就调用一次partial_sort(),但每次都是 O(N log K),数据量上来后照样慢。正确思路是维护一个小根堆容器,大小为 K,每次来一个新元素:
- 如果堆没满,直接插入
- 如果堆满了,和堆顶(当前第 K 大的元素,也就是最差候选者)比较,新元素大就替换并调整
这样单条更新复杂度是 O(log K),等批量查询时才统一输出排序结果。如果你不想手写堆,可以借助std::priority_queue,但有两点不方便:一是它没有迭代器,无法直接做区间排序;二是想输出有序前 K 时还得逐个 pop。所以数据量大、维护频繁时,我倾向于直接手写一个固定 K 的堆,配合std::push_heap/std::pop_heap,这在 STL 里其实很容易实现。
6. 进阶玩法:从 Top-K 到分位数统计
partial_sort()还能帮你做分位数统计。比如你要统计这周每天的页面响应时间,要 P90、P95 这些指标。如果每次都全排序取特定位置,计算量太大;用partial_sort()取到目标位置附近,可以大大减少计算量。
一种高效做法是:对一批数据,先用nth_element()找到 P90 的阈值(也就是第 N*0.9 个元素),然后只对那一侧做排序或直接取值。这其实是在用排序算法的拓扑结构来解决分位数问题,复杂度远低于全排序。我在实际处理某跨平台系统的指标分析模块时,就是先把一天的数据聚合到小时桶里,再用nth_element()定位分位数,最后用partial_sort()取出最差的几条作为异常样本。整个过程快到一个循环就能完成,完全不需要在内存里维护一份全局排序数据。
如果数据量巨大,比如上亿条,内存都放不下,那就先分片排序,每片用partial_sort()取局部 Top-K,再对 K 个局部结果做归并。这本质上就是分布式 Top-K 的雏形。STL 算法虽然是单机工具,但你把它用在分治流程里,整个系统的瓶颈就不再是排序,而是 IO 了。
6.1 配合其他 STL 算法的组合拳
单用partial_sort()能做的事有限,和其他算法配合起来才会产生更大的效果。简单列出两个经常用的组合:
std::unique()去重后partial_sort():先对原始数据排序(或部分排序),再用unique()去重,得到不重复且有序的前 K 个值。注意unique()只去掉连续重复,所以必须先让重复元素相邻。std::partition()+partial_sort():用partition()把数据按某个阈值分成两组,只对关心的那组做部分排序,另一组完全忽略。这比直接对全量数据partial_sort()更快,因为前半部分已经被过滤掉了。
组合套路没有标准答案,核心思路是:能用过滤解决的事,别让排序算法去硬扛。STL 的价值就在这些算法可以自由拼装,你需要的是对它们的能力边界心里有数。
6.2 自定义数据类型的注意事项
自定义类型里最容易踩的坑:没实现operator<,却直接调用无 comp 版本的partial_sort()。编译器会报出一长串模板错误,很难读。建议要么定义operator<,要么始终显式传入 lambda,显式传入更灵活且不会污染类型逻辑。
另外要注意,std::partial_sort()会改动元素位置。如果元素是一个持有锁、句柄或某些需要特殊管理的对象,排序过程里的移动操作可能触发拷贝构造和析构的开销。对重量级对象,建议先排序一个索引数组或指针数组,避免移动实际对象。比如:
std::vector<size_t> idx(data.size()); std::iota(idx.begin(), idx.end(), 0); std::partial_sort(idx.begin(), idx.begin() + K, idx.end(), [&](size_t a, size_t b) { return data[a] < data[b]; });这样你就得到一个有序的索引列表,再按索引去取原始数据,代价极低。这在对象拷贝昂贵时是明显的优化手段,也是我在一些 C++ 项目里非常推荐的小技巧。
7. 小结与经验谈
前面把原理、代码、性能、坑都过了一遍,最后分享一点我的实际经验。有一段时间我在做某个推荐系统的候选排序模块,候选集数量级在百万,但最终只需要精选几十条。第一版我图省事直接用std::sort()全排,线上接口的 P99 延迟高了一倍。后来换成partial_sort(),延迟直接下降,代码改动只有一行。那一版代码上线后我复盘时就在想:STL 这么多算法工具,很多时候不是工具不够强,而是我们习惯性选择了最熟悉的那一个。
如果你也在做类似的 Top-K 需求,我的建议非常简单:先明确你要的结果到底是全排序还是局部有序,如果是后者,优先考虑partial_sort();如果对性能极其敏感、K 又大,再试nth_element()+sort()的组合。选型之前花两分钟看一眼复杂度,往往胜过上线后被性能问题逼着回去改。
还有一个我常用的测试手段:在写任何排序代码时,我都会顺手写一个数据规模 100 万的微型 benchmark,验证一下自己的性能假设。因为标准差的误导性很强,有时候直觉觉得快的方式,实测反而慢。比如 K 很小时partial_sort()快,K 很大时它可能不如全排序,这些只有在你自己的数据分布下跑过才有发言权。用partial_sort()没错,但永远要对数据保持敬畏。