上一节我们学习的「快速排序」是一个基础版本的快速排序,我们知道了 partition 的意义:选择一个元素作为切分元素(pivot
经过一次 partition 以后,这个元素就能挪到排序以后它最终应该在的位置,并且它前面的所有元素都小于等于 pivot,它后面的所有元素都大于等于 pivot,接着我们只需要依次对 pivot 前面的所有元素和 pivot后面的所有元素依次执行同样的逻辑,每次都排定一个元素。

我们还指出了选择标定元素的时候需要随机选择,否则在面对极端测试用例(有序数组或者倒序数组)的时候,递归树会发生倾斜,导致复杂度退化成平方级别。解决的办法是「随机选择切分元素」。

随机选择切分元素有一种情况是无效的,那就是输入数组中有大量重复元素的数据交换过来的元素还是和 pivot 相等的元素。这个时候我们的策略是:
把和 pivot 相等的元素 平均分到数组的两边
使得递归树平衡,这就有了如下的指针对撞的快速排序


指针对撞的快速排序

以下是两种可行的 partition 过程循环不变量的设置,不管是哪一种,和切分元素相等的元素都全部被划分到了一侧,都会使得递归树失衡,即递归树的深度增加。
快速排序(对撞指针) - 图1

那么,有没有办法,让与切分元素相等的元素分散到数组的两侧呢?事实上也不太难,我们这个时候设置两个变量,往中间靠拢,这两个变量分别用 lege 表示。

循环不变量:在循环的过程中,总有 [left + 1, le) <= pivot 并且 (ge, right] >= pivot 成立;

  • le 的初值是 left + 1ge 的初值是 right + 1 ,这样上面的两个区间是空区间;
  • 我们采用两边夹的方式
    • le 遇到严格小于 pivot 的元素的时候自增,直到遇到一个元素大于等于 pivot 的时候停下;
    • ge 遇到严格大于 pivot 的元素的时候自减,直到遇到一个元素小于等于 pivot 的时候停下;

此时 le 来到了第 1 个大于等于 pivot 的位置; ge 来到了第 1 个小于等于 pivot 的位置,此时把它们交换,交换以后,它们各自再向中间走一步,重复这样的过程直到它们相遇为止。
请注意:等于 **pivot** 的元素通过交换,换到对面去,而不是直接吸收进来。通过这种方式,实现了等于 **pivot** 的元素平均分到数组两边的效果,从而也保持了循环不变量

什么时候停下呢?**le > ge** 的时候。最后需要特别注意:交换 leftge 位置上的元素,因为此时 le 已经在 ge 的右边,循环不变量小于等于 pivot 的这个区间是不包括 le 的,因此,le 所指向的元素很可能是大于 pivot,而 ge 来到了 le 的左边。根据循环不变量,它所指向的元素一定是小于等于 pivot 的,因此我们交换 leftge 位置上的元素,我们排定的元素是 ge 所对应的元素,应该把 ge 返回回去。
这种设置两个变量,一个从左往右遍历,一个从右往左遍历的技巧叫做「双指针」(two pointer)或者「指针对撞」。

快速排序的代码框架如下:

  1. 构建分界点
    1. 循环
    2. 随机基准值
      1. arr[Math.floor(Math.random() * (right - left + 1)) + left]
    3. 基准值左边找比它大的
    4. 基准值右边找比它小的
    5. 下标左小右大交换
    6. 有重复就跳过
  2. 分界点左边区域继续构建分解点
  3. 分界点右边区域继续构建分界点 ```javascript // 「力扣」第 912 题:排序数组

const sortArray = function sortArray(nums) { if (nums.length < 2) return nums; // 记得return return quickSort(nums, 0, nums.length - 1); }

const quickSort = function quickSort(nums, left, right) { if (left >= right) { return;
} /** 前序遍历位置 **/ // 通过交换元素构建分界点 p let p = partition(nums, left, right); /**递归**/ // 分界点 p 已经排好了 quickSort(nums, left, p - 1); quickSort(nums, p + 1, right); return nums; }

const partition = function partition(nums, left, right) { const randomInt = Math.floor(Math.random() * (right - left + 1)); const pivot = nums[left + randomInt]; let l = left; let r = right; while (l < r) { // 循环,直到在左边区域找到比pivot大的元素 while ([l] < pivot) { l++; } // 循环,直到在右边区域找到比pivot 小的元素 while (nums[r] > pivot) { r—; } // 两个循环出来的时候, 刚好一个大一个小 // 交换就完事了 if (l < r) { [nums[l], nums[r]] = [nums[r], nums[l]]; } // 当数组中存在重复数据时,即都为datum,但位置不同 // 继续递增i,防止死循环 if (nums[l] == nums[r] && l != r) { l++ } } return l; // i 和 j 已经一样了 } ```


练习

  1. 自行编写测试用例,完成下面的实验:在输入数据有大量重复元素的时候,这一节介绍的快速排序能起到优化的效果;
  2. 这一节我们没有相关的练习,大家把这一版 partition 写进快速排序的实现里,注意调试,通过「力扣」第 912 题即可。