博主你怎么还不会后缀自动机啊
Part 0 后缀自动机
§ 0.1 SAM的由来
SAM是什么?
短点儿的答案:
SAM是把特定节点捏在一起的后缀树。
长点儿的答案:
你先别急,先定义个有用的东西:
终点集:一个长长的字符串有很多子串,一个子串可能会在母串不同的位置出现很多次,这些出现位置的终点的集合叫做终点集。某个子串
这个东西能引出来三个定理:
- 两个终点集相同一定可推出一个子串是另一个的后缀。
- 两个终点集要么包含,要么相离,不可能相交一部分。
- 对于一个终点集,这里面包含的所有子串的长度一定覆盖一个连续区间。
证明:
- 不然呢?对于一组终点来说,在这里结束的两个长度不同的串肯定有一方是另一方的后缀。
- 如果两个终点集有交,由上面的定理可得其中一个对应的子串必然为另一个的后缀,那么长点的那个一旦出现,短点的那个也会在同一位置上出现,不可能有某个位置仅为长串的终点而不是短串的终点。
- 对于一个子串而言,它的后缀的终点集大小不会小于他,因此得证。(也就是说如果长子串的某个后缀可以在别的地方有一个终点,那么比这个后缀更短的后缀也会在这个地方出现,不可能又回到一开始长子串的终点集)
根据上面的证明3,我们再定义一个比较有用的东西:后缀链接。
具体来说,对于一个子串,我们不断把它缩短(删去首字符),那么这个串的终点集大小一定是单调不降的,换句话说,去掉这个串的第一个字符时,终点集的大小要么不变要么变多。对于每一次集合大小的扩充,我们从之前的终点集画一条出边连到这个新的终点集。这条出边就叫做后缀链接。
他有这么两个性质:
- 所有后缀链接必定形成一棵树。
- 后缀链接形成的树一定也是终点集形成的树(母集合连向若干个不相交、全覆盖子集)。
第一点的证明很简单,因为后缀链接只从较小的终点集连向较大的终点集,所以一定会形成一棵树。后者就更不用证明了。
还有最后一个东西要定义:
我们把所有终点集完全相同的子串放在一起,称作一个后缀等价类。这是一个非常关键的概念(刚刚构建的那棵后缀链接树中的节点就是一个后缀等价类)。
现在来说说什么是SAM吧!
后缀自动机是一张图,每条边的边权是一个字符,节点中有一个初始状态,从这个点走出的任意一条路径都对应着原字符串的一个子串。并且SAM是满足上述条件的图中最小的一个。
看到这个条件,我们首先会想到后缀树对吧。毕竟一个子串就是一个后缀的一个前缀,后缀树上的一条根链就是后缀的一个前缀。但是后缀树上的一条边不一定是一个字符。
我们把压缩前的后缀树写出来,称呼他为“伪后缀树”,下文中的“后缀树”均指伪后缀树。
但是展开后我们面临了新的问题,伪后缀树不一定满足上面所说的最小性。
为什么呢?假如说有一个子串 A 和另一个子串 B,并且【这两个子串在伪后缀树上对应的子树】的结构是完全相同的,那么可以把这两个节点捏在一起,使他们共用同一个子树,而依然满足从根开始的任意一条路径都是一个子串的性质。
那么怎么样构建一个满足最小性的图呢?
容易想到一种思路,把伪后缀树的所有能捏的节点捏在一起,最后得到的DAG一定满足最小性。
证明:假如不满足最小性,那么就等于说有两个点,它们本来可以写为同一个点,但是分开了。考虑两点能写为同一个点的充要条件是从这两个点出发的所有路径都相同,而这样的两个点正好是可以捏到一起的。
那么这张DAG上的节点有什么意义呢?
考虑在原来的伪后缀树上,“从一个点
感性理解一下,“在原串上向后走”和“在伪后缀树上向下走”两个动作是一致的。
那么如果有两个节点
没错,从
【从
而这只对应了一种可能——
没错!融合后的节点正是一个后缀等价类!
这里注意一下,实际情况下可能有三个或者更多节点融合为一个。
进一步可以推出,所有能融合的节点融合完毕后,新图恰好包含了所有后缀等价类。
正好,我们可以把前面提到的“后缀链接树”叠加到这张图上,这样图里面有了两类边:来自伪后缀树的转移边,和来自后缀链接的后缀链接边。
这个东西就是SAM了!
每个点需要维护这些东西:
- 这个后缀等价类的最小、最长子串长度
和 . - 这个点在后缀链接树上的父子节点。
- 这个点的入边和出边。
并且还有四个性质:
- 对于SAM中的一条转移边
,一定有 . - 对于一条转移边
,【 所对应的终点集】整体向右移动一位后包含【 所对应的终点集】. - 沿着后缀链接树往上走,
递减,且 . - 仅有一个点入度为零,仅有一个点出度为零。
证明:
- 考虑【被捏到
里面的所有原后缀树点】组成的集合,一个【被捏进 里面的原后缀树点】一定是这个集合里某个点的儿子。所以对于 中的每一种子串长度, 中都一定有一个更长的与之对应。 - 一旦加了一个字符之后的字符串出现,那么少了一个的也必定出现。
- 根据后缀链接树构建方法易证。
- 根据构造方法易证。
顺便这里还要说一个可能没什么用,但对于理解转移有一定帮助的细节:
首先要明确后缀自动机上的一个点代表了什么:
一个后缀等价类、与此等价类对应的终点集、此等价类包含的一些长度连续的子串。然后我们考虑沿着一条转移边走一步在原串上的意义:
假如说我们从点
出发,沿着一条字符为 的转移边走到了点 ,那么在原串上我们相当于从【 所代表的终点集】中的某一个位置 出发,向右走一格后到达一个写着字符 的位置,而 节点对应的就是包含 这个串的后缀等价类。 考虑走到的这个点(不妨称其为
)对应的后缀等价类中,每一个子串都有唯一的一条路径与它对应,而这些路径中的每一条都能走到 $X 这个点。 那么我们可以进一步推得,
在转移图的所有前驱 的 组成的区间均不相交,并且恰好覆盖 这个区间。 最后,沿着后缀链接向根走相当于遍历一个串的后缀。
§ 0.2 SAM的构建
一般采用增量构建的方法来构造SAM,具体来说就是每次在原字符串后面新增一个字符,并对SAM进行调整。
假设我们新插入的字符为
如果是一棵后缀树的话,增量更新是简单的:对于每一个原串的每一个后缀所对应的点,在这里新增一个字符为
但如果是后缀自动机的话,那些需要添加新转移的点应该是满足【对应的终点集含有“原串尾”这个位置】的所有点。
这些点在哪里呢?在后缀链接树上【表示整个字符串的点】的根链里。
考虑到这一点,我们可以知道算法的前两步:
- 找到原先DAG中对应“整个原串”的那个节点
,在它后面添加一个字符为 的转移,链接到我们新建的一个状态(暂且叫他 )。顺便初始化 .
是添加新字符前原串的长度。
- 我们不断通过
的后缀链接往上跳,假如说跳到的这个点 还没有字符为 的转移,就直接连一条出边到 这个点,并令 .
注意此时还不能确定
指向哪里。
但是如果
称呼追加字符前的串为“原串”,追加字符后的字符串为“新串”。
因为我们是按照
设
为什么?因为我们可以把这个串分成两部分,前面是一个长度至少为
整理一下,我们知道
也就是说包含
然后再来考虑一下
顺理成章地,我们可以分出两种情况:
-
:这种情况下,我们知道 对应的终点集正好就是【 对应的终点集中的某一些位置】在原串上向右移动一个位置得到的。而既然都这样了,那么我们直接在
的终点集里面塞一个 也没有什么不妥。所以直接令 ,然后退出算法。为什么直接退出?因为这种情况下,
在后缀链接树上的祖先也一定都有一个字符为 的转移,连向 在后缀链接树上的某个祖先。也就用不着我们去更新了。 -
:根据前面的推导,现在不能直接把新的串放进去了,因为以 为终点时,后缀等价类中的串不能全部出现。(或者说, 的等价类中,长度属于 区间的字串多在结尾多出现了一遍)为了满足后缀等价类的定义,我们需要把
这个状态裂成 和 两半,具体来说,二者的出边完全相同,但是 改为如下量:然后我们令
,并遍历 在后缀链接树上的祖先,一旦有祖先有连向原来 的转移,就把这个转移重定向到 。最后退出算法。(其实遇到第一个ch[x][c]!=q
的x
时就可以退出了)为什么只改祖先的而不改其他的?
因为只有祖先节点所对应的终点集里面包含原串的串尾位置。
为什么需要把所有转移定向到
,直接把 作为小的那一半不好吗?这样就不用遍历祖先了。的确不行,因为根据转移边的性质,一定还有其它的节点转移到
,它们的 和 均比 要大。而我们又知道所有长度
的串在结尾出现了一遍,所以需要给他们单独设一个新的状态,并作为剩下的那一部分的祖先。而原先转移到 的其它状态,自然不能转移到新状态这里。所以才需要暴力遍历祖先。
以上就是线性构建SAM的全部步骤了。下面提供我的代码:
void ExtendSAM(int c)
{
int cur = ++tot;
int p = lst;
maxlen[cur] = maxlen[lst] + 1;
while(p!=-1 && ch[p][c] == 0) { ch[p][c] = cur; minlen[cur] = minlen[p] + 1; p = link[p]; }
if(p==-1) {link[cur] = 1;}
else
{
int q = ch[p][c];
if(maxlen[q] == maxlen[p] + 1) { link[cur] = q; }
else
{
int t = ++tot;
for(int i=0; i<26; i++) ch[t][i] = ch[q][i];
link[t] = link[q]; maxlen[t] = maxlen[p] + 1; minlen[t] = minlen[q]; minlen[q] = maxlen[p] + 2;
while(p!=-1 && ch[p][c] == q)
{
ch[p][c] = t;
p = link[p];
}
link[q] = link[cur] = t;
}
}
lst = cur;
return;
}
运行上述代码前记得初始化
link[1] = -1
.
等等,你说是线性的这就是线性的?我不信。
好,那我们证明一下增量构建SAM的总复杂度是
首先容易观察到,复杂度主要取决于以下四个操作:
- 新建
节点。 - 遍历
在后缀链接树上的祖先。 - 分裂状态
. - 分裂
之后,遍历状态 的祖先。
其中,操作
而操作
定义
这句话有点绕,具体来说就是
的值。
接下来,考虑到算法中
-
的情景是平凡的,此时一定有 . -
的情景中,上式变为
综上,
感性理解的话,我们每次检查的后缀都的左右端点都是单调向后移动的。
操作 4 还不会证,咕了。
§ 0.3 SAM的典型应用
判断是否出现
判断一个串
对
当然,在走的过程中,我们还求出了
快速定位子串
已知一个子串的始点、终点,快速找到它在后缀自动机上所属的等价类。
预处理出每一个前缀在自动机上对应的点,然后从这个点开始,在链接树上倍增跳祖先即可。
求任意子串出现次数
第一,对于一个后缀等价类,里面所有的字符串的出现次数都是相等的。
因此,我们只需要对于每一个后缀等价类计算其终点集大小。
第二,后缀链接树上任意一个节点的点终点集大小等于其所有子节点的终点集大小之和。
这一点可以用终点集的性质轻松证明。
因此,我们只需要对于后缀链接树上的每一个叶子结点
博主血的教训:不能一边构建一边统计这个东西。因为如果在构建的时候计算每个
的大小的话,每次插入都必须 更新完所有祖先的 大小。
求本质不同子串个数
有两种方法:
一:考虑到不可能有两个本质相同的字串出现在两个不同的后缀等价类中,所以每个节点所包含的本质不同子串数量为
二:一个子串相当于从根开始的一条路径,SAM是一张有向无环图,DP统计路径条数即可。
本质不同子串的总长
就是上面那一问加了点小小的修饰。
一:可以对于每个点求和
二:DP的时候记录一个“从该节点出发的所有路径的总长”,每个点的该值由对所有子节点求和【从此点出发的路径数目+从此点出发的路径总长】。
字典序第 大
仍然是上面那一问,做完之后直接搜就行了。
这都字典序了,还用啥SAM啊,直接写SA不就行了。
求首次出现位置
求一个串
每次增量更新的时候维护一个新的值
每次新建一个状态
因为状态
所包含所有子串都是 所包含的所有子串的后缀,而 中的子串第一次“单独出现”是在 这个位置(也就是 ),这个位置一定大于 ,所以可令 .
求所有出现位置
和上例一样,维护
接下来考虑到一个点【在后缀链接树上的子树】中的所有叶子结点必然给出【这个点的
求最短不出现子串
直接DP就可以了,每个状态记录一下从当前点开始最少要走几步才可以遇到一个不存在某字符转移的状态。
求最长公共子串
现在有两个字符串
首先,对
那我们从
设当前走到的点为
- 如果的确有这个转移,那就直接转移过去,令
. - 如果没有这个转移,就沿后缀链接向根走,同时令
. 如果走了一步之后还是没有对应的转移,那就不停往上走,直到找到转移或者走到虚拟状态 为止。
答案就是上述过程中出现的
最长公共子串Pro
给出
把所有
对形如
直接DP求就可以了,然后对于每一合法状态的
注意!这里的时间复杂度为
最长公共子串ProMax
给出
这个东西如果用刚刚的方法强行做的话可能会超时,我们需要广义后缀自动机。
§ 0.3 广义后缀自动机
所以现在有一棵字典树,我们要对这一棵字典树构建后缀自动机。
等等,对一棵字典树建立后缀自动机是什么意思?
是指构建一个包含了这个字典树中所有字符串的所有子串的后缀自动机。
考虑一个字符串本质上就是一棵字典树退化成链的结果,所以我们理应只需要一些小的修改就可以建出广义SAM。
回顾构建一个普通后缀自动机的过程,本质上是选出自动机中已经存在的某个串
那么对于一棵字典树,我们按照其BFS序依次将字符插入,每次插入前将
等等!为什么是BFS序而不是DFS序?二者不是都可以保证这个性质吗?
这涉及到时间复杂度的分析。我们翻回到普通SAM的复杂度分析部分,重新看一下这几个操作的复杂度:
操作
操作
操作
§ 0.4 更多性质
注意出现位置相同的一定在一个等价类里,一个等价类里的一定出现位置相同。
可以由此推出 CF700E 的关键结论
Part 1 后缀数组
虽然都说后缀自动机真包含后缀数组,但是显然后者处理某些问题更加好写。
所以这里简要介绍一下。
§ 1.1 线性求SA
后缀自动机的构建是
不过求是可以求的,也就20KB吧
好,讲完了。
§ 1.2 普通方法求SA
先说说后缀数组的基本定义:
把一个字符串
用后缀自动机确实也可以做到这一点,不过后缀自动机要带上一个
的常数,在字符集较大的情况下会受到限制。所以我们要学习后缀数组。
看上去直接强行排序的话时间复杂度不会很友善,我们用倍增法排序。
首先,假设我们已经排列好了每一个字符开始,长度为
这很简单,对于一个子串
注意,排序是否稳定无关紧要,即使每轮排序完毕后相同子串的顺序随机,也不会影响最终结果。因为在最后一次排序时必然不会有两子串(此时是后缀了)内容一致,所以对于每对可能相同的子串,总有一次倍增后两者不同。
注意到值域不超过序列长度,并且是双关键字排序,我们考虑用基数排序实现。
具体来说,先对第二关键字进行计数排序,再对第一关键字进行计数排序。
计数排序的过程是,对于每一个值
计算它出现了几次( ),然后计算 的前缀和 . 最后倒序遍历原数组,把 放进 这个位置,并令 .
不过这样常数过大,考虑优化。
观察到第一轮计数排序的结果本质上是把所有
表示 中排名第 大的 .
这里有一个节省时间的小窍门:因为排序不需要稳定,所以无视掉之前排好的顺序,直接令
表示已经按照第二关键字排好的子串始点。
接下来一步中的“按照原序放入”的意思是按照第二关键字的顺序把子串始点放入
int p = 0;
for(int i=n-len+1; i<=n; i++) tmpsa[++p] = i;
for(int i=1; i<=n; i++)
if(sa[i]>len) tmpsa[++p] = sa[i] - len;
//如果当前枚举到的sa[i]可以作为一个第二关键字的话,就把对应的始点塞进去
接下来进行第一关键字排序,这就比较简单了
memset(cnt,0,sizeof(cnt));
for(int i=1; i<=n; i++) cnt[rk[tmpsa[i]]]++;
//这里的rk[x]表示的仍然是“始点为x,长度为len的字符串在其他等长字符串中的排名”,即上一轮的rk
for(int i=1; i<=m; i++) cnt[i] += cnt[i-1]; //这里的m是值域,在前一轮已计算完毕
for(int i=n; i>=1; i--) sa[cnt[rk[tmpsa[i]]]--] = tmpsa[i];
//这两行就是正常的计数排序
memcpy(tmprk+1,rk+1,sizeof(rk)); p=0;
for(int i=1; i<=n; i++)
{
if(tmprk[sa[i]]==tmprk[sa[i-1]] && tmprk[sa[i]+len]==tmprk[sa[i-1]+len])
rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
//从小到大枚举已经排好序的子串,如果和上一个双关键字都相同的话排名不变。
m=p; //下一轮计数排序的值域。
当然这里有一些地方可以优化,比如说把 rk[tmpsa[i]]
单独存出来减少擦车丢失;比如最后确定新排名时用比较函数而不是直接比较,减少数组嵌套;再比如一旦我们发现某轮的最后存在 p==n
,就可以直接退出循环。
inline cmp(int x,int y,int w){return tmpsa[x] == tmpsa[y] && tmpsa[x+w]==tmpsa[y+w];}
//...
memset(cnt,0,sizeof(cnt));
for(int i=1; i<=n; i++) cnt[key1[i] = rk[tmpsa[i]]]++;
for(int i=1; i<=n; i++) cnt[i] += cnt[i-1];
for(int i=n; i>=1; i--) sa[cnt[key1[i]]--] = tmpsa[i];
memcpy(tmpsa+1,rk+1,sizeof(rk)); p=0; //节省空间
for(int i=1; i<=n; i++)
{
if(cmp(sa[i],sa[i-1],len))
rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
m=p;
if(p==n) break;
总代码如下:
inline cmp(int x,int y,int w){return tmpsa[x] == tmpsa[y] && tmpsa[x+w]==tmpsa[y+w];}
//...
m=26;
for(int i=1; i<=n; i++) cnt[rk[i] = str[i] - 'a' + 1]++;
for(int i=1; i<=m; i++) cnt[i] += cnt[i-1];
for(int i=n; i>=1; i--) sa[cnt[rk[i]]--] = i;
//对单个字符进行计数排序
for(int len=1; len<n; len<<=1)
{
int p=0;
for(int i=n-len+1; i<=n; i++) tmpsa[++p] = i;
for(int i=1; i<=n; i++)
if(sa[i]>w) tmpsa[++p] = sa[i] - len;
memset(cnt,0,sizeof(cnt));
for(int i=1; i<=n; i++) cnt[key1[i] = rk[tmpsa[i]]]++;
for(int i=1; i<=m; i++) cnt[i] += cnt[i-1];
for(int i=n; i>=1; i--) sa[cnt[key1[i]]--] = tmpsa[i];
memcpy(tmpsa+1,rk+1,sizeof(rk)); p=0;
for(int i=1; i<=n; i++)
rk[sa[i]] = cmp(sa[i],sa[i-1],len) ? p : ++p;
m=p;
if(p==n) break;
}
背诵并默写全文!
§ 1.3 height数组
定义
对于此数组有如下引理:
即,始点为
的后缀【与前一名后缀的lcp】至少是始点为 的后缀【与前一名后缀的lcp】减一。
如何证明?
首先,
考虑画个图,我们假设排在
由图可得,
为什么排在中间?首先根据
的定义,浅绿串的字典序一定小于深绿串,这个时候如果来了个与 这个串具有比蓝串更长lcp,且字典序比 更小的新串 ,它与深绿串相同部分会更多,也就会排在浅绿串的后面。 换句话说,两个后缀,二者的lcp越长,字典序就越接近。
由此引理可以快速求得height数组:
for(int i=1,k=0; i<=n; i++)
{
if(k>0) k--;
while(s[i+k]==s[sa[rk[i]-1]+k]) k++;
height[rk[i]] = k;
}
然后再利用这个求一下任意两后缀的lcp:
感性理解:从i开始在排名维上一步步走到j,如果对于一个下标维上的位置(串上的位置),height始终大于他,那么他其实从i到j本质上就没变过,而如果height先是变短了又变长了,那么根据字典序的性质,即使变长了,变回来的也不是之前的东西了。
更加感性的理解:后缀数组相当于后缀树的每一个儿子从左到右排好序,height就是相邻两儿子的lca深度
当然,更常用的形式是这个
Part 2 基本子串结构
§ 2.0 压缩SAM
SAM中,不会有两个一度点指向同一个后继节点,所以我们把所有的一度点合并到它的后继上,就得到了压缩SAM。
注意,如果一个等价类里面含有字符串的终点,不能把他合并到后继里面。
观察到一个性质,由于合并的这个点只有一个出度,所以他和被合并的点的终点集仅仅只是平移一格的关系。
同时由于SAM本身的性质,这两个节点所对应字符串的始点集应当是完全相同的,因为SAM在匹配的时候不会舍弃已经匹配的前缀。
那么沿着被缩起来的这一条链一直往下走,终点集里面的每一个点都走出了一段路程,而他们的始点始终不动。这就形成了一个前缀等价类。
但是要注意,就像一个合并后的点包含了不止一个后缀等价类一样,一个合并后的点也包含了不止一个前缀等价类,具体可见下面这张图:
下面是感性理解:
一个SAM里面一个节点的最长串,表示了一个子串不断向左延长,在不损失出现位置的情况下最远延长多少。
一个压缩SAM里面一个节点的最长串,表示了一个子串不断向左、右两边延长,在不损失出现位置的情况下,最远延长到哪里。
所以我们可以知道,一个节点包含的所有子串的出现位置是“绑定”的,也就是可以通过平移得到。
这样我们就有了更有用的
§ 2.2 对称压缩SAM
把反串的压缩SAM也建立出来,发现这两张图上节点的含义是可以一一对应的。原因很显然——压缩SAM的每一个节点都具有对称性。
那么把两张SAM的边叠合在一起,就得到了对称压缩SAM。
可以解决这样的问题:一个模式串,支持左右两边加字符、回退历史版本,查询在文本串出现了几次。
可以直接维护这个模式串在文本串的对称压缩SAM的哪个节点上,加字符的时候记得判一下有没有超过当前节点的最长串,超过了就在对应方向的自动机上走。
对称压缩SAM的每一个节点表示了一种子串的“出现情况”,并且所有节点覆盖了所有的“出现情况”。
§ 2.3 基本子串结构
把一个节点里面所有的子串扔到我们的数据结构神器
另外,每一块都是一个左端上端对齐(类似左上三角)的阶梯。阶梯的一行代表正串SAM上的一个点,一列代表反串SAM上的一个点。
所有块的周长总和是
所以一些依赖块的周长的做法是可行的(比如逐行求前缀和之后逐列遍历),但是要保证一个块只被计算一次。
此外,我们可以引进基本子串结构的重链剖分。
具体来说,对包含在基本子串结构里面的两个
Part 3 基本子串字典
§ 3.0 一些定理
弱周期引理
强周期引理
这里有一个很漂亮的代数证明:
Part 4 Lyndon分解
§ 4.0 Lyndon分解
一个Lyndon串就是一个字典序小于它的所有非空后缀的字符串。
一个近似Lydon串是一个周期为Lyndon串的字符串。(可以证明Lyndon串的最小周期一定是他本身)。
Lyndon分解:把一个字符串拆成
§ 4.1 Runs
定义 Runs 为一个字符串内周期为
其实也可以理解成对于任意的
, 的周期均不为 ;对于任意的 , 的周期均不为 . 这是容易证明的。
本原平方串引理:若
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· .NET Core 中如何实现缓存的预热?
· 三行代码完成国际化适配,妙~啊~
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?