尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
快速排序核心原理与工程优化实战:分区、基准选择与避坑指南
很多人学完快速排序第一反应是“代码背下来了但让我自己写一遍却总觉得哪里不对尤其是分区那一步稍微改动一个边界条件数组就越界或者死循环”。还有人问得最多的是“网上快排版本这么多到底该记哪个面试写哪个最好”这篇文章就围绕快速排序这个经典算法把核心设计思路、分区实现细节、不同基准选择策略、常见优化手段以及我实际调代码时踩过的坑一次性讲透。不管你是刚学数据结构的本科生还是准备面试、想看工业级实现细节的开发者这篇文章都按“原理 - 手写实现 - 优化 - 避坑”的顺序来展开你可以直接对照着写代码、跑测试。1. 快速排序的整体设计思路快速排序能成为教科书级的经典算法核心不是“快”这个结果而是它的分治策略极其干净选一个基准值把数组分成“小于等于基准”和“大于等于基准”两半然后再对两半分别递归排序。这个过程中真正的难点和性能瓶颈都集中在一个动作上分区partition。1.1 分治思想与归并排序的差异很多初学者会拿快速排序和归并排序对比。归并排序的分治是“先递归、再合并”它的核心工作在合并阶段需要额外的O(n)空间把两个有序数组合并成一个有序数组过程中元素相对顺序稳定但空间占用是硬伤。快速排序的分治是“先分区、再递归”核心工作在分区阶段不需要额外的合并数组。基准值一旦选好、分区完成这个基准值的位置就是最终位置后续不会再变动。这是快速排序“原地排序”的根源也是它空间复杂度能做到O(log n)递归栈甚至O(1)非递归的原因。换句话说归并排序是把排序问题拆成“排好左边、排好右边、然后合并”快速排序是“选个老大比他小的站左边比他大的站右边然后把左边和右边各自再按这个逻辑排一遍”。1.2 为什么分区是核心中的核心快速排序的平均时间复杂度是O(n log n)但这个“平均”很微妙它建立在每次分区都能把数组大致对半分的假设上。如果你的分区代码写出来基准值总是偏到一头那递归树的高度就从O(log n)退化成了O(n)整体复杂度直接变成O(n^2)。所以快速排序的性能优劣本质上不取决于递归写得多漂亮而取决于两个问题基准值怎么选选得好两边均匀递归深度小。分区过程怎么移动指针移动方法不同边界条件完全不一样写错就是死循环或越界。这两个问题是快速排序的全部核心。以下内容我分两部分详细拆开讲。2. 基准选取策略与分区实现细节很多人写的快速排序版本直接固定取数组最后一个元素或第一个元素做基准。不是说不能这样写而是这样写有一个明显的性能缺陷如果输入数组已经有序固定取首元素或尾元素每次分区都极不均匀递归深度变成n快速排序直接退化成冒泡排序的复杂度。实际工程里数据“接近有序”是常态而不是例外。2.1 基准选取的三种常见策略我在不同场景下分别用过以下三种策略说下它们的取舍。固定位置取基准直接取第一个元素或最后一个元素代码最简单一个赋值搞定。适合理解算法逻辑也适合写教学示例。但遇到有序数组或几乎有序的数组时性能会急剧恶化。如果在随机数据上跑效果还不错所以很多算法教材拿它做默认实现。随机选取基准在数组中随机挑一个索引作为基准然后把它交换到端点位置再执行正常分区。这种做法能大幅降低遇到最坏情况的概率。因为输入数据千变万化我每次随机选一个就很难每次都不巧选中最大或最小值。随机化的代价是额外的一次随机数生成和一次交换但对整体性能影响极小。实际测试中对一个10万元素的近似有序数组固定取末尾基准的快排跑了接近8秒随机基准版本只跑了0.03秒。这个量级的差距直接决定了一个排序函数能不能用在线上环境。三数取中取数组的左端、中间、右端三个位置上的元素找出三个值中大小居中的那个把它交换到端点再分区。它比随机基准多了一定的确定性实测在接近有序和随机数据的综合表现都很好而且不需要调用随机数生成器不会引入随机性的不确定抖动。工程上很多排序库默认使用这种策略。三数取中的实现能顺带做一个优化选完中值后把三个数中的最小值放到数组开头最大值放到数组末尾中值放到倒数第二个位置。这样在后续分区时已经有一部分元素落在了正确的位置区域能稍微减少比较次数。不过这属于微优化优先级不高。从我的经验看如果只选一种策略记就记三数取中如果只要一个最稳定的版本就记随机基准。两种都掌握面试时提到“避免最坏情况”的思路就有了。2.2 分区算法的两种流派单指针扫描与双指针相向扫描分区是八十个易错点集中爆发的地方。我先把两种最主流的写法讲清楚。单指针扫描Lomuto分区思路是选最后一个元素做基准用指针i标记“小于等于基准的区间边界”用指针j从头扫描到倒数第二个元素。每当遇到小于等于基准的元素就把i向前挪一格然后把i位置和j位置的元素交换。扫描结束后i的位置就是基准的正确位置把基准和i位置交换即可。这种写法非常直观一个for循环就能完成适合学习和写简洁代码。但它有一个缺点交换次数偏多。当大量元素小于等于基准时几乎每个元素都要被交换一次虽然时间复杂度还是O(n)但常数系数比双指针法要大。双指针相向扫描Hoare分区思路是把基准值存下来左指针从数组左端向右找“大于等于基准”的元素右指针从数组右端向左找“小于等于基准”的元素找到后两者交换。两个指针持续向中间移动直到左指针超过右指针然后以右指针或左指针为界切分数组。这种写法交换次数少是快速排序最初的发明者使用的方案也是很多高级优化版本的基础。但它的边界条件极其微妙容易写错主要体现在指针移动和交换的先后顺序不同最终返回的分界点就不同。如果数组中存在大量重复元素用“大于”还是“大于等于”作为移动条件会直接影响分区是否均衡。递归调用时到底以left和right还是pivot和right作为边界需要仔细推敲。我的建议是初学阶段先从Lomuto分区开始把逻辑捋顺了再切换到Hoare分区。不要一上来就啃Hoare容易劝退。三路分区当数组中重复元素很多时不管是Lomuto还是Hoare都可能把大量等于基准的元素分散到左右两侧导致分区不均衡。一种改进是把数组分成三块小于基准、等于基准、大于基准。这样等于基准的元素不用参与后续递归当重复元素很多时性能提升明显。Java的排序库中对含大量重复元素的情况就是用了类似双基准加三路分区的思路。3. 手写快速排序从最简实现到工业级优化很多教程贴一段代码就完事我这次把三个版本的完整代码以及背后的设计取舍都写清楚你再结合自己的场景选择合适的版本去用。3.1 5分钟写一个最简递归版本先看Lomuto分区的递归版本这是我建议所有初学者第一个掌握的版本def quicksort_lomuto(arr, left, right): if left right: return pivot_index partition_lomuto(arr, left, right) quicksort_lomuto(arr, left, pivot_index - 1) quicksort_lomuto(arr, pivot_index 1, right) def partition_lomuto(arr, left, right): pivot arr[right] i left - 1 for j in range(left, right): if arr[j] pivot: i 1 arr[i], arr[j] arr[j], arr[i] arr[i 1], arr[right] arr[right], arr[i 1] return i 1我刚学的时候总会问为什么i的初始值是left - 1而不是left因为整个数组中“已处理的小于等于基准的区域”一开始是空的所以i要设置在数组左边界的前面一格。每发现一个符合要求的元素i先自增再交换。最后i 1的位置就是基准值应该待的最终位置。这段代码的递归结束条件是left right也就是区间为空或只有一个元素时不再递归。这部分好理解只要你把“区间”想成是闭区间[left, right]就一切正常。3.2 三数取中优化版本接下来是配合三数取中策略的Lomuto分区版本这个版本在面试和工程中都很实用def median_of_three(arr, left, right): mid (left right) // 2 if arr[mid] arr[left]: arr[left], arr[mid] arr[mid], arr[left] if arr[right] arr[left]: arr[left], arr[right] arr[right], arr[left] if arr[right] arr[mid]: arr[mid], arr[right] arr[right], arr[mid] return arr[mid] def quicksort_median(arr, left, right): if left right: return if right - left 1 16: insertion_sort(arr, left, right) return median median_of_three(arr, left, right) # 把中值交换到最后一个位置方便复用Lomuto分区逻辑 arr[right], arr[median_index(arr, left, right)] arr[right], arr[median_index(arr, left, right)] pivot_index partition_lomuto(arr, left, right) quicksort_median(arr, left, pivot_index - 1) quicksort_median(arr, pivot_index 1, right)这里有一段代码需要特别留意当子数组长度小于某个阈值时我直接改用插入排序不再递归。这是工业级排序实现中常用的优化手段原因后面会专门讲。上面的median_index函数需要你根据三数取中的交换逻辑额外返回基准值所在的下标我这里为了展示主流程没有把它内部细节展开。完整实现时你需要让median_of_three同时返回中值的值和下标。实操中有一个小技巧在做三数取中时既然已经知道三个数中最大值和最小值可以直接把它们分别放到当前区间的左右两端。每次递归时子区间的两端已经是局部最小和最大这能减少分区过程中的元素比较和交换次数。这种优化虽然效果不算特别大但在追求极致性能的排序库里确实能看到类似思想。3.3 非递归版本用显式栈模拟递归递归版本代码简洁但有一个隐患当数据规模极大且分区极端不均衡时递归深度可能非常大导致函数调用栈溢出。虽然实际开发中很难碰到这种极端情况但作为工程备选方案非递归版本能让你完全避开栈溢出的问题。非递归的核心思路是自己维护一个栈用(start, end)这样的二元组表示待排序区间。每次从栈中弹出一个区间分区后如果左子区间长度大于1就压栈右子区间同理。直到栈为空排序结束。def quicksort_iterative(arr): if len(arr) 1: return arr stack [(0, len(arr) - 1)] while stack: left, right stack.pop() if left right: continue pivot_index partition_lomuto(arr, left, right) if pivot_index - 1 left: stack.append((left, pivot_index - 1)) if pivot_index 1 right: stack.append((pivot_index 1, right)) return arr这种写法有个隐含的策略选择先把较长的子区间压栈还是先把较短的压栈如果偏爱“栈深度更浅”应该先压较长的子区间这样栈中同时保存的区间数量会更少。如果先压较短区间栈的峰值深度可能更大但实际影响一般也看不出来。我习惯先压右侧较长的再压左侧这样左侧优先被处理后续栈内元素更少。非递归的本质不是消灭了分治逻辑而是把系统调用栈换成了自己管理的数据结构。整个排序的基本流程没有变但是没有了函数层层调用的开销。在极端情况下显式栈确实比递归更加可控。4. 性能开销分析与工程优化技巧快速排序的性能不是一句话能讲完的它的实际表现取决于输入数据的分布、基准选择策略和分区实现细节。这里我不列复杂的数学公式而是直接从实测结果出发讲清楚几个关键结论。4.1 时间复杂度与递归深度的关系理想情况下每次分区都把区间一分为二递归深度是O(log n)总比较次数是O(n log n)。最坏情况下每次分区只把一个元素放到正确位置剩下n-1个元素还在一个区间里递归深度变成O(n)总比较次数退化成O(n^2)。随机化和三数取中策略的存在意义就是把“最坏情况”出现的概率降到极低。很多人忽略的是空间复杂度递归版本需要O(log n)的调用栈空间非递归版本使用显式栈也是同样量级。原地排序并不等于没有额外空间占用这点需要区分清楚。4.2 小数组切换到插入排序的阈值选择你对一个只有10个元素的子数组继续递归调用快排并不划算。快排的递归调用和分区操作都有固定开销而插入排序对接近有序的小数组极其高效常数因子非常小。所以工业级排序实现里通常会在数组规模低于某个阈值时切换到插入排序。这个阈值到底取多少各版本不完全一样。我实测过从8到32不等阈值16左右效果相对均衡。注意这个参数不是越大越好如果阈值太大小数组全用插入排序而插入排序对随机大数组是O(n^2)的反而拖慢整体速度。代码实现上就是把递归版本的第一步判断改一下if right - left 1 16: insertion_sort(arr, left, right) return插入排序的代码本身也很简洁但要保证仅处理指定区间不要越界def insertion_sort(arr, left, right): for i in range(left 1, right 1): key arr[i] j i - 1 while j left and arr[j] key: arr[j 1] arr[j] j - 1 arr[j 1] key光这个优化我在10万元素的随机数组上实测能带来15%到25%左右的性能提升。原因是减少了大量小规模递归的函数调用开销。4.3 警惕特殊数据重复元素过多的处理如果数组里有大量重复元素比如10万个元素全是同一个值标准的Lomuto分区会退化成什么状态每次分区基准值等于所有元素扫描一遍后所有元素都堆到基准的同一侧递归深度直接变成n复杂度O(n^2)。这种数据在真实业务中完全可能碰到比如用户状态字段的枚举值。应对方案有三种第一种是随机化基准但这种情况下基准再怎么随机也都是同一个值没有用第二种是双指针从两端向中间扫描让相等的元素尽量被交换到两侧对称的位置第三种是使用三路分区把等于基准值的元素集中到中间完全不参与后续递归。def partition_three_way(arr, left, right): pivot arr[right] less left greater right i left while i greater: if arr[i] pivot: arr[less], arr[i] arr[i], arr[less] less 1 i 1 elif arr[i] pivot: arr[i], arr[greater] arr[greater], arr[i] greater - 1 else: i 1 return less, greater三路分区的核心维护三个区域小于基准的区域、等于基准的区域、未扫描区域。less指向小于基准的边界greater指向大于基准的边界i遍历未扫描区域。每遇到一个小于基准的元素就把它放进小于区遇到大于基准的元素就把它丢到大于区且不推进i因为换过来的元素还没有被检查过。这个版本在处理大量重复元素的场景下时间复杂度从接近O(n^2)直接拉回O(n log n)而且等于基准的部分越多实际性能越好。面试中如果被问到“如果数组中有大量重复元素快排会不会退化”能当场写出三路分区是非常加分的行为。4.4 不得不知道的排序江湖快排与TimSort、堆排的关系快速排序不是万能的排序方案。工程实际中很多语言内置的排序算法并不是单纯的快排而是多种排序算法的组合。比如某些环境下的对象排序用的是归并排序的优化版本某些环境下用的是双基准快速排序。这是考虑到稳定性和数据分布特征的综合选择。快速排序不适合需要稳定排序的场景因为分区过程会跨越式地交换元素相同元素的相对顺序无法保证。如果业务上要求按多个字段连续排序且后一个字段不能打乱前一个字段的相对顺序你就需要稳定的排序算法。稳定性这个特性归并排序天生具备快速排序天生不具备。那为什么快排还有如此高的地位因为它平均性能好、原地排序省内存、缓存友好性佳。在大数据量、内存宝贵的服务器环境中这些特性都极其值钱。世界上不存在一个绝对最优的排序算法只有针对场景和约束的最合适方案。5. 快速排序常见问题与排查实录这里记录的是我实际写快速排序代码时踩过的坑以及我帮别人排查过的典型问题。这些问题如果你不看自己调试可能要花不少时间。5.1 死循环问题等号造成的分区失效我最早写Hoare分区时写出过一个死循环版本# 错误示例 while arr[i] pivot: i 1 while arr[j] pivot: j - 1两个指针指向等于基准的元素时不会进入内层循环然后交换。如果两个指针都指向等于基准的元素交换后两个值一模一样指针没有前进再次判断还是相等再次交换无限循环下去。解决办法是至少有一侧循环条件带等号或者在交换后强制移动指针while arr[i] pivot: i 1 while arr[j] pivot: j - 1 if i j: break arr[i], arr[j] arr[j], arr[i] i 1 j - 1交换后强制移动指针才能保证每次循环至少推进一格循环必然结束。这个细节非常重要。5.2 越界问题递归边界没有收干净Lomuto分区返回的基准位置pivot_index处理左子区间时使用(left, pivot_index - 1)右子区间使用(pivot_index 1, right)。如果这里不小心把pivot_index本身又包含进去了基准元素会被反复参与排序不过由于Lomuto分区中基准在分区后已经在正确位置再递归进去会导致逻辑错乱。更常见的问题是递归结束条件写错导致空区间或单元素区间也继续递归最终越界。可以用一个极端小数据手工走一遍。比如数组[2, 1]分区后基准位置返回0或1左子区间或右子区间至少一个为空。你按代码逻辑推一遍如果递归调用时传给了一个空区间且没有结束条件拦截肯定就出界了。5.3 性能退化时的定位方法如果你实现的快排排序一个10万元素的随机数组需要好几秒大概率是分区不均衡导致的。定位方法很简单在递归函数入口打印当前区间的长度观察打印出来的数值是否快速收敛到1。如果经常出现特别长的区间说明基准选择策略有问题。改用三数取中后绝大多数场景下递归深度都会维持在接近log2 n的水平。对100万元素的数组理想深度在20左右。如果递归深度显著超过这个值就得检查基准选取了。5.4 稳定性问题的实际案例有个朋友做排行榜查询要求相同分数的用户按注册时间升序排列。他直接用快排对分数排序结果相同分数的用户顺序每次跑都不一样后来换成稳定排序算法才解决。这不是快排的bug而是它的特性决定的。分区过程中的元素交换是无规则跨越式的两个相同分数的用户谁先被交换到前面完全是随机的和它们在原始数组中的先后顺序没有固定关系。所以当你明确需要稳定性时不要硬用快排。快速排序的原则是“不保证稳定”这与归并排序有本质区别。认清算法的边界避免强行使用是合格开发者的基本素质。5.5 一个容易忽略的问题对称但不等价的边界写法网上很多快排代码看起来只有一两行的差别但如果分区实现不同递归边界是完全不能互换的。某天你看到一段Hoare分区代码它返回的索引不是基准的最终位置而是一个分界点比如所有小于等于基准的元素都落在左半部分大于等于基准落在右半部分。此时递归就不能用pivot_index - 1和pivot_index 1来切分因为基准可能就在分界点附近但并没有确定落到某一个固定位置。这种代码有两种常见处理quicksort(arr, left, pivot_index) quicksort(arr, pivot_index 1, right)或者quicksort(arr, left, pivot_index - 1) quicksort(arr, pivot_index, right)区别取决于分区实现返回的到底是“左侧最后一个小于等于基准的位置”还是“右侧第一个大于等于基准的位置”。如果你从网上复制了一个分区函数却没注意它配套的递归边界写法那十有八九会死循环或越界。我建议你选定一个版本后用[5, 2, 3, 1, 4]这样的数据把分区过程每一步都打印出来自己走一遍彻底吃透这个版本再迁移。6. 双基准快速排序站在巨人的肩膀上看快排最后再扩展一个工业级方案。某些成熟的排序库中采用的不是单基准快排而是双基准快速排序。它选取两个基准p1和p2p1小于p2分区后数组被分成三块小于p1、介于p1和p2之间、大于p2。然后对三块区域分别递归排序。双基准的优势在于每轮分区后两个基准值都已经就位而且数组被分成三段递归时的子问题更小。当数据随机分布时双基准快排平均比较次数比单基准更少对缓存也更加友好。但它的实现复杂度明显更高两个基准的选取、三指针的移动、各种边界判断写起来非常容易出错。如果你已经能把单基准快排的自选基准、分区、递归边界都讲清楚理解双基准就是水到渠成的事。面试时主动提到双基准快排的存在和大致思路能体现你对排序算法的横向视野这是加分项。但日常手写代码不建议首选双基准来表达毕竟在45分钟内写一个正确无误的双基准实现比自己浪费时间要重要得多。快速排序这个题目看起来简单但它能同时考察编码基本功、边界条件思维、复杂度分析和实际工程优化意识所以深度非常够。我自己每次重新实现快排都能发现之前忽略的细节这种“越写越有意思”的感觉也许就是算法的魅力所在。最后分享一个小习惯我写完任何排序算法的第一件事不是拿大数组测性能而是拿[5, 4, 3, 2, 1]、[1, 1, 1, 1]、[1]这种极端小数组手动跑一遍确认边界逻辑没问题。这能省下后面大量调试时间。你在自己练习的时候也可以按这个思路建立一套快速自检流程。
RELATED

