Python數據結構與算法(十五、字典樹(又叫Trie,前綴樹))

保證一週更兩篇吧,以此來督促自己好好的學習!代碼的很多地方我都給予了詳細的解釋,幫助理解。好了,幹就完了~加油!
聲明:本python數據結構與算法是imooc上liuyubobobo老師java數據結構的python改寫,並添加了一些自己的理解和新的東西,liuyubobobo老師真的是一位很棒的老師!超級喜歡他~
如有錯誤,還請小夥伴們不吝指出,一起學習~
No fears, No distractions.

一、Trie

  1. 什麼是Trie?
    Trie不同於前面的樹結構,是一種多叉樹。Trie是一個真正的用於字典的數據結構,因爲Trie樹經常處理的對象大多數爲字符串。
    舉個栗子:
    a. 就拿我們以前實現的基於二分搜索樹的字典爲例,查詢的時間複雜度是O(logn),如果有100萬個條目,logn大約爲20。而Trie樹能夠做到查詢每個條目的時間複雜度和字典中一共有多少條目無關!且時間複雜度爲O(w),w爲單詞的長度!而且大多數的單詞的字符個數是小於10的,這就使得Trie在查詢單詞方面非常的高效。
    b. 上個圖吧。prefix
    百度搜索“我是大”,你看下邊蹦出來好多基於“我是大”爲前綴的關鍵字,這也是Trie樹主要的貢獻之一!所以Trie樹也叫“前綴樹”。

二、Trie樹講解

  1. 那麼這個樹的數據結構該如何組織呢?
    冒昧的用一下bobo老師的圖吧,自己實在是太難表述了/(ㄒoㄒ)/~~。trie

聲明:上圖源自liuyubobobo老師上課的PPT,如有侵權請聯繫,立刻刪除~

  1. 所有的字符串構建均從根節點出發!
  2. 我們最直觀的理解就是對於英文字符串而言,就只有26個字母嘛,所以每個節點最多隻有26個孩子節點,但是字符串裏面有特殊符號怎麼辦?所以這裏採取的辦法就是用相應的next char來代表邊,這樣我們的孩子節點就是動態的構建,也就可以創建無數個孩子節點了,很顯然通過這個邊到達的就是下一個節點,所以我們的節點裏面可以存放一個字典:對應的是char到next_Node的映射。(python中不一定是char,只要是能組成string的元素就行。)
  3. 對於pan這個單詞,代表平底鍋,而panda中pan是panda的前綴,如果我們的Trie中有"panda",那麼如何直到到底有沒有"pan"呢?因爲二者的路徑是一樣的呀。所以在這裏我們要在節點中再添加一個屬性:isWord,是bool類型的,代表該Node到底是不是一個單詞的標識。比如說"pan"這個單詞,如果節點’n’的isWord屬性是False,則代表我們的Trie樹中沒有"pan"這個單詞,反之就是存在這個單詞!
  4. 可以認爲trie是專門爲字符串所設計的集合或者是映射。
    綜上,我們的Trie樹的節點類所包含的屬性:
    class Node{
        bool isWord;    // 代表是否是一個單詞
        Map<char, Node> next;  // char代表兩節點的邊,Node則是下一個節點。
    }

三、實現

還是小小的說明一下吧,大家在跑簡單的測試樣例或者我的測試樣例的時候,要多多debug!pycharm的話調試更方便,多調試才能理解每個數據結構的在執行相關操作時的運作方式。調試確實幫助我這個菜逼很多。。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Data:             2018-12-22  15:26
# Python version:   3.6

class Node:
    """節點類"""
    def __init__(self, is_word=False):
        self.is_Word = is_word  # 默認情況下self.is_Word爲False
        self.next = dict()  # python是動態語言,無法指定類型,所以建立一個空字典就好了。
        # 小小的說明一下,正因爲無法指定類型,我們可以向我們的Trie中添加不限於英文字符的任意字符!這就是python的牛逼之處呀,其他靜態編譯語言
        # 大多數都會寫成:map<char, Node> 那麼只能處理英文字符串了。。

class Trie:
    def __init__(self):
        self._root = Node()
        self._size = 0  # size初始化爲零


    def isEmpty(self):
        return self._size == 0  # 判空


    def getSize(self):
        return self._size  # 獲取樹的size


    def contains(self, word):
        """
        判斷單詞word是否已經存在於樹中(非遞歸版本)。
        Params:
            - word: 待查找單詞
        """
        cur = self._root  # 從根節點開始
        for character in word:  # 遍歷要查詢的單詞
            cur = cur.next.get(character, None)  # 找下一個節點中的字典以character爲鍵所對應的值,沒找到返回None
            if cur == None:
                return False  # 沒找到返回False
        return cur.is_Word == True  # 即使找到最後了,也要注意那個"pan"和"panda"的問題哦,如果此時的is_Word爲True,表明真的存在這個單詞,否則還是不存在!


    def contains_RE(self, node, word, index):
        """
        判斷單詞word是否已經存在於樹中(遞歸版本)。
        Params:
            - node: 當前節點
            - word: 待查找單詞
            - index: 表明此時到達word的哪個element了,即word[index]是待考察的element。
        """
        if index == len(word):  # 遞歸到底的情況,同樣要注意最後一個元素的is_Word是不是真的爲True
            if node.is_Word:
                return True 
            return False 
        
        dst_element = word[index]  # 標記這個元素,方便大家理解後面的代碼
        if node.next.get(dst_element, None) is None:  # 如果當前節點的next的dict鍵中不包含dst_element
            return False # 直接返回False
        return self.contains_RE(node.next[dst_element], word, index + 1) # 否則去到node的next中以dst_element爲鍵的Node是否包含word[index + 1]
        

    def add(self, word):
        """
        向Trie中添加一個單詞word,注意不是單個字符哦(課上講的迭代版本)
        Params:
            - word: 待添加的單詞
        """
        if self.contains(word):  # 先判斷是否已經存在,存在直接返回就ok。
            return

        cur = self._root # 從根節點開始,前面也說過了,Trie的字符串全部是從根節點開始的哦
        for character in word:  # 遍歷要添加的單詞的element
            if cur.next.get(character, None) is None: # 如果next_node中以character爲鍵的值不存在
                cur.next[character] = Node() # 就新建一個Node作爲character的值
            cur = cur.next.get(character) # 更新cur到下一個以character爲邊的Node,注意代碼的邏輯哦,值不存在就新建,此時也是到下一個character爲邊的Node
            # 只不過此時到達的是一個我們剛剛新建的空Node。如果存在,就直接到下一個已經存在的Node了,一點問題沒有。

        cur.is_Word = True  # 最後注意既然是添加,所以單詞尾部的element的is_Word一定要設爲True,表示存在這個單詞了。
        self._size += 1  # 更新self._size


    def add_RE(self, node, word, index):
        """
        向Trie中添加一個單詞word(自己理解的遞歸版本)
        Params:
            - node: 當前節點
            - word: 待添加的單詞
            - index: 表明此時到達word的哪個element了,即word[index]是待考察的element。
        """
        if index == len(word): # 遞歸到底的情況,注意可能涉及到更新當前節點的is_Word
            if not node.is_Word:   # 如果當前節點的is_Word爲False
                node.is_Word = True # 更新爲True
                self._size += 1  # 並維護self._size
            return   

        dst_element = word[index] # 標記這個元素,方便理解後面的代碼
        if node.next.get(dst_element, None) is None:  # 如果當前節點的next的dict鍵中不包含dst_element
            node.next[dst_element] = Node() # 就爲這個鍵新建一個Node
        return self.add_RE(node.next[dst_element], word, index + 1)  # 新建了也好,沒新建也罷,都是要跑到下一個節點去看word[index + 1]這個element
        # 是否存在,不存在就新建,存在就順着往後擼。


    def isPrefix(self, astring):
        """
        查詢是否在Trie中有單詞以prefix爲前綴,注意'abc'也是'abc'的前綴,另外遞歸版本的就不寫了,甚至要比contains_RE簡單
        Params:
            -astring: 待查詢字符串
        Returns:
        有爲True,沒有返回False 
        """
        cur = self._root
        for character in astring:
            cur = cur.next.get(character, None)
            if cur is None:
                return False 
        return True  # 此時就不用考慮is_Word啦,因爲只要找前綴,並非確認該前綴是否真正存在與trie中

    def remove(self, astring):
        """
        刪除Trie樹中的字符串。(自己的理解,有問題還請小夥伴指出,一般的競賽神馬的也不會涉及到刪除操作的。)
        因爲我們的Trie樹只能從頭往後擼,所以要先遍歷一遍記錄每個Node,然後反向遍歷每個Node來從後往前刪除,有點像單向鏈表哦。
        Params:
            - astring: 待刪除的字符串
        """
        # 這裏不調用self.contains判斷astring是否存在於Trie中了,因爲這樣的話多了一次遍歷。
        cur = self._root   # 從根節點出發,準備記錄
        record = [cur]  # 初始化record,就是根節點
        # 因爲如果你想刪除'hello',那麼'h'的信息只有根節點有,所以根節點是必須要添加進record的。
        for character in astring:  #遍歷astring
            flag = cur.next.get(character, None) # 判斷Trie中到底有沒有astring
            if flag is None:  # 如果沒有,直接return就好。相比先contains判斷的話少一次循環哦。
                return 
            record.append(cur.next[character])  # 先將下一個Node添加進record中
            cur = cur.next[character]  # cur往後擼
        
        if len(cur.next):  # 這裏是一種特殊情況:比如我們的Trie中有'pan'和'panda',但是'panda'和'pan'有着共同的路徑,即p->a->n,
            # 所以是不可能全部刪除'p','a','n'的,因爲'panda'我們並不想刪除呀。
            # 這裏的處理反倒很簡單,直接將當前cur到達的node的is_Word設爲False就好啦~,'pan'就不復存在了!
            cur.is_Word = False 
            self._size -= 1  # 便忘了刪完後維護一下self._size
            return 
        
        # 刪除操作
        string_index = len(astring) - 1 # 從後往前刪,聯想單鏈表刪除操作
        for record_index in range(len(record) - 2, -1, -1):  # 此時record的容量應該爲len(astring) + 1,因爲我們一開始就添加了self._root
            # 而最後一個Node是沒用的,因爲要刪除的話只能找到目標的前一個node進行刪除,所以從倒數第二個node開始向前遍歷
            remove_char = astring[string_index] # 記錄要刪除的字符,便與小夥伴理解
            cur_node = record[record_index] # 記錄當前的node
            del cur_node.next[remove_char]  # 直接將當前node的next中以remove_char爲鍵的 鍵值對 刪除
            string_index -= 1  # 同步操作,維護一下string_index,準備刪除下一個字符
        self._size -= 1  # 最後刪完了別忘記維護一下self._size
            

    def printTrie(self):
        """打印Trie,爲了debug用的。湊合看吧/(ㄒoㄒ)/~~前綴打印不出來--救我~"""
        def _printTrie(node):
            if not len(node.next): # 遞歸到底的情況,下面沒Node了
                print('None')
                return 
            
            for keys in node.next.keys(): # 對當前node的下面的每個character
                print(keys, end='->') # 打印character
                _printTrie(node.next[keys]) # 依次對下面的每個node都調用_printTrie方法來遞歸打印
        _printTrie(self._root)

四、測試

# -*- coding: utf-8 -*-
import sys 
sys.path.append('/home/tony/Code/data_structure')
from dict_tree import trie 

test = trie.Trie() # 非遞歸測試
test_re = trie.Trie() # 遞歸測試
record = ['你好', 'hello', 'こんにちは', 'pan', 'panda', '我是大哥大666']

print('初始化是否爲空?', test.isEmpty())
print('此時的size:', test.getSize())

print('=' * 20)
print('測試非遞歸版本的成員函數:')
print('將record中的字符串全部添加進test中:')
for elem in record:
    test.add(elem)
print('打印:')
test.printTrie()
print('判斷record中的元素是否都已經存在於test中:')
for elem in record[::-1]:
    flag = test.contains(elem)
    if flag:
        print('"%s" 存在於 test 中。' % elem)
print('此時test的size:', test.getSize())
print('"hel"是否是test的前綴?', test.isPrefix('hel'))

print('='*20)
print('僅針對test進行刪除操作,test_re類似就不處理了')
print('刪除"pan"並打印:')
test.remove('pan')
test.printTrie()
print('刪除"我是大哥大666"並打印:')
test.remove('我是大哥大666')
test.printTrie()
print('此時test的容量:', test.getSize())


print('=' * 20)
print('測試遞歸版本的成員函數:')
print('將record中的字符串全部添加進test_re中:')
for elem in record:
    test_re.add_RE(test_re._root, elem, 0)
print('打印:')
test.printTrie()
print('判斷record中的元素是否都已經存在於test_re中:')
for elem in record[::-1]:
    flag = test_re.contains_RE(test_re._root, elem, 0)
    if flag:
        print('"%s" 存在於 test_re 中。' % elem)
print('此時test_re的size:', test_re.getSize())
print('"こんに"是否是test_re的前綴?', test_re.isPrefix('こんに'))

五、輸出

初始化是否爲空? True
此時的size: 0
====================
測試非遞歸版本的成員函數:
將record中的字符串全部添加進test中:
打印:
你->->None
h->e->l->l->o->None->->->->->None
p->a->n->d->a->None->->->->->6->6->6->None
判斷record中的元素是否都已經存在於test中:
"我是大哥大666" 存在於 test 中。
"panda" 存在於 test 中。
"pan" 存在於 test 中。
"こんにちは" 存在於 test 中。
"hello" 存在於 test 中。
"你好" 存在於 test 中。
此時test的size: 6
"hel"是否是test的前綴? True
====================
僅針對test進行刪除操作,test_re類似就不處理了
刪除"pan"並打印:
你->->None
h->e->l->l->o->None->->->->->None
p->a->n->d->a->None->->->->->6->6->6->None
刪除"我是大哥大666"並打印:
你->->None
h->e->l->l->o->None->->->->->None
p->a->n->d->a->None
此時test的容量: 4
====================
測試遞歸版本的成員函數:
將record中的字符串全部添加進test_re中:
打印:
你->->None
h->e->l->l->o->None->->->->->None
p->a->n->d->a->None
判斷record中的元素是否都已經存在於test_re中:
"我是大哥大666" 存在於 test_re 中。
"panda" 存在於 test_re 中。
"pan" 存在於 test_re 中。
"こんにちは" 存在於 test_re 中。
"hello" 存在於 test_re 中。
"你好" 存在於 test_re 中。
此時test_re的size: 6
"こんに"是否是test_re的前綴? True

六、習題

Leetcode 208:


Implement a trie with insert, search, and startsWith methods.
Example:
Trie trie = new Trie();
trie.insert(“apple”);
trie.search(“apple”); // returns true
trie.search(“app”); // returns false
trie.startsWith(“app”); // returns true
trie.insert(“app”);
trie.search(“app”); // returns true
Note:
You may assume that all inputs are consist of lowercase letters a-z.
All inputs are guaranteed to be non-empty strings.

# Your Code 216ms
# 這題幾乎就是我們以前實現的一些典型操作嘛。。非常的簡單,很好的練手題。


# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Data:             2018-12-23  15:39
# Python version:   3.6

class Node:
    # 肯定是要新建一個node類的!
    def __init__(self, is_Word=False):
        self.is_word = is_Word
        self.next = dict()

class Trie:

    def __init__(self):
        """
        Initialize your data structure here.
        """
        self._root = Node()
        self._size = 0
        

    def insert(self, word):
        """
        Inserts a word into the trie.
        :type word: str
        :rtype: void
        """
        cur = self._root 
        for character in word:
            flag = cur.next.get(character, None)
            if flag is None:
                cur.next[character] = Node()
            cur = cur.next[character]
        if not cur.is_word:
            cur.is_word = True 
            self._size += 1
        

    def search(self, word):
        """
        Returns if the word is in the trie.
        :type word: str
        :rtype: bool
        """
        cur = self._root
        for character in word:
            cur = cur.next.get(character, None)
            if cur is None:
                return False 
        if cur.is_word:
            return True 
        return False 
            

    def startsWith(self, prefix):
        """
        Returns if there is any word in the trie that starts with the given prefix.
        :type prefix: str
        :rtype: bool
        """
        def _startWith_re(node, prefix, index):
            # 這裏我用遞歸來寫的,練習一下遞歸的寫法。
            if index == len(prefix):
                return True 
            ret = node.next.get(prefix[index], None)
            if ret is None:
                return False 
            return _startWith_re(node.next[prefix[index]], prefix, index + 1)
        return _startWith_re(self._root, prefix, 0)
        

# Your Trie object will be instantiated and called as such:
# obj = Trie()
# obj.insert(word)
# param_2 = obj.search(word)
# param_3 = obj.startsWith(prefix)

Letcode 211:


Design a data structure that supports the following two operations:
void addWord(word)
bool search(word)
search(word) can search a literal word or a regular expression string containing only letters a-z or … A . means it can represent any one letter.
Example:
addWord(“bad”)
addWord(“dad”)
addWord(“mad”)
search(“pad”) -> false
search(“bad”) -> true
search(".ad") -> true
search(“b…”) -> true
Note:
You may assume that all words are consist of lowercase letters a-z.

# Your code 504ms
# 題意簡介名了,多了一個通配符'.',能夠匹配任意一個字符,所以遇到這個循環遍歷一遍當前node的next的所有node即可。

class Node:
    def __init__(self, isWord=False):
        self.is_Word = isWord
        self.next = dict()

class WordDictionary:

    def __init__(self):
        """
        Initialize your data structure here.
        """
        self._root = Node()
        self._size = 0
        

    def addWord(self, word):
        """
        Adds a word into the data structure.
        :type word: str
        :rtype: void
        """
        # 這裏用遞歸寫的,和前面的函數一樣
        def _addWord_re(node, word, index):
            if index == len(word):
                if not node.is_Word:
                    node.is_Word = True
                    self._size += 1
                return 
            flag = node.next.get(word[index], None)
            if flag is None:
                node.next[word[index]] = Node()
            _addWord_re(node.next[word[index]], word, index + 1)
        _addWord_re(self._root, word, 0) 
        

    def search(self, word):
        """
        Returns if the word is in the data structure. A word could contain the dot character '.' to represent any one letter.
        :type word: str
        :rtype: bool
        """
        def _search_re(node, word, index):
            """
            遞歸版本的search,需要考慮通配符'.',其實非常簡單,遇到'.'就把所有的下一個有效節點全部遞歸的search一下就好了。
            """ 
            if index == len(word):
                return node.is_Word
            if word[index] != '.':  # 不是'.'的情況,和以前一樣的,很簡單
                flag = node.next.get(word[index], None)
                if flag is None:
                    return False 
                return _search_re(node.next[word[index]], word, index + 1)
            else:  # 此時遇到'.',表明需要匹配當前next節點中所有的有效節點,循環遍歷一下即可,和打印Trie的操作思想是一致的
                for tmp_node in node.next.values():  # 遍歷所有next中的有效節點
                    if _search_re(tmp_node, word, index + 1):
                        return True  # 找到了就返回Ture
                return False  # 轉了一圈還沒匹配到,那就返回False
                # 千萬不能寫成 return _research_re(tmp_node, word, index + 1)
                # 因爲我們的目標是要匹配到字符串,這麼寫在循環中如果出現有一次沒匹配到就返回False了,此時剩餘的字符我們還沒
                # 判斷呢!
        return _search_re(self._root, word, 0)


# Your WordDictionary object will be instantiated and called as such:
# obj = WordDictionary()
# obj.addWord(word)
# param_2 = obj.search(word)

Leetcode 677:

Implement a MapSum class with insert, and sum methods.
For the method insert, you’ll be given a pair of (string, integer). The string represents the key and the integer represents the value. If the key already existed, then the original key-value pair will be overridden to the new one.
For the method sum, you’ll be given a string representing the prefix, and you need to return the sum of all the pairs’ value whose key starts with the prefix.
Example 1:
Input: insert(“apple”, 3), Output: Null
Input: sum(“ap”), Output: 3
Input: insert(“app”, 2), Output: Null
Input: sum(“ap”), Output: 5

# Your code  44ms
# 用Trie改寫成一個字典,insert方法很簡單,和以前一樣。但是sum方法沒見過,求的是以輸入的字符串爲前綴的所有字符串的和。

class Node:
    def __init__(self, value=0):
        self.value = value    # 代表字典的value值,默認是0。比如加入元素"("xyz", 3)",那麼x, y的value都是0,只有z的value是3。
        self.next = dict()


class MapSum:

    def __init__(self):
        """
        Initialize your data structure here.
        """
        self._root = Node()
        self._size = 0
        

    def insert(self, key, val):
        """
        :type key: str
        :type val: int
        :rtype: void
        """
        cur = self._root
        for key_character in key:
            flag = cur.next.get(key_character, None)
            if flag is None:
                cur.next[key_character] = Node()  # 默認的value都是0哦~
            cur = cur.next[key_character]
        cur.value = val  # 最後一個字符賦予value值爲val

        
    def sum(self, prefix):
        """
        想都不用想,求和肯定是遞歸來求的。
        :type prefix: str
        :rtype: int
        """
        def _sum_re(node):  # 求和的遞歸函數,輸入一個Node,求以該node爲根的Trie樹滿足相應條件的和
            if len(node.next) == 0: # 遞歸到底的情況
                return node.value
            res = node.value  # 獲取當前node的value
            for tmp_node in node.next.values():  # 對後面的每一個node
                res += _sum_re(tmp_node)   # 都加上後面的node的value,即使是中間的字符也沒問題,因爲他們的value爲0
            return res    
                

        cur = self._root   # 要先定位到prefix的最後一個字符哦,否則何來的以prefix爲前綴求和呢?
        for pre_character in prefix:
            flag = cur.next.get(pre_character, None)
            if flag is None:
                return 0
            cur = cur.next[pre_character] 
        return _sum_re(cur) # 調用求和遞歸函數。

# Your MapSum object will be instantiated and called as such:
# obj = MapSum()
# obj.insert(key,val)
# param_2 = obj.sum(prefix)

七、總結

  1. 首先託更了很長時間,對不起小夥伴了,當時都想放棄了,原因就是我太忙(lan)了,當時想自己會就行了更博客也沒什麼意義/(ㄒoㄒ)/~~,直到有一個小夥伴在“線段樹”那節評論我爲什麼不更新了,讓我看到了還有人在等我寫,好感動,就是再忙也要堅持寫下去!
  2. Trie添加、查詢操作的時間複雜度與當前元素總數無關,只正比於當前字符串的長度!
  3. Trie是一個非常非常重要的數據結構,要好好消化、理解,多多debug。
  4. Trie的最大的問題——空間!改進就是“壓縮字典樹”,相應的維護成本也變高了,有興趣的小夥伴可以實現一下。/(ㄒoㄒ)/~~另一個改進就是“三分搜索樹”。

若有還可以改進、優化的地方,還請小夥伴們批評指正!

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