trie字典树入门专题

trie字典树

字典树简介

字典树,又名前缀树(prefix-tree),英文名Trie,如其名:就是一棵像字典一样的树.我们首先通过一张图来理解字典树的结构:

结构特性

简单来说:字典树是一个通过申请空间,储存空间来"储存"字符串的一个树形数据结构;
注意:

  1. 树本身是不去储存字符的,而是通过储存"某段空间是否被开辟的状态"来储存字符的;我们储存的是字符的状态;具体的物理层面的实现就是通过ASCII码将字符的概念转化为数字,然后字符映射为整数当作保存下标的数组的索引;开辟空间具体指的就是创建一个新的结点储存我们对应的字符"存在"的状态,听起来好像要new,但是们可以先申请静态的数组,然后用一个游码id,去++id分配正整数数组下标,代表这个字符曾经有过;
    • 还有一点就是有时候一个一个new不好写,又慢;但是硬开静态空间写不了那么大;我们借用缓冲区的概念,每次new1e5个结点之类的;
  2. 数据结构主要的操作有增删查改,最重要的还是'查';你怎么储存,储存什么决定了你利用,更新这个东西的方式;字典树就是一个储存字符串结构的东西,我们从上面知道树储存的是每个(字符数字)的状态,我按顺序把要查询的东西取出每一个字符,去访问;如果一直访问成功,访问到了我们要查询的结尾,那就说明这个字典树曾经经过过这个结点,有不短于现在查询的字符串曾经来过;如果中间访问失败了,那就说明没有那么长的字符来过,只有部分"前缀"来过;

字典树的特性以及优缺点

优缺点

优点:

  1. 字典树记录了多个字符串的公共前缀,查询公共前缀时是十分高效的;他减少了无意义的字符串匹配,其查询效率要优于哈希.
    • hash的查询不是 \(O(1)\) 吗?查询一个字典树需要遍历整个字符串,为什么查询效率更高?[1]
    • 由于树自身的非线性结构,他把相同前缀的东西都设计为要历经同一个结点,他在查询一个字符串的时候无意间匹配了许多个字符串,这个是高效的原因;
  2. 字典树较线性package要优秀,因为他有公用的部分,而没有像hash一样去"把字符串通过算法看作一个变量"去变成索引;

缺点:字典树的内存消耗非常大.

我们不难发现,字典树的核心思想是用空间来换时间(利用公共前缀来降低查询时间的开销以达到提高效率的目的).虽然比hash硬存要好,但是每个结点除了必要的信息之外,需要储存大量的"状态";

我怎么一直在批评hash

字典树的基础应用:字符串检索

从字典树的结构不难发现:字典树可以用来保存大量的字符串(包括但不限于字符串),同时在一些高级的应用中可以用来统计和排序大量的字符串.在实际生产生活的应用中,我们经常将字典树用于文本的词频统计.

字典树操作及模板

插入操作

基本思路:
从签到后扫描这个单词,如果扫描指针指向的字母在相应的根节点下没有出现过,就插入这个字母;
否则沿着字典树往下走,扫描指针后移.

void trietree::Insert(string str,int index=-0x7f7f7f7f)
{
	int p=0;
	for(int i=0;i<str.size();i++){
		int ch=str[i]-'0';//字符作为索引
		if( !node[p].Next[ch])//如果没有出现过
			node[p].Next[ch]=++id;//动态开点
		p=node[p].Next[ch];//按着储存的顺序往下走
		node[p].sum++;
	}
	node[p].end++;
	//标记这个走到这个结点的路径所形成的字符串有多少个,也就是有多少个原串长成这样;
	node[p].index=index;
	//用于字典的一一对应概念
}
结点的编号问题:

结点的编号有两类,一个是这个结点的编号,也就是这个结点的物理位置;另一个就是下一个字符作为索引得到的下一个物理位置;代码中使用Next[]去储存下一个字符的物理状态,或者"下一个物理位置";

结点的编号问题查找单词(搜索)

查找单词是否存在,我们只需要在末尾结点上储存相应的信息;如果它存在的话,我们就可以按顺序搜索到它,我们在末尾的结点相应标记信息;

int trietree::search(string str)
{
    int p=0;
    for(int i=0;i<str.size();i++){
        int ch=str[i]-'0';
        p=node[p].Next[ch];
        if(!p)//没找到
            return 0;
    }
    // return node[p].index;
    return node[p].cnt;
}

统计前缀个数

在需要统计前缀个数的题目中,我们在结点中引入sum的概念;我们想象建树的过程中,我们知道,如果存在字符串有共同的前缀的情况我们肯定会重复的经过某一些结点;我们如果把某个个前缀当作一个字符串去进行搜索的话,我们在搜索末尾查询的sum大小就本是上是有多少个字符串前缀;

int trietree::search(string str)
{
    int p=0;
    for(int i=0;i<str.size();i++){
        int ch=str[i]-'0';
        p=node[p].Next[ch];
        if(!p)//没找到
            return 0;
    }
    return node[p].sum;
}

字典树模板

封装了的字典树类:

struct Node{
	int Next[2];//异或计算
// int Next[26];//字符串
// int Next[10];//数字计算
	int sum;//后缀个数
	int index;//所属下标
	int end;//结束标志
};

class trietree{
private:
	Node node[N];//树的根节点,N要记得自己敲
	int id;//动态开点的游码
public:
	//维护一个trie
	void Insert(int num,int index=-0x7f7f7f7f);
	void Insert(string str,int index=-0x7f7f7f7f);
	void Delete(int num);
	void Delete(string str);
	void reset();
public:
	//搜索函数
} trie;

void trietree::Insert(int num,int index)
{
	int p=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		if( !node[p].Next[ch])
			node[p].Next[ch]=++id;
		p=node[p].Next[ch];
		node[p].sum++;
	}
}

void trietree::Insert(string str,int index=-0x7f7f7f7f)
{
	int p=0;
	for(int i=0;i<str.size();i++){
		int ch=str[i]-'0';
		if( !node[p].Next[ch])
			node[p].Next[ch]=++id;
		p=node[p].Next[ch];
		node[p].sum++;
	}
	node[p].end++;
	node[p].index=index;
}

void trietree::Delete(int num)
{
	int p=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		p=node[p].Next[ch];
		if(!p)
			return ;
		node[p].sum--;
	}
	memset( node[p].Next,0,sizeof(node[p].Next) );
}

void trietree::Delete(string str)
{
	int p=0;
	for(int i=0;i<str.size();i++){
		int ch=str[i]-'0';
		p=node[p].Next[ch];
		if(!p)
			return ;
		node[p].sum--;
	}
	memset( node[p].Next,0,sizeof(node[p].Next) );
}

void trietree::reset()
{
	id=0;
	memset(node,0,sizeof(node) );
}

void init()
{
	// cin.tie(0)->sync_with_stdio(0);
	// cout.tie(0)->sync_with_stdio(0);
}

void reinit()
{
	trie.reset();
}

signed main(void)
{
	//code
	return 0;
}

常见的搜索函数

int Max_xor(int num);//计算最大异或值
int Min_xor(int num);//计算最小异或值
int Search2(int num);
// void Search(string str);
int Search1(int num,int limits);//限制异或值的搜索函数

int search(int num)
{//一般搜索结点搜索共同前缀个数,字符串存在性,以及搜索字典翻译
	int p=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		p=node.Next[ch];
		if( !p )
			return 0;
	}
	// return node[p].sum;
	return node[p].cnt;
	// return node[p].index;
}


int Max_xor(int num)
{//最大异或值计算
	int p=0;
	int ret=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		if( node[node[p].Next[ch^1]].sum ){
			p=node[p].Next[ch^1];
			ret|=(1<<i);
		}else{
			p=node[p].Next[ch];
		}
		if(!p)
			break;
	}
	return ret;
}

