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

字尾自動機學習筆記

字尾自動機學習筆記

推薦大佬的 \(blog\)

什麼是字尾自動機

字尾自動機是在一個 \(DAG\) 圖中, 表示出了字尾自動機的所有後綴
顯然字典樹可以解決,但是字典數的節點數是 \(n^2\) 的。
而後綴自動機可以讓節點數量達到線性。
字尾自動機的一個節點代表一個集合,而這個會在後面的集合中提到。

字尾自動機的前置知識

對於一個字串,他在原串中出現了若干次。出現的右端點編號的集合稱為 \(endpos\) 集合。\(e.g.\) 原串為 \(abcab\) 時, \(endpos(ab) = {2,5}\)

現在給出幾個結論 (不難證明)

1. 如果兩個不同子串的 \(endpos\)
集合中有任意一個元素相同,則其中子串一個必然為另一個的字尾

2. 對於任意兩個子串 \(a\)\(b\), \(len_a \le len_b\) 要麼 \(endpos(a) \in endpos(b)\) ,要麼 \(endpos(a) \cap endpos(b) = \emptyset\)

3. 對於 \(endpos\) 相同的子串,我們將它們歸為一個 \(endpos\) 等價類。對於任意一個 \(endpos\) 等價類,將包含在其中的所有子串依長度從大到小排序,則每一個子串的長度均為上一個子串的長度減 \(1\) ,且為上一個子串的字尾 (簡單來說,一個 \(endpos\)
等價類內的串的長度連續)

4. \(endpos\) 等價類個數級別為 \(O(n)\)

這裡要提到一個重要的東西叫做 \(parent\)
一個 \(endpos\)集合, 如果要把他分割成幾個集合, 每一個集合對應一個 \(endpos\) 等價類 (因為 \(endpos\) 等價類如果不是包含關係, 就不能有相同元素。所以只能把一個 \(endpos\) 集合分割成多個 \(endpos\) 集合,然後在其中繼續分割。)
於是這就是一個樹形結構, 稱其為 \(parent\)

以上圖片是一個字串為 \(aababa\) 的例子

在一個 \(endpos\) 等價類 \(a\)
中, 有最長的串, 同時也有最短的串。設該 \(endpos\) 等價類在\(parent\) 樹上的節點的父親為 \(fa(a)\), 設最長的字串長度是 \(len(a)\), 最短的是 \(minlen(a)\), 那麼 \(minlen(fa(a)) + 1= len(a)\)

附上一張在 \(endpos\) 等價類中的最長字串的圖片

這時, 我們的字尾自動機的節點就是這 \(parent\) 樹上的節點!
但是, 字尾自動機的邊不同於 \(parent\) 樹上的邊。

建立一個字尾自動機

先放程式碼

void ins(int x) {
	int p = las, now = las = ++tot;
	cnt[now] = 1, f[now].len = f[p].len + 1;
	for(; p && !f[p].ch[x]; p = f[p].fa) f[p].ch[x] = now;
	if(!p) f[now].fa = 1;
	else {
		int pto = f[p].ch[x];
		if(f[pto].len == f[p].len + 1) f[now].fa = pto;
		else {
			int sp = ++tot;
			f[sp] = f[pto], f[sp].len = f[p].len + 1;
			f[now].fa = f[pto].fa = sp;
			for(; p && f[p].ch[x] == pto; p = f[p].fa) f[p].ch[x] = sp;
		}
	}
}

如果在原來的字尾自動機中增加一個字元 \(x\), 那麼原串的字尾要增加一個字元 \(x\)
他出現的次數為 \(1\), 而且他的最長字串是原來的字尾長度 \(+1\)
於是有程式碼

int p = las, now = las = ++tot; // p 是原來的最長字尾,now是現在的最長字尾,las是記錄上次最長字尾的
cnt[now] = 1, f[now].len = f[p].len + 1; // len 同上面的描述,是最長字串長度, f是表示parent樹的一個endpos等價類的集合, 一個字尾自動機的節點

然後包含之原來的 \(las\) 的節點都被其 \(parent\) 樹上的父親節點所包含, 因此如果在他們的後面再新增一個字元 \(x\), 都會跳到該節點。但是如果他的父親已經有轉移了, 那麼就不用再增加這條邊了。而且如果遇到一個節點已經有字元 \(x\) 的轉移邊了, 他的父親一定也有字元 \(x\) 的轉移邊, 所以就不用繼續往上跳了。
程式碼:

for(; p && !f[p].ch[x]; p = f[p].fa) f[p].ch[x] = now;

考慮如何在\(parent\) 樹上處理 \(now\) 的父親指標。
如果一直往上跳的過程中, 所有節點都沒有向字元 \(x\) 的轉移邊, 就說明字元 \(x\) 是第一次出現(因為根結點也沒有字元 \(x\) 的轉移邊), 所以除了根結點沒有集合能夠包含集合編號為 \(now\) 的節點了, 於是其父親指標指向 \(1\)

if(!p) f[now].fa = 1;

否則 \(p\) 就在有轉移邊 \(x\) 的祖先節點上停下來了。
這時 \(p\) 是原來 \(las\) 的字尾, 都加上一個字元 \(x\) 之後, \(pto\) (假設點 \(p\) 加上一個 \(c\) 得到了 \(pto\)) 也是 \(now\)的字尾。 都是如果該節點 \(p\) 增加一個 \(x\) 得到的最長字尾恰好是原來字尾的最長字尾的長度\(+1\), 那麼就說明了再這個節點代表的所有字串中增加一個 \(x\) 後到達 \(p\)\(endpos\) 一致, 而 \(p\) 又是跳父親時第一個遇見的節點, 所以 \(pto\) 一定是 \(now\) 的最長字尾, \(now\)\(parent\) 樹上的父親就是 \(pto\)

int pto = f[p].ch[x];
if(f[pto].len == f[p].len + 1) f[now].fa = pto;

那麼 \(len(p) > len(pto) + 1\) 怎麼辦?
說明在 \(p\) 中還有另外一個