我一直觉得,把“插入、选择、冒泡、堆、快速、归并”这六个排序算法放在一起学,是最容易看出一个人算法底子的一件事。原因很简单:这六个名字几乎每个学编程的人都能背出来,但真让你在白板上一行不差地把它们写对,能在十分钟内搞定的人并不多;如果再把“为什么有的稳定、有的不稳定”“递归深度会怎样影响空间”“什么样的输入会让某一种排序退化”这些问题追问下去,大部分人就开始模糊了。
我实际遇到的触发点是前段时间帮同事 review 一段数据报表代码,他为了展示一个“稳定排序”的效果,自己实现了一版快速排序,结果数据里大量相同时间戳的记录,每次都出现顺序抖动,最后只能换成带稳定保证的归并排序。这个例子特别典型:排序算法不是“能排出来就行”,它背后藏着的比较次数、交换方式、额外内存和稳定性,才是真正决定你代码在不同数据规模下表现的东西。这篇文章我把这六个经典的数据结构排序算法摆在一起,从实现到复杂度,再到工程选型,按我自己的理解顺序讲一遍。不管你是刚准备面试,还是想在真实项目里决定到底该调哪个 API,应该都能直接用上。
1. 排序算法的学习坐标:先别急着背代码,先看它们在比什么
1.1 六种算法背后其实是两代排序思路的演进
很多人会把这六个算法当成六个孤立的记忆点,这是最吃亏的学法。往深一层看,它们其实只是“插入排序、交换排序、选择排序、归并排序”这几类在复杂度上的两个不同阶段。
冒泡排序和快速排序,本质上都属于交换排序。冒泡是老老实实把相邻两个元素一次次交换,大元素像气泡一样慢慢浮到右边;快速排序则是用分治的方式做交换:选一个基准值,把比它小的都换到一边,比它大的都换到另一边,然后递归处理两边。可以说快排就是“冒泡思路的高级加速版”。
选择排序和堆排序也一样。选择排序每轮扫描一遍剩下的数组,找到最小的那个拿出来;堆排序则是用最大堆这种数据结构,使“找到当前最大元素”这个动作从 O(n) 降到 O(log n)。它们只是在“找极值”这个操作上换了不同的工具,所以六种算法并不需要分开死记,按这个演进关系去理解会顺很多。
1.2 一张总表比一百行注解更能建立全局印象
学这六个算法之前,我建议先看一眼总体的复杂度表格,后续所有细节都能和这张表对上。下面的数据默认是对长度为 n 的数组进行原地或者非原地的内排序:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 额外空间 | 稳定性 |
|---|---|---|---|---|
| 插入排序 | 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) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
这张表里最容易被人忽略的两条信息是:第一,选择排序是不稳定的,但很多人以为它和插入、冒泡一样稳定;第二,快速排序空间复杂度是递归栈带来的,并不是严格 O(1),最坏情况下递归深度会到 n,这一点在嵌入式或者极端数据场景下会真实影响程序。后面我会分别展开解释。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. O(n²) 里的三个主角:冒泡、选择、插入的真实差异
2.1 插入排序:像整理手牌,从左侧逐步建立有序区
插入排序的核心思想是:把数组分成“左边已排序区”和“右边未排序区”两个部分,每次从右边取第一个元素,在左边已排序区从右往左找位置,然后把后面的元素往后移动一位,把空位填进去。这个过程很像你打牌时一张张整理手牌,抓到新牌后把它插到比它大或者比它小的牌前面。
python复制def insertion_sort(arr):
for i in range(1, len(arr)):
cur = arr[i]
j = i - 1
# 关键:j >= 0 要写在前面,否则数组下标会取到 -1
while j >= 0 and arr[j] > cur:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = cur
插入排序最容易被低估的一点是它的“最好情况”。如果一个数组已经是基本有序的状态,内层 while 循环基本不会执行,整个排序只需要遍历一遍数组,时间复杂度就退化成接近 O(n)。这一点在工程里特别有价值,很多高级排序算法在处理很小的子数组或者几乎有序的数据时,都会掉头去用插入排序,因为此时它的常数开销远比 O(n log n) 的算法小。
而且插入排序是稳定的,前提是比较时只写 arr[j] > cur,而不是 arr[j] >= cur。如果用了 >=,相同元素在向左移动时会被认为需要继续交换,相对顺序就会被破坏。
2.2 选择排序:每轮挑最小的放到前面,交换次数最少
选择排序的思路更直接:第一轮从整个数组里找出最小的元素,把它和第一个位置交换;第二轮从剩下的元素里找出最小的,和第二个位置交换;不断重复直到数组有序。
python复制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
if min_idx != i:
arr[i], arr[min_idx] = arr[min_idx], arr[i]
它的特点是交换次数很少,每一轮最多交换一次,总共只需要 O(n) 次交换。相比冒泡排序那种频繁相邻交换,选择排序把数组里的交换次数压到了很低。所以如果你的场景里“移动元素”的代价很贵,比如元素是很大的结构体而不只是整数时,选择排序也有它的存在价值。
很多人会把选择排序误认为稳定排序,实际上不稳定。举个例子:数组是 [3a, 3b, 1],第一轮找到最小值 1,把它换到位置 0,此时数组变成 [1, 3b, 3a],两个相等元素 3a 和 3b 原本的位置关系就反了。因为在找最小值的过程中,我们不会刻意保证相等元素的相对位置不变。
2.3 冒泡排序:非常适合入门,但实际工程里基本用不上
冒泡排序的做法大家都熟:从左往右依次比较相邻的两个元素,如果顺序不对就交换;一轮结束后,最大的元素会浮到最右边。下一轮处理前面剩下的元素,直到全部有序。稍微优化一点的版本会加一个 swapped 标记,如果某一轮没有任何交换,说明数组已经有序,可以提前退出。
python复制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
冒泡排序最大的价值是“好理解、好证明”。你能非常直观地看到一轮结束后最大值如何被送到正确位置,所以很多教材都拿它作为排序算法的第一课。但在真实项目里,它基本已经被淘汰了:相邻交换产生的大量内存读写,让它在同样 O(n²) 级别的排序中常数偏大;即使基本有序的情况下可以提前退出,它的实际表现也通常不如插入排序。
我个人的建议是,冒泡排序你可以写,但不要把它当主力排序方案。面试时如果被要求实现简单排序,优先写插入排序,因为它写起来同样短,而且更适合“几乎有序”的数据,常数也更小。
3. 快排和归并:同一个分治思想,两种完全不同的风格
3.1 快速排序:选基准、分区间、递归下去
快速排序的核心是分区。选定一个基准值,把小于基准值的元素都放到左边,大于基准值的元素都放到右边,然后对左右两个区间递归执行同样的操作。只要每次分区都能把数组大致分成两半,时间复杂度就是 O(n log n)。
常见的实现方式是 Lomuto 分区法,代码比较短,也适合用来理解和教学。这里我给出一个非常干净的版本:
python复制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
def quick_sort(arr, lo=0, hi=None):
if hi is None:
hi = len(arr) - 1
if lo >= hi:
return
p = partition(arr, lo, hi)
quick_sort(arr, lo, p - 1)
quick_sort(arr, p + 1, hi)
快排在工程中的地位非常高,原因是它的空间局部性好,排序过程基本都发生在数组内,缓存命中率比归并排序高,所以平均常数非常小。大量主流语言的标准排序库都用它作为整体框架。但要注意它的弱点:如果基准值每次都选得不好,比如数组已经完全有序,而我们又固定选最后一个元素做基准,那么每次分区只会把区间缩小一个元素,递归深度变成 n,时间复杂度退化到 O(n²)。所以工业级快速排序不会直接拿第一个或最后一个元素做基准,而是会使用“三数取中”或者“随机基准”来尽量避免这种退化。
更关键的是,递归本身会消耗栈空间。平均情况下递归深度是 O(log n),最坏情况下是 O(n)。当你排序一个千万级以上数据时,最坏情况可能导致栈溢出。因此真实库里往往还会配套一个“递归深度阈值”,超过某个值就切换到堆排序,这就是后面我要提到的 Introsort 思路。
3.2 归并排序:先拆到不能再拆,再两两合并
归并排序的思路是“先分再合”。把数组从中间分成两个子数组,递归地把左右两边排好序,最后把两个有序数组合并成一个有序数组。
python复制def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
归并排序最大的优势是稳定且最坏情况下依然保持 O(n log n)。它不像快排那样有“基准值选不好就会退化”的问题,每次都是严格从中间切分,所以复杂度不会受到输入数据分布的影响。
代价也很明显:合并过程中需要额外 O(n) 的临时空间。如果实现得像上面这份代码一样,每次递归都新建 result 和切片数组,那实际内存开销会更大,因为每一层递归都有临时数组存在。工程上通常会用一块预分配的辅助数组,配合下标区间去合并,避免频繁创建列表。
这里的稳定点在于 if left[i] <= right[j] 用了 < =。当左右两个数组里有相等元素时,我们会先把来自左侧数组的元素放进结果,因此保持了它们在原数组中的先后顺序。如果把 <= 写成 <,相等元素会优先取右侧元素,排序结果就变成不稳定的了。
3.3 快排与归并到底应该怎么选
我经常在面试里看到这样的问题:数组很大,内存够用,要求稳定,选归并;数组很大,内存紧张,不太在乎稳定性,选快排。这个答案方向是对的,但我想补充一个视角:真实世界的语言库不会只靠某一个算法打完天下。
比如 Python 内置的 sorted 和 list.sort 用的是 TimSort,它不是单纯归并,而是“归并排序 + 插入排序”的混合体。它在处理真实世界中常见的“部分有序”数据时,可以把复杂度降到接近 O(n)。C++ 标准库里的 std::sort 通常使用 Introsort,先走快排,递归深度超过一定阈值后改用堆排序,避免最坏情况。所以你会发现,插入、快排、堆、归并在真实库里往往是配合着出现的,谁也不是绝对的赢家。
4. 堆排序:不靠递归,靠一棵埋在数组里的完全二叉树
4.1 为什么堆排序经常让人“写得出来但讲不清”
在六个算法里,堆排序最容易被卡住。因为它的数据结构和代码形态都和其他排序差异很大,很多人把它当成“堆的插入 + 删除”来理解,反而绕远了。堆排序的本质是:先把数组调整成一个大顶堆,也就是让任意父节点都大于它的子节点,然后反复把堆顶的最大值挪到数组末尾,再让剩余部分重新满足堆性质。
这个过程其实很像选择排序的升级版。选择排序为了找一个最大值要遍历 O(n),而大顶堆让我们用 O(log n) 的时间就能找到并调整出新的最大值。这也是为什么堆排序的时间复杂度稳定在 O(n log n),无论数据是随机、有序还是全都相等。
4.2 下沉函数和完整实现:每一行都要想清楚边界
堆排序中有两个关键操作:建堆和堆顶下沉。我建议把“下沉”抽成一个独立函数,因为它会被调用两次:第一次自底向上建堆,第二次在每次交换堆顶和末尾元素后恢复堆结构。
python复制def sift_down(arr, i, n):
while True:
left = 2 * i + 1
if left >= n:
break
right = left + 1
largest = left
if right < n and arr[right] > arr[left]:
largest = right
if arr[largest] <= arr[i]:
break
arr[i], arr[largest] = arr[largest], arr[i]
i = largest
def heap_sort(arr):
n = len(arr)
# 建堆:从最后一个非叶子节点开始向下调整
for i in range(n // 2 - 1, -1, -1):
sift_down(arr, i, n)
# 依次把堆顶最大值交换到末尾
for end in range(n - 1, 0, -1):
arr[0], arr[end] = arr[end], arr[0]
sift_down(arr, 0, end)
为什么建堆循环要从 n // 2 - 1 开始,而不是从最后一个元素开始?因为完全二叉树里,下标从 n // 2 到 n-1 的节点都是叶子节点,它们本身没有子节点或者不需要调整,所以从最后一个非叶子节点 n // 2 - 1 开始,自底向上执行下沉就够了。很多实现写成两层嵌套的“向上调整”,也能建出堆,但那样会做很多无用功。
另一个容易出错的地方是每次“堆顶和末尾元素交换”之后,end 这个变量既代表已经排好序部分的边界,又代表下一次调整堆的规模。所以 sift_down(arr, 0, end) 里的 end 不能写成整个数组长度 n,否则已经被放到末尾的最大值又会被调回堆里。
从空间复杂度看,堆排序是所有 O(n log n) 排序中唯一做到常数空间开销的选手,因此在嵌入式、单片机等内存极度受限的场景中依然有不可替代的位置。面试中如果被问“不额外申请大内存,最坏情况也要 O(n log n),用哪个”,答案就是你写一遍就能记住的堆排序。
5. 稳定性、空间和手写边界:最容易被追问的细节都在这里
5.1 稳定排序到底意味着什么
很多初学者把稳定性误会成“时间复杂度不会波动”,这完全是两回事。稳定排序指的是:如果数组里有两个值相等的元素,排序后它们在原数组里的先后顺序不会改变。日常处理整数排序时,稳定性无所谓,因为 1 和 1 之间没有区别。但当你排序的是带 id 的对象,或者需要按多个关键字逐层排序时,稳定性就很有用了。
一个典型例子是先按学生班级排序,再按学生成绩排序。如果第二次排序是稳定的,那么同样是 90 分的学生,仍然会保持第一次排好的班级顺序;如果第二次排序不稳定,班级顺序就会被破坏。这也是归并排序在数据库、语言标准库中被看中的核心原因。
5.2 不同排序为什么会有不同的稳定性结果
插入排序和冒泡排序之所以稳定,是因为它们只交换相邻的逆序对;归并排序之所以能稳定,是因为合并时遇到相等值会优先选择左半部分的元素。选择排序不稳定,因为它在交换时可能把一个较大的相等元素跨越式地扔到更前面;快速排序不稳定,因为分区交换时元素是跨距离跳跃的;堆排序不稳定,因为堆调整过程中父子节点会跨层交换。
这里有一个很容易被问倒的坑:快排能否写成稳定排序?如果允许使用额外数组,稳定版本的快速排序完全可以实现,比如用三个临时数组分别存放小于、等于、大于基准值的元素,然后把它们按顺序拼接。但这样的实现就不是“原地快排”了,会引入额外内存开销。所以通常结论是:在标准的原地快速排序实现中,它是不稳定的。
5.3 手写实现最容易翻车的几行代码
代码越短越容易在边界条件上翻车,排序算法尤其如此。
先说插入排序的 while 循环。正确写法必须是 while j >= 0 and arr[j] > cur,把下标判断放在前面。有人会把条件反过来写成 arr[j] > cur and j >= 0,当 j 已经变成 -1 时,程序先去访问 arr[-1],这在 Python 里会取到数组最后一个元素,不会立刻报错,但结果完全错乱,而且这种 bug 非常难排查。
归并排序最容易漏的是递归出口。if len(arr) <= 1 不能只写 == 1,因为递归过程中可能出现空数组。用切片写左右子数组虽然直观,但每次递归都复制一份,分析空间复杂度时不能只按 O(n) 算,实际峰值会比理论值高不少。
快速排序的 partition 返回的是基准值的最终位置,递归范围一定要写成 lo 到 p-1 和 p+1 到 hi,不能把 p 再次包含进去,否则递归不会终止,最后栈溢出。Lomuto 分区法虽然写起来简洁,但它在所有元素都相等时会把数组分成一边一个、一边一堆的极端状态,所以真实引擎不会直接拿它处理大规模重复数据。
6. 从理论回到真实选择:高级语言库里到底在用谁
6.1 Python、Java、C++ 的排序实现都没有只靠一种算法
平时用 Python 做数据分析的人,大概率已经直接用 sorted 或者列表的 sort 方法排了很多次序列。你不一定知道的是,这种排序的内部实现叫 TimSort。它的核心是识别出数据中已经存在的“自然有序片段”,对这些片段用归并排序合并,当某个片段足够小时则转为插入排序。所以 TimSort 处理“基本有序”的数据时表现非常亮眼,这也是 Python 默认排序选择它的原因。Java 对对象数组的 Arrays.sort 走的也是类似路线。
C++ 的 std::sort 通常采用 Introsort,也就是先做快速排序,但给递归设置一个深度阈值;一旦深度超过 2 * log(n) 左右,就切换成堆排序,规避快排最坏 O(n²) 的风险。当递归区间长度小于某个阈值时,很多实现也会改用插入排序,因为小数组的递归开销比插入排序的常数还要大。从这个角度回头看,标题里的这六个算法没有一个是白学的:插入排序负责小数组和局部有序,快速排序负责平均最快的大数组,堆排序负责兜底最坏情况,归并排序负责稳定场景。
6.2 如果真要手写,算法库之外我会怎么选
离开标准库,落到一个“需要自己实现排序”或者“面试官不让你调库”的设定里时,我的判断顺序是这样的:第一位先看是否需要稳定。如果需要稳定且数组不太大,直接选归并排序,哪怕额外空间多一点,稳定性这个硬要求通常比内存更关键。如果内存卡得很死,比如只能使用 O(1) 额外空间,那即使不稳定也只能选堆排序。
第二个要看数据本身的形态。如果预测到输入大量是“基本有序”的,插入排序是很有效的前置手段,它甚至可以让整体排序在这一部分退化成近似 O(n)。如果是随机数据,原地快排的常数优势最大,但最好自己加上随机取基准或者三数取中。如果关注的不是完整排序,而是要在海量数据里找前 K 个最大的,那就不该动用完整归并或快排,直接用大小为 K 的小顶堆即可,这也是六种排序里堆结构除了完整排序以外最常见的实战用途。
再分享一个我带新人时经常用的训练方法:同一个数组,分别用六种方法写上测例,在每个函数入口和出口打印一次数组,你会发现插入排序和归并排序在每一步都表现得“很规矩”,选择排序会出现相等元素跳变,快速排序和堆排序则经常出现大幅度的跨越式交换。把这些过程亲眼看过一遍,比背十遍“快排不稳定、归并稳定”要管用得多。排序算法的价值不在那几分钟的背诵里,而在你能根据数据形态、内存预算和稳定性要求,快速判断该信任哪一个方案。
