Lucene 4.X 倒排索引原理與實現: (1) 詞典的設計

詞典的格式設計

詞典中所保存的信息主要是三部分:html

  • Term字符串
  • Term的統計信息,好比文檔頻率(Document Frequency)
  • 倒排表的位置信息

其中Term字符串如何保存是一個很大的問題,根據上一章基本原理的表述中,咱們知道,寫入文件的Term是按照字典順序排好序的,那麼如何將這些排好序的Term保存起來呢?前端

1. 順序列表式

一個直觀的想法就是順序列表的方式,即每一個Term都佔用相同的空間,而後你們依次排列下來,如圖所示:算法

image

這種方式查找起來也很方便,因爲Term是排好序的,並且每一項佔用空間相同,就能夠採起二分查找,較快的定位Term的位置。好比在文件中,詞典的起始地址是FP,共保存了N個Term,每一個Term佔用固定大小M個Byte,則中間的Term的位置爲clip_image004,將此位置的M個Byte讀取出來,轉換爲字符,若是是要找的Term則完畢,若是大於要找的Term,則在前半段二分查找,若是小於要找的Term,就在後半段二分查找。數組

這種方法的一個最大的缺點,從圖中咱們也能夠看出,就是對空間的浪費,咱們必須按照最長的詞所佔的空間來決定每個Term的空間,有的Term很長,如圖中的counterrevolutionary,有的Term則很短,如cab,對於短的Term來說,是空間的巨大浪費。並且另外一個棘手的問題是,咱們很難知道最長的字符串到底有多長。緩存

2. 指針列表式

有人要說了,這樣空間太浪費,像咱們八輩貧農,可不能浪費一點空間,我們要排列就緊密排列,一個挨一個:數據結構

image

就算Term與Term之間有分隔符號,但是茫茫辭海,我去那裏找個人Term啊,總不能每找一個Term都從頭開始掃描吧,我卻是想二分查找,但是每一個Term的空間都不相同,偏移量我可怎麼算呢?架構

有了,雖然每一個Term的長度不一樣,但是指針(文件中的指針也即偏移量)的長度是相同的,咱們將指針放成一個列表,不是就能夠二分查找了麼?。函數

image 

這種指針列表方式在Lucene中咱們也會常常看到,並且不只僅用在詞典的格式設計中,到時候你們不要忘記它,爲方便記憶,咱們稱之爲指針列表規則。工具

3. 前端編碼式

若是細心觀察的同窗會發現,Term按照字典順序排序,有一個好處,就是相鄰的Term很大可能性上會有相同的前綴,好比上面的例子中,共8個Term,其中字符「c」被保存了8遍,字符「a」保存了4遍,字符「l」保存了3遍,能不能將相同的前綴提取出來,只保存一份呢?性能

對於某個Term和前一個Term有相同的前綴的,後者僅僅保前綴在Term中的偏移量,外加後綴的部分,通常來講偏移量做爲數字所佔用的空間比字符要小得多,因此這種方式會進一步節約存儲空間,這種方式成爲前端編碼(front coding)。

image

空間是節約了,那麼如何快速的查找呢?二分法估計是不行了,並且解壓縮也成了問題,由於要想知道一個Term的全貌,必須把前一個Term也解壓縮出來,難不成每次查找都把整個詞典都解壓縮?固然不是,咱們能夠將Term分塊,每一塊都有一個排頭兵,整個快是基於排頭兵進行前端編碼,每次解壓縮,僅僅解壓縮一個塊就能夠了:

image

對於排頭兵,因爲數量相對於整個詞典數量少的多,可使用指針列表方式存儲從而能夠進行二分查找,甚至能夠所有加載到內存中,使得查找更加方便。這種前端編碼加詞典分塊的方式在Lucene中也會被用到,咱們姑且稱以前綴分塊規則。

4. 最小完美哈希

該省的空間都省了,下面該考慮一下查詢速度的問題了,在每次查詢的時候,爲了定位倒排表,首先須要定位詞典中Term的位置,就算是使用前端編碼加詞典分塊的方式,也須要儘快的定位排頭兵的位置,那麼怎麼才能找得快呢?

不少人首先想到的應該是哈希表,若是詞典可以所有放在內存中,用哈希表O(1)就能定位Term的位置。可是哈希函數很差選啊,弄很差就有衝突,固然咱們有各類方法來解決衝突,一種經常使用的方法就是後面掛個鏈表,衝突的都掛在一個位置就能夠了。可要是衝突的多了,都堆在一個鏈表中,那不又成了順序掃描了,哈希表的優點蕩然無存。那麼如何減小衝突呢?固然是哈希表越稀疏越好,哈希表有一個概念叫作裝載因子(Load Factor),裝的越滿因子越大,只要裝載因子比較小,衝突的機率天然就小,但是隨之而來的一個問題就是空間的浪費。

image

