我经常和刚学算法的朋友说,如果只选一类算法来入门,我肯定推荐排序。原因很简单:排序算法是数据结构、分治、递归、复杂度分析这些概念的天然载体。最近很多同学在刷各种排序算法,从冒泡、快排到归并、堆排序,还有各种实际场景里的排序问题,例如 MySQL 的 ORDER BY、JS 数组的 sort、MapReduce 的分组排序,甚至面试里常考的“三值排序”这类变种题。这篇内容我想把自己学习和实践排序算法的思路整理出来,重点讲实例、讲实现、讲踩坑,希望能帮你把“排序”这个模块吃得透一点,而不是停留在背代码的层面。
1. 排序算法学习:为什么它是算法入门的必修课
1.1 排序算法解决的根本问题
排序,本质上就是把一组无序的元素,按照某个关键字重新排列成有序序列。这个问题看起来简单,但它是很多高级算法和系统功能的基础。
比如要在大量数据里快速查找一个元素,如果数据有序,二分查找就能把时间复杂度从 O(n) 降到 O(log n)。比如要去重、统计频次、计算中位数、合并两个有序列表,这些操作全都默认数据是排好序的。数据库里的 ORDER BY、搜索引擎的结果相关性排序、排行榜、MapReduce 的 shuffle 阶段,底层也都离不开排序。
所以排序算法不是孤立的知识点,它是打通数据结构、分治思想、递归、复杂度分析这些核心能力的枢纽。掌握了排序,很多算法题的自然就有思路了。
1.2 搭建排序算法学习框架
学排序不能一上来就抄代码,先建立几个关键概念:时间复杂度、空间复杂度、稳定性、原地排序、比较排序与非比较排序。
大多数场景我们讨论的是比较排序,也就是通过元素之间的比较来决定顺序。比较排序有一个重要结论:基于比较的排序算法,时间复杂度下界是 O(n log n),不可能更优。这个结论来自决策树模型,理解它之后,你就知道为什么冒泡 O(n^2) 慢、快排和归并 O(n log n) 已经是很好的水平了。而非比较排序,比如计数排序、基数排序,在某些条件下能达到 O(n),但受限于数据范围。
稳定性是另一个容易被忽略但极其重要的概念:如果两个相等元素在排序后的相对顺序和排序前保持一致,那么这种排序是稳定的。为什么生产系统经常要求稳定排序?因为在多关键字排序时,稳定性能保证第一关键字的顺序不被第二关键字打乱。举个例子:先按订单时间排好,再按用户分组,如果分组排序不稳定,用户内部的订单时间顺序就可能错乱。
这些概念先立起来,后面看每种算法就会很清晰。我建议先做一张汇总表,再逐个去实现。
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
这张表只是一个起点,真正的理解来自手写实现和推演过程。
2. 手写实现:五种必备排序算法的实例拆解
2.1 冒泡排序与选择排序:最直观的入门算法
冒泡排序的逻辑很简单:从头到尾,两两比较相邻元素,如果前一个比后一个大就交换。一轮下来,最大的元素就像气泡一样冒到了最后面。重复 n-1 轮,整个数组就有序了。
def bubble_sort(arr): n = len(arr) for i in range(n): swapped = False for j in range(0, 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)。
冒泡排序的缺点是交换次数太多,每轮都可能进行多次交换,所以实际中很少用它排序大数组。但它的思路很适合作为理解“无双循环+相邻比较”的入门题。
选择排序则是另一个思路:每一轮在剩余未排序部分中找到最小值,把它放到当前位置。
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注意,选择排序是不稳定的。一个经典反例是数组[5, 8, 5, 2],第一轮找到最小值 2,和第一个 5 交换,导致两个 5 的相对顺序颠倒。这个细节在面试中经常被问到,如果你只是记住“选择排序不稳定”而没有想清楚原因,容易被问住。
个人实操体会:这两个算法适合用来练基础循环,但不要在生产代码里用它们处理大数组。它们的主要价值是让你感受“算法复杂度”和“元素移动”之间的关系,也作为后面学习高级排序的铺垫。
2.2 插入排序:小而美的算法
插入排序的思路特别像打扑克牌:摸一张牌,把它插入到手里已经有序的牌中正确位置。
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它的优势在于:对于小规模数据(比如十几二十个元素)和近乎有序的数据,插入排序非常快。为什么?因为内层循环拿到一个元素后,如果它已经处于正确位置,比较几次就停了,甚至不移动。而在大规模乱序数据上,它依然是 O(n^2)。
更关键的是,插入排序是很多高级排序算法的重要基石。比如 Python 的 Timsort、Java 的Arrays.sort,在处理小数组时都会切到插入排序。因为递归和分治的开销在小规模数据上反而比简单插入更大,插入排序常数小,实测更快。这也是我们在实现快排、归并时常见的优化策略:当子数组长度小于某个阈值时,改用插入排序。
插入排序稳定、原地、实现简单,是“小而美”的代表。我在实际中曾经用它来维护一个长度固定的有序队列,比如排行榜,数据量很小,每次插入一个新元素后调整位置,比重新排序高效得多。
2.3 快速排序:最常用的分治排序
快速排序是应用最广泛的排序算法之一,它的核心是分治:选一个基准值,把数组分成小于基准、等于基准、大于基准三个部分,然后递归处理左右部分。
先看一个易于理解的 Python 实现:
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)这个写法简洁,但面试和实际使用中往往要求原地分区,以减少内存占用。经典的原地分区采用 Lomuto 分区方案:
def quick_sort_inplace(arr, low, high): if low < high: pi = partition(arr, low, high) quick_sort_inplace(arr, low, pi - 1) quick_sort_inplace(arr, pi + 1, high) 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快排的平均时间复杂度是 O(n log n),但最坏情况下会退化成 O(n^2)。典型场景是数组已经有序,而基准总是选最大或最小元素。这时递归深度变成 n,每一次分区只拿掉一个元素,性能惨不忍睹。
针对这个问题,常见的优化策略有几种:随机选择基准、三数取中(取首、中、尾三个元素的中位数作为基准)、递归中将小数组交给插入排序。我自己的习惯是,在数组长度大于一定阈值时用三数取中,小于 16 的切片直接做插入排序。实测不仅能避免最坏情况,还能提升平均性能。
快排不是稳定排序,因为分区过程中会把相等元素的顺序打乱。如果业务上有稳定需求,需要谨慎选择。
2.4 归并排序:稳定且适合大数据量
归并排序同样是分治思想,但它把数组不断对半拆分,直到每个部分只有一个元素,然后再两两合并成有序数组。
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) 的额外空间,因为合并时必须开辟新数组存放结果。
归并排序非常契合链表排序和外部排序。链表不能随机访问,快排的原地分区会变得很麻烦,而归并只需要顺序遍历就能完成。外部排序指的是数据量远超内存,无法一次性加载的情况,这时会把数据切块,分别排序后写入磁盘,再用多路归并合并,这正是数据库和 MapReduce 常见的做法。
在 MapReduce 中,reduce 阶段收到的数据默认就是按 key 排好序的,这是由 shuffle 阶段的归并排序保证的。所以我看到热搜词里有“mapreduce排序—分组排序”,其实底层大量依赖了归并的思想。理解归并排序,对你理解大数据框架的 shuffle 机制也有帮助。
2.5 堆排序:利用堆结构的选择排序
堆排序利用了最大堆的性质:堆顶永远是整个堆的最大元素。先建堆,然后反复把堆顶和堆尾交换,缩小堆的范围,再调整堆。
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),且不需要额外空间,这是它最大的优点。但它也有明显短板:缓存局部性很差,因为堆的元素在内存中跳来跳去,实际执行速度通常不如快排和归并。
另一个容易忽略的点:堆排序是不稳定的。原因和选择排序类似,堆交换的时候很容易改变相等元素的相对位置。所以需要稳定排序的场合,堆排序往往不合适。
堆排序的价值更多在于“堆”这种数据结构本身,比如实现优先队列、TopK 问题、定时器等。学会堆排序后,再去理解优先队列就会顺很多。
3. 实战对比:不同场景下如何选择排序算法
3.1 数据规模对算法选择的影响
很多初学者会问:既然 O(n log n) 比 O(n^2) 快,那为什么不总是用快排或者归并?答案是:复杂度分析是渐进的,它忽略常数和实际机器环境。在小规模数据上,插入排序的常数远小于快排的递归开销,实测反而更快。
我给一个我常用的选择思路:如果数据量在几十个以内,直接插入排序或内置排序就好;如果数据量上百上千,快排通常是不错的选择;如果数据量大到内存放不下,那就要用外部归并排序;如果数据量很大且内存非常有限,可以考虑堆排序。排序算法的实际性能还受初始有序程度影响,近乎有序的数据用插入排序、Timsort 这类算法会有很好的表现。
大多数编程语言的内置排序已经做了很好的混合策略。比如 Python 的sorted使用的 Timsort 就是归并排序和插入排序的结合,它会检测数据中已经有序的片段,直接利用这些片段来减少合并次数。Java 的Arrays.sort对基本类型用双轴快排,对对象类型用 Timsort。所以你日常写代码时直接调用内置排序基本是最优解,真正需要手写排序的地方往往是面试、算法题或者底层库开发。
3.2 稳定性与内存占用
选排序算法时,稳定性往往比性能更重要。
我来举一个真实场景:你在数据库里存了一张订单表,需要先按用户分组,组内按下单时间从早到晚排序。如果使用 SQL 窗口函数,可以这样写:
SELECT user_id, order_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time) AS rn FROM orders这里PARTITION BY只负责分组,ORDER BY order_time负责组内排序。如果你自己实现一个类似的功能,先按 user_id 做分组,再对每个组做排序,必须保证组内的排序是稳定的,否则用户维度的时间顺序可能被打乱。这也是为什么稳定的归并排序在很多框架中会成为默认选择。
内存占用方面,快排是原地排序,递归栈平均 O(log n),堆排序 O(1),归并排序 O(n)。在内存敏感的嵌入式环境下,堆排序更有优势;在服务器端,内存不是首要瓶颈时,稳定性和性能更重要,归并排序常被使用。
3.3 实际系统里的排序:数据库、JS、MapReduce
数据库的 ORDER BY 是排序算法最直接的应用。MySQL 执行ORDER BY时,如果能用索引,就直接按索引顺序读取;如果不能用索引,就需要filesort,它可能使用快速排序(内存排序)或者外部归并排序(数据量超过 sort_buffer_size 时)。理解这个底层逻辑能帮我们优化慢查询:尽量不要对大数据量做无索引的排序,或者尽量减少排序的字段宽度,因为字段越宽,一次性排序的行数越少。
JavaScript 里的数组排序是另一个常见坑。Array.prototype.sort()的默认行为是把元素先转成字符串再按字典序排序,所以直接[10, 9, 2].sort()得到的是[10, 2, 9]。必须传比较函数:
[10, 9, 2].sort((a, b) => a - b); // [2, 9, 10]很多新人在项目里被这个坑折腾过。如果你要排序的是包含字母和数字的字符串,比如文件编号“A1”“A10”“A2”,默认字典序会出现 “A1、A10、A2” 这种不符合直觉的顺序。解决方法是自然排序,即把字符串中的数字部分提取出来按数值比较,或者使用 localeCompare 的numeric: true选项。
MapReduce 的排序则更为宏观。在 Hadoop MapReduce 中,Map 端的输出会先做本地排序,然后进行特定的“分区分组”,Reduce 端拉取数据后还会合并排序,最终按 key 有序传给 reduce 函数。其中分组是通过自定义GroupingComparator实现的。这个场景融合了排序、比较器、外部归并等多层技术,如果你之后要接触大数据,理解排序在这一层的作用会非常有帮助。
4. 排序算法常见误区与避坑指南
4.1 复杂度计算中的常见错误
我经常看到有人把“平均时间复杂度”和“最坏时间复杂度”混为一谈。比如写快排,有人写“复杂度是 O(n log n)”,严格说这只能代表平均情况,最坏情况是 O(n^2)。到底是 O 还是 θ,也有讲究。O 表示上界,比如“快排最坏时间复杂度是 O(n^2)”是说它不会超过 n^2 量级;θ 表示紧确界,比如“归并排序的时间复杂度是 θ(n log n)” 意味着它既是上界也是下界。大部分时候我们说“时间复杂度”通常默认指最坏情况的渐进上界 O,但在讨论平均复杂度时,最好说清楚是平均情况。
空间复杂度的坑同样不少。归并排序的空间复杂度是 O(n),这里不要忘记。快排的空间复杂度很多人写成 O(1),但递归栈平均是 O(log n),最坏是 O(n),不额外算递归栈的话不严谨。
还有稳定性,很多人记错。冒泡、插入、归并是稳定排序;选择、快排、堆排是不稳定排序。这个绝不能想当然,面试中变着花样考察。
4.2 递归实现快排的栈溢出问题
如果你用递归实现快速排序,当数组规模很大且基准选择不当时,递归深度可能达到 O(n)。在 Python、Java 等语言中,系统栈空间有限,深递归会直接导致栈溢出或者 StackOverflowError。
我之前在实际项目中就踩过这个坑。一次处理一个接近有序的百万级数组,基准用了最右边的元素,结果递归深度一路飙升,程序直接崩溃。后来改成随机基准,并且在小数组切到插入排序,才彻底解决。
除了随机基准,还有一个技巧:递归时先处理基准位置左边较短的区间,再迭代处理右边较长区间,这样可以控制递归深度。或者干脆用手动栈模拟递归,把递归改成迭代实现。总之,快排并不只是“记住代码”那么简单,生产环境的边界条件才是最考验经验的。
4.3 排序稳定性造成的“诡异Bug”
稳定性带来的 Bug 通常很隐蔽。比如你维护一个对象数组,每个对象包含name和score。你想先按score升序,相同分数的按name字典序排列。如果你的实现先按name排序,再用不稳定排序按score排序,那么相同score的对象的name顺序就可能被打乱,结果不符合预期。
解决办法有两种:一是直接用稳定排序,让后一次排序不破坏前一次顺序;二是使用复合比较器,在一次排序中同时比较score和name。第二种办法更高效,也让排序规则一目了然。但有些语言的内置排序并不保证稳定,比如旧版 Java 的Collections.sort是归并排序,稳定;而Arrays.sort对基本类型用双轴快排,不稳定。需要稳定时,最好用对象包装或者明确选择稳定算法。
4.4 面试中排序算法的常见考法与练习
面试官问排序,通常不满足于你背出代码。他们更关心你能否分析边界情况、优化策略、推导复杂度。
“三值排序”就是一个经典的变种题,来自 USACO。题目大意是:一个数组只包含 1、2、3,要把它排序,求最少的交换次数。这题不能直接调 sort 了事,因为要求最少交换次数。我的解题思路是:先统计 1、2、3 的数量,确定最终每个区域的范围;然后扫描一遍,优先处理“需要交换”的元素,比如 1 的位置上是 2,而 2 的区域里有 1,那就直接交换,一次交换修正两个位置。剩下的情况再按顺时针交换处理。这道题虽然和标准排序算法代码不同,但考察的是对排序本质的理解:交换的代价、有序性、位置映射。
另一个高频考点是“逆序对”。给定一个数组,求有多少个逆序对,可以用归并排序的合并过程在 O(n log n) 时间内计算。这是因为合并两个有序子数组时,如果右边数组的元素小于左边数组的某个元素,那么左边剩余的所有元素都和它构成逆序对。这类题能真正考察你会不会把排序算法改造成其他用途。
面试准备建议:不要死记代码,先自己画图模拟一遍排序过程,然后凭直觉写出来,再考虑边界条件(空数组、单元素、已有序、全部相等)。这样面试时即使紧张,核心逻辑也不会丢。
最后再分享一个学习技巧:写排序算法时,不要只跑普通用例,一定要跑几个“恶心”的用例,比如长度很大的有序数组、全部相同元素的数组、包含大量重复元素的数组。用time或者计时工具记录耗时,观察复杂度退化的情况。有了这些体验,你对排序算法的理解才算真正落地。我自己当时就是靠反复测试和调优,才把快排的边界条件彻底搞懂的。按这个路径去练,排序这个模块很快能成为你的强项。