前端學數據結構與算法(七): 從零實現優先隊列-堆及其應用

前言

爲什麼說樹結構是01世界裏最重要的數據結構,因爲只要調整一下節點的存儲順序或枝杈多少,解決問題的類型就可以完全不同。本章介紹的堆也是二叉樹的一種,與二叉搜索樹想比,只是改變了節點存放值的規則,它遵循的規則就是每個父節點的值必須大於或等於孩子節點的值,這種數據結構是二叉堆,也可以叫它優先隊列。

什麼是優先隊列?

上一章我們介紹了普通隊列,固定的從隊尾入隊,從隊首出隊。假如現在的場景需求是,出隊時每次必須出隊優先級最高的元素,你可能會說,這還不簡單麼,只需要出隊時遍歷整個隊列,從裏面找到優先級最高的元素出隊,然後再隊列裏移除該元素即可。這樣確實能滿足場景的需求,但也會出現一個問題,就是出隊效率太低,如果使用的是數組,找到優先級最高的元素需要O(n)的時間,然後出隊該元素數組又需要O(n)的位移操作,也就是說每次出隊需要消耗O(n²)的時間,這個有點太奢侈。

而堆這種數組結構就是專門用來解決這一類的問題,同樣是使用數組,同樣每次出隊優先級最高的元素,卻可以把入隊和出隊的操作穩定的保持在O(logn),雖然普通隊列入隊是O(1),但從入隊出隊的平均複雜度來看,性能的差距是O(log)O(n²)。我們來看下在處理十萬條數據的出隊/入隊時,堆與普通隊列之間的性能差距。 百萬級別的數據時家用機已經卡死,而堆依然可以一秒之內處理完,並且隨着數據規模的增加,它們之間的性能差距倍數只會越來越大。

什麼是堆?

[37, 22, 26, 12, 11, 25, 7, 3, 8, 10],初看起來這是一個數組,但如果我們把它按照二叉樹的結構排列起來,就會出現圖下的結構: 而這就是一個堆,它有幾個特點:

1. 父節點優先級高於或等於子節點

堆最顯著的特點就是所有父節點的優先級都高於或等於孩子節點。這裏的堆爲最大堆,根節點的值最大,任意節點的排列順序皆是如此。還有最小堆,也就是所有父節點的值小於或等於孩子節點,根節點的值最小。

2. 完全二叉樹

堆的排列都是一顆完全二叉樹,除去最底層的葉子節點,它就是一顆滿二叉樹,而且所有的葉子節點全部都朝樹的左側擺放。之前二叉樹特性章節已經有所說明,只要是滿二叉樹使用數組是最佳存儲方式。

3. 動態性

與二叉搜索樹類似,當往構建好的堆內再添加或取出元素時,爲了不破壞堆的性質,堆有自己的一套操作,無論何時添加/移除多少元素,始終不會破壞堆的性質。

從零實現最大堆

使用數組來表示二叉樹,最大的問題是當前節點沒有指向孩子節點的指針,其實這問題很好解決,通過當前節點的下標可以求出它的父節點和左右孩子節點,這也是使用數組存儲的優勢: 解決了這個難點後,我們首先寫出最大堆的輔助函數:

class MaxHeap {
  constructor() {
    this._data = [] // 存儲堆裏的數據
  }
  getParent(i) { // 獲取父節點
    return (i - 1) / 2 | 0 // 向下取整
  }
  getLeftChild(i) { // 獲取左孩子
    return i * 2 + 1
  }
  getRightChild(i) { // 獲取右孩子
    return i * 2 + 2
  }
}

增(siftUp)

往堆裏添加任意元素而又不能破壞堆的性質,首先將添加的元素推入堆數組的末尾,然後這個元素需要與自己的父節點進行比較,如果比父節點大,那麼它們之間就需要位置交換。交換位置之後繼續與之前當前位置的父節點進行比較,如果還是比父節點大,就繼續交換,直到遇到不大於父節點或已經達到根節點爲止。這個操作也叫siftUp,讓節點一步步的進行上浮。

class MaxHeap {
  constructor() {
    this._data = [] // 存儲堆裏的數據
  }
  ...
  
  push(val) {
    this._data.push(val) // 添加到隊尾
    this.siftUp(this._data.length - 1) // 上浮隊尾元素
  }
  siftUp(i) {
    const parent = this.getParent(i) // 獲取父節點的值
    if (this._data[i] > this._data[parent]) { // 如果大於父節點的值
      this.swap(i, parent) // 交換位置
      this.siftUp(parent) // 繼續上浮
    }
  }
  swap(i, j) { // 交換數組兩個元素的位置
    [this._data[i], this._data[j]] = [this._data[j], this._data[i]]
  }
}

取(siftDown)

