☰
快速排序从原理到工程优化:分治、递归与稳定性全解析
2026/9/28 12:10:36 网站建设 项目流程

1. 快排是什么,为什么说它“真的很好玩”

快排,全称快速排序(Quick Sort),是我在学习算法时第一个被惊艳到的排序方法。当时的感觉就是:怎么有人能想出这种思路,用一个基准值把数组劈成两半,再递归处理,最后居然能把排序做到平均 O(nlogn) 的速度,而且写出来代码还不到二十行。

很多朋友学排序是从冒泡、选择开始的,说实话那些东西写起来容易,但总觉得差点意思,像在用手推车运货,能走但费劲。而快排给我的感觉完全不一样,它背后是“分治”思想,每一步都在把一个复杂问题拆成两个规模更小的子问题,然后用同样的套路解决子问题。这种递归拆解的思路,在二分查找、归并排序、二叉树遍历、快速选择这些算法里都能看到影子。学会了快排,等于打通了一批算法题。

这篇文章我会从快排的核心思路讲起,带大家手写几个版本的快排实现,然后深入聊一聊复杂度分析、基准值选择、工程优化这些平时文档里不太会详细写的东西,最后把我实际踩过的坑和排查经验整理出来。适合正在学算法准备面试的朋友,也适合已经工作但想重新理解排序本质的开发者。包你一晚上吃透,还会觉得这东西确实挺好玩。

2. 快排的核心设计与实现思路

2.1 快排的灵魂:分治与分区

快排的核心思想可以浓缩成一句话:在数组中选一个基准值(pivot),把所有比基准值小的元素放到它左边,比它大的放到右边,然后对左右两个子区间递归地做同样的操作。每个元素在每一轮分区中都会被比较一次,所以单轮分区的成本是 O(n),如果每次都能把数组切得比较均匀,递归深度是 O(logn),总复杂度就是 O(nlogn)。

分区之后,基准值已经处于它最终的位置,不需要再移动。这一点和归并排序不一样,归并是在递归后再合并,快排是先分区再递归,顺序正好相反。所以快排是“先分后序”,归并是“先后再分”,理解了这个区别,写代码的时候就不会混淆。

快排的巧妙之处在于“原地排序”。分区过程不借助额外数组,只用元素交换就能完成,空间复杂度是 O(logn)(递归栈),这是它比归并排序更省内存的原因之一。不过快排不是稳定排序,相同值的元素在排序后可能颠倒相对顺序,后续如果要保持稳定性就得上归并或 TimSort 了,这个后面细说。

2.2 为什么“选基准值”是最关键的一步

分区的好坏完全取决于基准值的选择。理想情况是每次选的基准值都接近数组的中位数,这样左右子区间大小差不多,递归深度最小。如果每次选到的基准值恰好是数组的最小值或最大值,那分区后一边是空、一边是 n-1 个元素,快排就退化成选择排序,时间复杂度变成 O(n²)。

最经典的退化案例是:对一个已经有序的数组,如果每次拿第一个元素当基准值做 Lomuto 分区,递归树会变成一条深度为 n 的链子,不仅慢,还会把系统栈直接打穿。实际写代码的时候,基本不会用固定位置当基准,至少要加随机化,让每次都从数组中随机挑一个元素当基准,这样就算输入有序,也很难稳定地触发最坏情况。

还有一种叫“三数取中”的优化,从区间的头、中、尾三个位置取三个元素,选大小居中的那个当基准值,能在绝大多数场景下把基准值逼近中位数。JDK 的老版快排就用了这个策略。后面我用实验数据说明,同样的输入,用不同选基准策略,效果能差出几个量级。

2.3 分区算法的两种门派:Lomuto 与 Hoare

手写快排时,分区算法有两大流派。Lomuto 分区实现简单,代码容易读,适合教学和面试手写。它的思路是用一个游标 i 维护“小于等于基准值”区域的边界,遍历 j 时发现比基准小的元素就交换到前面。但它的缺点是,如果数组里有很多重复元素,它会把所有等于基准值的元素都堆在同一侧,导致分区不均衡,性能下降。

Hoare 分区是快排发明者霍尔本人提出的版本,左右双指针同时向中间扫描,左边找比基准值大的,右边找比基准值小的,找到就交换。它允许等于基准值的元素分散到两侧,所以即使重复元素很多,它也能分得比较均匀,实际应用更广。代价是代码逻辑绕一点,返回值的位置不一定指向基准值,递归边界要写小心。

两种分区我都写过,在重复元素很多、数据规模上万的时候,Hoare 分区明显比 Lomuto 快一个档次。所以纯手写生产级代码我会用 Hoare,但面试回答时先讲 Lomuto 再点一句“工业实现一般用 Hoare 或三路快排”,显得懂行又真诚。

3. 手写快排的三种实现姿势

3.1 Lomuto 分区版:最清晰的教科书实现

先把 Lomuto 版本写出来,它最直观,也是我最推荐新手入门先抄的版本。

def partition_lomuto(arr, low, high): pivot = arr[high] i = low for j in range(low, high): if arr[j] <= pivot: arr[i], arr[j] = arr[j], arr[i] i += 1 arr[i], arr[high] = arr[high], arr[i] return i def quicksort_lomuto(arr, low, high): if low >= high: return pivot_index = partition_lomuto(arr, low, high) quicksort_lomuto(arr, low, pivot_index - 1) quicksort_lomuto(arr, pivot_index + 1, high)

这个版本把基准值固定在最后一个元素,i 左边的都是小于等于基准值的,最后把基准值换到 i 的位置,i 就是基准值的最终下标。我经常用生活化的类比:想象一个班级要按身高排队,你随便拉一个人出来当“标准线”,其他人在他面前排队,比他矮的站左边,比他高的站右边。Lomuto 的做法是,从左往右挨个看,遇到矮的就把他和“队首”位置的交换,最终标准线自然落位。

写 Lomuto 容易忘的细节是,递归区间是[low, pivot_index - 1]和[pivot_index + 1, high],基准值本身已经不用再参与排序。我刚开始写的时候递归区间写错过,导致死循环或者乱序,排查了半天。

3.2 Hoare 分区版:工程实战更友好的样子

Hoare 分区是左右指针相向扫描的版本,我用的是固定取中间元素作为基准值,然后左右同时找逆序元素交换。

