Banner image of the blog
站长头像

YaLi Blog

DarkClever的个人博客

文章 评论 标签
5 0 5
分类列表

序列自动机

日期:2025-08-22浏览:0 编辑

问题引入

如果我们需要一种算法,使其可以将一个字符串的所有子序列表示为一个 DAG(即有向无环图),我们该如何构建?

构建

钦定 $n$ 为字符串的长度,$\Sigma$ 为字符集,$S$ 为字符串,且 $S$ 只会包含小写字母。

我们定义 $nxt_{i,j} (i \leq n,j \in \Sigma)$ 为在 $i$ 位置之后第一个 $j$ 字符出现的位置。

那么我们很容易得出构建方法:

struct SequentialAutomaton {
    int nxt[N][M];
    void init(string s){
        for(int i=s.length()-1;i>=0;i--){//从后往前遍历
            for(int j=1;j<=26;j++)nxt[i][j] = nxt[i+1][j];//将nxt数组从 i+1 转移至 i
            if(s[i] <= 'z' && 'a' <= s[i])nxt[i][s[i] - 'a' + 1] = i + 1;//并且更新状态,从下一个字符转移过来
            //我的nxt数组是从 1 开始的,所以从 s_i(对应了字符串第 i+1 个字符)转移
        }
    }
};

时间复杂度为 $O(n|\Sigma|)$,空间复杂度亦为 $O(n|\Sigma|)$。

显然,$nxt_i$ 只会指向 $j(j>i)$,所以这是一个 DAG。

构建完这个 DAG 后,它能做什么呢?

应用

寻找子序列/子序列匹配

这个十分简单,只需要在 DAG 上贪心的往后跳即可,因为如果 $i$ 无法跳到 $k$,那么 $j(j>i)$ 也一定无法跳到 $k$。

vector<int> Find(string x){
    int pt = 0,i = 0;//pt为当前匹配到的子序列中的位置,i当前字符串s1中跳到的位置
    vector<int> xl;//储存答案的vector
    while(pt!=x.length()){
        if(s1.nxt[i][x[pt] - 'a' + 1])//如果能跳,就贪心的跳 
            i = s1.nxt[i][x[pt] - 'a' + 1],pt++,xl.push_back(i);
        else
            return vector<int>();//如果找不到返回一个空的vector
    }
    return xl;//返回答案
}

时间复杂度为 $O(l)$,其中 $l$ 为 $x$ 的长度。

寻找一个子序列的出现次数

很简单,只要我们除了向后跳之外,我们也可以向下一个相同的找,就可以遍历到他每一个出现的情况。

int dfs(string x,int i = 0,int pt = 0){
    int anss = 0;
    //有两种可能
    //第1种,向后跳,此时将pt++
    if(pt != x.length() && s1.nxt[i][x[pt] - 'a' + 1]){
        anss += dfs(x,s1.nxt[i][x[pt] - 'a' + 1],pt+1);
    }
    //第2种,跳到下一个跟自己一样的
    if(pt>0 && s1.nxt[i][x[pt - 1] - 'a' + 1]){
        anss += dfs(x,s1.nxt[i][x[pt - 1] - 'a' + 1],pt);
    }
    if(pt == x.length()){
        //找完了
        return anss+1;
    }
    //此处注意到自己是由 pt-1 跳过来的
    return anss;
}

然后呢,我们发现时间复杂度是极高的,很容易超出时间限制,所以我们需要进行优化。

这个时候,我们就需要使用——记忆化搜索!

借助记忆化搜索,我们只用多开一个 $O(nl)$ 的数组,就可以将时间复杂度压缩到 $O(nl)$ 以下。

int dfs(string x,int i = 0,int pt = 0){
    if(f[i][pt]){
        return f[i][pt];
    }
    int anss = 0;
    //有两种可能
    //第1种,向后跳,此时将pt++
    if(pt != x.length() && s1.nxt[i][x[pt] - 'a' + 1]){
        anss += dfs(x,s1.nxt[i][x[pt] - 'a' + 1],pt+1);
    }
    //第2种,跳到下一个跟自己一样的
    if(pt>0 && s1.nxt[i][x[pt - 1] - 'a' + 1]){
        anss += dfs(x,s1.nxt[i][x[pt - 1] - 'a' + 1],pt);
    }
    if(pt == x.length()){
        //找完了
        return anss+1;
    }
    //此处注意到自己是由 pt-1 跳过来的
    f[i][pt] = anss;//记忆答案
    return anss;
}

统计本质不同子序列数量

我们可以考虑直接深搜,统计所有可能的子序列,并且使用记忆化搜索即可。

int f[N];
int dfs(int x){
    if(f[x])//还是记忆化搜索
        return f[x];
    for(int i=1;i<=26;i++)
        if(s1.nxt[x][i]) 
            f[x] += dfs(s1.nxt[x][i]);//进行转移即可
    if(x)//这里的判断是为了不统计空序列
        f[x]++;
    return f[x];
}

时间复杂度是 $O(n|\Sigma|)$,非常简单。

统计多个字符串之间公共子串数量

例题:P1819 和 P3856

终于进入正题了,这两道题除了取模和有一题要多输入一个长度之外没有区别,就一起讲了。

我们考虑直接对统计本质不同子序列数量进行改造,如果 $s1.nxt_{x,i} \land s2.nxt_{y,i} \land s3.nxt_{z,i}$,则将 $x,y,z$ 分别跳向 $s1.nxt_{x,i},s2.nxt_{y,i},s3.nxt_{z,i}$ 即可。

当然,我们仍然要使用记忆化搜索优化,空间复杂度为 $O(n^3)$,时间复杂度为 $O(n^3|\Sigma|)$。

int f[N][N][N];
int dfs(int x,int y,int z){
    if(f[x][y][z])//记~忆~化~搜~索~
        return f[x][y][z];
    for(int i=1;i<=26;i++)
        if(s1.nxt[x][i] && s2.nxt[y][i] && s3.nxt[z][i]) 
            f[x][y][z] += dfs(s1.nxt[x][i] , s2.nxt[y][i] , s3.nxt[z][i]);
    if(x && y && z)//为了不统计空序列(当然你也可以在输出答案时减一)
        f[x][y][z]++;
    return f[x][y][z];
}

好了,序列自动机以及这道题目就讲到这里 byebye~