前缀树的实现

61 篇文章 0 订阅
61 篇文章 0 订阅

1 前缀树 (Trie树)概述

Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。

Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

它有3个基本性质:

1)根节点不包含字符,除根节点外每一个节点都只包含一个字符。

2)从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。

3)每个节点的所有子节点包含的字符都不相同。

 

2 前缀树实现

前缀树相关的操作包括构建,查找和删除,下面分别介绍。

2.1 前缀树的构建

举个在网上流传颇广的例子:给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。

分析:这题当然可以用hash来解决,但是本文重点介绍的是Trie树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用Trie还是很简单。

现在回到例子中,如果我们用最傻的方法,对于每一个单词,我们都要去查找它前面的单词中是否有它。那么这个算法的复杂度就是O(n^2)。显然对于100000的范围难以接受。现在我们换个思路想。假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。

好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:


当时第一次看到这幅图的时候,便立马感到此树之不凡构造了。单单从上幅图便可窥知一二,好比大海搜人,立马就能确定东南西北中的到底哪个方位,如此迅速缩小查找的范围和提高查找的针对性,不失为一创举。

ok,如上图所示,对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。

那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。

这样一来我们查询和插入可以一起完成(重点体会这个查询和插入是如何一起完成的,稍后,下文具体解释),所用时间仅仅为单词长度,在这一个样例,便是10。

我们可以看到,trie树每一层的节点数是26^i级别的。所以为了节省空间。我们用动态链表,或者用数组来模拟动态。空间的花费,不会超过单词数×单词长度。

 

2.2 前缀树的查询和删除

上文中提到“比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用Trie还是很简单”。下面,咱们来看看这个前缀查询问题:

已知n个由小写字母构成的平均长度为10的单词,判断其中是否存在某个串为另一个串的前缀子串。下面对比3种方法:

1)最容易想到的:即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为O(n^2)。

2)使用hash:我们用hash存下所有字符串的所有的前缀子串,建立存有子串hash的复杂度为O(n*len),而查询的复杂度为O(n)* O(1)= O(n)。

3)使用Trie:因为当查询如字符串abc是否为某个字符串的前缀时,显然以b,c,d....等不是以a开头的字符串就不用查找了。所以建立trie的复杂度为O(n*len),而建立+查询在trie中是可以同时执行的,建立的过程也就可以成为查询的过程,hash就不能实现这个功能。所以总的复杂度为O(n*len),实际查询的复杂度也只是O(len)。(说白了,就是Trie树的平均高度h为len,所以Trie树的查询复杂度为O(h)=O(len)。好比一棵二叉平衡树的高度为logN,则其查询,插入的平均时间复杂度亦为O(logN))。

 

Trie树是简单但实用的数据结构,通常用于实现字典查询。我们做即时响应用户输入的AJAX搜索框时,就是Trie开始。本质上,Trie是一颗存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。和普通树不同的地方是,相同的字符串前缀共享同一条分支。下面,再举一个例子。给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:


可以看出:

1)每条边对应一个字母。

2)每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。

单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。同理,ate, age, adv, 和ant共享前缀"a",所以他们共享从根节点到节点"a"的边。

 

查询操纵非常简单。比如要查找int,顺着路径i -> in -> int就找到了。

 

搭建Trie的基本算法也很简单,无非是逐一把每则单词的每个字母插入Trie。插入前先看前缀是否存在。如果存在,就共享,否则创建对应的节点和边。比如要插入单词add,就有下面几步:

考察前缀"a",发现边a已经存在。于是顺着边a走到节点a。

考察剩下的字符串"dd"的前缀"d",发现从节点a出发,已经有边d存在。于是顺着边d走到节点ad

考察最后一个字符"d",这下从节点ad出发没有边d了,于是创建节点ad的子节点add,并把边ad->add标记为d。

 

删除操作和查询类似,查询不改变Trie的结构和数据,删除需要修改指定的节点状态即可。譬如要删除单词add,只需要沿着a->d->d查找,如果没有则直接返回,否则改变状态返回。

 

 

2.3 前缀树的相关题目

1)有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。

2)1000万字符串,其中有些是重复的,需要把重复的全部去掉,保留没有重复的字符串。请怎么设计和实现?

3)一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出思想,给出时间复杂度分析。

4)寻找热门查询:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录,这些查询串的重复读比较高,虽然总数是1千万,但是如果去除重复和,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。请你统计最热门的10个查询串,要求使用的内存不能超过1G。

a) 请描述你解决这个问题的思路;

b) 请给出主要的处理流程,算法,以及算法的复杂度。

5)。。。。。。。

 

3 前缀树实现代码

 


#include <iostream>
#include <stack>
#include <iomanip>
using namespace std;

/**********************************************************
* 定义前缀树的结构体, 对于该前缀树, 
* 输入的有效字符应在 [BEG, END]之间, 不在该范围的都会被忽略.
* 每个有效的单词长度不应该超过 LL-1
* 
***********************************************************/
struct Trie {
	static const int BEG = 'a';				// 字符范围上界
	static const int END = 'z';				// 字符范围下界
	static const int MM = END - BEG + 1;	// 范围长度
	static const int LL = 11;				// 单词最长长度
	Trie *next[MM];							// 字典树指针
	int status[1];							// 节点状态
	Trie() : next{ nullptr }, status{ 0 } {}
};

