本文我們來介紹一下編程中常見的一些數據結構。
為什么要學習數據結構?
隨着業務場景越來越復雜,系統並發量越來也高,要處理的數據越來越多,特別是大型互聯網的高並發、高性能、高可用系統,對技術要求越來越高,我們引入各種中間件,這些中間件底層涉及到的各種數據結構和算法,是其核心技術之一。如:
- ElasticSearch中用於壓縮倒排索引內存存儲空間的FST,用於查詢條件合並的SkipList,用於提高范圍查找效率的BKDTree;
- 各種分庫分表技術的核心:hash算法;
- Dubbo或者Nginx等的負載均衡算法;
- MySQL索引中的B樹、B+樹等;
- Redis使用跳躍表作為有序集合鍵的底層實現之一;
- Zookeeper的節點樹;
- J.U.C並發包的各種實現的阻塞隊列,AQS底層實現涉及到的鏈式等待隊列;
- JDK對HashMap的Hash沖突引入的優化數據結構紅黑樹...
可以發現,數據結構和算法真的是無處不在,作為一個熱愛技術,拒絕粘貼復制的互聯網工程師,怎么能不掌握這些核心技術呢?

與此同時,如果你有耐心聽8個小時通俗易懂的數據結構入門課,我強烈建議你看一下以下這個視頻,來自Google一位工程師:Data Structures Easy to Advanced Course - Full Tutorial from a Google Engineer
閱讀完本文,你將了解到一些常見的數據結構(或者溫習,因為大部分朋友大學里面其實都是學過的)。在每個數據結構最后一小節都會列出代碼實現,以及相關熱門的算法題,該部分需要大家自己去探索與書寫。只有自己能熟練的編寫各種數據結構的代碼才是真正的掌握了,大家別光看,動手寫起來。閱讀完本文,您將了解到:
- 抽象數據類型與數據結構的關系;
- 如何評估算法的復雜度;
- 了解以下數據結構,並且掌握其實現思路:數組,鏈表,棧,隊列,優先級隊列,索引式優先隊列,二叉樹,二叉搜索樹BST,平衡二叉搜搜書BBST,AVL樹,HashTable,並查集,樹狀數組,后綴數組。
- 文章里面不會貼這些數據結構的完整實現,但是會附帶實現的鏈接,同時每種數據類型最后一節的相關實現以及練習題,建議大家多動手嘗試編寫這些練習題,以及嘗試自己動手實現這些數據結構。
1、抽象數據類型
抽象數據類型(ADT abstract data type):是數據結構的抽象,它僅提供數據結構必須遵循的接口。接口並未提供有關應如何實現某種內容或以哪種編程語言的任何特定詳細信息。
下標列舉了抽象數據類型和數據結構之間的構成關系:
| ADT | DS |
|---|---|
| List | Dynamic Array Linked List |
| Queue | Linked List based Queue Array based Queue Stack based Queue |
| Map | Tree Map Hash Map HashTable |
| Vehicle | 自行車 電動車 摩托車 塞車 |
2、時間與空間復雜度
我們一般會關注程序的兩個問題:
時間復雜度:這段程序需要花費多少時間才可以執行完成?空間復雜度:執行這段代碼需要消耗多大的內存?
有時候時間復雜度和空間復雜度二者不能兼得,我們只能從中取一個平衡點。
下面我們通過Big O表示法來描述算法的復雜度。
2.1、時間復雜度
2.1.1、Big-O
Big-O表示法給出了算法計算復雜性的上限。
T(n) = O(f(n)),該公式又稱為算法的漸進時間復雜度,其中f(n)函數表示每行代碼執行次數之和,O表示執行時間與之形成正比例關系。
常見的時間復雜度量級,從上到下時間復雜度越來越大,執行效率越來越低:
- 常數階 Constant Time: O(1)
- 對數階 Logarithmic Time: O(log(n))
- 線性階 Linear Time: O(n)
- 線性對數階 Linearithmic Time: O(nlog(n))
- 平方階 Quadratic Time: O(n^2)
- 立方階 Cubic Time: O(n^3)
- n次方階 Exponential Time: O(b^n), b > 1
- 指數階 Factorial Time: O(n!)
下面是我從 Big O Cheat Sheet[1]引用過來的一張表示各種度量級的時間復雜度圖表:

2.1.2、如何得出Big-O
所謂Big-O表示法,就是要得出對程序影響最大的那個因素,用於衡量復雜度,舉例說明:
O(n + c) =>
O(n),常量可以忽略;O(cn) =>
O(n), c > 0,常量可以忽略;2log(n)3 + 3n2 + 4n3 + 5 =>
O(n3),取對程序影響最大的因素。
練習:請看看下面代碼的時間復雜度:

答案依次為:O(1), O(n), O(log(n)), O(nlog(n)), O(n^2)
第三個如何得出對數?假設循環x次之后退出循環,也就是說 2^x = n,那么 x = log2(n),得出O(log(n))
2.2、空間復雜度
空間復雜度是對一個算法在運行過程中占用存儲空間的大小的衡量。
- O(1):存儲空間不隨變量n的大小而變化;
- O(n):如:new int[n];
2.3、常用數據結構復雜度
一些常用的數據結構的復雜度(注:以下表格圖片來源於 Big O Cheat Sheet[1:1]):

2.4、常用排序算法復雜度
(注:以下表格圖片來源於 Big O Cheat Sheet[1:2])

關於復雜度符號
O:表示漸近上限,即最差時間復雜度;
Θ:表示漸近緊邊界,即平均時間復雜度;
Ω:表示漸近下界,即最好時間復雜度;
3、靜態數組和動態數組
3.1、靜態數組
靜態數組是固定長度的容器,其中包含n個可從[0,n-1]范圍索引的元素。
問:“可索引”是什么意思?
答:這意味着數組中的每個插槽/索引都可以用數字引用。
3.1.1、使用場景
- 1)存儲和訪問順序數據
- 2)臨時存儲對象
- 3)由IO例程用作緩沖區
- 4)查找表和反向查找表
- 5)可用於從函數返回多個值
- 6)用於動態編程中以緩存子問題的答案
3.1.2、靜態數組特點
- 只能由數組下標訪問數組元素,沒有其他的方式了;
- 第一個下標為0;
- 下標超過范圍了會觸發數組越界錯誤。

3.2、動態數組
動態數組的大小可以增加和縮小。
3.2.1、如何實現一個動態數組
使用一個靜態數組:
- 創建具有初始容量的靜態數組;
- 將元素添加到基礎靜態數組,同時跟蹤元素的數量;
- 如果添加元素將超出容量,則創建一個具有兩倍容量的新靜態數組,然后將原始元素復制到其中。
3.3、時間復雜度
| 操作 | 靜態數組 | 動態數組 |
|---|---|---|
| 訪問 | O(1) | O(1) |
| 查找 | O(n) | O(n) |
| 插入 | / | O(n) |
| 追加 | / | O(1) |
| 刪除 | / | O(n) |
3.4、編程實踐
-
JDK中的實現:
java.util.ArrayList -
練習:
4、鏈表
4.1、使用場景
- 在許多列表,隊列和堆棧實現中使用;
- 非常適合創建循環列表;
- 可以輕松地對諸如火車等現實世界的物體進行建模;
- 某些特定的Hashtable實現用於處理散列沖突;
- 用於圖的鄰接表的實現中。
4.2、術語
Head:鏈表中的第一個節點;
Tail:鏈表中的最后一個節點;
Pointer:指向下一個節點;
Node:一個對象,包含數據和Pointer。

4.3、實現思路
這里使用雙向鏈表作為例子進行說明。
4.3.1、插入節點
往第三個節點插入:x
從鏈表頭遍歷,直到第三個節點,然后執行如下插入操作:
遍歷到節點位置,把新節點指向前后繼節點:

后繼節點回溯連接到新節點,並移除舊的回溯關系:

前繼節點遍歷連接到新節點,並移除舊的遍歷關系:

完成:

注意指針處理順序,避免在添加過程中導致遍歷出現異常。
4.3.2、刪除節點
刪除c節點:
從鏈表頭遍歷,直到找到c節點,然后把c節點的前繼節點連接到c的后繼接節點:

把c節點的后繼節點連接到c的前繼節點:

移除多余指向關系以及c節點:

完成:

同樣的,注意指針處理順序,避免在添加過程中導致遍歷出現異常。
4.4、時間復雜度
| 操作 | 單向鏈表 | 雙向鏈表 |
|---|---|---|
| 查找 | O(n) | O(n) |
| Insert at head | O(1) | O(1) |
| 中間追加 | O(n) | O(n) |
| 尾部追加 | O(1) | O(1) |
| 頭部移除 | O(1) | O(1) |
| 尾部移除 | O(n) | O(1) |
| 中間移除 | O(n) | O(n) |
4.5、編程實踐
-
JDK中的實現:
java.util.LinkedList -
練習:
5、棧
堆棧是一種單端線性數據結構,它通過執行兩個主要操作(即推入push和彈出pop)來對現實世界的堆棧進行建模。

5.1、使用場景
- 文本編輯器中的撤消機制;
- 用於編譯器語法檢查中是否匹配括號和花括號;
- 建模一堆書或一疊盤子;
- 在后台使用,通過跟蹤以前的函數調用來支持遞歸;
- 可用於在圖上進行深度優先搜索(DFS)。
5.2、編程實戰
5.2.1、語法校驗
給定一個由以下括號組成的字符串:()[] {},確定括號是否正確匹配。
例如:({}{}) 匹配,{()(]} 不匹配。
思路:
凡是遇到( { [ 都進行push入棧操作,遇到 ) } ] 則pop棧中的元素,看看是否與當前處理的元素匹配:
匹配完成之后,棧必須是空的。
5.3、復雜度
| 操作 | 時間復雜度 |
|---|---|
| push | O(1) |
| pop | O(1) |
| peek | O(1) |
| search | O(n) |
| size | O(1) |
5.4、編程實踐
6、隊列
隊列是一種線性數據結構,它通過執行兩個主要操作(即入隊enqueue和出隊dequeue)來對現實世界中的隊列進行建模。
6.1、術語

- Dequeue:出隊,類似命名:Polling
- Enqueue:入隊,類似命名:Adding,Offering
- Queue Front:對頭
- Queue Back:隊尾
隊列底層可以使用數組或者鏈表實現
6.2、使用場景
- 任何排隊等候的隊伍都可以建模為Queue,例如在電影院里的隊伍;
- 可用於有效地跟蹤最近添加的x個元素;
- 用於Web服務器請求管理,保證服務器先接受的先處理;
- 圖的廣度優先搜索(BFS)。
6.2.1、請用隊列實現圖的廣度優先遍歷
提示:

