1. 程式人生 > >資料結構 | 30行程式碼,手把手帶你實現Trie樹

資料結構 | 30行程式碼,手把手帶你實現Trie樹

本文始發於個人公眾號:**TechFlow**,原創不易,求個關注

今天是演算法和資料結構專題的第28篇文章,我們一起來聊聊一個經典的字串處理資料結構——Trie。

在之前的4篇文章當中我們介紹了關於博弈論的一些演算法,其中應用最廣也是最重要的就是最後的SG函式。瞭解到這些之後,足夠我們應付常見的博弈論演算法問題了。博弈論本身就是一門學科,其中有這很深邃的理論基礎,我們只是淺嘗輒止,大家感興趣的可以自行鑽研一下,相信一定會很有收穫。

小故事

以前讀過一個大牛的文章,文章裡討論了一個問題,如果不是為了面試的話,我們為什麼要學演算法?

他講了一個他自己的故事,說是在很多年前,手機還是諾基亞功能機的時代,他為塞班系統開發了一個通訊簿查詢聯絡人的軟體。軟體的功能很簡單,就是儲存聯絡人,然後可以通過拼音或者是拼音首字母查詢到對應的聯絡人。這裡需要對漢字以及拼音的對映做一個處理,也不是很複雜的操作,我們腦補應該就可以想出來。

軟體很快做好了,做好了之後投入使用發現也很好用。但是很快遇到了一個沒想到的問題,就是當聯絡人多了之後,軟體的執行速度變得非常慢,也就是卡。卡的原因也很簡單,因為搜尋聯絡人的這個步驟他用的是遍歷查詢的方式搜尋的。他一開始先是自己腦補了一些優化方案和野路子,雖然能有些提升但是不能根本解決問題。後來被逼無奈,他在搜尋了相關資料之後,找到了我們今天的主角Trie,用上了這個演算法之後,這個問題瞬間迎刃而解,即使儲存了成千上萬的聯絡人再也不會卡頓了。從此他大徹大悟,演算法並不是奇淫技巧,真的是有用的。

我們就以這個文章當中的問題作為基礎,來看看Trie的原理,以及它為什麼可以解決這個問題。

Trie簡介

Trie樹有好幾個中文翻譯名字,有的稱為字典樹,有的稱為字首樹。這都是可以的,看大家各位的喜歡。我一般就稱Trie樹,對方聽不懂才會說字典樹XD。

從字典樹和字首樹的稱謂當中我們是可以腦補出來它的大概原理的,也就是以字典和字首的形式儲存資料。聽著有點抽象,其實我們看一張圖就完全明白了。

從圖中我們可以看出來,Trie樹是一棵多叉樹。樹中的每一個節點儲存一個字元,我們從根節點到節點的路徑上的字元連起來就成了單詞。也就是說所有的單詞都是這樣縱向的形式儲存在樹上的。

這樣儲存有什麼好處呢?最大的好處就是擁有相同字首的單詞可以共享字首,比如ana,ann和and這兩個單詞前兩個字元是相同的都是an,所以他們擁有一條公共字首鏈路。在這樣的結構之下,當我們需要查詢單詞是否在樹中的時候,我們只需要從樹根開始遍歷,如果能找到對應的結尾節點就說明單詞存在,否則就說明單詞不存在。

舉個例子,比如我們要查詢單詞doe,我們先從根節點查詢字元d。發現d存在,於是轉移到了d。接著我們查詢字元o,在d節點下面也找到了字元o,轉移到o,查詢字元e,發現e不存在,於是就說明了doe單詞不存在。但是這裡有一個問題,假設我們存在一個單詞是doea,我們查詢doe還是可以找到,但是doe單詞其實是不存在的,這不就錯誤了嗎?

的確,這樣可能存在問題,所以我們需要在節點當中記錄一下,是否是某一個單詞的結尾。這樣我們不僅需要找到對應的單詞,還需要防止我們將其他單詞的字首當成是單詞。

我們插入單詞的過程和查詢非常接近,同樣是一個樹上遍歷的過程,只不過如果我們發現查詢的節點不存在時會手動建立。整個單詞插入完成之後,將最後一個字元對應的節點進行標記,表明這是一個單詞的結尾。

簡單的Trie樹只需要完成新增和查詢即可,如果要涉及刪除,我們只需要在節點當中維護一下經過該節點的單詞數量即可。在刪除的時候,將沿途經過的節點標記的數量-1,如果遇到數量為0的節點,直接刪除即可。

程式碼實現

光說不練假把式,我們自然也是要來練練的。

相信大家也從描述當中看出來了,Trie的原理說穿了其實很簡單,實現起來也不困難。網上有許多版本,很多是面向過程實現的,我把它封裝了一下,用Python面向物件實現了一個版本。理解了原理之後,大家可以根據自己的需要開發自己的風格的版本,程式碼其實不太重要,主要還是理解原理。

我把Trie樹分成了兩個部分,第一個部分是樹上的節點。對於Trie樹上的節點來說它需要提供兩個功能。第一個功能是返回當前節點是否是某一個字串的結尾,第二個功能是根據字元查詢後繼的節點。我們只需要在類當中設定一個flag標記和一個dict屬性來儲存後繼元素就行了。

class Node:
    def __init__(self, is_leaf=False):
        self.child = {}
        self._is_leaf = is_leaf

    @property
    def is_leaf(self):
        return self._is_leaf

    @is_leaf.setter
    def is_leaf(self, is_leaf):
        self._is_leaf = is_leaf

    # 加入孩子節點
    def put(self, key, value):
        self.child[key] = value

    @staticmethod
    # 將字元轉化成數字
    # 其實沒有必要,因為用到了dict,如果用陣列儲存孩子的話,需要用它來計算下標
    def get_idx_of_str(_str):
        if len(_str):
            return -1
        if ord('a') <= ord(_str) <= ord('z'):
            return ord(_str) - ord('a')
        else:
            return ord(_str) - ord('A') + 26

    # 根據字元獲取下一個節點
    def get_next_node(self, _str):
        if len(_str) != 1:
            return None
        idx = Node.get_idx_of_str(_str)
        return self.child.get(idx, None)

    def get_node(self, key):
        return self.child.get(key, None)

這裡我將is_leaf兩個方法用property封裝,從而可以方便使用,這個也是常用的慣例。有了節點之後,我們再開發Trie類就很方便了,對於Trie這個類而言我們只需要實現兩個方法,一個是插入字串,一個是字串的查詢。在有了Node類之後,這兩個方法實現也很簡單了。

class Trie:
    def __init__(self):
        self.root = Node()

    def insert(self, _str):
        cur = self.root
        # 遍歷字元
        for c in _str:
            # 查詢下一個節點
            if cur.get_next_node(c) is None:
                # 如果節點不存在,自己建立一個新節點並插入
                key = Node.get_idx_of_str(c)
                cur.put(key, Node())
                cur = cur.get_node(key)
            # 否則繼續往下
            else:
                cur = cur.get_next_node(c)
        cur.is_leaf = True
    
    def query(self, _str):
        cur = self.root
        # 遍歷字元
        for c in _str:
            # 查詢,如果查詢不到返回False
            if cur.get_next_node(c) is None:
                return False
            cur = cur.get_next_node(c)
        # 返回是否是字串結尾
        return cur.is_leaf

這兩段程式碼應該都不能讀懂,最後,我們嘗試一下使用它來測試一下:

if __name__ == "__main__":
    trie = Trie()
    trie.insert('abcda')
    trie.insert('abcde')
    trie.insert('eecdab')
    trie.insert('mout')
    trie.insert('ymm')
    print(trie.query('abcda'))
    print(trie.query('mout'))
    print(trie.query('ym'))

輸出的結果和我們預期一致,說明大概率是正確的。

總結

Trie樹中我們將字串相同的字首儲存在了同樣的鏈路上,節省了大量空間的消耗。並且在查詢單詞的時候,我們沿著Trie樹進行遍歷,只需要單詞長度的時間就可以得到結果。並且我們可以在Node這個類當中儲存其他一些我們需要的資訊,這樣Trie就轉化成了一個以string為key的dict。

Trie樹在機器學習領域當中應用也非常廣泛,尤其是自然語言處理。可以實現文字的快速分詞、詞頻統計、模糊匹配等功能。並且Trie樹還有很多拓展,比如壓縮資料空間的雙陣列Trie樹以及AC自動機等等。

今天的文章到這裡就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支援吧(關注、轉發、點贊)。

本文使用 mdnice 排版

![](https://user-gold-cdn.xitu.io/2020/7/19/17366d5af21d9778?w=258&h=258&f=png&