[數據結構]——二叉樹(Binary Tree)、二叉搜索樹(Binary Search Tree)及其衍生算法


二叉樹(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

刪除二叉搜索樹中的節點:LintCodehttps://git.oschina.net/eudiwffe/lintcode/blob/master/C++/remove-node-in-binary-search-tree.cpp


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM