• 子序列自动机 学习笔记


    子序列自动机是什么?

    下文的 \(S\) 默认指一个长度为 \(n\) 的字符串 \(S\)\(S_i\) 代表 \(S\) 的第 \(i\) 位,\(c\) 代表一个字符。

    简介

    长度为 \(n\) 的字符串 \(S\),它的子序列指从 \(S\) 中将若干元素提取出来并不改变相对位置形成的序列,即 \(S_{p_1},S_{p_2},...,S_{p_k}\),其中 \(1\le p_1<p_2<\cdot\cdot\cdot<p_k\le n\)


    子序列自动机(Subsequence automaton,翻译自百度翻译), 是接受且仅接受一个字符串的子序列的自动机,是一个处理子序列的锐利武器。

    对于一个字符串 \(S\),我们可以通过使用子序列自动机得到它的每一个子序列,并方便地去维护、查询它们,让人惊喜不已。

    状态

    oi-wiki 上有形式化的说明,这里给出一个更易理解的版本:

    我们维护指针 \(to_{i,c}\),其中 \(i\) 是一个数字、\(c\) 是一个字符。它代表 \(S\) 的第 \(i\) 个位置后,第一个字符 \(c\) 所处的位置。如果 \(i\) 位置后没有 \(c\) 了,我们可以默认它指向 \(n+1\)

    比如 S="abcdc",那么 \(to_{1,'d'}=4,to_{2,'c'}=3,to_{3,a}=6\)

    这样,我们可以通过从一个位置 \(x\) 开始不断跳 to 指针来得到一个 \(S\) 的子序列。

    因此,为了方便,我们可以设 \(to_{0,c}\)\(S\) 中第一个 \(c\) 所出现的位置,这样跳指针时,设起点 \(x=0\) 即可。

    为什么 \(to_{i,c}\) 是指向 \(i\) 后面第一个字符 \(c\) 所处的位置,而不是第二个、第三个?

    对于两个位置 \(j,k\)\(j\)\(k\) 的前面,如果它们位置上的字符是一样的,都是 \(c\),那么以 \(k\) 开头的子序列的集合,肯定是以 \(j\) 开头的子序列的集合 的子集。所以我们可以采取贪心的思想,贪得无厌地选第一个位置,真是大快人心!

    子序列自动机怎么构建?

    字符集较小

    对于字符集较小的情况(比如字符集为 a~z),我们可以直接去维护 \(to_{i,c}\)

    倒行逆施地,我们从后往前,倒序枚举每一个位置 \(i\),构建 \(to_{i,c}\)

    巧夺天工地维护一个数组 \(las\)\(las_c\) 代表 \(c\) 目前出现最前的位置。

    首先我们让字符集里的所有字符 \(c\),都执行操作 $ to_{i,c}:=las_c$。接着更新 \(las\),令 \(\large las_{S_i}=i\) 即可。

    时间复杂度为 \(O(n|\sum|)\)\(|\sum|\) 为字符集大小。

    void build(){
    	for(int i=1;i<=m;++i)las[i]=n+1;
    	for(int i=1;i>=n;--i){
    		for(int j=1;j<=m;++j)to[i][j]=las[j];
    		las[ S[i] ]=i;
    	}
    }
    

    字符集较大

    如果不随人愿,字符集较大(比如字符集大小为 \(10^5\)),那么直接维护 \(to_{i,c}\),时间和空间复杂度都是庞然大物。改为用可持久化线段树去维护,也就是 \(i\) 代表的线段树为它的 \(to\) 的值。

    因为 \(i\) 的改变,只会让 \(las\) 改变一个位置的值,这就相当于单点修改,所以时间复杂度是 \(O(n\log |\sum|)\) 的。

    void plant0(int &o,int l,int r){
    	dcnt++,o=dcnt;
    	if(l==r){tre[o].val=n+1;return;}
    	int mid=(l+r)>>1;
    	plant0(tre[o].lv,l,mid),plant0(tre[o].rv,mid+1,r);
    }
    
    void plant(int ori,int &o,int l,int r,int wei,int val){
    	dcnt++,o=dcnt;
    	tre[o]=tre[ori];
    	if(l==r){tre[o].val=val;return;}
    	int mid=(l+r)>>1;
    	if(wei<=mid)plant(tre[ori].lv,tre[o].lv,l,mid,wei,val);
    	if(wei>mid) plant(tre[ori].rv,tre[o].rv,mid+1,r,wei,val);
    }
    
    int query(int o,int l,int r,int wei){
    	if(l==r)return tre[o].val;
    	int mid=(l+r)>>1;
    	if(wei<=mid)return query(tre[o].lv,l,mid,wei);
    	if(wei>mid) return query(tre[o].rv,mid+1,r,wei);
    }
    
    void build(){
    	plant0(rot[n],1,m);
    	for(int i=n;i>=1;--i)plant(rot[i],rot[i-1],1,m,S[i],i);
    }
    

    子序列自动机有什么用?

    例题:P5826 - 【模板】子序列自动机

    https://www.luogu.com.cn/problem/P5826

    题意

    给定字符串 \(S\)\(T\) 次询问,每次询问一个字符串 \(B\) 是否为 \(S\) 的子序列。

    \(n,T\le 10^5,|\sum|\le 10^5\)

    题解

    注意:第一篇 WYXkk 的题解不是本文所说的子序列自动机,一扶苏一 的题解才是。

    我们需要查询一个字符串 \(B\) 是否为 \(S\) 的子序列。根据对子序列自动机的理解,我们可以将内忧外患迎刃而解。

    \(B\)\(S\) 匹配,也就是在自动机上跑,用指针 \(k\) 指向 \(S\) 匹配到了哪里。

    假如正在试图匹配 \(B_i\),那么我们让 \(\large k=to_{k,B_i}\)。如果 \(k=n+1\) 说明匹配失败,\(B\) 不是 \(k\) 的子序列。如果匹配完 \(B\) 了,仍有 \(k\le n\) 那么代表 \(B\)\(k\) 的子序列。

    代码

    完整代码,干净又卫生,让人大快朵颐:

    #include<bits/stdc++.h>
    #define rep(i,x,y) for(int i=x;i<=y;++i)
    #define per(i,x,y) for(int i=x;i>=y;--i)
    #define lon long long
    using namespace std;
    const int n7=101234,m7=102,t7=n7*32;
    struct dino{int val,lv,rv;}tre[t7];
    int n,T,m,L,dcnt,S[n7],b[n7],to[n7][m7],las[n7],rot[n7];
    
    int rd(){
    	int shu=0;bool fu=0;char ch=getchar();
    	while( !isdigit(ch) ){if(ch=='-')fu=1;ch=getchar();}
    	while( isdigit(ch) )shu=(shu<<1)+(shu<<3)+ch-'0',ch=getchar();
    	return fu?-shu:shu;
    }
    
    void plant0(int &o,int l,int r){
    	dcnt++,o=dcnt;
    	if(l==r){tre[o].val=n+1;return;}
    	int mid=(l+r)>>1;
    	plant0(tre[o].lv,l,mid),plant0(tre[o].rv,mid+1,r);
    }
    
    void plant(int ori,int &o,int l,int r,int wei,int val){
    	dcnt++,o=dcnt;
    	tre[o]=tre[ori];
    	if(l==r){tre[o].val=val;return;}
    	int mid=(l+r)>>1;
    	if(wei<=mid)plant(tre[ori].lv,tre[o].lv,l,mid,wei,val);
    	if(wei>mid) plant(tre[ori].rv,tre[o].rv,mid+1,r,wei,val);
    }
    
    int query(int o,int l,int r,int wei){
    	if(l==r)return tre[o].val;
    	int mid=(l+r)>>1;
    	if(wei<=mid)return query(tre[o].lv,l,mid,wei);
    	if(wei>mid) return query(tre[o].rv,mid+1,r,wei);
    }
    
    void build(){
    	plant0(rot[n],1,m);
    	per(i,n,1)plant(rot[i],rot[i-1],1,m,S[i],i);
    }
    
    bool check(){
    	int now=0;
    	rep(i,1,L){
    		now=query(rot[now],1,m,b[i]);
    		if(now==n+1)return 0;
    	}	
    	return 1;
    }
    
    int main(){
    	rd(),n=rd(),T=rd(),m=rd();
    	rep(i,1,n)S[i]=rd();
    	build();
    	while(T--){
    		L=rd();
    		rep(i,1,L)b[i]=rd();
    		if(L>n)puts("No");
    		else puts(check()?"Yes":"No");
    	}
    	return 0;
    }
    

    P4608 - [FJOI2016]所有公共子序列问题

    https://www.luogu.com.cn/problem/P4608(三倍经验

    题意

    求两个字符串 \(S,T\) 的公共子序列个数。

    字符串长度小于等于 \(3010\)

    题解

    可以把子序列自动机看做一个图,连边 \(i\to to_{i,c}\),那么从 \(0\) 号点出发,任意一条路径就是一个子序列。

    对于 \(S,T\) 都构建自动机,分别称为 \(to,to'\)

    对于 k=0 我们可以搜索,枚举当前子序列可以接上的字符 c,搜索即可。

    对于 k=1,触类旁通地,我们想到用 dp 解决问题。

    \(f_{i,j}\) 为从 \(S_i\)\(T_j\) 开始的公共子序列的个数。

    既然如此,有:

    \[\Large f_{i,j}=\sum\limits_{c\in \text {alphabet}} f_{to_{i,c},to'_{j,c}} \]

    可以用记忆化搜索实现,鬼斧神工!

    P4112 - [HEOI2015]最短不公共子串

    https://www.luogu.com.cn/problem/P4112

    这里只做第 2、4 问,因为其余问的解法是 SAM,而非子序列自动机算法。

    题意

    给定两个字符串 \(S,T\),分别长为 \(n,m\),求:

    • \(S\) 的一个最短的子串,它不是 \(T\) 的子序列,输出其长度。
    • \(S\) 的一个最短的子序列,它不是 \(T\) 的子序列,输出其长度。

    字符串长度小于等于 \(2000\)

    题解

    \(T\) 修筑一个子序列自动机。

    第一问:

    枚举 \(S\) 的子串,用 \(T\) 的子序列自动机判断其是否为 \(T\) 的子序列即可。

    第二问:

    触类旁通地,我们使用 dp。

    \(f_{i,j}\) 为从 \(S_i\) 开始的一个子序列 \(B\),长度最小为多少,才能让 \(B\) 不是从 \(T_j\) 开始的某个子序列。

    则有:

    \[\Large f_{i,m+1}=0\\\Large f_{i,j}=\min\limits_{c\in \text{alphabet}} \{f_{to_{i,c},to_{j,c}}+1\} \]

    意思就是说,\(f_{i,j}\) 可以通过在 \(B\) 后面拼接一个字符 \(c\),那么就是对应的状态加上 1 的长度。

    倒序转移 dp 即可,答案显然为 \(f_{0,0}\)

    参考文献

    http://oi-wiki.com/string/seq-automaton

    https://www.luogu.com.cn/blog/fusu2333/solution-p5826

    再次感谢您的支持!

  • 相关阅读:
    [杂七杂八][高效程序员应该养成的七个习惯][转载]
    链表反转,只用两次头插法就实现了 hello
    清除空格 hello
    单词计数 hello
    单词查找程序 hello
    sdut 超级玛丽
    ural Episode Nth: The Jedi Tournament(缩点+建树)
    sudt 括号东东(模拟题)
    ural Pilot Work Experience(dfs + bfs )
    ural Team building(强连通分支)
  • 原文地址:https://www.cnblogs.com/BlankAo/p/15826647.html
Copyright © 2020-2023  润新知