1. 程式人生 > >資料結構——樹(7)——二叉搜尋樹及其操作原理

資料結構——樹(7)——二叉搜尋樹及其操作原理

二叉樹與二叉搜尋樹

在之前的文章中,我們提到過三叉樹,n叉樹,但是我們實際用的最多卻是二叉樹,因為這樣的結構更適合我們程式設計和更適合我們使用遞迴的方式。所以我們可以限制孩子的數量使得生成的樹更容易實施。那麼怎麼定義二叉樹呢?
- 樹中的每個節點至多有兩個孩子。
- 除根節點之外的每個節點均被指定為其父項的左側子項或右側子項。

第二個條件強調了二叉樹中的子節點相對於其父母排序。也就是說當子節點的順序不一樣的時候,他們就不是一棵相同的樹。(跟堆不同)。看下面的例子:
這裡寫圖片描述
儘管他們都是同一個節點B組成,在這兩種情況下,標記為B的節點是標記為A的根節點的子節點,但是B是第一棵樹中A的左邊的孩子,在第二棵樹中卻是A的右邊的孩子,是不同的。這樣定義的幾何關係的使得用二叉樹表示有序的資料集合變得方便。最常見的應用程式使用稱為二叉搜尋樹(binary search tree)

的特殊類二叉樹,它有以下屬性定義:
- 每個節點包含(可能除了其他資料之外)一個特殊值,稱為定義節點順序的關鍵字。
- 節點值是唯一的
- 在樹中的每個節點上,其值必須大於以其左子樹為根的子樹中的所有值,並且小於以其右子樹為根的子樹中的所有值。

簡單的來說就是,對於二叉搜尋樹中所有的節點X,在它左子樹的所有元素都小於X,在它右字數的元素都大於X.看下面的兩幅圖:
這裡寫圖片描述
左邊的是一棵標準的二叉搜尋樹,根節點為6,在6的左子樹中,所有的值都小於6(1 2 3 4),在右子樹中所有的值都大於6(8)。而子樹中也同樣滿足這樣的規律,如節點2.
右邊就不是一棵二叉搜尋樹。問題出在節點4中,7在4的右邊沒錯,但是7 > 6,節點7應該在6的右子樹。
二叉搜尋樹(如果構造得夠好)的平均深度是OTrees(log2(n))。

為什麼要使用二叉搜尋樹?

在我們之前的問題中,我們提到過一個問題叫插入問題。在一串數字之間插入一個數字,那麼在陣列的實現中,我們要為這個數後挪或者前移n個單位,以便為插入的資料提供空間以便插入。假設我們要在一串數子中間插入某個數字,那麼陣列的演算法複雜度就是O(N).為了很好的解決這個問題,我們採用了另一種資料結構來表示這樣的結構。那就是連結串列。我們前面提到過,在連結串列中實現插入刪除的演算法複雜度是O(1)。但是問題在於,在陣列中,找到這個中間元素是很容易的(長度減一嘛),在連結串列中,你必須得遍歷整個連結串列才能找到這個元素。
那麼為什麼連結串列有這個限制呢?我們用下面的例子來說明(假設你有一個包含下面單詞的連結串列):
這裡寫圖片描述


給定一個這樣的連結串列,你可以很容易地找到第一個元素,因為初始指標會給你它的地址。從那裡,你可以按照連結指標找到第二個元素,但是找到序列中間出現的元素並不容易。要做到這一點,你必須遍歷每個鏈指標,一直數到N / 2。這個操作需要線性時間。如果二叉搜尋能夠提高效率,那麼資料結構就能夠快速找到中間元素。
我們可以直接將指標指向我們的中間元素,這樣我們就可以很方便找到我們的中間元素了。是不是覺得很愚蠢這個想法,但是這卻是最有效果的。我們來看看:
這裡寫圖片描述
顯然,這個的話,我們可以說我們以Grumpy為起點,只能遍歷到這個單詞後面的部分,前面的我們都不能遍歷,沒有實際意義。那麼現在的問題是,我們要怎麼樣才能遍歷左邊的單詞呢?其實很簡單,我們只要把這邊的箭頭反轉就好了:
這裡寫圖片描述
這樣我們就可以遍歷所有的單詞了,演算法複雜度比我們的連結串列直接遍歷快了一半。而且中間的資料訪問的複雜度為o(1).此時,我們再想,如果這個時候我們繼續對分開的部分進行同樣的處理,那麼同樣可以快速實現搜尋。這就是我們的遞迴策略。這個時候結構就變成了這樣:
這裡寫圖片描述
關於這種特殊風格的二叉樹最重要的特點是它是有序的。對於樹中的任何特定節點,它所包含的字串必須遵循降序到左邊的子樹中的所有字串,並且在子樹的所有字串之前。在這個例子中,Bashful在Doc之前,Dopey在Doc之後。這樣我們就可以很方便的尋找到我們適合的元素。

二叉搜尋樹的基本操作

為了更好的使用二叉搜尋樹,我們為它們定義一些方法。很幸運,因為樹這樣的特殊結構我們為它們定義的方法都是可以用遞迴去實現的: 我們假設我們有下面一棵二叉搜尋樹:
這裡寫圖片描述
我們希望在二叉搜尋樹中得到的功能主要有:

findMax() //尋找這顆二叉搜尋樹的最大值並返回其值
findMin() //尋找這顆二叉搜尋樹的最小值並返回其值
contains() //判斷這顆二叉樹是否包含某個值.就是上述的找到某個節點
add() //往二叉搜尋樹裡面新增某個值
/*還有個比較難的方法*/
remove() //向樹中移除某個值

討論下列的方法:

findMax()

根據二叉搜尋樹的特點,我們從根部開始,直接搜尋右子樹(因為左子樹的值一定小於根的值),在右子樹中,每個節點的右孩子一定大於左孩子,所以我們的操作是:從根開始,向右搜尋,直到節點不再有右孩子為止,返回該節點的值。

findMin()

原理同上,但是操作為:從根開始,向左搜尋,直到節點不再有左孩子為止,返回該節點的值。

contains()

判斷元素X是不是屬於這棵二叉搜尋樹T,我們可以這樣實現:
1. 如果樹為空,直接返回false
2. 如果這樹只有一個節點,且節點就為X,那麼直接返回true
3. 判斷X與根的關係
- 若X > root,那麼在右子樹中遞迴呼叫contains()
- 若X < root,那麼在左子樹中遞迴呼叫contains()

add()

向樹中新增元素X,跟contains方法相似:
1. 如果樹為空,直接新增
2. 判斷X與根的關係
- 若X > root,那麼在右子樹遞迴呼叫add()
- 若X < root,那麼在左子樹遞迴呼叫add()

3.直到遍歷的節點為空,新增。

這裡我們舉個例子,假如在上圖中加入一個元素5,那麼步驟應該是這樣的,先判斷5是否大於6.否,插入的應該是左子樹,也就是6的左邊。再判斷5是否大於2,是,插入的應該是對應的右子樹,再判斷5是否大於4,是,插入的應該是對應的右子樹,此時4對應的右節點為空,因此我們將5插入這裡。

remove()

刪除節點,為什麼說這個操作比較難呢,因為存在幾種可能性。操作如下:
1. 尋找要刪除的節點
2. 如果要刪除的點是節點,那麼直接執行刪除操作
3. 如果節點有一個孩子,肯定是左孩子,那麼刪除的操作就是直接刪除該節點,並將上面節點的右節點直接指向這個節點的左孩子(是不是就是我們的連結串列的刪除呢?對的!!
4. 如果節點有兩個孩子,用它右子樹中最小的數代替這個節點,然後遞迴的刪除這個空節點。
5. 如果要刪除的數是樹根,那麼要特殊考慮

他們的演算法複雜度(平均)都是O(logN)

下一篇我就基於這篇原理來談談C++程式碼的實現