Trie 字典树
【Trie 树】
我们试图给每一个字符串一个对应的值。但是由于字符串长度不定,也不是数字,所以不能用数组。
有几种办法:
-
STL map,二叉排序树(平衡树),\(O(\log (n)\cdot len)\)。\(len\) 为字符串长度。
-
Trie,插入删除查询都是 \(O(len)\) 的。但是对空间有要求,每一个位置上可能的字符不能太多。
-
哈希。
我们现在讲讲 Trie。
Trie 是一个树形结构。一开始我们有一个根结点,这个根结点不代表任何字符。在插入字符串的过程中,我们维护这棵树。
每插入一个字符串,我们从根结点出发,如果当前结点有一个对应当前字符的儿子,就去往这个儿子并处理下一个字符;如果没有,就新建一个对应字符的儿子,再去往这个儿子。
等处理完所有字符,把所在结点标记为 \(end\)。这个操作是为了避免一个字符串是另一个的前缀。
Trie 的时间复杂度很低 \(O(len)\)。空间比较节省,因为对于字符串的相同前缀,我们只保留一份。
但是为了保证 Trie 的时间复杂度是稳定在 \(O(len)\),我们要保证从父节点去子节点是 \(O(1)\) 的。
于是我们一般在每个结点上都设立一个儿子数组。如果没有对应字符的儿子,可设为一个特殊数,如 \(0\);如果有对应字符的儿子,设为这个儿子的编号。
这样父节点可以直接调用数组查询儿子编号。但是这样也导致了我们空间开销大,即使没有对应字符的儿子,也会开一个数组空间。
如果每个位置上的字符选择太多,空间就会炸掉。
我们可以把 Trie 的每个结点写成结构体,再把 Trie 再写成结构体。用 vector 存结点们。这样可以避免空间浪费太多,虽然常数大一点。(也可以开个全局的 Trie,用一次清一次,常数小点)
我们可以在每个结点上记录一个 pfx:表示这个结点子树内所有结点 \(end\) 标记的总和,实际上也表示这个结点作为前缀出现了多少次。
函数传参用引用传参可以节省时间。
Trie 的优势是快,可以做一部分的字符串,逐步处理,map 和哈希只能处理完整的字符串;缺点是空间大。
【题目们】
互为前缀:走过的路径上 \(end\) 的个数 + 子树中 \(end\) 的个数 - 1(因为当前结点算了两次)
前缀、末尾信息记录
A 是 B 的前缀,B 是 C 的前缀。那在查找到 C 时一定已经走过了 A 和 B。
在 Trie 上搜索,到达叶子结点时比大小。
Trie 上搜索
dp 很好想:\(dp[i]\) 表示前 \(i\) 个字符分成若干个单词的方案数。
\(dp[i]=\displaystyle \sum dp[j],str[j+1,j+2,...,i]\) 是一个单词。
注意题目有条件单词长度不超过 \(100\),所以枚举的 \(j\geq i-99\)。
问题是如何快速判断 \(str[j+1\sim i]\) 是否是单词,这就可以用 Trie。
Trie 优化 dp
给每个人开一个 Trie。
把所有电话号翻转,再存进对应的 Trie 里。
如果一个结点的 \(pfx=cnt\),说明到这里的字符串不是任何其他字符串的后缀。(注意不能判断 \(pfx=1\),因为同一字符串可能存多次)
翻转 => 后缀,\(pfx=cnt\) 判断是否为其他的前/后缀
把 \(n\) 个字符串建成 Trie。
Trie 上搜索,\(srh(x,eq)\) 表示当前在 \(x\) 号结点,之前有 (eq=true)/没有 (eq=false) 和目前询问字符串不同的位置。
如果 \(eq=true\),那之后每一个位置都必须和询问字符串相同;如果 \(eq=false\),枚举当前结点下方所有子节点进入,如果这个子结点和询问字符串对应位置不相同,传参 \(eq=true\)。
问题来了:这个算法正确,但是复杂度看起来很高。每次查找一个单词最坏可能搜索整颗 Trie。
整条搜索路径看起来像是:一条主路(和原单词相同的路径)分出来很多条分路(一个位置不同,之后全相同)。
我们换个角度计算复杂度:考虑一个结点会贡献多少复杂度。
如果一个结点下方出去 \(x\) 条边,每一条边被作为分路贡献复杂度肯定只有在其他边作为主路的情况下,所以总复杂度应该是 \(O(\)结点数\()\)。
【Trie 和二进制编码】
我们可以把数化为二进制编码,把二进制编码视作字符串,建成一颗 Trie。这个 Trie 称为 0-1 Trie。当然,最好把所有二进制编码都做成相等长度。
一般在按位的操作下,才会用 01 Trie。
我们对所有可乐编号建立 01 Trie,在 Trie 上走,得出每个可能的 \(x\) 的可以喝到的可乐数量,逐位研究每一个二进制位。
假设 \(k=6\),我们研究一下代表 \(2^3\) 的结点选不选,选就是进入左子树,不选就是右子树。
如果选了 \(2^3\),那 \(x\) 和所有不选 \(2^3\) 的情况异或,至少是 \(2^3>k=6\),所以选不选 \(2^3\) 的情况其实是相互独立的。
我们递归地进入选/不选 \(2^3\) 的情况,这是一个同类型但规模更小的子问题。注意这里左右子树相互独立的原因是 \(2^3\) 大于 \(k\)。
然后就到了选不选 \(2^2\) 的情况。这又是不同的情况,因为 \(2^2\leq k\)。
如果我们不选 \(2^2\),也就是进入右子树。那么对于所有在右子树结尾的数,因为 \(2^0+2^1<2^2\leq k\),所以 $x\bigoplus $ 任意一个在右子树结尾的数的结果 \(res\leq 2^0+2^1<2^2\leq k\).
故所有在右子树结尾的数可乐都可以喝到,直接调用右子结点的 \(pfx\) 即可。
而左子树,因为我们不选 \(2^2\),与选 \(2^2\) 的异或一下产生 \(2^2\) 的异或,那么 \(x\) 与左子树 \(2^0,2^1\) 的异或值加起来不能超过 \(k-2^2\),否则 \(2^2+(k-2^2+1)>k\) 异或爆掉了。
这是一个同类型但更简单的子问题:\(x\bigoplus a\leq k-2^2\).
递归终点:如果当前结点为 \(0\),说明我们走了一个不存在的路,返回 \(0\);如果 \(k\) 减成负数,也返回 \(0\);如果当前子树怎么选都能符合,返回子树根结点 \(pfx\)。
综上,在每一个结点选择向左和向右的情况都讨论了,只要把两种情况递归求出再取 \(\max\) 即可。
↑ 运用性质:如果能不在当前结点产生贡献,就一定不要。因为二进制导致了之后所有可能贡献加起来都没有这个结点大。
按位处理,递归
相当经典
part 0
倍增 LCA 预处理出每个点的 LCA 和倍增的异或值。
然后 \(O(n^2)\) 枚举两个点。
part 1
注意到两个相等的数异或起来为 \(0\),\(0\) 异或任何数得任何数。
所以两个结点的路径边权异或,等于这两个点到根结点的路径边权异或 再异或。从 LCA 到根结点的部分异或两次消去了。
求出每个点到根结点的路径异或值。
有一个很基本的想法:\(O(n^2)\) 枚举两个结点,直接用两个结点到根的路径异或值异或。
这省掉了 LCA 的复杂度。
part 2
我们无法接受 \(O(n^2)\)。
考虑只枚举一个结点,如何快速求出谁和它异或最大?
我们把所有结点到根结点的路径异或值化为二进制,建成 01Trie。
当我们枚举到结点 \(x\) 时,\(x\) 到根结点的路径异或值为 \(a[x]\),我们从高位到低位枚举 \(a[x]\) 每一位。
如果当前位上是 \(bin\) 且 Trie 上的当前结点有 \(1-bin\) 的儿子,就必须去这个儿子。因为二进制有性质:之后所有数加起来都比不过这个儿子异或产生的贡献。
复杂度优化到 \(O(nh)\),\(h\) 是 Trie 的高度。
↑ 运用性质:1. 异或消去律; 2. 如果能不在当前结点产生贡献,就一定要。因为二进制导致了之后所有可能贡献加起来都没有这个结点大。
Trie 快速求一个数 \(x\) 与一堆数的最大异或。
按位处理,贪心
维护一个数据结构,支持:
-
插入数;
-
删除数;
-
给定一个数,查询这个数的最大异或。
插入和查询都在之前讲过,而删除实际上可以用类似插入的手法,把要删除的数在 Trie 上走一遍,所过之处 \(pfx\) 都减一,末尾处 \(cnt\) 减一。
搜索时进入新结点,先判断它的 \(pfx>0\)。
选出异或和最大的 \(k\) 个区间,求它们的异或和之和。
首先根据消去律,可以用俩前缀异或。
把前缀们建成 01 Trie 树。
发现 01 Trie 上都是左边大,右边小,所以可以用类似二叉排序树二分 \(pfx\) 的算法求出异或起来第 \(k\) 大的数。
经典想法:如何取出很多很多数中的前 \(k\) 个最值?
记 \((S_i,x)\) 为与 \(S_i\) 异或第 \(x\) 大的数。一开始把 \((S_i,1)\) 全部丢进优先队列里,每次取出最大的数 \((S_a,b)\),累计求和,然后把 \((S_a,b+1)\) 加入优先队列。
二分求出异或第 \(k\) 大,把很多数分类、把每一类的最值丢进一个优先队列、选了一个把这一类的下一个加入优先队列。
-
插入新数;
-
选一个数 \(L-1\leq p-1\leq r-1\),使 \(S_{p-1}\) 和 \(S_n\bigoplus x\) 的异或最大。
难点在于如何保证 \(L-1\leq p-1\leq r-1\)。
朴素算法
每个叶子结点对应一个数,每个数有一个编号。
在每个结点上记录一个 \(set\),存这个结点的子树内所有叶子结点对应的数的编号。
假设当我们在一个结点,\(S_n\bigoplus x\) 的位上是 \(0\),这意味着我们如果去 \(1\) 的方向存在一个数,编号在给定区间内,我们就一定要去 \(1\) 的方向。
这可以用 \(set\) 的 lower_bound
求出:如果 \(lower\_bound(L)\leq R\),说明有一个编号在 \([L,R]\) 内。
优化:离线算法
调整问题顺序,当我们加入一个数,\(n\) 变成 \(r\) 的时候,我们处理所有给定区间右端点为 \(r\) 的询问。
这样我们在结点上就不用记录 \(set\) 了,而只需要记录叶子结点编号最大值。只要最大值 \(\geq\) 左端点,就存在一个数的编号在给定区间内。(因为此时 \(r\) 就是总数,编号一定 \(\leq\) 总数)
【练习】
把所有数都插入,然后搜索,如果一条路径上有至少两个 \(end\) 就说明有前缀。
字符串版 LIS。
首先规则一肯定不会使用。
规则二可以视作规则三的 \(y=0\) 情况。
那我们就只看规则三好了。
part 1
后缀不好处理,字符串翻转变前缀,存 Trie 树里面。
我们在插入的时候额外在字符串结尾的结点记录一下这是几号单词的结尾。
part 2
然后我们可以用一次搜索得知谁是谁的后缀,并且建出一张图:\(x\rightarrow y\) 表示 \(x\) 是 \(y\) 的后缀。
我们断言:把每个点的子结点列表按照子结点子树大小从小到大排序,然后从根结点开始搜索,得到的 dfs 序就是使代价最小的排列顺序。
part 3
证明:
0)一个显然的结论:当一个结点(一个单词)被选之前,它的父结点(后缀)一定已经被选了。 (重点!思路也由此切入)
1) 先证明一定是按照 dfs 序。
如果不是按照 dfs 序来,考虑一个结点下属有若干子树,其中一颗子树有叶子结点 \(i\),另一颗子树有根结点是 \(j\),原本应该 \(i\) 比 \(j\) 更早遍历,但是此时我们把 \(i\) 放在 \(j\) 后面。
(这里能保证 \(i\) 是叶子结点的原因是上面证明的引理 0)
考虑此时总花费的变化,把 \(i\) 放到 \(j\) 后,会让 \(i\) 和 \(i\) 的父亲,\(j\) 和 \(j\) 的子孙的距离增加,花费至少加 \(2\),而节省的费用是 \(j\) 与 \(j\) 的父亲的距离,由于 \(i\) 是叶子,把 \(i\) 放在 \(j\) 前面的费用只会比 \(j\) 在 \(i\) 前面多 \(1\)。
花费增多。
2)再证明一定是按照子树大小从小到大最优。
因为考虑一个结点的所有儿子。第 \(i\) 个儿子产生的花费等于第 \(1\sim i-1\) 个儿子的子树规模之和。
所以肯定是规模越小越靠前。
字符串翻转,建 Trie 树,变成前缀相同的押韵。
我们观察一番,应该要找到一颗包含 \(cnt\) 最多的子树:
-
根结点之外的结点都是 \(end\);
-
所有结点的儿子至多只能有一个不是 \(end\)。
树形 dp。
其实就是跑一次 Trie 上搜索,但还有一点注意:
因为打印完最后可以留东西,所以最长的单词应该最后遍历,然后不用删除。
那我们可以在建完 Trie 后再取出最长的单词,用类似插入的方法给这个单词的每个结点打标记。搜索时优先去没标记的结点。
枚举每个字符串为最小的,那么说明第 \(i\) 层 Trie 中这个字符串的字符是最小的。这这个字符串的第 \(i\) 个字符为 \(c\),第 \(i\) 层的其他字符为 \(ch1,ch2,\dots\)。连单向边 \(c\rightarrow \overline{ch(1,2,\dots)}\) 表示规定了 \(c\) 比 \(ch1,\dots\) 小。
最后在建出来的图上跑拓扑排序,看看是否有环。有环就不行。
当然如果有其他字符串是前缀也不行。
一道水题 只要学过 Trie 就能写出来
因为单词的长度很短,所以我们可以直接枚举经过一次替换、删除、添加后所有可能的字符串,然后直接在 Trie 里查找是否存在。
注意因为可能有重复,所以每次查询再开一个 Trie 存下来所有被查询过的字符串。
\(O(n^2)\) 枚举 \(b\) 中留下哪一段,同时可以对 \(a\) 的每个位置记录一个 \(nxt[pos][1\sim 26]\) 表示下一个字符的位置。
但是我们需要判重,如果用 map,自带 \(1e9\) 的常数,所以我们要用 Hash 或者 Trie 判重。