☰
排序算法全解析:从复杂度到稳定性,彻底吃透快排归并堆排
2026/10/15 4:21:34 网站建设 项目流程

排序算法这个东西,一提起来很多人第一反应就是“背代码”。面试前突击一下快排,刷题时记一下归并模板,背完就忘,忘了再背,循环往复。我自己带过的不少同学都这样,一问他冒泡排序的代码,能默写出来;再问一句“为什么冒泡排序在近乎有序的数组上表现更好,而快排反而可能退化”,人就愣住了。这就是典型的“记住了代码,没理解算法”。

写这篇文章的原因很简单,我想把排序算法这件事彻底讲透。不光是给出一堆代码模板,而是把每个排序背后的直觉、适用场景、复杂度推导,以及笔试面试里最容易踩的坑,全部摊开来讲一遍。无论你是刚学数据结构的本科生,还是准备跳槽刷算法题的老兵,只要愿意在这个话题上花两三个小时,之后碰到排序相关的问题,你大概率不会再靠死记硬背应付了。

1. 先把排序算法的“骨架”搭起来

1.1 为什么排序值得花时间吃透

排序是计算机科学里最基础也最经典的问题之一。你可能觉得自己平时写业务代码根本用不到手写排序,直接调库就完事了,但排序算法真正教给你的东西,远远不只是“把数组排好序”这件事。

首先是复杂度思维。排序算法是理解时间复杂度和空间复杂度最好的入口。为什么冒泡是O(n²)?为什么快排平均是O(n log n)但最坏能到O(n²)?为什么堆排空间是O(1)?这些问题的答案直接决定了你在面对海量数据时的设计思路。你调数据库、设计接口、写缓存策略,本质上都是在跟复杂度打交道。

其次是分治、递归、双指针、堆这些核心思想的载体。快速排序用到了分治和指针交换,归并排序用到了递归和辅助数组,堆排序用到了树结构的数组化表示。这些思想学完之后,你去看二分查找、滑动窗口、线段树等别的问题时,会发现底层逻辑是相通的。说白了,排序算法的核心价值不在于排序本身,而在于它把许多通用的算法设计范式浓缩在一个简单清晰的问题里。

最后是工程落地能力。就算你永远不手写排序,你也要理解系统库里的排序为什么那样设计。比如主流语言内置的排序往往是混合排序,像一些主流的排序实现会在数据量小的时候退化成插入排序,因为插入排序在常系数上有优势。不理解排序算法,你就看不懂这些设计决策,出了问题也只能干瞪眼。

1.2 建立复杂度意识:先看天花板再看地板

学排序算法,第一关是建立关于复杂度的直觉。

很多初学者拿到一个排序算法就问“这玩意儿时间复杂度是多少”,然后背下来就结束了。这种背法毫无意义,因为一旦考官变换一下输入条件,比如数组几乎有序、数组中全部元素相同、数据量特别大,你就会慌。真正需要做的是搞清楚两个维度:一种是“无论如何都得跑完”的最坏情况复杂度,另一种是“理想情况下能多快”的平均复杂度。

举个例子,插入排序在乱序数组上的平均复杂度是O(n²),看起来是个很“慢”的算法。但如果数组几乎有序,每一趟插入的移动次数都非常少,整体复杂度能逼近O(n)。这就解释了为什么很多工业级的排序实现会在分治递归到小规模子数组时切回插入排序——因为小规模数据本身就近乎局部有序,插入排序的实际运行代价非常低。

同样,快速排序之所以被广泛应用,不是因为它在最坏情况下复杂度很漂亮,恰恰相反,快排最坏情况是O(n²),但在随机化处理下,它退化到最坏情况的概率极小,而它平均情况下的常系数非常小,实际运行速度极快。这就引出一个关键认知:对比排序算法时,不能只看大O层面的复杂度,还得看实际场景下的常系数和数据特征。后面我会逐个算法展开细说。

2. 三个基础排序:冒泡、选择、插入

2.1 冒泡排序:理解“交换相邻逆序对”的直觉模型

冒泡排序是大多数人学习的第一个排序算法,它的核心操作其实就一句话:不断比较相邻元素,如果顺序不对就交换。每一趟下来,当前范围内最大的元素就会像气泡一样浮到最右边,所以叫“冒泡”。

来看代码:

def bubble_sort(arr): n = len(arr) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] swapped = True if not swapped: break return arr

这段代码里的swapped标志是很多人容易忽略的优化点。如果某一趟遍历里一次交换都没有发生,说明数组已经全部有序,那就直接跳出循环。这个优化看着不起眼,但在数组基本有序的情况下,能让冒泡排序的复杂度从O(n²)直接降到O(n)。

冒泡排序的理解价值在于它的“相邻交换”构成了逆序对消除的基础模型。逆序对指的是数组中一对索引 i < j 但元素满足 arr[i] > arr[j] 的组合。插入排序和冒泡排序这类基于相邻比较交换的算法,每交换一次相邻元素,恰好消除一个逆序对,所以它们的最坏复杂度跟逆序对数量严格相关。这就是为什么逆序对数量越少,这类算法越快。

但冒泡排序在实际工程中基本没有用武之地。它虽然最好情况下是O(n),但平均和最坏都要跑很多趟,常数巨大,而且由于交换操作频繁,实际运行效率连同为O(n²)的插入排序都比不上。我个人的建议是:理解冒泡的核心思想和优化技巧就够了,真正写业务代码时别指望靠它拿性能。

2.2 选择排序:最简单的最直观方案

选择排序的思路比冒泡更直接:每一趟扫描剩余未排序部分,找到最小元素的下标,然后把它交换到“已排序区域”的末尾。跟冒泡排序不同,选择排序每一趟最多交换一次,所以它的交换次数是恒定的O(n),但比较次数依然是O(n²)。

def selection_sort(arr): n = len(arr) for i in range(n - 1): min_idx = i for j in range(i + 1, n): if arr[j] < arr[min_idx]: min_idx = j arr[i], arr[min_idx] = arr[min_idx], arr[i] return arr

选择排序最大的特点是“交换次数少”但“比较次数不减”。这带来一个有意思的工程场景:当交换元素的代价远超比较代价时,比如在数组元素是需要写入磁盘的大结构体时,选择排序反而有优势。但在普通内存数组中,选择排序性能很差,因为它的比较次数固定为 n(n-1)/2,不管输入数据是否有序,都要全量比较一遍。

选择排序还有一个很多人没注意到的性质——它是不稳定排序。举个例子,数组[5a, 3, 5b, 1],第一趟找到最小值1,跟5a交换后,5a跑到了最后,5b还在原位,但两个相同值5的相对顺序已经变了。记忆方法很简单:因为它会把远处的元素直接“跳着交换”过来,跨越了中间相同值的顺序,所以稳定性就没了。这一点在面试里经常被问到,属于高频考点。

2.3 插入排序:被严重低估的“小规模王者”

插入排序的思路可以类比打扑克牌时整理手牌:从左边第二张牌开始,依次把每一张牌插入到前面已经有序的位置中,插的时候从后往前逐个搬运。

def insertion_sort(arr): n = len(arr) for i in range(1, n): temp = arr[i] j = i - 1 while j >= 0 and arr[j] > temp: arr[j + 1] = arr[j] j -= 1 arr[j + 1] = temp return arr

注意这里的实现细节:我没有用arr[i]跟前面的元素两两交换,而是先暂存当前值temp,把前面比它大的元素整体右移一位,最后把temp放到正确位置。这样每趟的赋值次数是“移动次数+1”,而不是“交换次数×2”,实际性能更好。

插入排序是三种O(n²)基础排序里综合性能最好的一个,原因有三点。第一,数组规模小的时候,方法调用的常系数很低,线性扫描的开销可忽略。第二,当数据近乎有序时,每次插入几乎不需要移动元素,复杂度逼近O(n)。第三,它是稳定的,因为插入过程只越过比temp更大的元素,相同元素不会被搬动。

工程上大量排序实现会在递归分割到小规模子数组时切回插入排序,正是看中了它在这类场景的低常系数表现。我自己实测下来,在数组长度小于几十的区间里,插入排序往往比快排更快,因为它没有递归调用的栈开销和额外的分支预测成本。

3. 进阶排序:希尔、归并、快排、堆排

3.1 希尔排序:从“大步幅”到“小步幅”的启发式思路

希尔排序本质上是对插入排序的改进。插入排序的问题是:如果数组末尾的最小元素要挪到开头,需要把前面所有元素右移,代价就是一趟一趟的线性搬运。希尔排序想到的思路是,先按较大的间隔 gap 把数组分成若干组,组内做插入排序,让元素以大步幅跳跃接近最终位置,然后再逐步缩小 gap,最后 gap=1 时做一次完整插入排序,此时数组已经基本有序,插入排序的效率就非常高。

def shell_sort(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): temp = arr[i] j = i - gap while j >= 0 and arr[j] > temp: arr[j + gap] = arr[j] j -= gap arr[j + gap] = temp gap //= 2 return arr

这段代码里 gap 序列选的是“每次除以2”,这是最常见也最好写的方案。但这种间隔序列的复杂度大概在O(n²)量级;如果改用更科学的间隔序列,比如某些经过精心设计的增量序列,复杂度可以降到O(n^(3/2))甚至更低。我在实际写代码时基本不会手写希尔排序,因为工程上用库函数更省事,笔试面试里出现的频率也不高。但它作为“利用局部有序性来优化插入排序”的案例非常有教学价值。

希尔排序是不稳定的,原因跟选择排序类似:gap>1 时跨距离交换会越过中间若干相同值,导致相对顺序改变。

3.2 归并排序:稳定且可预测的分治方案

归并排序是最经典的分治思想示范。递归地将数组分成两半,分别排序,然后合并两个有序数组。合并在两个有序子数组之间进行,每次取较小的元素放入辅助数组。归并排序的关键在于合并步骤,它需要O(n)的额外空间来暂存数据。

def merge_sort(arr): if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): res = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: res.append(left[i]) i += 1 else: res.append(right[j]) j += 1 res.extend(left[i:]) res.extend(right[j:]) return res

这段代码写得非常清晰,但因为频繁切片arr[:mid],实际运行时会创建大量临时列表,性能不理想。工程上更常见的做法是维护一个全局辅助数组,在递归时通过下标范围直接操作原数组。

归并排序有三大优点:稳定、最坏情况和平均情况都是O(n log n)、对链式结构友好。它的稳定性来自合并时“相等时优先取左半部分”的小细节,写代码时必须注意left[i] <= right[j]这个小于等于号,如果写成<,遇到相等元素时会先取右半部分,稳定性就被破坏了。

归并排序在面试代码题里出场率极高,因为许多问题的解法思路就是在归并排序的合并过程中顺便统计信息。经典的“求逆序对数量”就是归并排序的经典延伸题:合并过程中如果发现左半部分的元素比右半部分大,那么左半部分剩余元素全部构成逆序对。这种思路学了归并排序之后一定要举一反三。

3.3 快速排序:时间复杂度逻辑与巧妙的分区操作

快速排序是实际应用中表现最好的通用排序算法之一。它的核心是:选一个基准值 pivot,通过一次partition操作把数组分成“小于基准值的区域”和“大于等于基准值的区域”,然后递归地对两个区域排序。

def quick_sort(arr, lo, hi): if lo >= hi: return p = partition(arr, lo, hi) quick_sort(arr, lo, p - 1) quick_sort(arr, p + 1, hi) def partition(arr, lo, hi): pivot = arr[hi] i = lo - 1 for j in range(lo, hi): if arr[j] < pivot: i += 1 arr[i], arr[j] = arr[j], arr[i] arr[i + 1], arr[hi] = arr[hi], arr[i + 1] return i + 1

我写的这个partition叫“单向扫描法”,逻辑简洁:维护一个边界指针i,凡是遇到比 pivot 小的元素,就把它跟i+1位置交换,扩大“小元素区”。最终把所有小于pivot的元素都放到数组前半部分,再把pivot交换到边界位置。

快排的平均复杂度是O(n log n),这个结论很多人只是背下来,不理解原因。直觉解释是:理想情况下每次partition都能把数组均匀分成两半,递归树的高度是O(log n),每层处理的总元素数是O(n),所以总时间是O(n log n)。如果每次选的pivot刚好是最大或最小元素,partition之后两半严重失衡,递归树高度变成O(n),总时间就退化成O(n²)。

这也是快排在面试里最多的考点之一:如何避免最坏情况。标准解法有两个方向:一是随机选取pivot,在每次partition前随机交换一个元素到末尾,这样退化的概率极低;二是三数取中法,取首、中、尾三个元素的中位数作为pivot。我个人的偏好是把两者结合起来用:先随机选一个位置跟lo交换,再从lo、mid、hi三个位置取中位数放到末尾。这样既利用随机性消除概率退化,又避免随机数生成的额外开销过大。

快排还有一个重要变体叫“三路快排”,专门处理大量重复元素的输入。经典快排在元素全部相等的数组上每轮partition都会退化成O(n²),因为小于pivot的区域为空,递归深度只能到n。三路快排把数组分成小于、等于、大于三个区域,重复元素直接跳过,复杂度降为O(n)。Java的标准库排序在基础类型里就是类似思路。

快排是不稳定的排序,这一点必须牢记。因为partition操作中存在大量跨距离交换,相同值的相对顺序没有保证。如果你想排序稳定且复杂度可控,那应该选用归并排序。

3.4 堆排序:不借额外空间的最坏情况O(n log n)方案

堆排序的思路是先把数组构建成一个大顶堆,然后反复把堆顶元素交换到数组末尾,再对剩余部分做向下调整。它最吸引人的特点是:最坏情况下时间复杂度是O(n log n),而且不需要额外的辅助空间,属于原地排序。

def heap_sort(arr): n = len(arr) for i in range(n // 2 - 1, -1, -1): heapify(arr, n, i) for i in range(n - 1, 0, -1): arr[i], arr[0] = arr[0], arr[i] heapify(arr, i, 0) return arr def heapify(arr, n, i): largest = i left = 2 * i + 1 right = 2 * i + 2 if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] heapify(arr, n, largest)

堆排序构建堆的时间是O(n),这一点也经常被误解成O(n log n)。解释一下:构建堆时,每个内部节点要做siftDown,但不同深度的节点向下调整的次数不同。多数底层节点调整次数少,推导累加后正好是O(n)。这个结论推导起来稍微有点数学,但记住“建堆是线性时间”这个结论,在面试里说出来会很加分。

堆排序的实际表现并不如快排,因为它访问内存的模式是跳跃式的,缓存命中率低,而且交换元素往往涉及父子节点之间的远距离操作。但堆排序的优点在于最坏情况可控且空间零额外,因此在实时系统或内存受限的嵌入场景里,它比快排更可靠。

堆排序还衍生出了一系列高频问题,比如“求数组中第K大的元素”“维护一个动态数据流的中位数”,这些问题的底子都是堆。学好堆排序不只是学一个排序,更是在学一种数据结构的使用方式。

4. 复杂度、稳定性与场景选型

4.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^(3/2))O(n²)O(n)O(1)不稳定
归并排序O(n log n)O(n log n)O(n log n)O(n)稳定
快速排序O(n log n)O(n²)O(n log n)O(log n)不稳定
堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定

这里要特别注意快排的空间复杂度那一栏。很多人以为快排是原地排序,空间是O(1),但实际上递归调用需要系统栈空间,平均情况下递归深度是O(log n),最坏情况下是O(n)。如果你在写递归快排时注意每次递归先处理元素少的那一半,可以将最坏栈深度限制在O(log n),这是一个非常实用的优化技巧。

4.2 实际场景怎么选

学完一堆排序算法,最终还是要落到“实际中该怎么选”。我给一个简单的决策路径:

如果数据规模很小,比如小于几十条,直接插入排序。不要觉得用高级算法才是高手,工程是要考虑常数的。

如果数据规模中等或较大,又对稳定性没有要求,首选快排,但必须做随机化处理。Java和C++标准库底层的基本类型排序都偏向快排跑出来的效率。

如果数据规模较大且要求稳定,用归并排序。归并排序的额外空间在现代机器上通常可以接受,但要注意内存受限的场景。

如果内存受限、不允许额外数组,或者对最坏情况时间敏感,堆排序是稳妥选择。它牺牲了平均速度来换取确定性的O(n log n)。

如果待排序对象不是数组而是链表,优先归并排序,因为链表极难做随机访问,双指针归并在原地就能完成合并。

这些原则不仅应付面试,实际工程项目里我反复用到的也就是插入、快排、归并和堆这四类,其余几个更多是理解价值。

5. 常见错误与调试技巧实录

5.1 边界条件翻车现场

排序算法代码看着不长,但新手自己手写时最容易在边界条件上栽跟头。我总结了几个高频翻车点。

