使用Trie 樹實現搜索引擎的搜索關鍵詞提示功能

搜索引擎的搜索關鍵詞提示功能不用講了吧,相信你們都用過.那麼他是如何實現的吶?今天就來講一說它底層最基本的原理:Trie 樹ios

什麼是「Trie 樹」?

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 樹的過程。
  • 另外一個是在 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 樹是很是耗內存的,用的是一種空間換時間的思路」。這是什麼緣由呢?

剛剛咱們在講 Trie 樹的實現的時候,講到用數組來存儲一個節點的子節點的指針。若是字符串中包含從 a 到 z 這 26 個字符,那每一個節點都要存儲一個長度爲 26 的數組,而且每一個數組存儲一個 8 字節指針(或者是 4 字節,這個大小跟 CPU、操做系統、編譯器等有關)。並且,即使一個節點只有不多的子節點,遠小於 26 個,好比 三、4 個,咱們也要維護一個長度爲 26 的數組。(其實這個想一下,那就是26262626....)

真正的數據是一個char,但存儲的卻還有26個指針,得不嘗試啊,並且若是符串中不只包含小寫字母,還包含大寫字母、數字、甚至是中文,那須要的存儲空間就會更多

固然,咱們不能否認,Trie 樹儘管有可能很浪費內存,可是確實很是高效。那爲了解決這個內存問題,咱們是否有其餘辦法呢?

咱們能夠稍微犧牲一點查詢的效率,將每一個節點中的數組換成其餘數據結構,來存儲一個節點的子節點指針。用哪一種數據結構呢?咱們的選擇其實有不少,好比有序數組、跳錶、散列表、紅黑樹等。

假設咱們用有序數組,數組中的指針 按照所指向的子節點中的字符的大小順序排列。查詢的時候,咱們能夠經過二分查找的方法,快速查找到某個字符應該匹配的子節點的指針。可是,在往 Trie 樹中插入一個字符串的時候,咱們爲了維護數組中數據的有序性,就會稍微慢了點。 替換成其餘數據結構的思路是相似的,這裏我就不一一分析了,你能夠結合前面學過的內容,本身分析一下。

實際上,Trie 樹的變體有不少,均可以在必定程度上解決內存消耗的問題。好比,縮點優化,就是對只有一個子節點的節點,並且此節點不是一個串的結束節點,能夠將此節點與子節點合併。這樣能夠節省空間,但卻增長了編碼難度。這裏我就不展開詳細講解了,你若是感興趣,能夠自行研究下。

在這裏插入圖片描述

Trie 樹與散列表、紅黑樹的比較

實際上,字符串的匹配問題,籠統上講,其實就是數據的查找問題。對於支持動態數據高效操做的數據結構,咱們前面已經講過好多了,好比散列表、紅黑樹、跳錶等等。實際上,這些數據結構也能夠實如今一組字符串中查找字符串的功能。咱們選了兩種數據結構,散列表和紅黑樹,跟 Trie 樹比較一下,看看它們各自的優缺點和應用場景。

在剛剛講的這個場景,在一組字符串中查找字符串,Trie 樹實際上表現得並很差。它對要處理的字符串有及其嚴苛的要求。

  • 第一,字符串中包含的字符集不能太大。咱們前面講到,若是字符集太大,那存儲空間可能就會浪費不少。即使能夠優化,但也要付出犧牲查詢、插入效率的代價。

  • 第二,要求字符串的前綴重合比較多,否則空間消耗會變大不少

  • 第三,若是要用 Trie 樹解決問題,那咱們就要本身從零開始實現一個 Trie 樹,還要保證沒有 bug,這個在工程上是將簡單問題複雜化,除非必須,通常不建議這樣作。

  • 第四,咱們知道,經過指針串起來的數據塊是不連續的,而 Trie 樹中用到了指針,因此,對緩存並不友好,性能上會打個折扣

綜合這幾點,針對在一組字符串中查找字符串的問題,咱們在工程中,更傾向於用散列表或者紅黑樹。由於這兩種數據結構,咱們都不須要本身去實現,直接利用編程語言中提供的現成類庫就好了。

講到這裏,你可能要疑惑了,講了半天,我對 Trie 樹一通否認,還讓你用紅黑樹或者散列表,那 Trie 樹是否是就沒用了呢?是否是今天的內容就白學了呢?

實際上,Trie 樹只是不適合精確匹配查找,這種問題更適合用散列表或者紅黑樹來解決。Trie 樹比較適合的是查找前綴匹配的字符串,也就是相似開篇的那種場景。

如何實現搜索引擎的搜索關鍵詞提示功能?

其實這個也不用講了吧,很簡單,顯示出來就好了嘛
在這裏插入圖片描述
若是再稍微深刻一點,你就會想到,上面的解決辦法遇到下面幾個問題:

  • 我剛講的思路是針對英文的搜索關鍵詞提示,對於更加複雜的中文來講,詞庫中的數據又該如何構建成 Trie 樹呢?

  • 若是詞庫中有不少關鍵詞,在搜索提示的時候,用戶輸入關鍵詞,做爲前綴在 Trie 樹中能夠匹配的關鍵詞也有不少,如何選擇展現哪些內容呢?

  • 像 Google 這樣的搜索引擎,用戶單詞拼寫錯誤的狀況下,Google 仍是可使用正確的拼寫來作關鍵詞提示,這個又是怎麼作到的呢?

你能夠先思考一下如何來解決,若是不會也不要緊,這些問題,咱們會在後面具體來說解。

實際上,Trie 樹的這個應用能夠擴展到更加普遍的一個應用上,就是自動輸入補全,好比輸入法自動補全功能、IDE 代碼編輯器自動補全功能、瀏覽器網址輸入的自動補全功能等等。

總的來說,就是:

  • 來作動態集合數據的查找,散列表或者紅黑樹來。
  • 查找前綴匹配的字符串(重複越多越好),好比搜索引擎中的關鍵詞提示功能這個場景,就比較適合用Trie 來解決,也是 Trie 樹比較經典的應用場景。

課後題(想到再來答):

咱們今天有講到,Trie 樹應用場合對數據要求比較苛刻,好比字符串的字符集不能太大,前綴重合比較多等。若是如今給你一個很大的字符串集合,好比包含 1 萬條記錄,如何經過編程量化分析這組字符串集合是否比較適合用 Trie 樹解決呢?也就是如何統計字符串的字符集大小,以及前綴重合的程度呢?

報一個神奇的bug(真的是玄學):

當我將上面代碼的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};
	}

真的是神奇啊,哈哈哈("馬買皮")

參考自:
極客時間 數據結構與算法之美

相關文章
相關標籤/搜索