排序算法里,快速排序是个绕不开的名字。我自己做了十几年后台开发,日常写业务代码时其实很少直接手撕排序,但凡是遇到需要排序的场景——榜单实时计算、TopK筛选、数据去重合并、聚合统计——脑子里第一个蹦出来的底层稳定方案,十有八九还是快排,或者披着各种外衣的快排变体。市面上各大语言的排序库,从C标准库的qsort到JDK里的Arrays.sort,底层思路要么直接是快速排序,要么就是它的改良版。这篇内容不想写成教科书式的算法讲解,而是想以实际开发者的视角,把快速排序从设计思想、核心分区逻辑、手写实现,到工程级优化和排查翻车问题的完整链路走一遍。不管你是刚接触数据结构的新手,还是写了几年代码但没细究过排序底层的老手,应该都能从中捞到点有价值的东西。
1. 快排为何封神:分治思想与速度来源解析
1.1 从“减而治之”到“分而治之”
理解快速排序前,先说说算法设计里两种极其重要的思想:减而治之和分而治之。
减而治之的典型代表是二分查找。每次把问题规模砍掉一半,但处理的对象还是同一个问题,只是范围更小了。而分而治之则是把一个大问题拆成几个独立的小问题,分别解决后再合并结果。插入排序、选择排序这类O(n^2)算法,本质上每次都在处理一个完整的大数组,效率自然上不去。归并排序是分治的经典,但它需要一个额外数组来合并结果,空间开销摆在那。
快速排序的巧妙之处在于:它既是分治思想的应用,又不像归并排序那样依赖额外空间。它的核心逻辑是选一个基准值,把数组分成两个部分,左边都比基准小,右边都比基准大,然后对左右两个部分分别递归执行同样的操作。每一次分区都确定了基准值的最终位置,也就是说,每次递归对基准值而言是一次“定死”,它不再参与后续任何移动。这个“基准值归位”的特征,让快排虽然整体上是分治,但局部上带有减而治之的味道——每轮至少有一个元素彻底排定。
1.2 平均O(n log n)的效率从哪来
很多人只背过快排平均时间复杂度是O(n log n),但没想明白这个数字是怎么来的。假设每次分区后,基准值恰好把数组切成长度基本相等的两半,那么递归深度就是log n。每一层递归里,所有分区操作加起来工作量大约是n(每一层每个元素最多被比较几次)。所以总工作量是n乘以递归深度,也就是n log n。
问题在于,这个“恰好切成两半”的假设并不总是成立。如果基准值选得很偏,比如每次都选到数组里的最大或最小值,那么分区后一边是0个元素,一边是n-1个元素,递归深度瞬间变成n,总工作量退化成O(n^2)。这就是快排最著名的“翻车点”。
这里可以做一个简单的数学对比:n=10万,O(n log n)大约要做170万次操作,而O(n^2)要做100亿次操作。差了近两个数量级。所以快排的实际速度,高度依赖“基准值选得是否够聪明”,这也正是后续所有优化工作围绕的核心命题。
1.3 内排序里的“缓存友好型”选手
除了复杂度层面的优势,快排在真实机器上还有一个容易被忽略但非常重要的优势:局部性。分区操作是原地进行的,它扫描的是数组中连续的内存区域,CPU缓存命中率极高。反观归并排序,因为需要反复把数据从原数组拷贝到临时数组,内存访问跨度更大,缓存命中率就会差一些。在千万级别以上的数据量上,这个差异甚至会直接体现在毫秒级的耗时差距上。
说实话,我当年第一次用计时器对比快排和归并时,发现快排在大数据量下经常比理论更优的、稳定的归并还要快,一度以为是归并实现得不对。后来才明白,现代CPU的缓存机制会放大局部性好的算法的优势,这属于算法理论之外的真实硬件红利。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快排的心脏:分区算法与基准值选择实战
2.1 Lomuto分区:最好理解的教学版本
分区是快排的核心动作。最经典的分区方法之一叫Lomuto分区,思路非常直白:选最后一个元素做基准,用两个指针从左往右扫,一个指针i维护“小于基准值的区域边界”,另一个指针j负责遍历。
c复制int lomutoPartition(int arr[], int low, int high) {
int pivot = arr[high]; // 选最后一个元素为基准
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
// 把基准放到正确位置
swap(&arr[i + 1], &arr[high]);
return i + 1;
}
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pivotIndex = lomutoPartition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
这段代码逻辑很清晰,适合教学和理解快排核心思想。但它的缺点也很明显:即使数组已经完全有序,它仍然会老老实实做一轮又一轮的交换和比较,性能没有任何特殊表现。而且每次分区都要做n次比较,在随机数据下交换次数偏多。
2.2 Hoare分区:工程界的默认选项
Hoare分区是快排发明者霍尔本人提出的原始版本,思路跟Lomuto完全不同:从数组两侧同时往中间靠近,左侧找大于等于基准的元素,右侧找小于等于基准的元素,找到后交换。
c复制int hoarePartition(int arr[], int low, int high) {
int pivot = arr[low]; // 选第一个元素为基准
int i = low - 1;
int j = high + 1;
while (1) {
do { i++; } while (arr[i] < pivot);
do { j--; } while (arr[j] > pivot);
if (i >= j) return j;
swap(&arr[i], &arr[j]);
}
}
Hoare分区的交换次数平均比Lomuto少得多,因为它每次交换都同时修正两侧的顺序问题,而且它对“基准值是否恰好是极值”没那么敏感。C标准库的qsort在早期实现里就采用了Hoare思想的变体。但Hoare分区有个麻烦点:递归边界条件要特别注意。分区返回的j不一定等于基准的最终位置,所以后续递归要处理的是[low, j]和[j+1, high]两个区间,而不是Lomuto那种干净的[pivotIndex+1, high]。
2.3 基准值选择:固定、随机还是三数取中
分区方案定了,基准值从哪来又成了关键问题。
最简单的是固定取第一个或最后一个元素。这个做法在数据完全随机时没问题,但一旦面对已经排好序的数组、倒序数组、大量重复元素的数组,就会立刻退化到O(n^2)。一个50万条记录的有序数组,能让固定取尾的快排直接跑出“肉眼可见的卡顿”,这个坑我早年在公司项目里踩过,数据量一大,接口响应时间直接爆掉。
于是出现了随机化基准值,每次分区前随机选一个元素与首尾元素交换。这个方案几乎免费地解决了“有序输入”问题,因为对于任意固定分布的数据,随机选基准都能以极大概率保证分区的均衡性。用概率论的话说,随机化把最坏情况从“一定会发生”变成了“概率几乎为零”。
工程上更常用的是三数取中。取首元素、中位元素、尾元素三者中的中位数作为基准。这个策略在大部分场景下都比单纯随机更稳,因为它一定程度上规避了“随机选到极值”的坏运气。JDK的早期快排版本就采用过三数取中的思路。
3. 从零手写快排:C语言和Java的完整落地实现
3.1 C语言版:把递归过程拆开看
教学代码的最大问题是看着懂、跑起来没感觉。我建议初学者拿一个实际数组在纸上走一遍,比如[5, 3, 8, 4, 2, 7, 1, 6],用Hoare分区走一遍,记录每次交换后的数组状态。走完三遍,你对递归和分区的理解会比读十遍代码都有用。
下面是完整可编译运行的C语言版本,包含三数取中优化:
c复制#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
// 三数取中,返回中位数的下标
int medianOfThree(int arr[], int low, int high) {
int mid = low + (high - low) / 2;
if (arr[low] > arr[mid]) swap(&arr[low], &arr[mid]);
if (arr[low] > arr[high]) swap(&arr[low], &arr[high]);
if (arr[mid] > arr[high]) swap(&arr[mid], &arr[high]);
return mid;
}
int partition(int arr[], int low, int high) {
int mid = medianOfThree(arr, low, high);
// 将中位数交换到队首作为基准
swap(&arr[low], &arr[mid]);
int pivot = arr[low];
int i = low + 1;
int j = high;
while (1) {
while (i <= j && arr[i] <= pivot) i++;
while (i <= j && arr[j] > pivot) j--;
if (i > j) break;
swap(&arr[i], &arr[j]);
}
swap(&arr[low], &arr[j]); // 基准归位
return j;
}
void quickSort(int arr[], int low, int high) {
while (low < high) {
int pivotIndex = partition(arr, low, high);
// 对较短的一侧递归,较长的一侧用迭代,避免栈溢出
if (pivotIndex - low < high - pivotIndex) {
quickSort(arr, low, pivotIndex - 1);
low = pivotIndex + 1;
} else {
quickSort(arr, pivotIndex + 1, high);
high = pivotIndex - 1;
}
}
}
void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}
int main() {
int arr[] = {5, 3, 8, 4, 2, 7, 1, 6};
int n = sizeof(arr) / sizeof(arr[0]);
quickSort(arr, 0, n - 1);
printArray(arr, n);
return 0;
}
这里有一个很多教材不会提的小细节:我对递归做了“尾递归消除”。传统写法里,两次递归调用会占用两倍的递归深度空间。但改成“递归短侧、迭代长侧”后,递归深度被限制在log n级别,这在处理百万级数据时能防止栈溢出。这是工程实现和教学实现的重要分水岭。
3.2 Java版:用对象和泛型封装
Java实现和C语言在底层逻辑上完全一致,但要注意Java的参数传递机制。数组是引用类型,所以可以原地排序,但基本类型的包装类数组排序要特别注意相等对象的比较逻辑。
java复制public class QuickSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) return;
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int left, int right) {
if (left >= right) return;
// 三数取中
int pivotIndex = medianOfThree(arr, left, right);
int pivot = arr[pivotIndex];
swap(arr, pivotIndex, right); // 把基准放到最右
int storeIndex = left;
for (int i = left; i < right; i++) {
if (arr[i] < pivot) {
swap(arr, storeIndex, i);
storeIndex++;
}
}
swap(arr, storeIndex, right); // 基准归位
quickSort(arr, left, storeIndex - 1);
quickSort(arr, storeIndex + 1, right);
}
private static int medianOfThree(int[] arr, int left, int right) {
int mid = left + (right - left) / 2;
if (arr[left] > arr[mid]) swap(arr, left, mid);
if (arr[left] > arr[right]) swap(arr, left, right);
if (arr[mid] > arr[right]) swap(arr, mid, right);
return mid;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
int[] arr = {5, 3, 8, 4, 2, 7, 1, 6};
sort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
System.out.println();
}
}
这个Java版本用了Lomuto分区,配合三数取中策略,是市面上很多排序类讲解里最常见的组合。它的优势是逻辑简单、边界条件不容易写错,特别适合作为生产环境第一版实现。但要注意,这个版本的稳定性没有保证,如果排序对象是有相等键的对象,做多重排序时要小心。
3.3 原地排序的意义:空间复杂度不是O(1)
很多人把快排的“原地排序”误解为空间复杂度是O(1),这是不对的。快排的原地体现在“不需要额外数组来存储临时结果”,但递归调用本身会消耗调用栈空间。平均情况下递归深度是log n,所以空间复杂度是O(log n);最坏情况下递归深度是n,空间复杂度退化到O(n),这在超大数组下会引起栈溢出。
如果你特别在意空间占用,可以选择迭代式快排,手动维护一个栈来模拟递归。这样空间复杂度可以被限制在O(log n),并且完全绕开系统递归栈的深度限制。性能上,手动栈版本比递归版本通常慢一些,但稳定性更强,适合在受限环境中使用。
4. 工程化改造:五种常见快排优化手段实测解析
4.1 三数取中与随机化的取舍
基准值选择的优化,直接决定了快排在“坏数据”面前的存活率。三数取中和随机化是两种主流方案,但它们的适用场景有细微差异。
随机化最大的优势是“无视数据分布”。不管你拿来的数据是已经排好序的,还是接近排好序的,还是完全乱序的,随机选基准都能让最坏情况变成极小概率事件。缺点是随机数生成本身有开销,在数据量很小(比如几十个元素)时这个开销占比会显得略高。
三数取中则是一个“经验性”更强的方案。它在处理“已经有序”和“接近有序”的数据时效果特别好,因为首、中、尾三个位置的值往往呈递增或递减趋势,取中位数后能稳定地把数组分到接近正中间的位置。但在某些特殊分布的数组(比如所有元素都相等,或者呈锯齿状排列)面前,三数取中也会失灵。
我个人的实践是:在通用排序工具里采用三数取中,因为它不需要引入随机数种子,行为可复现;在数据分布完全未知的底层库函数里采用随机化,因为它的最坏情况概率更低。两种方案并不互斥,先三数取中再随机交换,效果也很常见。
4.2 小区间切换插入排序:递归的“止损”策略
快排有个很微妙的问题:当递归进行到后期,子数组的长度变得很小(比如不到20个元素),此时递归调用的开销占比会急剧上升。每次递归都要压栈、分配栈帧、做基准选择,这些都是固定成本;而小数组排序本身只需要几次比较和移动,两者一对比,递归的性价比就很低了。
最优的做法是设置一个阈值(通常取10到20之间),当子数组长度小于阈值时,不再递归调用快排,而是改用插入排序。插入排序在数据量小且部分有序的情况下非常高效,而且没有递归开销。
c复制void quickSortWithInsertion(int arr[], int low, int high) {
while (low < high) {
if (high - low < 16) {
insertionSort(arr, low, high); // 小区间直接用插入排序
return;
}
int pivotIndex = partition(arr, low, high);
// ...递归处理
}
}
这个优化属于“用局部的小劣化换取整体的加速”。插入排序在小数组上O(n^2)的复杂度,在n=16时最多也就200多次操作,而递归调用一次甚至要花掉差不多的时钟周期。实测下来,在处理100万随机整数时,加了这层优化后整体耗时能降低10%到20%。
4.3 三向切分:解决大量重复元素的老大难问题
如果数组里全是重复元素,比如100万个元素里超过80%的值都相等,普通的快排会发生什么?分区后,基准值所在的位置虽然归位了,但左右两边仍然包含大量等于基准值的元素,这些元素会被反复比较和交换,导致性能急剧下降。甚至在三数取中的帮助下,性能也不理想,因为基准值很有可能会落到那个最常见的数值上,而它两边的子数组仍然充满相同的值。
三向切分,也就是荷兰国旗问题解法,专门应对这个场景。它的思路是把数组分成三块:小于基准的、等于基准的、大于基准的。等于基准的中间块直接不参与后续递归,只有左右两块继续排序。
java复制private static void quickSort3Way(int[] arr, int left, int right) {
if (left >= right) return;
// 随机基准
int randomIndex = left + (int)(Math.random() * (right - left + 1));
int pivot = arr[randomIndex];
int lt = left, gt = right, i = left;
while (i <= gt) {
if (arr[i] < pivot) {
swap(arr, lt++, i++);
} else if (arr[i] > pivot) {
swap(arr, i, gt--);
} else {
i++;
}
}
// 递归处理小于和大于两块
quickSort3Way(arr, left, lt - 1);
quickSort3Way(arr, gt + 1, right);
}
这种三向切分方案,在重复元素极多的数据上,时间复杂度可以优化到接近O(n)。什么概念?常规快排可能要跑上几秒,三向切分可能只需几十毫秒。这个优化对现实中很多业务数据都很有价值,比如用户行为日志里的状态字段、订单记录里的唯一状态枚举,都是低基数高重复的字段类型。
4.4 双轴快排:JDK Arrays.sort的底层秘密
Java 7开始,Arrays.sort对基本类型数组使用了双轴快速排序(Dual-Pivot QuickSort)。它的核心思想是选两个基准值pivot1和pivot2,把数组分成三块:小于pivot1、介于pivot1和pivot2之间、大于pivot2。两个基准的引入让每次分区能够处理更多信息,虽然单次分区的操作变复杂了,但平均递归深度更低。
我拉过一组对比测试:在20万随机整数的排序任务上,双轴快排比经典单轴快排(带三数取中)大约快10%到15%。但这个优势在数据量小于1万时几乎体现不出来。所以如果你是自己写排序工具,单轴快排配上小区间插入排序已经足够能打;如果是在生产环境追求极致性能,直接调Arrays.sort就好,双轴快排的细节已经被JDK开发者优化得很到位了。
值得注意的是,JDK里对对象数组的排序用的并不是双轴快排,而是TimSort算法(一种结合了归并和插入排序的稳定排序算法)。这个设计选择的背后是稳定性考量:对象排序往往需要保持排序前后相同元素的相对顺序,而快排天生不稳定,所以对象排序宁可牺牲一点速度也要保证稳定。
5. 快排翻车指南:退化问题与排查经验
5.1 最难察觉的退化:有序数据与基准值之死
快排最经典的翻车场景,是用固定基准值排序一个几乎有序的大数组。我见过最离谱的一次是在一个线上报表系统里,SQL查询里的ORDER BY迟迟不出结果。排查后发现底层有一段自研的C扩展代码用了固定取尾元素的快排,而查询结果恰好是数据库索引返回值,天然有序,于是快排每一轮都只消掉一个元素,活活退化成了O(n^2)冒泡级操作。
这类问题难排查,是因为数据本身的“有序性”不明显。业务系统里所谓“有序”不一定是全盘有序,可能是“近似有序”——比如按照时间戳生成的ID列表,大部分情况下就是升序的。如果基准值固定取第一个元素,这种近似有序数据会让快排反复陷入极端不平衡。
排查思路很简单:在排序前对数组做一次乱序预检测,或者干脆改为随机化基准。后者几乎免费地消灭了整个退化问题。
5.2 递归栈溢出:不只发生在数据量大的时候
很多人以为栈溢出只会在处理百万级大数据时出现,其实不然。只有当快排严重失衡、递归深度接近n时才会爆栈;也就是n=5万,最坏情况下递归深度也可能达到5万层。在很多系统默认的栈大小下,这个深度足以让程序崩溃。
生产环境里更好的预防手段,是我在前文代码展示过的那招:递归短侧、迭代长侧。这样可以保证递归深度最多接近log n,哪怕数据完全逆序,也永远不会有栈溢出的风险。这是我从实际项目里踩坑后总结的硬经验,建议所有写自研快排的同行都加上这个处理。
5.3 稳定性的“幽灵”:为什么相等的元素会乱序
快排不稳定的根源在于分区过程中会发生远距离交换。比如一组记录按照“时间”排序后,再按照“用户ID”排序,你希望相同用户ID的记录仍然保持时间的先后顺序。但快排的交换操作可能把后面的记录换到前面,破坏这个顺序。
如果业务对稳定性有硬性要求,有两个选择:一是使用稳定的归并排序或TimSort;二是给比较键附加次要字段(比如先比用户ID,再比时间戳),从源头上让“相等”不再真的相等。后者在工程里更常用,因为它不需要牺牲快排的速度优势,只需要在比较器上做文章。
5.4 来自实测的性能对比记录
最后分享一组我本地的测试数据。机器是日常开发用的笔记本,数据是随机生成的整数数组,时长取三次平均值。为了公平,所有版本都带了小区间插入排序优化:
| 数据量 | 单轴快排(三数取中) | 三向切分快排 | JDK Arrays.sort |
|---|---|---|---|
| 10万 | 12ms | 14ms | 9ms |
| 100万 | 95ms | 112ms | 78ms |
| 1000万 | 1.1s | 1.3s | 0.9s |
| 100万(大量重复) | 87ms | 22ms | 19ms |
可以看出三向切分在重复数据上的碾压性优势,以及JDK原生排序在综合场景下始终处于第一梯队的位置。自研快排在极致性能上想超越彻底调优过的库实现,难度极大。所以我的建议是:除非有特别苛刻的场景(比如嵌入式环境、特定数据分布、需要自定义内存策略),否则优先用标准库,把时间省下来研究业务问题。
快排最迷人的地方在于,看似简单的几十行代码,在极端数据分布面前能展现出完全不同的面貌。我自己也是在解决过几次线上排序性能问题后,才真正理解算法教材里那些“平均情况”“最坏情况”背后的现实分量。对这个话题感兴趣的同行,不妨拿一组生产环境里让你头疼的数据,自己动手实现一版快排,再把各种优化挨个加上去观察变化。这个过程里收获的细节,远比背住一遍复杂度推导来得实在。
