2.4.快速排序——先分区再递归,为什么它平均这么快却可能退化?
2.4.快速排序——先分区再递归为什么它平均这么快却可能退化系列搜索与排序 | 第 4 篇共 16 篇难度⭐⭐⭐☆☆ 中等标签排序快速排序分治随机化三路快排上一篇2.3.插入排序——像打牌一样整理数组为什么它对“几乎有序”数据特别友好下一篇2.5.归并排序——分而治之再合而并为什么它能稳定保持 O(n log n)前言学完前三篇基础排序后很多人会立刻问一个问题明明已经有了冒泡、选择、插入为什么后面还要单独学快速排序为什么很多面试里手写排序题第一反应也是快排它平均很快那到底快在哪里既然它最坏会退化到O(n²)为什么还敢大规模使用快速排序的魅力就在这里它不是“理论上最稳”的排序但经常是“实践里最快、最顺手、最值得掌握”的排序。快速排序的核心不在“交换得多漂亮”而在于通过一个基准值pivot把数组分成两边让左边都不大于它右边都不小于它然后只递归处理左右两个子问题这篇就把快速排序真正讲透它的分区过程到底在做什么为什么平均复杂度是O(n log n)为什么最坏会掉到O(n²)随机化、三路快排、小区间优化分别解决什么问题它和归并排序、堆排序到底该怎么选一、算法思想先选基准再把数组一分为二快速排序的核心思想可以浓缩成一句话找一个基准元素把数组按它切成左右两边然后递归处理左右两边。设基准值是pivot一次分区完成后数组会满足pivot左边的元素都 pivotpivot右边的元素都 pivotpivot自己已经处在最终正确位置上于是问题自然变成两个更小的子问题对左半部分继续快排对右半部分继续快排这就是典型的分治法。和归并排序相比它们都属于分治但思路不同归并排序先递归排好左右两边再把两个有序部分合并起来快速排序先通过分区把一个元素放到正确位置再递归处理左右两边也就是说归并排序的关键动作是合并快速排序的关键动作是分区核心不变量每次partition()结束后基准值两侧的元素虽然不一定各自完全有序但一定已经满足“左边不大于它、右边不小于它”。二、完整图解过程以数组[5, 3, 8, 1, 2, 7]为例演示最常见的写法选择最右元素7作为pivot第 1 步扫描一遍把小于等于7的元素往左放这里最容易绕晕的点是到底是谁在动怎么动为什么8会被一点点“挤”到后面去可以先记住两个指针j扫描指针从左到右依次检查每个元素i边界指针表示“当前最后一个 pivot的位置”初始时i -1表示左边还没有任何一个元素被确认放好。初始状态[5, 3, 8, 1, 2, 7] ↑ pivot7接下来让j从左往右扫描[5, 3, 8, 1, 2]规则只有一条只要发现nums[j] pivot就先把i往右扩一格再把nums[j]交换到这个新位置上。也就是说不是专门去“搬大数”而是不断把“小于等于 pivot 的数”塞进左侧合格区。扫描位置j当前值动作数组状态初始-i -1左侧合格区为空[5, 3, 8, 1, 2, 7]j 055 7i - 0交换nums[0]和nums[0][5, 3, 8, 1, 2, 7]j 133 7i - 1交换nums[1]和nums[1][5, 3, 8, 1, 2, 7]j 288 7不交换i不变[5, 3, 8, 1, 2, 7]j 311 7i - 2交换nums[2]和nums[3][5, 3, 1, 8, 2, 7]j 422 7i - 3交换nums[3]和nums[4][5, 3, 1, 2, 8, 7]这时候扫描结束i 3说明下标0 ~ 3的位置都已经是 7的元素8之所以跑到后面不是我们主动“把它搬走”而是因为1、2这两个更小的数先后插进了左侧区域把它挤到了右边最后一步再把pivot7和nums[i 1]交换也就是和8交换[5, 3, 1, 2, 8, 7] ↑ i1交换后[5, 3, 1, 2, 7, 8] ↑ pivot 已就位现在基准值7的位置已经确定了左边都 7右边都 7你可以把这一轮分区理解成一句话扫描指针j负责找“小数”边界指针i负责给“小数”腾位置扫描结束后再把pivot塞进左右分界点。第 2 步递归处理左半部分[5, 3, 1, 2]继续选最右元素2为基准[5, 3, 1, 2] ↑ pivot2分区后[1, 2, 5, 3] ↑ pivot 已就位于是继续递归左边[1]不用排右边[5, 3]继续排第 3 步处理[5, 3]选3为基准[5, 3] - [3, 5]这时整个数组变成[1, 2, 3, 5, 7, 8]排序完成。✅整体过程汇总阶段操作结果初始分区以7为基准分区[5, 3, 1, 2, 7, 8]递归左侧以2为基准分区[1, 2, 5, 3]继续递归排[5, 3][3, 5]全部完成左右子问题都解决[1, 2, 3, 5, 7, 8]三、代码实现在看代码前先把上面的图解和下面的模板对齐一下图解里为了方便理解直接固定取最右边的7作为pivot代码里为了更实战先随机选一个pivot_idx再把它交换到right位置所以随机化只影响“选谁当基准”不影响后面的分区动作真正进入扫描阶段后依然可以把它理解成“拿最右元素当pivot”对应关系是这样的图解里的扫描指针j↔ 代码里的for j in range(left, right)图解里的边界指针i↔ 代码里的i left - 1图解里“发现 pivot的数就扩边界并交换” ↔ 代码里的if nums[j] pivot: i 1; nums[i], nums[j] nums[j], nums[i]图解里“扫描结束后把pivot放到分界点” ↔ 代码里的nums[i 1], nums[right] nums[right], nums[i 1]如果代入上面那个例子第一次分区刚好就是left 0, right 5pivot 7i初始为-1j依次扫描0 ~ 4也就是说上面的图解和下面这份代码在分区逻辑上是一一对应的只是代码额外多了一步“随机选基准再换到最右边”。Python 版本随机化快排importrandomdefquick_sort(nums):defpartition(left,right):pivot_idxrandom.randint(left,right)nums[pivot_idx],nums[right]nums[right],nums[pivot_idx]pivotnums[right]ileft-1forjinrange(left,right):ifnums[j]pivot:i1nums[i],nums[j]nums[j],nums[i]nums[i1],nums[right]nums[right],nums[i1]returni1defsort(left,right):ifleftright:returnmidpartition(left,right)sort(left,mid-1)sort(mid1,right)ifnums:sort(0,len(nums)-1)returnnums nums[5,3,8,1,2,7]print(quick_sort(nums))# [1, 2, 3, 5, 7, 8]这个版本比“固定选最右边元素”更实战因为遇到本来有序、逆序、特殊构造数据时随机化能显著降低退化到O(n²)的概率C 版本#includeiostream#includevector#includecstdlib#includectimeusingnamespacestd;intpartition(vectorintnums,intleft,intright){intpivotIdxleftrand()%(right-left1);swap(nums[pivotIdx],nums[right]);intpivotnums[right];intileft-1;for(intjleft;jright;j){if(nums[j]pivot){i;swap(nums[i],nums[j]);}}swap(nums[i1],nums[right]);returni1;}voidquickSort(vectorintnums,intleft,intright){if(leftright){return;}intmidpartition(nums,left,right);quickSort(nums,left,mid-1);quickSort(nums,mid1,right);}intmain(){srand((unsigned)time(nullptr));vectorintnums{5,3,8,1,2,7};quickSort(nums,0,(int)nums.size()-1);for(intx:nums){coutx ;}return0;}四、复杂度分析情况时间复杂度原因最好情况O(n log n)每次分区都比较均衡递归树高度约为log n平均情况O(n log n)随机化后大多数情况下都比较均衡最坏情况O(n²)每次都极不均衡比如一边空、一边剩下n-1个元素空间复杂度O(log n)主要来自递归栈极端退化时可能达到O(n)稳定性❌ 不稳定分区交换会打乱相等元素的原始相对顺序为什么平均是O(n log n)因为每一层分区都会把当前区间扫描一遍总代价接近O(n)如果每次都能把问题切得比较平衡层数大约是log n于是总复杂度就是每层 O(n) × 层数 O(log n) O(n log n)对应递推式可以写成T(n) T(k) T(n-k-1) O(n)当k大致接近n/2时就接近O(n log n)当k总是0或n-1时就会退化成O(n²)。五、为什么它平均很快但最坏会退化快速排序“快”的核心不只是复杂度写成了O(n log n)而是它在工程上还有三个很讨喜的特点1原地排序额外空间小和归并排序不同快排通常不需要额外开一个O(n)的辅助数组。这意味着内存压力更小元素移动更贴近原数组常数往往更好看2局部性好缓存友好分区过程基本是在一段连续内存里扫描和交换缓存命中率通常不错。3子问题独立递归结构自然一次分区以后左边区间自己排右边区间自己排问题结构很干净。但它的风险也很明显为什么会退化到O(n²)如果你每次都选到了极差的pivot比如已升序数组里总选最后一个数已降序数组里总选第一个数那么每次只确定 1 个元素位置剩下几乎全部元素还在同一侧。递归会变成n (n-1) (n-2) ... 1 O(n²)所以快排真正要学的不只是“会写”而是会防退化。六、优化与变体优化 1随机化 pivot这是最常见、最实用的一种优化。做法很简单在当前区间里随机抽一个位置把它交换到末尾再按普通方式做分区好处是极端输入不容易稳定卡你平均表现更稳优化 2三路快排如果数组里有很多重复元素普通二路分区会比较吃亏。例如[5, 5, 5, 5, 5, 5, 5]这时反复分区其实很浪费。三路快排会把数组一次分成三部分 pivot pivot pivot这样中间那一整段都不用再递归。defquick_sort_3way(nums,left,right):ifleftright:returnpivotnums[left]ltleft ileft1gtrightwhileigt:ifnums[i]pivot:nums[lt],nums[i]nums[i],nums[lt]lt1i1elifnums[i]pivot:nums[i],nums[gt]nums[gt],nums[i]gt-1else:i1quick_sort_3way(nums,left,lt-1)quick_sort_3way(nums,gt1,right)优化 3小区间切换插入排序当区间已经很小时继续递归快排未必划算。常见做法如果子数组长度小于某个阈值如 16就直接改用插入排序这是因为小数组上插入排序常数很小代码简单能减少递归调用开销变体快速选择Quickselect快排的分区思想还能直接拿来求第k小第k大中位数因为每次分区后pivot的最终排名已经确定了。如果我们只关心“第k个位置”就只需要递归进入一侧而不是两边都递归。这就是Quickselect。七、与归并排序、堆排序对比对比项快速排序归并排序堆排序平均时间复杂度O(n log n)O(n log n)O(n log n)最坏时间复杂度O(n²)O(n log n)O(n log n)空间复杂度O(log n)O(n)O(1)稳定性不稳定稳定不稳定工程常用度很高很高中等典型优势平均快、常数小上界稳、适合链表原地且最坏也稳典型短板会退化额外空间大常数和局部性一般一句话记忆快排平均最快最常用但要防退化归并时间上界稳适合稳定排序与链表堆排空间最省最坏情况也稳但手感通常不如前两者八、OJ 例题讲解例题 1HDU 1040 — As Easy As AB可直接套裸快排模板题目来源HDU题号 1040难度⭐⭐☆☆☆ 简单题目链接http://acm.hdu.edu.cn/showproblem.php?pid1040题目描述(这道题眼熟不前面用过就拿来练裸排序用)给出多组整数序列请你把每组数据按升序输出。关键数据范围第一行输入测试组数T每组数据满足1 N 1000每组都要完整输出排好序的序列为什么这题适合放在快排章节因为它就是很纯粹的“排序模板落地题”题意没有额外陷阱就是老老实实把数组排好数据规模不大直接套前面的裸快排模板就能通过很适合第一次把partition quickSort真正写进 OJ解题思路每组数据单独读入一个数组直接套用前文的随机化二路快排模板排序完成后按题目要求输出即可C 解法随机化快排模板#includebits/stdc.husingnamespacestd;intpartition(vectorintnums,intleft,intright){intpivotIdxleftrand()%(right-left1);swap(nums[pivotIdx],nums[right]);intpivotnums[right];intileft-1;for(intjleft;jright;j){if(nums[j]pivot){i;swap(nums[i],nums[j]);}}swap(nums[i1],nums[right]);returni1;}voidquickSort(vectorintnums,intleft,intright){if(leftright){return;}intmidpartition(nums,left,right);quickSort(nums,left,mid-1);quickSort(nums,mid1,right);}intmain(){ios::sync_with_stdio(false);cin.tie(nullptr);intT;cinT;while(T--){intn;cinn;vectorintnums(n);for(inti0;in;i){cinnums[i];}if(n1){quickSort(nums,0,n-1);}for(inti0;in;i){if(i){cout ;}coutnums[i];}cout\n;}return0;}这题适合拿来练什么练最标准的“裸快排模板”怎么直接套到 OJ练partition(left, right)这一段的扫描与交换逻辑练多组输入下如何把算法模板写成完整可提交代码例题 2LeetCode 215 — 数组中的第 K 个最大元素Quickselect 经典题题目来源LeetCode题号 215难度⭐⭐⭐☆☆ 中等题目链接https://leetcode.cn/problems/kth-largest-element-in-an-array/题目描述给定整数数组nums和整数k返回数组中第k个最大的元素。关键数据范围1 k nums.length 10^5-10^4 nums[i] 10^4为什么这题和快排强相关因为你根本不需要把整个数组都排完。只要每次分区后判断pivot所在分块是否已经覆盖目标排名目标下标是在左半边还是右半边就能只继续处理一侧把平均复杂度压到O(n)。这题为什么不能直接照搬前面的单指针模板因为这题会卡“大量重复元素”的情况。比如数组全是1时如果还用前面那种 Lomuto 单指针分区每次分区都会把区间切得非常不均匀很可能一次只缩掉一个位置Quickselect就会被拖慢最终超时所以这题更稳的写法是随机化选pivot 双指针。核心代码C Quickselect 双指针优化classSolution{public:intpartition(vectorintnums,intleft,intright){intpivotnums[leftrand()%(right-left1)];intileft-1;intjright1;while(ij){do{i;}while(nums[i]pivot);do{j--;}while(nums[j]pivot);if(ij){swap(nums[i],nums[j]);}}returnj;}intfindKthLargest(vectorintnums,intk){inttargetk-1;intleft0;intright(int)nums.size()-1;while(leftright){intmidpartition(nums,left,right);if(targetmid){rightmid;}else{leftmid1;}}returnnums[left];}};为什么双指针版更稳左指针找“应该去右边”的数右指针找“应该去左边”的数两个指针相向而行重复元素会自然分散到两边即使数据像[1,1,1,1,1,...]这样极端分区也不会老是只缩一个点这题非常适合体会快排不只是排序算法还是选择算法而同样是分区面对重复元素时分区写法本身也会决定你能不能过题。例题 3LeetCode 75 — 颜色分类三路划分典型题题目来源LeetCode题号 75难度⭐⭐⭐☆☆ 中等题目链接https://leetcode.cn/problems/sort-colors/题目描述给定一个只包含0、1、2的数组对它们进行原地排序。关键数据范围1 nums.length 300nums[i]只能是0、1、2为什么这题放在快排章节很合适因为它的最优思路其实就是“三路划分”。虽然这题不需要真的写完整快排但它和三路快排共享同一个核心思想小的放左边等于的放中间大的放右边C 解法荷兰国旗问题classSolution{public:voidsortColors(vectorintnums){intleft0;inti0;intright(int)nums.size()-1;while(iright){if(nums[i]0){swap(nums[left],nums[i]);left;i;}elseif(nums[i]2){swap(nums[i],nums[right]);right--;}else{i;}}}};这题适合用来加深对“三路分区”的理解。九、适用场景场景是否适合原因通用数组排序✅平均性能强工程表现通常很好需要原地排序✅额外空间通常只要递归栈数据规模较大✅随机化后平均效率高重复元素很多✅用三路快排更合适需要稳定排序❌快排天然不稳定必须保证最坏时间上界⚠️单纯快排不稳需额外防退化或换算法十、常见错误总结错误原因正确做法递归边界写错没有在left right时返回先判断区间长度是否合法分区后递归区间写错把pivot位置又递归进去递归[left, mid-1]和[mid1, right]固定选端点导致退化遇到有序数组容易被卡优先随机化或三数取中误以为快排稳定交换会打乱相等元素顺序明确记住快排不稳定大量重复元素仍用普通二路分区递归效率变差考虑三路快排求第 k 大时还把整个数组全排完没利用分区排名信息改用 Quickselect总结要点内容核心思想选pivot分区后递归处理左右子数组平均复杂度O(n log n)最坏复杂度O(n²)空间复杂度O(log n)平均稳定性❌ 不稳定核心优势原地、平均快、工程常用主要短板可能退化需要防最坏情况一句话记住它快速排序不是靠“合并”取胜而是靠一次次高质量分区把无序问题快速切碎。上一篇2.3.插入排序——像打牌一样整理数组为什么它对“几乎有序”数据特别友好下一篇2.5.归并排序——分而治之再合而并为什么它能稳定保持 O(n log n)看完有收获的话点个赞再走有问题欢迎评论区讨论
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2480108.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!