本文譯自博文 Суффиксный автомат 與其英文翻譯版 Suffix Automaton 。其中俄文版版權協議為 Public Domain + Leave a Link;英文版版權協議為 CC-BY-SA 4.0。
本文版權協議為 CC-BY-SA 4.0 及未來(如果有)的更新版本。
轉載時,請務必遵守以上版權協議,謝謝!
LibreOJ 題庫維護組 / MingqiHuang @ GitHub / Mingqi_H @ LibreOJ / QQ 號:742745308
2018 年 8 月 23 日
后綴自動機
FBI Warning: 全文約 20000 字,閱讀全文約需要 1 小時。
后綴自動機是一個能解決許多字符串相關問題的有力的數據結構。
舉個例子,字符串問題:
- 在另一個字符串中搜索一個字符串的所有出現位置。
- 計算給定的字符串中有多少個不同的子串。
以上問題都可以在線性的時間復雜度內通過后綴自動機來實現。
直觀上,字符串的后綴自動機可以理解為給定字符串的所有子串的壓縮形式。值得注意的事實是,后綴自動機將所有的這些信息以高度壓縮的形式儲存。對於一個長度為 \(n\) 的字符串,它的空間復雜度僅為 \(O(n)\)。此外,構造后綴自動機的時間復雜度僅為 \(O(n)\)(這里我們將字符集的大小 \(k\) 看作常數,否則時間復雜度和空間復雜度均為 \(O(n\log k)\))。
后綴自動機的定義
給定字符串 \(s\) 的后綴自動機是一個接受所有字符串 \(s\) 的后綴的最小 DFA(確定性有限自動機或確定性有限狀態自動機)。
換句話說:
- 后綴自動機是一張有向無環圖。頂點被稱作狀態,邊被稱作狀態間的轉移。
- 一個狀態 \(t_0\) 為初始狀態,它必定為這張圖的源點(其它各點均與 \(t_0\) 聯通)。
- 每個轉移都標有一些字母。從一個頂點出發的所有轉移均不同。
- 一個或多個狀態為終止狀態。如果我們從初始狀態 \(t_0\) 出發,最終轉移到了一個終止狀態,則路徑上的所有轉移連接起來一定是字符串 \(s\) 的一個后綴。 \(s\) 的每個后綴均可用一條從 \(t_0\) 到一個終止狀態的路徑構成。
- 后綴自動機是所有滿足上述條件的自動機中頂點數最少的一個。
子串的性質
后綴自動機最簡單和最重要的性質是,它包含關於字符串 \(s\) 的所有子串的信息。任意從初始狀態 \(t_0\) 開始的路徑,如果我們將轉移路徑上的標號寫下來,都會形成 \(s\) 的一個子串。反之每個 \(s\) 的子串對應於從 \(t_0\) 開始的某條路徑。
為了簡化表達,我們將會說子串對應於一條路徑(從 \(t_0\) 開始且一些標號構成這個子串)。反過來我們說任意一條路徑對應於它的標號構成的字符串。
一條或多條路徑可以到達一個狀態,因此我們說一個狀態對應於字符串的集合,這也對應於那些路徑。
構造后綴自動機的實例
我們將會在這里展示一些簡單的字符串的后綴自動機。
我們用藍色表示初始狀態,用綠色表示終止狀態。
對於字符串 \(s=``"\):
對於字符串 \(s=``a\!"\):
對於字符串 \(s=``aa\!"\):
對於字符串 \(s=``ab\!"\):
對於字符串 \(s=``abb\!"\):
對於字符串 \(s=``abbb\!"\):
在線性時間內構造后綴自動機
在我們描述線性時間內構造后綴自動機的算法之前,我們需要引入幾個對理解構造過程非常重要的新概念並簡單證明。
結束位置 \(endpos\)
考慮字符串 \(s\) 的任意非空子串 \(t\),我們記 \(endpos(t)\) 為在字符串 \(s\) 中 \(t\) 的所有結束位置。例如,對於字符串 \(``abcbc\!"\),我們有 \(endpos(``bc\!")=2,\,4\)。
當兩個子串 \(t_1\) 與 \(t_2\) 的末尾集合相等時我們稱它們是 \(endpos\) 等價的:即 \(endpos(t_1)=endpos(t_2)\)。這樣所有字符串 \(s\) 的非空子串都可以根據它們的\(endpos\) 集合被分為幾個等價類。
顯然,在后綴自動機中的每個狀態對應於一個或多個 \(endpos\) 相同的子串。換句話說,后綴自動機中的狀態數等於所有子串的等價類的個數,加上初始狀態。后綴自動機的狀態個數等價於 \(endpos\) 相同的一個或多個子串。
我們稍后將會用這個假設介紹構造后綴自動機的算法。在那時我們將會發現,后綴自動機需要滿足的所有性質,除了最小性以外都滿足了。由 Nerode 定理我們可以得出最小性(這篇文章不會證明后綴自動機的最小性)。
由 \(endpos\) 的值我們可以得到一些重要結論:
引理 1:當且僅當字符串 \(u\) 以 \(w\) 的一個后綴的形式出現在字符串 \(s\) 中時,兩個非空子串 \(u\) 和 \(w\)(假設 \(length(u)\le length(w)\))是 \(endpos\) 等價的。
引理顯然成立。如果 \(u\) 和 \(v\) 的 \(endpos\) 相同,則 \(u\) 是 \(w\) 的一個后綴,且只以 \(s\) 中的一個 \(w\) 的后綴的形式出現。且根據定義,如果 \(u\) 為 \(w\) 的一個后綴,且只以后綴的形式在 \(s\) 中出現時,兩個子串的 \(endpos\) 值相等。
引理 2:考慮兩個非空子串 \(u\) 和 \(w\)(假設 \(length(u)\le length(w)\))。則它們的 \(endpos\) 構成的集合要么完全沒有交集,要么 \(endpos(w)\) 是 \(endpos(u)\) 的一個子集。並且這依賴於 \(u\) 是否為 \(w\) 的一個后綴。即:
證明:如果集合 \(endpos(u)\) 與 \(endpos(w)\) 有至少一個公共元素,那么由於字符串 \(u\) 與 \(w\) 都在一個位置結束,即 \(u\) 是 \(w\) 的一個后綴。但是如果如此在每次 \(w\) 出現的位置子串 \(u\) 也會出現,這意味着 \(endpos(w)\) 是 \(endpos(u)\) 的一個子集。
引理 3:考慮一個 \(endpos\) 等價類。將類中的所有子串按長度非遞增的順序排序。即每個子串都會比它前一個子串短,與此同時每個子串也是它前一個子串的一個后綴。換句話說,同一等價類中的所有子串均互為后綴,且子串的長度恰好覆蓋整個區間 \([x,\,y]\)。
證明:固定一些 \(endpos\) 等價類。如果等價類中只包含一個子串,引理顯然成立。現在我們來討論子串元素個數大於 \(1\) 的等價類。
由引理 1,兩個不同的 \(endpos\) 等價字符串中較短的一個總是較長的一個的真后綴。因此,等價類中不可能有兩個等長的字符串。
記 \(w\) 為等價類中最長的字符串,類似地,記 \(u\) 為等價類中最短的字符串。由引理1,字符串 \(u\) 是字符串 \(w\) 的真后綴。現在考慮長度在區間 \([length(u),\,length(w)]\) 中的 \(w\) 的任意后綴。容易看出,這個后綴也在同一等價類中。因為這個后綴只能在字符串 \(s\) 中以 \(w\) 的一個后綴的形式存在(也因為較短的后綴 \(u\) 在 \(s\) 中只以 \(w\) 的后綴的形式存在)。因此,由引理 1,這個后綴與字符串 \(w\) \(endpos\) 等價。
后綴鏈接 \(link\)
考慮后綴自動機中滿足 \(v\ne t_0\) 的一些狀態。我們已經知道,狀態 \(v\) 對應於具有相同 \(endpos\) 的等價類。我們如果定義 \(w\) 為這些字符串中最長的一個,則所有其它的字符串都是 \(w\) 的后綴。
我們還知道字符串 \(w\) 的前幾個后綴(如果我們用長度降序考慮這些后綴)在這個等價類中全部被包含,且所有其它后綴(至少一個—空后綴)在其它的等價類中。我們記 \(t\) 為最大的這樣的后綴,然后用后綴鏈接連到 \(t\) 上。
換句話說,一個后綴鏈接 \(link(v)\) 連接到對應於 \(w\) 的最長后綴的另一個 \(endpos\) 等價類的狀態。
以下我們假設初始狀態 \(t_0\) 對應於它自己這個等價類(只包含一個空字符串),為了方便我們規定 \(endpos(t)=\{-1,\,0,\,\ldots,\,length(s)-1\}\)。
引理 4:所有后綴鏈接構成一棵根節點為 \(t_0\) 的樹。
證明:考慮任意滿足 \(v\ne t_0\) 的狀態,一個后綴鏈接 \(link(v)\) 連接到的狀態對應於嚴格更短的字符串(根據后綴鏈接的定義和引理 3)。因此,通過在后綴鏈接上移動,我們早晚會到達對應空串的初始狀態 \(t_0\)。
引理 5:如果我們使用集合 \(endpos\) 構造一棵樹(所有子節點的集合為父節點的子集),則這個結構由后綴鏈接連接起來。
證明:由引理 2,我們可以用 \(endpos\) 集合構造一棵樹(因為兩個集合要么完全沒有交集要么互為子集)。
我們現在考慮任意滿足 \(v\ne t_0\) 的狀態和它的后綴鏈接 \(link(v)\),由后綴鏈接和引理 2,我們可以得到
,這與前面的引理證明了以下斷言成立:后綴鏈接構成的樹本質上是 \(endpos\) 集合構成的一棵樹。
以下是對於字符串 \(``abcbc\!"\) 構造后綴自動機時產生的后綴鏈接樹的一個例子,節點被標記為對應等價類中最長的子串。
小結
在學習算法本身前,我們對之前學過的知識進行一下總結,並引入一些輔助記號。
- \(s\) 的子串可以根據它們結束的位置 \(endpos\) 被划分為多個等價類;
- 后綴自動機由初始狀態 \(t_0\) 和與每一個 \(endpos\) 等價類對應的每個狀態組成;
- 對於每一個狀態 \(v\),一個或多個子串與之匹配。我們記 \(longest(v)\) 為其中最長的一個字符串,記 \(len(v)\) 為它的長度。類似地,記 \(shortest(v)\) 為最短的子串,它的長度為 \(minlen(v)\)。那么所有對應這個狀態的所有字符串都是字符串 \(longest(v)\) 的不同的后綴,且所有字符串的長度恰好覆蓋區間 \([minlength(v),\,len(v)]\) 中的每一個整數。
- 對於任意滿足 \(v\ne t_0\) 的狀態,定義后綴鏈接為連接到對應字符串 \(longest(v)\) 的長度為 \(minlen(v)-1\) 的后綴的一條邊。從根節點 \(t_0\) 出發的后綴鏈接可以形成一棵樹,與此同時,這棵樹形成了 \(endpos\) 集合間的包含關系。
- 我們可以對 \(v\ne t_0\) 的狀態使用后綴鏈接 \(link(v)\) 解釋 \(minlen(v)\) 如下:
- 如果我們從任意狀態 \(v_0\) 開始順着后綴鏈接遍歷,早晚都會到達初始狀態 \(t_0\)。這種情況下我們可以得到一個互不相交的區間 \([minlen(v_i),\,len(v_i)]\) 的序列,且它們的並集形成了連續的區間 \([0,\,len(v_0)]\)。
算法
現在我們可以學習算法本身了。這個算法是在線算法,這意味着我們可以逐個加入字符串中的每個字符,並且在每一步中對應地維護后綴自動機。
為了保證線性的空間復雜度,我們將只保存 \(len\) 和 \(link\) 的值和每個狀態的一個轉移列表,我們不會標記終止狀態(但是我們稍后會展示在構造后綴自動機后如何分配這些標記)。
一開始后綴自動機只包含一個狀態 \(t_0\),編號為 \(0\)(其它狀態的編號為 \(1,\,2,\,\ldots\))。為了方便,我們分配給它 \(len=0\) 和 \(link=-1\)(\(-1\) 表示一個虛擬的不存在的狀態)。
現在整個任務轉化為實現給當前字符串添加一個字符 \(c\) 的過程。算法流程如下:
- 令 \(last\) 為對應添加字符 \(c\) 之前的整個字符串(一開始我們設置 \(last=0\) 且我們會在算法的最后一步對應地更新 \(last\))。
- 創建一個新的狀態 \(cur\),並將 \(len(cur)\) 賦值為 \(len(last)+1\),在這時 \(link(cur)\) 的值還未知。
- 現在我們按以下流程進行:我們從狀態 \(last\) 開始。如果還沒有到字符 \(c\) 的轉移,我們就添加一個到狀態 \(cur\) 的轉移,遍歷后綴鏈接。如果在某個點已經存在到字符 \(c\) 的后綴鏈接,我們就停下來,並將這個狀態標記為 \(p\)。
- 如果沒有找到這樣的狀態 \(p\),我們就到達了虛擬狀態 \(-1\),我們將 \(link(cur)\) 賦值為 \(-1\) 並退出。
- 假設現在我們找到了一個狀態 \(p\),其可以轉移到字符 \(c\),我們將這個狀態轉移到的狀態標記為 \(q\)。
- 現在我們分類討論兩種狀態,要么 \(len(p) + 1 = len(q)\),要么不是。
- 如果 \(len(p)+1=len(q)\),我們只要將 \(link(cur)\) 賦值為 \(q\) 並退出。
- 否則就會有些復雜。需要復制狀態 \(q\):我們創建一個新的狀態 \(clone\),復制 \(q\) 的除了 \(len\) 的值以外的所有信息(后綴鏈接和轉移)。我們將 \(len(clone)\) 賦值為 \(len(p)+1\)。
復制之后,我們將后綴鏈接從 \(cur\) 指向 \(clone\),也從 \(q\) 指向 \(clone\)。
最終我們需要使用后綴鏈接從狀態 \(p\) 返回,因為存在一條通過 \(c\) 到狀態 \(q\) 的轉移,並在此過程中重定向所有狀態到狀態 \(clone\)。 - 以上三種情況,在完成這個過程之后,我們將 \(last\) 的值更新為狀態 \(cur\)。
如果我們還想知道哪些狀態是終止狀態而哪些不是,我們可以在為字符串 \(s\) 構造完完整的后綴自動機后找到所有的終止狀態。為此,我們從對應整個字符串的狀態(存儲在變量 \(last\) 中),遍歷它的后綴鏈接,直到到達初始狀態。我們將所有遍歷到的節點都標記為終止節點。容易理解這樣做我們會精確地標記字符串 \(s\) 的所有后綴,這些狀態恰好是終止狀態。
在下一部分,我們將觀察算法每一步的細節,並證明它的正確性。
現在,我們只注意到,因為我們只為 \(s\) 的每個字符創建一個或兩個新狀態所以后綴自動機只包含線性個狀態。
轉移個數是線性規模的,以及總體上算法的運行時間是線性規模的,這兩點還不那么清
楚。
正確性證明
- 若一個轉移 \((p,\,q)\) 滿足 \(len(p)+1=len(q)\) 則我們稱這個轉移是連續的。否則,即當 \(len(p)+1<len(q)\) 時,這個轉移被稱為不連續的。 從算法描述中可以看出,連續的和非連續的轉移是算法的不同情況。連續的轉移是固定的,我們不會再改變了。與此相反,當向字符串中插入一個新的字符時,非連續的轉移可能會改變(轉移邊的端點可能會改變)。
- 為了避免引起歧義,我們記向后綴自動機中插入當前字符 \(c\) 之前的字符串為 \(s\)。
- 算法從創建一個新狀態 \(cur\) 開始,對應於整個字符串 \(s+c\)。我們創建一個新的節點的原因很清楚。與此同時我們也創建了一個新的字符和一個新的等價類。
- 在創建一個新的狀態之后,我們會從對應於整個字符串 \(s\) 的狀態通過后綴鏈接進行遍歷。對於每一個狀態,我們嘗試添加一個從字符 \(c\) 到新狀態 \(cur\) 的轉移。然而我我們只能添加與原來已存在的轉移不沖突的轉移。因此我們只要找到已存在的 \(c\) 的轉移,我們就必須停止。
- 最簡單的情況是我們到達了虛擬狀態 \(-1\),這意味着我們為所有 \(s\) 的后綴添加了 \(c\) 的轉移。這也意味着,字符 \(c\) 從未在字符串 \(s\) 中出現過。因此 \(cur\) 的后綴鏈接為狀態 \(0\)。
- 第二種情況下,我們找到了現有的轉移 \((p,\,q)\)。這意味着我們嘗試向自動機內添加一個已經存在的字符串 \(x+c\)(其中 \(x\) 為 \(s\) 的一個后綴,且字符串 \(x+c\) 已經作為 \(s\) 的一個子串出現過了)。因為我們假設字符串 \(s\) 的自動機的構造是正確的,我們不應該在這里添加一個新的轉移。 然而,有一個難點。從狀態 \(cur\) 出發的后綴鏈接應該連接到哪個狀態呢?我們要把后綴鏈接連到一個狀態上,且其中最長的一個字符串恰好是 \(x+c\),即這個狀態的 \(len\) 應該是 \(len(p)+1\)。然而還不存在這樣的狀態,即 \(len(q)>len(p)+1\)。這種情況下,我們必須要通過拆開狀態 \(q\) 來創建一個這樣的狀態。
- 如果轉移 \((p,\,q)\) 是連續的,那么 \(len(q)=len(p)+1\)。在這種情況下一切都很簡單。我們只需要將 \(cur\) 的后綴鏈接指向狀態 \(q\)。
- 否則轉移是不連續的,即 \(len(q)>len(p)+1\),這意味着狀態 \(q\) 不只對應於長度為\(len(p)+1\) 的后綴 \(s+c\),還對應於 \(s\) 的更長的子串。除了將狀態 \(q\) 拆成兩個子狀態以外我們別無他法,所以第一個子狀態的長度就是 \(len(p)+1\) 了。
我們如何拆開一個狀態呢?我們復制狀態 \(q\),產生一個狀態 \(clone\),我們將 \(len(clone)\) 賦值為 \(len(p)+1\)。由於我們不想改變遍歷到 \(q\) 的路徑,我們將 \(q\) 的所有轉移復制到 \(clone\)。我們也將從 \(clone\) 出發的后綴鏈接設置為 \(q\) 的后綴鏈接的目標,並設置 \(q\) 的后綴鏈接為 \(clone\)。
在拆開狀態后,我們將從 \(cur\) 出發的后綴鏈接設置為 \(clone\)。
最后一步我們將一些到 \(q\) 轉移重定向到 \(clone\)。我們需要修改哪些轉移呢?只重定向相當於所有字符串 \(w+c\)(其中 \(w\) 是 \(p\) 的最長字符串)的后綴就夠了。即,我們需要繼續沿着后綴鏈接遍歷,從頂點 \(p\) 直到虛擬狀態 \(-1\) 或者是轉移到不是狀態 \(q\) 的一個轉移。
對操作次數為線性的證明
首先我們假設字符集大小為常數。如果字符集大小不是常數,后綴自動機的時間復雜度就不是線性的。從一個頂點出發的轉移存儲在支持快速查詢和插入的平衡樹中。因此如果我們記 \(k\) 為字符集的大小,則算法的漸進時間復雜度為 \(O(n\log k)\),空間復雜度為 \(O(n)\)。然而如果字符集足夠小,可以不寫平衡樹,以空間換時間將每個頂點的轉移存儲為長度為 \(k\) 的數組(用於快速查詢)和鏈表(用於快速遍歷所有可用關鍵字)。這樣算法的時間復雜度為 \(O(n)\),空間復雜度為 \(O(nk)\)。
所以我們將認為字符集的大小為常數,即每次對一個字符搜索轉移、添加轉移、查找下一個轉移—這些操作的時間復雜度都為 \(O(1)\)。
如果我們考慮算法的各個部分,算法中有三處時間復雜度不明顯是線性的:
- 第一處是遍歷所有狀態 \(last\) 的后綴鏈接,添加字符 \(c\) 的轉移。
- 第二處是當狀態 \(q\) 被復制到一個新的狀態 \(clone\) 時復制轉移的過程。
- 第三處是修改指向 \(q\) 的轉移,將它們重定向到 \(clone\) 的過程。
我們使用后綴自動機的大小(狀態數和轉移數)為線性的的事實(對狀態數是線性的的證明就是算法本身,對狀態數為線性的的證明將在稍后實現算法后給出)。
因此上述第一處和第二處的總復雜度顯然為線性的,因為單次操作均攤只為自動機添加了一個新轉移。
還需為第三處估計總復雜度,我們將最初指向 \(q\) 的轉移重定向到 \(clone\)。我們記 \(v=longest(p)\),這是一個字符串 \(s\) 的后綴,每次迭代長度都遞減—因為作為字符串 \(s\) 的位置隨着每次迭代都單調上升。這種情況下,如果在循環的第一次迭代之前,相對應的字符串 \(v\) 在距離 \(last\) 的深度為 \(k\) \((k\ge2)\) 的位置上(深度記為后綴鏈接的數量),那么在最后一次迭代后,字符串 \(v+c\) 將會成為路徑上第二個從 \(cur\) 出發的后綴鏈接(它將會成為新的 \(last\) 的值)。
因此,循環中的每次迭代都會使作為當前字符串的后綴的字符串 \(longest(link(link(last))\) 的位置單調遞增。因此這個循環最多不會執行超過 \(n\) 次迭代,這正是我們需要證明的。
實現
首先,我們描述一種存儲一個轉移的全部信息的數據結構。如果需要的話,你可以在這里加入一個終止標記,也可以是一些其它信息。我們將會用一個 map
存儲轉移的列表,允許我們在總計 \(O(n)\) 的空間復雜度和 \(O(n\log k)\) 的時間復雜度內處理整個字符串。
struct state {
int len, link;
map<char, int> next;
};
后綴自動機本身將會存儲在一個 state
結構體數組中。我們記錄當前自動機的大小 sz
和變量 last
,當前整個字符串對應的狀態。
const int MAXLEN = 100000;
state st[MAXLEN * 2];
int sz, last;
我們定義一個函數來初始化后綴自動機(創建一個只有一個狀態的后綴自動機)。
void sa_init() {
st[0].len = 0;
st[0].link = -1;
sz++;
last = 0;
}
最終我們給出主函數的實現—給當前行末增加一個字符,對應地重建自動機。
void sa_extend(char c) {
int cur = sz++;
st[cur].len = st[last].len + 1;
int p = last;
while (p != -1 && !st[p].next.count(c)) {
st[p].next[c] = cur;
p = st[p].link;
}
if (p == -1) {
st[cur].link = 0;
} else {
int q = st[p].next[c];
if (st[p].len + 1 = st[q].len) {
st[cur].link = q;
} else {
int clone = sz++;
st[clone].len = st[p].len + 1;
st[clone].next = st[q].next;
st[clone].link = st[q].link;
while (p != -1 && st[p].next[c] == q) {
st[p].next[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}
正如之前提到的一樣,如果你用內存換時間(空間復雜度為 \(O(nk)\),其中 \(k\) 為字符集大小),你可以在 \(O(n)\) 的時間內構造字符集大小 \(k\) 任意的后綴自動機。但是這樣你需要為每一個狀態儲存一個大小為 \(k\) 的數組(用於快速跳轉到轉移的字符),和另外一個所有轉移的鏈表(用於快速在轉移中迭代)。
更多的性質
狀態數
對於一個長度為 \(n\) 的字符串 \(s\),它的后綴自動機中的狀態數不會超過 \(2n-1\) (假設 \(n\ge2\))。
對上述結論的證明就是算法本身,因為一開始自動機含有一個狀態,第一次和第二次迭代中只會創建一個節點,剩余的 \(n-2\) 步中每步會創建至多 \(2\) 個狀態。
然而我們也能在不知道這個算法的情況下展示這個估計值。我們回憶一下狀態數等於不同的 \(endpos\) 集合個數。另外這些 \(endpos\) 集合形成了一棵樹(祖先節點的集合包含了它所有孩子節點的集合)。考慮將這棵樹稍微變形一下:只要它有一個只有一個孩子的內部頂點(這意味着該子節點的集合至少遺漏了它的父集合中的一個位置),我們創建一個含有這個遺漏位置的集合。最后我們可以獲得一棵每一個內部頂點的度數大於一的樹,並且葉子節點的個數不超過 \(n\)。因此這樣的樹里有不超過 \(2n-1\) 個節點。
對於每個確定的 \(n\),狀態數的上界是確定的。一個可能的字符串是:
從第三次迭代后的每次迭代,算法都會拆開一個狀態,最終產生恰好 \(2n-1\) 個狀態。
轉移數
對於一個長度為 \(n\) 的字符串 \(s\),它的后綴自動機中的轉移數不會超過 \(3n-4\)(假設 \(n\ge 3\))。
證明如下:
我們首先估計連續的轉移的數量。考慮自動機中從狀態 \(t_0\) 開始的最長路徑的生成樹。生成樹的骨架只包含連續的邊,因此數量少於狀態數,即,邊數不會超過 \(2n-2\)。
現在我們來估計非連續的轉移的數量。令當前非連續轉移為 \((p,\,q)\),其字符為 \(c\)。我們取它的對應字符串 \(u+c+w\),其中字符串 \(u\) 對應於初始狀態到 \(p\) 的最長路徑,\(w\) 對應於從 \(p\) 到任意終止狀態的最長路徑。一方面,對於每個不完整的字符串所對應的形如 \(u+c+w\) 的字符串是不同的(因為字符串 \(u\) 和 \(w\) 僅由完整的轉移組成)。另一方面,由終止狀態的定義,每個形如 \(u+c+w\) 的字符串都是整個字符串 \(s\) 的后綴。因為 \(s\) 只有 \(n\) 個非空后綴,且形如 \(u+c+w\) 的字符串都不包含 \(s\)(因為整個字符串只包含完整的轉移),所以非完整的轉移的總數不會超過 \(n-1\)。
將以上兩個估計值結合起來,我們可以得到上界 \(3n-3\)。然而,最大的狀態數只能在測試數據 \(``abbb\ldots bbb\!"\) 中產生,這個測試數據的轉移數量顯然少於 \(3n-3\),我們可以獲得更為緊確的后綴自動機的轉移數的上界:\(3n-4\)。
上界可以通過字符串
達到。
應用
下面我們來看一下一些可以用后綴自動機解決的問題。為了簡單,我們假設字符集的大小 \(k\) 為常數,允許我們認為增加一個字符和遍歷的復雜度為常數。
檢查字符串是否出現
給一個文本串 \(T\) 和多個模式串 \(P\),我們要檢查字符串 \(P\) 是否作為 \(T\) 的一個子串出現。
我們在 \(O(length(T))\) 的時間內為文本串 \(T\) 構造后綴自動機。為了檢查模式串 \(T\) 是否在 \(T\) 中出現,我們沿轉移(邊)從 \(t_0\) 開始根據 \(P\) 的字符進行轉移。如果在某個點無法轉移下去,則模式串 \(P\) 不是 \(T\) 的一個子串。如果我們能夠這樣處理完整個字符串 \(P\),那么模式串在 \(T\) 中出現過。因此
對於每個字符串 \(P\) 算法的時間復雜度為 \(O(length(P))\)。此外,這個算法還找到了模式串 \(P\) 在文本串中出現的最大前綴長度。
不同子串個數
給一個字符串 \(S\),計算不同子串的個數。
為字符串 \(S\) 構造后綴自動機。
每個 \(S\) 的子串都相當於自動機中的一些路徑。因此不同子串的個數等於自動機中以 \(t_0\) 為起點的不同路徑的條數。
考慮到后綴自動機為有向無環圖,不同路徑的條數可以使用動態規划計算。
即,令 \(d[v]\) 為從狀態 \(v\) 開始的路徑數量(包括長度為零的路徑),則我們有如下遞推方程式:
即,\(d[v]\) 可以表示為所有 \(v\) 的轉移的末端的和。
所以不同子串的個數為 \(d[t_0]-1\)(因為要去掉空子串)。
總時間復雜度為:\(O(length(S))\)。
所有不同子串的總長度
給定一個字符串 \(S\),計算所有不同子串的總長度。
本題做法與上一題類似,只是現在我們需要考慮分兩部分進行動態規划:不同子串的數量 \(d[v]\) 和它們的總長度 \(ans[v]\)。
我們已經在上一題中介紹了如何計算 \(d[v]\)。\(ans[v]\) 的值可以使用通過以下遞推式計算:
我們取每個鄰接頂點 \(w\) 的答案,並加上 \(d[w]\)(因為從狀態 \(v\) 出發的子串都增加了一個字符)。
算法的時間復雜度仍然是 \(O(length(S))\)。
字典序第 \(k\) 大子串
給定一個字符串 \(S\)。多組詢問,每組詢問給定一個數 \(K_i\),查詢所有子串中詞典序第 \(k\) 大的子串。
解決這個問題的思路基於前兩個問題的思路。字典序第 \(k\) 大的子串對應於后綴自動機中字典序第 \(k\) 大的路徑。因此在計算每個狀態的路徑數后,我們可以很容易地從后綴自動機的根開始找到第 \(k\) 大的路徑。
預處理的時間復雜度為 \(O(length(S))\),單次查詢的復雜度為 \(O(length(ans)\cdot k)\)(其中 \(ans\) 是查詢的答案,\(k\) 為字符集的大小)。
最小循環移位
給定一個字符串 \(S\)。找出字典序最小的循環移位。
我們為字符串 \(S+S\) 構造后綴自動機。則后綴自動機本身將包含字符串 \(S\) 的所有循環移位作為路徑。
所以問題簡化為尋找最小的長度為 \(length(S)\) 的路徑,這可以通過平凡的方法做到:我們從初始狀態開始,貪心地訪問最小的字符即可。
總的時間復雜度為 \(O(length(S))\)。
出現次數
對於一個給定的文本串 \(T\),有多組詢問,每組詢問給一個模式串 \(P\),回答模式串 \(P\) 在字符串 \(T\) 中作為子串出現了多少次。
我們為文本串 \(T\) 構造后綴自動機。
接下來我們做以下的預處理:對於自動機中的每個狀態 \(v\),預處理值等於 \(endpos(v)\) 這個集合大小的 \(cnt[v]\)。事實上對應於同一狀態 \(v\) 的所有子串在文本串 \(T\) 中的出現次數相同,這相當於集合 \(endpos\) 中的位置數。
然而我們不能明確的構造集合 \(endpos\),因此我們只考慮它們的大小 \(cnt\)。
為了計算這些值,我們進行以下操作。對於每個狀態,如果它不是通過復制創建的(且它不是初始狀態 \(t_0\)),我們用 \(cnt=1\) 初始化它。然后我們按它們的長度 \(len\) 降序遍歷所有狀態,並將當前的 \(cnt[v]\) 的值加到后綴鏈接上,即:
這樣做每個狀態的答案都是正確的。
為什么這是正確的?通過復制獲得的狀態,恰好是 \(length(T)\),並且它們中的前 \(i\) 個在我們插入前 \(i\) 個字符時產生。因此對於每個這樣的狀態,我們在它被處理時計算它們所對應的位置的數量。因此我們初始將這些狀態的 \(cnt\) 的值賦為 \(1\),其它狀態的 \(cnt\) 值賦為 \(0\)。
接下來我們對每一個 \(v\) 執行以下操作:\(cnt[link(v)]+=cnt[v]\)。其背后的含義是,如果有一個字符串 \(v\) 出現了 \(cnt[v]\) 次,那么它的所有后綴也在完全相同的地方結束,即也出現了 \(cnt[v]\) 次。
為什么我們在這個過程中不會重復計數(即把某些位置數了兩次)呢?因為我們只將一個狀態的位置添加到一個其它的狀態上,所以一個狀態不可能以兩種不同的方式將其位置重復地指向另一個狀態。
因此,我們可以在 \(O(length(T))\) 的時間內計算出所有狀態的 \(cnt\) 的值。
最后回答詢問只需要查找查找值 \(cnt[t]\),其中 \(t\) 為如果存在這樣的狀態就是狀態對應的模式串,如果不存在答案就為 \(0\)。單次查詢的時間復雜度為 \(O(length(P))\)。
第一次出現的位置
給定一個文本串 \(T\),多組查詢。每次查詢字符串 \(P\) 在字符串 \(T\) 中第一次出現的位置(\(P\) 的開頭位置)。
我們再構造一個后綴自動機。我們對自動機中的所有狀態預處理位置 \(firstpos\)。即,對每個狀態 \(v\) 我們想要找到第一次出現這個狀態的末端的位置 \(firstpos[v]\)。換句話說,我們希望先找到每個集合 \(endpos\) 中的最小的元素(顯然我們不能顯式地維護所有 \(endpos\) 集合)。
為了維護 \(firstpos\) 這些位置,我們將原函數擴展為 sa_extend()
。當我們創建新狀態 \(cur\) 時,我們令:
;當我們將頂點 \(q\) 復制到 \(clone\) 時,我們令:
(因為值的唯一其它選項 \(firstpos(cur)\) 肯定太大了)。
那么查詢的答案就是 \(firstpos(t)-length(P)+1\),其中 \(t\) 為對應字符串 \(P\) 的狀態。單次查詢只需要 \(O(length(P))\) 的時間。
所有出現的位置
問題同上,這一次需要查詢文本串 \(T\) 中模式串出現的所有位置。
我們還是為文本串 \(T\) 構造后綴自動機。與上一個問題相似地,我們為所有狀態計算位置 \(firstpos\)。
如果 \(t\) 為對應於模式串 \(T\) 的狀態,顯然 \(firstpos(t)\) 為答案的一部分。需要查找的其它位置怎么辦?我們使用了含有字符串 \(P\) 的自動機,我們還需要將哪些狀態納入自動機呢?所有對應於以 \(P\) 為后綴的字符串的狀態。換句話說我們要找到所有可以通過后綴鏈接到達狀態 \(t\) 的狀態。
因此為了解決這個問題,我們需要為每一個狀態保存一個指向它的后綴引用列表。查詢的答案就包含了對於每個我們能從狀態 \(t\) 只使用后綴引用進行 DFS 或 BFS 的所有狀態的 \(firstpos\) 值。
這種變通方案的時間復雜度為 \(O(answer(P))\),因為我們不會重復訪問一個狀態(因為對於僅有一個后綴鏈接指向一個狀態,所以不存在兩條不同的路徑指向同一狀態)。
我們只需要考慮兩個可能有相同 \(endpos\) 值的不同狀態。如果一個狀態是由另一個復制而來的,則這種情況會發生。然而,這並不會對復雜度分析造成影響,因為每個狀態至多被復制一次。
此外,如果我們不從被復制的節點輸出位置,我們也可以去除重復的位置。事實上對於一個狀態,如果經過被復制狀態可以到達,則經過原狀態也可以到達。因此,如果我們給每個狀態記錄標記 is_clone
,我們就可以簡單地忽略掉被復制的狀態,只輸出其它所有狀態的 \(firstpos\) 的值。
以下是實現的框架:
struct state {
...
bool is_clone;
int first_pos;
vector<int> inv_link;
};
// 在構造后綴自動機后
for (int v = 1; v < sz; v++) {
st[st[v].link].inv_link.push_back(v);
}
// 輸出所有出現位置
void output_all_occurrences(int v, int P_length) {
if (!st[v].is_clone)
cout << st[v].first_pos - P_length + 1 << endl;
for (int u : st[v].inv_link)
output_all_occurrences(u, P_length);
}
最短的沒有出現的字符串
給定一個字符串 \(S\) 和一個特定的字符集,我們要找一個長度最短的沒有在 \(S\) 中出現過的字符串。
我們在字符串 \(S\) 的后綴自動機上做動態規划。
令 \(d[v]\) 為節點 \(v\) 的答案,即,我們已經處理完了子串的一部分,當前在狀態 \(v\),想找到不連續的轉移需要添加的最小字符數量。計算 \(d[v]\) 非常簡單。如果不存在使用字符集中至少一個字符的轉移,則 \(d[v]=1\)。否則添加一個字符是不夠的,我們需要求出所有轉移中的最小值:
問題的答案就是 \(d[t_0]\),字符串可以通過計算過的數組 \(d[]\) 逆推回去。
兩個字符串的最長公共子串
給定兩個字符串 \(S\) 和 \(T\),求出最長公共子串,公共子串定義為在 \(S\) 和 \(T\) 中都作為子串出現過的字符串 \(X\)。
我們為字符串 \(S\) 構造后綴自動機。
我們現在處理字符串 \(T\),對於每一個前綴都在 \(S\) 中尋找這個前綴的最長后綴。換句話說,對於每個字符串 \(T\) 中的位置,我們想要找到這個位置結束的 \(S\) 和 \(T\) 的最長公共子串的長度。
為了達到這一目的,我們使用兩個變量,當前狀態 \(v\) 和 當前長度 \(l\)。這兩個變量描述當前匹配的部分:它的長度和它們對應的狀態。
一開始 \(v=t_0\) 且 \(l=0\),即,匹配為空串。
現在我們來描述如何添加一個字符 \(T[i]\) 並為其重新計算答案:
- 如果存在一個從 \(v\) 到字符 \(T[i]\) 的轉移,我們只需要轉移並讓 \(l\) 自增一。
- 如果不存在這樣的轉移,我們需要縮短當前匹配的部分,這意味着我們需要按照以下后綴鏈接進行轉移:
與此同時,需要縮短當前長度。顯然我們需要將 \(l\) 賦值為 \(len(v)\),因為經過這個后綴鏈接后我們到達的狀態所對應的最長字符串是一個子串。
- 如果仍然沒有使用這一字符的轉移,我們繼續重復經過后綴鏈接並減小 \(l\),直到我們找到一個轉移或到達虛擬狀態 \(-1\)(這意味着字符 \(T[i]\) 根本沒有在 \(S\) 中出現過,所以我們設置 \(v=l=0\))。
問題的答案就是所有 \(l\) 的最大值。
這一部分的時間復雜度為 \(O(length(T))\),因為每次移動我們要么可以使 \(l\) 增加一,要么可以在后綴鏈接間移動幾次,每次都減小 \(l\) 的值。
代碼實現:
string lcs (string S, string T) {
sa_init();
for (int i = 0; i < S.size(); i++)
sa_extend(S[i]);
int v = 0, l = 0, best = 0, bestpos = 0;
for (int i = 0; i < T.size(); i++) {
while (v && !st[v].next.count(T[i])) {
v = st[v].link ;
l = st[v].length ;
}
if (st[v].next.count(T[i])) {
v = st [v].next[T[i]];
l++;
}
if (l > best) {
best = l;
bestpos = i;
}
}
return t.substr(bestpos - best + 1, best);
}
多個字符串間的最長公共子串
給定 \(k\) 個字符串 \(S_i\)。我們需要找到它們的最長公共子串,即作為子串出現在每個字符串中的字符串 \(X\)。
我們將所有的子串連接成一個較長的字符串 \(T\),以特殊字符 \(D_i\) 分開每個字符串(一個字符對應一個字符串):
然后為字符串 \(T\) 構造后綴自動機。
現在我們需要在自動機中找到存在於所有字符串 \(S_i\) 中的一個字符串,這可以通過使用添加的特殊字符完成。注意如果 \(S_j\) 包含了一個子串,則后綴自動機中存在一條從包含字符 \(D_j\) 的子串而不包含以其它字符 \(D_1,\,\ldots,\,D_{j-1},\,D_{j+1},\,\ldots,\,D_k\) 開始的路徑。
因此我們需要計算可達性,它告訴我們對於自動機中的每個狀態和每個字符 \(D_i\) 是否存在這樣的一條路徑。這可以容易地通過 DFS 或 BFS 與動態規划計算。在此之后,問題的答案就是狀態 \(v\) 的字符串 \(longest(v)\) 中存在所有特殊字符的路徑。
練習
可以使用后綴自動機解決的問題:
- SPOJ #7258 SUBLEX 「字典序子串搜索」(難度:中等)
相關文獻
我們先給出與后綴自動機有關的最初的一些文獻:
- A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler, R. McConnell. Linear
Size Finite Automata for the Set of All Subwords of a Word. An Outline of
Results [1983] - A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler. The Smallest Automaton
Recognizing the Subwords of a Text [1984] - Maxime Crochemore. Optimal Factor Transducers [1985]
- Maxime Crochemore. Transducers and Repetitions [1986]
- A. Nerode. Linear automaton transformations [1958]
另外,在更現代化的一些資源里,在很多關於字符串算法的書中,這個主題都能被找到:
- Maxime Crochemore, Rytter Wowjcieh. Jewels of Stringology [2002]
- Bill Smyth. Computing Patterns in Strings [2003]
- Bill Smith. Methods and algorithms of calculations on lines [2006]