表,棧和隊列是計算機科學中最簡單和最基本的三種底層數據結構。事實上,每個有意義的程序都將明晰地至少使用一種這樣的數據結構,而棧則在程序中老是要間接地用到,無論你在程序中是否作了聲明。git
在計算機軟件編程中,咱們會接觸到諸如整型,浮點型,字符型,布爾型等基本數據類型,也有一些更爲複雜的複合數據類型,如數組,字典(散列表),元組等。若是咱們拋開這些數據類型具體實現,進一步抽象,給出更通常的定義,即每一種數據類型實際是一些特定操做
的集合。咱們稱這些操做的集合
爲抽象數據類型
(abstract data type, ADT
)。ADT 是數學意義上的抽象,它不約束各個操做的具體實現,對於每種 ADT 並不存在什麼法則來告訴咱們必需要有哪些操做,這只是一個設計決策。算法
表是一種形如 的數據結構。咱們說這個表的大小是
。咱們稱大小爲
的表爲空表(empty list)。對於除空表之外的任何表,咱們說
後繼
(或繼
以後)並稱
前驅
。定義表ADT的操做集合一般包含:數據庫
咱們最容易想到實現表的方法就是數組。使用數組實現表的各項操做,顯而易見的時間複雜度是:編程
咱們不難發現,當插入和刪除個元素時,須要花費
的時間,運行慢且表的大小必須事先已知。所以當須要具備插入和刪除操做時,一般不使用簡單數組來實現。數組
爲了不插入和刪除的線性開銷,咱們須要容許表能夠不連續存儲,不然表的部分或所有須要總體移動。所以,這種狀況下更好的實現方式是鏈表(linked list)。markdown
鏈表由一系列沒必要在內存中相連的結構組成。每個結構均含有表元素和指向包含該元素後繼元的結構的指針。咱們稱之爲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; } } 複製代碼
棧(stack)是限制插入和刪除只能在一個位置上進行的表,該位置是表的末端,叫作棧的頂(top)。對棧的基本操做有Push
(進棧)和Pop
(出棧),前者至關於插入,後者則是刪除最後插入的元素。最後插入的元素能夠經過使用Top
操做在執行Pop
以前讀取。post
因爲棧是一個表,所以任何實現表的方法都能實現棧。一般使用數組是一個較爲簡便的方法。spa
和棧同樣,隊列(queue)也是表。不一樣的是,使用隊列時插入在一端進行而刪除則在另外一端進行。
隊列的基本操做是Enqueue
(入隊),它是在表的末端(rear 隊尾)插入一個元素,還有 Dequeue
(出隊),它是刪除(或返回)在表的開頭(front 對頭)的元素。
對於大量的輸入數據,鏈表的線性訪問時間太慢,不宜使用。而「樹」大部分操做的運行時間平均爲。
一課樹是個節點
條邊的集合,其中的一個節點叫作根。
路徑
從節點到
的路徑(path)定義爲節點
的一個序列,使得對於
,節點
是
的父親。這個路徑的長(length)爲該路徑上的邊的條數,即
。從每個節點到它本身都有一條長爲
的路徑。從根到每一個節點有且僅有一條路徑。
深度
對於任意節點,
的深度(depth)爲從根到
的惟一路徑的長。所以,根的深度爲
。
高度
的高(height)是從
到一片樹葉的最長路徑的長。所以,全部樹葉的高度都是
。
祖先(ancestor)和後裔(descendant)
若是存在從到
的一條路徑,那麼
是
的一位祖先而
是
的一個後裔。若是
,那麼
是
的一位真祖先(
proper ancestor
)而是
的一個真後裔(
proper descendant
)。
將每一個節點的全部兒子都放在樹節點的鏈表中。FirstChild
是指向第一個兒子的指針,NextSibling
指向下一個兄弟節點。
typedef struct TreeNode *PtrToNode; struct TreeNode { ElementType Element; PtrToNode FirstChild; PtrToNode NextSibling; } 複製代碼
先序遍歷(preorder traversal)
在先序遍歷中,對節點的處理工做是在它的諸兒子節點被處理以前進行的。例如:打印目錄樹形結構圖,先打印父節點,再遞歸打印子節點。
後序遍歷(postorder traversal)
在後序遍歷中,在一個節點處的工做是在它的諸兒子節點被計算後進行的。例如:計算目錄所佔磁盤空間,在獲得父節點佔用空間前,須要先遞歸計算子節點所佔用的磁盤空間,最後才能逐級向上獲得根節點的磁盤總佔用空間。
中序遍歷(inorder traversal)
用於二叉樹。遍歷順序:左子樹,節點,右子樹。
在二叉樹中,每一個節點最多隻有兩個兒子。
二叉樹的平均深度爲,而對於特殊類型的二叉樹,如二叉查找樹(binary search tree),其平均深度是
。
由於一棵二叉樹最多有兩個兒子,因此咱們能夠用指針直接指向它們。樹節點的聲明在結構上相似於雙鏈表的聲明。在聲明中,一個節點就是由Key信息加上兩個指向其餘節點的指針(Left 和 Right)組成的結構。
typedef struct TreeNode *PtrToNode; typedef struct PtrToNode Tree; struct TreeNode { ElementType Element; Tree Left; Tree Right; } 複製代碼
二叉樹有許多與搜索無關的重要應用。二叉樹的主要用處之一是在編譯器的設計領域。如二元表達式樹。
二叉樹的一個重要的應用是它們在查找中的使用。使二叉樹成爲二叉查找樹的性質是,對於樹中的每一個節點,它的左子樹全部關鍵字的值小於
,而它右子樹中全部關鍵字值大於
的關鍵字值。
操做集合:
AVL(Adelson-Velskii 和 Landis)樹是帶有平衡條件的二叉查找樹。這個平衡條件必需要容易保持,並且它必須保證樹的深度是。最簡單的想法是要求左右子樹具備相同的高度。另外一種平衡條件是要求每一個節點都必需要有相同高度的左子樹和右子樹。雖然這種平衡條件保證了樹的深度小,可是它太嚴格,難以使用,須要放寬條件。
一棵AVL樹是其每一個節點的左子樹和右子樹的高度最多差1的二叉查找樹。
單旋轉
雙旋轉
伸展樹保證從空樹開始任意連續次對樹的操做最多花費
的時間。
雖然迄今爲止咱們所看到的查找樹都是二叉樹,可是還有一種經常使用的查找樹不是二叉樹。這種樹叫作B-樹(B-tree)。
階爲的B-樹是一棵具備下列結構特性的樹:
B-樹實際用於數據庫系統,在那裏樹被存儲在物理的磁盤上而不是主存中。通常來講,對磁盤的訪問要比任何的主存操做慢幾個數量級。若是咱們使用M階B-樹,那麼磁盤訪問的次數是
散列表的實現經常叫作散列(hashing)。散列是一種用於以常數平均時間執行插入,刪除和查找的技術。可是,那些須要元素間任何排序信息的操做將不會獲得有效的支持。所以,諸如 FindMin,FindMax 以及以線性時間按排序順序將整個表進行打印的操做都是散列所不支持的。
理想的散列表數據結構只不過是一個包含關鍵字(key)的具備固定大小的數組。典型狀況下,一個關鍵字就是一個帶有相關值(例如工資信息)的字符串。咱們把表的大小記做Table-Size
,並將其理解爲散列數據結構的一部分而不只僅是浮動於全局的某個變量。一般的習慣是讓表從0
到Table-Size - 1
變化。
每一個關鍵字被映射到從0
到Table-Size - 1
這個範圍中的某個數,而且被放到適當的單元中。這個映射就叫作散列函數
(hash function
)。理想狀況下它應該運算簡單
而且應該保證任何兩個不一樣的關鍵字映射到不一樣的單元。不過,這是不可能的,由於單元的數目是有限的,而關鍵字其實是用不完的。所以,咱們尋找一個散列函數,該函數要在單元之間均勻
地分配關鍵字。這就是散列的基本想法。剩下的問題則是選擇一個函數,決定當兩個關鍵字散列到同一個值的時候(稱爲衝突collision
)應該作什麼以及如何肯定散列表的大小。
若是輸入的關鍵字是整數,則通常合理的方法就是直接返回「Key mod TableSize」
(關鍵字對錶大小取模)的結果,除非Key
碰巧具備某些不理想的性質。例如,若表的大小是10
,而關鍵字都以0
爲個位,這意味全部關鍵字取模運算的結果都是0
(都能被10整除)。這種狀況,好的辦法一般是保證表的大小是素數(也叫質數,只能被1和自身整除)。當輸入的關鍵字是隨機的整數時,散列函數不只算起來簡單
並且關鍵字的分配也很均勻
。
一般,關鍵字是字符串;在這種情形下,散列函數須要仔細地選擇。
一種方法是把字符串中字符的ASCII
碼值加起來。如下該方法的C語言實現。
typedef unsigned int Index; Index Hash(const char *Key, int TableSize) { unsigned int HashVal = 0; while(*Key != '\0') HashVal += *Key++; return HashVal % TableSize; } 複製代碼
這種方法實現起來簡單並且能很快地計算出答案。不過,若是表很大,則函數將不會很好地分配關鍵字。例如,設(10007是素數),並設全部的關鍵字至多8個字符長。因爲
char
型量的值最可能是127
,所以散列函數只能取在0和1016之間,其中。這顯然不是一種均勻分配。
第二種方法。假設Key
至少有兩個字符外加NULL
結束符。值27
表示英文字母表的字母個數外加一個空格,而。該函數只考查前三個字符,假如它們是隨機的,而表的大小像前面那樣仍是
10007
,那麼咱們就會獲得一個合理的均衡分配。
Index Hash(const char *Key, int TableSize) { return (Key[0] + 27*Key[1] + 729*Key[2]) % TableSize; } 複製代碼
但是不巧的是,英文並非隨機的。雖然3個字符(忽略空格)有種可能組合,但查驗詞彙量足夠大的聯機詞典卻揭示:3個字母的不一樣組合數實際只有
2851
。即便這些組合沒有衝突,也不過只有表的28%
被真正散列到。所以,雖然很容易計算,可是當散列表足夠大的時候這個函數仍是不合適的。
一個更好的散列函數。這個散列函數涉及到關鍵字中的全部字符,而且通常能夠分佈得很好。計算公式以下:
它根據Horner
法則計算一個(32的)多項式。例如,計算的另外一種方式是藉助於公式
進行。
Horner
法則將其擴展到用於次多項式。
Index Hash(const char *Key, int TableSize) { unsigned int HashVal = 0; while(*Key != '\0') /* 1 */ HashVal = (HashVal << 5) + *Key++; /* 2 */ return HashVal % TableSize; /* 3 */ } 複製代碼
這裏之因此用 32 替代27,是由於用32做乘法不是真的去乘,而是移動二進制的5位。爲了運算更快,程序第2行的加法能夠用按位異或來代替。雖然就表的分佈而言未必是最好的,但確實具備及其簡單的優勢。若是關鍵字特別長,那麼該散列函數計算起來將會花費過多的時間,不只如此,前面的字符還會左移出最終的結果。這種狀況,一般的作法是不使用全部字符。此時關鍵字的長度和性質將影響選擇。
解決了關鍵字均勻映射的問題,剩下的主要編程細節是解決衝突的消除問題。若是當一個元素被插入時另外一個元素已經存在(散列值相同),那麼就產生了衝突,這種衝突須要消除。解決這種衝突的方法有幾種。最簡單的兩種是:分離連接法和開放定址法。
分離連接法是將散列到同一個值的全部元素保留到一個表中。爲了方便起見,這些表都有表頭,實現方法與表ADT
相同。若是空間很緊,則更可取的方法是避免使用這些表頭。
類型聲明:
#ifndef _HashSep_H struct ListNode; typedef struct ListNode *Position; struct HashTbl; typedef struct HashTbl *HashTable; HashTable InitializeTable(int TableSize); void DestroyTable(HashTable H); ElementType Retrieve(Position P); #endif struct ListNode { ElementType Element; Position Next; }; typedef Position List; struct HashTbl { int TableSize; List *TheLists; }; 複製代碼
初始化函數:
HashTable InitializeTable(int TableSize) { HashTable H; int i; if (TableSize < MinTableSize) { Error("Table size too small"); return NULL; } H = malloc(sizeof(struct HashTbl)); if (H == NULL) FatalError("out of space!!!"); H->TableSize = NextPrime(TableSize); /* 1 設置素數大小 */ H->TheLists = malloc(sizeof(List) * H->TableSize); /* 2 */ if (H->TheLists == NULL) FatalError("Out of space!!!"); /** * 分配鏈表表頭 * * 給每個表設置一個表頭,並將 Next 指向 NULL。若是不用表頭,如下代碼可省略。 */ for(i = 0; i < H->TableSize; i++) { H->TheLists[i] = malloc(sizeof(struct ListNode)); /* 3 */ if (H->TheLists[i] == NULL) FatalError("Out of space!!!"); else H->TheLists[i]->Next = NULL; } return H; } 複製代碼
以上程序低效之處是標記爲3處malloc
執行了H->TableSize
次。這能夠經過循環開始以前調用一次malloc
操做:
H->TheLists = malloc(sizeof(struct ListNode) * H->TableSize); 複製代碼
Find 操做:
Position Find(ElementType Key, HashTable H) { Position P; List L; L = H->TheLists[ Hash(Key, H->TableSize) ]; P = L->Next; while(P != NULL && P->Element != Key) /* ElementType 爲 int時比較。字符串比較使用 `strcmp` */ P = P->Next; return P; } 複製代碼
注意,判斷
P->Element != Key
,這裏適用於整數。字符串比較用strcmp
替換。
Insert 操做:
void Insert(ElementType Key, HashTable H) { Position Pos, NewCell; List L; Pos = Find(Key, H); if (Pos == NULL) { NewCell = malloc(sizeof(struct ListNode)); if(NewCell == NULL) FatalError("Out of space!!!"); else { L = H->TheLists[ Hash(Key, H->TableSize) ]; NewCell->Next = L->Next; NewCell->Element = Key; L->Next = NewCell; } } } 複製代碼
若是在散列中諸例程中不包括刪除操做,那麼最好不要使用表頭。由於這不只不能簡化問題並且還要浪費大量的空間。
類型聲明:
#ifndef _HashQuad_H typedef unsigned int Index; typedef Index Position; struct HashTbl; typedef struct HashTbl *HashTable; HashTable InitializeTable(int TableSize); void DestroyTable(HashTable H); Position Find(ElementType Key, HashTable H); void Insert(ElementType Key, HashTable H); ElementType Retrieve(Position P, HashTable H); HashTable Rehash(HashTable H); #endif enum KindOfEntry { Legitimate, Empty, Deleted }; struct HashEntry { ElementType Element; enum KindOfEntry Info; }; typedef struct HashEntry Cell; struct HashTbl { int TableSize; Cell *TheCells; }; 複製代碼
初始化開放定址散列表:
HashTable InitializeTable(int TableSize) { HashTable H; int i; if (TableSize < MinTableSize) { Error("Table size too small"); return NULL; } /* 給散列表分配內存 */ H = malloc(sizeof(struct HashTbl)); if (H == NULL) FatalError(Out of space!!!); H->TableSize = NextPrime(TableSize); /* 大於 TableSize 的第一個素數 */ /* 給數組全部單元分配內存 */ H->TheCells = malloc(sizeof(Cell) * H->TableSize); if(H->TheCells == NULL) FatalError("Out of space!!!"); for (i = 0; i < H->TableSize; i++) H->TheCells[i].Info = Empty; return H; } 複製代碼
Find 操做:
Position Find(ElementType Key, HashTbl H) { Position CurrentPos; int CollisionNum; CollisionNum = 0; CurrentPos = Hash(Key, H->TableSize); while(H->TheCells[CurrentPos].Info != Empty && H->TheCells[CurrentPos].Element != Key) /* 這裏可能須要使用 strcmp 字符串比較函數 !! */ { CurrentPos += 2 * ++CollisionNum - 1; if (CurrentPos >= H->TableSize) CurrentPos -= H->TableSize; } return CurrentPos; } 複製代碼
Insert 操做:
void Insert(ElementType Key, HashTable H) { Position Pos; Pos = Find(Keu, H); if (H->TheCells[Pos].Info != Legitimate) { H->TheCells[Pos].Info = Legitimate; H->TheCells[Pos].Element = Key; /* 字符串類型須要使用 strcpy 函數 */ } } 複製代碼
散列有着豐富的應用。編譯器使用散列表跟蹤源代碼中聲明的變量。這種數據結構叫作符號表(symbol table)
。散列表是這種問題的理想應用,由於只有Insert
和Find
操做。標識符通常都不長,所以其散列函數可以迅速被算出。
散列表常見的用途也出如今爲遊戲編寫的程序中。當程序搜索遊戲的不一樣的行時,它跟蹤經過計算機基於位置的散列函數而看到的一些位置。若是一樣的位置再出現,程序一般經過簡單移動變換來避免昂貴的重複計算。遊戲程序的這種通常特色叫作變換表(transposition table)
。
另一個用途是在線拼寫檢驗程序。若是錯拼檢測(與糾正錯誤相比)更重要,那麼整個詞典能夠被預先散列,單詞則能夠在常數時間內被檢測。散列表很適合這項工做,由於以字母順序排列單詞並不重要;而以它們在文件中出現的順序顯示出錯誤拼寫固然是能夠接受的。
隊列是一種先進先出的表ADT,正常來講,先入隊的元素,會先出隊,意味沒有那個元素是特殊的,擁有「插隊」的優先權。這種平等,並不試用全部場景。有時,咱們但願隊列中某類元素擁有比其餘元素更高的優先級,以便能提早獲得處理。所以,咱們須要有一種新的隊列來知足這樣的應用,這樣的隊列叫作「優先隊列(priority queue)」。
優先隊列容許至少兩種操做:Insert(插入) ,以及 DeleteMin(刪除最小者)。Insert 操做等價於 Enqueue(入隊),而 DeleteMin 則是隊列中 Dequeue(出隊) 在優先隊列中的等價操做。DeleteMin 函數也變動它的輸入。
在表頭以執行插入操做,並遍歷該鏈表以刪除最小元,這又須要
時間。另外一種作法是,始終讓表保持排序狀態;這使得插入代價高昂(
)而DeleteMin花費低廉(
)。基於DeleteMin的操做次數從很少於插入操做次數的事實,前者也許是更好的辦法。
使用二叉查找樹,Insert 和 DeleteMin 這兩種操做的平均運行時間都是。
實現優先隊列更加廣泛的方法是二叉堆
,以致於當堆
(heap)這個詞不加修飾地使用時通常都是指該數據結構(優先隊列)的這種實現。所以,咱們單獨說堆時,就是指二叉堆。同二叉查找樹同樣,堆也有兩個性質,即結構性
和堆序性
。正如AVL樹同樣,對堆的一次操做可能破壞這兩個性質的一個,所以,堆的操做必需要到堆的全部性質都被知足時才能終止。事實上這並不難作到。
堆是一棵被徹底填滿的二叉樹,有可能的例外是在底層,底層上的元素從左到右填入。這樣的樹稱爲徹底二叉樹(complete binary tree)。由於徹底二叉樹頗有規律,因此它能夠用一個數組表示而不須要指針。對於數組任意位置上的元素,其左兒子在位置
上,右兒子在左兒子後的單元
上,它的父親則在位置
上。所以,不只指針這裏不須要,並且遍歷該樹所須要的操做也極簡單,在大部分計算機上運行極可能很是快。
一個堆數據結構由一個數組,一個表明最大值的整數以及當前的堆大小組成。
優先隊列聲明:
#ifndef _BinHeap_H struct HeapStruct; typedef struct HeapStruct *PriorityQueue; PriorityQueue Initialize(int MaxElements); void Destroy(PriorityQueue H); void MakeEmpty(PriorityQueue H); void Insert(ElementType X, PriorityQueue H); ElementType DeleteMin(PriorityQueue H); ElementType FindMin(PriorityQueue H); int IsEmpty(PriorityQueue H); int IsFull(PriorityQueue H); #endif struct HeapStruct { int Capacity; int Size; ElementType *Elements; } 複製代碼
使操做被快速執行的性質是堆序
(heap order)性。因爲咱們想要快速地找出最小元,所以,最小元應該在根上。若是咱們考慮任意子樹也應該是一個堆,那麼任意節點就應該小於它的全部後裔。
PriorityQueue Initialize(int MaxElements) { PriorityQueue H; if (MaxElements < MinPQSize) Error("Priority queue size is too small"); H = malloc(sizeof(struct HeapStruct)); if (H == NULL) FatalError("Out of space!!!"); H->Elements = malloc((MaxElements + 1) * sizeof(ElementType)); if (H->Elements == NULL) FatalError("Out of space!!!"); H->Capacity = MaxElements; H->Size = 0; H->Elements[0] = MinData; return H; } 複製代碼
根據堆序性質,最小元老是能夠在根處找到。所以,咱們以常數時間完成附加運算FinMin。
void Insert(ElementType X, PriorityQueue H) { int i; if (IsFull(H)) { Error("Priority queue is full"); return; } for (i = ++H->Size; H->Elements[i / 2] > X; i /= 2) H->Elements[i] = H->Elements[i / 2]; H->Elements[i] = X; } 複製代碼
ElementType DeleteMin(PriorityQueue H) { int i, Child; ElementType MinElement, LastElement; if (IsEmpty(H)) { Error("Priority queue is empty"); return H->Elements[0]; } MinElement = H->Elements[1]; LastElement = H->Elements[H->Size--]; for(i = 1; i * 2 <= H->Size; i = Child) { Child = i * 2; if (Child != H->size && H->Elements[Child + 1] < H->Elements[Child]) Child++; if (LastElement > H->Elements[ Child ]) H->Elements[i] = H->Elements[Child]; else break; } H->Elements[i] = LastElement; return MinElement; } 複製代碼