相关推荐

rea三步法:把读过变成用得上,告别收藏夹焦虑

rea三步法:把读过变成用得上,告别收藏夹焦虑

事情要从我浏览器书签栏里那七千多条收藏说起。某天想找一篇半年前读过的关于分布式事务的文章,翻了三个子目录,点坏了不知道多少下滚轮,最后打开的那篇,标题眼熟、内容陌生——我明明把它存了下来,却等于从没见过。那…

📅 2026/10/20 4:14:24
AI应用测试全攻略:五大核心维度与实战避坑指南

AI应用测试全攻略:五大核心维度与实战避坑指南

做了好几年AI应用的交付,我最大的感受就是:AI应用测试不是“上线前补一个环节”,而是一条独立的、需要提前设计的工程主线。很多团队在模型效果上卷得很凶,但对测试的投入往往停留在“跑几个用例看看回答对不对”,结果…

📅 2026/10/20 4:09:24
Excel重复项筛选与删除:两种方法详解及避坑指南

Excel重复项筛选与删除:两种方法详解及避坑指南

做数据清洗的时候,最常被问到的问题就是“Excel里怎么把重复项筛出来再删掉”。这个需求看着简单,真做起来翻车率特别高,有人删完发现数据少了一大截,有人把不该删的重复值全抹了,还有人压根就没找全。这篇就好好聊聊筛…

📅 2026/10/20 4:09:24
MORE NEWS

更多资讯

📰

发电厂配电室巡检机器人落地:从试点到标配的五项关键经验

上个月我出差去某发电厂交流,走进配电室,迎面一台巡检机器人正沿着轨道慢慢滑过,摄像头左右转动,红外镜头对着开关柜一台一台扫过去。我站在那儿看了好一会儿,想起五年前这个集团刚开始试点配电室巡检机器人时&#xf…

📰

uni-app微信小程序外卖闭环:端侧AI+微信支付完整实现

简介:这是一套基于uniapp开发的美团外卖微信小程序实战项目源码,融合人工智能评论识别、商家店铺与商品列表管理、用户授权登录、微信支付等核心功能,面向前端初学者及小程序进阶开发者,适用于课程设计、毕设选题或工程实训场景。…

📰

Linux内核模块与KVM虚拟化:从加载机制到落地调优的实战笔记

记笔记这件事,我一直觉得最有价值的部分不是"记下来",而是"为了记清楚而不得不把原理搞明白"的过程。这套 Linux 内核模块与 KVM 虚拟化笔记,就是我在调优虚拟化平台性能时被逼出来的产物。当时遇到一个诡异现象&#xf…

📰

Kali Linux虚拟机安装零基础教程:从镜像到环境配置

如果让我列一个“Linux 折腾路上迟早要面对”的入门项目,把 Kali 装进虚拟机绝对能排前三。不管你是刚接触安全工具、想给自己搭一个随时能造的实验环境,还是单纯想在主力机器之外多体验一套预装了海量工具的 Linux 系统,虚拟机方案都比直接装…

📰

新能源电动汽车EEA设计:从功能清单到域控制器与DBC验证

简介:《新能源电动汽车电子电气架构设计》是一份面向新能源汽车电子电气工程师、车辆工程专业学生及相关技术研究人员的专业参考文献,聚焦电动汽车电子电气架构的设计理念与关键技术,帮助读者理解电池管理系统、电机控制系统、车身电子控制系…

📰

FME在林权类不动产数据整合中的实战应用与避坑指南

1. 林权类不动产数据整合到底难在哪林权类不动产数据整合,说白了就是把分散在林业、国土、农业等多个部门的林地权属、林木权属、承包合同、地形图、宗地图等数据,统一到一个标准框架下,形成一套完整、准确、可追溯的不动产登记数据库。这件事…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