排序这东西,几乎每个写代码的人都绕不过去。我刚入行参加面试、带新人、做底层框架选型,发现一个很有意思的现象:很多人张口闭口都是 Arrays.sort、Collections.sort,能调库就绝不自己写,可真问到内部原理、为什么快排比归并快、什么时候插入排序反而最好,一下子就卡壳了。这其实是一个很大的认知盲区——排序算法不只是应付面试的八股文,它背后是对时间复杂度、空间复杂度、稳定性这些底层概念的直观训练。
而且,排序算法的价值远不止"把数组排好序"这么简单。二分查找依赖有序数组,去重依赖相邻元素比较,归并排序的思路是外部排序的基础,堆排序的思路是优先队列的骨架,这些都是排序算法思想的外溢。所以哪怕日常工作里很少手写排序,我也建议你静下心把选择、冒泡、插入、归并、快速、堆这六种经典算法逐个实现一遍,建立起自己的算法直觉。这也是本文的初衷:把这六个经典算法放在一起对比着看,找出它们的异同。
这六种算法,按照时间复杂度可以分成两组。第一组是O(n²)家族:选择排序、冒泡排序、插入排序,三兄弟思路简单,适合教学和少量数据场景。第二组是O(n log n)家族:归并排序、快速排序、堆排序,三种方案思想完全不同,是真正常年被工程化的对象。本文会一一把它们的原理、代码、复杂度、稳定性讲透,并重点剖析一下应用最广、也是面试最高频的快速排序——它的partition过程、优化技巧和边界case处理,值得你花心思慢慢啃。
在动手写代码之前,我建议你先建立两个分析维度。第一个是时间复杂度:算法运行时间怎么随输入规模增长?第二个是稳定性:相同元素排序前后的相对顺序有没有改变?不要小看稳定性,它直接决定了排序算法在真实业务里的可用性。把这些维度搞清楚了,后面看每个算法的优缺点就会一目了然。
1. 简单排序三兄弟:选择、冒泡、插入的完整拆解
1.1 选择排序:最直观的"打擂台"思维
选择排序是六种算法里思路最简单的一个。它的核心思想是:在未排序的序列中,每次选出一个最小(或最大)的元素,放到已排序序列的末尾,直到整个序列有序。打个比方,就像你从一摞牌中反复抽出最小的那张放到一边,抽完所有牌,手里的牌也就排好了。
具体实现上,外层循环控制"放第几个位置",内层循环负责在剩余区间里找到最小值下标,然后交换。这段代码几乎没有弯弯绕绕,边界条件也简单,是我平时建议初学者第一个手写的排序算法。
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
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
但选择排序有一个非常要命的特性:无论输入数据原本是有序还是无序,它都要做满 n(n-1)/2 次比较,也就是说时间复杂度永远都是O(n²)。而且它是不稳定的,比如数组 [5, 5, 3],第一个5会跟3交换,两个5的前后顺序就颠倒了。如果你只是学习原理,选择排序很有价值;但真实业务里,它基本没有用武之地,除非是数据量极小、且不在乎稳定性。
1.2 冒泡排序:相邻交换的直觉与性能代价
冒泡排序的思路是:反复比较相邻两个元素,如果顺序不对就交换,每一轮结束后,最大的元素就像气泡一样"浮"到数组末尾。这个过程非常直观,所以在很多教科书里排在最前面,我也见过不少朋友用冒泡排序入门后,一直对"排序很难"有心理阴影,因为它的交换次数实在太多了。
冒泡值得讨论的地方在于它有一个天然优化:如果某一轮从头到尾都没有发生任何交换,说明数组已经有序,可以直接终止。加上这个优化的冒泡,在输入基本有序时性能很不错,时间复杂度可以最好到O(n)。初学者很快就能理解"提前终止"这种优化思路:
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
return arr
不过冒泡的问题也很明显:它每交换一次就要移动数据,最坏和平均复杂度都是O(n²);如果数据是逆序的,交换次数高达 n(n-1)/2,性能非常差。冒泡排序是稳定的,因为只有真正逆序的相邻元素才会交换位置,相同元素不会被交换。它的教学价值在于让你直观感受到"比较-交换"这个模型,但工程上几乎没有应用,我心目中它是六种算法里实用性最低的一个。
1.3 插入排序:几乎有序时的隐形王者
插入排序是我要特别推荐大家重视的一个算法。它的思路像打扑克时整理手牌:把新抓到的牌插入到手里已经排好序的牌堆中的适当位置。具体到数组实现上,就是从第二个元素开始,把当前元素从后往前逐个与前面已排序的元素比较,找到合适位置插入,同时把较大元素往后挪。
python复制def insertion_sort(arr):
n = len(arr)
for i in range(1, n):
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
插入排序的平均和最坏复杂度同样是O(n²),但它有两个其他O(n²)排序不具备的优点。第一,它稳定,相同元素的相对顺序不会变。第二,它对"几乎有序"的数据集非常友好——当每个元素离它的目标位置很近时,内层循环跑不了几次,插入排序的复杂度可以逼近O(n)。这一点在现代混合排序算法里被大量利用,比如Timsort,也就是Python内置sort的底层算法,会对小规模区间直接跑插入排序,因为它在小数组上常数极小、实现又简单。所以真的别小看插入排序,它是"廉价排序"里最值得掌握的一个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高效排序三强:归并、快速、堆的成绩单
2.1 归并排序:用空间换稳定性的分治方案
归并排序是分治思想最标准的教科书实现。把数组对半拆分,递归排好左右两半,再合并成一个有序数组。核心操作是"合并两个有序数组",这个过程只要同时扫描两个数组,谁小就把谁放进辅助数组,最后再把结果复制回原数组。
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:])
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
归并排序的时间复杂度稳定在O(n log n),无论输入是什么顺序都一样,不存在最坏情况退化的风险。它还是稳定的,因为合并时我们只用 <= 比较,左边数组的相同元素始终先被放入结果。代价是它需要O(n)的额外空间,如果递归版还要额外考虑递归栈的空间。
工程上,归并排序的地位比很多人想的高。很多语言标准库的稳定排序都建立在归并思想上,比如Python的Timsort就是"插入排序+归并排序"的混合体;另一大类应用是外部排序——当数据量大到内存放不下时,把磁盘上的大文件切成块排好,再反复执行"多路归并",最终合并成一个有序文件。我参与过一些日志文件的排序清洗任务,数据量以GB为单位的时候,快排根本没法直接用,归并思想才是保命方案。所以归并排序学的不只是代码,更是"把数据拆到能放入内存再逐层合并"的思路。
2.2 快速排序:平均性能最好的分治代表
快速排序同样是分治思想,但它的拆分方式跟归并略有不同:选择一个基准元素(pivot),把数组划分成"小于等于基准"和"大于等于基准"两个部分,然后递归对这两个部分排序。这里有个关键点——基准元素选完之后,它已经落在最终位置上,不需要再参与后续的递归排序。整个算法的核心在于partition(划分)这一步。
python复制def quick_sort(arr):
def sort_range(arr, lo, hi):
if lo >= hi:
return
mid = partition(arr, lo, hi)
sort_range(arr, lo, mid - 1)
sort_range(arr, mid + 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
sort_range(arr, 0, len(arr) - 1)
return arr
快速排序的平均时间复杂度是O(n log n),与归并排序同级,但它的常数因子更小:内层循环里只有几个简单比较和赋值,而且对CPU缓存非常友好,这使它在大多数内存级排序场景里表现得比归并更快。它的缺点是:第一,不稳定;第二,最坏情况下会退化到O(n²),比如数组本来就排好序、又恰好选了第一个或最后一个元素当基准。所以"选好基准"和"处理退化成有序数组"就成了快排工程实现的核心课题,我后面会专门用一整章来展开。
2.3 堆排序:不依赖递归的O(n log n)方案
堆排序的思路完全不同于前两者。它先把数组整理成大顶堆(每个父节点都大于等于子节点),此时堆顶就是全局最大值。然后把堆顶和堆尾交换,最大值就位,堆大小减一,再对新的堆顶执行下沉操作,如此反复,直到堆里只剩一个元素。整个过程不需要额外空间,也不需要递归,空间复杂度O(1),这是它最大的优势。
python复制def heap_sort(arr):
n = len(arr)
def heapify(arr, n, i):
largest = i
l = 2 * i + 1
r = 2 * i + 2
if l < n and arr[l] > arr[largest]:
largest = l
if r < n and arr[r] > arr[largest]:
largest = r
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0]
heapify(arr, i, 0)
return arr
堆排序的时间复杂度稳定O(n log n),但实际工程中它的表现往往不如快排,原因有两个:一是它访问数组的顺序是跳跃式的,对CPU缓存极不友好;二是在排序过程中,交换次数比快排多得多,常数因子偏大。它的价值更多体现在"堆"这个数据结构本身,比如Top-K问题、优先级队列、任务调度,这些场景里堆是主角。如果面试官问的是"在O(1)额外空间下排序",堆排序几乎就是标准答案。但如果你问我会不会用堆排序做日常数组排序,我的回答通常是不用——快排更合适,除非你明确被要求限制额外空间。
3. 快速排序深潜:为什么人人爱考快排
3.1 partition的两种主流写法与各自边界
快速排序代码本身很短,但每年手写快排出问题的同学依然一大片,问题几乎都出在partition上。最常见的交换式partition有两种。一种是Lomuto写法,也就是我在前面代码里给出的那种:用快慢指针把小于等于pivot的值都甩到左边,最后把pivot归位。它代码短、易于理解,适合教学和笔试。另一种是Hoare写法,用两个指针从两端相向移动,左边找大于pivot的元素,右边找小于pivot的元素,找到就交换,直到两个指针交错。Hoare写法交换次数更少,平均性能稍好一些,但边界条件更绕,容易写成死循环。
我自己在实战中更推荐先掌握Lomuto再学Hoare,因为Lomuto的逻辑和代码结构好记,笔试时不容易错;至于Hoare,理解它有助于你读一些高性能排序库的源码。不要一上来就想写最"优"的版本,先把一个版本写对,再谈优化。无论用哪种partition,有几个边界规则是通用的:递归终止条件是 lo >= hi;交换pivot时要格外小心越界;有大量重复元素时如果只做两端二分,递归树会严重不平衡。这些细节我在下一节结合优化一起说。
3.2 三个让快排真正变快的工程优化
快排的O(n²)退化不是理论上的杞人忧天。当数组已经有序,你如果固定取第一个或最后一个元素做基准,每次partition都只能切出一个空区间,递归深度变成n,复杂度立刻退化成O(n²)。针对这个问题,工程上有三个经典优化手段。
第一是三数取中。在数组的左端点、中点、右端点三个位置取中间值作为pivot,能极大降低有序输入造成的最坏概率。这个优化对"基本有序"的数组特别有效,因为中间值往往接近整个数组的中位数。第二是小区间用插入排序。递归到子数组长度很小(比如小于10)时,插入排序的常数优势远大于递归开销,很多标准库实现里都开了这个口子。第三是三路划分,专门对付大量重复元素。它的思路是把数组划分为"小于pivot""等于pivot""大于pivot"三块,等于pivot的区间不再递归,这样重复元素越多,递归规模就越小。我在处理一些业务里带有大量重复键的排序需求时,三路划分的效果非常明显。
把这些优化组合起来,快排才是标准库级别的东西。Java的Arrays.sort对基本类型数组使用双轴快排,核心思想就是三路划分的改进版;C++的std::sort则是快排+插入排序+堆排序的混合体,一旦递归深度过大就自动切到堆排序兜底,防止最坏情况。读这些源码之前,建议你先把自己的快排优化到能处理有序数组和重复数组的边界case,再去看它们,收获会大得多。
3.3 手写快排出错最多的三个位置
快排调试起来确实比冒泡麻烦,因为运行顺序不像冒泡那么直观。结合我带过的同学和自己在笔试现场的状态,有三类错误出现频率最高。第一个是循环条件写错,最常见的是在递归时把区间写成 [lo, mid] 而不是 [lo, mid-1],导致pivot位置被重复排序,形成死循环。第二个是partition内部下标越界,尤其Hoare写法里,左右指针已经交错却还在执行比较或交换,数组越界、交换错位都是这么来的。第三个是选择pivot时取错了值——数组元素是int时直接取 arr[hi] 没问题,但如果是对象排序或排序下标区间而不是整个数组时,忘记把pivot的值复制出来,中途交换一多pivot位置变了,整个partition逻辑立刻崩溃。
我建议你在本地练习时,多备几组测试数据:完全随机数组、有序数组、逆序数组、全部相同元素的数组、只有一个元素的数组。五组数据跑通,快排的边界处理就基本过关了。实际笔试时如果时间紧张,写一个Lomuto版本加"三数取中"是最稳妥的组合,兼顾可读性和性能。
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 log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | 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(1) | 不稳定 |
这张表里藏着很多值得琢磨的信息。比如选择排序和插入排序"纸面数据"几乎一样,都是O(n²)级,但插入排序在有序数据下能达到O(n),选择排序则永远稳如磐石地慢;再比如快速排序最坏是O(n²),但依然被大量工程采用,核心原因就是平均常数小。面试时能把这些"纸面背后"的原因讲清楚,比背下整张表更有说服力。
4.2 稳定性为什么不是小事:两个来自业务的例子
很多人觉得"稳定不稳定"只是教科书概念,实际无所谓,但我在业务里还真遇过必须靠稳定性撑住逻辑的情况。第一个例子是按多个条件排序。假设有一批订单,先按金额降序排,再按创建时间升序排。如果第二次排序是稳定的,第一次按金额排好的相对顺序会被保留;如果不稳定,同一个金额区间内部的顺序就可能被打乱,导致最终排序结果不符合预期。第二个例子是排行榜场景。用户按得分排序,得分相同时要求先达到该分数的用户排在前面。这个"先到者优先"在数据库里本质就是"按时间戳升序的稳定排序",如果你在应用层对得分做了一次不稳定排序,先后的顺序就丢了,得重新处理。所以,"稳定"在真实业务里不是加分项,是硬需求。
根据这个需求反向选型就很清晰:数据量小但要求稳定,用插入排序;数据量大且要求稳定,用归并排序;数据量大且不要求稳定,优先快速排序;空间限制严格且不要求稳定,才考虑堆排序。这个决策顺序我用了很多年,每次帮组里做排序方案选型都是这样快速定下来的。
4.3 不同语言标准库排序的选型逻辑
接触过几个主流语言标准库之后,你会惊叹于排序算法在工程里的"杂交"程度。Python内置的 sorted / list.sort 用的是Timsort,Timsort本质是"插入排序+归并排序"的混合:它会扫描数组找出天然有序的run块,用插入排序把run块拉长,再用归并把这些run合并,所以对真实世界的部分有序数据特别快,而且是稳定排序。Java的Arrays.sort更细致:对基本类型数组用双轴快排,因为基本类型不需要稳定性;对对象数组用的是Timsort变体,因为对象排序往往需要稳定。C++的std::sort用内省排序,混合了快速排序、插入排序和堆排序,递归深度太深就自动切换到堆排序,避免快排最坏情况。
学习这些标准库选型,比单纯背算法代码有价值得多。你会发现,工程级排序几乎没有"只用一种算法"的情况,全是组合拳。而我们作为使用者,至少应该做到:知道默认sort是稳定还是不稳定,知道什么时候可以放心调库,什么时候得自己实现特定排序。比如在Python里你用 sorted,只要不传key,排序就是稳定的,这种默认行为本身就比很多开发者用过的伪"稳定"排序可靠得多。
5. 手写排序的正确打开方式:测试方法、边界案例与通用注意事项
5.1 一套人人都能用的排序自测脚本
算法写完不是结束,验证才是重点。我给新手一个非常实用的自测方法:写一个随机数组生成器,固定种子,把六种排序算法跑在相同数据上,再和Python内置的 sorted 结果逐项对比。只要有一个输出不一致,马上就知道是哪里写错了。下面这套代码适合任何语言的算法练习,用Python写最省事:
python复制import random
def is_sorted(arr):
return all(arr[i] <= arr[i + 1] for i in range(len(arr) - 1))
def test_sort(sort_func, n=1000, seed=42):
random.seed(seed)
for _ in range(20):
arr = [random.randint(-1000, 1000) for _ in range(n)]
copy = arr[:]
sort_func(copy)
assert is_sorted(copy), f"排序结果不正确: {arr} -> {copy}"
print(f"{sort_func.__name__} 通过基础测试")
# 使用示例
test_sort(quick_sort)
除了随机数据,建议专门测四类特例:完全有序数组、完全逆序数组、全相同元素数组、空数组和单元素数组。很多排序的隐藏bug,前两种会触发快排和堆排序的最坏路径,第三种会暴露partition在重复元素上的死循环隐患,最后两种则是为了检查边界判断是否严密。我在组里带新人时,会让对方先把这六类测试跑通,再讨论"写得多优雅",顺序不能反。
5.2 递归深度和数据规模:容易被忽视的坑
归并和快速排序的递归版都不适合直接处理超大数组,因为递归深度可能达到n。Python里默认递归深度上限通常只有1000,数据规模一大,直接报RecursionError;有些语言不报错,但栈溢出会让程序崩溃。实战里的做法一般是两种:一是把递归改成显式栈的迭代写法,二是调高递归深度限制。前者更工程化,后者适合笔试或临时脚本。
python复制import sys
sys.setrecursionlimit(1000000)
但调高递归深度只是治标。真正到了生产环境,处理上千万甚至上亿条记录,靠快速排序的递归版直接在内存里排序并不现实,要么走数据库的ORDER BY,要么用外部排序思路分批处理。也就是说,排序算法写在纸面上时不难,难的是知道它在什么数据规模下还能不能成立。这个"规模感"是工作里慢慢打磨出来的,建议你练习时顺便用timeit测一测不同数据量下各算法的耗时,形成自己的直觉。
5.3 从手写排序到内化排序思维
最后说点实在的。手写这六个排序,核心目的不是让你以后每次都自己实现,而是让你建立一种"复杂度-稳定性-额外空间"的三角判断框架。以后你看到任何排序相关的算法题、设计题、甚至是某个中间件源码里出现的排序逻辑,都能快速分辨它属于哪一类、复杂度是多少、为什么不稳定、能不能替换成更合适的方案。这种能力,比背诵任何一段代码都值钱。
我自己面试候选人时,如果对方能把快速排序的partition讲清楚,并且下意识提到"如果数据有大量重复,可以改用三路划分",我就知道这个人是真理解快排,而不只是背了模板。反过来,候选人只说"快排是选一个pivot然后两边递归排",我就会继续追问,看看他对最坏情况的意识到什么程度。因此,这篇整理虽然从六种算法展开,但我真正想传递的是:把算法当作一个可以不断追问"为什么"的对象,而不是一份需要熟背的答案。
提示:递归版归并和快排在处理超大数组前,一定要先评估递归深度。Python默认的递归上限是1000,很容易撞上,建议练习阶段就把
setrecursionlimit考虑进去。
