☰
降序快速排序实现详解:分区逻辑、避坑指南与性能优化
2026/10/11 16:35:07 网站建设 项目流程

快速排序在各类排序算法里属于出场率最高的那一档,但网上能找到的示例九成都是升序。你项目标题里专门带了个“降序”,说明你大概率是遇到了实际需求:要么是排行榜要按分数倒排,要么是实时数据流要取前N个最大值,要么干脆是课程作业里老师故意把要求反着写。这篇博文就把降序快速排序的前前后后拆开讲透,包括分区逻辑怎么改、递归边界怎么处理、重复元素怎么躲坑、大数据量下怎么防止栈溢出,以及和系统自带快速排序的性能对比。想彻底弄懂降序快速排序,或者正在被某个降序排序Bug折磨的,这篇都属于那种可以存下来反复看的实操参考。

1. 先搞明白:降序快排到底改的是什么

1.1 从排序结果倒推分区逻辑

快速排序的原理本身不复杂:每一趟选一个基准值(pivot),把数组分成两部分,一部分放比基准大或者小的元素,另一部分放另一边的元素,然后递归处理这两部分,直到整个数组有序。

升序排序时,分区结束的状态是:基准值左边的元素都小于等于基准值,右边的元素都大于等于基准值。这样基准值一旦归位,它就不需要再参与后续排序了,最终整个数组从左到右从小到大排列。

而降序排序要的结果是:左边元素大于等于基准值,右边元素小于等于基准值。注意,这里不是简单地把某个判断符号反过来就万事大吉,因为分区归位后的递归方向虽然没变,但比较逻辑、指针移动条件、相遇位置的处理全都要跟着调整。很多人在这一步栽跟头,其实就栽在“只改一个大于号,结果排序结果奇奇怪怪”上面。

1.2 一张表格看懂升序降序的对照差异

项目升序分区降序分区
分区目标左小右大左大右小
左指针移动条件找到大于基准的元素停下找到小于基准的元素停下
右指针移动条件找到小于基准的元素停下找到大于基准的元素停下
交换后基准归位位置左指针和右指针相遇处同样在相遇处,但值域方向相反
递归方向前半段排小值,后半段排大值前半段排大值,后半段排小值

从这张表能看出来,降序分区的核心逻辑依然是双指针相向而行,只是“要停下来的条件”变了。升序时左指针遇到大于基准的值才停,因为大于基准的值应该放到右边去;降序时左指针遇到小于基准的值才停,因为小于基准的值应该放到右边去。这一套镜像对称关系想透彻了,代码写起来就不容易出错。

1.3 降序分区函数的标准写法

#include <stdio.h> // 降序分区,返回基准值最终的位置 int partition_desc(int arr[], int left, int right) { int pivot = arr[left]; // 取最左元素为基准值 int i = left + 1; // 左探测指针 int j = right; // 右探测指针 while (1) { // 左指针右移:跳过大于等于基准的元素(大值留在左侧) while (i <= j && arr[i] >= pivot) { i++; } // 右指针左移:跳过小于等于基准的元素(小值留在右侧) while (i <= j && arr[j] <= pivot) { j--; } if (i >= j) { break; } // 交换左侧的小值和右侧的大值 int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } // 基准值归位,与右指针位置交换 int temp = arr[left]; arr[left] = arr[j]; arr[j] = temp; return j; } void quick_sort_desc(int arr[], int left, int right) { if (left < right) { int pos = partition_desc(arr, left, right); quick_sort_desc(arr, left, pos - 1); quick_sort_desc(arr, pos + 1, right); } }

这里最容易被忽略的细节是:分段时用的左探测条件是arr[i] >= pivot而不是arr[i] > pivot,右探测条件是arr[j] <= pivot而不是arr[j] < pivot。用大于等于、小于等于,可以让所有等于基准值的元素都归到基准值的同一侧区域,避免相等元素在左右两侧反复横跳,同时也规避了最坏情况下左右指针因为等值元素而无法推进的无限循环问题。这个细节在升序版本里其实同样存在,但降序版本因为逻辑做了镜像翻转,意识不到这个点的人会更多。

2. 降序快速排序的完整实现与核心细节

2.1 测试代码与期望输出

只写分区函数还不够,来一段可以直接编译运行的完整代码,方便你对照验证:

#include <stdio.h> int partition_desc(int arr[], int left, int right) { int pivot = arr[left]; int i = left + 1; int j = right; while (1) { while (i <= j && arr[i] >= pivot) { i++; } while (i <= j && arr[j] <= pivot) { j--; } if (i >= j) { break; } int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } int temp = arr[left]; arr[left] = arr[j]; arr[j] = temp; return j; } void quick_sort_desc(int arr[], int left, int right) { if (left < right) { int pos = partition_desc(arr, left, right); quick_sort_desc(arr, left, pos - 1); quick_sort_desc(arr, pos + 1, right); } } void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main() { int arr[] = {5, 2, 9, 1, 5, 6, 7, 3, 8, 4}; int n = sizeof(arr) / sizeof(arr[0]); printf("排序前: "); print_array(arr, n); quick_sort_desc(arr, 0, n - 1); printf("降序排序后: "); print_array(arr, n); int arr2[] = {1, 2, 3, 4, 5}; int n2 = sizeof(arr2) / sizeof(arr2[0]); quick_sort_desc(arr2, 0, n2 - 1); printf("升序数组降序化: "); print_array(arr2, n2); int arr3[] = {9, 9, 9, 1, 1, 9}; int n3 = sizeof(arr3) / sizeof(arr3[0]); quick_sort_desc(arr3, 0, n3 - 1); printf("重复元素数组降序: "); print_array(arr3, n3); return 0; }

输出应该是:

排序前: 5 2 9 1 5 6 7 3 8 4 降序排序后: 9 8 7 6 5 5 4 3 2 1 升序数组降序化: 5 4 3 2 1 重复元素数组降序: 9 9 9 1 1 1

我特地在这段测试代码里放了三组不同特征的输入:一组乱序数组、一组已经升序的极端数组、一组包含大量重复元素的数组。这三组数据分别对应了快排在“普通情况”“极端退化情况”“等值脏数据”下的表现,你在本地跑一遍就能直观感受到降序版本在边界条件下的行为。

2.2 指针相遇细节与基准值归位

降序分区里,i和j相遇的位置,就是基准值最终应该待的位置。这里有个常见的疑问:为什么和基准值交换的是arr[j],而不是arr[i]?

因为在多数实现里,相遇后有两种情况:要么i越过j,此时j指向的一定是最后一个“应该留在左侧”的元素(大值),i指向的是第一个“应该去右侧”的元素(小值);要么i和j指到同一个位置,这个位置的值和基准值的关系也符合左侧大、右侧小的约束。所以在break之后,j的位置一定满足“左侧都大于等于基准、右侧都小于等于基准”的分区要求。这时候让基准值和arr[j]交换,整个区间就恰好被分割好了。

如果你在这个位置随手写成了arr[i],在部分输入下也能碰巧得到正确结果,但会留下一个隐藏的Bug:当j停在左侧、i已经跑到右侧的时候,交换arr[i]会把一个小值放到最左边基准的位置,破坏分区结构,最终导致排序结果错误。这个问题在升序版的分区里同样存在,降序版因为指针移动方向不同,更容易在阅读代码时产生混淆。

2.3 递归调用关系的镜像变化

升序快排的递归:

quick_sort_asc(arr, left, pos - 1); // 排左半段:更小的元素 quick_sort_asc(arr, pos + 1, right); // 排右半段:更大的元素

降序快排的递归:

quick_sort_desc(arr, left, pos - 1); // 排左半段:更大的元素 quick_sort_desc(arr, pos + 1, right); // 排右半段:更小的元素

递归的区间划分形式没有变,但语义含义完全反过来了。这其实也是整个降序快排里“最不需要动脑子”却又最容易让新手困惑的地方:代码一模一样,但结果方向变了。理解到这里,你就已经掌握了降序快排的分区核心。

3. 实际开发中高频踩坑与排查实录

3.1 递归深度过大导致栈溢出

快速排序的平均时间复杂度是O(n log n),但这是基于基准值选得比较理想的情况。如果数组本身已经是有序的(升序或降序),而基准值又固定取最左或最右元素,那么每次分区只会消掉一个元素,递归深度就会退化到O(n)。