費了這麼多事,都是爲了出隊的元素是優先級最高的,很明顯,直接出隊堆頂的元素即可,但出隊後,根節點就爲空,這個時候就破壞了堆的性質。所以堆又有對應的出隊操作siftDown,堆頂出隊後,將堆數組的最後一個元素放入堆頂,然後將堆頂的元素與它的左右孩子進行比較,與其中最大值的孩子交換位置,直至大於兩個孩子節點或到達根節點即可。

class MaxHeap {
  constructor() {
    this._data = [] // 存儲堆裏的數據
  }
  ...
  
  sift() { // 出隊
    if (this._data.length === 0) {
      return
    }
    const max = this._data[0] // 緩存出隊元素
    this.swap(0, this._data.length - 1) // 頂尾交換
    this._data.pop() // 移除隊尾
    this.siftDown(0) // 將換上來的節點進行下沉
    return max // 返回應該出隊的元素
  }
  
  siftDown() {
    const { _data } = this
    let max = this.getLeftChild(i) // 假如左孩子大
    if (max >= _data.length) { // 左孩子界限超出,遞歸終止
      return
    }
    if (max + 1 < _data.length && _data[max + 1] > _data[max]) {
      // 如果有右孩子且右孩子比左孩子大,max+1變爲右孩子的下標
      max++
    }
    if (_data[i] < _data[max]) { // 如果孩子節點大於當前節點,執行下沉操作
      this.swap(i, max) // 交換當前節點與孩子節點的位置
      this.siftDown(max) // 在孩子節點的位置繼續遞歸
    }
  }
}

下沉操作的關鍵是找到兩個孩子節點裏面比較大的那位進行交換,交換之後在孩子節點的位置進行遞歸即可。以上就是堆最重要兩個操作,入隊和出隊,已經算是從零完成了堆,不過有兩個堆的輔助函數,可以更加方便快速的完成堆的相應操作。

替換(replace)

現在有一個需求是出隊的同時,添加一個值到堆裏,可以使用前面實現的兩個方法方式,首先sift取出堆頂元素,然後push進一個元素即可,不過這樣的話會執行兩次O(logn)的操作,我們可以封裝一個replace方法,執行一次O(logn)即可。

class MaxHeap {
  constructor() {
    this._data = [] // 存儲堆裏的數據
  }
  ...
  
  replace(val) {
    if (this._data.length === 0) {
      return
    }
    const max = this._data[0] // 緩存出隊元素
    this._data[0] = val // 將添加的元素賦值給堆頂
    this.siftDown(0) // 堆頂執行下沉操作
    return max // 出隊
  }
}

數組快速堆化(heapify)

如何將一個數組快速轉換爲堆?直接把數組的每一項遍歷添加到堆裏即可,這樣確實可以實現,但這個過程並不是最快的。這裏我們可以藉助完全二叉樹的特性,以最後一個葉子節點的父節點爲起點,把起點節點到根節點之間的所有節點進行下沉操作即可。這樣的話,平均可以省去一半節點的操作,通過我看不懂的證明得知可從O(nlogn)變爲O(n)的複雜度。

class MaxHeap {
  constructor(arr = []) { // 構造函數支持傳入數組,快速堆化
    if (Array.isArray(arr) && arr.length > 0) {
      this._data = [...arr]
      this.heapify(this._data)
    } else {
      this._data = [] // 存儲堆裏的數據
    }
  }
  ...
  
  heapify(arr) {
    let parent = this.getParent(arr.length - 1)
    while (parent >= 0) {
      this.siftDown(parent)
      parent--
    }
  }
}

堆的應用

以上代碼我們從零實現了一個堆,但它有什麼用了?接下來介紹幾個示例來說明這種數據結構的作用,及在處理特定問題時的巧妙。

堆排序 ↓

從上面實現的最大堆不難發現,既然每次堆頂都是最大的值,那我們依次出隊整個堆,那不實現了一次降序的排序麼?依照這個特性,構建一個最小堆來實現一個排序算法試試!

function heapSort(arr) {
  let last = ((arr.length - 2) / 2) | 0; // 只需要從最後一個葉子的父節點開始
  const ret = [];
  while (last >= 0) {
    siftDown(arr, last, arr.length); // 下沉一半的節點實現堆化
    last--;
  }
  while (arr.length > 0) {
    ret.push(arr[0]); // 將堆頂元素放入新數組內
    [arr[0], arr[arr.length - 1]] = [arr[arr.length - 1], arr[0]]; // 交換首尾的元素
    arr.pop(); // 將交換的堆頂元素去掉
    siftDown(arr, 0, arr.length); // 重新下沉堆頂,不破壞堆的性質
  }
  return ret; // 返回排序好的數組
  
  function siftDown(arr, i, n) {
    let left = i * 2 + 1;
    if (left >= n) {
      return;
    }
    if (left + 1 < n && arr[left] > arr[left + 1]) { // 改爲判斷誰小誰 +1
      left++;
    }
    if (arr[left] < arr[i]) { // 讓大元素下沉
      [arr[left], arr[i]] = [arr[i], arr[left]];
      siftDown(arr, left, n);
    }
  }
}

雖然我們完成了排序的任務,但這個算法我們另外開闢了O(n)的空間去存放新的排序好的數組,那有沒可能省掉這個額外的空間了,答案是有的,可以使用原地堆排序的方式,我們來實現它!

原地堆排序 ↓

也就是在原數組上直接進行排序,而不借助額外空間,它的實現原理是使用最大堆,構建好了之後將堆頂元素與末尾進行交換,而後縮小右邊界,重新構建堆,依次執行上述過程即可。

function heapSort(arr) {
  let last = ((arr.length - 1) / 2) | 0;
  while (last >= 0) {
    siftDown(arr, last, arr.length); // 使用最大堆
    last--;
  }
  let r = arr.length;
  while (r > 0) {
    [arr[0], arr[r - 1]] = [arr[r - 1], arr[0]]; // 將堆頂與末尾元素交換
    r--; // 右邊界減1
    siftDown(arr, 0, r); // 重新下沉堆頂元素
  }
  
  function siftDown (arr, i, n) {
    let left = i * 2 + 1;
    if (left >= n) {
      return;
    }
    if (left + 1 < n && arr[left] < arr[left + 1]) { // 大頂堆
      left++;
    }
    if (arr[i] < arr[left]) { // 下沉小元素
      [arr[i], arr[left]] = [arr[left], arr[i]];
      siftDown(arr, left, n);
    }
  };
}

1046-最後一塊石頭的重量 ↓

有一堆石頭,每塊石頭的重量都是正整數。
每一回合,從中選出兩塊 最重的 石頭,然後將它們一起粉碎。
假設石頭的重量分別爲 x 和 y,且 x <= y。那麼粉碎的可能結果如下:
如果 x === y,那麼兩塊石頭都會被完全粉碎;
如果 x !== y,那麼重量爲 x 的石頭將會完全粉碎,而重量爲 y 的石頭新重量爲 y-x。
最後,最多隻會剩下一塊石頭。返回此石頭的重量。如果沒有石頭剩下,就返回 0。

輸入:[2,7,4,1,8,1]
輸出:1
解釋:
先選出 7 和 8,得到 1,所以數組轉換爲 [2,4,1,1,1],
再選出 2 和 4,得到 2,所以數組轉換爲 [2,1,1,1],
接着是 2 和 1,得到 1,所以數組轉換爲 [1,1,1],
最後選出 1 和 1,得到 0,最終數組轉換爲 [1],這就是最後剩下那塊石頭的重量。

這題取巧的地方就在於每次要找到最大的兩塊石頭,那我們使用最大堆來解這題就非常適合了,取兩次堆頂的元素元素即可,而後把相減的絕對值放入堆頂,重新下沉一次維持堆的性質即可,當堆裏的元素只剩一個時結束這個過程。代碼如下:

 const lastStoneWeight = function (stones) {
  let last = ((stones.length - 2) / 2) | 0;

  while (last >= 0) { // 堆化
    siftDown(stones, last);
    last--;
  }

  while (stones.length > 1) { // 當只有一個元素結束循環
    swap(stones, 0, stones.length - 1) // 首尾交換
    const first = stones.pop() // 取出尾巴里的最大值
    siftDown(stones, 0) // 執行下沉維持堆
    const second = stones[0] // 再取出堆頂的元素,即爲第二大的元素
    const diff = Math.abs(first - second) // 求出絕對值
    stones[0] = diff // 將絕對值覆蓋堆頂
    siftDown(stones, 0) // 再次維持堆
  }
  return stones[0];

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

  function siftDown(arr, i) {
    let left = i * 2 + 1;
    if (left >= arr.length) {
      return;
    }
    if (left + 1 < arr.length && arr[left] < arr[left + 1]) {
      left++;
    }
    if (arr[left] > arr[i]) {
      swap(arr, left, i)
      siftDown(arr, left);
    }
  }
};

堆是用的數組實現,所以儘可能的不讓數組有位移的操作,這個非常有必要考慮。

215-數組中的第K個最大元素 ↓

最簡單的解法就是先使用sort函數排序,然後選取對應下標的元素即可,但如果面試官出了這個題目,那麼想看到肯定就不是這麼一個O(nlogn)的暴力解法了,藉助本章學習的堆,我們可以交出O(nlogk)的解法。如果是求100萬數據的第100大小的數字,這個優化還是很有益的。

