1. 程式人生 > 實用技巧 >「Wallace 筆記」序列自動機入門

「Wallace 筆記」序列自動機入門

基本概念

記號

同為 DFA 的記號。

  • \(\Sigma\):字符集;
  • \(Q\):狀態集;
  • \(q_0(\in Q)\):起始狀態;
  • \(F(\in Q)\):接受狀態集;
  • \(\delta\):轉移函式。
    • \(\delta(x, c)\) 表示狀態 \(x\) 的字元 \(c\) 的轉移。若無記為 \(\text{null}\)
    • \(\delta(x, S)\) 其中 \(S\) 為字串,等價於 \(\delta(\delta(x, S[1, |S| - 1]), S_{|S|})\)

定義

序列自動機是接受且僅接受一個字串的子序列的自動機。

如當 \(\Sigma = \{ \texttt{a, b, c} \}\)

\(S = \texttt{abac}\) 時,其序列自動機為:

狀態

對字串 \(S\) 構建序列自動機,那麼這個自動機中存在 \(|S| + 1\) 個狀態。

對於字串 \(T\),狀態 \(\delta(q_0, T)\) 表示 \(T\) 作為 \(S\) 的子序列,在 \(S\) 中第一次出現的末端位置。

\(\delta(q_0, T) = \text{null}\),則說明 \(T\) 不是 \(S\) 的子序列。

根據定義,序列自動機上的所有狀態都是接受狀態(\(F = Q\))。

轉移函式

轉移函式 \(\delta\) 的設計,我們可以貪心地認為 \(\delta(x, c)\)

就是 字串中位置 \(x\) 之後,字元 \(c\) 下一次的出現位置

但很顯然之後可能會有多個 \(c\),為什麼選取最前面的呢?假如說 \(i < j\),那麼字尾 \(S[j, |S|]\) 中的所有子序列一定被完全包含在後綴 \(S[i, |S|]\) 中。換句話說,\(S[j, |S|]\) 的所有子序列的集合為 \(S[i, |S|]\) 所有子序列的子集。於是不難發現後面的一定不會優於前面。

形式化地講,\(\delta(x, c) = \min\{ y | S_y = c, y > x \}\)。如果不存在不妨設為 \(-1\) 表示 \(\text{null}\)

構造演算法

樸素演算法

首先,根據上文對轉移函式的討論,我們可以從後往前掃,逐位構建。

對於狀態 \(i\),假如已經求出了 \(\delta(i, c)\) 的轉移,考慮如何推出狀態 \(i - 1\) 的轉移函式。

不難發現,只有一個位置,即 \(S_i\) 的值的位置上,是需要變動的,那麼:\(\delta(i - 1, S_i) = i\)

其他字元的轉移,只需要直接複製下來即可。這樣的演算法的時空複雜度為 \(O(|S| \times |\Sigma|)\)

程式碼非常簡單:

for (register int i = 1; i <= m; i++) // 最後一位的所有轉移置為 null
	next[n][i] = -1;
for (register int i = n; i; i--) { // 倒序逐位構造
	for (register int j = 1; j <= m; j++) // 先複製前一輪的結果
		next[i - 1][j] = next[i][j];
	next[i - 1][dat[i]] = i; // 更新當前字元的轉移
}

可持久化陣列優化

上一個演算法的效率在 \(\Sigma\) 大小過大時都不夠優秀,問題在於對前面狀態複製了過多資訊。

然而我們只需要改變一個位置的值,把整個賦值顯然是浪費。

這就需要 可持久化 的思想了——我們先對最後一個建出線段樹,初始值為 \(-1\)

接下來在構建時只需在一個位置更新即可。時空複雜度 \(O(|S|\log |\Sigma|)\)。較之前都有較大的提高。

struct segt_node {
	segt_node* lc;
	segt_node* rc;
	int l, r, val;
	
	#define mid ((this->l + this->r) >> 1)
	segt_node(segt_node *last, int pos, int _val) : l(last->l), r(last->r), val(0) {
		if (this->l == this->r) { val = _val; return; }
		if (pos <= mid) {
			rc = last->rc;
			lc = new segt_node(last->lc, pos, _val);
		} else {
			lc = last->lc;
			rc = new segt_node(last->rc, pos, _val);
		}
	}
	segt_node(int L, int R) : l(L), r(R), val(-1) {
		if (l == r) return;
		lc = new segt_node(l, mid);
		rc = new segt_node(mid + 1, r);
	}
	int at(int pos) {
		if (this->l == this->r) return this->val;
		if (pos <= mid) return this->lc->at(pos);
		else return this->rc->at(pos);
	}
	#undef mid
} *root[N];

// main 函式內:
	root[n] = new segt_node(1, m);
	for (register int i = n; i; --i)
		root[i - 1] = new segt_node(root[i], dat[i], i);

二分查詢(非顯式構建)

其實還有一種不需要顯式構造出的方法。我們建出自動機,實際上就是需要快速計算出轉移函式 \(\delta\)

但假如我們並沒有構造出自動機,不過仍能高效得到轉移函式的值,好像並不是不可以。

這裡就有一種非常方便的方法:二分查詢。

我們先預處理出 \(\Sigma\) 中每一種值的出現位置,這一可以用 vector 實現。為方便討論,記 \(p(i)\) 為字元 \(i\) 在字串中的出現位置的集合。

在做轉移時,假設要得到 \(\delta(x - 1, c)\),那麼只要 找出集合 \(p(c)\) 中第一個 \(\ge c\) 的元素

由於我們預處理 \(p\) 集合時就是自然有序的,因此只需要二分查詢即可(upper_bound)。

這樣的空間可以達到 \(O(|S|)\)

習題

後記

reference: