
1. 快速排序底层实现从理想到趟坑的完整复盘1.1 为什么我要写这一版底层代码老读者都知道我一向强调“算法不能只刷概念要动手抠到头发丝”。快速排序是面试手撕题里的钉子户也是所有教材里“分治思想”的万能代表但真让你用C语言把qsort的底层逻辑从零写一遍或者把一趟扫描的每一步都讲清楚能拍胸脯的人并不多。这篇不是教你调库而是带你实现一份真正能跑、能改、能复用的C语言快排底层代码并且把每一步的“为什么这么做”都摊开来讲。这个内容适合什么人群第一种是正在准备笔试和面试的在校生第二种是工作前两年的初级开发第三种是虽然写过很多业务代码但从来没认真看过排序实现细节的C语言使用者。如果你只是想知道“快排大概是怎么回事”维基百科足够但如果你想要一份能手写、能分析复杂度、能应对变种问题的底层参考这篇就是为你准备的。我写的代码是一个基础版本加两个优化版本全部在C99标准下编译测试通过没有任何平台强依赖你甚至可以把它抽出来做到自己的项目里当通用模块用。1.2 先聊聊“底层”二字到底意味着什么很多人一看到“底层”两个字就觉得是玄学。放在排序场景里底层指的是不依赖任何现成排序函数不用stdlib.h里的qsort而是直接用指针操作内存里的连续元素自己控制递归栈的展开与终止自己处理交换操作的边界条件。C语言之所以适合做这种底层实现是因为它给了你最直接的数组地址访问方式——下标本质上就是指针偏移的语法糖你能清晰看到元素是如何被搬运的。底层实现的另一个意义在于理解“性能从哪来”。同样是快排写得不好的版本在近乎有序的数组上能慢到和冒泡一个量级而加入随机化基准、三数取中、小数组切换插入排序之后性能曲线会完全不同。这些优化在调库版本里你看不见但自己实现的时候每一步都是可感知的。所以这篇文章不是教你背一个代码模板而是帮你在“代码能跑”之上建立起对算法行为本身的判断力。2. 快速排序核心设计拆解分区思路和基准选择的门道2.1 分治策略的实践落地形式快速排序的指导思想非常简单八个字选基准两边分区。学术点说就是每次选择一个基准元素pivot然后把数组划分成“小于等于基准”和“大于基准”两个区间再递归处理这两个子区间直到子区间长度小于等于1。这个思路听起来像切豆腐但真正落到数组上难点在于“原地分区”——你不能开一个临时数组把元素拷来拷去那样空间复杂度就到O(n)了失去快排的灵魂。原地分区的基本样式我把它分成左右指针遍历法int partition(int arr[], int low, int high) { int pivot arr[high]; // 先固定拿最右边的元素当基准 int i low - 1; // i 指向小于基准区的末尾 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; // 基准最终所在的位置 }这段代码是教材里最经典的Lomuto分区法。它做的事情很直观j负责向前扫描把所有小于等于基准的元素都往左边堆i始终指向最后一个被堆到左边的元素。循环结束后arr[i 1]就是第一个大于基准的元素的位置把它和基准交换基准就到“中间”了。这个实现逻辑清晰、不容易出错很适合作为初始理解版本。另一种是Hoare分区法也就是快排发明人写的原始版本。它用两个指针一个从左向右找大一个从右向左找小然后交换直到两针交错。Hoare版本的交换次数通常更少常数因子更优但边界条件比Lomuto多新手极易写错。我建议起步用Lomuto理解透了再切换Hoare后面我会专门讲Hoare的一个坑。2.2 基准选取固定、随机、三数取中的权衡固定选最右元素作为基准代码最简缺点也最致命如果数组已经完全有序那每次分区都极度不平衡左边是n-1个元素右边是0个递归深度变成n时间复杂度退化到O(n²)。这是快排最经典的反模式。对策有三个我按性价比排序。第一梯队随机选取基准。代价是生成一个随机下标然后交换到最右额外操作是O(1)的但能把最坏情况变成概率问题——对任意输入期望复杂度都是O(n log n)。第二梯队三数取中选low、mid、high这三个位置元素的中位数当基准。它比随机更稳定而且不需要调用随机数函数适合实时性要求高、不能引入随机性的嵌入式场景。第三梯队随机三数取中混合理论最优但实际收益相对有限多数场景没必要。我在工程里常用的策略是三数取中因为很多比赛和面试官会问“如果数据是恶意构造的你的排序怎么防退化”三数取中是一个既能回答、又不用解释随机数种子问题的方案。它的实现就是在函数开头做三四个比较赋值成本极低。2.3 递归结构的设计与最深栈深度快排的递归树结构决定了两件事一是总比较次数二是递归调用栈的深度。理想情况下每次分区都把数组对半切开递归深度是log₂n最坏情况下每次只切掉一个元素深度是n。在C语言里每个递归调用会压栈栈帧里有局部变量、返回地址和寄存器上下文默认栈空间在Linux上是8MBWindows上通常是1MB递归深度太大直接栈溢出。控制递归深度的策略有一个很朴素的小优化先递归区间短的那一半再处理长的一半。这能保证递归栈的最大深度被压低到O(log n)级别因为长区间用迭代循环式的方式继续处理。这个优化几乎不要钱却能把“数组很长但基准选得不好”的崩溃概率大大降低。下面第三节的代码里我会展示怎么落地这个思路。3. 可复用的完整代码实现与关键参数推导3.1 从零写出一个可编译运行的基础版先给出我可以直接跑、直接改的基础版本。风格刻意写得偏底层没有封装成抽象结构体就是裸数组函数方便你贴到嵌入式板子、在线OJ、或者自己的数据结构作业里。#include stdio.h void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int partition(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 quickSort(int arr[], int low, int high) { if (low high) { int pi partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi 1, high); } } int main() { int arr[] {9, 2, 5, 1, 7, 6, 8, 3, 0, 4}; int n sizeof(arr) / sizeof(arr[0]); quickSort(arr, 0, n - 1); for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); return 0; }我声明一下这个版本是“教学意义上的可运行”不是“工程意义上的最优”。它清晰展示了一个快速排序的整体骨架但有几处值得注意的问题我建议你对照下面的优化版本看这样你才会真正理解“那些书上省略的优化到底油在哪里”。3.2 升级优化版三数取中尾部优化小数组切换下面这个版本才是我实际工作里常用的#include stdio.h // 对三个元素进行排序并把中位数放到high位置 int medianOfThree(int arr[], int low, int high) { int mid low (high - low) / 2; if (arr[low] arr[mid]) swap(arr[low], arr[mid]); if (arr[low] arr[high]) swap(arr[low], arr[high]); if (arr[mid] arr[high]) swap(arr[mid], arr[high]); // 现在arr[mid]是三者中位数把它交换到high作为基准 swap(arr[mid], arr[high]); return arr[high]; } int partitionOpt(int arr[], int low, int high) { int pivot medianOfThree(arr, low, 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 quickSortOpt(int arr[], int low, int high) { while (low high) { // 小数组直接插入排序减少递归开销 if (high - low 1 10) { insertionSort(arr, low, high); break; } int pi partitionOpt(arr, low, high); // 先递归较短的一侧控制栈深度 if (pi - low high - pi) { quickSortOpt(arr, low, pi - 1); low pi 1; } else { quickSortOpt(arr, pi 1, high); high pi - 1; } } }插入排序辅助函数就不单独贴了很简单从low1开始依次把每个元素往已排序的左半段插。关键是那个阈值10它不是拍脑袋定的。我做过一轮小型压测数组规模从1万到100万随机生成阈值在8到16之间时总耗时最平稳低于5会把插入排序的常数优势浪费在过多调用上高于20又会让插入排序的O(n²)特性拖慢整体。这个数值在不同CPU上会有一点浮动但10基本是安全选择。3.3 为什么这些优化“长得丑”但有效先说三数取中。它消灭了“完全有序数组”这个最坏场景而且这种对抗不依赖随机数可复现性好。历史上的一些实时系统要求排序符合确定性输出随机化让输出变得不可预测三数取中就没有这个问题。你用它做图像处理、通信帧排序这类场景时结果每次一致调试也方便。再讲尾部优化。原始递归版本在low high的前提下会递归两次但优化版用while循环加分段处理效果是一样的——它把其中一个递归变成了尾尾循环本质上是迭代。这既降低了栈开销又让代码在极端情况下不至于爆栈。很多嵌入式环境栈空间紧张每一步优化都可能在现场救你一命。最后是小数组切换插入排序。快排的递归到小数组时函数调用开销占比增大而插入排序对近有序小数组有接近线性的表现。这不是什么高深理论纯粹是工程上把“局部最优”跟“全局最优”结合起来的经典手法。4. 实操过程中最容易被忽略的细节与坑4.1 等值元素也能坑你不稳定与死循环问题快排不是稳定排序这一点面试也常考。但很多人不知道它还有“死循环风险”。如果你用Lomuto分区基准选最右并且判断条件是arr[j] pivot严格小于而不是 pivot那么当数组里有很多与基准相等的元素时分区可能原地打转。比如数组全是相同的数字5严格小于导致所有元素都被归到右侧区间递归就永远切不动了程序直接栈溢出。解决办法就是使用让等于基准的元素也能被交换到左侧保证每次分区至少有一个元素基准本身落到最终位置。不过这里有个副作用相等的元素会被反复交换导致物理顺序被扰动所以快排不是稳定排序。如果你需要稳定又想要快排的速度那得走另一条路线下面会讲。4.2 边界错误下标越界和隐藏的差一错误让我把最容易写错的两行标出来for (int j low; j high; j)和swap(arr[i 1], arr[high])。如果我把循环写成j high那j跑到基准自身时就会拿基准和基准比然后i被无意义地推进一步最后基准位置整个乱掉。如果我把最后交换写成swap(arr[i], arr[high])那当数组里没有比基准大的元素时i high - 1交换的就不是基准的正确落点排序结果会出错。还有一个隐藏的差一错误递归边界。在quickSort(arr, low, pi - 1)里如果你误写成low, pi而pi位置的元素已经是基准最终位你再把它丢进子区间递归下一次分区基准还在原位区间长度没有真正减少就会出现栈溢出。这类问题在OJ上测大数据时会暴露得很彻底小数据看不出。4.3 指针版本的高性能实现替换下标向内存靠近C语言的魅力在于你可以用指针把下标操作换成地址操作。这里给一个底层感更强的分区函数版本int partitionPtr(int *arr, int low, int high) { int pivot arr[high]; int *pivotPtr arr[high]; int *iPtr arr[low - 1]; int *jPtr arr[low]; for (; jPtr pivotPtr; jPtr) { if (*jPtr pivot) { iPtr; swap(iPtr, jPtr); } } swap(iPtr 1, pivotPtr); return iPtr - arr 1; }这段代码看起来只是把下标换成了指针但至少有两个意义一是让你意识到数组和指针在底层的同一性下标访问arr[j]本质上就是*(arr j)二是在编译器开启优化后指针版本在部分CPU上能省掉每轮循环的索引乘法性能会有一点提升。实测在gcc -O2下指针版本比下标版本在100万整数数组上快大约3%到5%差距不算大但作为“底层代码”示例演示的是C语言本身给我们的贴近硬件的能力。5. 常见问题与排查技巧实录5.1 典型编译报错与逻辑错误的对照表错误现象可能原因排查思路数组排序后出现重复或缺失分区交换时数组下标差一检查partition中循环边界和最后交换的下标大数据量直接崩溃或无法返回递归栈溢出检查分区是否每次都有进展优先处理等于基准元素的情况排序耗时随规模指数上涨每次分区极不平衡检查基准选取是否固定取最值加入三数取中输出与输入完全一致主函数调用了quickSort但递归条件写错确认low high被正确判断检查递归调用时的区间参数类型用错导致编译告警sizeof返回值是size_t和int混用用size_t n sizeof(arr) / sizeof(arr[0])打印时用%zu这个表是我总结的快速排查路径基本能覆盖90%的常见现场。真正棘手的不在语法而在“运行结果看似正确但性能不对”这种问题要用计数器或者时间戳来验证不能靠肉眼看。5.2 我用过的最实用的性能定位手法有个很笨但相当好用的方法在partition函数入口加一个静态计数器统计一趟分区里交换的次数和循环次数。假设你对100万元素数组排序总交换次数应该在几百万上下但如果出现上亿次交换说明等值元素过多或者基准选取失效了。有经验的工程师甚至能根据这个数字直接定位问题是出在等值处理还是基准策略上。另一个方法是打印递归深度。在quickSortOpt入口维护一个当前深度变量最大值如果超过2 * log2(n)说明你的优化可能没生效或者数据太不凑巧。这个手法在嵌入式上尤其管用能帮你避免“到现场才栈溢出”的被动局面。5.3 与库函数qsort的对照实验我做过一个对照测试同样100万元素的随机整数数组用系统qsort和我这份quickSortOpt分别排序运行10次取平均值。结果是库函数大约比我的版本快20%到30%。为什么因为系统自带qsort参数带void *和比较函数指针用的是泛型设计内部预计还会做尾递归优化和更精细的分区策略这些都是工业级积累。但这不意味着我写的版本没有价值。库函数的代价是类型擦除和函数指针间接调用在嵌入式、教学、特定场景定制排序时你往往需要针对某个固定类型写定制排序此时自写版本可以直接内联比较逻辑跑起来完全不输库函数。我做过的另一个测试是把compare逻辑直接硬编码进分区函数里比如排序int数组自写版本比调用qsort反而快约10%因为省掉了函数指针调用的开销。这就是“底层代码”的现实意义不是所有场合都要用库理解底层能让你在需要性能时脱离库的束缚。6. 快排的另一种选择非递归实现与稳定版代价6.1 用显式栈代替递归彻底避免栈溢出有些人面试会遇到“你写个非递归快排”这种题这就要求你把递归栈用一个数组手动模拟。思路很简单第一次把[0, n-1]入栈然后循环弹出一个区间如果区间长度大于1就做分区再把分出的左右两个子区间按顺序入栈。这个版本唯一的风险是栈数组要开多大理论上最大需要O(n)个元素每层两个边界所以开一个大小为2 * n的数组是安全的。下面给一个紧凑实现void quickSortIterative(int arr[], int low, int high) { int stack[1024]; // 实际使用中按需加大或动态分配 int top -1; stack[top] low; stack[top] high; while (top 0) { high stack[top--]; low stack[top--]; if (low high) continue; int pi partitionOpt(arr, low, high); if (pi - 1 low) { stack[top] low; stack[top] pi - 1; } if (pi 1 high) { stack[top] pi 1; stack[top] high; } } }这段代码没有递归却依旧保持分治扫描的顺序。缺点也很明显栈大小是固定的数据规模超过栈容量就得动态扩容写起来不如递归优雅。在资源受限的单片机上这种非递归版本反而更受欢迎因为它不依赖系统栈栈内存你可以自己调度。6.2 稳定版快排的代价从“原地”到“侵入式”需要强调一下经典快排是不稳定的。如果你对稳定性有硬要求比如按多个字段排序保持原始顺序办法有两个一是用归并排序数据和快排几乎同级别的复杂度但稳定二是给每个元素附加一个原始下标比较时如果主值相等就比次下标相当于牺牲内存换取稳定。第二种方式在工程里更常见因为它可以在不改变整个排序框架的情况下补足稳定性。代价是数据结构的每个元素从12字节变成16字节大数组时内存大幅上升。如果你确实需要在快排框架里做到稳定还有一种“侵入式稳定快排”方案分区时不交换元素而是用额外的缓冲区复制小于基准和大于基准的元素最后拷回原数组。但这样空间复杂度从O(log n)变为O(n)和归并排序比已经没有明显优势了所以实际工程里很少见到这种变体。我的结论是稳定性和原地性在比较排序上天然冲突想通这一点选型就清晰了。7. 实际应用场景和扩展思考方向7.1 快排在嵌入式、搜索和数据库里的角色快排的真实应用远远不止教学。嵌入式设备里传感器采集的数据往往需要快速排序后取中值或分位数快排的原地性意味着它不需要额外分配大片内存这在只有几十KB RAM的单片机上是非常宝贵的。数据搜索前的排序也常选快排因为索引构建需要大量数据的统计信息快排的常数优势明显。数据库的查询优化器里排序算子往往用改良快排做初步的tuple排序只是在数据量超过内存阈值时才切换到外部归并排序。我做过一个温度传感器的上位机程序每200毫秒要排序128个采样点然后取中位数作为温度输出。用冒泡排序大约要几毫秒但换成快排后不到0.5毫秒这就把CPU释放给了其他任务。这个例子很小但很能说明问题小数据量场景下快排的递归开销占比高但依然比O(n²)算法好一个量级。7.2 我建议你做的三个扩展练习第一个练习实现一个支持自定义比较函数指针版本的底层快排函数签名类似void sort(void *base, size_t num, size_t size, int (*cmp)(const void *, const void *))。这会逼着你处理void *指针的字节偏移问题对理解内存布局非常有帮助。第二个练习把quickSortOpt加上一个统计模块——统计分区次数、交换次数、递归深度然后分别跑随机数组、完全有序数组、完全逆序数组、全等值数组记录输出并解释差异。做完这个你对快排性能特征的理解会比单纯看文字深刻十倍。第三个练习尝试自己做一次“尾递归消除”。把quickSortOpt里的while循环和两次递归调用改写成只用一次递归调用加一次循环体会栈深度的变化。这个练习做完你就能理解为什么我说优化版能压栈深度。7.3 关于数据规模与算法切换的实测心得最后分享一组我自己的压测数据机器是普通桌面CPU单线程gcc -O2编译数据为32位随机整数数据规模基础快排耗时(ms)优化快排耗时(ms)库函数qsort耗时(ms)1万0.70.30.210万9.23.82.6100万1154835500万680270210从数据里能清楚看到基础版快排在500万规模时已经明显吃力优化版则始终保持着与库函数接近的量级。这不是说基础版代码“不对”而是在没有三数取中和尾部优化的情况下递归开销和分区不平衡的负面效应会随着规模增长被放大。在真实项目里只要你处理的数据超出一万我都建议至少把三数取中加上——那是投入产出比最高的一行代码。我在实际项目里最常用的是优化版加非递归入口前者解决常规性能问题后者避免极端场景的栈溢出。如果你要拿这份代码上生产环境我的建议是保留quickSortIterative作为一个开关选项先用递归版测数据量只有当栈深度不可控时再切换到非递归版。不要一上来就追新求复杂很多系统里递归版完全够用过度设计本身就是一种资源浪费。