快速排序核心原理与优化实战:从分区算法到工程实现
2026/9/10 10:01:05 网站建设 项目流程

1. 先理解快排为什么快:分区思想才是灵魂

很多人练快速排序,第一反应是背代码。背下来不难,但一两个月不用,又忘得一干二净。我这些年带过不少新人,发现一个规律:凡是能从原理层面讲清楚快排的人,代码怎么写都忘不了;凡是靠背诵入门的人,面试手写时往往卡在“递归边界到底怎么判断”这种最基础的地方。

快速排序的核心思想,用一句话说就是:分而治之。具体拆开是两件事:

  1. 选一个基准值(pivot),把数组里所有比它小的放到左边,所有比它大的放到右边。
  2. 左右两个子数组分别重复这个过程,直到子数组长度为0或1。

这里最关键的是第一步——分区(partition)操作。你不需要让两边各自有序,只需要做到“小归左、大归右”,把基准值放到它最终该待的位置上。一次分区之后,基准值的位置就固定了,不会再变。所有比较和交换,都是为了这个“固定位置”的目标服务。

画流程图的时候,很多人画的是整个快排的递归调用树,我觉得这样反而把人绕晕了。流程图最该画的,其实是单次分区的数据变化过程。你拿一个具体数组,比如:

[5, 3, 8, 1, 9, 2, 7]

选最后一个元素7作为基准,然后一趟分区下来的数组变化,每一步都要画清楚。把这一步画明白了,递归树只不过是把同一个过程套到左右子数组上而已。

这里要纠正一个常见误解:快排快,不是因为递归。递归只是一种手段,很多排序都可以用递归实现。快排真正的效率来源是分区操作的时间复杂度——理想情况下,每次分区都能把数组大致分成两半,于是递归深度是log₂n,每一层总的比较次数是n,整体复杂度是O(n log n)。如果分区不均匀,退化成每次都分成“1个和n-1个”,递归深度变成n,复杂度就退化成O(n²),比冒泡排序强不了多少。

所以在练习快排时,第一优先级不是“让递归跑通”,而是把分区操作练到极致。分区是快排的心脏,递归只是心跳的外壳。你分区写得稳,快排就稳;分区写得毛躁,后面全是坑。

2. 分区算法的两种主流写法:往返扫描与挖坑填数

分区算法看起来简单,真写起来花样挺多。主流的有两种:一种是Hoare提出的双指针往返扫描法,另一种是国内教材里常见的挖坑填数法。两种都要会,因为它们在不同场景下各有优势。

2.1 双指针往返扫描法:思路最直观

这种写法是用两个指针,一个从左往右找比基准大的,一个从右往左找比基准小的,找到就交换。等两个指针相遇,再把基准值放到中间。

以升序排序、选最左边元素为基准为例,伪代码是这样的:

左指针 i = 左边界,右指针 j = 右边界 基准 pivot = arr[left] while i < j: 先从右往左找 arr[j] >= pivot,j-- 再从左往右找 arr[i] <= pivot,i++ if i < j: 交换 arr[i] 和 arr[j] 最后交换 arr[left] 和 arr[i](或arr[j],此时i==j)

这里最容易搞错的地方是先从哪边移动。答案是:基准选在左边,就先移动右指针;基准选在右边,就先移动左指针。为什么?为了保证最后相遇的那个位置,存放的是一个“应该放到左边去”的值。如果基准在最左边,而你先动了左指针,可能导致最后把一个大值换到基准位置,整个分区就白做了。

2.2 挖坑填数法:初学者最容易上手的版本

另一种写法是维护一个“坑位”,把基准值先挖出来,然后循环填坑。以选最右元素为基准、升序排列为例:

i = left j = right pivot = arr[right] while i < j: while i < j and arr[i] <= pivot: i++ 填坑: arr[j] = arr[i] // 此时i位置变成新坑 while i < j and arr[j] >= pivot: j-- 填坑: arr[i] = arr[j] // 此时j位置变成新坑 最后 arr[i] 的位置就是基准的最终位置,填回 pivot

