前端學數據結構與算法(十):深刻理解快速排序

前言

上一章咱們已經實現了快速排序,在數據理想化的狀況下,上一章的快排性能確實也不錯,但若是數據比較極端的,快排的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問題時與堆處理的優劣(還有尾遞歸的優化,貌似瀏覽器不支持,就不列出了)。徹底理解快排也算是打通了算法的任督二脈,爲更難理解的算法打好基礎。做爲排序裏面的明星算法,快排值得一整個章節。

相關文章
相關標籤/搜索