1. 程式人生 > >史上最簡單清晰的查詢講解(紅黑樹、散列表、B樹)

史上最簡單清晰的查詢講解(紅黑樹、散列表、B樹)

我們會用三種經典的資料型別來實現高效的符號表:二叉查詢數紅黑樹散列表

二分查詢

我們使用有序陣列儲存鍵,經典的二分查詢能夠根據陣列的索引大大減少每次查詢所需的比較次數。

在查詢時,我們先將被查詢的鍵和子陣列的中間鍵比較。如果被查詢的鍵小於中間鍵,我們就在左子陣列中繼續查詢,如果大於我們就在右子陣列中繼續查詢,否則中間鍵就是我們要找的鍵。

一般情況下二分查詢都比順序查詢快的多,它也是眾多實際應用程式的最佳選擇。對於一個靜態表(不允許插入)來說,將其在初始化時就排序是值得的。

當然,二分查詢也不適合很多應用。現代應用需要同時能夠支援高效的查詢和插入兩種操作的符號表實現。也就是說,我們需要在構造龐大的符號表的同時能夠任意插入(也許還有刪除)鍵值對,同時也要能夠完成查詢操作

要支援高效的插入操作,我們似乎需要一種鏈式結構。當單鏈接的連結串列是無法使用二分查詢的,因為二分查詢的高效來自於能夠快速通過索引取得任何子陣列的中間元素。為了將二分查詢的效率和連結串列的靈活性結合起來,我們需要更加複雜的資料結構。

能夠同時擁有兩者的就是二叉查詢樹

二叉查詢樹

一顆二叉查詢樹(BST)是一顆二叉樹,其中每個節點都含有一個可比較的鍵(以及相關聯的值)且每個結點的鍵都大於其左子樹中的任意結點的鍵而小於右子樹的任意結點的鍵

 

一顆二叉查詢樹代表了一組鍵(及其相應的值)的集合,而同一個集合可以用多顆不同的二叉查詢樹表示。

如果我們將一顆二叉查詢樹的所有鍵投影到一條直線上,保證一個結點的左子樹中的鍵出現在它的右邊,右子樹中的鍵出現在它的右邊,那麼我們一定可以得到一條有序的鍵列。


查詢

在二叉查詢樹中查詢一個鍵的遞迴演算法:

如果樹是空的,則查詢未命中。如果被查詢的鍵和根結點的鍵相等,查詢命中。否則我們就在適當的子樹中繼續查詢。如果被查詢的鍵較小就選擇左子樹,較大就選擇右子樹。

在二叉查詢樹中,隨著我們不斷向下查詢,當前結點所表示的子樹的大小也在減小(理想情況下是減半)

插入

查詢程式碼幾乎和二分查詢的一樣簡單,這種簡潔性是二叉查詢樹的重要特性之一。而二叉查詢樹的另一個更重要的特性就是插入的實現難度和查詢差不多

當查詢一個不存在於樹中的結點並結束於一條空連結時,我們需要做的就是將連結指向一個含有被查詢的鍵的新結點。如果被查詢的鍵小於根結點的鍵,我們會繼續在左子樹中插入該鍵,否則在右子樹中插入該鍵。

分析

使用二叉查詢樹的演算法的執行時間取決於樹的形狀,而樹的形狀又取決於鍵被插入的先後順序。

在最好的情況下,一顆含有N個結點的樹是完全平衡的,每條空連結和根結點的距離都為~lgN。在最壞的情況下,搜尋路徑上可能有N個結點。但在一般情況下樹的形狀和最好情況更接近。


我們假設鍵的插入順序是隨機的。對這個模型的分析而言,二叉查詢樹和快速排序幾乎就是“雙胞胎”。樹的根結點就是快速排序中的第一個切分元素(左側的鍵都比它小,右側的鍵都比它大),而這對於所有的子樹同樣適用,這和快速排序中對於子陣列的遞迴排序完全對應。

【在由N個隨機鍵構造的二叉查詢樹中,查詢命中平均所需的比較次數為~2lgN。 N越大這個公式越準確】

平衡查詢樹

在一顆含有N個結點的樹中,我們希望樹高為~lgN,這樣我們就能保證所有查詢都能在~lgN此比較內結束,就和二分查詢一樣。不幸的是,在動態插入中保證樹的完美平衡的代價太高了。我們放鬆對完美平衡的要求,使符號表API中所有操作均能夠在對數時間內完成。

2-3查詢樹

為了保證查詢樹的平衡性,我們需要一些靈活性,因此在這裡我們允許樹中的一個結點儲存多個鍵。

2-結點:含有一個鍵(及值)和兩條連結,左連結指向的2-3樹中的鍵都小於該結點,右連結指向的2-3樹中的鍵都大於該結點。

3-結點:含有兩個鍵(及值)和三條連結,左連結指向的2-3樹中的鍵都小於該結點,中連結指向的2-3樹中的鍵都位於該結點的兩個鍵之間,右連結指向的2-3樹中的鍵都大於該結點。

(2-3指的是2叉-3叉的意思)



一顆完美平衡的2-3查詢樹中的所有空連結到根結點的距離都是相同的。

查詢

要判斷一個鍵是否在樹中,我們先將它和根結點中的鍵比較。如果它和其中的任何一個相等,查詢命中。否則我們就根據比較的結果找到指向相應區間的連結,並在其指向的子樹中遞迴地繼續查詢。如果這是個空連結,查詢未命中。

插入

要在2-3樹中插入一個新結點,我們可以和二叉查詢樹一樣先進行一次未命中的查詢,然後把新結點掛在樹的底部。但這樣的話樹無法保持完美平衡性。我們使用2-3樹的主要原因就在於它能夠在插入之後繼續保持平衡。

如果未命中的查詢結束於一個2-結點,我們只要把這個2-結點替換為一個3-結點,將要插入的鍵儲存在其中即可。如果未命中的查詢結束於一個3-結點,事情就要麻煩一些。

熱身

先考慮最簡單的例子:只有一個3-結點的樹,向其插入一個新鍵。

這棵樹唯一的結點中已經沒有可插入的空間了。我們又不能把新鍵插在其空結點上(破壞了完美平衡)。為了將新鍵插入,我們先臨時將新鍵存入該結點中,使之成為一個4-結點。建立一個4-結點很方便,因為很容易將它轉換為一顆由3個2-結點組成的2-3樹(如圖所示),這棵樹既是一顆含有3個結點的二叉查詢樹,同時也是一顆完美平衡的2-3樹,其中所有空連結到根結點的距離都相等。


向一個父結點為2-結點的3-結點中插入新鍵

假設未命中的查詢結束於一個3-結點,而它的父結點是一個2-結點。在這種情況下我們需要在維持樹的完美平衡的前提下為新鍵騰出空間。

我們先像剛才一樣構造一個臨時的4-結點並將其分解,但此時我們不會為中鍵建立一個新結點,而是將其移動至原來的父結點中。(如圖所示)


這次轉換也並不影響(完美平衡的)2-3樹的主要性質。樹仍然是有序的,因為中鍵被移動到父結點中去了,樹仍然是完美平衡的,插入後所有的空連結到根結點的距離仍然相同。

向一個父結點為3-結點的3-結點中插入新鍵

假設未命中的查詢結束於一個3-結點,而它的父結點是一個3-結點。

我們再次和剛才一樣構造一個臨時的4-結點並分解它,然後將它的中鍵插入它的父結點中。但父結點也是一個3-結點,因此我們再用這個中鍵構造一個新的臨時4-結點,然後在這個結點上進行相同的變換,即分解這個父結點並將它的中鍵插入到它的父結點中去。

我們就這樣一直向上不斷分解臨時的4-結點並將中鍵插入更高的父結點,直至遇到一個2-結點並將它替換為一個不需要繼續分解的3-結點,或者是到達3-結點的根。


總結

先找插入結點,若結點有空(即2-結點),則直接插入。如結點沒空(即3-結點),則插入使其臨時容納這個元素,然後分裂此結點,把中間元素移到其父結點中。對父結點亦如此處理。(中鍵一直往上移,直到找到空位,在此過程中沒有空位就先搞個臨時的,再分裂。)

★2-3樹插入演算法的根本在於這些變換都是區域性的:除了相關的結點和連結之外不必修改或者檢查樹的其他部分。每次變換中,變更的連結數量不會超過一個很小的常數。所有區域性變換都不會影響整棵樹的有序性和平衡性。

{你確定理解了2-3樹的插入過程了嗎? 如果你理解了,那麼你也就基本理解了紅黑樹的插入}

構造

和標準的二叉查詢樹由上向下生長不同,2-3樹的生長是由下向上的


優點

2-3樹在最壞情況下仍有較好的效能。每個操作中處理每個結點的時間都不會超過一個很小的常數,且這兩個操作都只會訪問一條路徑上的結點,所以任何查詢或者插入的成本都肯定不會超過對數級別

完美平衡的2-3樹要平展的多。例如,含有10億個結點的一顆2-3樹的高度僅在19到30之間。我們最多隻需要訪問30個結點就能在10億個鍵中進行任意查詢和插入操作。

缺點

我們需要維護兩種不同型別的結點,查詢和插入操作的實現需要大量的程式碼,而且它們所產生的額外開銷可能會使演算法比標準的二叉查詢樹更慢。

平衡一棵樹的初衷是為了消除最壞情況,但我們希望這種保障所需的程式碼能夠越少越好。

紅黑二叉查詢樹

【前言:本文所討論的紅黑樹之目的在於使讀者能更簡單清晰地瞭解紅黑樹的構造,使讀者能在紙上清晰快速地畫出紅黑樹,而不是為了寫出紅黑樹的實現程式碼。

若是要在程式碼級理解紅黑樹,則勢必需要記住其複雜的插入和旋轉的各種情況,我認為那只有助於增加大家對紅黑樹的恐懼,實際面試和工作中幾乎不會遇到需要自己動手實現紅黑樹的情況(很多語言的標準庫中就有紅黑樹的實現)。  若對於紅黑樹的C程式碼實現有興趣的,可移步至July的部落格。】

理解紅黑樹一句話就夠了紅黑樹就是用紅連結表示3-結點的2-3樹。那麼紅黑樹的插入、構造就可轉化為2-3樹的問題,即:在腦中用2-3樹來操作,得到結果,再把結果中的3-結點轉化為紅連結即可。而2-3樹的插入,前面已有詳細圖文,實際也很簡單:有空則插,沒空硬插,再分裂。  這樣,我們就不用記那麼複雜且讓人頭疼的紅黑樹插入旋轉的各種情況了。只要清楚2-3樹的插入方式即可。  下面圖文詳細演示。)

紅黑樹的本質

紅黑樹是對2-3查詢樹的改進,它能用一種統一的方式完成所有變換。

替換3-結點

★紅黑樹背後的思想是用標準的二叉查詢樹(完全由2-結點構成)和一些額外的資訊(替換3-結點)來表示2-3樹。

我們將樹中的連結分為兩種型別:紅連結將兩個2-結點連線起來構成一個3-結點,黑連結則是2-3樹中的普通連結。確切地說,我們將3-結點表示為由一條左斜的紅色連結相連的兩個2-結點

這種表示法的一個優點是,我們無需修改就可以直接使用標準二叉查詢樹的get()方法。對於任意的2-3樹,只要對結點進行轉換,我們都可以立即派生出一顆對應的二叉查詢樹。我們將用這種方式表示2-3樹的二叉查詢樹稱為紅黑樹。


紅黑樹的另一種定義是滿足下列條件的二叉查詢樹:

⑴紅連結均為左連結。

⑵沒有任何一個結點同時和兩條紅連結相連。

⑶該樹是完美黑色平衡的,即任意空連結到根結點的路徑上的黑連結數量相同。

如果我們將一顆紅黑樹中的紅連結畫平,那麼所有的空連結到根結點的距離都將是相同的。如果我們將由紅連結相連的結點合併,得到的就是一顆2-3樹。

相反,如果將一顆2-3樹中的3-結點畫作由紅色左連結相連的兩個2-結點,那麼不會存在能夠和兩條紅連結相連的結點,且樹必然是完美平衡的。


無論我們用何種方式去定義它們,紅黑樹都既是二叉查詢樹,也是2-3

(2-3樹的深度很小,平衡性好,效率高,但是其有兩種不同的結點,實際程式碼實現比較複雜。而紅黑樹用紅連結表示2-3樹中另類的3-結點,統一了樹中的結點型別,使程式碼實現簡單化,又不破壞其高效性。)

顏色表示

因為每個結點都只會有一條指向自己的連結(從它的父結點指向它),我們將連結的顏色儲存在表示結點的Node資料型別的布林變數color中(若指向它的連結是紅色的,那麼該變數為true,黑色則為false)。

當我們提到一個結點顏色時,我們指的是指向該結點的連結的顏色。

旋轉

在我們實現的某些操作中可能會出現紅色右連結或者兩條連續的紅連結,但在操作完成前這些情況都會被小心地旋轉並修復。

(我們在這裡不討論旋轉的幾種情況,把紅黑樹看做2-3樹,自然可以得到正確的旋轉後結果)

插入

在插入時我們可以使用旋轉操作幫助我們保證2-3樹和紅黑樹之間的一一對應關係,因為旋轉操作可以保持紅黑樹的兩個重要性質:有序性完美平衡性

熱身

向2-結點中插入新鍵

(向紅黑樹中插入操作時,想想2-3樹的插入操作。紅黑樹與2-3樹在本質上是相同的,只是它們對3結點的表示不同。

向一個只含有一個2-結點的2-3樹中插入新鍵後,2-結點變為3-結點。我們再把這個3-結點轉化為紅結點即可)


向一顆雙鍵樹(即一個3-結點)中插入新鍵

(向紅黑樹中插入操作時,想想2-3樹的插入操作。你把紅黑樹當做2-3樹來處理插入,一切都變得簡單了)

(向2-3樹中的一個3-結點插入新鍵,這個3結點臨時成為4-結點,然後分裂成3個2結點)


★一顆紅黑樹的構造全過程


平衡二叉樹(AVL樹)

定義:平衡二叉樹(Balance Binary Tree)又稱AVL樹。它或者是一顆空樹,或者是具有下列性質的二叉樹:它的左子樹和右子樹都是平衡二叉樹,且左子樹和右子樹的深度之差的絕對值不超過1。

若將二叉樹上結點的平衡因子BF(BalanceFactor)定義為該結點的左子樹深度減去它的右子樹深度,則平衡因子的絕對值大於1

其旋轉操作 用2-3樹的分裂來類比想象。

散列表

散列表是普通陣列概念的推廣。由於對普通陣列可以直接定址,使得能在O(1)時間內訪問陣列中的任意位置。在散列表中,不是直接把關鍵字作為陣列的下標,而是根據關鍵字計算出相應的下標。

使用雜湊的查詢演算法分為兩步。第一步是用雜湊函式將被查詢的鍵轉化為陣列的一個索引。

我們需要面對兩個或多個鍵都會雜湊到相同的索引值的情況。因此,第二步就是一個處理碰撞衝突的過程,由兩種經典解決碰撞的方法:拉鍊法和線性探測法。

散列表是演算法在時間和空間上作出權衡的經典例子。

如果沒有記憶體限制,我們可以直接將鍵作為(可能是一個超大的)陣列的索引,那麼所有查詢操作只需要訪問記憶體一次即可完成。但這種情況不會經常出現,因此當鍵很多時需要的記憶體太大。

另一方面,如果沒有時間限制,我們可以使用無序陣列並進行順序查詢,這樣就只需要很少的記憶體。而散列表則使用了適度的空間和時間並在這兩個極端之間找到了一種平衡

●雜湊函式

我們面對的第一個問題就是雜湊函式的計算,這個過程會將鍵轉化為陣列的索引。我們要找的雜湊函式應該易於計算並且能夠均勻分佈所有的鍵。

雜湊函式和鍵的型別有關,對於每種型別的鍵我們都需要一個與之對應的雜湊函式。

正整數

將整數雜湊最常用的方法就是除留餘數法。我們選擇大小為素數M的陣列,對於任意正整數k,計算k除以M的餘數。(如果M不是素數,我們可能無法利用鍵中包含的所有資訊,這可能導致我們無法均勻地雜湊值。)

浮點數

將鍵表示為二進位制數,然後再使用除留餘數法。(讓浮點數的各個位都起作用)(Java就是這麼做的)

字串

除留餘數法也可以處理較長的鍵,例如字串,我們只需將它們當做大整數即可。即相當於將字串當做一個N位的R進位制值,將它除以M並取餘

·····軟快取

如果雜湊值的計算很耗時,那麼我們或許可以將每個鍵的雜湊值快取起來,即在每個鍵中使用一個hash變數來儲存它的hashCode()返回值。

●基於拉鍊法的散列表

一個雜湊函式能夠將鍵轉化為陣列索引。雜湊演算法的第二步是碰撞處理,也就是處理兩個或多個鍵的雜湊值相同的情況。

拉鍊法:將大小為M的陣列中的每個元素指向一條連結串列,連結串列中的每個結點都儲存了雜湊值為該元素的索引的鍵值對。

查詢分兩步:首先根據雜湊值找到對應的連結串列,然後沿著連結串列順序查詢相應的鍵。


拉鍊法在實際情況中很有用,因為每條連結串列確實都大約含有N/M個鍵值對。

基於拉鍊法的散列表的實現簡單。在鍵的順序並不重要的應用中,它可能是最快的(也是使用最廣泛的)符號表實現。

●基於線性探測法的散列表

實現散列表的另一種方式就是用大小為M的陣列儲存N個鍵值對,其中M>N。我們需要依靠陣列中的空位解決碰撞衝突。基於這種策略的所有方法被統稱為開放地址散列表。

開放地址散列表中最簡單的方法叫做線性探測法:當碰撞發生時,我們直接檢查散列表中的下一個位置(將索引值加1),如果不同則繼續查詢,直到找到該鍵或遇到一個空元素。

(開放地址類的散列表的核心思想是:與其將記憶體用作連結串列,不如將它們作為在散列表的空元素。這些空元素可以作為查詢結束的標誌。)

特點:雜湊最主要的目的在於均勻地將鍵散佈開來,因此在計算雜湊後鍵的順序資訊就丟失了,如果你需要快速找到最大或最小的鍵,或是查詢某個範圍內的鍵,散列表都不是合適的選擇。

【應用舉例】

海量處理

給定a、b兩個檔案,各存放50億個url,每個url各佔64位元組,記憶體限制是4G,讓你找出a、b檔案共同的url?

答:

可以估計每個檔案安的大小為5G×64=320G,遠遠大於記憶體限制的4G。所以不可能將其完全載入到記憶體中處理。考慮採取分而治之的方法。

 分而治之/hash對映

遍歷檔案a,對每個url求取,然後根據所取得的值將url分別儲存到1000個小檔案(記為,這裡漏寫個了a1)中。這樣每個小檔案的大約為300M。遍歷檔案b,採取和a相同的方式將url分別儲存到1000小檔案中(記為)。這樣處理後,所有可能相同的url都在對應的小檔案()中,不對應的小檔案不可能有相同的url。然後我們只要求出1000對小檔案中相同的url即可。

 hash_set統計

求每對小檔案中相同的url時,可以把其中一個小檔案的url儲存到hash_set中。然後遍歷另一個小檔案的每個url,看其是否在剛才構建的hash_set中,如果是,那麼就是共同的url,存到檔案裡面就可以了。

(此題來源於v_July_v的部落格)

B樹(多向平衡查詢樹)

B-樹是對2-3樹資料結構的擴充套件。它支援對儲存在磁碟或者網路上的符號表進行外部查詢,這些檔案可能比我們以前考慮的輸入要大的多(以前的輸入能夠儲存在記憶體中)。

(B樹和B+樹是實現資料庫的資料結構,一般程式設計師用不到它。)

和2-3樹一樣,我們限制了每個結點中能夠含有的“鍵-連結”對的上下數量界限:一個M階的B-樹,每個結點最多含有M-1對鍵-連結(假設M足夠小,使得每個M向結點都能夠存放在一個頁中),最少含有M/2對鍵-連結,但也不能少於2對。

(B樹是用於儲存海量資料的,一般其一個結點就佔用磁碟一個塊的大小。)

【注】以下B樹部分參考自July的部落格,尤其是插入及刪除示圖,為了省力直接Copy自July。


B樹中的結點存放的是鍵-值對。圖中紅色方塊即為鍵對應值的指標。

B樹中的每個結點根據實際情況可以包含大量的關鍵字資訊和分支(當然是不能超過磁碟塊的大小,根據磁碟驅動(diskdrives)的不同,一般塊的大小在1k~4k左右);這樣樹的深度降低了,這就意味著查詢一個元素只要很少結點從外存磁碟中讀入記憶體,很快訪問到要查詢的資料。

查詢

假如每個盤塊可以正好存放一個B樹的結點(正好存放2個檔名)。那麼一個BTNODE結點就代表一個盤塊,而子樹指標就是存放另外一個盤塊的地址。

下面,咱們來模擬下查詢檔案29的過程:

1.  根據根結點指標找到檔案目錄的根磁碟塊1,將其中的資訊匯入記憶體。【磁碟IO操作1次】   

2.  此時記憶體中有兩個檔名17、35和三個儲存其他磁碟頁面地址的資料。根據演算法我們發現:17<29<35,因此我們找到指標p2。

3.  根據p2指標,我們定位到磁碟塊3,並將其中的資訊匯入記憶體。【磁碟IO操作 2次】   

4.  此時記憶體中有兩個檔名26,30和三個儲存其他磁碟頁面地址的資料。根據演算法我們發現:26<29<30,因此我們找到指標p2。

5.  根據p2指標,我們定位到磁碟塊8,並將其中的資訊匯入記憶體。【磁碟IO操作 3次】   

6.  此時記憶體中有兩個檔名28,29。根據演算法我們查詢到檔名29,並定位了該檔案記憶體的磁碟地址。分析上面的過程,發現需要3 3次磁碟IO操作和次磁碟IO操作和3次記憶體查詢 次記憶體查詢操作。關於記憶體中的檔名查詢,由於是一個有序表結構,可以利用折半查詢提高效率。至於IO操作是影響整個B樹查詢效率的決定因素。

插入

想想2-3樹的插入。2-3樹結點的最大容量是2個元素,故當插入操作造成超出容量之後,就得分裂。同樣m-階B樹規定的結點的最大容量是m-1個元素,故當插入操作造成超出容量之後也得分裂,其分裂成兩個結點每個結點分m/2個元素。(副作用是在其父結點中要插入一箇中間元素,用於分隔這兩結點。和2-3樹一樣,再向父結點插入一個元素也可能會造成父結點的分裂,逐級向上操作,直到不再造成分裂為止。)

向某結點中插入一個元素使其分裂,可能會造成連鎖反應,使其之上的結點也可能造成分裂。

總結:在B樹中插入關鍵碼key的思路:

對高度為h的m階B樹,新結點一般是插在第h層。通過檢索可以確定關鍵碼應插入的結點位置。然後分兩種情況討論:

1、  若該結點中關鍵碼個數小於m-1,則直接插入即可。

2、  若該結點中關鍵碼個數等於m-1,則將引起結點的分裂。以中間關鍵碼為界將結點一分為二,產生一個新結點,並把中間關鍵碼插入到父結點(h-1層)中

重複上述工作,最壞情況一直分裂到根結點,建立一個新的根結點,整個B樹增加一層。

【例】

1、下面咱們通過一個例項來逐步講解下。插入以下字元字母到一棵空的B 樹中(非根結點關鍵字數小了(小於2個)就合併,大了(超過4個)就分裂):C N G A H E K Q M F W L T Z D P R X Y S,首先,結點空間足夠,4個字母插入相同的結點中,如下圖:


2、當咱們試著插入H時,結點發現空間不夠,以致將其分裂成2個結點,移動中間元素G上移到新的根結點中,在實現過程中,咱們把A和C留在當前結點中,而H和N放置新的其右鄰居結點中。如下圖:


3、當咱們插入E,K,Q時,不需要任何分裂操作


4、插入M需要一次分裂,注意M恰好是中間關鍵字元素,以致向上移到父節點中


5、插入F,W,L,T不需要任何分裂操作


6、插入Z時,最右的葉子結點空間滿了,需要進行分裂操作,中間元素T上移到父節點中,注意通過上移中間元素,樹最終還是保持平衡,分裂結果的結點存在2個關鍵字元素。


7、插入D時,導致最左邊的葉子結點被分裂,D恰好也是中間元素,上移到父節點中,然後字母P,R,X,Y陸續插入不需要任何分裂操作(別忘了,樹中至多5個孩子)。


8、最後,當插入S時,含有N,P,Q,R的結點需要分裂,把中間元素Q上移到父節點中,但是情況來了,父節點中空間已經滿了,所以也要進行分裂,將父節點中的中間元素M上移到新形成的根結點中,注意以前在父節點中的第三個指標在修改後包括D和G節點中。這樣具體插入操作的完成,下面介紹刪除操作,刪除操作相對於插入操作要考慮的情況多點。


刪除(delete)操作

首先查詢B樹中需刪除的元素,如果該元素在B樹中存在,則將該元素在其結點中進行刪除,如果刪除該元素後,首先判斷該元素是否有左右孩子結點,如果有,則上移孩子結點中的某相近元素(“左孩子最右邊的節點”或“右孩子最左邊的節點”)到父節點中,然後是移動之後的情況;如果沒有,直接刪除後,移動之後的情況。

刪除元素,移動相應元素之後,如果某結點中元素數目(即關鍵字數)小於ceil(m/2)-1,則需要看其某相鄰兄弟結點是否豐滿(結點中元素個數大於ceil(m/2)-1)(還記得第一節中關於B樹的第5個特性中的c點麼?: c)除根結點之外的結點(包括葉子結點)的關鍵字的個數n必須滿足: (ceil(m / 2)-1)<= n <=m-1。m表示最多含有m個孩子,n表示關鍵字數。在本小節中舉的一顆B樹的示例中,關鍵字數n滿足:2<=n<=4),如果豐滿,則向父節點借一個元素來滿足條件;如果其相鄰兄弟都剛脫貧,即借了之後其結點數目小於ceil(m/2)-1,則該結點與其相鄰的某一兄弟結點進行“合併”成一個結點,以此來滿足條件。那咱們通過下面例項來詳細瞭解吧。

以上述插入操作構造的一棵5階B樹(樹中最多含有m(m=5)個孩子,因此關鍵字數最小為ceil(m/ 2)-1=2。還是這句話,關鍵字數小了(小於2個)就合併,大了(超過4個)就分裂)為例,依次刪除H,T,R,E。

1、首先刪除元素H,當然首先查詢H,H在一個葉子結點中,且該葉子結點元素數目3大於最小元素數目ceil(m/2)-1=2,則操作很簡單,咱們只需要移動K至原來H的位置,移動L至K的位置(也就是結點中刪除元素後面的元素向前移動)



2、下一步,刪除T,因為T沒有在葉子結點中,而是在中間結點中找到,咱們發現他的繼承者W(字母升序的下個元素),將W上移到T的位置,然後將原包含W的孩子結點中的W進行刪除,這裡恰好刪除W後,該孩子結點中元素個數大於2,無需進行合併操作。


3、下一步刪除R,R在葉子結點中,但是該結點中元素數目為2,刪除導致只有1個元素,已經小於最小元素數目ceil(5/2)-1=2,而由前面我們已經知道:如果其某個相鄰兄弟結點中比較豐滿(元素個數大於ceil(5/2)-1=2),則可以向父結點借一個元素,然後將最豐滿的相鄰兄弟結點中上移最後或最前一個元素到父節點中(有沒有看到紅黑樹中左旋操作的影子?),在這個例項中,右相鄰兄弟結點中比較豐滿(3個元素大於2),所以先向父節點借一個元素W下移到該葉子結點中,代替原來S的位置,S前移;然後X在相鄰右兄弟結點中上移到父結點中,最後在相鄰右兄弟結點中刪除X,後面元素前移。


4、最後一步刪除E, 刪除後會導致很多問題,因為E所在的結點數目剛好達標,剛好滿足最小元素個數(ceil(5/2)-1=2),而相鄰的兄弟結點也是同樣的情況,刪除一個元素都不能滿足條件,所以需要該節點與某相鄰兄弟結點進行合併操作;首先移動父結點中的元素(該元素在兩個需要合併的兩個結點元素之間)下移到其子結點中,然後將這兩個結點進行合併成一個結點。所以在該例項中,咱們首先將父節點中的元素D下移到已經刪除E而只有F的結點中,然後將含有D和F的結點和含有A,C的相鄰兄弟結點進行合併成一個結點。


5、也許你認為這樣刪除操作已經結束了,其實不然,在看看上圖,對於這種特殊情況,你立即會發現父節點只包含一個元素G,沒達標(因為非根節點包括葉子結點的關鍵字數n必須滿足於2=<n<=4,而此處的n=1),這是不能夠接受的。如果這個問題結點的相鄰兄弟比較豐滿,則可以向父結點借一個元素。假設這時右兄弟結點(含有Q,X)有一個以上的元素(Q右邊還有元素),然後咱們將M下移到元素很少的子結點中,將Q上移到M的位置,這時,Q的左子樹將變成M的右子樹,也就是含有N,P結點被依附在M的右指標上。所以在這個例項中,咱們沒有辦法去借一個元素,只能與兄弟結點進行合併成一個結點,而根結點中的唯一元素M下移到子結點,這樣,樹的高度減少一層。


為了進一步詳細討論刪除的情況,再舉另外一個例項

這裡是一棵不同的5序B樹,那咱們試著刪除C


於是將刪除元素C的右子結點中的D元素上移到C的位置,但是出現上移元素後,只有一個元素的結點的情況。

又因為含有E的結點,其相鄰兄弟結點才剛脫貧(最少元素個數為2),不可能向父節點借元素,所以只能進行合併操作,於是這裡將含有A,B的左兄弟結點和含有E的結點進行合併成一個結點。


這樣又出現只含有一個元素F結點的情況,這時,其相鄰的兄弟結點是豐滿的(元素個數為3>最小元素個數2),這樣就可以想父結點借元素了,把父結點中的J下移到該結點中,相應的如果結點中J後有元素則前移,然後相鄰兄弟結點中的第一個元素(或者最後一個元素)上移到父節點中,後面的元素(或者前面的元素)前移(或者後移);注意含有K,L的結點以前依附在M的左邊,現在變為依附在J的右邊。這樣每個結點都滿足B樹結構性質。


從以上操作可看出:除根結點之外的結點(包括葉子結點)的關鍵字的個數n滿足:(ceil(m / 2)-1)<= n <= m-1,即2<=n<=4。這也佐證了咱們之前的觀點。刪除操作完。


(我思:)

(1、       關於B樹中指標的表示。指標就是線索,是為了指示你找到目標。在記憶體中用記憶體的線性地址表示,在磁碟上,用磁碟的柱面和磁軌號表示。

(2、       B樹也是一種檔案組織形式。它與OS檔案系統的區別是,檔案系統是面向磁碟上各種應用的檔案的,所有檔案的索引都被組織在一個系統檔案表中。這樣,一個相關應用的檔案之間就沒有體現有序性,我們對某組相關的檔案進行查詢,效率就會較低。  而B樹是專門對某組相關的檔案進行組織,使其之間相對有序,提高查詢效率。 --尤其是對於需要頻繁查詢訪問檔案的操作。

例如: 對10億個有序數,其分佈在1000個檔案中。普通的查詢(類2分查詢),和構造一個B樹,普通的二分查詢不僅需要多次訪問檔案,且其通過OS的檔案系統通過檔名來訪問檔案,這樣效率低——OS需要在整張系統檔案表中通過檔名查詢檔案。  而B樹,其是多叉樹,樹的深度比二分樹要小很多,需要查詢的檔案比二分查詢需要的少。且其通過自己建立的B樹來索引檔案(每次查詢檔案都通過該B樹得到檔案在磁碟上的位置)。B樹是獨立於OS的檔案系統的,它中的每個檔案都有相應的磁碟位置,而不僅是檔名。

B+樹

B+ tree:是應檔案系統所需而產生的一種B-tree的變形樹。

一棵m階的B+樹和m階的B樹的異同點在於:

1、有n棵子樹的結點中含有n-1 個關鍵字; (與B 樹n棵子樹有n-1個關鍵字 保持一致,)

2、所有的葉子結點中包含了全部關鍵字的資訊,及指向含有這些關鍵字記錄的指標,且葉子結點本身依關鍵字的大小自小而大的順序連結。 

3、所有的非終端結點可以看成是索引部分,結點中僅含有其子樹根結點中最大(或最小)關鍵字。 

【總結:最大的區別在於,B樹是像2-3樹那樣把資料分散到所有的結點中,而B+樹的資料都集中在葉結點,上層結點只是資料的索引,並不包含資料資訊】


【應用舉例】

1、為什麼說B+-tree比B 樹更適合實際應用中作業系統的檔案索引和資料庫索引?

資料庫索引採用B+樹的主要原因是 B樹在提高了磁碟IO效能的同時並沒有解決元素遍歷的效率低下的問題。正是為了解決這個問題,B+樹應運而生。

B+樹只要遍歷葉子節點就可以實現整棵樹的遍歷。而且在資料庫中基於範圍的查詢是非常頻繁的,而B樹需要遍歷整棵樹,效率太低。

2、B+-tree的應用: VSAM(虛擬儲存存取法)檔案

B樹與B+樹

走進搜尋引擎的作者樑斌老師針對B樹、B+樹給出了他的意見(來源於July):

“B+樹還有一個最大的好處,方便掃庫,B樹必須用中序遍歷的方法按序掃庫,而B+樹直接從葉子結點挨個掃一遍就完了,B+樹支援range-query非常方便,而B樹不支援。這是資料庫選用B+樹的最主要原因。

比如要查 5-10之間的,B+樹一把到5這個標記,再一把到10,然後串起來就行了,B樹就非常麻煩。B樹的好處,就是成功查詢特別有利,因為樹的高度總體要比B+樹矮。不成功的情況下,B樹也比B+樹稍稍佔一點點便宜。B樹比如你的例子中查,17的話,一把就得到結果了。

有很多基於頻率的搜尋是選用B樹,越頻繁query的結點越往根上走,前提是需要對query做統計,而且要對key做一些變化。

另外B樹也好B+樹也好,根或者上面幾層因為被反覆query,所以這幾塊基本都在記憶體中,不會出現讀磁碟IO,一般已啟動的時候,就會主動換入記憶體。”

"mysql 底層儲存是用B+樹實現的,因為在記憶體中B+樹是沒有優勢的,但是一到磁碟,B+樹的威力就出來了"。

B+樹是B樹的變形,它把所有的附屬資料都放在葉子結點中,只將關鍵字和子女指標保存於內結點,內結點完全是索引的功能最大化了內結點的分支因子。不過是n個關鍵字對應著n個子女,子女中含有父輩的結點資訊,葉子結點包含所有資訊(內結點包含在葉子結點中,內結點沒有指向“附屬資料”的指標必須索引到葉子結點)。這樣的話還有一個好處就是對於每個結點所需的索引次數都是相等的,保證了穩定性

【B*樹】

       B*樹是B+樹的變體,在B+樹非根和非葉子結點再增加指向兄弟的指標B*樹定義了非葉子結點關鍵字個數至少為(2/3)*M,即塊的最低使用率為2/3(代替B+樹的1/2)