1. 程式人生 > >常見資料結構簡介

常見資料結構簡介

#Heap-like Data Structures

  • Heaps:小頂堆(二叉樹,完全樹),每個節點都比它的左右子樹小。按照層級從左到右插入節點,然後自下向上調整大小。刪除最小值的時候,直接刪除根節點(一直是最小的),然後把最後一個節點移到根節點,然後自頂向下調整大小。若給出一個已經建立好的完全樹,想調整為堆,則需要自底向上、從右到左地逐層調整,調整時還需要考慮子樹是否不再滿足堆條件,if so,自頂向下調整。由於堆是一個按照層次編號的完全樹,所以用一個數組作為資料結構。操作堆就是運算元組。

  • Binomial Queues:二項佇列,也叫Binomial heap,是一種形狀很有特點的樹。首先要知道什麼是二項樹:如圖為四個不同的二項樹,其規律為:度數為k的二項樹有一個根結點,根結點下有k個子女,每個子女分別是度數分別為k-1,k-2,…,2,1,0的二項樹的根。度數為k的二項樹共有2^{k}個結點,高度為k。在深度d處有{\tbinom nd}

    (二項式係數)個結點。二項堆自然就是滿足節點相對大小關係的二項樹。二項堆的插入很簡單,只需要新建一個節點,併合並。合併的物件只能是兩個度相同的二項堆:比較二個根結點關鍵字的大小,其中含小關鍵字的結點成為結果樹的根結點,另一棵樹則變成結果樹的子樹。刪除最小的節點就是刪除根節點,根節點下的幾個子樹全部分開。二項堆在各類操作中時間複雜度都為O(logn),可以說效能很好。其儲存結構一般為連結串列。

  • Fibonacci Heaps:如圖,斐波那契堆由這樣一些小頂堆組成,其中每個節點ADT 如下:
1
2
3
4
5
6
7
8
9
struct FibonacciHeapNode {
int key; //結點

int degree; //度
FibonacciHeapNode * left; //左兄弟
FibonacciHeapNode * right; //右兄弟
FibonacciHeapNode * parent; //父結點
FibonacciHeapNode * child; //第一個孩子結點
bool marked; //是否被刪除第1個孩子
};

並且用min 指向最小的根節點。每個節點有一個指標指向其一個子女,它的所有子女由雙向迴圈連結串列連線,不同的小頂堆的根節點也是通過這種連結串列連線,叫做根表。

插入新元素時,只要將其作為單元素 F 堆,並跟原有堆合併,合併的方式是新 F 堆加入原 F 堆的根表中。

刪除元素時,先把 min 指向的最小節點刪除,然後將剩下的小頂堆分開,並按照二項堆組合的方式重新組合。

  • Leftist Heaps:左偏堆,每個節點除了有左右孩子和鍵值外,還有一個距離。什麼是距離呢? 引用維基百科的話:「當且僅當節點 i 的左子樹且右子樹為空時,節點被稱作外節點(實際上儲存在二叉樹中的節點都是內節點,外節點是邏輯上存在而無需儲存。把一顆二叉樹補上全部的外節點,則稱為extended binary tree)。節點 i 的距離是節點 i 到它的後代中的最近的外節點所經過的邊數。特別的,如果節點 i 本身是外節點,則它的距離為 0;而空節點的距離規定為-1 。」如圖。除了堆的性質外,還有一條「節點的左子節點的距離不小於右子節點的距離。」其插入刪除等基本操作都是基於合併。怎樣合併呢?找到 root 鍵值最小的那個,用其右子樹與其他樹合併,若右子樹為空,把另外的樹直接弄過來,若此時右子樹距離比左子樹大了,那就交換左右子樹;若右子樹不空,把右子樹摘下來與其他樹合併,如此遞迴進行。刪除堆中最小值時,先刪除它,再把遺留的幾個子樹按照前述方法合併。左偏堆合併操作的平攤時間複雜度為O(log n)。

  • Skew Heaps:斜堆,上述左偏堆就是一種特殊的斜堆。斜堆沒有距離的概念,其合併過程與左偏堆幾乎一樣,只是在每次合併之後都要左右子樹互換一下(啟發規則)。這樣往往能導致最後形成的樹中左子樹比右子樹深,所以是斜的。

#Graph Algorithms

  • Breadth-First Search:廣度優先搜尋。圖可分為有向圖和無向圖(都可應用本演算法),其表示形式有圖形、鄰接表、臨界矩陣,注意圖中 Parent 和 Visited 兩個陣列。

  • Depth-First Search:深度優先搜尋,基本同上,除了搜尋的順序不同。搜尋過程中會涉及到回溯問題,而回溯可以用棧這種資料結構,也可以用遞迴的這種執行形式。廣度優先搜尋則不會有回溯的情況。

  • Connected Components:連通分量,對於無向圖,就是這樣的一個子圖:任意兩個節點都可以路徑可達,再加入該子圖之外的節點後就不滿足任意可達性了,所以也可以叫做最大連通子圖。其實每個獨立的無向圖都是連通分量。還有一個叫強聯通分量,是對應於有向圖的,這時就不太容易尋找一個有向圖的強連通分量了,因為要保證任意兩個節點是互相可達的。Kosaraju演算法、Tarjan演算法、Gabow演算法是目前比較有效的演算法。DSV 中用的哪種,我還沒看明白,等看完《演算法概論》中介紹的那種再說。

  • Dijkstra’s Shortest Path:用於求解帶權有向圖(也可求無向圖)的單源最短路徑。如圖,我們用這樣一個表格來演示並記錄。Vertex 表示圖中的節點;Known 表示執行到目前為止,是否確定了該節點的最終最短路徑,初始為 F(否);Cost 表示目前為止從源節點到該節點的最短路徑,初始為 INF(無窮大); Path 是源點經過哪個節點到達的這個節點,初始為-1(無)。演算法執行的過程:假設源點為2,此時2為 T,尋找2的直接鄰節點,並更新 Cost(如果新 cost 比當前的小就更新,否則不更新),同時更新 Path 為2;然後,在所有F 的節點中,選擇當前 Cost 最大的一個,標記為 T,這個節點的全域性最短路徑就確定下來了,以此作為中間節點,尋找其直接鄰節點,並更新 Cost(注意,已經為 T 的不再更新)和 Path;如此迴圈,直到所有節點都為 T。

最終得到如下圖所示的表格。接下來就是把 Path 表示出來。比如執行到7的時候,7的 path 是5,5的是6,6的是2,所以7的最終 path 是 2 6 5 7.

  • Prim’s Minimum Cost Spanning Tree:Prim 演算法,對於給定的無向圖,找出最小生成樹。最小生成樹就是包含圖中所有節點和部分邊是一個樹,並且其權值之和最小。該演算法的執行過程就是從源節點出發,選擇權值最小的鄰近節點,再以此為當前節點,選擇未訪問的權值最小的鄰近節點,如此迴圈,若到頭了,則回溯。總體來說是大 DFS 中包含著小 BFS。具體的執行過程可以使用Dijkstra演算法中使用的表格形式。

  • Topological Sort (Using Indegree array):對於一個DAG,一定存在拓撲排序,使得對於 uv,在拓撲排序中,u 一定在 v 前面。這裡使用直觀的Kahn演算法:有兩個集合,L 表示已經排好的,S 表示入度為0的節點;每次從 S 中取出一個節點 n 放入 L 中,並檢視從 n 出發的節點中是否有入度減為0的,若有,加入到 S 中,然後再從 S 中取節點,迴圈。。。如果最後剩下邊,說明該圖是有環的,不存在拓撲序列,否則最後得到的 L 即拓撲序列。從演算法的執行過程來看,它是基於佇列的。

  • Topological Sort (Using DFS):基於 DFS 的拓撲排序,這裡 S 是所有出度為0的節點的集合。對於每個節點,遞迴訪問以它為尾的所有節點,並標記為 Visited,並按照遞迴的退出順序把節點加入到 L 中。這種方法其實也很直觀,出度為0的節點,回溯到頭一般就是入度為0的節點了。深度優先遍歷會用到遞迴或棧。

  • Floyd-Warshall (all pairs shortest paths):留著,太複雜了。

  • Kruskal Minimum Cost Spanning Tree Algorithm:Kruskal 演算法,對於給定的無向圖,找出最小生成樹。其方法很簡單,就是從所有邊中,依次挑選最小的邊形成樹的一個邊,加入到當前的樹中,如果這個邊使樹成為圖,就捨棄,尋找次最小的,直到所有的節點都進入這個樹中。

#Dynamic Programming

Calculating nth Fibonacci number,
Making Change,
Longest Common Subsequence:動態規劃適用於有最優子結構和重疊子問題的問題。最優子結構是指區域性最優解能決定全域性最優解,這樣我們才能把問題分解成子問題來解決。子問題重疊性質是指「在用遞迴演算法自頂向下對問題進行求解時,每次產生的子問題並不總是新問題,有些子問題會被重複計算多次。動態規劃演算法正是利用了這種子問題的重疊性質,對每一個子問題只計算一次,然後將其計算結果儲存在一個表格中,當再次需要計算已經計算過的子問題時,只是在表格中簡單地檢視一下結果,從而獲得較高的效率。
」最常用的例子是斐波那契數列。其求解最常用的演算法是如下遞迴:

1
2
3
4
function fib(n)
if n = 0 or n = 1
return 1
return fib(n − 1) + fib(n − 2)

雖然通過遞迴把問題分解為子問題了,但是,遞迴過程中很多子問題被重疊計算了,比如當n=5時,fib(5)的計算過程如下:

1
2
3
4
5
fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))

改進的方法是,我們可以通過儲存已經算出的子問題的解來避免重複計算:

1
2
3
4
5
array map [0...n] = { 0 => 0, 1 => 1 }
fib( n )
if ( map m does not contain key n)
m[n] := fib(n − 1) + fib(n − 2)
return m[n]

其他

  • Disjoint Sets:並查集,也叫union-find algorithm,因為該資料結構上的主要操作是 find 和 union,還有一個基本的 makeset 操作。

資料結構是一個樹,每個節點除了有值外,還有一個指標指向父節點。一個樹就是一個集合,樹的根節點代表這個集合。由於只需要一個指標指向父節點,一般用一個數組表示這棵樹。

資料結構說清楚了,接下來談談其操作。makeset 的作用是建立一個只含X 的集合。find 的作用是找到 X 的根節點,即找到 X 屬於哪個集合。union 的作用是把 x 和 y 代表的集合合併。這三個操作的程式碼非常簡單,但是得到的樹會很高很偏,在之後的操作中效率就低了。對此有兩種優化方法:路徑壓縮和按秩合併。下面給出最終程式碼,在註釋處註明其改進:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

int father[MAX];//用陣列表示樹,記錄每個節點的父節點索引
int rank[MAX];//

/* 初始化集合*/
void Make_Set(int x)
{

father[x] = x; //只有一個元素的集合,父節點是自身,也可指定其他,如-1.
rank[x] = 0; //只有一個節點,秩(深度)為0
}

/* 查詢x元素所在的集合,回溯時壓縮路徑*/
int Find_Set(int x)
{

if (x != father[x])
{
father[x] = Find_Set(father[x]); //回溯時把中間經過的節點的父節點都指向根節點,使樹扁平化,壓縮路徑
}
return father[x];
}

/* 按秩合併x,y所在的集合 */
void Union(int x, int y)
{

x = Find_Set(x);
y = Find_Set(y);
if (x == y) return;//在一個集合中,不用合併
if (rank[x] > rank[y])
{
father[y] = x;//總是把秩小的樹的根節點指向較大的樹的根節點,這樣秩不會增加
}
else
{
if (rank[x] == rank[y])
{
rank[y]++;
}
father[x] = y;
}
}