后缀树

万恶的纸糊串 /fn。

参考文章 EA-炫酷后缀树魔术OldDriverTree-题解:P4143 采集矿石

后缀树,即考虑将一个字符串的所有后缀插入 trie 中。

懒得画图了 /kk。

将 abcbc 插入 trie 中。

            rt
          a/ b|  \c
          O   O   O
         b|  c|  b|
          O   O   O
         c|  b|  c|
          O   O   O
         b|  c|
          O   O
         c|
          O

发现有很多后缀作为其他后缀的前缀在 trie 上没有显现(如 bc),很多文章考虑在字符串最后加上一个特殊字符如 & 来避免这种问题,但是笔者还没想明白其必要性。

加入终止符后的后缀树。

            rt
          a/ b|  \c
          O   O    O
         b|  c|\&  b|\&
          O   O O   O O
         c|  b|    c|
          O   O     O
         b|  c|    &|
          O   O     O
         c|  &|
          O   O
         &|
          O

但是这样发现节点数是 \(O(n^2)\) 的,应用空间显然不大。考虑减少节点数,将只有一个子节点的节点压缩。

压缩后的后缀树。

            rt
          a/ b|  \c
          O   O    O
         b|  c|\&  b|\&
         c|  b| O  c| O
         b|  c|    &|     
         c|  &|     O
         &|   O
          O     

这样压缩后节点量级是 \(O(n)\) 级别,准确上界是 \(2n\)

假设当前后缀插入后节点个数增加,则该后缀从新建节点处往后均没有在后缀树内出现,故最终将缩成一点。

而新建节点时有可能使得原本压缩后的某个节点子节点数 \(>1\) 需要将该点分裂出来。

例:将 abcd& 插入。

            rt
          a/ b|  \c
          O   O    O
         b|  c|\&  b|\&
         c|  b| O  c| O
          O  c|    &|     
      d/ b|  &|     O
     &/  c|   O
     O   &|
          O   

接下来考虑后缀树具有什么性质。

· 每个叶子节点到根的路径都是原字符串的一个后缀。后缀树的 dfs 序即为 SA。

· 并且每个点都对应着原串的一个子串(包含被压缩的点),同样的,若将压缩的点全部计入 dfs 序,每个点的 dfs 序则对应其代表子串在所有子串中的排名。

· 后缀树上两个点的 lca 代表该两点代表的子串的 LCP。


构建后缀树。

Ukkonen 太困难了,没看懂,咕咕咕了 /hsh。

根据如上几条性质,发现建树过程即为按后缀排序的顺序插入后缀,将其与上一个插入的后缀连向两者 LCP。且每次插入后缀必定在后缀树的最右链上拓展。

先求出 SA。

考虑类似笛卡尔树用栈维护最右链。

O
 \a
  O
   \b
    \c
     O

假设当前最右链为上图,考虑插入 abd,因为插入后原本未压缩时 b 连向的节点子节点个数 >1。则需要在 bc 这条边上分裂,再拓展出 d 边,最右链变为 abd。

O
 \a
  O
   \b
    O
   c| \d
    O  O

这个分裂过程听起来似乎很麻烦,但实际上只需要维护每个节点到根对应的子串的长度 \(pos\)。随后差分就可以得到每条边上的元素。

而维护最右链,每次找到当前后缀从哪开始拓展,即为将所有栈内 \(pos>len(LCP(sa_i,sa_{i-1}))\) 的节点弹出,而不等式右边即为 SA 中的 height 数组可以 \(O(n)\) 求解。

随后若当前栈顶节点 \(pos=height_i\) 则无需上述分裂过程,栈顶所对节点对应的子串即为 \(LCP(sa_i,sa_{i-1})\),直接新建当前后缀的节点 \(u,pos_u=n-sa_i+1\) 加入最右链即可。

否则 \(LCP(sa_i,sa_{i-1})\) 不存在,需要新加入 \(LCP(sa_i,sa_{i-1})\) 以及当前后缀,将原本弹出的右链部分接到 \(LCP\) 上。

void build(){
	get();//求 SA 
	vector<int>q;
	//记录所有节点的顺序,保证最后连边后遍历的顺序是按字典序
	z[tp=1]=0,tot=0;//根节点 
	for(int i=1;i<=n;i++){
		while(pos[z[tp]]>h[i])--tp;//将 pos>height_i 的节点弹出 
		if(h[i]^pos[z[tp]])//不存在 LCP 
			fa[z[tp+1]][0]=++tot,pos[tot]=h[i],fa[tot][0]=z[tp],
			//新建 LCP,将原本弹出部分接到 LCP 上
			fa[i][0]=tot,pos[i]=n-sa[i]+1,z[++tp]=tot,z[++tp]=i,q.pb(tot),q.pb(i);
			//新建当前后缀维护最右链
		else //存在 LCP
			fa[i][0]=z[tp],z[++tp]=i,pos[i]=n-sa[i]+1,q.pb(i);
			//新建当前后缀维护最右链 
	}for(int i:q)e[fa[i][0]].pb(i);//建树 
}

因为按照这种方式建树应当是保证每个后缀都有一个节点 \(rk_i\) 代表,所以终止符在有没有必要性 /yun。


应用

查询字符串 \(s\)\(t\) 的出现次数。

建出后缀树后,走到代表 \(t\) 的节点 \(u\) 处(不存在则出现次数为 0),则出现次数为 \(u\) 子树内的后缀个数(每次出现必定是某个后缀的前缀且每个后缀至多对应一次出现)。

P4143 采集矿石

对于每个左端点 \(l\),右端点 \(r\) 不断增大,则 \(val\) 递增,但降序排名递减,所以每个左端点仅对应一个合法右端点。

考虑找到每个左端点对应的右端点,建立后缀树后在这个 \(s:[l,n]\) 这个后缀对应节点到根倍增找到最后一个 \(val\ge rank\) 的祖先 \(u\),然后在 \(fa_u\to u\) 这条边上继续倍增找到合法右端点(因为可能有几个右端点被压缩在这条边上)。

posted @ 2025-10-20 21:42  Uesugi1  阅读(4)  评论(0)    收藏  举报