1. 程式人生 > 其它 >在騰訊工作是一種怎樣的體驗?

在騰訊工作是一種怎樣的體驗?

基本概念

\(\texttt{Aho Corasick Automaton}\)​,中文縮寫名為 \(AC\)​ 自動機,是一種用於解決 多模式匹配 的字串演算法。\(AC\)​ 自動機可以解決的問題通常會給出 \(n\)​ 個模式串 \(S\)​ 以及主串 \(T\)​,詢問 \(n\)​ 個模式串分別在主串中出現的次數等資訊。\(AC\)​ 自動機可以解決 模式串相同 的情況,優化後時間複雜度約為 \(O(n + m)\)​。

\(AC\) 自動機採用在 \(Trie\) 樹上進行 \(KMP\) 的方法來優化樸素的暴力演算法,和 \(KMP, manacher\) 等演算法類似,都是通過省略列舉部分確定的資訊來優化時間複雜度。\(AC\)

自動機其實就是把樹踹倒(\(Trie\) 樹)然後烤饃片(\(KMP\)),因此如果需要複習或學習前置知識可以自行檢視筆者的 Trie 樹教程 以及 KMP 教程

演算法思想

\(AC\)​ 自動機需要對給出的 \(n\)​ 個模式串建立一棵 \(Trie\)​ 樹並在 \(Trie\)​ 樹上建立若干條 \(fail\)​ 邊,然後在 \(Trie\)​ 樹上沿著 \(fail\)​ 邊路徑進行匹配。\(fail\)​ 指標是 \(KMP\)​ 思想在 \(AC\)​ 自動機演算法中的體現,定義一棵 \(Trie\)​ 樹結點 \(u\)​ 的 \(fail\)​ 指標指向 \(Trie\)

​ 樹中最長且存在的結點 \(u\)​ 對應字串的字尾的尾字元對應結點。我們結合一個例子來理解一下 \(fail\)​ 指標的定義。

P3808 的第二組樣例為例。\(4\)​ 個模式串 \(\texttt{a, ab, abc, abcd}\)​ 對應的 \(Trie\)​ 樹應該如圖所示。假設我們需要求出結點 \(3\)​ 的 \(fail\)​ 指標,那麼我們考慮結點 \(3\)​ 對應字串最長的在 \(Trie\)​ 樹中出現過 \(2\)​ 次及以上的字尾。我們發現結點 \(c\)​ 對應的字串 \(\texttt{abc}\)\(Trie\)​ 樹中出現過​ \(2\)​ 次及以上的字尾只有 \(\texttt{c}\)

。此時這個字尾的尾字元為 \(\texttt{c}\),對應的結點為 \(5\),所以 \(3\) 號結點的 \(fail\) 指標指向 \(5\) 號結點。

\(fail\) 指標也可以理解為在結點 \(u\) 對應的字串失配以後,可以立刻繼續匹配的位置。我們在 \(AC\) 自動機的過程中列舉主串的每一個字尾,並且統計與該字尾有著相同字尾的模式串。因此當我們處理完了某個模式串 \(S^{\prime}\) 以後,我們希望下一個匹配的字串合法,即擁有與 \(S^{\prime}\) 以及主串字尾相同的尾字元的模式串。根據 \(fail\) 指標的定義,從 \(S^{\prime}\) 的尾字元對應結點出發,其 \(fail\) 指標指向的結點尾字元一定和 \(S^{\prime}\) 相同。又因為 \(fail\) 指標記錄的是最長字尾的尾結點,因此我們可以跳過全部已經匹配成功的字元,從而減少匹配的字元數量。

\(fail\) 指標的求解有兩個可能的轉移。假設當前需要求出 \(fail\) 指標的結點為 \(u\)\(u\) 的父結點 \(f\)\(u\) 的邊權字母為 \(x\)。此時我們希望 \(fail\) 指標指向的字尾長度儘量長,因此我們可以從父結點的 \(fail\) 指標出發,得到的長度一定是最長的。此時父結點的 \(fail\) 指標指向的子結點 \(v\) 就是結點 \(u\)\(fail\) 指標所指向的結點。

但是這樣做需要判斷結點是否存在邊權字母為 \(x\) 的兒子,很不方便,程式碼細節很多。我們考慮給每個結點虛擬出不存在的子結點,方面匹配的時候直接通過 \(fail\) 指標跳轉。假設當前需要求出 \(u\)​ 的子結點 \(v\)\(fail\) 指標,但是子結點 \(v\) 並不存在。這時我們直接把 \(u\) 的子結點指向 \(u\)\(fail\) 指標指向結點的邊權字母相同的子結點,注意這個子結點也可能並不存在。換言之,我們令 \(son_u = son_{fail_{u}}\)。這樣做令 \(AC\)​ 自動機的實現相當於不斷拓展字串的字尾,嘗試匹配最後一個字元。如果最後一個字元並不存在,那麼我們跳轉到下一個可能出現該字元的位置,直到結束為止。

還是使用 P3808 的第二組樣例為例,上圖中的紅色部分在真實的 \(Trie\)​ 樹中並不存在。假設我們嘗試更新結點 \(6\)​ 的 \(fail\)​ 指標。此時因為結點 \(6\)​ 不實際存在 ,考慮將結點 \(6\)​ 的 \(fail\)​ 指標指向結點 \(5\)​ 的 \(fail\)​ 指標所指向的結點的邊權字母為 \(w\)​ 的子結點。我們發現結點 \(5\)​ 的 \(fail\)​ 指標指向根結點的邊權字母為 \(c\)​ 的子結點為 \(0\)​,不清楚原理可以將本段多瀏覽幾遍。此時結點 \(0\)​ 的邊權字母為 \(w\)​ 的子結點為 \(7\)​,\(7\) 號結點並不存在,實際上在 \(Trie\) 樹中儲存為為 \(0\),所以結點 \(6\)\(fail\) 指標指向結點 \(0\)。當然,如果結點 \(0\) 存在邊權字母為 \(w\) 的子結點 \(7\),那麼結點 \(6\)\(fail\) 指標就應該指向結點 \(7\)​。

具體的程式碼實現因為要按深度更新結點的 \(fail\) 指標,所以我們可以考慮使用 \(bfs\) 實現。有一個顯然的性質是深度為 \(2\) 的結點 \(fail\) 指標一定指向根結點,所以可以先將第二層的結點全部加入佇列作為初始條件。

綜上,如果當前結點 \(u\) 存在子結點 \(v\) 時,\(v\)\(fail\) 指標指向 \(u\)\(fail\) 指標指向的結點的邊權字母與 \((u, v)\) 相同的子結點。反之,結點 \(u\) 的虛擬子結點指向原本 \(u\)\(fail\) 指標指向的結點。

處理好 \(fail\) 指標後嘗試在 \(AC\) 自動機中匹配主串 \(T\)。我們列舉主串 \(T\) 的每一個字尾,並在 \(Trie\) 樹中找到該字尾尾字元對應的結點。此時如果當前結點對應的字串是模式串,說明該模式串在主串中出現過,反之我們跳到該結點的 \(fail\) 指標,嘗試再次匹配該主串字尾,直到 \(fail\) 指標回到根結點為止。具體流程可以畫圖模擬資料理解,這裡不再贅述。

例題選講

模板題

題目連結

建好 \(Trie\) 並處理好 \(fail\) 指標後直接跑 \(AC\) 自動機即可。注意題目只需要我們求出每一個字串是否在主串中出現過,因此我們可以在遍歷完 \(Trie\)​ 樹結點後將 \(Trie\) 樹結點對應的模式串數量置為 \(- 1\)。當我們遇到一個對應模式串數量為 \(- 1\) 的結點時,說明該結點以及其之後的 \(fail\) 路徑都一定被訪問並統計過了,可以直接退出。

#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;

const int maxn = 1e6 + 5;

struct node
{
	int fail, cnt;
	int son[26];
} tree[maxn];

int n, m;
int tot, ans;
char s[maxn];
queue<int> q;

void insert(char *s)
{
	int t = 0;
	m = strlen(s);
	for (int i = 0; i < m; i++)
	{
		int c = s[i] - 'a';
		if (!tree[t].son[c])
			tree[t].son[c] = ++tot;
		t = tree[t].son[c];
	}
	tree[t].cnt++;
}

void get_fail()
{
	for (int i = 0; i < 26; i++)
	{
		int t = tree[0].son[i];
		if (t)
		{
			tree[t].fail = 0;
			q.push(t);
		}
	}
	while (!q.empty())
	{
		int t = q.front();
		q.pop();
		for (int i = 0; i < 26; i++)
		{
			if (tree[t].son[i])
			{
				tree[tree[t].son[i]].fail = tree[tree[t].fail].son[i];
				q.push(tree[t].son[i]);
			}
			else
				tree[t].son[i] = tree[tree[t].fail].son[i];
		}
	}
}

void query()
{
	int now = 0;
	m = strlen(s);
	for (int i = 0; i < m; i++)
	{
		int c = s[i] - 'a';
		now = tree[now].son[c];
		for (int t = now; t && tree[t].cnt != -1; t = tree[t].fail)
		{
			ans += tree[t].cnt;
			tree[t].cnt = -1;
		}
	}
}

