☰
八大排序算法复杂度与稳定性全解析:从原理到工程选型
2026/10/20 14:00:34 网站建设 项目流程

1. 一张总表先立住:八大排序的复杂度与稳定性对照

1.1 先看懂这张总览表

如果你正在准备面试,或者刚把数据结构课学完想理清这部分知识,那我先说结论:八大排序算法的时间复杂度、空间复杂度、稳定性,其实可以压缩成一张表。后面所有绕弯的问题,都是这张表的展开版。

排序算法平均时间复杂度最好时间复杂度最坏时间复杂度空间复杂度稳定性
冒泡排序O(n²)O(n)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n)O(n²)O(1)稳定
希尔排序视增量序列而定(约O(n^1.3))O(n log n)O(n²)O(1)不稳定
快速排序O(n log n)O(n log n)O(n²)O(log n)不稳定
归并排序O(n log n)O(n log n)O(n log n)O(n)稳定
堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定
基数排序O(d·(n+k))O(d·(n+k))O(d·(n+k))O(n+k)稳定

先解释表里几个容易让人迷糊的地方。空间复杂度列里写的都是额外辅助空间,不把待排序数组本身算进去;比如归并排序要额外开一个和原数组等长的辅助数组,所以是 O(n),快排的那个 O(log n) 是递归调用占用的系统栈空间。基数排序里的 d 表示最大数字的位数,k 是进制基数,十进制下 k 就是 10,后面我会细说。

读这张表的时候,我建议你不要只瞄一眼结论就完事,而是盯着每一列问自己一句:为什么是这个值?比如冒泡最好能达到 O(n)、选择排序最好也是 O(n²),这两个差异背后藏着一个很关键的"提前终止"机制,理解了它,你才算真正把这部分吃透了。

1.2 稳定与不稳定的记忆策略

八个算法,稳定和不稳定正好各占一半。稳定的是插入排序、冒泡排序、归并排序、基数排序;不稳定的是选择排序、快速排序、希尔排序、堆排序。

常见口诀是"插帽归基,排队整齐"——取插入、冒泡、归并、基数的谐音,它们四个老实,不插队;剩下"选快希堆"四兄弟就属于"喜欢乱窜"的类型。你如果怕口诀不牢,可以反过来记:只要看到快速排序、堆排序的名字,本能反应就是不稳定,因为"快"意味着用交换换取速度,"堆"意味着父子节点之间跳来跳去,这两个字本身就暗示了它会破坏相对次序。

1.3 看表之前必须先明确两个前提

第一,稳定性说的是"关键字相等时,排序前后相对顺序保持不变",它跟排序结果是否唯一是两回事。比如 [2a, 1, 2b] 排序,稳定排序输出 [1, 2a, 2b],不稳定排序可能输出 [1, 2b, 2a],两种输出都是正确的升序结果,但相对次序不同。

第二,这些复杂度结论不是"所有实现都一定如此"。比如快速排序你写成递归版和迭代版,空间复杂度实际算出来就是不一样的;归并排序如果用链表实现,辅助空间甚至能做到 O(1)。所以考场上答题时,先声明"我说的是基于比较、数组实现的通用结论",这个细节能让面试官觉得你不是只会背表,而是真知道边界在哪。

2. 稳定性的本质:为什么有的排序会"翻车"

2.1 稳定性原本要解决什么问题

很多初学者觉得稳定性是个考试概念,实际工程里根本没人关心。大错特错。我给你一个最简单的场景:学校成绩榜按分数排名,分数相同的同学按学号先后排。这时候数据本身就是先按学号排好的,你只需要在分数这一列做一次排序,并且要求分数相同的保持原来学号顺序。如果排序算法不稳定,相同分数的人的学号顺序就被打乱了,出来的榜单在并列名次部分一团糟,这就是稳定性真正要解决的事。

再比如电商后台要按"商品类目 + 上架时间 + 销量"排序,本质就是多个字段轮流排序,每轮能否保留上一轮的顺序,全靠排序算法的稳定性。所以别小看这个指标,它是多字段排序、基数排序的地基。

2.2 稳定排序为什么能稳定:四个代表的工作机制

冒泡排序稳定很好理解:它每次只比较相邻两个元素,如果相等就一律不交换。既然相等的元素从头到尾没有位置互换,那相对次序当然保持住了。

插入排序稳定的原因稍微隐晦一点,但也很朴素:从后往前扫描已排序区时,遇到"当前元素值等于被插入元素"的情况就停住,把新元素插到那个相等元素后面。换句话说,插入排序从不越过与它相等的元素,它只是"排在它们后面",自然稳定。

归并排序的稳定靠的是合并那一步的细节。合并左右两个有序子数组时,如果左半边的当前元素和右半边的当前元素相等,先说好的规则是"先取左半边"。这个规则一写,所有相等的值都优先保留左侧队列的先后顺序,递归合并回去之后全局顺序也不会乱。如果代码里把判断条件写成了>而不是>=,稳定排序立刻变不稳定,一个符号就能改变结论,这个很多人在手写代码时踩过坑。

基数排序更特殊,它本身是"调用若干轮桶排序或计数排序"的组合算法。它的稳定性建立在底层每一轮排序都必须稳定的基础上,只要底层的桶排序是不稳定的,那么从最低位往最高位累积的整个结果就会崩掉。这一点等会儿我会在工程场景里再展开。

2.3 不稳定排序翻车的瞬间

选择排序为什么不稳定?很多人第一反应是"它也没有跨越交换啊,每轮只是把最小值和当前位置交换"。但你亲手跑一遍就明白了:比如数组是 [5a, 5b, 1],第一轮找到最小值 1,把它和第一个位置上的 5a 交换,数组变成 [1, 5b, 5a]。看,两个 5 的相对顺序已经反了。原因是选择排序每一轮都可能把后面一个较小值"隔空挪到前面",而跨越过程中那些相等的值没法维持相对位置。

看一段直观的代码就能感受到:

# 选择排序:每轮从剩余元素里选最小的,和当前位置交换 def selection_sort(nums): n = len(nums) for i in range(n): min_idx = i for j in range(i + 1, n): if nums[j] < nums[min_idx]: min_idx = j # 这一步可能把后面的元素跨到前面 nums[i], nums[min_idx] = nums[min_idx], nums[i]

快速排序的不稳定,根源在分区操作。以经典的 Lomuto 分区为例,基准值所在位置会和比它小的元素不断交换,扫描过程中相等元素可能被甩到基准的另一侧。画个简化示意:数组 [3a, 7, 3b, 1],如果选 3a 做基准,处理完分区后 3b 很可能被换到 3a 前面,稳定性就这样丢了。你很难在保持快排性能的同时强行让它稳定,除非放弃原地分区,额外开数组存相等的值,那就不是教科书里的原地快排了。

堆排序不稳定也好理解。堆调整的时候,父子节点之间会做跨越式交换,堆顶元素和堆尾元素一交换,整个序列尾部就接住了那个"曾经在最前面"的元素。相等元素的相对顺序在这个过程中根本无法保证。

希尔排序的不稳定则最有迷惑性,因为它的子过程——插入排序——本身是稳定的。问题出在"按增量分组":先按 gap=2 分两组排序,再按 gap=1 排序,跨组移动时,两个相等的元素可能一个先被挪到前面,一个后到,等 gap 收成 1 的时候顺序已经乱了。所以那种"内部方法稳定,整体就稳定"的推导是不成立的。

2.4 关于稳定性最常见的误解

误解一:稳定的排序比不稳定的更好。其实稳定性只是工程场景里的一个属性,不是好坏标准。快排、堆排不稳定,照样是工业级主力算法。

误解二:输出一样排序,稳定不稳定就无所谓。输出确实可能一样,但原序列里相等元素的前后关系是否被保留,才是稳定性的测量标准。

误解三:只要算法名一样,稳定性就一样。同一个快速排序,不同教材的分区策略不同,某些特定实现碰巧能保持相等元素的顺序也不算主流结论。考试和面试里问的稳定性,都是指该算法最典型、最通用的实现。

3. 时间复杂度不是背出来的:最好、平均、最坏各自从哪里来

3.1 三个O(n²)排序之间的细微差别

冒泡、选择、插入三个排序,平均复杂度都是 O(n²),但"最好情况"差异很大。冒泡排序加一个标志位,如果某一趟扫描没有任何交换,说明序列已经有序,直接退出,所以最好情况是 O(n)。插入排序也类似,序列本来就基本有序的时候,每个元素只需要比较一两次就找到位置,整体接近 O(n),所以它对"近似有序"的数据非常友好。

选择排序就没有这个待遇,因为它每一轮都必须把剩余区间完整扫描一遍才能确定最小值的位置,不管数据原本长什么样,扫描次数一点都省不了,所以最好、最坏、平均全是 O(n²)。这就是为什么"数组基本有序时优先选插入排序"是个被验证过无数次的工程结论,它不只是经验,背后有复杂度支撑。

三个排序的循环结构也可以横向对比。冒泡是相邻交换,交换次数多但比较逻辑简单;选择是把交换次数压到 O(n),但比较次数完全固定;插入则是把比较和移动分开统计,移动次数和数据初始状态强相关。别看它们是同一个量级,常数和适合场景差别很大。

3.2 分治类排序的O(n log n):快排、归并、堆

归并排序的复杂度最好推导:把数组从中间切两半,左右分别递归排序,再线性合并,递推关系是 T(n) = 2T(n/2) + O(n)。展开之后,每一层合计 O(n),一共 log n 层,所以任何情况下都是 O(n log n),最坏也没有例外。

快速排序同样是分治,但它多了一个关键变量——基准值选得好不好。理想状态和归并一样,每次把数组对半分,递推式也是 T(n) = 2T(n/2) + O(n),复杂度 O(n log n)。但如果基准值每次都选到最大值或最小值,分区结果变成一边 0 个元素、一边 n-1 个元素,递推式退化成 T(n) = T(n-1) + O(n),这就是 O(n²)。面试里几乎必问的"如何避免快排退化",答案就是随机化选基准或三数取中,目的是让最坏情况变得几乎不可能发生。

堆排序的 O(n log n) 也要拆成两半来看。建堆阶段从最后一个非叶子节点往前做"下沉"调整,整体复杂度是 O(n) 而不是 O(n log n),很多人在这里记错。之后每轮把堆顶元素和堆尾交换,再对缩小后的堆做一次 O(log n) 的下沉调整,执行 n 轮,合计 O(n log n)。因为堆调整的次数和初始数据排列关系不大,所以堆排序的最好、平均、最坏都是 O(n log n),这点和快排截然不同。

3.3 希尔排序的复杂度为什么教科书不敢写死

希尔排序是插入排序的改进版,核心思想是先用较大增量进行"宏观"上的近排序,让数据快速接近有序,最后再用增量 1 做完整插入排序。问题在于,增量的取法千变万化,不同增量序列直接影响外层循环和每次比较次数,所以没有一个统一的精确复杂度公式。

实践中常见的结论是:使用原始 Shell 增量序列时最坏是 O(n²);使用 Hibbard 增量时空平均约 O(n^1.5);使用 Sedgewick 增量序列时经验复杂度约为 O(n^(4/3)),上限是 O(n^(7/6)) 这种级别。大多数教材表格里直接写"约为 O(n^1.3) 到 O(n²) 之间",不是教材偷懒,而是这个算法确实依赖具体实现。你如果笔试时被问到,回答"希尔排序复杂度取决于增量序列,常见的经验值是 O(n^1.3) 左右,最坏可能 O(n²)",就已经比背死书的人强了。

3.4 基数排序跳出"比较排序"的框架

基数排序不属于比较排序,所以它不受"比较排序下界 O(n log n)"约束。它把整数拆成多位数字,从最低位开始,每一位做一次稳定的桶分配,反复 d 轮。每一轮要遍历 n 个元素,并且把元素分到 k 个桶里,所以一轮是 O(n+k),总共 d 轮就是 O(d·(n+k))。

这个复杂度为什么实用?因为当 d 和 k 都比较小的时候,它甚至能在线性算量级完成大量整数的排序。比如 1 到 1000000 之间的一堆整数,d 最多 7 位,k=10,那复杂度就是 O(7n),非常可观。但要是排序对象是超长字符串、浮点数或者范围极其分散的数,d 会变大,基数排序的优势就没了。所以在实际选型时,它从来不是通用替代品,而是特定数据类型下的特化武器。

4. 空间复杂度与原地排序的隐藏成本

4.1 原地、递归栈、辅助数组三笔账

空间复杂度是很多人最容易糊涂的一栏,因为它要算的不是"代码里声明了几个变量",而是算法额外需要多少内存。冒泡、选择、插入、希尔、堆这五个,全程就在原数组上交换和覆盖,额外变量只有常数个,所以空间复杂度是 O(1),也就是"原地排序"。

归并排序的空间是 O(n),因为合并两个有序数组时,如果不额外开一块临时数组,原地的合并操作复杂度会大幅上升。教科书里最标准的做法就是申请一块和数组等长的辅助空间,然后交替拷贝回写。

快排的 O(log n) 是另一个容易被漏算的项目:递归函数每调用一层,系统栈就占用一层,平均情况下递归深度是 log n。如果基准选得极差,递归深度可能到 n,这就是它的最坏空间复杂度。用迭代加显式栈的方式可以模拟递归,但栈本身依然要占内存,只是把系统栈换成了程序栈。

基数排序的空间复杂度是 O(n+k)。k 是桶或计数数组的长度,十进制下 k 就是 10,如果要排序的数据范围非常大,比如直接用最大值作为 k,那计数数组本身就会变得非常占内存,这是使用基数排序前必须掂量的成本。

4.2 快排的递归栈最容易被忽视

我见过很多人在推导快排空间复杂度时直接写"O(1)",理由是它原地交换。这个答案严格说是错的。原地指的是不借助辅助数组,但递归调用本身的栈空间是真实存在的,而且不止一层。尤其面试官追问"最坏情况空间复杂度是多少"时,如果答不出来,说明你对递归执行过程的理解还不够扎实。

快排最理想的情况是每层递归两个子区间均匀分布,栈深度是 log n;最坏退化时,每次只减少一个元素,栈深度就是 n。所以工程实现里经常会做一步"尾递归优化"——只对短的那段递归,长的那段用循环处理,这样能把最坏空间压低到 O(log n)。这也是很多高级排序库的实际做法。

4.3 原地与稳定能不能兼得

聊到这里自然冒出一个问题:能不能写一个既原地、又稳定、又保持 O(n log n) 比较次数的排序?理论上可行,比如原地归并排序,但实现极其复杂,常数大得吓人,工程里几乎没人用。你只要记住一个现实结论:主流的高效排序里,稳定的基本都在用辅助空间(归并、TimSort),而原地的基本都不稳定(快排、堆排)。

这不是巧合。稳定排序要求"相等元素的相对顺序不被打断",而原地操作往往意味着元素会被跨越式交换,跨越交换天然倾向于破坏相对顺序。如果你想同时做到稳定和原地,通常要付出额外的时间成本,得不偿失。这也是为什么各大语言标准库最终都在"稳定但占内存"和"原地但不稳定"之间做选择,两边都有市场。

5. 稳定性在真实工程场景中的取舍与用法

5.1 多字段排序的核心技巧

如果你要对一个对象数组按多个字段排序,最佳实践不是写一个包含多个字段比较的复杂 comparator,而是连续做多次排序,每次按一个字段排,并且保持排序算法稳定。比如先按姓名排一次,再按成绩用稳定排序排一次,那么成绩相同的人之间,依然保持着姓名排好的顺序,最终结果等价于"成绩优先、姓名次之"。

这个技巧在 SQL 里对应ORDER BY score DESC, name ASC,底层引擎往往也是这么实现的。如果两次排序里有一次用了不稳定的排序,第二次排序就会把第一次的结果局部打乱,多字段排序就变成碰运气了。所以我做数据清洗时,凡是涉及多条件排序,会默认选择稳定排序,省去一大堆事后校验。

给你一个直观的例子,原始数据按姓名字典序排列:

姓名成绩
Alice90
Bob85
Carol90

如果按成绩降序做一次稳定排序,Alice 和 Carol 成绩都是 90,顺序保持 Alice 在前;如果用了不稳定排序,Carol 跑到 Alice 前面,虽然成绩排名没变,但姓名字典序没了,下游报表就可能出问题。

5.2 基数排序依赖稳定的根本原因

基数排序是"稳定性被利用得最彻底"的算法。它先按个位排序,再按十位排序,再按百位排序,每一轮都要求稳定。为什么必须稳定?因为高位排序的优先级更高,低位排序的结果只有通过"稳定"才能在第二轮中存活下来。

举个例子,数字 31 和 12 排序。第一轮按个位:31 的个位是 1,12 的个位是 2,排序后 31 在前、12 在后。第二轮按十位:31 的十位是 3,12 的十位是 1,排序后 12 该排前面。如果第二轮不稳定,12 和 31 的相对顺序补不定,但你至少希望第一轮里个位的顺序在"十位相等"时还能保留。再多想一步,为什么必须从低位排到高位,而不是从高位往低位排?正是因为低位排序结果影响力弱,必须由高位排序来覆盖,而这个覆盖过程依赖稳定,才能逐级累积出正确的全局顺序。这是稳定性的真正用武之地。

5.3 语言标准库的路线分歧

不同语言的默认排序背后,选的路线完全不一样。有些语言标准库里的排序是稳定版,底层接近归并排序加优化的版本,对象排序时会保留相等元素的顺序;有些语言标准库的排序则是不稳定的双轴快排或混合算法,追求更省内存和更快的平均速度,但稳定性不在保证范围内。

这个差异提醒我一件事:写代码时永远不要依赖"默认排序是否稳定"这个说不准的假设。如果你的业务里明确需要稳定排序,就显式调用文档标注了稳定性的排序接口,或者自己实现归并排序。仅靠"我记得这个语言默认是稳定的"来写,等库版本一升级,可能出线上问题。

5.4 按场景选型:一张决策参考

面对具体问题时,可以按这套逻辑快速决策:

场景推荐排序原因
数组很小或基本有序插入排序最好情况 O(n),常数极小
数据量很大且需要稳定归并排序/TimSort稳定,最坏也是 O(n log n)
数据量很大且不需要稳定原地快排平均最快,空间 O(log n)
内存受限且要保证最坏时间堆排序原地 O(1),任何情况 O(n log n)
待排数据是固定范围整数基数排序复杂度接近线性,避开比较排序下界
多字段组合排序稳定排序连续排序时保留上一轮顺序

这里面还有一个很实际的应用方向:找 Top K 问题。比如从 100 万个整数中找最大的 100 个,用堆排序维护一个大小为 K 的小顶堆,复杂度是 O(n log K),空间 O(K),对于大数据流场景非常香。这时候堆虽然不稳定,但你根本不需要稳定,只需要"快且省内存"。

最后分享一个我自己带人复习时屡试不爽的小习惯:先背"选快希堆"这四个不稳定的,剩下的自然就是稳定的,再配合复杂度表把每个算法的最好和最坏情况按"是否与初始有序状态相关"过一遍,你会发现整张表的内在逻辑是串起来的。实际操作中如果遇到容易混的说法,就用一行小数据亲手跑一遍,比如 [5a, 5b, 1],比任何口诀都管用。

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

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

立即咨询