Trie(字典樹、前綴樹)

什麼是Trie?

  Trie是一個多叉樹,Trie專門為處理字符串而設計的。使用我們之前實現的二分搜索樹來查詢字典中的單詞,查詢的時間複雜度為O(logn),如果有100萬(220)個單詞,則logn大約等於20,但是使用Trie這種數據結構,查詢每個條目的時間複雜度,和一共有多少個條目無關!時間複雜度為O(w),w為被查詢單詞的長度!大多數單詞的長度小於10。

  Trie將整個字符串以字母為單位,一個一個拆開,從根節點開始一直到葉子節點去遍歷,就形成了一個單詞,下圖中的Trie就存儲的四個單詞(cat,dog,deer,panda)

  每個節點有26個字母指向下個節點的指針,考慮不同的語言,不同的情境,比如現在這個26個字符是沒有包含大寫字母的,如果需要包含大寫字母,則需要讓每個節點有52個指向下個節點的指針,如果現在要加入郵箱呢?所以這裡描述為每個節點有若干個指向下個節點的指針。

  由於很多單詞可能是另外一個單詞的前綴,比如pan就是panda的前綴,那麼再Trie中如何存儲呢?所以我們應該對節點添加一個標識符,判斷該節點是否是某個單詞的結尾,某一個單詞的結尾只靠葉子節點是不能區別出來的,因此我們再設計Node節點時,應該添加一個IsWord,判斷該節點是否是單詞的結尾。

創建一棵Trie

  在創建Trie之前,我們需要先設計Trie的節點類,根據上面說的,每個節點都有若干個指向下個節點的指針,還需要一個isWord來判斷是否是單詞的結尾,代碼實現如下:

    //設計Trie的節點類
    private class Node{
        
        //判斷是否是一個單詞
        public boolean isWord;
        //每個節點有若干個指向下個節點的指針
        public TreeMap<Character,Node> next;

        //有參構造:對該節點進行初始化
        public Node(boolean isWord){
            this.isWord = isWord;
            next = new TreeMap<>();
        }
        
        //無參構造:默認當前節點不是單詞的結尾
        public Node(){
            this(false);
        }

    }

  現在就讓我們來實現一個Trie

public class Trie {

    //設計Trie的節點類
    private class Node{

        //判斷是否是一個單詞
        public boolean isWord;
        //每個節點有若干個指向下個節點的指針
        public TreeMap<Character,Node> next;

        //有參構造:對該節點進行初始化
        public Node(boolean isWord){
            this.isWord = isWord;
            next = new TreeMap<>();
        }

        //無參構造:默認當前節點不是單詞的結尾
        public Node(){
            this(false);
        }

    }

    private Node root;
    private int size;

    public Trie() {
        root = new Node();
        size = 0;
    }

    // 獲得Trie中存儲的單詞數量
    public int getSize(){
        return size;
    }
}

向Trie中添加元素

  Trie的添加操作:添加的是一個字符串,要把這個字符串拆成一個一個字符,把這一個一個字符作為一個一個節點,存入Trie中。

    //向Trie中添加一個新的單詞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加一,並且維護當前節點的isWord
        if (! cur.isWord){
            cur.isWord = true;
            size ++;
        }

    }

Trie的查詢操作

    //Tire的查詢操作
    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);
        }
        return cur.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的性能

  這裡對比二分搜索樹和Trie的性能,仍然是使用的以添加和統計《傲慢與偏見》這本書為例,關於該測試用例中的文件工具類,和《傲慢與偏見》文檔,請前往我之前寫的 集合和映射 進行獲取。

    public static void main(String[] args) {
        System.out.println("Pride and Prejudice");

        List<String> words = new ArrayList<>();

        if(FileOperation.readFile("pride-and-prejudice.txt", words)){
//            Collections.sort(words);

            long startTime = System.nanoTime();

            //使用基於二分搜索樹實現的集合進行添加和查詢操作
            BSTSet<String> set = new BSTSet<>();
            for(String word: words)
                set.add(word);

            for(String word: words)
                set.contains(word);

            long endTime = System.nanoTime();

            double time = (endTime - startTime) / 1000000000.0;
            //基於二分搜索樹實現的集合進行添加和查詢操作所花費的時間
            System.out.println("Total different words: " + set.getSize());
            System.out.println("BSTSet: " + time + " s");

            // --- 測試通過Trie通過添加和查詢所需要的時間

            startTime = System.nanoTime();

            Trie trie = new Trie();
            for(String word: words)
                trie.add(word);

            for(String word: words)
                trie.contains(word);

            endTime = System.nanoTime();

            time = (endTime - startTime) / 1000000000.0;

            System.out.println("Total different words: " + trie.getSize());
            System.out.println("Trie: " + time + " s");
        }

    }


  通過上面測試代碼可以看出,其實數據量不大的情況下,對於一個隨機字符串的集合,使用二分搜索書和Trie進行添加和查詢操作,差別是不大的,如果我們加入的數據是有序的,這時二分搜索樹就會退化成鏈表,時間複雜度就為O(n),運行效率是很低的,但是Trie並不受影響,我們可以對words進行排序後,在看一下運行結果:

  通過上面的測試,可以看出對有序的數據進行添加和查詢操作,差距是特別大的。

leetcode上的問題

  我們可以看到leetcode官網上的208好問題,就是實現一個Trie

其實從題目描述中就可以看出,這個問題中的三個方法就是我們實現的add(),contains(),isPrefix()操作,直接將我們寫的代碼改個方法名字提交就可以通過了。

我們再來看一道leetcode上的211號問題:添加與搜索單詞

  通過題目描述,我們會發現只是查詢操作和我們實現的Trie有所不同,添加操作沒有發改變。由於字符’.’可以代表任何一個字母,所以我們對於’.’,需要遍歷所有的可能。

    public boolean search(String word) {
        //遞歸匹配查找
        return match(root,word,0);
    }

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

        char c = word.charAt(index);
        if (c != '.'){
            if (node.next.get(c) == null)
                return false;
            return match(node.next.get(c),word,index+1);
        }
        else {
            //如果當前節點的的值為『.』,則需要遍歷當前節點的所有子節點
            for (char nextChar : node.next.keySet()) {
                if (match(node.next.get(nextChar),word,index+1)){
                    return true;
                }
            }
            return false;
        }
    }

代碼提交到leetcode後,就會提示通過了

我們再來看看leetcode上的677號問題:Map Sum Pairs(鍵值映射)

  根據題目描述,我們可以理解為:映射中存儲的是單詞和權重值。sum()方法是求得包含這個前綴單詞得權重和
代碼實現如下:

    //設計節點類
    private class Node{
        //單詞的權重值
        public int value;
        //每個節點都可能有若干個子節點
        public TreeMap<Character,Node> next;

        public Node(int value){
            this.value = value;
            next = new TreeMap<>();
        }

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

    private Node root;

    public MapSum(){
        root = new Node();
    }

    //添加操作和我們實現的字典樹中的添加操作類型
    public void insert(String word,int val){
        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);
        }
        cur.value = val;
    }

    //求前綴為prefix的權重和
    public int sum(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 0;
            }
            cur = cur.next.get(c);
        }
        return sum(cur);
    }

    private int sum(Node node) {
        int res = node.value;
        for (char c : node.next.keySet()) {
            res += sum(node.next.get(c));
        }
        return res;
    }

leetcode上的提交結果: