今天來學(xué)習(xí)一種特殊的的二叉樹二叉查找樹。二叉查找樹最大的特點(diǎn)就是支持動態(tài)數(shù)據(jù)集合的快速插入、刪除、查找操作。
我們之前說過,散列表也是支持這些操作的,并且散列表的這些操作比二叉查找樹更高效,時(shí)間復(fù)雜度是 O(1)。既然有了這么高效的散列表,使用二叉樹的地方是不是都可以替換成散列表呢?有沒有哪些地方是散列表做不了,必須要用二叉樹來做的呢?
二叉查找樹
二叉查找樹是二叉樹中最常用的一種類型,也叫二叉搜索樹。顧名思義,二叉查找樹是為了實(shí)現(xiàn)快速查找而生的。不過,它不僅僅支持快速查找一個(gè)數(shù)據(jù),還支持快速插入、刪除一個(gè)數(shù)據(jù)。它是怎么做到這些的呢?
這些都依賴于二叉查找樹的特殊結(jié)構(gòu)。二叉查找樹要求,在樹中的任意一個(gè)節(jié)點(diǎn),其左子樹中的每個(gè)節(jié)點(diǎn)的值,都要小于這個(gè)節(jié)點(diǎn)的值,而右子樹節(jié)點(diǎn)的值都大于這個(gè)節(jié)點(diǎn)的值。
1. 二叉查找樹的查找操作
我們先取根節(jié)點(diǎn),如果它等于我們要查找的數(shù)據(jù),那就返回。如果要查找的數(shù)據(jù)比根節(jié)點(diǎn)的值小,那就在左子樹中遞歸查找;如果要查找的數(shù)據(jù)比根節(jié)點(diǎn)的值大,那就在右子樹中遞歸查找。
2. 二叉查找樹的插入操作
二叉查找樹的插入過程有點(diǎn)類似查找操作。新插入的數(shù)據(jù)一般都是在葉子節(jié)點(diǎn)上,所以我們只需要從根節(jié)點(diǎn)開始,依次比較要插入的數(shù)據(jù)和節(jié)點(diǎn)的大小關(guān)系。
如果要插入的數(shù)據(jù)比節(jié)點(diǎn)的數(shù)據(jù)大,并且節(jié)點(diǎn)的右子樹為空,就將新數(shù)據(jù)直接插到右子節(jié)點(diǎn)的位置;如果不為空,就再遞歸遍歷右子樹,查找插入位置。同理,如果要插入的數(shù)據(jù)比節(jié)點(diǎn)數(shù)值小,并且節(jié)點(diǎn)的左子樹為空,就將新數(shù)據(jù)插入到左子節(jié)點(diǎn)的位置;如果不為空,就再遞歸遍歷左子樹,查找插入位置。
3. 二叉查找樹的刪除操作
針對要刪除節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)的不同,我們需要分三種情況來處理。
第一種情況是,如果要刪除的節(jié)點(diǎn)沒有子節(jié)點(diǎn),我們只需要直接將父節(jié)點(diǎn)中指向要刪除節(jié)點(diǎn)的指針置為 null。
第二種情況是,如果要刪除的節(jié)點(diǎn)只有一個(gè)子節(jié)點(diǎn)(只有左子節(jié)點(diǎn)或者右子節(jié)點(diǎn)),我們只需要更新父節(jié)點(diǎn)中,指向要刪除節(jié)點(diǎn)的指針,讓它指向要刪除節(jié)點(diǎn)的子節(jié)點(diǎn)就可以了。
第三種情況是,如果要刪除的節(jié)點(diǎn)有兩個(gè)子節(jié)點(diǎn),這就比較復(fù)雜了。我們需要找到這個(gè)節(jié)點(diǎn)的右子樹中的最小節(jié)點(diǎn),把它替換到要刪除的節(jié)點(diǎn)上。然后再刪除掉這個(gè)最小節(jié)點(diǎn),因?yàn)樽钚」?jié)點(diǎn)肯定沒有左子節(jié)點(diǎn)(如果有左子結(jié)點(diǎn),那就不是最小節(jié)點(diǎn)了),所以,我們可以應(yīng)用上面兩條規(guī)則來刪除這個(gè)最小節(jié)點(diǎn)。
實(shí)際上,關(guān)于二叉查找樹的刪除操作,還有個(gè)非常簡單、取巧的方法,就是單純將要刪除的節(jié)點(diǎn)標(biāo)記為“已刪除”,但是并不真正從樹中將這個(gè)節(jié)點(diǎn)去掉。這樣原本刪除的節(jié)點(diǎn)還需要存儲在內(nèi)存中,比較浪費(fèi)內(nèi)存空間,但是刪除操作就變得簡單了很多。而且,這種處理方法也并沒有增加插入、查找操作代碼實(shí)現(xiàn)的難度。
4. 二叉查找樹的其他操作
除了插入、刪除、查找操作之外,二叉查找樹中還可以支持快速地查找最大節(jié)點(diǎn)和最小節(jié)點(diǎn)、前驅(qū)節(jié)點(diǎn)和后繼節(jié)點(diǎn)。
二叉查找樹還有一個(gè)重要的特性:中序遍歷二叉查找樹,可以輸出有序的數(shù)據(jù)序列,時(shí)間復(fù)雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
支持重復(fù)數(shù)據(jù)的二叉查找樹
前面講二叉查找樹的時(shí)候,我們默認(rèn)樹中節(jié)點(diǎn)存儲的都是數(shù)字。很多時(shí)候,在實(shí)際的軟件開發(fā)中,我們在二叉查找樹中存儲的是一個(gè)包含很多字段的對象。我們利用對象的某個(gè)字段作為鍵值(key)來構(gòu)建二叉查找樹。我們把對象中的其他字段叫作衛(wèi)星數(shù)據(jù)。
前面我們講的二叉查找樹的操作,針對的都是不存在鍵值相同的情況。那如果存儲的兩個(gè)對象鍵值相同,這種情況該怎么處理呢?這里有兩種解決方法。
第一種方法比較容易。二叉查找樹中每一個(gè)節(jié)點(diǎn)不僅會存儲一個(gè)數(shù)據(jù),因此我們通過鏈表和支持動態(tài)擴(kuò)容的數(shù)組等數(shù)據(jù)結(jié)構(gòu),把值相同的數(shù)據(jù)存儲在同一個(gè)節(jié)點(diǎn)上。
第二種方法比較不好理解,不過更加優(yōu)雅。每個(gè)節(jié)點(diǎn)仍然只存儲一個(gè)數(shù)據(jù)。在查找插入位置的過程中,如果碰到一個(gè)節(jié)點(diǎn)的值,與要插入數(shù)據(jù)的值相同,我們就將這個(gè)要插入的數(shù)據(jù)放到這個(gè)節(jié)點(diǎn)的右子樹,也就是說,把這個(gè)新插入的數(shù)據(jù)當(dāng)作大于這個(gè)節(jié)點(diǎn)的值來處理。
當(dāng)要查找、刪除數(shù)據(jù)時(shí),遇到值相同的節(jié)點(diǎn),并不能停止查找,而是繼續(xù)在右子樹中查找,直到遇到葉子節(jié)點(diǎn)。
二叉查找樹的時(shí)間復(fù)雜度分析
當(dāng)二叉查找樹根節(jié)點(diǎn)的左右子樹極度不平衡時(shí),就退化成了鏈表,所以查找時(shí)間復(fù)雜度就變成了 O(n)。
剛剛分析了一種最糟糕的情況,我們現(xiàn)在來分析一個(gè)最理想的情況,二叉查找樹是一棵完全二叉樹,這個(gè)時(shí)候插入、刪除、查找的時(shí)間復(fù)雜度是多少?
從前面的介紹可以看出,不管是插入、刪除還是查找,時(shí)間復(fù)雜度都跟樹的高度成正比,即 O(height)?,F(xiàn)在問題轉(zhuǎn)化成了求一棵包含 n 個(gè)節(jié)點(diǎn)的完全二叉樹的高度。
樹的高度就等于最大層數(shù)減一,為了方便計(jì)算,我們轉(zhuǎn)換成層來表示。
包含 n 個(gè)節(jié)點(diǎn)的完全二叉樹中,第一層包含 1 個(gè)節(jié)點(diǎn),第二層包含 2 個(gè)節(jié)點(diǎn),第三層包含 4 個(gè)節(jié)點(diǎn),依次類推,下面一層節(jié)點(diǎn)個(gè)數(shù)是上一層的 2 倍,第 K 層包含的節(jié)點(diǎn)個(gè)數(shù)就是 2 ^ (k - 1)。
不過對于完全二叉樹來說,最后一層的節(jié)點(diǎn)個(gè)數(shù)不遵守上面的規(guī)律,它包含的節(jié)點(diǎn)個(gè)數(shù)在 1 個(gè)到 2 ^ (L - 1) 個(gè)之間,L 是最大層數(shù)。
如果 n 是各層節(jié)點(diǎn)數(shù)之和,那么 n 滿足這樣一個(gè)關(guān)系:
n >= 1 + 2 + 4 + 8 + ... + 2 ^ (L - 2) + 1
n <= 1 + 2 + 4 + 8 + ... + 2 ^ (L - 2) + 2 ^ (L - 1)
運(yùn)用等比數(shù)列求和公式可計(jì)算出 L 的范圍是 [ ]。
完全二叉樹的層數(shù)小于等于 ,也就是說,完全二叉樹的高度小于等于
。所以完全二叉樹查找時(shí)間復(fù)雜度為
。
顯然,極度不平衡的二叉查找樹,它的查找性能肯定不能滿足我們的需求。我們需要構(gòu)建一種不管怎么刪除、插入數(shù)據(jù),在任何時(shí)候,都能保持任意節(jié)點(diǎn)左右子樹都比較平衡的二叉查找樹,這就是我們下一節(jié)課要詳細(xì)講的,一種特殊的二叉查找樹,平衡二叉查找樹。平衡二叉查找樹的高度接近 logn,所以插入、刪除、查找操作的時(shí)間復(fù)雜度也比較穩(wěn)定,是 O(logn)。
開篇解答
我們在散列表那節(jié)中講過,散列表的插入、刪除、查找操作的時(shí)間復(fù)雜度可以做到常量級的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時(shí)間復(fù)雜度才是 O(logn),相對散列表,好像并沒有什么優(yōu)勢,那我們?yōu)槭裁催€要用二叉查找樹呢?我認(rèn)為有下面幾個(gè)原因:
第一,散列表中的數(shù)據(jù)是無序存儲的,如果要輸出有序的數(shù)據(jù),需要先進(jìn)行排序。而對于二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時(shí)間復(fù)雜度內(nèi),輸出有序的數(shù)據(jù)序列。
第二,散列表擴(kuò)容耗時(shí)很多,而且當(dāng)遇到散列沖突時(shí),性能不穩(wěn)定,盡管二叉查找樹的性能不穩(wěn)定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩(wěn)定,時(shí)間復(fù)雜度穩(wěn)定在 O(logn)。
第三,籠統(tǒng)地來說,盡管散列表的查找等操作的時(shí)間復(fù)雜度是常量級的,但因?yàn)楣_突的存在,這個(gè)常量不一定比 logn 小,所以實(shí)際的查找速度可能不一定比 O(logn) 快。加上哈希函數(shù)的耗時(shí),也不一定就比平衡二叉查找樹的效率高。
第四,散列表的構(gòu)造比二叉查找樹要復(fù)雜,需要考慮的東西很多。比如散列函數(shù)的設(shè)計(jì)、沖突解決辦法、擴(kuò)容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個(gè)問題,而且這個(gè)問題的解決方案比較成熟、固定。
最后,為了避免過多的散列沖突,散列表裝載因子不能太大,特別是基于開放尋址法解決沖突的散列表,會浪費(fèi)一定的存儲空間。
綜合這幾點(diǎn),平衡二叉查找樹在某些方面還是優(yōu)于散列表的,所以,這兩者的存在并不沖突。我們在實(shí)際的開發(fā)過程中,需要結(jié)合具體的需求來選擇使用哪一個(gè)。
課后思考
今天我講了二叉樹高度的理論分析方法,給出了粗略的數(shù)量級。如何通過編程,求出一棵給定二叉樹的確切高度呢?