【DataStruct】常見數據結構

常見數據結構

時間複雜度

在進入正題之前,我們先來了解下什麼是時間複雜度。

通常使用最差的時間複雜度來衡量一個算法的好壞。

常數時間 O(1) 代表這個操作和數據量沒關係,是一個固定時間的操作,比如說四則運算。

對於一個算法來說,可能會計算出操作次數爲 aN + 1,N 代表數據量。那麼該算法的時間複雜度就是 O(N)。因爲我們在計算時間複雜度的時候,數據量通常是非常大的,這時候低階項和常數項可以忽略不計。

當然可能會出現兩個算法都是 O(N) 的時間複雜度,那麼對比兩個算法的好壞就要通過對比低階項和常數項了。

概念

棧是一個線性結構,在計算機中是一個相當常見的數據結構。

棧的特點是只能在某一端添加或刪除數據,遵循先進後出的原則

在這裏插入圖片描述

實現

每種數據結構都可以用很多種方式來實現,其實可以把棧看成是數組的一個子集,所以這裏使用數組來實現

class Stack {
  constructor() {
    this.stack = []
  }
  push(item) {
    this.stack.push(item)
  }
  pop() {
    this.stack.pop()
  }
  peek() {
    return this.stack[this.getCount() - 1]
  }
  getCount() {
    return this.stack.length
  }
  isEmpty() {
    return this.getCount() === 0
  }
}

應用

選取了 LeetCode 上序號爲 20 的題目

題意是匹配括號,可以通過棧的特性來完成這道題目

var isValid = function (s) {
  let map = {
    '(': -1,
    ')': 1,
    '[': -2,
    ']': 2,
    '{': -3,
    '}': 3
  }
  let stack = []
  for (let i = 0; i < s.length; i++) {
    if (map[s[i]] < 0) {
      stack.push(s[i])
    } else {
      let last = stack.pop()
      if (map[last] + map[s[i]] != 0) return false
    }
  }
  if (stack.length > 0) return false
  return true
};

其實在 Vue 中關於模板解析的代碼,就有應用到匹配尖括號的內容。

隊列

概念

隊列是一個線性結構,特點是在某一端添加數據,在另一端刪除數據,遵循先進先出的原則。

在這裏插入圖片描述

實現

這裏會講解兩種實現隊列的方式,分別是單鏈隊列和循環隊列。

單鏈隊列

class Queue {
  constructor() {
    this.queue = []
  }
  enQueue(item) {
    this.queue.push(item)
  }
  deQueue() {
    return this.queue.shift()
  }
  getHeader() {
    return this.queue[0]
  }
  getLength() {
    return this.queue.length
  }
  isEmpty() {
    return this.getLength() === 0
  }
}

因爲單鏈隊列在出隊操作的時候需要 O(n) 的時間複雜度,所以引入了循環隊列。循環隊列的出隊操作平均是 O(1) 的時間複雜度。

循環隊列

class SqQueue {
  constructor(length) {
    this.queue = new Array(length + 1)
    // 隊頭
    this.first = 0
    // 隊尾
    this.last = 0
    // 當前隊列大小
    this.size = 0
  }
  enQueue(item) {
    // 判斷隊尾 + 1 是否爲隊頭
    // 如果是就代表需要擴容數組
    // % this.queue.length 是爲了防止數組越界
    if (this.first === (this.last + 1) % this.queue.length) {
      this.resize(this.getLength() * 2 + 1)
    }
    this.queue[this.last] = item
    this.size++
    this.last = (this.last + 1) % this.queue.length
  }
  deQueue() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    let r = this.queue[this.first]
    this.queue[this.first] = null
    this.first = (this.first + 1) % this.queue.length
    this.size--
    // 判斷當前隊列大小是否過小
    // 爲了保證不浪費空間,在隊列空間等於總長度四分之一時
    // 且不爲 2 時縮小總長度爲當前的一半
    if (this.size === this.getLength() / 4 && this.getLength() / 2 !== 0) {
      this.resize(this.getLength() / 2)
    }
    return r
  }
  getHeader() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    return this.queue[this.first]
  }
  getLength() {
    return this.queue.length - 1
  }
  isEmpty() {
    return this.first === this.last
  }
  resize(length) {
    let q = new Array(length)
    for (let i = 0; i < length; i++) {
      q[i] = this.queue[(i + this.first) % this.queue.length]
    }
    this.queue = q
    this.first = 0
    this.last = this.size
  }
}

鏈表

概念

鏈表是一個線性結構,同時也是一個天然的遞歸結構。鏈表結構可以充分利用計算機內存空間,實現靈活的內存動態管理。但是鏈表失去了數組隨機讀取的優點,同時鏈表由於增加了結點的指針域,空間開銷比較大。

在這裏插入圖片描述

實現

單向鏈表

class Node {
  constructor(v, next) {
    this.value = v
    this.next = next
  }
}
class LinkList {
  constructor() {
    // 鏈表長度
    this.size = 0
    // 虛擬頭部
    this.dummyNode = new Node(null, null)
  }
  find(header, index, currentIndex) {
    if (index === currentIndex) return header
    return this.find(header.next, index, currentIndex + 1)
  }
  addNode(v, index) {
    this.checkIndex(index)
    // 當往鏈表末尾插入時,prev.next 爲空
    // 其他情況時,因爲要插入節點,所以插入的節點
    // 的 next 應該是 prev.next
    // 然後設置 prev.next 爲插入的節點
    let prev = this.find(this.dummyNode, index, 0)
    prev.next = new Node(v, prev.next)
    this.size++
    return prev.next
  }
  insertNode(v, index) {
    return this.addNode(v, index)
  }
  addToFirst(v) {
    return this.addNode(v, 0)
  }
  addToLast(v) {
    return this.addNode(v, this.size)
  }
  removeNode(index, isLast) {
    this.checkIndex(index)
    index = isLast ? index - 1 : index
    let prev = this.find(this.dummyNode, index, 0)
    let node = prev.next
    prev.next = node.next
    node.next = null
    this.size--
    return node
  }
  removeFirstNode() {
    return this.removeNode(0)
  }
  removeLastNode() {
    return this.removeNode(this.size, true)
  }
  checkIndex(index) {
    if (index < 0 || index > this.size) throw Error('Index error')
  }
  getNode(index) {
    this.checkIndex(index)
    if (this.isEmpty()) return
    return this.find(this.dummyNode, index, 0).next
  }
  isEmpty() {
    return this.size === 0
  }
  getSize() {
    return this.size
  }
}

二叉樹

樹擁有很多種結構,二叉樹是樹中最常用的結構,同時也是一個天然的遞歸結構。

二叉樹擁有一個根節點,每個節點至多擁有兩個子節點,分別爲:左節點和右節點。樹的最底部節點稱之爲葉節點,當一顆樹的葉數量數量爲滿時,該樹可以稱之爲滿二叉樹。

在這裏插入圖片描述

二分搜索樹

二分搜索樹也是二叉樹,擁有二叉樹的特性。但是區別在於二分搜索樹每個節點的值都比他的左子樹的值大,比右子樹的值小。

這種存儲方式很適合於數據搜索。如下圖所示,當需要查找 6 的時候,因爲需要查找的值比根節點的值大,所以只需要在根節點的右子樹上尋找,大大提高了搜索效率。

在這裏插入圖片描述

實現

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
  }
}
class BST {
  constructor() {
    this.root = null
    this.size = 0
  }
  getSize() {
    return this.size
  }
  isEmpty() {
    return this.size === 0
  }
  addNode(v) {
    this.root = this._addChild(this.root, v)
  }
  // 添加節點時,需要比較添加的節點值和當前
  // 節點值的大小
  _addChild(node, v) {
    if (!node) {
      this.size++
      return new Node(v)
    }
    if (node.value > v) {
      node.left = this._addChild(node.left, v)
    } else if (node.value < v) {
      node.right = this._addChild(node.right, v)
    }
    return node
  }
}

以上是最基本的二分搜索樹實現,接下來實現樹的遍歷。

對於樹的遍歷來說,有三種遍歷方法,分別是先序遍歷、中序遍歷、後序遍歷。三種遍歷的區別在於何時訪問節點。在遍歷樹的過程中,每個節點都會遍歷三次,分別是遍歷到自己,遍歷左子樹和遍歷右子樹。如果需要實現先序遍歷,那麼只需要第一次遍歷到節點時進行操作即可。

// 先序遍歷可用於打印樹的結構
// 先序遍歷先訪問根節點,然後訪問左節點,最後訪問右節點。
preTraversal() {
  this._pre(this.root)
}
_pre(node) {
  if (node) {
    console.log(node.value)
    this._pre(node.left)
    this._pre(node.right)
  }
}
// 中序遍歷可用於排序
// 對於 BST 來說,中序遍歷可以實現一次遍歷就
// 得到有序的值
// 中序遍歷表示先訪問左節點,然後訪問根節點,最後訪問右節點。
midTraversal() {
  this._mid(this.root)
}
_mid(node) {
  if (node) {
    this._mid(node.left)
    console.log(node.value)
    this._mid(node.right)
  }
}
// 後序遍歷可用於先操作子節點
// 再操作父節點的場景
// 後序遍歷表示先訪問左節點,然後訪問右節點,最後訪問根節點。
backTraversal() {
  this._back(this.root)
}
_back(node) {
  if (node) {
    this._back(node.left)
    this._back(node.right)
    console.log(node.value)
  }
}

以上的這幾種遍歷都可以稱之爲深度遍歷,對應的還有種遍歷叫做廣度遍歷,也就是一層層地遍歷樹。對於廣度遍歷來說,我們需要利用之前講過的隊列結構來完成。

breadthTraversal() {
  if (!this.root) return null
  let q = new Queue()
  // 將根節點入隊
  q.enQueue(this.root)
  // 循環判斷隊列是否爲空,爲空
  // 代表樹遍歷完畢
  while (!q.isEmpty()) {
    // 將隊首出隊,判斷是否有左右子樹
    // 有的話,就先左後右入隊
    let n = q.deQueue()
    console.log(n.value)
    if (n.left) q.enQueue(n.left)
    if (n.right) q.enQueue(n.right)
  }
}

接下來先介紹如何在樹中尋找最小值或最大數。因爲二分搜索樹的特性,所以最小值一定在根節點的最左邊,最大值相反

getMin() {
  return this._getMin(this.root).value
}
_getMin(node) {
  if (!node.left) return node
  return this._getMin(node.left)
}
getMax() {
  return this._getMax(this.root).value
}
_getMax(node) {
  if (!node.right) return node
  return this._getMin(node.right)
}

向上取整和向下取整,這兩個操作是相反的,所以代碼也是類似的,這裏只介紹如何向下取整。既然是向下取整,那麼根據二分搜索樹的特性,值一定在根節點的左側。只需要一直遍歷左子樹直到當前節點的值不再大於等於需要的值,然後判斷節點是否還擁有右子樹。如果有的話,繼續上面的遞歸判斷。

floor(v) {
  let node = this._floor(this.root, v)
  return node ? node.value : null
}
_floor(node, v) {
  if (!node) return null
  if (node.value === v) return v
  // 如果當前節點值還比需要的值大,就繼續遞歸
  if (node.value > v) {
    return this._floor(node.left, v)
  }
  // 判斷當前節點是否擁有右子樹
  let right = this._floor(node.right, v)
  if (right) return right
  return node
}

排名,這是用於獲取給定值的排名或者排名第幾的節點的值,這兩個操作也是相反的,所以這個只介紹如何獲取排名第幾的節點的值。對於這個操作而言,我們需要略微的改造點代碼,讓每個節點擁有一個 size 屬性。該屬性表示該節點下有多少子節點(包含自身)。

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
    // 修改代碼
    this.size = 1
  }
}
// 新增代碼
_getSize(node) {
  return node ? node.size : 0
}
_addChild(node, v) {
  if (!node) {
    return new Node(v)
  }
  if (node.value > v) {
    // 修改代碼
    node.size++
    node.left = this._addChild(node.left, v)
  } else if (node.value < v) {
    // 修改代碼
    node.size++
    node.right = this._addChild(node.right, v)
  }
  return node
}
select(k) {
  let node = this._select(this.root, k)
  return node ? node.value : null
}
_select(node, k) {
  if (!node) return null
  // 先獲取左子樹下有幾個節點
  let size = node.left ? node.left.size : 0
  // 判斷 size 是否大於 k
  // 如果大於 k,代表所需要的節點在左節點
  if (size > k) return this._select(node.left, k)
  // 如果小於 k,代表所需要的節點在右節點
  // 注意這裏需要重新計算 k,減去根節點除了右子樹的節點數量
  if (size < k) return this._select(node.right, k - size - 1)
  return node
}

接下來講解的是二分搜索樹中最難實現的部分:刪除節點。因爲對於刪除節點來說,會存在以下幾種情況

  • 需要刪除的節點沒有子樹
  • 需要刪除的節點只有一條子樹
  • 需要刪除的節點有左右兩條樹

對於前兩種情況很好解決,但是第三種情況就有難度了,所以先來實現相對簡單的操作:刪除最小節點,對於刪除最小節點來說,是不存在第三種情況的,刪除最大節點操作是和刪除最小節點相反的,所以這裏也就不再贅述。

delectMin() {
  this.root = this._delectMin(this.root)
  console.log(this.root)
}
_delectMin(node) {
  // 一直遞歸左子樹
  // 如果左子樹爲空,就判斷節點是否擁有右子樹
  // 有右子樹的話就把需要刪除的節點替換爲右子樹
  if ((node != null) & !node.left) return node.right
  node.left = this._delectMin(node.left)
  // 最後需要重新維護下節點的 `size`
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}

最後講解的就是如何刪除任意節點了。對於這個操作,T.Hibbard 在 1962 年提出瞭解決這個難題的辦法,也就是如何解決第三種情況。

當遇到這種情況時,需要取出當前節點的後繼節點(也就是當前節點右子樹的最小節點)來替換需要刪除的節點。然後將需要刪除節點的左子樹賦值給後繼結點,右子樹刪除後繼結點後賦值給他。

你如果對於這個解決辦法有疑問的話,可以這樣考慮。因爲二分搜索樹的特性,父節點一定比所有左子節點大,比所有右子節點小。那麼當需要刪除父節點時,勢必需要拿出一個比父節點大的節點來替換父節點。這個節點肯定不存在於左子樹,必然存在於右子樹。然後又需要保持父節點都是比右子節點小的,那麼就可以取出右子樹中最小的那個節點來替換父節點。

delect(v) {
  this.root = this._delect(this.root, v)
}
_delect(node, v) {
  if (!node) return null
  // 尋找的節點比當前節點小,去左子樹找
  if (node.value < v) {
    node.right = this._delect(node.right, v)
  } else if (node.value > v) {
    // 尋找的節點比當前節點大,去右子樹找
    node.left = this._delect(node.left, v)
  } else {
    // 進入這個條件說明已經找到節點
    // 先判斷節點是否擁有擁有左右子樹中的一個
    // 是的話,將子樹返回出去,這裏和 `_delectMin` 的操作一樣
    if (!node.left) return node.right
    if (!node.right) return node.left
    // 進入這裏,代表節點擁有左右子樹
    // 先取出當前節點的後繼結點,也就是取當前節點右子樹的最小值
    let min = this._getMin(node.right)
    // 取出最小值後,刪除最小值
    // 然後把刪除節點後的子樹賦值給最小值節點
    min.right = this._delectMin(node.right)
    // 左子樹不動
    min.left = node.left
    node = min
  }
  // 維護 size
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}

AVL 樹

概念

二分搜索樹實際在業務中是受到限制的,因爲並不是嚴格的 O(logN),在極端情況下會退化成鏈表,比如加入一組升序的數字就會造成這種情況。

AVL 樹改進了二分搜索樹,在 AVL 樹中任意節點的左右子樹的高度差都不大於 1,這樣保證了時間複雜度是嚴格的 O(logN)。基於此,對 AVL 樹增加或刪除節點時可能需要旋轉樹來達到高度的平衡。

實現

因爲 AVL 樹是改進了二分搜索樹,所以部分代碼是於二分搜索樹重複的,對於重複內容不作再次解析。

對於 AVL 樹來說,添加節點會有四種情況

在這裏插入圖片描述

對於左左情況來說,新增加的節點位於節點 2 的左側,這時樹已經不平衡,需要旋轉。因爲搜索樹的特性,節點比左節點大,比右節點小,所以旋轉以後也要實現這個特性。

旋轉之前:new < 2 < C < 3 < B < 5 < A,右旋之後節點 3 爲根節點,這時候需要將節點 3 的右節點加到節點 5 的左邊,最後還需要更新節點的高度。

對於右右情況來說,相反於左左情況,所以不再贅述。

對於左右情況來說,新增加的節點位於節點 4 的右側。對於這種情況,需要通過兩次旋轉來達到目的。

首先對節點的左節點左旋,這時樹滿足左左的情況,再對節點進行一次右旋就可以達到目的。

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
    this.height = 1
  }
}

class AVL {
  constructor() {
    this.root = null
  }
  addNode(v) {
    this.root = this._addChild(this.root, v)
  }
  _addChild(node, v) {
    if (!node) {
      return new Node(v)
    }
    if (node.value > v) {
      node.left = this._addChild(node.left, v)
    } else if (node.value < v) {
      node.right = this._addChild(node.right, v)
    } else {
      node.value = v
    }
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    let factor = this._getBalanceFactor(node)
    // 當需要右旋時,根節點的左樹一定比右樹高度高
    if (factor > 1 && this._getBalanceFactor(node.left) >= 0) {
      return this._rightRotate(node)
    }
    // 當需要左旋時,根節點的左樹一定比右樹高度矮
    if (factor < -1 && this._getBalanceFactor(node.right) <= 0) {
      return this._leftRotate(node)
    }
    // 左右情況
    // 節點的左樹比右樹高,且節點的左樹的右樹比節點的左樹的左樹高
    if (factor > 1 && this._getBalanceFactor(node.left) < 0) {
      node.left = this._leftRotate(node.left)
      return this._rightRotate(node)
    }
    // 右左情況
    // 節點的左樹比右樹矮,且節點的右樹的右樹比節點的右樹的左樹矮
    if (factor < -1 && this._getBalanceFactor(node.right) > 0) {
      node.right = this._rightRotate(node.right)
      return this._leftRotate(node)
    }

    return node
  }
  _getHeight(node) {
    if (!node) return 0
    return node.height
  }
  _getBalanceFactor(node) {
    return this._getHeight(node.left) - this._getHeight(node.right)
  }
  // 節點右旋
  //           5                    2
  //         /   \                /   \
  //        2     6   ==>       1      5
  //       /  \               /       /  \
  //      1    3             new     3    6
  //     /
  //    new
  _rightRotate(node) {
    // 旋轉後新根節點
    let newRoot = node.left
    // 需要移動的節點
    let moveNode = newRoot.right
    // 節點 2 的右節點改爲節點 5
    newRoot.right = node
    // 節點 5 左節點改爲節點 3
    node.left = moveNode
    // 更新樹的高度
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    newRoot.height =
      1 +
      Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))

    return newRoot
  }
  // 節點左旋
  //           4                    6
  //         /   \                /   \
  //        2     6   ==>       4      7
  //             /  \         /   \      \
  //            5     7      2     5      new
  //                   \
  //                    new
  _leftRotate(node) {
    // 旋轉後新根節點
    let newRoot = node.right
    // 需要移動的節點
    let moveNode = newRoot.left
    // 節點 6 的左節點改爲節點 4
    newRoot.left = node
    // 節點 4 右節點改爲節點 5
    node.right = moveNode
    // 更新樹的高度
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    newRoot.height =
      1 +
      Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))

    return newRoot
  }
}

Trie

Tire是什麼

在計算機科學,trie,又稱前綴樹字典樹,是一種有序樹,用於保存關聯數組,其中的鍵通常是字符串。

簡單點來說,這個結構的作用大多是爲了方便搜索字符串,該樹有以下幾個特點

  • 根節點代表空字符串,每個節點都有N(假如搜索英文字符,就有 26 條)條鏈接,每條鏈接代表一個字符
  • 節點不存儲字符,只有路徑才存儲,這點和其他的樹結構不同
  • 從根節點開始到任意一個節點,將沿途經過的字符連接起來就是該節點對應的字符串

在這裏插入圖片描述

實現

總得來說 Trie 的實現相比別的樹結構來說簡單的很多,實現就以搜索英文字符爲例。

class TrieNode {
  constructor() {
    // 代表每個字符經過節點的次數
    this.path = 0
    // 代表到該節點的字符串有幾個
    this.end = 0
    // 鏈接
    this.next = new Array(26).fill(null)
  }
}
class Trie {
  constructor() {
    // 根節點,代表空字符
    this.root = new TrieNode()
  }
  // 插入字符串
  insert(str) {
    if (!str) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      // 獲得字符先對應的索引
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應沒有值,就創建
      if (!node.next[index]) {
        node.next[index] = new TrieNode()
      }
      node.path += 1
      node = node.next[index]
    }
    node.end += 1
  }
  // 搜索字符串出現的次數
  search(str) {
    if (!str) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應沒有值,代表沒有需要搜素的字符串
      if (!node.next[index]) {
        return 0
      }
      node = node.next[index]
    }
    return node.end
  }
  // 刪除字符串
  delete(str) {
    if (!this.search(str)) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應的節點的 Path 爲 0,代表經過該節點的字符串
      // 已經一個,直接刪除即可
      if (--node.next[index].path == 0) {
        node.next[index] = null
        return
      }
      node = node.next[index]
    }
    node.end -= 1
  }
}

並查集

概念

並查集是一種特殊的樹結構,用於處理一些不交集的合併及查詢問題。該結構中每個節點都有一個父節點,如果只有當前一個節點,那麼該節點的父節點指向自己。

這個結構中有兩個重要的操作,分別是:

  • Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
  • Union:將兩個子集合併成同一個集合。

在這裏插入圖片描述

實現

class DisjointSet {
  // 初始化樣本
  constructor(count) {
    // 初始化時,每個節點的父節點都是自己
    this.parent = new Array(count)
    // 用於記錄樹的深度,優化搜索複雜度
    this.rank = new Array(count)
    for (let i = 0; i < count; i++) {
      this.parent[i] = i
      this.rank[i] = 1
    }
  }
  find(p) {
    // 尋找當前節點的父節點是否爲自己,不是的話表示還沒找到
    // 開始進行路徑壓縮優化
    // 假設當前節點父節點爲 A
    // 將當前節點掛載到 A 節點的父節點上,達到壓縮深度的目的
    while (p != this.parent[p]) {
      this.parent[p] = this.parent[this.parent[p]]
      p = this.parent[p]
    }
    return p
  }
  isConnected(p, q) {
    return this.find(p) === this.find(q)
  }
  // 合併
  union(p, q) {
    // 找到兩個數字的父節點
    let i = this.find(p)
    let j = this.find(q)
    if (i === j) return
    // 判斷兩棵樹的深度,深度小的加到深度大的樹下面
    // 如果兩棵樹深度相等,那就無所謂怎麼加
    if (this.rank[i] < this.rank[j]) {
      this.parent[i] = j
    } else if (this.rank[i] > this.rank[j]) {
      this.parent[j] = i
    } else {
      this.parent[i] = j
      this.rank[j] += 1
    }
  }
}

概念

堆通常是一個可以被看做一棵樹的數組對象。

堆的實現通過構造二叉堆,實爲二叉樹的一種。這種數據結構具有以下性質。

  • 任意節點小於(或大於)它的所有子節點
  • 堆總是一棵完全樹。即除了最底層,其他層的節點都被元素填滿,且最底層從左到右填入。

將根節點最大的堆叫做最大堆大根堆,根節點最小的堆叫做最小堆小根堆

優先隊列也完全可以用堆來實現,操作是一模一樣的。

實現大根堆

堆的每個節點的左邊子節點索引是 i * 2 + 1,右邊是 i * 2 + 2,父節點是 (i - 1) /2

堆有兩個核心的操作,分別是 shiftUpshiftDown 。前者用於添加元素,後者用於刪除根節點。

shiftUp 的核心思路是一路將節點與父節點對比大小,如果比父節點大,就和父節點交換位置。

shiftDown 的核心思路是先將根節點和末尾交換位置,然後移除末尾元素。接下來循環判斷父節點和兩個子節點的大小,如果子節點大,就把最大的子節點和父節點交換。

在這裏插入圖片描述

class MaxHeap {
  constructor() {
    this.heap = []
  }
  size() {
    return this.heap.length
  }
  empty() {
    return this.size() == 0
  }
  add(item) {
    this.heap.push(item)
    this._shiftUp(this.size() - 1)
  }
  removeMax() {
    this._shiftDown(0)
  }
  getParentIndex(k) {
    return parseInt((k - 1) / 2)
  }
  getLeftIndex(k) {
    return k * 2 + 1
  }
  _shiftUp(k) {
    // 如果當前節點比父節點大,就交換
    while (this.heap[k] > this.heap[this.getParentIndex(k)]) {
      this._swap(k, this.getParentIndex(k))
      // 將索引變成父節點
      k = this.getParentIndex(k)
    }
  }
  _shiftDown(k) {
    // 交換首位並刪除末尾
    this._swap(k, this.size() - 1)
    this.heap.splice(this.size() - 1, 1)
    // 判斷節點是否有左孩子,因爲二叉堆的特性,有右必有左
    while (this.getLeftIndex(k) < this.size()) {
      let j = this.getLeftIndex(k)
      // 判斷是否有右孩子,並且右孩子是否大於左孩子
      if (j + 1 < this.size() && this.heap[j + 1] > this.heap[j]) j++
      // 判斷父節點是否已經比子節點都大
      if (this.heap[k] >= this.heap[j]) break
      this._swap(k, j)
      k = j
    }
  }
  _swap(left, right) {
    let rightValue = this.heap[right]
    this.heap[right] = this.heap[left]
    this.heap[left] = rightValue
  }
}

小結

這一章節我們學習了一些常見的數據結構,當然我沒有將其他更難的數據結構也放進來,能夠掌握這些常見的內容已經足夠解決大部分的問題了。當然你如果還想繼續深入學習數據結構,可以閱讀 算法第四版 以及在 leetcode 中實踐。

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