1. 程式人生 > >Trie樹進階:Double-Array Trie原理及狀態轉移過程詳解

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樹的瞭解還是處在一個模糊的狀態,那麼我想你現在可以移步到本人的另一篇部落格《

資料結構:字典樹的基本使用》,在對Trie樹有一個基本的瞭解之後,再來學習本文的內容應該會更加輕鬆自如(如果你對Trie樹已經有了或淺或深的瞭解,那麼可以直接看下面的內容了)。

  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中的節點資訊如下:

  1. private static class Node {
  2. int
    code;
  3. int depth;
  4. int left;
  5. int right;
  6. }
  code: 代表節點字元的編碼。如:'a'.code = 97

  depth: 代表節點所在樹的深度。root.depth = 0

  left: 代表節點的子節點在字典中範圍的左邊界

  rigth: 代表節點的子節點在字典中範圍的右邊界

1.DAT的建立

  和Trie樹一樣,DAT的建立只是建立Root的過程。如下:

  1. public int build(List<String> _key) {
  2. key = _key;
  3. ...
  4. resize(65536 * 32);
  5. ...
  6. Node root_node = new Node();
  7. root_node.left = 0;
  8. root_node.right = keySize;
  9. root_node.depth = 0;
  10. ...
  11. return error_;
  12. }

2.為節點parent生成子節點

  在生成子節點的過程中,如果碰到parent='B',而'B'又是某一個key的結尾。該如何辦呢?

  比如:比如若以"一舉"中的'舉'字元為parent,那麼parent.depth = 2,"一舉".length = 2.

  遇到這種情況,我們就需要對其進行過濾操作,過程如下:

  1. String tmp = key.get(i);
  2. int currCode = 0;
  3. if (tmp.length() != parent.depth) {
  4. currCode = (int) tmp.charAt(parent.depth) + 1;
  5. }
完整過程:
  1. private int fetch(Node parent, List<Node> siblings) {
  2. ...
  3. int prevCode = 0;
  4. for (int i = parent.left; i < parent.right; i++) {
  5. if (key.get(i).length() < parent.depth) {
  6. continue;
  7. }
  8. String tmp = key.get(i);
  9. int currCode = 0;
  10. if (tmp.length() != parent.depth) {
  11. currCode = (int) tmp.charAt(parent.depth) + 1;
  12. }
  13. ...
  14. if (currCode != prevCode || siblings.size() == 0) {
  15. Node tmp_node = new Node();
  16. tmp_node.depth = parent.depth + 1;
  17. tmp_node.code = currCode;
  18. tmp_node.left = i;
  19. if (siblings.size() != 0) {
  20. siblings.get(siblings.size() - 1).right = i;
  21. }
  22. siblings.add(tmp_node);
  23. }
  24. prevCode = currCode;
  25. }
  26. if (siblings.size() != 0) {
  27. siblings.get(siblings.size() - 1).right = parent.right;
  28. }
  29. return siblings.size();
  30. }

3.向Trie樹中插入子節點

  在DAT的建立過程中,insert是關鍵部分。

  在insert操作裡,我們使用了遞迴的思路來解決問題。為什麼要利用遞迴呢?因為在我們狀態轉移的過程中,父節點的base值需要依賴子返回的begin值,因為在DAT中,code[null] = 0,所以也可以認為是依賴於子節點的check值,如此反覆,層層巢狀。關於這一點在下面的結構圖展示中更容易體現。

(0)check的合法性檢查

  之前我們說check陣列是為了檢查各個字串是否是從同一個狀態轉移而來,但是,要如何檢查呢?看下面這段程式碼:

  1. outer: while (true) {
  2. position++;
  3. if (check[position] != 0) {
  4. continue;
  5. } else if (first == 0) {
  6. ...
  7. }
  8. begin = position - siblings.get(0).code; // 當前位置離第一個兄弟節點的距離
  9. ...
  10. for (int i = 1; i < siblings.size(); i++) {
  11. if (check[begin + siblings.get(i).code] != 0) {
  12. continue outer;
  13. }
  14. }
  15. break;
  16. }
  這裡的position即在陣列中的下標。可以看到這是一個迴圈遍歷的過程,我們在一個合適的位置開始,逐步地嘗試check值是否合法,找到第一個合法的begin值即可。  而check[i]合法的條件就是check[i]是否為0。如果check[i]不為0,則說明此位置已經被別的狀態佔領了,需要更換到下一個位置。

(1)計算所有子節點的check值

  1. for (int i = 0; i < siblings.size(); i++) {
  2. check[begin + siblings.get(i).code] = begin;
  3. }

(2)計算所有子節點的base值

  1. private int insert(List<Node> siblings) {
  2. ...
  3. for (int i = 0; i < siblings.size(); i++) {
  4. List<Node> new_siblings = new ArrayList<Node>();
  5. if (fetch(siblings.get(i), new_siblings) == 0) {
  6. base[begin + siblings.get(i).code] = (value != null) ? (-value[siblings.get(i).left] - 1) : (-siblings.get(i).left - 1);
  7. ...
  8. } else {
  9. int h = insert(new_siblings);
  10. base[begin + siblings.get(i).code] = h;
  11. }
  12. }
  13. return begin;
  14. }
在這一步中,大家可以很明顯地看到,這是一個遞迴的過程。我們需要獲得子節點的begin值。

採用遞迴之後,我們的DAT節點的狀態轉移過程

(3)整體的insert過程:

  1. private int insert(List<Node> siblings) {
  2. ...
  3. // check的合法性檢查
  4. ...
  5. // 計算所有子節點的check值
  6. // 計算所有子節點的base值
  7. ...
  8. }

DAT中雙陣列的狀態轉移過程

4.字首查詢

  現在假設待查詢字串T="走廊裡的壁畫",我們需要在之前的字典中查詢所有是T字首的字串。我們要怎麼做呢?

  其實在上面的雙陣列狀態轉移過程圖中,我們可以很清楚地找到一條滿足條件的路徑.如下:

  

關鍵程式碼如下:

  1. public List<Integer> commonPrefixSearch(String key, int pos, int len, int nodePos) {
  2. ...
  3. int b = base[nodePos];
  4. ...
  5. for (int i = pos; i < len; i++) {
  6. p = b;
  7. n = base[p];
  8. if (b == check[p] && n < 0) {
  9. result.add(-n - 1);
  10. }
  11. p = b + (int) (keyChars[i]) + 1;
  12. if (b == check[p]) {
  13. b = base[p];
  14. } else {
  15. return result;
  16. }
  17. }
  18. p = b;
  19. n = base[p];
  20. if (b == check[p] && n < 0) {
  21. result.add(-n - 1);
  22. }
  23. return result;
  24. }

5.關鍵詞智慧提示:

  在上面“字首查詢”的例子中,我們的匹配字串中比較長,在還沒到字串的最後一位就遇到狀態停止標誌。而如果匹配字串比較短,我就還可以做一些其他的事情了,比如常見的搜尋引擎中關鍵詞智慧提示。

  過程就是在上一步的基礎上,把終止迴圈的條件修改為直到遇到一個狀態停步標誌.這樣我們就可以在遍歷整條路徑。

  這個功能,在原始碼中沒有涉及。而本文的目的是在於解釋DAT的原理和其狀態轉移的過程。所以,這裡就暫不貼程式碼了。不過,在後期的《搜尋引擎:對使用者輸入有誤的關鍵詞進行糾錯處理》部落格中應該會有所涉及。感興趣的朋友,可以關注下。

實現原始碼下載: