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

劉超覺先發表於2014-08-28

詞典的格式設計

詞典中所儲存的資訊主要是三部分:

  • 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的過程。

相關文章