Trie
概述ψ(`∇´)ψ
又叫做字典树,思想是把字符串拆分成一个一个字符,然后将字符放到树的“边”上(字符指针),利用节点储存字符串结束等信息。
普通 Trie 一般用于检索字符串是否存在,或者作为 AC 自动机的一部分,用于多模式匹配。
变种的 01Trie 可以实现诸如查询和 \(x\) 异或值最大的数,维护异或和等位运算相关操作,尤其是异或。
结构一般长这样:
Trieψ(`∇´)ψ
Trie 支持两种操作,插入和查询。
首先明确 Trie 的结构到底是怎么样的,Trie 将字符储存于树的边上,并在节点上有一个 \(end\) 标记,表示当前节点是否是一个字符串的结尾。
实现时利用一个数组 \(tr[x,ch]\),表示节点 \(x\) 的 \(ch\) 字符指针指向的节点编号。
类比动态开点线段树的思想,Trie 只会在一个节点被建立时给予一个编号。
初始的时候 Trie 只有一个节点 \(root\),并且所有的 \(tr\) 都指向 \(\text{NULL}\) (空)。
插入字符串ψ(`∇´)ψ
将一个新的字符串 \(s\) 插入到 Trie 当中。
初始时令一个指针 \(p = root\),然后从最低位开始扫描字符串 \(s\)。
如果当前扫描到的字符为 \(s_i\),令 \(p = tr[p,s_i]\)。
如果 \(tr[p,s_i]\) 不存在,新建一个节点 \(q\),令 \(tr[p, s_i] = q\),然后把 \(p\) 跳到 \(q\)。
反之直接把 \(p\) 跳过去即可。
然后令 \(i = i + 1\),扫描下一个字符。
重复这个过程,直到 \(s\) 被扫描完毕,在当前的节点 \(p\) 上打上一个标记 \(end[p] = \text{true}\)。
放一张早年做的比较糙的 GIF:
询问ψ(`∇´)ψ
查询一个字符串 \(s\) 是否存在于 Trie 当中。
类似 Insert
操作,设一个指针 \(p = root\),然后扫描 \(s_i\)。
不断跳 \(p = tr[p, s_i]\),如果还没有扫完 \(s\),就已经出现了 \(tr[p,s_i]\) 指向 \(\text{NULL}\) 的情况,则返回 \(\text{false}\)。
如果已经扫描完了 \(s\),且 \(end[p] = \text{true}\),返回 \(\text{true}\)。
反之返回 \(\text{false}\)。
代码实现ψ(`∇´)ψ
普通 Trie 树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
一些性质ψ(`∇´)ψ
- 两个字符串的最长公共前缀是他们的尾标记所在的两个节点的 lca 到根的路径组成的字符串。
我认为 LCP 也可以这么暴力求。
-
可以把尾标记改成
int
,可以用来统计词频。 -
也可以方便的统计某个前缀在所有串当中出现的次数(结合 2)。
-
是 AC 自动机的一部分。
01Trieψ(`∇´)ψ
类似于普通的 Trie,只不过将字符集变为了 \(\{0,1\}\) 以维护一些二进制相关的信息。
求与 x 异或的最值ψ(`∇´)ψ
询问最值ψ(`∇´)ψ
将所有数二进制拆分,包括 \(x\)。
然后把这些二进制数从高位到低位插入进 Trie。
然后从高到低扫描 \(x\) 二进制下的每一位,同时维护一个指针 \(p\) 往下跳。
假设 \(x\) 当前的这一位为 \(k\),那么想要让异或和更大,就是让越高的位尽可能的为 \(1\)。
所以我们需要尽量往 \(k \operatorname{xor} 1\) (相反的)的指针走,如果当前节点没有 \(k \operatorname{xor} 1\) 这个指针,那么就只好走 \(k\) 这个指针。
跳到叶子节点之后,从 \(p\) 到 \(root\) 的路径组成的数就是所求的,和 \(x\) 异或起来的值最大的数,
在插入每个数之后,在对应结束的节点打一个标记,记录从这个节点到根的路径组成的数是多少即可。
异或起来最小同理。
代码实现ψ(`∇´)ψ
01Trie
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
维护异或和ψ(`∇´)ψ
01 Trie 可以用来维护一些数的异或和,支持插入,删除,还有全局加一操作。
我出的一道题里面有用到一个 Trick:异或和的二进制下某一位的值,取决于所有数的这一位的 \(1\) 的个数的奇偶性。
也就是,异或和 \(xorv\) 的第 \(k\) 位是 \(1\),当且仅当所有数当中有 奇数个 数的第 \(k\) 为是 \(1\)。
那么利用它来考虑维护全局异或和。
不过维护异或和的时候和一般的 01Trie 不一样,此时的 01Trie 应当是从低位到高位插入的,以方便全局加一操作的处理。
信息上传ψ(`∇´)ψ
用类似线段树的思想,我们自底向上收集信息。
设 \(xorv_i\) 表示节点 \(i\) 的子树所维护的异或和。
设 \(wei_i\) 指节点 \(i\) 到其父亲节点这条边上数值的数量(权值),也就是这条边 被所有插入的数 拆成二进制之后 在树上代表的路径 覆盖的次数。
不过我们实际上不需要知道具体维护了哪些数,维护了多少个,
也就是说,我们只需要知道 \(wei\) 的奇偶性就行了。
那么,对于一个非叶子节点 \(p\),可以有以下的过程:
首先,令 \(wei_p = wei_{tr[p][0]} + wei_{tr[p][1]}\)。
如果 \(p\) 的 \(0\) 指针指向的节点不为 \(\text{NULL}\),那么让 \(xorv_p = xorv_p \operatorname{xor} (xorv_{tr[p][0]}<<1)\),也就是先对齐每一位,然后异或起来。
如果 \(p\) 的 \(1\) 指针指向的节点不为 \(\text{NULL}\),那么让 \(xorv_p = xorv_p \operatorname{xor} ((xorv_{tr[p][1]} << 1) \operatorname{or} (wei_{tr[p][1]} \operatorname{and} 1))\)。
也就是先对齐每一位,然后看奇偶性。
插入 / 删除ψ(`∇´)ψ
插入直接递归,如果遇到空节点,那么新建即可。
为了之后全局加一进位方便,我们强制让每一个数都变成 MaxDepth
位,再插入进 Trie 当中。
MaxDepth
是你选择的 Trie 树的最大深度,一般比要插入的数的最高位数多出两三位。
当深度到达 MaxDepth
之后,令 \(wei_p + 1\),然后不断向上传递信息即可。
这样子就能保证,每插入一个数 \(x\),\(x\) 的二进制表示 在 Trie 树上代表的路径 上的所有节点的 \(wei\) 都会 \(+1\)。
删除操作也是类似的,只不过变成了让最后一位(最高位)所对应的节点的 \(wei -1\),再向上收集信息。
全局加一ψ(`∇´)ψ
思考一下,在二进制下的加一操作实质上是什么?
实际上就是:从最低位开始,找到第一个 \(0\),将其取反,然后将它后面(到最低位)的所有 \(1\) 变成 \(0\)。
对应到 01Trie 上,就是从 \(root\) 开始递归下去,找到第一个非空的 \(tr[p][0]\) 指针,然后交换 \(tr[p][0],tr[p][1]\)(取反)。
沿着交换之后的 \(tr[p][0]\) 递归下去操作即可。
代码实现ψ(`∇´)ψ
维护异或和
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
|
01Trie 合并ψ(`∇´)ψ
合并两颗 01Trie。
和线段树合并是比较类似的,用同样的思想思考即可。
就是把两个节点中的一个的信息合并到另外一个上,并返回合并之后的编号。
(当然,这里也可以根据情况把他们合并到一个新的节点上)。
如果有一个节点为空,那么就返回另外一个节点的编号即可。
Merge
1 2 3 4 5 6 7 8 9 10 |
|
参考资料:字典树 (Trie) - OI Wiki (oi-wiki.org)