JavaScript 快速排序:从 pivot、双指针到分治思想

最近在整理排序算法,前面学过冒泡排序、选择排序、插入排序,这几种排序都比较直观,但时间复杂度一般是 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,停下。

交换 52

csharp 复制代码
[4, 1, 2, 5]

这时指针继续移动,最后 ij 相遇。

然后把基准值 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 回到正确位置,后面的递归就顺了。

最后再记一句:

复制代码
快速排序快,不是因为少比较,而是因为每次都把问题规模切小。

这就是分治的威力。

相关推荐
沉默王二1 小时前
Agent底层原理连问8道,从ReAct到记忆压缩,PaiCLI项目实战拆解
面试·agent·ai编程
小月土星1 小时前
JavaScript 递归入门:从 1 到 n 求和,再到数组扁平化
javascript·算法·面试
蝎子莱莱爱打怪1 小时前
XZLL-IM干货系列 04|Netty 长连接实战:Pipeline 怎么排、心跳怎么跳、连接怎么管
后端·微服务·面试
还有多久拿退休金2 小时前
一个 var 让整个团队加班到凌晨——JS 闭包的那些暗坑
前端·javascript
weedsfly2 小时前
用了 React/Vue 之后,这些 DOM 操作的坑你踩过几个?
前端·javascript
Asize2 小时前
Ajax 入门:从 JSON 序列化到 XMLHttpRequest
前端·javascript·前端框架
铁皮饭盒2 小时前
@kognitivedev/rag, 用js做AI Agent开发
javascript·后端
kyriewen15 小时前
别再 console.log 了:5 个 Chrome DevTools 调试技巧,用过就回不去了
前端·javascript·面试