後綴自動機(SAM)+廣義後綴自動機(GSA)

 

經過一頓操作之後竟然疑似沒退役0 0

你是XCPC選手嗎?我覺得我是!

稍微補一點之前丟給隊友的知識吧,除了數論以外都可以看看,為Dhaka和新隊伍做點準備…

不錯的零基礎教程見 IO WIKI – 後綴自動機,這篇就從自己的角度總結一下吧,感覺思路總是和別人不一樣…盡量用我比較能接受的語言和邏輯重新組織一遍。

注意:在本文中,字元串的下標從1開始。

目前的 SAM 模板:

const int N=100005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

View Code

目前的 GSA 模板:

typedef pair<int,int> pii;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //向trie樹上插入一個字元串 
    void insert(char *s,int n)
    {
        for(int i=1,cur=0;i<=n;i++)
        {
            int c=s[i]-'a';
            if(!nxt[cur][c])
                nxt[cur][c]=++sz;
            cur=nxt[cur][c];
        }
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}gsa;

View Code

 

簡單的目錄:

背景

概念:endpos 等價類

概念:SAM 的狀態

概念:後綴鏈接 link

SAM 的建立

正確性證明(其實沒寫啥…)

SAM 的性質

經典應用(SAM)

從 SAM 到廣義 SAM

廣義 SAM(GSA)的建立

經典應用(GSA) 

一些練習題

 

 

~ 背景 ~ (SAM 要解決什麼問題,為什麼「暴力」方法不行)

很久以前就聽說過 SAM,一直有一個不明覺厲的印象,導致一直沒敢看。但其實 SAM 本質上來說是一個偏板子的知識點(甚至板子也不算長),其中比較困難的地方在於對於 SAM 結構的理解,以及再次基礎上對於其性質的利用。網路上的各種材料,感覺大多從一個已經有一定理解的角度來進行講述,一開始看下來感覺有點概念不清晰。

簡單來說,SAM 想要解決的是一個這樣的問題:首先對於字元串 $s$ 建立一個自動機,再將另外的字元串$t$扔到自動機裡面逐字元進行匹配。假設目前已經匹配到了 $t[i]$,那麼這個匹配到的狀態表示的意思是「$t[1:i]$ 的 $i$ 個後綴中,最長的出現在 $s$ 中的連續子字元串」。

舉個例子,$s=abb$、$t=abbcab$,那麼匹配到 $t[6]=b$ 時,匹配到的狀態應該是 $ab$。雖然 $s$ 與 $t$ 的最長公共連續子字元串為 $abb$,但是因為 $abb$ 不是 $t[1:6]$ 的後綴,所以不是需要求的結果。

對於這個問題有一種非常暴力的做法,就是用 $s$ 的 $\dfrac{|s|\cdot (|s|-1)}{2}$ 個子串來建立 AC 自動機。在這個自動機上我們能夠匹配到 $s$ 的任意連續子字元串,於是能夠滿足我們的需求,但是狀態數直接爆炸了。我們需要一個類似這個 AC 自動機的結構(包含狀態、轉移邊和 fail 邊的自動機,通過轉移邊進行一次匹配,失配的時候通過 fail 邊往回跳),但是狀態數是 $O(|s|)$ 的。

 

 

~ 概念:endpos 等價類 ~ (規定一種將多個子串歸入到同一個狀態的規則)

 

為了將狀態由 $O(|s|^2)$ 壓縮到 $O(|s|)$,我們需要一個狀態能夠表示多個 $s$ 的子串。我們規定,若兩個 $s$ 的子串 endpos 等價,則其屬於同一個 SAM 狀態

那麼我們首先需要了解 endpos 的概念。對於子串 $s’\in s$,其 endpos 等於其出現在 $s$ 中的右端位置的集合。舉個例子來說,$s=abcabbacab$、$s’=ab$,我們可以通過肉眼發現 $s’$ 在 $s$ 中出現的位置有$[1:2]$、$[4:5]$、$[9:10]$,那麼有 $\mathrm{endpos}(s’)=\{2,5,10\}$。需要注意的是,$s’$ 在 $s$ 中出現的位置可能重疊,比如$s=aaaa$、$s’=aa$,那麼 $s’$ 的出現位置為$[1:2]$、$[2:3]$、$[3:4]$,此時 $\mathrm{endpos}(s’)=\{2,3,4\}$。

對於兩個 $s$ 的子串$s_1’$、$s_2’$,如果有 $\mathrm{endpos}(s_1′)=\mathrm{endpos}(s_2′)$,那麼我們稱 $s_1’$ 與 $s_2’$ endpos 等價,其屬於同一個 endpos 等價類

下面以字元串 $s=abab$ 為例,對於其所有子串進行等價類劃分:

子串 endpos 子串 endpos
abab $\{4\}$ ba $\{3\}$
aba $\{3\}$ a $\{1,3\}$
bab $\{4\}$ b $\{2,4\}$
ab $\{2,4\}$ $\empty$ /

根據上表,一共可以劃分出 $5$ 個等價類:$\mathrm{endpos}(ba,aba)=\{3\}$,$\mathrm{endpos}(bab,abab)=\{4\}$,$\mathrm{endpos}(a)=\{1,3\}$,$\mathrm{endpos}(b,ab)=\{2,4\}$,以及需要單列出來的空串 $\empty$(我們可以認為 $\mathrm{endpos}(\empty)=\{0,1,2,\cdots,|s|\}$,包含 $0$ 是為了區分形如 $s=aa\cdots a$ 時的 $\mathrm{endpos}(a)$)。

 

 

~ 概念:SAM 的狀態 ~ (同一 / 不同狀態中的子串具有什麼性質與聯繫,這對於理解建立 SAM 的過程十分關鍵)

 

有了 endpos 等價類的定義,我們考慮屬於同一等價類的子串具有什麼樣的性質。

首先,假如兩個子串 $s_1’$ 與 $s_2’$ 具有相同的 $\mathrm{endpos}$,考慮一個具體的出現位置 $r\in \mathrm{endpos}$ 則有 $s_1’=s[l_1:r]$、$s_2’=s[l_2:r]$,那麼顯然兩者必須存在後綴關係

接著,我們考慮字元串 $s$ 的兩個子串 $s_{long}’$ 與 $s_{short}’$,其中 $s_{short}’$ 是 $s_{long}’$ 的一個後綴。對於這兩個子串的 endpos,顯然每個 $s_{long}’$ 出現的位置上均出現了$s_{short}’$,這也就是說有:

