1. 程式人生 > 其它 >淺談串串題裡的自動機

淺談串串題裡的自動機

眾所周知,字串問題裡會涉及到一車自動機模型的演算法,這裡寫點東西簡單總結一下。

一些內容概要和基本概念

本文會涉及到三個自動機:\(\sf AC\) 自動機(\(\sf ACAM\)),字尾自動機(\(\sf SAM\))和迴文自動機(又稱迴文樹,\(\sf PAM\))。當然這些知識點稍微有些難度,所以這裡先用一個比較簡單的自動機——\(\sf Trie\) 樹,作為引入和熱身吧。

首先,OI 中討論的自動機一般為確定有限狀態自動機(\(\sf DFA\)),它能被一張 \(\sf DAG\) 表示,且由五個部分組成:

  • \(\sum\),字符集,表示所有可能出現的字元。
  • \(S\),狀態集合,可以看成 \(\sf DAG\)
    上的點,在串串題裡可以是所有後綴的集合,所有字首的集合等。
  • \(s\),起始狀態,可以看成是 \(\sf DAG\) 上入度為 \(0\) 的那個結點,一般是一個虛擬結點。
  • \(T\subseteq S\),接受狀態集合,是一組有特殊意義的狀態,如是題目中給出的字串。
  • \(\delta\),轉移函式,可以看成是 \(\sf DAG\) 上的有向邊,如表示當前字元是 \(\tt a\) 時該轉移到哪個結點。

對於一個 \(\sf DFA\),我們可以把拿一個字串在上面轉移的過程,看成在它對應的 \(\sf DAG\) 上走的過程,而最終到達的結點會返回一個結果。即,\(\sf DFA\)

可以識別一個字串。

我們來看看 \(\sf Trie\) 樹對應的這五個部分。

  • \(\sum\),看題目,大部分是小寫英文字母集合。
  • \(S\)\(\sf Trie\) 樹表示的是插入所有字串的字首集合。
  • \(s\),一般設為 \(1\)
  • \(T\)\(\sf Trie\) 樹上擁有結束標記的結點,表示這個字首是整個串。
  • \(\delta\),可以得到當前狀態後加入某個字元時得到的字首對應的狀態。

對於 \(\sf Trie\) 樹,我們可以拿一個字串在上面沿著 \(\delta\) 走結點,如果最終走到的結點有結束標記,則說明這個字串曾經被插入過 \(\sf Trie\)

樹內。(當然插入的過程也是類似的,不再贅述)

相信大家已經理解 \(\sf DFA\) 是什麼了,那接下來就進入正題吧。(注意:因為如果把這仨自動機全都詳細介紹一遍,那本文就有點太長了,所以建立自動機部分製作簡單介紹)

AC 自動機

\(\sf AC\) 自動機,是一種能用於 多模式字串匹配\(\sf DFA\),它的建立是基於 \(\sf Trie\) 樹的結構,並用 \(\sf kmp\) 演算法的思想對 \(\sf Trie\) 樹的結構做了補充,使得字串匹配的過程大大加速。因為是基於 \(\sf Trie\) 樹的結構,所以 \(\sf DFA\) 的結構和它是一樣的。

具體來講,我們在 \(\sf Trie\) 樹上匹配時,如果出現失配,就只能回到根節點再來一遍,這是非常不划算的,不僅如此,我們匹配上時,還要考慮這個狀態的字尾是不是也在 \(\sf Trie\) 樹裡。等等,字尾?我們發現,如果能找到一個狀態,滿足是當前狀態的最長字尾,那不就能解決以上兩個問題了嗎!

這裡就要引出加速自動機匹配的最重要的一個概念了,字尾連結,一般稱作 \(\sf fail\)。就像剛剛介紹的,\(\sf fail\) 指標指向的是,在自動機裡當前狀態的最長字尾,當我們失配時就可以走到 \(\sf fail\) 指標對應的位置繼續匹配,而當我們匹配上時,就可以沿著 \(\sf fail\) 指標繼續跳,直到跳到根,就能找到所有匹配上的字尾了。

