ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

八大排序算法核心特性详解:复杂度、稳定性与工程选型指南

八大排序算法核心特性详解:复杂度、稳定性与工程选型指南 排序算法这东西说实话是很多程序员既熟悉又陌生的一块。面试的时候能背出快排、归并的时间复杂度但真放到项目里选型往往只能“凭感觉”——数据量小就用插入排序数据量大了要么无脑快排要么直接调库。排序算法到底有多少种特性值得关注为什么同一个算法在不同场景下表现天差地别这篇内容我会把八大排序算法的核心特性拆开揉碎从原理推导到工程选型逐个讲透适合正在刷算法题的应届生、做底层库优化的研发以及所有想摆脱“只会调sort”状态的开发者。1. 排序算法特性指标怎么读才不算白看1.1 时间复杂度先看懂数据规模下的增长速度网上到处是“冒泡O(n²)、快排O(n log n)”这种复杂度表但很多人没想过这些符号到底意味着什么。O(n²)和O(n log n)在n10的时候几乎没差别但n到了10万差距就大到离谱。我拿实际数据做个对比一台普通笔记本上冒泡排序处理10万元素大概要十几秒甚至更久而归并排序或快排通常几百毫秒内就能跑完。这不是优化层面的差距是算法本质上的增长速度差异。理解复杂度的关键不在于背住哪个算法是O(n log n)而在于弄清楚“最好情况、平均情况、最坏情况”这三个档位为什么会产生分化。以快排为例它的复杂度完全取决于基准值pivot选得怎么样每次都能把数组分成均匀两半递归深度是log n总复杂度是O(n log n)每次都选到最小或最大元素递归退化成n层复杂度直接掉到O(n²)。这就是为什么工程上的快排实现都要做随机化或者三数取中而不是偷懒直接拿第一个元素当基准。1.2 空间复杂度原地排序和额外内存哪个才是常态很多讨论只提时间复杂度但空间复杂度经常是决定算法能不能用的硬指标。排序算法里原地排序in-place指的是只需要常数级的额外空间比如交换变量用的temp而像归并排序不管怎么优化合并两个有序数组总得有地方放临时结果所以它的额外空间是O(n)。有同学会想现在内存动不动就十几G谁在乎那么点空间但注意工程场景不只是内存大小的问题。如果排序的数据量是几十G的文件归并排序需要同时把数据文件拆开合并额外空间成本会直接变成磁盘IO成本这时候原地排序的价值就体现出来了。即便是内存内部排序频繁分配大块临时缓冲也可能触发GC压力这点在做Java、Go这类带自动内存管理的语言时尤其明显。1.3 稳定性相等元素的相对顺序到底影响什么稳定性是排序算法里最容易被忽略、但面试和工程里都极其重要的特性。所谓稳定指的就是数组里两个值相等的元素排序之后它们的前后相对位置不变。比如有一组员工记录先按部门排过序再按入职时间排如果第二次排序是稳定的那部门相同的人内部依然保留着入职时间顺序如果算法不稳定这个信息就被打乱了。哪些算法稳定、哪些不稳定背后有规律可循。凡是“相邻元素比较后交换”的算法比如冒泡排序、插入排序天然是稳定的凡是“隔着老远跳着交换”的算法比如选择排序、快排、堆排序就可能破坏相对顺序。归并排序的稳定性则来自合并阶段当左右两个子数组中遇到相同元素时总是优先取左边那一个就能保持稳定。理解了这个原理以后判断一个自定义算法稳不稳定心里就有底了。1.4 被忽视的其他指标比较次数、交换次数、缓存友好性复杂度表给的是渐近性能但真实项目的性能还受很多“隐性指标”影响。比如选择排序的时间复杂度固定是O(n²)但它的交换次数只有O(n)如果交换对象的成本很高比如交换的是复杂对象而非整数选择排序反而不见得比冒泡差。插入排序的比较次数虽然多但它赋值操作轻而且对“近乎有序”的数据表现极好实际速度经常吊打理论上更快的算法。缓存友好性也值得一提。现代CPU的缓存机制对顺序访问的数组非常友好插入排序和冒泡排序从头到尾都是相邻访问局部性好而快排和归并的递归跳跃访问模式在超大数组上会产生不少缓存未命中。所以有时候你用个小数据集测性能结果跟线上大数据的表现完全反过来根源往往就在这个层面。2. 八大排序算法逐一点评谁适合什么活2.1 插入排序与冒泡排序小规模数据的基本盘插入排序是我在实际开发里用的最多的小型排序方案因为它的思路太符合人类直觉了。玩扑克牌的时候你一手牌从左边到右边边摸边理新拿到的牌跟前面的逐个比较插到合适的位置这就是插入排序。它的最好情况是数据已经有序这时每个元素只需要和前一个比较一次整体是O(n)。所以它特别适合处理“已经大致有序”的数据比如消息列表按时间戳追加后又偶尔乱序的情况。冒泡排序则更像是教学入门用的把相邻的元素两两比较大数往后挪每轮结束最大的数就到末尾。它最大的问题是交换次数多就算数据有序也要老老实实跑完所有比较。虽然可以加一个“本轮是否有交换”的标志来提前退出但整体上它的常数因子比较大同一份数据下往往比插入排序慢不少。我的经验是除非你在写课程作业否则别在生产代码里用冒泡排序真要写循环交换逻辑插入排序几乎全面优于它。2.2 希尔排序让插入排序摆脱O(n²)束缚希尔排序的思路挺有意思既然插入排序在基本有序时效率很高那就先做“宏观调整”让数组快速变得大致有序再做精细插入排序。它设置一个增量gap把相隔gap的元素分成一组做插入排序每轮缩小gap直到gap1做一次完整的插入排序。希尔排序的时间复杂度跟增量序列的选择关系非常大。经典的希尔增量是gap gap / 2但实测效果一般Hibbard增量序列、Sedgewick增量序列能让希尔排序达到接近O(n^1.25)的水平。我在某些嵌入式环境下用过希尔排序因为它不需要额外空间、代码又短整体性能比简单插入排序强很多稳定性也只是“相对不稳定”在数据量几万以内非常实用。不过它的复杂度分析至今没有统一的精确结论这一点面试的时候可以直接跟面试官坦白能说明白增量序列如何影响性能就已经展示了深度。2.3 选择排序交换次数最少的“冷面选手”选择排序的思想非常简单每轮从剩余元素里找到最小值放到当前轮次的位置。它的特点是无论数据长什么样比较次数始终是n(n-1)/2交换次数却只有n-1次。这个特性放在“交换成本极高”的场景里就有价值了比如数组元素是体积很大的结构体或者元素是受控资源尽量减少交换就减少了复制开销。但它有两个坑要记住第一它不稳定相同元素可能因为被选中作为极值而交换到后面去第二它几乎无法利用数据原本的有序性哪怕数据已经排好序它依然傻傻地跑完整套比较。所以选择排序通常只在交换比代价高的特殊场景下使用普通场景里它的表现跟冒泡半斤八两。2.4 快速排序工程界的排序王者但得懂它的脾气快排是大部分语言标准库排序算法的底层核心C的qsort、Java的Arrays.sort对primitive类型、以及很多语言的sort方法都基于改进快排。它的核心思想是分治选一个基准值把数组分成小于基准和大于基准两部分再分别对两个子区间递归排序。关键在于partition这个过程好的实现能在线性时间内把数据分成左右两个“大致有序”的区间。快排的平均性能非常出色而且它是原地排序空间复杂度O(log n)递归栈因此综合性价比很高。但它有两个明显弱点一是对已经有序或几乎有序的数据如果基准选得不好会退化成O(n²)二是不稳定。所以工程实现都用三数取中取头、中、尾三个元素的中位数或随机选基准来规避退化。我自己写C代码时还很喜欢用“挖坑法”写partition比教材常见的Hoare方法更容易理解且不容易写错。2.5 归并排序稳定且可预测的“老实人”归并排序同样基于分治思想把数组拆成两半分别排序再合并。它最大的优势是稳定性极好复杂度永远是O(n log n)不管数据多乱都不会变差最大的短板则是需要O(n)的额外空间而且递归版的空间分配是个隐患——如果每层递归都新开数组内存开销会成倍膨胀。归并排序特别适合两种场景一是对稳定性有硬性要求的数据比如需要保留原本顺序的复杂对象排序二是外部排序数据量大到内存装不下时把数据分段排序后写到磁盘再归并这是分布式计算框架中的基石思路。另外归并排序还可以用“自底向上”的方式实现不需要递归直接从小片段开始两两合并这样可以避免递归栈和函数调用开销我后面会详细展开。2.6 堆排序原地且无论何时都稳定的复杂度堆排序利用完全二叉堆通常是最大堆的特性先把数组调整成大顶堆然后把堆顶元素和末尾元素交换缩小堆的范围再调整堆重复n-1次就完成排序。它的复杂度稳定在O(n log n)且是原地排序这两点让它看起来挺完美但它实际上有个性能短板——对数组的顺序访问模式不友好每次堆调整都要跨很大距离去访问子节点缓存命中率低导致常数因子较大。实测下来堆排序在多数现代机器上比快排慢比归并也慢一些。但它有一项独门绝技部分排序。如果你只需要找出前k个最大或最小元素用堆来做的话时间复杂度是O(n k log n)比“全排序后取前k个”快得多。这在Top K问题里特别常用。另外稳定性上堆排序也是不稳定的选择时要心里有数。2.7 计数排序、基数排序与桶排序线性时间的线突破O(n log n)这个理论下界是可能的前提是数据本身有特殊信息。计数排序适用于范围有限的整数数据比如成绩0到100分考生有10万人直接开一个长度为101的计数数组扫一遍统计频率再回填结果复杂度O(n k)k就是数值范围。基数排序则是按照个位、十位、百位依次用稳定排序来排常见用计数排序作为内部排序最终复杂度O(d·(n k))d是数字位数。桶排序则是把数据按值域划分到多个桶里桶内再各自排序适合数据分布均匀的浮点数。这三个线性时间排序都有一个致命前提数据必须“好说话”。一旦数据极度稀疏、范围极大比如几十个分散在1亿范围内的数计数排序的空间和扫描开销就非常难看了。在真实项目中它们更适合做特定业务逻辑比如数据库里对数值列的统计、或者MapReduce框架中的数据划分通用排序场景里极少单独使用。3. 排序算法选型实战指南照着挑就行3.1 按数据规模来选从几十到几个亿数据规模决定了排序算法的档次。数量小于50插排或选择排序最简单可靠不需要任何复杂代码还能利用近乎有序数据的优点插排。数据在50到几千之间看稳定性需求入门的快排或希尔排序都能胜任此时常数因子比渐近复杂度更影响感受。数据在几千到几百万快排基本是首选尽量用三数取中或随机基准的版本避免数据分布坑人。数据量达到几个G、几亿条内存里放不下就得考虑外部归并排序了。思路是分批读入内存排序后写到临时文件最终一遍遍归并这些临时文件。在这个量级上磁盘IO往往是瓶颈所以要尽量多用顺序IO、控制归并的路数和缓冲区大小。框架层面像Hadoop的TeraSort最核心的优化就在这个归并环节。3.2 按数据特征来选有序程度、稳定性、内存约束数据特征比单纯看数量级更决定算法选择。已经近乎有序的数据插入排序的实际效果能把快排按在地上摩擦因为大量数据根本不需要移动。存在大量重复元素时快排的原生写法容易导致partition左右失衡需要三分区三路快排来处理而计数排序如果值域有限直接就是最优解。对稳定性有硬要求别想太多直接上归并排序或稳定版的快速排序需要额外O(n)空间。内存约束上嵌入式设备里堆排序和希尔排序因为不需要额外空间反而比归并排序受欢迎。我个人做过一个内存只有几兆的日志解析工具需要对几百兆日志按时间戳排序最后就是用堆排序把关键字段抽出来排序再回查原文位置既控制内存又保证了性能。3.3 工程中的折中混合排序才是真实答案真实的标准库很少用单一算法。以C的std::sort为例它内部是“快排插入排序”的混合大数组用快排当子数组规模小于某个阈值通常是16或32时改用插入排序因为此时插入排序的常数小递归调用反而浪费。如果检测到递归深度过大比如log n的若干倍栈溢出风险高还会改用堆排序来兜底。这个策略叫intro sort内省排序。Java对对象数组用的是TimSort一种结合插入排序和归并排序的稳定排序算法专门针对真实世界里“部分有序”的数据做了优化。Python的sorted和很多语言的内置排序也都用TimSort。这些工程实现说明一件事做选型时不要只选一个算法而是要想清楚如何组合。大多数情况下最省事的方案就是直接调标准库不要自己造轮子只有当你明确知道标准库实现不满足稳定性或空间约束时才该写自定义排序。4. 分治思想与归并排序的修改实践4.1 分治三步走分解、解决、合并分治是很多高效算法的灵魂。它其实只有三步分解——把原问题拆成规模更小但结构相同的子问题解决——递归地求解子问题直到子问题小到可以直接求解合并——把子问题的解拼成原问题的解。归并排序就是教科书级例子不断把数组从中间切开左右分别排序再把两个有序数组合并成一个。理解分治的关键在于分解和合并的成本不能太高。如果合并成本是线性的那么总复杂度就是O(n log n)如果合并成本能做到常数级复杂度甚至可降到线性。归并排序正是因为切分是O(1)、合并是O(n)加在一起形成了O(n log n)的均衡结构。拿这个标准去套别的算法很多思路都能被归到这个框架里比如快排也是分治但它的合并成本几乎为零难点落在了partition这一步。4.2 如何修改传统归并排序从递归版到自底向上优化传统教材里的归并排序几乎都写成递归但递归有两个实际问题一是深层递归容易爆栈数组上千万时递归深度达log n大约24层左右问题不大但如果是链表归并或者调试时递归逻辑复杂依然有风险二是每次递归都分配临时数组内存碎片化严重。我常用的优化方式是“自底向上归并”先把数组按1、2、4、8……的长度分成小组直接从最小粒度开始循环合并省掉递归调用也更容易控制缓冲区复用的生命周期。用C语言实现自底向上归并的核心代码如下它每次合并前只申请一次临时内存合并完再回收避免了递归版本的反复分配void merge_sort_bottom_up(int arr[], int n) { int *tmp (int *)malloc(n * sizeof(int)); if (!tmp) return; for (int width 1; width n; width * 2) { for (int left 0; left n; left 2 * width) { int mid left width n ? left width : n; int right left 2 * width n ? left 2 * width : n; merge(arr, tmp, left, mid, right); } // 每轮合并完结果都在arr中tmp只做暂存 } free(tmp); }这里的merge函数负责把arr的[left, mid)和[mid, right)两个有序段合并到tmp再拷回arr。细节处理上有几个容易踩坑的点区间边界要仔细算尤其是数组长度不是2的幂的时候tmp数组的索引要在每轮合并开始时重置如果两个子段长度悬殊循环合并要防止越界。这种写法跑起来通常比递归版快10%到20%而且内存分配次数从O(log n)降到1次。4.3 用分治思想延伸出更多变体自然归并、外部归并、并行归并理解归并排序的分治骨架后可以顺势做些变体。自然归并排序的思路是先扫描一遍数组找出已有的“连续递增片段”然后把这些片段两两归并。相比传统归并排序盲目地把数组均匀切半自然归并能利用数据原本就有序的片段减少归并轮数。在近乎有序的真实数据上自然归并排序比普通归并快不少这也是TimSort的核心思路。外部归并则是把分治推向了磁盘层面。大文件被切成多个小块每个小块读入内存排序后写回临时文件然后做多路归并K路归并常见用败者树或堆来优化多路归并时选最小元素的效率。这里就涉及选路数K的权衡路数越多单轮随机IO越少但每轮比较开销越高真实项目里要结合磁盘寻址时间和内存缓冲来平衡。并行归并则是在多核环境下把左右子问题分别交给不同线程处理关键点在于合并阶段不能简单交给单线程可以用分段合并策略让多个线程同时负责输出的一段区域。这些变体都是分治思想的直接产物理解了骨架之后再去读别的排序源码或者设计自己的排序方案都会顺很多。5. 常见问题与排查技巧实录5.1 快排为什么在有序数据上慢成龟速这是最经典的一道算法排查题。面试里常问工程里也真能遇到对一个近乎有序的大数组调用快排性能骤降。原因前面提过如果你取基准值是取第一个元素数组已经有序时每次partition只能切出一个空区间和一个满区间递归深度变成n。解决办法无非两条一是随机选基准用rand(0, n-1)的下标元素来当基准从概率上保证退化几乎不可能二是三数取中取arr[low]、arr[mid]、arr[high]三者中位数做基准。实际工程里三数取中更稳因为它还顺便优化了partition时极端值处理。遇到这种问题我给的建议是先在源码层看基准选择逻辑有没有随机化别急着优化排序主逻辑。快速排序一旦退化不是你代码写得不好而是assumption没覆盖到输入数据的特征。5.2 稳定性理解误区数组里的“相等”不等于“相同”很多人误以为稳定性只在排序含有多个字段的对象时才有意义其实对原始类型数组也有影响。比如你有一个id数组和一个name数组按id建立映射关系后需要同时排序如果sort不稳定id对应关系可能会错乱。另一个常见误区是数据结构排序算法讲时间复杂度时把“比较次数”和“交换次数”混为一谈。比如对大量重复元素快排比较次数会爆炸式增长但交换次数很少复杂度分析里虽然都算常数项但实际耗时差异悬殊你以为出现了性能bug其实只是算法固有特性。排查稳定性问题可以从两个角度定位用“带序号的键值对”做测试数据排序后检查相同键是否保持原来的相对顺序或者对标准库的稳定排序和不稳定排序分别做回归对比。不要把稳定性想成一种玄学它本质是partition过程中值相等时的分支处理逻辑。5.3 递归深度导致栈溢出小数组没事大数组直接崩递归实现的快排和归并在处理百万级数组时一般还没问题但如果数据规模上亿或者不断触发最坏情况递归深度可能让程序栈崩溃。操作系统默认栈大小通常只有几MB每一层递归都要压栈保存局部变量量级控制不好确实会炸。解决手段有三个优先用自底向上的非递归实现快排用它自己的显式栈模拟递归归并则用循环宽度合并或者对递归版的快排做尾递归优化只对短区间递归长区间用循环迭代或者干脆限制递归深度比如内省排序的做法是深度超过2log n就切到堆排序。我记得有一次在线预处理上亿条日志数据时用了递归快排线上调优调不出来最后用gdb看到栈帧溢出换成自底向上归并后不仅没崩性能还上来了。所以如果你的程序规模大请默认你写的递归和数组边界都有问题用最小复现用例测出来再去跑全量。5.4 排序结果看起来对了但性能惨不忍睹先看数据分布排序代码写完跑完测试正确性过了但性能不达标这种情况我遇到最多的是数据分布和算法选择不匹配。比如你以为数据是随机的用快排很合适结果线上数据大量重复且范围狭窄partition失衡严重或者你以为数据规模只有几万用了归并每次合并都malloc一块新内存内存分配开销成了大头。建议在排查前先做两步第一步打印数据的规模、值范围、有序度统计计算相邻逆序对数量用数据特征来验证假设第二步对不同算法做基准测试同一份输入数据跑几遍取中位数比较。如果只是想要快速优化还可以考虑给比较器加一个“门槛”数据量小于阈值时直接调插入排序而不是老想着“用哪个大算法解决问题”。毕竟工程里性能问题多半不是算法理论不够好而是实现层面对输入数据的假设没做对。5.5 排序稳定性与并行化的冲突另一个很容易踩的坑是为了追求并行加速把快排的两半分别交给两个线程处理但并行partition很容易破坏稳定性。原因很简单稳定排序是建立在“从左到右依次处理全局序列”这一约束上的一旦拆成并行任务全局顺序信息就丢了。解决思路有多种如果稳定性很重要可以在并行前先给元素附加一个序号排序时用“值序号”的复合键参与比较把不稳定的算法强行变“稳定”或者干脆用并行归并排序因为归并的合并阶段天然可以保留稳定性。实际项目里我就因为数据并行排序导致订单明细错乱排查过很久最后发现换一个稳定的压缩索引方案解决。排序跟很多并行优化一样正确性和性能经常顾此失彼做选择前一定要明确你是“要稳定”还是“要快”。写到最后的一点体会排序算法这个题目看起来基础但真想把每一个算法的特性和取舍讲清楚其实要花不少心思。我在实际项目里反复做过选型数据量小但要求稳定直接插入排序数据量中等内存充足归并就是答案数据量大且随机性强快排加固边界条件内存受限又不能装下全部数据那就拆成外部归并。没有哪一种算法是万能的这也是为什么看排序不能只背复杂度表而要理解每一种复杂度背后的数据假设。最后分享一个个人实践里的小技巧写排序相关的代码时我会先写一个单测故意构造最坏情况、近乎有序、大量重复、倒序四种数据集确保算法的复杂度不是只在理论上成立。很多人写快排跑测试只测随机分布等线上出了有序数据的性能事故才后知后觉。测试先行、数据分布先行这两条真的能帮你省掉很多线上排障的时间。
返回列表