这种写法的好处是边界条件相对不容易错,因为每一步填坑后指针的位置和坑的位置天然对应。我自己学习和教学时,都推荐先从挖坑填数法上手,因为它把分区过程“物化”了——你能直观看到基准值被挖出来、左右指针交替填空的整个过程。

2.3 两种写法对比

对比维度往返扫描法挖坑填数法
交换次数每找到一对就交换直接用赋值代替交换,次数更少
理解难度指针相遇逻辑稍绕坑位思想更直观
边界陷阱较多,特别是移动顺序较少,适合入门
适用场景理解快排本质快速实现、考试手写

这两种方法其实是同一个算法的不同“手感”,不是两个算法。你练的时候,最好各写三遍,写到不用思考就能把边界条件写对,再去谈优化。

3. 马拉松最关键的拐点:基准值怎么选

如果你只是“练习快排”,随便固定取左端或右端都能跑通。但当你拿真实数据跑测试,甚至用排序性能来评估自己写得好不好时,基准值的选择立刻成为一个无法回避的问题。

最坏情况什么时候出现?待排序数组已经是有序或逆序的时候。如果你固定选最左端元素为基准,而数组本身已经升序排列,那么每次分区都只会分出一个长度为n-1的子数组和一个空数组,递归深度达到n,时间复杂度O(n²),递归层次太深还会导致栈溢出。

我遇到过不止一次,有人兴冲冲拿快排去对一个近有序的大数组排序,结果程序直接卡死或栈溢出,然后跑来问我“代码是不是写错了”。代码没错,是基准策略错了。

工程上常用的几种改进策略:

  1. 随机基准值:每次分区前,随机交换一个元素到基准位置。这样最坏情况变成概率事件,实际几乎不会发生。
  2. 三数取中法:取数组左端、中间、右端三个元素的中位数做基准。代码稍微多一点,但效果稳定,不用依赖随机数生成器。
  3. 适合特定场景的取中策略:比如数据量小的时候直接取中间值,数据量大时先采样后再取中。

从练习的角度,我的建议是:第一版务必用最简单的“固定取左端”,先把分区和递归写对;第二版改成“三数取中”,感受性能差异;第三版可以对比随机基准,体会不同策略在不同数据分布下的表现。这样循序渐进去练,你对快排的理解就不只是一个算法,而是一整套“如何权衡最坏情况与平均情况”的思路。

这里还要说一个反直觉的点:三数取中看似稳妥,但如果数组元素大量重复,比如100万个元素全是同一个值,三数取中本身也会失效,因为左中右三个值都一样,取出来跟没取一样。面对这种数据,就需要考虑三路分区(把相等的元素集中到中间,不再参与后续递归),这也是经典快排进阶里不能不提的一笔。

4. 用Java和C语言各写一版递归快排:动手才是硬道理

理论说再多,不落到代码上等于没练。我强烈建议你用至少两门语言去实现同一个算法,因为语言的差异会逼你想清楚哪些是“算法本身”,哪些是“语言层面的实现细节”。

4.1 Java实现:以挖坑填数法为基准

public class QuickSort { public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } private static int partition(int[] arr, int left, int right) { int pivot = arr[right]; int i = left; int j = right; while (i < j) { while (i < j && arr[i] <= pivot) { i++; } arr[j] = arr[i]; while (i < j && arr[j] >= pivot) { j--; } arr[i] = arr[j]; } arr[i] = pivot; return i; } public static void main(String[] args) { int[] arr = {5, 3, 8, 1, 9, 2, 7}; quickSort(arr, 0, arr.length - 1); for (int num : arr) { System.out.print(num + " "); } } }

这个版本有几点值得琢磨:

  • partition返回的是基准值最终所在的下标,递归时左半部分是[left, pivotIndex - 1],右半部分是[pivotIndex + 1, right],基准值本身不需要再参与排序。
  • 内层两个while都带了i < j条件,防止指针越界,这个一定不能省,尤其是数组中存在连续大于或小于基准的元素时。
  • Java的赋值填坑写法天然少了一个临时变量,比交换写法更干净。

4.2 C语言实现:以双指针交换法为基准

#include <stdio.h> void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int partition(int arr[], int left, int right) { int pivot = arr[left]; int i = left; int j = right; while (i < j) { while (i < j && arr[j] >= pivot) { j--; } while (i < j && arr[i] <= pivot) { i++; } if (i < j) { swap(&arr[i], &arr[j]); } } swap(&arr[left], &arr[i]); return i; } void quickSort(int arr[], int left, int right) { if (left >= right) { return; } int pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } int main() { int arr[] = {5, 3, 8, 1, 9, 2, 7}; int n = sizeof(arr) / sizeof(arr[0]); quickSort(arr, 0, n - 1); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } return 0; }

C语言版本我特意用了双指针往返扫描法,就是希望你能感受两种写法的差异。这里有个Java版本里不需要关心、但C语言必须注意的细节:

  • 函数参数传数组时,在C语言里实际上传的是指针,所以sizeof(arr) / sizeof(arr[0])这种求长度的方法只能在main函数里用,一旦传进函数内部,arr已经退化成指针,sizeof的结果就不对了。很多人初学C语言版快排,在quickSort函数里试图再算数组长度,算出来是个很奇怪的数,就是这个原因。
  • 双指针写法里的交换比挖坑填数法的赋值多了几次赋值操作,但逻辑上更符合“交换”的直觉,排错时更容易用断点观察。

4.3 两种语言实现时的差异总结

对比项JavaC
数组传参传引用,天然支持退化为指针,注意长度计算
交换方式可以直接赋值填坑通常用swap函数
栈空间JVM管理,栈溢出不明显递归过深会直接栈溢出
调试方式IDE断点gdb或printf

我自己练的时候,是先写Java版跑通所有测试,再写C版,然后刻意让两个版本处理同一组极端数据(全相同数组、倒序数组、超长数组),对比它们的表现。这个“同算法、双语言、压测对比”的过程,比单纯刷十遍代码都管用。

5. 递归快排最容易踩的三个坑,我都替你踩过了

下面这几个问题,是我在实际教练代码时反复见到的,也是自己当年踩过的。单独拎出来说,是因为它们极具迷惑性。

5.1 死循环:内层while少写一个"i < j"

很多新手写内层循环时,只写了条件判断,忘了加上i < j

while (arr[i] <= pivot) { i++; }

当数组某个位置的值持续<=pivot时,i会一路加下去,直接越过数组边界,甚至跑到right之外。这种错误最坑的地方在于:小数组偶尔能跑对,大数组必崩,或者结果莫名其妙错乱。

解决办法:内层循环条件必须同时满足i < j和元素值与基准的大小关系。这是分区函数的“安全带”,不能省。

5.2 递归边界不统一,导致无限递归

递归边界有三种写法,都可行,但必须全篇统一

  • 左闭右闭:quickSort(arr, 0, n-1),递归调用quickSort(arr, left, pivotIndex-1)quickSort(arr, pivotIndex+1, right)
  • 左闭右开:quickSort(arr, 0, n),递归调用quickSort(arr, left, pivotIndex)quickSort(arr, pivotIndex+1, right)
  • 递归终止条件对应地也会变化,可能是left >= right,也可能是left >= right - 1

最常见的翻车组合是:调用时用了左闭右闭的n-1,但递归边界写成了left >= right - 1,或者反过来。结果就是某些子数组永远满足不了终止条件,无限递归,最终栈溢出。