第一个是快排partition里i的初始值。单向扫描法里i初始为lo - 1,有的同学写成lo,结果第一个小于pivot的元素没有被正确处理。第二个是递归边界lo >= hi时直接返回,很多人在lo == hi的情况漏掉return,导致无限递归。第三个是堆排序里左右子节点下标计算,如果把根节点 i 的左右子节点写成了2*i和2*i+1,那是把数组当成以下标1为根的下标体系,在用下标0为根的堆里必须写成2*i+1和2*i+2。

调试这类问题的有效方法是在关键位置打印数组状态。比如快排里每次partition之后打印整个数组,观察边界位置i+1是否是pivot正确落位的位置;堆排序里每次heapify后打印堆数组,确认父节点确实大于两个子节点。肉眼跑几轮胜过硬背百遍代码。

5.2 稳定性误判

“稳定性”这个概念初看很好懂,但真到判断具体算法时经常出错。很多人会记住“选择排序不稳定”这个结论,但问他为什么每轮只交换一次还会不稳定,他说不清楚。每次排序时,选择排序会把最小元素跟当前未排序区域的第一个元素交换,这个交换是“跨距离大跳”的,容易越过与最小元素相同值的其他元素,所以不稳定。

归并排序稳定,是因为合并时相等情况优先取左半数组。判断稳定性的通用规则是:看算法的比较交换是否只在“相邻”位置进行。冒泡和插入都是相邻比较交换,同类值不会跨过彼此,所以稳定;快排的partition会跨距离交换,必然不稳定;堆排序的父子交换距离更大,也不稳定。用这个规则去判断,比背表格可靠得多。

5.3 用对数器验证排序实现

验证排序算法实现是否正确,最踏实的方法是写一个“对数器”。简单说就是:写一个暴力但绝对正确的排序方法(比如直接用库函数或手写插入排序),然后生成大量随机测试数组,分别用你的排序函数和暴力方法排序,逐元素比对结果是否一致。同时要覆盖特殊输入:空数组、单元素数组、全相同元素数组、逆序数组、已排序数组。

我写排序代码时会固定跑一万组随机数据,只要有一组对不上,就缩小数组规模,打印出原始数组和排序结果,定位很快。这个习惯看起来消耗时间,但比起面试现场写错代码被挂,这点练习成本微不足道。

6. 把算法真正“吃进去”的学习方法

6.1 用生活类比建立直觉记忆

很多同学学了排序之后,隔一个月再问各个算法的区别,脑海里一片空白。我的经验是:别记代码,记“故事原型”。冒泡排序就是一瓶碳酸饮料里的气泡不断上浮;插入排序就是打扑克理牌,抓到一张新牌往前插;选择排序就是“矮子里面拔将军”,每轮选一个最小的放到前面;归并排序是把两叠已经排好序的牌并成一叠;快排是选一个裁判,比裁判小的站左边,比裁判大的站右边,然后两边各自再选裁判。

这些类比听起来不“高大上”,但它们能在你脑子里建立起具象模型。有了模型之后,哪怕忘了具体代码,你也能从第一性原理想起这个排序每一步应该做什么,代码只是具象模型的翻译而已。

6.2 手写训练的节奏建议

建议按照“理解原理 -> 手写代码 -> 用对数器验证 -> 口头讲解给别人听”这四步循环来练。

第一遍,对着文章里的代码抄,边抄边注释每一步在干什么,用实际例子手动跑一遍;第二遍,关掉代码,在纸上写出流程伪代码,再翻译成真实代码;第三遍,跑对数器,修复边界问题;最后找个人或自己录音把算法过程讲一遍,讲不清楚的地方就是你还没理解透的地方。

一个算法走完这个过程大概需要四十分钟,七个经典排序算法大概三个晚上能滚完。练完之后你可以问自己一个问题:如果面试官要求你现场写出一个最坏情况O(n log n)、空间O(1)、稳定的排序算法,你应该怎么回答?如果答案是“没有这样的比较排序存在”,那说明你对复杂度的理解已经到了一定深度。如果你还愣着想去找一个这样的算法,说明前面的复杂度关系还没完全串起来。

排序算法这一块,只要把每个算法的“为什么”想明白,代码永远不可能忘,因为就算忘了,你也能当场重新推导出来。这也是“吃透”跟“死记”最本质的区别。

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

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

立即咨询