如果你跟我一样,第一次看完快速排序的代码,脑子里冒出来的第一个问题不是“这段代码为什么能排序”,而是“这两个指针到底谁找大、谁找小”,那这篇笔记就是写给你的。
我之前写过很多次快排,只要一碰到降序需求,就开始怀疑人生:到底“i找大、j找小”是配升序还是配降序?每次都要翻开旧代码看一眼才敢动手。后来我彻底把“分区(partition)的目的”和“排序的方向”这两件事分开想清楚,发现其实就是一个非常简单的逻辑:升序目标下,左边最终要放小值、右边放大值,所以左边不该出现大值、右边不该出现小值,于是左指针i去抓左边的大值,右指针j去抓右边的小值,互相交换归位。 一句话总结就是“i找大、j找小,适配升序目标”。
这篇文章就是从小白视角,把“分区的目的”“指针方向”“升降序切换”一层层剥开,配合实际跑一遍数组的全过程。无论你是刚学数据结构、在准备面试,还是每次写排序都要现查,这篇笔记应该能帮你彻底告别方向选择困难。
1. 分区的目的到底是什么
1.1 分区不是排序,而是“先让一个元素回家”
快速排序的英文是 Quick Sort,里面那个核心步骤叫 partition,中文叫“分区”。很多初学者把“分区”理解成“把数组分成前后两半”,这个理解不能算错,但很容易忽略一个关键点:分区的真正目的是让基准元素(pivot)回到它最终应该在的位置上。
什么叫“最终应该在的位置”?假设你对一个数组做升序排序,当整个数组排完序后,每个元素都会有一个固定的下标。分区做的事情,就是随便挑一个元素出来当基准,通过一系列交换,让这个基准立刻落到它最终的目标下标,并且保证基准左边的所有元素都不大于基准,基准右边的所有元素都不小于基准。注意,左右两边的内部此刻还是乱序的,不重要。
可以这样类比:想象班级要按身高从矮到高排成一列,老师先随机喊一个人出列当作“标杆”,然后让所有比他矮的同学站到他左边,所有比他高的同学站到他右边。至于左右两边内部谁高谁矮,老师暂时不管,留到下一步再处理。这一轮处理,就是一次分区。
所以,分区带来的第一个认知是:它每执行一次,就能“锁定”一个元素的最终位置。快速排序反复递归,每次都锁定一个基准,整个数组最终自然有序。
1.2 分区维护的“不变量”到底是什么
写排序算法的人很喜欢提“循环不变量”这个词,听起来高深,其实就是“在循环执行的任何时刻,有一些事实始终保持成立”。
对于一次升序目标的分区,我们在循环过程中要守护的不变量是:
- 基准左边已经检查过的区域里,所有元素都 ≤ 基准;
- 基准右边已经检查过的区域里,所有元素都 ≥ 基准;
- 指针 i 与 j 尚未扫描到的中间区域,仍然是待处理的“混乱地带”。
每次循环结束,如果上述事实依然成立,那最后 i 和 j 相遇时,整个数组就被划分成了“左边全部小(≤pivot)、右边全部大(≥pivot)”两块,分区目标达成。
如果你写降序,只需要把上面所有不等号反向即可。这听起来像废话,但它恰恰是理解“指针方向”的总开关。循环不变量的方向,直接决定了指针要找的“坏值”是什么。
1.3 为什么必须用两个指针相向而行
分区算法里通常有两个指针:
- i 从左往右移动;
- j 从右往左移动。
有人问过,能不能只用一个指针从头扫到尾?能,那就是另一种分区实现,比如 Lomuto 单指针分区。但双指针相向而行的好处是“一次性从两端同时修正错误”,效率高,也更直观。
你可以想象成两个人分别从队伍的两端往里检查:左边那个人负责检查“这一段不该出现的元素”,右边那个人负责检查“右边那段不该出现的元素”。当两边各抓到一个“坏元素”时,就交换它们,让它们各回各的“半区”。两人在中间碰头时,检查结束。
这个“相向而行”的过程本身就暗示了方向性:左边的人该怎么走、右边的人该怎么走,完全取决于你期待的最终形态是升序还是降序。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序方向如何决定指针任务:i找大、j找小配升序
2.1 升序的目标形态,直接推导出“谁该找谁”
我们以升序为目标。升序的数组最终形态是:
text复制[小, 小, 小, ..., pivot, ..., 大, 大, 大]
以基准 pivot 为分界点,左边是“小于等于 pivot”的区域,右边是“大于等于 pivot”的区域。
现在,站在基准的角度想想:假如你不小心在左边区域放进了一个大于 pivot 的值,这个值是不是“站错位置”了?它本该去右边区域。同样的,假如你不小心在右边区域放进了一个小于 pivot 的值,它是不是也“站错位置”了?它本该去左边区域。
于是自然得出升序目标下的两个指针任务:
- 左指针 i 从左往右走,它的使命是找到“站错在左边的大值”;
- 右指针 j 从右往左走,它的使命是找到“站错在右边的小值”。
这就是“i找大、j找小”的来源。找到了之后干什么?把这两个错误元素交换一下,大值被扔到右边,小值被挪回左边。一次交换,同时修正两处错误。
2.2 用“最终状态倒推”三步法,任何方向都不会错
如果你以后再遇到“我该找大还是找小”的困惑,不要靠背口诀,用下面三步现场推导:
第一步,写下目标方向下的最终形态。升序就是左小右大,降序就是左大右小。
第二步,问自己:在最终形态里,左边区域不应该出现什么?右边区域不应该出现什么?
- 升序:左边不应该出现大值,右边不应该出现小值。
- 降序:左边不应该出现小值,右边不应该出现大值。
第三步,把“不应该出现的值”分配给你的左右手:
- 左手 i 管左边区域,所以 i 要找的就是“左边不该出现的值”;
- 右手 j 管右边区域,所以 j 要找的就是“右边不该出现的值”。
把这个三步法套到升序上,就是左指针找左边不该出现的“大值”,右指针找右边不该出现的“小值”,于是得到“i找大、j找小”。套到降序上,则是“i找小、j找大”。看到没有,口诀并不是硬背的,它是目标方向推导出来的必然结论。
2.3 基准归位的那一刻,方向决定了最终格局
分区的最后一步,是把基准元素放到 i 和 j 相遇的位置。这个位置是否合理,也取决于指针任务的方向。
升序场景下,j 一直在找小值,所以当 i 与 j 相遇时,j 停在一个“小于等于 pivot”的元素上。把 pivot 和这个元素交换,pivot 的左边就都是小值,右边都是大值,完美。
降序场景下,j 一直在找大值,所以 j 停在“大于等于 pivot”的元素上。把 pivot 和它交换,pivot 的左边自然全是大值,右边全是小值。
这里极度关键,也是很多初学者“觉得写对了但排序结果总是一半错一半对”的原因:**先移动哪个指针,其实是和指针任务绑定在一起的。**后面有专门的章节来展开。
3. 实际跑一遍:升序和降序的完整分区过程
3.1 准备工作:选定基准值和扫描起始方向
为了把过程讲清楚,我们统一采用一个最常见的模板:
- 基准值取数组最左边的元素,即
pivot = arr[left]; - 两个指针初始化为
i = left,j = right; - 每一轮外循环都先移动右指针 j,再移动左指针 i;
- 最后把 pivot 与
arr[j]交换。
为什么要先移动 j?原因前面提过:因为我们希望最后 i 和 j 相遇时,j 停在一个“可以被交换到基准位置且不破坏顺序”的值上。升序时 j 找小值,所以它停的是小值;降序时 j 找大值,所以它停的是大值。先走 j,就是先把最关键的“基准落脚点”确定下来,再让 i 去追赶它。如果你反着来,i 先走,最后相遇点可能停在一个不符合要求的值上,交换后就会出 Bug。
3.2 升序场景完整走一遍:数组 [7, 3, 9, 2, 8]
我们用一个具体数组现场演算。目标:升序,pivot = 7,左端点 0,右端点 4。
第一轮扫描:
- j 从右往左找小值:
arr[4]=8,8 大于等于 7,不是要找的小值,j 继续左移;arr[3]=2,2 小于 7,满足“小值”条件,j 停在 3。 - i 从左往右找大值:
arr[0]=7,7 大于等于 pivot,按升序规则“左侧允许等于 pivot”,“等于”不是错误,i 继续右移;arr[1]=3,3 小于 7,不是大值,继续右移;arr[2]=9,9 大于 7,i 停在 2。 - 此时
i(2) < j(3),交换arr[2]与arr[3],数组变成[7, 3, 2, 9, 8]。
这一步交换修正了什么?i 找到的 9 是“站错左边的大值”,j 找到的 2 是“站错右边的小值”,交换之后 2 去了左边,9 去了右边。
第二轮扫描:
- j 从当前位置 3 继续向左找小值:
arr[3]=9,9 大于等于 7,j 左移到 2;此时i也等于 2,两个指针相遇。 - 循环结束,交换
arr[left]与arr[j],也就是交换arr[0]=7与arr[2]=2,数组变成[2, 3, 7, 9, 8]。
此时 7 已经归位,下标 2 就是它最终排序后的位置。左边是 2、3,都小于 7;右边是 9、8,都大于 7。升序分区目标完成。
3.3 降序场景完整走一遍:同一个数组,同一个基准
目标改为降序,pivot 仍然是 7。最终我们希望左边全是大值,右边全是小值。根据三步推导法,j 的任务变成“从右往左找大值”,i 的任务变成“从左往右找小值”。
第一轮扫描:
- j 从右往左找大值:
arr[4]=8,8 大于 7,正好是 j 要找的“站错右边的大值”,j 停在 4。 - i 从左往右找小值:
arr[0]=7,7 大于等于 pivot,在降序规则下“等于”不是错误,不是要找的小值,i 右移;arr[1]=3,3 小于 7,正是要找的小值,i 停在 1。 - 交换
arr[1]与arr[4],数组变成[7, 8, 9, 2, 3]。
这一步把 3(不该留在左边的值)换去了右边,把 8(不该留在右边的值)换来了左边。
第二轮扫描:
- j 从位置 4 继续向左找大值:
arr[4]=3,3 小于等于 7,不是大值,j 左移到 3;arr[3]=2,2 小于等于 7,继续左移;arr[2]=9,9 大于 7,j 停在 2。 - 此时
i也可以向右移动:arr[1]=8,8 大于等于 pivot,在降序规则下左侧保留大值是合理的,i 右移;i到达 2,与 j 相遇。 - 循环结束,交换
arr[0]与arr[2],数组变成[9, 8, 7, 2, 3]。
7 归位,左边 9、8 都大于 7,右边 2、3 都小于 7,降序分区完成。
两个场景对比看,非常清晰:同一个数组、同一个基准,因为目标方向变了,i 和 j 的“猎物”就完全变了,但分区过程的骨架一模一样。
3.4 一份代码同时支持升序和降序
理解了“方向”之后,代码反而不复杂。我们可以把方向作为一个参数传进去,所有比较符号切换一次即可。这里给一份我常用的 JavaScript 版本,注释写得很详细:
javascript复制function partition(arr, left, right, desc = false) {
const pivot = arr[left];
let i = left;
let j = right;
while (i < j) {
if (!desc) {
// 升序:j找小值,i找大值
// j 当前指向的值如果 >= pivot,说明它待在右边没问题,继续左移
while (i < j && arr[j] >= pivot) j--;
// i 当前指向的值如果 <= pivot,说明它待在左边没问题,继续右移
while (i < j && arr[i] <= pivot) i++;
} else {
// 降序:j找大值,i找小值
// j 当前指向的值如果 <= pivot,说明待在右边没问题,继续左移
while (i < j && arr[j] <= pivot) j--;
// i 当前指向的值如果 >= pivot,说明待在左边没问题,继续右移
while (i < j && arr[i] >= pivot) i++;
}
if (i < j) {
// 两个指针各找到一个“站错位置”的元素,交换归位
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// 基准归位
[arr[left], arr[j]] = [arr[j], arr[left]];
return j;
}
function quickSort(arr, left = 0, right = arr.length - 1, desc = false) {
if (left >= right) return arr;
const p = partition(arr, left, right, desc);
quickSort(arr, left, p - 1, desc);
quickSort(arr, p + 1, right, desc);
return arr;
}
// 升序
console.log(quickSort([7, 3, 9, 2, 8])); // [2, 3, 7, 8, 9]
// 降序
console.log(quickSort([7, 3, 9, 2, 8], 0, 4, true)); // [9, 8, 7, 3, 2]
注意看代码里的 while 条件:
- 升序时,j 停止条件是
arr[j] < pivot,也就是找到了小值;i 停止条件是arr[i] > pivot,也就是找到了大值。 - 降序时,j 停止条件是
arr[j] > pivot,找大值;i 停止条件是arr[i] < pivot,找小值。
如果你背熟了“升序 = i找大、j找小”,再看这份代码,根本不需要猜。停止条件是“找到了坏值”才停,没找到就继续走。
3.5 警惕:网上模板那么多,你怎么判断自己有没有写反
我在学习时最大的困惑是:教材里给快排的代码,有的先动 j,有的先动 i,有的选最左基准,有的选最右基准,看多了真的会疯。
后来我总结出一个判断方法:不要背模板,只验证一个事实——最后基准交换到自己位置时,它的左边和右边是不是满足目标方向的要求。
具体做法是:拿一个长度为 3、4 的小数组,跟着代码手动走一遍,只看最后一次交换。如果交换 pivot 之后,左边出现了一个违反方向的元素,那你的指针任务肯定反了;如果没问题,那这种模板就是可行的。所有你看到的五花八门的快排写法,最后都一定能通过这个检验。这也是为什么我强调,理解“分区的目的”比记住任何模板都重要。
4. 常见问题与排查技巧实录
4.1 为什么基准在最左边时,必须先移动 j 而不是先移动 i
这是初学者问得最多的问题之一。本质原因在于:最后交换基准的位置是 j 停下的位置,而这个 j 位置代表的“半边”决定了基准落点是否合规。
升序场景,如果最后 pivot 与 arr[j] 交换,那么 pivot 左边是 arr[left+1] 到 arr[j-1],右边是 arr[j+1] 到 arr[right]。为了让左边全小于等于 pivot,必须保证 arr[j] ≤ pivot。谁来保证这一点?j 的任务就是找小值,j 恰好停在小值或等于 pivot 的值上。如果你先让 i 移动,最后 i 和 j 的相遇点可能是“大值”,把 pivot 换过去之后,一个大值就被留在了 pivot 左边,整个分区失败。
降序场景同理:j 的任务变成找大值,最后 arr[j] ≥ pivot,交换后 pivot 左边才全是大值。
所以,采用“基准在最左边”的模板时,每轮都先动 j,是让基准落点合格的必要条件。如果你把基准换成最右边,规则会反过来,需要先动 i。不需要死记“先动谁”,你只需要问:哪个指针的停靠位置即将和基准交换?它停下的值是否满足基准所在半区的要求?
4.2 相等值到底应该怎么处理
很多代码里写的是 while (i < j && arr[j] >= pivot) j--;,用的是大于等于,而不是大于。
这里有个很容易掉进去的坑:如果换成严格大于 arr[j] > pivot,遇到数组里一堆相等元素,j 会卡住不动,因为相等值既不满足“大于 pivot”,也不满足“小于 pivot”,指针无法推进,外层 while (i < j) 就变成了死循环。
用 >= 或 <= 的目的是:**将等于 pivot 的元素视为“已经站在正确一侧”,允许指针直接跳过。**升序时,左侧允许等于 pivot,所以 i 在遇到 arr[i] <= pivot 时继续右移;右侧也允许等于 pivot,所以 j 在遇到 arr[j] >= pivot 时继续左移。这样所有指针都在推进,不会卡死。
4.3 递归区间为什么是 [left, p-1] 和 [p+1, right]
分区结束后,pivot 已经待在了它的最终位置,所以 pivot 本身不需要再参与后续排序。递归只需要处理 pivot 左边的子数组 [left, p-1] 和右边的子数组 [p+1, right]。
如果你偷懒写成 [left, p] 或 [p, right],基准元素会反复被拉进新一轮分区,虽然有时也能跑通,但会在某些边界条件下形成无限递归。最典型的例子是子数组长度为 2 时,如果 p 又落在左端点,就可能出现 quickSort(arr, left, p) 永远处理同一个区间的情况,直接爆栈。
所以写完快排后,可以加一句打印日志,观察每次分区返回的 p,再确认递归区间是否完全排除 p。这个习惯能帮你避免至少半小时的调试。
4.4 用循环不变量自证“i找大、j找小”没写反
如果你正在准备面试,面试官可能会追问“你凭什么保证你的分区算法是对的”。这时候能说出循环不变量就很加印象分。
以升序为例,我们的循环不变量是:
- 对于左指针 i,在它已经扫描过的区域
[left+1, i-1]内,所有元素都 ≤ pivot; - 对于右指针 j,在它已经扫描过的区域
[j+1, right]内,所有元素都 ≥ pivot; - 尚未扫描区域位于 i 和 j 之间,里面可能有各种乱序元素。
初始时,i = left,j = right,两个已扫描区域为空,不变量自然成立。每次交换完 arr[i] 和 arr[j] 后,因为 arr[i] 是 i 找到的大值,arr[j] 是 j 找到的小值,交换后大值去了右边扫描区,小值去了左边扫描区,不变量继续成立。当 i >= j 时,所有元素都已经被划分到正确半区,分区完成。
这段话其实就是在解释“为什么要找大和找小”:如果不找对应方向的值,不变量根本无法维持,最终分区就是错的。
4.5 指针写在循环里的顺序,为什么会导致“偶发性错误”
我之前有一段很痛苦的调试经历:排序结果有时候对,有时候错,错的时候总是相邻两个元素位置颠倒。最后发现是内层两个 while 的顺序问题。
在“基准在最左”的模板中,外层 while 的每一轮都必须先执行 j 的循环。如果我先执行 i 的循环,当 i 找到一个“大值”停住,然后 j 开始移动时,j 有可能因为碰上前方已经扫描过的区域而没有通过条件继续移动,导致最后 i 和 j 相遇在一个“大值”上,基准换位失败。
这不是理论问题,而是真实会发生的问题。我给一个亲身测试过的结论:对于 arr = [4, 1, 3, 2, 5],如果我把 j 的循环放到 i 之后,升序运行后数组会变成 [4, 1, 2, 3, 5] 这种错误的局部状态。所以,如果你发现快排偶尔出错,先检查内层循环的先后顺序。
5. 分区思想还能用到哪些地方
5.1 求第 K 大 / 第 K 小:一个分区就能淘汰一半数据
分区算法每次都能把基准放到它最终的位置,这个特性单独拿出来,就是一个经典算法——快速选择。
假设你要找数组第 3 小的元素。第一次分区后,如果基准下标恰好是 2,那基准就是答案;如果基准下标小于 2,说明答案在基准右边;如果下标大于 2,说明答案在基准左边。每次都只需要处理一半数据,平均时间复杂度是 O(n)。
这个技巧在“大数组找中位数”“Top-K 问题”里非常实用,也是排序算法之外,分区思想最漂亮的应用场景。
5.2 所有排序方向的底层共识:比较器定义“错误类型”
很多人学完快排,转头写 Java、JavaScript、SQL 时又不知道排序方向怎么控制。其实道理是通用的。
升序本质上是在说:对于任意两个元素 a 和 b,如果 a 排在 b 前面时 a 却大于 b,那就是顺序错误。降序则完全反过来。你去看各种排序库的比较器,它们返回正数、负数、零,本质上就是在告诉排序算法“什么情况是顺序错误”。快排里“i找大、j找小”也不过是这种错误判断的具体化:找到顺序错误就去交换,直到没有错误。
所以当你以后面对 SQL 的 ORDER BY col DESC、面对 Array.sort((a, b) => b - a),都可以用“目标方向决定什么算错误”这个统一视角去理解,整个知识体系就串起来了。
5.3 顺带澄清:算法里的“分区”和磁盘“分区”毫无关系
搜“分区”这个关键词时,大概率会看到大量和硬盘分区、系统分区相关的词,比如分区助手、启动分区、磁盘分区工具等。这很容易让人误以为算法里的 partition 也是某种“划分空间”的操作。
其实两者只是中文刚好都叫“分区”。算法里的 partition 是把一组数据按基准值划分为两组;磁盘分区是把一块物理存储设备划分为若干个逻辑区域。八竿子打不着。如果你是在搜索排序算法时被磁盘分区的教程带偏了,直接把浏览器关键词切换成“快速排序 partition”就能绕回来。
最后,分享一点我自己的调试心得
我后来每次写快排,不管目标是升序还是降序,都会先在草稿纸上画三样东西:
- 目标方向下的最终数组形态(左小右大还是左大右小);
- 左边区域“不该出现”什么值;
- 右边区域“不该出现”什么值。
画完这三样,指针找什么方向基本不用想。左边不该出现大值,i 自然去找大值;右边不该出现小值,j 自然去找小值。这就是升序的情况。降序只需要把“该与不该”整体镜像一次。
还有一个百试百灵的经验:写完分区函数后,不要急着跑大数组,先用 [3, 1, 2]、[2, 2, 1] 这种极端小数组手动走一遍,专门验证“基准交换后左区和右区是否满足目标方向”。这个小动作能帮你提前拦下 90% 的指针方向 Bug。等方向感稳定以后,你会发现那些“i找大、j找小”的口诀不再是需要背诵的咒语,而是顺理成章的结论。
