字符串算法學習筆記(不定期更新)


暫時咕咕咕了。

1.SA

模擬退火后綴數組(Suffix Array)是一種很奇妙的算法。主要原因是它可以做到在 \(O(n\log n)\) 時間內完成排序。

關於如何完成這個比較基礎,具體可見洛谷日報

而后綴排序的重點在於“字典序排序”的一些奇妙性質。所以對於一般字符串的字典序排序,以下性質也適用。

首先可以發現的是 \(\operatorname{LCP}(i,j)=\min(\operatorname{LCP}(i,k),\operatorname{LCP}(k,j)),k\in[i,j]\)。這個比較顯然主要我也不怎么會嚴格證明。具體可以見洛谷日報的證明。

考慮有了這個我們可以干什么。考慮這樣一道題:按一定方式給定一堆字符串(總長度可能很大),問其中本質不同前綴的個數。

那么顯然可以發現,相鄰兩字符串的 \(\operatorname{LCP}\) 就是他們本質相同的前綴。換句話說,除此之外的部分都是本質不同的。

而根據那個奇怪的性質,相鄰兩個字符串 \((x,x+1)\)\(\operatorname{LCP}\) 一定 \(\geq (i,k),k\geq i+1\)\(\operatorname{LCP}\)。所以顯然成立。

但是這個相鄰的 \(\operatorname{LCP}\) 怎么求呢?

其實是有一個很simple的 \(O(n)\) 求法。什么SA-IS?完全不會。

具體來說,我們可以求出第 \(i\) 個位置與字典序在它前面的串的 \(\operatorname{LCP}\) \(h_i\)。可以發現有 \(h_{i}=h_{i-1}+1\)。於是乎就均攤 \(O(n)\) 了。

那么我們可以做什么了呢?求本質不同子串!每個后綴的前綴唯一對應一個子串,所以直接減就好了。

例:本質不同子串

#include<iostream>
#include<cstdio>
#include<cstring>
#define N 100010
using namespace std;
int b[N],sa[N],rk[N],a[N],id[N];
char s[N];
void SA_(int n,int m)
{
	for(int i=0;i<=m;i++) b[i]=0;
	for(int i=1;i<=n;i++) b[rk[i]]++;
	for(int i=1;i<=m;i++) b[i]+=b[i-1];
	for(int i=n;i>=1;i--) sa[b[rk[id[i]]]--]=id[i];
}
void SA(int n)
{
	int m=124;
	for(int i=1;i<=n;i++) rk[i]=s[i]-'0'+1,id[i]=i;
	SA_(n,m);int t=0;
	for(int p=1;p<n;m=t,t=0,p<<=1)
	{
		for(int i=1;i<=p;i++) id[++t]=n-p+i;
		for(int i=1;i<=n;i++) if(sa[i]>p) id[++t]=sa[i]-p;
		SA_(n,m); swap(id,rk); rk[sa[1]]=t=1;
		for(int i=2;i<=n;i++) rk[sa[i]]=(t+=id[sa[i-1]]!=id[sa[i]] || id[sa[i-1]+p]!=id[sa[i]+p]);
	}
}
int ht[N];
void get_ht(int n)
{
	for(int i=1,p=0;i<=n;ht[rk[i]]=p,i++)
	if(rk[i]!=1) for(p=p-!!p;sa[rk[i]-1]+p<=n && i+p<=n && s[i+p]==s[sa[rk[i]-1]+p];p++);
}
int main()
{
	int n;
	scanf("%d%s",&n,s+1);
	SA(n);
	get_ht(n);
	long long ans=1ll*n*(n+1)/2;
	for(int i=1;i<=n;i++) ans-=ht[i];
	printf("%lld\n",ans);
	return 0;
}
// 壓行?怎么可能?這叫 建築美(

看到這你或許會問:這個不是SAM也能做嗎?而且SAM是 \(O(n)\) 的。

的確,絕大部分SA能做的SAM都能做,而且SAM跑得快、支持在線、還更好些(所以我學SA干什么)。

別急,這里還有一個SA的晉升版本:

2.后綴平衡樹

沒想到吧,后綴平衡樹居然不是后綴樹變過來的(我也沒想到)。

首先我們還是考慮一般情況:給定一個字符串 \(S\) 的一堆子串,每次問某個子串 \(s0\) 與其他每個串的 \(\operatorname{LCP}\) 最大是多少。動態修改子串集合。

