標籤:target 打破 小結 就會 整數 其他 介紹 沒有 最壞情況
在很多有關資料結構和演算法的書籍或文章中,作者往往是介紹完了什麼是樹後就直入主題的談什麼是二叉樹balabala的。但我今天決定不按這個套路來。我個人覺得,一個東西或者說一種技術存在總該有一定的道理,不是能解決某個問題,就是能改善解決某個問題的效率。如果能夠先瞭解到存在的問題以及已存在的解決辦法的不足,那麼學習新的知識就更容易接受,也更容易理解。
萬幸的是,二叉樹的講解是可以按照上述順序來進行的。那麼,今天在我們討論二叉樹之前,我們先來討論一種情形、一種操作:假設現在有一個數組,數組中的資料按照某個關鍵字排好了序,現在我們希望判斷某個資料是否已存在於數組中,怎麼樣做才更快?(為了方便起見,我們假設數組就是一個int a[n],資料就是整數)
最簡單直接的辦法就是從a[0]開始一直到a[n-1],將數組中的元素逐一與目標資料進行比較,那麼在最壞的情況下(該資料不存在於數組中或者該資料為a[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; if(a[middle]>target) right=middle; 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;
二叉樹顯然是支援動態變化大小的(當然,它的刪除操作與我們之間類比的檔案系統樹是不一樣的),我們要明白的就是為什麼它能夠支援二分尋找。之前說過,鏈表“不支援”二分尋找的原因就是我們訪問某個中間結點時不得不“走一遍”其前面(或後面)的結點,那麼二叉樹是如何避免這個過程的呢?很簡單,讓我們一步一步看看。
稍微分析二分尋找的話,我們不難發現,二分尋找的基本要求是資料有序。如果我們要讓二叉樹支援二分尋找,就必須讓二叉樹中的資料也實現“有序”。那麼二叉樹中該如何?資料“有序”呢?很簡單,那就是讓每個結點都滿足左子樹中的所有結點均小於本結點,右子樹中的所有結點均大於本結點。經過這樣的“改造”的二叉樹,就是我們所說的二叉尋找樹。對於上述資料量為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; (t->left) ? temp = t->left : temp = t->right; free(t); return temp; } return t;}
上面的代碼中我們沒有寫出getMinNode()的實現,這個函數的實現並不困難,但我們還是要提一提它,為什麼呢?因為我們在getMinNode()的時候,我們其實已經“走到了”右子樹的最小結點處,但是我們沒有將其刪除,而是在後面調用deleteNode(t->right,t->data)時才去將其刪除!也就是說我們在這條路徑上走了兩趟!所以改進的措施就是寫一個特殊的deleteMin()函數,令其返回子樹最小結點資料的同時刪除該結點。
BinaryTree deleteMin(BinaryTree t,int *minData){ //若當前結點沒有左孩子,則其必為最小的結點 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
深入淺出資料結構C語言版(12)——從二分尋找到二叉樹