『常见字符串算法概要』

常见字符串算法概要

字符串Hash

通常使用多项式\(\mathrm{Hash}\)赋权的方法,将字符串映射到一个正整数。

\[f(s)=\sum_{i=1}^{|s|}|s_i|\times P^i\ (\bmod\ p)\]

可以支持\(O(1)\)末端插入字符,\(O(1)\)提取一段字串的\(\mathrm{Hash}\)值。

每次查询的冲突率大概在\(\frac{1}{p}\)左右,如果查询次数较多,可以采用双模数\(\mathrm{Hash}\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20 , Mod = 998244353 , P = 131;
inline int inc(int a,int b) { return a + b >= Mod ? a + b - Mod : a + b; }
inline int mul(int a,int b) { return 1LL * a * b % Mod; }
inline int dec(int a,int b) { return a - b < 0 ? a - b + Mod : a - b; }
inline void Inc(int &a,int b) { a = inc( a , b ); }
inline void Mul(int &a,int b) { a = mul( a , b ); }
inline void Dec(int &a,int b) { a = dec( a , b ); }
int Pow[N],val[N],n,m; char s[N];
inline int GetHash(int l,int r) { return dec( val[r] , mul( val[l-1] , Pow[r-l+1] ) ); }
int main(void)
{
    scanf( "%s" , s+1 );
    n = strlen( s + 1 );
    Pow[0] = 1;
    for (int i = 1; i <= n; i++)
        Pow[i] = mul( Pow[i-1] , P ) , val[i] = inc( s[i] - 'a' , mul( val[i-1] , P ) );
    scanf( "%d" , &m );
    for (int i = 1; i <= m; i++)
    {
        int l1,l2,r1,r2;
        scanf( "%d%d%d%d" , &l1 , &r1 , &l2 , &r2 );
        GetHash(l1,r1) == GetHash(l2,r2) ? puts("Yes") : puts("No");
    }
    return 0;
}

Trie树

确定性有限状态自动机,识别且仅识别字符串集合\(S\)中的所有字符串。

支持\(O(|s|)\)插入字符串,\(O(|s|)\)检索字符串。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20;
struct Trie
{
    int e[N][26],end[N],tot;
    Trie(void) { tot = 1; }
    inline void Insert(char *s)
    {
        int n = strlen( s + 1 ) , p = 1;
        for (int i = 1; i <= n; i++)
        {
            int c = s[i] - 'a';
            if ( !e[p][c] ) e[p][c] = ++tot;
            p = e[p][c];
        }
        end[p] = true;
    }
    inline bool Query(char *s)
    {
        int n = strlen( s + 1 ) , p = 1;
        for (int i = 1; i <= n; i++)
        {
            int c = s[i] - 'a';
            if ( !e[p][c] ) return false;
            p = e[p][c];
        }
        return end[p];
    }
};

Knuth-Morris-Pratt 算法

定义一个字符串的\(\mathrm{Border}\)为其公共前后缀。

定义字符串的前缀函数\[\pi(p)=\max_{s(1,t)=s(p-t+1,p)}\{t\}\]

含义即为字符串\(s\)的前缀\(s_p\)最长\(\mathrm{Border}\)的长度。遍历字符串,每次从上一个位置的最长\(\mathrm{Border}\)处开始向后匹配,如果匹配失败则再跳\(\mathrm{Border}\),直至匹配成功即可求出一个字符串的所有前缀函数。

定义势能函数\(\Phi(p)\)为前缀字符串\(s_p\)的最长\(\mathrm{Border}\)长度,根据\(\mathrm{Knuth-Morris-Pratt}\)算法,有\(\Phi(p)\leq\Phi(p-1)+1\),若暴力跳\(\mathrm{Border}\),则势能降低,可知总时间复杂度为\(O(n)\)

若求出了一个字符串的前缀函数,则可以实现单模式串的字符串匹配,失配就从最长的\(\mathrm{Border}\)处开始重新匹配即可,时间复杂度为\(O(n+m)\),分析方法类似。

#include <bits/stdc++.h>
using namespace std;
const int N = 1000020;
int n,m,fail[N]; char s[N],t[N];
int main(void)
{
    scanf( "%s\n%s" , s+1 , t+1 );
    n = strlen( s + 1 ) , m = strlen( t + 1 );
    for (int i = 2 , j = 0; i <= m; i++)
    {
        while ( j && t[j+1] != t[i] ) j = fail[j];
        j += ( t[j+1] == t[i] ) , fail[i] = j;
    }
    for (int i = 1 , j = 0; i <= n; i++)
    {
        while ( j && ( t[j+1] != s[i] || j == m ) ) j = fail[j];
        j += ( t[j+1] == s[i] );
        if ( j == m ) printf( "%d\n" , i - m + 1 );
    }
    for (int i = 1; i <= m; i++)
        printf( "%d%c" , fail[i] , " \n"[ i == m ] );
    return 0;
}

Knuth-Morris-Pratt 自动机

对于一个字符串\(s\),定义其\(\mathrm{KMP}\)自动机满足:

\(1.\) 状态数为\(n+1\)
\(2.\) 识别所有前缀。
\(3.\) 转移函数\(\delta(p,c)\)为状态\(p\)所对应前缀接上字符\(c\)后最长\(\mathrm{Border}\)位置前缀对应的状态

构造方法与\(\mathrm{Knuth-Morris-Pratt}\)算法类似,时间复杂度为\(O(n\Sigma)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20;
struct KMPAutomaton
{
    int trans[N][26],n;
    inline void Build(char *s)
    {
        n = strlen( s + 1 ) , trans[0][s[1]-'a'] = 1;
        for (int i = 1 , j = 0; i <= n; i++)
        {
            for (int k = 0; k < 26; k++)
                trans[i][k] = trans[j][k];
            trans[i][s[i]-'a'] = i + 1;
            j = trans[j][ s[i] - 'a' ];
        }
    }
};

Aho-Corasick 自动机

确定性有限状态自动机,识别所有后缀在指定字符串集合\(S\)中的字符串。

首先,我们初始化\(\mathrm{Aho-Corasick}\)自动机为指定字符串集合\(S\)\(\mathrm{Trie}\)树,然后按照\(\mathrm{bfs}\)序构造转移函数\(\delta\)

我们定义每一个状态有一个\(\mathrm{fail}\)指针,\(\mathrm{fail}(x)=y\)当且仅当状态\(y\)代表的字符串是状态\(x\)代表字符串的后缀,且\(y\)代表字符串的长度最长。

我们只需\(\mathrm{bfs}\)\(\mathrm{Trie}\)树,当节点\(x\)\(\mathrm{Trie}\)上存在字符为\(c\)的转移边时,我们令\(\delta(x,c)=\mathrm{Trie}(x,c)\),并更新其\(\mathrm{fail}\)指针为\(\delta(\mathrm{fail}(x),c)\),反之,则可以令\(\delta(x,c)=\delta(\mathrm{fail}(x),c)\),易知其正确性。

\(\mathrm{Aho-Corasick}\)自动机可以实现多模式串的文本匹配,构造和匹配的时间复杂度均为线性(值得注意的是,计算贡献如果选择暴跳\(\mathrm{fail}\),则时间复杂度无法保证)。

\(\mathrm{Knuth-Morris-Pratt}\)自动机就是只有一个串\(\mathrm{Aho-Corasick}\)自动机。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 20;
struct AhoCorasickautomaton
{
    int trans[N][26],fail[N],end[N],q[N],tot,head,tail;
    inline void insert(char *s,int id)
    {
        int len = strlen( s + 1 ) , now = 0;
        for (int i = 1; i <= len; i++)
        {
            int c = s[i] - 'a';
            if ( !trans[now][c] ) trans[now][c] = ++tot;
            now = trans[now][c];
        }
        end[id] = now;
    }
    inline void build(void)
    {
        head = 1 , tail = 0;
        for (int i = 0; i < 26; i++)
            if ( trans[0][i] ) q[++tail] = trans[0][i];
        while ( head <= tail )
        {
            int x = q[head++];
            for (int i = 0; i < 26; i++)
                if ( !trans[x][i] )
                    trans[x][i] = trans[fail[x]][i];
                else {
                    fail[trans[x][i]] = trans[fail[x]][i];
                    q[++tail] = trans[x][i];
                }
        }
    }
};

序列自动机

确定性有限状态自动机,识别且仅识别一个序列的所有子序列。

根据定义,可以构造一个\(|s|+1\)个状态的自动机,然后倒序连边即可,每一个状态都可以作为终止状态,时间复杂度\(O(n\Sigma)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6;
struct SequenceAutomaton
{
    int trans[N][26],next[26];
    inline void Build(char *s)
    {
        int n = strlen( s + 1 );
        memset( next , 0 , sizeof next );
        for (int i = n; i >= 1; i--)
        {
            next[ s[i] - 'a' ] = i;
            for (int j = 0; j < 26; j++)
                trans[i-1][j] = next[j];
        }
    }
};

最小表示法

求出一个字符串\(s\)所有循环表示中字典序最小的一个。

可以用两个指针\(i,j\)扫描,表示比较\(i,j\)两个位置开头的循环同构串,并暴力依次向下比较,直到发现长度\(k\),使得\(s_{i+k}>s_{j+k}\),那么我们可以直接令\(i=i+k+1\),因为对于任意的\(p\in[0,k]\),同构串\(s_{i+p}\)都比同构串\(s_{j+p}\)劣,所以不用再比较。

易知其时间复杂度为\(O(n)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 20;
int n,s[N<<1];
int main(void)
{
    scanf( "%d" , &n );
    for (int i = 1; i <= n; i++)
        scanf( "%d" , &s[i] ) , s[i+n] = s[i];
    int i = 1 , j = 2 , k;
    while ( i <= n && j <= n )
    {
        for (k = 0; k < n && s[i+k] == s[j+k]; k++);
        if ( k == n ) break;
        if ( s[i+k] > s[j+k] ) ( i += k + 1 ) += ( i == j );
        if ( s[i+k] < s[j+k] ) ( j += k + 1 ) += ( i == j );
    }
    i = min( i , j ) , j = i + n - 1;
    for (int p = i; p <= j; p++) printf( "%d " , s[p] );
    return puts("") , 0;
}

后缀自动机

确定性有限状态自动机,识别且仅识别一个字符串的所有后缀。

采用增量法构造,详见『后缀自动机入门 SuffixAutomaton』

使用静态数组存转移边,时空复杂度\(O(n\Sigma)\),用链表可以将时间复杂度优化到\(O(n)\)。用平衡树存转移边,时间复杂度\(O(n\log \Sigma)\),空间复杂度\(O(n)\)

struct SuffixAutomaton
{
    int trans[N][26],link[N],maxlen[N],tot,last;
    // trans为转移函数,link为后缀链接,maxlen为状态内的最长后缀长度
    // tot为总结点数,last为终止状态编号
    SuffixAutomaton () { last = tot = 1; } // 初始化:1号节点为S
    inline void Extend(int c)
    {
        int cur = ++tot , p;
        maxlen[cur] = maxlen[last] + 1;
        // 创建节点cur
        for ( p = last; p && !trans[p][c]; p = link[p] ) // 遍历后缀链接路径
            trans[p][c] = cur; // 没有字符c转移边的链接转移边
        if ( p == 0 ) link[cur] = 1; // 情况1
        else {
            int q = trans[p][c];
            if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q; // 情况2
            else {
                int cl = ++tot; maxlen[cl] = maxlen[p] + 1; // 情况3
                memcpy( trans[cl] , trans[q] , sizeof trans[q] );
                while ( p && trans[p][c] == q )
                    trans[p][c] = cl , p = link[p];
                link[cl] = link[q] , link[q] = link[cur] = cl;
            }
        }
        last = cur;
    }
};

广义后缀自动机

确定性有限状态自动机,识别且仅识别字符串集合\(S\)中所有字符串的所有后缀。

构造方法与狭义后缀自动机类似,只需在转移边产生冲突时分裂节点即可。

时空复杂度均与后缀自动机相同。

值得一提的是,广义后缀自动机如果采用线段树合并来维护\(\mathrm{endpos}\)集合,则需\(\mathrm{dfs}\)遍历\(\mathrm{Parent}\)树来合并,不可以按照基数排序的拓扑序来合并

struct SuffixAutomaton
{
    int trans[N][26],link[N],maxlen[N],tot;
    SuffixAutomaton () { tot = 1; }
    inline int Extend(int c,int pre)
    {
        if ( trans[pre][c] == 0 )
        {
            int cur = ++tot , p;
            maxlen[cur] = maxlen[pre] + 1;
            for ( p = pre; p && !trans[p][c]; p = link[p] )
                trans[p][c] = cur;
            if ( p == 0 ) link[cur] = 1;
            else {
                int q = trans[p][c];
                if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q;
                else {
                    int cl = ++tot; maxlen[cl] = maxlen[p] + 1;
                    memcpy( trans[cl] , trans[q] , sizeof trans[q] );
                    while ( p && trans[p][c] == q )
                        trans[p][c] = cl , p = link[p];
                    link[cl] = link[q] , link[q] = link[cur] = cl;
                }
            }
            return cur;
        }
        else {
            int q = trans[pre][c];
            if ( maxlen[q] == maxlen[pre] + 1 ) return q;
            else {
                int cl = ++tot; maxlen[cl] = maxlen[pre] + 1;
                memcpy( trans[cl] , trans[q] , sizeof trans[q] );
                while ( pre && trans[pre][c] == q )
                    trans[pre][c] = cl , pre = link[pre];
                return link[cl] = link[q] , link[q] = cl;
            }
        }
    }
};

后缀树

将一个字符串\(s\)的所有后缀插入到一个\(\mathrm{Trie}\)树中,我们称这棵\(\mathrm{Trie}\)树所有叶子节点的虚树为这个字符串的后缀树。

根据\(\mathrm{endpos}\)等价类的定义及性质,容易得知原串倒序插入后缀自动机后的\(\mathrm{Parent}\)树就是该串的后缀树,所以可以用后缀自动机的构造方法求后缀树。

时间复杂度和后缀自动机的时间复杂度相同,可以\(O(n)\)顺带求后缀数组。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5+20;
struct SuffixAutomaton
{
    int trans[N][26],link[N],maxlen[N],tot,last;
    int id[N],flag[N],trie[N][26],sa[N],rk[N],hei[N],cnt;
    // id 代表这个状态是几号后缀 , flag 代表这个状态是否对应了一个真实存在的后缀
    SuffixAutomaton () { tot = last = 1; }
    inline void Extend(int c,int pos)
    {
        int cur = ++tot , p;
        id[cur] = pos , flag[cur] = true;
        maxlen[cur] = maxlen[last] + 1;
        for ( p = last; p && !trans[p][c]; p = link[p] )
            trans[p][c] = cur;
        if ( p == 0 ) link[cur] = 1;
        else {
            int q = trans[p][c];
            if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q;
            else {
                int cl = ++tot; maxlen[cl] = maxlen[p] + 1;
                memcpy( trans[cl] , trans[q] , sizeof trans[q] );
                while ( p && trans[p][c] == q )
                    trans[p][c] = cl , p = link[p];
                link[cl] = link[q] , id[cl] = id[q] , link[q] = link[cur] = cl;
            }
        }
        last = cur;
    }
    inline void insert(int x,int y,char c) { trie[x][c-'a'] = y; }
    inline void Build(char *s,int n)
    {
        for (int i = n; i >= 1; i--)
            Extend( s[i]-'a' , i );
        for (int i = 2; i <= tot; i++)
            insert( link[i] , i , s[ id[i] + maxlen[link[i]] ] );
    }
    inline void Dfs(int x)
    {
        if ( flag[x] ) sa[ rk[id[x]] = ++cnt ] = id[x];
        for (int i = 0 , y; i < 26; i++)
            if ( y = trie[x][i] ) Dfs(y);
    }
    inline void Calcheight(char *s,int n)
    {
        for (int i = 1 , k = 0 , j; i <= n; i++)
        {
            if (k) --k; j = sa[ rk[i]-1 ];
            while ( s[ i+k ] == s[ j+k ] ) ++k;
            hei[ rk[i] ] = k;
        }
    }
};
SuffixAutomaton T; char s[N];
int main(void)
{
    scanf( "%s" , s+1 );
    int n = strlen( s+1 );
    T.Build( s , n ) , T.Dfs(1);
    T.Calcheight( s , n );
    for (int i = 1; i <= n; i++)
        printf( "%d%c" , T.sa[i] , " \n"[ i == n ] );
    for (int i = 2; i <= n; i++)
        printf( "%d%c" , T.hei[i] , " \n"[ i == n ] );
    return 0;
}

回文自动机

确定性有限状态自动机,识别且仅识别一个字符串\(s\)的所有回文字串的右半部分

由于回文串分奇偶,所以回文自动机有两个初始状态,分别代表奇回文串和偶回文串。

可以使用数学归纳法证明,字符串\(s\)最多只有\(|s|\)个本质不同的回文字串,所以回文自动机的一个状态就代表一个回文字串。而回文自动机的一条转移边就代表在原串的两边各加一个字符,这样转移后的字符串仍然是回文串,同时也解释了为什么回文自动机只识别回文串的右半部分。

回文自动机同样采用增量法构造。对于每一个状态,我们额外记录其最长回文后缀所对应的状态,称为\(\mathrm{link}\)函数。当我们在字符串末尾插入一个字符时,我们从原串最后的状态开始跳\(\mathrm{link}\),直至可以构成回文串,并确定新的状态。

对于新的状态,仍然可以继续跳\(\mathrm{link}\),找到其最长回文后缀。

可以把回文自动机看作两棵树,也称为回文树。对于\(\mathrm{link}\)指针,也构成了一棵树,可以称之为回文后缀树。定义势能函数\(\Phi(p)\)表示状态\(p\)在回文后缀树中的深度,根据构造算法,易知\(\Phi(p)\leq\Phi(\mathrm{link}(p))+1\),而跳\(\mathrm{link}\)则势函数减小。又因为回文自动机的状态数是\(O(n)\)的,回文后缀树的最大深度也就是\(n\),可以得知构造算法的时间复杂度不超过\(O(n)\)

其空间复杂度为\(O(n\Sigma)\),使用邻接表存边,时间复杂度升至\(O(n\Sigma)\),空间复杂度降至\(O(n)\)。如果使用\(\mathrm{Hash}\)表存边,时空复杂度均降至\(O(n)\)

由于一个回文串的最长回文后缀必然是它的一个\(\mathrm{Border}\),所以回文树\(\mathrm{dp}\)可能用到\(\mathrm{Border\ Series}\)的等差性质。回文自动机中就会额外记录两个参量\(\mathrm{dif}\)\(\mathrm{slink}\)\(\mathrm{dif}(x)=\mathrm{len}(x)-\mathrm{len}(\mathrm{link}(x))\)\(\mathrm{slink}(x)\)记录了回文后缀树上\(x\)最深的一个祖先,满足\(\mathrm{dif}(\mathrm{slink}(x))\not=\mathrm{dif}(x)\),这些都可以在构造过程中顺带维护。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20 , Mod = 1e9 + 7;
struct PalindromesAutomaton
{
    int n,tot,last,link[N],slink[N],trans[N][26],len[N],dif[N],s[N];
    PalindromesAutomaton(void)
    {
        len[ last = 0 ] = 0 , link[0] = 1;
        len[1] = -1 , tot = 1 , s[0] = -1;
    }
    inline void Extend(int c)
    {
        int p = last; s[++n] = c;
        while ( s[n] != s[ n - len[p] - 1 ] ) p = link[p];
        if ( trans[p][c] == 0 )
        {
            int cur = ++tot , q = link[p];
            len[cur] = len[p] + 2;
            while ( s[n] != s[ n - len[q] - 1 ] ) q = link[q];
            link[cur] = trans[q][c] , trans[p][c] = cur;
            dif[cur] = len[cur] - len[ link[cur] ];
            if ( dif[cur] != dif[ link[cur] ] ) slink[cur] = link[cur];
            else slink[cur] = slink[ link[cur] ];
        }
        last = trans[p][c];
    }
};

猜你喜欢

转载自www.cnblogs.com/Parsnip/p/12369642.html