常見數據結構
這一章節我們將來學習數據結構的內容。經常會有人提問說:學習數據結構或者算法對於前端工程師有用么?
總的來說,這些基礎學科在短期內收效確實甚微,但是我們首先不要將自己局限在前端工程師這點上。筆者之前是做 iOS 開發的,轉做前端以后,只有兩個技能還對我有用:
- 基礎學科內容,比如:網絡知識、數據結構算法
- 編程思想
其他 iOS 上積累的經驗,轉行以后基本就沒多大用處了。所以說,當我們把視野放到編程這個角度去說,數據結構算法一定是有用的,並且也是你未來的一個天花板。可以不花費集中的時間去學習這些內容,但是一定需要時常去學習一點,因為這些技能可以實實在在提升你寫代碼的能力。
這一章節的內容信息量會很大,不適合在非電腦環境下閱讀,請各位打開代碼編輯器,一行行的敲代碼,單純閱讀是學習不了數據結構的。
時間復雜度
在進入正題之前,我們先來了解下什么是時間復雜度。
通常使用最差的時間復雜度來衡量一個算法的好壞。
常數時間 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
}
}
應用
題意是匹配括號,可以通過棧的特性來完成這道題目
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
概念
在計算機科學,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
。
堆有兩個核心的操作,分別是 shiftUp
和 shiftDown
。前者用於添加元素,后者用於刪除根節點。
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 中實踐。