浅谈字符串匹配算法----字典树Trie

· · 个人记录

浅谈字符串匹配算法----字典树Trie

----谨以此篇来纪念我可怜的字符串水准

本来是不想写字典树的。。。

为了填上我AC自动机的坑也是没办法了

下面我们来看看字典树(Trie)

先上度娘:

字典树,又称单词查找树,键树,Trie树,是一种树形结构,是一种哈希树的变种

典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计

优点:查询效率比哈希树高

算法策略:利用字符串的公共前缀减少查询时间,最大限度地减少无谓的字符串比较

$Trie$中的键通常是字符串,但也可以是其它的结构。$Trie$算法可以很容易地修改为处理其它结构的有序序列,比如一串数字或者形状的排列。例如,$bitwise$ $Trie$中的键是一串位元,可以用于表示整数或者内存地址 ~~以上都是度娘说的,跟我没有半毛钱关系~~ 俗话说字如其人,我们可以确定字典树是一颗树~~(废话)~~ 按某位神仙的话来说,**$KMP$是$1$对$1$的线性字符串匹配,而字典树则是$1$对多的树上字符串匹配** 那么接下来我们正经的~~江~~讲一下字典树 字典树可以保存字符串与值的对应关系。实际上,它和$Java$的$HashMap$或$c++$的$map$功能一致,都是$key->value$映射,只不过$Trie$的$key$值只能是字符串 # 声明:以下的图片中,都使用的是将字符写在节点上,但其实这是不正确的,应当写在该节点与其父亲节点之间的树边上,即字符其实是树的边权 重要的东西要大点写。。。 感谢 @可爱即是正义 $(zsy)$指出错误 ## $Trie$的基本性质: $(1)$根节点没有存储字符,除根节点以外,每个节点只包含一个字符 $(2)$根节点到某一节点间路径上的字符从上到下连接起来,就是该节点存储的字符串 $(3)$每个节点的所有子节点包含的字符串不相同 ## $Trie$的一些特性: $(4)$如果总字符种数为$n$,则每个结点的出度为$n ## $Trie$的建树(插入) 关于$Trie$的建树~~其实是很简单的~~下面大概讲一下好了 我们假设一颗$Trie$中存储的是字符串 基本思想如下: 从根开始,沿着单词的各个字母所对应的树的节点分支向下走,特殊的,如果发现之前没有过某个字母对应的节点,就新建一个节点继续向下遍历,直到将整个单词遍历完毕之后,将最后的节点标记,表示有一个单词插入$Trie$树以该节点结尾,此时我们要插入的单词就插入完毕了 下面大概放一张图解释一下 比如说我们要插入的单词是$hen,hers,say,said$,那么我们建出来的$Trie$长这样: ![](https://i.loli.net/2018/10/13/5bc1e449bea51.png) 感性理解一下?~~应该能懂的吧~~ 此时插入完了这四个单词之后,我们并不知道出现过哪些单词,即没有标记单词结尾字符的节点,于是乎加上标记 ![](https://i.loli.net/2018/10/13/5bc1f04dc2258.png) 加上标记了就变得这样~~丑死了~~ 可能你还是不太懂怎么插入的单词,所以我们来看一看单词$"said"$的插入过程 这是还未插入$"said"$之前的情况: ![](https://i.loli.net/2018/10/13/5bc1f0ba40a78.png) 那么,此时如果我们要插入单词$"said"$的话,怎么办呢? 当然是先从根开始遍历咯! ![](https://i.loli.net/2018/10/13/5bc1f0fdb577a.png) 我们发现,根节点之前已经有过$"said"$的"s"字符儿子了,那么我们顺着向下: ![](https://i.loli.net/2018/10/13/5bc1f15077095.png) 诶!发现$"s"$还有一个$"a"$字符的儿子,我们继续顺着向下: ![](https://i.loli.net/2018/10/13/5bc1f18b58c96.png) 但是此时发现,$"a"$并没有一个是$"i"$字符的儿子节点,那么我们此时新建一个节点$"i"$,并顺着$"i"$向下: ![](https://i.loli.net/2018/10/13/5bc1f227073d5.png) 由于$"i"$节点之前是被新建出来的,那么它显然是没有儿子节点的,这时我们就需要继续新建一个$"d"$儿子节点: ![](https://i.loli.net/2018/10/13/5bc1f2a92b53e.png) 我们继续沿着$"d"$儿子走: ![](https://i.loli.net/2018/10/13/5bc1f2c71e729.png) 此时发现,我们要插入的字符串$"said"$已经插入完了,那么我们就不需要再向下走了 这时,标记$"d"$字符节点为单词结尾点 ![](https://i.loli.net/2018/10/13/5bc1f3470ecb7.png) 其实这张图里的另一个$"s"$、$"n"$、$"y"$节点都是单词结尾节点,都应该有标记的= = 于是乎这样这个插入的例子就讲完了 如果你要建立整棵树,那么重复上面的过程即可 ## $Trie$的查询 类似于上面的方法,我们从根开始按照单词的字母顺序向下遍历$Trie$,一旦发现某个节点标记不存在或者单词遍历完成而最后的节点未被标记,则表示该单词不存在,反之若最后的节点被标记,表示该单词存在 这个。。。就不用我举例了吧,结合上面的过程感性理解一下就应该能懂啦! ## $Trie$基本操作代码讲解 除了插入和查询的操作,$Trie$应该不会有什么别的操作了。。。 ~~(应该不会有哪个丧心病狂的出题人让你在$Trie$里删除一个单词吧)~~ $Trie$的代码嘛,大概讲一下(以小写字母字典树为例) 给出一道自己造的**模板题**: 给出$n$个小写字母组成的单词,再给出$Q$个询问,每次询问这$n$个单词中是否有该次询问的单词(也是小写字母组成)。如果存在请输出出现的个数,没有出现过输出$-1 我们来定义一下字典树的节点: ```cpp const int sz=26; const int maxn=1e5+3; int tot,tr[sz+3][sz+3],tag[maxn]; ``` 其中,我们设$tr[i][j]=k$,即表示字符$"i"$向第$k$个节点有一条边,权值为字符$"j"$;$tag[i]$表示第$i$个节点是否是单词的结尾,如果是那么就累加次数,否则为$0

根据上面的说明,我们能写出如下的Trie插入代码:

inline void Insert(char *s)//插入单词s
{
    int x=0,len=strlen(s);
    for(int i=0;i<len;i++)
        {
            int y=s[i]-'a';
            if(!tr[x][y])tr[x][y]=++tot;//没有该节点就新建一个
            x=tr[x][y];
        }
    tag[x]++;//最后结尾节点标记一下
}

注意:最开始我们是从根节点0开始遍历的,同时注意最后要标记单词结尾的节点

既然有了插入操作,那么查询操作的代码也很容易得到了:

inline int Search(char *s)//查询单词s
{
    int x=0,len=strlen(s);
    for(int i=0;i<len;i++)
        {
            int y=s[i]-'a';
            if(!tr[x][y])return 0;//没有中间节点显然无解
            x=tr[x][y];
        }
    return tag[x];//返回出现次数即可
}

是不是很类似于插入操作的代码呀

那么到这里,Trie的插入和查询操作就算是讲完了

下面放一下这个例题的完整代码:

#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
inline int read()
{
    int p=0,f=1;char c=getchar();
    while(c<'0'||c>'9'){if(c=='-')f=-1;c=getchar();}
    while(c>='0'&&c<='9'){p=p*10+c-'0';c=getchar();}
    return f*p;}
const int sz=26;
const int maxn=1e5+3;
int tot,tr[sz+3][sz+3],tag[maxn];
inline void Insert(char *s)
{
    int x=0,len=strlen(s);
    for(int i=0;i<len;i++)
        {
            int y=s[i]-'a';
            if(!tr[x][y])tr[x][y]=++tot;
            x=tr[x][y];
        }
    tag[x]++;
}
inline int Search(char *s)
{
    int x=0,len=strlen(s);
    for(int i=0;i<len;i++)
        {
            int y=s[i]-'a';
            if(!tr[x][y])return 0;
            x=tr[x][y];
        }
    return tag[x];
}
int n,Q;
int main()
{
    n=read();
    for(int i=1;i<=n;i++)
        {
            char s[maxn];
            scanf("%s",s);
            Insert(s);
        }
    Q=read();
    while(Q--)
        {
            char s[maxn];
            scanf("%s",s);
            int sum=Search(s);
            printf("%d\n",sum?sum:-1);
        }
    return 0;
}

对了,如果说是要求某个单词作为所有单词的前缀的出现次数,怎么办呢?

查询仍然不变,我们只需要改动插入的一处即可:

inline void Insert(char *s)//插入单词s
{
    int x=0,len=strlen(s);
    for(int i=0;i<len;i++)
        {
            int y=s[i]-'a';
            if(!tr[x][y])tr[x][y]=++tot;//没有该节点就新建一个
            x=tr[x][y];
            tag[x]++;//只要是前缀,都标记一下
        }
}

这样Trie就支持查询前缀出现次数啦!

讲完了字典树,发现它其实也不是很难

我们可以说,Trie是一种利用公共前缀来达到快速匹配的树形算法

线性的KMP也是一样,也利用了前缀

可能。。。这就是算法之美吧 $TSOI$ $sjt$ 写于$2018$年$10$月$14$日