最近在整理排序算法,前面学过冒泡排序、选择排序、插入排序,这几种排序都比较直观,但时间复杂度一般是 O(n^2)。
快速排序不一样。它的平均时间复杂度是 O(nlogn),也是面试和刷题里很常见的一种排序思路。
这篇文章用 JavaScript 写一版原地快速排序,重点讲清楚三个东西:
pivot基准值是什么- 双指针为什么能完成分区
- 快速排序和递归、分治之间是什么关系
一、先看几种基础排序
在理解快速排序之前,可以先回忆一下常见的简单排序。
冒泡排序
冒泡排序的思路是:相邻两个元素两两比较,如果顺序不对就交换。
比如从小到大排序:
4 1 5 2
第一轮比较后,最大的数会被慢慢交换到最后。
它的问题也很明显:比较次数多,交换次数也多。
时间复杂度通常是:
scss
O(n^2)
选择排序
选择排序的思路是:每一轮从未排序区域里找到最小值,然后放到当前应该放的位置。
比如第一轮找最小值,放到第 0 位。
第二轮找剩下元素里的最小值,放到第 1 位。
它也比较好理解,但时间复杂度还是:
scss
O(n^2)
插入排序
插入排序有点像打牌。
从当前元素开始,往前找它应该插入的位置。
如果前面的数比它大,就继续往前移动。
插入排序在数据接近有序的时候表现不错,但一般情况下时间复杂度仍然是:
scss
O(n^2)
二、快速排序为什么更快
快速排序的核心思路是:
选一个基准值 pivot
比 pivot 小的放左边
比 pivot 大的放右边
再分别处理左边和右边
比如数组:
ini
const arr = [4, 1, 5, 2];
如果选择 4 作为基准值,那么排序过程会先把数组分成两边:
css
[1, 2] 4 [5]
左边都比 4 小,右边都比 4 大。
接下来继续对左边 [1, 2] 排序,再对右边 [5] 排序。
这就是快速排序的基本思想。
三、什么是 pivot
pivot 翻译过来就是基准值。
它的作用是把数组切成两部分:
小于等于 pivot 的区域
pivot
大于等于 pivot 的区域
在本文这版代码里,我们选择当前区间最左边的元素作为基准值:
css
nums[left]
比如:
ini
const nums = [4, 1, 5, 2];
第一次排序时:
ini
left = 0;
right = 3;
pivot = nums[left]; // 4
所以 4 就是这一轮的基准值。
四、完整代码
ini
function partition(nums, left, right) {
let i = left;
let j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left]) {
j--;
}
while (i < j && nums[i] <= nums[left]) {
i++;
}
[nums[i], nums[j]] = [nums[j], nums[i]];
}
[nums[left], nums[i]] = [nums[i], nums[left]];
return i;
}
function quickSort(nums, left, right) {
if (left >= right) {
return;
}
const pivotIndex = partition(nums, left, right);
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
}
const arr = [4, 1, 5, 2];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [1, 2, 4, 5]
五、partition 函数是干什么的
快速排序里最重要的函数是 partition。
它的作用不是直接把整个数组排好,而是完成一件事:
把 pivot 放到它最终应该在的位置
同时保证:
pivot 左边的元素 <= pivot
pivot 右边的元素 >= pivot
注意,这一步结束后,左右两边内部不一定有序。
比如:
2 1 4 5
4 的位置已经对了,但左边 [2, 1] 还没有完全排好。
所以后面还需要递归处理左右两边。
六、双指针怎么移动
代码:
ini
let i = left;
let j = right;
i 从左边开始走。
j 从右边开始走。
这一版代码选择 nums[left] 作为基准值,所以右指针先走。
css
while (i < j && nums[j] >= nums[left]) {
j--;
}
这段代码的意思是:
如果右边的元素比 pivot 大,说明它本来就应该在右边
那就继续往左找
直到找到一个比 pivot 小的数。
然后左指针开始走:
css
while (i < j && nums[i] <= nums[left]) {
i++;
}
这段代码的意思是:
如果左边的元素比 pivot 小,说明它本来就应该在左边
那就继续往右找
直到找到一个比 pivot 大的数。
当左边找到了一个偏大的数,右边找到了一个偏小的数,就交换它们:
ini
[nums[i], nums[j]] = [nums[j], nums[i]];
七、手动走一遍
数组:
csharp
[4, 1, 5, 2]
基准值:
ini
pivot = 4
初始状态:
ini
i = 0
j = 3
右指针 j 从右往左找比 4 小的数。
css
nums[3] = 2
2 < 4,停下。
左指针 i 从左往右找比 4 大的数。
css
nums[0] = 4
nums[1] = 1
nums[2] = 5
找到 5,停下。
交换 5 和 2:
csharp
[4, 1, 2, 5]
这时指针继续移动,最后 i 和 j 相遇。
然后把基准值 4 和相遇位置的元素交换:
ini
[nums[left], nums[i]] = [nums[i], nums[left]];
结果:
csharp
[2, 1, 4, 5]
现在 4 已经在正确位置了。
左边 [2, 1] 都比 4 小。
右边 [5] 都比 4 大。
八、为什么还要递归
完成一次 partition 之后,只能保证基准值的位置正确。
但是左边和右边还没有完全有序。
所以需要继续排序:
scss
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
这两行就是递归。
左边再选一个基准值,继续分区。
右边也一样。
直到每个区间只剩 0 个或 1 个元素。
这时就不用排了:
sql
if (left >= right) {
return;
}
这就是递归出口。
九、递归和分治有什么区别
这两个概念经常一起出现,但它们不是同一个东西。
递归是一种代码实现方式:
函数自己调用自己
分治是一种算法思想:
把大问题拆成小问题
分别解决小问题
再合并结果
快速排序就是典型的分治:
分:用 pivot 把数组分成左右两边
治:递归排序左边和右边
合:因为 pivot 已经在正确位置,所以不需要额外合并
快速排序通常用递归实现,但递归只是工具,分治才是思想。
十、时间复杂度
快速排序平均时间复杂度是:
scss
O(nlogn)
原因可以粗略理解为:
每一层分区都要扫描当前区间,整体大约是 O(n)。
如果每次 pivot 都能把数组分得比较均匀,大约会分 logn 层。
所以平均复杂度是:
scss
O(nlogn)
但如果 pivot 选得很差,比如数组已经有序,而每次都选择最左边元素作为 pivot,就可能退化成:
scss
O(n^2)
这也是快速排序的一个易错点。
十一、空间复杂度
这版快速排序是原地交换,没有创建新的左右数组。
所以额外空间主要来自递归调用栈。
平均情况下空间复杂度是:
scss
O(logn)
最坏情况下可能是:
scss
O(n)
十二、容易写错的地方
1. 忘记递归出口
错误写法:
scss
function quickSort(nums, left, right) {
const pivotIndex = partition(nums, left, right);
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
}
如果没有:
sql
if (left >= right) return;
递归会一直调用下去。
2. partition 返回值写错
partition 应该返回 pivot 最终所在的位置:
kotlin
return i;
这个位置后面会作为分界线。
3. 递归范围包含 pivot
错误写法:
scss
quickSort(nums, left, pivotIndex);
quickSort(nums, pivotIndex, right);
pivotIndex 已经排好了,不应该再参与递归。
正确写法:
scss
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
4. 不理解为什么右指针先走
因为这版代码选的是最左边元素作为 pivot。
为了保证最后交换时位置正确,通常让右指针先走。
如果这个细节没想清楚,代码有时候能跑,有时候会出错。
十三、总结
快速排序的代码看起来不长,但它考察的不是背代码,而是能不能想清楚这条线:
选 pivot
双指针分区
pivot 归位
递归处理左右区间
我觉得学快速排序时,最应该盯住的是 partition。
只要明白 partition 的目标是让 pivot 回到正确位置,后面的递归就顺了。
最后再记一句:
快速排序快,不是因为少比较,而是因为每次都把问题规模切小。
这就是分治的威力。