1. 程式人生 > >Trie樹的構建和應用

Trie樹的構建和應用

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

我們為什麼用Trie樹

1. 節約字串的儲存空間

假設現在我們需要對海量字串構建字典。所謂字典就是一個集合,這個集合包含了所有不重複的字串,字典在對文字資料做資訊檢索系統時的作用我想毋庸贅述了。那麼現在就出現了一個問題,那就是字典對儲存空間的消耗過大。而當這些字串中存在大量的串擁有重複的字首時,這種消耗就顯得過於浪費了。比如:"ababc","ababd","ababrf","aba

b...",...,這些字串幾乎都擁有公共字首”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是最長公共字首的長度)直接找到。這裡就不給出具體演算法了。