1. 程式人生 > 實用技巧 >字尾資料結構學習筆記

字尾資料結構學習筆記

字尾資料結構學習筆記

By Tuifei_oier

字尾資料結構通常應用於字串,用於求解一些字串相關的問題,也因此這種資料結構往往可以和字串捆綁在一起和其他演算法巢狀,所以它的應用也是較繁雜的,但是也有一定的定式。

Part 1 基本定義

  1. 字串:在 OI 中表示為一些字元構成的陣列,預設下標從 \(1\) 開始\(n\) 表示其長度。
  2. 子串:一個字串中的連續一段,從下標 \(l\) 到下標 \(r\)\(S\) 的子串記為 \(S[l,r]\)
  3. 字首:下標 \(i\) 的字首即子串 \(S[1,i]\)
  4. 字尾:下標 \(i\) 的字尾即子串 \(S[i,n]\)

Part 2 字尾陣列

定義&構造

先來看一個題目:

給定字串 \(S\),求它的所有後綴按字典序排序的結果。
\(tips:1\le n\le10^6\)

我們首先有一個樸素解法:取出 \(S\) 的所有後綴,然後直接排序即可,複雜度 \(O(n^2)\),顯然有點大了。
於是,我們考慮先取出它的所有後綴,考慮我們複雜度的瓶頸:
因為我們每次比較都是比較兩個字尾的下一個字元,所以比較的複雜度為 \(O(n^2)\),但是注意到字尾之間是有包含關係的,也就是說這種比較方式導致我們有可以利用的資訊浪費了,所以考慮倍增優化來利用資訊。
具體而言,每次比較每個字尾的前 \(l\) 個字元(不足在後面補空字元),然後接下來比較前 \(2l\)

個字元,我們發現前 \(2l\) 個字元的比較過程,可以拆成先比較前 \(l\) 個,再比較後 \(l\) 個,然後這兩個東西的排名在比較前 \(l\) 個字元的過程中就求出來了,所以就可以直接做一個雙關鍵字排序,這個東西可以用基數排序方便的完成,複雜度為 \(O(nlogn)\)。(排序 \(O(n)\),排 \(O(logn)\) 次。)
之後我們可以得到 \(S\) 所有後綴排序後的結果,記 \(sa[i]\) 表示排名為 \(i\) 的字尾在 \(S\) 中的起始下標,則 \(sa\) 陣列即為我們所求得的字尾陣列。
至此,我們得到一個 \(O(nlogn)\) 構造字尾陣列的演算法。

應用

那麼,這玩意兒有什麼用呢?
實話實說,沒啥用。
但是,我們通過它再去求一些其它的玩意兒,就相當有用了。
首先,求一個也沒啥用的東西:\(rk[i]\),它表示字尾 \(S[i:n]\) 在所有後綴中的排名。顯然 \(rk[sa[i]] = i,sa[rk[i]] = i\)
然後,我們考慮求這樣一個數組 \(height[i]\),它表示所有後綴中排名為 \(i\) 的字尾和排名為 \(i-1\) 的字尾的最長公共字首(即 LCP)。
\(height[i]=LCP(S[sa[i-1],n],S[sa[i],n])\)
然後,這個東西有什麼用呢?我們可以 \(O(1)\) 求任意兩個字尾之間的 \(LCP\)!

定理:\(LCP(S[i,n],S[j,n])=\min\limits_{k=\min(rk[i],rk[j])+1}^{\max(rk[i],rk[j])}height[k]\)

怎麼證明?自證不難(我有點懶(不算很難,較好理解))。
所以,我們只要求出 \(height[i]\),然後用資料結構維護區間最值即可。
接下來考慮怎麼求 \(height[i]\)
設一個數組 \(h[i]\) ,表示 \(LCP(S[i,n],S[sa[rk[i]-1],n])\)
那麼我們就可以推出一個定理。

定理:\(h[i]\ge h[i-1]-1\)

這個定理的證明有很多方式,而且畫圖後比較好解決,如果實在證明不出來也可以直接 BFS。
然後只要直接用這個定理暴力求即可,每次暴力匹配的開始長度為上一次匹配的答案 \(-1\),不難證明覆雜度仍為 \(O(n)\)

接下來是一些例題,用到了字尾陣列以及一些它的性質。

Pro A (Luogu P2408)

題意:給定字串 \(S\),求 \(S\) 的本質不同的子串個數。
\(tips:1\le n\le10^6\)

這個題目算是字尾陣列的基礎應用了,下面講一種解法。
首先,因為每個子串都可以唯一地對應一個字尾的字首,所以只要考慮所有後綴的本質不同的字首數量。
這個過程可以這樣考慮:根據之前的 \(height\) 陣列的定理,可以發現在排序後的所有後綴中距離越遠 \(LCP\) 越短,所以考慮每個字尾對答案的貢獻時,比它排名恰好小 \(1\) 的字尾是和它重複最多的,所以減去它們的 \(LCP\) 即可。

因此,本題的答案即為 \(\sum\limits_{i=1}^nn-sa[i]+1-height[i]\)
時間複雜度 \(O(nlogn)\)

Pro B (Luogu P4094)

題意:給定字串 \(S\)\(Q\) 次詢問,每次詢問給出 \(4\) 個正整數 \(a,b,c,d\),求 \(S[a,b]\) 的所有子串與 \(S[c,d]\)\(LCP\) 的最大值。
\(tips:1\le n,Q\le10^5\)

首先利用 子串=字尾的字首 轉化為求 \(S[a,b]\) 所有後綴與 \(S[c,d]\)\(LCP\) 最大值。
因為這個查詢的操作不是在排序後的排名上連續的,所以不能直接利用之前的 \(height\) 的定理,而注意到答案有單調性,考慮二分答案。
二分這個最大值 \(Len\),然後就只要確定是否存在解。
我們可以先求出滿足 \(LCP(S[i,n],S[c,n])\ge Len\)\(i\) 在排序後排名的範圍,這個可以通過二分來解決(利用 \(height\) 的定理),設為 \([L,R]\),然後只要求有多少個數對 \((i,rk[i])\) 滿足 \(a\le i\le b-Len+1,L\le rk[i]\le R\),經典二維數點問題。

時間複雜度為 \(O(nlog^2n)\)

Pro C (Luogu P1117)

題意:給定字串 \(S\),求 \(S\) 的每一個子串表示成 \(AABB\) 形式的方案數之和。(要求 \(A,B\) 為非空串)
\(tips:1\le n\le30000\),多組資料

首先,我們考慮一個 \(AA\) 串對答案的貢獻。假設從 \(i\) 開始的 \(AA\) 串數量為 \(a\),以 \(i-1\) 結束的 \(AA\) 串數量為 \(b\),則 \(i\) 這個位置對答案有 \(a\cdot b\) 的貢獻,並且這樣算不會出現重複和遺漏。
接下來只要考慮怎麼統計以某個位置 開始/結束 的 \(AA\) 串的數量。
此時我們有一個常用的做法:插分隔符(一般不用實際插入字元)。
具體而言,為了統計長度為 \(2\cdot len\)\(AA\) 串,我們在字串上取下標分別為 \(len,2len,3len,...\) 的字元並打上標記。然後發現一個長度為 \(2\cdot len\)\(AA\) 串必然經過且只經過兩個相鄰標記節點,所以只需統計兩個相鄰節點之間的貢獻。
假設現在考慮 \(i\)\(i+len\) 兩個節點的貢獻,我們只需對 \(S[i:n],S[i+len:n]\) 求 LCP,對 \(S[1:i-1],S[1:i+len-1]\) 求最長公共字尾,設這兩個值分別為 \(l1,l2\)。當且僅當 \(l1+l2\ge len\) 時,這兩個標記點才對答案有影響(否則,\(AA\) 串的長度必然 \(<2\cdot len\),之前統計過了)。不難發現需要維護區間加,差分一下最後還原即可。
複雜度 \(O(nlogn)\)

Part 3 字尾自動機

字尾自動機是除了字尾陣列以外另一大字尾資料結構(字尾樹好像用處不蠻大?也沒見到只能用字尾樹做的),因此掌握這種資料結構也是很重要的。

定義與性質

字尾自動機由一些節點和邊組成,每個節點代表一個狀態(這也是 OI 中常用自動機的通式?)。在後綴自動機中,每個節點代表一些原串的子串,滿足它們的在原串中的出現位置(即每次出現結束位置的下標)都相同。

例如:對於 \(S=abab\),子串 \(ab\)\(b\) 的出現位置都相同,為 \(\{2,4\}\)。則 SAM 中 \(ab,b\) 會由同一個節點表示。

這是點的定義,不難發現一個節點包含的所有字串長度都不相等,長度每次 \(-1\),且短的串必定是長的串的字尾。

例如,存在於同一個節點的字串一定是這種形式:\(\{aabaa,abaa,baa,aa\}\),不可能是 \(\{aabaa,baa\}\) 或者 \(\{aabaa,aabab\}\)

上面的定義及推論都是不難理解的。於是我們對一個點可以記錄這些資訊:它代表的串中的最長串長度,最短串長度,分別記為 \(maxl,minl\)
接下來考慮邊。
SAM 中,邊分兩種:組成 DAG 的轉移邊和組成 parent 樹的父親邊。
轉移邊表示 \(u\) 代表的所有子串後面加上這條轉移邊上的字元可以得到 \(v\) 中的子串,而父親邊表示 \(u\) 代表串的出現位置是 \(v\) 的子集。
具體而言:

  1. 節點 \(u\) 向節點 \(v\) 連 DAG 邊當且僅當該節點代表所有字串在原串中的下一位都為字元 \(c\)
  2. 節點 \(u\) 向節點 \(v\) 連父親邊當且僅當 \(minl_u=maxl_v+1\)

例如,節點 \(u\) 代表 \(\{abaa,baa\}\),節點 \(v\) 代表 \(\{aa,a\}\),節點 \(c\) 代表 \(\{abaac,baac\}\),則 \(u\)\(v\) 連父親邊,向 \(c\) 連一條帶著字元‘c’的轉移邊。

由此,我們就可以得到 SAM 的定義了。這之後是由它的定義可以得到的一系列性質:

  1. SAM 中的某一個節點 \(u\) 的所有祖先 \(v\) 都滿足 \(v\) 中串是 \(u\) 中串的字尾,並且從 \(u\) 開始沿著 parent 樹(即父親邊)向上一定會走到初始節點,這個過程會訪問到 \(u\) 中最長子串的所有後綴。
  2. SAM 中每個節點儲存的子串數量 = \(maxl_u-maxl_{fa_u}\)
  3. SAM 中每個節點代表子串的出現次數 = \(sz_u\)

接下來是 SAM 的構建。
考慮用增量法來構建 SAM,每次加入一個新字元。
構建過程自行 BFS,結合以上性質就比較好理解了。
由構建過程可以得到 SAM 點數 \(\le 2n-1\),邊數 \(\le 3n-4\)

應用

SAM 有一大堆基礎應用。

  1. 判斷 \(T\) 是否在 \(S\) 中出現。
    只需從初始節點開始沿著轉移邊一直走即可。
  2. 不同子串個數。
    SAM 上每一條 DAG 上的路徑都對應一個子串,所以就是經典的 DAG 上 DP。
    還有一種求法:\(\sum\limits_{i=1}^{tot}maxl_i-maxl_{fa_i}\)(應用之前的性質)。
  3. 所有不同子串總長度。
    一樣的 DP,和上面的 DAG 上 DP 差不多。
    或者考慮每個節點的貢獻:

\[\sum_{i=1}^{tot}\dfrac{maxl_i(maxl_i+1)-maxl_{fa_i}(maxl_{fa_i}+1)}{2} \]

  1. 最小迴圈移位。
    建出 \(S+S\) 的 SAM,在上面貪心地挑最小邊走 \(n\) 步即可。
  2. 子串出現次數。
    dfs 預處理出每個節點所代表集合的出現位置集合大小,然後直接在 DAG 上從初始節點開始走即可。
  3. \(T\)\(S\) 中第一次出現的位置。
    考慮對每個節點預處理出一個資訊 \(firstpos_i\),表示這個節點代表的所有串出現位置的最小值。
    然後答案即為 \(firstpos_u-|T|+1\)
    考慮怎麼求這個:

\[firstpos_u=\begin{cases} maxl_u(u\ is\ a\ new\ node)\\ firstpos_v(u\ is\ cloned\ from\ v) \end{cases}\]

  1. 最短的未出現子串。
    同樣在 DAG 上 DP,設 \(dp_u\) 表示 \(u\) 節點開始最短的沒有出現的子串,則答案為 \(dp_{root}\)
    考慮它的求法:

\[dp_u=\begin{cases}\min\limits_{(u,v)\in E}\{dp_v\}+1(out\_degree\ne0)\\1(out\_degree=0)\end{cases} \]

  1. \(S,T\) 的最長公共子串。
    \(S\) 構造 SAM,相當於求 \(T\) 中所有字首在 \(S\) 中的最長公共字尾。
    通過指標在 SAM 上游走來求,設當前走到的節點為 \(u\),最長長度為 \(l\),則要求 \(\max\{l\}\)
    具體步驟如下:
    目前已經匹配到 \(T\) 的第 \(i\) 個字元,考慮匹配第 \(i+1\) 個。
    a. 如果存在下一個字元的轉移,則 \(u\) 轉移到 \(v\)(下一個狀態),同時 \(++l\)
    b. 如果不存在,\(u=fa_u,l=maxl_{fa_u}\),繼續如上過程直到存在轉移。

Pro A (SPOJ 1812)

給定 \(k\) 個字串 \(S_{1,..,k}\),求它們的最長公共子串。
\(tips:1\le k\le10,1\le n\le10^5\)

直接對第一個串建出 SAM,然後把其他的串放進去按兩個字串的方式跑,這個過程中實時更新每個點的答案,最後求最大即可。
時間複雜度 \(O(kn)\)

Summary

字尾資料結構在字串中算是比較有套路可循的題,通常在確定應該維護什麼後還是比較模板的。所以在今後的練習中一些基本的套路是需要積累才行的。