就沒有一個既節省空間,又不發生衝突的方法麼?好讓咱多快好省的建設社會主義嘛。別說,還真有,聽名字就很牛,叫最小完美哈希。咱們通常所說的哈希函數,就是將m個字符串,映射到k個位置上去,通常須要稀疏,因此k大於等於m,還有可能衝突,而這個算法設計的哈希函數一個都不會衝突,這就是所謂的完美,並且k=m,也即一個空間也不浪費,這就是所謂的最小。固然在實際應用中,通常不要求那麼的最小並且完美,爲了查詢效率,通常都傾向於犧牲一點空間來保證完美,有一些工具能夠幫助咱們來生成這樣的哈希函數,好比Gperf(http://www.gnu.org/software/gperf/),根據你的輸入的字符串列表而生成哈希函數,再如CMPH(C Minimal Perfect Hashing Library,http://cmph.sourceforge.net/index.html),它支持多種算法,能夠快速處理大量的字符串,咱們來介紹其中一種CHM算法,也即無環圖的方法,爲啥叫CHM呢?這種算法是根據Z.J. Czech, G. Havas, B.S. Majewski這三位老兄發表的一篇論文《An optimal algorithm for generating minimal perfect hash functions., Information Processing Letters, 43(5):257-264, 1992.》來的,因此借用三位名字中的首字母CHM來命名了這個算法。

按照最小完美哈希的定義,咱們先假設有三個字符串String1, String2, String3,它們通過哈希運算後,String1映射爲0,String2映射爲1,String3映射爲2,正好沒有一個空間浪費,也沒有一個哈希值衝突,這是咱們想最後實現的結果,如圖.

clip_image016

 

那麼哈希函數是什麼樣子的,才能達到這種效果呢?咱們先來看公式:

clip_image018

W表示須要哈希的字符串,m是字符串的個數。咱們能夠看出,這個哈希函數嵌套了兩層,第一層先用兩個函數clip_image020clip_image022將字符串分別映射成爲兩個數字,clip_image020[1]clip_image022[1]須要是獨立的,於是映射出來的兩個數字也是不一樣的,這兩個數字的取值範圍[0, n-1],姑且認爲n是一個比m大的數,至於n多大後面還會提到。

接着上面的例子,m=3,n假設爲4,如圖所示:

clip_image024

clip_image026

clip_image028

clip_image030

clip_image032

clip_image034

clip_image036

 

而後就進入第二層,clip_image038 函數將clip_image020[2]clip_image022[2]計算出的兩個數字進行處理,獲得最終的哈希值[0, m-1]。仍是上面的例子,clip_image038[1] 函數如何設計才能使得

clip_image040

clip_image042

clip_image044

設計clip_image038[2] 函數,咱們就使用無向圖的方式,如圖,將clip_image020[3]clip_image022[3]計算出的數字做爲頂點,而最終的哈希值做爲鏈接兩個頂點的邊,咱們想求的是clip_image046各是什麼,也即clip_image038[3] 函數的映射方式。

clip_image048

 

咱們先假設clip_image050,既然clip_image038[4] 函數要求clip_image052,從而能夠推出clip_image054也是0,由clip_image056,則推出clip_image058,依此類推,clip_image060

這個算法最後可以成功,還有一個關鍵點,就是這個圖必須是無環的,若是有環算法就會失敗。仍是上面的例子,好比clip_image062,便產生了如圖的有環圖。

clip_image064

在有環圖中,咱們開始假設clip_image050[1],最後繞了一圈回來,計算出clip_image066,二者矛盾,算法失敗。

那麼怎樣才能避免圖有環呢?這就不是clip_image038[5]函數的事情了,輪到該好好的設計clip_image020[4]clip_image022[4]函數了。從前面的描述中,咱們知道,圖中的節點是由clip_image020[5]clip_image022[5]計算出來的,取值範圍[0, n-1],共n個,而邊的個數是由最後的哈希值決定的,共m個,若是節點多邊少,則出現環的機率就小,按照論文中的說法,n>2m是最好的。

另外對於函數clip_image020[6]clip_image022[6],咱們採起這樣的設計,對於每個字符串w,都是由一系列字符組成的,對於每個字符w[i],生成一個取值[0, n-1]的隨機數,從而造成一個表格clip_image068,而後一樣產生另外一組隨機數,造成另外一個表格clip_image070,而後造成下面的公式:

clip_image072

clip_image074

好比對於字符串「abc」,對於a出如今第一個位置,咱們產生兩個隨機數clip_image076clip_image078,一樣對於b出如今第二個位置,也產生兩個隨機數clip_image080clip_image082,對於c出如今第三個位置也產生兩個隨機數clip_image084clip_image086,則clip_image088,clip_image090,則第一層完畢,下面就能夠開始構建圖了。

clip_image020[7]clip_image022[7]如此設計怎麼就能夠保證圖是無環的呢?固然不能保證隨機生成的兩個函數映射表最終造成的圖就必定是無環的。好在我們是基於隨機數的,一組隨機數最後發現有環,再來一組不就好了,直到造成的圖無環爲止,反正產生隨機數又不要錢。

下面我們就舉一個完整的例子,將這個過程演示一遍。

一年有12個月,採用縮寫就是:Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec。我們就針對這些字符串生成一個最小完美哈希。一共12個字符串,m=12,要求n>2m,我們就姑且取n=25。

首先第一步,構造隨機數表clip_image068[1]clip_image070[1],每一個字符串有三個字符,咱們經過觀察能夠發現,第二個和第三個字符的組合也是惟一的,爲簡單起見,我們僅僅考慮第二個和第三個字符,如圖所示。

clip_image092

 

第二步,由隨機數表,咱們就能夠計算出clip_image020[8]:

clip_image094

clip_image096

clip_image098

clip_image100

同理咱們能夠計算出clip_image022[8]:

clip_image102

clip_image104

clip_image106

clip_image108

第三步,由此咱們能夠獲得圖了,如圖,咱們從Jan開始構造圖,Jan的頂點爲5和12,邊就是咱們但願獲得的最後哈希值0,Feb的頂點爲5和22,邊爲1,Mar的頂點爲22和12,邊爲2,很差!居然出現環了。

clip_image110

 

咱們只好從新生成隨機數表,如圖所示:

clip_image112

 

而後從新計算出clip_image020[9]:

clip_image114

clip_image116

clip_image118

clip_image120

從新計算出clip_image022[9]:

clip_image122

clip_image124

clip_image126

clip_image128

從新繪製圖,如圖:

clip_image130

最後一步,獲得clip_image038[6] 函數的映射表。咱們假設每一個不連通的圖中值最小的節點的clip_image038[7] 函數的映射爲0,也即假設clip_image132,而後進行推導,推導過程如圖:

clip_image134

 

最後得出clip_image038[8] 函數的映射表如圖:

clip_image136

 

自此最小完美哈希大功告成。

在用戶查詢字符串Aug的時候,咱們就使用哈希函數:

clip_image138

正好找到哈希表中Aug所在的位置。

固然最小完美哈希惟一不夠完美的地方就是,它是針對靜態集合的,也即在構造最小完美哈希以前,全部的字符串都必須知道,於是對字符串的增刪改不能很好的支持。

5. 雙數組Trie樹

對於字符串來講,還有一種查詢效率較高的數據結構,叫作Trie樹。

好比咱們有一系列的字符串:{bachelor#, bcs#, badge#, baby#, back#, badger#, badness#},咱們之因此每一個字符串都加上#,是但願不要一個字符串成爲另一個字符串的前綴。把它們放在Trie樹中,如圖所示。

clip_image140

在這棵Trie樹中,每一個節點都包含27個字符。最上面的是根節點,若是字符串的第一個字符是「b」,則「b」的位置就有一個指針指向第二個層次的節點,從這一層的節點開始,下面掛的整棵樹,都是以「b」開頭的字符串。第二層的節點也是包含27個字符,若是字符串的第二個字符是「c」則「c」的位置也有一個指針指向第三個層次的節點,第三個層次下面掛的整棵樹都是以「bc」爲前綴的,以此類推,直到碰到「#」,則字符串結束。經過這種數據結構,咱們對於字符串的查找速度就和字符串的數量沒有關係了,而是字符串有多長,咱們就頂多查找多少個節點,而字符串的長度都是有限的,因此查詢速度是至關的快。

固然細心的同窗也發現了,高速度的代價就是空間佔用太大,並且是指數增長的,仍是以27爲底數的。好在仍是英文啊,說破天不過就是26個字母,要是中文可怎麼辦啊。因此我們可不能有沒有的都列在哪裏,出現的字符咱就佔用空間,不出現的咱可不浪費。基於這種理念,上面的那棵Trie樹就變成了圖的樣子。

clip_image142

 

圖中僅僅保留了已有的字符,並將每一個節點變成了一種狀態(State),在沒有任何輸入的狀況下,咱們處於根節點的狀態,當輸入字符「b」後,便到了下一層的狀態Sb ,當再輸入字符「a」後,就到了再下一層的狀態Sba ,全部在Sba 下面掛着的整棵樹都是以「ba」做爲前綴的。

熟悉編譯原理或者形式語言的同窗已經發現了,這是一個有限狀態機。不熟悉的同窗也沒關係,很容易理解,假設有一個門,有兩個按鈕「開」和「關」,表明用戶的輸入,門有兩種狀態,「開着」和「關着」。門的狀態根據用戶的輸入而變化,好比門處於「關着」的狀態,用戶輸入「開」,就轉換到「開着」的狀態,而後再點「關」,就回到「關着」的狀態。固然也能夠識別不合法的輸入,好比門原本就「開着」,你還猛點「開」這個按鈕,門或者報錯,或者沒有反應。在上面的有限狀態機中也是這樣的,一開始處於根節點的狀態,用戶輸入「b」,就進入狀態Sb,輸入「c」,就進入狀態Sbc ,再輸入「s」,進入狀態Sbcs ,最後用戶輸入「#」,字符串結束,進入狀態Sbcs# ,說明字符串「bcs#」在咱們的狀態機裏面是合法的,存在的。若是用戶輸入「b」以後輸入「z」,在狀態機中沒有對應的狀態,因此以「bz」開頭的字符串是不存在的。經過咱們的這個有限狀態機,一樣可以起到查詢字符串的做用。

其實這個狀態機還能夠進一步簡化。咱們發現有的狀態是有多個後續狀態的,好比Sbac ,根據輸入的不一樣進入不一樣的後續狀態,而有的狀態的後續狀態是惟一的,好比當用戶到達狀態Sbach ,此後惟一的合法輸入就是「elor#」,因此根本不須要一個個的進入狀態Sbache ,Sbachel ,Sbachelo ,Sbachelor ,直到狀態Sbachelor# 才發現用戶輸入是否存在,而是在到達狀態Sbach 以後,直接比較剩餘的字符串是不是「elor#」就能夠了,因此上面的有限狀態機能夠變成圖的樣子,所謂的剩餘的這些字符串,咱們稱之爲後綴。

clip_image144

 

接下來的任務,就是如何將這個簡化了的樹形結構更加緊湊的保存起來了。咱們在這裏要介紹一種不須要佔用太多空間的Trie樹的數據結構,雙數組Trie樹。

顧名思義,雙數組Trie樹就是將上述的樹形結構保存在兩個數組中,那怎麼保存呢?

咱們來看上面的這個樹形結構,多麼像我們的組織架構圖啊,最上面的根節點是總經理,各個中間節點是各部門的經理,最後那些後綴就是我們的員工了。如今公司要開會了,須要強行把這個樹形結構壓扁成數組結構,一個挨一個的坐,那最應該要維護的就是上下級的關係。對於總經理,要知道本身的直接下級,以及公司有多少領導幹部。對於中層領導,一方面要知道本身的上級在哪裏坐,下級在哪裏坐;對於基層領導,除了知道上級在哪裏坐,還須要知道員工在那裏坐。

雙數組Trie樹就是一個維護上下級關係的一個數據結構。它主要包含兩個數組BASE和CHECK,用來保存和維護領導幹部之間的關係的,另外還有一個順序結構TAIL,能夠在內存中,也能夠在硬盤上,用來安排我們員工坐的。更形象的說法,兩個數組就至關於主席臺,而員工只有密密麻麻坐在觀衆席上了。

BASE和CHECK數組就表明主席臺上的座位,若是第i位,BASE[i]和CHECK[i]都爲0,說明這個位置是空的,尚未人坐。若是不是空的,說明坐着一位領導幹部,BASE[i]數組裏面是一個偏移量offset,經過它,能夠計算出下屬都坐在什麼位置,好比領導Sb 有兩個下屬Sba 和Sbc ,若是領導Sb 坐在第r個位置,則BASE[r]中保存了一個偏移量q(q>=1),對於下屬Sba ,是由Sb 輸入「a」到達的,咱們將字符「a」編號成一個數字a,則Sba 就應該坐在q+a的位置,同理Sbc 就應該坐在q+c的位置。CHECK[i]數組裏面是一個下標,經過它,能夠知道本身的領導坐在什麼位置,好比剛纔講到的下屬Sba ,他坐在q+a的位置,他的領導Sb 坐在第r個位置,那麼CHECK[q+a]裏面等於r,同理CHECK[q+c]裏面也應該是r,那BASE[q+a]和BASE[q+c]中保存的什麼呢?固然就是Sba 和Sbc 他們的下屬的位子了。因此職場中,每一個人都同時扮演兩種角色,一方面是上司的下屬,一方面是下屬的上司,因此每一個位子i都有兩個數字BASE[i]和CHECK[i],坐在每一個位子上的人都應該知道,本身的上司是誰,下屬是誰。

對於基層領導稍有不一樣,由於基層領導的下屬就是普通員工了,不坐在雙數組主席臺上了,而是坐在TAIL觀衆席上了,因此對於基層領導,若是他坐在第i個位置,則BASE[i]就不是正的了,而是一個負的值p,表示他是基層領導,在雙數組主席臺上 沒有下屬了,而|p|則表示基層領導所下屬的哪些普通員工在TAIL觀衆席上的位置。

至於TAIL觀衆席的結構,就很是簡單了,普通員工嘛,別那麼多講究,一個挨一個的作,用$符合進行分割。

根據上述的原理,上面的那顆樹保存在雙數組裏面應該如圖,至於這裏面的數據如何造成,下面會一步一步詳細說明:

clip_image146

 

圖中的最下方是對每一個字符的編號。從圖中咱們能夠看出,總經理S老是坐在頭一把交椅,CHECK[1]=20,主席臺總共有20個位子,總經理固然應該對幹部的整體狀況有所把握。總經理的下屬Sb 坐在BASE[1]+b = 1+2=3的位子上,Sb 的上司是總經理,因此CHECK[3]=1,Sb 的下屬有兩個Sba 和Sbc ,他們的座位BASE[3]+a=6+1=7以及BASE[3]+c=6+3=9,天然CHECK[7]和CHECK[9]都等於3,以此類推。有人可能會困惑爲何BASE[1]是1而BASE[3]是6,不是1也不是5呢?這是在安排座位的過程當中逐漸造成的,從下面雙數組Trie樹的造成過程你們會更詳細的瞭解,在這裏就簡單說明一下,對於每個坐在第i個位置的領導,BASE[i]裏面都保存下屬相對於他的offset,固然每一個領導都但願offset越小越好,這樣本身的下屬也能坐在前面,對於總經理來講,固然他最牛,因此BASE[1]能夠取最小值1,由於總經理剛坐下的時候,主席臺是空的,他的下屬隨便坐均可以,對於其餘的領導幹部就不必定了,若是BASE[i]取1,結果計算後給本身的下屬安排位置的時候,發現位置以及被先來的人坐了,因此沒辦法,只有增長BASE[i],讓本身的下屬日後坐坐。對於狀態Sbab ,Sbc ,Sbach ,Sback ,Sbadn ,Sbadger ,Sbadge# ,他們的BASE[i]都是負的,因此他們是基層領導,經過BASE[i]裏面的值的絕對值,能夠找到TAIL觀衆席中本身的下屬,好比Sbab 的BASE值爲-17,在TAIL中第17個字符開始是「y#$」,因此鏈接起來就是「baby#」。固然TAIL中也有一些很奇怪的,好比第20和第22個都只保存了「#$」,這說明了,除告終束符「#」以外,在最後一個字符才與其餘的字符串作了區分,第20個就是這樣的,「back#」到了字符「k」才和「bachelor#」有所區分(「back#」和「bachelor#」都是以bac爲開頭的,都歸Sbac 領導,必須提拔字符「k」和「h」到主席臺,造成狀態Sback 和Sbach 來區分兩個團隊),既然分開了,就是一個單獨的團隊,雖而後面只跟了一個「#」,Sback 做爲一個小小領導,也須要等上主席臺,別拿村長不當幹部。其實還有更慘的,對於第13個,就只剩下分隔符「$」,這是由於「badge」徹底是另一個字符串「badger」的前綴,多虧加了個結束符「#」纔將二者區分開來,對於「badge#」來說,到了「#」字符才區分,那麼只好也作上主席臺,作個光桿司令了。還有一點奇怪的就是,TAIL中爲何有空位置啊,好比位置7,8,9?這是歷史緣由形成的,由於一開始字符串「bachelor#」剛來的時候,其餘的字符串還沒來,公司規模較小,就一個團隊,不須要那麼多層領導,因此就Sb 做爲惟一的一個團隊的頭坐主席臺,其餘的「achelor#」都坐觀衆席,因此「achelor#$」總共佔了9個位置,後來「bcs#」來了,光是領導Sb 不足以區分這兩個字符串團隊「bachelor#」和「bcs#」(他們都是以b開頭的啊),因此「achelor#」中的字符「a」和「bcs#」的字符「c」都被提拔爲領導崗位,對兩個字符串團隊以做區分,就造成了狀態Sba 和Sbc (今後「bachelor#」能夠說咱們是以ba開頭的,而「bcs#」能夠說咱們是以bc開頭的),後來「back#」 來了,僅僅字符「ba」以及「bac」都不足以區分「bachelor#」和「back#」,因此,不但「bachelor#」中的字符「c」被提拔成領導崗位,造成狀態Sbac ,字符「h」也被提拔,造成狀態Sbach ,從而員工就剩下了「elor#」,被提拔了三位,因此位置7,8,9就空下來了,那爲何不讓後面的字符跟上呢?一方面,在雙數組主席臺中,其餘團隊的下屬的位置都已經標好了,這一跟上都要改,比較麻煩,另一方面,TAIL極可能保存在硬盤文件中的,將文件中的內容移動,也是很低效的事情。

有了上述結構,對字符串進程查詢就方便了,通常按照如下的流程進行:

 

//輸入: String inputString=」a1 a2 …… an #」轉換成爲int[] inputCode

boolean doubleArrayTrieSearch(int[] inputCode) {

int r=1;

int h=0;

do {

int t = BASE[r] + inputCode[h];

if(CHECK[t] != r){

//在雙數組中找不到相同前綴,說明不存在與這個集合

// a1 a2 …… ah-1 都相同,ah 不一樣

//座位t上坐的不是你的領導,在這棵樹上這個字符串找不到組織

return false;

} else {

//前綴暫且相同,繼續找下一層狀態

// a1 a2 …… ah 都相同,下個循環比較ah+1

//說明你屬於這個大團隊,接着看是否屬於某一個小團隊

r = t;

}

h = h + 1;

} while(BASE[r]>0)

//到這一步雙數組中的結構查詢完畢,BASE[r]<0,應該從TAIL中查找了

If(h == inputCode.length - 1){

//若是已經到告終束符#,說明這個字符串全部的字符都在雙數組中,是個光桿司令

Return true;

}

Int[] tailCode = getTailCode(-BASE[r]);//從TAIL中拿出後綴

If(compare(tailCode, inputCode, h+1, inputCode.length -1) == 0){

//比較TAIL中的字符串和inputCode中剩下的」ah+1 …… an #」是否相同,相同則存在

Return true;

} else {

Return false;

}

}

 

接下來,咱們就來看看這種微妙的數據結構是如何構造的。其實構造過程提及來很簡單,就是一開始整個雙數組中只有根節點,而後隨着字符串的不斷插入而造成。要插入一個新的字符串,首先仍是要調用上面的代碼進行搜索一下的,若是可以搜索出來,則這個字符串原來就存在,則什麼都不作,若是沒有搜索出來,就須要進行插入操做。根據上面的搜索程序,搜索不出來分爲兩種狀況,也就是上面的程序中返回False的地方

1) 第一種狀況是在雙數組中找不到相同的前綴。也即對於輸入字符串a1 a2 … ah-1 ah ah+1 … an #,在雙數組中,a1 a2 … ah-1 能找到對應的狀態S a1 a2 … ah-1 ,然而從ah開始,找不到對應的狀態S a1 a2 … ah-1 ah,因此須要將S a1 a2 … ah-1 ah做爲S a1 a2 … ah-1的下屬加入到雙數組中,而後將ah+1 … an #做爲S a1 a2 … ah-1 ah的員工放到TAIL中。然而加入的時候存在一個問題,就是原來S a1 a2 … ah-1已經有了一些下屬,並通過原來排位置,找到了合適的BASE值,經過它可以找到這些下屬的座位。這個時候狀態S a1 a2 … ah-1 ah來了,當它想要按照BASE[r] + ah=t找到位置的時候,發現CHECK[t]不爲0,也即位置讓其餘先來的人佔去了。這個時候有兩種選擇,一種選擇是改變本身的領導S a1 a2 … ah-1的BASE值,使得連同S a1 a2 … ah-1 ah和其餘的下屬都可以找到空位子坐下,這就須要對本身的領導S a1 a2 … ah-1的原有下屬所有遷移。另外一種選擇就是既然CHECK[t]不爲零,說明被別人佔了,把這個佔了做爲的人遷走,我S a1 a2 … ah-1 ah仍是坐在這裏,要遷走位置t的人可不容易,要先看他的領導的面子,也即根據CHECK[t]=p找到他的領導的位置,遷移位置t的人,須要改變他的領導的BASE[p],而BASE[p]的改變,必將致使他的領導的原有全部下屬都要遷移,另找 位置。那麼選擇哪種方式呢?要看哪一種方式遷移的人數少,就採起哪一種方式。

2) 第二種狀況是在雙數組中找出的前綴相同,可是從TAIL中取出的後綴和輸入不一樣。也即對於輸入字符串a1 a2 … ah-1 ah ah+1 … an #,在雙數組中,a1 a2 … ah 能找到對應的狀態S a1 a2 … ah ,而ah是基層領導,從TAIL中找出基層員工ah+1 ah+2… ah+k b1b2……bm和剩餘的字符串ah+1 ah+2… ah+k ah+k+1 … an #進行比較,結果雖不相同,可是他們卻有共同的前綴ah+1 ah+2… ah+k,爲了區分這是兩個不一樣的字符串團隊,他們的共同領導ah+1 ah+2… ah+k是要放到雙數組中做爲中層領導的,而第一個可以區分兩個字符串的字符ah+k+2和b1則做爲基層領導放到雙數組中,二者在TAIL中的基層員工分別是ah+k+2 … an #和b2……bm

下面咱就詳細來一步一步看上面那個雙數組Trie是如何構造的。

步驟1 初始狀態

如圖,初始狀態,創業伊始,僅有根節點總經理,BASE[1]=1,CHECK[1]=1,TAIL爲空。

clip_image148

 

步驟2 加入bachelor#

加入第一個字符串團隊bachelor#,第一個字符「b」做爲基層領導進入雙數組,成爲總經理的下屬,因此狀態Sb的位置爲BASE[1]+b = 1 + 2 = 3,CHECK[3]爲0,可直接插入,設CHECK[3]=1,後綴achelor#進入TAIL,BASE[3] = -1,表面Sb爲基層領導,員工在TAIL中的偏移量爲1。如圖。

clip_image150

 

步驟3 加入bcs#

加入bcs#,找到狀態Sb,是基層領導,從TAIL中讀出後綴進行比較,achelor#和cs#,二者沒有共同前綴,因此將a和c放入雙數組中做爲基層領導區分兩個字符串團隊便可。新加入的兩個狀態Sba和Sbc都是狀態Sb的下屬,因此先求BASE[3]=q,先假設q=1,1+a = 1+1=2,1+c=1+3=4,CHECK[2]和CHECK[4]都爲0,能夠用來放Sba和Sbc,因此BASE[3]=1,CHECK[2]=3,CHECK[4]=3。兩個後綴chelor#和s#放入TAIL,基層領導Sba的BASE[2]=-1,指向TAIL中的後綴chelor#,BASE[4]=-10,指向TAIL中的後綴s#。如圖所示。

clip_image152

 

步驟4 加入badge#

加入badge#,找到狀態Sba,是基層領導,從TAIL中讀取後綴chelor#和dge#進行比較,二者沒有共同前綴,因而將字符c和d放入雙數組做爲基層領導來區分兩個字符串團隊,造成狀態Sbac和Sbad,都做爲Sba的下屬,因而要計算Sba的BASE[2]=q,假設q=1,1+c = 1+3 = 4,1+d = 1+4 = 5,因爲CHECK[4]不爲零,因此產生衝突,再次假設q=2,2+c = 5,2+d=6,檢查CHECK[5]和CHECK[6]都爲零,能夠放Sbac和Sbad,因此BASE[2]=2,CHECK[5]=2,CHECK[6]=2。兩個後綴helor#和ge#放入TAIL,基層領導Sbac的BASE[5]=-1,指向TAIL中的helor#後綴,基層領導Sbad的BASE[6]=-13,指向TAIL中ge#後綴。如圖。

clip_image154

 

步驟5 加入baby#

加入baby#,找到狀態Sba,有兩個下屬是Sbac和Sbad,卻沒有Sbab,要將Sbab加入到雙數組中。當前Sba的BASE[2]=2,根據它來安排Sbab的位置,2+b=4,然而CHECK[4]不爲零,有衝突,位置以及被佔了。有兩種選擇,一種是改變Sba的BASE[2]的值,形成Sbac,Sbad,Sbab都要移動,另外一種是移動第4位的狀態Sbc,先要找他的領導Sb,要移動Sbc,須要修改Sb的BASE[3]的值,若是被修改,則狀態Sba和Sbc須要移動。權衡兩種選擇,選擇後者。原來BASE[3]=q=1,假設q=2,2+a=3,2+c=5,CHECK[3]不爲零,有衝突,再假設q=3,也有衝突,直到假設q=6,6+a=7,6+c=9,CHECK[7]和CHECK[9]都不爲零,能夠用來放Sba和Sbc,因此BASE[3]=6,Sba從第2個位置移動到第7個位置,Sbc從第4個位置移動到第9個位置,CHECK[7]和CHECK[9]設爲3。把別人趕走了,Sbab就放在了第4個位置,CHECK[4]=7。後綴y#進入TAIL,基層領導Sbab的BASE[4]=-17指向TAIL中的後綴y#。如圖。

clip_image156

 

步驟6 加入back#

加入back#,找到狀態Sbac,是一個基層領導,從TAIL中讀取後綴helor#和k#進行比較,二者沒有共同前綴,須要將h和k放到雙數組中做爲基層領導來區分兩個字符串團隊,成爲狀態Sbach和Sback,都是Sbac的下屬,計算Sbac的BASE[5]=q,假設q=1,1+k=12,1+h=9,因爲CHECK[9]不爲零,衝突,再假設q=2,2+k=13,2+h=10,CHECK[10]和CHECK[13]都爲零,能夠用來存放狀態Sbach和Sback,因此BASE[5]=2,CHECK[10]=5,CHECK[13]=5。後綴elor#和#進入TAIL,狀態Sbach的BASE[10]=-1指向TAIL中的後綴elor#,狀態Sback所謂BASE[13]=-20指向TAIL中的後綴#。如圖。

clip_image158

 

步驟7 加入badger#

加入badger#,找到狀態Sbad,是基層領導,從TAIL中讀取後綴ge#和ger#進行比較,二者有相同的前綴ge,因此g和e須要放入雙數組做爲中層領導,造成狀態Sbadg和Sbadge,另外#和r須要放入雙數組做爲基層領導,來區分兩個不一樣的字符串團隊,造成狀態Sbadge#和Sbadger

