查找
查找(Searching)就是根據給定的某個值,在查找表中確定一個其關鍵字等於給定值的數據元素(或記錄)。
在互聯網上查找信息是我們的家常便飯。所有這些需要被查的數據所在的集合,我們給它一個統稱叫查找表。
查找表(Search Table)是由同一類型的數據元素(或記錄)構成的集合。
關鍵字(Key)是數據元素中某個數據項的值,又稱為鍵值,用它可以標識一個數據元素。也可以標識一個記錄的某個數據項(字段) ,我們稱為關鍵碼。
若此關鍵字可以唯一地標識一個記錄,則稱此關鍵字為主關鍵字 (Primary Key)。
注意這也就意味着,對不同的記錄,其主關鍵字均不相同。主關鍵字所在的數據項稱為主關鍵碼。
那么對於那些可以識別多個數據元素(或記錄)的關鍵字,我們稱為次關鍵字(Secondary Key)。次關鍵字也可以理解為是不以唯一標識一個數據元素(或記錄)的關鍵字,它對應的數據項就是次關鍵碼。
查找表按照操作方式來分有兩大種:靜態查找表和動態查找表。
靜態查找表(Static Search Table) :只作查找操作的查找表。它的主要操作有:
( 1 ) 查詢某個"特定的"數據元素是否在查找表中。
( 2 ) 檢索某個"特定的"數據元索和各種屬性。
按照我們大多數人的理解,查找,當然是在已經有的數據中找到我們需要的。靜態查找就是在干這樣的事情,不過,現實中還有存在這樣的應用:查找的目的不僅僅只是查找,還可能邊查找邊作其它操作。
動態查找表(Dynamic Search Table): 在查找過程中同時插入查找表中不存在的數據元素,或者從查找表中刪除已經存在的某個數據元素。顯然動態查找表的操作就是兩個:
( 1 ) 查找時插入數據元素。
( 2 ) 查找時刪除數據元素。
為了提高查找的效率 ,我們需要專門為查找操作設置數據結構,這種面向查找操作的數據結構稱為查找結構。
從邏輯上來說,查找所基於的數據結構是集合,集合中的記錄之間沒有本質關系。可是要想獲得較高的查找性能,我們就不得不改變數據元素之間的關系,在存儲時可以將查找集合組織成表、樹等結構。
例如,對於靜態查找表來說,我們不妨應用線性表結構來組織數據,這樣可以使用順序查找算法,如果再對主關鍵字排序,則可以應用折半查找等技術進行高效的查找。
如果是需要動態查找,則會復雜一些,可以考慮二叉排序樹的查找技術。另外,還可以用散列表結構來解決一些查找問題,這些技術都將在后面的講解中說明。
一、順序表查找
順序查找 (Sequential Search) 又叫線性查找,是最基本的查找技術, 它的查找過程是:從表中第一個(或最后一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,若某個記錄的關鍵字和給定值相等,則查找成功,找到所查的記錄;如果直到最后一個(或第一個)記錄,其關鍵字和給定值比較都不等時,則表中沒有所查的記錄,查找不成功 。
順序查找的算法實現如下。
/** * 順序查找 * * @param a * 數組 * @param key * 待查找關鍵字 * @return 關鍵字下標 */ public static int sequentialSearch(int[] a, int key) { for (int i = 0; i < a.length; i++) { if (a[i] == key) return i; } return -1; }
這段代碼非常簡單,就是在數組a中查看有沒有關鍵字key,當你需要查找復雜表結構的記錄時,只需要把數組a與關鍵字key定義成你需要的表結構和數據類型即可。
順序表查找優化
到這里並非足夠完美,因為每次循環時都需要對i是否越界,即是否小於等於n作判斷。事實上,還可以有更好一點的辦法,設置一個哨兵,可以解決不需要每次讓i與n作比較。看下面的改進后的順序查找算法代碼。
/**
* 有哨兵順序查找
*
* @param a
* 數組(下標為0存放哨兵元素)
* @param key
* 待查詢關鍵字
* @return 關鍵字下標 返回0 則未找到
*/
public static int sequentialSearch2(int[] a, int key) {
int index = a.length - 1;
a[0] = key;// 將下標為0的數組元素設置為哨兵
while (a[index] != key) {
index--;
}
return index;
}
這種在查找方向的盡頭放置"哨兵"免去了在查找過程中每一次比較后都要判斷查找位置是否越界的小技巧,看似與原先差別不大,但在總數據較多時,效率提高很大,是非常好的編碼技巧。當然,"哨兵"也不一定就一定要在數組開始,也可以在末端。
對於這種順序查找算法來說,查找成功最好的情況就是在第一個位置就找到了,算法時間復雜度為O(1),最壞的情況是在最后一位置才找到,需要n次比較,時間復雜度為O(n),當查找不成功時,需要n+1次比較,時間復雜度為O(n)。我們之前推導過,關鍵字在任何一位置的概率是相同的,所以平均查找次數為(n+1)/2 ,所以最終時間復雜度還是O(n)。
很顯然,順序查找技術是有很大缺點的,n很大時,查找效率極為低下,不過優點也是有的,這個算法非常簡單,對靜態查找表的記錄沒有任何要求,在一些小型數據的查找時,是可以適用的。
另外,也正由於查找概率的不同,我們完全可以將容易查找到的記錄放在前面,而不常用的記錄放置在后面,效率就可以有大幅提高。
有序表查找
一個線性表有序時,對於查找總是很有幫助的。
二、折半查找
折半查找(Binary Search)技術,又稱為二分查找。它的前提是線性表中的記錄必須是關鍵碼有序(通常從小到大有序) ,線性表必須采用順序存儲。折半查找的基本思想是:在有序表中,取中間記錄作為比較對象,若給定值與中間記錄的關鍵字相等,則查找成功;若給定值小於中間記錄的關鍵字,則在中間記錄的左半區繼續查找;若給定值大於中間記錄的關鍵字,則在中間記錄的右半區繼續查找。不斷重復上 述過程,直到查找成功,或所有查找區域無記錄,查找失敗為止。
我們來看折半查找的算法是如何工作的。
/**
* 折半查找
*
* @param a
* 數組
* @param key
* 待查找關鍵字
* @return 返回折半下標, -1表示不存在該關鍵字
*/
public static int binarySearch(int[] a, int key) {
int low, mid, high;
low = 0;// 最小下標
high = a.length - 1;// 最大小標
while (low <= high) {
mid = (high + low) / 2;// 折半下標
if (key > a[mid]) {
low = mid + 1; // 關鍵字比 折半值 大,則最小下標 調成 折半下標的下一位
} else if (key < a[mid]) {
high = mid - 1;// 關鍵字比 折半值 小,則最大下標 調成 折半下標的前一位
} else {
return mid; // 當 key == a[mid] 返回 折半下標
}
}
return -1;
}
該算法還是比較容易理解的,同時我們也能感覺到它的效率非常高。但到底高多少?關鍵在於此算法的時間復雜度分析。
首先,我們將數組的查找過程繪制成一棵二叉樹,如果查找的關鍵字不是中間記錄的話,折半查找等於是把靜態有序
查找表分成了兩棵子樹,即查找結果只需要找其中的一半數據記錄即可,等於工作量少了一半,然后繼續折半查找,效率當然是非常高了。
根據二叉樹的性質4,具有n個結點的完全二叉樹的深度為[log2n]+1。在這里盡管折半查找判定二叉樹並不是完全二
叉樹,但同樣相同的推導可以得出,最壞情況是查找到關鍵字或查找失敗的次數為[log2n]+1,最好的情況是1次。
因此最終我們折半算法的時間復雜度為O(logn),它顯然遠遠好於順序查找的O(n)時間復雜度了。
不過由於折主查找的前提條件是需要有序表順序存儲,對於靜態查找表,一次排序后不再變化,這樣的算法已經比較好了。但對於需要頻繁執行插入或刪除操作的數據集來說,維護有序的排序會帶來不小的工作量,那就不建議使用。
三、插值查找
現在我們的新問題是,為什么一定要折半,而不是折四分之一或者折更多呢?
打個比方,在英文詞典里查"apple",你下意識里翻開詞典是翻前面的書頁還是后面的書頁呢?如果再讓你查"zoo",你又怎么查?很顯然,這里你絕對不會是從中間開始查起,而是有一定目的的往前或往后翻。
同樣的,比如要在取值范圍0 - 10000之間100個元素從小到大均勻分布的數組中查找5,我們自然會考慮從數組下標較小的開始查找。
看來,我們的折半查找,還是有改進空間的。折半計算mid的公式,我們略微等式變換后得到:
mid = (low+high)/2 = low + (high-low)/2
也就是mid等於最低下標low加上最高下標high與low的差的一半。算法科學家們考慮的就是將這個 1/2 進行改進,通過類比,改進為下面的計算方案:
mid = low + ((key - a[low])/(a[high] - a[low]))(high - low)
這樣就可以大大提高查找的效率。
插值查找(Interpolation Search)是根據要查找的關鍵字 key 與查找表中最大最小記錄的關鍵字比較后的查找方法,其核心就在於插值的計算公式(key - a[low])/(a[high] - a[low])。應該說,從時間復雜度來看,它也是O(logn),但對於表長較大,而關鍵字分布又比較均勻的查找表來說,插值查找算法的平均性能比折半查找要好得多 。反之, 數組中如果分布類似{0,1,2,2000,2001,.......,999998, 999999}這種極端不均勻的數據,用插值查找未必是很合適的選擇。
/**
* 插值查找
*
* @param a
* 數組
* @param key
* 待查找關鍵字
* @return 返回折半下標, -1表示不存在該關鍵字
*/
public static int interpolationSearch(int[] a, int key) {
int low, mid, high;
low = 0;// 最小下標
high = a.length - 1;// 最大小標
while (low < high) {
mid = low + (high - low) * (key - a[low]) / (a[high] - a[low]);
// mid = (high + low) / 2;// 折半下標
if (key > a[mid]) {
low = mid + 1; // 關鍵字比 折半值 大,則最小下標 調成 折半下標的下一位
} else if (key < a[mid]) {
high = mid - 1;// 關鍵字比 折半值 小,則最大下標 調成 折半下標的前一位
} else {
return mid; // 當 key == a[mid] 返回 折半下標
}
}
return -1;
}
四、斐波那契查找
斐波那契查找(Fibonacci Search)時,它是利用了黃金分割原理來實現的。
下面我們根據代碼來看程序是如何運行的。
/** 斐波那契數列 */
static int[] f = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 };
/**
* 斐波那契查找(黃金分割原理)
*
* @param a
* 待查詢數組
* @param key
* 待查找關鍵字
* @return 返回關鍵字在a數組中的下標,返回-1表示數組中不存在此關鍵字
*/
public static int fibonaciSearch(int[] a, int key) {
int low, mid, high, k;
low = 0;
high = a.length - 1;
// 斐波那契數列下標
k = 0;
// 獲取斐波那契分割值下標
while (high > f[k] - 1)
k++;
// 利用Java工具類Arrays構造長度為f[k]的新數組並指向引用a
a = Arrays.copyOf(a, f[k]);
// 對新數組后面多余的元素賦值最大的元素
for (int i = high + 1; i < f[k]; i++) {
a[i] = a[high];//當key是是最大值時候,防止角標越界異常
}
while (low <= high) {
// 前半部分有f[k-1]個元素,由於下標從0開始
// 減去 1 獲取 分割位置元素的下標
mid = low + f[k - 1] - 1;
if (key < a[mid]) {// 關鍵字小於分割位置元素,則繼續查找前半部分,高位指針移動
high = mid - 1;
// (全部元素) = (前半部分)+(后半部分)
// f[k] = f[k-1] + f[k-2]
// 因為前半部分有f[k-1]個元素, 則繼續拆分f[k-1] = f[k-2] + f[k-3]成立
// 即在f[k-1]個元素的前半部分f[k-2]中繼續查找,所以k = k - 1,
// 則下次循環mid = low + f[k - 1 - 1] - 1;
k = k - 1;
} else if (key > a[mid]) {// 關鍵字大於分割位置元素,則查找后半部分,低位指針移動
low = mid + 1;
// (全部元素) = (前半部分)+(后半部分)
// f[k] = f[k-1] + f[k-2]
// 因為后半部分有f[k-2]個元素, 則繼續拆分f[k-2] = f[k-3] + f[k-4]成立
// 即在f[k-2]個元素的前半部分f[k-3]繼續查找,所以k = k - 2,
// 則下次循環mid = low + f[k - 2 - 1] - 1;
k = k - 2;
} else {
// 當條件成立的時候,則找到元素
if (mid <= high)
return mid;
else
// 出現這種情況是查找到補充的元素
// 而補充的元素與high位置的元素一樣
return high;
}
}
return -1;
}
斐波那契查找算法的核心在於 :
1 ) 當 key=a[mid] 時,查找就成功。
2 ) 當 key<a[mid]時,新范圍是第low個到第mid-l個,此時范圍個數為f[k-1]-1個;
3 ) 當 key>a[mid]時,新范圍是第mid+l個到第high個,此時范圍個數為f[k-2]-1個。
也就是說,如果要查找的記錄在右側,則左側的數據都不用再判斷了,不斷反復進行下去,對處於當中的大部分數據,其工作效率要高一些。所以盡管斐波那契查找的時間復雜也為O(logn),但就平均性能來說,斐波那契查找要優於折半查找。可惜如果是最壞情況,比如這里key=l,那么始終都處於左側長半區在查找,則查找效率要低於折半查找。
還有比較關鍵的一點,折半查找是進行加法與除法運算mid=(low+ high)/2,插值查找進行復雜的四則運算mid = low + ((key - a[low])/(a[high] - a[low]))(high - low),而斐波那契查找只是最簡單加減法運算mid=low+f[k-l]-1,在海量數據的查找過程中,這種細微的差別可能會影響最終的查找效率。
應該說,三種有序表的查找本質上是分隔點的選擇不同,各有優劣,實際開發時可根據數據的特點綜合考慮再做出選擇。
線性索引查找
我們前面講的幾種比較高效的查找方法都是基於有序的基礎之上的,但事實上,很多數據集可能增長非常快,如果要保證記錄全部是按照當中的某個關鍵字有序,其時間代價是非常高昂的,所以這種數據通常都是按先后順序存儲。
那么對於這樣的查找表,我們如何能夠快速查找到需要的數據呢?辦法就是--索引。
數據結構的最終目的是提高數據的處理速度,索引是為了加快查找速度而設計的一種數據結構。索引就是把一個關鍵字與它對應的記錄相關聯的過程,一個索引由若干個索引項構成,每個索引項至少應包含關鍵字和其對應的記錄在存儲器中的位置等信息。索引技術是組織大型數據庫以及磁盤文件的一種重要技術。
索引按照結構可以分為線性索引、樹形索引和多級索引。我們這里就只介紹線性索引技術。所謂線性索引就是將索引項集合組織為線性結構,也稱為索引表。我們重點介紹三種線性索引;稠密索引、分塊索引和倒排索引。
稠密索引
稠密索引是指在線性索引中,將數據集中的每個記錄對應一個索引項,如圖下圖所示。
對於稠密索引這個索引表來說,索引項一定是按照關鍵碼有序的排列。
索引項有序也就意味着,我們要查找關鍵字時,可以用到折半、插值、斐被那契等有序查找算法,大大提高了效率 ,比如上圖中,我要查找關鍵字是18的記錄,如果直接從右側的數據表中查找,那只能順序查找,需要查找6次才可以查到結果。而如果是從左側的索引表中查找,只需兩次折半查找就可以得到18對應的指針,最終查找到結果。
這顯然是稠密索引優點,但是如果數據集非常大,比如上億,那也就意味着索引也得同樣的數據集長度規模,對於內存有限的計算機來說,可能就需要反復去訪問磁盤,查找性能反而大大下降了。
分塊索引
稠密索引因為索引項與數據集的記錄個數相同,所以空間代價很大。為了減少索引項的個數,我們可以對數據集進行分塊,使其分塊有序,然后再對每一塊建立一個索引項,從而減少索引項的個數。
分塊有序,是把數據集的記錄分成了若干塊,並且這些塊需要滿足兩個條件:
• 塊內無序,即每一塊內的記錄不要求有序。當然 ,你如果能夠讓塊內有序對查找來說更理想,不過這就要付出大量時間和空間的代價,因此通常我們不要求塊內有序 。
• 塊間有序,例如,要求第二塊所有記錄的關鍵字均要大於第一塊中所有記錄的關鍵字,第三塊的所有記錄的關鍵字均要大於第二塊的所有記錄關鍵字……因為只有塊間有序,才有可能在查找時帶來放率。
對於分塊有序的數據集,將每塊對應一個索引項,這種索引方法叫做分塊索引。
如下圖所示,我們定義的分塊索引的索引項結構分三個數據項 :
• 最大關鍵碼,它存儲每一塊中的最大關鍵字,這樣的好處就是可以使得在它之后的下一塊中的最小關鍵字也能比這一塊最大的關鍵字要大;
• 存儲了塊中的記錄個數,以便於循環時使用;
• 用於指向塊首數據元素的指針,便於開始對這一塊中記錄進行遍歷。
在分塊索引表中查找,就是分兩步進行:
1. 在分塊索引表中查找要查關鍵字所在的塊。由於分塊索引表是塊間有序的,因此很容易利用折半、插值等算法得到結果。例如,在上圖的數據集中查找62,我們可以很快可以從左上角的索引表中由57<62<96得到62在第三個塊中。
2. 根據塊首指針找到相應的塊,並在塊中順序查找關鍵碼。因為塊中可以是無序的,因此只能順序查找。
我們再來分析一下分塊索引的平均查找長度。設 n 個記錄的數據集被平均分成 m 塊,每個塊中有 t 條記錄,顯然 n=m×t,或者說 m=n/t。再假設 Lb 為查找索引表的平均查找長度,因最好與最差的等概率原則,所以Lb平均長度為(m+1)/2。Lw為塊中查找記錄的平均查找長度,同理可知它的平均查找長度為(t+1)/2。
這樣分塊索引查找的平均查找長度為:
ASLw = Lb + Lw = (m+1)/2 + (t+1)/2 = (m+t)/2 + 1 = (n/t + t)/2 + 1
注意上面這個式子的推導是為了讓整個分塊索引查找長度依賴 n 和 t 兩個變量。從這里了我們也就得到,平均長度不僅僅取決於數據集的總記錄數 n ,還和每一個塊的記錄個數 t 相關。最佳的情況就是分的塊數m與塊中的記錄數 t相同,此時意味着n = m × t = t²,即ASLw = (n/t + t)/2 + 1 = √n + 1
可見,分塊索引的效率比順序查找的O(n)是高了不少,不過顯然它與折半查找的O(logn)相比還有不小的差距。因此在確定所在塊的過程中,由於塊間有序,所以可以應用折半、插值等手段來提高效率。
總的來說,分塊索引在兼顧了對細分塊內不需要有序的情況下,大大增加了整體查找的速度,所以普遍被用於數據庫表查找等技術的應用當中。
倒排索引
搜索引擎通常檢索的場景是:給定幾個關鍵詞,找出包含關鍵詞的記錄。
我們來看樣例,現在有兩篇極短的英文"文章"--其實只能算是句子,我們暫認為它是文章,編號分別是1和2。
1.Books and friends should be few but good.(讀書如交友,應求少而精。)
2.A good book is a good friend.(好書如摯友。)
假設我們忽略掉如"books" 、"friends" 中的復數"s"以及如"A"這樣的大小寫差異。我們可以整理出這樣一張單詞表,如下表所示,並將單詞做了排序,也就是表格顯示了每個不同的單詞分別出現在哪篇文章中,比如"good"在兩篇文章中都有出現,而"is"只是在文章2中才有。
有了這樣一張單詞表,我們要搜索文章,就非常方便了。如果你在搜索框中填寫"book"關鍵字。系統就先在這張單詞表中有序查找"book",找到后將它對應的文章編號1和2的文章地址(通常在搜索引擎中就是網頁的標題和鏈接)返回,並告訴你,查找到兩條記錄,用時0.0001秒。由於單詞表是有序的,查找效率很高,返回的又只是文章的編號,所以整體速度都非常快。
如果沒有這張單詞表,為了能證實所有的文章中有還是沒有關鍵字"book",則需要對每一篇文章每一個單詞順序查找。在文章數是海量的情況下,這樣的做法只存在理論上可行性,現實中是沒有人願意使用的。
在這里這張單詞表就是索引表,索引項的通用結構是:
• 次關鍵碼,例如上面的"英文單詞";
• 記錄號表,例如上面的"文章編號"。
其中記錄號表存儲具有相同次關鍵字的所有記錄的記錄號(可以是指向記錄的指針或者是該記錄的主關鍵字)。這樣的索引方法就是倒排索引(inverted index)。倒排索引源於實際應用中需要根據屬性(或字段、次關鍵碼)的值來查找記錄。這種索引表中的每一項都包括一個屬性值和具有該屬性值的各記錄的地址。由於不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,因而稱為倒排索引。
二叉排序樹
假設查找的數據集是普通的順序存儲,那么插入操作就是將記錄放在表的末端,給表記錄數加一即可,刪除操作可以是刪除后,后面的記錄向前移,也可以是要刪除的元素與最后一個元素互換,表記錄數減一,反正整個數據集也沒有什么順序,這樣的效率也不錯。 應該說,插入和刪除對於順序存儲結構來說,效率是可以接受的,但這樣的表由於無序造成查找的效率很低。
如果查找的數據集是有序線性表,並且是順序存儲的,查找可以用折半、插值、斐波那契等查找算法來實現,可惜,因為有序,在插入和刪除操作上,就需要耗費大量的時間 。
有沒有一種即可以使得插入和刪除效率不錯,又可以比較高效率地實現查找的算法呢?
我們把這種需要在查找時插入或刪除的查找表稱為動態查找表。現在就來看着什么樣的結構可以實現動態查找表的高效率。
如果在復雜的問題面前,我們束手無策的話,不妨先從最最簡單的情況入手。現在我們的目標是插入和查找同樣高效 。假設我們的數據集開始只有一個數{62}, 然后現在需要將88插入數據集,於是數據集成了{62,88},還保持着從小到大有序。再查找有沒有58,沒有則插入,可此時要想在線性表的順序存儲中有序,就得移動 62 和
88 的位置,如下左圖,可不可以不移動呢?那就需要使用二叉樹結構。當我們用二叉樹的方式時,首先我們將第一個數62定為根結點,88因為比62大,因此讓它做62的右子樹,58因比62小,所以成為它的左子樹。此時58的插入並沒有影響到62與88的關系,如下右圖所示。
也就是說, 若我們現在需要對集合{62,88,58,47,35,73,51,99,37,93}做查找,在我們打算創建此集合時就考慮用二叉樹結構,而且是排好序的二叉樹來創建。如下圖所示,62、88、58創建好后,下一個數 47 因比58小,是它的左子樹(見③),35是47的左子樹(見④),73比62大,但卻比88小,是88的左子樹(見⑤),51比62小、比58小、比47大,是 47的右子樹(見⑥),99比62、88都大,是它的右子樹(見⑦),37比62、58、47都小,但卻比35大,是35的右子樹(見③) ,93則因比62、88大是99的左子樹(見⑨)。
這樣我們就得到了一棵二叉樹,並且當我們對它進行中序遍歷時,就可以得到一個有序的序列{35,37,47,51,58,62,73,88,93,99},所以我們通常稱它為二叉排序樹。
二叉排序樹(Binary Sort Tree),又稱為二叉查找樹。它或者是一棵空樹,或者是具有下列性質的二叉樹。
• 若它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值;
• 若它的右子樹不空 ,則右子樹上所有結點的值均大於它的根結點的值;
• 它的左、右子樹也分別為二叉排序樹。
從二叉排序樹的定義也可以知道,它前提是二叉樹,然后它采用了遞歸的定義方法,再者,它的結點間滿足一定的次序關系,左子樹結點一定比其雙親結點小,右子樹結點一定比其雙親結點大。
構造一棵二叉排序樹的目的,其實並不是為了排序,而是為了提高查找和插入刪除關鍵字的速度。不管怎么說,在一個有序數據集上的查找,速度總是要快於無序的數據集的,而二叉排序樹這種非線性的結構,也有利於插入和刪除的實現。
二叉排序樹查找操作
首先我們提供一個二叉樹的結構。
/**
* 二叉樹 數據結構
*/
class BinTree {
int data;
BinTree lchild;
BinTree rchild;
}
然后我們來看看二叉排序樹的查找是如何實現的。
public class SearchBST {
public static void main(String[] args) {
// TODO Auto-generated method stub
// 主要是表達查詢,所以手動構造一棵二叉排序樹
BinTree bt1 = new BinTree();
bt1.data = 62;
BinTree bt2 = new BinTree();
bt1.lchild = bt2;
bt2.data = 58;
BinTree bt3 = new BinTree();
bt2.lchild = bt3;
bt3.data = 47;
BinTree bt4 = new BinTree();
bt3.lchild = bt4;
bt4.data = 35;
BinTree bt5 = new BinTree();
bt4.rchild = bt5;
bt5.data = 37;
BinTree bt6 = new BinTree();
bt3.rchild = bt6;
bt6.data = 51;
BinTree bt7 = new BinTree();
bt1.rchild = bt7;
bt7.data = 88;
BinTree bt8 = new BinTree();
bt7.lchild = bt8;
bt8.data = 73;
BinTree bt9 = new BinTree();
bt7.rchild = bt9;
bt9.data = 99;
BinTree bt10 = new BinTree();
bt9.lchild = bt10;
bt10.data = 93;
boolean search = searchBST(bt1, 93, null);
System.out.println(search == true ? "查找成功:" + parentNode.data : "查找失敗!");
}
/** 全局變量 存放查找到的關鍵字所在的父節點 */
static BinTree parentNode = new BinTree();
/**
* 二叉排序樹
*
* @param bt
* 待查詢二叉排序樹
* @param key
* 查找關鍵字
* @param parent
* 指向bt的雙親,其初始調用值為null
* @return 查找關鍵字key成功 返回true,並把樹結點賦值給全局變量result,查找失敗,返回false
*/
public static boolean searchBST(BinTree bt, int key, BinTree parent) {
if (null == bt || 0 == bt.data) {// 樹節點不存在,返回
parentNode = parent;
return false;
} else if (key == bt.data) {// 查找成功
parentNode = bt;
return true;
} else if (key < bt.data) {// 關鍵字小於根節點則查找左子樹
return searchBST(bt.lchild, key, bt);
} else// 關鍵字大於根節點則查找右子樹
return searchBST(bt.rchild, key, bt);
}
/**
* 二叉樹 數據結構
*/
private static class BinTree {
int data;
BinTree lchild;
BinTree rchild;
}
}
二叉排序樹插入操作
有了二叉排序樹的查找函數,那么所謂的二叉排序樹的插入,其實也就是將關鍵字放到樹中的合適位置而已,來看代碼。
public class SearchBST {
public static void main(String[] args) {
// TODO Auto-generated method stub
// 主要是表達查詢,所以手動構造一棵二叉排序樹
BinTree bt1 = new BinTree();
bt1.data = 62;
BinTree bt2 = new BinTree();
bt1.lchild = bt2;
bt2.data = 58;
BinTree bt3 = new BinTree();
bt2.lchild = bt3;
bt3.data = 47;
BinTree bt4 = new BinTree();
bt3.lchild = bt4;
bt4.data = 35;
BinTree bt5 = new BinTree();
bt4.rchild = bt5;
bt5.data = 37;
BinTree bt6 = new BinTree();
bt3.rchild = bt6;
bt6.data = 51;
BinTree bt7 = new BinTree();
bt1.rchild = bt7;
bt7.data = 88;
BinTree bt8 = new BinTree();
bt7.lchild = bt8;
bt8.data = 73;
BinTree bt9 = new BinTree();
bt7.rchild = bt9;
bt9.data = 99;
BinTree bt10 = new BinTree();
bt9.lchild = bt10;
bt10.data = 93;
insertBST(bt1,12);
}
/** 全局變量 存放查找到的關鍵字所在的父節點 */
static BinTree parentNode = new BinTree();
/**
* 二叉排序樹
*
* @param bt
* 待查詢二叉排序樹
* @param key
* 查找關鍵字
* @param parent
* 指向bt的雙親,其初始調用值為null
* @return 查找關鍵字key成功 返回true,並把樹結點賦值給全局變量result,查找失敗,返回false
*/
public static boolean searchBST(BinTree bt, int key, BinTree parent) {
if (null == bt || 0 == bt.data) {// 樹節點不存在,返回
parentNode = parent;
return false;
} else if (key == bt.data) {// 查找成功
parentNode = bt;
return true;
} else if (key < bt.data) {// 關鍵字小於根節點則查找左子樹
return searchBST(bt.lchild, key, bt);
} else// 關鍵字大於根節點則查找右子樹
return searchBST(bt.rchild, key, bt);
}
/**
* 在二叉排序樹中插入關鍵字key(如果不存在)
*
* @param bt
* 二叉排序樹
* @param key
* sdddd
* @return 插入成功返回true 錯誤返回false
*/
public static boolean insertBST(BinTree bt, int key) {
BinTree s;
if (!searchBST(bt, key, null)) {
s = new BinTree();
s.data = key;
s.lchild = s.rchild = null;
if (null == parentNode)// 不存在,則表明是父節點,將s指向bt成為新的根節點
bt = s;
else if (key < parentNode.data)
parentNode.lchild = s;// 當key小於子根結點,則插入為左孩子
else
parentNode.rchild = s;// 當key大於子根結點,則插入為右孩子
preOrderTraverse(bt);
return true;
} else
System.out.println("該節點已存在!");
return false;
}
/** 中序遍歷打印線索二叉樹 */
static void preOrderTraverse(BinTree t) {
if (null == t || 0 == t.data)
return;
if (null != t.lchild)
preOrderTraverse(t.lchild);/* 接着中序遍歷左子樹 */
if (0 != t.data)
System.out.print("[" + t.data + "]");/* 顯示當前結點數據 */
if (null != t.rchild)
preOrderTraverse(t.rchild);/* 最后遍歷右子樹 */
}
/**
* 二叉樹 數據結構
*/
private static class BinTree {
int data;
BinTree lchild;
BinTree rchild;
}
}
有了二叉排序樹的插入代碼,我們要實現二叉排序樹的構建就非常容易了。下面的代碼就可以創建一棵如下圖所示的樹。
public class SearchBST {
public static void main(String[] args) {
// TODO Auto-generated method stub
int[] a = { 62, 88, 58, 47, 35, 73, 51, 99, 37, 93 };
for (int i = 0; i < a.length; i++) {
generateBST(a[i]);
System.out.println();
}
}
static BinTree newTree = new BinTree();
/** 全局變量 存放查找到的關鍵字所在的父節點 */
static BinTree parentNode = new BinTree();
/**
* 二叉排序樹
*
* @param bt
* 待查詢二叉排序樹
* @param key
* 查找關鍵字
* @param parent
* 指向bt的雙親,其初始調用值為null
* @return 查找關鍵字key成功 返回true,並把樹結點賦值給全局變量result,查找失敗,返回false
*/
public static boolean searchBST(BinTree bt, int key, BinTree parent) {
if (null == bt || 0 == bt.data) {// 樹節點不存在,返回
parentNode = parent;
return false;
} else if (key == bt.data) {// 查找成功
parentNode = bt;
return true;
} else if (key < bt.data) {// 關鍵字小於根節點則查找左子樹
return searchBST(bt.lchild, key, bt);
} else// 關鍵字大於根節點則查找右子樹
return searchBST(bt.rchild, key, bt);
}
/** 生成二叉排序樹 */
public static boolean generateBST(int key) {
if (!searchBST(newTree, key, null)) {
BinTree s = new BinTree();
s.data = key;
s.lchild = s.rchild = null;
if (null == parentNode)// 不存在,則表明是父節點,將s指向bt成為新的根節點
newTree = s;
else if (key < parentNode.data)
parentNode.lchild = s;// 當key小於子根結點,則插入為左孩子
else
parentNode.rchild = s;// 當key大於子根結點,則插入為右孩子
preOrderTraverse(newTree);
return true;
} else
System.out.println("該節點已存在!");
return false;
}
/** 中序遍歷打印線索二叉樹 */
static void preOrderTraverse(BinTree t) {
if (null == t || 0 == t.data)
return;
if (null != t.lchild)
preOrderTraverse(t.lchild);/* 接着中序遍歷左子樹 */
if (0 != t.data)
System.out.print("[" + t.data + "]");/* 顯示當前結點數據 */
if (null != t.rchild)
preOrderTraverse(t.rchild);/* 最后遍歷右子樹 */
}
/**
* 二叉樹 數據結構
*/
private static class BinTree {
int data;
BinTree lchild;
BinTree rchild;
}
}
二叉排序樹刪除操作
俗話說"請神容易送神難",我們已經介紹了二叉排序樹的查找與插入算法,但是對於二叉排序樹的刪除,就不是那么容易,我們不能因為刪除了結點,而讓這棵樹變得不滿足二叉排序樹的特性,所以刪除需要考慮多種情況。
如果需要查找並刪除如37、51、73、93這些在二叉排序樹中是葉子的結點,那是很容易的,畢竟刪除它們對整棵樹來說,其他結點的結構並未受到影響,如圖所示 。
對於要刪除的結點只有左子樹或只有右子樹的情況,相對也比較好解決。那就是結點刪除后,將它的左子樹或右子樹整個移動到刪除結點的位置即可,可以理解為獨子繼承父業。比如下圖所示,就是先刪除35和99結點,再刪除58結點的變化圖,最終,整個結構還是一個二叉排序樹。
但是對於要刪除的結點既有左子樹又有右子樹的情況怎么辦呢?比下圖中的47結點若要刪除了,它的兩兒子以及子孫們怎么辦呢?
起初的想法,我們當47結點只有一個左子樹,那么做法和一個左子樹的操作一樣,讓35及它之下的結點成為58的左子樹,然后再對47的右子樹所有結點進行插人操作,如下圖所示。 這是比較簡單的想法,可是47的右子樹有子孫共5個結點,這么做效率不高且不說, 還會導致整個二叉排序樹結構發生很大的變化,有可能會增加樹的高度。增加高度可不是個好事,這我們待會再說,總之這個想法不太好。
我們仔細觀察一下,47的兩個子樹中能否找出一個結點可以代替47呢?果然有,37或者48都可以代替47,此時在刪除 47后, 整個二叉排序樹並沒有發生什么本質的改變。
為什么是37和48? 對的,它們正好是二叉排序樹中比它小或比它大的最接近47的兩個數。也就是說,如果我們對這棵二叉排序樹進行中序遍歷,得到的序列{29,35,36,37,47,48,49,50,51,56,58,62,73,88,93.99},它們正好是47的前驅和后繼。
因此,比較好的辦法就是,找到需要刪除的結點p的直接前驅(或直接后繼),用s來替換結點p,然后再刪除此結點s,如下圖所示。
根據我們對刪除結點三種情況的分析:
• 葉子結點;
• 僅有左或右子樹的結點 :
• 左右子樹都有的結點 ,我們來看代碼,下面這個算法是遞歸方式對二叉排序樹T查找key,查找到時刪除。
public static boolean deleteBST(BinTree bt, int key) {
if (!searchBST(bt, key, null)) { // 不存在關鍵字等於key的元素
return false;
} else {
if (bt.data == key) {
return delete(bt);
} else if (key < bt.data) {
return deleteBST(bt.lchild, key);
} else {
return deleteBST(bt.rchild, key);
}
}
}
這段代碼和前面的二叉排序樹查找幾乎完全相同,唯一的區別就在於bt.data==key成立的時候,此時執行的是delete 方法,對當前結點進行刪除操作。我們來看delete的代碼。
/** 從二叉排序樹中刪除結點p,並重接它的左或右子樹 */
public static boolean delete(BinTree bt) {
BinTree q, s;
if (null == bt.rchild) {
bt = bt.lchild; // 右子樹為空則只需重接左子樹
} else if (null == bt.lchild) {
bt = bt.rchild; 左子樹為空則只需重接右子樹
} else {
q = bt;
s = bt.lchild;
while (null != s.rchild) {// 轉左,然后向右到盡頭(找到待刪結點前驅)
q = s;
s = s.rchild;
}
bt.data = s.data;// s指向被刪除結點的直接前驅
if (q != bt) {//
q.rchild = s.lchild;// 重接q的右子樹
} else {// q.data == bt.data,則說明s.rchild == null
q.lchild = s.lchild; // 重接q的左子樹
}
}
return true;
}
二叉排序樹總結
總之, 二叉排序樹是以鏈接的方式存儲,保持了鏈接存儲結構在執行插入或刪除操作時不用移動元素的優點,只要找到合造的插入和刪除位置后,僅需修改鏈接指針即可。插入刪除的時間性能比較好。而對於二叉排序樹的查找,走的就是從根結點到要查找的結點的路徑,其比較次數等於給定值的結點在二叉排序樹的層數。極端情況,最少為1次,即根結點就是要找的結點,最多也不會超過樹的深度。也就是說,二叉排序樹的查找性能取決於二叉排序樹的形狀。可問題就在於,二叉排序樹的形狀是不確定的。
例如{62,88,58,47,35,73,51,99,37,93}這樣的數組,我們可以構建如下左圖的二叉排序樹。但如果數組元素的次序是從小到大有序,如 {35,37,47,51,58,62,73,88,93,99},則二叉排序樹就成了極端的右斜樹,注意它依然是一棵二叉排序樹,如下右圖。此時,同樣是查找結點99,左圖只需要兩次比較,而右圖就需要10次比較才可以得到結果,二者差異很大。
也就是說,我們希望二叉排序樹是比較平衡的,即其深度與完全二叉樹相同,均為[log2n]+1,那么查找的時間復雜也就為O(logn),近似於折半查找,事實上,上左圖也不夠平衡,明顯的左重右輕。不平衡的最壞情況就是像上右圖的斜樹,查找時間復雜度為O(n),這等同於順序查找。
因此,如果我們希望對一個集合按二叉排序樹查找,最好是把它構建成一棵平衡的二叉排序樹。這樣我們就引申出另一個問題,如何讓二叉排序樹平衡的問題。
平衡二叉樹(AVL樹)
平衡二叉樹,是一種二叉排序樹,其中每一個節點的左子樹和右子樹的高度差至多等於1。
從平衡二叉的英文名字(AVL樹),你也可以體會到,它是一種高度平衡的二叉排序樹。
那什么叫做高度平衡呢?意思是說,要么它是一棵空樹,要么它的左子樹和右子樹都是平衡二叉樹,且左子樹和右子樹的深度之差的絕對值不超過1。我們將二叉樹上結點的左子樹深度減去右子樹深度的值稱為平衡因子BF(Balance Factor) ,那么平衡二叉樹上所有結點的平衡因子只可能是-1、0和1。只要二叉樹上有一個結點的平衡因子的絕對值大於1,則該二叉樹就是不平衡的。
距離插入結點最近的,且平衡因子的絕對值大於1的結點為根的子樹,我們稱為最小不平衡子樹。如下圖,當新插入結點37時,距離它最近的平衡因子絕對值超過1的結點是58(即它的左子樹深度2減去右子樹深度0),所以從58開始以下的子樹為最小不平衡子樹。
平衡二叉樹實現原理
平衡二叉樹構建的基本思想就是在構建二叉排序樹的過程中,每當插入一個結點時,先檢查是否因插入而破壞了樹的平衡性,若是,則找出最小不平衡子樹。在保持二叉排序樹特性的前提下,調整最小不平衡子樹中各結點之間的鏈接關系,進行相應的旋轉,使之成為新的平衡子樹。
平衡二叉樹實現算法
首先是需要改進二叉排序樹的結點結構,增加一個bf用來存儲平衡因子。
/**
* 二叉樹的二叉樹鏈表結構定義
*/
private static class BinTree {
int data;// 結點數據
int bf;// 結點的平衡因子
BinTree lchild, rchild;// 左右孩子引用
@Override
public String toString() {
return "[結點值:" + data + ", 平衡因子:" + bf + ", 左孩子:" + lchild + ", 右孩子:" + rchild + "]";
}
}
然后,對於右旋操作,我們的代碼如下。
/** 右旋 */
static BinTree rRotate(BinTree bt) {
BinTree l;
l = bt.lchild;//l指向bt的左子樹的根節點
bt.lchild = l.rchild;//l的右子樹指向bt的左子樹
l.rchild = bt;//bt指向l的右子樹
bt = l;//l指向新的根節點
return bt;
}
此函數代碼的意思是說,當傳入一個二叉排序樹bt,將它的左孩子結點定義為l,將l的右子樹變成bt的左子樹,再將 bt成l的右子樹,最后將l替換bt成為根結點,這樣就完成了一次右旋操作。
左旋操作代碼如下。
/** 左旋 */
static BinTree lRotate(BinTree bt) {
BinTree r;
r = bt.rchild;
bt.rchild = r.lchild;
r.lchild = bt;
bt = r;
return bt;
}
這段代碼與右旋代碼是對稱的。
現在我們來看左平衡旋轉處理的函數代碼。
static final int LH = 1;// 左高
static final int EH = 0;// 等高
static final int RH = -1;// 右高
/** 用於存放各個方法中返回的臨時樹結點 */
static BinTree temp = new BinTree();
/**
* 處理完成后返回平衡二叉樹bt
* @param bt 待處理的左重右輕的二叉樹
* @return
*/
static BinTree leftBalance(BinTree bt) {
BinTree l, lr;
l = bt.lchild;// l指向bt的左子樹根節點
switch (l.bf) {
// 檢查左子樹的平衡度,並作相應平衡處理
case LH:/* 新結點插在bt左孩子的左子樹上,需要進行右旋處理 */
bt.bf = l.bf = EH;
temp = rRotate(bt);
bt = temp;
break;
case RH:/* 新結點插在bt的左孩子的右子樹上,需要作雙旋處理 */
lr = l.rchild;/* lr指向bt左孩子的右子樹根 */
switch (lr.bf) {/* 修改bt及其左孩子的平衡因子 */
case LH:
bt.bf = LH;
l.bf = EH;
break;
case EH:
bt.bf = l.bf = EH;
break;
case RH:
bt.bf = EH;
l.bf = LH;
break;
}
lr.bf = EH;
temp = lRotate(bt.lchild);/* 對bt的左孩子作左旋平衡處理 */
rRotate(temp);/* 對bt作右旋平衡處理 */
bt = temp;
}
return bt;
}
同樣的,右平衡旋轉處理的函數代碼非常類似。
/**
* 處理完成后返回平衡二叉樹
* @param bt 左輕右重的平衡二叉樹
* @return
*/
static BinTree rightBalance(BinTree bt) {
BinTree r, rr;
r = bt.rchild;// r指向bt的右子樹根節點
switch (r.bf) {
// 檢查右子樹的平衡度,並作相應平衡處理
case RH:/* 新結點插在bt右孩子的右子樹上,需要進行左旋處理 */
bt.bf = r.bf = EH;
temp = lRotate(bt);
bt = temp;
break;
case LH:/* 新結點輸入在bt的右孩子的左子樹上,需要作雙旋處理 */
rr = r.lchild;/* rr指向bt右孩子的左子樹根 */
switch (rr.bf) {/* 修改bt及其左孩子的平衡因子 */
case LH:
bt.bf = RH;
r.bf = EH;
break;
case EH:
bt.bf = r.bf = EH;
break;
case RH:
bt.bf = LH;
r.bf = EH;
break;
}
rr.bf = EH;
temp = rRotate(bt.rchild);/* 對bt的右孩子作右旋平衡處理 */
bt.rchild = temp;
temp = lRotate(bt);/* 對bt作左旋平衡處理 */
bt = temp;
}
return bt;
}
有了這些准備,我們的主函數才算是正式登場了。
/** 全局變量,用於標識樹是否增高 */
static boolean taller = false;
/**
* 在平衡二叉樹t中若不存在與e有相同關鍵字的結點則插入t中並返回
* t,若插入e后使得t失去平衡則需要作平衡處理
* @param t 平衡二叉排序樹
* @param e 待插入元素
* @return
*/
static BinTree insertAVL(BinTree t, int e) {
if (null == t) {
// 插入新結點。樹長高,taller為true
t = new BinTree();
t.data = e;
t.lchild = t.rchild = null;
t.bf = EH;
taller = true;
} else {
if (e == t.data) {
// 樹中已存在和e有相同關鍵字的結點則不再插入
taller = false;
return t;
} else if (e < t.data) {
// 當e小於根結點或子根節點則應繼續在t的左子樹中進行搜索
BinTree lchild = insertAVL(t.lchild, e);
t.lchild = lchild;
if (!taller)
return t;
if (taller) {// 已插入到t的左子樹中且左子樹"長高"
switch (t.bf) {// 檢查t的平衡度
case LH:// 原本左子樹比右子樹高,需要作左平衡處理
temp = leftBalance(t);
t = temp;
taller = false;
break;
case EH:// 原本左右子樹等高,現因左子樹增高而樹增高
t.bf = LH;
taller = true;
break;
case RH:// 原本右子樹比左子樹高,現左右子樹等高
t.bf = EH;
taller = false;
break;
}
}
} else {// 應繼續在t的右子樹中進行搜索.
BinTree rchild = insertAVL(t.rchild, e);
t.rchild = rchild;
if (!taller)
return t;
if (taller) {// 已插入到 t的右子樹中且右子樹"長高"
switch (t.bf) {// 檢查t的平衡度
case LH:// 原本左子樹比右子樹高,現左右子樹等高
t.bf = EH;
taller = false;
break;
case EH:// 原本左右子樹等高,現因右子樹增高而樹增高
t.bf = RH;
taller = true;
break;
case RH:// 原本右子樹比左子樹高,需要作右平衡處理
temp = rightBalance(t);
t = temp;
taller = false;
break;
}
}
}
}
return t;
}
對於這段代碼來說,在main函數中我們只需要在需要構建平衡二叉樹的時候執行如下列代碼即可在內存中生成一棵如下圖所示相同的平衡的二叉樹。
int[] a = { 3, 2, 1, 4, 5, 6, 7, 10, 9, 8 };
BinTree t = null;
for (int i = 0; i < a.length; i++) {
t = insertAVL(t, a[i]);
System.out.println(t+"\n");
}
本算法代碼很長,是有些復雜,編程中容易在很多細節上出錯,要想真正掌握,需要多上機調試,在圖紙上畫畫。不過其思想還是不難理解的,總之就是把不平衡消滅在最早時刻。
如果我們需要查找的集合本身沒有順序,在頻繁查找的同時也需要經常的插入和刪除操作,顯然我們需要構建一棵二叉排序樹,但是不平衡的二叉排序樹,查找效率是非常低的,因此我們需要在構建時,就讓這棵二叉排序樹是平衡二叉樹,此時我們的查找時間復雜度就為O(logN),而插入和刪除也為O(logN)。這顯然是比較理想的一種動態查找表算法。
多路查找樹(B樹)
我們前面討論過的數據結構,處理數據都是在內存中,因此考慮的都是內存中的運算時間復雜度。
但如若我們要操作的數據集非常大,大到內存已經沒辦法處理了怎么辦呢?如數據庫中的上千萬條記錄的數據量、硬盤中的上萬個文件等。 在這種情況下,對數據的處理需要不斷從硬盤等存儲設備中調入或調出內存頁面。
一旦涉及到這樣的外部存儲設備,關於時間復雜度的計算就會發生變化,訪問該集合元素的時間已經不僅僅是尋找該元素所需比較次數的函數,我們必須考慮對硬盤等外部存儲設備的訪問時間以及將會對該設備做出多少次單獨訪問。
試想一下,為了要在一個擁有幾十萬個文件的磁盤中查找一個文本文件,你設計的算法需要讀取磁盤上萬次還是讀取幾十次,這是有本質差異的。此時,為了降低對外存設備的訪問次數,我們就需要新的數據結構來處理這樣的問題。
我們之前談的樹,都是一個結點可以有多個孩子,但是它自身只存儲一個元素。二叉樹限制更多,結點最多只能有兩個孩子。
一個結點只能存儲一個元素,在元素非常多的時候,就使得要么樹的度非常大(結點擁有子樹的個數的最大值),要么樹的高度非常大,甚至兩者都必須足夠大才行。這就使得內存存取外存次數非常多,這顯然成了時間效率上的瓶頸,這迫使我們要打破每一個結點只存儲一個元素的限制 ,為此號引入了多路查找樹的概念。
多路查找樹,其每一個結點的孩子數可以多於兩個,且每一個結點處可以存儲多個元素。由於它是查找樹,所有元素之間存在某種特定的排序關系。
在這里,每一個結點可以存儲多少個元素,以及它的孩子數的多少是非常關鍵的。為此,我們講解它的4種特殊形式 : 2-3 樹、2-3-4 樹、B 樹和B+樹。
2-3 樹
2-3樹是這樣的一棵多路查找樹;其中的每一個結點都具有兩個孩子(我們稱它為2結點)或三個孩子(我們稱它為3結點) 。
一個2結點包含一個元素和兩個孩子(或沒有孩子),且與二叉排序樹類似,左子樹包含的元素小子該元素,右子樹包含的元素大於該元素。不過,與二叉排序樹不同的是,這個2結點要么沒有孩子,要有就有兩個,不能只有一個孩子 。一個3結點包含一小一大兩個元素和三個孩子(或沒有孩子),一個3結點要么沒有孩子,要么具有3個孩子。如果某個3結點有孩子的話,左子樹包含小於較小元素的元素,右子樹包含大於較大元素的元素,中間子樹包含介於兩元素之間的元素。並且2-3樹中所有的葉子都在同一層次上。如下圖,就是一棵有效的2-3樹。
事實上,2-3樹復雜的地方就在於新結點的插入和已有結點的刪除。畢竟,每個結點可能是2結點也可能是3結點,要保證所有葉子都在同一層次,是需要進行一番復雜操作的。
2-3樹的插入實現
對於2-3樹的插入來說,與二叉排序樹相同,插入操作一定是發生在葉子結點上。可與二叉排序樹不同的是,2-3樹插入一個元素的過程有可能會對該樹的其余結構產生連鎖反應。
2-3樹插入可分為三種情況。
1) 對於空樹,插入一個2結點即可,這很容易理解。
2) 插入結點到一個2結點的葉子上。應該說,由於其本身就只有一個元素,所以只需要將其升級為3結點即可。如圖 所示,我們希望從左圖的2-3樹中插入元素3,根據遍歷可知,3比8小、比4小,於是就只能考慮插入到葉子結點1所在的位置,因此很自然的想法就是將此結點變成一個3結點,即右圖這樣完成插入操作。當然,要視插入的元素與當前葉子結點的元素比較大小后,決定誰在左誰在右。例如,若插入的是0,則此結點就是"0"在左"1"在右了。
3)要往3結點中插入一個新元素。 因為3結點本身已經是2-3樹的結點最大容量(已經有兩個元素),因此就需要將其拆分,且將樹中兩元素或插入元素的三者中選擇其一向上移動一層。復雜的情況也正在於此。
第一種情況,見下圖,需要向左圖中插入元素5。經過遍歷可得到元素5比8小比4大,因此它應該是需要插入在擁有6、7元素的3結點位置。問題就在於,6和7結點已經是3結點,不能再加。此時發現它的雙親結點4是個2結點,因此考慮讓它升級為3結點,這樣它就得有三個孩子,於是就想到,將6、7結點拆分,讓6與4結成3結點,將5成為它的中間孩子,將7成為它的右孩子,如下右圖所示。
另一種情況,如下圖所示,需要向左圖中插入元素11。經過遍歷可得到元素11比12、14比9、10大,因此它應該是需要插入在擁有9、10元素的3結點位置。同樣道理,9和10結點不能再增加結點。 此時發現它的雙親結點12、14 也是一個3結點,也不能再插入元素了。再往上看,12、14結點的雙親,結點8是個2結點。於是就想到,將9、10拆分, 12、14也拆分,讓根結點8升級為3結點,最終形成如下右圖樣子。
再來看個例子, 如下圖所示,需要在左圖中插入元素2。經過遍歷可得到元素2比4小、6比1大,因此它應該是需要插入在擁有1、3元素的3結點位置。與上例一樣,你會發現,1、3結點,4、6結點都是3結點,都不能再插入元素了,再往上看,8、12結點還是一個3結點,那就意味着,當前我們的樹結構是三層已經不能滿足當前結點增加的需要了。 於是將1、3拆分,4、6拆分,連根結點8、12 也拆分,最終形成如下右圖樣子。
通過這個例子,也讓我們發現,由於2-3樹插入的傳播放應導致了根結點的拆分,則樹的高度就會增加。
2-3樹的刪除實現
對於2-3樹的刪除來說,如果對前面插入的理解足夠到位的話,應該不是難事了。2-3樹的刪除也分為三種情況。與插入相反,我們從3結點開始說起。
1)所刪除元素位於一個 3 結點的葉子結點上,這非常簡單,只需要在該結點處刪除該元素即可,不會影響到整棵樹的其他結點結構。如下圖所示,刪除元素9,只需要將此結點改成只有元素10的2結點即可。
2)所刪除的元素位於一個2結點上,即要刪除的是一個只有一個元素的結點。如果按照以前樹的理解,刪除即可, 可現在的2- 樹的定義告訴我們這樣做是不可以的 . 比如下圖所示,如果我們刪除了結點1,那么結點4本來是一個2結點(它擁有兩個孩子 ),此時它就不滿足定義了。
因此,對於刪除葉子是2結點的情況,我們需要分四種情形來處理。
情形一,此結點的雙親也是2結點,且擁有一個3結點的右孩子。 如下圖所示,刪除結點1,那么只需要左旋,即6成為雙親,4成為6的左孩子,7是6的右孩子。
情形二,此結點的雙親是2結點,它的右孩子也是2結點。如下圖,此時刪除結點1,如果直接左旋會造成沒有右孩子,因此需要對整棵樹變形,辦法就是,我們目標是讓結點7變成3結點,那就得讓比7稍大的元素8下來,隨即就得讓
比元素8稍大的元素9補充結點8的位置,於是就有了下面中間圖,於是再用左旋的方式,變成右圖結果。
情形三,此結點的雙親是一個3結點。如下圖所示,此時刪除結點10,意味着雙親12、14這個結點不能成為3結點了 ,於是將此結點拆分,並將12與13合並成為左孩子。
情形四,如果當前樹是一個滿二叉樹的情況,此時刪除任何一個葉子都會使得整棵樹不能滿足2-3樹的定義。如下圖所示,刪除葉子結點8時(其實刪除任何一個結點都一樣),就不得不考慮要將2-3的層數減少,辦法是將8的雙親7和其在子樹6合並為3結點,再將14與9合並為3結點 ,最后成為右圖的樣子。
3)所刪除的元素位於非葉子的分支結點。此時我們通常是將樹按中序遍歷后得到此元素的前驅或后繼元素,考慮讓它們來補位即可。
如果我們要刪除的分支結點是2結點。如下圖所示我們要刪除4結點,分析后得到它的前驅是1后繼是6,顯然,由於 6、7是3結點,只需要用6來補位即可,如下右圖所示。
如果我們要刪除的分支結點是3結點的某一元素,如下圖所示我們要刪除12、14 結點的12,此時,經過分析,顯然應該是將是3結點的左孩子的10上升到刪除位置合適。
當然,如果對2-3樹的插入和刪除等所有的情況進行講解,既占篇幅,又沒必要,總的來說它是有規律的,需要你們在上面的這些例子中多去體會后掌握。
2-3-4樹
有了2-3樹的講解,2-3-4樹就很好理解了,它其實就是2-3樹的概念擴展,包括了4結點的使用。一個4結點包含小中大三個元素和四個孩子(或沒有孩子),一個4結點要么沒有孩子,要么具有4個孩子。如果某個4結點有孩子的話,左子樹包含小於最小元素的元素;第二子樹包含大於最小元素,小於第二元素的元素;第三子樹包含大於第二元素,小於最大元素的元素;右子樹包含大於最大元素的元素。
B樹
我們本節名稱叫B樹,但到了現在才開始提到它,似乎這主角出來的實在大晚了,可其實,我們前面一直都在講B樹。
B樹(B-Tree)是一種平衡的多路查找樹,2-3樹和2-3-4樹都是B樹的特例。結點最大的孩子數目稱為B樹的階(order) ,因此,2-3樹是3階B樹, 2-3-4樹是4階B樹。
一個m階的B樹具有如下屬性:
• 如果根結點不是葉結點,則其至少有兩棵子樹。
• 每一個非根的分支結點都有k-l個元素和k個孩子,其中[m/2]<=k<=m。
每一個葉子結點n都有k-l個元素,其中[m/2]<=k<=m。
• 所有葉子結點都位於同一層次。
•所有分支結點包含下列信息數據(n,A0,K1,A1,K2,......KN,AN),其中:Ki(i=1,2…,n)為關鍵字,且Ki(i =1,2…,n);為關鍵字,且Ki<K(i+1)(i=0,2,…,n)為指向子樹根結點的指針,且指針Ai所指子樹中所有結點的關鍵字均小於Ki(i=1,2,…,n),An所指子樹中所有結點的關鍵字均大於Kn,n( [m/2]-1<=n<=m-l)為關鍵字的個數(或n+1為子樹的個數)。
例如,2-3-4樹中插入9個數后的圖轉成 B 樹示意就如下圖的右圖所示。左側灰色方塊表示當前結點的元素個數。
在 B 樹上查找的過程是一個指針查找結點和在結點中查找關鍵字的交叉過程。
比方說,我們要查找數字7,首先從外存(比如硬盤中)讀取得到根結點3、5 、8三個元素,發現7不在當中,但在5和 8之間,因此就通過A2再讀取外存的6、7結點,查找到所要的元素.
至於 B 樹的插入和刪除,方式是與2-3樹和2-3-4樹相類似的,只不過階數會很大而已 。
我們在本節的開頭提到,如果內存與外存交換數據次數頻繁,會造成了時間效率
上的瓶頸,那么B樹結構怎么就可以做到減少次數呢?
我們的外存,比如硬盤,是將所有的信息分割成相等大小的頁面,每次硬盤讀寫的都是一個或多個完整的頁面,對於一個硬盤來說, 一頁的長度可能是211到214個字節。
在一個典型的B樹應用中,要處理的硬盤數據量很大,因此無法一次全部裝入內存。因此我們會對B樹進行調整,使得 B樹的階數(或結點的元素)與硬盤存儲的頁面大小相匹配。比如說一棵B樹的階為1001(即1個結點包含1000個關鍵字) ,高度為2,它可以儲存超過10億個關鍵字,我們只要讓根結點持久地保留在內存中,那么在這棵樹上,尋找某一個關鍵字至多需要兩次硬盤的讀取即可。這就好比我們普通人數錢都是一張一張的數,而銀行職員數錢則是五張、十張 ,甚至幾十張一數,速度當然是比常人快了不少。
通過這種方式,在有限內存的情況下,每一次磁盤的訪問我們都可以獲得最大數量的數據。由於B樹每結點可以具有比二叉樹多得多的元素,所以與二叉樹的操作不同,它們減少了必須訪問結點和數據塊的數量,從而提高了性能。可以說,B樹的數據結構就是為內外存的數據交互准備的。
B+樹
盡管前面我們已經講了 B 樹的諸多好處,但其實它還是有缺陷的。對於樹結構來說,我們都可以通過中序遍歷來順序查找樹中的元素,這一切都是在內存中進行。
可是在B樹結構中,我們往返於每個結點之間也就意味着,我們必須得在硬盤的頁面之間進行多次訪問,如下圖所示,我們希望遍歷這棵B樹,假設每個結點都屬於硬盤的不同頁面,我們為了中序遍歷所有的元素,頁面2→頁面1→頁面 3→頁面1→頁面4→頁面1→頁面5。而且我們每次經過結點遍歷時,都會對結點中的元素進行一次遍歷,這就非常糟糕。有沒有可能讓遍歷時每個元素只訪問一次呢?
為了能夠解決所有元素遍歷等基本問題,我們在原有的B樹結構基礎上,加上了新的元素組織方式,這就是B+樹。
B+樹是應文件系統所需而出的一種B樹的變形樹,注意嚴格意義上講,它其實已經不是第之前所定義的樹了。在B樹中 ,每一個元素在該樹中只出現一次,有可能在葉子結點上,也有可能在分支結點上。而在B+樹中,出現在分支結點中的元素會被當作它們在該分支結點位置的中序后繼者(葉子結點)中再次列出。另外,每一個葉子結點都會保存一個指向后一葉子結點的指針。
例如下圖所示,就是一棵B+樹的示意,灰色關鍵字即是根結點中的關鍵字
在葉子結點再次列出,並且所有葉子結點都鏈接在一起。
一棵m階的B+樹和m階的B樹的差異在於:
• 有n棵子樹的結點中包含有n個關鍵字;
• 所有的葉子結點包含全部關鍵字的信息,及指向含這些關鍵字記錄的指針,葉子結點本身依關鍵字的大小自小而大順序鏈接;
• 所有分支結點可以看成是索引,結點中僅含有其子樹中的最大(或最)關鍵字。
這樣的數據結構最大的好處就在子 ,如果是要隨機查找,我們就從根結點出發,與B樹的查找方式相同,只不過即使在分支結點找到了待查找的關鍵字,它也只是用來索引的,不能提供實際記錄的訪問,還是需要到達包含此關鍵字的終端結點。
如果我們是需要從最小關鍵字進行從小到大的順序查找,我們就可以從最左側的葉子結點出發,不經過分支結點,而是延着指向下一葉子的指針就可遍歷所有的關鍵字。
B+樹的結構特別適合帶有范圍的查找。比如查找我們學校18-22歲的學生人數,我們可以通過從根結點出發找到第一個18歲的學生,然后再在葉子結點按順序查找到符合范圍的所有記錄。
B+樹的插入、刪除過程也都與B樹類似,只不過插入和刪除的元素都是在葉子結點上進行而已。
————————————————
版權聲明:本文為CSDN博主「sky凌亂的微笑」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/smile_from_2015/article/details/72190562
