搜索引擎的搜索關鍵詞提示功能不用講了吧,相信你們都用過.那麼他是如何實現的吶?今天就來講一說它底層最基本的原理:Trie 樹ios
Trie 樹,也叫「字典樹」。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。web
固然,這樣一個問題能夠有多種解決方法,好比散列表、紅黑樹,或者一些字符串匹配(KMP,BM)算法,可是,Trie 樹在這個問題的解決上,有它特有的優勢。不只如此,Trie 樹能解決的問題也不限於此,咱們一下子慢慢分析。算法
咱們先來看下,Trie 樹到底長什麼樣子。編程
我舉個簡單的例子來講明一下。咱們有 6 個字符串,它們分別是:how,hi,her,hello,so,see
。咱們但願在裏面屢次查找某個字符串是否存在。若是每次查找,都是拿要查找的字符串跟這 6 個字符串依次進行字符串匹配,那效率就比較低,有沒有更高效的方法呢?數組
這個時候,咱們就能夠先對這 6 個字符串作一下預處理,組織成 Trie 樹的結構,以後每次查找,都是在 Trie 樹中進行匹配查找。Trie 樹的本質,就是利用字符串之間的公共前綴,將重複的前綴合並在一塊兒。最後構造出來的就是下面這個圖中的樣子。瀏覽器
其中,根節點不包含任何信息。每一個節點表示一個字符串中的字符,從根節點到紅色節點的一條路徑表示一個字符串(注意:紅色節點並不都是葉子節點)(這裏我有點不太懂 )緩存
爲了讓你更容易理解 Trie 樹是怎麼構造出來的,我畫了一個 Trie 樹構造的分解過程。構造過程的每一步,都至關於往 Trie 樹中插入一個字符串。當全部字符串都插入完成以後,Trie 樹就構造好了。數據結構
當咱們在 Trie 樹中查找一個字符串的時候,好比查找字符串「her」,那咱們將要查找的字符串分割成單個的字符 h,e,r,而後從 Trie 樹的根節點開始匹配。如圖所示,綠色的路徑就是在 Trie 樹中匹配的路徑。數據結構和算法
若是咱們要查找的是字符串「he」呢?咱們還用上面一樣的方法,從根節點開始,沿着某條路徑來匹配,如圖所示,綠色的路徑,是字符串「he」匹配的路徑。可是,路徑的最後一個節點「e」並非紅色的。也就是說,「he」是某個字符串的前綴子串,但並不能徹底匹配任何字符串。編程語言
看到這裏,我以爲我剛開始想得就是這種樹,哈哈哈哈^_^
知道了 Trie 樹長什麼樣子,咱們如今來看下,如何用代碼來實現一個 Trie 樹。
從剛剛 Trie 樹的介紹來看,Trie 樹主要有兩個操做,
瞭解了 Trie 樹的兩個主要操做以後,咱們再來看下,如何存儲一個 Trie 樹? 從前面的圖中,咱們能夠看出,Trie 樹是一個多叉樹。咱們知道,二叉樹中,一個節點的左右子節點是經過兩個指針來存儲的,那對於多叉樹來講,咱們怎麼存儲一個節點的全部子節點的指針呢?
其中一種存儲方式,也是經典的存儲方式,大部分數據結構和算法書籍中都是這麼講的。還記得咱們前面講到的散列表嗎?藉助散列表的思想,咱們經過一個下標與字符一一映射的數組,來存儲子節點的指針。這句話稍微有點抽象,不怎麼好懂,我畫了一張圖你能夠看看。
具體實現和結構定義請看代碼,我本身以爲這個理解起來還算是比較簡單,重點應該放在實現
#include <iostream> #include <string> #include <new> #include <vector> using namespace std; class TrieNode { public: explicit TrieNode(char data_t, bool end) : data_(data_t) { children_[26] = {nullptr}; isEndingChar_ = end; } public: char data_; TrieNode *children_[26]; // a-z bool isEndingChar_; }; class TrieTree { public: TrieTree() { root = new TrieNode('/', false); } ~TrieTree() { destroy(root); } void insertString(const string &str) { TrieNode *tmp = root; int num = str.size(); for (int i = 0; i < num; i++) { int index = str[i] - 'a'; if (!tmp->children_[index]) { if (i != num - 1) tmp->children_[index] = new TrieNode(str[i], false); else tmp->children_[index] = new TrieNode(str[i], true); } // not null tmp = tmp->children_[index]; } } int searchString(string str) { TrieNode *tmp = root; int index = 0; for (auto i : str) { index = i - 'a'; if (!tmp->children_[index]) return -1; tmp = tmp->children_[index]; } if (tmp->isEndingChar_) return 0; else return 666; } private: class TrieNode *root; void destroy(TrieNode *root) { if (!root) return; for (int i = 0; i < 26; i++) { destroy(root->children_[i]); } delete root; root = nullptr; } }; int main(void) { TrieTree tree; string insertstrings[5] = {"how", "hi", "hello", "so", "see"}; for (auto t : insertstrings) { tree.insertString(t); } cout << "Please input the strings :" << endl; string t1; while (1) { cin >> t1; switch (tree.searchString(t1)) { case 0: cout << "success find " << endl; break; case -1: cout << "not find " << endl; break; case 666: cout << "is public substr " << endl; break; } } return 0; }
Trie 樹的實現,你如今應該搞懂了。如今,咱們來看下,在 Trie 樹中,查找某個字符串的時間複雜度是多少?
若是要在一組字符串中,頻繁地查詢某些字符串,用 Trie 樹會很是高效。構建 Trie 樹的過程,須要掃描全部的字符串,時間複雜度是 O(n)(n 表示全部字符串的長度和)。可是一旦構建成功以後,後續的查詢操做會很是高效。
其實這個也比較容易想的來,就像樹同樣,挨個字符向下找就好了啊,因此說,構建好 Trie 樹後,在其中查找字符串的時間複雜度是 O(k),k 表示要查找的字符串的長度。,時間仍是主要仍是花費在構建樹.
前面咱們講了 Trie 樹的實現,也分析了時間複雜度。如今你應該知道,Trie 樹是一種很是獨特的、高效的字符串匹配方法。可是,關於 Trie 樹,你有沒有聽過這樣一種說法:「Trie 樹是很是耗內存的,用的是一種空間換時間的思路」。這是什麼緣由呢?
剛剛咱們在講 Trie 樹的實現的時候,講到用數組來存儲一個節點的子節點的指針。若是字符串中包含從 a 到 z 這 26 個字符,那每一個節點都要存儲一個長度爲 26 的數組,而且每一個數組存儲一個 8 字節指針(或者是 4 字節,這個大小跟 CPU、操做系統、編譯器等有關)。並且,即使一個節點只有不多的子節點,遠小於 26 個,好比 三、4 個,咱們也要維護一個長度爲 26 的數組。(其實這個想一下,那就是26262626....)
真正的數據是一個char
,但存儲的卻還有26個指針,得不嘗試啊,並且若是符串中不只包含小寫字母,還包含大寫字母、數字、甚至是中文,那須要的存儲空間就會更多
固然,咱們不能否認,Trie 樹儘管有可能很浪費內存,可是確實很是高效。那爲了解決這個內存問題,咱們是否有其餘辦法呢?
咱們能夠稍微犧牲一點查詢的效率,將每一個節點中的數組換成其餘數據結構,來存儲一個節點的子節點指針。用哪一種數據結構呢?咱們的選擇其實有不少,好比有序數組、跳錶、散列表、紅黑樹等。
假設咱們用有序數組,數組中的指針 按照所指向的子節點中的字符的大小順序排列。查詢的時候,咱們能夠經過二分查找的方法,快速查找到某個字符應該匹配的子節點的指針。可是,在往 Trie 樹中插入一個字符串的時候,咱們爲了維護數組中數據的有序性,就會稍微慢了點。 替換成其餘數據結構的思路是相似的,這裏我就不一一分析了,你能夠結合前面學過的內容,本身分析一下。
實際上,Trie 樹的變體有不少,均可以在必定程度上解決內存消耗的問題。好比,縮點優化,就是對只有一個子節點的節點,並且此節點不是一個串的結束節點,能夠將此節點與子節點合併。這樣能夠節省空間,但卻增長了編碼難度。這裏我就不展開詳細講解了,你若是感興趣,能夠自行研究下。
實際上,字符串的匹配問題,籠統上講,其實就是數據的查找問題。對於支持動態數據高效操做的數據結構,咱們前面已經講過好多了,好比散列表、紅黑樹、跳錶等等。實際上,這些數據結構也能夠實如今一組字符串中查找字符串的功能。咱們選了兩種數據結構,散列表和紅黑樹,跟 Trie 樹比較一下,看看它們各自的優缺點和應用場景。
在剛剛講的這個場景,在一組字符串中查找字符串,Trie 樹實際上表現得並很差。它對要處理的字符串有及其嚴苛的要求。
第一,字符串中包含的字符集不能太大。咱們前面講到,若是字符集太大,那存儲空間可能就會浪費不少。即使能夠優化,但也要付出犧牲查詢、插入效率的代價。
第二,要求字符串的前綴重合比較多,否則空間消耗會變大不少。
第三,若是要用 Trie 樹解決問題,那咱們就要本身從零開始實現一個 Trie 樹,還要保證沒有 bug,這個在工程上是將簡單問題複雜化,除非必須,通常不建議這樣作。
第四,咱們知道,經過指針串起來的數據塊是不連續的,而 Trie 樹中用到了指針,因此,對緩存並不友好,性能上會打個折扣。
綜合這幾點,針對在一組字符串中查找字符串的問題,咱們在工程中,更傾向於用散列表或者紅黑樹。由於這兩種數據結構,咱們都不須要本身去實現,直接利用編程語言中提供的現成類庫就好了。
講到這裏,你可能要疑惑了,講了半天,我對 Trie 樹一通否認,還讓你用紅黑樹或者散列表,那 Trie 樹是否是就沒用了呢?是否是今天的內容就白學了呢?
實際上,Trie 樹只是不適合精確匹配查找,這種問題更適合用散列表或者紅黑樹來解決。Trie 樹比較適合的是查找前綴匹配的字符串,也就是相似開篇的那種場景。
其實這個也不用講了吧,很簡單,顯示出來就好了嘛
若是再稍微深刻一點,你就會想到,上面的解決辦法遇到下面幾個問題:
我剛講的思路是針對英文的搜索關鍵詞提示,對於更加複雜的中文來講,詞庫中的數據又該如何構建成 Trie 樹呢?
若是詞庫中有不少關鍵詞,在搜索提示的時候,用戶輸入關鍵詞,做爲前綴在 Trie 樹中能夠匹配的關鍵詞也有不少,如何選擇展現哪些內容呢?
像 Google 這樣的搜索引擎,用戶單詞拼寫錯誤的狀況下,Google 仍是可使用正確的拼寫來作關鍵詞提示,這個又是怎麼作到的呢?
你能夠先思考一下如何來解決,若是不會也不要緊,這些問題,咱們會在後面具體來說解。
實際上,Trie 樹的這個應用能夠擴展到更加普遍的一個應用上,就是自動輸入補全,好比輸入法自動補全功能、IDE 代碼編輯器自動補全功能、瀏覽器網址輸入的自動補全功能等等。
總的來說,就是:
咱們今天有講到,Trie 樹應用場合對數據要求比較苛刻,好比字符串的字符集不能太大,前綴重合比較多等。若是如今給你一個很大的字符串集合,好比包含 1 萬條記錄,如何經過編程量化分析這組字符串集合是否比較適合用 Trie 樹解決呢?也就是如何統計字符串的字符集大小,以及前綴重合的程度呢?
當我將上面代碼的TrieNode
改成這樣時,他竟然他媽的錯了:
explicit TrieNode(char data_t, bool end) : data_(data_t), isEndingChar_(end) { children_[26] = {nullptr}; }
或者像這樣也會錯:
explicit TrieNode(char data_t, bool end) : data_(data_t) { isEndingChar_ = end; children_[26] = {nullptr}; }
真的是神奇啊,哈哈哈("馬買皮")
參考自:
極客時間 數據結構與算法之美