\[\mathrm{endpos}(s_{long}’)\subseteq \mathrm{endpos}(s_{short}’)\]

這個式子說明,對於某個子串 $s’$ 的所有後綴,其 $\mathrm{endpos}$ 集合的大小隨著後綴長度的增加而單調不增,那麼屬於同一狀態(即 endpos 相同)的所有子串應該是一些長度連續的、互為後綴的字元串。【這裡說實話有點繞,需要努力理解一下】

引用 OI WIKI 的表述,也許能更好的幫助理解 「長度連續、互為後綴」:

對於每一個狀態 $v$,一個或多個子串與之匹配。我們記 $longest(v)$ 為其中最長的一個字元串,記 $len(v)$ 為它的長度。類似地,記 $shortest(v)$ 為最短的子串,它的長度為 $minlen(v)$。那麼對應這個狀態的所有字元串都是字元串 $longest(v)$ 的不同的後綴,且所有字元串的長度恰好覆蓋區間 $[minlen(v), len(v)]$ 中的每一個整數。

舉一個例子,假如字元串為 ababcabcd,那麼集合劃分如下,可以驗證一下上述性質:

字元串 endpos 字元串 endpos 字元串 endpos
a $\{1,4\}$ c $\{3,6\}$ ca $\{4\}$
b $\{2,5\}$ bc bca
ab abc abca
cab $\{5\}$ cabc $\{6\}$  
bcab bcabc
abcab abcabc

雖然在實際中我們並不會存下一個 SAM 狀態中的全部字元串,但暫時可以將每個狀態形象地理解成 endpos 等價的的字元串集合,從而幫助理解建立 SAM 的過程。

從自動機匹配的角度來看的話,如果我們在 SAM 上匹配到了某一狀態,這就表示自動機接受了這個字元串集合中的其中一個字元串(其實我們有辦法確定具體是哪一個,但要等到學完怎麼建 SAM 後才能說清楚)。

 

 

~ 概念:後綴鏈接 link ~ (SAM 中的 「fail」 轉移邊)

 

除了狀態以外,自動機的另一個重要的組成部分就是轉移。與 AC 自動機十分相似的是,後綴自動機也有 $\mathrm{nxt}$(匹配成功時)與 $\mathrm{link}$(匹配失敗時,對應著 AC 自動機中的 $\mathrm{fail}$)兩類轉移。其中 $\mathrm{nxt}$ 的意義與 AC 自動機中的 $\mathrm{nxt}$ 完全一致,就不做展開了;需要稍微解釋一下的是後綴鏈接 $\mathrm{link}$。

後綴鏈接的定義為:對於 SAM 中的一個狀態 $state$,其對應 endpos 等價類中的字元串均為 $\mathrm{longest}(state)$ 的後綴(這是之前的結論);我們取一個最長的 $\mathrm{longest}(state)$ 的後綴,使得其與 $\mathrm{longest}(state)$ 不 endpos 等價(其實這個後綴就是 $\mathrm{shortest}(state)$ 再去掉最左邊的一個字元,長度為 $\mathrm{minlen}(state)-1$),我們就將這個最長後綴所在的狀態賦給 $\mathrm{link}(state)$。於是我們得到一個挺常用的性質:

\[\mathrm{minlen}(state)=\mathrm{len}\left(\mathrm{link}(state)\right)+1\]

根據上述定義,我們可以對上面的例子得到後綴鏈接(紅色箭頭):

我們嘗試理解一下為什麼失配時要跳後綴鏈接。對於當前已匹配到的狀態 $state$,有$\mathrm{endpos}(state)=\{p_1, p_2,\cdots, p_n\}$,那麼在狀態 $state$ 嘗試匹配字元 $c$ 失配,就表示 $s[p_1+1], s[p_2+1], \cdots, s[p_n+1]$ 均不等於 $c$(否則就存在 $\mathrm{nxt}(c)$ 的轉移邊),那麼我們就需要捨棄一部分已匹配串的前綴(即取已匹配串的一個後綴)後再次嘗試匹配。當我們通過後綴鏈接跳到 $\mathrm{link}(state)$ 時,這個狀態的 endpos 集合更大了一些,我們說不定能找到某個新增的 $p_i$ 使得 $s[p_i+1]=c$,從而完成匹配。從這個角度來看,轉移到 $\mathrm{link}(state)$ 時我們只需要捨棄最小的已匹配長度,就會帶來 endpos 集合的變大,而 endpos 集合的變大則有可能新增一些 $\mathrm{nxt}$ 轉移使得待匹配的字元 $c$ 被接受,這個邏輯和 AC 自動機的 $\mathrm{fail}$ 邊是完全一致的。

 

 

~ SAM 的建立 ~

 

SAM 的建立是一個在線的過程。假如我們需要對於字元串 $s$ 構建 SAM,我們依次往 SAM 中添加每一個字元 $s[i]$ 來將 SAM 進行一次擴展,接下來細說一次擴展中需要做的事情。

首先,在未添加任何字元時,有一個初始狀態對應著空集 $\empty$ 的等價類,我們將其記作 $state_0$。

在進行擴展的過程中,我們維護了一個變數:$last$。其表示在擴展字元 $s[i]$ 之前,字元串 $s[1:i-1]$ 在部分建成的 SAM 中所屬的狀態。初始時 $last=state_0$。

接下來,我們嘗試在當前串 $s[1:i-1]$ 的後面再插入一個字元 $s[i]=c$。我們首先創建一個新的節點 $cur$,並賦值 $\mathrm{len}(cur)=\mathrm{len}(last)+1$,此時 $last$ 的轉移邊及 $\mathrm{link}(cur)$ 仍未確定,需要進行如下的討論:

1. 我們從 $last$ 開始,沿著後綴鏈接 $\mathrm{link}$ 一直走,邊走邊將途徑節點(包含 $last$)的 $\mathrm{nxt}(c)$ 均賦值為 $cur$,直到某個節點 $p$ 存在 $\mathrm{nxt}(c)$ 的轉移邊才停下。假如這樣的節點 $p$ 不存在,那麼我們會一直走到 $state_0$,此時我們直接令 $\mathrm{link}(cur)=state_0$ 即可完成操作。

