1. 程式人生 > 實用技巧 >[HNOI2015]亞瑟王

[HNOI2015]亞瑟王

AC自動機利用trie樹可以高效解決有關多個字串的問題。

Trie樹

也稱字典樹,它的本質是使得字串集合\(S\)構成一棵樹,其中邊權記錄字元資訊。
它的根到任意節點的路徑對應集合\(S\)中某一字串的字首。
任意節點向深度增大的方向經過的路徑對應\(S\)中某一字串的子串。
比如下面這一棵\(\text{trie}\),就記錄了\(\text{why,who,when}\)這幾個字串所構成的集合的資訊:

簡單來說,\(\text{trie}\)的插入是這樣的:

  1. 從根節點進入,從插入的字串的首位開始考慮,若屬於這位字元的邊走向的點已存在,就繼續往下走;否則,就為這條邊建立一個新的節點;
  2. 向下走後重複以上過程;
  3. 直到走完所有字元,最後所處的位置進行標記,表明從根節點到這的路徑屬於集合中。
int tot=0;
void insert(char *s,int l_s)
{
	int p=0;
	for (int i=1;i<=l_s;i++)
	{
	  if (!trie[p][s[i]-'a'])
	      trie[p][s[i]-'a']=++tot;
	  p=trie[p][s[i]-'a'];
	}
	end[p]++;
}

關於trie樹的定義

  • 狀態:從根節點到任何一個字典樹上的節點的路徑表示的字串都代表著一個狀態
  • 轉移:任意一個狀態新增一個新的字元得到新狀態
  • 可識別:若一個字串能對應字典樹上的某一狀態,則稱其為可識別
  • 標記狀態:特指屬於集合S的狀態

查詢字串\(s\)是否在集合中\(\rightarrow\)\(\text{trie}\)上遍歷,在結尾處查詢是否有相應狀態(遍歷與插入的過程類似)
查詢是否有字串\(s\)的某個字首在集合中\(\rightarrow\)遍歷的時候每經過一個節點就檢查一遍是否為標記狀態
查詢是否有字串\(s\)的某個子串在集合中\(\rightarrow\)因為子串都是某個字首的字尾,所以只需查詢\(s\)每個字首有多少可識別並被標記的字尾
一般敏感詞遮蔽就可以用這樣的方法,不過有可能會誤傷一些連起來的正常詞語。

下面我們來解決查詢是否有字串\(s\)的某個子串在集合中的這個問題

我們定義\(\text{fail[s[1..i]]}\)表示\(\text{s[1..i]}\)最長可識別字尾(你可以將它理解為一個字串指標,雖然實際上我們會用陣列實現,使它指向一個狀態的編號)。容易發現的是,可識別字尾具有傳遞性,即:若\(s\)\(t\)的可識別字尾,\(t\)\(r\)的可識別字尾,則\(s\)也為\(r\)的可識別字尾.
依據這個性質,設\(\text{s[1..i]}\) \(=t\),其所有可識別字尾為\(\text{fail[t],fail[fail[t]],fail[fail[fail[t]]]}\) \(\cdots\),且長度遞減。

那麼,怎麼處理這個\(\text{fail}\)指標呢?
假設有字串\(x\),\(\text{fail[x]=y}\).考慮\(x\)加上一個字元\(c\)後的新字串\(xc\)\(\text{fail}\)指標。顯然,\(xc\)的可識別字尾必然為\(x\)的某個可識別字尾加上\(c\)的形式。所以,我們為了求出\(xc\)的最長可識別字尾,只需查詢\(\text{yc,fail[y]c,fail[fail[y]]c}\) \(\cdots\)中最長的可識別串即可.

接下來考慮依賴關係。由上,我們知道想要一個字串的\(\text{fail}\)指標與比其長度少一的可識別字尾有關,長度在\(\text{trie}\)樹上的表現即為深度。所以,我們應按照深度從小到大計算每個節點所代表的狀態的\(\text{fail}\)指標。可用\(\text{BFS}\)實現。

建立AC自動機虛擬碼:

\(build\ trie\ T\ from\ S=\left\{str_1,str_2...\right\}\)
\(sort\ every\ state\ in\ T\ by\ depth\)
\(fail[root]=\phi\)
\(for\ each\ c,trans(\phi, c)=root\ (to\ avoid\ some\ error)\)
\(for\ state\ x\ in\ T\)
\(\ \ \ \ \ \ for\ xc\ in\ T\)
\(\ \ \ \ \ \ \ \ \ \ \ \ \ y=fail[x]\)
\(\ \ \ \ \ \ \ \ \ \ \ \ \ while \ y\neq\phi\ and \ yc\ not\ in\ T\)
\(\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ y=fail[y]\)
\(\ \ \ \ \ \ \ \ \ \ \ \ \ fail[xc]=yc\)

注意到,當新加進來字元的狀態不能直接被當前的\(fail\)匹配時,需要一直在\(fail\)鏈上跳,直到能擴充套件匹配或跳到空集。有一個小優化可以在我們不需要訪問或更改\(\text{trie}\)樹的資訊使用:若\(yc\)不存在,則將其直接指向\(fail[y]c\)。這樣以來,就可以避免多餘的跳動,直接指到最長可匹配的狀態。
結合程式碼理解:

void build()
{
	for (int i=0;i<26;i++)
		if (trie[0][i])
        {
        	fail[trie[0][i]]=0;
			q.push(trie[0][i]);
        }

	while(!q.empty())//藉助佇列依照深度從小到大的關係對狀態進行更新
	{
		int u=q.front();
		q.pop();
		for (int i=0;i<26;i++)
		{
			if (trie[u][i])
			{
				fail[trie[u][i]]=trie[fail[u]][i];//不需要考慮是否真的有對應狀態,若有,直接匹配;若沒有,得到之前深度最大的匹配
				q.push(trie[u][i]);//繼續更新深度更大的狀態
			}
			else
				trie[u][i]=trie[fail[u]][i];//相當於一個虛點,指向它相當於指向前一個點,這是一個傳遞的關係,減少了嘗試匹配的操作
		}
	}
}

接下來,求\(s\)\(S\)中的字串匹配的次數只需在於AC自動機上遍歷\(s\)的過程中求出每個狀態\(fail\)鏈上的標記數即可。需要注意的是,如果我們要求的是有多少個字串出現在s中,那麼每個標記狀態應只被記錄一次。為避免重複記錄,應將訪問過的標記狀態的貢獻記為-1,就像這樣:

int solve(char *s,int l)
{
	int u=0,ans=0;
	for (int i=1;i<=l;i++)
	{
		u=trie[u][s[i]-'a'];
		for (int t=u;t&&end[t]!=-1;t=fail[t])//由於fail的傳遞性,fail鏈上的狀態應是連續的被訪問過再到未被訪問過
			ans+=end[t],end[t]=-1;
	}
	return ans;
}

如果我想要知道所有字串分別被匹配的總次數呢?改變一下狀態的記錄方式,把每個字串的終點編號記錄下來,每次訪問到該狀態就將這個次數加1。這是非常直觀的想法,但是想想我們求解\(fail\)的過程,往上跳真的很需要時間!接下來好好想想,怎麼樣才能避免那麼多的跳躍?
由於\(fail\)是具有唯一前驅狀態的,這符合一棵樹的形態,其根節點為空集。當我們訪問某一個節點,其到根的鏈上的節點都被訪問一次。所以,為了避免每次都通過跳躍來更新資訊,我們應該在每次第一個訪問的節點上打上標記,當所有標記都打完之後自底向上地更新資訊。可以用拓撲排序,也可以\(\text{dfs}\)回溯時更新資訊,方法多樣。

相關練習

AC自動機 模板1
AC自動機 模板2