Trie 字典树 详解

😊 | Powered By HeartFireY | Tire Algorithm

一、字典树

1.字典树简介

字典树,英文名Trie,如其名:就是一棵像字典一样的树。

我们首先通过一张图来理解字典树的结构:

在这里插入图片描述

我们假定结点的顺序按照图中给定的顺序进行编号,容易发现,在一个给定的树上,从每个根节点出发到达子节点的路径边代表一个字母。实际上,每个节点出发的几条边所代表的字母是从左到右的顺序按照字典序排列的。

那么我们可以知道:从根节点出发,到达某个指定的结点的路径可以构成一个字符串。

一个例子: 1 → 4 → 8 → 13 ⟹ c a a 1\to4\to 8\to 13 \Longrightarrow caa 14813caa

在这里插入图片描述

在实际应用中,我们在建立Trie字符集之前,应当首先确定边所映射的字符集,然后可以根据边的编号一一对应映射的字符构成目的字符串。

Trie 的结构非常好懂,我们用 δ ( u , c ) \delta(u,c) δ(u,c) 表示结点 u u u c c c 字符指向的下一个结点,或着说是结点 u u u 代表的字符串后面添加一个字符 c c c 形成的字符串的结点。( c c c 的取值范围和字符集大小有关,不一定是 0 ∼ 26 0\sim 26 026。)

有时需要标记插入进 Trie 的是哪些字符串,每次插入完成时在这个字符串所代表的节点处打上标记即可。

2.字典树的特性以及优缺点

1.优缺点

优点:我们不难发现字典树中实际上记录了多个字符串的公共前缀,因此用于查询公共前缀时是十分高效的,他减少了无意义的字符串匹配,其查询效率要优于哈希树。

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

我们不难发现,字典树的核心思想是用空间来换时间(利用公共前缀来降低查询时间的开销以达到提高效率的目的)。

2.特性
  1. 根节点不包含字符,除根结点之外每一个结点都只包含一个字符;
  2. 字典树用边表示字母表示
  3. 从根节点到某一结点, 路径上经过的字符连接起来,为该结点对应的字符串
  4. 每个节点的所有子结点包含的字符都不同。每个结点最多有26个子节点(假设给定字符集中包含26个英文字母)
  5. 有相同前缀的单词共用前缀节点
  6. 整棵树的根节点是空的,便于插入和查找
  7. 每个单词结束的时候用一个特殊字符表示,那么从根节点到任意一个特殊字符,所经过的边的所有字母表示一个单词

3.字典树的应用:

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

二、字典树操作及模板

1.字典树相关操作及实现

1).插入操作

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

在插入的过程中我们需要给每个字符进行编号。

我们设 t r e e [ i ] [ j ] = k tree[i][j] = k tree[i][j]=k,表示编号为 i i i的结点的第 j j j个孩子是编号为 k k k的结点。

这里需要特别注意结点的编号问题!

我们通常将编号 ( i ,   j ) (i,\ j) (i, j)分为两类:

实际编号 ( i ,   k ) (i,\ k) (i, k)即为我们在实际储存树结构时的编号,是相对于整棵树而言的;

关系编号:表示当前结点是结点 i i i的第 j j j个孩子,是相对于单个结点而言的。

因此我们可以知道:对于给定的 t r e e [ i ] [ j ] = k tree[i][j] = k tree[i][j]=k,表示那么实际编号为 ( i ,   k ) (i,\ k) (i, k),关系编号为 j j j

那么插入操作就应该分为以下的步骤:

  • 首先我们需要记录根结点的编号,初始化为0;
  • 从左到右扫描这个单词,每次计算关系编号 i d id id(在字符集{a~z}中,关系编号为’当前字符’ - ‘a’);
  • 如果之前没有从 r o o t root root i d id id的前缀,则插入该编号(需要预先在声明计数变量,对一种编号进行记录);
  • 执行 r o o t = t r e e [ r o o t ] [ i d ] root = tree[root][id] root=tree[root][id],表示顺着字典树继续向下走。

在这里插入图片描述

2).查找单词

我们可以使用一个数组 v i s [ s t r . l e n ( ) ] vis[str.len()] vis[str.len()]记录到字符第 i i i个字符为止的串是否存在。 v i s [ i ] vis[i] vis[i]表示节点 i i i 是否是单词结束的标志。

搜索完成后直接返回 v i s [ r o o t ] vis[root] vis[root]的值即可。

3).前缀出现次数的记录

在需要统计前缀个数的题目中,我们额外引入一个 s u m [ ] sum[] sum[]数组,表示位置 i 被访问过的次数

那么最后返回 s u m [ r o o t ] sum[root] sum[root]即可,插入操作中每访问一个节点,都要让其对应的 s u m [ i ] + + sum[i]++ sum[i]++

但值得注意的是:此处前缀次数标记在前缀的最后一个字母对应位置的后一个位置上

在这里插入图片描述

例如上图中的字符串前缀"abc",那么对应储存的 s u m sum sum值如图所示。

2.字典树模板

这里放一个用结构体封装的模板:

在使用数组进行储存的时候,由于数组的空间是预先分配好的,因此不需要单独进行释放。

struct trie{
    int tree[100000][26], cnt;
    bool exist[100000];			//以该结点结尾的字符串是否存在

    void insert(char *s, int l){
        int root = 0;
        for(int i = 0; i < l; i++){
            int id = s[i] - 'a';
            if(!tree[root][id]) tree[root][id] = ++cnt;	//如果之前没有从root到id的前缀,那么就插入
            root = tree[root][id];
        }
        exist[root] = 1;
        //sum[root]++; //前缀数记录
    }

    bool find(char *s, int l){
        int root = 0;
        for(int i = 0; i < l; i++){
            int id = s[i] - 'a';
            if(!tree[root][id]) return 0;
            root = tree[root][id];
        }
        return exist[root];
    }
};

下面是一个采用动态内存申请的Trie模板,在这种方式下,空间的利用效率相对更高一些。

struct trie{
    //代表当前节点可以延伸出来的边,采用动态内存申请,提升空间利用率
    trie *Next[maxn];
    //标记当前节点是否保存信息的结尾,也可以代表前缀的个数
    int flag;
    trie(){
        flag = 1; //初始化以该信息为前缀的信息个数
        memset(Next, NULL, sizeof(Next));
    }
} * root;

//1、插入操作
void Insert(string s){
    int len = s.size();
    trie *p = root, *q;
    //将s的每一个字符插入到trie树
    for (int i = 0; i < len; i++){
        int id = s[i] - 'a';
        //如果没有边,则新建一个trie节点,产生一个新的边代表该字符
        if (p->Next[id] == NULL){
            q = new trie();
            p -> Next[id] = q;
            p = p -> Next[id];
        }
        //如果存在则继续往下走
        else{
            p = p->Next[id];
            (p -> flag)++;
        }
    }
}

//2、查询操作
int Query(string s){
    int len = s.size();
    trie *p = root;
    //在trie树上顺序搜索s的每一个字符
    for (int i = 0; i < len; i++){
        int id = s[i] - 'a';
        p = p->Next[id];
        //如果是空集,表示不存在以此为前缀的信息
        if (p == NULL) return 0;
    }
    //返回以该信息为前缀的信息的个数
    return p->flag;
}

//3、删除操作
void Free(trie *t){
    if (t == NULL) return;
    //逐个释放结点所占用的内存
    for (int i = 0; i < maxn; i++)
        if (t -> Next[i]) free(t -> Next[i]);
    delete (t);
}

注:以上模板参考自博客_链接

三、字典树的高级应用

1.字典树::排序算法

在许多场景下,我们需要对多个字符串进行排序;我们都知道在传统排序算法下时的时间复杂度为 O ( n log ⁡ n ) O(n\log{n}) O(nlogn),而如果我们通过字典树对给定的串进行排序,则可以将时间复杂度降至 O ( n ) O(n) O(n)

那么如何利用字典树对多个字符串进行排序呢?

首先,我们根据多个字符串建立Trie树,之后,通过先序遍历此树,即可得到字符串从小到大的顺序。

如何证明这个过程?我们都知道在字典树中,我们对于每个节点的出边是按照字典序进行编号,因此在执行先序遍历的时候会首先访问字典序较小的结点,容易得知经过先序遍历可得到有序的字符串组合。

这里给出使用指针建树的先序遍历方式:

void trie_sort(trie *root){
    if(!root) return;
    if(root -> flag){ cout << root -> s << endl; return; }
    for(int i = 0; i < 26; i++) trie_sort(root -> tree[i]);
}
//注:我们假定每个尾结点中储存以该结点结尾的字符串s

2.字典树::维护异或极值

⚠ 此部分内容为扩展内容,难度相对较高,请确保掌握字典树后再深入学习

将数的二进制表示看作一个字符串,就可以建立字符集为 { 0 , 1 } \{0, 1\} {0,1}的Trie树。

首先我们假设场景:给定一颗带边权的树,求 ( u , v ) (u,v) (u,v)使得 u u u v v v路径上的边权异或和最大,输出这个最大值。

数据范围:点数不超过 1 0 5 10^5 105,边权在 [ 0 , 2 31 ) [0, 2^{31}) [0,231)内。

如何使用Trie解决这个问题?

我们随便指定一个根 r o o t root root,用 T ( u , v ) T(u, v) T(u,v) 表示 u u u v v v 之间的路径的边权异或和,那么 T ( u , v ) = T ( r o o t , u ) ⊕ T ( r o o t , v ) T(u,v)=T(root, u)\oplus T(root,v) T(u,v)=T(root,u)T(root,v),因为 L C A LCA LCA以上的部分异或两次抵消了。

那么,如果将所有 T ( r o o t , u ) T(root, u) T(root,u) 插入到一棵 Trie中,就可以对每个 T ( r o o t , u ) T(root, u) T(root,u) 快速求出和它异或和最大的 T ( r o o t , v ) T(root, v) T(root,v)
从 trie 的根开始,如果能向和 T ( r o o t , u ) T(root, u) T(root,u) 的当前位不同的子树走,就向那边走,否则没有选择。

贪心的正确性:如果这么走,这一位为 1 1 1;如果不这么走,这一位就会为 0 0 0。而高位是需要优先尽量大的。

#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int head[N], nxt[N << 1], to[N << 1], weight[N << 1], cnt;
int n, dis[N], ch[N << 5][2], tot = 1, ans = 0;


void insert(int x){
    for(int i = 30, u = 1; i >= 0; i--){
        int c = ((x >> i) & 1);
        if(!ch[u][c]) ch[u][c] = ++tot;
        u = ch[u][c];
    }
}

void get(int x){
    int res = 0;
    for(int i = 30, u = 1; i >= 0; i--){
        int c = ((x >> i) & 1);
        if(ch[u][c ^ 1]){
            u = ch[u][c ^ 1];
            res |= (1 << i);
        }
        else u = ch[u][c];
    }
    ans = max(ans, res);
}

void add(int u, int v, int w){
    nxt[++cnt] = head[u];
    head[u] = cnt;
    to[cnt] = v; 
    weight[cnt] = w;
}

void dfs(int u, int fa){
    insert(dis[u]);
    get(dis[u]);
    for(int i = head[u]; i; i = nxt[i]){
        int v = to[i];
        if(v == fa) continue;
        dis[v] = dis[u] ^ weight[i];
        dfs(u, v);
    }
}

signed main(){
    ios_base::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    cin >> n;
    for(int i = 1; i < n; i++){
        int u, v, w;
        cin >> u >> v >> w;
        add(u, v, w);
        add(v, u, w);
    }
    dfs(1, 0);
    cout << ans << endl;
    return 0;
}

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

⚠ 此部分内容为扩展内容,难度相对较高,请确保掌握字典树后再深入学习

相似的,我们可以通过字典树来维护异或和。通常把这种维护异或和的字典树称为0-1 Trie。0-1 Trie可以用来维护一些数字的异或和,同时支持修改(删除+重新插入),和全局+1操作。

如果需要维护异或和,我们需要按值从低位到高位建立Trie。

1).插入 & 删除

如果要维护异或和,我们 只需要 知道某一位上 01 个数的 奇偶性 即可,也就是对于数字 1 来说,当且仅当这一位上数字 1 的个数为奇数时,这一位上的数字才是 1,请时刻记住这段文字:如果只是维护异或和,我们只需要知道某一位上 1 的数量即可,而不需要知道 Trie 到底维护了哪些数字。

对于每一个节点,我们需要记录以下三个量:

  • ch[o][0/1] 指节点 o 的两个儿子,ch[o][0] 指下一位是 0,同理 ch[o][1] 指下一位是 1
  • w[o] 指节点 o 到其父亲节点这条边上数值的数量(权值)。每插入一个数字 xx 二进制拆分后在 trie 上 路径的权值都会 +1
  • xorv[o] 指以 o 为根的子树维护的异或和。

具体维护结点的代码如下所示。

void maintain(int o){
    w[o] = xorv[o] = 0;
    if (ch[o][0]){
        w[o] += w[ch[o][0]];
        xorv[o] ^= xorv[ch[o][0]] << 1;
    }
    if (ch[o][1]){
        w[o] += w[ch[o][1]];
        xorv[o] ^= (xorv[ch[o][1]] << 1) | (w[ch[o][1]] & 1);
    }
    // w[o] = w[o] & 1;
    // 只需知道奇偶性即可,不需要具体的值。当然这句话删掉也可以,因为上文就只利用了他的奇偶性。
}

插入和删除的代码非常相似。

需要注意的地方就是:

  • 这里的 MAXH 指 trie 的深度,也就是强制让每一个叶子节点到根的距离为 MAXH。对于一些比较小的值,可能有时候不需要建立这么深(例如:如果插入数字 4,分解成二进制后为 100,从根开始插入 001 这三位即可),但是我们强制插入 MAXH 位。这样做的目的是为了便于全局 +1 时处理进位。例如:如果原数字是 311),递增之后变成 4100),如果当初插入 3 时只插入了 2 位,那这里的进位就没了。

  • 插入和删除,只需要修改叶子节点的 w[] 即可,在回溯的过程中一路维护即可。

namespace trie{
    const int MAXH = 21;
    int ch[_ * (MAXH + 1)][2], w[_ * (MAXH + 1)], xorv[_ * (MAXH + 1)];
    int tot = 0;
    int mknode(){
        ++tot;
        ch[tot][1] = ch[tot][0] = w[tot] = xorv[tot] = 0;
        return tot;
    }
    void maintain(int o){
        w[o] = xorv[o] = 0;
        if (ch[o][0]){
            w[o] += w[ch[o][0]];
            xorv[o] ^= xorv[ch[o][0]] << 1;
        }
        if (ch[o][1]){
            w[o] += w[ch[o][1]];
            xorv[o] ^= (xorv[ch[o][1]] << 1) | (w[ch[o][1]] & 1);
        }
        w[o] = w[o] & 1;
    }
    void insert(int &o, int x, int dp){
        if (!o) o = mknode();
        if (dp > MAXH) return (void)(w[o]++);
        insert(ch[o][x & 1], x >> 1, dp + 1);
        maintain(o);
    }
    void erase(int o, int x, int dp){
        if (dp > 20) return (void)(w[o]--);
        erase(ch[o][x & 1], x >> 1, dp + 1);
        maintain(o);
    }
} 
2).全局加一

所谓全局加一就是指,让这棵 trie 中所有的数值 +1

形式化的讲,设 trie 中维护的数值有 V 1 , V 2 , V 3 … V n V_1, V_2, V_3 \dots V_n V1,V2,V3Vn, 全局加一后 其中维护的值应该变成 V 1 + 1 , V 2 + 1 , V 3 + 1 … V n + 1 V_1+1, V_2+1, V_3+1 \dots V_n+1 V1+1,V2+1,V3+1Vn+1

void addall(int o) {
  	swap(ch[o][0], ch[o][1]);
  	if (ch[o][0]) addall(ch[o][0]);
  	maintain(o);
}

我们思考一下二进制意义下 +1 是如何操作的。

我们只需要从低位到高位开始找第一个出现的 0,把它变成 1,然后这个位置后面的 1 都变成 0 即可。

下面给出几个例子感受一下:(括号内的数字表示其对应的十进制数字)

1000(10)  + 1 = 1001(11)  ;
10011(19) + 1 = 10100(20) ;
11111(31) + 1 = 100000(32);
10101(21) + 1 = 10110(22) ;
100000000111111(16447) + 1 = 100000001000000(16448);

对应 trie 的操作,其实就是交换其左右儿子,顺着 交换后0 边往下递归操作即可。

回顾一下 w[o] 的定义:w[o] 指节点 o 到其父亲节点这条边上数值的数量(权值)。

有没有感觉这个定义有点怪呢?如果在父亲结点存储到两个儿子的这条边的边权也许会更接近于习惯。但是在这里,在交换左右儿子的时候,在儿子结点存储到父亲这条边的距离,显然更加方便。

3).01-trie 合并

指的是将上述的两个 01-trie 进行合并,同时合并维护的信息。

可能关于合并 trie 的文章比较少,其实合并 trie 和合并线段树的思路非常相似,可以搜索“合并线段树”来学习如何合并 trie。

其实合并 trie 非常简单,就是考虑一下我们有一个 int merge(int a, int b) 函数,这个函数传入两个 trie 树位于同一相对位置的结点编号,然后合并完成后返回合并完成的结点编号。

考虑怎么实现?
分三种情况:

  • 如果 a 没有这个位置上的结点,新合并的结点就是 b

  • 如果 b 没有这个位置上的结点,新合并的结点就是 a

  • 如果 a,b 都存在,那就把 b 的信息合并到 a 上,新合并的结点就是 a,然后递归操作处理 a 的左右儿子。

    提示:如果需要的合并是将 a,b 合并到一棵新树上,这里可以新建结点,然后合并到这个新结点上,这里的代码实现仅仅是将 b 的信息合并到 a 上。

int merge(int a, int b) {
  	if (!a) return b;  // 如果 a 没有这个位置上的结点,返回 b
  	if (!b) return a;  // 如果 b 没有这个位置上的结点,返回 a
  /*
    如果 `a`, `b` 都存在,
    那就把 `b` 的信息合并到 `a` 上。
  */
  	w[a] = w[a] + w[b];
  	xorv[a] ^= xorv[b];
  /* 不要使用 maintain(),
    maintain() 是合并a的两个儿子的信息
    而这里需要 a b 两个节点进行信息合并
   */
  	ch[a][0] = merge(ch[a][0], ch[b][0]);
  	ch[a][1] = merge(ch[a][1], ch[b][1]);
  	return a;
}

其实 trie 都可以合并,换句话说,trie 合并不仅仅限于 01-trie。

4.AC自动机

⚠ 此部分转移到单独的新博客

5.可持久化字典树

⚠ 此部分转移到单独的新博客
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

HeartFireY

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值