1. 一次排序波动,让我重新理解“有序”
前几天我在调一个线上内容榜单,业务方给的数据流连着两天对不上。白天看热度正常,凌晨一刷新,前十名的顺序整个翻了个个儿。第一反应是算法算错了,查了半天发现算法没问题——问题出在“排序”本身。
我们给用户展示的榜,要求按一个综合加权分排序,但这个加权分的计算依赖多张表的数据,数据更新的节奏不一致。有的表一天一更,有的表准实时,最要命的是我们缓存了排序结果,缓存过期时间和数据更新时间正好错开。所以用户看到的“顺序”,其实是好几个时间切片被硬捏在一起的结果。
这个事让我想明白一个道理:我们平时说的“排序”,在真实系统里根本不是一个排序函数的事。它是一种对“秩序”的定义。什么算是“更靠前”?热度更高算靠前,还是发布时间更新算靠前,还是要给新内容一点加权?这个标准一旦定下来,后面所有技术动作都是在为核心标准服务。
这也正好配得上这次的标题,“排序:万物皆有序”。说到底,排序这件事从来不只存在于计算机的算法书里。图书馆要给书编号,是排序;早高峰地铁的排队,是排序;蜂群出巢采蜜的分工次序,是排序;你手机里几百个 App 在信息流里争夺你的注意力,背后还是一套排序逻辑在操控。
所以这篇博客我不打算只讲快排和归并。我想把“排序”这个东西掰开揉碎,先聊聊算法世界里的排序到底怎么回事,再聊聊工程落地时那些坑,最后聊到我的一些私心体会。不管你是刚入门不久的新手,还是已经写了几年代码的老人,这篇文章里应该都会有一两个能拿去直接用的点。
2. 十种排序算法的江湖与选择
如果说排序是计算机世界的一门基础手艺,那排序算法就是这门手艺的刀法门派。很多初学者都背过八种排序、十种排序,但背完就完了,不知道它们各管什么用。我换个方式讲——先不谈最优解,就谈它们在什么样的场景里能“舒服”地干活,什么样的场景里会“翻车”。
2.1 O(n²) 的三兄弟,都没有传闻那么不堪
先看插入排序、冒泡排序、选择排序这三个基础款。它们的时间复杂度是 O(n²),听起来很吓人,但实际工程里插入排序的地位相当稳固。原因是:当数据规模很小,比如几十个上百个元素,而且数据本身已经基本有序时,插入排序跑得飞快,常数小,没有额外内存开销。
冒泡排序在争奇斗艳的算法家族里显得很笨拙,只有一个场景它有存在感——让初学者一眼看懂“比较-交换”这个过程。我自己开玩笑叫它“教学排序”。
选择排序则是我最不推荐的一种。它每次选个最小的往前放,看起来很直观,但它的比较次数永远是 n²/2,不管数据有序还是乱序。它唯一的亮点是交换次数少,但在现代内存里,少几次交换省下来的时间远不如多出来的比较耗的时间。
这三兄弟在工程里都成不了主角,但是它们在“小规模数据兜底”上有不可替代的实用价值。很多成熟的语言库,做小数组排序时就靠插入排序兜底。
2.2 快排是天花板,但天花板也会漏水
快速排序大概是名气最大的排序算法。思路极简:选一个基准值,把数组分成小于基准和大于基准两半,再递归处理两边,合起来就是有序的。平均时间复杂度 O(n log n),常数小,在内存里跑起来非常快,所以它成了很多语言默认排序函数的底层实现。
但快排有一个“漏水”的地方:如果每次选的基准都正好是当前区间的最小值或最大值,它的递归就成了一条长链,时间复杂度退化成 O(n²)。有个专有名词叫“快排退化”。防止退化有很多办法,比如取随机基准、三数取中,或者像某语言标准库那样做双轴快排,同时规定一个递归深度阈值,超过阈值就改用堆排序收尾。
2.3 稳定与不稳定,差的不只是名字
归并排序和堆排序,两者的时间复杂度都是 O(n log n),但性格完全不同。归并排序是“稳”的,它把数组不停二分,再在合并阶段把两个有序子序列拼接起来。因为每次合并时,如果遇到相等的值,它总能保持左边子序列的元素在前,所以相等的元素相对次序不会被打乱。堆排序则是通过维护一个二叉堆反复取出最大元素,堆这个东西的“取最大”过程本身就带有随机移动,所以它是稳定性的反面教材。
为什么稳定性这么重要?举一个贴近业务的例子:一个商品列表,第一列是“销量”,第二列是“上架时间”,用户先按销量从高到低排,再按上架时间从新到旧排。你肯定希望销量相同的一批商品,内部还是按上架时间倒序排列。如果你用的排序算法不稳定,这一层嵌套次序就全废了。
2.4 线性时间的黑魔法:计数排序、基数排序与桶排序
最后提三个不是靠“比较”吃饭的算法:计数排序、基数排序、桶排序。它们都要求数据分布存在某种可利用的结构。比如计数排序,适合排序范围小、且值都是整数的一堆数据,比如 0 到 10000 之间的年龄数据。它的核心是开一个统计数组记录每个值出现的次数,然后前缀和算位置,一趟填完。基数排序则像洗牌,按低位到高位逐趟按位排序,每趟用稳定排序来搬。桶排序是把数据按范围分到若干桶里,桶内分别排序再拼起来。
这三个算法的优势是把 O(n log n) 的下界给破了,能在 O(n+k) 的时间完成,但缺点是空间开销大,而且对数据的分布规律有要求。工程上,如果待排序的数据是几千万条内存索引,或者需要排序的 key 正好是有限取值范围的主键 ID,这类线性排序会带来肉眼可见的性能飞跃。
2.5 一张表,解决 90% 的选择焦虑
与其纠结背口诀,不如把关键维度做成一张表。我平时选排序算法就看三件事:时间复杂度上限、是否稳定、空间开销。
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 是 |
| 插入排序 | O(n²) | O(n²) | O(1) | 是 |
| 选择排序 | O(n²) | O(n²) | O(1) | 否 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 否 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 是 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 否 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 是 |
| 基数排序 | O(d·(n+k)) | O(d·(n+k)) | O(n+k) | 是 |
| 桶排序 | O(n+k) | O(n²) | O(n) | 看桶内实现 |
还有一个很多人忽略的事实:快排在数据量大的时候未必永远比归并快,它胜在缓存友好,能更好地利用局部性原理。而外部排序,比如内存装不下的大文件排序,几乎都是归并的变种。这一点在后面的工程实战里还要展开。
3. 工程里的排序:稳定、内存与数据特征,一个都别落
概念聊完,进入真实工程。新手最容易踩的坑是:背得一手好算法书,到了项目里还是不知道用哪个。实际上成熟的工程里很少从零手写排序,多数时候用的是语言标准库里的排序函数,但要用好它们,你必须先想清楚环境约束。
3.1 语言内置排序都是“混血策略”
先说几种主流语言里排序函数背后的真相。
某动态语言的内置sorted,底层是 Timsort。这个算法是归并排序和插入排序的结晶:它会把输入切成若干段已经有序的“run”,再用归并把这些 run 合并,遇到小的 run 就用插入排序补齐。Timsort 有一个巨大优势:对现实世界中常见的“部分有序”数据,它经常能跑出接近 O(n) 的时间。
某静态语言的标准库sort则在不同版本间摇摆:老版本用快排变种,新版本把短序列的排序改成了插入排序,又加了堆排序救急方案,整个函数是一个策略组合体。所以你在项目里调用它,实际上用的是三四种算法的接力赛。
另一门语言的标准库sort.Slice底层是混合了插入排序、归并排序的跨时代实现 pdqsort,它在普通随机数组上跑出接近理论最优的性能,在几乎有序的数组上又能用探测手段减少比较次数。
这个现象说明什么?说明当年教科书里让你自己手写快排,更多是练习思维,而不是为了在项目里用它。
3.2 真正要关心的是排序稳定性与多关键字
工程里最常见的排序需求并非“把一组整数排好”,而是“把一组结构化对象按某个字段重排”。一旦涉及对象,稳定性就成了硬需求。我自己遇到过这么一件事:
一个订单表格,运营要求默认按“成交时间倒序”,但同一秒成交的订单需要按“订单金额更大优先”来排。如果底层排序不稳定,同一秒的订单可能会随机前后抖动,用户截图反馈表格顺序会“跳变”。最后方案是先按金额排序,再用稳定排序按时间排序,或者干脆一次排序同时比较时间字段和金额字段两种 key。
这里有个小技巧:如果你只能拿到一个不相等的排序接口,而它内部实现不稳定,你照样可以构造出稳定的假象——让比较函数在业务字段相等时,再比较一个自增序号。这样即使算法本身不稳定,它也无法区分两条记录的原始顺序,因为他们永远不会被判定为“相等”。
3.3 数据规模决定内存策略:外部排序不是摆设
内存里数据量到几十万条时,快排、归并随便选,性能差距都不大。但数据一旦上了几个 GB,或者从数据库导出的日志文件有几十个 GB,内存根本装不下,普通排序就废了。
这时候正统方案是外部排序。核心流程倒不复杂:把大文件切成多个能装进内存的块,每块排序后写到临时文件,形成若干个有序片段,然后用一个多路归并把它们逐一合并成完整的有序文件。归并过程中,内存只需要保留每个片段当前的最小元素,再维护一个最小堆,每次从堆顶弹出全局最小,写入最终文件,再从对应片段读下一个元素补进堆。
我曾在一次日志处理任务中对比过:同样一份 8 GB 的访问日志,用脚本直接sort跑内存排序,还没开始就 OOM 了;改成流式外排,内存限制到 500 MB,跑完只用了两分钟多一点。这个差距在真实生产环境里是非常直观的。
3.4 数据库里排序的隐性成本
很多人的第一次排序实战是写 SQL:ORDER BY,看起来最简单,其实暗藏坑最多。表里如果没有合适的索引,数据库就得把结果集捞出来做一次显式排序,在数据量大的时候这一下可能把慢查询拖到几秒甚至几十秒。
我建议把ORDER BY当成一次“面向索引的查询设计”。要让排序走索引,你就得让排序字段和筛选条件能用同一个复合索引覆盖,并且方向要一致:比如索引是(category, updated_at DESC),查询里也按这个方向和顺序排,数据库就能直接从索引里读有序数据,连排序步骤都省了。
对于确实无法走索引的排序,一个常用优化是先缩小结果集再排,或者把大排序拆到离线任务里,把排好的结果提前物化成一张中间表,线上查询直接读物化结果。
3.5 流式榜单与实时 Top-N:不要老想着“全排”
榜单是排序需求的高发区。很多人接到需求就写“取全部数据,排完序,截前 100 名”。这个写法在数据量小的时候无所谓,但一旦到百万级甚至千万级,每次都全排就是灾难。
正确姿势是维护一个大小为 N 的最小堆。每进来一条新数据,就拿它和堆顶比,如果它比堆顶更大,就替换堆顶,然后重新调整堆。这样你永远只需要保留当前最大的 N 个,时间复杂度 O(U log N),U 是数据总量,不需要把 U 个数据全部排好。
实测下来,百万级数据取 Top 100,全排序耗时在秒级,而最小堆方案在十几毫秒到几十毫秒之间。像游戏排行榜这类实时性要求高的场景,都建议用这个方案。如果还要考虑过期淘汰、降权,就得在堆之外再维护一个索引结构了。
4. 跑得快的秘诀:排序通常只是第一步,也是最值钱的一步
有一部分人把排序当成终点:排完,交付,完事。但实际开发里,排序更常见的角色是“预处理”——先把数据理顺,后面那一串算法才有施展空间。这一节我要讲的是怎么把排序当成杠杆用。
4.1 二分查找的前提,就是有序
如果你有一组无序数据,想找某个值,只能线性遍历,O(n);如果这组数据先排好序,二分查找能把复杂度压到 O(log n)。log n 和 n 的差距,在数据量到千万时是几十倍。
我知道你要说“我有哈希表,O(1) 不就完了”。没错,哈希表适合等值查询,但它做不了范围查询,比如“找出价格在 100 到 200 之间的所有商品”。范围查询最自然的实现就是维护有序结构,树或有序数组二分查。数据库里为什么用 B+ 树而不只用哈希索引,核心原因就在这。
所以如果下次面试官问你“为什么很多算法要先排序”,最好的回答不是背“线性时间内的两数之和可以先排序再用双指针”,而是说清楚排序把无序数据转化成了具备“可推断性”的结构,很多问题在这个结构上都有了更快的解法。
4.2 排序是去重与合并的免费午餐
去重问题的一个笨办法是双层循环比较每一对元素,O(n²)。聪明一点的办法是哈希表 O(n)。但还有一个不那么显眼但非常好用的办法:先排序,再从头到尾扫描一遍,遇到相邻相同就跳过。这样去重的时间是排序的 O(n log n),空间几乎不用额外开销。
合并两个有序数组也同理:如果你提前把两组数据排好序,用类似归并的双指针就能在线性时间内合并出有序结果,而且还能顺带统计出“两组数据的差异元素”。我在给某跨平台系统做配置数据同步时,两边各十几万条配置,直接哈希对比常出现内存压力,改成排序后归并对比,内存降了一半,速度反而上去了。
4.3 求 TopK、中位数和逆序对,都是排序的衍生问题
回到排行榜场景。有很多问题看起来和“排序”无关,但它们的标准解法都建立在有序性的思想上。
求中位数:如果数据已排好序,直接取中间下标,复杂度 O(1)。经典“双顶堆”维护中位数的方案,说到底就是维护两个有序半区,一个最大堆放较小的一半,一个最小堆放较大的一半。
求逆序对数量:一个数组里有多少对元素满足 i<j 且 arr[i]>arr[j]?暴力是 O(n²),但用归并排序的合并过程,可以在左右两边合并时顺便统计跨越两边的逆序对,总复杂度 O(n log n)。这一步的“免费”体现在:归并本身就要做,你只是多写了几行计数。
def merge_sort_count(arr, left, right): if right - left <= 1: return 0 mid = (left + right) // 2 cnt = merge_sort_count(arr, left, mid) + merge_sort_count(arr, mid, right) i, j, k = left, mid, [] while i < mid and j < right: if arr[i] <= arr[j]: k.append(arr[i]); i += 1 else: k.append(arr[j]); j += 1 cnt += mid - i k.extend(arr[i:mid]); k.extend(arr[j:right]) arr[left:right] = k return cnt这段代码里最关键的是cnt += mid - i这一行——在合并时,只要右半边的元素比左半边的当前元素小,那左半边剩下的所有元素都比它大,一次就能数出一整段逆序对。
4.4 分布式场景下的排序:shuffle 与全局有序
当单机内存不够时,排序就需要跨机器。分布式计算框架里最经典的一步叫 shuffle:上游把数据按 key 分区,同一个 key 的数据全部送进同一个 reducer,然后在下游局部排序。如果你要的是全局有序,就得先做一次采样估算 key 的分布,再做范围分区,保证第一个分区里全是“小”的,最后一个分区里全是“大”的,最后按区号拼接。
这个方案的一个隐藏难点是数据倾斜。如果某个 key 的值特别多,对应分区就会比别的分区大很多,整个任务卡在长尾上。我经历过一次十几亿行的排序任务,就是因为一个“默认值”字段占了三分之一的数据,所有数据全挤进一个分区,跑了三个小时才结束。后来在分区前先加一层随机前缀打散,排序结束后再按真实 key 排序,总耗时直接降到四十分钟。
4.5 排序与数据压缩的意外结合
最后说一个很多人不知道的小实践:有序数据天然更适合压缩。相同前缀的数据会连续出现,字典压缩的重复率大幅提高,游程编码在有序数据上几乎能逼近理论极限。
我做过一个实验:同一份 1 GB 的生产日志,压缩前先按时间排序,再走同一种压缩算法,压缩后体积比未排序版本减少了近三成。因为日志里相同的服务名、路径、状态码会聚成连续的大块,压缩器的字典命中率高得多。所以看到海量文件归档的任务,别急着一上来就压,先排序再压缩,有时候是最省事的“优化”。
5. 比“怎么排”更难的,是定义“什么才算有序”
讲到这里,技术上的排序基本聊完了。但我想把标题里“万物皆有序”这句话再往深挖一层:计算机里的所有排序,都需要一个比较函数来回答“谁在前”。这个比较函数,才是整个排序系统里最接近“价值观”的部分。
5.1 比较器里的哲学问题
写代码的人都有过这种体验:用Comparator.comparing(x -> x.getScore()).reversed()就能给一堆对象按分数降序排列。看似简单,但分数相同怎么办?要不要加第二关键字?加第三关键字?这个决策本身就是一种价值排序。
我在做一个某内容社区的信息流排序时,最初产品定的规则是“按热度倒序”。后来发现这个规则独宠老内容:老内容积累的总热度永远比新内容高,新的优质内容永远浮不上去。后来改成“按热度衰减后得分排”,本质是在比较规则里加入了一个时间衰减因子。代码上这不困难,困难的是团队得先一致认可:我们要的“有序”,是绝对热度最大,还是单位时间受人欢迎的程度。
如果业务方说“都重要,你看着写”,那这个排序系统上线之日就是埋雷之时。我建议在接需求时,把排序规则拆成三个层面的问题打包问清楚:
- 一级指标是什么:主排序字段,决定大方向。
- 并列时怎么办:二级排序字段,保证结果稳定可预期。
- 要不要时间衰减:衰减周期多长、幅度多大,是产品策略,不是技术参数。
5.2 生活中的“排序标准”往往比算法更稀缺
离开代码,回到生活。我们每个人的一天也是一个排序任务:有限的时间,一项项待办,怎么排优先级?大多数人的默认排序是“紧急程度”,谁催得紧谁先做。但这个标准持续用一段时间就会发现,你整天在救火,真正重要的长期计划永远被挤到最后。
后来我把排序标准从“紧急程度”改成了“重要程度 × 截止时间影响”,相当于比较函数加了一个加权因子。短期的确会漏掉一些不太重要的急事,但长期看,核心目标推进带来的收益远比琐碎事务大。这种调整,和代码里给比较器加时间衰减因子是同构的。
信息流软件也是同样的逻辑。你刷到的每一条内容都经过后台一次排序,这个排序的“价值函数”决定了你是看到更多娱乐内容还是更多知识类内容。说到底,我们每天都在被别人的比较函数安排着生活,而大多数人对此毫无察觉。
5.3 环境上的“有序”能节省巨量心智
还有一类排序经常被忽略——空间上的整理。文件命名按时间前缀排序,编程时变量声明按生命周期分组排好,办公室桌面把常用工具放在固定位置。这些做法的本质都是降低查找成本,让你从每天多次的 O(n) 遍历,变成 O(1) 的定点取用。
我有个习惯:代码文件头部的 import 顺序永远按字母排,类成员的声明顺序永远按“常量、静态变量、实例变量、构造函数、公共方法、私有方法”排列。这不是强迫症,而是当文件大到上千行时,这种固定次序能让后来接手的人少花大量时间找东西。代码规范里这叫“一致性”,本质上就是一种排序约束。
5.4 判断一个排序系统是否成功,看它能否经得起异常值冲击
在实际操作中,我判断一个排序方案是否成熟,会做“脏数据测试”:插入一个负数分数、插入一个超大字段、插入空值,看排序会不会崩溃、会不会把空值排到最前面、会不会因为默认值导致前几名全是异常的。
处理这些边界,比较器里需要写显式的空值策略:排前面还是排后面?异常值警告还是静默吞掉?这些规则不是算法能替你决定的。我在多个项目里见过因为空值默认排最前,导致线上榜单首页直接出现“未知来源”内容的问题。归根结底,排序算法负责把标准执行得又快又稳,但“标准是什么、异常怎么处理”,一定得人先想清楚。这一环节偷懒,后面写多少行代码都填不上。
写在最后:给排序一点“啰嗦的耐心”
这篇博客从一次线上榜波动开始,一直聊到算法复杂度、工程取舍、分布式陷阱,再到比较器里的价值观。回头一看,排序真的不只是 sort 一下的事。
就我个人体会而言,这几年写代码,最有效的性能优化往往不是把某个排序换成一个更快的排序,而是想清楚这个排序是不是必要的:能不能省掉?能不能下推到数据库?能不能从 O(n log n) 的全排改成 O(n log m) 的 Top-N?甚至能不能用一个天生维护有序性的数据结构,比如跳表和二叉堆,把排序摊到每次写入里,读的时候就秩序井然了。
我也建议刚入行的朋友,别急着背诵十种排序的代码,先拿一份十万条数据的真实文件,用内置排序跑一跑,再手写一个快排对比,然后逐步将场景改成“取前 100 名”和“按多个字段稳定排序”,体会每一步的实际耗时和内存变化。这些亲手的对比,比看十篇博客都更能建立你对排序的直觉。
排序是一种把混乱变成秩序的思维习惯。愿你在代码里,也在生活里,都能找到那个让自己舒服、也让事情成立的比较函数。