2026/10/11 16:35:02

C语言快速排序降序实现:从partition到性能优化

C语言快速排序降序实现:从partition到性能优化 快排这东西我最早是在大一的数据结构课上接触的。当时只觉得“分治”这个思路很巧妙但真正在项目里大规模用到是后来给别人写一套商品销量排行榜模块的时候。需求很朴素一堆商品按销量从高到低排老板要一眼看到卖得最好的前十名。那时候我脑子里第一个冒出来的就是快速排序然后当场卡住了——书上的例子全是升序我背得滚瓜烂熟的是从小到大排而现在要的是降序。可能有人觉得降序不就是把升序结果倒过来嘛或者把比较符号反过来就行。话是没错但里面涉及的具体改法、边界情况、性能差异还是有不少细节值得展开说说的。这篇文章我就把这套“C语言快速排序降序实现”从头到尾拆一遍从递归思路、partition分割函数的写法到各种性能优化手段和踩坑记录一次性讲透。这篇内容适合正在学排序算法、准备笔试面试的同学也适合工作里突然要用C语言处理排序需求的开发者。看完之后你不仅能写出一个能跑的降序快排还能理解它为什么这么写以及怎么让它更稳、更快。1. 项目拆解先搞懂快排的核心逻辑1.1 快排在解决什么问题排序算法很多冒泡简单、选择直观、插入适合小规模数据但一旦数据量上了十万、百万级别这些O(n²)的算法就明显吃力了。快速排序的平均时间复杂度是O(n log n)属于比较排序里的第一梯队而且它是原地排序不需要像归并排序那样额外开一块大内存去合并数组。它解决的核心问题可以概括成一句话通过一趟扫描把数组分成“一大一小”两部分然后递归处理这两部分。升序是左边小右边大降序则反过来左边大右边小。理解了这个降序实现其实就是在升序版上做很少的改动。我习惯用一个生活化的类比来解释快排逻辑想象你是一个图书管理员要把一架子书按高度从高到低排好。快排的做法是随手抽一本书当“标尺”然后把所有比标尺高的书扔到左边比标尺矮的扔到右边。标尺这本书的位置就固定了它左边全是高的右边全是矮的。接下来左右两边各自重复这个过程直到每个区域只剩一本书。这就是分治思想把大问题拆成两个独立的小问题各自解决。1.2 为什么降序版本值得单独写很多教材默认只写升序导致很多人对排序算法形成一种“固定写法”的依赖。实际上在真实业务里降序排序出现的频率非常高排行榜、库存从高到低、时间倒序、价格降序排列全是降序。掌握了原理的人可能觉得“不就是改个符号吗”但对初学者来说把改成的时机、在哪个函数里改、改了之后递归边界需不需要动这些看起来琐碎的问题非常容易出错。而且降序实现不是简单地把最终结果reverse一遍那么简单。如果先升序排完再倒序虽然结果正确但浪费了一次O(n)的遍历。在数据量极大的场景下这个开销能省则省。正确的做法是从partition函数的设计层面直接按降序分割递归也保持在降序逻辑里这样整个过程一步到位性能和思路都干净。1.3 快速排序的整体架构快速排序的代码骨架包含两个函数partition分割函数这是最核心的部分。选定一个基准值pivot通过交换操作让基准值左边的元素全部满足某种规则右边的全部满足另一种规则然后返回基准值最终所在的下标。quickSort递归排序函数调用partition拿到分界点然后对左边区间和右边区间分别递归调用自己。所以写快排的本质就是写对partition降序和升序的区别也几乎只在这个函数里体现。递归函数本身不需要动因为它只管“区间划分后继续排”至于区间里怎么分是partition的职责。2. 降序快速排序的C语言实现2.1 从需求反向设计接口先别看代码先想清楚这个排序函数需要什么参数。最基础的接口设计是这样void quickSort(int arr[], int left, int right);arr是要排序的数组left是本次要排序区间的左边界下标right是右边界下标。注意这个接口是闭区间也就是说[left, right]范围内的所有元素都要参与排序。为什么不是只传数组和长度因为递归过程中每次要排序的是数组的一个片段而不是整个数组。如果只传数组名就只能递归整段没法处理片段了。所以必须把左右边界传进去。降序的最终效果是排序结束后arr[left] arr[left1] ... arr[right]。我拿一个测试数组举例原始数组{5, 2, 9, 1, 5, 6} 降序结果{9, 6, 5, 5, 2, 1}这个结果看起来简单但里面藏着两个需要注意的地方第一5出现了两次降序排列后两个5相邻顺序无所谓第二快排不是稳定的排序算法也就是说相等的元素原本的相对顺序可能会变这一点在很多业务场景比如按分数排序同分的人希望保持学号顺序里是硬伤快排做不到得靠归并。这个话题后面单说。2.2 标准的降序partition实现直接上代码。下面这个版本我用的是经典的单边扫描法也叫Lomuto分割法逻辑清晰适合讲解。int partitionDesc(int arr[], int left, int right) { int pivot arr[left]; // 基准值简单起见取区间第一个元素 int i left 1; // i用于“扫描”从基准的下一个开始 int j left; // j指向“目前分界区间的最后一个大元素” for (; i right; i) { if (arr[i] pivot) { j; // 把 arr[i] 交换到“大元素区间的尾部” int temp arr[i]; arr[i] arr[j]; arr[j] temp; } } // 扫描结束把基准值放到正确位置 int temp arr[left]; arr[left] arr[j]; arr[j] temp; return j; }解释一下这段代码在干什么。我们选了arr[left]作为基准值目标是让数组中所有大于基准的要素都集中在基准的左边所有小于等于基准的都跑到右边去。j的初始值是left它代表“最后一个被确认是大于基准的元素的下标”。当i从左往右扫描发现某个元素比基准大就把j先加一然后把i位置的元素和j位置的元素交换。你可以把j想象成一个“收纳指针”每一个大于基准的元素都会被换到它前面的位置去直到扫描结束。扫描完成后[left1, j]区间里的元素全部大于基准[j1, right]区间里的元素全部小于等于基准。最后一步把基准值arr[left]和arr[j]交换这样基准值就到了下标j的位置它的左边全是大于它的元素右边全是小于等于它的元素。最后返回j作为分界点。2.3 递归主体怎么写partition写好之后递归就很简单了void quickSortDesc(int arr[], int left, int right) { if (left right) { return; // 区间内只有0个或1个元素不用排 } int mid partitionDesc(arr, left, right); quickSortDesc(arr, left, mid - 1); // 排左边大于基准的部分 quickSortDesc(arr, mid 1, right); // 排右边小于基准的部分 }递归的终止条件是left right也就是说区间里没有元素或者只剩一个元素时直接返回。这个条件必须写在函数开头否则递归会无限调用最后爆栈。调试的时候可以在quickSortDesc开头加一行printf打印区间范围和partition返回值观察它是怎么一步步把区间缩小的。我第一次写着这个题调试时总感觉递归像套娃一样抽象后来才习惯“只信任边界不追踪全局”的调试方式。2.4 升序和降序到底差在哪一行把升序partition拿来对比一下降序版只需要改一个符号// 升序版的核心判断 if (arr[i] pivot) { j; // 交换 }而前面降序版的关键判断是if (arr[i] pivot) { j; // 交换 }对唯一的区别就是把换成了。基准值交换位置之前的分割逻辑完全一样。这其实是一个很容易被忽略的知识点partition算法的骨架是通用的变的只是“把什么样的元素归到基准左边”这个规则。我见过不少人在应付面试题时背快排模板结果考官随口问“怎么改成降序”当场愣住了。原因就是背代码不背原理看到的全是具体字符而不是一个“按规则划分区间”的抽象过程。2.5 用函数指针实现一个函数兼容升序降序如果工作里经常要同时支持升序、降序一个很实用的设计是引入函数指针或者比较器参数。C语言虽然没有C的模板或Java的泛型那么方便但函数指针完全够用。// 比较器类型返回正数表示 a b0 表示相等负数表示 a b typedef int (*Comparator)(int a, int b); // 升序比较器 int asc(int a, int b) { return a - b; } // 降序比较器 int desc(int a, int b) { return b - a; } // 通用快排通过 comp 控制升降序 void quickSortWithComp(int arr[], int left, int right, Comparator comp) { if (left right) return; int pivot arr[left]; int i left 1; int j left; for (; i right; i) { if (comp(arr[i], pivot) 0) { j; int temp arr[i]; arr[i] arr[j]; arr[j] temp; } } int temp arr[left]; arr[left] arr[j]; arr[j] temp; int mid j; quickSortWithComp(arr, left, mid - 1, comp); quickSortWithComp(arr, mid 1, right, comp); }调用的时候int data[] {3, 1, 4, 1, 5, 9, 2, 6, 5}; quickSortWithComp(data, 0, 8, asc); // 升序 quickSortWithComp(data, 0, 8, desc); // 降序这个方案的优点是排序逻辑只写一遍升降序完全由比较器决定。后续如果要改成按结构体某个字段排序也只需要换一个比较器排序函数本体完全不用动。这个设计模式在工作中的价值大于单纯的“会写一个降序快排”。3. 性能考量与优化手段3.1 快排的复杂度与退化场景快排的平均时间复杂度是O(n log n)但这里必须强调“平均”这两个字。它的时间复杂度高度依赖基准值的选择。在极端情况下比如数组已经有序而基准又恰好总是取第一个元素快排会退化成O(n²)。用脑补的方式理解这个退化每次partition只能分出一个元素的位置剩下的区间大小只减少一这样一来递归深度接近n每层又要做O(n)的扫描总耗时就是O(n²)。数据量一上来这种退化直接让程序从“秒完”变成“等到天荒地老”。降序版本同样存在这个问题而且更隐蔽。如果原始数组就是降序的而我们又要排降序那么基准值取第一个元素扫描一趟下来发现所有元素都大于基准partition返回值会等于right。这种情况下递归深度会退化成O(n)。这不是一个理论问题实测在十万级别数据量、已经有序的数组上未优化的快排会比随机数据慢一到两个数量级。3.2 基准值选择的工程解法解决退化问题的经典手段是三数取中法。在arr[left]、arr[mid]、arr[right]三个位置的值里选一个中位数作为基准值然后把它交换到left位置再走刚才的partition流程。三数取中的思路是从区间里抽三个样本取它们的中位值。样本越多基准越接近真正的中位数但抽三个性价比最高再多抽几个收益就不明显了。实现代码也不复杂int medianOfThree(int arr[], int left, int right) { int mid left (right - left) / 2; // 简单的三个数排序让 arr[left] arr[mid] arr[right] if (arr[left] arr[mid]) swap(arr[left], arr[mid]); if (arr[left] arr[right]) swap(arr[left], arr[right]); if (arr[mid] arr[right]) swap(arr[mid], arr[right]); // 此时中位数在 mid 位置 return arr[mid]; }然后在partition中做一次交换把mid位置的基准值放到left位置后面的逻辑就完全不用改了。在实际工程中再进一步的做法是随机选基准。函数内调用rand() % (right - left 1) left取一个随机下标当基准。随机化的好处是不管数据怎么排出现最坏概率都变得极低。因为数据本身可能是有序的但基准是随机抽的不会每次都恰好命中极端位置。我自己的习惯是数据量小的时候无所谓随便写数据量上了十万就老老实实用三数取中或者随机基准别赌运气。3.3 小区间插入排序削减递归开销快排的递归调用是有开销的。每次递归都要压栈、传参数、函数跳转。当区间缩小到十几二十个元素时继续递归的性价比很低因为插入排序在这种小数据量下反而更快。一个常见的优化是在quickSortDesc里加一个阈值判断void quickSortOptimized(int arr[], int left, int right) { if (left right) return; if (right - left 1 16) { insertionSort(arr, left, right); return; } int mid partitionDesc(arr, left, right); quickSortOptimized(arr, left, mid - 1); quickSortOptimized(arr, mid 1, right); }插入排序的实现很简单专门针对小数组void insertionSort(int arr[], int left, int right) { 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; } }这段插入排序也要写成降序比较条件用的是arr[j] key意思是只要前面的元素小于当前值就往前移。别忘了自己写的时候也要同步改符号。这个阈值16是一个经验值不同编译器和平台上最优阈值略有差别一般在10到30之间。不做深度调优的话取16就很稳妥。3.4 三向切分解决大量重复元素问题如果数组里有大量重复值比如一万个元素里有九千个是同一个值普通快排会做大量无意义的交换。因为partition把等于基准的元素分到哪边都行无论在哪边递归时都会重复处理它们。三向切分3-way partitioning的思路是把数组分成三段大于基准、等于基准、小于基准。递归时只处理大于和小于这两段等于段直接跳过。降序三向切分的代码思路如下void quickSort3Way(int arr[], int left, int right) { if (left right) return; int pivot arr[left]; int i left 1; int lt left; // lt 及其左边都是大于 pivot 的元素 int gt right; // gt 及其右边都是小于 pivot 的元素 while (i gt) { if (arr[i] pivot) { swap(arr[i], arr[lt 1]); // 这里细节较多需要边写边验证 lt; i; } else if (arr[i] pivot) { swap(arr[i], arr[gt]); gt--; // i 不自增因为换过来的 gt 元素还没判断过 } else { i; } } // 最后 arr[lt1..gt] 全是等于 pivot 的部分 // 把 pivot 从 left 放到 lt 的位置 swap(arr[left], arr[lt]); quickSort3Way(arr, left, lt - 1); quickSort3Way(arr, gt 1, right); }三向切分的代码比普通版要难写一点因为三个边界下标lt、i、gt的移动规则很容易搞混。但它在处理实际业务数据时效果显著销售数据、评分数据、成绩数据里重复值非常常见普通快排会白白消耗大量时间。3.5 尾递归优化控制栈深度之前说过快排在极端情况下递归深度能达到n这在大数组上可能直接导致栈溢出。一个常见的缓解手段是把递归改成尾递归形式也就是只递归调用其中一个子区间另一个子区间用循环迭代处理。void quickSortTail(int arr[], int left, int right) { while (left right) { int mid partitionDesc(arr, left, right); // 只递归短的区间长的区间继续循环处理 if (mid - left right - mid) { quickSortTail(arr, left, mid - 1); left mid 1; } else { quickSortTail(arr, mid 1, right); right mid - 1; } } }这个优化的巧妙之处在于每次都递归处理较短的子区间长的子区间留在循环里继续切分。这样做能让递归深度保持在O(log n)量级因为每次递归处理的区间大小至少减半。尾递归优化在实际工程里能降低栈溢出的概率但它的可读性确实比普通递归版本差一些。如果不是明确遇到深度过大的问题初学者还是先保住递归写法就好。4. 常见问题与排查实录4.1 最经典的排序结果不对比较符号反了这个是最容易犯的错误。分区逻辑是降序但最后的递归区间范围没对应上或者插入排序写着写着又用回升序的判断导致结果乱七八糟。症状很典型排序出来的数组前半段有点降序的意思但后半段又不对了或者整个序列看起来“部分有序但局部错乱”。排查方法先用升序版跑一遍确认数组本身没问题再改降序跑。如果升序对而降序错99%是partition里那个最终交换条件或者递归区间写错了。降序版里基准值左边放的是“大于”基准的元素右边是“小于等于”的这个规则要刻在脑子里。4.2 死循环与无限递归程序运行到一半不输出了任务管理器里CPU占用拉满大概率是死循环。快排里的死循环通常来自边界处理不当。举个例子如果partition的返回值在某些情况下等于left或者right而递归时又使用了包含这个哨兵位置的区间就会导致区间没法缩小递归没完没了。正确的递归边界是quickSortDesc(arr, left, mid - 1)和quickSortDesc(arr, mid 1, right)。基准值本身在partition结束后已经在正确位置上了不需要再参与排序。如果写成了[left, mid]或者[mid, right]就会出问题。碰到死循环时的排查技巧在递归函数开头打印left和right看到输出一直是同一个区间就说明边界写错了。4.3 数组越界C语言里数组越界不会像Java那样抛异常它只是悄悄地访问了不该访问的内存程序可能在某个时刻崩溃也可能在排序中途就把数据改乱了非常隐蔽。最容易越界的场景是在函数外部定义数组后调用时传入的right参数写成数组长度而不是最后一个元素的下标。比如数组有10个元素正确传法是quickSortDesc(arr, 0, 9)如果传10循环里i right就会访问arr[10]这已经越界了。实战经验是不要依赖调试器去查越界崩溃直接在入口写防御性检查打印传入的right和sizeof(arr)/sizeof(arr[0]) - 1对比一下一眼就能看出问题。4.4 大量重复元素时的性能陷阱前面提到三向切分是解决这个问题的杀手锏。但如果你已经写完了普通版本还没做三向切分优化可以先做一个低成本的“简单去重”优化如果区间内的第一个元素、中间元素、最后一个元素都一样说明这个区间全是同一个值直接跳过不排。这个只是临时救急正规场景还是三向切分最可靠。我实测过在50万条全是重复值的数组上普通快排耗时是三向切分优化版的六倍以上差距非常大。4.5 常见问题速查表症状可能原因解决办法排序结果完全错误partition比较符号写反检查arr[i] pivot是否按降序书写部分有序部分乱递归区间边界包含基准值改为[left, mid-1]和[mid1, right]程序死循环区间不缩小或递归边界错误打印left和right交叉核对区间程序崩溃数组越界检查right参数是否等于长度-1大量重复数据时极慢普通快排无三向切分换用三向切分版本有序数组时退化成O(n²)基准选择固定为端点值用三数取中或随机基准同分元素相对顺序变了快排本身不稳定需要稳定排序时改用归并排序5. 扩展实践从整数到结构体的降序排序5.1 按结构体字段排序很多实际业务里排序的对象不全是整数而是结构体数组。比如一个学生结构体有学号、姓名、成绩、班级要按成绩降序排列成绩相同的再按学号升序。C语言中结构体排序最常见的方式还是手写比较器。在手写partition的时候不要真的去交换整个结构体可能很大而是对下标索引或者指向结构体的指针进行排序排序结束后按顺序输出或回填。以身高排名为例结构体定义typedef struct { char name[32]; int height; } Person; // 降序比较器按身高从高到低 int personDescByHeight(const void* a, const void* b) { const Person* p1 (const Person*)a; const Person* p2 (const Person*)b; return p2-height - p1-height; }然后可以直接用C标准库的qsort函数它内部就是快排实现Person arr[100]; // ...填充数据... qsort(arr, 100, sizeof(Person), personDescByHeight);qsort是C标准库自带的快排传入比较器即可升降序由比较器的返回正负控制。需要注意personDescByHeight里p2-height - p1-height如果身高有负数或者int溢出风险更稳妥的写法是if (p2-height p1-height) return 1; if (p2-height p1-height) return -1; return 0;这种写法避免了相减导致的整数溢出。虽然身高一般不会大到溢出但这是一种好习惯可以养成。5.2 自定义快排还是直接调qsort一个很现实的问题既然标准库有qsort为什么还要自己写快排答案是为了理解和工作需要。笔试面试里考官经常要求手撕快排这不只是考察背诵更是考察边界处理能力和递归思维。另外标准库qsort的实现通常高度优化但它的通用性也带来了一些额外开销因为比较器是通过函数指针调用的没法内联。如果要排序的数据量极大有极致性能要求手写一个针对特定数据类型的快排用内联比较逻辑替代函数指针往往能再挤出一部分性能。不过日常开发中我的建议是优先用标准库qsort它经过广泛测试边界稳省时省心。自己写的版本更多用于面试、教学、特殊场景优化。5.3 多级排序的降序快排实现思路多级排序比单级复杂一点。一个常见的需求是先按成绩降序成绩相同再按学号升序。这种需求用比较器解决非常丝滑int compareStudent(const void* a, const void* b) { const Student* s1 (const Student*)a; const Student* s2 (const Student*)b; if (s2-score s1-score) return 1; if (s2-score s1-score) return -1; // 成绩相同按学号升序 if (s1-id s2-id) return 1; if (s1-id s2-id) return -1; return 0; }值得注意的是多级排序的判断顺序决定了排序结果的优先级。把哪个字段放前面哪个字段就是主排序键。比较器只负责定义“谁应该排在前面”排序算法不用关心这个规则是怎么算出来的这个解耦设计非常干净。6. 实测心得与经验补充6.1 数据规模对算法选择的影响我做过一个简单的体验测试随机生成1000、10万、100万个int数据分别用冒泡排序、普通快排和优化快排三数取中插入排序跑一遍。1000数据量时几乎看不出区别都在毫秒级。10万数据量时冒泡已经明显吃力快排还是“秒完”。100万数据量时未优化的快排如果恰好碰上偏有序的数据能明显感觉到卡顿而优化版始终稳定。这个实验让我彻底明白了为什么说单论性能表现必须结合数据特征。降序快排不是万能药但它在绝大多数非极端情况下都能给出很优秀的性能。6.2 调试修炼打印、随机化、断言调试快排时我最常用的是三个工具第一在递归函数开头打印当前区间[left, right]肉眼确认递归边界。第二步用少量数据比如8个元素反复跑观察partition返回值是否在合法范围内。第三写一个小断言检查partition返回值永远在[left, right]之间。还有一个挺有用的小技巧做测试用例时覆盖几种典型场景——完全随机、完全有序、完全降序、全部相等、只要一个元素、空数组。把这几类用例跑通排序函数基本就稳了。6.3 快排思想在别处的延伸学会了快排不光是会排序。快排的分治思想和partition手法在很多地方都用得上。比如经典的”查找数组中第K大的元素“用partition一趟扫描后根据返回的j值和K的关系只递归一侧时间复杂度能降到O(n)平均。再比如把数组按奇偶分成两部分、把负数移到正数前面、把0移到末尾这些本质上都是partition的变形。编程里多一种思想就多一把解决问题的刀。升降序快速排序看起来只是一个小练习但它背后代表的“按规则划分递归解决”的思路能帮你处理很多非常规的需求。6.4 最后的实操小建议根据我的经验给正在学习快排的人几个实操建议一不要只盯着升序代码看把降序版本自己动手写几遍从partition到递归完整过一遍。二用纸笔模拟一趟partition的执行过程把数组下标和值的变化一步步写出来书到用时方恨少这一步比看十遍代码都有效。三写完代码后务必跑一下全等元素、空数组、单元素这几个边界用例。还有如果你是在面试里写快排先把思路讲清楚再说“我习惯用单边扫描法降序就是比较符号换一下基准选择我会用三数取中”面试官会觉得你不仅会背模板对细节也有掌握。这一点在实际面试里很加分。这篇内容写到这里算是我个人对快排降序实现的一次完整记录。我曾经因为一个排行榜需求临时改写快排也曾经因为边界条件在深夜排查死循环这些经验今天都沉淀成了文字里的一个个注意点和测试建议。排序算法是基础中的基础但真正吃透它之后你在遇到更复杂的算法问题时会多一分底气。