Fail
我们把给定的若干模式串插入到字典树 T T T 中。
对于某个节点 u u u 代表的前缀 S S S ,类比 KMP 中的 next \text{next} next 数组,令 fail ( u ) \text{fail}(u) fail ( u ) 指向最长的、在 T T T 中的后缀 S ′ S' S ′ 对应的节点。
类比于 KMP 的思路,对 fail ( u ) \text{fail}(u) fail ( u ) 扣掉 u u u 后,一定也在 f a fa f a 的 fail \text{fail} fail 链上。由于深度为 1 1 1 的节点的 fail \text{fail} fail 一定是 0 0 0 ,那么进行一个 BFS:
1 2 3 4 5 6 7 8 9 10 push-depth1-nodes() while q.notempty(): u = q.pop() for ch = 'a' to 'z' : j = u while not tr[j][ch].exist(): j = fail[j] if tr[j][ch].exist(): j = tr[j][ch] fail[tr[u][ch]] = j
然而,通常情况下我们不会这么写。我们定义 u = trans ( f a , c h ) u=\text{trans}(fa,ch) u = trans ( f a , c h ) :
如果字典树 T T T 中存在 ( f a , c h ) (fa,ch) ( f a , c h ) 这条边,那么 trans ( f a , c h ) = T ( f a , c h ) \text{trans}(fa,ch)=T(fa,ch) trans ( f a , c h ) = T ( f a , c h ) ,同时令 fail ( u ) = trans ( fail ( f a ) , c h ) \text{fail}(u)=\text{trans}(\text{fail}(fa),ch) fail ( u ) = trans ( fail ( f a ) , c h ) ;
如果字典树 T T T 不存在 ( f a , c h ) (fa,ch) ( f a , c h ) 这条边,那么 trans ( f a , c h ) = trans ( fail ( f a ) , c h ) \text{trans}(fa,ch)=\text{trans}(\text{fail}(fa),ch) trans ( f a , c h ) = trans ( fail ( f a ) , c h ) 。
那么对应到 Cpp 的代码就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void build () { queue<int > q; for (int i = 0 ; i < 26 ; i++) if (tr[0 ][i]) q.push (tr[0 ][i]); while (q.size ()) { int fa = q.front (); for (int i = 0 ; i < 26 ; i++) { int & u = tr[fa][i]; if (!u) u = tr[fail[fa]][i]; else { fail[u] = tr[fail[fa]][i]; q.push (u); } } } }
Luogu P5357
初始时令当前节点 u = 0 u=0 u = 0 ,意思是空串;每添加一个字符 c h ch c h ,我们就尝试在 T T T 上走 ( u , c h ) (u,ch) ( u , c h ) 这条边:
如果 T ( u , c h ) T(u,ch) T ( u , c h ) 存在,那么直接走下去;
如果 T ( u , c h ) T(u,ch) T ( u , c h ) 不存在,我们需要尝试从 fail \text{fail} fail 链上走 c h ch c h 边。
由上面的定义,我们知道,这个操作就等价于 u ← trans ( u , c h ) u\gets \text{trans}(u,ch) u ← trans ( u , c h ) 。
接下来该怎么做?因为目前的节点代表一个 S S S 的后缀,这个后缀需要满足:
它是某些模式串的前缀,即它在字典树上;
它是满足上面条件的最长的那个。
这意味着,以当前位置结尾的、所有可能出现的串都是这个后缀的子串,并且由于它们以当前位置结尾,所以它们在 fail \text{fail} fail 链上,因此把当前节点沿着 fail \text{fail} fail 链贡献上去即可。
看上去可能需要树剖?我们不妨反过来思考,把一个节点向所有祖先贡献,最后算答案,这个过程等价于每个节点对子树中所有可能的贡献求一个和,所以最终 DFS 一次求子树贡献和就可以做出这道题目了。
AC Code 。
Nowcoder 14612
从构建过程可以看出,ACAM 是一个离线数据结构,并不支持插入这种操作。
所以我们需要离线下来,把所有的串全部插入后,通过 “解锁” 某些串的贡献来完成类似于在线的操作。
这个题目只要求我们求出所有模式串的出现次数,所以我们考虑使用某些数据结构来维护。
当我们进行查询的操作时,跳转到某个节点,统计的是 fail \text{fail} fail 链上的模式串个数,所以每解锁一个字符串相当于给子树 + 1 +1 + 1 ,这启发我们使用 dfs 序来进行统一操作。
具体地说,维护一个树状数组支持区间 + 1 +1 + 1 单点查询即可。
AC Code 。
Luogu P14363
听说今年 CSP-S 出了一个 ACAM 的题目,来看看怎么回事。
我在第一时间就有了一个暴力的做法:用 s i , 1 s_{i,1} s i , 1 建立 ACAM,并在每个串结尾的位置记录下 s i , 2 s_{i,2} s i , 2 。对于每个询问,我们把 t 1 t_1 t 1 放到 ACAM 上匹配,对每个位置暴力跳 fail \text{fail} fail 判断修改后是否能和 t 2 t_2 t 2 相同。
复杂度高的原因是我们不能暴力跳 fail \text{fail} fail ,因此我们需要尝试记录 fail \text{fail} fail 链上的信息。
这就引出了一个问题,如何判断当前节点替换 t 1 t_1 t 1 后是否等于 t 2 t_2 t 2 ?
当前位置 t 1 , t 2 t_1,t_2 t 1 , t 2 的后缀相等,这一步可以通过 O ( ∣ t ∣ ) O(|t|) O ( ∣ t ∣ ) 记录最长公共后缀的位置,每一次 O ( 1 ) O(1) O ( 1 ) 判断。
当前位置替换后 t 1 , t 2 t_1,t_2 t 1 , t 2 的前缀 p 1 , p 2 p_1,p_2 p 1 , p 2 相等。
下面是关键的一步:我们把替换操作看成一个向量加减法操作。
具体地说,我们希望 p 1 − s i , 1 + s i , 2 = p 2 p_1-s_{i,1}+s_{i,2}=p_2 p 1 − s i , 1 + s i , 2 = p 2 ,这里 s i , 1 s_{i,1} s i , 1 和 s i , 2 s_{i,2} s i , 2 前面补 0 0 0 直到与前缀长度相等。
为什么这样操作?因为这样可以把询问和 fail \text{fail} fail 树上的数据独立出来,即判断有多少个 s i , 1 − s i , 2 = p 1 − p 2 s_{i,1}-s_{i,2}=p_1-p_2 s i , 1 − s i , 2 = p 1 − p 2 。
到了这一步,就可以使用哈希了,但是这个哈希不能被前缀 0 0 0 的长度影响,因为我们实际上并不关心这些向量的前缀 0 0 0 部分。
尝试使用普通的字符串哈希 f ( S ) = ∑ i = 1 ∣ S ∣ S i B ∣ S ∣ − i f(S)=\sum_{i=1}^{|S|} S_iB^{|S|-i} f ( S ) = ∑ i = 1 ∣ S ∣ S i B ∣ S ∣ − i ,我们首先需要保证不取模之前这个哈希是不会碰撞的,即 ∣ S ∣ = ∣ T ∣ |S|=|T| ∣ S ∣ = ∣ T ∣ 时有:
∑ i = 1 ∣ S ∣ S i B ∣ S ∣ − i = ∑ i = 1 ∣ T ∣ T i B ∣ T ∣ − i ⇔ ∀ i = 1 ∼ ∣ S ∣ , S i = T i \sum_{i=1}^{|S|}S_iB^{|S|-i}=\sum_{i=1}^{|T|}T_iB^{|T|-i}\Leftrightarrow \forall i=1\sim |S|,S_i=T_i
i = 1 ∑ ∣ S ∣ S i B ∣ S ∣ − i = i = 1 ∑ ∣ T ∣ T i B ∣ T ∣ − i ⇔ ∀ i = 1 ∼ ∣ S ∣ , S i = T i
这个等价于:
∑ i = 1 n a i B n − i = 0 ⇔ ∀ i = 1 ∼ n , a i = 0 \sum_{i=1}^na_iB^{n-i}=0\Leftrightarrow\forall i=1\sim n, a_i=0
i = 1 ∑ n a i B n − i = 0 ⇔ ∀ i = 1 ∼ n , a i = 0
由于我们哈希的对象 − 25 ≤ a i ≤ 25 -25\le a_i\le 25 − 25 ≤ a i ≤ 25 ,我思考出了一个必要条件:非零最高位决定符号就能做到这一点。
想做到这一点,需要对所有的 n n n 都满足:
B n − 1 > 25 ( B 0 + B 1 + ⋯ + B n − 2 ) = 25 B n − 1 − 1 B − 1 B^{n-1}>25(B^0+B^1+\cdots+B^{n-2})=25\frac{B^{n-1}-1}{B-1}
B n − 1 > 25 ( B 0 + B 1 + ⋯ + B n − 2 ) = 25 B − 1 B n − 1 − 1
化简一下可以得到 B − 1 > B n − 1 − 1 B n − 1 25 ≈ 25 B-1>\frac{B^{n-1}-1}{B^{n-1}}25\approx 25 B − 1 > B n − 1 B n − 1 − 1 25 ≈ 25 ,所以正常取 B = 131 B=131 B = 131 就可以了。
因此我们的做法就是在 fail \text{fail} fail 树上建主席树,存下来每一个 f ( s i , 1 − s i , 2 ) f(s_{i,1}-s_{i,2}) f ( s i , 1 − s i , 2 ) ,然后查询 f ( p 1 − p 2 ) f(p_1-p_2) f ( p 1 − p 2 ) 的计数即可。
AC Code 。
Luogu P7456
看到这个题目,就立马有一个朴素的 DP 想法,设 d p i dp_i d p i 表示前缀 s [ 1 , i ] s[1,i] s [ 1 , i ] 凑出来的最小单词数,那么转移有:
d p i = min j = i − ∣ a k ∣ i − 1 d p j + 1 dp_i=\min_{j=i-|a_k|}^{i-1} dp_j+1
d p i = j = i − ∣ a k ∣ min i − 1 d p j + 1
这里需要满足 s [ 1 , i ] s[1,i] s [ 1 , i ] 以 a k a_k a k 为后缀,于是你就会发现,我们需要的只是最长的满足条件的 a k a_k a k 。
因为你可以观察这个式子,更短的 a k a_k a k 对答案的贡献一定会被最长的算进去。
而 ACAM 就可以求出来最长的那个长度。
这道题目剩下的部分就是一个线段树优化 DP 了。
AC Code 。
Luogu P4052
相当于,在 ACAM 上走 m m m 步并且不走到 fail \text{fail} fail 链上有标记的点的方案数。
设 d p ( u , k ) dp(u,k) d p ( u , k ) 表示节点 u u u 走 k k k 步的方案数,那么由能走到的节点 t o to t o 的 d p ( t o , k − 1 ) dp(to,k-1) d p ( t o , k − 1 ) 转移过来即可。
AC Code 。
HDU 2457
设 d p ( i , u ) dp(i,u) d p ( i , u ) 设添加第 i i i 个字符后,在 ACAM 上匹配到以 u u u 结尾的最小次数。
当我们添加一个字符时,需要对每个节点 u u u 都尝试沿着 A,T,G,C \texttt{A,T,G,C} A,T,G,C 向下走。
形式化地,d p ( i + 1 , t o ) ← d p ( i , u ) + 0 / 1 dp(i+1,to)\gets dp(i,u)+0/1 d p ( i + 1 , t o ) ← d p ( i , u ) + 0/1 ,这里 0 / 1 0/1 0/1 表示走的边是否等于原始串对应位置的字符。
发现只会由 i i i 向 i + 1 i+1 i + 1 推,所以可以滚动数组优化掉这一维。
AC Code 。