序列自动机
一般来说算法竞赛中出现的有关于字符串的 “自动机算法” 都比较高大上,然而 “序列自动机” ----- 一开始我根本没懂这个东西怎么沾上的 “自动机” 的边,但是结合前几天学习 “AC自动机” 有一篇博客中讲的一句话:AC自动机是 “被极端简化了的确定有限状态自动机”,忽然意识到,这个 “序列自动机” 不像自动机的原因 ------ 它被简化到只剩一个数组了。
序列自动机可以快速判断若干字符串 s1,s2,…sk 中的每一个,是否是字符串 T 的子序列,还有解决这个问题的一些衍生问题。
序列自动机的构造
假设现在我要判断字符串 S = s1s2s3…sn 是否是 T 的子序列(lenS<=lenT),我首先要在 T 中寻找 s1 这个字符,那么是不是我必须找出 T 中所有的 s1 字符呢?很显然不需要,我只需要贪心地在 T 里取最前面的一个 s1 即可,因为如果用后面的 s1 可以构造出字符串 S,那么用前面的也可以,反之则不一定。所以我们每次贪心地取 S 中的每个字符在 T 中最前面的一个。
我们用
N
e
x
t
[
i
]
[
j
]
Next[i][j]
Next[i][j] 表示位置 i 后面最近的字符 j 的下标(这里要给字符集中的每个元素编个号,例如如果字符集是’a’~‘z’,可以编号 1~26)。如果位置 i 后面找不到字符 j ,
N
e
x
t
[
i
]
[
j
]
=
0
Next[i][j] = 0
Next[i][j]=0。
构造母串的Next数组:
void Init()
{
int len = strlen(str);
for(int i=len;i>=1;i--) //显然需要从后向前构造
{
for(int j=1;j<=26;j++) //字符集是26个小写字母
Next[i-1][j] = Next[i][j];
Next[i-1][str[i-1]-'a'+1] = i;
}
}
Next 数组的第二维要开字符集大小,并且字符集中的每一个字符要有一个整数映射。例如如果模板串和模式串都是小写字母组成的,定义 N e x t [ l e n ] [ 27 ] Next[len][27] Next[len][27]。虽然这样定义有一个很大的缺陷,但这是最常用的一种序列自动机,已经可以解决大多数(简单)问题了。后面会写怎样优化它。
在验证一个字符串是否是模板串的子序列的时候,只需对每一个字符顺着 Next 数组向后找,如果存在找不到的字符,就不是模板串的子序列。
for(int i=1;i<=N;i++) //假设询问N个字符串是否是模板串的子序列
{
scanf("%s", str2);
int len = strlen(str2);
int ok = 1, now = 0;
for(int i=0;i<len;i++)
{
now = Next[now][str2[i]-'a'+1]; //找下一个字符的位置
if(!now) //找不到就不是子序列
{
ok = 0;
break;
}
}
if(ok)
printf("Yes\n");
else
printf("No\n");
}
当询问的字符串很多时,序列自动机比暴力寻找子序列要快很多。因为跳过了很多不必要字符。
序列自动机的经典问题
- 求两个字符串不同的公共子序列个数。
假装不知道有DP。。。
对两个字符串分别构造 Next 数组 用记忆化搜索(可能过渡一下就得到了DP??,我DP没学好。。)
ll DFS(int x, int y) //表示目前字符是串1的第x位,串2的第y位
{
if(data[x][y])
return data[x][y];
for(int i=1;i<=26;i++)
if(next1[x][i]&&next2[y][i])
data[x][y] += DFS(next1[x][i], next2[y][i]);
if(x||y)
++data[x][y];
return data[x][y];
}
- 求一个字符串的回文子序列个数
分别对原串和反串构造 Next 数组,可以想到原串的回文子序列在这里体现为原串和反串的公共子序列,因为回文串正反是一样的。这样就把问题划归为了第一个问题,但是我们要注意两个地方:
- 因为构造出来的原串和反串本质上是一个串,相当于从左右端点向中间找子序列,因此DFS时参数也有限制,当 x + y ≤ l e n + 1 x+y≤len+1 x+y≤len+1 才是合法的
- 回文串分奇串和偶串,直接DFS会丢掉一部分奇串,这部分需要手动加上。
int dfs(int x, int y)
{
if(data[x][y])
return data[x][y];
for(int i=1;i<=26;i++)
if(next1[x][i]&&next2[y][i])
{
if(next1[x][i]+next2[y][i]>len+1)
continue;
if(next1[x][i]+next2[y][i]<n+1)
data[x][y]++;
data[x][y] += dfs(next1[x][i], next2[y][i]));
}
return ++data[x][y];
}
- 给定三个串A,B,C,求一个最长的A,B的公共子序列S,要求C是S的子序列。
还是同样的dfs(x, y, z),表示一匹配到 C 的第 z 位,需要改变一下 C 的Next数组构造方式。
for(int i=1;i<=26;++i)
next[lenc][i] = lenc;
for(int i=0;i<lenc;++i)
{
for(int j=1;j<=26;++j)
next[i][j] = i;
next[i][c[i+1]] = i+1;
}
真正的序列自动机
前面写到了:构造 Next 数组,有一个很大的缺陷。这个缺陷就是:这种思路只适用于字符集较小的情况,如果字符集很大(例如:字符集是 1 到 1e5 之间的所有数字),这样还想像刚才一样构造
N
e
x
t
[
l
e
n
]
[
1
e
5
]
Next[len][1e5]
Next[len][1e5] 数组,时间和空间都不允许。
我们再看一下构造 Next 数组的代码:
void Init()
{
int len = strlen(str);
for(int i=len;i>=1;i--) //显然需要从后向前构造
{
for(int j=1;j<=26;j++) //字符集是26个小写字母
Next[i-1][j] = Next[i][j];
Next[i-1][str[i-1]-'a'+1] = i;
}
}
注意到:
N
e
x
t
[
i
−
1
]
[
j
]
Next[i-1][j]
Next[i−1][j] 实际上大部分直接继承了
N
e
x
t
[
i
]
[
j
]
Next[i][j]
Next[i][j]的值,因为只有 S[i] 这一项会影响到当前的 Next[j],所以
N
e
x
t
[
i
−
1
]
[
j
]
Next[i-1][j]
Next[i−1][j] 和
N
e
x
t
[
i
]
[
j
]
Next[i][j]
Next[i][j]只有一位不同,因此这个数组的第一维没有什么实际必要,可以想办法去掉。我们想到,i 每向前移动一位,就改变一个数组值,然后还需要随时访问改变以前的数组值(因为从前向后验证子序列),可以看出来,这是一个可持久化数组,这个操作是主席树的单点修改。
我们对
N
e
x
t
Next
Next 数组建主席树,主席树的每一个 Root,就对应了一个 Next 数组。还是从后向前构造,i 每向前移动一位,就把数组第 str[i] 个元素改成 i,这样对主席树 Root[i] 询问当前这这棵线段树的某个元素,就是离当前 i 结点最近的这个字符。
for(int i=1;i<=N;i++) //这是原序列(母串)
scanf("%d", &A[i]);
for(int i=N;i>=1;i--) //主席树的单点修改
{
root[i-1] = update(root[i], 1, N, A[i], i);
//root[i-1] 是 root[i] 将A[i]这一项修改为i
}
//用 query(root[pos], 1, N, t[j]); 主席树的单点查询,来查询pos后最近的 t[j] 字符
例题
普通序列自动机模板:
牛客小白月赛12:月月查华华的手机.
三个串的公共子序列计数(和两个串的本质相同):
洛谷P1819 公共子序列.
真·序列自动机:
洛谷P5826 【模板】子序列自动机.
毒瘤题目,序列自动机+动态规划+高精度:
洛谷P4608 [FJOI2016]所有公共子序列问题.
这个题目序列自动机的部分其实很好理解,然而在后面两个东西的加持下,它变得很困难。(这道题笔者本人拿不到满分)