遍歷順序:0 -> 2, 5 -> 6 -> 1 -> 9, 3, 2, 7 -> 3, 4, 8, 9
6.3、時間復雜度
| 操作 | 時間復雜度 |
|---|---|
| 入隊 | O(1) |
| Dequeue | O(1) |
| Peeking | O(1) |
| Contains | O(n) |
| Removal | O(n) |
| isEmpty | O(1) |
6.4、編程實踐
- 基於數組實現的Queue:ArrayQueue
- 基於鏈表實現的Queue:LinkedListQueue
- 練習:
7、優先級隊列PQ
優先級隊列是一種抽象數據類型(ADT),其操作類似於普通隊列,不同之處在於每個元素都具有特定的優先級。 優先級隊列中元素的優先級決定了從PQ中刪除元素的順序。
注意:優先級隊列僅支持可比較的數據,這意味着插入優先級隊列的數據必須能夠以某種方式(從最小到最大或從最大到最小)進行排序。 這樣我們就可以為每個元素分配相對優先級。
為了實現優先級隊列,我們必須使用到堆 Heap。
7.1、什么是堆
堆是基於樹的非線性結構DS,它滿足堆不變式:
- 堆中某個節點的值總是不大於或不小於其父節點的值;
二叉堆是一種弄特殊的堆,其滿足:
- 是一顆完全二叉樹[2]。
將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。
在同級兄弟或表親之間沒有隱含的順序,對於有序遍歷也沒有隱含的順序。
堆通常使用隱式堆數據結構實現,隱式堆數據結構是由數組(固定大小或動態數組)組成的隱式數據結構,其中每個元素代表一個樹節點,其父/子關系由其索引隱式定義。將元素插入堆中或從堆中刪除后,可能會違反堆屬性,並且必須通過交換數組中的元素來平衡堆。
7.2、使用場景
- 在Dijkstra最短路徑算法的某些實現中使用;
- 每當您需要動態獲取“次佳”或“次佳”元素時;
- 用於霍夫曼編碼(通常用於無損數據壓縮);
- 最佳優先搜索(BFS)算法(例如A*)使用PQ連續獲取下一個最有希望的節點;
- 由最小生成樹(MST)算法使用。
7.3、最小堆轉最大堆
問題:大多數編程語言的標准庫通常只提供一個最小堆,但有時我們需要一個最大PQ。
解決方法:
- 由於優先級隊列中的元素是可比較的,因此它們實現了某種可比較的接口,我們可以簡單地取反以實現最大堆;
- 也可以先把所有數字取反,然后排序插入PQ,然后在取出數字的時候再次取反即可;
7.4、實現思路
優先級隊列通常使用堆來實現,因為這使它們具有最佳的時間復雜性。
優先級隊列是抽象數據類型,因此,堆並不是實現PQ的唯一方法。 例如,我們可以使用未排序的列表,但這不會給我們帶來最佳的時間復雜度,以下數據結構都可以實現優先級隊列:
- 二叉堆;
- 斐波那契堆;
- 二項式堆;
- 配對堆;
這里我們選取二叉堆來實現,二叉堆是一顆完全二叉樹[2:1]。
7.4.1、二叉堆排序映射到數組中
二叉堆索引與數組一一對應:
二叉堆排好序之后,即按照索引填充到數組中:

索引規則:
- i節點左葉子節點:2i + 1
- i節點右葉子節點:2i + 2
7.4.2、添加元素到二叉堆
insert(0)
如下圖,首先追加到最后一個節點,然后一層一層的跟父節點比較,如果比父節點小,則與父節點交換位置。


7.4.3、從二叉堆移除元素
poll() 移除第一個元素
-
第一個元素與最后一個元素交換位置,然后刪除掉最后一個元素;

-
第一個元素嘗試sink 下沉操作:一直與子節點對比,如果大於任何一個子節點,則與該子節點對換位置;

remove(7) 移除特定的元素
-
依次遍歷數組,找到值為7的元素,讓該元素與最后一個元素對換,然后刪除掉最后一個元素;

-
被刪除索引對應節點嘗試進行sink 下沉操作:與所有子節點比較,判斷是否大於子節點,如果小於,那么就與對應的子節點交換位置,然后一層一層往下依次對比交換;
-
如果最終該元素並沒有實際下沉,那么嘗試進行swim 上浮操作:與父節點比較,判斷是否小於父節點,如果是則與父節點對換位置,然后一層一層往上依次對比交換;
思考:請問如何構造一個小頂堆呢?
遍歷數組,所有元素依次與子節點對比,如果大於子節點則交換。
7.5、嘗試讓刪除操作時間復雜度變為O(log(n))
以上刪除算法的效率低下是由於我們必須執行線性搜索O(n)以找出元素的索引位置。
我們可以嘗試使用哈希表進行查找以找出節點的索引位置。如果同一個值在多個位置出現,那么我們可以維護一個特定節點值映射到的索引的Set或者Tree Set中。
數據結構如下:

這樣我們在刪除的時候就可以通過HashTable定位到具體的元素了。
7.6、時間復雜度
| 操作 | 時間復雜度 |
|---|---|
| construction | O(n) |
| poll | O(log(n)) |
| peek | O(1) |
| add | O(log(n)) |
| remove | O(n) |
| remove with a hash table | O(log(n)) |
| contains | O(n) |
| contains with a hash table | O(1) |
7.7、編程實踐
- JDK中的實現:
java.util.PriorityQueue - 基於最小堆實現的優先級隊列 BinaryHeap
- 最小堆實現的優先級隊列,優化了刪除方法
8、索引式優先隊列 IPQ
索引優先級隊列(Indexed Priority Queue IPQ)是傳統的優先級隊列變體,除了常規的PQ操作之外,它還提供了索引用於支持鍵值對的快速更新和刪除。
我們知道前面的優先級隊列的元素都是存放到一個list里面的,我們想知道知道某一個值在優先級隊列中的位置,也是需要遍歷一個個對比才知道的,要是有重復的值,那就區分不了了。既然找不到元素,那么對元素的更新和刪除也就無從說起了。
為此,我們引入了如下兩個索引:節點索引ki和位置索引im:

如:
- 請查找節點ki所在的優先級位置:可以很快可以從表1中找到 pm[ki];
- 請查找優先級位置im存的是什么節點:可以很快從表2中找到節點的索引 ki[im]
與構造或更新刪除PQ不同的是,IPQ需要額外維護這些索引的關系。
8.1、時間復雜度
| 操作 | 時間復雜度 |
|---|---|
| delete(ki) | O(log(n)) |
| valueOf(ki) | O(1) |
| contains(ki) | O(1) |
| peekMinKeyIndex() | O(1) |
| pollMinKeyIndex() | O(log(n)) |
| peekMinValue() | O(1) |
| pollMinValue() | O(log(n)) |
| insert(ki, value) | O(log(n)) |
| update(ki, value) | O(log(n)) |
| decreaseKey(ki, value) | O(log(n)) |
| increaseKey(ki, value) | O(log(n)) |
8.2、編程實踐
9、二叉樹與二叉搜索樹BST
二叉樹(Binary Tree)是每個節點最多具有兩個子節點的樹;
二叉搜索樹(Binary Search Tree)是滿足以下條件二叉樹:左子樹的元素較小,右子樹的元素較大。

9.1、使用場景
- 某些map和set的ADT的實現;
- 紅黑樹;
- AVL樹;
- 伸展樹(Splay Tree 分裂樹);
- 用於二叉堆的實現;
- 語法樹;
- Treap-概率DS(使用隨機BST)
9.2、實現思路
9.2.1、插入元素
- 二叉搜索樹(BST)元素必須具有可比性,以便我們可以在樹中對其進行排序;
- 插入元素時,我們將其值與當前節點中存儲的值進行比較:
- 小於節點值:向下遞歸左子樹;
- 大於節點值:向下遞歸右子樹;
- 等於節點值:處理重復值;
- 不存在節點:創建新節點。
極端場景:

這種情況就變為了線性結構,比較糟糕,這就是平衡二叉搜索樹出現的原因。
9.2.2、移除元素
移除元素可以分為兩步:
- 找到我們想要移除的元素;
- 如果存在后續節點,那么我們用后續節點替換掉要刪除的節點;
移除會有以下三種情況:
9.2.2.1、移除的元素是一個葉子節點
找到對應待移除的節點,直接刪除掉即可:
remove(16):

9.2.2.2、移除的元素下面有左子樹或者右子樹
如果移除的元素下面帶有左子樹或者右子樹,那么:找到對應待移除的節點,用子樹的根節點作為移除元素的后繼者,替換掉需要移除的元素即可:

9.2.2.3、移除的元素下面有左子樹和右子樹
如果移除的元素下面帶有左子樹和右子樹,那么應該用左子樹還是右子樹中的節點作為刪除元素的后繼者呢?
答案是兩者都可以! 后繼者可以是左側子樹中的最大值,也可以是右側子樹中的最小值。
下面我們執行remove(8),統一選擇使用右子樹中的最小值。
具體步驟:
-
查找到需要刪除的元素;
-
在其右子樹中尋找到最小值的節點;

-
最小值的節點和待刪除元素的值互換;
-
使用9.2.2.2的步驟刪除掉原來最小值節點位置的節點;

9.2.3、查找元素
BST搜索元素會出現以下四種情況之一:
- 我們命中了一個空節點,這時我們知道該值在我們的BST中不存在;
- 比較器值等於0,說明找到了元素;
- 比較器值小於0,說明元素在左子樹中;
- 比較器值大於0,說明元素在右子樹中。
以下是find(6)操作:

9.3、樹的遍歷
可以分為深度優先遍歷和廣度優先遍歷。而深度優先遍歷又分為:前序遍歷、中序遍歷、后序遍歷、層序遍歷。
9.3.1、深度優先遍歷
深度優先遍歷都是通過遞歸來實現的,對應的數據結構為棧。
9.3.1.1、前序遍歷
在遞歸方法最開始處理節點信息,代碼邏輯:
void preOrder(node) {
if (node == null) return;
print(node.value);
preOrder(node.left);
preOrder(node.right);
}
如下二叉樹將得出以下遍歷結果:A B D H I E J C F K G

9.3.1.2、中序遍歷
在遞歸調用完左子節點,准備右子節點之前處理節點信息,代碼邏輯:
void inOrder(node) {
if (node == null) return;
inOrder(node.left);
print(node.value);
inOrder(node.right);
}
二叉搜索樹使用中序遍歷,會得到一個排好序的列表。
以下二叉搜索樹將得出如下遍歷結果:1 3 4 5 6 7 8 15 16 20 21

9.3.1.3、后序遍歷
在遞歸完左右子樹之后,再處理節點信息,代碼邏輯:
void postOrder(node) {
if (node == null) return;
postOrder(node.left);
postOrder(node.right);
print(node.value);
}
以下二叉樹得出如下遍歷結果:1 4 3 6 7 5 16 15 21 20 8

9.3.2、廣度優先遍歷
在廣度遍歷中,我們希望一層一層的處理節點信息,常用的數據結構為隊列。每處理一個節點,同時把左右子節點放入隊列,然后從隊列中取節點進行處理,處理的同時把左右子節點放入隊列,反復如此,直至處理完畢。
9.4、BST時間復雜度
| 操作 | 平均 | 最差 |
|---|---|---|
| insert | O(log(n)) | O(n) |
| delete | O(log(n)) | O(n) |
| search | O(log(n)) | O(n) |
9.5、編程實踐
10、平衡二叉搜索樹BBST
平衡二叉搜索樹(Balanced Binary Search Tree BBST)是一種自平衡的二叉搜索樹。所以自平衡意味着會自行調整,以保持較低(對數)的高度,從而允許更快的操作,例如插入和刪除。
10.1、樹旋轉
大多數BBST算法核心組成部分是:樹不變式和樹旋轉的巧妙用法。
樹不變式:是在每次操作后必須滿足的屬性/規則。為了確保始終滿足不變式,通常會應用一系列樹旋轉。
在樹旋轉的過程中需要確保保持BST的不變式:左子樹比較小,右子樹比較大。
10.1.1、更新單向指針的BBST
為此,我們可以使用以下操作實現右旋轉,注意觀察宣傳前后,BST的不變式:
public void rightRotate(Node a) {
Node b = a.left;
a.left = b.right;
b.right = a;
return b;
}
如下圖,我們准備執行rightRotate(a):

為什么可以這樣變換呢?
還是那個原則:BST不變式。
所有BBST都是BST,因此對於每個節點n,都有:n.left <n && n < n.right。(這里的前提是沒有重復元素)
我們在變換操作的時候只要確保這個條件成立即可,即保持BST不變性成立的前提下,我們可以對樹中的值和節點進行隨機變換/旋轉。
注意,如上圖,如果a節點還有父節點p,那么就把p節點原來指向a節點變更為指向b節點。
10.1.2、更新雙向指針的BBST
在某些需要經常方位父節點或者同級節點的BBST中,我們就不是像上面那樣最多更新3個指針,而是必須更新6個指針了,操作會復制些許。
以下是代碼實現:
public void rightRotate(Node a) {
Node p = a.parent
Node b = a.left;
a.left = b.right;
if (b.right != null) {
b.right.parent = a;
}
b.right = a;
a.parent = b;
b.parent = p;
// 更新父指針
if (p != null) {
if (p.left == a) {
p.left = b;
} else {
p.right = b;
}
}
return b;
}

BBST通過在不滿足其不變性時執行一系列左/右樹旋轉來保持平衡。
10.2、AVL樹
AVL樹是平衡二叉搜索樹的一種,它允許以O(log(n))的復雜度進行插入、搜索和刪除操作。
AVL樹是第一種被發現的BBST,后來出現了其他類型包括: 2-3 tree、AA tree、scapegoat tree(替罪羊樹)、red-black tree(紅黑樹)。
使AVL樹保持平衡的屬性稱為平衡因子(balanced factor BF)。
BF(node) = H(node.right) - H(node.left)
其中H(x)是節點的高度,為x和最遠的葉子之間的邊數
AVL樹中使其保持平衡的不變形是要求平衡因子BF始終為-1、0或者1。
10.2.1、節點存儲信息
-
節點存儲的實際值,此值必須可以比較;
-
BF的值;
-
節點在樹中的高度;
-
指向左右子節點的指針。
10.2.2、AVL的自平衡
當節點的BF不為-1、0或者1的時候,使用樹旋轉來進行調整。可以分為幾種情況:
左左

左右

右右

右左

