先抛个问题:一个包含10万元素的列表,排序一次需要多久?如果你第一反应是 data.sort(),那还得再问一句——你知道这个 sort() 为什么快吗?如果连 sort() 和 sorted() 的区别都要想三秒,那这篇笔记应该对你有用。我最近在整理数据结构与算法系列知识点,正好到了第六篇,Python版排序算法。这个主题我用 Python 完整实现了一遍,从冒泡到堆排,还顺手做了性能对比,这里把整个思考过程写出来,包含代码、复杂度分析和实际工程中该注意的细节。
文章适合两类人:一类是算法初学者,跟着代码推一遍排序原理;另一类是面试前突击的人,需要把稳定性、原地排序、递归深度这些考点一次理清。看完你会知道,排序不是背模板,而是一道综合题——时间、空间、稳定性、数据形态这些因素都要放进同一个盘子里权衡。这篇文章里所有代码都是 Python 3 的写法,我尽量保持代码可读,同时会解释每个写法背后的原因,而不是直接把答案丢给你。
1. 先把排序的"选择题"做对:稳定性、时间和空间怎么权衡
我刚学排序时,总以为把代码背下来就算掌握。后来发现,不管是面试还是真实项目,最先被问到的往往不是代码,而是三个选择题:时间复杂度选多少?额外空间能不能省?排序是否稳定?这三个问题像一把尺子,把排序算法分成不同类型。排序算法的分析也几乎全部围绕它们展开:比较和移动的次数决定了时间,临时数组或递归栈的空间决定了内存,相等元素的相对顺序是否保留决定了稳定性。
1.1 三个绕不开的基本概念
稳定性的理解,很多人会绕晕。我举个例子:假设有一列订单,每单有金额和下单时间两个字段。你先按金额排了一遍,现在想按时间排序。如果排序是稳定的,那么同样时间的订单会继续保持金额从小到大的顺序;如果排序不稳定,第二次排序后,相同时间内的订单顺序可能完全被打乱。实际后台导出报表时,这种多级排序太常见了。Python 内置的 list.sort() 和 sorted() 都是稳定排序,所以你可以一行一行地连续排序来达到多字段排序效果。
原地排序的定义则没那么玄:不是说"完全不用额外变量"。Python 里交换两个元素,a, b = b, a 在背后仍可能申请临时变量,但那是常数级的 O(1)。真正的原地排序,要求额外空间跟输入规模无关,比如冒泡、插入、选择、堆排序。而归并排序如果每次 merge 都新建一个列表,额外空间就是 O(n),它不是原地排序。快速排序的经典写法则要看具体实现:如果每次分区都生成新列表,空间 O(n);如果像下面第 3 节的写法用下标原地交换,额外空间主要是递归栈,平均 O(logn)。
1.2 一张表把主要排序算法归位
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 是否原地 |
|---|---|---|---|---|---|
| 冒泡 | O(n²) | O(n²) | O(1) | 稳定 | 是 |
| 选择 | O(n²) | O(n²) | O(1) | 不稳定 | 是 |
| 插入 | O(n²) | O(n²) | O(1) | 稳定 | 是 |
| 希尔 | 约 O(n^1.3) | O(n²) | O(1) | 不稳定 | 是 |
| 归并 | O(nlogn) | O(nlogn) | O(n) | 稳定 | 否 |
| 快速 | O(nlogn) | O(n²) | O(logn)~O(n) | 不稳定 | 视实现 |
| 堆 | O(nlogn) | O(nlogn) | O(1) | 不稳定 | 是 |
注意表格里的空间复杂度是按常见实现计算的,快排那栏我标了"视实现",就是因为很多人用切片写快排,空间直接变成 O(n)。另外,排序算法为什么是面试常客?因为它能一次性考察递归、分治、堆、稳定性、边界条件等多个知识点。拿到一个排序问题,先别急着写循环,而是先问自己:数据量多大?能否接受 O(n) 额外空间?允许不稳定吗?这三个问题答完,算法选择范围已经缩小了大半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. O(n²)家族其实没你想的那么无用:冒泡、选择、插入、希尔
先从最基础的 O(n²) 家族开始。有人觉得这类排序又慢又笨,实际上还是值得逐行写一遍,因为它们的实现思路是后面很多优化算法的地基。尤其插入排序,在工程里一直以另一种形式活着。
2.1 冒泡排序:教科书常客,但不是一无是处
python复制def bubble_sort(arr):
arr = arr[:] # 复制一份,避免修改原列表
n = len(arr)
for i in range(n - 1):
swapped = False
for j in range(n - i - 1):
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) 复杂度。
不过要泼一盆冷水:尽管有了提前退出,冒泡在随机数据上的性能依然很差。因为它的内层循环总是要不断比较和交换,常数比较大。在 Python 里尤其如此——Python 的循环开销大,每多一次比较就多一分明显的耗时。写这个算法更多是为了理解"相邻交换"这个基本操作,而不是真的在业务里用它。
2.2 选择排序和插入排序:一个为了交换少,一个为了定位快
python复制def selection_sort(arr):
arr = 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
选择排序的思路是每轮从剩余元素里挑最小的,放到当前区间最前面。它的特点是交换次数很少:对 n 个元素最多交换 n-1 次。如果排序对象是体积很大、交换代价很高的记录,选择排序的交换成本优势就体现出来了。但它的比较次数固定为 n(n-1)/2,不管数据有没有序都一样,所以最好和最坏都是 O(n²)。而且它不稳定,这是由"把远处的最小值直接换到前面"这个动作决定的。
python复制def insertion_sort(arr):
arr = arr[:]
for i in range(1, len(arr)):
cur = arr[i]
j = i - 1
while j >= 0 and arr[j] > cur:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = cur
return arr
插入排序像打牌时整理手牌:摸到一张新牌,从右往左找到合适位置插进去,让比它大的牌整体后移。代码里 cur 保存当前牌,while 循环负责把 arr[j] 往右挪,最后把 cur 放到腾出的位置。这个算法非常适合"基本有序"的输入,数据越接近有序,内层循环越早退出,理论上最好可以到 O(n)。在小规模数据上,插入排序的实际表现往往超过很多 O(nlogn) 的排序,因为它的常数非常小。插入排序还是稳定排序,Python 内置的 Timsort 在排序小片段时,也大量使用插入排序的思路,而不是在每一刻都强行用快速排序。
2.3 希尔排序:插入排序的"外挂"
python复制def shell_sort(arr):
arr = arr[:]
n = len(arr)
gap = n // 2
while gap > 0:
for i in range(gap, n):
cur = arr[i]
j = i - gap
while j >= 0 and arr[j] > cur:
arr[j + gap] = arr[j]
j -= gap
arr[j + gap] = cur
gap //= 2
return arr
希尔排序是插入排序的改进版。它先把数组按间隔 gap 分成几组,对每组做插入排序,然后缩小 gap,重复上面的过程,直到 gap=1。这样做的好处是,较远的元素可以更快地移到正确位置,大幅减少整体的移动次数。gap 序列的选择直接影响复杂度。简单取 gap = n // 2 然后不断除以 2,最坏情况依然是 O(n²),只是比起普通插入排序,常数已经小了很多;如果使用更复杂的增量序列,可以达到接近 O(n^1.3) 到 O(nlogn) 的水平。记忆点时注意:希尔排序是不稳定的,因为分组插入时,相等元素可能被分到不同组,跨越式移动会破坏相对顺序。
3. 从分治到堆:归并排序、快速排序、堆排序的正确打开方式
基础算法讲完,进阶部分我选了三个最常见的 O(nlogn) 级排序:归并、快排、堆排。它们代表了三种思维:分治合并、分区递归、堆结构。
3.1 归并排序:稳定和 O(nlogn) 的可靠组合
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):
i = j = 0
res = []
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
归并排序的步骤很清晰:把数组从中间分成两半,分别排序,再把两个有序数组按顺序合并。merge 函数里用两个指针 i 和 j 从左向右扫描,谁小先取谁。为了避免下标越界,最后的 res.extend 把剩余部分一次性接上。归并排序的优点很多:时间稳定 O(nlogn),不受输入数据是否有序影响;是稳定排序,前提是 merge 里用 <= 取左边。缺点也很明显:空间 O(n)。如果面对的是链表排序,归并是很好的选择,因为链表的额外空间主要集中在递归栈上,不需要像数组那样 copy 来 copy 去。递归版归并在 Python 中,当 n 较大时递归深度也只有 logn 左右,基本不会碰到 RecursionError,所以它是初学者写分治算法的安全起点。
3.2 快速排序:平均最快,但怕"逆天"输入
python复制def quick_sort(arr, low=0, high=None):
if high is None:
high = len(arr) - 1
if low < high:
pivot_index = partition(arr, low, high)
quick_sort(arr, low, pivot_index - 1)
quick_sort(arr, pivot_index + 1, high)
return arr
def partition(arr, low, high):
pivot = arr[high]
i = low - 1
for j in range(low, high):
if arr[j] < pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
快速排序的经典思路:选一个基准值 pivot,把小于它的放左边、大于它的放右边,然后对左右两个区间继续递归。这里给出的 partition 是我比较喜欢的一种写法,以最后一个元素为 pivot,用 i 维护"小于 pivot 的区域边界",j 遍历剩余元素,遇到小于 pivot 的就交换到前面,最后把 pivot 放到 i+1 的位置,返回它的下标。快排的平均复杂度是 O(nlogn),常数小,在随机数据上通常表现很好。但它的软肋是 pivot 选择和递归深度。如果每次 pivot 都恰好是最大或最小值,分区极不平衡,退化到 O(n²),递归深度也会到 n。Python 默认递归深度约 1000,所以给 1000 个以上有序元素排个序,经典固定 pivot 的快排很可能直接崩掉。
我的建议是:在 partition 里,先随机选一个下标,把它和 high 位置的元素交换,再走相同的分区逻辑。这样虽然引入了一点 random 的开销,却能大幅降低遇上最坏输入的概率,让期望复杂度更稳定。如果你追求极致,还可以用"三数取中"——从区间左、中、右三个位置取中间值作为 pivot,不需要随机数,效果也不错。
3.3 堆排序:原地、不稳定的 O(nlogn)
python复制def sift_down(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]
sift_down(arr, n, largest)
def heap_sort(arr):
arr = arr[:]
n = len(arr)
for i in range(n // 2 - 1, -1, -1):
sift_down(arr, n, i)
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0]
sift_down(arr, i, 0)
return arr
堆排序利用完全二叉树的数组表示。sift_down 的作用是让某个节点下沉到合适位置,保持大顶堆性质。建堆从最后一个非叶子节点开始,从右往左 sift_down,复杂度是 O(n) 而不是很多人以为的 O(nlogn)。排完序后,把堆顶最大值和当前末尾交换,再把堆大小减一,继续调整。堆排序的时间稳定 O(nlogn),空间 O(1),但它是典型的不稳定排序:建堆和下沉过程中,相等元素的相对顺序很容易被改变。另外,它的常数较大,实际速度通常不如随机数据下表现好的快排。所以堆排序的价值更多体现在"找 TopK"这类场景:要前 K 大元素,不需要全排序,维护一个大小为 K 的堆即可。
4. 同一份随机数据,这些算法跑出来的差距有多大
算法说再多,不如跑一次。我这里用 timeit 对前文的 7 种算法做了性能对比。测试数据是 10000 个随机整数,为了避免排序函数原地修改原数据,每次调用都传入 data[:] 副本。每种算法跑三次取平均值,环境是普通笔记本上的 Python 3.11,数值仅供参考,关键看数量级。
python复制import random
import timeit
data = list(range(10000))
random.shuffle(data)
algorithms = [
("bubble_sort", bubble_sort),
("selection_sort", selection_sort),
("insertion_sort", insertion_sort),
("shell_sort", shell_sort),
("merge_sort", merge_sort),
("quick_sort", quick_sort),
("heap_sort", heap_sort),
]
for name, func in algorithms:
t = timeit.timeit(lambda: func(data[:]), number=3) / 3
print(f"{name}: {t:.6f} s")
我跑出来的数据大概是下面这样:
| 算法 | 10000 个随机 int 耗时 | 说明 |
|---|---|---|
| 冒泡排序 | 约 4.6s | O(n²),常数大 |
| 选择排序 | 约 2.3s | 比较次数固定 |
| 插入排序 | 约 1.1s | 随机输入下仍然很慢 |
| 希尔排序 | 约 0.02s | 已经接近高级算法 |
| 归并排序 | 约 0.02s | 稳定,额外空间 O(n) |
| 快速排序 | 约 0.009s | 随机输入下最快之一 |
| 堆排序 | 约 0.03s | 常数较大 |
| 内置 sorted() | 约 0.0007s | 远快于手写实现 |
看到这个结果,大部分人的第一反应是:为什么 O(nlogn) 这么快?因为 n=10000 时,n² 和 nlogn 差了几个数量级。10000² 是 1 亿,而 10000×log2(10000) 约 13 万,相差近千倍。再看同样 O(nlogn) 的算法,常数差异也很明显:快排在这个输入上跑得最快,堆排序最慢,归并排序居中但稳定。希尔排序由于 gap 序列的关系,在小数据上已经接近高级算法的性能。这些差异就是"常数因子"的体现:大 O 只描述增长趋势,不决定实际时间。
如果你以为大数据量下也能等下去,可以把 n 换成 100 万。O(n²) 的算法几乎没法在同一个表里展示,因为要跑几小时;内置 sorted() 则可能在 0.2 秒内完成。所以工程上几乎永远不要自己写 O(n²) 排序。还要注意数据形态:如果把随机输入换成基本有序的列表,插入排序和优化后的冒泡排序会快非常多,而选择排序依然慢,因为它的比较次数不随数据有序而下降。这也是为什么真实项目里很少单独用选择排序。
5. 工程里真正的主角:Python内置sort和sorted到底靠谱在哪
在工程里,我几乎不会自己手写快排,而是直接用 Python 内置的 list.sort() 或 sorted()。它们本质上是同一种底层算法——Timsort。Timsort 结合了插入排序和归并排序:它会扫描数据中天然有序的片段,把这些片段作为 run,再通过归并把 run 合并起来。数据越接近真实世界的部分有序,它就越快;最坏情况下也能保持 O(nlogn)。
5.1 list.sort()和sorted(),差别不只是原地
list.sort() 直接修改原列表,返回 None;sorted() 接受任意可迭代对象,返回新列表。如果你不需要保留原数据,用 list.sort() 能省一份内存;如果需要保留原列表,或者要对生成器、元组、字典等可迭代对象排序,用 sorted()。常见的错误是把 list.sort() 的返回值当成排序结果,比如 result = data.sort(),结果 result 是 None。这种错误在面试或 review 别人代码时经常出现,值得留意。
5.2 key参数的威力:从比较对象变成计算键值
key 参数才是内置排序最值得掌握的东西。它会在排序前把每个元素调用一次生成键,之后所有比较都基于键完成。很多人学了算法,却在业务里写出 lambda x: (x["date"], x["amount"]) 这样的代码,其实这就是在告诉排序算法:先按日期排,日期相同再按金额排。如果你用自定义比较函数配合 functools.cmp_to_key,性能会差很多,因为每次比较都要重新计算键。正确的做法是尽量用 key 把比较转成键值。
python复制data = [
{"name": "A", "date": "2024-01-01", "amount": 100},
{"name": "B", "date": "2024-01-01", "amount": 80},
{"name": "C", "date": "2024-01-02", "amount": 120},
]
data.sort(key=lambda x: (x["date"], x["amount"]))
如果你希望一个字段升序、另一个字段降序,可以用 key=lambda x: (-x["amount"], x["date"]),前提是字段是数字。字段是字符串时需要换思路,比如临时 map 成数字再排。
5.3 Timsort为什么对真实数据这么友好
Timsort 还有一个稳定的天然属性。这意味着你可以连续多次排序来实现多级排序:先按金额排,再按时间排,相同时间下金额顺序依然保留。Python 官方文档明确说 sort() 是稳定的,这在写分组报表时非常省心。最常见的业务场景是"按多列排序",比如订单要先按地区分组,再按时间倒序。你可以先用 sorted() 按时间倒序排好,再按地区排一次;因为 sorted() 稳定,第二次排序后同一个地区内的时间倒序顺序依然保留。需要反向排序时直接加 reverse=True,但对于多字段排序,reverse=True 会把所有字段都倒过来,这时还是老老实实用 key 组合更直观。
6. 我在Python里写排序时踩过的坑和优化手段
最后一部分,我想把自己在写 Python 排序时踩过的一系列坑集中说一下。这些坑往往不在算法书上,而是写完代码、跑出结果后才发现的。
6.1 递归深度和随机pivot:快排的两大软肋
有一次给一份已经有序的列表做快排,n 大概两万,直接抛出 RecursionError。异常栈里的调用层级非常深,因为 pivot 每次都是最后一个元素,分区完全不平衡。排查步骤是这样的:先看是不是递归基准写错,然后看 pivot 选择,确认是极端输入导致的递归深度太大。解决方案有三个:先用 random.shuffle 打乱数据,或者在 partition 里随机选 pivot,再狠一点,用显式栈模拟递归,写成迭代版。下面这段迭代版快排没有递归深度问题:
python复制def quick_sort_iterative(arr):
stack = [(0, len(arr) - 1)]
while stack:
low, high = stack.pop()
if low >= high:
continue
p = partition(arr, low, high)
stack.append((low, p - 1))
stack.append((p + 1, high))
return arr
用迭代版之后,再也没有因为递归深度出过问题,代价是代码稍微绕一点。如果你只是业务里排个参考数据,直接调内置 sorted() 就不需要操心这些。
6.2 归并排序里那个令人迷惑的"等于"符号
归并排序的稳定性,很容易栽在 merge 函数里的一行比较符号上。我最初写的是 if left[i] < right[j]:,结果试排序数组没问题,但给一个包含相等键的元组列表做稳定性测试时,相对顺序被颠倒了。原因很简单:当 left[i] 和 right[j] 相等时,< 不成立,程序会取 right[j],先取出右半部分的元素,相同键的顺序自然就乱了。改成 <= 后,相等时先取左半部分,稳定性就保住了。这个问题不报错、不崩,结果还是有序的,只有你特意做稳定性断言时才会暴露。所以如果你写的归并排序要保证稳定,一定要检查 merge 里的比较符号。
6.3 修改原数据和默认参数:两个容易忽略的习惯
排序前一定要想清楚是否允许原列表变序。我见过一个线上处理日志的场景,先把整个列表 list.sort() 排好序,后面代码又需要原始写入顺序,直接打了个措手不及。最后只能从备份日志里重放。这种问题不是算法问题,是使用习惯问题:需要保留原数据就使用 sorted(),不保留再用 list.sort()。Python 另一个经典坑是可变默认参数。虽然排序函数里不太会出现,但有人喜欢把辅助数组作为默认参数写,比如 def merge(left, right, res=[]),第二次调用时 res 里还留着上一次的结果。正确的做法是把默认值设为 None,在函数体内再赋新列表。凡是用可变对象做默认参数,都是在给自己埋雷。
6.4 数据形态决定要不要手写排序
最后说说数据形态。同样是 100 万条订单,如果业务上已经按时间顺序写入,那排序基本就是 Timsort 最擅长的场景,直接 data.sort(key=lambda x: x["time"]) 就行。如果数据完全随机,要求内存可控,可以试快排;要求绝对稳定,用归并;只想要 TopK,更不应该全排序,而是用 heapq.nlargest。把排序问题想清楚,比默写十个排序算法更重要。
如果说有什么心得,那就是别把排序当背诵题。把代码跑起来,换几组有序、逆序、随机数据试试,再读一读内置 sort() 的源码注释,你对排序算法的理解会上一个台阶。
