[學習筆記]后綴自動雞/SAM


〇、關於后綴自動雞的一些牢騷廢話和引入

它取名叫后綴自動雞,但是實際上在一個自動雞出爐之后好像和后綴基本上沒什么關系,按照 \(\text{JZM}\) 大佬的話,叫 “子串自動雞” 可能更恰當.

只不過,它取名叫后綴自動雞,原因是之一可能是在原本的串 \(S\) 后面插入新字符 \(c\) 時,將 \(S\) 的許多后綴遍歷了一遍,因而得名 “后綴自動雞”.

但是實際上,它在建成之后,自動雞中似乎並沒有彰顯 “后綴” 的東西,而更像是把串 \(S\) 的所有子串放到一個時空復雜度都很優秀的 \(DAG\) 中.

哦,還有句話,后綴自動雞的簡稱 \(\text{SAM/Suffix Automaton}\),而不是 \(\text{SAC/Suffix Autochiken}\).

壹、一些新概念英語

在進行正式說明前,我們有一些新概念需要理清.

一、終止節點等價類(\(\text{endpos}/\text{right}\) 等價類)

首先,對於串 \(S\) 中的每個子串 \(s_i\),我們將其出現的地方全部找出來,然后將這些出現的地方的尾端放到同一個類里面,然后給這個類取一個名字,叫終止節點集合,或者 \(\text{endpos}\) 集合(在某些文章中稱為 \(\text{right}\) 集合),舉個栗子,有這樣的串:

\[\tt abaabab \]

我們有一個子串 \(s_i=\tt aba\),發現有:

\[s_i=S[1..3]=S[4..6] \]

那么,\(\text{endpos}_i\) 集合就是 \(\{3,6\}\).

繼續,現在我們每個不同的子串都有一個 \(\text{endpos}\) 集合(顯然相同的子串沒啥用),對於這些不同的子串,我們\(\text{endpos}\) 集合相同的子串再放到一個集合中,叫做 \(\text{endpos}\) 等價類,將這個等價類用 \(\text{endpos}\) 集合中的元素進行代替.

那么,在這個例子中,有這些等價類(無特定順序):

\[\begin{aligned} &\text{endpos=}[1,n]\cap \mathcal Z,\text{string class=}\emptyset \\ &\text{endpos={1,3,4,6}},\text{string class=}\{\tt a\} \\ &\text{endpos={2,5,7}},\text{string class=}\{\tt b,ab\} \\ &\text{endpos={3,6}},\text{string class=}\{\tt ba,aba\} \\ &\text{endpos={4}},\text{string class=}\{\tt aa\} \\ &\text{endpos={7}},\text{string class=}\{\tt bab,abab,aabab,baabab,abaabab\} \\ \end{aligned} \]

可以看到,有一些 \(\text{endpos}\) 集合相同的串被歸為了同一個類.

下文中,我們將用 代替 \(\text{endpos}\) 等價類(能少一點就少一點).

二、自動雞

有一個叫做 \(AC\) 自動雞的東西 但是不是拿來自動過題的,它也叫 “自動雞”,但是到底什么是 "自動雞" 呢?

我們認為一個東西是 "自動雞",有一些基本概念:

  • 這里的自動雞其實是有限狀態自動雞(\(\text{FSM}\));
  • 自動雞不是 算法/數據結構,只是一種數學模型,對於同一個問題可能有很多只雞,但是不是所有的雞都滿足我們解決問題時的資源消耗(比如時間和空間);

同時,一只雞由這幾個部分組成(雖然下文不會用到):

  1. 字符集 \(\Sigma\),表示這只雞只能輸入這些字符;
  2. 狀態集合 \(Q\),即在自動雞建好之后形成的的 \(DAG\) 上的頂點;
  3. 起始狀態 \(start/s\),不解釋;
  4. 接受狀態集合 \(F\),有 \(F\subseteq Q\)
  5. 轉移函數 \(\delta\),即點之間的轉移方法;

明白概念即可,下文大概率用不到.

貳、終止節點集合與類的億些特性及證明

對於上文的類有一些特性 並給它們取了名字,接下來給出並證明:

一、同類即后綴

如果有兩個串 \(S,T(|S|\le |T|)\)(其實取不到等號) 同屬一個類,那么一定有 \(S\)\(T\) 的后綴.

證明:\(\mathcal Obviously\).

二、從屬或無關

\(\forall S,T(|S|\le |T|)\),他們的 \(\text{endpos}\) 集合只有兩個情況:

\[\text{endpos}(S)\subseteq \text{endpos}(T)\space or\space \text{endpos}(S)\cap \text{endpos}(T)=\emptyset \]

證明:\(\mathcal Obviously\).

三、同類長連續

屬於同一個類中的所有串,他們的長度一定是連續的,同時,如果按照長度從小到大排序,那么一定有前一個串是后一個串去掉第一個字符的后綴,或者說,后一個串是前一個串在前面加上了一個字符.

證明:\(\mathcal Obviously\).

所以,如果我們想要儲存一個類,只需要存在最短串長度與最長串的長相就可以了.

四、類數為線性

類的個數為 \(\mathcal O(n)\) 級別的.

證明:對於兩個類 \(\mathcal{X,Y}\),由 "從屬或無關" 我們可以知道 \(\mathcal{X,Y}\) 要么包含要么不相交,同時,我們也可以找到同一個集合 \(\mathcal Z\) 滿足 \(\mathcal{X,Y}\subseteq \mathcal Z\),那么,我們可以得到 \(\mathcal{X,Y}\) 其實是 \(\mathcal Z\) 進行了分裂得到的兩個類,其實,\(\mathcal Z\) 可以一次性分裂成兩個或更多個類,但由於我們最開始的集合 \(\mathcal Z=\{1,2,3,...n\}\),即 \(|\mathcal Z|=n\),由線段樹對於集合最多的不交划分結論可知,總類的數量不會超過 \(\mathcal O(2n)\),實際上最多 \(2n-1\) 個類.

終於不是顯然了.

五,鄰類鄰長度

對於一個類 \(\mathcal X\),如果有一個 \(\mathcal Y\) 滿足 \(\mathcal X\subseteq \mathcal Y\),且不存在 \(\mathcal Z\) 滿足 \(\mathcal X\subseteq \mathcal Z\subseteq Y\),即 \(\mathcal X\)\(\mathcal Y\) 是直接父子關系,那么有 \(\mathcal X\) 中的最短的串 \(S\)\(\mathcal Y\) 中最長的串 \(T\) 滿足 \(|S|=|T|+1\).

證明:對於一個集合的分裂,其實就是在最長的串 \(A\) 前面加上一個字符得到新的字符串 \(B\),那么,\(B\) 的出現的限制條件一定比 \(A\) 更強,即 \(B\) 所屬的類就變成了 \(A\) 所屬的類 \(\mathcal P\) 的子集了,為甚說 \(A,B\) 一定不在同一個類中呢?如果在同一類中,那么 \(A\) 就不是最長的串而是 \(B\) 了,而得到的 \(B\) 就是 \(\mathcal P\) 的某個子集中最短的一個了,那么就有 \(|A|+1=|B|\).

所以,我們只要知道詳細父子關系,那么如果我們想要表示一個類只需要存下最長的串的樣子就可以了.

叄、如何建自動雞

其實在證明中已經使用了一些類之間存在父子關系的特性了.

定義直接父子關系:對於一個類 \(\mathcal X\),如果有一個 \(\mathcal Y\) 滿足 \(\mathcal X\subseteq \mathcal Y\),且不存在 \(\mathcal Z\) 滿足 \(\mathcal X\subseteq \mathcal Z\subseteq Y\),那么 \(\mathcal X\)\(\mathcal Y\) 是直接父子關系.

我們按照類的直接父子關系,建出一棵樹,這棵樹叫做 \(\text{parent tree}\),這個 \(\text{parent tree}\) 就是 \(SAM\) 的骨架,但是自動雞只有這棵樹似乎什么用都沒有,就像 \(\tt fail\) 指針基於 \(\tt trie\) 樹得到 \(AC\) 自動雞一樣,我們得在 \(\text{parent tree}\) 上加些東西.

首先,根據一些證明的說明,沿 \(\text{parent tree}\) 邊就是在串的前面動手腳,但是我們還需要在串的后面做手腳,這個時候就需要加入新的邊了,表示在串的后面加些字符,這就是自動雞上面的邊.

給出代碼,做出進一步說明:

/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];

/** @brief the number of nodes*/
int ncnt = 1;
/** @brief this variable is necessary, it records the id of the node of the origin string*/
int lst = 1;
/**
 * pay attention : node 1 is an empty node, it represent root
 * and the length of this node is 0
*/
inline void add(const int c){
    // the id of the original string
    int p = lst;
    // the id of new node or class
    int u = lst = ++ ncnt;
    // just means the length increases by 1
    lenth[u] = lenth[p] + 1; sz[u] = 1;
    /** @brief
     *  if the suffix of the original string
     *  add a new character @p c hasn't appeared yet, 
     *  then we connect the represent node with the new node, 
     *  it means that we can get a new class if we add @p c to the end
    */
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    /** @brief
     *  if all suffixes haven't appeared before after adding @p c
     *  we'll reach the situation where @p p equals to 0, 
     *  which means the father of the new class is 1(root)
    */
    if(!p) fa[u] = 1;
    /** @brief if the situation is not like this
     *  just means some suffixes have appeared before after adding @p c
     *  which means the new class is a split version of a certain class(actually class @p tre[p][c] )
    */
    else{
        /** @brief
         *  of course @p q is the original version of the new class,
         *  that is, @p q is apparently the father of @p u,
         *  but there are some other questions that
         *  some suffixes of @p q will appear at n,
         *  in other words, some part of @p q will add END POINT @p n (present length),
         *  which will make a different to class @p q and
         *  cause the split of class @p q.
         *  We're going to judge whether @p q should be split or not, 
         *  if the answer is true, we're supposed to handle the split
        */
        int q = tre[p][c];
        /** @brief 
         *  if all suffix have appeared before after adding @p c ,
         *  then there's no need to split class @p q ,
         *  because add END POINT will add n(present length)
         *  we just need to connect the new class @p u with it father
        */
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        /** @brief
         *  if not, then we're going to find out
         *  which part of class @p q is going to add END POINT @p n (present length),
         *  and we split it from here
        */
        else{
            /** @brief part to add new END POINT
             *  now node @p q represent a class which won't add new END POINT
             *  obviously that class @p q and class @p u are the subset of class @p split_part
            */
            // pay attention : the new node has no real meaning, so the size shouldn't be added.
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            /** @brief
             *  because the original node @p q is split to two part,
             *  @p split_part and @p q (another meaning)
             *  then if an edge which connect a certain node with origin @p q ,
             *  it is supposed to be changed
            */
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

首先看變量定義:

/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];

\(\tt tre[][]\) 表示的即為自動雞上面的邊;

\(\tt fa[]\) 表示自動雞上的父子關系,即我們通過保存父親節點來維護整個 \(\text{parent tree}\)

\(\tt lenth[]\) 表示的每個點代表的類中,最長的串有多長;

接下來進入 \(\tt add()\) 函數:

inline void add(const int c){

這個參數 \(\tt c\) 表示我們要在串的末尾插入的字符;

// the id of the original string
int p = lst;
// the id of new node or class
int u = lst = ++ ncnt;
// just means the length increases by 1
lenth[u] = lenth[p] + 1;

\(\tt p\) 表示我們在插入 \(\tt c\) 之前,代表整個串的點的編號是多少;

\(\tt u\) 代表我們要將插入 \(\tt c\) 之后,代表新的整個的串的點的編號;

\(\tt lenth[u]\) 顯然就是在未插入之前的長度基礎上增加一;

/** @brief
 *  if the suffix of the original string
 *  add a new character @p c hasn't appeared yet, 
 *  then we connect the represent node with the new node, 
 *  it means that we can get a new class if we add @p c to the end
*/
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;

接下來,我們考慮對於原串 \(S\),在增加 \(\tt c\) 之后,原來的 \(|S|-1\) 個后綴全都有了新的樣子(在原來的基礎上在末尾加上 \(\tt c\)),同時,還多出來一個新的后綴——單獨一個 \(\tt c\),我們稱這些東西為新后綴,這個循環要做的事情就是,這些新后綴如果在之前沒有出現過,那么我們要給他們歸為一個新的類——\(\text{endpos}=\{|S|+1\}\),同時,顯然,后綴越長,它越不容易出現,所以我們從代表 \(S\) 的點 \(\tt p\) 開始向上走,表示從長到短枚舉原來的后綴,對於沒出現過的,即 \(\tt tre[p][c]=0\),我們將他們連接到新的類——代表 \(\text{endpos}=\{|S|+1\}\) 的點 \(\tt u\) 上,表示在這些后綴后加上 \(\tt c\) 可以到達新的類;

if(!p) fa[u] = 1;
/** @brief if the situation is not like this
 *  just means some suffixes have appeared before after adding @p c
 *  which means the new class is a split version of a certain class(actually class @p tre[p][c] )
*/

如果上一個循壞直接走到空點,也就是說對於原串 \(S\) 的所有后綴,在加上 \(\tt c\) 之后,在之前的串中都沒見過,那么它的父節點就是 \(\text{endpos=}[1,n]\cap \mathcal Z\) 的根節點了.

else{
/** @brief
 *  of course @p q is the original version of the new class,
 *  that is, @p q is apparently the father of @p u,
 *  but there are some other questions that
 *  some suffixes of @p q will appear at n,
 *  in other words, some part of @p q will add END POINT @p n (present length),
 *  which will make a difference to class @p q and
 *  cause the split of class @p q.
 *  We're going to judge whether @p q should be split or not, 
 *  if the answer is true, we're supposed to handle the split
*/
int q = tre[p][c];

否則,則說明有些新后綴在之前出現過,即有些后綴的 \(\text{endpos}\) 集合不只是 \(\{|S|+1\}\),還有些其他的東西,同時,由於之前全部的后綴的點的類中都加上 \(|S|+1\) 這個元素,那么此時,僅僅只代表 \(|S|+1\) 這個元素的類的點 \(\tt u\) 肯定就是其他點的兒子了,那 \(\tt u\) 的父親究竟是誰?顯然就是之前循環中第一個不滿足條件的 \(\tt p\)\(\tt tre[p][c]\),我們稱這個點為 \(\tt q\).

同時,對於 \(\tt q\),由於它和 \(\tt p\) 並非有實際父子關系,而是由自動雞連接的 你居然不是我親生的,也就是說 \(\tt q\) 代表的串並非全是原串 \(S\) 的后綴(但一定會有一個 \(S\) 的后綴,即在 \(\tt p\) 所代表的后綴之后加上一個 \(\tt c\) 的后綴 ),但是我們找到它的原因是什么——它其中的某些串在 \(S\) 加上 \(\tt c\) 之后,會在 \(|S|+1\) 再出現一次,也就是有些串的 \(\text{endpos}\) 就會改動,同樣的,\(\tt q\) 中有些串又不會改動,這下出了個問題——\(\tt q\) 所代表的類中的串的 \(\text{endpos}\) 集合不一樣了,根據定義已經不能再將他們歸為同一類,所以接下來我們要考慮將 \(\tt q\) 分裂——加上 \(|S|+1\) 的部分,和沒有加上 \(|S|+1\) 的部分;

/** @brief 
 *  if all suffix have appeared before after adding @p c ,
 *  then there's no need to split class @p q ,
 *  because add END POINT will add n(present length)
 *  we just need to connect the new class @p u with it father
*/
if(lenth[q] == lenth[p] + 1) fa[u] = q;

上文提及,\(\tt q\) 中一定會包含一個 \(S\) 的后綴,即在 \(\tt p\) 所代表的后綴之后加上一個 \(\tt c\) 的后綴,但是,如果我們發現 \(\tt q\) 類中最長的就是這個后綴(下文稱其為特征串),由 “同類即后綴” 意味着 \(\tt q\) 類中所有串的 \(\text{endpos}\) 集合都同時加入 \(|S|+1\) 這個元素,那么這個 \(\tt q\) 類就沒有分裂的必要了;

/** @brief
 *  if not, then we're going to find out
 *  which part of class @p q is going to add END POINT @p n (present length),
 *  and we split it from here
*/
else{
/** @brief part to add new END POINT
 *  now node @p q represent a class which won't add new END POINT
 *  obviously that class @p q and class @p u are the subset of class @p split_part
*/
int split_part = ++ ncnt;

否則,則意味着 \(\tt q\) 逃不掉分裂的命運,接下來我們要考慮的是從哪里裂開,先申請一個新的點 \(\tt split\_part\) 當作分裂出去的部分(此處認定分裂部分為加上 \(|S|+1\) 元素的部分)的編號.

rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;

\(\tt p\) 的信息繼承到 \(\tt split\_part\) 上,同時,顯然我們分裂的點就是特征串(由 “同類即后綴” 同理可得),顯然,原 \(\tt q\)(沒加上元素 \(|S|+1\))就是多出 \(|S|+1\) 點的 \(\tt split\_part\) 類的一個子集,那么可以確定他們有直接父子關系了 (僅僅只多出一個元素,顯然不可能找到 \(\mathcal Z\) 滿足 \(\tt q\subseteq\mathcal Z\subseteq \tt \tt split\_part\)),那么我們將 \(\tt q\) 的父親設置為 \(\tt split\_part\),同樣,只有一個元素 \(|S|+1\)\(\tt u\) 類的父親肯定也是 \(\tt split\_part\) 類了.

/** @brief
 *  because the original node @p q is split to two part,
 *  @p split_part and @p q (another meaning)
 *  then if an edge which connect a certain node with origin @p q ,
 *  it is supposed to be changed
*/
for(; p && tre[p][c] == q; p = fa[p])
    tre[p][c] = split_part;

但是,我們確實是將 \(\tt split\_part\) 分裂出去了,但是,這個 \(\tt split\_part\) 是代表原來的 \(\tt q\) 的,但是還有一些點是沿着自動雞連接在舊的 \(\tt q\) 上,我們要將他們改過來,將他們和 \(\tt split\_part\) 連接.

然后,插入新點結束,構建自動雞時只需:

rep(i, 1, n) add(s[i] - 'a');

貼一發整合代碼:

const int maxn = 1e6;

int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];
int sz[maxn * 2 + 5];

int ncnt = 1;
int lst = 1;

char s[maxn + 5];
int n; // the length of string s

inline void input(){
    scanf("%s", s + 1);
    n = strlen(s + 1);
}

inline void add(const int c){
    int p = lst;
    int u = lst = ++ ncnt;
    lenth[u] = lenth[p] + 1;
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    if(!p) fa[u] = 1;
    else{
        int q = tre[p][c];
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        else{
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

signed main(){
    input();
    rep(i, 1, n) add(s[i] - 'a');
}

注意:如果需要統計某個點的子節點大小,在將 \(\tt q\) 裂開之后產生的新點不能計算 \(\tt sz\),因為它只是一個裂開的點為了表示一個新類,並不會對總點數產生貢獻.

肆、復雜度分析

一、點數線性

點數即類數,上文已證明,最多 \(2n-1\) 個 類/點.

二、邊數線性

考慮自動雞有以下性質:

  • \(s\) 開始,沿不同的路徑,最終一定會走到不同的子串;
  • 由 “同類即后綴”,以及 “同類長連續”,可以說明,只要這個類中有一個串是整個串的后綴,那么整個類都是串的后綴(其實從另一個角度說,同一個類中的串,區別只在於他們在前面加字符,但是在后面的字符是不動的)

然后,我們進行一個定義:定義廣義環為忽略邊的方向,將所有邊視為無向邊之后形成的環成為廣義環.

現在,我們可以開始證明邊數不超過 \(3n-4\) 了.

首先,假設我們有了一個自動雞,但是他們還沒有連邊,我們從 \(s\) 開始走后綴,現在規定走后綴的規則:

  1. 每次走后綴只有一次花費;
  2. 如果走的邊和原來的邊沒有形成廣義環,那么我們走這條邊並將這條邊添加進自動雞,並且這條邊不會使用花費;
  3. 如果走的邊和原來的邊形成了廣義環,那么我們走這條邊並將這條邊添加進自動雞,並且使用花費;

如果我們沿這些規律走后綴,最后最多只會有 \(3n-4\) 條邊,

三、\(\tt add()\) 函數復雜度證明

我再貼一個代碼:

inline void add(const int c){
    int p = lst;
    int u = lst = ++ ncnt;
    lenth[u] = lenth[p] + 1;
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    if(!p) fa[u] = 1;
    else{
        int q = tre[p][c];
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        else{
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

其實這里就幾個循環,而對於前兩個,我們都是在加邊,但是由於我們邊數已經是 \(3n-4\) 的上界了,那么這倆循環加起來總共也不超過 \(3n-4\),但是我們考慮我們的第三個循環:

for(; p && tre[p][c] == q; p = fa[p])
    tre[p][c] = split_part;

這個不好分析,我們考慮整個 \(\tt add()\) 在干什么:

  1. \(lst\) 向上爬,這個過程會減小可能成為 \(u\) \(\tt fa[u]\)\(\text{minlen}\)(就是類里面最小的字符串長度);
  2. 爬到頭了,從 \(p\) 走到 \(q\),這個過程會讓可能成為 \(u\) \(\tt fa[u]\)\(\text{minlen}\) 不多不少 \(+1\)
  3. \(u\) 認父親,然后讓 \(u\) 變成 \(lst\)(也就是下一次的 \(u\));

我們發現,每次 \(fa[u]\)\(\text{minlen}\) 最多只會增加一,但是減少卻可能一次性減少很多,這類似於 \(\tt SA\)\(\text{heit}\) 數組的過程,那么總復雜度大概在 \(\mathcal O(n)\) 級別.

實際上,如果我們將 \(\text{minlen}(fa[u])\) 當成勢能函數,可能會更好理解.

伍、應用

部分引自 \(\tt oi-wiki\),對於部分 我知道的 可以使用 \(SA\) 解決的也會大致描述,同樣,如果有其它如使用 \(\tt kmp\) 的巧妙方法亦會大致描述.

一、檢查子串

  • 使用 \(SAM\)

檢查 \(T\) 是否是 \(S\) 的某個子串.

后綴自動雞中實際上保存了 \(S\) 的所有子串信息,所以你只需要從開始節點沿自動雞邊走就可以了.

  • 使用 \(SA\)

\(S\)\(T\) 拼在一起,中間用分隔符隔開,判斷 \(\text{heit}\) 即可.

二、本質不同串個數

  • 使用 \(SAM\)

\(SAM\) 沿自動雞邊走形成的串本來就是本質不同的子串,所以,我們只需要統計從開始節點走的不同路徑數,由於是 \(DAG\),可以直接跑拓撲 \(DP\).

  • 使用 \(SA\)

每個后綴長度減去 \(\text{heit}\) 之和.

三、第 k 大子串

這里有板題 OAO

  • 使用 \(SAM\)

如果是去重,那么每個點的 \(sz\) 定為 \(1\),即表示每個字串都只出現一次而不因 \(\text{parent tree}\) 樹的出現而改變,反之,\(sz\) 即為其 \(\text{parent tree}\) 子樹的大小.

然后,我們處理出一個 \(\tt sum\) 數組表示經過這個點的子串有多少,那么就有

\[sum[u]=sz[u]+\sum_{v\in tre[u]}sum[v] \]

處理出來之后用類似 \(\tt trie\) 樹的做法即可.

  • 使用 \(SA\)

如果是去重,處理出 \(\tt SA\) 之后,由每個后綴都有 \(n-sa[i]+1-heit[i]\) 個本質不同的子串,然后就可以解決.

如果是非去重,這里直接引用 大佬 的解決方案.

因為我們前面的字母是確定的,那么當前面的字母一樣的時候,后面一個字母一定是單調不下降的。

那我們就能二分求出這個字母最后一個的位置。

我們建一個后綴長度的前綴和,那我們就能求出以這個字母開頭的子串有多少個。

當個數大於 \(k\) 時,就確定了這個字母,否則 \(k\) 減去,繼續枚舉下一個字母。

枚舉完一位繼續下一位,那我們就能把范圍縮小,知道求出答案。

實際上本質和 \(SAM\) 的做法差不多.

四、二元最長公共子串(LCS)

  • 使用 \(SAM\)

對於一個串建立 \(SAM\),拿另一個串在 \(SAM\) 上能跑多遠跑多遠,跑不了了就往父親跳.

  • 使用 \(SA\)

兩個串用 \(SA\) 經典流氓做法接在一起(注意中間用分隔符隔開),處理出 \(\tt sa\) 之后將 \(\tt heit\) 排序從大到小枚舉長度,直到一個屬於兩個串的 \(\tt heit\) 滿足標准后就找到長度(如果要找長什么樣子也可以,這里不做贅述).

五、最小循環位移

  • 使用 \(\tt kmp\)

先找到 \(n\) 位置對應的 \(nxt\),那么最小循環位移就可能是 \(n-nxt[n]\),檢查一下,如果不滿足就 \(GG\).

  • 使用 \(SAM\)

復制一個 \(S\) 並對其建立 \(SAM\),然后在 \(SAM\) 上尋找最小的長度為 \(|S|\) 的路徑即可.找最小的,我們只需要貪心往最小字符走即可.

  • 使用 \(SA\)

理論上可以當 \(\tt kmp\) 用,我們對於反串排出 \(\tt sa\),然后看 \(S'[1...n]\) 的位置,那么長度要么就是它和它前一名、要么就是它和它后一名的 \(LCP\),然后我們暴力檢驗就可以了.

六、子串出現次數

  • 使用 \(SAM\)

找到子串對應的點,然后算出這個點的 \(sz\) 就可以了.

  • 使用 \(SA\)

用流氓做法,把子串接在后面然后處理出 \(\tt sa\),然后看屬於兩個串的 \(heit\) 剛剛好是子串長度的 \(heit\) 個數即可.

七、多元最長公共子串(exLCS)

  • 使用 \(SAM\)

兩種思路:

  1. 對於第一個建立 \(SAM\),然后其他的在這個上跑,對於每個點,記錄 \(f[i]\) 表示走到 \(i\) 能夠匹配上的最長公共子串的長度,注意 \(f[i]\le \text{longest}(i)\),最后跑拓撲,用 \(f[i]\) 更新 \(f[fa[i]]\),得到匹配完某個串之后的 \(f[]\),然后記錄在 \(ans[i]\) 中,其中 \(ans[i]\) 表示第 \(i\) 個點的歷史中能匹配的最小長度(因為我們要求的是滿足所有的串),最后在 \(ans[i]\) 中找最大就可以了.
  2. \(T=S_1+D_1+S_2+D_2+...+D_{n-1}+S_n+D_n\),其中 \(S_i\) 是我們想要找的,\(D_i\) 是對應的分隔符,我們對於 \(T\) 建立 \(SAM\),然后,如果 \(S_i\) 中有一個子串,那么存在一條路徑從 \(D_i\) 不經過其他的 \(D_j\) 的路徑. 然后我們處理連通性,使用 \(\tt BFS\) 等搜索算法之族以及動規計算,然后,答案就是所有的 \(D_i\) 都能到達的點中 \(\text{longest}\) 的最大值.
  • 使用 \(SA\)

流氓將所有串暴力接起來,從大到小枚舉 \(heit\) 長度,並用並查集枚舉每一個合並的塊中有多少不同的集合,當某個連通塊中存在了所有的串,此時枚舉的長度就是答案串的長度,尋找就隨意了.

八、子串中未出現最小串


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM