數據結構基礎知識: 表 棧 隊列 樹 散列 堆
- 2019 年 11 月 21 日
- 筆記
1. 表,棧和隊列
表,棧和隊列是電腦科學中最簡單和最基本的三種底層數據結構。事實上,每一個有意義的程式都將明晰地至少使用一種這樣的數據結構,而棧則在程式中總是要間接地用到,不管你在程式中是否做了聲明。
1.1 抽象數據類型(ADT)
在電腦軟體編程中,我們會接觸到諸如整型,浮點型,字元型,布爾型等基本數據類型,也有一些更為複雜的複合數據類型,如數組,字典(散列表),元組等。如果我們拋開這些數據類型具體實現,進一步抽象,給出更一般的定義,即每一種數據類型實際是一些特定操作
的集合。我們稱這些操作的集合
為抽象數據類型
(abstract data type, ADT
)。ADT 是數學意義上的抽象,它不約束各個操作的具體實現,對於每種 ADT 並不存在什麼法則來告訴我們必須要有哪些操作,這只是一個設計決策。
1.2 表ADT
表是一種形如 A_1, A_2, A_3, ldots, A_N 的數據結構。我們說這個表的大小是 N 。我們稱大小為 0 的表為空表(empty list)。對於除空表以外的任何錶,我們說 A_{i+1}(i<N) 後繼 A_i (或繼 A_i 之後)並稱 A_{i-1}(i>1) 前驅 A_i 。定義表ADT的操作集合通常包含:
- PrintList: 列印表
- MakeEmpty: 返回空表
- Find: 返回關鍵字(key)首次出現的位置
- Insert: 從表的指定位置插入關鍵字(key)
- Delelte: 從表的指定位置刪除關鍵字(key)
- FindKth: 返回指定位置上的元素
1.2.1 簡單數組實現
我們最容易想到實現表的方法就是數組。使用數組實現表的各項操作,顯而易見的時間複雜度是:
- PrintList: O(N)
- Find: O(N)
- FindKth: O(1)
- Insert: O(N)
- Delete: O(N)
我們不難發現,當插入和刪除 N 個元素時,需要花費 O(N^2) 的時間,運行慢且表的大小必須事先已知。因此當需要具有插入和刪除操作時,通常不使用簡單數組來實現。
1.2.2 鏈表實現
為了避免插入和刪除的線性開銷,我們需要允許表可以不連續存儲,否則表的部分或全部需要整體移動。因此,這種情況下更好的實現方式是鏈表(linked list)。
鏈表由一系列不必在記憶體中相連的結構組成。每一個結構均含有表元素和指向包含該元素後繼元的結構的指針。我們稱之為Next
指針。最後一個單元的Next
指針指向Null
。鏈表分為:單鏈表,雙鏈表,循環鏈表。
鏈表的 C 語言實現:
#include <stdlib.h> struct Node { int Element; Node* Next; }; int IsEmpty(Node* L) { return L->Next == NULL; } int IsLast(Node* P, Node* L) { return P->Next == NULL; } Node* Find(int x, Node* L) { Node* P; P = L->Next; while(P != NULL && P->Element != x) P = P->Next; return P; } Node* FindPrevious(int x, Node* L) { Node* P; P = L; while(P->Next != NULL && P->Next->Element != x) P = P->Next; return P; } void Delete(int x, Node* L) { Node* P; Node* TmpCell; P = FindPrevious(x, L); if (!IsLast(P, L)) { TmpCell = P->Next; P->Next = TmpCell->Next; free(TmpCell); } } void Insert(int x, Node* L, Node* P) { Node* TmpCell; TmpCell = (Node*)malloc(sizeof(struct Node)); if (TmpCell != NULL) printf("Out of space!!!"); TmpCell->Element = x; TmpCell->Next = P->Next; P->Next = TmpCell; } void DeleteList(Node* L) { Node* P; Node* Tmp; P = L->Next; L->Next = NULL; while(P != NULL) { Tmp = P->Next; free(P); P = Tmp; } }
1.2 棧ADT
棧(stack)是限制插入和刪除只能在一個位置上進行的表,該位置是表的末端,叫做棧的頂(top)。對棧的基本操作有Push
(進棧)和Pop
(出棧),前者相當於插入,後者則是刪除最後插入的元素。最後插入的元素可以通過使用Top
操作在執行Pop
之前讀取。
由於棧是一個表,因此任何實現表的方法都能實現棧。通常使用數組是一個較為簡便的方法。
1.3 隊列ADT
和棧一樣,隊列(queue)也是表。不同的是,使用隊列時插入在一端進行而刪除則在另一端進行。
隊列的基本操作是Enqueue
(入隊),它是在表的末端(rear 隊尾)插入一個元素,還有 Dequeue
(出隊),它是刪除(或返回)在表的開頭(front 對頭)的元素。
2. 樹
2.1 基礎知識
對於大量的輸入數據,鏈表的線性訪問時間太慢,不宜使用。而「樹」大部分操作的運行時間平均為 O(log N) 。
一課樹是 N 個節點 N-1 條邊的集合,其中的一個節點叫做根。
路徑
從節點 n_1 到 n_k 的路徑(path)定義為節點 n_1, n_2, …, n_k 的一個序列,使得對於 1 le i < k ,節點 n_i 是 n_{i+1} 的父親。這個路徑的長(length)為該路徑上的邊的條數,即 k-1 。從每一個節點到它自己都有一條長為 0 的路徑。從根到每個節點有且僅有一條路徑。
深度
對於任意節點 n_i , n_i 的深度(depth)為從根到 n_i 的唯一路徑的長。因此,根的深度為 0 。
高度
n_i 的高(height)是從 n_i 到一片樹葉的最長路徑的長。因此,所有樹葉的高度都是 0 。
祖先(ancestor)和後裔(descendant)
如果存在從 n_1 到 n_2 的一條路徑,那麼 n_1 是 n_2 的一位祖先而 n_2 是 n_1 的一個後裔。如果 n_1 ne n_2 ,那麼 n_1 是 n_2 的一位真祖先(proper ancestor
)而 n_2 是 n_1 的一個真後裔(proper descendant
)。
2.2 樹的實現
將每個節點的所有兒子都放在樹節點的鏈表中。FirstChild
是指向第一個兒子的指針,NextSibling
指向下一個兄弟節點。
typedef struct TreeNode *PtrToNode; struct TreeNode { ElementType Element; PtrToNode FirstChild; PtrToNode NextSibling; }
2.3 樹的遍歷
先序遍歷(preorder traversal)
在先序遍歷中,對節點的處理工作是在它的諸兒子節點被處理之前進行的。例如:列印目錄樹形結構圖,先列印父節點,再遞歸列印子節點。
後序遍歷(postorder traversal)
在後序遍歷中,在一個節點處的工作是在它的諸兒子節點被計算後進行的。例如:計算目錄所佔磁碟空間,在得到父節點佔用空間前,需要先遞歸計運算元節點所佔用的磁碟空間,最後才能逐級向上得到根節點的磁碟總佔用空間。
中序遍歷(inorder traversal)
用於二叉樹。遍歷順序:左子樹,節點,右子樹。
2.4 二叉樹(binary tree)
在二叉樹中,每個節點最多只有兩個兒子。
二叉樹的平均深度為 O(sqrt{N}) ,而對於特殊類型的二叉樹,如二叉查找樹(binary search tree),其平均深度是 O(log N) 。
2.4.1 二叉樹實現
因為一棵二叉樹最多有兩個兒子,所以我們可以用指針直接指向它們。樹節點的聲明在結構上類似於雙鏈表的聲明。在聲明中,一個節點就是由Key資訊加上兩個指向其他節點的指針(Left 和 Right)組成的結構。
typedef struct TreeNode *PtrToNode; typedef struct PtrToNode Tree; struct TreeNode { ElementType Element; Tree Left; Tree Right; }
二叉樹有許多與搜索無關的重要應用。二叉樹的主要用處之一是在編譯器的設計領域。如二元表達式樹。
2.4.2 查找樹ADT——二叉查找樹
二叉樹的一個重要的應用是它們在查找中的使用。使二叉樹成為二叉查找樹的性質是,對於樹中的每個節點 X ,它的左子樹所有關鍵字的值小於 X ,而它右子樹中所有關鍵字值大於 X 的關鍵字值。
操作集合:
- MakeEmpty
- Find
- FindMin 和 FindMax
- Insert
- Delete
2.4.3 AVL 樹
AVL(Adelson-Velskii 和 Landis)樹是帶有平衡條件的二叉查找樹。這個平衡條件必須要容易保持,而且它必須保證樹的深度是 O(log N) 。最簡單的想法是要求左右子樹具有相同的高度。另一種平衡條件是要求每個節點都必須要有相同高度的左子樹和右子樹。雖然這種平衡條件保證了樹的深度小,但是它太嚴格,難以使用,需要放寬條件。
一棵AVL樹是其每個節點的左子樹和右子樹的高度最多差1的二叉查找樹。
單旋轉
雙旋轉
2.4.4 伸展樹(splay tree)
伸展樹保證從空樹開始任意連續 M 次對樹的操作最多花費 O(M log N) 的時間。
2.5 B-樹
雖然迄今為止我們所看到的查找樹都是二叉樹,但是還有一種常用的查找樹不是二叉樹。這種樹叫做B-樹(B-tree)。
階為 M 的B-樹是一棵具有下列結構特性的樹:
- 樹的根或者是一片樹葉,或者其兒子數在2和M之間。
- 除根外,所有非樹葉節點的兒子數在[ M/2 ]和 M 之間。
- 所有的樹葉都在相同的深度上。
B-樹實際用於資料庫系統,在那裡樹被存儲在物理的磁碟上而不是主存中。一般來說,對磁碟的訪問要比任何的主存操作慢幾個數量級。如果我們使用M階B-樹,那麼磁碟訪問的次數是 O(log_{M}N)
3. 散列
散列表的實現常常叫做散列(hashing)。散列是一種用於以常數平均時間執行插入,刪除和查找的技術。但是,那些需要元素間任何排序資訊的操作將不會得到有效的支援。因此,諸如 FindMin,FindMax 以及以線性時間按排序順序將整個表進行列印的操作都是散列所不支援的。
3.1 一般想法
理想的散列表數據結構只不過是一個包含關鍵字(key)的具有固定大小的數組。典型情況下,一個關鍵字就是一個帶有相關值(例如工資資訊)的字元串。我們把表的大小記作Table-Size
,並將其理解為散列數據結構的一部分而不僅僅是浮動於全局的某個變數。通常的習慣是讓表從0
到Table-Size - 1
變化。
每個關鍵字被映射到從0
到Table-Size - 1
這個範圍中的某個數,並且被放到適當的單元中。這個映射就叫做散列函數
(hash function
)。理想情況下它應該運算簡單
並且應該保證任何兩個不同的關鍵字映射到不同的單元。不過,這是不可能的,因為單元的數目是有限的,而關鍵字實際上是用不完的。因此,我們尋找一個散列函數,該函數要在單元之間均勻
地分配關鍵字。這就是散列的基本想法。剩下的問題則是選擇一個函數,決定當兩個關鍵字散列到同一個值的時候(稱為衝突collision
)應該做什麼以及如何確定散列表的大小。
3.2 散列函數
3.2.1 輸入整數關鍵字
如果輸入的關鍵字是整數,則一般合理的方法就是直接返回「Key mod TableSize」
(關鍵字對錶大小取模)的結果,除非Key
碰巧具有某些不理想的性質。例如,若表的大小是10
,而關鍵字都以0
為個位,這意味所有關鍵字取模運算的結果都是0
(都能被10整除)。這種情況,好的辦法通常是保證表的大小是素數(也叫質數,只能被1和自身整除)。當輸入的關鍵字是隨機的整數時,散列函數不僅算起來簡單
而且關鍵字的分配也很均勻
。
3.2.2 輸入字元串關鍵字
通常,關鍵字是字元串;在這種情形下,散列函數需要仔細地選擇。
一種方法是把字元串中字元的ASCII
碼值加起來。以下該方法的C語言實現。
typedef unsigned int Index; Index Hash(const char *Key, int TableSize) { unsigned int HashVal = 0; while(*Key != '