常見數據結構——樹
處理大量的數據時,鏈表的線性時間太慢了,不宜使用。在樹的數據結構中,其大部分的運行時間平均為O(logN)。並且通過對樹結構的修改,我們能夠保證它的最壞情形下上述的時間界。
樹的定義有很多種方式。定義樹的自然的方式是遞歸的方式。一棵樹是一些節點的集合,這個集合可以是空集,若非空集,則一棵樹是由根節點r以及0個或多個非空子樹T1,T2,T3,......,Tk組成,這些子樹中每一棵的根都有來自根r的一條有向的邊所連接。
從遞歸的定義中,我們發現一棵樹是N個節點和N-1條邊組成的,每一個節點都有一條邊連接父節點,但是根節點除外。
具有相同父親的節點為兄弟,類似的方法可以定義祖父和孫子的關系。
從節點n1到nk的路徑定義為節點n1,n2,...,nk的一個序列,並且ni是ni+1的父親。這個路徑的長是路徑上的邊數,即k-1。每個節點到自己有一條長為0的路徑。一棵樹從根到葉子節點恰好存在一條路徑。
對於任意的節點ni,ni的深度為從根到ni的唯一路徑長。ni的高是從ni到一片葉子的最長路徑的長。因此,所有的樹葉的高度都是0,一棵樹的高等於它的根節點的高。一棵樹的深度總是等於它最深葉子的深度;該深度等於這棵樹的高度。
樹的實現
實現樹的一種方法可以是在每一個節點除數據外還要有一些指針,使得該節點的每一個兒子都有一個指針指向它。但是由於每個節點的兒子樹可以變化很大而且事先不知道,故在各個節點建立子節點的鏈接是不可行的,這樣將會浪費大量的空間。
實際的做法很簡單:將每個節點的所有兒子都放在樹節點的鏈表中。下面是典型的聲明:
typedef struct TreeNode *PtrToNode struct TreeNode{ ElementType Element; PtrToNode FirstChild; PtrToNode NextSibling }
下面是兒子兄弟表示法的圖示:
樹的遍歷及應用
一個常見的使用是操作系統中的目錄結構。unix中的目錄就是含有它的所有兒子的一個文件,下面是一個打印目錄的例子,輸出的格式是:深度為di的文件的名字將被di次跳格tab后縮進:
static void ListDir(DictoryOrFile D, int Depth){ if(D is a legitimate entry){ PrintName(D, Depth); if(D is a directory) for each childm C, of D ListDir(C, Depth + 1); } }
void ListDirectory(DirectoryOrFile D){ ListDie(D, 0); }
上述的遍歷策略叫做先序遍歷。在先序遍歷中,對節點的處理工作是在他的諸子節點被處理之前進行的。另一種遍歷樹的方法是后序遍歷。在后序遍歷中,每一個節點處理的工作是在他的諸子兒子節點計算之后進行的。
記錄一個目錄大小的例程
static void SizeDirectory(Directory D){ int TotalSize=0; if(D is a legitimate entry){ for each child, C of D: TotalSize +=SizeDirectory(C); } return TotalSize; }
二叉樹
二叉樹是一種樹,他的每個節點都不能有多於兩個的兒子。二叉樹的一個性質是平均二叉樹的深度要比N小的多,分析表明平均的深度為O(sqrt(N)),而對於特殊的二叉樹而言,其深度的平均值是O(logN)的。
對於二叉樹的實現,最多有兩個兒子,我們可以用指針直接指向它們。在聲明中,一個節點就是由Key(關鍵字)信息加上兩個指向其他節點的指針組成的結構。
typedef struct TreeNode *PtrToNode; typedef struct PtrToNode Tree; struct TreeNode{ ElementType Element; Tree Left; Tree Right; };
二叉樹上就是圖,在畫二叉樹的時候,我們一般不會畫出NULL指針,因為具有N個節點的每一棵二叉樹都將需要N+1個NULL指針。二叉樹有許多與搜索無關的重要應用。二叉樹的重要用處之一是在編譯器的設計原則領域。
表達式樹
下面是一個表達式樹的例子:
表達式樹的樹葉是操作數,比如常數或者變量,而其他的節點為操作符。由於這里所有操作都是二元的,因此這棵特定的樹正好是二叉樹。有的節點也有可能只有一個兒子,如具有一目的減運算符的情形。在上面的樹中,左子樹的值是a+(b*c),右子樹的值是((d*e)+f)*g,整棵樹的表示(a+(b*c))+(((d*e)+f)*g)。
對於上面的二叉樹,我們可以通過遞歸產生一個帶括號的左表示式,然后打印出在根處的運算符,最后在遞歸的產生一個帶括號的右表達式進而得到一個中綴表達式。這樣一般的方法稱為中綴表達式,由於其產生的表達式類型,這種遍歷很容易記住。
我們也可以通過后序遍歷的方式,得到表達式的后綴表達式。
構造一顆表達式樹
下面我們給出一種算法,來把表達式的后綴表示轉化為表達式樹。對於將中綴表達式轉換為后綴表達式的算法,我們可以通過棧進行實現。對於構建表達式樹的算法:我們一次一個符號的讀入表達式,如果符號是操作數,那么我們就建立一個單節點數並將一個指向它的指針推入棧,如果符號是操作符,那么我們就從棧中彈出指向兩棵樹T1和T2的那兩個指針並形成一顆新的數,該樹的根就是操作符,它的左,右兒子分別指向T2和T1。然后將指向這棵樹的指針壓入棧中。
查找樹ADT——二叉查找樹
二叉樹的一個重要的應用就是查找。假設樹中的每個節點被指定一個關鍵字值。在我們的例子中,雖然任意復雜的關鍵字都是可以的,但是為了簡單起見,假設它們都是整數。我們還將假設,所有字是互異的,后面再處理重復的情況。
使得二叉樹成為二叉查找樹的性質是:對於樹中的每個節點X,它的左子樹中所有關鍵字值都小於X的關鍵字值,而它的右子樹中所有關鍵字值大於X的關鍵字值。
二叉查找樹的平均深度是O(logN)。
二叉查找樹的聲明和MakeEmpty
struct TreeNode typedef struct TreeNode *Position; typedef struct TreeNode *SearchTree; struct TreeNode{ ElementType Element; SearchTree Left; SearchTree Right; }; SearchTree MakeEmpty(SearchTree T){ if(T != NULL){ MakeEmpty(T->Left); MakeEmpty(T->Right); free(T); } return NULL: }
Find
find操作一般是需要返回具有關鍵字節點的指針,如果節點不存在則返回NULL。如果T為NULL,那么我們就返回NULL。否則,如果存儲在T中的關鍵字是X,則返回T。否則,我們遞歸的調用遍歷左子樹或者右子樹,這取決於當前節點和X之間的關系。
下面的代碼是通過遞歸進行實現的,我們發現函數中的兩次遞歸都是尾遞歸,很明顯可以通過goto進行實現。但是在這里進行尾遞歸也是合理的,降低速度換的代碼的簡明性,並且使用得棧空間也是O(logN)。
Position Find(ElementType X, SearchTree T){ if(T == NULL) return NULL; if(X < T->Element){ Find(X, T->Left); }else if(X > T->Element){ Find(X, T->Right); }else{ return T; } }
FindMin 和 FindMax
這些例程分別是返回樹中最小值和最大值的位置。返回這些元素的准確值似乎更合理,但是這將與Find操作不相容。FindMin操作只需要從根節點開始不斷向左進行,終止點就是最小值。FindMax操作則是相反即可。
下面分別使用遞歸和非遞歸編寫實現:
遞歸實現FindMin:
Position FindMin(SearchTree T){ if(T == NULL) return NULL; else if(T->Left == NULL) return T; else return FindMin(T->Left); }
FindMax的非遞歸操作
Position FindMax(SearchTree T){ if(T != NULL) while(T->Right != NULL) T = T->Right;
return T; }
Insert進行操作在例程上面是簡單的,為了將X插入樹中,我們可以像用Find那樣沿着樹進行查找。如果找到X,則什么也不做(或做一些更新)。否則,將X插入到遍歷路徑上面的最后一點。
重復元的插入可以通過在節點記錄中保留一個附加於以指示發生的頻率處理,不過這將會使得樹整體空間增加,但是卻比將重復信息放到樹中要好(它將使樹的深度增加)。當然,如果關鍵字只是一個更大結構的一部分,那么這種方法行不通。此時我們可以把具有相同關鍵字的所有結構保留在一個輔助數據結構中,如表或者另一顆查找樹中。
下面是插入例程代碼:
SearchTree Insert(ElementType X, SearchTree T){ if(T == NULL){ T = malloc(sizeof(struct TreeNode)); T->Element = X; T->Left = T->Right = NULL; }else if(X < T->Element){ T->Left = Insert(X, T->Left); }else if(X > T->Element){ T->Right = Insert(X, T->Right); } return T; }
Delete操作
就像許多數據結構一樣,最困難的操作刪除。一旦發現要刪除節點,我們需要考慮幾種可能的情況。
如果節點是一片樹葉,那么它可以被立即刪除。如果節點有一個兒子,則該節點可以在其父節點調整指針繞過該節點后刪除(為了清楚起見,下面給出示意圖)
復雜的情況是處理具有兩個兒子的節點。一般的刪除策略是用其右子樹的最小數據代替該節點的數據並且遞歸刪除那個節點。因為右子樹的最小節點不可能有左兒子,所以第二次刪除要更容易。下面是刪除的示意圖:
上面所示的程序完成的效率不高,因為它沿着該樹進行了兩趟搜索來查找和刪除右子樹最小的節點。可以寫一個DeleteMin函數來進行改變效率。
如果刪除次數不多,則通常使用的策略是懶惰刪除,但一個元素要被刪除時,它會仍然留在樹中,而是只做一個被刪除的記號。這種做法特別是在有重復關鍵字時很流行,因為此時記錄出現的頻數的域可以減1。如果樹中實際節點數和被刪除節點數相同,那么樹的深度預計只上升一個小的常數。因此,存在一個與懶惰刪除相關的非常小的時間損耗。再有,如果被刪除的關鍵字要重新插入,就可以避免分配空間的消耗。
平均情形分析
直觀上,除MakeEmpty外,我們期望前一節所有操作都花費O(logN)時間,因為我們用常數時間在樹中降低了一層,這樣一來,對樹的操作大致減少一半左右。因此,除了MakeEmpty外,所有操作都是O(d),其中d是包含說訪問的關鍵字的節點深度。
SeachTree Delete(ElementType X, SearchTree T){ Position TmpCell; if(T == NULL) Error(); else if(X < T->Element) T->Left = Delete(X, T->Left); else if(X > T->Element) T->Right = Delete(X, T->Right); else if(T->Left && T->Right){ TmpCell = FindMin(T->Right); T->Element = TmpCell->Element; T->Right = Delete(T->Element, T->Right); }else{ TmpCell = T; if(T->Left == NULL) T = T->Right; else if(T->Right == NULL) T = T->Left; free(TmpCell); } return T; }
下面要證明,假設所有的樹出現的機會均等,則樹的所有節點的平均深度為O(logN)。
一棵樹的所有節點的深度的和稱為內部路徑長。我們現在將要計算二叉查找樹平均內部路徑長,其中的平均是對向二叉查找樹中所有可能的插入序列進行的。
令D(N)是具有N個節點的某棵樹T的內部路徑長,D(1)=0。一棵N節點樹是由一棵i節點左子樹和一棵(N-i-1)節點右子樹以及深度為0的一個根節點組成的,其中0<=i<N,D(i)為根的左子樹的內部路徑長。但是在原樹中,所有這些節點都要加深一度。同樣的結論對於右子樹也是成立的。因此我們得到遞歸關系:
D(N) = D(i) + D(N - i - 1) + N - 1
如果所有的子樹的大小都是等可能出現,這對於二叉查找樹是成立的(因為子樹的大小只依賴於第一個插入樹中的元素的相對的秩),但是對於二叉樹不成立。那么,D(i)和D(N-i-1)的平均值都是:
通過求解這個遞歸關系,得到平均值為D(N)=O(NlogN)。因此任意節點的期望深度為O(logN)。
但是,上來就斷言這個結果並不意味着上一節討論的所有操作的平均運行時間是O(logN)並不完全正確。原因就在於刪除操作,我們並不清楚是否所有的二叉查找樹都是等可能的出現的。
特別的,上面描述的刪除算法有助於左子樹比右子樹深,因為我們總是用右子樹的一個節點來代替刪除的節點。已經證明,我們在交替插入和刪除大量次數之后,樹的期望深度將會變成O(sqrt(N)),樹會變得明顯的不平衡。
在刪除的操作中,我們可以通過隨機選取右子樹的最小元素或左子樹的最大元素來代替被刪除的元素以消除這種不平衡。這樣能夠明顯的消除樹的偏向並使樹保持平衡。
在沒有刪除或是使用懶惰刪除的情況下,可以證明所有的二叉查找樹都是等可能的,所以可以斷言:上述的操作都是O(logN)的時間復雜度。
另外比較新的方法是放棄平衡條件,允許樹有任意的深度,但是每次操作時候使用一個規則進行調整,使得后面的操作效率更高。
AVL樹
AVL樹是帶有平衡條件的二叉查找樹。這個平衡條件必須容易保持,而且能夠保證樹的深度是O(logN)。最簡單的想法是要求左右子樹具有相同的高度。
另一種平衡條件是要求每個節點都必須要有相同高度的左右子樹。這個平衡條件雖然保證樹的深度小,但是太過於嚴格,難以使用。
AVL樹是其每個節點的左子樹和右子樹的高度最多差1的二叉查找樹。(空樹的高度定義為-1)。一顆AVL樹的高度最多是1.44log(N+2)-1.328,但是實際上高度只是比logN稍微多一點點。
在高度為h的AVL樹中,最少節點數S(h)由S(h)=S(h-1)+S(h-2)+1給出。對於h=0,S(h)=1;h=1,S(h)=2。函數S(h)與斐波那契密切相關,由此推出上面的AVL樹的高度的界。
當進行插入時,我們需要更新通向根節點路徑上面的那些節點的所有平衡信息,插入操作的困難在於,插入一個節點可能會破壞AVL樹的特性。發生這種情況,就需要對樹進行恢復以后才算完成插入操作。事實上,可以通過對樹進行旋轉來完成。
插入節點以后,只有從插入點到根節點的路徑上的節點的平衡可能改變,因為只有這些節點的子樹可能發生變化。當我們沿路徑上行到根並且更新節點平衡信息時,我們可以找到一個節點,它的平衡破壞了AVL條件。我們將指出如何在第一個這樣的節點重新平衡這個樹,並證明,這一重新平衡保證整個書滿足AVL特性。
如果把重新平衡的節點叫做a。由於任意節點最多有兩個兒子,因此高度不平衡時,a點的兩棵子樹的高度差2,易知,不平衡出現在下面的四種情況中:
1.對a的左兒子的左子樹進行一次插入
2.對a的左兒子的右子樹進行一次插入
3.對a的右兒子的左子樹進行一次插入
4.對a的右兒子的右子樹進行一次插入
情況1和4是關於a點的鏡像對稱,2和3是關於a點的鏡像對稱。
第一種情況是插入發生在外邊,即左-左或右-右的情況,該情況通過對樹進行一次單旋轉而完成調整。第二種情況是發生在內部的情形,即左-右或者右-左的情況,該情況會稍微復雜一些通過雙旋轉進行調整。
雙旋轉
對於右左插入或者左右插入的情況,是無法通過單旋轉進行解決的,需要進行雙旋轉來解決。
現在讓我們對上面的討論做個總結。除幾種情形外,為將關鍵字X插入一顆AVL樹中,我們遞歸的將X插入到T對應的樹中。如果TLR的高度不變,那么就插入完成。否則,如果在T中出現不平衡,那么我們根據X以及T和TLR中的關鍵字做適當的單旋轉或者雙旋轉,更新這些高度,並解決好與樹的其余部分的連接,從而完成一次插入。
另一種效率問題設計到高度的存儲。真正需要存儲的是子樹的高度,應該保證它很小,我們可以用兩個二進制位來進行存儲。
struct AvlNode; typedef struct AvlNode *Position; typedef struct AvlNode *AvlTree; struct AvlNode{ ElementType Element; AvlTree Left; AvlTree Right; int Height; };
對於AVL樹的刪除多少要比插入復雜。如果刪除操作相對較少,那么懶惰刪除恐怕是最好的策略。
計算節點的高度
static int Height(Position P){ if(P == NULL){ return -1; }else{ return P->Height; } }
向AVL樹中插入節點的函數
AvlTree Insert(ElementType X, AvlTree T){ if(T == NULL){ T = malloc(sizeof(struct AvlNode)); if(T == NULL) return ERROR(); T->Element = X; T->Height = 0; T->Left = T->Right = NULL; }else if(X < T->Element){ T->Left = Insert(X, T->Left); if(Height(T->Left) - Height(T->Right) == 2) if(X < T->Left->Element) T = SingleRotateWithLeft(T); else T = DoubleRotateWithLeft(T); }else if(X > T->Element){ T->Right = Insert(X, T->Right); if(Height(T->Right) - Height(T->Left) == 2) if(X > T->Right->Element) T = SingleRotateWithRight(T); else T = DoubleRotateWithRight(T); } T->Height = Max(Height(T->Left), Height(T->Right)) + 1; return T; }
static Position SingleRotateWithLeft(Position K2){ Position K1; K1 = K2->Left; K2->Left = K1->Right; K1->Right = K2; K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1; K1->Height = Max(Height(K1->Left), K2->Height) + 1; return K1; }
static Position DoubleRitateWithLeft(Position K3){ K3->Left = SingleRotateWithRight(K3->Left); return SingleRotateWithLeft(K3); }
樹的遍歷
對於二叉樹的遍歷而言,由於每個節點工作花費的時間以及總共有N個節點,因此總的運行時間是O(N)。
樹的遍歷可以分為前序遍歷,中序遍歷,后序遍歷和層次遍歷。對於樹的層次遍歷,我們使用隊列進行輔助,而不是遞歸默認的棧。
B-樹
B-樹是一種常用的查找樹,階為M的B樹具有下列的結構特性:
-
樹的根或者是一片樹葉,或者是其兒子數在2和M之間
-
除根外,所有非樹節點的兒子數在[M/2]和M之間
-
所有的樹葉都在相同的深度上
所有的數據都存儲在樹葉上,在每一個內部的節點上皆含有指向該節點個兒子的指針P1,P2,......,Pm和分別代表在子樹P2,P3,......,Pm中發現的最小關鍵字的值K1,K2,......,km-1。當然可能有指針是NULL,而其對應的Ki則是未定義的。對於每一個節點,其子樹P1中的關鍵字都小於子樹P2的關鍵字,如此等等。
樹葉包含所有的實際數據,這些數據或者是關鍵字本身,或者是指向含有關鍵字的記錄的指針。
對於一棵階為M的B樹的性質:
樹的根或者是一片葉子,或者是兒子數在2和M之間
除根外的,非樹葉子節點的兒子數在M/2(向上取整)和M之間
所有的樹葉都在相同的深度上
所有的數據都存儲在葉子上
每個內部節點皆含有指向各個兒子的指針和分別代表非首節點的最小關鍵值的值
對於每個節點其子樹P1中所有的關鍵值都小於子樹P2的關鍵字
對於B的的Insert操作,先按照Find()操作進行,當到達樹葉時,就找到了插入X的正確的位置。當樹葉的單元不夠存儲時,我們需要進行一系列的操作調整。
對於一般的M階B樹,當插入一個關鍵字,唯一的的困難發生在接收該關鍵字的節點已經有M個關鍵字的時候。這個關鍵字使得該節點具有M+1個關鍵字。我們可以把它分成兩個節點,它們分別具有(M+1)/2個和(M+1)/2個關鍵字。由於這使得父節點多出一個兒子,因此需要檢查這個節點是否可被父節點接收,如果父節點已經具有M個兒子,那么父節點就要被分裂成兩個節點。我們重復這個過程,直到找到一個父節點至少M個兒子。如果我們分裂根節點,我們就要創建一盒新的根,這個根有兩個兒子。
B樹的深度最多是[log(M/2)N]。在路徑上的每個節點,我們執行O(logM)時間的工作量以確定選擇哪個分子。
下面是一個AVL的例程:
#include<stdio.h> #include<stdlib.h> #include<string.h> using namespace std; typedef int Height; typedef int Element; typedef struct AVLTree; typedef AVLTree* Tree; struct AVLTree{ Element element; Tree left; Tree right; Height height; AVLTree(Element x):element(x), left(NULL), right(NULL), height(0){}; }; int max(int a, int b){ return a > b ? a : b; } Tree makeEmpty(Tree t){ if(t != NULL){ makeEmpty(t->left); makeEmpty(t->right); delete(t); } return NULL; } Height getHeight(Tree t){ if(t == NULL){ return -1; }else{ return t->height; } } Tree singleRotateLeft(Tree t){ Tree p; p = t->left; t->left = p->right; p->right = t; t->height = max(getHeight(t->left), getHeight(t->right)) + 1; p->height = max(getHeight(p->left), t->height) + 1; return p; } Tree singleRotateRight(Tree t){ Tree p; p = t->right; t->right = p->left; p->left = t; t->height = max(getHeight(t->left), getHeight(t->right)) + 1; p->height = max(t->height, getHeight(p->right)) + 1; return p; } Tree doubelRotateLeft(Tree t){ t->left = singleRotateRight(t->left); return singleRotateLeft(t); } Tree doubelRotateRight(Tree t){ t->right = singleRotateLeft(t->right); return singleRotateRight(t); } Tree insertNode(Tree t, Element x){ if(t == NULL){ t = new AVLTree(x); }else if(x < t->element){ t->left = insertNode(t->left, x); if(getHeight(t->left) - getHeight(t->right) == 2){ if(x < t->left->element){ t = singleRotateLeft(t); }else{ t = doubelRotateLeft(t); } } }else if(x > t->element){ t->right = insertNode(t->right, x); if(getHeight(t->right) - getHeight(t->left) == 2){ if(x < t->right->element){ doubelRotateRight(t); }else{ singleRotateRight(t); } } } t->height = max(getHeight(t->left), getHeight(t->right)) + 1; return t; } void inPrinter(Tree t){ if(t == NULL){ return; }else{ inPrinter(t->left); printf("%d %d\n", t->element, t->height); inPrinter(t->right); } } int main(){ Tree t = NULL; t = makeEmpty(t); t = insertNode(t, 5); t = insertNode(t, 2); t = insertNode(t, 8); t = insertNode(t, 1); t = insertNode(t, 4); t = insertNode(t, 7); t = insertNode(t, 3); inPrinter(t); printf("\n==========================\n"); // t = insertNode(t, 6); printf("\n==========================\n"); // inPrinter(t); // t = insertNode(t, 1); // t = insertNode(t, 1); }