Trie樹的構建和應用

Trie樹又叫“字典樹”,是一種在字符串計算中極爲常見的數據結構。在介紹Trie樹的具體結構之前,我們首先要搞明白的就是Trie樹究竟是用來解決哪一類問題的,爲什麼這類問題可以用Trie樹高效的解決。

我們爲什麼用Trie樹

1. 節約字符串的存儲空間

假設現在我們需要對海量字符串構建字典。所謂字典就是一個集合,這個集合包含了所有不重複的字符串,字典在對文本數據做信息檢索系統時的作用我想毋庸贅述了。那麼現在就出現了一個問題,那就是字典對存儲空間的消耗過大。而當這些字符串中存在大量的串擁有重複的前綴時,這種消耗就顯得過於浪費了。比如:"ababc","ababd","ababrf","abab...",... ,這些字符串幾乎都擁有公共前綴”abab”。 我們直接的想法是,能不能通過一種存儲結構節約存儲成本,使得所有擁有重複前綴的串對於公共前綴只存儲一遍。這種存儲的應用場景如果是對DNA序列的存儲,那麼出現重複前綴的可能性更大,空間需求也就更爲強烈。

2. 字符串檢索

檢索一個字符串是否屬於某個詞典時,我們當前一般有兩種思路:

  • 線性遍歷詞典,計算複雜度O(n)n 爲詞典長度;
  • 利用hash表,預先處理字符串集合。這樣再搜索運算時,計算複雜度O(1) 。但是hash計算可能存在碰撞問題,一般的解決辦法比如對某個hash值所代表的字符串實施二次檢索,則計算時間也會上來。而且,hash雖說是一種高效算法,其計算效率比直接字符匹配還是要略高的。

所以,能不能設計一種高效的數據結構幫助解決字符串檢索的問題?

3. 字符串公共前綴問題

這裏有兩個非常典型的例子:

  • 求取已知的n 個字符串的最長公共前綴,樸素方法的時間複雜度爲O(nt)t 爲最長公共前綴的長度;
  • 給定字符串a ,求取a 在某n 個字符串中和哪些串擁有公共前綴

對於問題(2),除了樸素的比較法之外,我們還可以採取對每個字符串的所有前綴計算hash值的方法,這樣一來,計算所有前綴hash值複雜度O(nlen)len 爲字符串的平均長度,查詢的複雜度爲O(n) 。雖然降低了查詢複雜度,但是計算hash值顯然費時費力。

Trie樹的構造

1. 結構

Trie樹是如圖所示的一棵多叉樹。其中存儲的字符串集合爲:
{"a","aa","ab","ac","aab","aac","bc","bd","bca","bcc"}


從上圖我們可以看出,Trie樹有如下3點特徵:

  • 根節點不代表字符,除根節點外每一個節點都只代表一個字符(一般的解釋是,是除根節點外所有節點只“包含”一個字符,我在這裏說“代表”,而不說“包含”是因爲後面的算法設計中,爲了使Trie樹的結構更加清晰,我並沒有讓任何節點“包含”字符)。
  • 從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串。
  • 每個節點的所有子節點包含的字符都不相同。

其實,一棵完整的Trie樹應該每個非葉節點都擁有26個指針,正好對應着英文的26個字母,這樣整棵樹的空間成本爲26ll 爲最長字符串的長度。但是爲了節省空間,我們可以根據字符串集本身爲每個非葉節點,“量身定做”子節點。以上面的圖爲例,以”a”開頭的字符串中,第二個字符只有”a, b, c”3種可能,我們當然沒有必要爲節點u1 生成26個子節點了,3個就夠了。

除此之外,由於有些字符就是集合中其他字符的前綴,爲了能夠分辨清楚集合中到底有哪些字符串,我們還需要爲每個節點賦予一個判斷終止與否的bool值,記爲end 。比如上圖,由於同時存在字符串{"a","ab","ac","aa","aab","aac"} ,我們就令節點u1,u2end值爲True,表示從根節點到u1,u2 的路徑上的字符按順序可以構成集合中一個完整的字符串(如”a”, “aa”)。圖中,我們將end == True的節點標紅。

2. 構建Trie樹

理解了上面Trie樹的結構,就可以放手去寫代碼了,實現起來其實非常簡單,幾乎沒有任何難度,需要注意的是我們究竟以一種什麼樣的形式來定義節點。這一點其實每個人的想法還是有些區別的,我是這般定義:

class TrieTreeNode(object):
    def __init__(self):
        self.end = False

        # The labels of pointers in the node
        self.pointerLabels = []

        # The pointers
        self.pointers = []

除了end之外,還有兩個list型的變量,pointerLabelspointers,前者表示的是此節點的所有指針的標籤,標籤表示其實才是字符,如上圖每個指針上面的字符,而pointers代表的是此節點的每個孩子節點的地址。這樣設計的好處在於,查詢時我們能夠直接根據當前節點包含的數據判斷一下個字符否存在,該往那條路徑繼續遍歷,而不是依次訪問當前節點的所有孩子。而除了根節點之外的所有節點都“代表”着被指向的指針的標籤。拿上圖的節點u2 來說,它的結構是這樣的:

root.end = True
root.pointers = [u4, u5]
root.pointerLabels = ["b", "c"]

下面給出完整的構建Trie樹的代碼:

def buildTrieTree(stringList):
    """
    :param stringList: the collection of strings
    :return: 
    """

    root = TrieTreeNode()

    for ele in stringList:
        cur = root

        for char in ele:

            if char not in cur.pointerlabels:
                cur.pointerLabels.append(char)
                newNode = TrieTreeNode()
                cur.pointers.append(newNode)

                if char != ele[-1]:
                    cur = newNode

            # When char in cur.pointerlabels
            elif char != ele[-1]:
                pos = cur.pointerlabels.index(char)
                cur = cur.pointers[pos] 
            else:
                cur.end = True
    return root

3. 查詢Trie樹

給出在Trie樹上查詢某個字符串是否存在的代碼,這個非常簡單了,不多說了。

def trieTreeQuery(inputString, trieTreeRoot):
    """
    :param inputString: the string that need to be searched
    :param trieTreeRoot: the root of Trie tree
    :return: 
    """
    cur = trieTreeRoot
    for char in inputString:
        if char not in cur.pointerlabels:
            return False
        else:
            pos = cur.pointerlabels.index(char)
            cur = cur.pointers[pos]
    if cur.end is True:
        return True
    return False

性能分析

從上面字符串檢索的算法我們可以分析出,無論有多少字符串,我們檢索一個字符串的時間爲O(m)m 爲要檢索的字符串的長度。若要查詢一個已知串是否爲字符串集合中某些字符串的前綴,也可以通過Trie樹查找到相應的分支,將分支往下一直到葉子的所有路徑找出,就是檢索結果了,比如上圖中,要查”aa”是否爲前綴,我們當然是先遍歷到節點u2 ,然後再找出以u2 爲根的子樹的所有葉子(u4,u5 ),每條從root到這每個葉子的路徑就構成了字符串。至於求取公共子串的問題,Trie樹可以以複雜度O(t)t 是最長公共前綴的長度)直接找到。這裏就不給出具體算法了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章