前端学数据结构与算法(十):深刻理解快速排序

前言

上一章咱们已经实现了快速排序,在数据理想化的状况下,上一章的快排性能确实也不错,但若是数据比较极端的,快排的O(nlogn)就不太稳定了,本章将介绍几种快排应对极端数据下优化方案;以及介绍partition操做延伸出来的快速选择算法在解决top K问题时高效。面试

优化分区点的选择

上一章咱们直接选择了数组的范围内的第一个元素做为分区点,当数据有序度很低时,这样选择确实也没问题。可是若是自己就是一个有序数组,这个时候就会出问题: 算法

由于partition的操做是以分区点为中心,将数组一分为二。而此时数组是有序的,也就是说每次选择的这个分区点没法将数组一分为二,会致使快排最终的复杂度退化为O(n²)。因此此时要改变选择分区点的规则。数组

三数取中

不只是从头部,不管是从数组哪一个位置,只要是单单选择一个位置,都有可能出现退化的状况。因此咱们能够多选几个位置从里面挑一个出来。如从范围数组中的头、中间、尾选择三个元素,比较它们的大小,选择中间大小的值做为分区点。这样就能避免遇到有序数组时的退化状况,代码以下:浏览器

const quickSort = arr => {
  const _quickSort = (arr, l, r) => {
    if (l >= r) { // 递归终止条件
      return
    }
    const p = _partition(arr, l, r) // 返回分区点所在下标
    _quickSort(arr, l, p - 1) // 递归进行左半部分
    _quickSort(arr, p + 1, r) // 递归进行右半部分
  }
  _quickSort(arr, 0, arr.length - 1)
}

function _partition(arr, l, r) {
  // 三数取中分区点
  const mid = l + (r - l) / 2 | 0  // 中间位置
  const t1 = arr[l] > arr[mid] ? l : mid
  const t2 = arr[t1] > arr[r] ? r : t1
  swap(arr, l, t2) // 让最左侧的l和中间大小的值交换位置
  const v = arr[l] // 让中间值做为分区点
  
  let j = l
  for (let i = l + 1; i <= r; i++) { // 从l + 1,刨去分区点的位置
    if (v > arr[i]) { // 当分区点大于当前节点时
      swap(arr, j + 1, i)
      // 让大区间的开头与当前节点交换
      j++  // 小区分范围增长
    }
  }
  swap(arr, j, l) // 最后让分区点回到其所在位置
  return j // 返回其下标,进行接下来的分区操做
}

function swap (arr, i, j) {
  [arr[i], arr[j]] = [arr[j], arr[i]]
}

随机选择

从被须要排序数组的区间中随机选择一个位置做为分区点,虽然说不能保证每次都选到合适的值做为分区点,但从几率来讲,每一次都选到数组里最大或最小的值,几率几乎为0,理想中能保持O(nlogn)的复杂度,这个方法也常常被采用。代码以下:dom

const quickSort = arr => {
  const _quickSort = (arr, l, r) => {
    if (l >= r) { // 递归终止条件
      return
    }
    const p = _partition(arr, l, r) // 返回分区点所在下标
    _quickSort(arr, l, p - 1) // 递归进行左半部分
    _quickSort(arr, p + 1, r) // 递归进行右半部分
  }
  _quickSort(arr, 0, arr.length - 1)
}

function _partition(arr, l, r) {
  // 随机选择分区点
  const rdmIndex = Math.random() * (r - l + 1) + l | 0;
  swap(arr, l, rdmIndex) // 让最左侧的l与随机下标交换位置
  const v = arr[l] // 让随机值做为分区点
  
  ...
}

应对重复数据过多的三路快排

上面咱们解决了分区点选择的问题,但此时又有一个新的问题。假如一个数组里面有100万条数据,但它的取值范围都是0 ~ 10,此时再采用以前的partition算法就不行了。由于重复数据比较多,而上面partition里没有对值相等时的状况处理,会形成相等的数据所有堆积在分区数组其中的一边,又回到上一个问题,会致使分区极度不平衡。性能

此时咱们可使用三路快排,它会对相等的数据作单独的处理,不在仅仅是一分为二,而是一分为三,将相等的数据单独做为一个区间。并且再进行递归时,能够将相等的区间刨除在外,只对小区间或大区间进行partition操做,减小操做次数。图解示例以下:优化

仍是有两个边界下标left/right,游走下标更换为lt/i/gtlt表示为小区间的最后一位,i表示为当前访问到的元素,也能够理解为等于区间的最后一位,gt表示大区间的第一位。此次的partition操做将比较分为三种状况:ui

  1. 小于分区点

咱们须要将lt + 1i进行交换,并将lti依次进行后移。由于lt表示为小区间的最后一位,因此lt + 1就表示等于区间的第一个元素,而此时i又小于区间值,因此交换后lt依然是小区间的最后一位,而i继续遍历下一个元素。code

  1. 大于分区点

咱们须要将gt - 1i进行交换,由于gt表示的是当前大区间的第一位,而gt - 1则表示最红等于区间的最后一位,交换位置以后,大区间的范围也就增长了。此时仅仅gt前移一位便可,i不须要移动,由于交换过去的值还不肯定它的大小,正好做为当前元素便可。blog

  1. 等于分区点 那么i + 1便可,增长等于区间的范围及遍历下一个元素,lt/gt坐标都不须要移动。最终igt碰上以后结束遍历过程。

代码以下:

const quickSort3Ways = arr => {
  const _quickSort3Ways = (arr, l, r) => {
    if (l >= r) {
      return
    }
    
    const rdmIndex = Math.random() * (r - l + 1) + l | 0 // 选择随机分区点
    [arr[l], arr[rdmIndex]] = [arr[rdmIndex], arr[l]]
    
    const v = arr[l]
    let lt = l
    let gt = r + 1
    let i = l + 1
    
    while (i < gt) {
      if (arr[i] < v) { // 小于区间值
        [arr[i], arr[lt + 1]] = [arr[lt + 1], arr[i]]
        lt++
        i++
      } else if (arr[i] > v) { // 大于区间值
        [arr[i], arr[gt - 1]] = [arr[gt - 1], arr[i]]
        gt--
      } else { // 等于区间值
        i++
      }
    }
    
    [arr[l], arr[lt]] = [arr[lt], arr[l]] 
    // 交换分区点与小区间的最后一位,维持三个区间
    _quickSort3Ways(arr, l, lt - 1)
    // [l ... lt-1]表示的就是小区间
    _quickSort3Ways(arr, gt, r)
    // [gt ... r]表示的就是大区间
    // 而直接能够舍弃等于区间,提升效率
  }
  _quickSort3Ways(arr, 0, arr.length - 1)
}

改用遍历的方式,不用担忧调用栈溢出的状况,且三分以后,相等区间的范围在每次遍历时均可以直接忽略掉,由于它们已经在最终排好序的位置。

使用插入排序代替小范围排序

O(nlogn)的排序算法的确是比O(n²)快不少,但这描述的是随着数据规模n的增加而增加的趋势,这里忽略了常数以及低阶的状况,而当n小到必定常数时使用插入快排代替就是一种合理的选择,由于范围小则它有序度高的概率就大,插入排序在应对近似有序时的效率又奇高。能够这么理解,快排虽然快,但它的启动会慢一些。

const quickSort = arr => {
  const _quickSort = (arr, l, r) => {
    if (r - l < 10) { // 终止条件替换为插入排序
      insertSort(arr, l, r)
      return
    }
  
    const p = _partition(arr, l, r) // 返回分区点所在下标
    _quickSort(arr, l, p - 1) // 递归进行左半部分
    _quickSort(arr, p + 1, r) // 递归进行右半部分
  }
  _quickSort(arr, 0, arr.length - 1)
  
  ...
}

当要待排序的数组小到必定程度时,咱们改成插入排序,同时这里的插入排序也须要改一下,给它限定排序的范围:

const insertSort = (arr, l, r) => {
  for (let i = l + 1; i <= r; i++) {
    let e = arr[i]
    let j;
    for (j = i; j > l && arr[j - 1] > e; j--) {
      arr[j] = arr[j - 1]
    }
    arr[j] = e
  }
}

比堆更有效率的解决top-K问题

这是以前第七章介绍堆的一个力扣题目,当时使用的堆解决,堆能在O(nlogk)的时间复杂度里解出,这已是合格的解法了,不过在此借鉴快排的partition思想后,能交出O(n)的满分答案。先来回顾下题目:

215-数组中的第K个最大元素 ↓

在未排序的数组中找到第 k 个最大的元素。
请注意,你须要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不一样的元素。

示例:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5

咱们知道partition操做会把分区点放在合适的位置,最后返回它所在的下标,而这个下标偏偏就是当数组彻底排好序后,它正好所在的位置,因此咱们只须要找到下标正好等于K的元素便可。

这里又会有两种状况,返回的下标大于K或者小于K,由于partition操做已经对数组进行了分区,因此只须要将返回的下标与K进行比较便可,若是大于就去大区间查找,反之亦然。每次查找平都可以舍弃一半的查找范围,因此这个算法最差也是O(n)的时间复杂度。代码以下:

var findKthLargest = function (nums, k) {
  if (nums.length === 0 || k > nums.length || k < 0) {
    return -1;
  }
  const _partition = (nums, l, r) => { // 返回对应下标
    const rdmIndex = (Math.random() * (r - l + 1) + l) | 0;
    [nums[rdmIndex], nums[l]] = [nums[l], nums[rdmIndex]];
    const v = nums[l];
    let j = l;
    for (let i = l + 1; i <= r; i++) {
      if (nums[i] > v) { // 采用降序,正好对应第k大
        [nums[i], nums[j + 1]] = [nums[j + 1], nums[i]];
        j++;
      }
    }
    [nums[l], nums[j]] = [nums[j], nums[l]];
    return j;
  };
  let l = 0;
  let r = nums.length - 1;
  while (true) {
    const p = _partition(nums, l, r);
    if (p + 1 === k) { // 下标须要加1, 0表示第1大
      return nums[p];
    } else if (p < k) { // 缩小partition的范围
      l = p + 1;
    } else {
      r = p - 1;
    }
  }
};

严格意义上来讲,partition的思想确实在解决这个题目时是最快的解法,但其实它和用堆解法也是各有优劣。

当这个数组是动态随时会有新数据加入其中时,当前解法每次又须要O(n)时间去查找。而堆应对这种场景就有优点了,面对动态的数组集合,每次只须要从新维护堆结构便可,在O(logn)复杂度便可返回结果。

最后

本章咱们介绍了关于快排在面对极端数据时的优化以及它延伸出来的快速选择算法,还有在面对高频面试题Top-K问题时与堆处理的优劣(还有尾递归的优化,貌似浏览器不支持,就不列出了)。彻底理解快排也算是打通了算法的任督二脉,为更难理解的算法打好基础。做为排序里面的明星算法,快排值得一整个章节。

相关文章
相关标签/搜索