排序算法这东西,刚入门的时候觉得课本上那几张动图看懂了就是会了,真到写代码、调性能、排查线上问题的时候才发现,懂和会用之间隔着一条不小的沟。这篇内容围绕交换排序、插入排序、选择排序、归并排序这四类基础排序展开,把每种算法的核心机制、代码实现、性能边界和实际选型逻辑拆开讲透。不管你是刚接触数据结构的学生,还是工作几年后想回头把基础夯实一遍的开发者,都能从里面找到能直接上手复现的东西。我会尽量少讲空话,多讲“为什么这么写”“什么场景下别用”“哪里容易写错”,这些都是我自己踩过或者看别人踩过的坑。
1. 先把四种排序的定位摆清楚
在动手写代码之前,有必要先建立一个整体认知:这四种排序不是并列关系,它们在时间复杂度、空间开销、稳定性、实现复杂度上各有取舍,适用的场景也完全不同。很多人学排序的时候容易陷入一个误区,就是背复杂度表格,背完就完了,但真让他选一个排序去解决实际问题,还是凭感觉。我见过不少代码里明明数据量很小、几乎有序,却硬套一个归并排序,白白多开了O(n)的辅助空间;也见过对稳定性有要求的场景用了选择排序,结果相同键值的元素顺序被打乱,引发下游逻辑出错。
1.1 四类排序的核心特征对照
先把关键指标摆出来,后面每一节都会围绕这些指标展开细节。
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 典型适用场景 |
|---|---|---|---|---|---|
| 交换排序(冒泡) | O(n²) | O(n²) | O(1) | 稳定 | 教学演示、小规模近乎有序数据 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小规模数据、近乎有序数据、作为高级排序的子过程 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 交换成本高的场景、对稳定性无要求的小数据 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 大规模数据、外部排序、对稳定性有要求 |
这张表不是让你背的,是让你在选型时有个快速参照。比如你手上有一批几万条、基本已经按时间排好序、只偶尔有几个乱序的记录,插入排序的实际运行时间可能比归并排序还短,因为插入排序在近乎有序的数据上会退化成接近O(n)的行为。这个结论光看复杂度表是看不出来的,得理解算法内部做了什么。
1.2 稳定性为什么值得单独拎出来说
稳定性指的是:排序前后,键值相等的元素相对顺序是否保持不变。这个性质在实际业务里经常被忽略,直到出了问题才想起来。举个典型场景:你有一批订单记录,先按金额排了序,现在想再按下单时间排序,如果用的排序算法是稳定的,那么同一时间下的订单仍然保持金额有序;如果用了不稳定的排序,同一时间的订单金额顺序就乱了,之前那次排序白做。
交换排序(这里特指冒泡排序的相邻交换版本)和插入排序是稳定的,因为它们的交换或移动只在严格逆序时发生,相等元素不会被跨越。选择排序不稳定,原因在于它每次把最小值换到前面时,可能把前面某个和最小值相等的元素甩到后面去。归并排序在合并时只要保证相等时优先取左边的元素,就能维持稳定。这个细节在写归并的合并函数时非常关键,后面会具体讲。
1.3 一个常被混淆的点:交换排序到底指什么
“交换排序”这个词在不同教材里范围不太一样。广义上,凡是靠交换元素位置来推进排序的都算交换排序,包括冒泡排序和快速排序。但在这篇内容的语境里,结合标题把它和插入、选择、归并并列,通常指的是冒泡排序这一类基于相邻交换的排序。我下面讲交换排序时,主要围绕冒泡排序展开,同时会提一下它的一个改进版本(鸡尾酒排序),因为那个改进在特定数据分布下确实有效,不是纯粹的炫技。
把定位摆清楚之后,后面每一节就可以深入到具体实现了。我的建议是,读每一节的时候手边开着编辑器,把代码敲一遍,然后拿几组数据跑一跑,观察交换次数和比较次数的变化,这比光看文字理解得深得多。
2. 交换排序:冒泡的交换次数藏着数据的秘密
冒泡排序大概是所有人学的第一个排序算法,逻辑直观到几乎不需要解释:从头到尾比较相邻两个元素,逆序就交换,一轮下来最大的元素“冒”到最后,然后对剩下的部分重复这个过程。但恰恰因为它太简单,很多人写完就扔了,没有注意到它的一些性质其实很有用。
2.1 冒泡排序的基础实现与提前退出优化
先看基础版本,用Python写,方便阅读:
def bubble_sort_basic(arr): n = len(arr) for i in range(n - 1): for j in range(n - 1 - i): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr这个版本的问题在于,即使某一轮没有任何交换发生(说明数组已经有序),它还是会老老实实跑完剩下的所有轮次。加一个标志位就能解决:
def bubble_sort_optimized(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这个优化看起来不起眼,但在近乎有序的数据上效果非常明显。我实测过一组一万条、只有末尾两个元素逆序的数据,基础版本要跑接近五千万次比较,加了标志位之后第一轮就发现只有一次交换、第二轮直接退出,比较次数降到一万出头。这个差距不是常数级的,是数量级的。
注意:提前退出优化只对“某一整轮无交换”生效,如果数据是“大部分有序但每隔一段就有一个逆序对”,这个优化帮助有限,因为每一轮都会有交换发生,标志位永远不会触发退出。
2.2 鸡尾酒排序:双向扫描解决“小元素在末尾”的尴尬
冒泡排序有个天然的偏向性:它把大元素快速推向末尾,但小元素往前的速度很慢,每轮只能前进一位。如果最小值恰好在数组最末尾,它需要n-1轮才能挪到最前面。鸡尾酒排序(也叫双向冒泡)就是针对这个问题:一轮从左到右把最大值推到末尾,下一轮从右到左把最小值推到开头,交替进行。
def cocktail_sort(arr): n = len(arr) left, right = 0, n - 1 while left < right: swapped = False for i in range(left, right): if arr[i] > arr[i + 1]: arr[i], arr[i + 1] = arr[i + 1], arr[i] swapped = True right -= 1 for i in range(right, left, -1): if arr[i - 1] > arr[i]: arr[i - 1], arr[i] = arr[i], arr[i - 1] swapped = True left += 1 if not swapped: break return arr这个版本在“小元素集中在尾部”的数据上比普通冒泡快接近一倍,因为那些小元素可以在一轮反向扫描中集体前移。但它的代码复杂度上去了,边界条件(left和right的更新时机)容易写错。我的经验是,除非你明确知道数据分布有这种偏向性,否则普通冒泡加提前退出已经够用,鸡尾酒排序更多是理解双向扫描思想的一个练习。
2.3 冒泡排序的交换次数其实是个有用信号
这一点很少有人提:冒泡排序的交换次数恰好等于数组的逆序对数量。因为每次交换都恰好消除一个逆序对,且不会引入新的逆序对。这个性质在某些场景下可以直接利用——比如你需要统计一组数据的“混乱程度”,又不想单独写一个归并求逆序对的函数,直接跑一遍冒泡数交换次数就行(当然数据量大的时候还是用归并更划算)。
我在一个模拟项目里就遇到过类似需求:需要判断用户提交的一批配置项顺序偏离推荐顺序有多远,数据量只有几十条,直接冒泡数交换次数,代码三行搞定,比专门写个逆序对统计清爽得多。这种“用排序算法的副产物解决另一个问题”的思路,值得在工具箱里留个位置。
2.4 冒泡排序在工程中的真实地位
说实话,正经工程代码里几乎不会用冒泡排序去排真实数据,O(n²)的复杂度在数据量上千之后就明显吃力了。但它在两个地方仍然有价值:一是教学,它把“比较”和“交换”这两个排序的基本动作展示得最清楚;二是作为其他算法的退化情况来理解,比如快速排序在最坏情况下退化成冒泡式的O(n²)行为,理解了冒泡的慢,才能理解快排为什么要随机化基准。
3. 插入排序:小数据量下的隐形冠军
如果说冒泡排序是教学工具,那插入排序就是真正能上生产的小数据排序方案。它的思路是维护一个已排序的前缀,每次从后面取一个元素,在已排序部分从后往前找位置插入。这个过程和整理手里的扑克牌一模一样:左手是已排好的牌,右手摸一张新牌,从右往左比,比它大的往右挪,直到找到合适的位置插进去。
3.1 插入排序的实现细节与移动代替交换
先看标准实现:
def insertion_sort(arr): for i in range(1, len(arr)): key = arr[i] j = i - 1 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] j -= 1 arr[j + 1] = key return arr注意这里用的是“移动”而不是“交换”。这是一个容易被忽略但影响性能的细节:如果写成每次比较都做一次swap,那么一次插入需要三次赋值(临时变量交换),而用移动的方式,每个被挪动的元素只赋值一次,最后key一次性落位。在数据量大、移动频繁的情况下,这个差别会累积成可观的性能差异。我做过一个粗略的对比,在随机的一万条整数上,移动版本比交换版本快大约30%到40%,具体数字取决于语言和实现,但方向是一致的。
3.2 为什么插入排序在近乎有序数据上接近线性
插入排序的内层while循环,在数据已经有序的情况下,每次只做一次比较就退出(因为arr[j] > key不成立),所以总比较次数是n-1次,整体是O(n)。数据越有序,内层循环平均执行次数越少。这个性质让插入排序成为“自适应”排序:它的实际运行时间会随着数据的有序程度自动调整。
这个特性在实际中非常有用。很多真实数据天然带有局部有序性,比如日志按时间追加、数据库记录按主键递增插入、用户操作序列大体按时间排列。对这类数据,插入排序的表现远超它的O(n²)理论复杂度所暗示的水平。我在处理一批按时间戳生成、但中间有少量补录记录乱序的数据时,插入排序的实际耗时和归并排序几乎持平,而代码量和内存开销小得多。
3.3 二分插入排序:减少比较次数但移动次数不变
既然插入排序的瓶颈在“找位置”和“挪元素”两件事上,那能不能用二分查找把找位置的过程从O(n)降到O(log n)?可以,这就是二分插入排序:
import bisect def binary_insertion_sort(arr): for i in range(1, len(arr)): key = arr[i] pos = bisect.bisect_right(arr, key, 0, i) for j in range(i, pos, -1): arr[j] = arr[j - 1] arr[pos] = key return arr但要清醒地认识到:二分只优化了比较次数,移动次数一点没少,仍然是O(n²)。所以二分插入排序在“比较成本远高于移动成本”的场景下才有意义,比如比较的是长字符串或者复杂对象,而移动的只是指针。如果元素是简单的整数,比较和移动成本差不多,二分插入排序的收益就很有限,甚至因为二分查找的额外开销而略慢。这个取舍在做选型时要想清楚,别看到“二分”两个字就觉得一定更快。
3.4 插入排序作为高级排序的收尾手段
插入排序最重要的工程价值,其实是作为混合排序算法的子过程。很多工业级排序实现(包括一些标准库里的排序)会在数据规模缩小到某个阈值(通常是十几到几十个元素)时,切换成插入排序。原因很简单:在小数组上,插入排序的低常数开销和缓存友好性,让它比归并或快排的递归开销更划算。这个阈值不是拍脑袋定的,是实测出来的,不同语言、不同硬件上会有差异,但思路是通用的——大问题用分治拆,小问题用插入收尾。
4. 选择排序:交换次数最少的O(n²)选手
选择排序的逻辑是:每一轮在未排序部分找到最小值,和未排序部分的第一个元素交换。它的比较次数固定是n(n-1)/2,和数据的初始状态无关,但交换次数最多只有n-1次。这个“交换次数少”的特点,在特定场景下是它唯一的优势。
4.1 选择排序的实现与交换次数分析
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] return arr注意那个if min_idx != i的判断,它避免了当最小值就在当前位置时的无意义自交换。这个判断在数据已经有序时能把交换次数降到0,虽然比较次数还是那么多,但省掉的是写操作,在某些存储介质上(比如写寿命有限的闪存)是有实际意义的。
选择排序的交换次数上界是n-1,这个数字在所有O(n²)排序里是最低的。冒泡排序最坏情况下交换次数是O(n²)级别,插入排序的移动次数也是O(n²)级别。所以当“交换/移动元素的成本极高”时,选择排序反而有它的位置。
4.2 选择排序不稳定的根因:一次交换可能跨越相等元素
前面提过选择排序不稳定,这里把原因讲透。假设数组是[5a, 5b, 2],第一轮找最小值是2,和第一个位置交换,得到[2, 5b, 5a]。原本5a在5b前面,排序后5b跑到了5a前面,相等元素的相对顺序被破坏了。问题出在“把最小值换到前面”这个动作上,它是一次长距离交换,可能把某个元素甩到和它相等的元素后面去。
如果业务对稳定性有要求,选择排序基本可以直接排除。替代方案要么用插入排序(稳定且在小数据上表现不差),要么用归并排序(稳定且大规模可用)。只有在明确不需要稳定性、且交换成本是主要矛盾时,才考虑选择排序。
4.3 选择排序的变体:堆排序其实是它的进化形态
选择排序每轮找最小值用的是线性扫描,O(n)时间。如果把这个“找最小值”的过程用堆结构优化到O(log n),整体就变成了堆排序,复杂度降到O(n log n)。所以从算法演进的角度看,堆排序可以理解为选择排序的加速版:选择的思想不变,只是找极值的数据结构升级了。理解了这个脉络,再看堆排序就不会觉得它是凭空冒出来的。
不过堆排序本身不在这次的四类排序里,这里提一句是为了帮你建立联系。实际选型时,如果需要一个原地、O(n log n)、不稳定的排序,堆排序是比选择排序更合理的选择;如果数据量小到O(n²)可以接受,那选择排序的简单直接反而有优势。
4.4 什么情况下我会真的用选择排序
坦白说,纯选择排序在生产代码里出现的频率很低。我能想到的真实场景有两类:一是数据量很小(几十条以内)且元素交换成本高(比如元素是很大的结构体,交换涉及大量内存拷贝),这时候选择排序的最少交换特性有价值;二是作为教学对照,用来展示“比较次数固定”和“交换次数最少”这两个性质如何影响实际性能。除此之外,大多数时候插入排序是更好的O(n²)选择,因为它在近乎有序数据上的自适应能力是选择排序不具备的。
5. 归并排序:分治思想的标准范本
归并排序是这四种里唯一达到O(n log n)的,也是理解分治思想最好的入口。它的核心就两步:把数组从中间劈开,分别排好序,然后合并两个有序数组。递归下去,直到子数组长度为1(天然有序),再逐层合并回来。
5.1 归并排序的递归实现与合并函数的关键细节
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): 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合并函数里那个<=是稳定性的关键。当左右两边元素相等时,优先取左边的,这样左边元素(在原数组中靠前)就保持在右边元素前面,稳定性得以维持。如果写成<,相等时取右边,稳定性就被破坏了。这个细节很多人在写的时候不会注意,但它是归并排序能作为稳定排序使用的根本原因。
另外,上面这个实现每次递归都创建了新列表(arr[:mid]和arr[mid:]),空间开销比理论上的O(n)要大一些,因为切片和result列表都在分配内存。在追求性能的场景下,通常会改写成原地辅助数组的版本,只分配一个和原数组等大的临时数组,通过索引区间来操作。
5.2 自底向上的归并:省掉递归但逻辑更绕
递归版本的归并排序在数据量极大时可能遇到递归深度问题(虽然log n的深度通常不会爆栈,但在某些受限环境里仍需注意)。自底向上的版本从长度为1的子数组开始,两两合并,逐步扩大归并段长度:
def merge_sort_bottom_up(arr): n = len(arr) size = 1 while size < n: for start in range(0, n, 2 * size): mid = min(start + size, n) end = min(start + 2 * size, n) if mid < end: merged = merge(arr[start:mid], arr[mid:end]) arr[start:end] = merged size *= 2 return arr这个版本的边界处理(mid和end的min操作)是容易写错的地方,尤其是数组长度不是2的幂时,最后一段可能不足一个完整的归并段。我建议初学时先用递归版本把逻辑跑通,再来自底向上版本对照,理解两者在归并顺序上的差异。自底向上的好处是没有递归调用开销,在某些语言里对缓存也更友好。
5.3 归并排序的空间开销到底值不值
归并排序需要O(n)的辅助空间,这是它相比其他三种排序最大的劣势。在内存紧张的环境里,这个开销可能是决定性的。但换个角度看,这O(n)空间换来的是稳定的O(n log n)最坏复杂度,以及一个非常适合并行化和外部排序的结构。
外部排序是归并排序真正发光的地方。当数据量大到内存放不下时,可以把数据分块读入内存、每块用内部排序排好写回磁盘,然后对多个有序块做多路归并。这个思路在处理大规模日志、数据库大表排序时是标准做法。归并的“合并两个有序序列”这个操作天然适合流式处理,不需要一次性把所有数据装进内存,这是它相比快排的根本优势。
5.4 归并排序的稳定性在业务中的实际价值
回到前面订单的例子:先按金额排、再按时间排,需要第二次排序稳定。归并排序能保证这一点,而且它的O(n log n)复杂度让它在数据量大时仍然可用。这就是为什么很多语言的标准库排序(比如某些实现对对象排序时)会选择归并或者归并的变体(如TimSort,它是归并和插入的结合)。TimSort的核心思路就是:利用数据中天然存在的有序片段(run),用插入排序扩展这些片段,再用归并合并,兼顾了自适应性和最坏复杂度保证。
理解归并的稳定性来源,还能帮你在需要时自己改造合并逻辑。比如你需要“相等时按另一个字段排序”,那就在合并函数的比较里加上第二关键字,而不是排两次。这种在合并阶段做多关键字比较的技巧,在写复杂排序需求时很实用。
6. 选型不是背表格,是看数据长什么样
四种排序讲完,最后落到最实际的问题:拿到一个具体任务,怎么选。我的经验是,先问三个问题:数据量多大、数据初始有序程度如何、对稳定性和内存有什么约束。这三个问题的答案基本能锁定方案。
6.1 按数据规模和有序程度做决策
数据量在几十条以内,插入排序几乎总是对的,代码短、常数小、近乎有序时还快。数据量上千且基本有序,插入排序仍然能打,但如果完全随机,就该考虑归并了。数据量上万且要求稳定,归并是稳妥选择。数据量上万但不要求稳定、且内存紧张,那其实该考虑快排或堆排,虽然它们不在这次的四类里,但选型时不能因为标题只列了四种就自我设限。
有序程度这个维度经常被低估。我做过一组对比实验:同样一万条数据,完全随机时插入排序耗时约是归并的十几倍;但把数据打乱程度降到只有1%的逆序对时,插入排序的耗时反超归并。这个拐点大概在逆序对比例很低的时候出现,具体阈值和实现、硬件有关,但趋势是明确的。所以如果你能预判数据大体有序,别急着上归并。
6.2 稳定性与内存约束下的取舍
稳定性有硬要求时,交换排序(冒泡)、插入排序、归并排序三选一。数据小用插入,数据大用归并,冒泡基本只在教学场景出现。稳定性无要求时,选择排序在交换成本高的场景有一席之地,但多数时候插入排序更均衡。
内存约束紧、不允许O(n)辅助空间时,归并出局,只能在O(n²)的原地排序里选。这时候插入排序通常是首选,除非交换成本特别高才考虑选择排序。这个决策链条不复杂,关键是别在没想清楚约束的情况下凭直觉选。
6.3 几个我踩过的实现层面的坑
第一个坑是边界条件。插入排序的内层while循环条件j >= 0 and arr[j] > key,两个条件的顺序不能反,必须先判断j >= 0再访问arr[j],否则会越界。这个错误在Python里会抛异常还算好发现,在某些语言里可能读到脏数据而不报错,排查起来很痛苦。
第二个坑是归并的切片开销。递归版本里arr[:mid]这种切片在数据量大时会频繁分配内存,我一开始没在意,后来在一个处理十万级数据的脚本里发现排序占了大部分时间,改成索引区间操作后快了不少。这个优化不难,但要有意识去做。
第三个坑是稳定性验证。写完一个声称稳定的排序,一定要用带重复键的数据测一下,比如[(1,'a'), (1,'b'), (1,'c')]这种,排序后检查相同键的元素相对顺序有没有变。光看代码觉得“应该是稳定的”不够,实测才算数。
6.4 把这四种排序当成理解更复杂算法的台阶
最后说个心态上的事。这四种排序单独拿出来,除了插入排序在小数据场景还有点实战价值,其余三种在工程里基本都被更高级的算法或标准库替代了。但它们真正的价值在于建立对排序问题的基本直觉:什么叫比较、什么叫交换、什么叫分治、稳定性从哪来、复杂度怎么分析。这些直觉在你后面理解TimSort、基数排序、外部排序、甚至数据库的排序算子时,会反复用到。
我自己回头看,当年觉得冒泡排序“太简单没什么好学的”,恰恰是这种轻视让我在后来看快排的退化分析、归并的外部排序应用时多绕了弯路。基础算法的每一个“缺点”(冒泡的慢、选择的不稳定、归并的费内存)背后都对应着一类真实约束,理解了约束,选型才不会拍脑袋。这四种排序值得花时间把代码敲一遍、把数据跑一遍、把边界测一遍,这个投入在后面会以各种形式还回来。