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

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

詞典的格式設計

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

  • 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