ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python排序算法全攻略:从冒泡到Timsort的工程实践

Python排序算法全攻略:从冒泡到Timsort的工程实践 排序算法这个老生常谈的话题几乎所有学Python的人都会碰到。面试要考日常写业务代码要处理榜单、排行榜、数据分析前的预处理也绕不开。我在带新人时最常被问到的就是网上讲排序的教程这么多背哪个用哪个为什么Python自带的sort好像是万能的这篇文章我想站着工程实践的角度把排序这桌菜重新上一遍。我会用四剑的思路来组织内容暴力派的冒泡、选择、插入分治派的快排、归并、堆巧力派的计数、桶、基数最后落到实战选型拆解Python内置sort的Timsort到底做了什么。这样从暴力到高效一路走下来你不光能背出代码还能理解每个算法背后的权衡在真实的业务场景里做出合理选择。无论你是刚学Python的入门读者还是写了两三年代码但一直没把排序原理搞透的开发者这文都值得花二十分钟看完。我尽量用大白话讲原理用可跑的代码讲实现再把我这些年踩过的坑一并交代清楚。1. 暴力派三剑客先把排序的最朴素逻辑吃透1.1 冒泡排序一趟一趟把最大值顶上去冒泡排序的思路是最直观的从头到尾遍历数组相邻两个元素两两比较如果前一个比后一个大就交换位置。这样每一轮走完之后当前未排序区间里最大的那个数就会像气泡一样一路浮到末尾。def bubble_sort(arr): n len(arr) for i in range(n): 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)次比较就能结束这是很多人没注意到的细节。冒泡排序的时间复杂度是O(n²)空间复杂度O(1)是稳定排序。它的主要问题在于交换操作太多、比较次数太冗余工程上除了教学几乎没人直接用。但它的价值在于为理解比较-交换这个排序基本模型打下了基础。1.2 选择排序每轮挑一个最小的放前面选择排序的思路和冒泡有点像但交换策略完全不同每一轮从剩余未排序元素中找到最小值然后和当前轮次的起始位置交换。它把比较和交换分开了——比较依然要全量做但交换次数最多只有n-1次。def selection_sort(arr): n len(arr) for i in range(n): 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选择排序的亮点是交换次数少。如果排序对象的交换操作代价很高比如数据是存在磁盘上的大对象交换一次要付出很大开销那选择排序就有它的用武之地。但它的比较次数仍然固定是O(n²)而且不稳定比如 [5a, 5b, 3] 这种重复元素场景第一个5会被换到后面去相对顺序就乱了。1.3 插入排序像整理扑克牌一样逐个插入我个人一直觉得三种暴力排序里最值得认真理解的是插入排序。它的思路是维护一个已排序前缀每次从未排序部分取一个元素在已排序前缀里找到合适位置插入进去。打扑克牌时把新摸到的牌插进手里已经排好序的牌里就是这个过程。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插入排序的重要性在于它对近似有序的数据非常友好。如果数据已经基本有序内层while循环几乎不会执行时间复杂度可以退化到O(n)。这个特性后来被Timsort充分利用了——Python内置的sorted排序算法本质就是在已经有序的片段上做插入排序的加强版。三种暴力排序的直观对比算法平均时间复杂度最坏时间复杂度最好时间复杂度空间复杂度稳定性冒泡排序O(n²)O(n²)O(n)O(1)稳定选择排序O(n²)O(n²)O(n²)O(1)不稳定插入排序O(n²)O(n²)O(n)O(1)稳定提示如果只是为了应付面试插入排序必须手写熟练冒泡和选择至少要能说清思路。如果是为了工程使用这三种基本可以全员pass除非你明确知道自己面对的是几乎有序的小规模数据。1.4 暴力派的共同局限暴力派算法的根本问题在于它们的比较次数是O(n²)级别的。原因也很直接它们在每一轮里对数据的“认知”都是局部的——冒泡只知道相邻两个大小选择只知道当前最小值插入只知道已排序前缀。这些信息没有在轮与轮之间被高效复用。聪明的排序算法是怎么做的答案是分治——把大数组拆成小数组分别排序再把结果合并。想要让比较信息产生“复利效应”就必须舍得拆和合。这就引出了下一组算法。2. 分治派两板斧从 O(n²) 跃迁到 O(n log n)2.1 快速排序平均最快的分治选手快速排序的核心一句话选一个基准值pivot把小于它的放左边、大于它的放右边然后递归处理左右两个子区间。它的平均时间复杂度是O(n log n)常数小实际运行速度在通用比较排序里是第一梯队的。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)上面这个写法是最容易理解也最不容易写错的教学版快排但它额外创建了列表空间开销更大。如果你要写一个面试版的就地快排可以参考下面的代码def quick_sort_inplace(arr, low, high): if low high: return 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] idx i 1 quick_sort_inplace(arr, low, idx - 1) quick_sort_inplace(arr, idx 1, high)快排的噩梦场景是pivot选得不好。如果每次选的pivot恰好是最大值或最小值分区就会严重偏科左边空荡荡、右边挤满人递归深度退化成O(n)时间复杂度退化到O(n²)。业界常用的优化有随机选pivot、三数取中取首中尾三个数的中间值。我自己实测随机选pivot在大多数场景都能很好规避退化问题。快排是不稳定排序。原因很直观分区时把小于pivot的元素往左挪、大于pivot的往右挪这一挪就可能把相等的元素的相对次序打乱。这也是为什么一些对稳定性有要求的场景宁可选归并排序也不选快排。2.2 归并排序稳定且可预期的分治典范归并排序的思路是先把数组从中间一分为二递归把左右两边分别排好序然后用一个合并过程把两个有序子数组合并成一个整体有序数组。合并过程是归并排序的灵魂它通过双指针比较两个子数组的头元素谁小谁先进结果数组。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归并排序的最大优势有两个。第一是稳定性合并时我们写的是left[i] right[j]时才取左边相等元素保持了原始顺序所以归并是稳定的。第二是复杂度可预期无论数据长什么样它都是严格的O(n log n)不存在快排那种退化到O(n²)的风险。代价是需要额外的O(n)辅助空间因为合并时不可避免地要开新列表存结果。这也让它在内存极紧张的嵌入式场景里不那么受欢迎但在普通应用开发里这点空间完全不是问题。归并排序还有一个重要应用场景是外部排序——数据量大到内存放不下时需要先在磁盘上分块排序再逐块合并这其实就是归并思想的延伸。2.3 堆排序用数据结构换效率的典型堆排序的思路是通过二叉堆来维护当前最大值/最小值。Python里其实不用自己写堆标准库的heapq模块直接提供了堆化、入堆、出堆操作所以工程实现非常简洁import heapq def heap_sort(arr): heapq.heapify(arr) return [heapq.heappop(arr) for _ in range(len(arr))]先说清楚一个常被误解的点heapq.heapify(arr)建堆的时间复杂度是O(n)不是O(n log n)。它的原理是自底向上逐层下沉调整第二层的节点下沉1次、第三层下沉2次……总操作次数是一个收敛的级数算下来就是O(n)。然后每次heappop取最小值是O(log n)取n次总复杂度O(n log n)。堆排序的特点是原地排序、空间复杂度O(1)但不稳定。由于它完全通过堆结构操作元素相等的元素在插入、删除堆的过程中相对位置可能被破坏。它的常数因子比快排和归并都大实际运行速度通常不如快排。但它在两个场景里很有价值一是内存受限的环境二是从海量数据中取前K个最大/最小这类Top K问题。比如要从一亿个数字里找出最大的100个堆是完美解法维护一个大小为100的小顶堆遍历数据时只要当前元素比堆顶大就替换堆顶并重新堆化。这样不管数据量多大空间占用都是O(K)时间也只有O(n log K)。2.4 为什么 O(n log n) 几乎是比较排序的天花板很多人背下了快排平均O(n log n)但不明白为什么是O(n log n)不是O(n)或者更低。这里有一个信息论层面的解释基于比较的排序每一次比较最多产生大于/小于两种结果相当于一次二选一的决策。n个元素的全排列一共有n!种可能排序的本质就是通过决策树在n!种可能里定位到正确的那一种。决策树的树高至少是log₂(n!)用来斯特林公式展开log₂(n!) ≈ n log₂ n - 1.44n。所以任何基于比较的排序算法最坏情况下都不可能突破O(n log n)这个下限。理解了这一点你就会明白为什么想再快只能换赛道——不在比较上做文章而是利用数据本身的特殊性直接计算位置。这就是非比较排序的思路也就是接下来的第三剑。3. 巧力派三个杀手锏不比较也能排序3.1 计数排序整数场景下的作弊器计数排序的前提非常苛刻数据必须是有限范围内的整数或者可以映射成整数的离散值。它的思路是把每个值出现的次数统计到一个计数数组里然后按顺序输出。整个过程完全没有比较动作所以它的时间复杂度可以做到O(n k)其中k是数据范围。def counting_sort(arr): if not arr: return arr max_val max(arr) min_val min(arr) range_size max_val - min_val 1 count [0] * range_size for x in arr: count[x - min_val] 1 result [] for i, c in enumerate(count): result.extend([i min_val] * c) return result注意我做了x - min_val的偏移处理这样负整数也能排同时压缩了计数数组的长度。计数排序真正的坑在于内存。如果数据范围是0到1亿即使你只有100个数字要排计数数组也得开1亿个位置这显然得不偿失。所以使用前一定要评估数据分布范围小、数据量大计数排序性价比极高范围大、数据稀疏千万别用。比如按年龄排几百万条用户记录年龄范围0-120用它非常合适。3.2 桶排序把数据切块后分而治之桶排序可以理解为计数排序的泛化版计数排序用每个整数作为桶桶排序则把连续区间切成若干桶每个桶内部再用其他排序算法通常是插入排序或递归桶排序搞定。它是一种典型的分布式思想——数据分流到各个桶里桶与桶之间有天然的大小顺序最后串起来就是整体有序。def bucket_sort(arr, bucket_num5): if not arr: return arr min_val min(arr) max_val max(arr) if max_val min_val: return arr bucket_range (max_val - min_val) / bucket_num buckets [[] for _ in range(bucket_num)] for x in arr: idx min(int((x - min_val) / bucket_range), bucket_num - 1) buckets[idx].append(x) result [] for bucket in buckets: result.extend(sorted(bucket)) return result上面代码里我用Python内置sorted做桶内排序这在实际工程里是合理选择——桶内数据量小内置排序的C底层实现非常快。桶排序的复杂度分析很有意思平均情况下如果数据分布均匀、桶的数量合理每个桶里的元素大约是n/k个桶内排序复杂度是O((n/k) log(n/k))总复杂度近似O(n k * (n/k) log(n/k))k取得好的时候接近O(n)。但如果所有数据全部挤进同一个桶就退化成了桶内排序的复杂度最坏O(n²)。最经典的桶排序应用是考试成绩排序。0-100分的区间切成10个桶每个桶10分数据均匀分布在分数段里效果非常好。反例是数据呈极端偏态分布的场景比如大部分数据都集中在90-100分那最后一个桶就会塞得很满性能立刻崩。3.3 基数排序按位逐步稳定排序基数排序的思路是把整数按位数拆开从最低位开始逐位进行稳定排序。每一位排序可以使用计数排序作为底层工具。比如数字123个位是3先按个位排再按十位排最后按百位排。每一轮都是稳定排序是基数排序正确性的关键。def radix_sort(arr): if not arr: return arr max_val max(arr) exp 1 while max_val // exp 0: arr counting_sort_by_digit(arr, exp) exp * 10 return arr def counting_sort_by_digit(arr, exp): n len(arr) output [0] * n count [0] * 10 for x in arr: digit (x // exp) % 10 count[digit] 1 for i in range(1, 10): count[i] count[i - 1] for i in range(n - 1, -1, -1): digit (arr[i] // exp) % 10 output[count[digit] - 1] arr[i] count[digit] - 1 return output整个过程的时间复杂度是O(d × (n 10))d是最大数字的位数。对于固定范围内的整数d是个小常数所以基数排序可以近似看成线性复杂度。它比计数排序更省内存因为它只需要一个长度为10的计数数组外加一个和原数组等长的输出数组。基数排序最常见的工程应用是处理身份证号、手机号这类固定长度的数字编码以及定长字符串的排序。不过要注意上面的实现只能处理非负整数如果数据里有负数需要先做整体偏移或者把负数单独拿出来排好再合并回去这是很多初学者容易忽略的地方。3.4 三种非比较排序的适用边界算法时间复杂度空间复杂度稳定性适用场景计数排序O(n k)O(k)稳定整数数据范围远小于数据量桶排序平均O(n)最坏O(n²)O(n)取决于桶内排序数据分布均匀的浮点数/分数段基数排序O(d(n k))O(n k)稳定固定位数的整数/字符串这三种算法的共同前提是数据必须满足特定形态。它们不是通用排序而是一类典型的按特征取胜的方法。我在实际项目中用到最多的场景是排行榜分位数据、年龄分层统计、订单号排序这类结构化的离散数据。但要强调一点如果你的数据是普通的浮点数列表、字符串列表、对象列表别去硬套非比较排序。老老实实用内置sort是绝大多数情况下的最优解。4. 实战派总结Python内置sort与算法选型4.1 Python内置sorted到底做了什么Python的sorted()和列表的.sort()方法背后用的是Timsort算法。Timsort是Tim Peters在2001年设计的专门为真实世界的数据特征优化——现实中的数据往往不是完全随机的它天然包含很多已经排好序的小片段比如榜单里成绩相同的区域、日志里时间戳递增的片段。Timsort的核心思想是先扫描数据找出所有天然有序的run片段。如果run太短就先用二分插入排序把它扩展到一个最小长度。然后把这些run按规则压入一个栈中不断合并相邻的run最终合成一个完整的有序序列。这个过程中插入排序扮演了重要的奠基角色这也是我在第一部分再三强调插入排序的原因——它不是没用而是被Timsort用在了更底层的位置。一个容易被忽略的细节是Timsort还专门对降序run做了处理。当它检测到数据是降序的会直接把这段run反转过来变成升序然后纳入合并流程。这就是为什么Python的sorted在应对接近有序和接近逆序的数据时表现得异常出色——它让数据原有的顺序信息得到了最大程度的复用。4.2 sort和sorted怎么选key参数怎么用这两个API最本质的区别是list.sort()是原地排序直接修改原列表返回None适合不需要保留原列表的场景sorted()返回一个新的排好序的列表原列表不动适合需要保留原始数据的场景。从内存角度讲list.sort()更省空间从使用灵活性讲sorted()适用于任何可迭代对象包括元组、字典、集合。实际开发中key参数的使用频率非常高。它的语法是给每个元素计算一个排序键然后按键排序。常见用法包括# 按字符串长度排序 sorted(names, keylen) # 按字典的某个字段排序 sorted(stu_list, keylambda s: s[score], reverseTrue) # 多字段排序先按成绩降序再按姓名升序 sorted(stu_list, keylambda s: (-s[score], s[name]))多字段排序有个很实用的技巧哪个字段优先就把它的键写在前面。如果要降序、后面还要升序最简单的写法是用负数取反——但这个技巧只对数值型字段有效。如果字段是字符串又要降序可以把排序拆成多轮或者直接用functools.cmp_to_key自定义比较函数不过那样性能会比key写法差不少。4.3 排序稳定性对工程的影响稳定性是一个经常被面试官追问、也经常被工程忽略的属性。它说的是当两个元素的排序键相等时排序后它们的相对顺序是否保持不变。如果保持不变这个排序就是稳定的。为什么工程里要关心稳定性最典型的场景是多字段排序。比如一个排行榜先按积分排积分相同再按注册时间排。如果你用一次排序完成写法会比较绕但如果你先按注册时间排一遍再按积分排一遍只要第二遍的排序是稳定的积分相同的人自然保持了注册时间的升序。这就是多次排序级联的经典用法。Python的sorted是稳定排序。这意味着你可以放心用两次稳定排序来表达多字段排序需求不会出乱子。4.4 性能实测不同数据形态下怎么选为了让你对从暴力到高效的差距有直观感受我用自己的笔记本简单跑了一组对比对10万个随机整数进行排序冒泡排序大约要30秒以上快排和归并大约0.3秒Python内置sorted只需要0.02秒左右。这组数字只是一个量级参考不同机器差异很大但数量级的差距是稳定的。再来看不同数据形态下的表现差异。我测试过三种数据集完全随机、接近有序只有少数位置乱序、完全逆序。对这种实际数据的排序而且数据规模接近10万时几种算法各有特点完全随机内置sorted 快排 归并 堆排序 计数排序如果范围合理 暴力派接近有序内置sorted远远领先因为它直接复用了已有的有序run插入排序也能跑出接近线性的速度完全逆序内置sorted依然很强它会先反转逆序run快排如果不做随机pivot会退化这里就能看出Python内置sorted几乎在每种数据形态下都是最优或接近最优的。所以我的选型建议非常明确注意日常工作里遇到排序需求先无脑用sorted()或.sort()。只有当你明确分析出瓶颈在排序本身、并且数据形态满足非比较排序的特殊前提时才值得手写别的算法。手写排序的90%场景是为了面试或者是为了学习原理。4.5 常见排序坑位速查把key函数写成了key函数()。前者传函数本身后者是立即调用函数得到返回值结果通常是报错或者排出一个完全不符合预期的序列。对包含None的列表排序。sorted([3, None, 1])会直接抛TypeError因为None无法和整数比较。常规处理是用keylambda x: (x is None, x)把None统一放到最后。在排序过程中修改原列表。如果你在调用list.sort()后又遍历原列表做筛选看起来没问题但一旦排序键依赖那些被修改的属性结果就不可预测。推荐用sorted生成新列表避免副作用。对浮点数做精确相等判断。某些场景下排序结果需要做相邻元素的差值判断浮点数精度问题会导致误判建议用math.isclose。试图用计数排序处理超大范围的整数。比如数据范围0到10亿k太大计数数组直接吃满内存得不偿失。这些坑我几乎每个都踩过。特别是那个key传函数还是传返回值的问题刚转Python的新人十个里有八个会中招写的时候多留个心眼。5. 从面试到工程一份个人向的排序心法5.1 面试时怎么答排序题面试环节排序通常不是让你直接排序而是考察三件事能不能正确分析复杂度、能不能写出无bug的代码、能不能根据场景选对算法。我建议你把三种暴力排序里只重点练插入排序其余两个能说思路即可快排和归并必须手写熟练快排练就地partition版本归并练合并过程堆排序知道heapq的用法就能应付大多数问题。如果被问到为什么Python内置排序这么快不要只回答因为C实现的。里面还有Timsort对数据特征的自适应以及Python底层用C语言操作数组的高效。说清楚这两点面试官基本就认可你确实理解排序。5.2 工程中排序的终极心法工程里真正让我觉得排序是个问题的场景反而是数据量极大、需要外部排序的场景。这时候Python内置sorted反而不适合因为它会把所有数据载入内存。大文件的排序通常要借助外部工具先把文件切块、每块排好序写入临时文件再用归并的思路逐块合并。这种外部归并排序的思路才是排序知识在真实工程里最有价值的延伸。日常业务中我给出的排序决策链路是先看能不能直接用内置sort的key和reverse解决再看数据量级是否超过内存承受能力超过了考虑外部归并或数据库排序最后才评估特殊数据形态是否值得用非比较排序。大部分情况下走到第二步就结束了。说实话写了这么多年代码我自己手写排序算法的机会屈指可数。但理解排序原理给我的回报是巨大的——它训练了我对算法复杂度的直觉让我在写任何嵌套循环时都会下意识思考能不能优化也在面试和带人的时候给了我一套完整的解释框架。如果你正在学这部分内容我建议你亲手把上面的代码每个都跑一遍断点调试一遍比看十篇文章都管用。排序算法这东西纸上得来终觉浅绝知此事要躬行。
返回列表