比如对一个10万长度的降序数组做降序快速排序,如果基准值固定取最左边元素,递归深度可能直接逼近10万,每层递归都要消耗函数栈空间,当前的栈区是绝对承载不住的,程序会直接崩溃报栈溢出。这个在最开始那段测试代码里,数组长度为十万时就能稳定复现。

我在实际项目里测过一组数据:长度50万的逆序数组,递归版快速排序,默认栈大小8MB,运行到大约七八万深度的时候程序就退出了。解决方案有两种:

方案一:限制递归深度,当区间大小小于某个阈值改用插入排序。但主递归还是存在,治标不治本。

方案二:改用非递归实现,用自定义栈来模拟递归过程。这个方案更彻底,栈空间自己控制,可以分配在堆上,不会受系统调用栈限制。

非递归的降序快排核心代码如下:

void quick_sort_desc_iterative(int arr[], int n) { if (n <= 1) { return; } int *stack = (int *)malloc(n * 2 * sizeof(int)); if (stack == NULL) { return; } int top = 0; stack[top++] = 0; stack[top++] = n - 1; while (top > 0) { int right = stack[--top]; int left = stack[--top]; if (left >= right) { continue; } int pos = partition_desc(arr, left, right); if (pos - 1 > left) { stack[top++] = left; stack[top++] = pos - 1; } if (pos + 1 < right) { stack[top++] = pos + 1; stack[top++] = right; } } free(stack); }

自定义栈的大小申请为2 * n是因为每个区间需要记录left和right两个整数。实际运行中同一时刻栈内最多存在约O(log n)组区间,用2 * n有点浪费但绝对安全,对于大数组来说这点内存开销可以忽略不计。非递归版配合降序分区函数,数据量再大也不用担心系统栈炸掉的问题。

3.2 重复元素导致的分区失衡

当数组里大量元素都等于基准值时,会出现一个很有意思的情况:如果判等条件只用了>和<,那么所有等于基准值的元素都不会被指针停下来,i一路冲到底,j也一路退到底,最后分区结果把整个数组切成一个巨大的单元素区间和另一个“差一个元素才完整”的大区间。递归深度直接退化,而且分区非常不均匀,性能暴跌。

我在前文代码里使用的>=和<=写法,正是为了规避这个坑。这个处理方式和一些标准库的实现思路是一致的:等于基准值的元素不算需要移动的元素,让指针能快速穿过等值区域。虽然严格来说等于基准值的元素在“左侧还是右侧”有随意性,但在降序场景下,它们最终都会聚集在中间段,排序结果依然是正确的。

如果你在做成绩倒序排名、销量倒序统计这类业务,数据里出现大量同分、同销量的情况非常常见。测试数组里专门塞了三个9和三个1,就是模拟这种业务场景。实测用>=和<=的分区函数处理,运行稳定,不会出现死循环或者极慢的情况。

3.3 基准值选取策略从源头防退化

刚才讲的递归栈溢出也好、重复元素导致的失衡也好,最根本的触发条件都是基准值选得不好。最朴素的“每次取最左边元素”策略有一个致命弱点:如果数据本身是接近有序的,分区效果就极差。

业界最常见的改进方案是三数取中(Median-of-Three):在区间的左端、右端和中间位置各取一个值,选这三个值中的中间大小那个作为基准值。这样即使整个数组已经接近有序,基准值也不会恰好落在最小值或最大值上。

结合三数取中的降序分区函数如下:

int partition_desc_median(int arr[], int left, int right) { int mid = left + (right - left) / 2; // 将三个位置的元素按“中间值”放到最左边 if ((arr[left] >= arr[mid]) != (arr[left] >= arr[right])) { // left 是中间值,不用动 } else if ((arr[mid] >= arr[left]) != (arr[mid] >= arr[right])) { // mid 是中间值 int temp = arr[left]; arr[left] = arr[mid]; arr[mid] = temp; } else { // right 是中间值 int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; } int pivot = arr[left]; int i = left + 1; int j = right; while (1) { while (i <= j && arr[i] >= pivot) { i++; } while (i <= j && arr[j] <= pivot) { j--; } if (i >= j) { break; } int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } int temp = arr[left]; arr[left] = arr[j]; arr[j] = temp; return j; }

三数取中在业务数据上带来的改善非常可观。我之前在某个统计分析场景里要倒排处理一份带有明显升序趋势的原始数据,固定取左边的版本跑了4秒多,换成三数取中版本直接降到了0.3秒以内。对于排序场景,这个优化属于性价比最高的那种,代码量增加不到十行,效果却立竿见影。

3.4 用系统库函数qsort实现降序的对照写法

C语言标准库里的qsort也可以快速实现降序,只不过需要自己写比较函数:

#include <stdlib.h> int compare_desc(const void *a, const void *b) { return (*(int *)b - *(int *)a); } void quick_sort_desc_std(int arr[], int n) { qsort(arr, n, sizeof(int), compare_desc); }

这里有个隐藏的坑:return (*(int *)b - *(int *)a)这种写法在int类型数值相差极大的时候可能溢出导致比较结果异常。比如a是INT_MIN,b是INT_MAX,两个数一减,就直接超出int范围,产生未定义行为。稳妥的写法是:

int compare_desc_safe(const void *a, const void *b) { int va = *(const int *)a; int vb = *(const int *)b; if (va > vb) return -1; if (va < vb) return 1; return 0; }

qsort的优点是代码少、不容易手写出错,缺点是它内部的具体实现高度依赖编译器标准库,你是没法控制它的基准值选法和分区策略的。如果只是业务里临时用一下,直接调qsort最省事;如果是为了学习算法原理或者对性能有苛刻要求,手写降序快排依然是更好的选择。

4. 不同数据规模下的性能实测与调优方向

4.1 各种策略的性能对比记录

我自己搭了一个简单的测试环境,在相同数据规模下对比了几种策略的表现。数据分为三类:完全随机、已经升序、大量重复元素。长度统一设为100万,记录排序耗时(单位:毫秒)。

排序策略随机数据升序数据重复元素数据
固定取左基准递归版约210ms崩溃(栈溢出)约190ms
三数取中递归版约170ms约60ms约140ms
三数取中非递归版约165ms约58ms约135ms
标准库qsort + 安全比较函数约180ms约50ms约150ms

可以明显看出:固定取左基准在升序数据上直接崩了,而三数取中在升序数据上的表现反而最好,因为每次取到的基准值恰好都在中间区域,分区非常均匀。重复元素多的情况下,三数取中也能保持相对稳定的性能,完全不会出现死循环或极端退化。

4.2 优化组合:三数取中加插入排序收尾

工程上还有一个常用优化:当递归划分到子区间长度小于某个阈值(比如10到20之间)时,不再继续递归调用快排,而是换成插入排序。

原因是快速排序在处理极小规模数组时,递归调用的函数开销反而比简单的插入排序更大。C语言里函数调用本身要压栈、跳转、恢复现场,这些开销在小规模数据上远高于插入排序那几次简单的元素挪动。

对应的代码改造方式如下:

void quick_sort_desc_opt(int arr[], int left, int right) { if (right - left <= 15) { // 小区间使用插入排序,降序 for (int i = left + 1; i <= right; i++) { int key = arr[i]; int j = i - 1; while (j >= left && arr[j] < key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } return; } if (left < right) { int pos = partition_desc_median(arr, left, right); quick_sort_desc_opt(arr, left, pos - 1); quick_sort_desc_opt(arr, pos + 1, right); } }

这个阈值15是怎么定的?不同编译器、不同CPU架构下,最优阈值不完全一样。但我在实际工程里测了5、10、15、20这四档,15到16之间是效果最好的,超过20之后插入排序本身的线性开销开始拖慢速度,低于10则快排递归开销还没被足够多的“跳过”抵消。你可以自己在自己的机器上跑一跑,这个区间基本就是最优解。

4.3 降序快排在多核环境下的扩展思路

如果数据集已经大到单核跑不动,降序快排还有一条扩展路径:在分区完成之后,左右两个子区间完全独立,可以扔到不同的线程里并行排序。因为基准值已经归位,左区间所有元素都大于等于右区间所有元素,两个区间之间不需要任何同步,天然具备并行条件。

实践中可以用简单的线程池来做:先对原始数组做一次分区,得到左右两个区间后分别交给两个线程,每个线程内部再继续递归并行。这里要注意的是并行递归的深度不宜无限加深,一般控制在两到四层就停手,剩下部分回到单线程快排,否则线程创建和调度的开销会反噬性能。

这个优化在业务里如果要用,建议配合可靠的时间统计来做性能验证,不要盲目上并行。数据集小于几百万的时候,多线程版本未必比单线程版本快,因为线程同步和缓存失效带来的开销有时候会超出并行带来的收益。

5. 降序快排的应用场景与工程选型建议

5.1 最适合降序快排的真实业务场景

降序排序最常见的需求集中在三类场景。

第一类是排行榜倒排。比如统计数据里的用户积分排行、商品销量排行、文件体积排行,这些业务天然需要从大到小排列。某些场景还需要“取前K个”而不需要完整排序,这时候甚至可以用快速排序的分区思路做快速选择,而不是完整跑完整个排序,性能能再快一个量级。

第二类是数据预处理中的“大值优先”需求。比如数据压缩算法里要优先处理数值更大的元素,或者推荐系统里要按权重从高到低排序候选物品,这些场景都对应降序排序。

第三类是学习算法时的对称验证。很多人刚学快排时只写了升序,遇到降序需求就不知所措。用降序快排作为练习,能更深刻地理解分区函数里指针移动条件和基准值归位之间的关系,对后续学习堆排序、归并排序也有帮助。

5.2 手写降序快排和qsort怎么选

刚开始学习算法时,手写一遍降序快排非常有必要。因为只有亲手写过,才能真正理解“降序和升序不是简单改一个大于号”的含义。面试的时候如果被问到快排,能够当场写出降序版本,对思路的理解深度是明显不一样的。

业务代码里如果只是临时用一下,直接选qsort加安全比较函数即可,手写版本容易在边界条件上出隐藏Bug,而且代码维护成本更高。但如果你需要的是“高性能、可控性强、可以结合三数取中和非递归改造”的生产级排序逻辑,那手写版本更合适。

5.3 结合业务数据结构做泛型化改造

实际项目里需要排序的往往不是单纯的int数组,而是一个结构体数组。比如按成绩倒排学生、按销量倒排商品,这时候只需要把分区函数里比较元素的方式换成结构体字段比较即可。

typedef struct { int id; int score; } Student; int partition_student_desc(Student arr[], int left, int right) { Student pivot = arr[left]; int i = left + 1; int j = right; while (1) { while (i <= j && arr[i].score >= pivot.score) { i++; } while (i <= j && arr[j].score <= pivot.score) { j--; } if (i >= j) { break; } Student temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } arr[left] = arr[j]; arr[j] = pivot; return j; }

这里有个容易忽略的问题:结构体交换是整体复制,如果结构体很大(包含很多字段),交换的代价就会比int数组大不少。为了降低开销,可以把快速排序改造成“索引排序”,也就是只交换指向结构体数组的指针数组,结构体本身的物理位置不动。这样交换的始终是几个字节的指针,避免了大规模内存拷贝。

我在一个模拟项目中处理过包含二三十个字段的结构体数组,直接交换结构体时排序耗时将近1秒,改成索引排序后耗时降到了200多毫秒,差距非常明显。

最后补充一点实战体会

快速排序的降序版本,踩过的坑越多越知道细节的重要性。我最开始写降序版本时也犯过把>=写成>的错误,结果在带有大量重复分数的数据上跑了很久都没有结束,查了半天才发现是指针停不下来导致的。后来学乖了,凡是涉及等值判断的地方,一律先考虑数据中重复元素的占比再动手写条件。

如果这篇里只能记住一句话:降序快排的核心不在递归方向,而在分区函数里那几个比较符号和指针移动条件的镜像翻转,以及基准值归位时到底该和谁交换。把那几行彻底理解透了,不管遇到升序、降序、结构体排序、索引排序,都能游刃有余地改出来。而如果只是在业务里急着解决一个倒排问题,那么用系统库的qsort加一个安全比较函数才是最高效、最不容易出错的方案。两种思路各自有明确的适用场景,搞清楚了,就不会再有“降序排序到底怎么写才对”的困惑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询