这个标题,是我当年学快速排序时在草稿纸上反复纠结过的问题。先甩结论:在基准取最左、升序目标的经典写法里,“i找大、j找小”是对的;如果你把目标换成降序,指针的“找法”确实要跟着翻过来,也就是“i找小、j找大”。但只记住这句话还不够,因为还有一个配套纪律:基准在最左时,右指针 j 必须先走。这篇笔记适合正好被分区、排序方向绕晕的初学者,我会用一次完整的数组走查,把分区目的、指针方向、升降序切换讲明白。看完你会发现,这根本不是背诵题,而是一道逻辑推导题。
1. 先搞清楚:分区到底在做什么
1.1 分区的本质:不是排序,是归类
很多小白第一次接触快速排序,看到“分区”两个字就以为分区等于排序,这是第一个误区。分区的英文叫 partition,它的目标不是让整个数组有序,而是做一件更朴素的事:选一个基准值,让数组里所有比基准“该靠前”的元素跑到基准左边,所有比基准“该靠后”的元素跑到基准右边,最后基准自己落在它最终应该在的那个位置上。一次分区只能确定一个元素的最终位置,其他元素只是被粗略分成了两拨,内部仍然可能是乱的。
我打个比方。想象你是一个体检中心的护士,手里拿着一张标准身高线,比如160cm。你要做的是把所有身高低于160的人安排到左边房间,高于或等于160的安排到右边房间。在这过程中,你不会去管左边房间里谁高谁矮,右边房间里谁高谁矮,你只保证“分界线”成立。这个“分界线”成立的过程,就是分区。
所以分区的核心思想是归类,不是排序。快速排序的高明之处在于:每一次分区都能“钉死”一个基准元素的最终位置,然后对基准左右两侧的子数组继续做同样的操作,分而治之。每一个元素轮流当一次基准,最终全部归位,排序自然完成。理解这一点,你再看后面的指针移动,就不会觉得“i找大、j找小”是在瞎折腾了。
1.2 分区之后,数组到底变成什么样
光说概念不够,我们直接看一个例子。假设待排序数组是:
text复制[6, 1, 2, 7, 9, 3, 4, 5, 10, 8]
我们取最左边的 6 作为基准值,做一次升序目标的分区(也就是最终要从左到右从小到大排列)。一次分区完成后,数组会变成下面这个样子:
text复制[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]
注意看,数字 6 从原来的第一个位置,移动到了第 6 个位置。它的左边是 5、1、2、4、3,全都比 6 小;右边是 9、7、10、8,全都比 6 大。数字 6 现在站的位置,就是它在最终排序结果中的位置。如果你继续对左边的 [5,1,2,4,3] 和右边的 [9,7,10,8] 分别做同样的事,每个数字都会像 6 一样被“钉”到自己的最终位置上。
这个例子也能回答一个常见困惑:为什么分区完成后,6 左边并不是有序的? 因为分区只保证左右大小关系,不保证内部次序。左边 5 和 1 的顺序还是错的,但那没关系,下一次递归会处理左边这一整块。这种“每次解决一个元素,问题规模减半”的思路,就是快速排序平均时间复杂度能做到 O(n log n) 的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “i找大、j找小”的秘密:升序分区的完整走查
2.1 用“挖坑法”理解左右指针
现在进入最核心的部分:两个指针 i 和 j 到底在做什么?我用“挖坑法”来拆解,因为这是最直观、也最容易推导的口诀。
升序情境下,我们把基准值 6 先拿出来存在变量 pivot 里。此时数组最左边 arr[left] 的位置就空了,像一个“坑”。坑在左边,左边应该站的是“小元素”,所以我们得先从右边找一个小元素来填这个坑。谁来干这个活?右指针 j。它从右往左扫描,找到第一个比基准小的元素,停下来,把这个小元素填进左边的坑。填完之后,j 刚才站的位置变成了新坑。
新坑在右边,右边应该站的是“大元素”,所以左指针 i 上场了。它从左往右扫描,找到第一个比基准大的元素,停下来,把这个大元素填进右边的坑。填完之后,i 的位置又变成了新坑。接下来重复:坑在左边,j 再去找小元素来填;坑在右边,i 再去找大元素来填……直到 i 和 j 在某个位置相遇,那个位置就是最后一个坑,把基准值 pivot 放进去。
所以“i找大、j找小”这句口诀,本质上说的是:谁出现在它不该出现的区域,就让对应的指针把它找出来,扔到对面去。 左边区域只欢迎小元素,所以从左往右走的 i 负责抓混进来的大元素;右边区域只欢迎大元素,所以从右往左走的 j 负责抓混进来的小元素。这个逻辑一通,后面降序怎么改你也自己会推了。
2.2 完整走查:数组一点点变样
我们把刚才的例子完整走一遍。数组是:
text复制[6, 1, 2, 7, 9, 3, 4, 5, 10, 8]
取基准 pivot = 6,设置 i = 0(最左),j = 9(最右)。我用“X”标记当前逻辑上的坑位。
第一轮:坑在 0 号位,j 从右往左找小于 6 的元素。8 和 10 都比 6 大,跳过;5 比 6 小,停住,j 停在 7 号位。把 5 填进 0 号坑:
text复制[5, 1, 2, X, 9, 3, 4, 7, 10, 8]
注意现在坑移到 7 号位,7 号位展示为 X。轮到 i 从左往右找大于 6 的元素。5、1、2 都小于 6,跳过;7 大于 6,停住,i 停在 3 号位。把 7 填进 7 号坑:
text复制[5, 1, 2, 4, 9, 3, X, 7, 10, 8]
这里我提醒一个细节:填坑是覆盖写,上图 3 号位被填成 4 之前,我先展示了“把 7 填进坑后,坑转移到 3 号位”的状态。实际代码执行后数组里的值会持续变化,你只要盯住坑在哪就行。
第二轮:坑在 3 号位,j 从当前位置继续向左找小于 6 的元素。7 不小,跳过;4 小于 6,停住,j 停在 6 号位。把 4 填进 3 号坑:
text复制[5, 1, 2, 4, 9, 3, X, 7, 10, 8]
然后 i 从左往右找大于 6 的元素,4 小跳过,9 大于 6,停住,i 停在 4 号位。把 9 填进 6 号坑:
text复制[5, 1, 2, 4, 3, 9, X, 7, 10, 8]
第三轮:坑在 6 号位,j 继续向左找小于 6 的元素。9 不小,跳过;3 小于 6,停住,j 停在 5 号位。把 3 填进 6 号坑:
text复制[5, 1, 2, 4, 3, X, 9, 7, 10, 8]
现在 i 从 4 号位继续向右找大于 6 的元素,4 号位是 3,跳过;i 走到 5 号位,此时 i == j,循环结束。最后把基准 6 放进这个相遇位置的坑:
text复制[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]
检查一下:6 左边全是小于 6 的,右边全是大于 6 的,分区目标达成。这个例子你完全可以自己在草稿纸上再推一遍,推完就会发现,“i找大、j找小”不是背出来的,而是“哪里是坑,就找对应的元素来填坑”的自然结果。
2.3 为什么升序必须是“i找大、j找小”
有人可能会问:如果升序时我把规则反过来,让 i 找小、j 找大,行不行?直觉上会觉得,两个指针反正一左一右往中间走,谁找大谁找小不那么重要吧?我们来分析为什么不建议这么做。
升序的最终目标是“左边小、右边大”。i 从左往右走,它经过的区域是未来的左半区,左半区坐满了小元素才叫正确。如果 i 发现一个大元素,说明这个位置被“错位元素”占据了,它必须被送到右边。同理,j 从右往左走,它经过的区域是未来的右半区,右半区应该都是大元素,如果 j 发现一个小元素,说明这个元素来错地方了,应该被送回左边。两个错误的位置一旦交换,一次就修正了两个元素的归属。
如果你反过来,让 i 找小、j 找大,会发生什么?i 扫过的左边区域里明明应该留下小元素,它却专门去找小元素然后拿走去填右边;j 在右边区域里专门找大元素然后拿走去填左边。这等于是把应该留在左边的小元素往右边送,把应该留在右边的大元素往左边送。配合挖坑法的“坑位”来看,初始坑在左边,左边需要的是小元素,而 i 找的是小元素,可 i 在左边根本还没找到坑位之前,就把左边的小元素挖走填到右边的坑里,这完全颠倒了填坑逻辑。所以升序场景下,规则必须是“i找大、j找小”。
这个推导过程特别重要。以后你遇到“某个排序题要求降序”的变体,只要把这个“错位元素”的逻辑翻过来,就能自己推出正确写法,不用再死记口诀。
3. 降序目标:把“找法”翻过来,“先手”不变
3.1 从“该待在哪一侧”推导指针方向
现在假设题目要求降序,也就是最终从左到右从大到小排列。一切还是那张“错位元素”的逻辑,只是“该在哪一侧”变了:左边应该全是大元素,右边应该是小元素。
那么 i 从左往右走,扫描的是未来的左半区,左半区应该坐满大元素。它如果发现一个小元素,说明这个小元素不该待在左边,就得把它抓出来送到右边。所以降序时 i 找的是“小元素”,口诀是“i找小”。j 从右往左走,扫描的是未来的右半区,右半区应该坐满小元素。它如果发现一个大元素,说明这个大元素不该待在右边,就得抓到左边去。所以 j 找的是“大元素”,口诀是“j找大”。
一句话总结:指针找的都是“不该待在自己扫描区域里的元素”。目标顺序一翻转,每个区域“应该待什么元素”就翻转,于是指针的“找法”也必须翻转。这就是标题里“i找小、j找大”适配降序目标的真正原因。注意,这里有一个非常关键的点:尽管“找法”变了,但“先走哪个指针”没变。基准在最左时,初始坑在最左边,仍然是右指针 j 先走,只是 j 要找的东西从“小元素”变成了“大元素”。
3.2 降序分区完整走查
还是用同一个数组,这次做降序分区:
text复制[6, 1, 2, 7, 9, 3, 4, 5, 10, 8]
基准 pivot = 6,i = 0,j = 9。第一轮,坑在 0 号位,j 从右往左找大于 6 的元素。8 大于 6,停住,j 停在 9 号位。把 8 填进 0 号坑:
text复制[8, 1, 2, 7, 9, 3, 4, 5, 10, X]
坑移到 9 号位。i 从左往右找小于 6 的元素,8 大跳过,1 小于 6,停住,i 停在 1 号位。把 1 填进 9 号坑:
text复制[8, X, 2, 7, 9, 3, 4, 5, 10, 1]
第二轮,坑在 1 号位,j 从 8 号位向左找大于 6 的元素,10 大于 6,停住,j 停在 8 号位。把 10 填进 1 号坑:
text复制[8, 10, 2, 7, 9, 3, 4, 5, X, 1]
i 从 2 号位向右找小于 6 的元素,10 大跳过,2 小于 6,停住,i 停在 2 号位。把 2 填进 8 号坑:
text复制[8, 10, X, 7, 9, 3, 4, 5, 2, 1]
第三轮,坑在 2 号位,j 从 7 号位向左找大于 6 的元素。5、4、3 都小于 6,跳过;9 大于 6,停住,j 停在 4 号位。把 9 填进 2 号坑:
text复制[8, 10, 9, 7, X, 3, 4, 5, 2, 1]
i 从 3 号位向右找小于 6 的元素,7 大跳过,i 走到 4 号位,此时 i == j,循环结束。把基准 6 放进这个坑:
text复制[8, 10, 9, 7, 6, 3, 4, 5, 2, 1]
验证:6 的左边是 8、10、9、7,全大于 6;右边是 3、4、5、2、1,全小于 6。降序分区目标达成。注意这次并不要求左边内部有序,8 和 10 的顺序暂时还是乱的,但那是递归要处理的事。
3.3 升降序代码对比:只差两个比较符号
用代码写出来,升降序的分区函数长得几乎一模一样。升序版本:
java复制private static int partitionAsc(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
// j 先走:从右往左找第一个小于基准的元素
while (i < j && arr[j] >= pivot) j--;
arr[i] = arr[j];
// i 后走:从左往右找第一个大于基准的元素
while (i < j && arr[i] <= pivot) i++;
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
降序版本:
java复制private static int partitionDesc(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
// j 先走:从右往左找第一个大于基准的元素
while (i < j && arr[j] <= pivot) j--;
arr[i] = arr[j];
// i 后走:从左往右找第一个小于基准的元素
while (i < j && arr[i] >= pivot) i++;
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
肉眼对比一下,升序里第一个内层循环是 arr[j] >= pivot,第二个内层循环是 arr[i] <= pivot;降序里第一个变成 arr[j] <= pivot,第二个变成 arr[i] >= pivot。比较符号完全反转,其他骨架一个字都不改。这就是“i找大、j找小”和“i找小、j找大”在代码层面的直观体现:找大”对应忽略小于等于基准的值,“找小”对应忽略大于等于基准的值。
注意:
>=和<=里的等号非常重要。它们的作用是跳过与基准相等的元素,避免把相等的值来回交换。如果去掉等号,碰到大量相同元素时很容易出问题,后面我会专门讲。
4. 从分区到完整排序:方向正确,排序自然完成
4.1 递归的魔力:基准归位一次,问题缩小一半
分区函数只负责把一段数组分成“左小右大”或“左大右小”两段,并返回基准元素最终所在的下标。接下来快速排序只需要做一件事:递归处理基准左右两侧的子数组。升序排序的完整递归逻辑是这样:
java复制public static void quickSortAsc(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int pos = partitionAsc(arr, left, right);
quickSortAsc(arr, left, pos - 1);
quickSortAsc(arr, pos + 1, right);
}
很多人第一次看这段代码会觉得奇怪:递归调用里没有“合并”操作,怎么就能排序成功?这是因为分区函数每次都会把基准元素放到它最终应该在的位置,这个元素不再参与后续任何移动。数组的长度被拆成两段,每段继续执行同样的逻辑。随着递归一层层展开,每个元素都会在某一次分区中被选为基准,从而被“钉死”。等所有元素都被钉死,整个数组自然有序。
我建议你拿一个短数组,比如 [3, 1, 2],手动推一遍这个过程:先分区,基准 2 放到中间,返回下标 1;再对左边 [1] 和右边 [3] 递归,它们各自都满足 left >= right,直接返回。整个过程只有一层递归,数组已经变成 [1, 2, 3]。这个“递归+分区”的配合,就是快速排序的全部秘密。
4.2 完整快速排序代码(升序/降序)
如果你既想升序又想降序,一种做法是写两个递归方法,分别调用 partitionAsc 和 partitionDesc。另一种更简洁的做法是给递归方法加一个布尔参数,把“方向”传下去:
java复制public class QuickSort {
public static void sort(int[] arr, boolean ascending) {
quickSort(arr, 0, arr.length - 1, ascending);
}
private static void quickSort(int[] arr, int left, int right, boolean ascending) {
if (left >= right) {
return;
}
int pos = ascending ? partitionAsc(arr, left, right) : partitionDesc(arr, left, right);
quickSort(arr, left, pos - 1, ascending);
quickSort(arr, pos + 1, right, ascending);
}
private static int partitionAsc(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
while (i < j && arr[j] >= pivot) {
j--;
}
arr[i] = arr[j];
while (i < j && arr[i] <= pivot) {
i++;
}
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
private static int partitionDesc(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
while (i < j && arr[j] <= pivot) {
j--;
}
arr[i] = arr[j];
while (i < j && arr[i] >= pivot) {
i++;
}
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
}
调用方式很简单:
java复制int[] arr1 = {6, 1, 2, 7, 9, 3, 4, 5, 10, 8};
QuickSort.sort(arr1, true); // 升序
System.out.println(Arrays.toString(arr1));
int[] arr2 = {6, 1, 2, 7, 9, 3, 4, 5, 10, 8};
QuickSort.sort(arr2, false); // 降序
System.out.println(Arrays.toString(arr2));
输出分别是:
text复制[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
看到了吗?分区方向一变,整个排序方向就跟着变,递归骨架完全不用动。这就是标题里“适配升序目标”和“适配降序目标”的深刻含义:排序方向是由分区方向决定的,而不是由递归方向决定的。
4.3 基准在最右怎么办:一张速查表
前面所有分析都假设基准取最左,这也是绝大多数教科书里的默认写法。但面试或工程里有时会看到基准取最右的写法,比如数组的右端被选中。这时候“先走哪个指针”的规律要跟着变:基准在哪一侧,初始坑就在哪一侧,另一侧的指针先走。
| 目标顺序 | 基准在最左 | 基准在最右 |
|---|---|---|
| 升序 | j 先走,j 找小,i 找大 | i 先走,i 找大,j 找小 |
| 降序 | j 先走,j 找大,i 找小 | i 先走,i 找小,j 找大 |
这张表看起来内容很多,实际上只有两条规律:
- 基准在最左,右指针 j 先走;基准在最右,左指针 i 先走。 理由是初始坑的位置在基准那一侧,坑需要等另一侧的元素来填。
- 升序时,左侧找大、右侧找小;降序时,左侧找小、右侧找大。 理由是每个指针都要寻找“不该待在自己扫描区域里的元素”。
把这两条规律组合起来,表格里的四种情况就全都能推导出来。如果你拿到的代码是“三数取中”选基准,常见的工程实现会先把基准交换到最左或最右,再套上面这张表的规则,所以核心还是这两条。
5. 常见问题与排查技巧实录
5.1 死循环与相等元素:>= 和 <= 不能乱改
我在实战里见过最多的错误,就是新手觉得“反正就是要找小于基准的数”,把内层循环写成了 arr[j] > pivot、arr[i] < pivot,结果数组里一旦出现和基准相等的元素,就陷入死循环。举个例子,数组 [5, 5, 5],基准 5,升序排序。如果内层条件用严格的 > 和 <,j 从右往左找“小于 5”的元素,找不到,j 一直走到 i,看起来能结束;但换一种交换式的分区写法,等值元素会导致 i 和 j 在中间反复交换同一个位置,指针永远不推进,程序卡死。
正确做法是内层循环保留等号:升序时写 arr[j] >= pivot 和 arr[i] <= pivot,降序时写 arr[j] <= pivot 和 arr[i] >= pivot。这样所有等于基准的元素都会被跳过,不会触发交换逻辑,循环能正常推进到 i == j。
不过这里有个反直觉的点:跳过等于基准的元素,意味着这些相等元素可能会被分到基准两侧,所以快速排序是不稳定的。如果你需要稳定排序,比如按学生的成绩排序但希望成绩相同的人保持原来的先后顺序,那应该选归并排序,而不是快速排序。这是个常见的面试追问点,顺便记一下。
5.2 越界就是边界条件没管住
数组越界是分区函数最容易踩的坑。很多人写内层循环时,只记得“找小于基准的”,却忘了随时检查 i < j:
java复制// 错误示范:少了 i < j 条件
while (arr[j] >= pivot) {
j--;
}
如果整个数组里所有元素都比基准大,j 会一直向左减,直接减到 left 之外,访问到负数下标,程序立刻抛异常。正确写法必须带边界条件:
java复制while (i < j && arr[j] >= pivot) {
j--;
}
为什么这个边界条件不能省?因为 i 和 j 相遇是分区结束的标志。一旦相遇,就说明该填的坑都已经填完,基准可以归位了。此时继续让任何一个指针越界移动,都是没有意义的,而且必然出事。还有一个经常犯的越界错误在递归入口:递归调用 quickSort 时,如果 pos 等于 left,那么 quickSort(arr, left, pos - 1) 的右边界会比左边界小,所以递归第一行必须写 if (left >= right) return;。这个判断处理了“子数组为空”的情况,不能漏。
5.3 分区后基准位置返回错了,递归直接崩
有时候排序结果不对,甚至出现栈溢出,问题不在分区内部的移动,而在最后基准归位的代码。挖坑法结束时的正确写法是把基准放到相遇位置:
java复制arr[i] = pivot;
return i;
有的同学想着“反正 i 和 j 相遇了,用谁都一样”,随手写成 arr[j] = pivot; return j;。在大多数情况下确实没问题,因为退出循环时 i == j。但如果你改过循环结构,或者在内层循环之后又做了 i++ 或 j-- 操作,i 和 j 可能不相等,这时候返回错误的位置,递归切分的左右区间就错了。轻则基准元素重复参与排序,重则递归永远切不出空区间,直接栈溢出。
我的排查建议:在分区函数结尾打印每次分区后的数组和返回下标,比如:
java复制System.out.println("left=" + left + ", right=" + right + ", pos=" + i + ", array=" + Arrays.toString(arr));
把几次输出对照一下,很快就能看出基准是不是真的被放到了中间位置。这个方法对任何“看起来结果不对”的排序 bug 都适用。
5.4 别被同名词带偏:算法分区和磁盘分区是两回事
最后说一个有点搞笑但真的会让小白困惑的坑:搜索“分区”这个词,你会看到大量和磁盘分区有关的内容,比如“傲梅分区助手”“DiskGenius 分区”“Windows 系统分区”等等。这里的 partition 指的是把一块物理硬盘划分成 C 盘、D 盘这样的逻辑区域,和算法里的 partition 完全是两码事。
写算法题时看到 partition,默认指数据结构里的“按基准值把数组分成两段”;做系统维护时提到分区,才是指磁盘空间的划分。两者英文是同一个词,但知识体系完全不相交。如果你在学快速排序时不小心点进了磁盘工具教程,不用怀疑自己理解错了,直接退出来继续看算法就好。这种同名异义的情况在计算机领域特别常见,区分清楚能省下不少时间。
最后:我的实际经验
我在实际写快速排序时,已经不再背“i找大、j找小”或者“i找小、j找大”这类口诀了。每次动手前只问自己三个问题:第一,基准放在哪一侧?第二,目标顺序要求左边、右边分别应该站什么元素?第三,初始坑在哪一侧,哪个指针先走?三个问题一过,代码自己就写出来了。
还有一个我踩过多次坑之后养成的小习惯:写完排序一定要用三种特殊数组自测——基本有序的数组、完全逆序的数组、全部元素相同的数组。快速排序对固定取最左基准的写法有个天然弱点,当数组基本有序时,每次分区都选到最值,递归深度退化成 O(n),效率很低。虽然这是另一个话题,但自测时能逼你发现这个问题,也算一石二鸟。分区方向这点事,想通了就真的再也不忘了。
