數據結構與算法—Trie樹


Trie,又經常叫前綴樹,字典樹等等。它有很多變種,如后綴樹,Radix Tree/Trie,PATRICIA tree,以及bitwise版本的crit-bit tree。當然很多名字的意義其實有交叉。

Trie樹是一種非常重要的數據結構,它在信息檢索,字符串匹配等領域有廣泛的應用,同時,它也是很多算法和復雜數據結構的基礎,如后綴樹,AC自動機等。

典型應用是用於統計和排序大量的字符串(但不僅限於字符串),所以經常被搜索引擎系統用於文本詞頻統計

它的優點是:最大限度地減少無謂的字符串比較,查詢效率比哈希表高
Trie的核心思想是空間換時間利用字符串的公共前綴來降低查詢時間的開銷以達到提高效率的目的
Trie樹也有它的缺點,Trie樹的內存消耗非常大.當然,或許用左兒子右兄弟的方法建樹的話,可能會好點.

 

什么是Trie樹

Trie樹,又叫字典樹、前綴樹(Prefix Tree)、單詞查找樹或鍵樹,是一種多叉樹結構。

字典樹(Trie)可以保存一些字符串->值的對應關系。基本上,它跟 Java 的 HashMap 功能相同,都是 key-value 映射,只不過 Trie 的 key 只能是字符串。是一種哈希樹的變種。

 

它有3個基本性質:

  1. 根節點不包含字符,除根節點外每一個節點都只包含一個字符。
  2. 從根節點到某一節點,路徑上經過的字符連接起來,為該節點對應的字符串。
  3. 每個節點的所有子節點包含的字符都不相同。

通常在實現的時候,會在節點結構中設置一個標志,用來標記該結點處是否構成一個單詞(關鍵字)。

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

 

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

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

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

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

Trie樹可以對關鍵字按字典序排序。

 

 舉一個例子。給出一組單詞,inn, int, at, age, adv, ant, 我們可以得到下面的Trie:

 可以看出:

  • 每條邊對應一個字母。
  • 每個節點對應一項前綴。葉節點對應最長前綴,即單詞本身
  • 單詞inn與單詞int有共同的前綴“in”, 因此他們共享左邊的一條分支,root->i->in。同理,ate, age, adv, 和ant共享前綴"a",所以他們共享從根節點到節點"a"的邊。

 

Trie樹的應用

1、字符串檢索

給出N個單詞組成的熟詞表,以及一篇全用小寫英文書寫的文章,請你按最早出現的順序寫出所有不在熟詞表中的生詞。

檢索/查詢功能是Trie樹最原始的功能。給定一組字符串,查找某個字符串是否出現過,思路就是從根節點開始一個一個字符進行比較:

  • 如果沿路比較,發現不同的字符,則表示該字符串在集合中不存在。
  • 如果所有的字符全部比較完並且全部相同,還需判斷最后一個節點的標志位(標記該節點是否代表一個關鍵字)。
struct trie_node
{
    bool isKey;   // 標記該節點是否代表一個關鍵字
    trie_node *children[26]; // 各個子節點 
};

 

2、詞頻統計

 Trie樹常被搜索引擎系統用於文本詞頻統計 。

struct trie_node
{
    int count;   // 記錄該節點代表的單詞的個數
    trie_node *children[26]; // 各個子節點 
};

思路:為了實現詞頻統計,我們修改了節點結構,用一個整型變量count來計數。對每一個關鍵字執行插入操作,若已存在,計數加1,若不存在,插入后count置1。

 

3、排序

Trie樹可以對大量字符串按字典序進行排序,思路也很簡單:
給定N個互不相同的僅由一個單詞構成的英文名,讓你將他們按字典序從小到大輸出。用字典樹進行排序,采用數組的方式創建字典樹,這棵樹的每個結點的所有兒子很顯然地按照其字母大小排序。對這棵樹進行先序遍歷即可。

 

4、前綴匹配

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

trie樹前綴匹配常用於搜索提示。如當輸入一個網址,可以自動搜索出可能的選擇。當沒有完全匹配的搜索結果,可以返回前綴最相似的可能

 

5、最長公共前綴

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

舉例說明:給出N 個小寫英文字母串,以及Q 個詢問,即詢問某兩個串的最長公共前綴的長度是多少?

解決方案:首先對所有的串建立其對應的字母樹。此時發現,對於兩個串的最長公共前綴的長度即它們所在結點的公共祖先個數,於是,問題就轉化為了離線(Offline)的最近公共祖先(Least Common Ancestor,簡稱LCA)問題。而最近公共祖先問題同樣是一個經典問題,可以用下面幾種方法:

  1. 利用並查集(Disjoint Set),可以采用采用經典的Tarjan 算法;
  2. 求出字母樹的歐拉序列(Euler Sequence )后,就可以轉為經典的最小值查詢(Range Minimum Query,簡稱RMQ)問題了;

關於並查集,Tarjan算法,RMQ問題,網上有很多資料。

 

6、作為輔助結構

如后綴樹,AC自動機等。

 

7、應用實例

  1. 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間復雜度分析。
    • 之前在此文:海量數據處理面試題集錦與Bit-map詳解中給出的參考答案:用trie樹統計每個詞出現的次數,時間復雜度是O(nle)le10k(le表示單詞的平均長度),然后是找出出現最頻繁的前10個詞。也可以用堆來實現(具體的操作可參考第三章、尋找最小的k個數),時間復雜度是O(nlg10)O(nlg10)。所以總的時間復雜度是O(nle)與O(nlg10)中較大的哪一個。
  2. 有一個1G大小的一個文件,里面每一行是一個詞,詞的大小不超過16字節,內存限制大小是1M。返回頻數最高的100個詞。
  3. 1000萬字符串,其中有些是重復的,需要把重復的全部去掉,保留沒有重復的字符串。請怎么設計和實現?
  4. 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間復雜度分析。
  5. 尋找熱門查詢:搜索引擎會通過日志文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度為1-255字節。假設目前有一千萬個記錄,這些查詢串的重復讀比較高,雖然總數是1千萬,但是如果去除重復和,不超過3百萬個。一個查詢串的重復度越高,說明查詢它的用戶越多,也就越熱門。請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
    • (1) 請描述你解決這個問題的思路;
    • (2) 請給出主要的處理流程,算法,以及算法的復雜度。

 

Trie樹的實現

Trie樹的插入、刪除、查找的操作都是一樣的,只需要簡單的對樹進行一遍遍歷即可,時間復雜度:O(n)(n是字符串的長度)。

trie樹每一層的節點數是26i級別的。所以為了節省空間,對於Tried樹的實現可以使用數組和鏈表兩種方式。空間的花費,不會超過單詞數×單詞長度。

  1. 數組:由於我們知道一個Tried樹節點的子節點的數量是固定26個(針對不同情況會不同,比如兼容數字,則是36等),所以可以使用固定長度的數組來保存節點的子節點

    • 優點:在對子節點進行查找時速度快
    • 缺點:浪費空間,不管子節點有多少個,總是需要分配26個空間
  2. 鏈表:使用鏈表的話我們需要在每個子節點中保存其兄弟節點的鏈接,當我們在一個節點的子節點中查找是否存在一個字符時,需要先找到其子節點,然后順着子節點的鏈表從左往右進行遍歷

    • 優點:節省空間,有多少個子節點就占用多少空間,不會造成空間浪費
    • 缺點:對子節點進行查找相對較慢,需要進行鏈表遍歷,同時實現也較數組麻煩

Java實現:

import java.util.ArrayList;
import java.util.List;
/**
 * 單詞查找樹
 */
class Trie {
    /** 單詞查找樹根節點,根節點為一個空的節點 */
    private Vertex root = new Vertex();
    /** 單詞查找樹的節點(內部類) */
    private class Vertex {
        /** 單詞出現次數統計 */
        int wordCount;
        /** 以某個前綴開頭的單詞,它的出現次數 */
        int prefixCount;
        /** 子節點用數組表示 */
        Vertex[] vertexs = new Vertex[26];
        /**
         * 樹節點的構造函數
         */
        public Vertex() {
            wordCount = 0;
            prefixCount = 0;
        }
    }
    /**
     * 單詞查找樹構造函數
     */
    public Trie() {
    }
    /**
     * 向單詞查找樹添加一個新單詞
     * 
     * @param word
     *            單詞
     */
    public void addWord(String word) {
        addWord(root, word.toLowerCase());
    }
    /**
     * 向單詞查找樹添加一個新單詞
     * 
     * @param root
     *            單詞查找樹節點
     * @param word
     *            單詞
     */
    private void addWord(Vertex vertex, String word) {
        if (word.length() == 0) {
            vertex.wordCount++;
        } else if (word.length() > 0) {
            vertex.prefixCount++;
            char c = word.charAt(0);
            int index = c - 'a';
            if (null == vertex.vertexs[index]) {
                vertex.vertexs[index] = new Vertex();
            }
            addWord(vertex.vertexs[index], word.substring(1));
        }
    }
    /**
     * 統計某個單詞出現次數
     * 
     * @param word
     *            單詞
     * @return 出現次數
     */
    public int countWord(String word) {
        return countWord(root, word);
    }
    /**
     * 統計某個單詞出現次數
     * 
     * @param root
     *            單詞查找樹節點
     * @param word
     *            單詞
     * @return 出現次數
     */
    private int countWord(Vertex vertex, String word) {
        if (word.length() == 0) {
            return vertex.wordCount;
        } else {
            char c = word.charAt(0);
            int index = c - 'a';
            if (null == vertex.vertexs[index]) {
                return 0;
            } else {
                return countWord(vertex.vertexs[index], word.substring(1));
            }
        }
    }
    /**
     * 統計以某個前綴開始的單詞,它的出現次數
     * 
     * @param word
     *            前綴
     * @return 出現次數
     */
    public int countPrefix(String word) {
        return countPrefix(root, word);
    }
    /**
     * 統計以某個前綴開始的單詞,它的出現次數(前綴本身不算在內)
     * 
     * @param root
     *            單詞查找樹節點
     * @param word
     *            前綴
     * @return 出現次數
     */
    private int countPrefix(Vertex vertex, String prefixSegment) {
        if (prefixSegment.length() == 0) {
            return vertex.prefixCount;
        } else {
            char c = prefixSegment.charAt(0);
            int index = c - 'a';
            if (null == vertex.vertexs[index]) {
                return 0;
            } else {
                return countPrefix(vertex.vertexs[index], prefixSegment.substring(1));
            }
        }
    }
    
    /**
     * 調用深度遞歸算法得到所有單詞
     * @return 單詞集合
     */
    public List<String> listAllWords() {
        List<String> allWords = new ArrayList<String>();
        return depthSearchWords(allWords, root, "");
    }
    /**
     * 遞歸生成所有單詞
     * @param allWords 單詞集合
     * @param vertex 單詞查找樹的節點
     * @param wordSegment 單詞片段
     * @return 單詞集合
     */ 
    private List<String> depthSearchWords(List<String> allWords, Vertex vertex,
            String wordSegment) {
        Vertex[] vertexs = vertex.vertexs;
        for (int i = 0; i < vertexs.length; i++) {
            if (null != vertexs[i]) {
                if (vertexs[i].wordCount > 0) {
                    allWords.add(wordSegment + (char)(i + 'a'));
                    if(vertexs[i].prefixCount > 0){
                        depthSearchWords(allWords, vertexs[i], wordSegment + (char)(i + 'a'));
                    }
                } else {
                    depthSearchWords(allWords, vertexs[i], wordSegment + (char)(i + 'a'));
                }
            }
        }
        return allWords;
    }
}
public class Main {
    public static void main(String[] args) {
        Trie trie = new Trie();
        trie.addWord("abc");
        trie.addWord("abcd");
        trie.addWord("abcde");
        trie.addWord("abcdef");
        System.out.println(trie.countPrefix("abc"));
        System.out.println(trie.countWord("abc"));
        System.out.println(trie.listAllWords());
    }
}
View Code

 

 

 

參考:

1、https://www.cnblogs.com/panweishadow/p/3722109.html

2、https://blog.csdn.net/v_july_v/article/details/6897097

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM