Trie(字典树) : 如何实现搜索引擎的关键词提示功能?


搜索中的关键词提示


当我们在搜索引擎中进行搜索时,有时仅仅输入了搜索内容的一部分,搜索引擎就会提示我们可能的一些选择,这样我们就不再需要将查询词完整的输入,大大节约了我们的时间。

而实现这一功能的基石,正是Trie树


Trie树的介绍

Trie树又叫做字典树、前缀树。顾名思义,它是一个用于处理多模式字符串匹配的多叉树,用来在一组字符串中快速的找到某个字符串。

其本质就是共享字符串的公共前缀,即利用字符串之间的公共前缀,将重复的前缀合并在一起。

对于每一个节点来说,它的每个子节点就代表着一种字符,例如我们插入一个hi,其就会在根节点寻找是否存在h节点,如果没有则新建一个,接着进入h节点,寻找是否存在i,如果没有也新建一个。接着到了i之后,当前字符串插入结束,为了标识hi是一个完整的单词而不是前缀,就会在该节点中做一个标记。

如下图,我们往里面插入几个字符串,插入流程如下。
在这里插入图片描述
在这里插入图片描述
此时,如果我们想在里面寻找是否存在hello
在这里插入图片描述
此时找到了这个单词,并且结束的位置o上存在标记,说明这时一个完整的单词,查找成功

接着查找se
在这里插入图片描述
虽然找到了se,但是此时并不存在标记,所以当前的se是前缀也不是单词,查找失败。


Trie树的实现

了解了思路后下面就可以开始实现了

首先进行数据结构的选择

我们从上面描述的原理可以得知,由于Trie树需要标记多种不同的字符,所以不可能是二叉树,而是一个多叉树,所以我们就需要对子节点的数据结构进行一个选择

我们可以选用有序数组、哈希表、红黑树、跳表等数据结构,例如我们的字典树中只存在英文字母的时候,就可以选择用一个大小为26的数组来存储,例如以下结构,这时很常见的一种实现方法。
在这里插入图片描述
但是如果想要实现一个关键词提示功能,字符的范围绝不限制于字母,还可能有数字、标点符号、各种语言等,所以对于子数组的大小,我们无法得知。

这里我选择使用哈希表来完成,哈希表可以动态扩容,我们就不必关心字符的种类数量,并且可以用O(1)的时间复杂度来找到一个字符是否存在,大大的提高了效率。

struct TrieNode
{
    
    
	char _data;		//当前字符
	bool _isEnd;	//标记当前是否能构成一个单词
	unordered_map<char, TrieNode*> _subNode;	//子节点
};

代码实现如下,具体的实现思路写在了注释中。

#include<unordered_map>
#include<string>
#include<vector>

using std::vector;
using std::string;
using std::unordered_map;
using std::make_pair;

//Trie树节点
struct TrieNode
{
    
    
	TrieNode(char data = '\0')
		: _data(data)
		, _isEnd(false)
	{
    
    }

	char _data;		//当前字符
	bool _isEnd;	//标记当前是否能构成一个单词
	unordered_map<char, TrieNode*> _subNode;	//子节点
};

//Trie树
class TrieTree
{
    
    
public:
	TrieTree()
		: _root(new TrieNode())
	{
    
    }

	~TrieTree()
	{
    
    
		delete _root;
	}

	//防拷贝
	TrieTree(const TrieTree&) = delete;
	TrieTree& operator=(const TrieTree&) = delete;

	//插入字符串
	void insert(const string& str)
	{
    
    
		if (str.empty())
		{
    
    
			return;
		}

		TrieNode* cur = _root;
		for (size_t i = 0; i < str.size(); i++)
		{
    
    
			//如果找不到该字符,则在对应层中插入
			if (cur->_subNode.find(str[i]) == cur->_subNode.end())
			{
    
    
				cur->_subNode.insert(make_pair(str[i], new TrieNode(str[i])));
			}
			cur = cur->_subNode[str[i]];
		}
		//标记该单词存在
		cur->_isEnd = true;
	}

	//查找字符串
	bool find(const string& str)
	{
    
    
		if (str.empty())
		{
    
    
			return false;
		}

		TrieNode* cur = _root;
		for (size_t i = 0; i < str.size(); i++)
		{
    
    
			if (cur->_subNode.find(str[i]) == cur->_subNode.end())
			{
    
    
				return false;
			}
			cur = cur->_subNode[str[i]];
		}
		//如果当前匹配到的是一个前缀而非一个完整的单词,则返回错误
		return cur->_isEnd == true ? true : false;
	}

	//查找是否存在以包含str为前缀的字符串
	bool startsWith(const string& str)
	{
    
    
		if (str.empty())
		{
    
    
			return false;
		}

		TrieNode* cur = _root;
		for (size_t i = 0; i < str.size(); i++)
		{
    
    
			if (cur->_subNode.find(str[i]) == cur->_subNode.end())
			{
    
    
				return false;
			}
			cur = cur->_subNode[str[i]];
		}
		return true;
	}

	//返回所有以str为前缀的字符串
	vector<string> getPrefixWords(const string& str)
	{
    
    
		if (str.empty())
		{
    
    
			return {
    
    };
		}

		TrieNode* cur = _root;
		for (size_t i = 0; i < str.size(); i++)
		{
    
    
			if (cur->_subNode.find(str[i]) == cur->_subNode.end())
			{
    
    
				return {
    
    };
			}
			cur = cur->_subNode[str[i]];
		}
		vector<string> res;
		string s = str;

		_getPrefixWords(cur, s, res);
		
		return res;
	}

private:
	//使用回溯来找到所有包含该前缀的字符串
	void _getPrefixWords(TrieNode* cur, string& str, vector<string>& res)
	{
    
    
		//如果当前能构成一个单词,则加入结果集中
		if (cur->_isEnd)
		{
    
    
			res.push_back(str);
		}

		//匹配当前所有可能字符
		for (const auto& sub : cur->_subNode)
		{
    
    
			str.push_back(sub.first);	//匹配当前字符
			_getPrefixWords(sub.second, str, res);	//匹配下一个字符
			str.pop_back();	//回溯,尝试其他结果
		}
	}

	TrieNode* _root;	//根节点,存储空字符
};

下面进行一个简单的测试,演示一下上面实现的所有函数

#include<iostream>
#include"TrieTree.hpp"

using namespace std;

int main()
{
    
    
	TrieTree trie;

	trie.insert("hello");
	trie.insert("helo");
	trie.insert("hill");
	trie.insert("world");
	trie.insert("test");
	trie.insert("cpp");
	trie.insert("我");
	trie.insert("我爱学习");
	trie.insert("我爱C++");
	trie.insert("你");
	
	//测试中文,查找以我为前缀的所有字符串
	for (auto str : trie.getPrefixWords("我"))
	{
    
    
		cout << str << endl;
	}
	//测试英文,查找以h为前缀的所有字符串
	for (auto str : trie.getPrefixWords("h"))
	{
    
    
		cout << str << endl;
	}
	cout << trie.find("我") << endl;
	cout << trie.find("它") << endl;
	cout << trie.find("cpp") << endl;
	cout << trie.find("java") << endl;
	cout << trie.startsWith("h") << endl;

	return 0;
}

在这里插入图片描述


搜索的关键词提示

回到我们开头说的那种情景,在我们往搜索引擎中输入字符串的过程时,它就会把这个词作为一个前缀子串在Trie树中匹配,并且找到所有满足条件的结果,这也就是我们实现的getPrefixWords函数的功能,这就是搜索关键词提示的最基本的算法原理。

但是在搜索引擎背后的技术,不仅仅只有这么简单。当我们在搜索时,即使我们不以前缀输入,而是输入其中的一个片段,又或者我们输入的查询词错误,他也会校正后返回正确的结果,这又是如何做到的呢?我们是否能将这个功能更加广泛化,如实现编译器、输入法的自动补全等?

在这里就先挖一个坑,或许未来我会写一篇有关这方面的博客来具体讲一讲它们背后的原理。

如果想要了解搜索引擎的简单原理,可以参考我的往期博客,这是C++实现的一个简单的站内搜索引擎。
【项目介绍】搜索引擎


Trie树 VS 红黑树、哈希

通常来说,在查找问题上我们都会使用红黑树和哈希等数据结构,那么和他们比起来,Trie树有什么优势吗?
从上面的实现可以看出,构建一个Trie树的时间复杂度为O(N * M)(N为字符串数,M为字符串长度,这里可直接视为总字符数),而字符串的查找时间为O(M)

虽然这个效率确实挺高,但是比起上述的数据结构,并没有什么突出的地方,并且Trie树还有以下几种严重的缺点

缺点

  • 内存消耗大,从上面可以看出来,Trie树是典型的以时间换空间的做法,为了维护每一个节点的子节点花费了大量的空间。
  • 要求字符串的前缀重合多,否则为了维护子节点消耗的空间会变多
  • 常见的语言如JAVA、C++库中都实现了红黑树、哈希表,而没有实现Trie树,所以需要自己实现

按照上面所描述的,难道Trie真的那么无用吗?错了,Trie树只是不适合那种精确的匹配查找,它的优势在于查找前缀匹配的字符串,也就是我们开头的那种场景。

猜你喜欢

转载自blog.csdn.net/qq_35423154/article/details/109076473