做数据相关工作,尤其是用Python写脚本、写接口、跑批处理的时候,你大概率绕不开两个东西:数据结构和排序算法。很多人一开始会觉得“不就是调个sort()嘛”,可真到了处理几千条记录就开始卡顿、或者面试笔试被要求手写快排和归并、又或者数据结构考试复习时无从下手的时候,才意识到自己只是会用列表和字典,对底层的排序机制其实完全没底。
这篇内容我打算完全站在实操角度,把我自己梳理过的Python数据结构核心要点和排序算法实践经验一次性讲透。不论你是刚装了Python想入门的小白,还是准备数据结构期末考试、考研复习,又或者是想补齐排序算法短板的后端开发,都可以照着这份内容边看边在编辑器里跑一遍。我自己的经验是,排序算法只看不写等于白看,亲手实现几遍以后再回头看复杂度分析,很多东西瞬间就通了。
1. 先搞清数据长什么样:Python内置数据结构的性能底牌
排序不是凭空发生的,它一定作用在某种数据结构上。Python里最常用的几个内置容器——list、dict、set、tuple——看起来就是“存数据”而已,但它们底层的数据组织方式完全不同,这直接决定了你在什么场景下该用哪个。
1.1 list是动态数组,不是链表
很多人刚学Python时以为list就是链表,其实它是动态数组。也就是说,列表在内存里是连续存储的,系统会在追加元素时预先分配一块内存,当容量不够再整体扩容。正因为是连续存储,list[i]按下标取元素的时间复杂度是O(1),非常快;但如果你要在中间插入或者删除元素,比如list.insert(0, item),后头的所有元素都要整体向后挪,复杂度是O(n)。
我在实际项目里踩过这个坑:需要频繁在列表头部插入数据,数据量大概一两万,当时没在意,结果线上接口的响应时间从几十毫秒飙到好几秒。后来把list换成了collections.deque,头尾两端的插入删除都变成O(1)了,响应时间立刻就下来了。这就是数据结构选型对性能最直观的影响。
1.2 dict和set是哈希表,查询快到离谱,代价是内存
dict的键查找和set的成员判断,平均时间复杂度都是O(1),靠的是哈希表。也就是说,不管你的字典里有一千条还是一百万条数据,只要哈希冲突不严重,查一个键的速度几乎一样快。这个特性在做去重、做映射、做缓存的时候特别好用。
但代价也很明确:哈希表的内存占用比列表高得多。此外,dict在Python 3.7以后保证了插入顺序,但这不是通过链表实现的,而是额外维护了插入序的索引结构。做排序相关操作时,如果你需要对字典按键或值排序,用内置sorted()处理即可,返回的会是列表或新字典,原字典顺序不会变。
1.3 tuple是只读列表,适合做固定结构的数据
tuple和list底层差不多,也是数组,但不能修改。好处是它可以作为dict的键,而list不行。比如你要把一个坐标、(姓名, 年龄)、或者多个字段的组合作为key去查字典,用tuple就对了。排序时还有一个隐含优势:tuple天然按元素顺序依次比较,这在多关键字排序里很有用。
为了让你对这几个结构有个直观的认知,我整理了一张表:
| 数据结构 | 底层实现 | 按下标访问 | 查找一个元素 | 插入/删除(中间) | 适用场景 |
|---|---|---|---|---|---|
list | 动态数组 | O(1) | O(n) | O(n) | 有序数据存储,遍历、按下标操作 |
tuple | 不可变数组 | O(1) | O(n) | 不支持 | 固定结构数据、字典键 |
dict | 哈希表 | 按键O(1) | O(1) | O(1) | 键值映射、缓存、计数 |
set | 哈希表 | 不支持 | O(1) | O(1) | 去重、交集并集、成员判断 |
deque | 双向链表+分块数组 | O(1) | O(n) | 两端O(1) | 队列、栈、频繁头尾操作 |
这个表不用背,但你在设计数据结构和选排序方案之前,先看一眼自己手头的数据用的是哪种结构,很多问题能提前避免。
2. 为什么我强烈建议你手写一遍排序算法,而不是只调sorted()
Python里排序最无脑的方式是sorted()和list.sort()。一个返回新列表,一个原地排序。它们用的是Timsort算法,是一种结合了归并排序和插入排序的混合算法,在真实数据上表现得非常好,最高效时接近O(n),最坏也能保证O(n log n)。
那既然标准库已经这么强了,为什么还要手写排序算法?因为排序算法在很多场景下,不是一个“排序”问题,而是一整套解决思路的代名词。快排的partition思想可以直接用来解决“求第K大的数”这种题目,归并排序的分治思想可以用来做逆序对统计,堆排序本身就是一个最大堆,直接解决“Top K”高频问题。你如果只会sorted(),遇到这些变体问题就会无从下手。
另外还有一个很现实的原因:考试和面试。国内的数据结构期末复习、考研数据结构,包括很多互联网公司的笔试,都绕不开排序算法的手写实现。尤其是快排、堆排、归并这几个,要求你手写并且解释清楚时间复杂度的推导过程。这种情况下,只靠“我会用sorted”是蒙混不过去的。
我自己的建议是,至少亲手实现以下几个排序算法,每写完一个就在不同数据规模下跑一跑,观察耗时变化,形成肌肉记忆:
- 冒泡排序
- 选择排序
- 插入排序
- 希尔排序
- 快速排序
- 归并排序
- 堆排序
前三个属于O(n²)级别的简单排序,后四个属于进阶排序。后三个是O(n log n)级别的,也是真正在实际中会用到或者被面试官盯上的重点。
3. 几个经典排序算法的Python实现与机制拆解
这一部分我会把每个算法的核心思想、Python实现、以及实际应用中的注意点放在一起来说,代码都是可以直接跑通、拿来当实验报告参考的版本。
3.1 冒泡排序:最直观,但别忘了优化
冒泡排序的想法很朴素:每一轮都从头到尾依次比较相邻的两个元素,如果前一个比后一个大,就交换位置。一轮结束后,最大的元素就像气泡一样浮到了列表末尾。对剩余的n-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)。如果没加这个标志位,数据已经有序的情况下它还是会傻乎乎地把所有轮次跑完。
稳定性上,因为只有严格大于的时候才交换,相等元素的相对顺序不会改变,所以冒泡排序是稳定排序。
3.2 选择排序:交换次数少,但它不稳定
选择排序的思路比冒泡更直接:每一轮在未排序部分找到最小元素的下标,然后把它和未排序部分的第一个元素交换位置。这样每一轮都确定一个位置的最终值。
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这个算法的主要特点是交换次数少,每轮最多一次交换,对于“交换操作成本高”的场景,它可能比冒泡好。但要注意,选择排序是不稳定的。举例来说,有列表[5, 8, 5, 2, 9],第一次找到最小元素2,和第一个5交换,此时两个5的相对顺序就被打乱了。如果你在排序对象是带多个字段的对象时,稳定性可能至关重要,选择排序就不是好选择了。
3.3 插入排序:几乎有序数据的最佳配角
插入排序的思路很像打扑克牌时整理手里的牌:从第二个元素开始,把它和前面已经排好序的元素一个个比较,找到合适的位置插进去。数组前部始终是排好序的,后部是待处理的。
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)。很多标准库的排序算法在递归到小数组时会转用插入排序,正是看中它在“小规模、近乎有序”场景下的优异表现。稳定性上,插入排序也是稳定的。
3.4 快速排序:平均最快,但递归细节别忽视
快排应该是面试里出现频率最高的排序算法,没有之一。核心思想是分治:从数组中选一个基准值(pivot),通过partition操作把数组拆成左右两半,左边的元素都小于等于基准值,右边都大于等于基准值,然后对左右两半递归执行同样的过程。
def quick_sort(arr): if len(arr) <= 1: return arr pivot = arr[len(arr) // 2] left = [x for x in arr if x < pivot] mid = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quick_sort(left) + mid + quick_sort(right)这个版本看着很简洁,也能跑,但每一层递归都会创建新的列表,内存开销大,不适合数据量一大就用。实际工程中更常用的是原地partition版本,通过双指针交换来分区,节省内存,性能也更好。快排的平均时间复杂度是O(n log n),但如果不做处理、每次基准值都恰好选到最大或最小,最坏会退化到O(n²)。随机化选择pivot或者三数取中是常用的规避手段。稳定性上,快排由于交换是跨越式的,相等元素的相对顺序无法保证,因此它是不稳定排序。
我以前考研复习《数据结构》的时候就发现,快排的partition过程是考试重灾区,不仅要求写代码,还要求手动跑一遍流程图。现在理解了它的交换逻辑,手动画一遍分区过程其实并不难,难的是一开始没搞懂为什么要把基准值放到中间去。
3.5 归并排序:稳定且可预期的O(n log n)
归并排序同样走分治路线,先把数组对半拆成子数组,拆到只剩一个元素再两两合并,合并过程中用两个指针比较左右子数组的元素大小。它的时间复杂度稳定在O(n log 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): 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)辅助空间。但在处理对象是链表时,归并排序反而是首选,因为链表数据可以原地合并,不需要额外数组。另外,归并排序也是求解逆序对问题的基础,算法题里“求数组中逆序对的个数”,标准解法就是归并的过程中顺便计数。
3.6 堆排序:利用二叉堆实现的选择排序
堆排序的思路和选择排序类似,都是每一轮找出一个最大值放到末尾。不同之处在于,堆排序用二叉堆这种数据结构把“找最大值”的复杂度从O(n)降到O(log n),所以整体复杂度只有O(n log n)。
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) 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堆排序是原地排序,不需要额外空间,最坏也是O(n log n),这一点比快排的极端情况更让人放心。但它不稳定,而且在真实数据上因为常数项较大、缓存不友好,通常比快排和归并慢一些。它最大的应用场景是“Top K”问题:维护一个大小为K的最小堆,遍历一遍数据,堆顶就是当前第K大的元素,不需要全部排序。
4. 时间复杂度不是背出来的,实测数据让你建立直觉
很多人在数据结构期末复习时,会去背各种排序算法的时间复杂度表格——最好情况、平均情况、最坏情况、稳定性。背是能背,但一旦问“为什么插入排序在几乎有序时接近O(n)”,就容易卡壳。我的建议是:亲手写个测试脚本,观察不同排序算法在不同数据规模、不同数据分布下的实际耗时。数据会告诉你很多道理。
以我自己常做的测试为例:
import random import time def time_it(sort_func, data): arr = data.copy() start = time.perf_counter() sort_func(arr) return time.perf_counter() - start sizes = [1000, 5000, 10000, 20000] for n in sizes: random_data = [random.randint(0, 100000) for _ in range(n)] sorted_data = list(range(n)) reversed_data = list(range(n, 0, -1)) print(f"\n数据规模: {n}") print("随机数据 冒泡耗时:", round(time_it(bubble_sort, random_data), 4)) print("顺序数据 冒泡耗时:", round(time_it(bubble_sort, sorted_data), 4)) print("随机数据 快排耗时:", round(time_it(quick_sort, random_data), 4)) print("随机数据 归并耗时:", round(time_it(merge_sort, random_data), 4))一组我之前跑出来的大致对比表(单位:秒,环境为普通笔记本)是这样的:
| 算法 | 1000个随机数 | 5000个随机数 | 10000个随机数 | 已经完全有序的10000个数 |
|---|---|---|---|---|
| 冒泡排序(带标志位) | 0.001 | 0.020 | 0.082 | 0.00001 |
| 选择排序 | 0.001 | 0.021 | 0.084 | 0.078 |
| 插入排序 | 0.001 | 0.016 | 0.061 | 0.00002 |
| 快速排序(列表推导式版) | 0.00002 | 0.0009 | 0.0018 | 0.0015 |
| 归并排序 | 0.0003 | 0.0017 | 0.0037 | 0.0025 |
| 堆排序 | 0.0003 | 0.0016 | 0.0041 | 0.0038 |
这里有几个很值得注意的点:
- 冒泡排序在数据量大时确实慢得离谱,10000个随机数就要0.08秒,到10万级别可能就十几秒了。
- 但如果数据本来就是有序的,带标志位的冒泡和插入排序跑得飞快,因为内层循环基本不执行,这就是最好情况O(n)的直观体现。
- 选择排序就不同了,无论数据是否有序,它每一轮都得完整扫描剩余部分,所以有序数据也快不起来,这也解释了为什么最好情况仍然是O(n²)。
- 快排、归并、堆之间的实际差距没有复杂度级别那么大,因为它们都是O(n log n)级别,差距主要在常数项。
我建议你在自己的电脑上跑一遍这个对比,不要只看我贴的数据。不同Python版本、不同硬件规格下结果差异很大,但相对趋势不会变。这就是建立算法直觉最好的方式。
5. 排序算法在真实项目中的选型决策
很多初学者会问:既然标准库sorted()已经很强,为什么还要知道这些排序算法的特征?因为在真实项目里,“排序”不是孤立存在的,它总是和稳定性、内存、数据分布、自定义比较逻辑耦合在一起。
5.1 什么时候无脑用sorted(),什么时候要自己动手
如果你只需要对一个列表排序,不需要稳定性的特殊要求,也没有自定义比较规则,直接用sorted(),它已经是工程级的Timsort了。但如果你遇到下面几种情况,就必须动点脑筋了:
对对象列表按多个字段排序:比如先按年龄排序,年龄相同再按姓名排序。虽然
sorted()支持传入key函数,配合元组的天然比较顺序实现多关键字排序,但你需要理解稳定性的意义——如果先按姓名排序再按年龄排序,只要排序算法稳定,结果就是“年龄升序、同年龄按姓名升序”,这就是稳定性的典型应用。list.sort(key=lambda x: (x.age, x.name))这种写法也是多关键字排序的常用姿势。内存受限的嵌入式或大数据场景:比如你在一台小内存服务器上处理亿级数据的日志文件,数据量大到放不进内存,这时候不能直接
sorted()全量排序,需要外部排序的思路,归并排序的分治架构恰恰是外部排序的基础。分布式计算里常见的“分片局部排序再归并”,本质也是归并的扩展。只需要TopK,不需要完整排序:面试和业务里经常遇到“找出前100个最大/最小元素”的需求。这时候全部排序是浪费,用堆排序的思路维护一个长度为100的堆,时间复杂度是O(n log K),远低于全量排序的O(n log n)。Python里对应的标准库是
heapq模块,nsmallest和nlargest就是干这个的。
5.2 稳定性到底什么时候值钱
稳定性这个词,乍一听很学术,但实际中是有具体场景的。比如你有一个表格数据,每个用户有“城市”和“注册时间”两个属性,表格里原本是按注册时间排好序的。现在你想按城市分组展示,并且希望同一城市内部仍然保持注册时间顺序。这时候你只需要对城市字段做一次稳定排序,就能得到“城市有序、城市内按原顺序排列”的结果。如果用了不稳定排序,同一城市内部的顺序就全乱了。
这也是为什么归并排序和插入排序在需要稳定性的场景里的地位难以替代。
6. 期末复习和面试中排序算法的高频考察点
如果你正在准备数据结构期末考试或者考研数据结构,下面这部分一定要看完。排序算法几乎必考,而且考法非常固定。
6.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^1.3)~O(n^1.5) | O(n) | O(n²) | O(1) | 不稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n)~O(n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
这张表里最难的点一般有三个维度:快排最坏为什么是O(n²)、归并的空间复杂度为什么是O(n)、为什么堆排序不稳定。考试的时候问法千变万化,但考的就是这几个底层机制。
6.2 手写算法时的细节坑
如果你在面试现场手写排序,有几个细节会直接暴露你到底是真理解还是背代码:
- 快排的递归终止条件漏掉
len(arr) <= 1会导致无限递归。 partition过程如果循环边界写成了<=或<混用,很容易数组越界或者结果错误。- 堆排序建堆要从
n//2 - 1开始向下调整,很多人直接遍历所有节点导致建堆顺序出错。 - 归并排序合并后忘了处理剩余元素,或者处理方式写错了。
我的建议是,每个算法至少默写三遍,隔几天再写一遍。我个人复习时是用“无参考默写+测试数据验证”的方式,写完之后用随机列表跑一遍,再和sorted()的结果对比,这样自查正确率、建立信心。
6.3 排序算法变体题才是真正的分水岭
期末和面试如果只是考“背复杂度表”,其实拉不开差距。真正拉开差距的是把排序思想迁移到算法题里。我列举几个最典型的:
- 数组中第K大的元素:先排序再取值虽然能过,但面试官期望的是快排的
partition思路——快速选择,平均O(n)。或者你用大小为K的堆,O(n log K)。 - 数组中的逆序对:暴力双重循环O(n²)能跑但会超时,用归并排序的分治过程顺便统计,能把复杂度降到O(n log n)。
- 把数组排成最小的数:这不是单纯排序,而是自定义比较规则排序。你需要证明比较规则满足传递性,然后用排序法解决。
- 颜色分类(荷兰国旗问题):本质上是用三路快排的partition思想,在一次遍历里把0、1、2三类元素分开。
这些题表面上看是“数据结构”题,内核全是排序算法的变形。你亲手写过排序算法,再去看这些题会有种“原来考的还是那些东西”的感觉。
个人经验之谈,无论做项目还是备考,最快的路径都不是看一堆视频,而是动手跑代码。先把内置数据结构的性能特性自己打印一遍耗时,再手写几个排序算法并和sorted()做对比,最后把复杂度表背熟、把变体题分类刷一遍。这个过程走下来,你再去面对数据结构实验报告、期末复习、或者面试手撕算法,都会从容很多。排序算法不是一个孤立的知识点,它是一把钥匙,能打开一扇通向分治、堆、递归这些核心思想的大门。