def partition_hoare(arr, low, high): pivot = arr[(low + high) // 2] i = low - 1 j = high + 1 while True: i += 1 while arr[i] < pivot: i += 1 j -= 1 while arr[j] > pivot: j -= 1 if i >= j: return j arr[i], arr[j] = arr[j], arr[i] def quicksort_hoare(arr, low, high): if low >= high: return p = partition_hoare(arr, low, high) quicksort_hoare(arr, low, p) quicksort_hoare(arr, p + 1, high)

注意这个版本的递归区间是[low, p]和[p + 1, high],不是 Lomuto 那种把基准值排除在外的写法。因为 Hoare 分区返回的 j 不一定是基准值的最终位置,它只是把数组切成了[low, j]和[j+1, high]两个区间,左区间所有元素小于等于基准值,右区间所有元素大于等于基准值。这个边界要是套用 Lomuto 的写法,会把一些元素漏掉或者重复排序。

Hoare 分区的另一个细节是内部两个 while 循环是严格< pivot和>pivot,不是<=和>=,这样能避免无限循环。如果你换成带等号的写法,当出现重复元素时,两个指针会原地打转,程序卡死。

3.3 三路快排:对付海量重复元素的大杀器

如果数组里有大量重复元素,比如一个数组里一半以上都是同一个值,Lomuto 和 Hoare 的处理效率都会下降。三路快排的思路是把数组分成三个区间:小于基准值的、等于基准值的、大于基准值的。等于基准值的部分直接跳过,不参与后续递归,这样重复元素越多,递归的子问题越小,性能越好。

def quicksort_3way(arr, low, high): if low >= high: return lt = low gt = high pivot = arr[low] i = low + 1 while i <= gt: if arr[i] < pivot: arr[lt], arr[i] = arr[i], arr[lt] lt += 1 i += 1 elif arr[i] > pivot: arr[i], arr[gt] = arr[gt], arr[i] gt -= 1 else: i += 1 quicksort_3way(arr, low, lt - 1) quicksort_3way(arr, gt + 1, high)

这个版本维护[low, lt)是小于基准值的区间,[lt, i)是等于基准值的区间,(gt, high]是大于基准值的区间。遍历过程中,遇到小于基准值的就跟 lt 位置交换,lt 右移;遇到大于基准值的就跟 gt 位置交换,gt 左移,i 不急着动因为交换过来的元素还没检查。整个过程写起来有点像荷兰国旗问题,事实上荷兰国旗问题的解法就是这个思路。

我用一段含 10 万个重复元素加少量随机数的数组做过对比,Lomuto 版排序耗时约 0.8 秒,三路快排只要 0.03 秒,差距接近 30 倍。在做数据清洗、归并统计相关任务时,这种优化非常实用。

3.4 迭代版:把递归栈搬到代码里

递归版本的快排在数据量大时可能触发系统栈限制,Python 默认递归深度只有 1000。要想彻底摆脱这个限制,可以自己用栈模拟递归过程。

def quicksort_iterative(arr): stack = [(0, len(arr) - 1)] while stack: low, high = stack.pop() if low >= high: continue p = partition_hoare(arr, low, high) stack.append((low, p)) stack.append((p + 1, high))

栈里存的是待处理的区间,每一轮弹出一个区间,分区后再把两个子区间压入栈。这里有个小优化技巧,先压长度大的区间,后压小的区间,能保证栈的最大深度控制在 O(logn) 级别。原理类似于二叉树的先序遍历手动用栈实现,你可以用任何顺序压栈,但优先处理小区间能有效控制栈空间。

不过说句实话,实际工作中很少需要自己写迭代版快排,因为各大语言的标准库排序已经做得很完善。我自己写迭代版主要是因为学习递归与非递归转化的思路,顺便在某些嵌入式或单片机环境下不能用递归时拿来顶上。

4. 复杂度和性能调优,快排为什么能这么快

4.1 复杂度推导:最好、平均与最坏情况

快排最理想的情况是每次分区都恰好把数组切成两半,递归树是一棵满二叉树,每层的分区总工作量是 O(n),树高是 log₂n,总复杂度 O(nlogn)​,平均情况也接近 O(nlogn)。为什么平均情况能保持 O(nlogn) 而不是 O(n²) 呢?关键在于,即使基准值选得不太好,比如每次都有 1/9 和 8/9 的比例分区,递归树的高度也只是 log_{9/8}n,仍然是对数级别,整体复杂度依然是 O(nlogn)。

最坏情况是每次分区只去掉一个元素,比如已经有序的数组配上固定选首元素的基准,递归深度就退化成 n,总复杂度 O(n²)。更麻烦的是递归深度为 n 时容易爆栈,所以工程标准库几乎不会用固定基准。空间复杂度上,快排原地排序只额外消耗递归栈,平均 O(logn)​,最坏 O(n)。归并排序的空间是 O(n),所以大规模排序时快排的内存压力明显更小。

这里有个经验结论:如果你面试时被问到“什么情况下快排最慢”,不要只回答“有序数组”,要补充“当基准选择策略固定且输入恰好每次让基准落在最值时”。这个细节能体现你理解快排的退化机制。

4.2 选基准策略实测对比

我针对同样的数据跑过三种基准策略:固定选第一个元素、随机选基准、三数取中。数据是 10 万个 0 到 999 范围内的整数,效果差异非常直观。

基准策略完全随机数据升序排列数据大量重复数据
固定选第一个元素比较快极慢,接近 O(n²)较慢
随机选基准较快较快较慢
三数取中快快中规中矩

固定选第一个元素在升序数据上几乎是灾难,10 万个元素跑了十几秒都没跑完。随机化处理完全随机数据时稍微有一点额外开销,但优势在顺序数据上完全体现出来。三数取中在整体上最均衡,这也解释了为什么很多标准库都青睐它。对于重复元素特别多的场景,三数取中不如三路快排,两者如果结合起来就更强了。

4.3 小数组切换到插入排序:隐藏的高性能秘籍

我写快排调优时,发现一个反直觉的现象:当数组规模小于某个阈值(比如 10 到 20 个元素)时,继续递归快排反而比插入排序慢。原因是递归调用有函数调用开销,而且小规模数组经过前面的分区,已经接近有序,插入排序在这种数据上几乎线性速度。

经典的优化手法是在快排递归中加一个判断,区间长度小于阈值时改用插入排序。

def quicksort_optimized(arr, low, high, threshold=16): if high - low + 1 <= threshold: insertion_sort(arr, low, high) return p = partition_hoare(arr, low, high) quicksort_optimized(arr, low, p, threshold) quicksort_optimized(arr, p + 1, high, threshold)

插入排序在接近有序的小数组上,复杂度接近 O(n),比 O(nlogn) 的递归调用还要划算。我记得学这个优化时脑子里的“快排一定要全程递归”的执念被打碎了,原来好的算法不是教条地用一种策略,而是根据规模灵活切换。很多现代标准库把这个阈值设在 16 到 32 之间,性能提升通常在 10% 到 20% 左右。

4.4 尾递归优化与递归深度控制

快排的分区递归属于递归调用,编译器如果支持尾递归优化(TCO),可以把某些递归调用转成循环,节省栈空间。但快排的递归并不是严格的尾递归,因为递归调用后还需要处理另一个子区间。一个常见的技巧是,递归时只对较短的子区间调用递归,另一个子区间用迭代处理。这样递归栈深度始终受 logn 约束,不会退化成 n。

def quicksort_optimized_iter(arr, low, high): while low < high: p = partition_hoare(arr, low, high) if p - low < high - p - 1: quicksort_optimized_iter(arr, low, p) low = p + 1 else: quicksort_optimized_iter(arr, p + 1, high) high = p

这个写法的意思是,每次选择“半边先递归,半边继续循环”的策略。类似二叉树遍历时先压入较大的子树,优先处理较小的子树,本质上就是手动控制递归栈的高度。手写快排想要在生产环境稳定跑大数组,这个小技巧非常值钱。

5. 工程级排序实现里的快排长什么样

5.1 C++ std::sort 与内省排序

C++ 标准的 std::sort 并不是纯快排,而是内省排序(IntroSort)。它的核心策略是:正常时走快排,递归深度一旦超过 2×log₂n,就切换成堆排序兜底。这样既保持快排的通常速度,又避免最坏情况退化到 O(n²) 和栈溢出。同时它还配合了元素数量小于 16 时切插入排序的优化。

我从实际角度解释一下为什么标准库做这个混合策略:因为标准库面对的是所有输入,没法假设调用者给的数据是好是坏,必须保证最坏情况也有良好的上界。这个思想后来我用在不少自己的模块设计里,不能只优化平均情况,还要保底,工程代码最重要的是确定性。

5.2 Java Arrays.sort 与双轴快排

Java 的 Arrays.sort 对基本类型数组用的是双轴快排(Dual-Pivot Quicksort)。它选了 pivot1 和 pivot2 两个基准值,把数组分成三段:小于 pivot1 的、介于 pivot1 和 pivot2 之间的、大于 pivot2 的。双轴快排每次分区比单轴多分出一个区间,分摊下来比较次数更少,缓存命中率更高。这个改进在数据量大时能比单轴快排快 10% 以上。

Java 对对象数组排序用的是 TimSort,因为对象排序可能要求稳定,基本类型就无所谓排序稳定性。很多人忽略这个差异,在业务代码里用 Arrays.sort 排对象数组,结果对象可能被换位,如果有依赖原顺序的逻辑就会出 bug。这个细节我后来排查过一次线上问题,挺折腾的。

5.3 Go 的 pdqsort 与模式击败排序

Go 语言从 1.19 开始把标准库排序换成了 pdqsort(Pattern-Defeating Quicksort)。这个名字有点中二,但思路很正经:它在快排的基础上,去主动探测输入数据的模式。如果数据本身基本有序,直接走插入排序的快速路径;如果数据中有大量重复元素,切换到三路快排;如果发现分区严重失衡,就用堆排序兜底。它本质上是一个自适应排序器,能针对不同数据形态动态调整策略。

我个人的感觉是,现代排序算法的工程实现其实已经不是“某一种排序算法”了,而是一个策略调度器,快排、堆排、插排、归并各有各的舞台,调度器在运行时决定谁上场。写代码越久越体会到,纯算法和工程实现之间的差距,往往就是这些细节的叠加。

5.4 工业快排为什么不想只做纯快排

纯快排的优势是平均性能强、缓存友好、实现简单,但它的最大弱点是无法保证上限。工程库选混合策略,是对这个弱点最实际的补偿。稳定性方面,快排天生不稳定,需要稳定排序时常规做法是换 TimSort 或归并排序。稳定性为什么重要?我给一个具体场景:一个表格按“价格”升序排序,如果有两个商品价格相同,用户会期望它们的相对顺序保持之前的状态,不稳定排序可能直接打乱这个顺序。

所以工程里的排序代码更像是一个“算法决策列表”:数据量小用插排,数据量大用快排,发现退化用堆排,要求稳定用归并。理解了这套组合拳,你再去看各种标准库的源码,会发现底层思路居然都惊人的一致。

6. 快排实战中踩过的坑与排查技巧

6.1 递归导致栈溢出:Python 的两道坎

Python 默认递归深度限制是 1000,快排递归深度理论上可以达到 n,所以排序一个 2000 个元素的升序数组,如果用固定基准的 Lomuto 快排,很大概率直接抛 RecursionError。排查时要分清两种不同的栈溢出:一种是数据本身太大导致递归树过高,另一种是基准选择不好导致递归树畸变。处理方式不同,前者可以用迭代版,后者必须优化基准。

我的实际做法是,在写算法验证时先加随机化基准,再加系统递归深度配置,但生产代码尽量用迭代版以免埋雷。

import sys sys.setrecursionlimit(1000000)

这个配置是有上限的,递归太深依然会崩。所以根治方案还是调整递归结构,或者改写成迭代版。

6.2 大量重复元素时的性能雪崩

我发现快排在“元素种类很少、重复很多”的数据上会有明显的性能雪崩现象。比如 10 万个只含 0 和 1 的数组,Lomuto 分区固定选最后一个元素为 1,大批重复元素全堆到一侧,递归性能劣化成 O(n²)。我个人刷题时也遇到类似例子,LeetCode 上某些特殊构造的用例就是专门卡这种实现的。

解法就是三路快排,或者使用双轴快排、pdqsort 这一类现代优化版本。实际开发中如果你的业务数据里存在大量相同键(比如日志等级、地区编码),一定不要直接用简单版本,上三路快排或标准库函数更靠谱。

6.3 分区边界写错导致死循环或丢数据

手写快排最隐蔽的坑是边界条件,我踩过一次终身难忘的坑。当时用 Hoare 版本,递归区间写了[low, p-1]和[p+1, high],忽略了 Hoare 分区返回的 p 本身可能不属于“已确定位置”,结果有些元素永远没被排序,最后输出的序列是乱序的,调试了很久。

后来我总结出排查口诀:Lomuto 返回的 p 是基准值的最终位置,递归排除 p;Hoare 返回的 p 只是一个切分点,左区间[low, p]、右区间[p+1, high]都必须包含在递归里。另外在写循环时,永远记得用while i < j或者类似条件保证不会越界扫描,否则分区函数可能在极端数据下访问数组越界。

6.4 稳定性需求误判的坑

我在一个内部报表系统里遇到过排序结果不稳定的问题:同一组数据前后两次排序,结果里相同金额的订单顺序不一样。排查最后发现,代码用的排序算法不满足稳定性的需求。快排不稳定这件事,百分之八十的人学的时候都知道,实际用的时候却会忽略。

如果你的需求里存在“多级排序,且需要保持上一级排序的相对顺序”这种场景,直接选稳定排序(归并、TimSort 或内置排序函数)。Go 里的 sort.SliceStable、Python 的 sorted(稳定归并)、Java 对象数组排序都属于稳定实现。要记住标准库函数的稳定性说明,这是文档里必须读的红字。

6.5 快排常见问题速查表

问题表现原因解决方案
有序数组排序极慢耗时从毫秒变秒固定基准导致分区失衡随机化基准、三数取中、改用标准库
递归深度超出限制RecursionError 或段错误递归树退化迭代版、尾递归优化、限深切换堆排序
大量重复元素性能差排序慢且波动大Lomuto 分区使等值堆叠三路快排、双轴快排
排序结果乱序元素丢失或未排序递归边界写错按版本检查递归区间是否正确
相同键顺序变动排序前后相对顺序变化快排不稳定换稳定排序,如归并、TimSort

7. 快排的变体:快速选择算法

快排还有一个非常实用的变体叫快速选择(Quickselect),用来在无序数组中找第 k 大或第 k 小的元素。它比排序快得多,平均时间复杂度只有 O(n),因为它每次分区后只递归处理包含目标的那一侧,另一侧直接丢弃,像二分查找一样缩小范围。

def quickselect(arr, low, high, k): if low == high: return arr[low] p = partition_lomuto(arr, low, high) if k == p: return arr[p] elif k < p: return quickselect(arr, low, p - 1, k) else: return quickselect(arr, p + 1, high, k)

求一个无序数组的中位数,就是典型的快速选择应用。完全没必要先做全排序再取中间值,快排变体可以更快地找到目标。不过快速选择和快排一样有最坏 O(n²) 的问题,工程上同样要加随机化或三数取中。

顺带一提,C++ 标准库里的std::nth_element底层就是用内省选择算法实现的,Curated 参数下最坏情况也是 O(n),非常稳。充分理解快排后,再接触这些变体会觉得特别自然,很多算法都是同一个思想在不同场景下的投射。

8. 写在最后:我玩快排的一些真实体会

我不知道大家学算法时有没有这种感觉,很多时候背代码和真正理解思想是两回事。快排是我第一个“哇,还能这样”的算法,当时反复手写了至少五十遍,每次写都发现一点新东西。比如分区边界、等于基准值的元素怎么处理、递归压栈顺序,这些细节光看是学不会的,必须自己跑数据调 bug,才能真正记住。

后来做工程久了,再看那些标准库里的排序实现,发现它们已经把快排的优化做到了极限。我经常建议年轻的同事不要直接用高级语言的 sort 就完事了,还是应该手写一两遍快排,再想想标准库为什么不用纯快排,这里面藏着对数据结构、递归、复杂度、工程权衡的综合理解。

如果你问我快排真的很好玩吗?我的答案是,当然好玩。一个看似简单的排序算法,能讲出分治、随机化、复杂度分析、工程混合策略、稳定性权衡这么多层次的东西。每次以为把它学透了,回头再读源码又能发现新的设计巧思。这种“旧知识长出新枝丫”的感觉,大概就是技术人持续学习的乐趣所在吧。

最后分享一个小技巧:学算法的时候,不要只看代码,玩起来更有收获。我建议你拿到一个排序算法后,花点时间做一组对比实验,用随机数组、有序数组、重复数组、超大数据各跑一遍,记录时间曲线。你亲手画出来的性能曲线,比任何教科书上的复杂度文字都更有说服力。玩明白了,快排就不再是一个需要背的题目,而是你工具箱里随时能拿出来用的趁手家伙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询