2. 假如我們在一個節點 $p$ 停下,那麼我們記 $p$ 通過字元 $c$ 轉移到的節點為 $q$,即有 $\mathrm{nxt}_p(c)=q$。現在考慮 $\mathrm{len}(p)$ 與 $\mathrm{len}(q)$ 之間的關係。假如 $\mathrm{len}(p)+1=\mathrm{len}(q)$ 成立,那麼我們僅需令 $\mathrm{link}(cur)=q$ 即可完成操作。

3. 假如有 $\mathrm{len}(p)+1\neq \mathrm{len}(q)$,那麼需要通過依次通過四步來擴展 SAM:

  (1) 新建一個節點 $clone$,設定 $\mathrm{len}(clone)=\mathrm{len}(p)+1$,並複製 $q$ 的所有 $\mathrm{nxt}$ 轉移。例如,假如有 $\mathrm{nxt}_q(c)=tmp$,就同樣令 $\mathrm{nxt}_{clone}(c)=tmp$;

  (2) 同樣是對於 $clone$,複製 $q$ 的後綴鏈接(即令 $\mathrm{link}(clone)=\mathrm{link}(q)$),同時抹去 $\mathrm{link}(q)$

  (3) 從 $p$ 開始(包含 $p$)沿著後綴鏈接 $\mathrm{link}$ 一直走到 $state_0$。假設在這個過程中經過某個節點 $x$,且存在 $\mathrm{nxt}_x(c)=q$ 的轉移,就將該轉移重定向至 $clone$(即令 $\mathrm{nxt}_x(c)=clone$);

  (4) 將 $q$ 和 $cur$ 的後綴鏈接指向 $clone$,即令 $\mathrm{link}(q)=\mathrm{link}(cur)=clone$。
做完以上四步以後,最麻煩的 $\mathrm{len}(p)+1\neq \mathrm{len}(q)$ 的 case 也已經解決了。

(上圖中實際上節點 $p$ 也是需要重定向的,但為了表示的方便,沒有畫出重定向 $\mathrm{nxt}_p(c)$ 的箭頭)

最後的最後,我們令 $last=cur$,為擴展下一個字元做準備。

雖然說了那麼多,其實程式碼實現相當簡單,與上面的討論一一對應,甚至沒有什麼注釋的必要:

int sz,lst; //狀態數上限=2|S|-1 
int len[N<<1],link[N<<1];
int nxt[N<<1][C];
//初始情況下,sz=lst=0,len[0]=0,link[0]=-1
//令link[0]=-1是為了標記後綴鏈接路徑的終點 

void extend(int c)
{
    int cur=++sz;
    len[cur]=len[lst]+1;
    
    int p=lst;
    while(p!=-1 && !nxt[p][c])
        nxt[p][c]=cur,p=link[p];
    
    if(p==-1)
        //case #1
        link[cur]=0;
    else
    {
        int q=nxt[p][c];
        if(len[p]+1==len[q])
            //case #2
            link[cur]=q;
        else
        {
            //case #3
            int clone=++sz;
            len[clone]=len[p]+1;
            link[clone]=link[q];
            memcpy(nxt[clone],nxt[q],C*4);
            
            while(p!=-1 && nxt[p][c]==q)
                nxt[p][c]=clone,p=link[p];
            link[q]=link[cur]=clone;
        }
    }
    lst=cur;
}

 

 

~ 正確性證明 ~

 

細節有點多(懶),具體可以參考 OI WIKI – SAM – 正確性證明 以及 OI WIKI – SAM – 對操作數為線性的證明不過對於抄板子不是那麼關鍵。

為了便於感性的理解,我們不妨舉一個擴展 SAM 的過程中最複雜的 Case #3 的例子。

字元串 $s=abb$,當僅擴展了 $ab$ 與擴展完了 $abb$ 時,SAM 的結構分別如下:

可以看出,做一次 Case #3 的擴展相當於對於一個 endpos 等價類(如圖中的 $\{b,ab\}$)做拆分。更深入的分析就不展開了。

 

 

~ SAM 的性質 ~

 

對於一個長度為 $n$ 的字元串,SAM 的狀態數是 $2n$ 級別的,轉移數是 $3n$ 級別的。這個性質沒什麼特殊的應用,記得數組別開小就行了。

比較關鍵的性質在於 SAM 的後綴鏈接 $\mathrm{link}$ 上。

 

1. SAM 的後綴鏈接構成一個樹形結構

我們不妨回顧一下後綴鏈接的定義:對於 SAM 中的一個狀態 $state$,我們取一個最長的 $\mathrm{longest}(state)$ 的後綴,使得其與 $\mathrm{longest}(state)$ 不 endpos 等價,$\mathrm{link}(state)$ 就指向該後綴所屬於的狀態。

這也就是說,對於任意狀態 $state$ 的後綴鏈接所指向的狀態 $\mathrm{link}(state)$,$state$ 所包含的子串長度均長於 $\mathrm{link}(state)$ 中的子串長度(即 $\mathrm{minlen}(state)>\mathrm{len}(\mathrm{link}(state))$)。利用這個性質,我們發現後綴鏈接不會構成環,因為沿著後綴鏈接走時,所經過狀態中的子串長度嚴格單調減

同時,由於所有後綴鏈接的路徑一定止於 $state_0$,所以所有後綴鏈接一定構成樹形結構。有說法稱其為 parent 樹。

 

2. 在 parent 樹上,祖先對應的字元串是子孫的後綴

上文已經回答過了,「沿著後綴鏈接走時,所經過狀態中的子串長度嚴格單調減」。

稍微推廣一下,我們可以求出多個字元串的最長公共後綴:對於字元串 $s_1,\cdots,s_n$,我們找到其在 SAM 上的位置 $x_1,\cdots,x_n$,那麼最長公共後綴就是 $\mathrm{LCA}(x_1,\cdots,x_n)$。

 

3. 若某個狀態存在 $\mathrm{nxt}(c)$ 轉移,那麼其在 parent 樹上的祖先均存在 $\mathrm{nxt}(c)$ 轉移

子孫存在 $\mathrm{nxt}(c)$ 轉移,說明子孫出現的某個位置 $r_i\in \mathrm{endpos}(\text{子孫})$ 後面有一個字元 $c$。因為祖先是子孫的後綴,所以子孫出現的所有地方祖先均出現,那麼顯然有 $r_i\in \mathrm{endpos}(\text{祖先})$,則祖先也存在 $\mathrm{next}(c)$ 轉移。

 

4. 每個狀態的 $\mathrm{endpos}$ 集合為其在 parent 樹中子樹 $\mathrm{endpos}$ 集合的並集

我們在構建 SAM 的過程中,將 $last$ 所經過的狀態提取出來,則這些狀態分別包含子串 $s[1:i]$,$1\leq i\leq n$。那麼我們為 $s[1:i]$ 所在的狀態打上 $i$ 的標籤,表示 $i$ 為該狀態的一個出現位置。然後我們考慮該狀態在 parent 樹上的祖先們,由於祖先是子孫的後綴,那麼 $i$ 也是祖先們的一個出現位置,那麼我們可以將 $i$ 的標籤打在 $s[1:i]$ 所在狀態一直到 $state_0$ 的路徑上。對於所有 $s[1:i]$ 均這樣打標籤,最終每個狀態的 $\mathrm{endpos}$ 就是被打上的標籤集合。

不過這個結論暫時還不夠嚴謹,我們需要證明 $s[1:i]$ 所在狀態的子孫均不出現在位置 $i$。這也並不困難,因為一個子串出現在位置 $i$ 當且僅當其為 $s[1:i]$ 的一個後綴,而 $s[1:i]$ 的後綴僅會存在於 $s[1:i]$ 所在狀態到 $state_0$ 的路徑上,一定不在 $s[1:i]$ 所在狀態的子樹中。

 

淦,雖然感覺這裡有很多性質可以挖,但寫的時候只能列出比較顯然的幾條…另外的一些 DP 相關的性質需要在實際應用中體現吧。

 

補充5. 在 SAM 上,對於每個節點暴力跳後綴鏈接到 $state_0$,總的時間複雜度是 $\mathrm{O}(n^{1.5})$

雖然在少量部落格上看到這個性質,但是並沒有找到證明,不是十分確定。不過有題目用到了這個性質(如 2019 ICPC 徐州的 L 題),所以大概是對的。從直觀上確實比較難卡出很壞的情況。

 

 

~ 經典應用(SAM) ~

 

1. 所有本質不同的子串及其出現次數(SAM 性質 / parent 樹上 DP)

由於 SAM 中所有狀態所表示的字元串都是互不相同的,所以本質不同的子串數量就是$\sum_{state}\mathrm{len}(state)-\mathrm{minlen}(state)+1$,即把每個狀態中包含的字元串數量全部加起來。

例題:LOJ2033 「SDOI2016」生成魔咒

#include <map>
#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;

const int N=100005;
const int C=26; //注意檢查字符集大小!

ll ans=0;

struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    //int nxt[N<<1][C];
    map<int,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                //memcpy(nxt[clone],nxt[q],C*4);
                nxt[clone]=nxt[q];
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
        ans+=len[cur]-len[link[cur]];
    }
}sam;

int n;
int a[N];

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
        scanf("%d",&a[i]);
    
    for(int i=1;i<=n;i++)
        sam.extend(a[i]),printf("%lld\n",ans);
    return 0;
}

View Code

另一方面,某一子串的出現次數就等於其 $\mathrm{endpos}$ 集合的大小。根據性質「每個狀態的 $\mathrm{endpos}$ 集合為其在 parent 樹中子樹 $\mathrm{endpos}$ 集合的並集」,我們對於 $s[1:i]$ 所在狀態賦為 $1$,其餘狀態為 $0$,然後通過在 parent 樹上 dfs 求子樹中 $1$ 的個數即可。

例題:洛谷P3804 【模板】後綴自動機 (SAM)

#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

int n;
char s[N];

int cnt[N<<1];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    
    for(int i=1;i<=n;i++)
        sam.extend(s[i]-'a'),cnt[sam.lst]=1;
    
    sam.sort();
    
    ll ans=0;
    for(int i=sam.sz;i>=1;i--)
    {
        int id=sam.id[i];
        if(cnt[id]>1)
            ans=max(ans,1LL*cnt[id]*sam.len[id]);
        
        cnt[sam.link[id]]+=cnt[id];
    }
    printf("%lld\n",ans);
    return 0;
}

View Code

在以上的實現中,並沒有顯式地對於 parent 樹進行 dfs,而是將所有狀態拓撲排序後按序處理。進行拓撲排序的策略並不複雜,我們只需要按照 $\mathrm{len}(state)$ 進行一趟桶排,這樣即可保證祖先($\mathrm{len}$ 較小)一定排在子孫($\mathrm{len}$ 較大)的前面,此時倒序即為一個合法的拓撲排序。

int id[N<<1];

//構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
void sort()
{
    static int bucket[N<<1];
    
    memset(bucket,0,sizeof(bucket));
    for(int i=1;i<=sz;i++)
        bucket[len[i]]++;
    for(int i=1;i<=sz;i++)
        bucket[i]+=bucket[i-1];
    for(int i=1;i<=sz;i++)
        id[bucket[len[i]]--]=i;
}

 

2. 字典序第 K 大子串(SAM 上 DP)

我們先考慮本質相同的子串僅計算一次的情況。剛剛在計算本質不同子串數量的時候,我們利用狀態的性質解決了,但實際上還有一個在 SAM 上 DP 的做法。

有這樣的一個結論:一個字元串本質不同的子串數量,相當於在 SAM 上通過 $\mathrm{nxt}$ 轉移產生的路徑數量。我們嘗試理解這個結論。首先,這樣的計數一定不會少算(有子串沒被算到),因為每個本質不同的子串都會在 SAM 上存在唯一對應的路徑;同時,這個計數也不會多算(算到多餘的子串),因為 SAM 僅接受模式串的子串。

利用這個結論,我們直接利用 DP 計算從每個狀態出發的路徑數量即可(由於 $\mathrm{nxt}$ 轉移構成的圖是一個 DAG,我們仍然可以通過令 $\mathrm{len}$ 從小到大來獲得一個正拓撲序)。DP 的轉移方程為 $path[x]=1+\sum_{y=\mathrm{nxt}_x(c_i)}path[y]$,需要按照逆拓撲序依次計算。

對於本質相同的子串計算出現次數的情況,將上面轉移方程中的 $1$ 替換成 $cnt[x]$ 即可。

那麼求字典序第 $K$ 大子串,只需要從 $state_0$ 開始貪心,每次選取最小的前綴路徑數量大於等於 $K$ 的轉移即可(原理同線段樹上二分,只不過 SAM 上每個狀態有字符集大小的分叉)。

例題:LOJ2102 「TJOI2015」弦論

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;

const int N=500005;
const int C=26; //注意檢查字符集大小!

struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

int n;
char s[N];
int type,K;

int cnt[N<<1];
ll path[N<<1][2];

int main()
{
    scanf("%s",s+1);
    scanf("%d%d",&type,&K);
    n=strlen(s+1);
    
    for(int i=1;i<=n;i++)
        sam.extend(s[i]-'a'),cnt[sam.lst]=1;
    
    sam.sort();
    
    for(int i=sam.sz;i>=0;i--)
    {
        int id=sam.id[i];
        
        path[id][0]=1,path[id][1]=cnt[id];
        for(int j=0;j<C;j++)
        {
            int nxt=sam.nxt[id][j];
            if(nxt)
                for(int k=0;k<2;k++)
                    path[id][k]+=path[nxt][k];
        }
        if(id!=-1)
            cnt[sam.link[id]]+=cnt[id];
    }
    
    if(path[0][type]-cnt[0]<K)
        printf("-1\n");
    else
    {
        int cur=0;
        while(K)
        {
            for(int i=0;i<C;i++)
            {
                int nxt=sam.nxt[cur][i];
                if(!nxt)
                    continue;
                
                if(path[nxt][type]>=K)
                {
                    cur=nxt,putchar('a'+i);
                    K-=(type?cnt[cur]:1);
                    break;
                }
                K-=path[nxt][type];
            }
        }
    }
    return 0;
}

View Code

 

3. 兩個字元串的最長公共子串(SAM 上匹配)

對於 $s,t$ 兩個字元串,對於 $s$ 串建立 SAM,$t$ 在這個 SAM 上進行匹配,失配時跳 $\mathrm{link}$。實現上和 AC 自動機差不多。

例題:SPOJ-LCS Longest Common Substring

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;

const int N=250005;
const int C=26; //注意檢查字符集大小!

struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

int n,m;
char s[N],t[N];

int main()
{
    scanf("%s%s",s+1,t+1);
    n=strlen(s+1),m=strlen(t+1);
    
    for(int i=1;i<=n;i++)
        sam.extend(s[i]-'a');
    
    int ans=0,cur=0,len=0;
    for(int i=1;i<=m;i++)
    {
        int p=cur;
        while(p!=-1 && !sam.nxt[p][t[i]-'a'])
            p=sam.link[p],len=sam.len[p];
        
        if(p!=-1)
            cur=sam.nxt[p][t[i]-'a'],len++;
        else
            cur=len=0;
        
        ans=max(ans,len);
    }
    printf("%d\n",ans);
    return 0;
}

View Code

 

4. 多個字元串的最長公共子串(SAM 上匹配 + parent 樹上 DP)

想同時處理所有字元串是比較困難的,我們考慮先對於一個串建立 SAM,然後對於其餘的串依次求出「當匹配到狀態 $state$ 時,最長的匹配長度」。

考慮對於具體某一個待匹配串 $t$,我們還是像兩個字元串一樣求 $t[1:i]$ 在 SAM 上匹配得到的狀態以及匹配上的長度 $len$。但不同的是,我們還需要額外維護一個數組 $curlen$,表示字元串 $t$ 對於每個狀態的 $\mathrm{longest}(state)$ 能匹配的最長後綴長度。更新的規則為 $curlen[x]=\mathrm{max}(curlen[x],len)$,因為 $t[1:i]$ 所對應的狀態可能由多個狀態轉移而來,我們需要取其中最大的匹配長度。

需要注意到,我們在維護 $curlen$ 的過程中,只考慮到了 $t[1:i]$ 這不超過 $|t|$ 個狀態,但實際上對於 SAM 上的任意狀態均應計算 $curlen$。所以我們按照 $\mathrm{len}$ 從大到小的順序計算沒經過的狀態的 $curlen$,並使用 $curlen[x]$ 來更新 $curlen[\mathrm{link}(x)]$,規則為 $curlen[\mathrm{link}(x)]=\mathrm{max}(curlen[\mathrm{link}(x)],curlen[x])$。

對於一個待匹配串 $t$ 我們能按照如上的方法求得每個狀態的 $curlen$,那麼所有字元串的在每個狀態的最長匹配長度,就是 $ans[state]=\mathrm{min}_t(curlen_t[state])$。而最終答案為 $\mathrm{min}_{state}(ans[state])$。

例題:SPOJ-LCS2 Longest Common Substring II

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;

const int N=100005;
const int C=26; //注意檢查字符集大小!

struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

int n,m;
char s[N],t[N];

int ans[N<<1],curlen[N<<1];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    
    for(int i=1;i<=n;i++)
        sam.extend(s[i]-'a');
    sam.sort();
    
    for(int i=1;i<=sam.sz;i++)
        ans[i]=sam.len[i];
    
    while(~scanf("%s",t+1))
    {
        m=strlen(t+1);
        
        int cur=0,len=0;
        for(int i=1;i<=m;i++)
        {
            int p=cur,c=t[i]-'a';
            while(p!=-1 && !sam.nxt[p][c])
                p=sam.link[p],len=sam.len[p];
            
            if(p!=-1)
                cur=sam.nxt[p][c],len++;
            else
                cur=len=0;
            
            //對於同一cur,可能從多個p轉移過來,故不能用len直接賦值cur
            curlen[pos]=max(curlen[pos],len);
        }
        
        for(int i=sam.sz;i>=1;i--)
        {
            int id=sam.id[i];
            curlen[sam.link[id]]=max(curlen[sam.link[id]],curlen[id]);
        }
        for(int i=1;i<=sam.sz;i++)
            ans[i]=min(ans[i],curlen[i]),curlen[i]=0;
    }
    
    int fans=0;
    for(int i=1;i<=sam.sz;i++)
        fans=max(fans,ans[i]);
    printf("%d\n",fans);
    return 0;
}

View Code

 

 

~ 從 SAM 到廣義 SAM ~

 

在上文中處理多個字元串的問題時,我們先對於其中一個字元串建立 SAM,對於剩餘的字元串依次在這個 SAM 上面跑。有沒有辦法對於多個字元串直接建立一個自動機,使得任意字元串的任意子串都能被自動機接受?答案是肯定的。

我們考慮這樣的實現:對於字元串 $s_1,\cdots,s_n$,我們首先對於 $s_1$ 使用 $extend()$ 函數依次插入建立 SAM,接著我們將 $last$ 移回 $state_0$ 並重新依次插入 $s_2$。這個實現的邏輯比較簡單,對於能夠「接受任意字元串的任意子串」的正確性也是比較顯然的,不過在細節上存在一些問題:對於多次經過同一個狀態時反覆插入會產生孤立聯通塊,這些孤立連通塊內的點在進行從 $state_0$ 開始的匹配時永遠不會被走到(詳見 ix35 – 悲慘故事 長文警告 關於廣義 SAM 的討論)。這種現象的存在會導致進行拓撲排序等操作時出錯(一個例子為 yy1695651 – ICPC2019 G題廣義SAM的基排為什麼不行?)。

另一方面,上述方法的複雜度與 $\sum_i |s_i|$ 線性相關,因為相當於依次插入 $n$ 個字元串。但假如題目中的字元串不是直接給出的,而是採用 trie 樹的形式,那麼 $n$ 個節點的 trie 樹可以產生總長為 $n^2$ 級別的字元串,其形狀大概如下:

出於適用範圍更廣的原則,本文介紹基於 trie 樹和離線 bfs 的廣義 SAM 實現方法。其能夠建立正確的廣義 SAM,同時時空複雜度僅與 trie 樹的節點數相關。

 

 

~ 廣義 SAM(GSA)的建立 ~

 

我們從 trie 樹的角度來考慮 SAM 的實現。由於模式串僅為單一字元串,所以對於 SAM 而言是基於退化為一條鏈的 trie 樹建立的。

那麼對於一個非退化的 trie 樹,我們也可以在修改很少幾處的情況下建立 GSA。

 

1. 插入順序

在建立 SAM 的過程中,我們是按照字元串中從前到後的順序依次擴展 SAM 的,從 trie 樹的角度看就是從淺到深。那麼在建立 GSA 時,我們為了保證先擴展較淺的節點、再擴展較深的節點,可以採用 bfs 進行實現。

如果不採用從淺到深的順序,就可能出現由狀態 $x$ 轉移到狀態 $y$、而狀態 $y$ 已經先求完 $\mathrm{nxt}$ 與 $\mathrm{link}$ 的情況,此時會產生類似每次重置 $last$ 一樣的孤立連通塊問題,需要額外的不少討論才能保證正確性。使用從淺到深的 bfs 則不會出現這種情況。

 

2. 節點標號

在建立 GSA 前,我們首先需要用 $\mathrm{nxt}$ 數組來建立 trie 樹。因此每步插入的 $cur$ 都是已經建好的節點,無須開新點;只有 $clone$ 需要動態開點。同時,由於我們按照深度擴展,每次的 $last$ 都是通過 bfs 得知的,無須再額外維護 $last$ 這個變數。

 

3. 資訊隔離

在建立 SAM 的過程中,我們在插入 $s[i]$ 時不會知道任何 $s[j]$($j>i$)的資訊。但是在建立 GSA 時,我們有可能獲得更深節點的資訊,具體來說就是 $\mathrm{nxt}_{last}$ 可能指向比 $last$ 更深的節點。因此我們需要仔細檢查之前 SAM 的板子中所有用到 $\mathrm{nxt}$ 的地方:

void extend(int c)
{
    int cur=++sz;
    len[cur]=len[lst]+1;
    
    int p=lst;
    //nxt[lst][c]一定存在,且等於cur【會產生問題 #1】 
    while(p!=-1 && !nxt[p][c])
        nxt[p][c]=cur,p=link[p];
    
    if(p==-1)
        link[cur]=0;
    else
    {
        int q=nxt[p][c]; 
        //這裡我們認為dep[p]<dep[lst],
        //那麼假如nxt[p][c]是某次clone賦值的,說明那次clone在當前擴展操作之前,那麼直接使用是ok的; 
        //假如nxt[p][c]是構建trie樹時賦值的,則dep[q]=dep[p]+1<=dep[lst]<dep[cur],說明一定已經被擴展到了。
        //綜上,【不會產生問題】 
        if(len[p]+1==len[q])
            link[cur]=q;
        else
        {
            int clone=++sz;
            len[clone]=len[p]+1;
            link[clone]=link[q];
            memcpy(nxt[clone],nxt[q],C*4);
            //假如nxt[q][i]是構建trie樹時賦值的,dep[nxt[q][i]]最大可以等於dep[cur], 
            //即使nxt[q][i]與cur同深度,在廣義SAM的擴展過程中也有先後之分,
            //所以如果擴展cur時未擴展過nxt[q][i],那麼nxt[q][i]在此時應該視為0【會產生問題 #2】 
            
            while(p!=-1 && nxt[p][c]==q)
                nxt[p][c]=clone,p=link[p];
            //同q=nxt[p][c]處的分析,對於nxt[p][c]的使用都是安全的【不會產生問題】 
            link[q]=link[cur]=clone;
        }
    }
    lst=cur;
}

我們需要對於會產生問題的兩處稍加修改,具體而言:

  (1) 在 #1 處,初始令 $p=\mathrm{link}[lst]$ 即可(因為 $\mathrm{nxt}[lst][c]=cur$ 在構建 trie 樹時就被賦值了;而其餘所有 $\mathrm{nxt}[p][c]$ 假如為非 clone 點,在 trie 樹上的深度一定小於 $dep[cur]$,則可以直接使用 );

  (2) 在 #2 處,我們對於 $q$ 的每一個 $\mathrm{nxt}$ 轉移,均檢查是否被擴展過(等價於檢查 $\mathrm{len}$ 是否為 $0$),若未被擴展過,即使 $\mathrm{nxt}[q][i]\neq 0$ 也必須視為 $0$。

 

綜合以上三點,我們可以寫出如下的 GSA 的實現:

typedef pair<int,int> pii;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //向trie樹上插入一個字元串 
    void insert(char *s,int n)
    {
        for(int i=1,cur=0;i<=n;i++)
        {
            int c=s[i]-'a';
            if(!nxt[cur][c])
                nxt[cur][c]=++sz;
            cur=nxt[cur][c];
        }
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}gsa;

 

 

~ 經典應用(GSA) ~

 

1. 本質不同的子串數量及出現次數

本質不同的子串數量還是利用 GSA 的性質即可,等於 $\sum_{state}\mathrm{len}(state)-\mathrm{len}\left(\mathrm{link}(state)\right)$。

例題:洛谷P6139 【模板】廣義後綴自動機(廣義 SAM)

#include <queue>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //向trie樹上插入一個字元串 
    void insert(char *s,int n)
    {
        for(int i=1,cur=0;i<=n;i++)
        {
            int c=s[i]-'a';
            if(!nxt[cur][c])
                nxt[cur][c]=++sz;
            cur=nxt[cur][c];
        }
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}gsa;

int n,m;
char s[N];

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
    {
        scanf("%s",s+1);
        m=strlen(s+1);
        gsa.insert(s,m);
    }
    
    gsa.build();
    
    ll ans=0;
    for(int i=1;i<=gsa.sz;i++)
        ans+=gsa.len[i]-gsa.len[gsa.link[i]];
    printf("%lld\n",ans);
    return 0;
}

View Code

出現次數也是在 parent 樹上 DP。

例題:洛谷P3181 [HAOI2016]找相同字元

這道題中,具體一個狀態產生的貢獻是 $\left(\mathrm{len}(state)-\mathrm{minlen}(state)+1\right)\cdot cnt_1[state]\cdot cnt_2[state]$。

#include <queue>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;

const int N=400005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //向trie樹上插入一個字元串 
    void insert(char *s,int n)
    {
        for(int i=1,cur=0;i<=n;i++)
        {
            int c=s[i]-'a';
            if(!nxt[cur][c])
                nxt[cur][c]=++sz;
            cur=nxt[cur][c];
        }
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}gsa;

int n,m;
char s[N],t[N];
int cnt1[N<<1],cnt2[N<<1];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    scanf("%s",t+1);
    m=strlen(t+1);
    
    gsa.insert(s,n);
    gsa.insert(t,m);
    
    gsa.build();
    gsa.sort();
    
    for(int i=1,cur=0;i<=n;i++)
    {
        cur=gsa.nxt[cur][s[i]-'a'];
        cnt1[cur]++;
    }
    for(int i=1,cur=0;i<=m;i++)
    {
        cur=gsa.nxt[cur][t[i]-'a'];
        cnt2[cur]++;
    }
    
    ll ans=0;
    for(int i=gsa.sz;i>=1;i--)
    {
        int id=gsa.id[i];
        int lst=gsa.link[id];
        ans+=1LL*(gsa.len[id]-gsa.len[lst])*cnt1[id]*cnt2[id];
        cnt1[lst]+=cnt1[id];
        cnt2[lst]+=cnt2[id];
    }
    printf("%lld\n",ans);
    return 0;
}

View Code

 

多個字元串的最長公共子串也許不能用 GSA 做

SPOJ 的那題是比較弱的,因為 $n\leq 10$,所以可以存個長度為 $10$ 的數組搞一搞。以下討論的是 $n, \sum_i |s_i|\leq 1\times 10^6$ 級別的該問題。

在 SAM 上,疑似對於每個狀態暴力跳 fail 是 $\mathrm{O}(n^{1.5})$ 的。那麼在 GSA 上,是否能夠保證所有串各自佔有的狀態之和是 $|s_i|^{1.5}$ 呢?這個結論是存疑的…就算成立,時間複雜度也明顯比 SAM 要差。

 

 

~ 一些練習題 ~

 

SAM / GSA 的裸題一般都是一眼會,稍微難一點的題目會在此基礎上套線段樹合併LCT access 維護樹鏈等內容,但此時難點就不再 SAM 上了。做到啥補充啥吧。

 

Kattis-firstofhername First of Her Name(2019 ICPC WF)【GSA 裸題】

題目中給定了一棵 trie 樹,所以建立 GSA 是比較自然的想法。考慮回答查詢,某個字元串的前綴如果是 $s$,就相當於反轉的 $s$ 是從 trie 樹的對應位置到 $state_0$ 的路徑的前綴。所以我們只需要用類似求出現次數的方法,對於每個字元串在結尾處令 $cnt=1$(在這題中就是將 $cnt[1:n]$ 均置 $1$),然後按照拓撲序 DP 即可。

#include <queue>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

ll cnt[N<<1];

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}gsa;

int n,q;
char s[N];

int main()
{
    scanf("%d%d",&n,&q);
    for(int i=1;i<=n;i++)
    {
        char ch=getchar();
        while(ch<'A' || ch>'Z')
            ch=getchar();
        
        int lst;
        scanf("%d",&lst);
        gsa.nxt[lst][ch-'A']=i,cnt[i]=1;
    }
    
    gsa.sz=n;
    gsa.build();
    gsa.sort();
    
    for(int i=gsa.sz;i>=1;i--)
    {
        int id=gsa.id[i];
        cnt[gsa.link[id]]+=cnt[id];
    }
    
    while(q--)
    {
        scanf("%s",s+1);
        int n=strlen(s+1);
        
        int cur=0;
        for(int i=n;i>=1;i--)
        {
            int c=s[i]-'A';
            if(!gsa.nxt[cur][c])
            {
                cur=0;
                break;
            }
            cur=gsa.nxt[cur][c];
        }
        printf("%d\n",gsa.len[cur]>=n?cnt[cur]:0);
    }
    return 0;
}

View Code

 

計蒜客42551 Loli, Yen-Jen, and a cool problem(2019 ICPC 徐州)【GSA 基礎性質,去重處理】

這題本身挺裸的。對於每次詢問 $s[x-L+1:x]$ 的出現次數,我們只需要找到其所在的狀態就行了,從 $x$ 沿著 $\mathrm{link}$ 暴力跳、直到當前狀態的 $\mathrm{len}$ 區間包含 $L$ 即可。理論上需要將 $x$ 相同的詢問同時處理,不過數據貌似沒卡。

這題稍微有點噁心的是 trie 樹上會有重合的節點。對於重合的節點,我們選擇最早出現的一個作為代表,其餘節點全部映射到代表。這會導致非代表點變成孤立連通塊,在進行拓撲排序的時候需要注意忽略這些 $\mathrm{len}=0$ 的孤立點。

#include <queue>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;