\(\sf AC\) 自動機上,建立 \(\sf fail\) 指標可以基於它在 \(\sf DFA\) 上的前置狀態(也就是在 \(\sf DAG\) 上指向它的那個狀態)的 \(\sf fail\) 來得到,具體實現可以用 \(\rm bfs\)

接下來,就用在這仨裡面相對簡單一點的 \(\sf AC\) 自動機來介紹一下自動機上能幹啥。

字串匹配

P5357 【模板】AC 自動機(二次加強版)

給出一個文字串 \(S\)\(n\) 個模式串 \(T_{1\sim n}\),請你求出每個模式串 \(T_i\)\(S\) 中出現的次數。(\(1\le n,\sum T_i,|S|\le 2\times 10^5\))

考慮我們匹配的過程,就是用文字串在 \(\sf AC\) 自動機上走一遍,這部分的時間複雜度是 \(\mathcal{O}(|S|)\) 的。而找到終止結點時,我們需要跳 \(\sf fail\) 指標來找到所有出現的字尾,這部分的時間複雜度最壞可以到 \(\mathcal{O}(n)\),而兩者是乘法的關係,所以樸素的匹配是 \(\mathcal{O}(n|S|)\) 的複雜度,顯然不夠優秀。

觀察我們跳 \(\sf fail\) 指標的過程,看起來很像給一條鏈上所有的結點加 \(1\),而直覺告訴我們,這個過程是很可以優化的。觀察 \(\sf fail\) 指標構成的結構,因為除了 \(s\) 狀態,所有狀態都有對應的 \(\sf fail\) 出邊,即如果總狀態有 \(p\) 個,則我們有 \(p-1\) 條邊。而注意到 \(\sf fail\) 指標形成的結構是連通的(感性理解吧,咱這兒沒有證明),所以我們就可以把 \(\sf fail\) 指標單獨拉出來形成一棵樹。一般叫做 \(\sf fail\) 樹。

則我們跳 \(\sf fail\) 的過程就可以變為在 \(\sf fail\) 樹上朝根走的過程。這樣的話,我們就可以用我們學過的樹相關的演算法對這個過程加以優化了。用樹上差分,可以做到 \(\mathcal{O}(|S|+\sum T_i)\) 的複雜度。

程式碼:\(\tt code\)

fail 樹

剛剛提到的 \(\sf fail\) 樹在不同的自動機可能有不同的叫法,但總的來說都是把 \(\sf fail\) 指標形成的結構變成樹這麼一個我們熟知的結構,從而能用我們熟知的演算法加以解決,這個功能是十分強大的。不過要注意區分 \(\sf DFA\) 本身和 \(\sf fail\) 樹。

P2414 [NOI2011] 阿狸的打字機

給出 \(n\) 個字串和 \(m\) 組詢問,每組詢問需要回答第 \(x\) 個字串在第 \(y\) 箇中出現了多少次。(\(1\le n,m\le 10^5\))

原題的這 \(n\) 個字串就是以 \(\sf Trie\) 樹的形式給出的,做法甩臉上了。建出來 \(\sf AC\) 自動機後,考慮 \(\sf fail\) 指標的意義,即最長的字尾,而注意到 \(\sf AC\) 自動機上的狀態集合是字首,字首的字尾......是子串!這剛好和我們想要的東西不謀而合,所以我們要考慮的狀態是 \(\sf DAG\)\(y\) 對應的結束狀態到 \(s\) 路徑上的所有狀態。需要考慮的狀態中,每個對應的貢獻就是它在 \(\sf fail\) 樹上到根節點的路徑上,出現的狀態中,在 \(x\) 狀態子樹內的狀態個數。

所以我們相當於詢問,\(\sf DAG\) 上的一條鏈上所有結點,在 \(\sf fail\) 樹上的到根節點的路徑上在 \(x\) 狀態子樹內出現的次數和。由於子樹在 \(\rm dfn\) 序上對應的是一段區間,所以這個可以用主席樹實現,每個狀態用它在 \(\sf DAG\) 上的前置結點作為上一個根即可,維護每個 \(\rm dfn\) 序對應的結點出現了多少次。時間複雜度 \(\mathcal{O}(m\log n)\)

程式碼:\(\tt code\)

動態規劃

我們發現,\(\sf DFA\) 的結構是一個 \(\sf DAG\),這不跑 \(\rm dp\) 虧了。

P5319 [BJOI2019]奧術神杖

給出一個長為 \(n\) 的字串 \(S\),其中一些位置可以選擇。再給出 \(m\) 個串和對應的價值 \((s_i,v_i)\),如果在選擇 \(S\) 中空缺的字元後,可重集 \(S=\{s_i\}\) 內的字串在 \(S\) 中出現(出現多次統計多次),則價值為:

\[\sqrt[|S|]{\prod_{i=1}^{|S|}v_i} \]

求最大價值,輸出一種對應的方案。(\(1\le n,m,\sum |s_i|\le 1501,1\le v_i\le 10^9\))

觀察到這個式子很噁心,沒法統計(你甚至沒法計算),所以要做點變化:

\[\begin{aligned}&\max \sqrt[|S|]{\prod_{i=1}^{|S|}v_i}\\=&\max \ln\sqrt[|S|]{\prod_{i=1}^{|S|}v_i}\\=&\max \dfrac{1}{|S|}\sum_{i=1}^{|S|}\ln v_i\end{aligned} \]

所以我們現在的目標就從最大化原來那個噁心的根式,變成最大化後面那個和式。注意到這是 \(0/1\) 分數規劃的形式,考慮二分一個答案 \(x\),則二分的條件:

\[\begin{aligned}\dfrac{1}{|S|}\sum_{i=1}^{|S|}\ln v_i&>x\\\sum_{i=1}^{|S|}\ln v_i&>x|S|\\\sum_{i=1}^{|S|}(\ln v_i-x)&>0\end{aligned} \]

所以現在我們的目標就是,對於 \(x\),最大化左邊那個式子,然後比較它和 \(0\) 的關係,從而找到二分應該如何調整左右邊界。

而我們想求出這個值,只需要給 \(\sf AC\) 自動機上每個狀態的權值設定為它在 \(\sf fail\) 樹上到根結點的權值和即可,狀態初始權值僅有結束狀態有,為 \(\ln v_i\),並記錄每個狀態到根結點的狀態數 \(\rm cnt\),每次給每個結點減去 \(x\rm cnt\) 即可。考慮 \(\rm dp\),設 \(f_{i,j}\) 表示前 \(i\) 個字元,匹配到第 \(j\) 個狀態,能得到的最大權值,轉移的時候直接按照剛剛求好的權值加一加即可。注意,這裡要按照拓撲序來轉移,而由於 \(\sf AC\) 自動機構建的特殊性,加入結點的順序就是拓撲序,直接做就好了。記得再維護一下路徑以便輸出答案。時間複雜度 \(\mathcal{O}(n\sum |s_i|\log \log v_i)\)

程式碼:\(\tt code\)

當然 \(\rm dp\) 也有很多種,不止可以做這道題最大化型別的 \(\rm dp\)。狀壓,數位等等都可以做,非常靈活。

圖的結構

\(\sf DAG\)\(\sf fail\) 樹畢竟也是圖的一種,有沒有可能,用它們的結構乾點什麼呢。(提一嘴,這種結構叫 \(\sf Trie\) 圖,我們剛剛匹配就一直在用這個)

P2444 [POI2000]病毒

給出 \(n\)\(\tt 0,1\)\(s_i\),求是否存在一個無限長的串不包含裡面的任意串。(\(1\le n\le 2\times10^3,1\le \sum |s_i|\le 3\times 10^4\))

考慮我們匹配的過程,是找到結束結點的過程,而本題要求我們儘量在匹配的時候不找到結束結點,並問能否一直走下去。建出來 \(\sf AC\) 自動機對應的 \(\sf Trie\) 圖後,我們就在這張圖上面走,如果能走進一個環,且環上所有結點沒有結束結點,也不會匹配到有結束結點的狀態,那我們就可以在這張環上一直走,從而找到一個無限長的串。對於結束結點的維護,可以用剛剛奧術神杖那道題的思路,而找環就是一遍 \(\rm dfs\) 的事。時間複雜度 \(\mathcal{O}(\sum |s_i|)\)

程式碼:\(\tt code\)

\(\sf AC\) 自動機的介紹就暫時到此為止了,因為筆者想不起來什麼其他的用處了。(如果您有想法歡迎在討論區提出!)接下來,讓我們移步,來看看一個強大的自動機,\(\sf SAM\)

字尾自動機

字尾自動機(通常簡稱為 \(\sf SAM\)\(\sf Suffix\ Automaton\)),是一種能用於 大部分字串問題\(\sf DFA\),它以高度壓縮的形式儲存了一個字串的所有子串資訊,也就是說,它的狀態集合是一個字串的所有子串。而結束狀態,就像它的名字一樣,是一個字串的所有後綴。不僅如此,\(\sf SAM\) 是滿足以上性質的自動機中,狀態數和轉移數最少的。

\(\sf SAM\) 的壓縮,是基於 \(\sf endpos\) 集合(在原串中出現的結束結點集合)的,它把 \(\sf endpos\) 集合相同的一類子串壓縮成一個狀態,且由於 \(\sf endpos\) 集合的優秀性質,我們能通過這個狀態得知許多資訊。(以下 \(\sf endpos\) 簡稱 \(\sf edp\))建立 \(\sf SAM\) 的過程不再贅述,具體可以看 OI wiki 或者 \(\tt cmd\) 的部落格,接下來說一些 \(\sf SAM\) 的性質:

  1. \(\sf SAM\) 是由一個 \(\sf DFA\) 和上面的字尾連結 \(\sf fail\) 組成的,而 \(\sf fail\) 組成的 \(\sf fail\) 樹又被稱為 \(\sf parent\) 樹,\(\sf fail\) 指標連向的狀態是該狀態內最長串最長的不在該狀態內的字尾,所在的狀態。
  2. \(\sf SAM\) 上每個結點表示的子串互為字尾關係,且長度連續,在 a[a[p].f].len + 1a[p].len 之間,其中 f 是字尾連結,len 是該狀態內最長串的長度。
  3. \(\sf SAM\) 每個結點 p 表示的子串個數是 a[p].len - a[a[p].f].len
  4. \(\sf edp\) 集合要麼不交,要麼相互包含,且 \(\sf parent\) 樹上的非葉子結點的 \(\sf edp\) 由子節點並得到。
  5. 反串的 \(\sf parent\) 樹是原串的字尾樹。
  6. 隨機字串的 \(\sf parent\) 樹期望高度是 \(\mathcal{O}(\log n)\) 級別的。

我們做題就是根據這些性質(當然還有我沒提到的性質)來使用 \(\sf SAM\) 的。此外,我們觀察到其實本質上,\(\sf SAM\) 相當於把所有後綴加入的 \(\sf AC\) 自動機,只不過為了保證複雜度,極大壓縮了資訊。(因為所有後綴的所有字首就是所有子串!)所以 \(\sf AC\) 自動機能做的事人家 \(\sf SAM\) 也能幹,\(\sf AC\) 自動機做不了的事,\(\sf SAM\) 還能幹。

字串匹配

應該不需要再把 \(\sf AC\) 自動機那一套再說一遍了吧。

SP1812 LCS2 - Longest Common Substring II

\(n\) 個字串 \(s_i\),求它們的最長公共子串。(\(1\le n\le 10,1\le |s_i|\le 10^5\))

考慮對第一個串建 \(\sf SAM\),然後設 slen[i] 表示所有剩下的串中,以 i 結尾的字首的最長匹配長度的最小值,則最終答案即 max(slen[i])。考慮對每個串的每個字首,在 \(\sf SAM\) 上跑匹配,得到匹配長度後與 slen[i]\(\min\) 即可。具體過程與 \(\sf AC\) 自動機十分類似,不過由於我們沒有 \(\sf Trie\) 圖那樣的結構,所以只能一個個跳 \(\sf fail\)(有沒有哥哥教育一下這個複雜度為啥是對的),跳到哪匹配長度就是那個狀態內的最長子串。而最後如果匹配上了,匹配長度加一即可。時間複雜度大概是 \(\mathcal{O}(n|s_i|)\)

程式碼:\(\tt code\)

parent 樹

對標剛剛 \(\sf AC\) 自動機的 \(\sf fail\) 樹。不過由於這玩意還能當字尾樹用,所以用處更大了。(好吧因為這玩意的資訊壓縮過了,所以用法幾乎沒啥交集)

P3804 【模板】字尾自動機 (SAM)

給出 \(S\),求出它所有出現次數不為 \(1\) 的子串的出現次數乘上子串長度的最大值。(\(1\le |S|\le 10^6\))

注意到,\(\sf parent\) 樹上父親結點的 \(\sf edp\) 是由若干不交的子結點併成的,所以我們可以用 siz 記錄每個結點的 \(\sf edp\) 大小,初始時只有字首對應的狀態的 siz\(1\),因為我們只確定它們的出現次數,然後在 \(\sf parent\) 樹上合併即可。時間複雜度 \(\mathcal{O}(n)\)。注意到,因為父子結點之間 len 的嚴格大小關係,我們還可以用 \(\mathcal{O}(n)\) 的排序(比如基數排序)找到父子關係,做到同樣的複雜度。

程式碼:\(\tt code\)

P4248 [AHOI2013]差異

給出一個長為 \(n\) 的字串 \(S\),令 \(T_i\) 表示它以 \(i\) 開頭的字尾,求:

\[\sum_{1\le i<j\le n}\operatorname{len}(T_i)+\operatorname{len}(T_j)-2\times\operatorname{lcp}(T_i,T_j) \]

其中 \(\operatorname{len}\) 表示字串長度,\(\operatorname{lcp}\) 表示最長公共字首。(\(2\le n\le 5\times 10^5\))

前半部分非常好求,考慮後半部分,要求的是兩兩字尾的 \(\operatorname{lcp}\) 長度和。考慮字尾樹上,兩個結點對應字尾的 \(\operatorname{lca}\) 的深度,即為它們的 \(\operatorname{lcp}\) 長度。而對應到 \(\sf parent\) 樹上,就是對應狀態的 len,為 \(\operatorname{lcp}\) 長度。用反串建出來 \(\sf parent\) 樹後統計子樹內子樹外的貢獻即可。時間複雜度 \(\mathcal{O}(n)\)。(其實能發現,如果用正串搞,求出來的是 \(\operatorname{lcs}\),最長公共字尾)

程式碼:\(\tt code\)

當然,\(\sf parent\) 樹既然是一棵性質非常好的樹,就一定能把樹上演算法(重剖,\(\sf LCT\),點分治等)拿來套到這上面維護些什麼。因為筆者很菜,所以還沒做出來過這樣的題,這裡只扔一道例題。

P4482 [BJWC2018]Border 的四種求法

給出一個字串 \(S\)\(q\) 次區間詢問 \(\rm border\) 長度。(\(1\le |S|,q\le 2\times 10^5\))

動態規劃

\(\sf AC\) 自動機,只不過因為 \(\sf SAM\) 建圖的特殊性,拓撲序不再是編號順序了,需要我們從根開始遍歷。

P3975 [TJOI2015]弦論

給出一個字串 \(s\),求出它的第 \(k\) 小子串,分別對不同位置的相同子串算多個和算一個求解。(\(1\le |s|\le 10^5,1\le k\le 10^9\))

注意到一個子串對應的就是一條路徑,所以如果我們能求出從一個結點出發有多少條路徑,我們就能用類似 \(\sf BST\) 上找第 \(k\) 大的思路找到第 \(k\) 小的子串了。首先考慮在 \(\sf parent\) 樹上合併出 \(\sf edp\) 的大小 siz。然後就可以 \(\rm dp\) 了,如果本質相同的子串算一個,那就是單純的統計路徑條數,如果算多個,那就要把點權設為 siz 再求和,因為 siz 的值就是這個 \(\sf edp\) 內子串出現的次數。具體實現細節見程式碼。

程式碼:\(\tt code\)

CF700E Cool Slogans

給出一個字串 \(S\),要求構造出 \(s_1,s_2,\cdots,s_k\),滿足任意 \(s_i\) 都是 \(S\) 的子串,且 \(\forall i\in[2,n]\),都有 \(s_{i-1}\)\(s_i\) 中出現了至少兩次。求出最大的 \(k\)。(\(1\le |S|\le 2\times10^5\))

注意到這個選擇的關係,前一個必須在後一箇中出現,而這在 \(\sf SAM\)\(\sf parent\) 樹上可以看成是父子關係,即 \(s_{i-1}\) 必須是 \(s_{i}\) 的祖先結點,且在 \(s_i\) 中出現了兩次。這就非常像樹形 \(\rm dp\) 了,考慮設 \(f_i\) 表示走到 \(i\) 號結點能得到的最大的 \(k\),為了方便轉移,並設 \(g_i\) 表示 \(i\) 號結點對應的最大的 \(k\) 的結尾結點。(有可能 \(i\) 選不上,所以要額外維護)則轉移時,我們考慮接上父親結點的 \(g\),判斷 \(g_{fa_i}\) 對應的字串是否在 \(i\) 中出現兩次,如果出現,則就接上這個最長 \(k\)

\[f_i=f_{fa_i}+1,g_i=i \]

否則接不上:

\[f_i=f_{fa_i},g_i=g_{fa_i} \]

特別地,如果這個結點沒有父結點,或父節點是根結點,則為邊界條件:

\[f_i=1,g_i=i \]

最後答案為 \(f_i\) 的最大值。發現整個過程中,我們唯一沒辦法用 \(\sf SAM\) 的結構很方便維護的就是,判斷一個子串是否在另一箇中出現兩次。這個要用到下文馬上要說的,維護具體的 \(\sf edp\) 集合。維護出來之後,找到每個結點任意一個 \(\sf edp\)\(pos\)。判斷 \(x\) 是否在 \(y\) 中出現兩次時,因為 \(x\)\(y\) 的祖先(字尾),所以 \(pos\) 處一定出現了一次,而剩下的一次,只需要出現在 \(y\) 內就可以了,即判斷 \(x\) 有沒有 \(\sf edp\) 值位於這個區間:

\[[pos-\operatorname{len}(y)+\operatorname{len}(x),pos) \]

所有合併和查詢的過程均可以用線段樹合併實現,時間複雜度 \(\mathcal{O}(n\log n)\)

程式碼:\(\tt code\)

維護具體的 edp 集合

發現 \(\sf edp\) 集合的合併,完全可以用線段樹合併做到維護 \(\sf edp\) 集合裡具體有哪些位置。而我們有了這個資訊,那對於特定的字串問題是無往而不利。

P4094 [HEOI2016/TJOI2016]字串

給出一個長為 \(n\) 的字串 \(S\),和 \(m\) 組詢問,每組詢問以 \((a,b,c,d)\) 描述,求出 \(S_{a,b}\) 的所有子串與 \(S_{c,d}\)\(\operatorname{lcp}\) 的最大值。(\(1\le n,m\le 10^5\))

這道題看起來很無從下手,但注意到答案滿足單調性,所以考慮二分轉化成判斷性問題。現在問題變為,\(S_{c,c+x-1}\) 是否在 \(S_{a,b}\) 中出現過,這問題一下就可做了。首先注意到,\(S_{c,c+x-1}\) 是字首的形式,在 \(\sf SAM\) 上不太好處理,考慮轉化為字尾,即把原串反轉,下文中的 \(a,b,c,d\) 均指反轉後的。然後我們拿出 \(\sf parent\) 樹,在上面做線段樹合併,即把兒子的 \(\sf edp\) 集合對應的值域線段樹合併到父結點,初始時字首所在的狀態有值,為字首的結束結點。然後如果我們能找到 \(S_{d-x+1,d}\) 所在的狀態,就可以通過查詢這個狀態的 \(\sf edp\) 集合,是否在 \([a+x-1,b]\) 中有值來判斷是否在 \(S_{a,b}\) 中出現了。現在我們的問題是找到 \(S_{d-x+1,d}\) 的狀態。注意到如果我們記錄字首所在狀態,則可以方便找到 \(S_{1,d}\) 所在狀態,然後從這個狀態開始往根節點走,\(\sf edp\) 集合越來越大,同時 len 值越來越小,當剛好 len 值滿足大於等於 \(x\) 時,我們就找到了這個狀態。發現上面的過程很容易用樹上倍增優化。總時間複雜度 \(\mathcal{O}(n\log^2n)\)

程式碼:\(\tt code\)

P4384 [八省聯考 2018] 制胡竄

我寫過 八省聯考的總結

廣義 SAM

\(\sf SAM\) 的擴充套件版,把 \(\sf SAM\) 搬到了 \(\sf Trie\) 樹上,從而把 \(\sf SAM\) 能處理的領域又增加到了多串。注意,這裡採用的方法是 \(\rm bfs\) 離線建廣義 \(\sf SAM\)不要學盜版做法。(如果我的寫假了也請提醒一聲qwq)

P6139 【模板】廣義字尾自動機(廣義 SAM)

給出 \(n\) 個字串 \(s_i\),求它們的本質不同子串個數。(\(1\le n\le 4\times 10^5,1\le \sum|s_i|\le 10^6\))

用板子題簡單說說廣義 \(\sf SAM\) 的建立過程和性質吧。離線 \(\rm bfs\) 建立,就是先把所有串插入 \(\sf Trie\) 樹內,然後再 \(\rm bfs\) 整個 \(\sf Trie\) 樹,並用 \(\sf Trie\) 樹的結構來輔助廣義 \(\sf SAM\) 的建立。具體來講,對於 \(\sf Trie\) 樹上的每個結點,我們額外維護 fa 表示它在 \(\sf Trie\) 樹上的父結點(這麼說可能有點怪,反正就是轉移指向它的那個狀態),和 ch 表示 fa 到當前狀態,對應轉移的字元。\(\rm bfs\) 到當前結點時,就用 fa\(\sf SAM\) 上對應的狀態當 las,加入 ch 這個字元,並存儲當前結點在 \(\sf SAM\) 上對應的狀態。初始時,\(\sf Trie\) 樹的根對應 \(\sf SAM\) 的根。

建立完後,我們得到的廣義 \(\sf SAM\) 相當於是儲存了加入的所有字串的所有子串的資訊,所有性質和普通的 \(\sf SAM\) 都是相似的。不僅如此,我們還能額外給每個結點打上標記,表示它是來自哪個字串的,從而實現更強的功能。(詳見下一題)而對於本題,我們只需要求出每個狀態能表示的狀態和即可,也就是 a[i].len - a[a[i].f].len。時間複雜度 \(\mathcal{O}(\sum |s_i|)\)

程式碼:\(\tt code\)

P6793 [SNOI2020] 字串

給出兩個長度均為 \(n\) 的字串 \(a,b\),求它們所有長度為 \(k\) 的子串,兩兩配對後,\(\operatorname{lcp}\) 之和的最大值。(\(1\le k\le n\le 1.5\times 10^5\))

首先發現,兩個串的 \(\operatorname{lcp}\) 是字尾樹上 \(\operatorname{lca}\) 的深度,而兩兩配對時,我們有一個顯然的貪心方法,那就是能在深處匹配上就在深處匹配上。所以如果我們能建出來一棵字尾樹,並維護一個結點的子樹內,分別有有多少結點屬於 \(a,b\),我們就能實現上述貪心過程。

考慮把 \(a,b\) 的反串建起廣義 \(\sf SAM\),並在插入 \(\sf Trie\) 樹時,就把所有深度大於等於 \(k\) 的結點打上它屬於 \(a\) 還是 \(b\) 的標記,並在加入 \(\sf SAM\) 時也一併繼承過去。注意,標記在 \(\sf Trie\) 樹上有可能重疊,請注意處理。現在,我們就擁有了一個剛剛提到的字尾樹,只需要 \(\rm dfs\) 一遍,維護每個結點子樹內還剩多少 \(a,b\) 的結點能匹配,子樹匹配完後合併上來它再匹配即可。時間複雜度 \(\mathcal{O}(n)\)

程式碼:\(\tt code\)

迴文自動機

迴文自動機(也稱作 \(\sf PAM\),迴文樹)是一種能處理 大部分迴文串問題\(\sf DFA\),它儲存的是字串的所有迴文串資訊。因為這東西好像不是特別被認可是標準的 \(\sf DFA\),所以就不再介紹它的 \(\sf DFA\) 要素了,乾脆當做樹介紹了。由於是迴文,所以一定有奇迴文和偶迴文的區別,\(\sf PAM\) 上,有兩個根,奇根和偶根分別處理它們的資訊。邊表示從上一個狀態兩邊加上兩個字元。而奇根的長度是 \(-1\),這保證第一次加字元僅會加 \(1\) 個。除此之外,它還有後綴連結,類似的,連向最長的迴文字尾。\(\sf PAM\) 的狀態數僅為 \(\mathcal{O}(n)\),這是因為一個串最多有 \(\mathcal{O}(n)\) 個本質不同迴文串。建立過程 OI wiki 講的挺詳細的,這裡僅給出 板子題的程式碼

沒做過啥題,只有一道非板子題的介紹。

Palindromeness

定義一個串的迴文度:

  • 如果這個串不是迴文串,則迴文度為 \(0\)
  • 一個字元的迴文度是 \(1\)
  • 長度為 \(x(x>1)\) 的字串的迴文度是它長度為 \(\lfloor\frac{x}{2}\rfloor\) 的字首的迴文度加 \(1\)

給出一個字串 \(S\),求出它的所有非空子串的迴文度之和。(\(1\le |S|\le 10^5\))

顯然可以只考慮所有本質不同的迴文串,把它們的迴文度乘上出現次數。首先我們先來解決個子問題,求一個迴文串的出現次數。

考慮 \(\sf PAM\) 的插入過程,顯然編號就是拓撲序,所以我們只需要逆序列舉所有狀態,將當前狀態的出現次數加入到 \(\sf fail\) 指標對應狀態的出現次數即可,畢竟它都出現了,字尾肯定出現了。初始狀態可以在插入的時候求出。

現在我們的問題就是解決求一個迴文串的迴文度了。對於一個迴文串,它的迴文度就是長度為 \(\lfloor\frac{x}{2}\rfloor\) 的字首的迴文度,考慮改成字尾,這是等價的。考慮按照拓撲序列舉結點,這樣在處理當前結點時,如果長度為 \(\lfloor\frac{x}{2}\rfloor\) 的迴文字尾存在,那它的迴文度一定被處理好了,可以直接計算當前狀態的迴文度。現在我們的問題變為了,判斷一個迴文串的長度為 \(\lfloor\frac{x}{2}\rfloor\) 的字尾是否是迴文,如果是,找出它在迴文自動機上出現的位置。

注意到,這個問題可以很簡單的通過爬 \(\sf fail\) 樹實現,只需要看看能不能找到長度為 \(\lfloor\frac{x}{2}\rfloor\) 的結點即可。而根據 \(\sf SAM\) 的經驗,這個過程可以用樹上倍增優化掉。所以最終時間複雜度 \(\mathcal{O}(n\log n)\)

程式碼:\(\tt code\)