/**********************************************************
* int insert(Trie *root, const char *word, int n)  
* 功能: 插入字符串
* 输入: root要插入的前缀树的根节点, word待插入的单词, n插入个数(默认1)
* 返回: -1插入出错, k(0<=k)插入该单词的个数
* 
***********************************************************/
int insert(Trie *root, const char *word, int n = 1) {
	if (!(root && word)) return -1;
	for (; *word; ++word) {
		if (Trie::BEG <= *word && *word <= Trie::END){
			if(!root->next[*word - Trie::BEG])
				root->next[*word - Trie::BEG] = new Trie;
			root = root->next[*word - Trie::BEG];
		}
	}
	n = n < 0 ? 0 : n;
	root->status[0] += n;
	return n;
}

/**********************************************************
* int remove(Trie *root, const char *word, int n)
* 功能: 移除字符串
* 输入: root要移除的前缀树的根节点, word待移除的单词, n移除该单词的个数(默认全部)
* 返回: -1移除出错, k(0<=k)移除该单词的个数
* 
***********************************************************/
int remove(Trie *root, const char *word, int n=-1) {
	if (!(root && word)) return -1;
	for (; *word && root; ++word)
		root = Trie::BEG <= *word && *word <= Trie::END ? root->next[*word - Trie::BEG] : root;
	if (root) {
		n = root->status[0] < n || n < 0 ? root->status[0] : n;
		root->status[0] -= n;
	}
	else n = 0;
	return n;
}

/**********************************************************
* int clear(Trie *root, const char *word)
* 功能: 清除字符串处节点(默认根节点)的后续节点(删除以某字符串为真前缀的所有字符串)
* 输入: root要清除的前缀树的根节点, word待清除的单词的前缀(默认空, 即删除该前缀树)
* 返回: -1清除出错, k(0<=k)清除单词的个数
*
***********************************************************/
int clear(Trie *root, const char *word = "") {	
	if (!(root && word)) return -1;
	for (; *word && root; ++word)
		root = Trie::BEG <= *word && *word <= Trie::END ? root->next[*word - Trie::BEG] : root;
	if (!root) return 0;
	int res(0);
	for (int k1(0);k1 < Trie::MM;++k1)
		if (root->next[k1]) {
			res += clear(root->next[k1]);
			res += root->next[k1]->status[0];
			delete root->next[k1];
			root->next[k1] = nullptr;
		}
	return res;
}

/**********************************************************
* int search(Trie *root, const char *word)
* 功能: 查找单词
* 输入: root要查找的前缀树的根节点, word待查找的单词
* 返回: -1查找出错, 0单词不存在, k(0<k)查找到单词的个数
*
***********************************************************/
int search(Trie *root, const char *word) {
	if (!(root && word)) return -1;
	for (; *word && root; ++word)
		root = Trie::BEG <= *word && *word <= Trie::END ? root->next[*word - Trie::BEG] : root;
	return root ? root->status[0] : 0;
}

/**********************************************************
* int triePrint(Trie *root, const char *word, ostream &os)
* 功能: 打印以某单词为前缀的所有单词
* 输入: root要打印的前缀树的根节点, word前缀单词(默认打印整个前缀树), os打印目标(默认标准输出窗口cout)
* 返回: -1打印出错, k(0<=k)打印单词的个数
*
***********************************************************/
int triePrint(Trie *root, const char *word = "", ostream &os = cout) {
	if (!(root && word)) return -1;
	char str[Trie::LL], *sp1(str), *sp2(nullptr);
	for (; *word && root; ++word) {
		root = Trie::BEG <= *word && *word <= Trie::END ? root->next[*word - Trie::BEG] : root;
		*sp1++ = *word;
	}
	if (!root) return 0;
	stack<Trie*> st;
	int res(0);
	sp2 = sp1;
	if (sp2 != str && root->status[0]) {
		*sp2 = '\0';
		os << setw(Trie::LL) << left << str << setw(8) << right << root->status[0] << endl;
		++res;
	}
	*sp2 = Trie::BEG;
	while (1) {
		for (;*sp2 <= Trie::END && !root->next[*sp2 - Trie::BEG];++*sp2);
		if (Trie::END < *sp2) {
			--sp2;
			if (sp2 < sp1) break;
			++*sp2;
			root = st.top();
			st.pop();
		}
		else {
			st.push(root);
			root = root->next[*sp2 - Trie::BEG];
			++sp2;
			if (root->status[0]) {
				*sp2 = '\0';
				os << setw(Trie::LL) << left << str << setw(8) << right << root->status[0] << endl;
				++res;
			}
			*sp2 = Trie::BEG;
		}
	}
	return res;
}

/**********************************************************
* ostream& operator<<(ostream &os, Trie &t)
* 功能: 前缀树输出重载
*
***********************************************************/
ostream& operator<<(ostream &os, Trie &t) {
	triePrint(&t, "", os);
	return os;
}

int main() {
	Trie root[1];
	insert(root, "a");
	insert(root, "ab");
	insert(root, "abc",2);
	insert(root, "abc");
	insert(root, "abd",4);
	insert(root, "abcd");
	cout << "****输出整个前缀树:" << endl;
	cout << root[0] << endl;
	cout << "****输出以\"abc\"为前缀的单词有:" << endl;
	cout << "****共有" << triePrint(root, "abc") << "个不同的单词" << endl;

	cout << "****移除\"abc\":" << remove(root,"abc") << endl;
	cout << "****移除3个\"abd\":" << remove(root, "abd",3) << endl;
	cout << "****查找\"abcd\":" << search(root, "abcd") << endl;

	cout << "****输出整个前缀树:" << endl;
	cout << root[0] << endl;

	cout << "****清除以\"ab\"为真前缀的节点:" << clear(root, "ab") << endl;
	cout << "****输出整个前缀树:" << endl;
	cout << root[0] << endl;
	clear(root);
	return 0;
}




 

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值