1. 程式人生 > 其它 >自動機入門——字尾自動機

自動機入門——字尾自動機

自動機入門——字尾自動機

1 資料結構簡介

字尾自動機是一個可以解決許多字串相關問題的有力的資料結構,字串的 SAM 可以理解為給定字串的所有子串的壓縮形式,SAM 的空間複雜度和構造的時間複雜度均為線性的,準確的說,一個 SAM 最多有 \(2n-1\) 個節點和 \(3n-4\) 條轉移邊。

2 定義

字串 \(s\) 的 SAM 是一個接受 \(s\) 的所有後綴的最小 DFA(確定性有限自動機或確定性有限狀態自動機)。

換句話說:

  • SAM 是一張有向無環圖。結點被稱作 狀態,邊被稱作狀態間的 轉移
  • 圖存在一個源點 \(t_0\),稱作 初始狀態,其它各結點均可從 \(t_0\) 出發到達。
  • 每個 轉移 都標有一些字母。從一個結點出發的所有轉移均 不同
  • 存在一個或多個 終止狀態。如果我們從初始狀態 \(t_0\) 出發,最終轉移到了一個終止狀態,則路徑上的所有轉移連線起來一定是字串 \(s\) 的一個字尾。\(s\) 的每個字尾均可用一條從 \(t_0\) 到某個終止狀態的路徑構成。
  • 在所有滿足上述條件的自動機中,SAM 的結點數是最少的。

3 性質

SAM 包含關於字串 \(s\) 的所有子串的資訊,任意從初始狀態開始的路徑,如果我們將轉移路徑上的標號寫下來都會形成一個 \(s\) 的子串,反之每一個 \(s\) 的子串對應從 \(t_0\) 開始的某條路徑。

為了簡化表達,我們稱子串對應一條路徑,反過來,我們說任意一條路徑對應它的標號構成的字串。

到達某個狀態的路徑可能不止一條,因此我們說一個狀態對應一些字串的集合這個集合的每一個元素對應這些路徑。

4 構造

4.1 結束位置

結束位置 \(endpos\) 是一個比較重要的概念。

考慮字串 \(s\) 的任意非空子串 \(t\) ,我們記 \(endpos(t)\) 為在字串 \(s\)\(t\) 出現的所有結束位置。兩個子串的 \(t_1\)\(t_2\)\(endpos\) 集合可能相等,我們稱如果兩個子串的 \(endpos\) 集合相等,我們都可以根據它們的 \(endpos\) 集合分為若干等價類。

SAM 中的每一個狀態都對應一個等價類,也就是說 SAM 的狀態總數為等價類的個數 \(+1\)

(初始節點)。

  • 引理 \(1\)

    字串 \(s\) 的兩個非空子串 \(u,w\) ,(假設 \(|u|<|v|\))的 \(endpos\) 相同,當且僅當字串 \(u\)\(s\) 中的每次出現,都是以 \(w\) 的字尾形式存在的。

    引理顯然成立。

  • 引理 \(2\)

    考慮兩個非空子串 \(u,w\) (假設 \(|u|\le |w|\)),那麼要麼 $endpos(u)\cap endpos(w)=\varnothing $ ,要麼 \(endpos(w)\subseteq endpos(u)\) ,這取決與 \(u\) 是否為 \(w\) 的一個字尾,如果不是,就是前者,否則就是後者。

    其實也比較顯然,因為如果不是字尾,顯然 \(w\) 出現的地方 \(u\) 不可能出現,所以是空集,如果是字尾,那麼長度小的有可能出現在更多地方,並且一定在 \(w\) 都出現的地方出現過。

  • 引理 \(3\)

    考慮一個 \(endpos\) 等價類,對於同一等價類中的任意兩個子串,較短者為較長者的字尾,且該等價類中的子串長度是連續的。

    顯然前面這個字尾關係是顯然的,我們來證明它們是連續的。如果不連續,那麼設字串 \(q\) 為夾在兩個屬於同一等價類的字串 \(s,t(|s|<|t|)\) 之間的一個字串,且 \(q\)\(t\) 的字尾,\(s\)\(q\) 的字尾,根據引理 \(2\) ,不難推出矛盾。

通過 SAM 的轉移,即一些有向邊,通過不同的方式走到狀態 \(u\) ,我們就可以得到狀態 \(u\) 對應的等價類所對應的所有字串。

考慮 SAM 中某一個不是 \(t_0\) 的狀態 \(v\) ,我們已經知道,狀態 \(v\) 對應於具有相同 \(endpos\) 的等價類,設 \(w\) 是最長的一個,那麼所有等價類中的字串都是 \(w\) 的字尾。

我們還知道字串 \(w\) 的前幾個字尾全部包含於這個等價類,且所有其它字尾都在其他的等價類中,我們記 \(t\) 為最長的等價類不和 \(w\) 的相同的字尾。然後將 \(v\) 的字尾連結連到 \(t\) 的等價類上。

為了方便,我們規定:\(endpos(t_0)=\{-1,0,...,|s|-1\}\)

  • 引理 \(4\)

    所有的字尾連結構成一棵根節點為 \(t_0\) 的樹。

    比較顯然,首先一定有 \(n-1\) 條邊,其次因為字串長度遞減,所以不會出現環。然後一直遞減,一定會到達初始狀態 \(t_0\)

  • 引理 \(5\)

    通過 \(endpos\) 集合構造的樹(每個子節點的 \(subset\) 都包含在父節點的 \(subset\) 中)與通過後綴連結 \(link\) 構造的樹相同。

    由引理 \(2\) ,這種實質是字尾關係的 \(endpos\) 能夠形成一棵樹。我們考慮不是 \(t_0\) 的狀態 \(v\) ,顯然有 \(endpos(v)\subsetneq endpos(link(v))\)。所以定理成立。

    實際上這個定理有一個約束條件是父節點其實是滿足條件的長度最長的。

4.3 小結

  • \(s\) 的子串可以被劃分成多個等價類。
  • SAM 由若干狀態構成,其中每一個狀態對應一個等價類。對於每一個狀態 \(v\) ,一個或多個子串與之匹配,我們記 \(longest(v)\) 為裡面最長的一個,記 \(len(v)\) 為它的長度,記 \(shortest(v)\) 為最短的子串,它的長度為 \(minlen(v)\) ,那麼所有字串的長度恰好覆蓋 \([minlen(v),len(v)]\) 中的每一個整數。
  • 字尾連結可以定義為連線到對應字串 \(longest(v)\) 的長度為 \(minlen(v)-1\) 的字尾的一條邊。從根節點 \(t_0\) 出發的字尾連結可以形成一棵樹。這棵樹也表示 \(endpos\) 集合間的包含關係。
  • 我們有 \(minlen(v)=len(link(v))+1\)
  • 如果我們從 \(v_0\) 開始一直走到 \(t_0\) ,那麼沿途所有字串的長度形成了連續的區間 \([0,len(v_0)]\)

4.4 演算法

這個演算法是一個線上演算法,可以逐個加入字串中的每個字元並在每一步維護 SAM。

為了保證線性的時間複雜度,我們將只儲存 \(len\)\(link\) 的值和每個狀態的轉移列表,我們不會標記終止狀態,但這些標記可以被分配,我們後面將會展示。

一開始 SAM 只包含一個狀態 \(t_0\) ,編號為 \(0\) ,對於狀態 \(t_0\) 我們指定 \(len=0,link=-1\) 。(這裡 \(-1\) 就是一個虛擬狀態)

現在任務轉化為實現給當前字串新增一個字元 \(c\) 的過程,演算法流程如下:

  • \(last\) 為新增字元 \(c\) 之前,整個字串對應的狀態。
  • 建立一個新的狀態 \(cur\) ,並將 \(len(cur)\) 賦值為 \(len(last)+1\)
  • 現在我們從狀態 \(last\) 開始按一下流程進行:如果沒有字元 \(c\) 的轉移,我們就新增一個到狀態 \(cur\) 的轉移,遍歷字尾連結,如果在某個點已經存在字元 \(c\) 的轉移,我們就停下來,並將這個狀態標記為 \(p\)
  • 如果沒有找到這樣的狀態 \(p\) ,我們就到達了虛擬狀態 \(-1\) ,我們將 \(link(cur)\) 賦值為 \(0\) 並退出。
  • 假設現在我們找到了一個狀態 \(p\) ,其可以通過字元 \(c\) 轉移,我們將轉移到的狀態記為 \(q\)
  • 如果 \(len(p)+1=len(q)\) ,我們只需要將 \(link(cur)\) 賦值為 \(q\) 並退出。
  • 否則,我們需要複製狀態 \(q\) ,我們建立一個新的狀態 \(clone\) ,複製 \(q\) 的除了 \(len\) 的值以外的所有資訊(字尾連結和轉移)。我們將 \(len(clone)\) 賦值為 \(len(p)+1\) 。複製之後,我們將字尾連結從 \(cur\) 指向 \(clone\) ,也從 \(q\) 指向 \(clone\) 。最終我們需要使用字尾連結從狀態 \(p\) 往回走,只要存在一條通過 \(p\) 到狀態 \(q\) 的轉移,就將該轉移重新定向到狀態 \(clone\)
  • 以上三種情況,在完成這個過程之後,我們將 \(last\) 的值更新為 \(cur\)

而終止狀態我們只需要從最後的 \(last\) 順著字尾連結走,一直到初始狀態,我們完整的遍歷了一遍字串 \(s\) 的所有後綴,這些狀態都是初始狀態。

因為我們只對 \(s\) 的每一個字元建立一個或兩個新狀態,所以 SAM 只包括線性個狀態。

4.5 正確性證明

  • 如果一個轉移 \((p,q)\) 滿足 \(len(p)+1=len(q)\) ,則我們稱這個轉移是連續的。否則,即當 \(len(p)+1<len(q)\) 時稱其為不連續的。連續的轉移是固定的,而不連續的轉移可能會改變。

  • 為了避免引起歧義,我們稱 SAM 中插入當前字元 \(c\) 之前的字串為 \(s\)

  • 演算法從建立一個新狀態 \(cur\) 開始,對應於整個字串 \(s+c\) ,我們建立一個新的節點的原因很清楚,就是要建立一個包含 \(endpos(s+c)\) 的等價類。

  • 在建立一個新的狀態之後,我們會從對應整個字串 \(s\) 的狀態通過後綴連結進行遍歷,對於每一個狀態,我們嘗試新增一個通過字元 \(c\) 到新狀態 \(cur\) 的轉移。然而我們只能新增原有轉移不衝突的轉移。因此我們只要找到已存在的 \(c\) 的轉移,我們就必須停止。

  • 換句話說,當我們加入一個字元 \(c\) 的時候,會產生 \(|s|\) 個新的字尾,我們不斷跳字尾連結,其實就是不斷跳 \(s\) 的字尾,然後如果不衝突我們就連一條到 \(cur\) 的邊。

  • 如果不存在衝突,也就是說我們到達了虛擬狀態 \(-1\) ,那意味著我們為所有 \(s\) 的字尾所對應的狀態添加了轉移 \(c\) ,這同時也意味著 \(c\) 之前從來沒有在字串中出現過,所以顯然 \(cur\) 的字尾連結為 \(0\)

  • 否則,在第二種情況下,我們找到了轉移 \((p,q)\) ,這意味著我們嘗試向自動機內新增一個已經存在的字串 \(x+c\),其中 \(x\)\(s\) 的字尾,對應狀態 \(p\) 中最長的字串,並且 \(x+c\) 已經作為一個 \(s\) 的子串出現過了。顯然,如果我們接著狀態 \(p\) 繼續向上跳字尾連結,這些狀態也肯定有一個字元 \(c\) 的轉移,因為他們都是字串 \(x\) 的字尾。

  • 因為字串 \(s\) 的自動機的構造是正確的,所以我們不應該在這裡新增一個新的轉移,可是難點在於,\(cur\) 的字尾連結應該連線到哪一個狀態呢?根據前面所說字尾連結的定義,顯然我們要把這個字尾連結連到一個狀態上,滿足這個狀態最長的一個字串為 \(x+c\)

  • 如果轉移 \((p,q)\) 是連續的,即 \(len(q)=len(p)+1\),說明狀態 \(q\) 裡最長的子串就是 \(x+c\) ,我們把 \(cur\) 的字尾連結連到 \(q\) 上就可以。

  • 但是如果 \((p,q)\) 不連續,即 \(len(q)>len(p)+1\) ,注意這裡不可能出現小於,因為狀態 \(q\) 中一定包含著字串 \(x+c\) 。如果不連續,就意味著有多個狀態轉移到 \(q\) ,並且 \(q\) 裡面最長的字串並不是 \(x+c\) ,我們除了將 \(q\) 拆成兩個狀態沒有其他方法。

    如何拆開一個狀態呢?我們先複製狀態 \(q\)\(clone\) ,我們將 \(len(clone)\) 賦值為 \(len(p)+1\) 。由於我們不想改變遍歷到 \(q\) 的路徑,我們將 \(q\) 的所有轉移複製到 \(clone\) ,我們也將從 \(clone\) 出發的字尾連結設定為 \(q\) 的字尾連結並將 \(q\) 的字尾連結連線到 \(clone\) 。然後把 \(cur\) 的字尾連結設定為 \(clone\)

    最後我們將一些到 \(q\) 的轉移從定向到 \(clone\) ,我們需要繼續沿著字尾連結遍歷,從節點 \(p\) 直到虛擬狀態 \(-1\) 或者轉移到不是狀態 \(q\) 的一個轉移。

    不難發現,這樣做之後,\(clone\) 相當於一個最長字串為 \(x+c\) 的狀態,由字尾連結的定義,不難發現 \(q\) 的字尾連結應該為 \(clone\) ,而 \(clone\) 的字尾連結應該為 \(q\) 原來的字尾連結,這樣做並不會出現兩個狀態等價類相同的情況,因為我們加入字元 \(c\) 之後狀態 \(clone\) 中的字元又出現了一遍,但是拆完之後的狀態 \(q\) 裡並沒有再出現一遍,因為如果狀態 \(q\) 裡面的某個字串出現過了,那麼因為 \(x\) 是它的字尾,所以我們一定不會找到 \(x\) 而會找到這個字串,但是我們找到的是 \(x\) ,所以這就推出矛盾。

    至於最後的定向,因為所有從 \(p\) 往前到以前 \(q\) 的轉移,設是從狀態 \(e\) 轉移過來的,其 \(len(e)\) 一定是小於等於 \(len(clone)-1\) ,也就是說,這些轉移以前都是轉移到現在 \(clone\) 的部分。正確性顯然。

4.6 對操作次數為線性的證明

一下我們認為字符集的大小為常數。

我們考慮演算法的各個部分,有三處時間複雜度不明顯是線性的:

  • 第一處是遍歷所有狀態 \(last\) 的字尾連結,新增字元 \(c\) 的轉移。
  • 第二處是當狀態 \(q\) 被複制到一個新的狀態 \(clone\) 時複製轉移的過程。
  • 修改指向 \(q\) 的轉移,將它們重定向到 \(clone\)

因為 SAM 的狀態數和轉移數(狀態數的線性證明在上面,轉移數在實現演算法的時候證明)為線性,所以第一處和第二處是線性的。

我們接下來證明第三處也是線性的。

在每一次新增字元時我們不妨關注一下 \(shortest(link(last))\) ,在向 \(s\) 中新增字元之前,有 \(shortest(p)\ge shortest(link(last))\) ,這是因為 \(link(last)\) 至多是 \(p\) ,我們由 \(q\) 拷貝得到了節點 \(clone\) ,並試圖從 \(p\) 沿字尾連結上溯,將所有通往 \(q\) 的轉移重定向為 \(clone\) ,這時 \(shortest(clone)\) 是嚴格變小的,加完字元後,我們有 \(last=cur\rightarrow link(last)=link(cur)=clone\) ,所以 \(shortest(link(last))\) 實在嚴格變小的,所以第三處是線性的。

4.7 程式碼實現

struct node{
    int len,link,ch[26];
};

struct SAM{
    node p[N];
    int size,last;
    inline SAM(){
        p[0].len=0;p[0].link=-1;size++;last=0;
    }
    inline void insert(char c){
        int cur=++size;
        p[cur].len=p[last].len+1;
        int now=last;
        while(now!=-1&&!p[now].ch[c]){
            p[now].ch[c]=cur;
            now=p[now].link;
        }
        if(now==-1) p[cur].link=0;
        else{
            int q=p[now].ch[c];
            if(p[now].len+1==p[q].len) p[cur].link=q;
            else{
                int clone=++size;
                p[clone]=p[now];p[clone].len++;
                while(now!=-1&&p[now].ch[c]==q){
                    p[now].ch[c]=clone;now=p[now].link;
                }
                p[now].link=p[cur].link=clone;
            }
        }
        last=cur;
    }
};

程式碼是顯然的,理解了上述過程後代碼就變得淺顯易懂。

5 例題

例題

我們挖掘一下 SAM link 的一些性質。我們首先把由字尾連結組成的樹建出來。然後把所有字首對應的狀態設為終點節點,我們在這顆樹上 dp,每一次記錄即可。

為什麼這樣做是對的,原因很簡單,考慮一下這棵樹上的葉子結點是什麼?是隻出現過一次的字首,我們從如果一個狀態不包含字首,那麼顯然它的所有兒子節點劃分了他的 \(endpos\) 集合,我們累加兒子的資訊就可以知道字串出現次數,但是如果有字首,那麼這個字首是不會被統計到的,我們額外給它加上,總而言之,我們直接在所有的字首狀態上加 \(1\) ,然後直接樹形 dp 就可以。

程式碼:

#include<bits/stdc++.h>
#define dd double
#define ld long double
#define ll long long
#define uint unsigned int
#define ull unsigned long long
#define N 2001000
#define M 4000000
using namespace std;

const int INF=0x3f3f3f3f;

inline int Max(int a,int b){
    return a>b?a:b;
}

template<typename T>  inline void read(T &x) {
    x=0; int f=1;
    char c=getchar();
    for(;!isdigit(c);c=getchar()) if(c == '-') f=-f;
    for(;isdigit(c);c=getchar()) x=x*10+c-'0';
    x*=f;
}

struct edge{
    int to,next;
    inline void intt(int to_,int ne_){
        to=to_;next=ne_;
    }
};
edge li[M];
int head[N],tail;

inline void add(int from,int to){
    li[++tail].intt(to,head[from]);
    head[from]=tail;
}

int f[N],ans;

struct node{
    int len,link,ch[26];
};

struct SAM{
    node p[N];
    int size,last;
    inline SAM(){
        p[0].len=0;p[0].link=-1;last=0;
    }
    inline void insert(int c){
        int cur=++size;f[size]++;
        p[cur].len=p[last].len+1;
        int now=last;
        while(now!=-1&&!p[now].ch[c]){
            p[now].ch[c]=cur;
            now=p[now].link;
        }
        if(now==-1) p[cur].link=0;
        else{
            int q=p[now].ch[c];
            if(p[now].len+1==p[q].len) p[cur].link=q;
            else{
                int clone=++size;
                p[clone]=p[q];p[clone].len=p[now].len+1;
                while(now!=-1&&p[now].ch[c]==q){
                    p[now].ch[c]=clone;now=p[now].link;
                }
                p[q].link=p[cur].link=clone;
            }
        }
        last=cur;
    }
};
SAM sam;

char s[N];

inline void dfs(int k){
//    printf("k:%d\n",sam.p[k].len);
    for(int x=head[k];x;x=li[x].next){
        int to=li[x].to;
        dfs(to);
        f[k]+=f[to];
    }
    if(f[k]>1) ans=Max(ans,f[k]*sam.p[k].len);
}

int main(){
    scanf("%s",s);
    for(int i=0;s[i];i++){
        sam.insert(s[i]-'a');
    }
    for(int i=1;i<=sam.size;i++){
//        printf("%d\n",sam.p[i].link);
        add(sam.p[i].link,i);
    }
    dfs(0);
    printf("%d",ans);
    return 0;
}

引用

有一些明顯的錯誤已經在本文改正。