Trie樹的基本原理及應用

前言

在做用戶 query 理解的過程中,有許多需要使用詞典來"識別"的過程。在此期間,就避免不了使用 Trie 樹這一數據結構。

因此今天我們來深入的學習一下 Trie 樹相關的理論知識,並且動手編碼實現。

理論知識

什麼是 Trie 樹

下面的定義引自維基百科。

在計算機科學中,trie,又稱前綴樹或字典樹,是一種有序樹,用於保存關聯數組,其中的鍵通常是字符串。與二叉查找樹不同,鍵不是直接保存在節點中,而是由節點在樹中的位置決定。一個節點的所有子孫都有相同的前綴,也就是這個節點對應的字符串,而根節點對應空字符串。一般情況下,不是所有的節點都有對應的值,只有葉子節點和部分內部節點所對應的鍵纔有相關的值。

一個簡單的 Trie 結構如下圖所示:

2019-12-06-19-20-04

從上面的圖中,我們可以發現一些 Trie 的特性。

  • 根節點不包含字符,除根節點外的每一個子節點都包含一個字符。
  • 從根節點到某一節點,路徑上經過的字符連接起來,就是該節點對應的字符串。
  • 每個單詞的公共前綴作爲一個字符節點保存。

通常在實現的時候,會在節點結構中設置一個標誌,用來標記該結點處是否構成一個單詞(關鍵字), 或者存儲一些其他相關的值。

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

Trie 樹的每個節點的子節點,是一堆單字符的集合,我們可以很方便的進行對所有字符串進行字典序的排序工作。只需要將字典序先序輸出,輸出所有子節點時按照字典序遍歷即可。所以 Trie 樹又叫做字典樹。

Trie 的優劣勢

Trie 樹的核心思想就是:用空間來換時間,利用字符串的公共前綴來降低查詢時間的開銷以達到提高效率的目的。

當然,在大數據量的情況下,Trie 樹的空間也未必會大於哈希表。只要通過共享前綴節省的空間能夠 Cover 對象的額外開銷。

Trie 的強大之處就在於它的時間複雜度,插入和查詢的效率很高,都爲O(N),其中 N 是待插入/查詢的字符串的長度,而與 Trie 中保存了多少個元素無關。

關於查詢,會有人說 hash 表時間複雜度是O(1)不是更快?但是,哈希搜索的效率通常取決於 hash 函數的好壞,若一個壞的 hash 函數導致很多的衝突,效率並不一定比 Trie 樹高。

而 Trie 樹中不同的關鍵字就不會產生衝突。它只有在允許一個關鍵字關聯多個值的情況下才有類似 hash 碰撞發生。

此外,Trie 樹不用求 hash 值,對短字符串有更快的速度。因爲通常,求 hash 值也是需要遍歷字符串的。

也就是說,從理論上來講,Trie 樹的時間複雜度是穩定的,而 hash 表的時間複雜度是不穩定的,取決於 hash 函數的好壞,也和存儲的字符串集有關係。

而從工業應用上來講,個人推薦:如果你不需要用到 Trie 樹前綴匹配的特性,直接用 hash 表即可。

原因有以下幾點:

  1. hash 表實現極其簡單,且大多數語言都有完善的內部庫。使用方便。
  2. 大部分時間就 K-V 存儲而言,hash 是由於 Trie 樹的,尤其是 語言庫中經過各方大佬優化的 hash 表。
  3. Trie 樹要自己實現,且要經過各種邏輯上的測試,保證覆蓋率,還要壓測等等才能投入使用,成本太高。

Trie 的應用場景

作爲一個工程師,我學習一個東西最重要的地方就是了解他的應用場景,所有隻存在於書本上而沒有成熟應用的技術,我都淺嘗輒止。

在學習 Trie 樹時,我也花了很多時間來查找,記錄它的應用場景,列舉在此處,如果各位同學有其他的應用場景,不妨留言大家討論。

K-V 存儲及檢索

這是 Trie 樹嘴原始樸素的使用方法,也就是需要和 hash 表進行競爭的地方。

