查找(二)
散清單是普通數組概念的推廣。由于對普通數組可以直接尋址,使得能在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-樹是對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)(還記得第一節中關于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+ tree:是應檔案系統所需而産生的一種B-tree的變形樹。
一棵m階的B+樹和m階的B樹的異同點在于:
1、有n棵子樹的結點中含有n-1 個關鍵字; (與B 樹n棵子樹有n-1個關鍵字 保持一緻,)
2、所有的葉子結點中包含了全部關鍵字的資訊,及指向含有這些關鍵字記錄的指針,且葉子結點本身依關鍵字的大小自小而大的順序連結。 (而B 樹的葉子節點并沒有包括全部需要查找的資訊)
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+樹的威力就出來了"。
對于典型的應用程式,應該在散清單和二叉查找樹之間進行選擇。
相對于二叉查找樹,散清單的優點在于代碼更簡單,且查找時間最優(常數級别)。二叉查找樹相對于散清單的優點在于抽象結構更簡單(不需要設計散列函數),紅黑樹可以保證最壞情況下的性能且它能夠支援的操作更多(如排名、選擇和範圍查找)。
大多數程式員的第一選擇都是散清單,在其他因素更重要時才會選擇紅黑樹。(”第一選擇”的例外:當鍵都是長字元串時,我們可以構造出比紅黑樹更靈活而又比散清單更高效的資料結構 Trie樹)
=================================================字元串的查找============================================
單詞查找樹的英文單詞trie來自于E.Fredkin在1960年玩的一個文字遊戲,因為這個資料結構的作用是取出(retrieval)資料,但發音為try是為了避免與tree相混淆。
基本性質:
每個結點都含有R條連結,其中R為字母表的大小。(單詞查找樹一般都含有大量的空連結,是以在繪制一顆單詞查找樹時一般會忽略空連結。)
樹中的每個結點中不是包含一個或幾個關鍵字,而是隻含有組成關鍵字的符号。例如,若關鍵字是數值,則結點中隻包含一個數位;若關鍵字是單詞,則結點中隻包含一個字母字元。我們将每個鍵所關聯的值儲存在該鍵的最後一個字母所對應的結點中。
(這種樹會給某種類型關鍵字的表的查找帶來友善。)
假設有如下關鍵字的集合
{ CAI、CAO、LI、LAN、CHA、CHANG、WEN、CHAO、YUN、YANG、LONG、WANG、ZHAO、LIU、WU、CHEN
}
若以樹的多重連結清單來表示Trie樹,則樹的每個結點中應含有d個指針域。
若從Trie樹中某個結點到葉子結點的路徑上每個結點都隻有一個孩子,則可将該路徑上所有結點壓縮成一個“葉子結點”,且在該葉子結點中存儲關鍵字及指向記錄的指針等資訊。
在Trie樹中有兩種結點:
分支結點:含有d個指針域和一個訓示該結點中非空指針域的個數的整數域。(分支結點所表示的字元是由其指向子樹指針的索引位置決定的)
葉子結點:含有關鍵字域和指向記錄的指針域。
typedef structTrieNode
{
NodeKind kind ;
union {
struct {KeyType K; Record *infoptr} lf ; //葉子結點
struct {TrieNode *ptr[27]; int num} bh ; //分支結點
} ;
} TrieNode,*TrieTree ;
在Trie樹上進行查找的過程為:從根結點出發,沿給定值相應的指針逐層向下,直至葉子結點,若葉子結點中的關鍵字和給定值相等,則查找成功。若分支結點中和給定值相應的指針為空,或葉結點中的關鍵字和給定值不相等,則查找不成功。
分割
查找操作的時間依賴于樹的深度。
我們可以對關鍵字集選擇一種合适的分割,以縮減Trie樹的深度。
例如:先按首字元不同分成多個子集之後,然後按最後一個字元不同分割每個子集,再按第二個字元……,前後交叉分割。
如下圖:在該樹上,除兩個葉子結點在第四層上外,其餘葉子結點均在第三層上。
若分割的合适,則可使每個葉子結點中隻含有少數幾個同義詞。
插入和删除
在Trie樹上易于進行插入和删除,隻是需要相應地增加和删除一些分支結點。
把沿途分支結點中相應的指針域置空,再把其分支結點中的num-1,然後删除葉子結點。當分支結點中num域的值減為1時,便可删除。
尋找熱門查詢,300萬個查詢字元串中統計最熱門的10個查詢。