ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

快速排序全攻略:原理、优化与工程实践

快速排序全攻略:原理、优化与工程实践 如果只能让我给新人推荐一个必须吃透的排序算法我会毫不犹豫地选快速排序。它不仅是面试题里的常客更是分治思想、递归、随机化乃至工程折中的一次集中体现。不管是C语言、Java还是JavaScript也无论是写一个纯前端表格的点击表头排序还是在MapReduce里给海量数据做分组排序你都会撞上快速排序的影子。这篇博文我会把快速排序从原理到实现、从优化到排错完整梳理一遍顺手把常见的排序算法对比、非递归写法、字符串排序这些高频问题一并讲透。1. 先搞清楚快速排序到底在“快”什么1.1 分治思想是骨架很多教材一上来就抛代码我反而建议先把思想嚼透。快速排序的核心是分治把一个数组拆成两部分左边所有元素都不大于某个基准值右边所有元素都不小于这个基准值然后对左右两部分递归地做同样的事。每一次分区至少有一个元素基准落到了最终位置剩下的问题规模就缩小了。这个思路和归并排序的分治有本质区别——归并排序的分治发生在递归下降过程合并发生在回溯过程而快速排序的核心工作量就在“分区”这一步递归返回时几乎什么都不用做。有人拿整理书架来类比你随手抽一本书当基准把小于它的书放左边大于它的放右边。这样基准书的位置就确定了剩下的书虽然还是乱的但规模变成了左右两摞而且互相不会越界。接着对每一摞重复同样的操作直到每摞只剩一两本。这就是快速排序最直观的画面。分治带来了一个非常漂亮的性质每一层递归处理的总数据量大约是O(n)而递归深度平均是O(log n)所以平均时间复杂度是O(n log n)。但这句话有个隐含前提——每次分区都要能把数组均匀切开。如果基准每次都挑到最大值或最小值递归深度就退化成O(n)总复杂度直接变成O(n^2)。这就是为什么基准选择是快速排序的命门后面我会专门展开。1.2 基准元素选择决定命运基准选谁直接决定快速排序的上限和下限。固定选首元素或尾元素实现最简单但一旦遇到有序或逆序输入每次分区都切出一块0和一块n-1复杂度直接爆炸。随机选基准通过随机化消除了对输入分布的依赖让任何输入的最坏情况都变成概率事件。这是工程中最常用的手段代价仅仅是取一次随机数。三数取中取首、中、尾三个元素的中位数做基准。对于近似有序的数据效果非常好能大幅减少最坏情况出现的概率也是很多教科书和工程库的标准做法。三数取中随机化组合兼顾了随机性和中位数的稳定性在极端场景下更稳但实现成本略高。我在代码里最常用的是“随机选基准”原因很简单代码改动最小效果立竿见影。三数取中在数据量小的时候优势不明显数据量大了以后又要消耗额外的比较和交换两者综合下来随机化是性价比最高的选择。不过也有例外——如果你明确知道数据是近似有序的三数取中会表现得更好。1.3 两种主流分区算法的对比分区是快速排序的执行核心这里有两个流派初学者经常搞混。Lomuto分区是教科书最爱以最后一个元素为基准用一个慢指针i和快指针j扫描数组遇到比基准小的就和i交换最后把基准换到i1位置。代码短、好理解但交换次数偏多。Hoare分区是原始版本两个指针分别从数组两端往中间走左边找比基准大的右边找比基准小的找到就交换直到两指针相遇。交换次数更少常数因子更优但边界条件容易写错递归边界也不能简单地用基准位置来切。从实际性能看Hoare分区要比Lomuto快20%左右尤其在数据量大的时候差异明显。从严谨性看Lomuto更不容易出边界bug适合学习阶段。我自己的实践是学习用Lomuto生产代码用Hoare面试时看情况选。如果你背熟了Lomuto但面试官追问Hoare的细节别慌把两指针相遇的图示画出来讲清楚就行。1.4 复杂度与稳定性一次说透快速排序的平均时间复杂度是O(n log n)最坏O(n^2)最好O(n log n)空间复杂度主要是递归栈平均O(log n)最坏O(n)。它的“不稳定”体现在一个典型场景键相同的元素在排序后相对位置可能改变。这点在排序对象只有数字时无感但当你对一组带业务信息的对象做排序、且后续还要依赖稳定的相对顺序时就得特别注意。需要区分的是稳定性是排序算法的固有属性不是“能用快排解决”的问题。工程上如果要求稳定排序一般会改用归并排序或插入排序的变体或者给元素追加序号作为次关键字把不稳定问题消解掉。我在做SQL排序需求时经常这么干先按主排序键排序如果存在相同键再按一个自增序号排这样用快排也能模拟出稳定效果。2. 多语言实现从教科书到工程代码2.1 C语言实现与指针踩坑先给一份教科书级别的C语言递归快排使用的就是Lomuto分区方便对比理解void swap(int* a, int* b) { int t *a; *a *b; *b t; } int partition(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 pi partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi 1, high); } }这段代码的坑集中在partition里。第一循环条件是j high而不是j high因为high本身就是基准不需要和自己比较第二i从low - 1开始确保第一个比基准小的元素也能正确交换第三最终交换的是arr[i1]和基准而不是arr[i]。这三个细节任何一个错位排序结果就会乱套。我见过很多新手在这三个地方来回翻车调试半天才发现是边界差了一位。工程上如果数据量极大我建议把递归改成循环或者把low和high用结构体打包传入防止函数参数太多导致栈帧过大。C语言写快排最需要注意的是指针和边界一旦越界轻则排序错误重则直接段错误。2.2 Java实现与Arrays.sort的底层差异Java里最常见的快排写法是用Arrays.sort但很多人不知道它内部的行为。Java对基本类型数组用的是Dual-Pivot QuickSort双轴快排这是对经典快排的改造一次选出两个基准把数组分成三段减少了比较次数和递归深度。而对对象数组用的是TimSort一种基于归并的稳定排序。这个底层差异意味着你对int[]排序和对Integer[]排序走的不是同一套算法。如果你需要自己实现快排我建议把比较逻辑抽象出来用Comparator传参这样一套代码就能处理整数、字符串、对象字段排序。下面是一个支持泛型的简洁版本import java.util.*; public class QuickSortT { public void sort(ListT list, Comparator? super T cmp) { sort(list, cmp, 0, list.size() - 1); } private void sort(ListT list, Comparator? super T cmp, int low, int high) { if (low high) return; int pi partition(list, cmp, low, high); sort(list, cmp, low, pi - 1); sort(list, cmp, pi 1, high); } private int partition(ListT list, Comparator? super T cmp, int low, int high) { T pivot list.get(high); int i low - 1; for (int j low; j high; j) { if (cmp.compare(list.get(j), pivot) 0) { i; Collections.swap(list, i, j); } } Collections.swap(list, i 1, high); return i 1; } }在Java里做排序优先用Arrays.sort和Collections.sort这两个方法经过数年优化性能远超手写版本。我见过的很多“手写快排比Arrays.sort快”的说法基本都是在小数据量或特殊数据分布下的巧合缺乏普适性。手写快排的意义在于理解原理、应对面试、以及在没有标准库的嵌入式环境里使用。2.3 JavaScript的排序玩法与点击表头场景JavaScript的Array.prototype.sort在不同引擎里实现差异很大。V8引擎早前用的是快排后来改成了TimSort原因是快排不稳定容易给前端排序带来诡异的bug。现在你写[5,3,1,2,4].sort()默认的排序规则是把元素转成字符串再比较所以[10, 9, 100].sort()会得到[10, 100, 9]而不是[9, 10, 100]。这个坑几乎每个前端都会踩一次解决办法永远是传比较函数。前端最常见的快速排序应用场景是表格的点击表头排序。我的实现思路是这样的function sortTable(data, key, direction) { const dir direction asc ? 1 : -1; return data.slice().sort((a, b) { const av a[key]; const bv b[key]; if (av null bv null) return 0; if (av null) return -dir; if (bv null) return dir; if (typeof av number typeof bv number) { return (av - bv) * dir; } return String(av).localeCompare(String(bv), zh-Hans-CN, { numeric: true }) * dir; }); }这段代码处理了三个常见问题空值排到最后、数字按数值排、中文字符串按拼音排。如果你直接拿字符串的、比较中文排序会按Unicode码点排跟用户预期的拼音顺序完全不同。localeCompare加numeric: true是我处理“字母数字组合的排序”的法宝比如[item2, item10, item1]会正确排成item1, item2, item10而不会排成item1, item10, item2。JavaScript里手写快排并不难难点在于理解内置sort的语义和引擎差异。我建议业务代码全用内置sort只有面试或者做算法题时才手写。2.4 非递归快排显式栈消除递归隐患递归快排虽好但当数据规模达到几十万以上、且遇到逆序输入时递归深度可能超过系统栈上限导致栈溢出。解决办法是把递归改为显式的栈模拟。思路很直接把(low, high)区间压栈每次弹出处理产生的新区间再压栈。下面给出一段C#风格的参考代码public void QuickSortIterative(int[] arr) { Stack(int left, int right) stack new Stack(int, int)(); stack.Push((0, arr.Length - 1)); while (stack.Count 0) { var (low, high) stack.Pop(); if (low high) continue; int pi Partition(arr, low, high); if (pi - 1 low) stack.Push((low, pi - 1)); if (pi 1 high) stack.Push((pi 1, high)); } }这段代码里有个细节先压左区间还是先压右区间不影响正确性但会影响栈的使用量。一般建议先压较大区间让小区间优先处理这样栈的深度能控制在O(log n)左右。这个技巧和递归版的尾递归优化异曲同工我是实际排查栈溢出问题时才深切体会到的。非递归版还有一个好处方便在分区后做额外处理比如对小区间直接切换插入排序或者统计每个区间的数据规模为动态负载均衡提供数据。如果你在做分布式排序这种可控的区间任务分配方式远比递归好调试。2.5 字符串与混合类型数据的排序技巧对字符串做快速排序最直接的方式是直接比较字符串大小。但字符串比较本身可能很耗时因为要逐字符比较。一个工程技巧是先按首字符分组再对组内做二次排序这样可以减少跨组比较。如果字符串很长、数量很多还可以考虑先算出每个字符串的哈希值按哈希排序后再对哈希冲突的组做精确比较。我在处理千万级别的日志字符串时采用过这个方案性能提升非常明显。字母数字组合的排序也有坑。比如身份证号、订单号这类字符串按字典序排和按数值序排是完全不同的结果。前端处理表格排序时我一般会先判断每列的数据类型全数字就按数值排混合字母数字就按自然排序规则排纯中文就按拼音排。这个判断逻辑放在一个公共函数里其他业务都能复用。混合类型数据排在Java中使用Comparator就能优雅解决JavaScript则要小心不同类型比较时隐式类型转换带来的诡异结果。我的原则是排序前先统一类型排序中显式指定比较规则排序后做一次完整性校验。类型统一这一步看着简单出错率却非常高尤其当你拿到的数据来自多个表或多种接口时。3. 性能优化的三板斧真正让快排脱胎换骨3.1 小数组切换插入排序任何排序算法都有适用规模。快速排序在小区间上的递归调用和分区开销超过了插入排序的直接比较损耗。所以工程实现里几乎都有一个阈值数据量小于某个值常见取10到20时改用插入排序。这个优化在数据量大时效果尤其明显——递归树的底部有大量小区间每个区间省掉一次分区调用累计起来能减少大约10%到15%的排序时间。我自己测试过阈值取8到16之间差别不大但取32以上反而可能变慢。具体原因和Java即时编译器的优化策略有关不能一概而论。如果你想找最优阈值可以针对目标数据规模做一个简单的基准测试一般在10到20之间选一个整数就行。这个优化思路不仅适用于递归版非递归版同样可以加。每弹出一个区间先判断区间大小小于阈值就切换到插入排序。这样写虽然代码略复杂但性能收益实打实。3.2 三数取中和随机化的正确姿势三数取中的实现不复杂取首、中、尾三个位置的元素找出中位数把它和最后一个元素交换然后继续执行标准分区。这样能避免有序数组下首元素做基准的悲剧还能让基准更接近真正的中位数。三数取中在近似有序数据上效果很好但在随机数据上提升有限因为随机数据下任何基准的期望表现都差不多。随机化的正确姿势是每次分区前把基准元素与中间某个随机位置的元素交换。注意这里有个容易踩的坑如果你用固定随机种子那么在每次运行得到相同排序结果的同时也失去了随机化的意义——因为最坏情况可能总是对应同一个输入模式。我一般用当前时间作为种子而不是写死一个常量。从理论上讲随机化能让快速排序在任意输入上的最坏情况概率变得极低。但实际工程里随机数生成本身也有开销。如果数据已经保证是随机的或者来自实时流有时不随机化反而更快。这个取舍没有标准答案我的经验是数据来源不可控就用随机化数据来源可控就先用三数取中性能不够再考虑随机化。3.3 三路快排解决重复元素的顽疾标准快排在大量重复元素面前会严重性能退化。想象一个数组全是同一个值固定选最后一个元素做基准分区后左边是所有其他元素右边是0个递归深度直接O(n)整体复杂度变成O(n^2)。解决思路是三分区把数组分成小于基准、等于基准、大于基准三段。等于基准的元素原地不动只递归处理小于段和大于段。三路快排在处理重复数据时的性能提升是数量级的。数据全是相同值时一趟分区后左右区间都为空排序立即结束复杂度降到O(n)。这个算法在Java的Arrays.sort对基本类型的实现中也用到了类似思路。我处理过几千万条只有几十个不同取值的业务数据时用三路快排的方案比普通快排快了近十倍。3.4 从快排到双轴快排双轴快排是Java 7之后Arrays.sort对基本类型数组的默认实现。它一次选出两个基准把数组分成三段理论上可以把比较次数减少三分之一左右。双轴快排并非在所有场景下都优于单轴快排但它对数据分布的适应性更强。我自己没有生产环境手写双轴快排的需求因为标准库已经优化得足够好。但如果你在做算法竞赛或底层库理解双轴快排的分区细节非常有价值。双轴快排的实现难点在于两个基准的选取和分区结束后的递归区间划分代码长度几乎是单轴的两倍。如果你不是必须自己实现建议直接用标准库。真正重要的不是记住双轴快排的每一行代码而是理解它为什么比单轴快——减少递归深度、每次分区更多元素落位、缓存命中率更好。4. 实际场景选型与应用经验4.1 数据库排序与快排思想的渗透MySQL的ORDER BY底层实现会根据数据量和索引情况选择排序策略。如果是内存排序MySQL会用优先队列或快速排序的思路如果数据量超过缓冲区会退化成归并排序写临时文件。SQL Server的分组排序、组内排序本质上也是在排序后做窗口函数计算比如ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC)就是先在分区内排序再赋予行号。这里我想强调一个工程认知从外部看快排是一种算法从内部看很多数据库的排序实现都融合了快排和归并的思想。比如MySQL的filesort算法里有一个阶段叫做“快速排序优化”在内存排序部分用的就是类快排算法。理解这一点你就能明白为什么数据库排序在小数据集上非常快而一旦结果集超过缓冲区性能会断崖式下跌——因为算法切换了。我在实际SQL调优时看到一个慢查询是因为全表排序第一步不是优化排序算法而是尝试减少排序的数据量。加索引把排序下推到索引扫描阶段或者改分批查询往往会比任何排序算法层面的优化都见效。4.2 前端点击表头排序背后的取舍点击表头排序是快速排序思想在业务代码里最常见的落地场景。前端排序的难点不在算法本身而在交互体验。比如用户点击表头期望的是瞬间完成排序而不是看到卡顿。数据量在几千条以内时不管用什么排序算法都感觉不到差别但数据量上万甚至几万条时排序性能和渲染性能就开始撕裂了。我处理表格排序一般分三步第一步判断数据规模几千条直接前端排序加虚拟滚动几万条以上考虑后端排序每次只传当前页数据第三种折中方案是前端做一次缓存排序把排序后的数据保存起来后续翻页直接取缓存。这三种方案的选型依据是数据量和交互频率而不是算法优劣。前端排序还有一个容易被忽略的细节排序稳定性。如果你先按时间排序再按部门排序稳定排序会让同一部门内的数据保持按时间排序的相对顺序。JS的Array.prototype.sort在V8里现在是稳定排序但早期不是。所以写代码时不要依赖引擎行为需要对稳定性有明确需求时就手动追加次关键字。4.3 大数据排序MapReduce中的快速排序思想在大数据场景下单机排序不够用了但快排的思想依然被广泛使用。MapReduce的排序流程是Map端把数据写成带键值对的记录经分区器分到不同Reduce任务每个Reduce任务收到数据后先做内存排序再用归并输出。这个内存排序的底层不同实现会选择快排或堆排序。头歌上那些“MapReduce排序—分组排序”“倒排序索引”的关卡本质上就是在练这些流程的细节。我自己在调MapReduce作业时最常见的性能问题不是排序本身而是分区不均导致某些Reduce任务数据量极大。快排在这里的核心启示是一次排序把数据分成多个互不干扰的区间分布式系统里的分区思想其实和快排的分区一脉相承。所以学好快排不仅仅是学会一个算法而是学会“分而治之”这种解决大规模问题的通用策略。在流处理或其他大数据框架里类似Kafka的分区策略、数据库的分库分表逻辑背后都有分区思想的影子。快速排序在这里更像是思维训练帮助你建立拆分问题的直觉。4.4 从八大排序算法对比看选型思路网上流行的“八大排序算法总结”通常包括冒泡、选择、插入、希尔、归并、快速、堆排序、基数排序。选型时不能只看时间复杂度还要考虑稳定性、空间复杂度、常数因子、数据特征。排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序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^1.3)O(n^2)O(1)不稳定归并排序O(n log n)O(n log n)O(n)稳定快速排序O(n log n)O(n^2)O(log n)不稳定堆排序O(n log n)O(n log n)O(1)不稳定基数排序O(d(nk))O(d(nk))O(nk)稳定这个表里没有绝对最优解。数据量小用插入排序最快数据量大且随机用快排要求稳定用归并内存紧张用堆排序数据范围小用基数排序。CLRS里对选择排序循环不变量的证明可以用来体会算法正确性的形式化论证但对选型并没有直接帮助——工程选型永远先看数据特征和业务约束再看理论复杂度。我在面试中常问候选人的一个问题就是如果要对一个几乎有序的数组排序你会用什么答案是插入排序因为它的最好情况复杂度是O(n)。很多候选人第一反应是快排正因为没意识到数据特征决定了算法选择。4.5 特殊排序问题拓扑排序和三值排序有人会把拓扑排序混进排序算法里其实拓扑排序解决的是有向无环图中的先后依赖问题跟比较排序完全不同。它在工程里的典型场景是构建系统里的依赖解析哪些模块必须先编译哪些可以并行编译。虽然都叫排序但图算法和比较排序的思维方式差异极大。三值排序是USACO里的一道经典题给定只有三个值的数组要在O(n)时间内排序。思路是一次扫描用三个指针把0、1、2分别归位。这个题的工程价值在于当数据的取值域极小且已知时计数排序或三指针扫描远比快速排序高效。这提醒我们一个常被忽略的原则快速排序是通用算法但永远有更适配特定场景的特化算法存在。我自己做SQL调优时也遇到过类似的场景某个字段只有两个取值但查询计划里出现了排序操作。我强制改用了分组聚合和条件统计绕过了排序查询时间从秒级降到了毫秒级。排序本身不可怕可怕的是默认要用通用方案解决所有问题。5. 常见问题排查与实操避坑5.1 递归深度与栈溢出快速排序最常见的线上事故就是Stack Overflow。特征非常明显数据量大、输入有序或近似有序、选用固定基准。排查思路第一是看数据分布第二是看代码写法。递归深度是O(n)时百万级数据就足以把系统栈压爆。我的规避方案是三层防线第一层基准选择用三数取中或随机化避免有序输入直接命中最坏情况第二层开启非递归实现把递归栈搬到堆上彻底规避系统栈限制第三层在递归函数开头加一个区间规模判断小区间直接切插入排序减少递归深度。三层叠加后即使数据分布再恶劣栈溢出的概率也非常低。5.2 大量重复元素导致性能退化重复元素的性能退化不像栈溢出那么好识别因为排序结果看起来是正确的但耗时从几十毫秒涨到几十秒。复现方法是构造一个全是相同值的数组做基准测试。标准快排和随机化基准快排都会性能退化三路快排则是这个场景的官方解药。除此之外还有一个经验如果重复元素比例很高可以考虑先做一次数据压缩把每个唯一值连同出现次数记录下来对唯一值排序后再展开。这个方法在业务报表排序中特别实用比如统计每个城市的订单量城市名只有几百个但数据行有几千万。先聚合后排序复杂度直接从O(n log n)降到O(k log k)加O(n)k远小于n时效果惊人。5.3 逆序数据的极端情况逆序数组对固定选首元素的快排来说是最标准的最坏情况但对三数取中就能轻松化解。排查逆序输入的有效办法是专门构造一个逆序数组跑一次排序观察递归深度和耗时。如果耗时异常第一检查基准选择第二检查递归条件。这里还要注意一个被忽视的点分区后的边界处理。标准写法是递归调用时左区间取(low, pi-1)右区间取(pi1, high)。如果你把基准位置也包含进递归区间会在极端情况下造成死循环或无限递归。我排查过一个线上死循环事故最后发现就是边界写错导致区间无法缩小。5.4 快排正确性与性能验证三板斧写完快排后怎么确认它正确且够快我推荐三套验证方案。第一正确性验证准备几组特殊数据——空数组、单元素数组、有序数组、逆序数组、全部相同元素数组、随机大数组。每组都跑一遍然后和后端接口返回的排序结果对比。特殊数据覆盖了快排最容易出错的边界随机大数组则验证常规路径。第二性能基准测试对比手写快排和标准库排序在同一组数据上的耗时。数据量从一万到一百万递进观察曲线是否接近O(n log n)。如果百万级数据耗时不是十毫秒到几十毫秒级别就需要检查分区函数的常数因子。如果曲线明显上翘说明遇到了最坏情况检查基准选择。第三内存监控用工具观察排序过程中的内存变化。快排的空间复杂度是O(log n)内存应该平稳。如果内存突然飙升可能是递归过深也可能是数据拷贝过多。内存变化曲线是排查性能问题的重要线索比单纯看时间更可靠。5.5 排序问题的调试技巧调试排序算法我最常用的工具就是打印。在partition的入口打印当前区间和基准值在出口打印分区后的数组。打印几轮之后能直观看到分区是否符合预期。特别是Hoare分区两个指针的相遇条件非常容易错打印一次交换过程立刻就能定位问题。另一种高效调试方式是写一个排序正确性断言工具排序完成后遍历数组一遍检查后一个元素是否始终不小于前一个。这个断言在单元测试里加上会帮你拦截掉大量回归问题。我用这个断言工具顺手排查过SQL Server分组后组内排序序号错乱的问题——本质是排序键不唯一导致顺序不稳定。调试的经验之谈是不要只盯着排序结果看要看中间状态。排序是过程性的算法结果对了不代表过程对了。过程错了但在某些输入上恰好结果对才是最隐蔽的坑。6. 最后分享一点个人体会我在实际项目里被快速排序“坑”过两次一次是百万级有序数据导致递归栈溢出一次是海量重复值导致耗时暴增。这两次事故让我彻底明白算法不是背下来就完事的必须理解它的适用边界和失效模式。快速排序的威力建立在基准选择的合理性和数据分布的多样性之上一旦这两个前提被打破它就会从最快的排序变成最慢的排序之一。如果你现在正在学排序算法我建议动手把每个算法都实现一遍然后跑一组相同的数据做对比记录下每次的耗时和递归深度。这个记录过程比你看任何博客都有效因为你会亲眼看到理论上的复杂度和实际运行之间的鸿沟与关联。排序算法是少数几个“代码就几十行、但足够你琢磨一辈子的细节”的主题快速排序更是其中最有代表性的一个。
返回列表