「Note」字符串方向 - 自动机相关
1. AC 自動機 ACAM
1.1. 簡介
AC 自動機用于解決多模式串匹配問題,例如求多個模式串在文本串中的出現次數。顯著地,它的應用實際上非常廣泛。
借助 KMP 的思想,我們對 Trie 樹上的每個節點構造其失配指針 \(fail_i\),指向對于當前字符串的最長后綴(其他(前綴)作為當前串后綴的最長的一個),顯著地,每個節點的失配指針都指向一個更短的狀態。當這樣的后綴不存在時,失配指針會指向表示空串的根節點。
考慮如何構建 \(fail_i\):
根據每個節點的失配指針都指向一個更短的狀態這個性質,考慮用 BFS 解決 \(fail_i\) 的構建,對于當前節點 \(now\) 來說,假設深度較小的節點都已經被處理完了。
現在假設當前節點 \(i\) 由 \(fa_i\) 經過字符 \(ch\) 轉移過來,使 \(fail_i\leftarrow trans(fail_{fa_i},ch)\),若不存在 \(fail_{fa_i}\) 通過 \(ch\) 轉移到的某一節點,則嘗試使 \(fail_i\leftarrow trans(fail_{fail_{fa_i}},ch)\)。直到 \(fail\) 指向根節點,說明根本不存在合法前綴,我們使 \(fail_i\leftarrow rt\)。
特殊地,若不存在 \(trans(fa,ch)\) 這個轉移方式,則直接令 \(trans(fa,ch)\leftarrow trans(fail_{fa_i},ch)\)。
1.2. 常見技巧
1.2.1 fail 樹的性質
構建的 \(fail\) 指針會形成一棵樹,稱為 fail 樹。這不是廢話嗎。
- fail 樹為一顆有根樹,可以進行樹剖等樹上操作。
- 對于節點 \(p\) 與其對應字符串 \(t_p\),對于任意一個子樹內節點 \(q\),都有 \(t_p\) 是 \(t_q\) 的后綴。逆命題亦成立。
- 設 \(cnt_p\) 表示作為 \(t_p\) 后綴的字符串數量。若無重復串,則 \(cnt_p\) 為樹上節點 \(p\) 到根節點上字符串節點數量。
1.2.2 應用
ACAM 可以與 DP 結合,在自動機中進行 DP。
1.3. 例題
\(\color{blueviolet}{P5357}\)
時間瓶頸在于每次跳 \(tail\) 的重復訪問,考慮經過每個點時記錄權值,最后一起統計,可以采用 DFS 或者拓撲排序。
$\text{Code}$:
#include<bits/stdc++.h>
#define LL long long
#define UN unsigned
using namespace std;
//--------------------//
//IO
inline int rd()
{
int ret=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')f=-f;ch=getchar();}
while(ch>='0'&&ch<='9')ret=ret*10+ch-'0',ch=getchar();
return ret*f;
}
//--------------------//
const int N=2e6+5,Ch=30;
int n;
char str[N];
int id[N],ans[N];
struct Edge
{
int to,nex;
}edge[N];
int etot,head[N];
void add(int from,int to)
{
edge[++etot]={to,head[from]};
head[from]=etot;
return;
}
struct ACAM
{
struct Trie_Node
{
int nex[Ch];
int fail,flag,cnt;
}t[N];
int tot,fcnt;
void insert(char *s,int temp)
{
int now=0,len=strlen(s+1);
for(int i=1;i<=len;i++)
{
if(!t[now].nex[s[i]-'a'+1])
t[now].nex[s[i]-'a'+1]=++tot;
now=t[now].nex[s[i]-'a'+1];
}
if(!t[now].flag)
t[now].flag=++fcnt;
id[temp]=t[now].flag;
return;
}
void get_fail()
{
queue<int>q;
for(int i=1;i<=26;i++)
{
if(t[0].nex[i])
q.push(t[0].nex[i]);
}
while(!q.empty())
{
int now=q.front();
q.pop();
for(int to,i=1;i<=26;i++)
{
to=t[now].nex[i];
if(!to)
{
t[now].nex[i]=t[t[now].fail].nex[i];
continue;
}
t[to].fail=t[t[now].fail].nex[i];
q.push(to);
}
}
return;
}
void get_ans(char *s)
{
int len=strlen(s+1),now=0;
for(int i=1;i<=len;i++)
{
now=t[now].nex[s[i]-'a'+1];
t[now].cnt++;
}
return;
}
void build()
{
for(int i=1;i<=tot;i++)
add(t[i].fail,i);
return;
}
void DFS(int now)
{
//printf("%d %d\n",now,t[now].cnt);
for(int to,i=head[now];i;i=edge[i].nex)
{
to=edge[i].to;
DFS(to);
t[now].cnt+=t[to].cnt;
}
if(t[now].flag)
ans[t[now].flag]=t[now].cnt;
return;
}
}A;
//--------------------//
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%s",str+1),A.insert(str,i);
A.get_fail();
scanf("%s",str+1);
A.get_ans(str);
A.build();
A.DFS(0);
for(int i=1;i<=n;i++)
printf("%d\n",ans[id[i]]);
return 0;
}
2. 后綴自動機 SAM
2.1. 簡介
2.1.1. 基本定義與結論
SAM 一般用于在線性時間內解決如下問題:
- 在一個字符串中求另一字符串出現的位置。
- 一個字符串的本質不同的子串個數。
SAM 的定義:一個長為 \(n\) 的字符串 \(s\) 的SAM 是一個接受 \(s\) 的所有后綴的最小的有限狀態自動機。
較為人話的說法:
- SAM 是一張有向無環圖。每個節點為一個狀態,邊則為狀態間的轉移。
- 存在一個源點 \(t_0\),稱作初始狀態,其他狀態均可從 \(t_0\) 出發到達。
- 每個轉移(邊)對應一個字符(一條路徑表示一個字符串),從一個狀態(節點)出發的轉移均不同。
- 從初始狀態 \(t_0\) 出發,最終轉移到一個終止狀態,則此徑代表的字符串一定是原字符串 \(s\) 的一個后綴,\(s\) 的每個后綴都可以用一條這種路徑表示。
- 滿足上述條件的自動機中,SAM 的節點數量最少。
SAM 的較為重要的一條性質:
- 從初始狀態出發到任意狀態的路徑與串 \(s\) 的所有子串(本質不同)一一對應。
接下來給出一些定義和符號表示:
- \(c_{p,q}\):轉移 \(p\to q\) 代表的字符。
- \(\mathrm{st}(p,c)\):狀態 \(p\) 經過字符 \(c\) 轉移所到達的狀態。
- \(\mathrm{endpos}(t)\):字符串 \(t\) 在原字符串中所有結束位置的集合。
- 等價類:對于 \(\mathrm{endpos}\) 集合相同的子串,我們將它們劃分為一個等價類,作為一個狀態。
- \(\mathrm{ep}(p)\):狀態 \(p\) 所對應的 \(\mathrm{endpos}\) 集合。
- \(\mathrm{substr}(p)\):狀態 \(p\) 所表示的所有子串的集合。
- \(\mathrm{longest}(p)\):狀態 \(p\) 所表示的所有子串中,長度最長的那一個子串。
- \(\mathrm{shortest}(p)\):狀態 \(p\) 所表示的所有子串中,長度最短的那一個子串。
- \(\mathrm{len}(p)\):狀態 \(p\) 所表示的所有子串中,長度最長的那一個子串的長度。
- \(\mathrm{minlen}(p)\):狀態 \(p\) 所表示的所有子串中,長度最短的那一個子串的長度。
方便理解,我們再次整理上述定義。SAM 的每個狀態對應一個等價類,即 \(\mathrm{endpos}\) 集合相同的子串所組成的狀態。具體地,我們給出例子。假設現有串 \(s=\texttt{"abcab"}\),則 \(\mathrm{endpos}(\texttt{"ab"})=\mathrm{endpos}(\texttt{"b"})={2,5}\),而串 \(\texttt{"ab"},\texttt{"b"}\) 便屬于統一等價類。
\(\mathrm{longest}(p),\mathrm{shortest}(p),\mathrm{len}(p),\mathrm{minlen}(p)\) 則描述了狀態 \(p\) 對應的字符串集合 \(\mathrm{substr}(p)\) 中最長、最短的字符串以及它們的長度。
下面介紹兩個結論:
- 對于兩個非空字符串 \(x,y\)(\(|x|\leq |y|\)),要么有 \(\mathrm{endpos}(x)\subseteq \mathrm{endpos}(y)\),要么有 \(\mathrm{endpos}(x)\cup\mathrm{endpos}(y)=\varnothing\)
- 對于一個狀態 \(p\),其包含的字符串長度連續,且較短者是較長者的后綴。
兩個結論的證明過程并不復雜,簡單思考也可以感性理解,在這里不給出具體證明,具體可參考 Alex_Wei 的博客或者 OI-Wiki,鏈接見參考資料部分。
總結以上兩個性質有:
- 對于一個子串 \(t\) 的所有后綴,其 \(endpos\) 集合的大小隨著后綴長度減小而單調不降,并且較大的集合包含較小的集合。簡單定性分析,當后綴越短時,約束條件越寬松,出現位置更可能多。
根據上面的性質,我們給出 SAM 中最核心的定義:
- 后綴鏈接 \(\mathrm{link}(p)\):對于所有 \(\mathrm{ep}(p)\subseteq\mathrm{ep}(q)\),\(\mathrm{link}(p)\) 指向 \(\mathrm{len}(q)\) 最大的那個 \(q\)。
稍微直觀一點理解我們仍然用一個例子,假設現有串 \(s=\texttt{"babcab"}\)
我們假設狀態 \(p\) 對應著我們的字符串集合 \(\{\texttt{"cab"}\}\),對應有 \(\mathrm{ep}(p)=\{6\}\)。
狀態 \(a\) 對應字符串集合 \(\{\texttt{"ab"}\}\),對應有 \(\mathrm{ep}(a)=\{3,6\}\)。
狀態 \(b\) 對應字符串集合 \(\{\texttt{"b"}\}\),對應有 \(\mathrm{ep}(b)=\{1,3,6\}\)。
根據剛才的定義,現有 \(\mathrm{ep}(p)\subseteq\mathrm{ep}(a),\mathrm{ep}(p)\subseteq\mathrm{ep}(b)\),且只有 \(a,b\) 兩狀態的 \(\mathrm{ep}\) 包含狀態 \(p\) 的 \(\mathrm{ep}\),又有 \(\mathrm{len}(a)>\mathrm{len}(b)\),所以 \(\mathrm{link}(p)\) 應指向狀態 \(a\)。
再次重復一下 \(\mathrm{link}(p)\) 的意義,它指向了狀態 \(p\) 的所有后綴狀態中(最長)長度最大的那個,易知 \(\mathrm{len}(\mathrm{link}(p))+1=\mathrm{minlen}(p)\)。
對于后綴鏈接有這樣一條性質:
- 所有后綴鏈接形成一顆以 \(t_0\) 為根的樹。(\(t_0\) 是我們最開始定義的初始狀態,它包含了空串。)
顯著的,對于任意狀態(除了 \(t_0\)),沿著后綴鏈接移動總會達到一個 \(\mathrm{len}\) 更短的狀態,直到 \(t_0\)。
后綴鏈接構成的樹本質上是 \(\mathrm{endpos}\) 集合構成的一棵樹,我們一般稱為 Parent 樹。
2.1.2. 關鍵結論
這部分主要摘自 Alex_wei 的博客,理解構建 SAM 的過程需要理解此部分的結論。如果你能較為直觀地理解 Parent 樹,那么這部分的結論都很顯然,大部分證明請參考 Alex_wei 的博客。
第一組結論:
- 從任意狀態 \(p\) 出發通過后綴鏈接跳轉到 \(t_0\) 的路徑,所有路徑上的狀態 \(q\) 的 \([\mathrm{minlen}(q),\mathrm{len}(q)]\) 無交集,并且范圍隨著在 Parent 上的深度減小而減小,并且他們的并集形成一個連續區間 \([0,\mathrm{len}(p)]\)。
- 從任意狀態 \(p\) 出發通過后綴鏈接跳轉到 \(t_0\) 的路徑,所有路徑上的狀態 \(q\) 的 \(\mathrm{substr}(q)\) 的并集為 \(\mathrm{longest}(p)\) 的所有后綴。
第二組結論:
- 有任意狀態 \(p\) 使得有從 \(p\) 到 \(q\) 的轉移,對于 \(\forall t_p\in \mathrm{substr}(p)\),有 \(t_p+c_{p,q}\in\mathrm{substr}(q)\)。
- 對于 \(\forall t_q\in\mathrm{substr}(q)\),存在且只存在一個狀態 \(p\) 使得有從 \(p\) 到 \(q\) 的轉移,并且 \(\exist t_p\in \mathrm{substr}(p)\) 使得 \(t_p+c_{p,q}=t_q\)。
第三組結論:
- 不存在從狀態 \(p\) 到狀態 \(q\) 的轉移使得 \(\mathrm{len}(p)+1>\mathrm{len}(q)\)。
- 存在唯一狀態 \(p\),有 \(p\) 到 \(q\) 的轉移使得 \(\mathrm{len}(p)+1=\mathrm{len}(q)\)。
- 存在唯一狀態 \(p\),有 \(p\) 到 \(q\) 的轉移使得 \(\mathrm{minlen}(p)+1=\mathrm{minlen}(q)\)。
在給出第四組結論之前,我們先給出兩個定義:
- \(\mathrm{maxtrans}(q)\):有 \(p\) 到 \(q\) 的轉移使得 \(\mathrm{len}(p)+1=\mathrm{len}(q)\) 的唯一 \(p\)。
- \(\mathrm{mintrans}(q)\):有 \(p\) 到 \(q\) 的轉移使得 \(\mathrm{minlen}(p)+1=\mathrm{minlen}(q)\) 的唯一 \(p\)。
第四組結論:
- 對于轉移 \(p\to q\),一定有 \(p\) 在 Parent 樹上為 \(\mathrm{maxtrans}(q)\) 或其祖先。
- 對于轉移 \(p\to q\),一定有 \(p\) 在 Parent 樹上為 \(\mathrm{mintrans}(q)\) 或其子樹內節點。
- 對于轉移 \(p\to q\),所有這樣的 \(p\) 在 Parent 樹上構成了一條深度遞減鏈,即 \(\mathrm{mintrans}(q)\to\mathrm{maxtrans}(q)\)。
并不難理解,考慮到 Parent 樹的定義以及性質,一條從上到下的鏈中字符串長度連續并且都為鏈底長串的子串。
2.1.3 SAM 的構建
至此為止,我們可以用以上的所有性質來構建 SAM 了。
我們考慮在前綴串 \(s[1,i-1]\) 的 SAM 基礎上插入當前字符更新整個 SAM。
設上一狀態(目前已插入的前綴所在狀態)為 \(las\),當前狀態為 \(cur\),狀態總數為 \(tot\)。初始時 \(las,cnt\) 均為 \(1\),即我們設初始狀態 \(t_0=1\)。
我們使先新建編號為 \(cur\) 賦值,\(cur\) 表示的是以當前插入字符結尾前綴的狀態,然后令 \(p\leftarrow las\),\(p\) 表示我們現在更新到的節點。
考慮轉移邊的處理,我們將 \(p\) 沿著 Parent 樹向上跳,可以保證的是每到一個節點都是 \(s[1,i-1]\) 的后綴,所以我們要更新其向 \(s_i\) 的轉移,若其沒有此轉移,我們就為其新建出邊,并繼續沿著 Parent 向上跳轉。直到我們到一個節點存在此轉移,說明再往上的節點都有此轉移,就不必再更新了。
接下來考慮 Parent 樹邊的構建,我們分三種情況討論。
情況一:
不存在一個 \(p\) 有以 \(s_i\) 的轉移。
這種情況存在且只存在于 \(s_i\) 這個字符從未被加入過,我們令 \(\mathrm{link}(cur)\leftarrow t_0\) 即可。
情況二:
存在 \(p\) 有以 \(s_i\) 的轉移,令 \(q=\mathrm{st}(p,s_i)\),且 \(\mathrm{len}(p)+1=\mathrm{len}(q)\)。
我們設 \(las\to t_0\) Parent 樹上的路徑 \(p\) 的前一個狀態有 \(p'\),并且 \(p'\) 已經新建了 \(s_i\) 的轉移到 \(cur\),根據 Parent 性質有 \(\mathrm{minlen}(cur)=\mathrm{len}(p')+1=(\mathrm{len}(p)+1)+1=\mathrm{len}(q)+1\),根據定義令 \(\mathrm{link}(cur)\leftarrow q\)。
情況三:
存在 \(p\) 有以 \(s_i\) 的轉移,令 \(q=\mathrm{st}(p,s_i)\),且 \(\mathrm{len}(p)+1\not=\mathrm{len}(q)\)。
當 \(\mathrm{len}(p)+1\not=\mathrm{len}(q)\),只能存在 \(\mathrm{len}(p)+1<\mathrm{len}(q)\)。狀態 \(q\) 中有一部分是無法轉移到我們當前狀態 \(cur\) 的,可以理解為 \(\mathrm{substr}(q)\) 不全為 \(\mathrm{substr}(cur)\) 的后綴,因為在 \(q\) 中存在以除 \(p\) 之外的狀態轉移過來的部分。我們考慮將 \(q\) 分為小于等于 \(\mathrm{len}(p)+1\) 和大于 \(\mathrm{len}(p)+1\) 的兩部分,并新建狀態 \(cl\) 存儲小于等于 \(\mathrm{len}(p)+1\) 的部分。對于繼承我們需要進行以下操作:
- \(cl\) 保存所有 \(q\) 向外的轉移。
- \(\mathrm{len}(cl)\) 應等于 \(\mathrm{len}(p)+1\)。
- \(\mathrm{link}(cl)\) 應等于原來的 \(\mathrm{link}(q)\)。
- 對于 Parent 樹上 \(p\to t\) 路徑上的狀態,我們也應該將原指向 \(q\) 的轉移指向 \(cl\)。
- \(\mathrm{link}(q),\mathrm{link}(cur)\) 應等于 \(cl\)。
在構建完 Parent 樹邊后,我們使 \(las\leftarrow cur\),退出構建即可。
2.1.4. SAM 的時空間限制
對于 SAM 構造、使用時間復雜度為線性的證明略復雜,仍是可參考 Alex_Wei 的博客或者 OI-Wiki。
因為每次加入字符最多新建兩個節點,所以空間應當開雙倍,特殊地,當字符集很大時,可以用 map 維護轉移。
2.2. 常用技巧
2.2.1. 求本質不同子串個數
考慮每個子串存在且只存在于一個狀態,考慮計數所有狀態中的子串總和,對于每個狀態 \(i\) 有答案 \(\sum\mathrm{len}(i)-\mathrm{len}(\mathrm{link}(i))\)。
另一種考慮方式,SAM 上一條路徑對應一個子串,對于每個狀態求一下以此狀態結尾的路徑數量,最后求和,可以考慮用拓撲排序。(相當于轉化為所有前綴的后綴個數。)
2.2.2. 解決匹配串問題
較為簡單的,直接在 SAM 上各種跑,失配就跳 Parent,與 KMP 的思想相似。
2.2.3. 求 \(\mathrm{endpos}\) 集合大小
每加入一個新狀態 \(cur\),為其計數器打上 \(1\),SAM 構建好后求一下 Parent 子樹內計數器和即可。
2.3. 例題
\(\color{blueviolet}{P3804}\)
SAM 板子。
$\text{Code}$:
#include<bits/stdc++.h>
#define LL long long
#define UN unsigned
using namespace std;
//--------------------//
//IO
inline int rd()
{
int ret=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')f=-f;ch=getchar();}
while(ch>='0'&&ch<='9')ret=ret*10+ch-'0',ch=getchar();
return ret*f;
}
//--------------------//
const int N=1e6+5,N2=2e6+5;
struct Edge
{
int to,nex;
}edge[N2];
int etot,head[N2];
void add(int from,int to)
{
edge[++etot]={to,head[from]};
head[from]=etot;
return;
}
char s[N];
struct SAM
{
struct SAM_Node
{
int nex[30];
int len,fa;
int cnt;
}a[N2];
int las=1,tot=1;
void insert(char ch)
{
int it=ch-'a'+1,p=las;
int cur=++tot;
a[cur].len=a[las].len+1,las=cur,a[cur].cnt=1;
while(!a[p].nex[it]&&p)
a[p].nex[it]=cur,p=a[p].fa;
if(!p)
{
a[cur].fa=1;
return;
}
int q=a[p].nex[it];
if(a[p].len+1==a[q].len)
{
a[cur].fa=q;
return;
}
int cl=++tot;
a[cl]=a[q],a[cl].cnt=0,a[cl].len=a[p].len+1;
a[cur].fa=a[q].fa=cl;
while(a[p].nex[it]==q&&p)
a[p].nex[it]=cl,p=a[p].fa;
return;
}
void build()
{
for(int i=1;i<=tot;i++)
add(a[i].fa,i);
return;
}
LL ans=0;
void DFS(int now)
{
for(int to,i=head[now];i;i=edge[i].nex)
{
to=edge[i].to;
DFS(to);
a[now].cnt+=a[to].cnt;
}
if(a[now].cnt!=1)
ans=max(ans,1LL*a[now].cnt*a[now].len);
return;
}
}S;
//--------------------//
int main()
{
scanf("%s",s+1);
int len=strlen(s+1);
for(int i=1;i<=len;i++)
S.insert(s[i]);
S.build();
S.DFS(1);
printf("%lld",S.ans);
return 0;
}
參考資料
\(\mathcal{Alex\_Wei's\ Blog}\)
\(\mathcal{OI-Wiki}\)
總結
以上是生活随笔為你收集整理的「Note」字符串方向 - 自动机相关的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深入探讨控制反转(IOC)与依赖注入(D
- 下一篇: 邮件传输协议SMTP和SMTPS