int Min_xor(int num)
{//最小异或值计算
	int p=0;
	int ret=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		if( !node[p].Next[ch] || node[p].Next[ch^1] && node[ node[p].Next[ch] ].sum<=1 ){
			ch^=1;
			ret|=(1<<i);
		}
		p=node[p].Next[ch];
		if(!p)
			break;
	}
	return ret;
}


int Search1(int num,int limits)
{//限制异或值的搜索函数
	int p=0;
	int temp=0;
	int ret=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		if( node[ node[p].Next[ch^1] ].sum &&  ( temp|(1<<i) ) < limits ){
			temp|=(1<<i);
			ret+=node[ node[p].Next[ch] ].sum;
			p=node[p].Next[ch^1];
		}else{
			p=node[p].Next[ch];
		}
		if(!p)
			break;
	}
	return ret;
}


字典树的应用

字典树::排序算法

在许多场景下,我们需要对多个字符串进行排序;一般地,把字符串看作一整个变量,进行快速排序的话复杂度是 \(O(nlog_{n})\) ;
极限一点的情况,我们如果是采用桶排序的思想,我们按照顺序遍历按顺序得到有顺序的序列复杂度是 \(O(1)\) ,也就是需要访问的时间,比如说一个很大的map,我们只需要 \(O(1)\) 去查询(然鹅没有一个很方便得到字典序顺序的遍历方法);
我们采用字典树去进行字符串的排序的时候,我们借由每个结点的信息,我们可以按顺序从小到大或者是从大到小那样按顺序进行访问从而得到字符串原来的下标集合,也就是复杂度 \(O(mn)\) 或者说是 \(O(\sum{字符的个数})\) (m为平均长度,n就是个数);

void trietree::search(int k)
{
    if(k)
        for(int i=0;i<26;i++){
            if(  node[k].cnt )
                ans.push_back(node[k].index);
            if(node[k].Next[i])
                search(node[k].Next[i] );
        }
}

字典树::维护异或极值

异或是二进制运算,所以我们要从二进制考虑;
异或运算律就是:两个数不同为1,相同为0;可以认为,异或直接的效果就是保存不同的部分,然后消去相同的部分;
我们先根据感觉猜测:如果一个数不同的个数越多,也就是和某个数的异或值就越4大;
这看看起来好像和trie没有半毛钱关系,因为trie便于处理的是相同的前缀,你现在让我找整体不同的部分最多,差异化最大,这个数据结构要是是处理这种问题就很鸡肋了,因为从部分的特性推到整体的特性,需要借助大规模的搜索,毕竟前缀不同,整体不同的个数也不一定就更大;
看起来无解了,我们回去看一下这个猜测,发现经不起推敲:我们很轻易地发现 \((10000000)_{2}>(01111111)_{2}\) ,也就是说,异或值异或出来的那个1不仅和数量有关,还和位置有关,而且位置因素的影响永远要大于个数的影响,在高位的1可以顶地过地位的无数个1;
拨云见日,原来这个题目让我们去找最大的异或值,本质上应该是从高位到第一依次把原来字符串的那个字符进行异或(0变1,1变0),如果有的话那个就是最优解(而且这里的最优解可以直接导致全局最优,证明在上面,所以这里可以采取贪心策略),没有的话再找找看咯;然后原路一直往下找,找到自己的那种情况就是最小值0;

int trietree::Max_xor(int num)
{
	int p=0;
	int ret=0;
	for(int i=30;i>=0;i--){
		int ch=(num>>i)&1;
		if( node[node[p].Next[ch^1]].sum ){
	//从高到低往下找每一位的异或出来的对应着的那个字符的字符串前缀
	//是一种尝试查找和自己有异或关系的那么一个搜索函数
			p=node[p].Next[ch^1];
			ret|=(1<<i);
		}else{
			p=node[p].Next[ch];
		}
		if(!p)//找不到,这里不会存在这种情况
			break;
	}
	return ret;
}

有极大值,那么也就有极小值;极小值怎么求呢?最小值照样理解就是求和自己差异最小的,也就是前缀尽量重合的;
最小能是多少呢?是0;这个看起来一点毛病都没有,一个数异或另一个数最小,那肯定得是选自己啊,因为自己和自己没有差异;但是题目不会这么单纯问你这个,它会要求你不能选自己;我们能感觉出来就是在到达原字符串的最近的那个岔口走过去,但是这个感觉没有办法直接编码,因为它有悖于trie原本的结构,我们需要额外再保存很多信息,花很多时间搜索;
那么我们直接往下走,往下走的时候怎么避开自己?前缀尽量重合的字符串如果没有的话,那么就是理论上的最大异或值,前缀重合部分逐渐增多的话,异或值就会逐渐减小;前缀重合部分完全一样那就会成为0;
我们在搜和他自己重合部分最多的时候,我们只需要看这个前缀有没有其他人有,如果有的话,就继续走,走到最后一个和离他最近的那个字符串分叉的时候我们只需要变道一次,然后一直走到底即可;

字典树::维护异或和(0-1 Trie)

异或和?什么玩意;
异或被称为不进位的加法,原因就是如果不考虑进位的话,正常的加法和异或有同样的结果;如果说加法求和是把全部的数字加在一起;那么异或和就是把所有的数字异或在一起的到的东西;
异或和?怎么求任意区间的异或和呢?由异或的性质,我们异或一个数字偶数次,就等于没有异或过这个数字;我们求任意区间的异或,可以处理成类似于前缀和的东西,然后我们用全部的异或和抑或上前面不要的部分,就得到了中间的异或和;
这个是固定的套路,如果我们要求异或和的最大值就先求出其前缀和,然后再求前缀和里面的异或最大值,就转换成了上面那种问题;
这里一般的题目就是转化为维护异或最值的题目加上简单的维护操作;
维护一个字典树前面已经提到过了基本的操作;更难一点的题目,需要可持久化编码,这里就不怎么展开了;

字典树::前缀匹配1

Hat’s Words

题目大意

给定一组单词,如果说存在两个不同的单词可以首尾拼接成为另一个组内的单词,那么按照字典序输出那个可以被拼出来的单词;

Input:

a
ahat
hat
hatword
hziee
word

Output:

ahat
hatword

解题思路

拼接单词??有点像之前做过的乔治拼接木棒的那道题目;直觉告诉需要写一个限制深度的dfs(),然后枚举所有的字符串去尝试是否能将其分解恰好两个:

int Find(string &str,int index,int deep)//要分解的字符串,这次开始的下标,搜索深度(枚举了的个数)
{
    if(deep>2)//超过两个不对
        return 0;
    else if( index==str.size() && deep==2){//刚好分解,good
        return 1;
    }else{//deep<2 也就是只枚举了一个,那就继续枚举
        int p=0;
        for(int i=index;i<str.size();i++){
            int ch=str[i]-'a';
            p=trie[p].Next[ch];
            if( trie[p].cnt && !trie[p].vis){//乱七八糟的代码
                trie[p].vis=1;
                if( Find(str,i+1,deep+1) ){
                    trie[p].vis=0;
                    return 1;
                }
                trie[p].vis=0;
            }
        }
        return 0;
    }
}

这种思路搞下去和map暴力储存然后 $O(n^2) $爆搜没有什么区别了,效率低;而且思路具体实现的时候发现很多问题顾不过来,补东补西就是一直WA;
反思一下过程:我们刚刚采用的是组合的思路,现在我们改成拆解的思路;把以一个已经有了的字符串进行拆解,然后去查找它们是不是都在给的那组单词里面,这里会用到我们的结尾标记;

int Find(string str)
{
    int p=0;
    for(int i=0;i<str.size();i++){
        int ch=str[i]-'a';
        p=trie[p].Next[ch];
        if( !p )
            return 0;
    }
    return trie[p].cnt;
}

signed main(void)
{
    cin.tie(0)->sync_with_stdio(0);
    cout.tie(0)->sync_with_stdio(0);

    for( size__=1; getline(cin,str[size__]) ; size__++ ){
        ins(str[size__]);
    }
    for(int i=1;i<=size__;i++){
        for(int j=1;j<str[i].size();j++){
            if( Find( str[i].substr(j) ) && Find( str[i].substr(0,j) ) ){
                cout<<str[i]<<'\n';
                break;
            }
        }
    }
    return 0;
}

小结一下

当我们需要去将一个字符串(模式串pattern)匹配到一个等长或者是更长的字符串中(文本串text);我们应该采取的是分解长串的方式,去查找分解出来的单元是否存在;

字典树::前缀匹配2

Babelfish

题目大意

输出一个字典,然后给出'生词'输出'熟词';

Input:

dog ogday
cat atcay
pig igpay
froot ootfray
loops oopslay

atcay
ittenkay
oopslay

Output:

cat
eh
loops

解题思路

这个本质上就是一个映射的问题;借由前面的思路,我们查询的东西一定是需要被储存的东西;但是储存的东西并不是他直接需要输出的东西,而是之前输入的东西;
那么我们就是需要去仔细地保存输入的东西,然后一一对应地记录输入地时候给地关系,这里面我们采用每个单词的结点末尾储存对应的单词的下标,查询的时候只需要返回下标就可以了;
也就是map的功能,只不过省空间又快速;

struct trietree{
	int Next[26];
	int cnt;
	int sum;
	int index;
}trie[N];int id;

void ins(string str,int index=-0x7f7f7f7f)
{
	int p=0;
	for(int i=0;i<str.size();i++){
		int ch=str[i]-'a';
		if( !trie[p].Next[ch])
			trie[p].Next[ch]=++id;
		p=trie[p].Next[ch];
	}
	trie[p].index=index;
}

其他

  • 可持久化trie
  • 作为辅助结构
    • AC自动机
    • 回文树
    • 后缀树
  • 字典树优化
    • 优化map的储存和查找等等
  • 优化字典树
    1. 按位树
    2. 节点压缩
      1. 分支压缩
      2. 节点映射表
    3. 双数组TRIE树(Double Array Trie)

和其他字符串算法的关系

1
图中可以看到这样一些关系:extend-kmp 是kmp的扩展;ac自动机是kmp的多串形式;它是一个有限自动机;而trie图实际上是一个确定性有限自动机;ac自动机,trie图,后缀树实际上都是一种trie;后缀数组和后缀树都是与字符串的后缀集合有关的数据结构;trie图中的后缀指针和后缀树中的后缀链接这两个概念及其一致。

题单

题解报告

序号 题号 标题 考点 难度评级
1 POJ 2001 Shortest Prefixes 统计前缀
2 POJ 3630 Phone List 统计前缀
3 洛谷P2922 Secret Message G 统计前缀
4 HDU 1277 全文检索 字符串匹配
5 洛谷P3879 阅读理解 字符串匹配
6 HDU 1247 Hat’s Words 字符串匹配 ⭐⭐
7 CodeForces-633C Spy Syndrome 2 字符串匹配(dfs) ⭐⭐
8 POJ 3764 The xor-longest Path 最大异或和 ⭐⭐
9 LightOJ-1269 Consecutive Sum 最大最小异或值 ⭐⭐
10 HDU 5687 Problem C 最大异或值 ⭐⭐
11 CodeForces-817E Choosing The Commander 统计较小异或值 ⭐⭐⭐
12 HDU-5536 Chip Factory 字典树的综合应用 ⭐⭐⭐

参考资料

<<算法竞赛>>--罗永军
字典树Trie--皮小猪的时光
Trie 字典树 详解--HeartFireY


  1. Trie树不用求hash值,对短字符串有更快的速度;通常,求hash值也是需要遍历字符串的(与hash函数相关);但当hash函数很好时,Trie树的查找效率低于哈希搜索。 ↩︎

posted @ 2023-03-19 01:17  ZZQ323  阅读(82)  评论(0)    收藏  举报