先说一句掏心窝的话:这道题我在课程作业、面试手写代码、甚至帮人改代码时都见过无数次。“C语言实现快速排序,以第一个元素作为枢轴”,听起来像教科书里最经典的开篇案例,可真正动手写的时候,大多数人卡在了同一个地方——单趟划分的指针移动顺序和边界条件。网上代码一搜一大把,但能讲清楚为什么这么写、哪里容易翻车的,真不多。
这篇文章我不打算只丢一段能跑的代码给你。我会把“以第一个元素作为枢轴”这件事拆开揉碎,从核心思路、单趟划分手推过程、完整C代码实现,到最容易被忽略的性能退化问题,再到我实际调试中踩过的坑,一次性讲透。不管你是正在学数据结构的学生、准备算法面试的开发者,还是想把这个经典写法真正吃透的C语言爱好者,都能在这里找到你需要的东西。
1. 这个题到底在考什么
1.1 快速排序的核心思想,一句话就能说清
快速排序(Quick Sort)是典型的分治(Divide and Conquer)算法。它的核心思路其实特别朴素:从待排序区间里挑一个元素,把它当作“枢轴”(pivot),然后把剩下的元素分成两拨,比枢轴小的放到它左边,比枢轴大的放到它右边。这样一来,枢轴元素就落在了它最终应该在的位置上。
接下来就简单了:枢轴左边的子区间和右边的子区间,分别再做同样的事情,递归下去。直到子区间里只剩一个元素或者空,排序就结束了。
这个过程有个特别直观的类比:想象你是一群人的组织者,你随机点名一个人当“标杆”,然后让所有比他矮的人站到他左边,所有比他高的人站到他右边。现在标杆的位置已经对了,剩下要做的事情就是让左边那一堆人再重复这个动作,右边那一堆人也重复这个动作。每一轮都在“归位”一个标杆,递归下去,所有人就都站好了。
1.2 为什么题目偏偏指定“第一个元素当枢轴”
“以第一个元素作为枢轴”这个要求,很多人不理解,觉得这不是很蠢吗?如果数组本来就是有序的,第一个元素当枢轴,岂不是每次都把区间切成长度1和长度n-1这样极端不对称的两部分?
这里要分两层看。第一层,教学和考试场景里,这个指定是为了让算法的步骤“可预期”。不管你输入什么数组,第一个元素是谁,划分规则就是固定的,这样老师可以手推步骤、出题考过程,学生也容易验证自己写的代码对不对。它牺牲了一部分性能上的聪明劲儿,换来了清晰和简单。
第二层才是真正值得你记住的:它暴露了“固定选枢轴”这一策略的天然缺陷。这种缺陷在工程上是致命的,但在学习阶段却非常有价值——它逼你去思考,为什么工业级的快速排序实现,比如很多标准库里的排序函数,都不会死板地用第一个元素当枢轴,而是要加随机化或者三数取中。理解了这一点,你才算真正把快速排序学透了,而不只是背代码。
我先说结论,后面会专门用一整节来展开:如果数组恰好是正序或逆序,固定选第一个元素会导致快速排序退化成“慢速排序”,时间复杂度从平均的O(n log n)直接掉到O(n²)。这不是危言耸听,我实测过10000个有序整数,排序耗时和10000个随机整数差了两个数量级。
2. 单趟划分:整个算法的灵魂
2.1 挖坑法:双指针的动态过程
以第一个元素作为枢轴的实现方式里,我个人最推荐的是“挖坑法”(也叫挖坑填数法)。原因很简单:它把元素交换的动作,变成了“挖坑”和“填坑”的交替移动,对初学者来说极为直观,也特别不容易在细节上出错。
先把第一个元素的值用一个变量pivot保存起来。取出这个值之后,在数组逻辑上就出现了一个“坑”,它在最左边。接下来安排两个指针:i指向左边界(也就是坑的位置),j指向右边界。
规则就两条:
- j从右往左走,找到一个比pivot小的元素。找到了,把它填到i当前的坑里。填完之后,j的位置就变成了新的坑。
- i从左往右走,找到一个比pivot大的元素。找到了,把它填到j当前的坑里。填完之后,i的位置又变成了新的坑。
如此循环往复,i和j互相往中间靠拢,不停“填坑—产生新坑”。直到i和j相遇,这个位置就是pivot的最终归宿,把pivot填进去,单趟划分就结束了。
你可能会问:为什么一定要先从j开始,而不是先动i?因为坑在左边,你必须有东西来填这个坑。右边找到一个小的填过来,左边才有资格腾出新的坑给右边的大元素。如果反过来,先让i找大的,你会面临一个尴尬局面:左边坑还没填,你又把一个大值挖走,数组里就会同时出现两个坑,逻辑就乱了。这个顺序不是约定俗成的摆设,是保证单趟划分正确性的必要条件。
2.2 一个完整的手推示例,带你走一遍
光说概念没用,我拿一个具体数组手推一遍。假设数组是:
arr[6] = {5, 3, 8, 2, 1, 7}区间是0到5,第一个元素arr[0]=5就是pivot。
初始状态:i=0,j=5,pivot=5。arr[0]被挖走,数组逻辑上变成:
[_ , 3, 8, 2, 1, 7]第一回合,j从5开始往左找比5小的数:
- j=5,arr[5]=7,7不小于5,继续。
- j=4,arr[4]=1,1小于5,停。
- 把arr[4]=1填到i=0的坑里。数组变成:
[1 , 3, 8, 2, _, 7]现在坑在j=4的位置。
第二回合,i从0开始往右找比5大的数:
- i=0,arr[0]=1,1不大于5,继续。
- i=1,arr[1]=3,3不大于5,继续。
- i=2,arr[2]=8,8大于5,停。
- 把arr[2]=8填到j=4的坑里。数组变成:
[1 , 3, _, 2, 8, 7]现在坑在i=2的位置。
第三回合,j继续从4往左找比5小的数:
- j=4,arr[4]=8,8不小于5,继续。
- j=3,arr[3]=2,2小于5,停。
- 把arr[3]=2填到i=2的坑里。数组变成:
[1 , 3, 2, _, 8, 7]现在坑在j=3的位置。
第四回合,i继续从2往右找比5大的数:
- i=2,arr[2]=2,2不大于5,继续。
- i=3,此时i和j相遇了,外层循环结束。
最后,把pivot=5填到i=3这个坑里。数组变成:
[1, 3, 2, 5, 8, 7]返回值是3。你可以检查一下:5左边的1、3、2全都小于5,5右边的8、7全都大于5。这个位置就是5在有序数组中最终的归宿。
如果你把整个过程在纸上画一遍,你会明显感觉到“坑”像接力棒一样从左传到右,又从右传回左,最后停在中间。理解了这一次手推,你就掌握了挖坑法的全部精髓。
3. 完整实现与关键细节解析
3.1 可以直接复制的C语言代码
下面是一份完整的、自带测试用例的C语言实现。你在本地环境里编译运行,可以立刻看到效果。
#include <stdio.h> // 单趟划分:以区间第一个元素为枢轴,返回枢轴最终的下标 int partition(int arr[], int left, int right) { int pivot = arr[left]; // 取出枢轴的值,left位置变成“坑” int i = left; int j = right; while (i < j) { // 从右向左找第一个小于pivot的元素,填左边的坑 while (i < j && arr[j] >= pivot) { j--; } arr[i] = arr[j]; // 从左向右找第一个大于pivot的元素,填右边的坑 while (i < j && arr[i] <= pivot) { i++; } arr[j] = arr[i]; } // i和j相遇,这个位置就是pivot的最终位置 arr[i] = pivot; return i; } // 递归实现快速排序 void quickSort(int arr[], int left, int right) { if (left >= right) { return; } int pos = partition(arr, left, right); quickSort(arr, left, pos - 1); // 排序左子区间 quickSort(arr, pos + 1, right); // 排序右子区间 } void printArray(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main() { int arr[] = {5, 3, 8, 2, 1, 7}; int n = sizeof(arr) / sizeof(arr[0]); printf("排序前: "); printArray(arr, n); quickSort(arr, 0, n - 1); printf("排序后: "); printArray(arr, n); return 0; }运行结果:
排序前: 5 3 8 2 1 7 排序后: 1 2 3 5 7 83.2 代码里那些一眼带过、暗藏杀机的细节
这段代码看起来不长,但我跟你说,几乎每一行都有人在上面栽过跟头。
第一个细节:内层两个while循环的条件里,为什么都必须带上i < j?你可以试想一下,如果去掉内层的i < j,j可能会一直往左走到数组开头甚至更远,i也可能一路冲到数组末尾之外。虽然pivot这个“哨兵”能挡住一部分越界情况,但一旦数组里出现连续多个小于或大于pivot的元素,指针就会悄悄跑飞,轻则越界访问,重则段错误。内层加i < j不是冗余,是保命符。
第二个细节:为什么内层比较时用的是>=和<=,而不是>和<?这涉及到等值元素怎么处理。如果去掉等号,当arr[j]的pivot相等时,循环会停下来,然后发生交换,而交换过去的元素和枢轴一样大,下一轮又会被另一个指针停下,如此往复,指针卡死在原地,外层while永远退不出去,程序死循环。加上等号之后,遇到相等的元素直接跳过,等到i和j相遇为止,既不会死循环,也不会做无意义的交换。
第三个细节:递归终止条件写成left >= right而不是left == right。有些情况下,比如pivot恰好是区间最小值,partition返回的pos等于left,那么右区间是pos + 1到right,左区间是left到pos - 1,也就是left到left - 1,此时left大于right。如果不写>=,这种空区间就会继续递归,导致无限循环或者栈溢出。这个坑我见过太多人踩了。
第四个细节:partition返回的是i,不是left也不是right。枢轴最终的位置是i和j相遇的地方,它可能落在区间中间,也可能落在边界上。返回正确的位置,递归才能精确地切分左右区间。
4. 第一个元素当枢轴,最大的坑在哪里
4.1 有序数组是怎么把快速排序拖垮的
我先把结论放这里:当输入数组已经有序时,以第一个元素为枢轴的快速排序,会退化成最坏时间复杂度O(n²)。
为什么?你想,如果数组是{1, 2, 3, 4, 5, 6},第一个元素1是全局最小值。单趟划分结束后,pivot被放回最左边,左区间为空,右区间是{2, 3, 4, 5, 6}。下一轮,枢轴又是2,依然是当前区间的最小值,左区间依然为空,右区间长度减一。就这样一刀一刀切下去,每次只去掉一个元素,递归深度变成了n,总比较次数变成了n(n-1)/2,和冒泡排序一个水平。
如果你以为这种退化只在理论上存在,那就天真了。实际开发中,有序数据出现的频率远比想象中高:数据库里按主键读出的记录、日志文件按时间排列的记录、接口返回的列表,都是现成的有序或近似有序数据。如果你在排序这些数据时用了固定第一个元素当枢轴,等于亲手埋下了一颗性能地雷。
我自己做过一次很粗糙的实测:10000个随机整数,用这个实现排序,耗时大概几毫秒;换成10000个已经排好序的整数,耗时直接飙升到几十毫秒甚至上百毫秒。数据量再大一点,比如十万个有序数,程序会明显卡顿,递归深度过大甚至可能把栈空间耗尽,直接栈溢出崩溃。这个反差,比你想象中扎心得多。
4.2 常用的补救手段,以及和题目的关系
既然问题出在“固定选第一个元素”,那思路就是让枢轴的选取变得不那么死板。工程上常用的有三种手段。
第一种是随机选枢轴。在区间内生成一个随机下标,把这个位置的值和第一个元素交换,然后继续用你熟悉的“以第一个元素为枢轴”的划分逻辑。这样即使数组本身有序,每次枢轴落在区间中的位置也是随机的,期望时间复杂度回到O(n log n)。我看到过有的同学不理解这种做法的合法性,其实关键点在于:快速排序的正确性只依赖“划分后左边都比枢轴小、右边都比枢轴大”,枢轴到底选哪个元素,无关紧要。随机交换只是改变了哪个值当枢轴,并没有改变排序算法的骨架。
第二种是三数取中。取区间左端、右端和中间位置三个值,找出中间大小的那个,把它换到第一个位置。这种做法避免了随机数生成的额外开销,也不会碰运气碰到连续坏运气,对付“近似有序”的数据效果显著。
第三种是小区间插入排序。当递归划分的子区间长度小于某个阈值(比如10到20)时,直接改用插入排序。插入排序在数据量小的时候常数极小,而且没有递归开销,能有效提升整体性能。很多工业级排序实现都会做这个优化。
这里我多说一句:题目要求“以第一个元素作为枢轴”,你是不是觉得上面这些优化和题目冲突了?不冲突。比如随机选枢轴或者三数取中,你可以先做一次交换,把新枢轴放到第一个位置,接下来的划分逻辑依然是你熟悉的“第一个元素作为枢轴”。这相当于保留题目的划分框架,只对“谁来当枢轴”做了一次预处理。你在练习时完全可以先实现基础版本,再在这个版本上逐步加优化,两条腿走路,又稳又深。
5. 调试历程与高频翻车点排行
5.1 我当年调试时的真实经历
第一次写这个算法的时候,我犯过一个特蠢的错:内层while的比较符号写反了。我写成了arr[j] <= pivot,结果j一路往左猛冲,把比枢轴大的元素全当“要找的目标”填到了左边,一轮下来,左边比右边还乱,排序结果完全错误。
当时我没有意识到是符号问题,以为是递归边界写错了,于是疯狂打印数组状态。打印了几轮之后我发现,每次划分完之后,枢轴左右两边并没有被正确切分,才回头检查partition。你猜怎么着?就这么一个>=和<=的问题,我调了半个多小时。说这个故事是想告诉你:遇到排序结果错乱,别急着怀疑递归,先检查单趟划分后的数组,看看“左边比枢轴小、右边比枢轴大”这个铁律有没有被破坏。
另一次更隐蔽。我在一个几乎全相等的数组上测试,比如{3, 3, 3, 3, 3},程序倒是没死循环,但跑得极慢,递归栈一层接一层。后来才想明白:全等数组其实也触发了最坏情况,因为每个枢轴都落在区间一端,每次划分只能去掉一个元素。这让我意识到,性能退化不只有“有序”一种触发条件,“大量重复元素”同样危险。
5.2 高频问题速查表
我把这些经验整理成一张表,方便你排查问题。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 排序结果不对,部分元素顺序混乱 | partition的比较符号写反,或填坑顺序错误 | 单步调试,打印每趟划分后的数组,检查“左边都小、右边都大”是否成立 |
| 程序死循环,卡住不动 | 内层while缺少i<j条件,或等值元素处理不当 | 检查内层两个while循环的条件,确认带上了i<j |
| 递归过深,栈溢出崩溃 | 输入数组有序或大量重复,固定枢轴导致最坏情况 | 改用三数取中或随机选枢轴,必要时小区间用插入排序 |
| 划分后枢轴位置不对,返回的下标异常 | 最后一步arr[i]=pivot写错位置,或返回的不是i而用了left/right | 用极小数组(3到5个元素)手推一遍,与代码逻辑对照 |
| 空区间无限递归 | 终止条件写成left==right,没考虑left>right的情况 | 改成left >= right |
排查的时候,我给你一个屡试不爽的建议:测试用例从最小规模开始。先用两个元素的数组,再用三个元素,再用包含重复元素的小数组,一步一打印。小用例能跑对,再上大用例,问题就能迅速定位。千万不要一上来就扔一个一万个随机数的数组进去,黑盒测试会浪费你大量时间。
6. 聊聊我自己的一点体会
如果你把上面这些内容消化了,你会发现自己不只是多背了一段代码,而是把“以第一个元素作为枢轴”这个经典写法背后的原理、坑和优化方向都串起来了。以后再遇到别的排序算法,或者面试官追问“为什么标准库排序不用固定枢轴”,你都能接上话。
我个人在实际动手中的体会是:学这种经典算法,最忌讳“代码能跑就万事大吉”。快速排序这种代码,运行起来正确只是及格线,真正见功夫的地方是你能不能在脑子里把单趟划分的每一步推演出来,能不能说清楚等号为什么要加、边界为什么这样写、性能为什么退化。做到这一步,你在面试时手写快排,和那种背模板默写的状态,完全是两个层次。
最后再分享一个小技巧:在测试这个算法时,不妨故意准备三组数据,一组随机、一组升序、一组全部相同,分别观察它们的耗时和递归深度。当你亲眼看到有序数据把快速排序拖垮的那一刻,你才算真正理解了“枢轴选择”这四个字的分量。这个经验,比背一百遍理论都管用。