算法學習筆記之散列表與非線性表結構
記錄一些關於散列表與非線性表結構,包括二叉樹,紅黑樹,堆,圖等相關數據結構的學習筆記,接上一篇《算法學習筆記之複雜度分析與線性表》
文章目錄
1. 散列表
散列表用的是數組支持按照下標隨機訪問數據的特性,所以散列表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。散列表的英文叫“Hash Table”,我們平時也叫它“哈希表”或者“Hash 表”。
散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。我們通過散列函數把元素的鍵值映射爲下標,然後將數據存儲在數組中對應下標的位置。當我們按照鍵值查詢元素時,我們用同樣的散列函數,將鍵值轉化數組下標,從對應的數組下標的位置取數據。
散列表兩個核心問題是散列函數設計和散列衝突解決。
1.1 散列函數
散列函數,顧名思義,它是一個函數。我們可以把它定義成 hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。
- 散列函數計算得到的散列值是一個非負整數;
- 如果 key1 = key2,那 hash(key1) == hash(key2);
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
1.2 散列衝突
常用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。
1.2.1 開放尋址法
- 線性探測(Linear Probing)
當我們往散列表中插入數據時,如果某個數據經過散列函數散列之後,存儲位置已經被佔用了,我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。 - 二次探測(Quadratic probing)
線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探測探測的步長就變成了原來的“二次方” - 雙重散列(Double hashing)
所謂雙重散列,意思就是不僅要使用一個散列函數。我們使用一組散列函數 hash1(key),hash2(key),hash3(key)……我們先用第一個散列函數,如果計算得到的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。
爲了儘可能保證散列表的操作效率,一般情況下,我們會儘可能保證散列表中有一定比例的空閒槽位。我們用裝載因子(load factor)來表示空位的多少。裝載因子的計算公式是:散列表的裝載因子=填入表中的元素個數/散列表的長度,裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會下降。
1.2.2. 鏈表法
在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中。
1.3 設計散列函數
- 當數據量比較小、裝載因子小的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的原因。
- 基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。
- Java 中的 HashMap
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
}
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}
- Java 中的 LinkedHashMap
LinkedHashMap 是通過雙向鏈表和散列表這兩種數據結構組合實現的。LinkedHashMap 中的“Linked”實際上是指的是雙向鏈表,並非指用鏈表法解決散列衝突。
1.4 散列表總結
散列表這種數據結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之後無規律存儲的。也就說,它無法支持按照某種順序快速地遍歷數據。如果希望按照順序遍歷散列表中的數據,那我們需要將散列表中的數據拷貝到數組中,然後排序,再遍歷。因爲散列表是動態數據結構,不停地有數據的插入、刪除,所以每當我們希望按順序遍歷散列表中的數據的時候,都需要先排序,那效率勢必會很低。爲了解決這個問題,我們將散列表和鏈表(或者跳錶)結合在一起使用。
2. 哈希算法
2.1 概念定義
將任意長度的二進制值串映射爲固定長度的二進制值串,這個映射的規則就是哈希算法,而通過原始數據映射之後得到的二進制值串就是哈希值。
- 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法);
- 對輸入數據非常敏感,哪怕原始數據只修改了一個 Bit,最後得到的哈希值也大不相同;
- 散列衝突的概率要很小,對於不同的原始數據,哈希值相同的概率非常小;
- 哈希算法的執行效率要儘量高效,針對較長的文本,也能快速地計算出哈希值。
2.2 應用場景
安全加密,唯一標識,數據校驗,散列函數,脫庫加鹽值(salt),負載均衡、數據分片、分佈式存儲(一致性哈希算法)。
3. 二叉樹
3.1 二叉樹的定義
“樹”,裏面每個元素我們叫作“節點”;用來連線相鄰節點之間的關係,我們叫作“父子關係”。比如,A 節點就是 B 節點的父節點,B 節點是 A 節點的子節點。B、C、D 這三個節點的父節點是同一個節點,所以它們之間互稱爲兄弟節點。把沒有父節點的節點叫作根節點,把沒有子節點的節點叫作葉子節點或者葉節點。
- 高度(Height)
節點到葉子節點的最長路徑,即邊數,其實就是從下往上度量,從最底層開始計數,並且計數的起點是 0。 - 深度(Depth)
根節點到這個節點所經歷的邊的個數,從上往下度量的,從根結點開始度量,並且計數起點也是 0 - 層(Level)
根節點的高度,跟深度的計算類似,不過,計數起點是 1,也就是說根節點的位於第 1 層。
二叉樹,顧名思義,每個節點最多有兩個“叉”,也就是兩個子節點,分別是左子節點和右子節點。不過,二叉樹並不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。
- 葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫作滿二叉樹。
- 葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫作完全二叉樹。堆其實就是一種完全二叉樹,最常用的存儲方式就是數組。
3.2 二叉樹的存儲
存儲一棵二叉樹,我們有兩種方法,一種是基於指針或者引用的二叉鏈式存儲法,一種是基於數組的順序存儲法。
-
二叉鏈式存儲法
每個節點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。我們只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。這種存儲方式我們比較常用。大部分二叉樹代碼都是通過這種結構來實現的。 -
順序存儲法
把根節點存儲在下標 i = 1 的位置,那左子節點存儲在下標 2 * i = 2 的位置,右子節點存儲在 2 * i + 1 = 3 的位置。
組順序存儲的方式比較適合完全二叉樹。
3.3 二叉樹的遍歷
- 前序遍歷是指,對於樹中的任意節點來說,先打印這個節點,然後再打印它的左子樹,最後打印它的右子樹。
- 中序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它本身,最後打印它的右子樹。中序遍歷二叉查找樹,可以輸出有序的數據序列,時間複雜度是 O(n),非常高效。
- 後序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它的右子樹,最後打印這個節點本身
二叉樹遍歷的時間複雜度是 O(n)。
3.4 二叉查找樹
二叉查找樹最大的特點就是,支持動態數據集合的快速插入、刪除、查找操作。
二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。時間複雜度其實都跟樹的高度成正比,也就是 O(height)
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data) p = p.left;
else if (data > p.data) p = p.right;
else return p;
}
return null;
}
public static class Node {
private int data;
private Node left;
private Node right;
public Node(int data) {
this.data = data;
}
}
}
- 二叉查找樹的插入操作
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
- 二叉查找樹的刪除操作
public void delete(int data) {
Node p = tree; // p指向要刪除的節點,初始化指向根節點
Node pp = null; // pp記錄的是p的父節點
while (p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 沒有找到
// 要刪除的節點有兩個子節點
if (p.left != null && p.right != null) { // 查找右子樹中最小節點
Node minP = p.right;
Node minPP = p; // minPP表示minP的父節點
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 將minP的數據替換到p中
p = minP; // 下面就變成了刪除minP了
pp = minPP;
}
// 刪除節點是葉子節點或者僅有一個子節點
Node child; // p的子節點
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 刪除的是根節點
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
- 散列表與二叉樹
- 散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的數據序列。
- 散列表擴容耗時很多,而且當遇到散列衝突時,性能不穩定,儘管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間複雜度穩定在 O(logn)。
- 因爲哈希衝突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高。
- 散列表的構造比二叉查找樹要複雜,需要考慮的東西很多。比如散列函數的設計、衝突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。
- 爲了避免過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,不然會浪費一定的存儲空間。
4. 紅黑樹
二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。紅黑樹的英文是“Red-Black Tree”,簡稱 R-B Tree。
- 紅黑樹中的節點,一類被標記爲黑色,一類被標記爲紅色。
- 根節點是黑色的
- 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;
- 任何相鄰的節點都不能同時爲紅色,也就是說,紅色節點是被黑色節點隔開的;
- 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;
紅黑樹是一種性能非常穩定的二叉查找樹,所以,在工程中,但凡是用到動態插入、刪除、查找數據的場景,都可以用到它。不過,它實現起來比較複雜,如果自己寫代碼實現,難度會有些高,這個時候,我們其實更傾向用跳錶來替代它。
5. 堆
堆是一個完全二叉樹;堆中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值。即堆中每個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。對於每個節點的值都大於等於子樹中每個節點值的堆,我們叫作“大頂堆”。對於每個節點的值都小於等於子樹中每個節點值的堆,我們叫作“小頂堆”。
數組中下標爲 i 的節點的左子節點,就是下標爲 i∗2 的節點,右子節點就是下標爲 i∗2+1 的節點,父節點就是下標爲 2i 的節點。
5.1 往堆中插入一個元素
讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關係,我們就互換兩個節點。一直重複這個過程,直到父子節點之間滿足剛說的那種大小關係。
public class Heap {
private int[] a; // 數組,從下標1開始存儲數據
private int n; // 堆可以存儲的最大數據個數
private int count; // 堆中已經存儲的數據個數
public Heap(int capacity) {
a = new int[capacity + 1];
n = capacity;
count = 0;
}
public void insert(int data) {
if (count >= n) return; // 堆滿了
++count;
a[count] = data;
int i = count;
while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
swap(a, i, i/2); // swap()函數作用:交換下標爲i和i/2的兩個元素
i = i/2;
}
}
}
5.2 刪除堆頂元素
我們把最後一個節點放到堆頂,然後利用同樣的父子節點對比方法。對於不滿足父子節點大小關係的,互換兩個節點,並且重複進行這個過程,直到父子節點之間滿足大小關係爲止。這就是從上往下的堆化方法。
public void removeMax() {
if (count == 0) return -1; // 堆中沒有數據
a[1] = a[count];
--count;
heapify(a, count, 1);
}
private void heapify(int[] a, int n, int i) { // 自上往下堆化
while (true) {
int maxPos = i;
if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
往堆中插入一個元素和刪除堆頂元素的時間複雜度都是 O(logn)。
5.3 堆排序
首先將數組原地建成一個堆。所謂“原地”就是,不借助另一個數組,就在原數組上操作。建堆的過程,有兩種思路。
private static void buildHeap(int[] a, int n) {
for (int i = n/2; i >= 1; --i) {
heapify(a, n, i);
}
}
private static void heapify(int[] a, int n, int i) {
while (true) {
int maxPos = i;
if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
建堆的時間複雜度就是 O(n)。
建堆結束之後,數組中的數據已經是按照大頂堆的特性來組織的。數組中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標爲 n 的位置。當堆頂元素移除之後,我們把下標爲 n 的元素放到堆頂,然後再通過堆化的方法,將剩下的 n−1 個元素重新構建成堆。堆化完成之後,我們再取堆頂的元素,放到下標是 n−1 的位置,一直重複這個過程,直到最後堆中只剩下標爲 1 的一個元素,排序工作就完成了。
// n表示數據的個數,數組a中的數據從下標1到n的位置。
public static void sort(int[] a, int n) {
buildHeap(a, n);
int k = n;
while (k > 1) {
swap(a, 1, k);
--k;
heapify(a, k, 1);
}
}
建堆過程的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),所以,堆排序整體的時間複雜度是 O(nlogn)。堆排序不是穩定的排序算法。
5.4 堆的應用
5.4.1 優先級隊列
一個堆就可以看作一個優先級隊列。很多時候,它們只是概念上的區分而已。往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。很多數據結構和算法都要依賴它,比如赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。很多語言中,都提供了優先級隊列的實現,比如,Java 的 PriorityQueue,C++ 的 priority_queue 等。
優先級隊列應用:合併有序小文件,高性能定時器…
5.4.2 利用堆求 Top K
一直維護一個 K 大小的小頂堆,當有數據被添加到集合中時,就拿它與堆頂的元素對比。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理。這樣,無論任何時候需要查詢當前的前 K 大數據,我們都可以立刻返回給他。
5.4.3 利用堆求中位數
面對動態數據集合,中位數在不停地變動,藉助堆這種數據結構,我們不用排序,就可以非常高效地實現求中位數操作。
維護兩個堆,一個大頂堆,一個小頂堆。大頂堆中存儲前半部分數據,小頂堆中存儲後半部分數據,且小頂堆中的數據都大於大頂堆中的數據。
利用兩個堆不僅可以快速求出中位數,還可以快速求其他百分位的數據。
6. 圖
6.1 圖結構定義
- 圖中的元素我們就叫作頂點(vertex)。圖中的一個頂點可以與任意其他頂點建立連接關係。我們把這種建立的關係叫作邊(edge)。跟頂點相連接的邊的條數叫做,度(degree)
- 在圖中畫一條從 A 到 B 的帶箭頭的邊,來表示邊的方向。畫一條從 A 指向 B 的邊,再畫一條從 B 指向 A 的邊。我們把這種邊有方向的圖叫作“有向圖”。以此類推,我們把邊沒有方向的圖就叫作“無向圖”。
- 在有向圖中,我們把度分爲入度(In-degree)和出度(Out-degree)。頂點的入度,表示有多少條邊指向這個頂點;頂點的出度,表示有多少條邊是以這個頂點爲起點指向其他頂點。
- 帶權圖(weighted graph)。在帶權圖中,每條邊都有一個權重(weight)
6.2 圖的存儲結構
-
鄰接矩陣(Adjacency Matrix)
鄰接矩陣的底層依賴一個二維數組。對於無向圖來說,如果頂點 i 與頂點 j 之間有邊,我們就將 A[i][j]和 A[j][i]標記爲 1;對於有向圖來說,如果頂點 i 到頂點 j 之間,有一條箭頭從頂點 i 指向頂點 j 的邊,那我們就將 A[i][j]標記爲 1。同理,如果有一條箭頭從頂點 j 指向頂點 i 的邊,我們就將 A[j][i]標記爲 1。對於帶權圖,數組中就存儲相應的權重。 -
鄰接表(Adjacency List)
針對上面鄰接矩陣比較浪費內存空間的問題,我們來看另外一種圖的存儲方法,鄰接表(Adjacency List)。
有向圖的鄰接表存儲方式,每個頂點對應的鏈表裏面,存儲的是指向的頂點。
對於無向圖來說,,每個頂點的鏈表中存儲的,是跟這個頂點有邊相連的頂點。 -
逆鄰接表
逆鄰接表中,每個頂點的鏈表中,存儲的是指向這個頂點的頂點。
我們可以將鄰接表中的鏈表改成平衡二叉查找樹。實際開發中,我們可以選擇用紅黑樹。這樣,我們就可以更加快速地查找兩個頂點之間是否存在邊了。當然,這裏的二叉查找樹可以換成其他動態數據結構,比如跳錶、散列表等。除此之外,我們還可以將鏈表改成有序動態數組,可以通過二分查找的方法來快速定位兩個頂點之間否是存在邊。
- 總結
鄰接矩陣存儲方法的缺點是比較浪費空間,但是優點是查詢效率高,而且方便矩陣運算。鄰接表存儲方法中每個頂點都對應一個鏈表,存儲與其相連接的其他頂點。儘管鄰接表的存儲方式比較節省存儲空間,但鏈表不方便查找,所以查詢效率沒有鄰接矩陣存儲方式高。針對這個問題,可以將鏈表換成更加高效的動態數據結構,比如平衡二叉查找樹、跳錶、散列表等。