先放入狀態Sbadg,他的領導是Sbad,計算BASE[6]=q,假設q=1,1+g=8,因爲CHECK[8]爲零不衝突,因此第8個位置能夠用來放狀態Sbadg,BASE[6]=1,CHECK[8]=6。

而後放狀態Sbadge,他的領導是Sbadg,計算BASE[8]=q,假設q=1,1+e=6,因爲CHECK[6]不爲零,衝突,再假設q=2仍是衝突,直到假設q=6,6+e=11,因爲CHECK[11]爲零,因此不衝突,因此第11個位置能夠用來放狀態Sbadge,因而BASE[8]=6,CHECK[11]=8。

最後放狀態Sbadge#和Sbadger,他們的領導是Sbadge,計算BASE[11]=q,假設q=1,1+r=19,1+#=20,因爲CHECK[19]和CHECK[20]都爲零,不衝突,因此第19個位置和第20個位置能夠用來放狀態Sbadger和Sbadge#,因而BASE[11]=1,CHECK[19]=11,CHECK[20]=11。

後綴#和空後綴進入TAIL,基層領導Sbadger的BASE[19]=-22,指向TAIL中的後綴#,基層領導Sbadge#的BASE[20]=-13,指向TAIL中的空後綴。

 

clip_image160

 

步驟8 加入badness#

加入badness#,找到狀態Sbad,不是基層領導,有下屬Sbadg,因此要將狀態Sbadn加入雙數組以加入一個新的字符串團隊,後綴ess#入TAIL中。

要放置Sbadn,須要看他的領導Sbad的BASE[6]=1,1+n=15,CHECK[15]爲零,不衝突,正好第15個位置空着,能夠放置狀態Sbadn。如圖。

clip_image162

 

好了至此爲止,你們應該明白如何建立雙數組Trie樹了吧,在這裏仍是提醒你們,類似的原理也在Lucene中獲得了應用,咱們姑且稱之有限狀態機規則。

雙數組Trie樹是個優秀的數據結構,可是整個結構須要保存在內存中,若是寫入硬盤,則但願不要改變,否則對於衝突的移動不但複雜,並且耗費性能。

6. M路查找樹

若是詞典很是的大,內存放不下, 就須要保存在硬盤上了,一旦涉及到硬盤,性能即是一個讓人頭疼的事情,若是還須要這種數據結構可以進行插入和刪除,事情將變得更加糟糕。

那麼什麼樣的數據結構,不要設計的那麼複雜,以致於要作改變的時候牽一髮而動全身,而查詢效率還不錯的呢?出了O(1)以外,退而求其次的即是O(logN),說到這裏,不少人就會立刻想到,二叉樹,如圖。

clip_image164

 

二叉樹確實不錯,插入一個節點或者刪除一個節點,影響到的只有本節點和父節點,不會影響整棵樹的結構,並且查詢速度也不錯,O(logN)。若是怕由於樹不平衡從而影響性能,可使用平衡二叉樹,如AVL或者紅黑樹。

可是若是咱們仔細計算一下,單就二叉樹仍是有問題的,由於全部的節點都保存在硬盤上,若是咱們有100萬的數據,那麼每次查詢須要進行log2N 約爲20次磁盤訪問,次數仍是太多了。這是由於對於二叉樹,每一個節點保存一個元素,度僅僅爲2。若是要提升性能,則須要減小磁盤的訪問次數,一個方法就是每一個節點多放幾個元素,增長度,減小樹的高度,因此就有了m路查找樹。

M路查找樹或者是空樹,或者知足下面的性質:

  • 每一個節點最多有m棵子樹,節點的結構爲<n, A0, E1, A1, E2, A2, …… , En, An>,其中Ai是指向子樹的指針,Ei是數據元素,每一個數據元素都有一個關鍵字Key,固然還有其餘的信息Data。
  • Ei.Key < Ei+1.Key
  • Ai指向的子樹的關鍵字大於Ei.Key,小於Ei+1.Key
  • 全部的子樹Ai都是m路查找樹

好比如圖,就是一個三路查找樹。

clip_image166

 

概念不難理解,首先要考慮的問題是m取多大,m過小的話,一次查詢須要屢次讀取硬盤,若是m太大,一個節點的讀取就須要很長時間,並且內存也有限,因此m的選取應該是的一個節點的大小是緩存單位或者磁盤塊的大小,這也是爲何在向樹中插入元素的算法中,超過m就必定要進行節點分裂。其次的問題是如何進行查找,好比要找關鍵字爲x的元素,天然是從最頂層節點開始若是在元素中找到Ei.Key==x,則成功,若是元素中沒有,則尋找Ei.Key < x < Ei+1.Key,而後根據指針Ai讀取子節點查找。最後就是樹的平衡性問題,m路搜索樹沒有規定每一個節點中元素的數目,因此有的節點多是滿的,有的多是空的,若是出現最很差的狀況,每一個節點都只有一個元素,那麼又變成二叉樹了,不平衡下降了查詢效率,因此須要一些多路平衡樹,下面介紹的B樹和B+樹就是。

M階的B樹是一個m路查找樹,它或者是一個空樹,或者知足以下的性質:

  • 根至少有兩個孩子
  • 除了根節點以外,全部的節點至少clip_image168個孩子
  • 全部的外部節點位於同一層

如圖,就是一個4階B樹

clip_image170

 

這裏須要討論幾個事情,首先爲何要保證每一個節點至少clip_image168[1]個孩子呢?這就是上面咱們討論過的平衡性,爲了提升性能,咱們可不想花了半天時間從磁盤上讀取出一個節點,結果只有少許的元素,我還要費勁去讀別的節點,因此這些元素大家怎麼就不能緊湊點放在一塊兒呢?這就是爲何在從樹中刪除元素的算法中,當每一個節點的孩子小於clip_image168[2]就進行節點合併。其次,爲何根能夠只有兩個孩子呢?這是由於通常狀況下,根節點中保存的元素是有限的,內存中基本放的下,根節點不少狀況下是保存在內存裏面的。再者,有關外部節點,對於B樹來說,是沒有外部節點的,由於全部的元素都是放在樹形結構的內部節點中的,在實踐中,指向外部節點的指針通常設爲NULL,若是遇到外部節點,就說明查找失敗,之因此強調外部節點是和B+樹相區別。

對於向B樹裏面插入一個元素,通常須要先沿着樹找元素應該在的位置,若是樹的高度爲h,則這個過程可能須要讀取h次磁盤,而後找到位置後,若是m比較大,通常狀況下,直接寫入相應的節點就能夠了,因而進行了1次寫節點操做,總共須要h+1次磁盤操做,並且僅僅影響一個節點,和咱們的指望是很接近的。然而若是碰到某個節點滿了,則須要進行節點的分裂,不然一個節點過大,超過一個磁盤塊或者緩存單位的話,找另外一個磁盤塊須要硬盤磁頭從新定位,並且緩存須要刷新,從而影響性能。

如圖,展現了一個3路B樹的普通的分裂過程:

clip_image172

 

對於通常的分裂,受到影響的就是本節點和父節點,本節點一個變兩個,兩個都要寫到硬盤中,父節點要加一項。好比圖中插入30,首先通過查詢,應該插入到b節點,插入後b節點就成了三個元素,四棵子樹了,須要分裂,中間的元素20插入到父節點,左側的元素10和右側的元素30分別寫入單獨的節點中,共須要3次磁盤寫入,再加上定位階段的h次磁盤讀取,共h+3次磁盤操做。

固然在有的分裂過程當中,父節點由於子節點分裂而加入元素後,也須要分裂,更有甚者一直分裂到根節點。好比圖中插入60,通過查詢,應該插入到c節點,但是插入後就溢出了,須要分裂,中間的元素70插入到父節點,60和80分別寫入兩個節點,但是父節點插入70後也溢出了,因而還須要分裂,中間的元素40插入新的根節點,20和70分別寫入兩個節點。在整個過程當中,定位須要讀取h個節點,除了最頂層,每一層一個節點分裂成兩個節點須要2(h-1)次寫入,最頂層除了分裂,還須要寫入新的根節點,須要3次寫入,因此總共須要h+2(h-1)+3次磁盤操做,也即3h+1次。

在m較大的狀況下,分裂的狀況仍是機率相對較小的,尤爲是連鎖反應的分裂,因此能夠證實,插入操做磁盤訪問的平均次數約爲h+1,至於如何證實,不少數據結構的書上都有。

對於從B樹中刪除一個元素,一樣須要經過查詢來定位這個元素,若是元素在葉子節點上,則能夠直接刪除,若是元素不在葉子節點上,如圖中刪除節點70,則須要在節點70右面的指針指向的子樹的最小值71來替換70,而後考慮刪除葉子節點中的元素71便可。

clip_image174

 

當m比較大的時候,通常狀況下,將節點中的元素刪除後,再將節點寫入就能夠了,若是刪除的元素原本就在葉子節點,則須要磁盤訪問h+1次,若是要刪除的元素不在葉子節點,則須要磁盤訪問h+2次。若是刪除完元素後,節點中的元素數目小於clip_image176(也即指針的數目小於clip_image168[3]),那就須要調整了,由於每一個節點的元素數目過少將會意味着讀取磁盤的次數增長。

如圖,展現了B樹的刪除過程:

clip_image178

 

調整的方法一,就是向兄弟節點借。好比要刪除元素60,發現節點的元素已經小於1,發現節點e是滿的,借一個元素,因而父節點中70進入節點c,節點e中80進入節點f。在這個過程當中,定位要刪除的元素讀取磁盤次數h,讀取被借的節點次數1,借元素的節點和被借元素的節點,以及父節點都須要寫入磁盤,次數3,共h+4次磁盤操做。

調整的方法二,就是兄弟合併。好比要刪除元素70,發現節點e裏面也只有1個,沒有可借的,因而合併,節點c,節點e,以及父節點中的分割元素80三方合併,成爲節點c中有80, 90。結果更不幸的事情發生了,由於合併,分割元素80須要從父節點中須要刪除,然而刪除80致使父節點也元素不足,須要向兄弟借,結果兄弟節點a也沒錢,又要合併了,因而節點a,節點f和父節點中的分割元素40合併,成爲節點a中有20,40,刪除父節點中的分割元素,父節點是根節點,沒有元素剩餘了,刪除此節點。在一次合併過程當中,定位須要讀取磁盤h次,讀取兄弟節點1次,寫合併後的節點1次,最後還要寫父節點1次,共h+3次磁盤操做。

從上面能夠看出,借比合並須要的磁盤操做次數多,可是借不能連鎖反應,而合併能夠連鎖反應,因而最差的一種狀況是,h層,下面h-2層都連鎖反應的進行合併,到了最上面的兩層根節點和其子節點,變成借。定位須要讀取h次,對於除了根節點和根節點的子節點以外其餘h-2個層次的合併,都須要讀取1次兄弟節點,而後將合併結果寫入1次 (須要寫父節點的那1次,由於父節點也須要參與他那個層次的合併而不進行磁盤寫入) ,共2 (h-2)次磁盤訪問,直到根節點的子節點的刪除,須要讀取兄弟節點1次,借完元素後寫兄弟節點1次,寫本節點一次,寫父節點1次,共4次,因此總共h + 2(h-2) +4 = 3h次磁盤訪問。

從上面對B樹讀寫磁盤的分析咱們能夠看出,B樹從原理上來說,磁盤操做的次數是大約等於樹的高度的,然而當爲了保持樹的平衡性進行分裂,合併的時候,磁盤操做的次數會成倍的增長,這不是咱們指望的。而減小分裂,合併出現的機率的方法,就是m取得比較大的值。

然而前面也分析過,m的取值的大小要是的一個節點的大小約爲一個磁盤塊或者緩存單元。對一個系統來說,磁盤塊和緩存單元的大小是固定的,要增大m,須要減小每一個元素的大小。但是元素的大小也不可能減小啊?從上面的分析中,咱們能夠看出,咱們全部的操做都是針對元素的Key來的,與元素其餘的信息無關,而Key的大小通常是不會佔據整個元素的主要空間的,既然如此,咱們爲何在分裂,合併的操做中,讀寫整個元素呢?好比咱們將外部節點利用起來,存放真正的元素,在內部節點的整棵樹上,僅僅保存元素的Key,一方面,對於一樣大小的節點,僅僅保存key但是使得m更大,另外一方面,對於樹的各類調整,都是讀寫Key,讀寫的數據量大大減小。這就是咱們接下來要介紹的B+樹。

一棵m階B+樹具備以下的性質:

  • 節點分索引節點和數據節點。索引節點至關於B樹的內部節點,全部的索引節點組成一棵B樹,具備B樹的全部的特性。在索引節點中,存放着Key和指針,並不存放具體的元素。數據節點至關與B樹的外部節點,B樹的外部節點爲空,在B+樹中被利用了起來,用於存放真正的數據元素,裏面包含了Key和元素的其餘信息,可是沒有指針。
  • 整棵索引節點組成的B樹僅僅用來查找具備某個Key的數據元素位於哪一個外部節點。在索引節點中找到了Key,事情沒有結束,要繼續找到數據節點,而後將數據節點中的元素讀出來,或者二分查找,或者順序掃描來尋找真正的數據元素。
  • M這個階數僅僅用來控制索引節點部分的度,至於每一個數據節點包含多少元素,與m無關。
  • 另外有一個鏈表,將全部的數據節點串起來,能夠順序訪問。

如圖,所示:

clip_image180

 

從圖中咱們能夠看出,這是一個3階B+樹,而一個外部數據節點最多包含5項。若是插入的數據在數據節點,若是不引發分裂和合並,則索引節點組成的B樹就不會變。

若是在71到75的外部節點插入一項76,則引發分裂,71,72,73成爲一個數據節點,74,75,76成爲一個數據節點,而對於索引節點來說至關於插入一個Key爲74的過程。

若是在41到43的外部節點中刪除43,則引發合併,41,42,61,62,63合併成一個節點,對於索引節點來說,至關於刪除Key爲60的過程。

相關文章
相關標籤/搜索