在很多有關數據結構和算法的書籍或文章中,作者往往是介紹完了什么是樹后就直入主題的談什么是二叉樹balabala的。但我今天決定不按這個套路來。我個人覺得,一個東西或者說一種技術存在總該有一定的道理,不是能解決某個問題,就是能改善解決某個問題的效率。如果能夠先了解到存在的問題以及已存在的解決辦法的不足,那么學習新的知識就更容易接受,也更容易理解。
萬幸的是,二叉樹的講解是可以按照上述順序來進行的。那么,今天在我們討論二叉樹之前,我們先來討論一種情形、一種操作:假設現在有一個數組,數組中的數據按照某個關鍵字排好了序,現在我們希望判斷某個數據是否已存在於數組中,怎么樣做才更快?(為了方便起見,我們假設數組就是一個int a[n],數據就是整數)
最簡單直接的辦法就是從a[0]開始一直到a[n-1],將數組中的元素逐一與目標數據進行比較,那么在最壞的情況下(該數據不存在於數組中或者該數據在[n-1]),我們要比較n次才能結束查找操作。
那么,我們有沒有更快速的算法來完成這個操作呢?有的,那就是二分查找。所謂二分查找,就是在有序表中,每次都讓被查找數據與當前表的中間結點進行比較,根據比較結果“舍棄”掉以中間結點划分的某一半子表。
上面的說法可能比較難懂(我都覺得難懂,隨手打的╮(╯_╰)╭),但我們可以舉一個生活中的實例來體會一下什么是二分查找。
假設現在有一個商品的價格是1-100元之間的整數,請你猜出它的價格且猜的次數不能超過10次,請問該怎么猜?最愚蠢的辦法就是用類似於順序比較的辦法,從1開始一個一個的猜,這樣猜的話最壞情況下需要猜100次才能猜出來,只有10%的概率能在10次以內猜中。但是我們大家都知道一個辦法,肯定能夠在10次以內猜出來,那就是:先猜50,如果“大了”則繼續猜25,如果“小了”則繼續猜75,每一次都是猜“中間結點”,然后根據結果“舍棄掉”某一半子表,比如猜50時“大了”我們就“舍棄”了從51到100的那一半子表。通過這樣的二分查找,我們可以保證在至多7次后就找到目標價格。
int binarySearch(int *a,int n,int target) { int left=0; int right=n-1; int middle=(left+right)/2; while(left!=right) { if(a[middle]==target) return middle; if(a[middle]<target) left=middle+1; if(a[middle]>target) right=middle-1; middle=(left+right)/2; } return -1; }
作為題外話,我們再來算算二分查找的時間復雜度是怎樣的。
對於二分查找,我們知道兩點:
1.每次我們都將表的大小減半,也就是除以二
2.最壞情況下我們要一直“除以2”直到表只剩下一個元素
綜合這兩點我們就會發現,二分查找花費的時間關鍵點就是比較了多少次,而比較的次數在最壞情況下就是表的大小n不斷除以2直至n為1的次數。這樣以來我們很快就能得出二分查找的時間復雜度:O(log2N)(也可以忽略底數2記為O(logN))
好了,對於二分查找的介紹就到此為止。接下來我們探討一個新的問題:如果我們希望表的大小能夠動態的變化該怎么辦?不假思索的回答是使用雙向鏈表(因為有時候我們需要向前尋找 ,所以雙向是有必要的)。但其實鏈表(不論是否是雙向)根本不適合使用二分查找。為什么呢?因為在數組中,我們比較中間結點與被查找數據時可以直接使用下標來找到中間結點,如a[50],但是在鏈表中,我們如果要比較第50個結點與被查找數據,我們不得不“經過”前49個結點,也就是說,雖然比較操作依然是一次,但是類似於middle=middle->next這樣的操作將會隨着“中間結點”所在的位置而變化,在最壞情況下(假設要查找的數據在表尾),我們將不得不“走遍”整個鏈表,也就是類似middle=middle->next的操作我們不得不執行n次,最終算法的時間復雜度依然是O(n)。
那么,是否存在一種數據結構可以既支持二分查找,又支持動態變化大小呢?幸運的是,有的,那就是二叉樹。
在學習普通的樹時,我們對每個結點有多少個孩子沒有做出限制,而二叉樹則是對每個結點能有幾個孩子做出了限制的樹。在二叉樹中,一個結點最多有兩個孩子,這也是“二叉”的取名原因。(下圖為一棵二叉樹)
由於每個結點的最大孩子數是固定的,所以我們可以將結點定義改為如下:
typedef struct BinaryTreeNode { int data; int frequency; //當插入數據與當前結點相同時+1,當刪除當前結點時-1 struct BinaryTreeNode *left; //指向左孩子 struct BinaryTreeNode *right; //指向右孩子 }BinaryTreeNode; typedef BinaryTreeNode *BinaryTree;
二叉樹顯然是支持動態變化大小的(當然,它的刪除操作與我們之間模擬的文件系統樹是不一樣的,我們簡單地令frequency--來表示刪除該結點,若結點的frequency<=0,則表示該結點“不存在”),我們要明白的就是為什么它能夠支持二分查找。之前說過,鏈表“不支持”二分查找的原因就是我們訪問某個中間結點時不得不“走一遍”其前面(或后面)的結點,那么二叉樹是如何避免這個過程的呢?很簡單,讓我們一步一步看看。
稍微分析二分查找的話,我們不難發現,二分查找的基本要求是數據有序。如果我們要讓二叉樹支持二分查找,就必須讓二叉樹中的數據也實現“有序”。那么二叉樹中該如何實現數據“有序”呢?很簡單,那就是讓每個結點都滿足左子樹中的所有結點均小於本結點,右子樹中的所有結點均大於本結點。經過這樣的“改造”的二叉樹,就是我們所說的二叉查找樹。對於上述數據量為100的情況,我們暫且假設有這樣一棵二叉查找樹
稍微看看這棵樹就會發現二分查找可以“完美地”應用於其中,當我們拿着給定數據進入樹時,首先訪問到的就是原表中的中間結點,如果給定數據大於該結點,則我們向該結點的右子樹尋找(此時我們又將恰好地訪問到子表的中間結點),反之向該結點的左子樹尋找,且對於每一個結點我們都保持這種做法,最終我們可以找到目標數據,或者因為到達某結點,其不存在孩子且該結點亦非目標數據,則目標數據不存在。
因此,二分查找應用於二叉查找樹時的代碼如下:
bool searchByTree(BinaryTree t,int data) { if (t == NULL) return false; if (t->data == data&&t->frequency > 0) return true; if (data < t->data) return searchByTree(t->left, data); if (data > t->data) return searchByTree(t->right, data); return false; }
而要想滿足二叉查找樹的性質,插入結點的代碼就應該如下:
BinaryTree Insert(BinaryTree t,int data) { if (t == NULL) { BinaryTree temp = (BinaryTree)malloc(sizeof(BinaryTreeNode)); temp->data = data; temp->frequency = 1; temp->left = temp->right = NULL; return temp; } if (data < t->data) t->left = Insert(t->left, data); else if (data > t->data) t->right = Insert(t->right, data); else t->frequency++; return t; }
現在我們有了實現插入的辦法了,該如何實現上面假設的那棵完美的二叉查找樹呢?很不幸的是,如果你要這樣完美的(完美匹配二分查找)二叉查找樹,那你只能先將數據在數組中排好序,然后將其按二分查找的訪問順序將數組元素逐個插入到二叉樹中,而且你之后也不能再插入新結點,因為那樣必將打破其完美的特性。可是如果這樣做的話,我們又為什么還需要二叉查找樹呢?所以實際使用的時候,我們往往是直接將隨機數據插入到二叉樹,這樣做的話最后生成的二叉查找樹有可能是長這樣的(左右嚴重不平衡)
這樣的二叉查找樹已經不能完美匹配二分查找了,但是!二叉查找樹依然很好的同時實現了快速查找和動態變化大小。所以二叉查找樹依然是一種很有意義的數據結構(其實二叉樹還有其他的應用,比如赫夫曼編碼,有興趣的可以去查來看看)。
接下來,我們看看如何實現二叉查找樹中的刪除操作。
之前我們定義結點的時候為結點保留了一個frequency域,當我們要插入的新數據已存在於樹中時,我們將對應結點的frequency加一以表示該數據在樹中的實際個數。所以在刪除結點時我們也可以利用這個frequency,即刪除結點時遞減frequency即可。即使frequency遞減至0,我們也依然保留該結點。這樣的刪除實現我們稱之為“懶惰刪除”,其好處在於實現簡單、快速,壞處則是結點需要額外的空間。
//懶惰刪除 void lazyDelete(BinaryTree t, int data) { if (t == NULL) return; if (t->data == data && t->frequency > 0) t->frequency--; if (data < t->data) return deleteNode(t->left, data); else return deleteNode(t->right, data); }
如果希望實際地刪除結點,就會更麻煩一點。首先我們要明白,被刪除結點可能有三種狀態:無孩子,有一個孩子,有兩個孩子。
對於無孩子的情況,我們直接釋放被刪除結點即可。
對於只有一個孩子的情況,我們令其孩子替代其位置,然后釋放被刪除結點即可。(下圖假設刪除結點4)
對於有兩個孩子的情況,處理則稍微麻煩一點,因為我們還要保持二叉查找樹的基本特征。所以我們可選的操作有兩種:
將被刪除結點的數據修改為左子樹中最大結點的數據,然后刪除左子樹中最大的結點;
將被刪除結點的數據修改為右子樹中最小結點的數據,然后刪除右子樹中最小的結點。
假設我們選擇用右子樹的最小結點替代,下圖為刪除結點2的示意
知道了上述三種情況該如何應對后,我們就可以寫出刪除結點的代碼了:
BinaryTree deleteNode(BinaryTree t, int data) { if (t == NULL) return t; //若被查找數據小於當前結點 if (data < t->data) t->left = deleteNode(t->left, data); //若被查找數據大於當前結點 else if (data > t->data) t->right = deleteNode(t->right, data); //如果t即需要刪除的結點,且有兩個孩子 else if (t->left&&t->right) { t->data=getMinData(t->right); t->right=deleteNode(t->right,t->data); } //否則t只有一個孩子或沒有孩子,我們均可以用以下代碼處理 else { BinaryTree temp = NULL; temp = (t->left) ? t->left : t->right; free(t); return temp; } return t; }
上面的代碼中我們沒有寫出getMinNode()的實現,這個函數的實現並不困難,但我們還是要提一提它,為什么呢?因為我們在getMinNode()的時候,我們其實已經“走到了”右子樹的最小結點處,但是我們沒有將其刪除,而是在后面調用deleteNode(t->right,t->data)時才去將其刪除!也就是說我們在這條路徑上走了兩趟!所以改進的措施就是寫一個特殊的deleteMin()函數,令其返回子樹最小結點數據的同時刪除該結點。
BinaryTree deleteMin(BinaryTree t,int *minData) {
if(t==NULL)
return t; //若當前結點t沒有左孩子,則t必為最小的結點 if (t->left == NULL) { *minData = t->data; BinaryTree temp = t->right; free(t); return temp; } //若當前結點有左孩子,則刪除其左子樹中的最小結點 else t->left = deleteMin(t->left, minData); return t; }
然后將deleteNode()中當t有兩個孩子時的代碼修改為如下:
else if (t->left&&t->right) { int minData; t->right = deleteMin(t->right,&minData); t->data = minData; }
這樣一來,我們就在這條路徑上只走了一遍。
在刪除結點時被刪除結點有兩個孩子的解決辦法中,我們使用了一點兒小技巧(右子樹的最小結點)來保證了二叉查找樹的性質不被改變。那么,我們能通過某種技巧令整棵二叉樹在不斷的Insert和deleteNode時盡量保持左右子樹的平衡(即深度盡可能相似)嗎?答案是能!下一篇博文我們就將介紹什么是平衡二叉樹。其可以很好的應用於經常查找,偶爾插入、刪除的情境下!
最后,給出一個簡單對比無序數組查找和二叉查找樹查找效率的程序,在給定數據量為1000時兩者的效率就可以有較大差別(根本原因是程序中查找時每到一個非目標結點均暫停1毫秒,如果不這么做,在我的電腦上需要數據量達到1000萬才能比較出兩者的效率差異)
https://github.com/nchuXieWei/ForBlog------binarySearchTree
———————————————————————————補充————————————————————————————————
在樹的學習中,我們提到了對樹的兩種遍歷方法:先序遍歷和后序遍歷。但是在二叉樹中,我們還有一種遍歷的方法叫做“中序遍歷”,因為每個結點的孩子數最多就是兩個,所以我們可以選擇先處理左子樹,再處理結點本身,再處理右子樹。而這樣的遍歷就叫中序遍歷(如果不懂的話可以去看我寫的(10))。那么中序遍歷有什么特殊之處呢?
假設我們對一棵二叉查找樹進行中序遍歷,遍歷時的操作就是將當前結點輸出到某個數組的尾巴(遍歷開始前該數組為空),那么一趟中序遍歷下來,那個數組中的元素是不是已經排好了序呢?顯然是的!那么通過二叉樹來實現對數據的排序是否可行呢?這個我們以后將會討論。
temp =