資料結構-字典樹

小墨魚3發表於2020-01-31

字典樹(Trie)

什麼是字典樹?

Trie樹, 又叫字典樹、字首樹(Prefix Tree)、單詞查詢樹或鍵樹, 是一種多叉樹結構。 Trie通常只用來處理字串。

Trie特性:

  1. 根節點不包含字元, 除根節點外每一個子節點都包含一個字元。
  2. 從根節點到某一個節點, 路徑上經過的字元連線起來, 為該節點對應的字串(單詞)。
  3. 每個節點的所有子節點包含的字元互不相同。
  4. 通常在實現Trie的時候, 會在節點結構中設定一個標識, 用來標識該節點處是否構成一個單詞

可以看出, Trie樹的關鍵字一般都是字串, 而且Trie樹把每個關鍵字儲存在一條路徑上, 而不是一個節點中, 另外, 兩個有公共字首的關鍵字, 在Trie樹中字首部分的路徑相同, 所以Trie樹又叫做字首樹(Prefix Tree)。

Trie優缺點:
Trie樹的核心思想是空間換時間, 利用字串的公共字首來減少無謂的字串比較以達到提高查詢效率的目的。

優點:

  1. 插入和查詢的效率很高,都為O(w),其中w是待插入/查詢的字串的長度
  2. Trie樹中不同的關鍵字不會產生衝突。
  3. Trie樹只有在允許一個關鍵字關聯多個值的情況下才有類似hash碰撞發生。
  4. Trie樹不用求 hash 值,對短字串有更快的速度。通常,求hash值也是需要遍歷字串的。
  5. Trie樹可以對關鍵字按字典序排序。

缺點:

  • 當 hash 函式很好時,Trie樹的查詢效率會低於雜湊搜尋。
  • 空間消耗比較大。

目前對hash不瞭解沒關係, 只要有個大概的印象就可以了。

假如現在有100萬條資料, 如果使用Trie查詢, 就和有多少條目沒有關係。

資料結構 時間複雜度 備註
Trie O(w) 其中w為字串長度
BST O(logn)
Tire資料結構

在考察一個字串或者單詞看成是一個整體, 但是Tire卻打破了這種方式, 它以一個字母為單位拆分儲存, 從根節點開始一直到葉子節點去遍歷, 遍歷到一個葉子節點就形成一個單詞。

如圖[1-1]中, 可以看到儲存了4個單詞, 分別是{"cat", "dog", "deer", "panda"}

圖[1-1]

1-1

我們要查詢任何一個單詞, 從根節點出發只需要經過這個單詞有多少個字母, 過了多少個節點, 最終達到葉子節點。就成功查詢到單詞。這樣的資料結構就叫做Trie。

Trie每一個節點是如何定義的?

由於我們的英文字母有26個, 所以每一個節點都有26個指向下一個節點的指標, 只不過我們圖[1-1]中沒有畫那麼多而已。

所以在Trie中節點大概定義如下

class Node {
  char c ; // 每個節點裝載一個字母
  Node[26] next ; // 裝載26個指標
}
複製程式碼

不過不同的場景下, 26個指標可能是富裕的, 有可能是不夠的。 比如說, 每個節點下面跟26個孩子, 但是並沒有考慮大小寫的問題。如果我們設計的Trie要考慮大寫的話, 相應的有52個指標。但是, 如果我們的Trie設計的更加複雜, 比如說裝載了網址或者郵件地址, 相應的有一些字元也應該計算在內, 如: "@,:,_-"等等。

所以通常並不會固定指標數量, 除非該場景固定就26個字母。
所以我們需要每一個節點都有若干個指向下一個節點的指標。


class Node {
  char c ;
  Map<char, Node> next ;
}
複製程式碼

其實, 我們從根節點找到下一個節點的過程中, 我們就已經知道這個字母是誰了, 換句話說, 我從根節點來搜尋"cat"這個詞, 之所以能夠來到這個節點, 是因為在根節點就知道我的下一個節點要到'c'所在的這個節點中。

所以, 在我們的設計中, 可以不儲存這個字元

class Node {
  Map<char, Node> next ;
}
複製程式碼

不過上述的設計還是有問題, Trie從根節點一直到葉子節點才到了一個單詞的地方。 比如我們查詢到了't'我們就找到了"cat"這個詞, 我們查詢到了'g'我們就找到了"dog"這個詞, 以此類推, 不過在英語中有些單詞可能是另外一個單詞的字首

比如說: "pan"這個單詞, 如果我們這個Trie中既要儲存"pan"又要儲存"panda"那麼怎麼辦呢? 此時這個"pan"它的結尾'n'並不是葉子節點, 正因為如此, 每一個節點都需要一個標識,這個標識來告訴大家當前這個節點是否是某一個單詞的結尾, 某一個單詞的結尾光靠葉子節點是無法區分出來的, 所以我們設計應該在加入一個欄位代表是否為一個單詞的結尾。

class Node {
  boolean isWord ;
  Map<char, Node> next ;
}
複製程式碼
實現Trie
構建Trie

public class Trie {
/**
 * 更具上面所述, 構建我們的Node
 */
  private class Node {
    public boolean isWord;
    public Map<Character, Node> next;

    public Node(boolean isWord) {
        this.isWord = isWord;
        this.next = new TreeMap<>();
    }

    public Node() {
        this(false);
    }
  }

  // 根節點
  private Node root ;
  private int size ;

  // 初始化節點資訊
  public Trie() {
    this.root = new Node();
    this.size = 0;
  }

  public int getSize() {
    return size;
  }
}
複製程式碼
向Trie新增元素

/**
 * 向Trie中新增一個新的單詞word
 * @param word
 */
public void add(String word) {
    Node cur = root;
    for (int i = 0 ; i < word.length(); i ++) {
        char c = word.charAt(i);
        if (cur.next.get(c) == null) // 如果下一個節點不存在字元就新增, 如果存在不做任何操作
            cur.next.put(c, new Node());

        cur = cur.next.get(c); // 重新賦值, 這樣就到葉子節點但是有可能是某個非葉子節點
    }

    // 結束之後, 不能直接就size++, 需要判斷是否之前就新增過該單詞了, 就判斷尾巴是否為true
    if (!cur.isWord) {
        cur.isWord = true;
        size ++;
    }
}

// 新增元素遞迴版
public void addRE(String word) {
    addRE(word, 0, root);
}

// 新增元素遞迴版
private void addRE(String word, int index, Node node) {

    if (index == word.length()) {
        if (!node.isWord) {
            node.isWord = true;
            size ++;
        }

        return ;
    }

    char c = word.charAt(index);
    if (node.next.get(c) == null)
        node.next.put(c, new Node());
    addRE(word, ++index, node.next.get(c));
}
複製程式碼
查詢單詞是否在Trie中
/**
 * 查詢單詞是否在trie中
 * @param word
 * @return
 */
public boolean contains(String word) {
    Node cur = root;
    for (int i = 0 ; i < word.length(); i ++) {
        char c = word.charAt(i);
        if (cur.next.get(c) == null) // 如果不存在查詢的單詞字母, 則直接返回
            return false;

        cur = cur.next.get(c);
    }

    // 記住, 這裡計算遍歷出來後也不能直接返回true, 比如一開說的pan是panda字首, 如果我們沒有新增pan卻返回了true就有問題了
    //        return true;
    return cur.isWord; // 正確的方式直接返回當前節點的標識
}

// 查詢單詞是否在trie中, 遞迴寫法
public boolean containsRE(String word) {
    return containsRE(word, 0, root);
}

private boolean containsRE(String word, int index, Node node) {
    if (index == word.length()) {
        return node.isWord;
    }

    char c = word.charAt(index);
    return node.next.get(c) == null ? false : containsRE(word, ++index, node.next.get(c));
}
複製程式碼
字首查詢

幾乎和查詢邏輯是一樣的, 只不過我們不需要按照isWord返回, 如果我們能順利退出迴圈, 就表示我們能查詢到該字串的字首。


// 查詢Trie中有單詞以prefix為字首
public boolean isPrefix(String prefix) {
    Node cur = root;
    for (int i = 0; i < prefix.length(); i ++) {
        char c = prefix.charAt(i);
        if (cur.next.get(c) == null)
            return false;

        cur = cur.next.get(c);
    }

    return true;
}

// 查詢Trie中有單詞以prefix為字首(遞迴寫法)
public boolean isPrefixRE(String prefix) {
    return isPrefixRE(prefix, 0, root);
}

private boolean isPrefixRE(String prefix, int index, Node node) {
    if (index == prefix.length()) {
        return true;
    }
    char c = prefix.charAt(index);
    return node.next.get(c) == null ? false : isPrefixRE(prefix, ++index, node.next.get(c));
}
複製程式碼
擴充套件Trie

基本上, 我們上面已經實現了Trie的功能了, 但是我們現在來新增一些擴充套件, 比如說: 如果輸入"."就代表匹配任意字元進行匹配。

那麼, 如何處理呢?


public boolean match(String word) {
        return match(word, 0, root);
    }

private boolean match(String word, int index, Node node) {

    if (index == word.length())
        return node.isWord;

    char c = word.charAt(index);
    if (c != '.') {
        return node.next.get(c) == null ? false : match(word, ++index, node.next.get(c));
    } else {
        // 如果是"."需要把所有節點遍歷進行匹配
        for (Character nextChar : node.next.keySet()) {
            return node.next.get(nextChar) == null ? false : match(word, ++index, node.next.get(nextChar));
        }

        return false;
    }
}
複製程式碼

avatar

相關文章