詞頻統計

我們可以修改 Trie 樹的實現,將每個節點的 是否在此構成單詞標誌位改成此處構成的單詞數量. 這樣我們可以用它進行搜索場景常見的詞頻統計。

當然這個需求 hash 表也是可以實現的。

字典序排序

將所有待排序集合逐個加入到 Trie 樹中,然後按照先序遍歷輸出所有值。在遍歷某個節點的所有子節點的時候,按照字典序進行輸出即可。

前綴匹配

例如:找出一個字符串集合中所有以 ab 開頭的字符串。我們只需要用所有字符串構造一個 trie 樹,然後輸出以a>b>a->b->開頭的路徑上的關鍵字即可。

trie 樹前綴匹配常用於搜索提示。比如各種搜索引擎上的 自動聯想後半段功能。

2019-12-06-23-13-00

最長公共前綴
查找一組字符串的最長公共前綴,只需要將這組字符串構建成 Trie 樹,然後從跟節點開始遍歷,直到出現多個節點爲止(即出現分叉)。

作爲輔助結構
作爲其他數據結構的輔助結構,如後綴樹,AC 自動機等

編碼實現

首先實現 Trie 樹的節點:

package com.huyan.trie;

import java.util.*;

/**
 * Created by pfliu on 2019/12/06.
 */
public class TNode {
    /**
     * 當前節點字符
     */
    private char c;
    /**
     * 當前 節點對應數字
     */
    int count = 0;

    private TNode[] children;

    private static int hash(char c) {
        return c;
    }

    @Override
    public String toString() {
        return "TNode{" +
                "c=" + c +
                ", count=" + count +
                ", children=" + Arrays.toString(children) +
                '}';
    }

    TNode(char c) {
        this.c = c;
    }

    /**
     * 將 給定字符  添加到給定列表中。
     * @param nodes 給定的 node 列表
     * @param c 給定字符
     * @return 插入後的節點
     */
    private static TNode add(final TNode[] nodes, char c) {
        int hash = hash(c);
        int mask = nodes.length - 1;

        for (int i = hash; i < hash + mask + 1; i++) {
            int idx = i & mask;
            if (nodes[idx] == null) {
                TNode node = new TNode(c);
                nodes[idx] = node;
                return node;
            } else if (nodes[idx].c == c) {
                return nodes[idx];
            }
        }
        return null;
    }

    /**
     * 將 當前節點 放入到給定的 節點列表中。
     * 用於 resize 的時候轉移節點列表
     * @param nodes 節點列表
     * @param node 給定節點
     */
    private static void add(final TNode[] nodes, TNode node) {
        int hash = hash(node.c);
        int len = nodes.length - 1;

        for (int i = hash; i < hash + len + 1; i++) {
            int idx = i & len;
            if (nodes[idx] == null) {
                nodes[idx] = node;
                return;
            } else if (nodes[idx].c == node.c) {
                throw new IllegalStateException("Node not expected for " + node.c);
            }
        }
        throw new IllegalStateException("Node not added");
    }

    /**
     * 將  給定字符 插入到當前節點的子節點中。
     * @param c 給定字符
     * @return 插入後的節點
     */
    TNode addChild(char c) {
        // 初始化子節點列表
        if (children == null) {
            children = new TNode[2];
        }

        // 嘗試插入
        TNode node = add(children, c);
        if (node != null)
            return node;

        // resize
        // 轉移節點列表到新的子節點列表中
        TNode[] tmp = new TNode[children.length * 2];
        for (TNode child : children) {
            if (child != null) {
                add(tmp, child);
            }
        }

        children = tmp;
        return add(children, c);
    }

    /**
     * 查找當前節點的子節點列表中,char 等於給定字符的節點
     * @param c 給定 char
     * @return 對應的節點
     */
    TNode findChild(char c) {
        final TNode[] nodes = children;
        if (nodes == null) return null;

        int hash = hash(c);
        int len = nodes.length - 1;

        for (int i = hash; i < hash + len + 1; i++) {
            int idx = i & len;
            TNode node = nodes[idx];
            if (node == null) {
                return null;
            } else if (node.c == c) {
                return node;
            }
        }
        return null;
    }
}

然後實現 Trie 樹。

package com.huyan.trie;

import java.util.*;

/**
 * Created by pfliu on 2019/12/06.
 */
public class Trie {

    /**
     * 根節點
     */
    final private TNode root = new TNode('\0');

    /**
     * 添加一個詞到 Trie
     *
     * @param word  待添加詞
     * @param value 對應 value
     */
    public void addWord(String word, int value) {
        if (word == null || word.length() == 0) return;
        TNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            // 當前 char 添加到 trie 中,並拿到當前 char 對應的那個節點
            node = node.addChild(c);
        }
        node.count = value;
    }

    /**
     * 查找 word 對應的 int 值。
     *
     * @param word 給定 word
     * @return 最後一個節點上存儲的 int.
     */
    public int get(String word) {
        TNode node = root;
        for (int i = 0; i < word.length(); i++) {
            node = node.findChild(word.charAt(i));
            if (node == null) {
                return 0;
            }
        }
        return node.count;
    }

    private int get(char[] buffer, int offset, int length) {
        TNode node = root;
        for (int i = 0; i < length; i++) {
            node = node.findChild(buffer[offset + i]);
            if (node == null) {
                return 0;
            }
        }

        return node.count;
    }

    /**
     * 從給定字符串的 offset 開始。
     * 查找最大匹配的第一個 int 值。
     *
     * @param str    給定字符串
     * @param offset 開始查找的偏移量
     * @return 第一個匹配的字符串德最後一個節點的 int 值。
     */
    public String maxMatch(String str, int offset) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i < str.length(); i++) {
            char c = str.charAt(i);
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return lastMatchIdx == offset ? null : str.substring(offset, lastMatchIdx + 1);
    }

    /**
     * 從給定字符串的 offset <b>反向</b>開始。
     * 查找最大匹配的第一個 int 值。
     *
     * @param str    給定字符串
     * @param offset 開始查找的偏移量
     * @return 第一個匹配的字符串德最後一個節點的 int 值。
     */
    public int maxMatchBack(String str, int offset) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i >= 0; i--) {
            char c = str.charAt(i);
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return offset - lastMatchIdx + 1;
    }

    /**
     * 從給定字符串的 offset 開始。檢查 length 長度。
     * 查找最大匹配的第一個 int 值。
     *
     * @param buffer 給定字符串
     * @param offset 開始查找的偏移量
     * @return 第一個匹配的字符串德最後一個節點的 int 值。
     */
    public int maxMatch(char[] buffer, int offset, int length) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i < offset + length; i++) {
            char c = buffer[i];
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return lastMatchIdx - offset + 1;
    }

    public static void main(String[] args) {
        Trie trie = new Trie();

        for (String s : Arrays.asList("呼延", "呼延二十")) {
            trie.addWord(s, 1);
        }

        String input = "延十在寫文章";

        System.out.println(trie.maxMatch(input, 0));

    }

}

代碼中基本上實現了 Trie 的基本功能,但是對 trie 的應用方法有很多,比如匹配前綴,比如求最長匹配前綴的長度等。這些就不一一實現了。

參考文章

https://www.cnblogs.com/huangxincheng/archive/2012/11/25/2788268.html

https://zh.wikipedia.org/wiki/Trie


完。

聯繫我

最後,歡迎關注我的個人公衆號【 呼延十 】,會不定期更新很多後端工程師的學習筆記。
也歡迎直接公衆號私信或者郵箱聯繫我,一定知無不言,言無不盡。


ChangeLog

2019-05-19 完成

以上皆爲個人所思所得,如有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文鏈接。

聯繫郵箱:[email protected]

更多學習筆記見個人博客或關注微信公衆號 < 呼延十 >------>呼延十

發佈了94 篇原創文章 · 獲贊 12 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章