這個可以怎么做?考慮使用平衡樹套Hash。具體可以見Hash學習筆記中的一道口胡的題(里面好像還有一個強制在線)。

這個是 \(O(n\log^2 n)\) 的,雖然比較暴力已經足夠優秀了。但是如果我們插入的字符串有一些規律可循,是不是有更快的做法。

【模板】后綴平衡樹

題意

維護一個字符串,支持加一堆字符,刪一堆字符,詢問某個字符串出現次數。強制在線。總字符串長度 \(\leq 10^6\)

出題人真的是喪心病狂。。。

AC自動機能過?那就強制在線。

SAM還能過?那就每次加一堆字符。

啥?兩只 \(\log\) 艹過去了?那就開到 \(10^6\)真·5步出題法

顯然我們需要一個更高妙的做法。考慮多一只 \(\log\) 的瓶頸在於每次判斷字典序時必須 \(O(\log n)\) 處理。再加上判斷 \(O(\log n)\) 次,所以總 \(O(\log^2 n)\)

平衡樹的 \(\log n\) 沒辦法優化,考慮優化判斷字典序。可以發現,我們要加入的字符串 \(u\) 在加入前它的前綴一定已經出現過了,所以前綴和當前要比較的節點 \(p\) 均出現過。

可以發現,當前加入的字符串 \(u\) 除了最后一個字符之外其他都與前綴 \(u-1\) 完全一致,所以我們先暴力比較 \(u\)\(p\) 的最后一個字符,如果相同意味着這個 \(u-1\)\(p-1\) 的字典順序決定了 \(u\)\(p\) 的字典順序。但是直接這樣比較還是 \(O(\log n)\)

考慮如果我們維護出了所有前綴的 \(rank\),那么顯然 \(rank\) 的相對順序就對應最后的結果。但是我們不能直接維護rank,這樣會平白多出一個 \(\log n\)。考慮我們只需要知道 \(rank\) 的相對順序即可。考慮利用平衡樹的性質,每個點取一個權值 \(v_i=\frac{L+R} 2\),然后根據 \(v_i\) 將區間分為兩段遞歸處理。可以發現,這樣滿足 \(v_{ls_u}< v_u< v_{rs_u}\)

這樣建樹的時間復雜度 \(O(|S|\log |S|)\)

考慮這題維護的東西:出現次數。

這個就很好辦了。考慮差分,比如要查 \(\texttt{AB}\),我們就查字典序在 \(\texttt{AA[}\)\(\texttt{AB[}\) 之間的字符。(\(\texttt{[}\) 的字典序大於所有大寫字母)。具體來說,由於后綴平衡樹中不存在字符 \(\texttt{[}\) ,我們可以直接用字典序小於 \(\texttt{AB[}\) 的數量減去小於 \(\texttt{AA[}\) 的數量。

由於這里需要保證相對順序,所以我們必須使用重量平衡樹,這里可以是替罪羊樹(當然好像treap也行,splay應該會被卡)。

總時間復雜度 \(O(|S|\log |S|)\),空間復雜度 \(O(|S|)\)

特別,這里需要支持刪除。但是我們不能按照普通的替罪羊樹那樣打標記,因為這個點會被替換掉。

所以我們直接暴力刪除,按照BST的方式找到左子樹中最右端的點,然后拼接上去。由於樹深是 \(O(\log n)\),所以這樣復雜度也是對的。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define N 2000010
#define db double
#define alp 0.72
#define MAXD 1e16
using namespace std;
char str[N],s[N];
db v[N];
int ch[N][2],siz[N];
int swp[N],stot,rt;
void upd(int u){siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;}
void dfs(int u)
{
	if(!u) return;
	dfs(ch[u][0]),swp[++stot]=u;dfs(ch[u][1]);
	ch[u][0]=ch[u][1]=0;
}
void build(int &u,int l,int r,db lf=0,db rf=MAXD)
{
	if(l>r) return;
	int mid=(l+r)>>1;db mf=(lf+rf)/2;
	u=swp[mid];
	v[u]=mf;
	build(ch[u][0],l,mid-1,lf,mf),build(ch[u][1],mid+1,r,mf,rf);
	upd(u);
}
void reb(int &u,db lf,db rf)
{
	if(max(siz[ch[u][0]],siz[ch[u][1]])<siz[u]*alp) return;
	stot=0;dfs(u);
	build(u,1,stot,lf,rf);
}
int cmp(int x,int y){return s[x]==s[y]?v[x-1]<v[y-1]:s[x]<s[y];}
void insert(int &u,int k,db lf=0,db rf=MAXD)
{
	if(!u){siz[u=k]=1;v[u]=(lf+rf)/2;ch[u][0]=ch[u][1]=0;return;}
	if(cmp(k,u)) insert(ch[u][0],k,lf,v[u]);
	else insert(ch[u][1],k,v[u],rf);
	upd(u),reb(u,lf,rf);
}
void erase(int &u,int k)
{
	if(u==k)
	{
		if(!ch[u][0] || !ch[u][1]){u=ch[u][0]|ch[u][1];return;}
		int p=ch[u][0],las=u;
		for(;ch[p][1];las=p,p=ch[p][1]) siz[p]--;
		if(las==u) ch[p][1]=ch[u][1];
		else ch[p][0]=ch[u][0],ch[p][1]=ch[u][1],ch[las][1]=0;
		u=p;
		upd(u);
		return;
	}
	if(cmp(k,u)) erase(ch[u][0],k);
	else erase(ch[u][1],k);
	upd(u);
}
bool cmp_s(int u){for(int i=1;str[i];i++,u=u-!!u) if(str[i]!=s[u]) return str[i]<s[u];return false;}
int answer(int u)
{
	if(!u) return 0;
	if(cmp_s(u)) return answer(ch[u][0]);
	else return answer(ch[u][1])+siz[ch[u][0]]+1;
}
void get_c(char s[],int mask)
{
	int len=strlen(s);
	for(int i=0;i<len;i++)
	{
		mask=(mask*131+i)%len;
		char t=s[i];
		s[i]=s[mask];
		s[mask]=t;
	}
}
char opt[7];
int main()
{
	int n,m,k,las=0;
	scanf("%d%s",&m,s+1);n=strlen(s+1);
	for(int i=1;i<=n;i++)
	insert(rt,i);
	for(int i=1;i<=m;i++)
	{
		scanf("%s",opt);
		if(opt[0]=='D'){scanf("%d",&k);while(k --> 0) erase(rt,n),n--;continue;}
		scanf("%s",str+1);
		get_c(str+1,las);
		int l=strlen(str+1);
		if(opt[0]=='A') for(int j=1;j<=l;j++) s[++n]=str[j],insert(rt,n);
		else if(opt[0]=='Q')
		{
			reverse(str+1,str+l+1);
			str[l+1]='Z'+1,str[l+2]='\0';
			int ans=answer(rt);
			str[l]--;
			ans-=answer(rt);
			printf("%d\n",ans);
			las^=ans;
		}
	}
	return 0;
}
//壓行?不存在的。

bzoj3682 Phorni

題意

維護一個 \(n\) 個元素的序列,每個元素是字符串 \(s\) 的一個后綴。

支持:在 \(s\) 開頭加一個字符,改變序列 \(a_i\) 表示的后綴位置,求序列 \([l,r]\) 元素中字典序最小的編號(如有相同輸出編號最小)。強制在線

第一眼:這不是**題嗎?直接SA就好了。

然后發現:強制在線

SA沒了,直接后綴平衡樹就好了。本來后綴平衡樹也被叫做“動態后綴數組”。

首先將串反過來,就變成了在末尾加字符,查詢前綴。

用平衡樹維護各前綴之間的相對位置,然后用一顆線段樹處理 \(n\) 個前綴的相對權值即可。

注意這里這個相對權值的精度要求可能比較高,直接比較可能把兩個相同的前綴看做不同的。需要特判該情況。

復雜度 \(O(n\log n)\)

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define N 2000010
#define db double
#define alp 0.74
#define MAXD 1e16
using namespace std;
char s[N];
db v[N];
int siz[N],ch[N][2];
void upd(int u){siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;}
int ton[N],td;
void dfs(int u)
{
	if(!u) return;
	dfs(ch[u][0]),ton[++td]=u,dfs(ch[u][1]);
	ch[u][0]=ch[u][1]=0;
}
void bld(int &u,int l,int r,db lf=0,db rf=MAXD)
{
	if(l>r) return;
	int mid=(l+r)>>1;
	u=ton[mid];v[u]=(lf+rf)/2;
	bld(ch[u][0],l,mid-1,lf,v[u]),bld(ch[u][1],mid+1,r,v[u],rf);upd(u);
}
void reb(int &u,db lf,db rf)
{
	if(max(siz[ch[u][0]],siz[ch[u][1]])<=siz[u]*alp) return;
	td=0;dfs(u);
	bld(u,1,td,lf,rf);
}
int cmp(int x,int y){return s[x]==s[y]?v[x-1]<v[y-1]:s[x]<s[y];}
void insert(int &u,int k,db lf=0,db rf=MAXD)
{
	if(!u){siz[u=k]=1;v[u]=(lf+rf)/2;ch[u][0]=ch[u][1]=0;return ;}
	if(cmp(k,u)) insert(ch[u][0],k,lf,v[u]);
	else insert(ch[u][1],k,v[u],rf);
	upd(u);reb(u,lf,rf);
}
int val[N<<2];
int p[N];
void update(int u){val[u]=p[val[u<<1]]==p[val[u<<1|1]]?min(val[u<<1],val[u<<1|1]):(v[p[val[u<<1]]]<v[p[val[u<<1|1]]]?val[u<<1]:val[u<<1|1]);}
void build(int u,int l,int r)
{
	if(l==r){val[u]=l;return;}
	int mid=(l+r)>>1;
	build(u<<1,l,mid),build(u<<1|1,mid+1,r);
	update(u);
}
void change(int u,int l,int r,int k)
{
	if(l==r) return;
	int mid=(l+r)>>1;
	if(k<=mid) change(u<<1,l,mid,k);
	else change(u<<1|1,mid+1,r,k);
	update(u);
}
int qry(int u,int l,int r,int L,int R)
{
	if(L<=l && r<=R) return val[u];
	int mid=(l+r)>>1,w1=0,w2=0;
	if(L<=mid) w1=qry(u<<1,l,mid,L,R);
	if(R>mid) w2=qry(u<<1|1,mid+1,r,L,R);
	if(!w1 || !w2) return w1+w2;
	return p[w1]==p[w2]?min(w1,w2):(v[p[w1]]<v[p[w2]]?w1:w2);
}
char opt[3];
int main()
{
	int n,m,l,T,rt=0;
	scanf("%d%d%d%d",&n,&m,&l,&T);
	scanf("%s",s+1);
	reverse(s+1,s+l+1);
	for(int i=1;i<=l;i++) insert(rt,i);
	for(int i=1;i<=n;i++) scanf("%d",&p[i]);
	build(1,1,n);
	int las=0;
	for(int i=1;i<=m;i++)
	{
		int v,w;
		scanf("%s%d",opt+1,&v);
		if(opt[1]=='I')
		{
			v^=las*T;
			s[++l]='a'+v;
			insert(rt,l);
		}
		else if(opt[1]=='C')
		{
			scanf("%d",&w);
			p[v]=w;
			change(1,1,n,v);
		}
		else
		{
			scanf("%d",&w);
			printf("%d\n",las=qry(1,1,n,v,w));
		}
	}
	return 0;
}

3.SA-IS

這里

4.回文樹

這玩意似乎比后綴自動機什么的性質好很多。

幾個顯然的性質:

  1. 一個回文串的回文前綴同時也是它的回文后綴。
  2. 一個長度大於 \(2\) 回文串同時刪去首字符和尾字符后仍然是一個回文串。

類比后綴樹,我們現在想要得到一顆樹來表示字符串 \(s\),每個節點 \(u\) 表示互不相同的字符串 \(S_u\),字符串的每個回文子串都被恰好一個 \(S_u\) 表示。並且使得每條連接 \((u,v)\) 樹邊 \(\texttt{c}\) 滿足 \(S_v=\texttt{c}S_u\texttt{c}\)

當然奇回文串和偶回文串不能互相到達,所以這里其實有兩顆樹。特別的奇根表示的字符串長度為 \(-1\)。由於每個節點表示的字符串兩兩不同,所以回文樹的節點數等於當前字符串的本質不同回文子串數量

接下來我們假設這個回文樹最后一次插入后位於 \(u\),想要擴展一個字符 \(\texttt{c}\)。可以發現,\(u\) 存的其實是當前字符串的最長回文后綴。

我們意外發現這個操作和 \(\text{KMP}\) 中求 \(\text{next}\) 數組很類似。不過一個是向后匹配,一個是向前匹配。

這啟發我們:不斷找到 \(S_u\) 的最長回文后綴 \(S_v\),直到 \(S_v\) 上一位字符為 \(\texttt{c}\)。如果 \(v\) 不存在一條 \(\texttt{c}\) 的樹邊鏈接的點 \(x\),我們就新建一個點 \(x\),令 \(S_x=\texttt{c}S_v\texttt{c}\)

接下來我們需要證明不存在一個新的回文子串沒有被表示。考慮使用反證法,假設存在一個后綴 \(S'\) 沒有被表示:

  1. \(|S'|<|\texttt{c}S_v\texttt{c}|\) ,由於新增的回文子串必然是原字符串的后綴,所以有 \(S'\)\(\texttt{c}S_v\texttt{c}\) 的后綴。由性質 \(1\) 可知 \(S'\) 必然也是 \(\texttt{c}S_v\texttt{c}\) 的前綴。由於\(|S'|\neq|\texttt{c}S_v\texttt{c}|\)\(S'\) 必然是 \(\texttt{c}S_v\) 的前綴,所以 \(S'\) 並不是一個新的回文子串,矛盾。
  2. \(|S'|>|\texttt{c}S_v\texttt{c}|\),由於 \(|S'|\) 首尾字母都是 \(\texttt{c}\),那么存在一個 \(S_p\) 滿足 \(|S_p|>|S_v|\)\(S'=\texttt{c}S_p\texttt{c}\)。那么 \(S_v\) 不是 \(S_u\) 的最長回文后綴,矛盾。

由此也可以知道一個字符串 \(S\) 的本質不同回文串數量 \(\leq|S|\)

那么我們怎么求出 \(S_u\) 的最長回文后綴呢?我們考慮在加入 \(u\) 的時候就處理出最長回文后綴 \(P_u\)

我們假設加入 \(u\) 時的字符串為 \(\texttt{c}S_v\texttt{c}\)。如果存在最長回文后綴,那么其首字母必然也是 \(\texttt{c}\)。那么我們仿照上面的思路,不斷跳 \(S_v\) 的最長回文后綴直到其上一位字符為 \(\texttt{c}\)。那么這個就是最長回文后綴。

那么為什么它的復雜度是正確的?它的瓶頸在於跳最長回文后綴,而這個步驟本質和 \(\texttt{KMP}\) 的步驟是基本相同的。一個感性的證明是,每跳一次最長回文后綴,后續的所有插入操作的長度至少 \(-1\),而每加入一個字符,后續插入的操作長度至多 \(+1\)。故總復雜度仍是 \(O(n)\)

代碼:

int get_nxt(int u){while(s[n-len[u]-1]!=s[n]) u=nxt[u];return u;}
int insert(int c)
{
	int u=get_nxt(las);
	if(!ch[u][c])
	{
		nxt[++tot]=ch[get_nxt(nxt[u])][c];
		len[tot]=len[u]+2;
		ch[u][c]=tot;
	}
	return ch[u][c];
}

拓展1:廣義回文樹

后綴自動機可以擴展到 \(\text{Trie}\) 上,那回文樹是不是也可以?

然而遺憾的是,直接擴展回文樹的復雜度是錯誤的,復雜度是 \(O(\sum{|s_i|})\),即所有字符串長度之和。不過雖然不能處理 \(\text{Trie}\) 樹的問題,但是單純的多串問題,復雜度仍然是對的。具體的做法基本同廣義后綴自動機。

但是參考\(\text{AC}\)自動機的思路,我們能否直接處理出每個回文串最長的回文后綴 \(link[c]\) 使得其前面的字符為 \(c\)。答案是可行的,不過這樣時間復雜度就變為 \(O(n\Sigma)\)

同樣,這樣處理的回文樹也是支持后刪。

拓展2:雙向回文樹

考慮回文樹如何支持前加。首先當然要記錄最長回文前綴對應的位置。

可以發現,由於回文串的回文前綴等於回文后綴,所以型如 \(\texttt{c}S_v\) 的后綴一定對應型如 \(S_v\texttt{c}\) 的前綴,可以直接用拓展 1 中的 \(link[c]\)。甚至更新都與拓展 1 中的部分幾乎一樣。

特別的,對於前加字符時,如果最后得到的最長回文前綴是整個串,那么需要同時更新最長回文后綴對應的位置。對於后加時同理。

復雜度仍是 \(O(n\Sigma)\)

5.Lyndon&Runs

由於篇幅過長,移至 Lyndon Word&Runs 學習筆記

6.KMP自動機與 border tree

咕咕咕。

7.隱式后綴樹

暫時移到這里,密碼可以猜一猜或者線下問我。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM