字尾自動機學習筆記

Neal_lee發表於2021-07-05

定義和一些記號

定義

自動機由 \(5\) 個模組組成:

  1. \(\Sigma\) :字符集
  2. \(state\) : 狀態集合
  3. \(init\) : 初始狀態(只有一個)
  4. \(end\) : 終止狀態集合
  5. \(Trans(s,c)\) : 轉移函式,狀態 \(s\) 在接受字元 \(c\) 之後,返回下一個狀態

一個字串 \(S\) 的字尾自動機(\(SAM\),Suffix Automaton)是其最小的確定有限狀態自動機(\(DFA\) ,Deterministic Finite Automaton),僅接受\(S\) 的所有字尾,即只在輸入字尾後,自動機會達到終止狀態。

記號

\(null\) : 不存在狀態,若 \(s\) 無法接受字元 \(c\),則 \(Trans(s,c)=null\)。同時定義 \(Trans(null,*)=null\)

\(ST(str)\)\(Trans(init,str)\),即從初始狀態開始輸入 \(str\) 之後得到的狀態。

\(str[l,r]\) : 字串 \(str\) 中下標 \(\in[l,r]\) 的子串

\(Fac\) : 子串集合

\(Right(s)\) : 狀態 \(s\) 在字串中出現的所有位置的集合,即 \(Right(s)=\{r\;|ST(str[l,r])=s\}\)

\(Reg(A)\) : 自動機 \(A\) 能夠識別的字串的集合

\(Reg(s)\) : 從狀態 \(s\) 開始能夠識別的字串的集合,即 \(Reg(s)=\{str\;|Trans(s,str)\in end\}\)

 

前置性質

如果用樸素的想法去實現 \(SAM\) 的功能,其實只需將字串 \(str\) 的所有字尾拿出來建一棵 \(Trie\) 即可,不過這樣的狀態數是 \(O(n^2)\) 級別的,我們需要將自動機的狀態數簡化。觀察兩個串 \(a\)\(b\),如果 \(Reg(a)=Reg(b)\),可以發現兩者在自動機上被處理的方式完全相同,從而我們將這些串合併起來,儲存在同一個狀態 \(s\) 中。

 

在進一步討論之前,我們先分析一些性質。

  1. 達到狀態 \(s\) 的字串其長度構成一段區間,我們設其為 \([Min(s),Max(s)]\)

這些字串達到狀態 \(s\),說明它們在原串中的出現次數等於 \(|Right(s)|\),而這些串之間為字尾關係,一個串在原串中的出現次數隨著其長度增加而減小,且其母串的出現位置為自己的子集,所以出現次數為 \(|Right(s)|\) 這一值的串長構成一段區間。

  1. 對於兩個狀態 \(a\)\(b\),要麼 \(Right(a)\bigcap Right(b)=\phi\),要麼 \(Right(a)\subset Right(b)\) 或者 \(Right(a)\supset Right(b)\)

考慮 \(Right(a)\bigcap Right(b)\neq \phi\) 的情況,設 \(r\in Right(a)\bigcap Right(b)\),對於這個位置,兩者的 \(len\) 區間不能有交,否則就存在一個字串同時能夠到達兩個狀態了,由於 \(SAM\)\(DFA\),這種情況不被允許。那麼我們設 \(Max(a) < Min(b)\),則 \(a\) 中所包含的字串均為 \(b\) 中字串的字尾,\(b\) 中字串是由 \(a\) 中延伸出來的,從而有 \(Right(a)\supset Right(b)\)

 

有了性質 \(2\),我們可以用樹的方式將狀態之間聯絡起來,引入父親的概念:

\(par(s)\) : 最小的滿足 \(Right(s)\) 為其 \(Right\) 子集的狀態

我們將建出的這棵樹稱為 \(Parent\;\;Tree\),再分析 \(Parent\;\;Tree\) 的性質:

  1. 每一個非葉子節點都有至少 \(2\) 個兒子。

由於父親兒子之間的 \(Right\) 是真子集關係,而每一個位置都要被一個狀態認領,性質成立。

  1. \(Parent\;\;Tree\) 至多有 \(n\) 個葉子節點,總共至多有 \(2n-1\) 個節點。

葉子節點就是字串匹配到最後的情況,僅剩下一個可能位置,顯然至多有 \(n\) 種這樣的情況,比如 \(str=aaaaa\) 的時候,葉子節點就只有 \(1\) 個。由於至少是二叉樹,顯然至多有 \(2n+1\) 個節點。

由於 \(Parent\;\;Tree\) 是由所有狀態構成的,性質 \(4\) 說明了 \(SAM\) 的狀態數至多有 \(2n-1\) 種。

 

我們再來證明 \(SAM\) 狀態之間的轉移數量是 \(O(n)\) 的。

  1. \(SAM\) 的狀態轉移數量 \(\leq 3n-4\)

我們取出 \(SAM\) 狀態轉移關係構成的圖的最長路徑生成樹。

對於樹邊,根據前面的結論其數量 \(\leq 2n-2\)

對於非樹邊,考慮一條非樹邊 \(a\rightarrow b\),我們先從 \(init\) 沿著樹邊走到 \(a\),然後經過 \(a\rightarrow b\) 走到 \(b\),令這條非樹邊對應所有 \(b\) 最終可以到達的字尾,顯然至少有一個。對於每一個字尾,如果它匹配的過程中經過了非樹邊,則讓其對應經過的第一條非樹邊,從而每個字尾最多對應一條非樹邊。所以非樹邊的數量 \(\leq\) 字尾的數量,由於這裡是最長路徑生成樹,\(str\) 本身不經過非樹邊,所以非樹邊數量 \(\leq n-1\)

但是兩種情況無法同時取等,可以說明總共的轉移數量 \(\leq 3n-4\)

 

在愉快地構建 \(SAM\) 之前,再觀察一些性質,記 \(t=Trans(s,c), \;fa=par(s)\)

  1. \(Right(t)=\{r+1\;|r\in Right(s),s[r+1]=c\}\)\(SAM\) 是一個 \(DAG\)

前者根據定義顯然,由於 \(r\) 一直在增加,不會出現環的情況,所以 \(SAM\) 是一個 \(DAG\)

  1. \(Max(t)>Max(s)\)

\(Max(s)\) 對應的那個字串多增加了 \(c\) 字元,顯然要變大了。

  1. \(Trans(s,c)\neq null \Rightarrow Trans(fa,c)\neq null\)

\(Right(fa)\supset Right(s)\),顯然

  1. \(Max(fa)=Min(s)-1\)

根據合法的 \(len\) 的連續性可證。

  1. \(Right(Trans(s,c))\subseteq Right(Trans(fa,c))\)

\(Parent\;\;Tree\) 的包含關係所保證。

  1. \(ST(str)\neq null\iff str\in Fac\)

這些性質不會在構造中全部用到,但是在實際應用 \(SAM\) 的時候很重要。

 

構建 \(SAM\)

假設當前已經有了 \(T\)\(SAM\),考慮構建 \(T+x\)\(SAM\),設 \(L=|T|\)

  • 找到狀態 \(u\) 滿足 \(Right(u)=\{L\}\)\(u\)\(Parent\;\;Tree\) 的一個葉子節點。

  • 構造狀態 \(u'\)\(Right(u')=\{L+1\}\)\(Max(u')=L+1\)

  • 現在考慮從 \(u\)\(init\) 的這一段鏈,將節點依次記為 \(v_1,v_2,...,v_k\)\(v_k=init\)),找到 \(Trans(v,x)=null\) 的所有狀態,它們應為 \(\{v\}\) 的一段字首,令 \(Trans(v,x)=u'\)

  • 找到第一個 \(Trans(v,x)\neq null\) 的狀態 \(v_p\)

    \(v_p\) 不存在,則令 \(par(u')=init\) (因為鏈上除了 \(init\) 沒有節點的 \(Right\) 能包含 \(u'\)

    \(v_p\) 存在,設 \(q=Trans(p,x)\),有 \(Max(q)\geq Max(v_p)+1\),分兩種情況:

    (圖中的橫線為 \([r_i-Max(s)+1, r_i]\)

字尾自動機學習筆記
  1. \(Max(q)=Max(v_p)+1\)

    此時 \(v_p\) 中的所有字串都可以直接擴充到 \(L+1\),因為紅線和藍線左端對齊了,那麼紅線區域中的字串可以,藍色的也可以。令 \(par(u')=q\) 即可。

  2. \(Max(q)>Max(v_p)+1\)

    這個時候 \(q\) 中的串不止 \(vp\) 中的,有一些更長的可能匹配不了 \(x\),如右邊藍色虛線部分,如果強行轉過去,\(Max(q)\) 就反而變小了,這可能會導致一系列問題。為了解決這種情況,我們新建一個結點 \(nq\),將 \(q\)\(len\leq Max(v_p)+1\) 的字串給 \(nq\)\(L+1\) 那一塊轉移也交給 \(nq\) 處理。

    由於 \(L+1\) 以外的位置還沒法轉移,所以 \(nq\) 的轉移情況是和 \(q\) 完全一樣的,\(Trans(nq,*)=Trans(q,*)\)。而 \(par(nq)\) 即為先前 \(SAM\) 中的 \(par(q)\),由圖,可以知道有這樣的變化:

    \[par(q)\leftarrow nq,\; par(u')\leftarrow nq \]

    然後我們找到所有滿足 \(Trans(v,x)=q\) 的結點(是 \(v_p\)\(v_e\) 的一段區間),現在 \(q\) 已經滿足不了 \(L+1\) 的轉移需求了,但是 \(nq\) 可以,所以將 \(Trans(v,x)\) 改為 \(nq\)

然後就沒了。初始是 \(SAM\) 是單獨一個結點 \(init\),每次增量構造即可。

 

時間複雜度

  1. 對於原先 \(Trans(v,x)=null\),後來改為 \(u'\) 的結點,這個操作增加了一個轉移,先前證轉移數是 \(O(n)\) 的,從而該操作是 \(O(n)\) 的。
  2. 增加新結點 \(nq\) 的操作是和總結點數掛鉤的,總節點數 \(O(n)\),從而此操作 \(O(n)\)
  3. 對於將 \(Trans(v,x)\) 改為 \(nq\) 這個操作,時間複雜度證明:

\(lst\) 為上一次的 \(u\),每一次構造後它變為 \(u'\),觀察 \(Min(par(lst))\) 的變化情況:

\[Min(u)=Min(v_1)>Min(v_2)>...>Min(v_e)\geq Min(q)-1\geq Min(nq)-1 \]

倒數第二個大於等於是因為 \(q\) 向右擴充了一個 \(x\),所以會增加,最後一個大於等於即繼承關係。可以發現在鏈上往上走的過程中,\(Min\) 一直減小,到了 \(q\) 處才可能會加 \(1\),而下一次往上跳的時候經過 \(nq\) 就直接到 \(v_{e-1}\) 處了,\(Min\) 會減少至少 \(1\),而 \(Min\) 最大為 \(|S|\),從而總時間複雜度為 \(O(n)\) 的。

綜上,\(SAM\) 的構造時間複雜度為 \(O(n)\)

 

程式碼實現

話說著很多,但是程式碼還挺短的,這裡的 \(len\)\(Max(s)\)\(link\)\(par(s)\)

struct Suffix_Automaton{
    struct state{
        int len, link;
        int next[26];
    } t[N<<1];
    int cnt, lst;

    void init(){
        t[1].len = 0, t[1].link = 0;
        mem(t[1].next, 0);
        cnt = 1, lst = 1;
    }

    void extend(int c){
        int cur = ++cnt;
        t[cur].len = t[lst].len+1;
        mem(t[cur].next, 0);
        int u = lst;
        while(u && !t[u].next[c])
            t[u].next[c] = cur, u = t[u].link;
        if(u == 0) t[cur].link = 1;
        else{
            int q = t[u].next[c];
            if(t[q].len == t[u].len+1) t[cur].link = q;
            else{
                int nq = ++cnt;
                memcpy(t[nq].next, t[q].next, sizeof(t[nq].next));
                t[nq].link = t[q].link;
                t[q].link = t[cur].link = nq;

                while(u && t[u].next[c] == q)
                    t[u].next[c] = nq, u = t[u].link;
            }
        }
        lst = cur;
    }
} SAM;

 

應用

\(SAM\) 其特殊之處在於它維護的是字尾資訊,但是讀取串卻是從前往後讀的,所以它能夠解決眾多子串型別的問題,其中最典型的一個,即 \(ST(T)\neq null \iff T\in Fac\)

CF123D String

給定一個串 \(S\),記 \(f(t)\) 為串 \(t\)\(S\) 的出現次數,求 \(\sum_{t\subseteq S}\frac{f(t)(f(t)-1)}{2}\)\(|S|\leq 10^5\)

我們只需要維護每個子串的出現次數即可,即該串對應的狀態 \(s\)\(|Right(s)|\),注意到 \(Right(s)\) 即為其子樹中所有節點 \(Right\) 的並集,那麼只需在建 \(SAM\) 的時候,在第一個 \(Right\) 中包含 \(L+1\) 的節點,即新添的 \(u'\) 上記錄一下即可。具體來說,維護狀態的一個屬性 \(siz=|Right(s)|\),每次在 \(u'\) 的地方 \(siz\) 設為 \(1\),表示 \(L+1\) 的出現,然後自底向上貢獻到父親的 \(siz\) 中。一個節點中的字串數量即為 \(Max(s)-Min(s)+1=Max(s)-Max(par(s))\),將它們的值加入答案中即可。

時間複雜度 \(O(n)\)

Code

#include<iostream>
#include<cstring>
#define mem(a,b) memset(a, b, sizeof(a))
#define rep(i,a,b) for(int i = (a); i <= (b); i++)
#define per(i,b,a) for(int i = (b); i >= (a); i--)
#define N 101000
#define ll long long
#define t(x) SAM.t[x]
using namespace std;

struct Suffix_Automaton{
    struct state{
        int len, link, siz;
        int next[26];
    } t[N<<1];
    int cnt, lst;

    void init(){ cnt = lst = 1; }

    void extend(int c){
        int cur = ++cnt;
        t[cur].len = t[lst].len+1, t[cur].siz = 1;
        int u = lst;
        while(u && !t[u].next[c])
            t[u].next[c] = cur, u = t[u].link;
        
        if(u == 0) t[cur].link = 1;
        else{
            int q = t[u].next[c];
            if(t[q].len == t[u].len+1) t[cur].link = q;
            else{
                int nq = ++cnt;
                t[nq].len = t[u].len+1; 
                memcpy(t[nq].next, t[q].next, sizeof(t[nq].next));
                t[nq].link = t[q].link;
                t[q].link = t[cur].link = nq;
                
                while(u && t[u].next[c] == q)
                    t[u].next[c] = nq, u = t[u].link;
            }
        }
        lst = cur;
    }
} SAM;

int head[2*N], to[2*N], nxt[2*N];
int cnt;
string s;
ll ans;

void init(){ mem(head, -1), cnt = -1; }
void add_e(int a, int b){
    nxt[++cnt] = head[a], head[a] = cnt, to[cnt] = b;
}

void dfs(int x){
    for(int i = head[x]; ~i; i = nxt[i])
        dfs(to[i]), t(x).siz += t(to[i]).siz;
    ans += (ll)(t(x).len-t(t(x).link).len) * t(x).siz * (t(x).siz+1) / 2;
}

int main(){
    ios::sync_with_stdio(false);
    cin>>s;
    SAM.init();
    for(char c : s) SAM.extend(c-'a');
    init();
    rep(i,2,SAM.cnt) add_e(t(i).link, i);
    dfs(1);
    cout<<ans<<endl;
    return 0;
}

 

CF235C Cyclical Quest

給定一個主串 \(S\)\(n\) 個串 \(x\),求每個串 \(x\) 的迴圈同構串在 \(S\) 中的出現次數。

\(|S|\leq 10^6,\;n\leq 10^5,\;\sum|x|\leq 10^6\)

先通過和上面相同的方式將每個狀態對應的字串數量求出來,要求迴圈同構串的出現次數,考慮將 \(x_i\) 重複兩遍作為一個串,在 \(SAM\) 上找所有匹配長度 \(\geq |x_i|\) 的位置數量。

在匹配的過程中,如果當前 \(trans(st,c)\neq null\),則直接走,匹配長度 \(L++\) ,否則一直跳 \(par(st)\) 直到存在轉移,如果最終不存在,則 \(st\) 回到 \(init\)\(L=0\),否則 \(L=Max(st)+1\) (這肯定是變小了),\(st=Trans(st,c)\) 。由於 \(|x_i|\) 可能還有迴圈節,我們直接匹配的時候還要注意去重,若 \(L\geq |x_i|\),則一直跳 \(par(st)\) 直到 \(Max(par(st))<L\),此時的 \(st\) 即為該迴圈節真正匹配上的狀態,\(ans\leftarrow siz_{st}\),在這個節點上記錄此次匹配,防止一個狀態被重複匹配。

時間複雜度 \(O(n+\sum|x|)\)

Code

Automaton 中的內容和上一份程式碼完全一樣,就不貼了。

#include<iostream>
#include<cstring>
#define mem(a,b) memset(a, b, sizeof(a))
#define rep(i,a,b) for(int i = (a); i <= (b); i++)
#define per(i,b,a) for(int i = (b); i >= (a); i--)
#define N 1001000
#define t(x) SAM.t[x]
using namespace std;

struct Suffix_Automaton{ ... } SAM;

int head[N<<1], to[N<<1], nxt[N<<1];
int cnt;
string s, x;

void init(){ mem(head, -1), cnt = -1; }
void add_e(int a, int b){
    nxt[++cnt] = head[a], head[a] = cnt, to[cnt] = b;
}
void dfs(int x){
    for(int i = head[x]; ~i; i = nxt[i])
        dfs(to[i]), t(x).siz += t(to[i]).siz;
}

int main(){
    ios::sync_with_stdio(false);
    cin>>s;
    SAM.init();
    for(char c : s) SAM.extend(c-'a');
    init();
    rep(i,2,SAM.cnt) add_e(t(i).link, i);
    dfs(1);

    int T; cin>>T;
    rep(o,1,T){
        cin>>x;
        int m = x.size();
        x += x;
        int st = 1, l = 0, ans = 0;
        rep(i,0,2*m-2){
            int c = x[i]-'a';
            if(t(st).next[c]) l++, st = t(st).next[c];
            else{
                while(st && t(st).next[c] == 0) st = t(st).link;
                if(st == 0) st = 1, l = 0;
                else l = t(st).len+1, st = t(st).next[c];
            }
            if(i >= m-1 && l >= m){
                while(t(t(st).link).len >= m) st = t(st).link;
                if(t(st).vis < o) ans += t(st).siz, t(st).vis = o;
                l = m;
            }
        }
        cout<<ans<<endl;
    }
    return 0;
}

 

UOJ#395. 【NOI2018】你的名字

待更。

相關文章