我在练习时的方法是:写代码前先明确标注我的区间是闭区间还是开区间,然后所有边界计算都从区间定义推导出来,不靠猜。这个习惯帮我省了无数调试时间。

5.3 对已排序数组直接栈溢出

固定选左端为基准,递归深度与数组长度相同,100万长度的有序数组直接栈溢出。这个问题在Java里不一定立刻暴露,因为栈空间默认配置还比较大;在C语言里几乎必现,因为默认栈空间只有几MB。

解决思路分两层:

  • 短期:改用随机基准或三数取中,大幅降低最坏情况发生概率。
  • 长期:把递归改成非递归(用显式栈模拟递归过程),彻底摆脱递归栈深度的限制。

非递归版本的核心思想是:用栈保存待处理的子区间,每次弹出一个区间,分区后再把左右子区间压入栈。循环往复,直到栈空。这个改写练习非常有价值,它逼你理解“递归的本质就是栈”。

6. 不只是练习:把快排从“能跑”优化到“像样”

如果你练快排的目的是应付作业或面试手写,那么能写出正确版本就够了。但如果你的工作里真需要用到排序性能,或者你参加面试时被问到“你怎么优化快排”,只写出基础版是无法让面试官满意的。

6.1 当数组很短时,改用插入排序

数据量小于一定阈值(比如10~20)时,递归调用带来的函数调用开销已经超过排序本身的收益。此时改用插入排序,整体性能会更好。

这个优化看似不起眼,实际效果非常明显。在标准库的排序实现里,这个阈值普遍存在。你可以自己加个判断:

if (right - left <= 15) { insertionSort(arr, left, right); return; }

优化后跑大数据测试,能明显感受到时间缩短。

6.2 三路分区解决大量重复元素的问题

前面提到过,当数组中有大量重复元素,传统两路分区的性能会严重退化。三路分区把数组分成三块:小于基准、等于基准、大于基准。递归时只需要处理左边的小于区和右边的大于区,中间的全部跳过。

这个算法在Java标准库的Arrays.sort()底层就是以双轴快排形式存在的。练习三路分区,不仅是对快排理解的深化,也是理解工业级实现的重要一步。

6.3 递归转非递归,做好栈空间管理

你可以用Stack数据结构来手动管理递归:

Stack<int[]> stack = new Stack<>(); stack.push(new int[]{left, right}); while (!stack.isEmpty()) { int[] range = stack.pop(); int l = range[0], r = range[1]; if (l >= r) continue; int p = partition(arr, l, r); stack.push(new int[]{l, p - 1}); stack.push(new int[]{p + 1, r}); }

这个版本理解起来比递归版难一档,但它让你真正明白“递归是编译器帮你维护调用栈,非递归是你自己维护数据栈”这句话的含义。

6.4 从手写快排到理解标准库排序的进化路径

我建议的练习路线是:

  1. 写递归版,跑通随机数组、逆序数组、含重复元素数组。
  2. 加上三数取中,对比随机数组和有序数组的性能差异。
  3. 加上小数组插入排序优化。
  4. 实现三路分区,测试大量重复元素场景。
  5. 改写非递归版,压测超大规模数组。
  6. 去看Java的Arrays.sort()源码,看你的优化和工业级实现的差距。

走完这六步,你对快排的理解已经不只是一道面试题,而是一条完整的技术脉络。面试官无论怎么追问——复杂度分析、最坏情况、稳定性、优化策略、底层实现——你都能接得住。

我自己当年也是从“死记硬背快排代码”起步,到后来因为一次真实项目里的千万级数据排序性能问题,才真正沉下心把快排的每一个变体都过了一遍。那次经历之后,我最大的感触是:**算法这东西,只有当你亲手把它写飞过、写崩过、再救回来过,才真正变成你的东西。**练习快排最大的收获不是会写这一个算法,而是学会了“分区—递归—优化—权衡”这套解决问题的思维框架,它能迁移到很多其他问题上。

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

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

立即咨询