int main()
{
	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
	{
		scanf("%s", s);
		insert(s);
	}
	get_fail();
	scanf("%s", s);
	query();
	printf("%d\n", ans);
	return 0;
}

拓撲排序優化

題目連結

這道題需要求出可能重複的 \(n\) 個模式串分別在主串中的出現次數。為了避免重複模式串帶來的影響,我們給 \(Trie\) 樹中的每一個結點維護一個 vector 陣列表示該結點對應的若干個模式串編號。接著我們直接在 \(Trie\) 樹上跑 \(AC\) 自動機會 \(TLE\),原因是這裡需要統計出現次數,因此會重複遍歷 \(fail\) 路徑,時間複雜度不是嚴格 \(O(n)\)。我們可以採用類似 \(lazy\) 的思想,每次僅更新當前主串字尾對應的結點,不沿著 \(fail\) 邊繼續更新。接著我們根據 \(fail\) 邊建立出一棵 \(fail\) 樹。顯然在 \(fail\) 樹中結點 \(u\) 對應的字串一定是結點 \(u\) 的子結點對應的字串的字尾。換言之我,我們只需要查詢 \(fail\) 樹中結點 \(u\) 的子樹內的模式串數量就可以求出結點 \(u\) 對應的模式串的出現次數了,具體原因是結點 \(u\) 的祖先結點對應的字串一定包含結點 \(u\) 對應的字串,結點 \(u\) 的祖先結點反之。詳見程式碼。

#include <cstdio>
#include <cstring>
#include <vector>
#include <queue>
using namespace std;

const int maxn = 2e5 + 5;

struct node
{
	int cnt, fail;
	int son[26];
	vector<int> v; //結點 u 對應的若干模式串編號 
} tree[maxn];

struct Edge // fail 樹 
{
	int to, nxt;
} edge[maxn * 2];

int n, m;
int cnt, tot;
int head[maxn], tar[maxn];
int size[maxn], ans[maxn];
bool vis[maxn];
char s[maxn * 10];
queue<int> q;

inline void add_edge(int u, int v)
{
	cnt++;
	edge[cnt].to = v;
	edge[cnt].nxt = head[u];
	head[u] = cnt;
}

inline void write(int x) // 本題輕度卡常 
{
	if (x < 0)
	{
		x = -x;
		putchar('-');
	}
	if (x > 9)
		write(x / 10);
	putchar(x % 10 + '0');
}

inline void insert(char *s, int id) // 插入模式串 
{
	int t = 0;
	m = strlen(s);
	for (register int i = 0; i < m; i++)
	{
		int c = s[i] - 'a';
		if (!tree[t].son[c])
			tree[t].son[c] = ++tot;
		t = tree[t].son[c];
	}
	tree[t].v.push_back(id);
	tar[id] = t;
}

inline void get_fail() // 求 fail 陣列 
{
	for (register int i = 0; i < 26; i++)
	{
		if (tree[0].son[i])
		{
			tree[tree[0].son[i]].fail = 0;
			q.push(tree[0].son[i]);
		}
	}
	while (!q.empty())
	{
		int t = q.front();
		q.pop();
		add_edge(tree[t].fail, t);
		for (register int i = 0; i < 26; i++)
		{
			if (tree[t].son[i])
			{
				tree[tree[t].son[i]].fail = tree[tree[t].fail].son[i];
				q.push(tree[t].son[i]);
			}
			else
				tree[t].son[i] = tree[tree[t].fail].son[i];
		}
	}
}

inline void dfs(int u)
{
	int len = tree[u].v.size();
	size[u] = tree[u].cnt;
	for (int i = head[u]; i; i = edge[i].nxt) // 計算 u 子樹內的模式串數量 
	{
		dfs(edge[i].to);
		size[u] += size[edge[i].to];
	}
	for (int i = 0; i < len; i++)
		ans[tree[u].v[i]] = size[u]; // 用 ans[i] 儲存第 i 個模式串的出現次數 
}

inline int query()
{
	int now = 0;
	m = strlen(s);
	for (register int i = 0; i < m; i++)
	{
		now = tree[now].son[s[i] - 'a']; // 只更新主串字尾對應的結點 
		tree[now].cnt++;
	}
	dfs(0);
}

int main()
{
	scanf("%d", &n);
	for (register int i = 1; i <= n; i++)
	{
		scanf("%s", s);
		insert(s, i);
	}
	scanf("%s", s);
	get_fail();
	query();
	dfs(0);
	for (register int i = 1; i <= n; i++)
		write(ans[i]), putchar('\n');
	return 0;
}