首先說明思路,構建一個k大小的最小堆,之後遇到的元素如果大於堆頂,就替換堆頂的元素,執行下沉操作,保持堆的性質,最後當整個數組遍歷完,這個最小堆的堆頂就是需要返回的元素了,因爲比第k大的全部在堆頂的下面。

var findKthLargest = function (nums, k) {
  const minHeap = [] // 最小堆
  for (let i = 0; i < nums.length; i++) {
    if (i < k) { // 堆的大小還沒達到k
      minHeap.push(nums[i]) // 直接push
      siftUp(minHeap, minHeap.length - 1) // 執行上浮操作維持堆的性質
    } else if (nums[i] > minHeap[0]) { // 如果下一個元素大於堆頂
      minHeap[0] = nums[i] // 替換堆頂元素
      siftDown(minHeap, 0) // 執行下沉操作維持堆性質
    }
  }
  return minHeap[0] // 最後返回最小堆堆頂元素即可
};

function siftUp(nums, i) {
  const parent = (i - 1) / 2 | 0
  if (nums[i] < nums[parent]) { // 將小的元素上浮
    swap(nums, i, parent)
    siftUp(nums, parent)
  }
}

function siftDown(nums, i) {
  let l = i * 2 + 1
  if (l + 1 > nums.length) {
    return
  }
  if (l + 1 <= nums.length && nums[l] > nums[l + 1]) {
    l++  // 選取小的元素
  }
  if (nums[i] > nums[l]) { // 將大的元素下沉
    swap(nums, i, l)
    siftDown(nums, l)
  }
}

function swap(nums, i, parent) {
  [nums[i], nums[parent]] = [nums[parent], nums[i]]
}

373-查找和最小的K對數字 ↓

給定兩個以升序排列的整形數組 nums1 和 nums2, 以及一個整數 k。
定義一對值 (u,v),其中第一個元素來自 nums1,第二個元素來自 nums2。
找到和最小的 k 對數字 (u1,v1), (u2,v2) ... (uk,vk)。

輸入: nums1 = [1,7,11], nums2 = [2,4,6], k = 3
輸出: [1,2],[1,4],[1,6]
解釋: 返回序列中的前 3 對數:
     [1,2],[1,4],[1,6],[7,2],[7,4],[11,2],[7,6],[11,4],[11,6]

依次遍歷嵌套的兩個數組,這個是無可避免的,但這題我們能優化的是這個計算結果的存放,不使用暴力的每次計算後進行排序。使用堆即可,維持一個k大小的最大堆,每次計算的值與堆頂元素進行比較,如果比堆頂的小,就放入堆裏,最後重新排序這個k大小的堆即可。

var kSmallestPairs = function (nums1, nums2, k) {
  const heap = []
  for (let i = 0; i < nums1.length; i++) {
    for (let j = 0; j < nums2.length; j++) {
      if (heap.length === k && nums1[i] + nums2[j] >= (heap[0][0] + heap[0][1])) {
        // 因爲都是升序的數組,所以當這次的和大於堆頂時,
        // 那麼之後的循環也一定是大於的,所以跳出這輪循環即可。
        break
      }
      if (heap.length < k) { // 堆沒有滿時
        heap.push([nums1[i], nums2[j]])
        siftUp(heap, heap.length - 1) // 構建最大堆
      } else if ((nums1[i] + nums2[j]) <= sum(heap[0])) { // 只有當小於堆頂時
        heap[0] = [nums1[i], nums2[j]] // 替換堆頂的元素
        siftDown(heap, 0) // 維持最大堆
      }
    }
  }
  return heap.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]))
  // 因爲是最大堆,所有返回升序的順序
};

function siftUp(heap, i) {
  const parent = (i - 1) / 2 | 0
  if (sum(heap[i]) > sum(heap[parent])) {
    swap(heap, i, parent)
    siftUp(heap, parent)
  }
}

function siftDown(heap, i) {
  let left = i * 2 + 1
  if (left >= heap.length) {
    return
  }
  if (left + 1 < heap.length && sum(heap[left]) < sum(heap[left + 1])) {
    left++
  }
  if (sum(heap[i]) <= sum(heap[left])) {
    swap(heap, i, left)
    siftDown(heap, left)
  }
}

function sum(arr) { // 求和
  return arr[0] + arr[1]
}

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

最後

寫了這麼多,不難發現無非就是siftupsiftdown函數的編寫, 只要熟練掌握堆的這兩種操作,基本上來說堆也就掌握了90%。還有最主要的是熟悉堆的思想,針對前k類似的題目,使用堆都是非常適合的,而堆的動態性又非常適合處理數據隨時有新元素加入的場景。居然有這麼cool的數據結構~ 本章github源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章