日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

《大话数据结构》读书笔记-查找

發布時間:2025/4/5 编程问答 28 豆豆
生活随笔 收集整理的這篇文章主要介紹了 《大话数据结构》读书笔记-查找 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

寫在前面:本文僅供個人學習使用。《大話數據結構》通俗易懂,適合整體做筆記輸出,構建體系。并且文中很多圖片來源于該書,如有侵權,請聯系刪除。

文章目錄

    • 8.1 開場白
    • 8.2 查找概論
    • 8.3 順序表查找
      • 8.3.1 順序表查找算法
      • 8.3.2 順序表查找優化
    • 8.4 有序表查找
      • 8.4.1 折半查找
      • 8.4.2 插值查找
      • 8.4.3 斐波那契查找
    • 8.5 線性索引查找
      • 8.5.1稠密索引
      • 8.5.2 分塊索引
      • 8.5.3倒排索引
    • 8.6 二叉排序樹
      • 8.6.1 二叉排序樹查找操作
      • 8.6.2 二叉排序樹插入操作
      • 8.6.3 二叉排序樹刪除操作
      • 8.6.4 二叉排序樹總結
    • 8.7 平衡二叉樹(AVL樹)
      • 8.7.1 平衡二叉樹實現原理
      • 8.7.2 平衡二叉樹實現算法
    • 8.8 多路查找樹(B樹)
      • 8.8.1 2-3樹
        • 2-3樹的插入實現
        • 2-3樹的刪除實現
      • 8.8.2 2-3-4樹
      • 8.8.3 B樹
      • 8.8.4 B+樹
    • 8.9 散列表(哈希表)查找概述
      • 8.9.1 散列表查找定義
      • 8.9.2 散列表查找步驟
    • 8.10 散列函數的構造方法
      • 8.10.1 直接定址法
      • 8.10.2 數字分析法
      • 8.10.3 平方取中法
      • 8.10.4 折疊法
      • 8.10.5 除留余數法
      • 8.10.6 隨機數法
    • 8.11 處理散列沖突的方法
      • 8.11.1 開放定址法
      • 8.11.2 再散列函數法
      • 8.11.3 鏈地址法
      • 8.11.4 公共溢出區法
    • 8.12 散列表查找實現
      • 8.12.1 散列表查找算法實現
      • 8.12.2 散列表查找性能分析

8.1 開場白

相信在座的同學都用過搜索引擎。那么,你知道它的大概工作原理嗎?

當你精心制作了一個網頁、或寫了一篇博客、或者上傳了一組照片到互聯網上,來自世界各地的無數“蜘蛛”便會蜂擁而至。所謂蜘蛛就是搜索引擎公司服務器上的軟件,它如同蜘蛛一樣把互聯網當成了蜘蛛網,沒日沒夜地訪問互聯網上的各種信息。

它抓取并復制你的網頁,且通過你網頁上的鏈接爬上更多的頁面,將所有信息納入到搜索引擎網站的索引數據庫。服務器拆解你網頁上的文字內容、標記關鍵詞的位置、字體、顏色,以及相關圖片、音頻、視頻的位置等信息,并生成龐大的索引記錄,如圖8-1-1所示。


當你在搜索引擎上輸入一個單詞,點擊搜索時,它會在不到1秒內,帶著單詞奔向索引數據庫的每個神經末梢,檢索到所有包含搜索詞的網頁,依據它們的瀏覽次數與關聯性等一系列算法確定網頁級別,排列出順序,最終按你期望的格式呈現在網頁上。

這就是一個“關鍵詞”的云端之旅。過去的十多年,成就了本世紀最早期的創新明星Google,還有Yandex、Navar和百度等搜索引擎,搜索引擎已經稱為人們最依賴的互聯網工具。

作為學習編程的人,面對查找或者叫做搜索這種最為頻繁的操作,理解它的原理并學習應用它是非常必要的事情,讓我們對“Search”的探索之旅開始吧。

8.2 查找概論

只要你打開電腦,就會涉及到查找技術。如炒股軟件中查股票信息、硬盤文件中找照片等,所有這些需要被查的數據所在的集合,我們給它們一個統稱叫查找表。

查找表(search table)是由同一類型的數據元素(或記錄)構成的集合。例如圖8-2-1就是一個查找表。

關鍵字(key)是數據元素中某個數值項的值,又稱為鍵值,用它可以標識一個數據元素。也可以表示一個記錄的某個數據項(字段),我們稱為關鍵碼,如圖中的①和②所示。

若次關鍵字可以唯一地標識一個記錄,則稱此關鍵字為主關鍵字(Primary key).這也就意味著,對不同的記錄,其主關鍵字均不相同,主關鍵字所在的數據項稱為主關鍵碼,如圖③和④所示。

那么對于那些可以標識多個數據元素(或記錄)的關鍵字,我們 稱為次關鍵字(Secondary key),如圖⑤所示。次關鍵字也可以理解為是不用以唯一識別一個數據元素(或記錄)的關鍵字。

查找(searching)就是根據給定的某個值,在查找表中確定其關鍵字等于給定值的數據元素(或記錄)。

若表中存在這樣的一個記錄,則稱查找是成功的,此時查找的結果給出整個記錄的信息,或指示該記錄在查找表中的位置。比如圖8-2-1所示,如果我們查找主關鍵碼“代碼”的主關鍵字為“sh601398”的記錄時,就可以得到第2條唯一記錄。 如果我們查找次關鍵碼“漲跌幅”為“-0.11”的記錄時,就可以得到兩條記錄。

若表中不存在關鍵字等于給定值的記錄,則稱查找不成功,此時查找的結果可給出一個“空”記錄或“空”指針。

查找表按照操作方式來分有兩大種: 靜態查找表和動態查找表。

靜態查找表(Static Search Table):只作查找操作的查找表。
它的主要操作有: (1) 查找某個“特定的”數據元素是否在查找表中。(2)檢索某個“特定的”數據元素和各種屬性。

動態查找表(Dynamic Search Table):在查找過程中同時插入查找表中不存在的數據元素,或者從查找表中刪除已經存在的某個數據元素。
它的操作就是兩個:(1) 查找時插入數據元素;(2)查找時刪除數據元素。

為了提高查找的效率,我們需要專門為查找操作設置數據結構,這種面向查找操作的數據結構稱為查找結構。

從邏輯上來說,查找所基于的數據結構是集合,集合中的記錄之間沒有本質關系。可以要想獲得較高的查找性能,我們就不能不改變數據元素之間的關系,在存儲時可以將查找集合組織成表、樹等結構。

例如,對于靜態查找表來說,我們不妨應用線性表結構來組織數據,這樣可以使用順序查找算法,如果再對主關鍵字排序,則可以應用折半查找等技術進行高效的查找。

如果是需要動態查找,則會復雜一些,可以考慮二叉排序樹的查找技術。

另外,還可以利用散列表結構來解決一些查找問題,這些技術都會在接下來一一介紹。

8.3 順序表查找

設想一下,要在散落的一大堆書中找到你需要的那本有多麻煩。碰到這種情況大多人都會考慮去做這樣一件事,那就是把這些書排列整齊,比如豎起來排到書架上,這樣根據書名,就很容易找到需要的圖書,如圖8-3-1所示。

散落的圖書可以理解為一個集合,而將它們排列整齊,就如同是將此集合構造成一個線性表,我們要針對這一線性表進行查找操作,因此它就是靜態查找表。

順序查找(Sequntial Search) 又叫線性查找,是最基本的查找技術,它的查找過程是:從表中第一個(或最后一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,如某個記錄的關鍵字和給定值相等,則查找成功,找到所查的記錄;如果直到最后一個(或第一個)記錄,其關鍵字和給定值比較時都不等,則表中沒有所查的記錄,查找不成功。

8.3.1 順序表查找算法

/* 無哨兵順序查找,a為數組,n為要查找的數組個數,key為要查找的關鍵字 */ int Sequential_Search(int *a,int n,int key) {int i;for(i=1;i<=n;i++){if (a[i]==key) return i;}return 0; }

這段代碼非常簡單,就是在數組a(注意元素值從下標1開始)中查看有沒有關鍵字,當你需要查找復雜表結構的記錄時,只需要把數組a與關鍵字key定義成你需要的表結構和數據類型即可。

8.3.2 順序表查找優化

到這里并非完美,因為每次循環時都需要對i是否越界,即是否小于等于n做判斷。事實上,還可以有更好一點的辦法,設置一個哨兵,可以解決不需要每次讓i和n作比較。看下面的改進后的順序查找算法代碼

/* 有哨兵順序查找 */ int Sequential_Search2(int *a,int n,int key) {int i;a[0]=key;// 設置a[0]為關鍵字值,我們稱之為”哨兵“i=n;循環從數組尾部開始while(a[i]!=key){i--;}return i;//返回0則表示查找失敗 }

此時代碼時從尾部開始查找,由于a[0]=key,也就是說,如果在a[i] 中有key 則返回i值,查找成功;否則一定時最終a[0]處等于key值,此時返回的是0,即說明查找失敗。

這種在查找方向的盡頭放置”哨兵“免去了在查找過程中每一次比較后都要判斷查找位置是否越界的小技巧,看似與原先差別不大,但在總數據量較多時,效率提高很大,是非常好的編碼技巧。 當然,"哨兵"也不一定非要出現在數組開始,也可以在末端。

對于這種順序查找算法來說,查找成功最好的情況是在第一個位置就找到,算法上的時間復雜度為O(1),最壞的情況則是在最后一個位置找到,最壞時間復雜度是O(n),我們之間推導過,關鍵字在任何一個位置的概率是相同的,所以平均查找次數是n(n+1)/2,最終的時間復雜度為O(n).

很顯然,順序查找計數是有很大缺點的,n很大時,查找效率極為低下,不過優點也是有的,這個算法非常簡單,對靜態查找表的記錄沒有任何要求,在一些小型數據的查找時,是可以適用的。

另外,也正由于查找概率的不同,我們完全可以將容易查找的記錄放在前面,而不常用的記錄放置在后面,效率就可以大幅提高。

8.4 有序表查找

一個線性表有序時,對于查找總是有幫助的。

8.4.1 折半查找

折半查找(Binary Search)技術,又稱為二分查找。它的前提是線性表中的記錄必須是關鍵碼有序(通常從小到大有序),線性表必須采用順序存儲。折半查找的基本思想是:在有序表中,取中間記錄作為比較對象,若給定值與中間記錄的關鍵字相等,則查找成功;若給定值小于中間記錄的關鍵字,則在中間記錄的左半區繼續查找;若給定值大于中間記錄的關鍵字,則在中間記錄的右半區繼續查找。不斷重復上述過程,直到查找成功,或所有查找區域無記錄,查找失敗為止。

/* 折半查找 */ int Binary_Search(int *a,int n,int key) {int low,high,mid;low=1; /* 定義最低下標為記錄首位 */high=n; /* 定義最高下標為記錄末位 */while(low<=high){mid=(low+high)/2; /* 折半 */if (key<a[mid]) /* 若查找值比中值小 */high=mid-1; /* 最高下標調整到中位下標小一位 */else if (key>a[mid])/* 若查找值比中值大 */low=mid+1; /* 最低下標調整到中位下標大一位 */else{return mid; /* 若相等則說明mid即為查找到的位置 */}}return 0; }

具有n個結點的完全二叉樹的深度為?log2n?+1\lfloor log_2n \rfloor +1?log2?n?+1,在這里盡管折半查找判定二叉樹并不是完全二叉樹,但同樣的推導可以得出,最壞情況是查找到關鍵字或查找失敗的次數是?log2n?+1\lfloor log_2n \rfloor +1?log2?n?+1
最好情況呢?當然是1次了。

因此最終我們折半查找的時間復雜度為O(logn)O(logn)O(logn),它顯然遠遠好于順序查找的O(n)復雜度了。

不過由于折半查找的前提條件是需要有序表順序存儲,對于靜態查找表,一次排序后不再變化,這樣的算法已經比較好了。但對于需要頻繁執行插入或刪除操作的數據集來說,維護有序的排序會帶來不小的工作量,那就不建議使用。

8.4.2 插值查找

現在我們的問題是,為什么一定要折半,而不是折四分之一或者折更多呢?

打個比方,在英文詞典中查找apple這個單詞,你下意識翻開詞典的前面還是后面?如果再讓你查zoo這個單詞呢? 很顯然,這里你絕對不會是從中間開始查起,而是有一定目的的往前或往后翻。

同樣的,比如要在取值=~10000之間 共有100個元素從小到大均勻分布的數組中查找5,我們自然會考慮從數組下標較小的開始查找。

看來,我們的折半查找,還是有改進空間的。

折半查找mid= (low+high)/2 = low+(high-low)/2

算法科學家們考慮的就是將這個1/2進行改進,改進為下面的計算方案:

mid=low+key?a[low]a[high]?a[low](high?low)mid=low+\frac{key-a[low]}{a[high]-a[low]}(high-low)mid=low+a[high]?a[low]key?a[low]?(high?low)

將1/2改成key?a[low]a[high]?a[low]\frac{key-a[low]}{a[high]-a[low]}a[high]?a[low]key?a[low]?有什么道理嗎?

假設 a[11]={0,1,16,24,35,47,59,62,73,88,99},low=1,high=10,則 a[low]=1,a[high]=99,如果我們要查找的是key=16時,按照原來折半的做法,我們需要4次(如圖8-4-6)才可以得到結果。

但如果用新方法,key+a[low]a[high]?a[low]=(16?1)/(99?1)=0.153\frac{key+a[low]}{a[high]-a[low]}=(16-1)/(99-1)=0.153a[high]?a[low]key+a[low]?=(16?1)/(99?1)=0.153,即mid= 1+0.153*(10-1)=2.377,取整得到mid=2,我們只需要兩次就查找到結果了,顯然大大提高了查找的效率。

換句話說,我們只需要在折半查找算法的代碼中更改一行代碼便可以得到插值查找的代碼

/* 插值查找 */ int Interpolation_Search(int *a,int n,int key) {int low,high,mid;low=1; /* 定義最低下標為記錄首位 */high=n; /* 定義最高下標為記錄末位 */while(low<=high){mid=low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */if (key<a[mid]) /* 若查找值比插值小 */high=mid-1; /* 最高下標調整到插值下標小一位 */else if (key>a[mid])/* 若查找值比插值大 */low=mid+1; /* 最低下標調整到插值下標大一位 */elsereturn mid; /* 若相等則說明mid即為查找到的位置 */}return 0; }

插值查找(Interpolation Search)是根據要查找的關鍵字key與查找表中最大最小記錄的關鍵字比較后的查找方法,其核心在于插值的計算公式key?a[low]a[high]?a[low]\frac{key-a[low]}{a[high]-a[low]}a[high]?a[low]key?a[low]?。 應該說,從時間復雜度來看,它就是O(logn),但對于表長較大,而關鍵字分布又比較均勻的查找表來說,差值查找算法的平均性能比折半查找要好得多。反之,數組中如果分布類似{0,1,2,2000,2001,…,99999919,99999999} 這種極端不平均的數據,用插值查找未必是合適的算法。

8.4.3 斐波那契查找

還有沒有其他辦法? 我們折半查找是從中間分,也就是說,每一次查找總是一分為二,無論數據是偏大還是偏小,很多時候這都未必是最合理的做法。除了插值查找外,我們再介紹一種有序查找,斐波那契查找(Fibonacci Search),它是利用黃金分割的原理來實現的。

為了能夠介紹清楚這個查找算法,我們先需要有一個斐波那契數列的數組,如圖8-4-8所示。

下面我們根據代碼來看程序是如何運行的

/* 斐波那契查找 */ int Fibonacci_Search(int *a,int n,int key) {int low,high,mid,i,k=0;low=1; /* 定義最低下標為記錄首位 */high=n; /* 定義最高下標為記錄末位 */while(n>F[k]-1)k++;for (i=n;i<F[k]-1;i++)a[i]=a[n];while(low<=high){mid=low+F[k-1]-1;if (key<a[mid]){high=mid-1; k=k-1;}else if (key>a[mid]){low=mid+1; k=k-2;}else{if (mid<=n)return mid; /* 若相等則說明mid即為查找到的位置 */else return n;}}return 0; }

1 程序開始運行,參數 a[11]={0,1,16,24,35,47,59,62,73,88,99}, n=10,要查找的關鍵字key=59。注意此時我們已經有了事先計算好的全局變量數組F 的具體數據,它是斐波那契數列, F={0,1,1,2,3,5,8,13,21,…}

2 第6-8行是計算當前的n處于斐波那契數列的位置。現在是n=10,F[6]<10<F[7],,所以計算出來k=7。

3 第9~10行,由于k=7,計算時是以F[7]=13為基礎,而a中最大的僅是a[10],后面的a[11],a[12]均未賦值, 這不能構成有序序列,因此將它們都賦值為最大的數組值,所以此時a[11]=a[12]=a[10]=99(此段代碼后面有解釋)。

4第11~31行查找正式開始。

5 第13行,mid=1+F[7-1]-1=8,也就是說,我們第一個要對比的數值是從下標為8開始的

6 由于此時key=59而a[8]=73,因此執行第16~17行 得到high=7,k=6.

7再次循環,mid=low+F[k-1]-1 =1+F[6-1]-1=5, 此時a[5]=47<key,因此執行19~23行代碼,得到low=mid+1=6,k=k-2=4 ,注意此時k下調2個單位。

8 再次循環,mid=low+F[k-1]-1 =6+F[4-1]-1=7, 此時a[7]=62>key,因此執行14~18行代碼,得到high=mid-1=6,k=k-1=3 .

9再次循環, mid=low+F[k-1]-1 =6+F[3-1]-1=6, 此時a[6]=59=key,因此執行代碼第26~27行,返回值為6. 程序運行結束。

如果key=99,此時查找循環第一次時,mid=8與上例是相同的,第二次循環時,mid=11,如果a[11]沒有值(a中最大的僅是a[10],后面的a[11],a[12]均未賦值, 這不能構成有序序列)就會使得與key的比較失敗,為了避免這樣的情況出現,第9~10行的代碼就起到了這樣的作用。

斐波那契查找算法的核心在于:
1) 當 key=a[mid]時,查找就成功;
2)當key<a[mid]時,新范圍是 第low個到第mid-1個,此時范圍個數為F[k-1]-1個。
3) 當key>a[mid]時,新范圍時第m+1個到第high個,此時范圍個數是F[k-2]-1個。

也就是說,如果要查找的記錄在右側,則左側的數據偶讀不用再判斷了,不斷反復進行下去,對處于當中的大部分數據,其工作效率要高一些。所以盡管斐波那契查找的時間復雜度也為O(logn),但就平均性能而言,斐波那契查找要由于折半查找。可惜如果是最壞情況,比如這里key=1,那么始終都處于左側長半區在查找,則查找效率要低于折半查找。

還有比較關鍵的一點,折半查找是進行加法與除法運算(mid=(low+high)/2;),插值查找進行復雜的四則運算(mid=low+key?a[low]a[high]?a[low](high?low)mid=low+\frac{key-a[low]}{a[high]-a[low]}(high-low)mid=low+a[high]?a[low]key?a[low]?(high?low)),而斐波那契查找只是最簡單的加減法運算(mid=low+F[k-1]-1),在海量數據的查找過程中,這種細微的差別可能會影響最終的查找效率。

應該說,三種有序表的查找本質上是分隔點的選擇不同,各有優劣,實際開發時可根據數據的特點綜合考慮再做出選擇。

8.5 線性索引查找

我們前面講的幾種比較高效的查找方法都是在有序的基礎上進行操作的,但事實上,很多數據集可能增長非常快,例如,某些微博網站或大型論壇的帖子和回復數每天都是成百萬上千萬條,如圖8-5-1所示,或者一些服務器日志信息記錄也是海量數據,要保證記錄全部時按照當中的某個關鍵字有序,其時間代價是非常高昂的,所以這種數據結構通常都是按先后順序存儲。


那么,對于這樣的查找表,我們如何能夠快速查找到需要的數據呢? 辦法就是-----索引。

數據結構的最終目的是提高數據的處理速度,索引是為了加快查找速度而設計的一種數據結構。索引就是把一個關鍵字與它對應的記錄相關聯的過程,一個索引由若干個索引項構成,每個索引項至少應包含關鍵字和其對應的記錄在存儲器中的位置等信息。索引技術是組織大型數據庫以及磁盤文件的一種重要技術。

索引按照結構可以分為線性索引、樹形索引和多級索引。我們這里就只介紹線性索引技術。所謂線性索引就是將索引項集合組織為線性結構,也稱為索引表。我們重點介紹三種線性索引:稠密索引、分塊索引和倒排索引。

8.5.1稠密索引

稠密索引是指在線性索引中,將數據集中的每個記錄對應一個索引項。如圖8-5-2所示。

稠密索引要對應的可能是成千上萬的數據,因此,對于稠密索引這個索引表來說,索引項一定是按照關鍵碼有序的排列。

索引表有序也就意味著,我們要查找關鍵字時,可以用到折半、插值、斐波那契等有序查找方法,大大提高了效率。 比如圖8-5-2中,我要查找關鍵字是18的記錄,如果從右側的數據表中查找,那就只能順序查找,需要查找6次才可以查到結果。 而如果是從左邊的索引表中查找,只需兩次折半查找就可以得到18對應的指針,最終查到結果。

這顯然是稠密索引的優點,但是如果數據集非常大,比如上億,那也就意味著索引也得同樣的數據集長度規模,對于內存有限的計算機來說,可能就需要反復去訪問磁盤,查找性能反而大大下降了。

8.5.2 分塊索引

回想一下圖書館是如何藏書的。顯然它不會是順序擺放的,給我們一個稠密索引表去查,然后再找到書給你。圖書館的圖書分類擺放是有完整的學科體系的,而它最重要的一個特點就是分塊。

稠密索引因為索引項與數據集的記錄個數相同,所以空間代價很大。為了減少索引項的個數,我們可以對數據集進行分塊,使其分塊有序,然后再對每一塊建立一個索引項,從而減少索引項的個數。

分塊有序,是把數據集的記錄分成了若干塊,并且這些塊需要滿足兩個條件:

  • 塊內無序,即每一塊內的記錄不要求有序。當然,你如果能夠讓塊內有序對查找來說更理想,不過這就要付出大量時間和空間的代價,因此通常我們不要求塊內有序。
  • 塊間有序,例如,要求第二塊所有記錄的關鍵字均要大于第一塊中所有記錄的關鍵字,第三塊的所有記錄的關鍵字均要大于第二塊的所有記錄關鍵字…,因為只有塊間有序,才有可能在查找時帶來效率。

對于分塊有序的數據集,將每塊對應一個索引項,這種索引方法稱為分塊索引。如圖8-5-4所示,我們定義的分塊索引的索引項結構分三個數據項:

  • 最大關鍵碼,它存儲每一塊中最大的關鍵字,這樣的好處是可以使得在它之后的下一塊中的最小關鍵字也能比這一塊最大的關鍵字還要大。
  • 存儲了塊中的記錄個數,以便于循環時使用。
  • 用于指向塊首數據元素的指針,便于開始對這一塊中記錄進行遍歷。

在分塊索引表中查找,就是分兩步進行:

  • 在分塊索引表中查找待查關鍵字所在的塊。由于分塊索引表是塊間有序的,因此很容易利用折半、插值等算法得到結果。例如,如圖8-5-4的數據集中查找62,我們可以很快從左上角的索引表中由57<62>96 得到62在第三個塊中。
  • 根據塊首指針找到對應的塊,并在塊中順序查找關鍵碼。因為塊中可以是無序的,因此只能順序查找。
  • 應該說,分塊索引的思想是很容易理解的,我們通常在整理書架時,都會考慮不同的層板放置不同類別的圖書。例如,我家里就是最上層方不太常看的小說,中間層放計算機相關的專業書,這就是分塊的概念,并且讓它們塊間有序了。 只至于上層是《紅樓夢》在《三國演義》的左邊還是有邊,并不是很重要。 畢竟要找小說《紅樓夢》,只需要對這一層的書用眼睛掃一遍即可。

    我們再來分析一下分塊索引的平均查找長度。設n個記錄的數據集被平均分為m塊,每個塊中有t條記錄,顯然 n=m*t. 再假設 Lb 為查找索引表的平均查找長度,因最好與最差的等概率原則,所以Lb的平均長度為 (m+1)/2,Lw為塊中查找記錄的平均查找長度,同理可以知道它的平均查找長度為(t+1)/2.

    這樣分塊索引查找的平均查找長度為:

    注意這個式子的推導是為了讓整個分塊索引查找長度依賴于n和t兩個變量。從這里我們也就得到,平均長度不僅僅取決于數據集的總記錄數n,還和每一個塊的記錄個數t相關。最佳的情況就是分的塊數m與塊中的記錄數t相同,此時意味著 n=m?t=t2n =m*t =t^2n=m?t=t2,即

    可見,分塊索引的效率比順序查找的O(n)是高了不少,不過它顯然與折半查找的O(logn)相比還有不小的差距。 因此再去欸的那個所在塊的過程中,由于塊間有序,所以可以應用折半、插值等手段來提高效率。

    總的來說,分塊索引在兼顧了對細分塊不需要有序的情況下,大大增加了整體查找的速度,所以普遍被用于數據庫表查找等技術的應用當中。

    8.5.3倒排索引

    我不知道大家有沒有對搜索引擎好奇過,無論你查找什么樣的信息,它都可以在極短的時間內給你一些結果,如圖8-5-5所示。是什么算法技術得到這樣的高效查找呢?

    我們在這里介紹最簡單的,也算是最基礎的搜索技術—倒排索引。

    我們來看樣例,現在有兩篇極短的英文文章—其實只能算是英文句子,我們暫時認為它是文章,編號分別是1和2.

    假設我們忽略掉“books” “friends”中的復數s,以及大小寫差異,我們可以整理出這樣的一張單詞表,如表8-5-1所示,并將單詞做了排序,也就是表格顯示了每個不同的單詞分別出現在哪篇文章中,比如“good”出現在兩篇文章中,而“is”只是在文章2中出現。

    有了這樣一張單詞表,我們要搜索文章,就非常方便了。如果你在搜索框中填寫book關鍵字。系統就先在這張單詞表中有序查找“book”,找到后將它對應的文章編號1和2的文章地址(通常在搜索引擎中就是網頁的標題和鏈接)返回,并告訴你,查找到兩條記錄,用時0.0001秒。由于單詞表是有序的,查找效率很高,返回的又只是文章的編號,所以整體速度會非常快。

    如果沒有這張單詞表,為了能證實所有的文章中有沒有沒有關鍵字book,則需要對每一篇文章每一個單詞順序查找。在文章數是海量的情況下,這樣的做法只存在理論上的可行性,現實中沒有人愿意使用它。

    在這里這張單詞表就是索引表,索引項的通用結構是:

    • 次關鍵碼,例如上面的英文單詞;
    • 記錄號表,例如上面的文章編號。

    其中記錄號表存儲具有相同次關鍵字的所有記錄的記錄號(可以是指向記錄的指針或者是該記錄的主關鍵字)。這樣的索引方法就是倒排索引(Inverted index). 倒排索引源于實際應用中需要根據屬性(或字段、次關鍵碼)的值來查找記錄。這種索引表中的每一項都包括一個屬性值和具有該屬性值的各記錄的地址。由于不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,因而稱為倒排索引。

    倒排索引的好處顯然就是查找記錄非常快,基本等于生成索引表后,查找時都不用去讀取記錄,就可以得到結果。但是它的缺點是這個記錄號不定長,比如上例中有7個單詞的文章編號只有一個,而book ,good 等單詞有兩個文章編號,若是對多篇文章所有單詞建立倒排索引,那每個單詞都將對應相當多的文章編號,維護比較困難,插入和刪除操作都需要做相應的處理。

    當然,現實中的搜索技術非常復雜,這里只介紹了最簡單的思想。

    8.6 二叉排序樹

    假設查找的數據集是普通的順序存儲,那么插入操作就是將記錄放在表的末端,給表記錄數加一即可,刪除操作可以是刪除后,后面的記錄向前移,也可以是要刪除的元素與最后一個元素互換,表記錄數減一,反正整個數據集也沒有什么順序,這樣的效率也不錯。應該說,插入和刪除對于順序存儲結構來說,效率是可以接受的,但這樣的表由于無序造成查找的效率低下。

    如果查找的數據集是有序線性表,并且是順序存儲的,查找可以用折半、插值、斐波那契等查找算法來實現,可惜,因為有序,在插入和刪除操作上,就需要耗費大量的時間。

    有沒有一種既可以使得插入和刪除效果不錯,又可以比較高效率地實現查找的算法呢? 還真有。

    我們在8.2 節把這種需要在查找時插入或刪除的查找表稱為動態查找表。我們現在就來看看什么樣的結構可以實現動態查找表的高效率。

    如果在復雜的問題面前,我們束手無策的話,不妨先從最簡單的情況入手。現在我們的目標是插入和查找同樣高效。假設我們的數據集開始只有一個數{62},然后仙子啊需要將88插入數據集,于是數據集成了{62,88},還保持著從小到大有序。再查找有沒有58,沒有則插入,可此時要想在線性表的順序存儲中有序,就得移動62和88的位置,如圖8-6-2的左圖,可不可以不移動呢?


    嗯,當然是可以,那就是二叉樹結構。當我們用二叉樹的方式時,首先我們將第一個數62定為根結點,88因為比62大,因此讓它作為62的右子樹,58因比62小,所以成為它的左子樹。此時58的插入并沒有影響到62和88的關系,如上圖右圖所示。

    也就是說,若我們現在需要對集合{62,88,58,47,35,73,51,99,37,93}做查找,在我們打算創建此集合時就考慮用二叉樹結構,而且是排好序的二叉樹來創建。 如圖8-6-3所示,62,58,88創建好后,下一個數47因為比58小,是它的左子樹(見③),35是47的左子樹(見④),73比62大,但卻比88小,是88的左子樹(見⑤),51比62小,比58小,比47大,是47的右子樹(見⑥),99比62大,比88大,是88的右子樹(見⑦),37比62、58、、47都小,但是比35大,所以37是35的右子樹(見⑧),93則比62、88大,比99小,所以98是99的左子樹(見⑨)。

    這樣我們就得到了一棵二叉樹,并且當我們對它進行中序遍歷時,就可以得到一個有序的序列{35,37,47,51,58,62,73,88,93,99},所以我們通常稱它為二叉排序樹。

    二叉排序樹(Binary Sort Tree) ,又稱二叉查找樹,它或者是一棵空樹,或者是具有下列性質的二叉樹:

    • 若它的左子樹不空,則左子樹上所有結點的值均小于它的根結點的值;
    • 若它的右子樹不空,則柚子樹上所有結點的值均大于它的根結點的值;
    • 它的左右子樹也分別為二叉排序樹。

    從二叉排序樹的定義也可以知道,它前提是二叉樹,然后它采用了遞歸的定義方式,再者,它的結點間滿足一定的次序關系,左子樹結點一定比其雙親結點小,右子樹結點一定比其雙親結點大。

    構造一棵二叉排序樹的目的,其實并不是為了排序,而是為了提高查找和插入刪除關鍵字的速度。不管怎么說,在一個有序數據集上的查找,速度總是要快于無序的數據集的,而二叉排序樹這種非線性結構,也有利于插入和刪除的實現。

    8.6.1 二叉排序樹查找操作

    首先我們提供一個二叉樹的結構

    //二叉樹的二叉鏈表結點結構定義tydedef struct BiTNode{ //結點結構int data; // 結點數據struct BiTNode *lchild, *rchild;// 左右孩子指針} BiTNode, *BiTree;

    然后我們來看看二叉排序樹的查找是如何實現的

    //遞歸查找二叉排序樹T中是否存在key //指針f指向T的雙親,其初始調用值為NULL //若查找成功,則指針p指向該數據元素結點,并返回TRUE //否則指針p指向查找路徑上訪問的最后一個結點并返回FALSEStatus SearchBST(BiTree T, int key , BiTree f, BiTree *p){if(!T){*p=f;return FALSE;}else if(key==T->data){*p=T;return TRUE;}else if(key<T->data){return SearchBST(T->lchild,key,T,p);//在左子樹繼續查找}else return SearchBST(T->rchild,key ,T ,p);//在右子樹繼續查找}

    1 SearchBST 函數是一個可遞歸運行的函數,函數調用時的語句為 SearchBST(T,93,NULL,p);參數T是一個二叉鏈表,其中數據如圖8-6-3所示,key代表要查找的關鍵字,目前我們打算查找93,二叉樹f指向T的雙親,當T指向根結點時,f的初值就為NULL,它在遞歸時有用,最后的參數p時為了查找成功后可以得到查找到的結點位置。

    下面復習一下二叉鏈表

    二叉樹每個結點最多有兩個孩子,所以為它設計一個數據域和兩個指針域是比較自然的想法,我們稱這樣的鏈表為二叉鏈表。結點結構圖如下所示

    其中data為數據域,lchild和rchild都是指針域,分別存放指向左孩子和右孩子的指針。

    以下是我們的二叉鏈表的結點結構定義代碼。

    typedef struct BiTNode{TElemType data; //結點數據struct BiTNode *lchild ,*rchild;//左右孩子指針 }BiTNode, *BiTree;

    結構示意圖如下圖所示

    下面繼續二叉排序樹的查找

    2 第3~7行,是用來判斷當前二叉樹是否到葉子結點,顯然圖8-6-3告訴我們當前T指向根結點62的位置,T不為空,第5 ~ 6行不執行。

    3 第8~12行是查找到相匹配的關鍵字時執行語句,顯然93≠62,第10 ~11行不執行。

    4 第13~14行是當要查找的關鍵字小于當前結點值時執行,由于93>62,第14行不執行。

    5 第15~16行時當要查找的關鍵字大于當前結點值時執行,由于93>62,所以遞歸調用SearchBST(T->rchild,key,T,p); 此時T指向了62的右孩子88,如圖8-6-4所示。

    6 此時第二層SearchBST,因93比88大,所以執行第16行,再次遞歸調用SearchBST(T->rchild,key,T,p); 此時T指向了88的右孩子99,如圖8-6-5所示。

    7 第三層的SearchBST,因93小于99,所以執行第14行,遞歸調用SearchBST(T->lchild,key,T,p); 此時T指向了99的左孩子93,如圖8-6-6所示。

    8 第四層 SearchBST,因為key==T->data,所以執行10~11行,此時指針p指向93所在的結點,并返回TRUE到第三層、第二層、第一層,最終函數返回TRUE.

    8.6.2 二叉排序樹插入操作

    看了二叉排序樹的查找函數,那么所謂的二叉排序樹的插入,其實也就是將關鍵字放到樹中合適的位置而已,來看代碼。

    //當二叉排序樹T中不存在關鍵字等于key的數據元素時, //插入key并返回TRUE,否則返回FALSEStatus InsertBST(BiTree *T, int key){BiTree p,s;if( !SearchBST( *T, key ,NULL, &p)){ //查找不成功:p返回不成功最后訪問的結點位置s=(BiTree) malloc(sizeof(BiTNode));s->data=key; //值key賦值s結點s->lchild=s->rchild=NULL;//葉子結點if(!p){//空的*T=s; // 插入s為新的根結點}else if(key<p->data)p->lchild=s;//插入s為左孩子else p->rchild=s;//插入s為右孩子return TRUE;}else return FALSE;// 樹中已有關鍵字相同的結點,不再插入}

    這段代碼非常簡單。如果你調用函數 InsertBST(T,93) 那么結果就是FALSE ,如果是InsertBST(T,95),那么一定就是在93結點增加一個右孩子95,并且返回TRUE。如圖8-6-7所示。

    有了二叉排序樹的插入代碼,我們要實現二叉排序樹的構建就非常容易了。下面的代碼可以構建如圖8-6-3的一棵樹。

    int i;int a[10]={62,88,58,47,35,73,51,99,37,93};BiTree T=NULL;for(i=0;i<10;i++){InsertBST(&T, a[i]);}

    8.6.3 二叉排序樹刪除操作

    俗話說“請神容易送神難”,我們已經介紹了二叉排序樹的查找和插入算法,但是對于二叉排序樹的刪除,就不是那么容易,我們不能因為刪除了結點,而讓這棵樹變得不滿足二叉排序樹的特性,所以刪除需要考慮多種情況。

    如果需要查找并刪除 如37、51、73、93這些在二叉排序樹中是葉子的結點,那是很容易的,畢竟刪除它們對整棵樹來說,其他結點的結構并未受到影響,如圖8-6-8所示。

    對于要刪除的結點只有左子樹或者右子樹的情況,相對也比較好處理。 那就是結點刪除后,將它的左子樹或右子樹移動到刪除結點的位置即可,可以理解為獨子繼承家業。比如圖8-6-9,就是先刪除35和99結點,再刪除58結點的變化圖,最終,這個結構還是一個二叉排序樹。

    但是對于要刪除的結點既有左子樹又有右子樹的情況怎么辦呢? 比如圖8-6-10中的結點47 若要刪除了,它的兩個兒子以及子孫們怎么辦呢?(這里增加了結點47下的子孫結點數量)

    起初的想法,我們當結點47只有一個左子樹,那么做法和一個左子樹的操作一樣,讓35以及它之下的結點成為58的左子樹,然后再對47的右子樹所有結點進行插入操作,如圖8-6-11所示。這是比較簡單的做法,可是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,用s來代替結點p,然后再刪除此結點s,如圖8-6-12所示。


    根據我們對刪除結點三種情況的分析:

    • 葉子結點;
    • 僅有左子樹或右子樹的結點;
    • 左右子樹都有的結點。
      我們來看代碼,下面這個算法是遞歸方式對二叉排序樹T查找key,查找到時刪除。
    //若二叉排序樹T中存在關鍵字等于key的數據元素時,則刪除該結點 //并返回TRUE,否則返回FALSEStatus DeleteBST( BiTree *T, int key){if( ! * T) //不存在關鍵字等于key的數據元素return FALSE;else{if(key==(*T)->data) return Delete(T);// 找到關鍵字等于key的數據元素else if( key<(*T)->data)return DeleteBST(&(*T)->lchild,key);else return DeleteBST(&(*T)->rchild,key);}}

    這段代碼和前面的二叉排序樹查找幾乎完全相同,唯一的區別在于第8行,此時執行第是Delete方法,對當前結點進行刪除操作。我們來看Delete的代碼

    //從二叉排序樹中刪除結點p,并重接它的左或右子樹Status Delete(BiTree *p){BiTree q,s;if((*p)->rchild ==NULL) { //右子樹為空,只需要重掛接它的左子樹q=*p; (*p)=(*p)->lchild; free(q); //先把它賦值給結點q,然后把它左兒子付給它,然后釋放q}else if( (*p)->lchild == NULL){ //左子樹為空,只需要重掛接它的右子樹q=*p; *p= (*p) ->rchild; free(q);}else{ //左右子樹都不為空q=*p; s=(*p)->lchild;while( s->rchild){ //轉左,燃火向右到盡頭(找待刪結點的前驅)q=s; s=s->rchild;}(*p)->data=s->data; // s指向被刪除結點的直接前驅if(q!= *p)q->rchild=s->lchild; //重接q的右子樹elseq->lchild=s->lchild; //重接q的左子樹free(s);}return TRUE; }

    1 程序開始執行, 代碼第4~7行目的是為了刪除沒有右子樹只有左子樹的結點。此時只需要將此結點的左孩子替換它自己,然后釋放此結點的內存,就等于刪除了。

    2 代碼第8~11行 是同樣的道理 ,處理只有右子樹而沒有左子樹的結點。

    3第12~25行,處理復雜的左右子樹都存在的問題。

    4 在第14行,將要刪除的結點p賦值給臨時變量q,再將p的左孩子 p->lchild 賦值給臨時變量s。此時 q指向47結點, s指向35結點,如圖8-6-13所示。

    5 第15~18行,循環找到左子樹的右結點,直到右側盡頭。就當前的例子來所,就是讓q指向35,而s指向了37這個再沒有右子樹的結點,如下圖所示。

    6 第19行,此時讓待刪除的結點p的位置的數據被賦值為s->data ,即讓 p->data = 37,如下圖所示。【用直接前驅覆蓋】

    7 第20~23行,如果q和p的指向不同,則將s->lchild 賦值給q->rchild,否則就是將s->lchild 賦值給q->lchild,顯然這個例子p不等于q,將s->lchild 指向的36 賦值給 q->rchild ,也就是讓q->rchild 指向36結點,如下圖。

    8 第24行,free(s),就非常好理解了,將結點37刪除,如下圖。

    從這段代碼可以看出,我們其實是在找刪除結點的前驅結點替換,對于用后繼結點替換的思路,方法上是一樣的。

    8.6.4 二叉排序樹總結

    總之,二叉排序樹是以鏈接的方式存儲,保持了鏈接存儲結構在執行插入和刪除操作時不用移動元素的優點,只要找到合適的插入和刪除位置后,僅需要修改鏈接指針即可。 插入刪除的時間性能比較好。而對于二叉排序樹的查找,走的就是從根結點到要查找結點的路徑,其比較次數等于給定值的結點在二叉排序樹的層數。 也就是說,二叉排序樹的查找性能取決于二叉排序樹的形狀。可問題就在于,二叉排序樹的形狀是不確定的。

    例如{62,88,58,47,35,73,51,99,37,93} 這樣的數組,我們可以構建如圖8-6-18左圖的二叉排序樹。但如果數組元素的次序是從小到大有序,則二叉排序樹就變成了極端的右斜樹,注意它依然是一棵二叉排序樹,如右圖。此時,同樣是查找結點99,左圖僅需要2次比較,而右圖就需要進行10次比較才可以得到結果,二者差異很大。

    也就是說,我們希望二叉排序樹是比較平衡的,即其深度與完全二叉樹相同,均為?log2n?+1\lfloor log_2n \rfloor +1?log2?n?+1,那么查找的時間復雜度就是O(logn),近似于二分查找,事實上,上圖中的左圖也不夠平衡,明顯的左重右輕。

    不平衡的最壞情況就是像上圖右圖的斜樹,查找時間復雜度為O(n),這等同于順序查找。

    因此,我們希望對一個集合按照二叉排序樹查找,最好是把它構建成一個平衡的二叉排序樹。這樣我們就引申出另一個問題,如何讓二叉排序樹平衡的問題。

    最后,記住二叉排序樹解決的問題是:

    既可以使得插入和刪除效果不錯,又可以比較高效率地實現查找\color{red}{既可以使得插入和刪除效果不錯,又可以比較高效率地實現查找 }使

    8.7 平衡二叉樹(AVL樹)

    平衡二叉樹(Self-Balancing Binary Search Tree 或Height-Balanced Binary Search Tree),是一種排序二叉樹,其中每一個結點的左子樹和右子樹的高度差至多為1.

    有兩位俄羅斯的數學家 G.M.Adelson-Velskii和 E.M.Landis 在1962年共同發明一種解決平衡二叉樹的算法,所以有不少資料也稱平衡二叉樹為AVL樹。

    從平衡二叉樹的英文名字中,你也可以體會到,它是一種高度平衡的二叉排序樹。那什么叫做高度平衡呢? 意思是說, 要么它是一棵空樹,要么它的左子樹和右子樹都是平衡二叉樹,且左子樹和右子樹的深度之差的絕對值不超過1. 我們將二叉樹上結點的左子樹深度減去右子樹深度的值稱為平衡因子BF(Balance Factor),那么平衡二叉樹上所有結點的平衡因此只可能是-1,0,1.只要二叉樹上有一個結點的平衡因子的絕對值大于1,則該二叉樹就是不平衡的。

    看圖8-7-2,為什么圖1是平衡二叉樹,而圖2而不是呢?這里要考察的是我們對平衡二叉樹定義的理解,它的前提首先是一棵二叉排序樹,右上圖的59比58大,卻是58的左子樹,這是不符合二叉排序樹的定義的。圖3不是平衡二叉樹在于結點58的左子樹高度為2,而沒有右子樹,兩者之差大于1,因此它是不平衡的。而經過適當調整的ttu4,它就符合平衡二叉樹的定義,因此它是平衡二叉樹。

    距離插入結點最近的,且平衡因子的絕對值大于1的結點為根的子樹,我們 稱為最小不平衡子樹。 圖8-7-3,當新插入結點37時,距離它最近的平衡因子絕對值超過1的結點為58,所以從58開始以下的子樹為最小不平衡二叉樹。

    8.7.1 平衡二叉樹實現原理

    平衡二叉樹構建的基本思想就是在構建二叉排序樹的過程中,每當插入一個結點時,先檢查是否因插入而破壞了樹的平衡性,若是,則找出最小不平衡二叉樹。在保持二叉排序樹特性的前提下,調整最小不平衡二叉樹中各結點之間的鏈接關系,進行相應的旋轉,使之成為新的平衡子樹。

    為了能在講解算法時輕松一些,我們先講一個平衡二叉樹構建過程的例子。 假設我們現在有一個數組 a[10]={3,2,1,4,5,6,7,10,9,8} 需要構建二叉排序樹。在沒有學習平衡二叉樹之前,根據二叉排序樹的特性,我們通常會將它構建成如圖8-7-4的圖1所示的樣子。雖然它完全符合二叉排序樹的定義,但是對這樣高度達到8的二叉樹來說,查找是非常不利的。我們更期望能構建如圖8-7-4圖2的樣子,高度為4的二叉排序樹才可以提供高的查找效率。那么現在我們就來研究如何將一個數組構建出圖2的樹結構。

    對于數組 a[10]={3,2,1,4,5,6,7,10,9,8} 的前兩位3和2,我們很正常地構建,到了第三個數“1”時,發現此時根結點3的平衡因子變成了2,此時整棵樹成為了最小不平衡子樹,因此需要調整,如圖8-7-5的圖1(結點左上角數字為平衡因子BF值)。因為BF值為正,因此我們將整個樹進行右旋(順時針旋轉),此時結點2變成了根結點,3變成了2的右孩子,這樣三個結點的BF值均為0,非常的平衡,如圖2所示。

    然后我們再增加結點4,平衡因子沒有超過2,如圖3.

    增加結點5,結點的3的BF值變成了-2,說明要旋轉了。由于BF是負值,所以我們對這棵最小不平衡二叉樹左旋(逆時針旋轉),如圖4,此時我們整個樹又達到了平衡。

    繼續,增加結點6時,發現根結點2的BF值變成了-2,如圖8-7-6的圖6,所以我們對根結點進行左旋,注意此時本來結點3是4的左孩子,由于旋轉后需要滿足二叉排序樹特性,因此它成了結點2的右孩子,如圖7.增加結點7,同樣的左旋轉,使得整棵樹達到平衡,如圖8和圖9.

    當增加結點10時,結構無變化,如圖8-7-7的圖10.再增加結點9,此時結點7的BF變成了-2,理論上我們只需要旋轉最小不平衡子樹7、9、10即可,但是如果左旋轉后,結點9就變成了10的右孩子,這是不符合二叉排序樹的特性的,此時不能簡單地左旋,如圖11所示。

    仔細觀察圖11,發現根本原因在于結點7的BF是-2,而結點10的BF是1,也就是說,他們倆一正一負,符號并不統一,而前面的幾次旋轉,無論左旋還是右旋,最小不平衡子樹的根結點它的子結點符號都是相同的。這就是不能直接進行旋轉的關鍵。那怎么辦呢?

    不統一,不統一就把它們先轉到符號統一再說,于是我們先對結點9和結點10右旋,使得10變成結點9的右子樹,結點8的BF變成-1,此時就和結點7的BF值符號統一了,如圖8-7-7的圖12所示。

    這樣我們再以結點7為最小不平衡子樹進行左旋,得到圖8-7-8的圖13.
    接著插入結點8,情況和剛才類似,結點6的BF是-2,而它的右孩子9的BF是1,如圖14,因此首先以9為根結點,進行右旋,得到圖15,此時結點6和結點7的符號都是負,再以6為根結點左旋,最終得到最后的平衡二叉樹。

    相信大家有點明白,所謂的平衡二叉樹,其實就是在二叉排序樹創建過程中保證它的平衡性,一旦發現有不平衡的情況,馬上處理,這樣就不會造成不可收拾的情況出現。通過剛才這個例子,你會發現,當最小不平衡子樹根結點的平衡因子BF是大于1時,就右旋,小于-1時就左旋。插入結點后,最小不平衡子樹的BF與它的子樹的BF符號相反時,就需要對結點先進行一次旋轉以使得符號相同,之后再反向旋轉一次才能夠完成平衡操作。

    8.7.2 平衡二叉樹實現算法

    好了,有了這么多的準備工作,我們可以來講解代碼了。首先是需要改進二叉排序樹的結點結構,增加一個bf,用來存儲平衡因子。

    //二叉樹的二叉鏈表結點結構定義typedef struct BiTNode{int data;// 結點數據int bf;// 結點的平衡因子struct BiTNode *lchild, *rchild;//左右孩子指針 }BiTNode, *BiTNode;

    然后,對于右旋操作,我們的代碼如下。

    //對以p為根的二叉排序樹作右旋處理 //處理之后p指向新的樹根結點,即旋轉處理之前的左子樹的根結點void R_Rotate(BiTree *p){BiTree L;L=(*p) -> lchild; //L指向p的左子樹根結點(* p) -> rchild=L->rchild;L->rchild=( *p);*p =L;}

    此段代碼的意思是說,當傳入一個二叉排序樹P,將的它的左孩子結點定義為L,將L的右子樹變成P的左子樹,再將P改成L的右子樹,最后將L替換P成為根結點。 這樣就完成了一次右旋操作,如圖8-7-9所示。圖中三角形代筆子樹,N代表新增結點。 上面例子中的新增結點N(對應下圖中的圖1和圖2),就是右旋操作。

    左旋操作代碼如下

    //對以p為根的二叉排序樹做左旋處理//處理之后p指向新的樹根結點,即旋轉處理之前的右子樹的根結點R void L_Rotate(BiTree *p){BiTree R;R=( *p)->rchild;(*p)->rchild= R->rchild;R->lchild=(*p);*p=R; //p指向新的根結點 }

    這段代碼和右旋代碼對稱,在此不做過多解釋。

    現在我們來看看左平衡旋轉處理的函數代碼

    #define LH 1 //左高 #define EH 0 //等高 #define RH -1 //右高//對以指針T所指結點為根的二叉樹作左平衡旋轉處理 //本算法結束時,指針T指向新的根結點void LeftBalance(BiTree *T){BiTree L,Lr;L=(*T)->lchild;// L指向T的左子樹根結點switch( L->bf){case LH: // 新結點插入在T的左孩子的左子樹上,要做單右旋處理(*T)->bf=L->bf=EH;R_Rotate(T);break;case RH: //新結點插入在T的左孩子的右子樹上,要做雙旋處理Lr=L->rchild;// Lr指向T的左孩子的右子樹根switch(Lr->bf){case LH:(*T)->bf=RH;L->bf=EH;break;case EH:(*T)->bf=L->bf=EH;break;case RH:(*T)->bf=EH;L->bf=LH;break;}Lr->bf=EH;L_Rotate(*(*T)->lchild); //對T的左子樹作左旋平衡處理R_Rotate(T);//對T作右旋平衡處理}}

    首先,我們定義了三個常數變量,分別是1、0和-1.

    1 函數被調用,傳入一個需要調整平衡性的子樹T. 由于LeftBalance 函數被調用時,其實是已經確認當前子樹是不平衡狀態,且左子樹的高度大于右子樹的高度。換句話說,此時T的根結點應該是平衡因子BF的值大于1的數。

    2 第4行,我們將T的左孩子賦值給L.

    3 第5~27行是分支判斷

    4 當L的平衡因子為LH,即為1時,表明它與根結點的BF值符號相同,因此,第8行,將 它們的BF值都改成0,并且第8行,進行右旋操作。操作方式如圖8-7-9所示。

    5 當L的平衡因子為RH,即為-1時,表明它與根節點的BF值符號相反,此時需要作雙旋處理。第13~22行代碼,針對L的右孩子Lr的BF作判斷,修改根結點T的L的BF值。第24行將當前Lr的BF改為0.

    6 第25行,對根結點的左子樹進行左旋,如圖8-7-10第二圖所示。

    7 第26行,對根結點進行右旋,如圖8-7-10第三圖所示,完成平衡操作。


    同樣的,右平衡旋轉處理的函數代碼非常類似,直接看代碼,不做講解了。

    /* 對以指針T所指結點為根的二叉樹作右平衡旋轉處理, */ /* 本算法結束時,指針T指向新的根結點 */ void RightBalance(BiTree *T) { BiTree R,Rl;R=(*T)->rchild; /* R指向T的右子樹根結點 */ switch(R->bf){ /* 檢查T的右子樹的平衡度,并作相應平衡處理 */ case RH: /* 新結點插入在T的右孩子的右子樹上,要作單左旋處理 */ (*T)->bf=R->bf=EH;L_Rotate(T);break;case LH: /* 新結點插入在T的右孩子的左子樹上,要作雙旋處理 */ Rl=R->lchild; /* Rl指向T的右孩子的左子樹根 */ switch(Rl->bf){ /* 修改T及其右孩子的平衡因子 */ case RH: (*T)->bf=LH;R->bf=EH;break;case EH: (*T)->bf=R->bf=EH;break;case LH: (*T)->bf=EH;R->bf=RH;break;}Rl->bf=EH;R_Rotate(&(*T)->rchild); /* 對T的右子樹作右旋平衡處理 */ L_Rotate(T); /* 對T作左旋平衡處理 */ } }

    我們前面例子中的新增結點9和8就是典型的右平衡旋轉,并且雙旋完成平衡的例子(如圖9-7-7的圖11,12,圖8-7-8的圖14、15、16)

    有了這些準備,我們的主函數才正式登場

    //若平衡的二叉排序樹T中不存在和e有相同關鍵字的結點,則插入一個數據元素為額的新結點并返回1,否則返回0. //若因插入而使二叉排序樹失去平衡,則作平衡旋轉處理,布爾變量taller反映T長高與否。Status InsertAVL(BiTree *T, int e, Status *taller){if(! *T){//插入新結點,樹“長高”,置taller 為TRUE*T=(BiTree) malloc(sizeof(BiNode));(*T)->data=e;(*T)->lchild=(*T)->rchild=NULL;(*T)->bf=EH;*taller=TRUE;}else{if(e==(*T)->data){//樹中已經存在和e有相同關鍵字的結點則不再插入*taller=FALSE;return FALSE;}if( e<(*T)->data){//應繼續在T的左子樹中進行搜索if( !InsertAVL( &(*T)->lchild,e,taller)) //未插入return FALSE;if(*taller){//已經插入到T的左子樹中且左子樹長高switch((*T)->bf){//檢查T的平衡度case LH://原本左子樹比右子樹高,需要作左平衡處理LeftBalance(T);*taller=FALSE;break;case EH://原本左右子樹高度相同,現因為左子樹增高而樹增高(*T)->bf=LH;*taller=TRUE;break;case RH: //原本右子樹比左子樹高,現在左右子樹等高(*T)->bf=EH;*taller=FALSE;break;}}}else{//應該繼續在T的右子樹中進行搜索if( !InsertAVL( &(*T)->rchild,e,taller)) //未插入return FALSE;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: //原本右子樹比左子樹高,需要作右平衡處理RightBalance(T);*taller=FALSE;break;}}}}return TRUE; }

    1 程序開始執行時,第3~10行是指當前T為空時,則申請內存新增一個結點。

    2 第13~17行表示當存在相同結點,則不需要插入

    3 第18~40行,當新結點小于T的跟結點值時,則在T的左子樹中查找

    4 第20~21行,遞歸調用本函數,直到找到則返回false,否則說明插入結點成功,繼續執行下面的語句。

    5 第22~39行,當taller為true時,說明插入了結點(在左子樹中),此時需要判斷T的平衡因子,如果時1,說明左子樹高于右子樹,需要調用LeftBalance函數進行左平衡旋轉處理。如果為0或者-1,則說明新插入結點沒有讓整棵二叉排序樹失去平衡性,只需要修改相關的BF值即可。

    6 第41~63行,說明新結點e大于T的根結點的值,在T的有子樹中查找。代碼與上面類似。不再贅述。

    對于這段代碼,我們只需要在需要構建平衡二叉樹的時候執行如下列代碼即可在內存中生成一棵與圖8-7-4的圖2相同的平衡的二叉樹。

    int i; int a[10]={3,2,1,4,5,6,7,10,9,8} BiTree T=NULL; Status taller; for(i=0;i<10;i++){InserAVL(&T,a[i],&taller); }

    終于講完了,本算法代碼較長,是有些復雜,編程中容易在很多細節上出錯,要想真正掌握它,需要自己多練習。不過其思想還是不難理解的,總之就是把不平衡消滅在最早時刻。

    如果我們需要查找的集合本身沒有順序,在頻繁查找的同時也需要經常的插入和刪除,顯然我們需要構造一棵二叉排序樹,但是不平衡的二叉排序樹,查找效率是很低的,因此我們在構建時,就讓這棵二叉排序樹是平衡二叉樹,此時我們的查找時間復雜度為O(logn),而插入和刪除也為O(logn)。這顯然是比較理想的一種動態查找表算法。

    8.8 多路查找樹(B樹)

    內存一般都是由硅制的存儲芯片組成,這種技術的每個存儲單元單位代價都是比磁盤存儲技術昂貴兩個數量級,因此基于磁盤技術的外存,容量比內存的容量至少大兩個數量級。這也就是目前PC通常內存幾個G而已,而硬盤去可以成百上千G容量的原因。

    我們前面討論的數據結構,處理數據都是在內存中,因此考慮的都是內存中的運算時間復雜度。

    但如果我們要操作的數據集非常大,達到內存已經沒有辦法處理了怎么辦呢? 如數據庫中上千萬條記錄的數據表、硬盤中上萬個文件等。在這種情況下,對數據的處理需要不斷從硬盤等存儲設備中調入或調出內存頁面。

    一旦涉及到這樣的外部存儲設備,關于時間復雜度的計算就會發生變化,訪問該集合元素的時間已經不僅僅是尋找該元素所需比較次數的函數,我們必須考慮對硬盤等外部設備的訪問時間以及將會對該設備做出多少次單獨訪問。

    試想一下,為了要在一個擁有幾十萬個文件的磁盤中查找一個文本文件,你設計的算法需要讀取磁盤上萬次還是讀取幾十次,這是有本質差別的。此時,為了降低對外存設備的訪問次數,我們就需要新的數據結構來處理這樣的問題。

    我們之前談到的樹,都是一個結點可以有多個孩子,但是它自身只存儲一個元素。二叉樹限制更多,結點最多只能有兩個孩子。

    一個結點只能存儲一個元素,在元素非常多的時候,就使得要么樹的度(結點擁有子樹的個數的最大值)非常大,要么樹的高度非常大,甚至兩者都必須足夠大才行。這就使得內存存取外存次數非常多,這顯然成立時間效率上的瓶頸,這迫使我們要打破每一個結點只存儲一個元素的限制,為此引入了多路查找樹這個概念。

    多路查找樹(Multi-way Search Tree),其每一個結點的孩子數可以多于兩個,且每一個結點處可以存儲多個元素。由于它是查找樹,所以元素之間存在某種特定的排序關系

    在這里,每一個結點可以存儲多少個元素,以及它的孩子數的多少是非常關鍵的。為此,我們講解它的四種特殊形式:2-3樹,2-3-4樹,B樹和B+樹。

    8.8.1 2-3樹

    2和3是基本的阿拉伯數字,用它們來命名一種樹結構,顯然是說明這種結構與數字2和3有密切關系。

    2-3樹是這樣的一棵多路查找樹:其中的每一個結點都具有兩個孩子(我們稱之為2結點)或三個孩子(我們稱之為3結點)。

    一個2結點包含一個元素和兩個孩子(或沒有孩子),且與二叉排序樹類似,左子樹包含的元素小于該元素,右子樹包含的元素大于該元素。不過,與二叉排序樹不同的是,這個2結點要么沒有孩子,要有就有2個,不能只有1個孩子。

    一個3結點包含一小一大兩個元素和三個孩子(或沒有孩子),一個3結點要么沒有孩子,要么具有3個孩子。如果某個3結點有孩子的話,左子樹包含小于較小元素的元素,右子樹包含大于較大元素的元素,中間子樹包含介于兩元素之間的元素。

    并且2-3樹中所有葉子結點都在同一層次上。如圖8-8-2所示,圖中就是一棵有效的2-3樹。

    2-3樹的插入實現

    對于2-3樹的插入來說,與二叉排序樹相同,插入操作一定是發生在葉子結點上。可與二叉排序樹不同的是,2-3樹插入一個元素的過程有可能會對該樹的其余結構產生連鎖反應。

    2-3樹插入可分為三種情況:

    1)對于空樹,插入一個2結點即可,這很容易理解。

    2)插入結點到一個2結點的葉子上。應該說,由于其本身就只有一個元素,所以只需要將其升級為3結點即可。如圖8-8-3所示(這里是對圖8-8-2的簡化表達),我們希望從左圖的2-3樹種插入元素3,根據遍歷可知,3比8小,比4小,于是就只能考慮插入到葉子結點1所在的位置,因此很自然的想法就是將此結點變成一個3結點,即右圖這樣完成插入操作。當然,要視插入的元素與當前葉子結點的元素大小關系,決定誰在左誰在右。例如,若插入的是0,則此結點就是0在1的左邊了。

    3)要往3結點中插入一個新元素。因為3結點本身已經是2-3樹的結點最大容量(已經有兩個元素),因此就需要將其拆分,且在該3結點中的兩元素以及待插入元素中,選擇其一向上移動一層。復雜的情況也在于此。

    第一種情況,見圖8-8-4,需要向左圖中插入元素5.經過遍歷可得到元素6比8小比4大,因此它應該是需要插入在擁有6和7元素的這個3結點位置。問題就在于,這已經是一個3結點,不能再添加。此時發現它的雙親結點4是個2結點,因此考慮讓它升級為3結點,這樣它就得有3個孩子,于是就想到,將6、7結點拆分,讓6與4結合形成3結點,將5成為它的中間孩子,將7變成它的右孩子,如右圖。

    另一種情況,如圖8-8-5所示,需要向左圖插入元素11.經過遍歷可得到元素比12小,比10大,因此它應該插入在擁有9、10元素的3結點位置。同樣道理,9和10 所在節點不能再增加元素。此時發現它的雙親結點12,14也是3結點,也不能再插入新元素。在往上看,12,14的雙親結點,結點8是2結點。于是就想到,將9,10拆分,12,14也拆分,讓根結點8升級到3結點,最終形成右圖的樣子。

    再來看個例子,如圖8-8-6所示,需要在左圖中插入元素2.

    經過遍歷可得到元素2比4小,比1大,因此需要插入在擁有1和3元素的3結點位置。與上例一樣,你會發現1,3結點,4,6結點,甚至是8,12都是3結點,那就意味著,當前我們的樹結構是3層已經不能滿足結點增加的要求了。于是將1,3拆分,4,6拆分,甚至根結點8,12也拆分,形成右圖所示的樣子。

    通過這個例子,也讓我們發現,如果2-3樹插入的傳播效應導致了根結點的拆分,則樹的高度就會增加。

    2-3樹的刪除實現

    對于2-3樹的刪除來說,如果對前面插入的理解到位的話,應該不是難事兒。2-3樹的刪除也分為三種情況。與插入相反,我們從3結點說起。

    1) 所刪除元素位于一個3結點的葉子結點上,這非常簡單,只需要在該結點處刪除該元素即可,不會影響到整棵樹的其他結點的結構。如圖8-8-7所示,刪除元素9,只需要將此結點改成只有元素10的2結點即可。

    2) 所刪除的元素位于一個2結點上,即要刪除的是一個只有一個元素的結點。如果按照以前樹的理解,刪除即可,可是現在2-3樹的定義告訴我們這樣做是不可以的。比如圖8-8-8中,如果我們刪除了結點1,那么結點4本來是一個2結點(它擁有2個孩子),此時它就不滿足定義了。


    對于刪除葉子是2結點的情況,我們需要分為四種情況來處理。

    情形一,此結點的雙親也是2結點,且擁有一個3結點的右孩子。
    如圖8-8-9,刪除結點1,那么只需要左旋,即6變成雙親,4成為6的左孩子,7是6的右孩子。

    情形二,此結點的雙親是2結點,它的右孩子也是2結點。

    如圖8-8-10,此時刪除結點4,如果直接左旋回造成沒有右孩子,因此需要對整棵樹變形,辦法就是,我們的目標就是讓結點7變成結點3,那就得讓比7稍大的元素8下來,隨即就得讓比元素8稍大的元素補充結點8的位置,于是就有了中間圖,于是再用左旋的方式,變成右圖的效果。

    情形三,此結點的雙親是一個3結點。

    如圖8-8-11,此時刪除結點10,意味著雙親12,14這個結點不能成為3結點了,于是將此結點拆分,并將12與13合并成為左孩子。

    情形四,如果當前樹是一個滿二叉樹的情況,此時刪除任何一個結點都使得整棵樹不能滿足2-3樹的定義。如圖8-8–12所示,刪除葉子結點8時,就不得不考慮要將2-3的層數減少,辦法是將8的雙親和其左子樹6合并成為一個3結點,再將14與9合并成3結點,最后變成右圖。

    3)所刪除的元素位于非葉子的分支結點。此時我們通常是將樹按照中序遍歷后得到此元素的前驅或者后繼元素,考慮讓它們來補位即可。

    如果我們要刪除的分支結點是2結點。如圖8-8-13,我們要刪除結點4,分析后得到它的前驅是1后繼是6,顯然,由于6,7是3結點,只需要用6來補位即可。

    如果我們要刪除的分支結點是3結點的某一元素,如圖8-8-14所示我們要刪除12,14結點的12,此時,經過分析,顯然應該是將3結點的左孩子的10上升到刪除位置合適。

    當然,如果對2-3樹的插入和刪除等所有情況講解,既占篇幅,又沒必要,總的來說它是有規律 的,需要你們在上面的這些例子中多去體會后掌握。

    8.8.2 2-3-4樹

    有了2-3樹的講解,2-3-4樹就很好理解了,它其實就是2-3樹概念的拓展,包括了4結點的使用。一個4結點包含小中大三個元素和四個孩子(或沒有孩子),一個4結點要么沒有孩子,要么具有4個孩子。如果某個4結點有孩子的話,左子樹包含小于最小元素的元素;第二個子樹包含大于最小元素,小于第二元素的元素;第三子樹包含大于第二元素,小于最大元素的元素;右子樹包含大于最大元素的元素。

    由于2-3-4樹和2-3樹類似,我么你這里就簡單介紹一下,如果我們構建一個數組為{7,1,2,5,6,9,8,4,3} 的2-3-4樹的過程,如圖8-8-15所示。圖1是在分別插入7,1,2時候的結果,因為3個元素滿足2-3-4樹的單個4結點定義,因此此時不需要拆分,接著插入元素5,因為已經超過4結點的定義,因此要拆分,變成圖2的樣子。之后的圖其實就是在元素不斷插入時最后形成了圖7的2-3-4樹。

    圖8-8-16 是對一個2-3-4樹的刪除結點的演變過程,刪除順序為1,6,3,4,5,2,9.

    8.8.3 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-1個元素和k個孩子,其中 ?m/2?≤k≤m(這里是向上取整)\lceil m/2\rceil ≤k≤m(這里是向上取整)?m/2?km
    • 所有葉子結點都位于同一層次。
    • 所有分支結點包含下列信息數據n,A0,K1,A1,K2,A2,...,Kn,Ann,A_0,K_1,A_1,K_2,A_2,...,K_n,A_nn,A0?,K1?,A1?,K2?,A2?,...,Kn?,An? ,其中 KiK_iKi?為關鍵字,且Ki<Ki+1,K_i<K_{i+1},Ki?<Ki+1?,Ai為指向子樹根結點的指針,且指針Ai?1A_{i-1}Ai?1?所指子樹中所有結點的關鍵字均小于 KiK_iKi?,AnA_{n}An?所指子樹中所有結點的關鍵字均大于KnK_nKn?,n?m/2??1≤n≤m?1(這里是向上取整)\lceil m/2\rceil-1 ≤n≤m-1(這里是向上取整)?m/2??1nm?1)為關鍵字的個數(或n+1為子樹的個數)

    例如,在講2-3-4樹的時候插入9個數后的圖轉成B樹示意圖就如圖8-8-17右圖所示,左側灰色方塊表示當前結點的元素個數。

    在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樹的數據結構就是為內外存的數據交互準備的。

    那么對于n個關鍵字的m階B樹,最壞情況是要查找幾次呢? 我們來分析一下。

    第一層至少有1個結點,第二層至少有2個結點,由于除根結點外每個分支結點至少有?m/2?\lfloor m/2\rfloor?m/2?棵子樹,則第三層至少有2×?m/2?2\times \lfloor m/2\rfloor2×?m/2?個結點,…,這樣第k+1層至少有2×(?m/2?)k?12\times (\lfloor m/2\rfloor)^{k-1}2×(?m/2?)k?1個結點,而實際上,k+1層的結點就是葉子結點。若m階B樹有n個關鍵字,那么當你找到了葉子結點,其實也就等于查找不成功的結點為n+1,因此n+1≥2×(?m/2?)k?1n+1≥2\times (\lfloor m/2\rfloor)^{k-1}n+12×(?m/2?)k?1,即:

    k≤log?m2?(n+12)+1k≤ log_{\lceil \frac{m}{2}\rceil}(\frac{n+1}{2})+1klog?2m???(2n+1?)+1

    也就是說,在含有n個關鍵字的B樹上查找時,從根結點到關鍵字結點路徑上涉及的結點數不超過log?m2?(n+12)+1log_{\lceil \frac{m}{2}\rceil}(\frac{n+1}{2})+1log?2m???(2n+1?)+1

    8.8.4 B+樹

    盡管我們講了B樹,但是它還是有缺陷的。對于樹結構來說,我們都可以通過中序遍歷來順序查找樹中的元素,這一切都是在內存中進行。

    可是在B樹結構中,我們往返于每個結點之間也就意味著,我們必須得在硬盤的頁面之間進行多次訪問,如圖8-8-18所示,我們希望遍歷這棵B樹,假設每個結點都屬于硬盤的不同頁面,我們為了中序遍歷所有的元素,頁面2→頁面1→頁面3→頁面1→頁面4→頁面1→頁面5. 而且我們每次經過結點遍歷時,都會對結點中元素進行一次遍歷,這就非常糟糕。有沒有可能讓遍歷時每個元素只訪問一次呢?

    為了說明解決這個問題的方法,我舉個例子。一個優秀的企業盡管可能有非常成熟的屬性組織結構,但是這并不意味著員工也很滿意,恰恰相反,由于企業管理更多考慮的是企業的利益,這就容易忽略員工的各種訴求,造成了管理者與員工之間的矛盾。正因為如此,工會就產生了,工會原意指基于共同利益而自發組織的社會團體。這個共同利益團體諸如為同一雇主工作的員工,在某一產品領域的個人。工會組織成立的主要作用,可以與雇主談判工資薪水、工作時限和工作條件等。這樣,其實在整個企業的運轉過程中,除了正規的層級管理外,還有一個代表員工的團隊在發揮另外的作用。

    同樣的,為了解決所有元素遍歷等基本問題,我們在原有的B樹結構基礎上,加上了新的元素組織方式,這就是B+樹。

    B+樹是應文件系統的需求而出的一種B樹的變形樹,注意嚴格意義上來講,它其實已經不是第六章定義的樹了。 在B樹中,每一個元素在該樹中只出現一次,有可能在葉子結點上,也有可能在分支結點上。而在B+樹中,出現在分支結點中的元素會被當作它們在分支結點位置的中序后繼者(葉子結點)中再次列出。另外,每一個葉子結點都會保存一個孩子想后一葉子結點的指針。

    例如圖8-8-19所示,就是一棵B+樹的示意圖,灰色關鍵字即是根節點中的關鍵字在葉子結點再次列出,并且所有葉子結點都鏈接在一起。

    一棵m階的B+樹和m階B樹的差異在于:

    • 有n棵子樹的結點中包含有n個關鍵字
    • 所有的葉子結點包含全部關鍵字的信息,及指向含有這些關鍵字記錄的指針,葉子結點本身依關鍵字的大小自小而大順序鏈接。
    • 所有分支結點可以看成是索引,結點中僅含有其子樹中的最大(最小)關鍵字。

    這樣的數據結構最大好處在于,如果要隨機查找,我們就從根結點出發,與B樹的查找方式相同,只不過即使在分支結點找到了待查找的關鍵字,它也只是用來索引的,不能提供實際記錄的訪問,還是需要到達包含此關鍵字的終端結點。

    如果我們是需要從最小關鍵字進行從小到大的順序查找,我們就可以從最左側的葉子結點出發,不經過分支結點,而是沿著指向下一葉子的指針就可以遍歷所有的關鍵字。

    B+樹的結構特別適合帶有范圍的查找。比如查找我們學校18~22歲的學生的人數,我們可以通過從根結點出發找到第一個18歲的學生,然后再在葉子結點按順序查找符合范圍的所有記錄。

    B+樹的插入、刪除過程也都與B樹類似,只不過插入和刪除的元素都是在葉子結點上進行而已。

    8.9 散列表(哈希表)查找概述

    能夠直接通過關鍵字key得到要查找的記錄的內存存儲的位置呢?

    8.9.1 散列表查找定義

    我們需要某個函數 f,使得

    存儲位置= f ( 關鍵字 )

    那樣我們就可以通過查找關鍵字不需要比較就能獲得需要的記錄的存儲位置。這是一種新的存儲技術—散列技術。

    散列技術是在記錄的存儲位置和它的關鍵字之間建立一個確定的對應關系f,使得每個關鍵字key對應一個存儲位置f(key) 。 查找時,根據這個確定的對應關系找到給定值key 的映射f(key),若查找集合中存在這個記錄,則必定在f(key) 的位置上。

    這里我們把這種對應關系f稱為散列函數 ,又稱為哈希(Hash)函數。
    按這個思想,采用散列技術將記錄存儲在一塊連續的存儲空間中,這塊連續空間稱為散列表或者哈希表(Hash Table)。那么關鍵字對應的記錄存儲位置我們稱為散列地址。

    8.9.2 散列表查找步驟

    整個散列過程其實就是兩步。

    1)在存儲時,通過散列函數計算記錄的散列地址,并按照此散列地址存儲該記錄。不管是什么記錄,我們需要用同一個散列函數計算出地址再存儲。

    2)當查找記錄時,我們通過同樣的散列函數計算記錄的散列地址,按此散列地址訪問該記錄。說起來很簡單,在哪兒存的,就到哪兒找,由于存取用的是同一個散列函數,因此結果當然是相同的。

    所以說,散列技術既是一種存儲方法,也是一種查找方法。然而它與線性表、樹、圖等結構不同的是,前面幾種結構,數據元素之間都存在某種邏輯關系,可以用連線圖示表示,而散列技術的記錄之間不存在什么邏輯關系,它只有關鍵字有關。因此,散列主要是面向查找的存儲結構。

    散列技術最適合的求解問題是查找與給定值相等的記錄。 對于查找來說,簡化了比較過程,效率就會大大提高。但萬事有利就有弊,散列技術不具備很多常規數據結構的能力。

    比如那種同樣的關鍵字,它能對應很多記錄的情況,卻不適合用散列技術。一個班級幾十個人,他們的性別有男有女,你用關鍵字男去查找,對應的有許多學生的記錄,這顯示是不合適的。只有用如學號或者身份證號來散列存儲,此時一個號碼唯一對應一個學生。

    同樣散列表也不適合范圍查找,比如查找一個班級18~22歲的同學,在散列表中無法進行。想獲得表中記錄的排序也不可能,像最大值、最小值等結果也都無法從散列表中計算出來。

    我們說了這么多,散列函數應該如何設計? 這個我們需要重點來講解,總之設計一個簡單、均勻、存儲利用率高的散列函數是散列技術中最關鍵的問題。

    另一個問題是沖突。在理想的情況下,每一個關鍵字,通過散列函數計算出來的地址都是不一樣的,可現實中,這只是一個理想。我們時常會碰到兩個關鍵字 key1≠ key2,但是f(key1) = f(key2) ,這種現象我們稱為沖突(collision),并把key1和key2稱為這個散列函數的同義詞(synonym)。出現了沖突當然非常糟糕,那就造成數據查找錯誤。經過我們可以通過精心設計的散列函數讓沖突盡可能少,但是不能完全避免。于是如何處理沖突就成了一個重要的課題,后面會有詳細講解。

    8.10 散列函數的構造方法

    不管做什么事要達到最優都不容易,既要付出盡可能的少,又要得到最大化的多。那么什么才是好的散列函數呢? 這里我們有兩個原則可以參考。
    1 計算簡單

    散列函數的計算時間不應該超過其他查找技術與關鍵字比較的時間。

    2散列地址分布均勻

    我們剛才也提到沖突帶來的問題,最好的辦法就是盡量讓散列均勻地分布在存儲空間中,這樣可以保證存儲空間的有效利用,并減少為處理沖突而耗費的時間。

    接下來我們介紹幾種常用的散列函數構造方法。

    8.10.1 直接定址法

    我們現在要對0~100歲的人口數字統計,如表8-10-1所示,那么我們對年齡這個關鍵字就可以直接用年齡的數字作為地址。此時f(key)=key.

    如果我們現在要統計的是80后出生年份的人口數,如下表。那么我們對出生年份這個關鍵字可以用年份減去1980來作為地址。此時f(key)=key-1980.

    也就是說,我們可以取關鍵字的某個線性函數值作為散列地址,即
    f(key)=a×key+b,(a,b為常數)f(key)= a \times key +b ,(a,b為常數)f(key)=a×key+b,(a,b)

    這樣的散列函數優點是簡單、均勻,也不會產生沖突,但問題是這需要事先知道關鍵字的分布情況,適合查找表較小且連續的情況。由于這樣的限制,在現實應用中,此方法雖然簡單,但卻并不常用。

    8.10.2 數字分析法

    如果我們的關鍵字是位數較多的數字,比如我們的11為手機號,其中前三位是接入號,一般對應不同運營商公司的子品牌,比如130是聯通如意通,136是移動神州行,153是電信等。中間四位是HLR識別號,表示用戶號的歸屬地;后四位才是真正的用戶號,如下表。

    若我們現在要存儲某家公司員工登記表,如果用手機號作為關鍵字,那么極有可能前7位都是相同的。那么我們選擇后面的四位稱為散列地址就是不錯的選擇。如果這樣的抽取工作還是容易出現沖突問題,還可以對抽取出來的數字再進行反轉(如1234改成4321),右環位移(如1234變成4123),左環位移,甚至前兩數和后兩數疊加(如1234改成12+34=46)等方法。總的目的就是為了提供一個散列函數,能夠合理地將關鍵字分配到散列表的各位置。

    這里我們提到了一個關鍵詞—抽取。抽取方法是使用關鍵字的一部分來計算散列存儲位置的方法,這在散列函數中是經常用到的手段。

    數字分析法適合處理關鍵字位數比較多的情況,如果事先知道關鍵字的分布且關鍵字的若干位分布較均勻,就可以考慮用這個方法。

    8.10.3 平方取中法

    這個方法計算很簡單,假設關鍵字是1234,它的平方是1522756,再抽取中間的3位就是227,用作散列地址。 再比如關鍵字4321,那么它的平方就是18671041,抽取中間的3位就可以是671或者是710,用作散列地址。平方取中法比較適用于不知道關鍵字的分布,而位數又不是很大的情況。

    8.10.4 折疊法

    折疊法是將關鍵字從左到右分割成位數相等的幾部分(注意最后一部分位數不夠時可以短些),然后將這幾部分疊加求和,并按散列表表長,取后幾位作為散列地址。

    比如我們的關鍵字是 9876543210,散列表表長為3位,我們將它分為四組,987,654,321,0,然后將它們疊加求和987+654+321+0=1962,再求后3位得到散列地址為962.

    有時可能這還不夠保證均勻分布,不妨從一端向另一端來回折疊后對齊相加。比如我們將987和321反轉,再與654和0相加,變成789+654+123+0=1566,此時散列地址為566.

    折疊法事先不需要知道關鍵字的分布,適合關鍵字位數較多的情況。

    8.10.5 除留余數法

    此方法為最常用的構造散列函數的方法。對于散列表長為m的散列函數公式為

    f(key)=keymodp(p≤m)f ( key )= key \mod \quad p \quad (p≤ m)f(key)=keymodp(pm)

    mod是取模(求余數)的意思。事實上,這方法不僅可以對關鍵字直接取模,還可以在折疊、平方取中后再取模。

    很顯然,該方法的關鍵在于選擇合適的p,p如果選得不好,就可能會容易產生同義詞。

    例如表8-10-4,我們對于有12個記錄的關鍵字構造散列表,就用了f(key)= key mod 12 的方法,比如 29 mod 12 =5 ,所以它存儲在下標為5的位置。


    不過這也會存在沖突, 如下表,p=12,此時下標全部是0,這就極為糟糕。

    我們不選 p=12來做除留余數法,而選用p=11,如下表

    此時只有12和144有沖突,相對來說,就要好很多。

    因此根據前輩們的經驗,若散列表表長為m,通常p為小于等于表長(最好接近m)的最小質數或不包含小于20質因子的合數。

    8.10.6 隨機數法

    選擇一個隨機數,取關鍵字的隨機函數值為它的散列地址。也就是f(key)= random(key) ,這里的random是隨機數函數。當關鍵字的長度不等時,采用這個方法構造散列函數是比較合理的。

    有同學問,如果關鍵字是字符串如何處理? 其實無論是英文字符,還是中文字符,也包含各種各樣的符號,它們都可以轉化為某種數字來對待,比如ASCII碼或者Unicode碼等,因此也就可以使用上述方法。

    總之,現實中,應該視不同的情況采用不同的散列函數。我們只能給出一些考慮因素來提供參考:

  • 計算散列地址所需要的時間
  • 關鍵字的長度
  • 散列表的大小
  • 關鍵字的分布情況
  • 記錄查找的頻率。
  • 綜合這些因素,才能決策選擇哪種散列函數更合適。

    8.11 處理散列沖突的方法

    沖突其實是不能避免的,要考慮怎么來處理。

    8.11.1 開放定址法

    所謂的開放定址法就是一旦發生了沖突,就去尋找下一個空的散列地址,只要散列列表足夠大,空的散列地址總能找到,并將記錄存入。

    它的公式是

    比如說,我們的關鍵字集合為{12,67,56,16,25,37,22,29,15,47,48,34},表長為12.我們用散列函數 f(key) = key mod 12.

    當計算 前5個數{12,67,56,16,25}時,都是沒有沖突的散列地址,直接存入,如下表。

    計算key=37時,發現f(37)=1,此時就和25的位置沖突。于是我們應用上面的公式 f(37)=(f(37)+1) mod 12 =2 ,于是將37存入下標為2的位置。 如下表

    接下來22,29,15,47都沒有沖突,正常的存入,如下表。

    到了 key=48, 我們計算得到f(48)=0,沖突,不要緊 , f(48)=(f(48)+1) mod 12 =1 ,還是沖突。 再來, f(48)=(f(48)+2) mod 12 =2,沖突…,一直到 f(48)=(f(48)+6) mod 12 =6 時 ,才有空位,機不可失,趕快存入,如下表。

    我們把這種解決沖突的開放定址法稱為線性探索法。

    從這個例子中我們也看到,在解決沖突的時候,還會碰到 如48和37這種本來都不是同義詞卻需要爭奪一個地址的情況,我們稱這種現象為堆積。很顯然,堆積的出現,使得我們需要不斷處理沖突,無論是存入還是查找效率都會大大降低。

    考慮深一步,如果發生這樣的情況,當最后一個key=34, f(key)=10 ,與22 所在的位置沖突,可是22后面沒有空位置了,反而它的前面有一個空位置,盡管可以不但求余數后得到結果,但效率很差。 因此我們可以改進 di=12,?12,22,?22,...,q2,?q2(q≤m/2)d_i=1^2,- 1^2,2^2,-2^2,..., q^2,-q^2(q≤m/2)di?=12,?12,22,?22,...,q2,?q2(qm/2),這樣就等于時可以雙向尋找到可能的空位置。 對于34來說,我們取di=-1即可找到空位置了。另外增加平方運算的目的是為了不讓關鍵字都聚集在某一塊區域。我們稱這種方法為二次探測法。

    還有一種方法是,在沖突時,對于位移量 di采用隨機函數得到,我們稱之為隨機探測法。

    此時一定有人會問,既然是隨機,那么查找的時候不也隨機生成di嗎? 如何可以獲得相同的地址? 這是一個問題。這里的隨機其實是偽隨機數。 偽隨機數是說, 如果我們設置隨機種子相同,則不斷調用隨機函數可以生成不會重復的數列,我們在查找時,用同樣的隨機種子,它每次得到的數列是相同的,相同的di當然可以得到相同的散列地址。

    總之,開放定址法只要在散列表未滿時,總是能找到不發生沖突的地址,是我們常用的解決沖突的方法。

    8.11.2 再散列函數法

    對于我們的散列表來說,我們事先準備多個散列函數。

    這里RHi就是不同的散列函數,你可以把我們前面說的什么除留余數、折疊、平方取中全部用上。每當發生散列地址沖突時,就換一個散列函數計算,相信總會有一個可以把沖突解決掉。這種方法能夠使得關鍵字不產生聚集,當然,相應地增加了計算的時間。

    8.11.3 鏈地址法

    思路還是可以再換一換,為什么有沖突就要換地方呢,我們直接就在原地想辦法不行嗎? 于是我們就有了鏈地址法。

    所有關鍵字為同義詞的記錄存儲在一個單鏈表中,我們稱這種表為同義詞子表,在散列表中只存儲所有 同義詞子表的頭指針。 對于關鍵字集合{12,67,56,16,25,37,22,29,15,47,48,34},我們用前面同樣的12為除數,進行除留余數法,可以得到下圖的結構,此時,已經不存在什么沖突換址的問題,無論有多少個沖突,都只是在當前位置給單鏈表增加結點。


    鏈地址法對于可能會造成很多沖突的散列函數來說,提供了絕不會出現找不到地址的保證。當然,這也就帶來了查找時需要遍歷單鏈表的性能損耗。

    8.11.4 公共溢出區法

    這個方法其實更好理解,你不是沖突嗎?好吧,凡是沖突的都跟我走,我給你們這些沖突找個地兒待著。這就如同孤兒院收留很多無家可歸的孩子一樣,我們為所有沖突的關鍵字建立一個公共的溢出區來存放。

    就前面的例子而言,我們共有三個關鍵字{37,48,34}以之前的關鍵字位置有沖突,那么就將它們存儲在溢出表,如下圖。

    在查找時,對給定值通過散列函數計算出散列地址后,先與基本表的相應位置進行比對,如果相等,則查找成功;如果不相等,則到溢出表去進行順序查找。如果相對于基本表而言,有沖突的數據很少的情況下,公共溢出區的結構對查找性能來說還是非常高的。

    8.12 散列表查找實現

    說了這么多散列表查找的思想,我們就來看看查找的實現代碼

    8.12.1 散列表查找算法實現

    首先需要定義一個散列表的結構以及一些相關的常數,其中HashTable就是散列表結構。結構當中的elem為一個動態數組。

    #define SUCCESS 1 #define UNSUCCESS 0 #define HASHSIZE 12 #define NULLKEY -32768typedef struct{int *elem;// 數據元素存儲基址,動態分配數組int count;//當前數據元素個數 }HashTable; int m=0;//散列表表長,全局變量

    有了結構的定義,我們可以對散列表進行初始化

    //初始化散列表Status InitHashTable(HashTable *H){int i;m=HASHSIZE;H->count=m;H->elem=(int *) malloc(m*sizeof(int));for(i=0;i<m;i++)H->elem[i]=NULLKEY;return OK;}

    為了插入時計算地址,我們需要定義散列函數,散列函數可以根據不同情況更改算法。

    int Hash( int key){return key % m; // 除留余數法 }

    初始化完成后,我們可以對散列表進行插入操作。假設我們要插入的關鍵字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}.

    //插入關鍵字進散列表void InsertHash(HashTable *H,int key){int addr=Hash(key); //求散列地址while( H->elem[addr] != NULLKEY) //如果不為空,表示沖突addr= (addr+1) %m; //開放定址法的線性探測H->elem[addr]=key;//直到有空位后插入關鍵字}

    代碼中插入關鍵字時,首先算出散列地址,如果當前地址不為空關鍵字,則說明有沖突。此時我們應用開放定址法的線性探測進行重新尋址,此時也可以更改為鏈地址法等其他解決沖突的方法。

    散列表存在后,我們在需要時就可以通過散列表查找要找的記錄。

    //散列表查找關鍵字Status SearchHash(HashTable H, int key , int *addr){*addr=Hash(key); //求散列地址while( H.elem[*addr]!=key) { //如果不為空,有沖突*addr=(*addr+1) %m; //開放定址法的線性探測if(H.elem[*addr] == NULLKEY || *addr == Hash(key))return UNSUCCESS;//如果循環回到原點,則說明關鍵字不存在}return SUNCESS;}

    8.12.2 散列表查找性能分析

    最后,我們對散列表查找的性能做一個簡單的分析。如果沒有沖突,散列查找是我們本章介紹的所有查找里面效率最高的,散列查找時間復雜度為O(1),這是在沒有沖突的情況下。但是在實際情況下,沖突是不可避免的。那么散列查找的平均查找長度取決于哪些因素呢?

    1 散列函數是否均勻
    散列函數的好壞直接影響著出現沖突的頻繁程度,不過,由于不同的散列函數對同一組隨機的關鍵字,產生沖突的可能性是相同的,因此我們可以不考慮它對平均查找長度的影響。

    2 處理沖突的方法

    相同的關鍵字、相同的散列函數,但處理沖突的方法不同,會使得平均查找長度不同。比如線性探測處理沖突可能會產生堆積,顯然就沒有二次探測法好,而鏈地址法處理沖突不會產生任何堆積,因而具有更好的平均查找性能。

    3 散列表的裝填因子

    所謂的裝填因子 α =填入表中的記錄個數散列表長度\frac{填入表中的記錄個數}{散列表長度}?。 α標志著散列表的裝滿程度。 當填入表中的記錄越多,α就越大,產生沖突的可能性就越大。比如我們前面的例子,如圖8-11-5,如果你的散列表長度是12,而填入表中的記錄個數為11,那么此時的裝填因子α =11/12=0.9167,再填入最后一個關鍵字產生沖突的可能性就非常之大。 也就是說,散列表的平均查找長度取決于裝填因子,而不是取決于查找集合中的記錄個數

    不管記錄個數n有多大,我們總可以選擇一個合適的裝填因子以便將平均查找長度限定在一個范圍之內,此時我們散列查找的時間復雜度就真的是O(1)了。為了做到這一點,通常我們都是將散列表的空間設置得比查找集合大,此時雖然浪費了一定的空間,但換來的是查找效率的大大提升,總的來說,還是非常值得的。

    散列函數測試代碼

    #include "stdio.h" #include "stdlib.h" #include "io.h" #include "math.h" #include "time.h"#define OK 1 #define ERROR 0 #define TRUE 1 #define FALSE 0#define MAXSIZE 100 /* 存儲空間初始分配量 */#define SUCCESS 1 #define UNSUCCESS 0 #define HASHSIZE 12 /* 定義散列表長為數組的長度 */ #define NULLKEY -32768 typedef int Status; /* Status是函數的類型,其值是函數結果狀態代碼,如OK等 */ typedef struct {int *elem; /* 數據元素存儲基址,動態分配數組 */int count; /* 當前數據元素個數 */ }HashTable;int m=0; /* 散列表表長,全局變量 *//* 初始化散列表 */ Status InitHashTable(HashTable *H) {int i;m=HASHSIZE;H->count=m;H->elem=(int *)malloc(m*sizeof(int));for(i=0;i<m;i++)H->elem[i]=NULLKEY; return OK; }/* 散列函數 */ int Hash(int key) {return key % m; /* 除留余數法 */ }/* 插入關鍵字進散列表 */ void InsertHash(HashTable *H,int key) {int addr = Hash(key); /* 求散列地址 */while (H->elem[addr] != NULLKEY) /* 如果不為空,則沖突 */{addr = (addr+1) % m; /* 開放定址法的線性探測 */}H->elem[addr] = key; /* 直到有空位后插入關鍵字 */ }/* 散列表查找關鍵字 */ Status SearchHash(HashTable H,int key,int *addr) {*addr = Hash(key); /* 求散列地址 */while(H.elem[*addr] != key) /* 如果不為空,則沖突 */{*addr = (*addr+1) % m; /* 開放定址法的線性探測 */if (H.elem[*addr] == NULLKEY || *addr == Hash(key)) /* 如果循環回到原點 */return UNSUCCESS; /* 則說明關鍵字不存在 */}return SUCCESS; }int main() {int arr[HASHSIZE]={12,67,56,16,25,37,22,29,15,47,48,34};int i,p,key,result;HashTable H;key=39;InitHashTable(&H);for(i=0;i<m;i++)InsertHash(&H,arr[i]);result=SearchHash(H,key,&p);if (result)printf("查找 %d 的地址為:%d \n",key,p);elseprintf("查找 %d 失敗。\n",key);for(i=0;i<m;i++){key=arr[i];SearchHash(H,key,&p);printf("查找 %d 的地址為:%d \n",key,p);}return 0; }

    測試結果

    查找 39 失敗。 查找 12 的地址為:0 查找 67 的地址為:7 查找 56 的地址為:8 查找 16 的地址為:4 查找 25 的地址為:1 查找 37 的地址為:2 查找 22 的地址為:10 查找 29 的地址為:5 查找 15 的地址為:3 查找 47 的地址為:11 查找 48 的地址為:6 查找 34 的地址為:9

    總結

    以上是生活随笔為你收集整理的《大话数据结构》读书笔记-查找的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。

    主站蜘蛛池模板: 好屌妞视频这里只有精品 | 成人在线观看一区二区三区 | 欧美一区二区三区在线观看视频 | 99xav| 久久久久九九九 | 国产女主播喷水高潮网红在线 | 又黄又爽又色的视频 | 岛国av大片| 熟妇女人妻丰满少妇中文字幕 | 中文天堂网| 成人国产在线 | 日日干夜夜爽 | 亚洲国产片 | 亚洲一区精品视频在线观看 | 欧美色图11p | 青青草原在线免费 | 欧美人与性动交α欧美片 | 97在线观视频免费观看 | 日本免费黄色片 | 成人毛片100免费观看 | 九草av| 亚洲国产综合一区 | 国产一卡二卡在线 | 亚洲91色| 高清免费视频日本 | 亚洲精品在线视频免费观看 | 美女赤身免费网站 | 亚洲看看| 色综合色婷婷 | 97超碰人人爱 | 黄色小视频免费在线观看 | 免费精品一区 | 天天爱天天射 | 男插女视频免费 | 国产精品成人一区二区三区电影毛片 | 国内精品久久久 | 成人做爰视频www网站小优视频 | 激情综合五月网 | 久久艹在线观看 | 国内自拍99 | 久久久性视频 | 婷婷亚洲综合五月天小说 | 殴美一区二区 | 成年人网站免费观看 | 欧美性日韩 | 久久高清国产 | caoprom在线视频| 少妇2做爰bd在线意大利堕落 | 精品影片一区二区入口 | 18成人免费观看网站下载 | 午夜性激情| 鲁丝片一区二区三区 | 国产精品有码 | 国产精品第五页 | 操www| 国产亚洲欧美视频 | 雨宫琴音一区二区三区 | 看中国毛片| 蜜桃视频色 | 亚洲欧美在线免费观看 | 日韩黄色短片 | av大帝在线 | 精产国品一二三产品蜜桃 | 国产一区二区影院 | 成人免费在线观看网站 | av网址在线播放 | 给我看免费高清在线观看 | 国产亚洲福利 | 亚洲欧美一区二区在线观看 | 久久国产精品系列 | 天天综合网在线观看 | 精品人妻一区二区三区在线视频 | 99精品视频在线免费观看 | 91福利片 | 丰满人妻一区二区三区四区53 | 性欧美熟妇videofreesex | 久久午夜夜伦鲁鲁片无码免费 | 亚洲永久在线观看 | 天天操好逼 | 亚洲一区二区视频在线播放 | 国产精品成人自拍 | 亚洲精品乱码久久久久久蜜桃91 | 91网址在线播放 | 成人手机视频 | 日韩欧美中文字幕一区二区 | 97干视频 | 免费看黄色片的网站 | 大j8黑人w巨大888a片 | 在线观看a级片 | 大肉大捧一进一出好爽动态图 | 草色噜噜噜av在线观看香蕉 | 欧美黑人性生活 | 日本黄色录像 | 国产欧美一区二区精品久久久 | 亚洲色图首页 | 国产精品无码一本二本三本色 | 国产草草视频 | 97成人精品视频在线观看 | 国产真人做爰视频免费 |