const int N=300005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct GeneralSuffixAutomaton
{
    int sz; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(int,char)
    
    GeneralSuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        sz=0;
    }
    
    //擴展GSA,對於lst的c轉移進行擴展 
    int extend(int lst,int c)
    {
        int cur=nxt[lst][c];
        len[cur]=len[lst]+1;
        
        int p=link[lst];
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                for(int i=0;i<C;i++)
                    nxt[clone][i]=len[nxt[q][i]]?nxt[q][i]:0;
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        return cur;
    }
    
    //基於trie樹建立GSA
    void build()
    {
        queue<pii> Q;
        for(int i=0;i<C;i++)
            if(nxt[0][i])
                Q.push(pii(0,i));
        
        while(!Q.empty())
        {
            pii tmp=Q.front();
            Q.pop();
            
            int cur=extend(tmp.first,tmp.second);
            for(int i=0;i<C;i++)
                if(nxt[cur][i])
                    Q.push(pii(cur,i));
        }
    } 
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            if(len[i])
                bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            if(len[i])
                id[bucket[len[i]]--]=i;
    }
}gsa;

int n,q;
char s[N];

int to[N];
ll cnt[N<<1];

int main()
{
    scanf("%d%d",&n,&q);
    scanf("%s",s+1);
    
    gsa.nxt[0][s[1]-'A']=1,to[1]=1,cnt[1]++;
    for(int i=2;i<=n;i++)
    {
        int x,c=s[i]-'A';
        scanf("%d",&x);
        
        x=to[x];
        if(!gsa.nxt[x][c])
            gsa.nxt[x][c]=i;
        to[i]=gsa.nxt[x][c];
        cnt[to[i]]++;
    }
    
    gsa.sz=n;
    gsa.build();
    gsa.sort();
    
    for(int i=gsa.sz;i>=1;i--)
    {
        int id=gsa.id[i];
        if(id)
            cnt[gsa.link[id]]+=cnt[id];
    }
    
    while(q--)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        
        x=to[x];
        while(gsa.len[gsa.link[x]]>=y)
            x=gsa.link[x];
        printf("%lld\n",cnt[x]);
    }
    return 0;
}

View Code

 

牛客4010D 卡拉巴什的字元串2020 Wannafly Camp Day2)【題意轉化,SAM 基礎性質】

我們考慮依次加入字元,那麼在加到 $s[i]$ 時,我們需要考慮 $s[1:i-1]$ 的答案與 $s[1:i]$ 的區別。假如加入 $s[i]$ 會使得答案發生改變,那麼最長的 LCP 一定是一個 $s[1:i]$ 的後綴,否則該 LCP 也一定在 $s[1:i-1]$ 中出現過了。

通過這樣的轉化後,我們只需要關心最長的、出現過多次的 $s[1:i]$ 的後綴是什麼。在插入完 $s[i]$ 後,SAM 的 $last$ 指針指向 $\mathrm{endpos}=\{i\}$ 的狀態,那麼根據後綴鏈接的性質,$\mathrm{link}(last)$ 恰為我們所要的東西(因為其 endpos 集合大於 $1$,即出現多次),所以答案即為 $\mathrm{len}(\mathrm{link}(last))$ 的 MEX。有一個細節是,當字元串為形如 “aaaa” 時答案應該全是 $0$,因為 LCP 的值為 $-,1,2,3$,並沒有出現過 $0$。

#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

const int N=1000005;
const int C=26; //注意檢查字符集大小!

//在結構題外開任何與SAM狀態相關的數組,都需要N<<1
struct SuffixAutomaton
{
    int sz,lst; //狀態數上限=2|S|-1 
    
    int len[N<<1],link[N<<1];
    int nxt[N<<1][C];
    //map<char,int> nxt[N<<1];
    //extend(char),並使用nxt[clone]=nxt[q]替換memcpy
    
    SuffixAutomaton()
    {
        len[0]=0,link[0]=-1;
        lst=sz=0;
    }
    
    void init()
    {
        for(int i=0;i<=sz;i++)
        {
            len[i]=link[i]=0;
            for(int j=0;j<C;j++)
                nxt[i][j]=0;
        }
        link[0]=-1;
        lst=sz=0;
    }
    
    void extend(int c)
    {
        int cur=++sz;
        len[cur]=len[lst]+1;
        
        int p=lst;
        while(p!=-1 && !nxt[p][c])
            nxt[p][c]=cur,p=link[p];
        
        if(p==-1)
            link[cur]=0;
        else
        {
            int q=nxt[p][c];
            if(len[p]+1==len[q])
                link[cur]=q;
            else
            {
                int clone=++sz;
                len[clone]=len[p]+1;
                link[clone]=link[q];
                memcpy(nxt[clone],nxt[q],C*4);
                
                while(p!=-1 && nxt[p][c]==q)
                    nxt[p][c]=clone,p=link[p];
                link[q]=link[cur]=clone;
            }
        }
        lst=cur;
    }
    
    int id[N<<1];
    
    //構建完成後,id順序為len遞增(逆拓撲序)【僅可排一次】 
    void sort()
    {
        static int bucket[N<<1];
        
        memset(bucket,0,sizeof(bucket));
        for(int i=1;i<=sz;i++)
            bucket[len[i]]++;
        for(int i=1;i<=sz;i++)
            bucket[i]+=bucket[i-1];
        for(int i=1;i<=sz;i++)
            id[bucket[len[i]]--]=i;
    }
}sam;

int n;
char s[N];

int main()
{
    int T;
    scanf("%d",&T);
    while(T--)
    {
        scanf("%s",s+1);
        n=strlen(s+1);
        
        sam.init();
        
        printf("0");
        sam.extend(s[1]-'a');
        
        int mx=0,zero=0;
        for(int i=2;i<=n;i++)
        {
            sam.extend(s[i]-'a');
            
            int val=sam.len[sam.link[sam.lst]];
            mx=max(mx,val);
            zero|=(val==0);
            printf(" %d",zero?mx+1:0);
        }
        printf(" \n");
    }
    return 0;
}

View Code

 

牛客38727C Cmostp(2022 牛客暑期多校 加賽)【LCT access 維護 parent 樹的鏈並,離線轉化】

這題其實只有模型抽象用到了 SAM,所以題解寫在 LCT 那裡了。右端點在區間 $[l,r]$ 的狀態等價於字元串 $s[1:l]$ 到 $s[1:r]$ 這 $r-l+1$ 個狀態在 parent 樹上的鏈並。

 

(待續)

 

SAM 在處理字典序相關問題時比較乏力,在這種情況下從後綴樹、後綴數組的角度往往具有更好的性質。

當問題中的詢問數是 $|s|$ 級別的時,可以往 SAM 想一想。

Tags: