1. 程式人生 > 實用技巧 >[學習筆記] 字尾自動機

[學習筆記] 字尾自動機

其實我字尾自動機在 \(2020/2\) 的時候就會了,刷了很多題,但是一直沒有搞懂原理,現在補一發關於字尾自動機原理的部落格。我儘量節約時間,把最重要的東西寫的儘量好理解。

我是看這位巨佬的部落格學的,所以會有一些重合的地方,但是我會按照我自己的理解寫(仔細看你會發現我們的表述相差很大),爭取達到一個優化的效果。

什麼是字尾自動機?

這才是究極大暴力,學會了之後隨便切很多字串毒瘤題。

他主要乾的事情是維護一個字串中的所有子串,但是基礎複雜度卻能保證是 \(O(n)\) ,此外還有很多優美的性質 \(....\) 原諒我現在沒有辦法一一向你介紹,具體可以看最後的部分 字尾自動機應用

中心思想是 \(\tt endpos\)

,也就是維護子串的 出現結束位置集合

一些基礎的結論

\(\tt endpos\) 有很多優美的性質,且看我一一向你介紹。

結論1:\(\tt endpos\) 相同的兩個字串 \(A,B\;(|A|\leq |B|)\) ,滿足 \(A\) 一定是 \(B\) 的字尾。

這麼明顯的結論為什麼不感性理解呢?

證明的話考慮他們 \(\tt endpos\) 完全相同,那麼 \(B\) 出現的話一定會連帶著 \(A\) 出現。對於每個結束位置都如此,不難發現 \(A\)\(B\) 的字尾。

現在你應該能感知到為什麼要叫字尾自動機,因為 \(\tt endpos\) 會連帶著一些關於字尾的性質

結論2:對於兩個字串 \(A,B\;(|A|\leq |B|)\) ,那麼要麼 \(\tt endpos(B)\subseteq endpos(A)\) ,要麼 \(\tt endpos(A)\cap endpos(B)=\empty\)

其實也很好感性理解呀!

我們就要證明不會出現相交的情況,使用反證法,假設出現了相交。那麼 \(B\) 出現的某些地方會出現 \(A\)\(B\) 出現的另一些地方又不會出現 \(A\) ,顯然矛盾,所以只會有包含或者是不交的情況。

結論3:對於 \(\tt endpos\) 相同的子串,我們將其歸為一個 \(\tt endpos\) 等價類,對於每一個等價類按長度排序,每一個子串的長度都等於上一個字串的長度\(+1\)

好顯然啊!

我們假設這個等價類中最長的子串是 \(len\) ,那麼最短的子串是 \(jzm\) ,顯然 \(jzm\)\(len\) 的一個字尾。那麼既然 \(jzm\) 都可以被劃分到這個等價類中,所以長度在他們中間的一定都會劃分到這個等價類中。

有了這個結論,我們可以用 \(len(i)\) 表示第 \(i\)\(\tt endpos\) 等價類的很多資訊了,也就是我們只需要維護等價類中最長的子串

結論4:\(\tt endpos\) 等價類的個數為 \(O(n)\)

這個結論是字尾自動機複雜度得到保證的關鍵,我不敢再說顯然了

我們考慮用樹形關係來表示 \(\tt endpos\) 之間的聯絡,如果 \(i\) 等價類是 \(j\) 等價類的父親,那麼 \(i\) 等價類的所有子串都是 \(j\) 等價類所有子串的字尾。而因為結論 \(2\),我們知道 \(i\) 的所有兒子 \(j\) 所表示的 \(\tt endpos\) 集合一定是不交的。

這樣可以用一種劃分關係來理解這個樹形結構,也就是我們會把 \(i\)\(\tt endpos\) 劃分分給他的兒子。一共只會劃分 \(n-1\) 次就可以到達底層,而 \(1\) 個點的作用相當於劃分了一次,所以點數 \(O(n)\)

我們把這樣的樹形關係叫做 \(\tt parent\;tree\) ,這棵樹就是字尾自動機的關鍵。

如何構建字尾自動機

上面的結論主要是說明了字尾自動機的可行性和一些關鍵特徵,現在我們來解決具體的構建。

字尾自動機之所以叫自動機,是因為他也有所謂的 轉移 ,就像 \(\tt AC\) 自動機一樣。

字尾自動機的轉移是這個意思:在這個點的子串後面加上一個字元 \(c\) 所能到達的點(子串的長度要儘量短)。如果我們能構造出轉移,那麼這個自動機就很好用了。現在我們先來看程式碼吧,我會逐一講解程式碼:

void add(int c)
{
    int p=last,np=last=++cnt;val[cnt]=1;
    a[np].len=a[p].len+1;
    for(;p && !a[p].ch[c];p=a[p].fa) a[p].ch[c]=np;
    if(!p) a[np].fa=1;
    else
    {
        int q=a[p].ch[c];
        if(a[q].len==a[p].len+1) a[np].fa=q;
        else
        {
            int nq=++cnt;
            a[nq]=a[q];a[nq].len=a[p].len+1;
            a[q].fa=a[np].fa=nq;
            for(;p && a[p].ch[c]==q;p=a[p].fa) a[p].ch[c]=nq;
        }
    }
}

我們一個一個加入字元,所以我們處理的是原串字首的字尾自動機。但是現在我們叫加入 \(c\) 之前的字串為原串,加入 \(c\) 之後的串為新串,那麼 \(np\) 其實是整個新串對應的節點,要乾的事有兩個:把 \(np\) 連進圖中 \(/\) 原串字尾加上 \(c\) 之後的 \(\tt endpos\) 可能變化,所以這也要修改一下。

int p=last,np=last=++cnt;val[cnt]=1;
a[np].len=a[p].len+1;

這兩句話就是基本的定義,很顯然,沒有什麼要講的。

for(;p && !a[p].ch[c];p=a[p].fa) a[p].ch[c]=np;
if(!p) a[np].fa=1;

這一部分就是修改 \(p\) 和他的祖先關於 \(c\) 的轉移,如果他們沒有東西可以轉移,那麼轉移到 \(np\) 就是極好的(其實結合轉移的定義就不難理解了),下一句話是如果 \(1\)(根)都沒有 \(c\) 可以轉移,那麼顯然 \(c\) 是在原串中沒有出現過的,所以 \(np\) 直接成為 \(1\) 的兒子。

int q=a[p].ch[c];
if(a[q].len==a[p].len+1) a[np].fa=q;

現在的 \(p\) 已經是第一個有轉移的祖先了,我們先拿到 \(p\) 的轉移 \(q\),那麼如果 \(len[q]=len[p]+1\) ,那麼說明 \(q\) 一定是 \(p\) 的最長子串後面再接上一個 \(c\) 的結果,這簡直就是無縫連線啊!然後不難看出 \(q\) 一定是 \(np\) 的字尾,所以把 \(np\) 的父親設定為 \(q\)\(\tt make\; sense\) 的。

int nq=++cnt;
a[nq]=a[q];a[nq].len=a[p].len+1;
a[q].fa=a[np].fa=nq;
for(;p && a[p].ch[c]==q;p=a[p].fa) a[p].ch[c]=nq;

這種情況就是 \(len[q]>len[p]+1\) ,也就是 \(q\)\(p\) 的最長子串後面再加上若干字元。這種情況不能直接設定父親,因為加上若干字元之後就不滿足了字尾關係。那麼我們考慮建一個新點 \(nq\) ,那麼 \(len[nq]=len[p]+1\),也就是它代表了 \(p\) 代表的字串直接接上 \(c\) 字元的結果。\(nq\) 相當於把一個子串拆出來

那麼現在就滿足字尾關係了,\(q\)\(np\) 的父親都可以直接賦值為 \(nq\)

現在也不要忘了更新轉移喲,對於 \(p\) 以及 \(p\) 的祖先中轉移是到 \(p\) 點的話,現在轉移就要改成 \(nq\) 點了。結合轉移的定義就知道我們要找的是長度最小的點,所以肯定轉移到 \(nq\) 啊。


現在進入喜聞樂見的複雜度證明環節:由於邊數和點數都是 \(O(n)\) 的,觀察所有的操作發現時間複雜度 \(O(n)\)

字尾自動機的應用

會慢慢補充的 \(....\)