从SA入门到SAM精通

SA

基本应用

读入一个长度为 $ n $ 的由大小写英文字母或数字组成的字符串,请把这个字符串的所有非空后缀按字典序(用 ASCII 数值比较)从小到大排序。

解法

1.将每个后缀取出来,直接排序 \(O(n^2 \log n)\)
2.用hash二分LCP比较下一位,\(O(n \log^2 n)\)
3.倍增求后缀数组,\(O(n \log n)\)
4.高级方法求后缀数组,\(O(n)\)

倍增

先比较每个后缀的第一位,再比较前两位,前四位...
问题在于如何快速比较前两位,前四位。
一个有趣的性质是在比较\(2^k\)位时,我们知道\(2^{k-1}\)位的大小,所以\(2^k\)位的大小只与前一半\(2^{k-1}\)和后一半\(2^{k-1}\)有关,所以可以用基数排序由上一层推到这一层。
image

基数排序

正常基数排序,是按数位从高到低依次比较大小,比如说三位数,就先比较百位的数字,将百位为 \(0\) 的放在一起,将百位为 \(1\) 的放在一起...。然后,对十位进行比较,在百位为 \(0\) 的里面把十位为 \(0\) 的放在一起,十位为 \(1\) 的放在一起...,最后所有数都有序。
SA的基数排序,就是相当于只有两位数来排序。

代码实现

代码比较抽象要多理解,多思考

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,sa[N],rk[N],x[N],y[N],cnt,num;
char s[N];
void SA()
{
	for(int i=1;i<=n;i++)rk[x[i]=s[i]]++;//rk辅助数组,x是上一层的排名
	for(int i=1;i<=m;i++)rk[i]+=rk[i-1];
	for(int i=n;i>=1;i--)sa[rk[x[i]]--]=i;//正序倒序都可以,sa是排名为i的后缀的起始下标
	for(int k=1;k<=n;k<<=1)
	{
		cnt=0;
		for(int i=n-k+1;i<=n;i++)y[++cnt]=i;//没有后一半是最强的,最靠前的
		for(int i=1;i<=n;i++)if(sa[i]>k)y[++cnt]=sa[i]-k;//如果可以做后一半,就做
		//正序枚举,因为y的顺序是后一半从小到大的顺序
		for(int i=1;i<=m;i++)rk[i]=0;//清零
		for(int i=1;i<=n;i++)rk[x[i]]++;//根据前一半
		for(int i=1;i<=m;i++)rk[i]+=rk[i-1];
		for(int i=n;i>=1;i--)sa[rk[x[y[i]]]--]=y[i],y[i]=0;//后一半更大的在前一半相同时排后面
		swap(x,y);//y临时存一下上一层x的值。
		x[sa[1]]=1,num=1;
		for(int i=2;i<=n;i++)
		{
			x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;//确定这一层的排名
		}
		if(num==n)break;//分完了
		m=num;
	} 
    for(int i=1;i<=n;i++)cout<<sa[i]<<' ';
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
    cin>>s+1;
    n=strlen(s+1),m=150;
    SA();
	return 0;
} 

进阶应用

LCP与hieght数组

LCP:最长公共前缀,\(lcp(i,j)\) 表示字符串 \(i\) 与字符串 \(j\) 的最长公共前缀。
思考题:如果一直\(sa\) 数组如何快速求任意两个后缀的最长公共前缀。(想不出来是正常的不然还有什么讲的必要)
要先引入一个新数组 \(height\),定义\(height[i]=lcp(sa[i],sa[i-1])\),现在先想如何求 \(height\) 数组。感觉也不好求。
那我们先研究它的性质,我们可以发现 \(height[rk[i]-1]-1 \le height[rk[i]]\) (发现不了一点) 考虑如何证明。
image
我们先将从 \(i-1\)\(n\)\(k\)\(n\) 的后缀取出来.(\(rk[k]=rk[i-1]-1\))。图中的黄色部分就是它们的LCP,如果\(lcp\le 1\) 上面的式子显然成立,考虑大于 \(1\) 的情况。
image
我们可以去掉开头字母,则 \(k+1\)\(i\)的lcp为 \(height[rk[i]-1]-1\),所以\(height[i]\) 至少为 \(height[rk[i]-1]-1\),因为只有可能出现下图的情况或就是\(k+1\).
image
这样就可以继承前面的信息暴力改。

void geth()
{
    int kk=0;
    for(int i=1;i<=n;i++)x[sa[i]]=i;
    for(int i=1;i<=n;i++)
    {
    	if(x[i]==1)continue;
        if(kk)kk--;
        int j=sa[x[i]-1];
        while(i+kk<=n&&j+kk<=n&&s[i+kk]==s[j+kk])kk++;
        hi[x[i]]=kk;
   }
}
    

时间复杂度,\(k\) 不会超过 \(n\),最多加 \(2n\)\(n\) 所以时间复杂度是 \(O(n)\)
回到最开始的地方,现在一直 \(height\) 如何求任意后缀LCP,显然是它们的最小值(感性理解即可,不理解就是没有理解后缀数组多看几遍)。所以可以用st表维护最小值。

可重叠最长重复子串

\(height\)的最大值。
规定长度就是区间 \(height\) 的最小值的最大值:P2852

不可重叠最长重复子串

首先二分答案 \(x\), 对height数组进行分组,保证每一组最小都大于 \(x\)(有x的长度)
依次枚举每一组,记录下最大和最小长度,如果相减大于\(x\)(不重叠)那么可以更新答案。

本质不同子串

子串是后缀的前缀,后缀拍完序之后,每次新增的只有除LCP以外的子串。
所以总数为 \(\frac{n*(n+1)}{2}-\sum_{i=1}^{n}height[i]\) P2408

比较子串大小

求出两子串所属的后缀的LCP,如果LCP比长度大则一个是另一个的子串,所以按长度即可比较大小,如果不是,则直接根据 \(rk\) 比较大小。

更多

SAM

字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为 \(n\) 它的空间复杂度仅为 \(O(n)\), 时间复杂度\(O(n)\) 。(准确的所是\(O(n\log m)\),m为字符集大小。

标准定义:字符串 \(s\) 的 SAM 是一个接受 \(s\) 所有后缀的最小 DFA(确定性有限状态自动机)

前置

endpos

定义为字符串在一个 非空子串 \(s'\)\(s\) 中的 结束位置 构成的集合。

根据定义我们可以得出一些性质。

  1. 对于 \(s\) 的两个非空节点 \(a,b\) ( \(|a|\le |b|\) ) ,如果 \(endpos(a)=endpos(b)\)\(a\)\(b\) 的后缀 (同一位置出现了 \(a\)\(b\) ,且 \(|a|\le |b|\) )。

  2. 如果 \(a\)\(b\) 的后缀,\(endpos(b)\subseteq endpos(a)\) (所以 \(b\) 出现时 \(a\) 都会出现,但是 \(a\) 出现 \(b\) 不一定出现)。相反如果不是,则\(endpos(a) \cap endpos(b)=∅\)

有上面两条性质,我们可以推出,如果将 \(endpos\) 相同的放在一起叫一个 \(endpos\) 等价类,我们可以发现

  1. 同一个 \(endpos\) 等价类中的字符串的长度不相同且连续。

parent 树

因为只有包含或不交的东西(性质 \(2\) ),我们可以把它建成树的样子。

所以将一个 \(endpos\) 等价类当成一个节点,我们可以根据包含关系,建出树,这棵树就是 \(parent\) 树。树上的父亲就是最长的不属于的后缀。

\(parent\) 树上跳父亲,相当于在子串前面删一些字符 (不一定只有一个) 。

SAM 的构造

$parent $ 树已经接受了所有子串,我们用树上的节点来建 SAM 。我们现在可以实现在前面删字符,还要实现在后面加字符。这个我们可以仿照 \(tire\) 将加一个字符的转移到的节点记下来,来实现向后加数。

我们考虑新加入一个字符,我们肯定要新建一个点(整个串一定是第一次出现)。我们要记录上一个前缀节点 \(lst\) 。从这个点开始跳父亲(在前面删字符即取后缀),如果这个点没有这个字符的出边,就新建一条边。如果遇到了,就停下来,设沿这条边到的节点是 \(q\) 。我们要看 \(q\)\(endpos\) 中的字符串是不是全是 \(lst\) 的 后缀,如果是就直接将新节点的父亲指向它,否则我们发现这个 \(endpos\) 中的字符串结束位置出现了不同,有一些出现了结尾的结束位置,有的没有。所以我们将 \(q\) 拆成两个点,将新点指向拆出来的只有后缀的点。怎么判断后缀?本节点的最大长度是否是上一个节点的最大长度加一(因为是长度递减地找,长度更长的串一定不是,是的话就不会在这找到,会先找到)。

void insert(int c)
{
	int p=las,np=las=++tot;
	sam[np].len=sam[p].len+1;
	for(;!sam[p].ch[c]&&p;p=sam[p].fa)sam[p].ch[c]=np;
	if(!p){sam[np].fa=1;return;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;sam[p].ch[c]==q&&p;p=sam[p].fa)sam[p].ch[c]=nq;
}

SAM的点数最多大约为为 \(O(2n-1)\) (考虑一个有 \(n\) 个的二叉树),边数最多也是 \(O(n)\) (字符集大小是常数 ,如果字符集过大,可以开 \(map\) ) 。构建的时间复杂度是 \(O(n)\) 。(我也没太懂,可以看这里具体证明

广义SAM

定义多个字符串 \(s_1,s_2,...,s_n\) 的 SAM 是一个接受 \(s_1,s_2,...,s_n\) 所有后缀的最小 DFA。

有不少的假做法,我们直接讲正确的做法。

有两种,一种是无需特判的 离线BFS ,一种是加特判的在线做法(我还不会,先咕了)。

离线做法

资料1

我们可以先对所有的串建出一颗 \(Tire\) 树,然后遍历这颗 \(Trie\) 建 SAM ,建都是一样的建,所以建的东西一定是可以接受所有后缀的。然后又由于 \(Tire\) 自己压缩了很多重复的东西,所以复杂度也是对的。

可以证明复杂度为 \(O(tire树节点数)\)

void insert()
{
	int len=strlen(s+1),u=1;
	for(int i=1;i<=len;i++)
	{
		int c=s[i]-'a';
		if(!sh[u][c])sh[u][c]=++cnt,fa[cnt]=u,z[cnt]=c;
		u=sh[u][c];
	}
}//建出trie树
void bfs()
{
	for(int i=0;i<26;i++)if(sh[1][i])q.push(sh[1][i]);
	pos[1]=1;//pos记录对应SAM上的点的编号
	while(!q.empty())
	{
		int u=q.front();q.pop();
		pos[u]=add(z[u],pos[fa[u]]);
		for(int i=0;i<26;i++)if(sh[u][i])q.push(sh[u][i]);
	} 
} 

SAM部分是一样的,唯一的区别是要返回新建的节点(要存下来,不能直接用 \(lst\)) 。

基本应用

本质不同子串个数

思路

  1. 因为SAM的每一条路径对应一个子串,等价于DAG求路径数,dp。
  2. 将每个点上的字符串相加,节点 \(x\) 的字符串数是 \(len_x-len_{fa_x}\) (父亲的长度是节点中最短的长度减一)

题目

P2408 不同子串个数

板子题。

按照上面的思路实现即可。

#include<bits/stdc++.h>

using namespace std;
using i64=long long;

constexpr int maxn=1e5+10;
char s;
int n,tot,lst,tr[maxn<<1][26],lnk[maxn<<1],len[maxn<<1];
i64 ans;

void ins(int c){
	int p=lst;int nw=lst=++tot;
	len[nw]=len[p]+1;
	for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
	if(!p) lnk[nw]=1;
	else{
		int q=tr[p][c];
		if(len[p]+1==len[q]) lnk[nw]=q;
		else{
			int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
			lnk[nw]=nq;lnk[nq]=lnk[q];lnk[q]=nq;
			for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
		}
	}
}

int main(){
	ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
	cin>>n;tot=lst=1;for(int i=1;i<=n;++i){ cin>>s;ins(s-'a');}
	for(int i=1;i<=tot;++i) ans+=len[i]-len[lnk[i]];
	cout<<ans<<endl;
	return 0;
}
SP7258 SUBLEX - Lexicographical Substring Search - 洛谷

变成了本质不同的第 \(k\) 小的字符串。

相同的,等价于求 DAG 路径的第 \(k\) 小,做一个 dp 即可。(具体地,记录每个点有多少条路径,然后从起点贪心的走)

#include<bits/stdc++.h>

using namespace std;
using i64=long long;

constexpr int maxn=5e5+10;
int n,t,k,tot,lst,tr[maxn<<1][26],len[maxn<<1],lnk[maxn<<1],siz[maxn<<1];
char s[maxn];
bool vis[maxn<<1];
i64 f[maxn<<1];

void ins(int c){
	int p=lst;int nw=lst=++tot;
	len[nw]=len[p]+1;
	for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
	if(!p) lnk[nw]=1;
	else{
		int q=tr[p][c];
		if(len[q]==len[p]+1) lnk[nw]=q;
		else{
			int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
			lnk[nq]=lnk[q];lnk[q]=nq;lnk[nw]=nq;
			for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
		}
	}
}

void dfs(int u){
	if(vis[u]) return;vis[u]=true;
	for(int x,i=0;i<26;++i) if(x=tr[u][i]){ dfs(x);f[u]+=f[x];}
}

void solve(int u,int s){
	if(s<=siz[u]) return;
	s-=siz[u];
	for(int i=0;i<26;++i){
		int x=tr[u][i];
		if(!x) continue;
		else if(s>f[x]) s-=f[x];
		else{ cout<<(char)(i+'a');solve(x,s);return;}
	}
}

int main(){
	ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
	cin>>(s+1)>>t;n=strlen(s+1);tot=lst=1;
	
	for(int i=1;i<=n;++i) ins(s[i]-'a');
	
	for(int i=1;i<=tot;++i) f[i]=siz[i]=1;
	siz[1]=f[1]=0;dfs(1);
    while(t--){
        cin>>k;
    	if(f[1]<k){ cout<<"-1"<<endl;continue;}
    	solve(1,k);cout<<endl;
    }
	return 0;
}
P3975 [TJOI2015] 弦论

上一道题的加强版。

我们只需要解决不同位置算多个的方案数。思考求本质不同答案时,每个点初始化为 \(1\) , 相当于到达一个点后就至少一种本质不同字符串,在加上走一条边到达的字符串。现在,我们可以初始化为每个点的 \(endpos\) 集合大小,表示到了这个点就有至少这么多个字符串。

而每个点的 \(endpos\) 大小怎么求? 将所有前缀节点赋为 \(1\) ,子树求和。

#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N=2e6+10;
struct node
{
	int len,siz,fa,ch[30];
}sam[N];
int tot=1,t,k,lst=1;
ll f[N];
char s[N];
vector<int> ve[N];
void insert(int c)
{
	int np=++tot,p=lst;lst=np;
	sam[np].len=sam[p].len+1;sam[np].siz=1;
	while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
	if(!p){sam[np].fa=1;}
	else
	{
		int q=sam[p].ch[c];
		if(sam[q].len==sam[p].len+1)sam[np].fa=q;
		else
		{
			int nq=++tot;
			sam[nq]=sam[q];sam[nq].siz=0;sam[nq].len=sam[p].len+1;
			sam[np].fa=sam[q].fa=nq;
			while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
		}
	}
}
void dfs1(int u)
{
	int len=ve[u].size();
	for(int i=0;i<len;i++)
	{
		int v=ve[u][i];
		dfs1(v),sam[u].siz+=sam[v].siz;
	}
	
} 
void dfs(int u)
{
	if(f[u])return;
	f[u]=t?sam[u].siz:1;
	for(int i=0;i<26;i++)
	{
		int v=sam[u].ch[i];
		if(!v)continue;
		dfs(v);
		f[u]+=f[v];
	}
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>(s+1);
	int len=strlen(s+1);
	for(int i=1;i<=len;i++)insert(s[i]-'a');
	//建sam 
	for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);//将parent树建出来
	dfs1(1); 
	//预处理
	cin>>t>>k;k+=t?len:1;
	dfs(1);
	if(k>f[1]){cout<<"-1"<<'\n';return 0;}
	int u=1;
	while(1)
	{
		int z=t?sam[u].siz:1;
		if((k-=z)<=0)break;
		for(int i=0;i<26;i++)
		{
			int v=sam[u].ch[i];
			if(!v)continue;
			if(f[v]<k)k-=f[v]; 
			else 
			{
				cout<<char(i+'a');
				u=v;break;	
			}
		}
	}	
	return 0;
 } 
P4070 [SDOI2016]生成魔咒 - 洛谷

我们发现它就是要求本质不同字符串,唯一的不同是每加入一个就要输出答案,所以不能每次都在 DAG上跑或每次重新建树求答案,所以我们可以考虑维护答案的变化量。我们可以用对每一个节点求个数的方案来理解,每一次新建节点的时候都暴力维护贡献的变化。(减去旧的加上新的)

#include <bits/stdc++.h>
using namespace std;
const int N=2e6+10;
int n,m,k,cnt=1,las=1,num,tot,hd[N],go[N],nxt[N];
char s[N];
#define ll long long
ll zhi[N],ans=0; 
void insert(int x,int y)
{
    nxt[++tot]=hd[x];go[tot]=y;hd[x]=tot;
    return ;
}
struct node
{
    int fa,len;
    map<int,int> ch;
}dian[N];
void add(int c)//char?
{
    int p=las,np=las=++cnt;zhi[cnt]=1;
    dian[np].len=dian[p].len+1;
    for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;
    if(!p)dian[np].fa=1,ans+=(dian[np].len-dian[1].len);//1kaishi
    else
    {
        int q=dian[p].ch[c];
        if(dian[q].len==dian[p].len+1)dian[np].fa=q,ans+=(dian[np].len-dian[dian[np].fa].len);
        else
        {
            int nq=++cnt;
            dian[nq]=dian[q];dian[nq].len=dian[p].len+1;
            ans+=(dian[dian[q].fa].len-dian[q].len);
            ans+=(dian[nq].len-dian[dian[nq].fa].len);
			dian[np].fa=dian[q].fa=nq;
			ans+=(dian[np].len-dian[dian[np].fa].len);
			ans+=(dian[q].len-dian[dian[q].fa].len);
            for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;
        }
    }
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
    cin>>n;
    for(int i=1;i<=n;i++)
	{
		int x;
		cin>>x,add(x),cout<<ans<<'\n';
	}
	return 0;
} 

中间那坨很吓人,而且很相似,我们仔细分析发现其实除了 \(3\) 以外的 \(3\) 个式子是可以消掉的,所以只有 \(np\) 点的有用。

其实这不是巧合,可以证明分裂节点不影响本质不同字符串数量只有新增才会影响。(可以感性理解分裂只是将本来有字符串分到两个点来计数)。

最小表示法

每次可以将做最左边的方块放在最右边,求出能操作出的最小字典序。

思路

显然是一个环的结构,所以断环为链(复制一边放在后面),那么答案就是这个字符串中长度为 \(n\) 的子串最小的字典序。

SAM每一条路径就是一个子串,所以贪心的选择最优走 \(n\) 次即可。

例题

P1368 【模板】最小表示法

板子题。

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10; 
struct node
{
	int len,c[40],fa;
}sam[N];
int n,k[N],las=1,tot=1;
void insert(int x)
{
	int p=las,u=++tot;
	sam[u].len=sam[las].len+1;las=u;
	while(p&&!sam[p].c[x])sam[p].c[x]=u,p=sam[p].fa;
	if(!p){sam[u].fa=1;return;}
	int q=sam[p].c[x];
	if(sam[q].len==sam[p].len+1)sam[u].fa=q;
	else
	{
		int nq=++tot;
		sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
		sam[u].fa=sam[q].fa=nq;
		while(p&&sam[p].c[x]==q)sam[p].c[x]=nq,p=sam[p].fa;
	}
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)cin>>k[i],k[i+n]=k[i];
	for(int i=1;i<=2*n;i++)insert(k[i]);
	int pos=1;
	for(int i=1;i<=n;i++)
	{
		for(int j=0;j<=30;j++)
			if(sam[pos].c[j])
			{
				pos=sam[pos].c[j];
				cout<<j<<" ";
				break;
			}
	}
	return 0;
 } 

求SAM上两个节点对应的串的最长公共后缀

比较重要的性质,但是也比较简单。

思路

因为对于一个节点到根的路径上的点都是后缀,所以两个的公共后缀就是树上 \(lca\) 的最长串。

求一个串在区间 \([l,r]\) 中的出现次数

引出一个和SAM比较常结合的算法,(可持久化)线段树合并。

思路

比较显然的是,这个东西明显就是找到这个串的 \(endpos\) 然后求在 \(l,r\) 之间的有多少个。

刚才我们做了一个求 \(endpos\) 的大小的题,这一次我们不仅要求个数,还要记录下位置,最后的答案是区间求某个东西。这让我们想起了线段树,我们可以对每一个点开一个线段树维护 \(endpos\) 是否包含 \(i\) 这个位置,询问就是区间求和。将前缀节点初始化,然后线段树合并上去就可以得到每一个点的信息。

同时,这种做法还可以维护每个串的最早出现次数,所有出现位置(修改线段树即可)。

求两个串的最长公共子串

思路

首先我们要建SAM,两个串都是一样,所以我们可以考虑随便选一个比如拿 \(1\) 来建 SAM 。建完以后呢?我们可以双指针扫第二个串,对于每一个 \(i\) 记录最长的长度 \(len\) 使第二个串的 \([i-len+1,i]\) 出现在第一个串中,然后所有值取最大即可。

正确性显然。具体是双指针这么做?其实就是加入一个字符串就考虑在SAM上转移,如果没有这条出边就跳 \(parent\) 树,一直到有为止。时间复杂度显然 \(O(n)\)

例题

SP1811 LCS - Longest Common Substring - 洛谷

板子题。

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
	int ch[30],len,fa;
}sam[N];
int ans,cur,le,tot=1,las=1; 
char s[N],t[N];
void insert(int c)
{
	int p=las,np=las=++tot;
	sam[np]=sam[p];sam[np].len=sam[p].len+1;
	while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
	if(!p){sam[np].fa=1;return ;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;} 
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
} 
void search(int c)
{
	if(sam[cur].ch[c])
	{
		cur=sam[cur].ch[c];le++;
		ans=max(ans,le); 
		return ;
	}
	while(cur&&!sam[cur].ch[c]) cur=sam[cur].fa;
	if(!cur){cur=1,le=0;return ;}
	le=sam[cur].len+1;cur=sam[cur].ch[c];//注意向上跳了之后,长度取是节点的maxlen
	ans=max(ans,le); 
} 
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>(s+1)>>(t+1);
	int len=strlen(s+1);
	for(int i=1;i<=len;i++)insert(s[i]-'a');
	//build sam
	len=strlen(t+1);cur=1,le=0;
	for(int i=1;i<=len;i++)search(t[i]-'a');
	cout<<ans<<'\n'; 
	return 0;              
} 
SP1812 LCS2 - Longest Common Substring II - 洛谷

和上一道唯一的区别是不只两个字符串,但是我们可以延续两个字符串的做法。

将第一个字符串建SAM,一个一个扫剩下的字符串。但是答案不能在想上一道题一样得到,我们考虑在 SAM 上统计答案,对每个点维护最大匹配长度。

如何维护?首先每一个字符串求出在每个点的匹配长度,最后答案一定是这些长度取 \(\min\) 。然后就变成一个串如何求? 首先一个点能匹配到,它的 \(parent\) 树上的祖先就都能被匹配到,所以子树答案取 \(\max\) 再与自身长度取 \(\min\) 。所以在每一次经过一个节点时就取 \(\max\) 当初始化,最后 dfs 一遍即可。

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
	int ch[30],len,fa;
}sam[N];
int cur,le,tot=1,las=1,f[N],ans[N]; 
char s[N],t[N];
vector<int> ve[N];
void insert(int c)
{
	int p=las,np=las=++tot;
	sam[np]=sam[p];sam[np].len=sam[p].len+1;
	while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
	if(!p){sam[np].fa=1;return ;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;} 
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
} 
void search(int c)
{
	if(sam[cur].ch[c])
	{
		le++;cur=sam[cur].ch[c];
		f[cur]=max(f[cur],le);
		return ;
	}
	while(cur&&!sam[cur].ch[c]) cur=sam[cur].fa;
	if(!cur){cur=1,le=0;return ;}
	le=sam[cur].len+1;cur=sam[cur].ch[c];
	f[cur]=max(f[cur],le);
} 
void dfs(int u)
{
	int len=ve[u].size();
	for(int i=0;i<len;i++)
	{
		int v=ve[u][i];
		dfs(v);
		f[u]=max(f[u],f[v]);
	}
	ans[u]=min(ans[u],f[u]);
	return;
} 
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>(s+1);
	int len=strlen(s+1);
	for(int i=1;i<=len;i++)insert(s[i]-'a');
	for(int i=1;i<=tot;i++)ans[i]=sam[i].len,ve[sam[i].fa].push_back(i);//建树 
	//build sam
	while(cin>>(t+1))
	{
		len=strlen(t+1);cur=1,le=0;
		for(int i=1;i<=len;i++)search(t[i]-'a'); 
		dfs(1); 
        for(int i=1;i<=tot;i++)f[i]=0;
	}	
	cout<<*max_element(ans+1,ans+tot+1)<<'\n';
	return 0;              
} 

快速定位一个串在SAM对应的节点

先假设保证出现在 \(s\) 中。

将前缀节点记录。

然后倍增预处理出 \(k\) 级祖先,根据长度判断即可。

但是如果不一定出现,我们就可以先用刚才求公共子串的双指针,求出每一个 \(i\) 的最长匹配长度判断一下即可。

简单应用

到目前为止,你已经会基本使用SAM解决一些简单问题了。

接下来这有一些不那么一眼的题目。

P6640 [BJOI2020] 封印 - 洛谷

先回忆一下求两个串的最长公共子串怎么做?

这里两个串不同求 \(s\) 一个区间的答案,显然将 \(t\) 建成SAM。然后双指针扫出每个 \(i\) 的最长匹配。然后统计答案,发现显然对于分成两部分来统计,一部分是最长匹配会超出 \(l\) ,一段是不会超出,且这个分界点显然可以二分。所以将两部分的答案取 \(\max\) 即可。

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10; 
struct node
{
	int len,fa,ch[30];
}sam[N];
int q,ll[N],st[N][20],lg[N],las=1,tot=1;
char s[N],t[N];
void insert(int c)
{
	int p=las,np=las=++tot;
	sam[np].len=sam[p].len+1;
	while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
	if(!p){sam[np].fa=1;return ;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
}
int query(int l,int r)
{
	if(r<l)return 0;
	int k=lg[r-l+1];
	return max(st[l][k],st[r-(1<<k)+1][k]);
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>(s+1)>>(t+1)>>q;
	int len=strlen(t+1);
	for(int i=1;i<=len;i++)insert(t[i]-'a');
	len=strlen(s+1);
	int cur=1,le=0;
	lg[0]=-1;
	for(int i=1;i<=len;i++)
	{
		lg[i]=lg[i>>1]+1;
		int c=s[i]-'a';
		while(cur&&!sam[cur].ch[c])cur=sam[cur].fa,le=sam[cur].len;
		if(!cur)ll[i]=i-le,cur=1,le=0;
		else
		{
			cur=sam[cur].ch[c];le++;
			ll[i]=i-le;
		}
		st[i][0]=i-ll[i];
	}
	for(int k=1;k<20;k++)
		for(int i=1;i+(1<<k)-1<=len;i++)
			st[i][k]=max(st[i][k-1],st[i+(1<<(k-1))][k-1]);
	for(int i=1;i<=q;i++)
	{
		int lt,rt;cin>>lt>>rt;
		int l=lt,r=rt,jg;
		while(l<=r)
		{
			int mid=(l+r)>>1;
			if(ll[mid]<lt)jg=mid,l=mid+1;
			else r=mid-1;
		}
		cout<<max(jg-lt+1,query(jg+1,rt))<<'\n';
	}
	return 0;
 }

SP687 REPEATS - Repeats - 洛谷

题目虽然和子串有关,但是直接做SAM不好统计答案。

我们思考怎么求连续出现的相同的串?如果我们直接找答案串或重复出现的串都不好判断是否满足。

但是我们可以枚举答案串的border,有了border就可以算出答案串的长度。具体地,设border的长度为 \(l\) ,两个border重叠的长度是 \(len\) ,那么答案显然是 $\left \lfloor {l+len\over len} \right \rfloor $ 。(为了让答案最大,我们要让 \(l\) 尽量大,\(len\) 尽量小)

如果你不懂,可以看下面的例子自己试一下。

ABBAABBA
    ABBAABBA

还有怎么证这个串一定是重复出现的?

(1)ABBA|(2)ABBA
          (3) ABBA | (4)ABBA

根据 border 的定义可以知道,(1) 与 (3)、(2)与(4) 是相同的,又因为(2)和(3)代表的是同一段所以也是相同的。综上,这个字符串是由一个串连续重复出现构成的。

作为 border 的这两个子串一定在同一个 \(endpos\) 里 (因为它们完全相同),所以我们对每个 \(endpos\) 计算答案然后取最值。对于一个 \(endpos\) 的最大答案, \(l\) 就直接用最大值,错位的长度( \(j-i\) ) 要最小可以用线段树维护。然后线段树合并上去,就可在每个点上 \(O(1)\) 得到答案。总时间复杂度为 \(O(n\log n)\)

好像结束了,但是我还有一个问题,刚才的证明是两个子串有重叠,如果没有重叠不符合定义也会被统计到会影响答案吗?其实不会,错位的距离是用 $ j-i$ 来算的,如果距离大于 \(l\) 那么算出来答案是 \(1\) (显然是下界不影响真正的答案)。

#include  <bits/stdc++.h>
using namespace std;
const int N=2e6+10,inf=0x3f3f3f3f,M=1e5+5;
vector<int> ve[2*M];
char s[10];
int rt[2*M],n,tot=1,las=1,cnt;
struct node
{
	int l,r,ls,rs,mn;
	node(){l=r=-1;mn=inf;}
}sh[M*60];
struct stu
{
	int len,ch[30],fa;
}sam[2*M];
void update(int x)
{
	int lls=sh[x].ls,rrs=sh[x].rs;
	sh[x].mn=inf,sh[x].l=inf,sh[x].r=-1;
	if(lls)sh[x].l=sh[lls].l,sh[x].r=max(sh[x].r,sh[lls].r),sh[x].mn=min(sh[x].mn,sh[lls].mn);
	if(rrs)sh[x].r=sh[rrs].r,sh[x].l=min(sh[x].l,sh[rrs].l),sh[x].mn=min(sh[x].mn,sh[rrs].mn);
	if(lls&&rrs)sh[x].mn=min(sh[x].mn,sh[rrs].l-sh[lls].r); 
}
int modify(int x,int l,int r,int wz)
{
	if(!x) x=++cnt; 
	if(l==r)
	{
		sh[x].l=sh[x].r=l;
		return x;
	}
	int mid=(l+r)>>1;
	if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
	else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
	update(x);
	return x;
} 
int merge(int u,int v)
{
	if(!u||!v)return u+v;
	int x=++cnt;
	sh[x].ls=merge(sh[u].ls,sh[v].ls);
	sh[x].rs=merge(sh[u].rs,sh[v].rs);
	update(x);
	return x;
}
void insert(int c)
{
	int p=las,np=las=++tot;
	sam[np].len=sam[p].len+1;
	rt[np]=modify(rt[np],1,M,sam[np].len); 
	for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
	if(!p){sam[np].fa=1;return;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq; 
}
void dfs(int u)
{
	for(int v:ve[u])
	{
		dfs(v);
		rt[u]=merge(rt[u],rt[v]);
	}
}
void clear()
{
	memset(rt,0,sizeof(rt));
	for(int i=0;i<=cnt;i++)sh[i].l=sh[i].r=-1,sh[i].mn=inf,sh[i].ls=sh[i].rs=0;
	cnt=0;
	for(int i=0;i<=tot;i++)sam[i]=sam[0],ve[i].clear();
	las=tot=1;
}
void solve()
{
	clear();
	//注意清零
	cin>>n;
	for(int i=1;i<=n;i++){cin>>s;insert(s[0]-'a');}//先建sam
	for(int i=2;i<=tot;i++)
	{
		ve[sam[i].fa].push_back(i);//再建parent树 
	}
	dfs(1);//遍历树,做线段树合并
	int ans=0;
	for(int i=1;i<=tot;i++)
	{
		if(sh[rt[i]].mn==inf)continue;
		ans=max(ans,(sam[i].len+sh[rt[i]].mn)/sh[rt[i]].mn);//统计sam每个节点的答案 
	} 
	cout<<ans<<'\n';
	return ;
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int t;cin>>t;
	while(t--)solve();
	return 0;
}

SP8093 JZPGYZ - Sevenk Love Oimaster - 洛谷

简单题。

首先对所有模板串建广义SAM,对模板串的每一个前缀节点打上标记,\(parent\) 树上当前节点到根的链上是当前前缀的所有后缀。子串的定义就是所有前缀的后缀,所以找到查询串在SAM上的对应节点,求出子树内有多少种不同的标记,直接用线段树维护信息,用线段树合并将信息上传。

注意字符集,因为离线广义SAM让我写的很难受,所以当做只有小写字母来写的,其实题目没有说明不能直接这样做,可以用下一道的写法。

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct node
{
	int len,fa,ch[30];
}sam[N];
int n,m,tot=1,ctot,cur,cnt=1,ch[N][30],rt[N],pos[N];
vector<int> ve[N],wz[N];
char s[N];
struct stu
{
	int ls,rs,sum;
}sh[N*60];
void update(int x){sh[x].sum=sh[sh[x].ls].sum+sh[sh[x].rs].sum;}
int modify(int x,int l,int r,int wz)
{
	if(!x)x=++ctot;
	if(l==r)
	{
		sh[x].sum=1;
		return x;
	}
	int mid=(l+r)>>1;
	if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
	else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
	update(x);
	return x;
}
void ins(int c,int id)
{
	if(!ch[cur][c])ch[cur][c]=++cnt;
	cur=ch[cur][c]; 
	wz[cur].push_back(id);
};
int insert(int las,int c,int id)
{
	int p=las,np=++tot;
	sam[np].len=sam[p].len+1;
	for(int v:wz[id])rt[np]=modify(rt[np],1,n,v);
	for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
	if(!p){sam[np].fa=1;return np;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;} 
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq;
	return np;
}
queue<int> q;
void bfs()
{
	for(int i=0;i<26;i++)
	{
		int v=ch[1][i];
		if(v)q.push(v),pos[v]=insert(1,i,v); 
	}
	while(!q.empty())
	{
		int u=q.front();q.pop();
		for(int i=0;i<26;i++)
		{
			int v=ch[u][i];
			if(v)q.push(v),pos[v]=insert(pos[u],i,v); 
		} 
	}
}
int merge(int u,int v,int l,int r)
{
	if(!u||!v)return u+v;
	int x=++ctot;
	if(l==r)
	{
		sh[x].sum=sh[u].sum|sh[v].sum;
		return x;
	} 
	int mid=(l+r)>>1;
	sh[x].ls=merge(sh[u].ls,sh[v].ls,l,mid);
	sh[x].rs=merge(sh[u].rs,sh[v].rs,mid+1,r);
	update(x);
	return x;
}
void dfs(int u)
{
	for(int v:ve[u])
	{
		dfs(v);
		rt[u]=merge(rt[u],rt[v],1,n);
	}
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>s;
		int len=strlen(s);cur=1;
		for(int j=0;j<len;j++)ins(s[j]-'a',i);
	}
	bfs();
	for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);
	dfs(1);
	for(int i=1;i<=m;i++)
	{
		cin>>s;
		int len=strlen(s),u=1,flag=0;
		for(int j=0;j<len;j++)
		{
			int c=s[j]-'a';
			if(!sam[u].ch[c])flag=1;
			u=sam[u].ch[c];
		}
		if(flag) cout<<"0"<<'\n';
		else cout<<sh[rt[u]].sum<<'\n';
	}
	return 0;
 } 

P2336 SCOI2012 喵星球上的点名 - 洛谷

上一道题的加强版,我们发现第一小问与上一题完全一样,但是我们可以用一种不同的做法(也可以用上一题的做法),子树问题可以通过 dfs 序转换为区间问题,那么第一小问转化为区间问题后就是经典的区间数颜色问题,可以用莫队和扫描线等算法来做(我选择了扫描线,毕竟理论时间复杂度更优)。

而第二小问相当于对每个颜色求这种颜色对多少个区间有贡献,我们仍可以用扫描线解决。从左往右扫 \(r\) ,树状数组上所维护的数组 下标 \(i\) 上存的是扫到的有多少个区间左端点是 \(i\) ,遇到一个区间左端点 \(l\) 就将 \(l\) 的位置加一,遇到右端点就将对应的 \(l\) 减一。答案的更新就是,当前位置 \(i\) 的颜色的答案加上包含 \(i\) 的区间数减去包含这个颜色上一次出现的位置的区间数。求区间数就是树状数组求前缀和。

#include <bits/stdc++.h>
using namespace std;
const int N=5e5+10;
struct node
{
	int fi,se;
	node(){}
	node(int x,int y):fi(x),se(y){}
};
struct stu
{
	int fa,len;
	map<int,int> ch;
}sam[N];
int n,m,ans[N],las=1,tot=1,dfn[N],pre[N],dfnx,c[N],siz[N],sh[N],pp[N]; 
vector<int> ve[N];
vector<node> q1[N],q2[N];
int lowbit(int x){return x&(-x);} 
void modify(int x,int z){while(x<=tot){sh[x]+=z;x+=lowbit(x);}}
int query(int x){int sum=0;while(x){sum+=sh[x];x-=lowbit(x);}return sum;}
void insert(int x)
{
	int p=las,np=las=++tot;
	sam[np].len=sam[p].len+1;
	for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
	if(!p){sam[np].fa=1;return ;}
	int q=sam[p].ch[x];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
	return;
}
void dfs(int u)
{
	dfn[u]=++dfnx;pre[dfnx]=u;
	siz[u]=1;
	for(int v:ve[u])
	{
		dfs(v);
		siz[u]+=siz[v];
	}
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		int len;
		cin>>len;
		for(int j=1,x;j<=len;j++)cin>>x,insert(x),c[las]=i;
		insert(-1); 
		cin>>len; 
		for(int j=1,x;j<=len;j++)cin>>x,insert(x),c[las]=i;	
		insert(-1); 	 
	}
	for(int i=2;i<=tot;i++)ve[sam[i].fa].emplace_back(i);
	dfs(1);
	for(int i=1;i<=m;i++)
	{
		int len,flag=0,cur=1;cin>>len;
		for(int j=1,x;j<=len;j++)
		{
			cin>>x;
			if(!sam[cur].ch[x]){flag=1;}
			cur=sam[cur].ch[x];
		}
		if(!flag)//有对应节点 
		{
			int l=dfn[cur],r=dfn[cur]+siz[cur]-1;
			q1[r].emplace_back(node(l-1,i));
			q2[l].emplace_back(node(l,1));
			q2[r+1].emplace_back(node(l,-1));
		}
	}
	for(int i=1;i<=tot;i++)
	{
		int col=c[pre[i]];
		if(col)
		{
			modify(i,1);
			if(pp[col])modify(pp[col],-1);
			pp[col]=i;
		}
		int len=q1[i].size(); 
		for(int j=0;j<len;j++)
		{
			node qq=q1[i][j];
			ans[qq.se]=query(i)-query(qq.fi);		
		} 
	} 
	for(int i=1;i<=m;i++)cout<<ans[i]<<'\n',ans[i]=0;
	for(int i=1;i<=tot;i++)sh[i]=0,pp[i]=0;
	for(int i=1;i<=tot;i++)
	{
		for(node qq:q2[i])
		{
			if(qq.se==1)modify(qq.fi,1);
			else modify(qq.fi,-1);
		}
		int col=c[pre[i]];
		ans[col]+=query(i);
		if(pp[col])ans[col]-=query(pp[col]);
		pp[col]=i;
	}
	for(int i=1;i<=n;i++)cout<<ans[i]<<' ';
	return 0;
} 

P4081 [USACO17DEC] Standing Out from the Herd P - 洛谷

并没有前面几道难,一样的打标记如果一个节点的子树里有不只一种标记就不管,否则对这种标记得答案加一,所以只需简单的 dfs 即可。

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct stu
{
	int fa,len,ch[30];
}sam[N];
int n,m,tot=1,cur=1,cnt=1,col[N],c[N],ch[N][30],wz[N],ans[N],fa[N],z[N];
char s[N];
vector<int> ve[N];
int insert(int p,int x)
{
	int np=++tot;
	sam[np].len=sam[p].len+1;
	for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
	if(!p){sam[np].fa=1;return np;}
	int q=sam[p].ch[x];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
	return np;
}
void ins(int c,int x)
{
	int u=cur;
	if(!ch[u][c])ch[u][c]=++cnt,fa[cnt]=u,z[cnt]=c,col[cnt]=x;
	else if(col[ch[u][c]]!=x)col[ch[u][c]]=-1; 
	cur=u=ch[u][c];
}
queue<int> q;
void bfs()//新写法 
{
	wz[1]=1;
	for(int i=0;i<26;i++)
	{
		int j=ch[1][i];
		if(j)q.push(j),wz[j]=insert(1,i),c[wz[j]]=col[j];
	}
	while(!q.empty())
	{
		int u=q.front();q.pop();//cout<<u<<'\n';
		for(int i=0;i<26;i++)
		{
			int j=ch[u][i];
			if(j)q.push(j),wz[j]=insert(wz[u],i),c[wz[j]]=col[j];
		} 
	}	
}
void dfs(int u)
{
	for(int v:ve[u])
	{
		dfs(v);
		if(!c[u])c[u]=c[v];
		else if(c[u]!=c[v]) c[u]=-1;
	}
	if(c[u]!=-1)ans[c[u]]+=(sam[u].len-sam[sam[u].fa].len);
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)	
	{
		cin>>(s+1);cur=1;
		int len=strlen(s+1);
		for(int j=1;j<=len;j++)ins(s[j]-'a',i); 
	} 
	bfs();
	for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);
	dfs(1);
	for(int i=1;i<=n;i++)cout<<ans[i]<<'\n';
	return 0;
}

P4465 [国家集训队] JZPSTR - 洛谷

只会暴力,会单独写题解然后扔到 \(bitset\) 里面。

SAM做法比较复杂,块状链表加SAM,块内用SAM,块间用KMP。

P4094 [HEOI2016/TJOI2016] 字符串 - 洛谷

SAM擅长处理公共后缀,所以将整个字符串翻转求最长公共后缀。

答案显然有可二分性,所以我们二分这个公共后缀的长度 \(len\) ,问题就变成了给出字符串 \(s[d-len+1,d]\) 是否是 \(s[a,b]\) 的子串。

我们只需要提前记录,快速定位到 \(s[d-len+1,d]\) 在SAM上的位置,提前用线段树合并处理出 \(endpos\) 集合,在线段树上求出不超过 \(d\) 的最大的存在的值,然后只需要看这个值是否存在大于 \(a+len-1\) 即可。

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct stu
{
	int fa,len,ch[30];
}sam[N];
struct node
{
	int ls,rs,sum;
}sh[N*60];
int n,m,tot=1,las=1,cnt,zb[N],fa[N][21],rt[N];
char s[N];
vector<int> ve[N];
void insert(int x)
{
	int p=las,np=las=++tot;
	sam[np].len=sam[p].len+1;
	for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
	if(!p){sam[np].fa=1;return ;}
	int q=sam[p].ch[x];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[np].fa=sam[q].fa=nq;
	for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
	return;
}
void update(int x)
{
	int ls=sh[x].ls,rs=sh[x].rs;
	sh[x].sum=sh[ls].sum+sh[rs].sum;
	return ;
}
int modify(int x,int l,int r,int wz)
{
	if(!x)x=++cnt;
	if(l==r)
	{
		sh[x].sum+=sh[x].sum?0:1;
		return x;
	}	
	int mid=(l+r)>>1;
	if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
	else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
	update(x);
	return x;
}
int merge(int u,int v)
{
	if(!u||!v)return u+v;
	int x=++cnt;
	sh[x].ls=merge(sh[u].ls,sh[v].ls);
	sh[x].rs=merge(sh[u].rs,sh[v].rs);
	update(x);
	return x;
}
void dfs(int u)
{
//	cout<<fa[u][0]<<' '; 
	for(int i=1;i<=20;i++)
	{
		fa[u][i]=fa[fa[u][i-1]][i-1];
	//	cout<<fa[u][i]<<' ';
	}
//	cout<<'\n';
	for(int v:ve[u])
	{
	//	cout<<v<<'\n'; 
		dfs(v);
		rt[u]=merge(rt[u],rt[v]);
	}
}
int query2(int x,int l,int r)
{
	if(l==r)return l;
	int mid=(l+r)>>1;
	if(sh[sh[x].rs].sum)return query2(sh[x].rs,mid+1,r);
	else return query2(sh[x].ls,l,mid);
}
int query(int x,int l,int r,int lt,int rt)
{
	if(l>=lt&&r<=rt)return sh[x].sum?query2(x,l,r):0;
	int mid=(l+r)>>1,ans=0;
	if(lt<=mid)ans=max(ans,query(sh[x].ls,l,mid,lt,rt));
	if(rt>mid)ans=max(ans,query(sh[x].rs,mid+1,r,lt,rt));
	return ans;
} 
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m>>(s+1);
	for(int i=1;i<=n;i++)
	{
		insert(s[n-i+1]-'a');//翻转
		zb[i]=las;
		rt[las]=modify(rt[las],1,n,i);
	}
	//fa[1][0]=1;
	for(int i=2;i<=tot;i++)
	{
		fa[i][0]=sam[i].fa;
		ve[sam[i].fa].push_back(i);
	}
	//for(int i=2;i<=tot;i++)cout<<fa[i][0]<<'\n';
	dfs(1);
	for(int i=1;i<=m;i++)
	{
		int a,b,c,d;cin>>a>>b>>c>>d;
		a=n-a+1,b=n-b+1,c=n-c+1,d=n-d+1;swap(a,b);swap(c,d);
		int p=zb[d];
		for(int j=20;j>=0;j--)
		{
			int np=fa[p][j];//cout<<np<<'\n';
			if(!np)continue;
			int mxl=query(rt[np],1,n,1,b);
			if(sam[np].len>=mxl-a+1)p=np;
		}
		//cout<<p<<'\n';
		int mxl=query(rt[p],1,n,1,b)-a+1;
		cout<<min(max(mxl,sam[sam[p].fa].len),d-c+1)<<'\n';
	} 
	return 0;
 } 

P4022 [CTSC2012] 熟悉的文章 - 洛谷

首先我们在这看到了熟悉的模型,最小值最大,考虑二分这个最小值 \(len\)

再思考如何 \(check\) ,我们可以思考 dp,设 \(dp_i\) 表示处理 \(1-i\) 的字符串在满足长度限制的匹配最多选择多少长度,转移就是 \(f_i=max(f_{i-1},f_j+i-j,j\in [i-mx,i-len])\) 。( \(mx\) 就是 \(i\) 的最长匹配距离)。观察发现两个边界都单调不降,所以上单调队列优化即可。

现在问题就变成了求 \(mx\) ,发现这就是 P6640 [BJOI2020] 封印​,双指针做一下就好了。

#include <bits/stdc++.h>
using namespace std;
const int N=2e6+10;
struct node
{
	int ch[30],len,fa;
}sam[N];
int ch[N][30],pos[N],fa[N],col[N],dp[N],ll[N],tot=1,cnt=1,len,qq[N];
char s[N],t[N];
int insert(int c,int las)
{
	int p=las,np=++tot;
	sam[np].len=sam[las].len+1;
	for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
	if(!p){sam[np].fa=1;return np;}
	int q=sam[p].ch[c];
	if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;}
	int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
	sam[q].fa=sam[np].fa=nq;
	for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq;
	return np;
}
queue<int> q;
void bfs()
{
	for(int i=0;i<26;i++)if(ch[1][i])q.push(ch[1][i]);
	pos[1]=1;
	while(!q.empty())
	{
		int u=q.front();q.pop();
		pos[u]=insert(col[u],pos[fa[u]]);
		for(int i=0;i<26;i++)if(ch[u][i])q.push(ch[u][i]);
	}
}
bool check(int x)
{
	int h=1,t=0;
	for(int i=1;i<=len;i++)
	{
		dp[i]=dp[i-1]+1;
		if(i-x>=0)
		{
			while(t>=h&&dp[qq[t]]>=dp[i-x])t--;
			qq[++t]=i-x;
		}
		while(h<=t&&qq[h]<ll[i])h++;
		if(h<=t)dp[i]=min(dp[i],dp[qq[h]]);
		//if(x==5)cout<<dp[i]<<'\n';
	}
//	cout<<x<<" "<<dp[len]<<'\n';
	return dp[len]*10<=len;
}
void solve()
{
	cin>>(s+1);
	len=strlen(s+1);
	int cur=1,le=0;
	for(int i=1;i<=len;i++)
	{
		int c=s[i]-'0';
		while(!sam[cur].ch[c]&&cur)cur=sam[cur].fa,le=sam[cur].len;
		if(cur==0)cur=1,le=0;
		else{cur=sam[cur].ch[c],le++;}
		ll[i]=i-le;
	}//对于每个右端点预处理出可以匹配的左端点
	int l=1,r=len,ans=0;
	while(l<=r)
	{
		int mid=(l+r)>>1;
		if(check(mid))ans=mid,l=mid+1;
		else r=mid-1;
	}
	cout<<ans<<'\n';
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int n,m;cin>>n>>m;
	for(int i=1;i<=m;i++)
	{
		cin>>(t+1);
		len=strlen(t+1);int cur=1;
		for(int j=1;j<=len;j++)
		{
			int c=t[j]-'0';
			if(!ch[cur][c]) ch[cur][c]=++cnt,col[cnt]=c,fa[cnt]=cur;
			cur=ch[cur][c];
		}
	} 
	bfs();//建广义sam
	while(n--)solve();
	return 0;
}

困难模式

前面的题已经基本会用SAM,接下来是考验你ds和字符串基础的好时候。

遗留的问题

  1. 时间复杂度证明

  2. 在线广义SAM

  3. 叶子节点是否一定是前缀节点,前缀节点是否一定是叶子?(建分裂节点时就有两个儿子,前缀节点可以不是叶子(\(aba\)))

posted @ 2024-10-30 12:20  exCat  阅读(54)  评论(0)    收藏  举报