二叉樹(Binary Tree)是最簡單的樹形數據結構,然而卻十分精妙。其衍生出各種算法,以致於占據了數據結構的半壁江山。STL中大名頂頂的關聯容器——集合(set)、映射(map)便是使用二叉樹實現。由於篇幅有限,此處僅作一般介紹(如果想要完全了解二叉樹以及其衍生出的各種算法,恐怕要寫8~10篇)。
1)二叉樹(Binary Tree)
顧名思義,就是一個節點分出兩個節點,稱其為左右子節點;每個子節點又可以分出兩個子節點,這樣遞歸分叉,其形狀很像一顆倒着的樹。二叉樹限制了每個節點最多有兩個子節點,沒有子節點的節點稱為葉子。二叉樹引導出很多名詞概念,這里先不做系統介紹,遇到時再結合例子一一說明。如下一個二叉樹:
/* A simple binary tree * A ---------> A is root node * / \ * / \ * B C * / / \ * / / \ * D E F ---> leaves: D, E, F * * (1) ---> Height: 3 * */
其中節點B只有一個子節點D;D, E, F沒有子節點,被稱為葉子。對於節點C來說,其分出兩子節點,所以C的出度為2;同理,C有且只有一個父節點,所以其入度為1。出度、入度的概念來源於圖(Graph,一種更加高級復雜的數據結構),當然,也可以應用於二叉樹(二叉樹或者說樹形數據結構也是一類特殊的圖)。顯然,二叉樹的根節點入度為0,葉子節點出度為0。
如何衡量一顆二叉樹?比如大小、節點稠密等。與樓房一樣,一般會對二叉樹分層,並且通常將根節點視為第一層。接下來B與C同屬第二層,D, E, F同屬第三層。注意,並不是所有的葉子都在同一層。通常將二叉樹節點的最高層數作為其樹的高度,上例中二叉樹高度為3。顯然,一個二叉樹的節點總數必然小於2的樹高冪,轉化成公式表示為:N<2^H,其中N為節點總數,H為二叉樹高度;對於第k層,最多有2^(k-1)個節點。更加細化的分類,如下:
完全二叉樹:除了最高層以外,其余層節點個數都達到最大值,並且最高層節點都優先集中在最左邊。
滿二叉樹:除了最高層有葉子節點,其余層無葉子,並且非葉子節點都有2個子節點。
如下例:
/* Complete Binary Tree (CBT) and Full Binary Tree (FBT) * A A A * / \ / \ / \ * / \ / \ / \ * B C B C B C * / \ / \ / \ / \ * / \ / \ / \ / \ * D E D E F G D E * * (2) (3) (4) * CBT FBT not CBT * */
其中(2)就是一個完全二叉樹;(3)是一個滿二叉樹;而(1)和(4)不屬於這兩者,(雖然(4)是(2)的一種鏡像二叉樹)。易知,滿二叉樹必然是一個完全二叉樹,反之則不然。從節點數量上看,滿二叉樹的第k層有2^(k-1)個節點,所以其總節點數為2^H - 1;完全二叉樹除了最后一層外,第k層節點有2^(k-1)個節點,最后一層最多有2^(H-1)個節點。
其實,關於完全二叉樹的定義有多種,然而不管怎樣定義,其實質是一樣的,關鍵在於怎樣理解。如果完全二叉樹除去最后一層,則成為一個滿二叉樹。所謂的“最后一層節點優先集中在左邊”,用語言很難解釋,但是結合上例的(2)和(4)可以很好理解。為什么要這樣定義呢?這是因為這種完全二叉樹的效率非常高,並且完全二叉樹絕大多數情況使用數組存儲,即無序堆(Heap)!可以參見關於堆的博文http://www.cnblogs.com/eudiwffe/p/6202111.html為了充分利用數組的存儲空間,優先將葉子安排在最左邊,以保證該數組每個存儲單元都被利用(如果是(4)的情況,則該數組會有部分空間浪費)。這就是為什么要要求“最后一層優先集中在最左邊”。
2)二叉樹的構建和遍歷
數據結構和算法,最終要落實在代碼上,首先給出一般C風格的二叉樹節點定義,其中val在同一顆樹中唯一:
// A simple binary tree node define typedef struct __TreeNode { int val; struct __TreeNode *left, *right; }TreeNode;
很簡單,看着很像雙鏈表節點的定義,如果拋開字段名稱,其實質完全跟雙鏈表節點結構一樣。事實上,有很多情況下需要將二叉樹就地轉換成一個雙鏈表,甚至是單鏈表。如何構建一個二叉樹?很抱歉,這個占據數據結構與算法半壁江山的二叉樹,竟然沒有一個標准的構建方法!因為二叉樹使用太過廣泛,針對不同應用有不同的構建方法,如果僅僅將一個節點插入(或刪除)到二叉樹中,這又太過簡單,簡單的與鏈表插入(或刪除)一樣。故本文不提供構建方法。
對於給定的一顆二叉樹,如何遍歷呢?有四種常見方法。
中序遍歷:即左-根-右遍歷,對於給定的二叉樹根,尋找其左子樹;對於其左子樹的根,再去尋找其左子樹;遞歸遍歷,直到尋找最左邊的節點i,其必然為葉子,然后遍歷i的父節點,再遍歷i的兄弟節點。隨着遞歸的逐漸出棧,最終完成遍歷。例如(1)中的遍歷結果為:D->B->A->E->C->F
先序遍歷:即根-左-右遍歷,不再詳述。例如(1)中的遍歷結果:A->B->D->C->E->F
后序遍歷:即左-右-根遍歷,不再詳述。例如(1)中的遍歷結果:D->B->E->F->C->A
層序遍歷:即從第一層開始,逐層遍歷,每層遍歷按照從左到右遍歷。例如(1)中的遍歷結果:A->B->C->D->E->F
很明顯,先序遍歷的第一個節點必然是樹的根節點;后序遍歷的最后一個節點也必然是樹的根節點。層序遍歷更加符合人對二叉樹的樹形結構的遍歷順序。
下面給出一般的實現代碼供參考:
// root is in middle order travel, (1):D->B->A->E->C->F void inorder(TreeNode *root) { if (root == NULL) return; inorder(root->left); printf("%d ",root->val); // visit inorder(root->right); } // previous visit root order travel, (1):A->B->D->C->E->F void preorder(TreeNode *root) { if (root == NULL) return; printf("%d ",root->val); // visit preorder(root->left); preorder(root) } // post vist root order travel, (1):D->B->E->F->C->A void postorder(TreeNode *root) { if (root == NULL) return; postorder(root->left); postorder(root->right); printf("%d ",root->val); // visit }
看着很簡單感覺不太對,毋庸置疑,事實上就是這么簡單。此處僅給出遞歸版本,雖然遞歸間接用到了棧,但是即便使用循環版本實現,其仍然需要輔助空間存儲。為什么在實現堆的代碼中,用的是循環而不是遞歸?這就是因為堆的形象化是一個完全二叉樹,並且用數組存儲,可見完全二叉樹的效率如此之高。對於層序遍歷,就需要使用輔助的存儲空間,一般使用隊列(queue),因為其要求每層的順序要從左到右。下面使用STL中queue進行實現,關於隊列的介紹,請自行補充。
// level order travel, (1):A->B->C->D->E->F void levelorder(TreeNode *root) { if(root==NULL) return; queue<TreeNode*> q; for(q.push(root); q.size(); q.pop()){ TreeNode *r = q.front(); printf("%d ",r->val); // visit if (r->left) q.push(r->left); if (r->right) q.push(r->right); } }
上面是一種層序遍歷,但並沒有對每層進行分割,換言之,並不知道當前遍歷的節點屬於哪一層。如需實現,只需要兩個隊列交替遍歷,每個隊列遍歷完就是一層的結束,感興趣的可以自行寫出。
其中,前面三種遍歷最為常見,先序遍歷是二叉樹的深度優先遍歷(Depth First Search,DFS),使用最廣泛。層序遍歷是二叉樹的廣度優先遍歷(Breadth First Search,BFS)。
3)二叉樹的序列化(serialize)和反序列化(deserialize)
簡單講,序列化就是將結構化數據轉化成可順序傳輸的數據流;反序列化就是將順序數據流還原成原來的數據結構。
前面幾種遍歷方法,雖然都可以將二叉樹轉換成順序的數據流,但還不能稱作序列化,因為沒有辦法還原二叉樹結構。以(1)為例,其常見四種遍歷方法得到的數據流為:
/* A simple binary tree four typical traversals * A * / \ in order : D->B->A->E->C->F * / \ pre order : A->B->D->C->E->F * B C post order : D->B->E->F->C->A * / / \ level order: A->B->C->D->E->F * / / \ * D E F * * (1) * */
單獨使用無法將其還原成二叉樹。但是,仔細觀察發現,先序遍歷的第一個節點A為根節點;后序遍歷的最后一個節點A也是根節點。如果同時知道一個二叉樹的先序和后序遍歷順序,是否可以還原樹呢?很抱歉,雖然兩種遍歷的方法不一樣,但其只能確定根節點的位置,其他節點無法確定。那么,如果使用中序+先序遍歷結果,是否可行呢?讓我們試試。
根據先序遍歷知道第一個節點A為根節點,接下來“B->D->C->E->F”是左右節點的順序,雖然目前還無法判斷到底哪個是左,哪個是右;
前面已知,中序遍歷以根節點為分隔,左邊是左子樹,右邊是右子樹,於是在中序中找到A的位置,以此分隔,左部分“D->B”是左子樹,右部分“E->C->F”是右子樹;
請注意,對於任意一個節點來說,都是某個子樹的根節點,即便是葉子節點,它也是一個空二叉樹的根節點!由此引出,先序遍歷的每個節點都曾充當父節點(某子樹的根節點)。
於是,對於剩下的先序遍歷數據流“B->D->C->E->F”來說,B也是剩下的某子樹的根節點,究竟是哪個子樹呢?顯然是左子樹,因為先序遍歷的順序就是“根-左-右”。因此,在左子樹“D->B”中找到B,其為左子樹的根;於是將“D->B”分成左子樹“D”和右子樹“”(空)。根據遞歸的出棧,接下來處理先序遍歷中的“D->C->E->F”,緊接着是“C->E->F”...最終,完成二叉樹的還原。部分步驟示意圖:
// Using In order and Pre order to deserialize /* * A* A A A * / \ ====> / \ / \ / \ * / \ / \ / \ / \ * D-B E-C-F B* E-C-F B E-C-F B C* * / \ / / / \ * / \ / / / \ * D NULL D* D E F * root root root root * | | | | * IN: D-B-A-E-C-F D-B D E-C-F * PRE:A-B-D-C-E-F B-D-C-E-F D-C-E-F C-E-F * | | | | * root root root root * */
每次根據先序遍歷結果確定當前的根節點(用*標記),然后在中序遍歷結果中尋找該節點,並以此為分割點,分成左右子樹;反復執行,直到先序遍歷結束,二叉樹還原完畢。下面給出C風格的代碼,僅供參考:
// Using In order and Pre order to deserialize TreeNode *deserialize(int pre[], int in[], int n, int begin, int end) { static int id = 0; // current position in PRE order if (begin==0 && end==n) id=0; // reset id TreeNode *r = (TreeNode*)malloc(sizeof(TreeNode)); int pos; // current root position in IN order for (pos=begin; pos<end && in[pos]!=pre[id]; ++pos); if (in[pos]!=pre[id]) exit(-1); // preorder or inorder is error r->val = pre[id++]; r->left = deserialize(pre,in,n,begin,pos); r->right= deserialize(pre,in,n,pos+1,end); return r; }
其中pre[]為先序遍歷結果,in[]為中序遍歷結果,此處假設節點的值(val)為唯一(對於不唯一的,可以增加關鍵字字段)。n為節點總數,也即為數組的長度;start和end表示尋找中序遍歷的區間范圍[start,end)。如果給定的pre[]和in[]絕對正確,那么第9行的錯誤處理將不會執行。對於一棵N節點的二叉樹,直接調用deserialize(pre,in,n,0,n)則可還原該二叉樹。整個逆序列化的過程,實際上是“先序遍歷”的過程,不妨看看10~12行代碼。
同理,使用中序+后序也可還原二叉樹,這里不再詳述。
不妨算法其時間復雜度,對於先序數據流,其使用了靜態的id作為遍歷下標,故為O(n);但是對於中序遍歷數據流,其根據[start,end)區間進行遍歷尋找,為O(nlogn)。感興趣的不妨嘗試改進層序遍歷,使其達到序列化和反序列化的要求(注意分層和空節點)。
4)二叉搜索樹(Binary Search Tree)
之所以稱為二叉搜索樹,是因為這種二叉樹能大幅度提高搜索效率。如果一個二叉樹滿足:對於任意一個節點,其值不小於左子樹的任何節點,且不大於右子樹的任何節點(反之亦可),則為二叉搜索樹。如果按照中序遍歷,其遍歷結果是一個有序序列。因此,二叉搜索樹又稱為二叉排序樹。不同於最大堆(或最小堆),其只要求當前節點與當前節點的左右子節點滿足一定關系。下面以非降序二叉搜索樹為例。
// Asuming each node value is not equal /* A simple binary search tree * 6 6 * / \ / \ * / \ / \ * 3 8 3 8 * / / \ / / \ * / / \ / / \ * 2 7 9 2 4* 9 * * (A) BST (B) Not BST * */
其中(A)為二叉搜索樹,(B)不是。因為根節點6小於右子樹中的節點4。
構建二叉搜索樹的過程,與堆的構建類似,即逐漸向二叉搜索樹種添加一個節點。每次新添加一個節點,直接尋找到對應的插入點,使其滿足二叉搜索樹的性質。下面是一種簡易的構建過程:
// Initialize a bst TreeNode *bst_init(int arr[], int n) { if (n<1) return NULL; TreeNode *r = (TreeNode*)malloc(sizeof(TreeNode)); r->val = arr[0]; // ensure bst_append will not update root address r->left = r->right = NULL; for (; --n; bst_append(r,arr[n])); return r; }
對於給定的數組數據,如果僅有一個元素,則直接構造一個節點,將其返回;否則,逐漸遍歷該數組,將其元素插入到二叉樹中(不要忘記將無子節點的指針置為空),其中bst_append將元素插入的二叉查找樹中。為什么對於單獨一個元素要特殊處理,而不是所有節點都通過bst_append插入呢?顯然,當插入第一個元素時,此時二叉樹根節點為空,直接插入必然修改根節點的地址。當然可以通過返回值獲取插入后二叉樹的根節點指針,但這樣僅僅針對1/n的情況,卻每次(共N次)都重新對根節點賦值,犧牲太多性能。當然也可以將bst_append傳參列表聲明為二級指針,這里為了追求簡潔,故不使用。
當給出插入節點的代碼時,你會發現二叉搜索樹的構建跟堆的構建思路有異曲同工之妙,並且插入方法與先序遍歷十分相似:
// Append a node to bst, return add count int bst_append(TreeNode *r, int val) { // find insertion position for (; r && r->val!=val;){ if (r->val < val && r->right) r=r->right; else if (r->val > val && r->left) r=r->left; else break; } if (r==NULL || r->val==val) return 0; TreeNode *tn = (TreeNode*)malloc(sizeof(TreeNode)); tn->left = tn->right = NULLL; tn->val = val; if (r->val < val) r->right = tn; else r->left = tn; return 1; }
通常情況,認為二叉樹的節點值為唯一,即不存在新插入的值與已有節點值相同的情況,正如一個集合中不存在相同的兩個元素。雖然STL也提供multiset與multimap以便允許重復元素,但其增加了新的字段count用於存儲每個值val所包含的節點個數。易知,對於set而言,其每個節點的count值均為1。注意,對於同一個元素集合,其數組中的順序不同,生成的二叉查找樹也不同。其中,二叉搜索樹的插入時間復雜度為O(logn),構建二叉搜索樹的總時間復雜度為O(nlogn)。尋找插入位置的過程,實際上類似於二分查找。
既然叫二叉搜索樹,那么如何高效的查找一個元素是否在該二叉搜索樹呢?與插入類似,同樣使用先序遍歷的結構:
// Find value in bst, return node address TreeNode *bst_find(TreeNode *r, int val) { for (; r && r->val!=val;){ if (r->val < val) r=r->right; else if (r->val > val) r=r->left; } return r; }
如果找到了,直接返回該節點指針,否則返回空指針。二叉搜索樹對於元素的查找效率與二分查找一樣,都為O(logn),只不過前者使用二叉樹鏈式存儲,而二分查找使用順序的數組存儲,兩者各有優劣。
很多時候,常常需要刪除其中的某些元素,對於二分查找來說,其使用的是有序數組存儲,對於數據的插入和刪除效率較低,均為O(n);而二叉搜索樹卻有着O(logn)的快速,那么如何刪除節點?與堆不同,二叉搜索樹使用鏈式存儲,需要注意內存釋放,避免其父節點、左右子節點意外分離於原二叉搜索樹。因此需要根據待刪除節點所處位置,進行分類處理。
在這之前,首先引入一個概念——前驅節點(Precursor Node)。所謂前驅,即按照某種遍歷方法,節點前的一個節點為該節點的前驅節點。以(1)為例,其中序遍歷為“D->B->A->E->C->F”,那么對於節點A來說,其前驅節點為B;對於節點E來說,A是其前驅節點(下面不作特殊說明,均以中序遍歷順序情況)。與之相反,后繼節點則為按照某種遍歷方法該節點的下一個節點。即,A是B的后繼節點。對於二叉搜索樹來講,如果使用中序遍歷,其遍歷結果是有序的,即:任意一個節點的前驅節點是滿足不大於該節點的最大節點;任意一個節點的后繼節點是滿足不小於該節點的最小節點。以(A)為例,其中序遍歷為“2-3-6-7-8-9”。
對於二叉搜索樹的節點刪除,一般可分為三種情況:待刪除的節點有兩個子節點,待刪除的節點有一個子節點,待刪除的節點無子節點:
/* Erase node from a bst - sketch, i' is special for erase 6 (i) * 6 d=6,(3) f=6 6 d=6,(5) * / \ / \ / \ / \ / \ * / \ / \ / \ / \ / \ * 3 8 p=3 8 d=3 8 3 f=8 f=3 8 * / / \ / / \ / / \ / / \ / \ / \ * / / \ / / \ / / \ / / \ / \ / \ * 2 7 9 2 7 9 2 7 9 2 d=7 9 2 p=5 7 9 * / * BST (i) (ii) (iii) / (i') * erase 6 erase 3 erase 7 4 * */
(i) 待刪除的節點有兩個子節點:以刪除6為例,為了便於說明,這里將待刪除節點稱為d=6,其前驅節點為p=3。按照(i)圖示方法,可以將其前驅節點p的值替換待刪除節點d,並刪除前驅節點。注意,如果前驅節點p仍有子節點(子樹),則其必然是左節點(左子樹),為什么?請自行思考。這里將前驅節點p的父節點稱為f,此時的f正好是d,但不是所有情況都是。對於(i')圖示,前驅節點p=5的父節點為f=3,當刪除d=6時,可以將f的右子節點指向p的左子節點;對於(i),由於f與d相同,所以可以直接將d的左子節點指向p的左子節點。
(ii)待刪除的節點有一個子節點:以刪除3為例,由於只有一個子節點,所以可將d節點的子節點繼承d,此時需要將d的父節點f=6的子節點指向繼承節點。並且需要區分當前刪除節點d是父節點f的左子節點還是右子節點,以及d節點的子節點是左子還是右子。圖示d為f的左子節點,d有左子節點,所以將f的左子節點指向d的左子節點。
(iii)待刪除的節點無子節點:以刪除7為例,很簡單,將其直接刪除,並且將其父節點f的子節點指向空。同樣需要判斷d是f的左子還是右子。
請注意,對於單根二叉樹,即一個二叉搜索樹有且只有一個節點,此時需要刪除該根節點,那么刪除根節點后,二叉樹為空。與bst_append類似,如果為空,需要通過返回值回傳根節點為空,或者通過傳參列表聲明二級節點指針。為了簡化代碼,此處不對其進行處理,由調用刪除節點處自行處理。
下面是一種實現代碼,其中返回值表示刪除的節點個數,對於單根二叉樹返回-1,告訴調用者,並由調用者自行處理:
int bst_erase(TreeNode *r, int val) { TreeNode *f, *p, *d; // f is father node // p is precursor node // d is to be deleted node for (f=NULL,d=r; d && d->val!=val;){ f = d; if (d->val < val) d=d->right; else d=d->left; } if (d==NULL) return 0; // cannot find erase node if (d->left && d->right){ // deletion has two children // find deletion node d's precursor for (f=d,p=d->left; p->right; f=p, p=p->right); d->val = p->val; // replace deletion val by precursor if (f==d) d->left = p->left;// case (i) else f->right = p->left; // case (i') } else if (d->left==NULL && d->right==NULL){ if (d==r) return -1; // deletion is single root, this will // replace root address to NULL, please // deal this at calling procedure. // deletion is leaf if (f->left == d) f->left=NULL; else if (f->right == d) f->right=NULL; free(d); } else { // deletion has single child node or branch p = (d->left ? d->left : d->right); d->val = p->val; d->left = p->left; d->right = p->right; free(p); } return 1; // return erase node count }
到此為止,二叉搜索樹介紹完畢。顯然,二叉搜索樹的刪除要復雜的多。實際上,二叉搜索樹才僅僅是二叉樹的一個衍生樹,后續的平衡二叉搜索樹、AVL樹以及紅黑樹等,才是實際使用最為廣泛的。由於篇幅限制,二叉樹及其衍生算法介紹完畢。
注:本文涉及的源碼:binary tree : https://git.oschina.net/eudiwffe/codingstudy/blob/master/src/binarytree/binarytree.c
binary tree deserialize : https://git.oschina.net/eudiwffe/codingstudy/blob/master/src/binarytree/btdeserialize.c
binary search tree : https://git.oschina.net/eudiwffe/codingstudy/blob/master/src/binarytree/bst.c
刪除二叉搜索樹中的節點:LintCode, https://git.oschina.net/eudiwffe/lintcode/blob/master/C++/remove-node-in-binary-search-tree.cpp