Trie樹進階:Double-Array Trie原理及狀態轉移過程詳解
前言:
Trie樹本身就是一個很迷人的資料結構,何況是其改進的方案。
在本部落格中我會從DAT(Double-Array Tire)的原理開始,並結合其原始碼對DAT的狀態轉移過程進行解析。如果因此你能從我的部落格中有所收穫或啟發,It's my pleasure.
特別說明:
1.本文參考:
圖形展示及說明:
0.樸素Trie樹示意圖:
從上圖中可以看到,這樣的樹結構是非常稀疏的。造成了資源的巨大浪費。
1.DAT節點示意圖:
這裡"NULL"代表結束。
DAT原理說明:
0.簡介:
在學習DAT(Double-Array Trie)之前,如果你對Tire樹的瞭解還是處在一個模糊的狀態,那麼我想你現在可以移步到本人的另一篇部落格《
DAT的本質是一個有限自動機(因為博主在學習DAT之前對自動機的相關內容也是一知半解,在學習DAT的過程,難免有一些痛苦。博主也緊追一下這方面的知識,也會在後面的部落格中寫一些相關的博文).我們要構建一些狀態,用於狀態的自動轉移。顧名思義,在DAT中用的就是雙陣列:base陣列和check陣列。雙陣列的分工是:base負責記錄狀態,用於狀態轉移;check負責檢查各個字串是否是從同一個狀態轉移而來,當check[i]為負值時,表示此狀態為字串的結束。
你可能問一個這樣的問題:那麼base陣列和check陣列是怎麼來進行狀態轉移呢?
請看下面關於DAT雙陣列的計算過程。
1.DAT中雙陣列的計算過程:
假定有字串狀態s,當前字串狀態為t,假定t加了一個字元c就等於狀態tc,加了一個字元x等於狀態tx,那麼有:base[t] + c.code = base[tc]base[t] + x.code = base[tx]check[tc] = check[tx]
上面的幾個等式就是狀態base和它的轉移方程。
Double-Array Trie原始碼解析:
0.特別說明:
DAT中的節點資訊如下:
- private static class Node {
- int
code; - int depth;
- int left;
- int right;
- }
code: 代表節點字元的編碼。如:'a'.code = 97depth: 代表節點所在樹的深度。root.depth = 0
left: 代表節點的子節點在字典中範圍的左邊界
rigth: 代表節點的子節點在字典中範圍的右邊界
1.DAT的建立
和Trie樹一樣,DAT的建立只是建立Root的過程。如下:
- public int build(List<String> _key) {
- key = _key;
- ...
- resize(65536 * 32);
- ...
- Node root_node = new Node();
- root_node.left = 0;
- root_node.right = keySize;
- root_node.depth = 0;
- ...
- return error_;
- }
2.為節點parent生成子節點
在生成子節點的過程中,如果碰到parent='B',而'B'又是某一個key的結尾。該如何辦呢?
比如:比如若以"一舉"中的'舉'字元為parent,那麼parent.depth = 2,"一舉".length = 2.
遇到這種情況,我們就需要對其進行過濾操作,過程如下:
- String tmp = key.get(i);
- int currCode = 0;
- if (tmp.length() != parent.depth) {
- currCode = (int) tmp.charAt(parent.depth) + 1;
- }
完整過程:- private int fetch(Node parent, List<Node> siblings) {
- ...
- int prevCode = 0;
- for (int i = parent.left; i < parent.right; i++) {
- if (key.get(i).length() < parent.depth) {
- continue;
- }
- String tmp = key.get(i);
- int currCode = 0;
- if (tmp.length() != parent.depth) {
- currCode = (int) tmp.charAt(parent.depth) + 1;
- }
- ...
- if (currCode != prevCode || siblings.size() == 0) {
- Node tmp_node = new Node();
- tmp_node.depth = parent.depth + 1;
- tmp_node.code = currCode;
- tmp_node.left = i;
- if (siblings.size() != 0) {
- siblings.get(siblings.size() - 1).right = i;
- }
- siblings.add(tmp_node);
- }
- prevCode = currCode;
- }
- if (siblings.size() != 0) {
- siblings.get(siblings.size() - 1).right = parent.right;
- }
- return siblings.size();
- }
3.向Trie樹中插入子節點
在DAT的建立過程中,insert是關鍵部分。
在insert操作裡,我們使用了遞迴的思路來解決問題。為什麼要利用遞迴呢?因為在我們狀態轉移的過程中,父節點的base值需要依賴子返回的begin值,因為在DAT中,code[null] = 0,所以也可以認為是依賴於子節點的check值,如此反覆,層層巢狀。關於這一點在下面的結構圖展示中更容易體現。
(0)check的合法性檢查
之前我們說check陣列是為了檢查各個字串是否是從同一個狀態轉移而來,但是,要如何檢查呢?看下面這段程式碼:
- outer: while (true) {
- position++;
- if (check[position] != 0) {
- continue;
- } else if (first == 0) {
- ...
- }
- begin = position - siblings.get(0).code; // 當前位置離第一個兄弟節點的距離
- ...
- for (int i = 1; i < siblings.size(); i++) {
- if (check[begin + siblings.get(i).code] != 0) {
- continue outer;
- }
- }
- break;
- }
這裡的position即在陣列中的下標。可以看到這是一個迴圈遍歷的過程,我們在一個合適的位置開始,逐步地嘗試check值是否合法,找到第一個合法的begin值即可。 而check[i]合法的條件就是check[i]是否為0。如果check[i]不為0,則說明此位置已經被別的狀態佔領了,需要更換到下一個位置。(1)計算所有子節點的check值
- for (int i = 0; i < siblings.size(); i++) {
- check[begin + siblings.get(i).code] = begin;
- }
(2)計算所有子節點的base值
- private int insert(List<Node> siblings) {
- ...
- for (int i = 0; i < siblings.size(); i++) {
- List<Node> new_siblings = new ArrayList<Node>();
- if (fetch(siblings.get(i), new_siblings) == 0) {
- base[begin + siblings.get(i).code] = (value != null) ? (-value[siblings.get(i).left] - 1) : (-siblings.get(i).left - 1);
- ...
- } else {
- int h = insert(new_siblings);
- base[begin + siblings.get(i).code] = h;
- }
- }
- return begin;
- }
在這一步中,大家可以很明顯地看到,這是一個遞迴的過程。我們需要獲得子節點的begin值。採用遞迴之後,我們的DAT節點的狀態轉移過程
(3)整體的insert過程:
- private int insert(List<Node> siblings) {
- ...
- // check的合法性檢查
- ...
- // 計算所有子節點的check值
- // 計算所有子節點的base值
- ...
- }
DAT中雙陣列的狀態轉移過程
4.字首查詢
現在假設待查詢字串T="走廊裡的壁畫",我們需要在之前的字典中查詢所有是T字首的字串。我們要怎麼做呢?
其實在上面的雙陣列狀態轉移過程圖中,我們可以很清楚地找到一條滿足條件的路徑.如下:
關鍵程式碼如下:
- public List<Integer> commonPrefixSearch(String key, int pos, int len, int nodePos) {
- ...
- int b = base[nodePos];
- ...
- for (int i = pos; i < len; i++) {
- p = b;
- n = base[p];
- if (b == check[p] && n < 0) {
- result.add(-n - 1);
- }
- p = b + (int) (keyChars[i]) + 1;
- if (b == check[p]) {
- b = base[p];
- } else {
- return result;
- }
- }
- p = b;
- n = base[p];
- if (b == check[p] && n < 0) {
- result.add(-n - 1);
- }
- return result;
- }
5.關鍵詞智慧提示:
在上面“字首查詢”的例子中,我們的匹配字串中比較長,在還沒到字串的最後一位就遇到狀態停止標誌。而如果匹配字串比較短,我就還可以做一些其他的事情了,比如常見的搜尋引擎中關鍵詞智慧提示。
過程就是在上一步的基礎上,把終止迴圈的條件修改為直到遇到一個狀態停步標誌.這樣我們就可以在遍歷整條路徑。
這個功能,在原始碼中沒有涉及。而本文的目的是在於解釋DAT的原理和其狀態轉移的過程。所以,這裡就暫不貼程式碼了。不過,在後期的《搜尋引擎:對使用者輸入有誤的關鍵詞進行糾錯處理》部落格中應該會有所涉及。感興趣的朋友,可以關注下。