尧图精选

快速排序核心原理与优化实战:从分区算法到工程实现

🕒 发布时间:2026/9/10 10:01:06 📁 来源:尧图网络
1. 先理解快排为什么快分区思想才是灵魂很多人练快速排序第一反应是背代码。背下来不难但一两个月不用又忘得一干二净。我这些年带过不少新人发现一个规律凡是能从原理层面讲清楚快排的人代码怎么写都忘不了凡是靠背诵入门的人面试手写时往往卡在“递归边界到底怎么判断”这种最基础的地方。快速排序的核心思想用一句话说就是分而治之。具体拆开是两件事选一个基准值pivot把数组里所有比它小的放到左边所有比它大的放到右边。左右两个子数组分别重复这个过程直到子数组长度为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] pivotj-- 再从左往右找 arr[i] pivoti if i j: 交换 arr[i] 和 arr[j] 最后交换 arr[left] 和 arr[i]或arr[j]此时ij这里最容易搞错的地方是先从哪边移动。答案是基准选在左边就先移动右指针基准选在右边就先移动左指针。为什么为了保证最后相遇的那个位置存放的是一个“应该放到左边去”的值。如果基准在最左边而你先动了左指针可能导致最后把一个大值换到基准位置整个分区就白做了。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²)递归层次太深还会导致栈溢出。我遇到过不止一次有人兴冲冲拿快排去对一个近有序的大数组排序结果程序直接卡死或栈溢出然后跑来问我“代码是不是写错了”。代码没错是基准策略错了。工程上常用的几种改进策略随机基准值每次分区前随机交换一个元素到基准位置。这样最坏情况变成概率事件实际几乎不会发生。三数取中法取数组左端、中间、右端三个元素的中位数做基准。代码稍微多一点但效果稳定不用依赖随机数生成器。适合特定场景的取中策略比如数据量小的时候直接取中间值数据量大时先采样后再取中。从练习的角度我的建议是第一版务必用最简单的“固定取左端”先把分区和递归写对第二版改成“三数取中”感受性能差异第三版可以对比随机基准体会不同策略在不同数据分布下的表现。这样循序渐进去练你对快排的理解就不只是一个算法而是一整套“如何权衡最坏情况与平均情况”的思路。这里还要说一个反直觉的点三数取中看似稳妥但如果数组元素大量重复比如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 jwhile (arr[i] pivot) { i; }当数组某个位置的值持续pivot时i会一路加下去直接越过数组边界甚至跑到right之外。这种错误最坑的地方在于小数组偶尔能跑对大数组必崩或者结果莫名其妙错乱。解决办法内层循环条件必须同时满足i j和元素值与基准的大小关系。这是分区函数的“安全带”不能省。5.2 递归边界不统一导致无限递归递归边界有三种写法都可行但必须全篇统一左闭右闭quickSort(arr, 0, n-1)递归调用quickSort(arr, left, pivotIndex-1)和quickSort(arr, pivotIndex1, right)。左闭右开quickSort(arr, 0, n)递归调用quickSort(arr, left, pivotIndex)和quickSort(arr, pivotIndex1, 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数据结构来手动管理递归Stackint[] 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 从手写快排到理解标准库排序的进化路径我建议的练习路线是写递归版跑通随机数组、逆序数组、含重复元素数组。加上三数取中对比随机数组和有序数组的性能差异。加上小数组插入排序优化。实现三路分区测试大量重复元素场景。改写非递归版压测超大规模数组。去看Java的Arrays.sort()源码看你的优化和工业级实现的差距。走完这六步你对快排的理解已经不只是一道面试题而是一条完整的技术脉络。面试官无论怎么追问——复杂度分析、最坏情况、稳定性、优化策略、底层实现——你都能接得住。我自己当年也是从“死记硬背快排代码”起步到后来因为一次真实项目里的千万级数据排序性能问题才真正沉下心把快排的每一个变体都过了一遍。那次经历之后我最大的感触是**算法这东西只有当你亲手把它写飞过、写崩过、再救回来过才真正变成你的东西。**练习快排最大的收获不是会写这一个算法而是学会了“分区—递归—优化—权衡”这套解决问题的思维框架它能迁移到很多其他问题上。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →