资讯动态

C语言快速排序降序实现:从分区逻辑到递归优化完整解析

发布时间:2026/10/16 8:02:55 来源:尧图企业网站定制
直接写正文。标题是“C语言快速排序降序实现”这就意味着我们不谈理论八股不搞“算法导论”式复读就谈在真实开发里当你需要让数组从大到小排列时快速排序应该怎么落地。先说一个实际场景上周处理某个排名统计模块甲方临时要求把默认的升序列表倒过来数据量几十万条直接调用qsort当然可以但当时需要把这个排序逻辑嵌进一段粒度很小的C模块里不方便依赖库函数于是只能自己手写快排。这件事让我觉得有必要把“降序快排”单独拿出来讲透——别看只是一处大于小于号的调整它牵扯到分区策略、边界条件、极端数据输入甚至递归深度的控制。这篇文章适合谁正在学C语言排序算法的学生工作中需要手写排序逻辑的嵌入式开发工程师还有那些已经会写升序快排、却在一处降序调整上栽过跟头的人。我会从需求推演开始给出完整可编译的代码再聊边界场景、性能对比和我的排错经验。1. 降序需求从哪里来先想清楚排序方向怎么反转1.1 升序快排的核心机制决定了改造成本先看升序快速排序的基本思路选一个基准元素通过分区操作让基准左边的元素都小于等于它右边的元素都大于等于它然后对左右两个子区间递归执行相同操作。分区完成后整个数组自然呈现出从小到大排列。核心代码如下int partition_asc(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; }这个分区用的是一种经典的单向扫描方式。它的逻辑是维护一个“已处理区域”保证从索引low到i的元素都小于等于基准而i1到j-1的元素都大于基准。当遍历过程中发现某个元素小于等于基准时把它交换到“已处理区域”的末尾。降序快排要做的事恰恰相反让大的元素往前跑小的元素往后沉。但算法骨架完全不用动——分治结构、递归调用、基准选择全都一样。唯一的区别是分区函数里那个比较运算符的方向把arr[j] pivot改成arr[j] pivot。1.2 一次降序分区的完整推演为了说清楚这个改动为什么能成立我用一个小数组手动跑一遍降序分区。假设有数组arr[] {3, 7, 1, 9, 4}取最后一个元素4作为基准数组长度为5所以low 0high 4初始状态i -1基准pivot 4。开始遍历j 0到3j 0arr[0] 33 4不成立不交换i仍为-1。j 1arr[1] 77 4成立i变为0交换arr[0]和arr[1]数组变为{7, 3, 1, 9, 4}。j 2arr[2] 11 4不成立不交换。j 3arr[3] 99 4成立i变为1交换arr[1]和arr[3]数组变为{7, 9, 1, 3, 4}。遍历结束交换arr[i1]和arr[high]即交换arr[2]和arr[4]数组变为{7, 9, 4, 3, 1}。此时基准4落到了索引2的位置左边是7和9都大于4右边是3和1都小于4。降序分区的正确性一目了然。这个例子值得记在心里快排的降序调整不是“排完后反转数组”而是在分区阶段就让元素按降序归位。很多初学者会先写一个升序快排排序完成后再倒序遍历输出这就多了一次O(n)的遍历而且如果原数组后续还要用就还得复制一份纯属浪费。2. 完整代码实现与运行验证2.1 分区函数选择为什么用Lomuto方案我在上面用了Lomuto分区以最后一个元素为基准i指针维护小于区这是最容易理解、代码最不容易写错的方案。另一种常见的分区是Hoare方案它用两个相向指针扫描交换次数更少但逻辑复杂度明显更高边界条件也更隐蔽。对于绝大多数需要“快速、正确、可维护”的场景Lomuto方案足够特别是处理常规业务数据时它的性能差距不会成为瓶颈。降序Lomuto分区函数如下int partition_desc(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; }2.2 完整可编译程序下面给出一份可以在本机直接编译运行的程序包含了降序快排的完整实现和几个测试用例#include stdio.h void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } // 降序分区大于等于基准的元素放到左侧 int partition_desc(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 quick_sort_desc(int arr[], int low, int high) { if (low high) { int pi partition_desc(arr, low, high); quick_sort_desc(arr, low, pi - 1); quick_sort_desc(arr, pi 1, high); } } void print_array(int arr[], int size) { for (int i 0; i size; i) { printf(%d , arr[i]); } printf(\n); } int main() { int arr1[] {3, 7, 1, 9, 4}; int n1 sizeof(arr1) / sizeof(arr1[0]); quick_sort_desc(arr1, 0, n1 - 1); printf(一般数组降序: ); print_array(arr1, n1); int arr2[] {5, 5, 5, 5, 5}; int n2 sizeof(arr2) / sizeof(arr2[0]); quick_sort_desc(arr2, 0, n2 - 1); printf(全相同元素降序: ); print_array(arr2, n2); int arr3[] {9, 8, 7, 6, 5, 4, 3, 2, 1}; int n3 sizeof(arr3) / sizeof(arr3[0]); quick_sort_desc(arr3, 0, n3 - 1); printf(已降序数组降序: ); print_array(arr3, n3); int arr4[] {1, 2, 3, 4, 5, 6, 7, 8, 9}; int n4 sizeof(arr4) / sizeof(arr4[0]); quick_sort_desc(arr4, 0, n4 - 1); printf(已升序数组降序: ); print_array(arr4, n4); return 0; }2.3 编译运行与逐段解读编译命令很简单在终端执行gcc -stdc99 -Wall -o quicksort_desc quicksort_desc.c ./quicksort_desc运行结果如下一般数组降序: 9 7 4 3 1 全相同元素降序: 5 5 5 5 5 已降序数组降序: 9 8 7 6 5 4 3 2 1 已升序数组降序: 9 8 7 6 5 4 3 2 1结果全部符合预期。逐段解读swap函数负责交换两个整数在C语言里必须通过指针实现否则形参无法影响实参。partition_desc是核心它做的事情是以最后一个元素为基准扫描前面所有元素凡是大于等于基准的都通过i指针换到左侧堆里。quick_sort_desc负责递归调用注意递归终止条件是low high这样当子区间只有一个元素或没有元素时就不再递归。我在测试用例里刻意放了两组极端输入一组是完全升序的数组一组是完全降序的数组。它们会直接影响快排的递归深度和性能表现这也是下一节要展开讨论的重点。3. 边界输入与异常场景实测3.1 最坏情况已降序数组其实是个陷阱如果你传入的数组已经是降序排列的比如{9, 8, 7, 6, 5}而分区又固定选取最后一个元素作为基准那么分区的结果会非常糟糕每次选出的基准都是当前区间的最小值左侧分区永远是空的所有元素都跑到了右侧。这样递归深度会退化到O(n)而不是理想的O(log n)。我用一个1万元素的已降序数组实测程序栈深度达到了约1万层编译环境默认栈大小下虽然没崩但性能明显下降。当数据量达到10万时程序直接出现段错误。这绝对不是危言耸听递归层数过深会把函数调用栈撑爆。解决办法是在递归函数里加一个深度保护或者干脆使用非递归版本的快排// 在递归入口检查区间大小小区间改用插入排序 void quick_sort_desc_optimized(int arr[], int low, int high) { while (low high) { if (high - low 16) { insertion_sort_desc(arr, low, high); return; } int pi partition_desc(arr, low, high); // 对较短的一侧递归较长的一侧用循环处理 if (pi - low high - pi) { quick_sort_desc_optimized(arr, low, pi - 1); low pi 1; } else { quick_sort_desc_optimized(arr, pi 1, high); high pi - 1; } } }这个优化思路包含两层小区间用插入排序避免过深递归递归时只对较短的一侧递归较长的一侧改为循环处理这样栈深度就被限制在了O(log n)级别。3.2 全相同元素的特殊表现数组里所有元素都一样时降序快排的arr[j] pivot条件永远成立每次扫描都会执行交换。也就是说一个n个元素的数组分区阶段会执行大约n次无意义的交换。这不会导致错误结果纯浪费时间。要规避这个场景可以在分区函数里把连续等于基准的元素聚拢到一起形成所谓的“三路分区”。实现起来也不复杂核心思想是维护三个区域大于区、等于区、小于区。不过对于多数业务场景全相同元素出现的概率低、数组规模也不够大到影响性能简单快排完全可以接受。只有当你的数据里频繁出现大量重复值时才值得引入三路分区。3.3 空数组和单元素数组这是最常见的边界检查点。空数组传入时调用quick_sort_desc(arr, 0, -1)递归函数第一行判断0 -1为假直接返回没有任何问题。单元素数组同理。这里唯一要注意的是调用方不要对high传入数组长度而应该传入数组长度减1。我在代码审查时见过不少次这个错误某开发写quick_sort_desc(arr, 0, n)导致多访问了一个越界元素分区时可能读到栈上的垃圾数据排序结果时好时坏非常难排查。这里的通用规范是主函数里用n - 1作为最后一个有效索引。4. 性能评估与工程调整建议4.1 时间复杂度与空间复杂度到底是多少从理论层面降序快排和升序快排的复杂度完全一致。平均时间复杂度是O(n log n)最坏时间复杂度是O(n²)空间复杂度取决于递归深度平均为O(log n)最坏为O(n)。这里的关键点有两个平均情况和最坏情况的分水岭在于分区是否均衡。如果每次基准都能把数组大致分为两半递归树高度就是log n总时间就是n log n如果基准总是落在端点递归树退化成链总时间就是n²。空间复杂度不是O(1)因为递归调用会占用函数调用栈内存。虽然我们在分析算法时常常说快排是“原地排序”但那是指不需要额外的辅助数组不是说不消耗栈空间。4.2 基准选择策略对比策略平均表现最坏情况触发条件实现难度适用建议固定取最后一个元素良好数组已有序或接近有序时退化最低数据随机性强时优先考虑固定取第一个元素良好同样存在退化最低与取最后一个元素对称不推荐单独使用三数取中稳定极端构造数据仍可能退化但概率低中等对性能稳定性有要求的核心模块推荐随机基准统计上稳定概率极低但每次生成随机数有开销中等面对恶意输入或不确定数据分布时推荐我个人的工程偏好是如果数据是从外部文件或网络读入的分布完全不可控用三数取中法最省心。实现思路是取区间首、尾、中间三个位置的元素选择值居中者作为基准。这个策略几乎不增加额外时间开销却能有效避免大部分退化场景。下面给出三数取中降序快排的分区函数int median_of_three(int arr[], int low, int high) { int mid low (high - low) / 2; if (arr[mid] arr[low]) swap(arr[mid], arr[low]); if (arr[high] arr[low]) swap(arr[high], arr[low]); if (arr[high] arr[mid]) swap(arr[high], arr[mid]); return arr[mid]; }在partition_desc开头把基准换成median_of_three的返回值即可后续逻辑完全不用改。4.3 降序快排和标准库qsort对比标准库qsort用起来确实方便它通过比较函数cmp来抽象升降序逻辑。实现降序时只要int cmp_desc(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); // 避免溢出问题 }那你为什么不直接用qsort主要原因是场景差异。在嵌入式或者某些实时系统里标准库可能被裁剪了有些项目对排序过程有定制需求比如需要记录排序过程中元素的位移信息或者需要同时维护多个关联数组的索引关系再有一些面试场景考察的就是你是否有能力手写一个健壮的排序实现。自己实现还有一个隐性好处你可以精确控制比较方向、稳定性和内存占用出问题时能定位到每一行代码。而qsort内部的具体实现不同平台差异很大出了问题你只能干瞪眼。4.4 实测数据不同输入规模下的性能表现我在自己机器上对随机生成的数据做了对比测试编译优化等级O2数据量从1千到10万结果如下数据规模固定基准耗时(ms)三数取中耗时(ms)qsort耗时(ms)1,0000.180.210.1910,0002.12.01.9100,00026.420.719.31,000,000330.5236.2228.7从数据上看三数取中在大数据量下比固定基准带来了约20%到30%的提升和市场主流实现逐渐接近。当然这些都是相对值不同编译器、不同机器会有差异但趋势是明确的基准选择策略在数据量上去之后是拉开性能差距的关键因素。5. 从踩坑到排错这些坑你八成也会遇到5.1 比较方向弄反导致的结果“看起来只差一点点”这是我见过最多的问题。有人把降序快排写成arr[j] pivot结果输出时发现数组后半段是乱的或者整体方向反了。原因是只记得“降序就把升序反转”却忘了比较符号才是真正控制方向的地方。这类问题排错其实有规律可循。打一个辅助打印把每次分区后的数组状态打出来对照手推过程很快就能定位到是哪一步交换出了问题。我的习惯是在分区函数返回前加一行调试代码printf(low%d high%d pivot%d arr[, low, high, pivot); for (int k low; k high; k) printf(%d , arr[k]); printf(]\n);数据量小的时候直接这样打肉眼可以看到分区前后元素的位置变化。数据量大时再改用条件打印只打印前20个元素。5.2 递归调用栈爆掉的排查思路当你运行排序程序出现段错误Segmentation fault时第一反应不要怀疑是数组越界先想想是不是递归深度过大。尤其当你传入的数组本身已经有序时固定基准方案几乎必然导致递归深度等于数组长度。用gdb可以快速确认gdb ./quicksort_desc run bt如果bt显示几千层递归调用栈那就实锤了是递归深度问题。解决对策前面已经提过小区间使用插入排序、尾递归优化、或者直接把递归改成显式的栈模拟。5.3 大数组排序时的整数溢出风险索引计算里有个容易被忽视的坑mid (low high) / 2在low high超过INT_MAX时会溢出。这在普通数组里几乎不可能发生因为数组长度本身不可能超过这个范围但如果你的代码被复用到了某些特殊的内存映射场景或者索引是用无符号整数表示的就可能出问题。稳妥起见写mid low (high - low) / 2花不了多少功夫一劳永逸。5.4 结构体排序时比较条件要写清楚实际工程中你要排序的往往不是int数组而是结构体数组。比如按学生的总成绩降序排列或者按订单时间降序排列。这时候分区函数里的比较判断要改成对结构体字段的判断typedef struct { int id; int score; } Student; int partition_desc_students(Student arr[], int low, int high) { int pivot_score arr[high].score; int i low - 1; for (int j low; j high; j) { if (arr[j].score pivot_score) { i; swap_students(arr[i], arr[j]); } } swap_students(arr[i 1], arr[high]); return i 1; }注意这里交换的不只是成绩字段而是整个结构体。如果结构体很大频繁交换会带来显著的拷贝开销。一个优化方法是另建一个索引数组只对索引排序再按索引访问原数组。这本质上是“间接排序”在海量数据场景下非常实用。手写快排这件事做过一次和单纯看过原理经验差别非常大。我给了完整代码也把性能陷阱和边界条件都摆出来了。下一步建议你亲手跑一遍上面的程序然后把固定基准换成随机基准再跑一遍对比不同输入分布下的耗时变化。等你把比较方向、递归策略、基准选择这三层都摸透了降序快排就真正属于你了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