10.3、從BBST中移除元素
參考BST小節的刪除邏輯,與之不同的是,在刪除元素之后,需要執行多一個自平衡的過程。
10.4、時間復雜度
普通二叉搜索樹:
| 操作 | 平均復雜度 | 最差復雜度 |
|---|---|---|
| Insert | O(log(n)) | O(n) |
| Delete | O(log(n)) | O(n) |
| Remove | O(log(n)) | O(n) |
| Search | O(log(n)) | O(n) |
平衡二叉搜索樹:
| 操作 | 平均復雜度 | 最差復雜度 |
|---|---|---|
| Insert | O(log(n)) | O(log(n)) |
| Delete | O(log(n)) | O(log(n)) |
| Remove | O(log(n)) | O(log(n)) |
| Search | O(log(n)) | O(log(n)) |
10.5、編程實踐
11、HashTable
11.1、什么是HashTable
HashTable,哈希表,是一種數據結構,可以通過使用稱為hash的技術提供從鍵到值的映射。
key:其中key必須是唯一的,key必須是可以hash;
value:value可以重復,value可以是任何類型;
HashTable經常用於根據Key統計數量,如key為服務id,value為錯誤次數等。
11.2、什么是Hash函數
哈希函數 H(x) 是將鍵“ x”映射到固定范圍內的整數的函數。
我們可以為任意對象(如字符串,列表,元組等)定義哈希函數。
11.2.1、Hash函數的特點
如果 H(x) = H(y) ,那么x和y可能相當,但是如果 H(x) ≠ H(y),那么x和y一定不相等。
Q:我們如何提高對象的比較效率呢?
A:可以比較他們的哈希值,只有hash值匹配時,才需要顯示比較兩個對象。
Q:兩個大文件,如何判斷是否內容相同?
A:類似的,我們可以預先計算H(file1)和H(file2),比較哈希值,此時時間復雜度是O(1),如果相等,我們才考慮進一步比較穩健。(穩健的哈希函數比哈希表使用的哈希函數會更加復雜,對於文件,通常使用加密哈希函數,也稱為checksums)。
哈希函數 H(x) 必須是確定的
就是說,如果H(x) = y,那么H(x)必須始終能夠得到y,而不產生另一個值。這對哈希函數的功能至關重要。
我們需要嚴謹的使用統一的哈希函數,最小化哈希沖突的次數
所謂哈希沖突,就是指兩個不同的對象,哈希之后具有相同的值。
Q:上面我們提到HashTable的key必須是可哈希的,意味着什么呢?
A:也就是說,我們需要是哈希函數具有確定性。為此我們要求哈希表中的key是不可變的數據類型,並且我們為key的不可以變類型定義了哈希函數,那么我們可以成為該key是可哈希的。
11.2.2、優秀哈希函數特點
一個優秀的Hash函數具有如下幾個特點:
正向快速:給定明文和Hash算法,在有限的時間和優先的資源內能計算到Hash值;
碰撞阻力:無法找到兩個不相同的值,經過Hash之后得到相同的輸出;
隱蔽性:只要輸入的集合足夠大,那么輸入信息經過Hash函數后具有不可逆的特性。
謎題友好:也就是說對於輸出值y,很難找到輸入x,是的H(x)=y,那么我們就可以認為該Hash函數是謎題友好的。
Hash函數在區塊鏈中占據着很重要的地位,其隱秘性使得區塊鏈具有了匿名性。
11.3、HashTable如何工作
理想情況下,我們通過使用哈希函數索引到HashTable的方式,在O(1)時間內很快的進行元素的插入、查找和刪除動作。
只有具有良好的統一哈希函數的時候,才能真正的實現花費恆定時間操作哈希表。
11.3.1、哈希沖突的解決辦法
哈希沖突:由於哈希算法被計算的數據是無線的,而計算后的結果范圍是有限的,因此總會存在不同的數據結果計算后得到相同值,這就是哈希沖突。
常用的兩種方式是:鏈地址法和開放定址法。
11.3.1.1、鏈地址法
鏈地址法是通過維護一個數據結構(通常是鏈表)來保存散列為特定key的所有不同值來處理散列沖突的策略之一。
鏈地址通常使用鏈表來實現,針對散列沖突的數據,構成一個單向鏈表,將鏈表的頭指針存放在哈希表中。
除了使用鏈表結構,也可以使用數組、二叉樹、自平衡樹等。
如下,假設我們哈希函數實現如下:名字首字符的ASCII碼 mod 6,有如下數據需要存儲到哈希表中:
| Key (name) | Value(age) | Hash(name首字符 mod 6) |
|---|---|---|
| Tom | 12 | 0 |
| Jim | 18 | 2 |
| Talor | 14 | 0 |
| Will | 12 | 3 |
| Shelly | 14 | 5 |
| Sam | 15 | 5 |
| Jay | 14 | 2 |
| Jason | 12 | 2 |
構造哈希表如下:

Q:一旦HashTable被填滿了,並且鏈表很長,怎么保證O(1)的插入和查找呢?
A:應該創建一個更大容量的HashTable,並將舊的HashTable的所欲項目重新哈希分散存入新的HashTable中。
Q:如何查找元素?
A:把需要查找的元素hash成具體的key,在HashTable中查找桶位置,然后判斷是否桶位置為一個鏈表,如果是則遍歷鏈表一一比較元素,判斷是否為要查找的元素:
如查找Jason,定位到桶2,然后遍歷鏈表對比元素:

Q:如何刪除HashTable中的元素
A:從HashTable中查找到具體的元素,刪除鏈表數據結構中的節點。
11.3.1.2、開放式尋址法
在哈希表中查找到另一個位置,把沖突的對象移動過去,來解決哈希沖突。
使用這種方式,意味着鍵值對存儲在HashTable本身中,並不是存放在單獨的鏈表中。這需要我們非常注意HashTable的大小。
假設需要保持O(1)時間復雜度,負載因子需要保持在某一個固定值下,一旦負載因子超過這個閾值時間復雜度將成指數增長,為了避免這種情況,我們需要增加HashTable的大小,也就是進行擴容操作。以下是來自wikipedia的負載因子跟查找效率的關系圖:
當我們對鍵進行哈希處理H(k)獲取到鍵的原始位置,發現該位置已被占用,那么就需要通過探測序列P(x)來找到哈希表中另一個空閑的位置來存放這個原始。
開放式尋址探測序列技術
開放式尋址常見的探測序列方法有:
- 線性探查法:P(x) = ax + b,其中a、b為常數
- 平方探查法:P(x) = ax^2 + bx + c,其中a, b, c為常數
- 雙重哈希探查法:P(k, x) = x * H2(k),其中H2(k),是另一個哈希函數;
- 偽隨機數發生器法:P(k, x) = x*RNG(H(k), x),其中RNG是一個使用H(k)作為seed的隨機數字生成函數;
11.3.1.2.1開放式尋址法的解決思路
在大小為N的哈希表上進行開放式尋址的一般插入方法的偽代碼如下:
x = 1;
keyHash = H(k) % N;
index = keyHash;
while ht[index] != null {
index = (keyHash + P(k, x)) %N;
x++;
}
insert (k, v) at ht[index]
其中H(k)是key的哈希函數,P(k, x)是尋址函數。
11.3.1.2.2、混亂的循環
大多數選擇的以N為模的探測序列都會執行比HashTable大小少的循環。當插入一個鍵值對並且循環尋址找到的所有桶都被占用了,那么將會陷入死循環。
諸如線性探測、二次探測、雙重哈希等都很容易引起死循環問題。每種探測技術都有對應的解決死循環的方法,這里不深入展開探討了。
11.4、使用場景
https://blog.csdn.net/winner82/article/details/3014030
- 數據校驗
- 單向性的運用,hash后存儲,hash對比是否一致
11.5、時間復雜度
| 操作 | 平均 | 最差 |
|---|---|---|
| insert | O(1) | O(n) |
| remove | O(1) | O(n) |
| search | O(1) | O(n) |
11.6、編程實踐
- JDK中的實現,鏈地址法:
java.util.HashMap - JDK中的實現,開放式尋址法:
java.lang.ThreadLocal.ThreadLocalMap
12、並查集
關於並查集,有一個很牛逼的比喻博文,還不了解並查集的同學可以看看這里:超有愛的並查集~,包你一看就懂。主要提供三個功能:
- 查找根節點
- 合並兩個集合
- 路徑壓縮
12.1、使用場景
- Kruskal最小生成樹算法
- 網格滲透
- 網絡連接狀態
- 圖像處理
12.2、最小生成樹[3]
最小生成樹:一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的所有 n 個結點,並且有保持圖連通的最少的邊。 [1] 最小生成樹可以用kruskal(克魯斯卡爾)算法或prim(普里姆)算法求出。
如果對圖相關概念不太了解,可以查閱這篇文章:圖論(一)基本概念。
生成基本流程:
-
把圖的邊按照權重進行排序;

-
遍歷排序的邊並查看該邊所屬的兩個節點,如果節點有連接在一起的路徑了,則不用納入該邊,否則將其納入在內並連接節點;這里判斷節點是否已連接和連接節點主要用到並查集的查找根節點和合並兩個集合操作;
-
當c處理完每條邊或所有頂點都連接在一起之后,算法終止。

12.3、實現思路
12.3.1、構建並查集
假設我們想通過這些字母構建並查集:E A B F C D,我們可以把這些字母映射到數組的索引中,數組的元素值代表當前字母的上級字母索引值,由於剛開始還沒有做合並操作,所以所有元素存的都是自己的索引值:

同時我們新增一個數組,用於記錄當前字母手下收了多少個字母小弟,當兩個字母要合並的時候,首先找到兩個字母的大佬,然后字母大佬收的小弟少的要拜字母大佬小弟多的人為大佬:

為了合並兩個元素,可以找到每個組件的根節點,如果根節點不同,則使一個根節點成為另一個根節點的父節點。
接下來我們要執行以下合並操作:
union(E, A), union(A, B)

接着執行
union(F, C), union(C, B)

執行到這里,這里會剩下兩個組件:EABFC,D
12.3.2、並查集搜索
看到這里,相信你對並查集的搜索原理也了解了。要查找某個特定元素屬於哪個組件,可以通過跟隨父節點直到達到自環(該節點父節點指向本身)來找到該組件的根。比如要搜索C,我們會沿着記錄的parent索引id一直往上層搜索,最終搜到E。
- 組件數等於剩余的root根數。 另外,請注意,根節點的數量永遠不會增加。
12.2.3、並查集路徑壓縮
我們可以發現,在極端情況下,需要找很多層的parent節點,才能找到最終的根節點。
為此我們可以在find查找節點的時候,找到該節點到跟節點中間的所有節點,重新指向最終找到的根節點來減小路徑長度,這樣下次在find這些節點的時候,就非常快了。如下圖,我們查找A的根節點,查找到之后進行路徑壓縮:

12.3、時間復雜度
| 操作 | 時間復雜度 |
|---|---|
| construction | O(n) |
| union | α(n) |
| find | α(n) |
| size | α(n) |
| checkConnected | α(n) |
| countComponents | O(1) |
α(n):均攤時間[4]
12.4、編程實踐
13、樹狀數組 Fenwick Tree
13.1、為什么需要Fenwick Tree
假設我們有一個數組A,需要計算數組中[i, j) 區間的數據之和,為了方便獲取,我們提前把算好的前面n個元素之和存到另一個數組B的n+1中,如下:

這樣我們就很方便的計算區間和了,如:
[2, 5) = B[5] - B[2] = 18 - 6 = 12
但是假設我們想修改A中第i個元素的值,那么B中第i+1之后的元素值都得更新:

也就是說更新的復雜度為O(n),有沒有更好的辦法加快更新速度呢?這個時候我們的Fenwick Tree就要出場了,Fenwick Tree也叫Binary Indexed Tree(二元索引樹)。
13.2、什么是Fenwick Tree
Fenwick Tree是一種支持給靜態數組范圍求和,以及在靜態數組中更新元素的值后也能夠進行進行范圍求和的數據結構。
最低有效位(LSB least significant bit):靜態數組的小標可以轉換為二進制,如8:01000,最低有效位指的是從右往左數,不為0的位,這里為 1000,計算數組小標最低有效為的函數我們一般命名為lowbit,實現參考后續代碼。
數組下標的最低有效位的值n,表示該下標存儲了從該下標往前數n位元素的數值之和。如下圖:

我們可以發現:
- 1:只保存當前下標元素的值,對應上面紅色區塊;
- 10:保存下標往前數總共2個元素的值,對應上面藍色區塊;
- 100:保存下標往前數總共4個元素的值,對應上面紫色區塊;
- 1000:保存下標往前數總共8個元素的值,對應上面綠色區塊;
- 10000:保存下標往前數總共16個元素的值,對應上面淺藍色區塊;
13.2.1、范圍求和
有了上面的數據結構,我們就可以進行范圍求和了。
假設我們要求和[1, 7],我們只要把以下紅色區塊值相加就可以了,也就是 sum = B[7] + B[6] + B[4]

如果我們要求和[10, 14],那么我們可以這樣處理:sum = sum[1, 14] - sum[1, 9] = (B[14] + B[12] + B[8]) - (B[9] + B[8])。
也就是說,針對范圍查詢,我們會根據LSB一直回溯上一個元素,然后把回溯到的元素都加起來。
最差的情況下,求和的復雜度為:O(log2(n))
以下是實現范圍求和的代碼:
/**
* 求和 [1, i]
*/
public int prefixSum(int i) {
sum = 0;
while(i != 0) {
sum = sum + tree[i]
i = i = lowbit(i);
}
return sum;
}
/**
* 求和 [i, j]
*/
public int rangeQuery(int i, int j) {
return prefixSum(j) - prefixSum(i - 1);
}
13.2.2、單點更新
更新數組中的某一個元素的過程中,與范圍查詢相反,我們不斷的根據LSB計算到下一個元素位置,同時給該元素更新數組。如下,更新A[9],會級聯查找到以下紅色的位置的元素:

以下是實現代碼,給第i個元素+x:
public void add(int i, int x) {
while (i < N) {
tree[i] = tree[i] + x;
i = i + lowbit(i)
}
}
13.2.3、構造Fenwick Tree
假設A為靜態數組,B數組存放Fenwick Tree,我們首先把A數組clone到B數組,然后遍歷A數組,每個元素A[i]依次加到下一個負責累加的B節點B[i + LSB]中(稱為父節點),直到到達B數組的上界,代碼如下:
public FenwickTree(int[] values) {
N = values.length;
values[0] = 0L;
// 為了避免直接操縱原數組,破壞了其所有原始內容,我們復制一個values數組
tree = values.clone();
for (int i = 1; i < N; i++) {
// 獲取當前節點的父節點
int parent = i + lowbit(i);
if (parent < N) {
// 父節點累加當前節點的值
tree[parent] += tree[i];
}
}
}
思考:如果我們想要快速更新數組的區間范圍,如何實現比較好呢?參考:
13.3、時間復雜度
| 操作 | 時間復雜度 |
|---|---|
| construction | O(n) |
| point update | O(log(n)) |
| range sum | O(log(n)) |
| range update | O(log(n)) |
| adding index | / |
| removing index | / |
13.4、編程實踐
- 單點更新,區間查找:FenwickTreeRangeQueuePointUpdate
- 區間更新,單點查找:FenwickTreeRangeUpdatePointQuery
- 練習:
14、后綴數組 Suffix Array
后綴數組是后綴樹的一種節省空間的替代方法,后綴樹本身是trie的壓縮版本。
后綴數組可以完成后綴樹可以完成的所有工作,並且帶有一些其他信息,例如最長公共前綴(LCP)數組
14.1、后綴數組格式
如下圖,字符串:arthinking,所有的后綴,從長到短列出來:

給后綴排序,排序后對應的索引構成的數組既是后綴數組:

后綴數組sa:后綴suffix列表排序后,suffix的下標構成的數組sa;
rank:suffix列表每個元素的排位權重(權重越大排越后面);
14.2、后綴數組構造過程
上面的后綴構造過程是怎樣的呢?
這里我們介紹最常見的倍增算法來得到后綴數組。
我們獲取每一個元素的權重rank,獲取到之后,依次繼續
- 第0輪:suffix[i] = suffix[i] + suffix[2^0],重新評估rank;
- 第1輪:suffix[i] = suffix[i] + suffix[2^1],重新評估rank;
- ...
- 第n輪:suffix[i] = suffix[i] + suffix[2^n],重新評估rank;
- ...
最終得到所有rank都不相等即可。如下圖所示:

這樣就得到rank了,我們可以根據rank很快推算出sa數組。
為什么可以這樣倍增,跳過中間某些元素進行比較呢?
這是一種很巧妙的用法,因為每個后綴都與另一個后綴有一些共同之處,並不是隨機字符串,遷移輪比較,為后續比較墊底了基礎。
假設要處理
substr(i, len)子字符串。我們可以把第k輪substr(i, 2^k)看成是一個由substr(i, 2^k−1)和substr(i + 2^k−1, 2^k−1)拼起來的東西,而substr(i, 2^k−1)的字符串是上一輪比較過的並且得出了rank。
14.3、后綴數組使用場景
- 在較大的文本中查找子字符串的所有出現;
- 最長重復子串;
- 快速搜索確定子字符串是否出現在一段文本中;
- 多個字符串中最長的公共子字符串
- ...
LCP數組
最長公共前綴(LCP longest common prefix)數組,是排好序的suffix數組,用來跟蹤獲取最長公共前綴(LCP longest common prefix)。
4.4、編程實踐
這篇文章的內容就差不多介紹到這里了,能夠閱讀到這里的朋友真的是很有耐心,為你點個贊。
本文為arthinking基於相關技術資料和官方文檔撰寫而成,確保內容的准確性,如果你發現了有何錯漏之處,煩請高抬貴手幫忙指正,萬分感激。
大家可以關注我的博客:itzhai.com 獲取更多文章,我將持續更新后端相關技術,涉及JVM、Java基礎、架構設計、網絡編程、數據結構、數據庫、算法、並發編程、分布式系統等相關內容。
如果您覺得讀完本文有所收獲的話,可以關注我的賬號,或者點贊吧,碼字不易,您的支持就是我寫作的最大動力,再次感謝!
關注我的公眾號,及時獲取最新的文章。
更多文章
- JVM系列專題:公眾號發送 JVM
本文作者: arthinking
博客鏈接: https://www.itzhai.com/algorithms/common-datasstructure-part-one.html
版權聲明: 版權歸作者所有,未經許可不得轉載,侵權必究!聯系作者請加公眾號。

References
Data Structures Easy to Advanced Course - Full Tutorial from a Google Engineer


