后缀数组、后缀自动机学习笔记
后缀数组、后缀自动机学习笔记
后缀数组(SA)
前置知识
倍增、计数排序、基数排序。
后缀数组定义
我们约定字符串 的后缀 指 。
后缀数组(Suffix Array)主要是两个数组 和 , 表示后缀排序后第 小的后缀编号, 表示后缀 的排名。
显然,。
例如对字符串 的后缀排序如下:
求法
这个求法不用我讲吧,直接对着 string 搞 sort,比较字符串是 的,所以排序是 的。
求法
字符串哈希预处理,然后二分求最长公共前缀。
求法
我们把 全部填充为 。
这个做法需要倍增的思想。
设 表示 在 的排名,对于 我们认为 。
我们可以首先对每个字符进行排序得到 ,然后考虑怎么利用 求出 。只要以 为第一关键字,以 为第二关键字排序,就可以求出 。
至于为什么? 包含了 的信息, 包含了 的信息,类似于 ST 表,把它们合并起来就是 的信息。
如果用 sort 进行排序,时间复杂度为 。
做法
既然说“如果用 sort 进行排序”,说明可以不用 sort 进行排序,能够做到更优,省掉一只 。
这时候就需要用到前置知识——计数排序、基数排序了。
需要排序的数组是排名,值域显然为 ,且只有两个关键字,基数排序只需要排序两次,排序的时间复杂度优化为 。
做法的一些常数优化
(一)如果排名已经互不相同,就没必要继续排序了。例如上表中的 。
(二)排名数组的值域可能不到 ,可以记一下具体的值域,计数排序时少循环一些。例如上表中的 和 。
(三)减少不连续内存访问。然而这个我不会。
放一份用了常数优化(一)(二)的代码:
//By: Luogu@rui_er(122461)
#include <bits/stdc++.h>
#define rep(x,y,z) for(int x=y;x<=z;x++)
#define per(x,y,z) for(int x=y;x>=z;x--)
#define debug printf("Running %s on line %d...\n",__FUNCTION__,__LINE__)
#define fileIO(s) do{freopen(s".in","r",stdin);freopen(s".out","w",stdout);}while(false)
using namespace std;
typedef long long ll;
const int N = 1e6+5;
char s[N];
int n, sa[N], rk[N], lst[N<<1], id[N], cnt[N];
template<typename T> void chkmin(T& x, T y) {if(x > y) x = y;}
template<typename T> void chkmax(T& x, T y) {if(x < y) x = y;}
int main() {
scanf("%s", s+1);
n = strlen(s+1);
int m = 300; // 值域
rep(i, 1, n) ++cnt[rk[i] = s[i]]; // 计数排序基本操作,统计每个数出现次数
rep(i, 1, m) cnt[i] += cnt[i-1]; // 计数排序基本操作,用前缀和求出每个值的排名
per(i, n, 1) sa[cnt[rk[i]]--] = i; // 计数排序基本操作,利用每个值的排名从右往左算每个数排名并赋值,计数排序不熟的话建议手玩一下
for(int w=1,p=0;;w<<=1,m=p) {
// 基数排序按关键字从不重要到重要排序
// 下面三行是对第二关键字的排序,sa[i] 是 i 的第一关键字,id[i] 表示第二关键字排名为 i 的数,第一关键字的位置
p = 0;
per(i, n, n-w+1) id[++p] = i; // 把最后 w 个第二关键字是无穷小的放进来
rep(i, 1, n) if(sa[i] > w) id[++p] = sa[i] - w; // 找第二关键字,如果 sa[i] > w 就可以作为别人的第二关键字,那就把第一关键字的坐标添加进 id 里
// 上面进行了常数优化(二),因为第二关键字的排序不需要使用计数排序
// 下面四行是对第一关键字的排序,使用计数排序,与上面计数排序基本相同,不再解释
memset(cnt, 0, sizeof(cnt));
rep(i, 1, n) ++cnt[rk[id[i]]];
rep(i, 1, m) cnt[i] += cnt[i-1];
per(i, n, 1) sa[cnt[rk[id[i]]]--] = id[i];
memcpy(lst, rk, sizeof(rk)); // 备份一下排名数组,因为下面要修改
p = 0;
rep(i, 1, n) { // 求出每个位置新的排名
if(lst[sa[i]] == lst[sa[i-1]] && lst[sa[i]+w] == lst[sa[i-1]+w]) rk[sa[i]] = p; // 如果与前一名两个关键字都相等,那么它们并列
else rk[sa[i]] = ++p; // 否则排在前一名的后面,即把名次加一
}
if(p == n) { // 常数优化(一),即顺序已经确定不需要继续排序
rep(i, 1, n) sa[rk[i]] = i;
break;
}
}
rep(i, 1, n) printf("%d%c", sa[i], " \n"[i==n]);
return 0;
}
代码加了不少注释,这是无注释代码:
//By: Luogu@rui_er(122461)
#include <bits/stdc++.h>
#define rep(x,y,z) for(int x=y;x<=z;x++)
#define per(x,y,z) for(int x=y;x>=z;x--)
#define debug printf("Running %s on line %d...\n",__FUNCTION__,__LINE__)
#define fileIO(s) do{freopen(s".in","r",stdin);freopen(s".out","w",stdout);}while(false)
using namespace std;
typedef long long ll;
const int N = 1e6+5;
char s[N];
int n, sa[N], rk[N], lst[N<<1], id[N], cnt[N];
template<typename T> void chkmin(T& x, T y) {if(x > y) x = y;}
template<typename T> void chkmax(T& x, T y) {if(x < y) x = y;}
int main() {
scanf("%s", s+1);
n = strlen(s+1);
int m = 300;
rep(i, 1, n) ++cnt[rk[i] = s[i]];
rep(i, 1, m) cnt[i] += cnt[i-1];
per(i, n, 1) sa[cnt[rk[i]]--] = i;
for(int w=1,p=0;;w<<=1,m=p) {
p = 0;
per(i, n, n-w+1) id[++p] = i;
rep(i, 1, n) if(sa[i] > w) id[++p] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
rep(i, 1, n) ++cnt[rk[id[i]]];
rep(i, 1, m) cnt[i] += cnt[i-1];
per(i, n, 1) sa[cnt[rk[id[i]]]--] = id[i];
memcpy(lst, rk, sizeof(rk));
p = 0;
rep(i, 1, n) {
if(lst[sa[i]] == lst[sa[i-1]] && lst[sa[i]+w] == lst[sa[i-1]+w]) rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
if(p == n) {
rep(i, 1, n) sa[rk[i]] = i;
break;
}
}
rep(i, 1, n) printf("%d%c", sa[i], " \n"[i==n]);
return 0;
}
UPD 新的写法(感觉这个更好记):
//By: Luogu@rui_er(122461)
#include <bits/stdc++.h>
#define rep(x,y,z) for(int x=y;x<=z;x++)
#define per(x,y,z) for(int x=y;x>=z;x--)
#define debug printf("Running %s on line %d...\n",__FUNCTION__,__LINE__)
#define fileIO(s) do{freopen(s".in","r",stdin);freopen(s".out","w",stdout);}while(false)
using namespace std;
typedef long long ll;
const int N = 2e6+5;
int n, rk[N], sa[N], cnt[N], id[N];
char s[N];
template<typename T> void chkmin(T& x, T y) {if(x > y) x = y;}
template<typename T> void chkmax(T& x, T y) {if(x < y) x = y;}
int main() {
scanf("%s", s+1);
n = strlen(s+1);
rep(i, 1, n) {
sa[i] = i;
rk[i] = s[i];
}
int m = max(n, 300), p = 0;
for(int w=1;;w<<=1,m=p) {
// sort key 2
memset(cnt, 0, sizeof(cnt));
memcpy(id, sa, sizeof(sa));
rep(i, 1, n) ++cnt[rk[id[i]+w]];
rep(i, 1, m) cnt[i] += cnt[i-1];
per(i, n, 1) sa[cnt[rk[id[i]+w]]--] = id[i];
// sort key 1
memset(cnt, 0, sizeof(cnt));
memcpy(id, sa, sizeof(sa));
rep(i, 1, n) ++cnt[rk[id[i]]];
rep(i, 1, m) cnt[i] += cnt[i-1];
per(i, n, 1) sa[cnt[rk[id[i]]]--] = id[i];
// update rk
p = 0;
memcpy(id, rk, sizeof(rk));
rep(i, 1, n) {
if(id[sa[i]] == id[sa[i-1]] && id[sa[i]+w] == id[sa[i-1]+w]) rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
if(p == n) break;
}
rep(i, 1, n) printf("%d%c", sa[i], " \n"[i==n]);
return 0;
}
做法
然而一般来讲时间复杂度瓶颈不在后缀排序,而且这两个太毒瘤了,所以不想学。
height 数组
最长公共前缀(LCP)
两个字符串 的 LCP 为最大的满足 的 ,显然 。
height 数组定义
对于字符串 ,我们称 为“后缀 ”,记 表示后缀 和后缀 的 LCP,则定义 height 数组满足 。特别地,。
求 height 数组
引理一:。
感性理解,因为按字典序排列,所以排的越远相似度(LCP)越低。
严格证明可以参考论文。
引理二:。
证明:设 ,即后缀 和排在它前一名的后缀的 LCP,原式化为 。
设 ,即排在后缀 前一名的是后缀 。
如果 ,显然成立。
如果 ,因为后缀 和后缀 的 LCP 大于一,所以第一个字符对字符串大小的比较没有影响,得到后缀 排在后缀 前面,且 。根据引理一 成立。
综上,。
说了这么多,是时候来讲讲 求 height 数组的算法了,这个算法就是——暴力。复杂度?根据引理二显然。
代码:
rep(i, 1, n) {
int j = height[rk[i-1]];
if(j) --j;
while(a[i+j] == a[sa[rk[i]-1]+j]) ++j;
height[rk[i]] = j;
}
SA 和 height 数组练习题
P3809 【模板】后缀排序、P6456 [COCI2006-2007#5] DVAPUT、P4051 [JSOI2007]字符加密等。
然后 OI Wiki 有不少题。
后缀自动机(SAM)
前置知识
等价类。
zpl 要求我解释解释,那我试试吧。
我们定义一个集合 上的等价关系 ,对于一个元素 ,所有满足 且 的元素 都在 的等价类中。
举几个例子:
- 是北京市常住人口的集合,定义等价关系 为住在同一个区,则所有住在海淀区的人在同一个等价类中,但住在海淀区的人和住在朝阳区的人不在同一个等价类中。
- 是整数集合 ,定义等价关系 为模 余数相等,则所有奇数在同一个等价类中,所有偶数在同一个等价类中。
- 是分数集合 (也就是有理数集合 的分数形式),定义等价关系 为 ,则所有满足 的分数在同一个等价类中。
定义
下文记 为字符集。
后缀自动机(Suffix Automaton)是一个字符串所有子串的压缩形式,能解决许多字符串相关问题。
字符串 的 SAM 是接受 所有后缀的最小 DFA(确定性有限状态自动机)。
如果你不知道自动机是啥(看不懂上面那句话),那么可以看下面的解释:
- SAM 是一个 DAG,节点称为状态,边称为转移。
- 图存在源点 ,称为初始状态,从 出发可以到达所有节点。
- 有若干个终止状态,满足从 出发,经过若干条边走到一个终止状态,则路径上所有转移的字母连起来是 的后缀, 的后缀也都可以用这样一条路径表示。
- SAM 是满足前三条性质的节点数最少的自动机。
SAM 包含一个字符串所有子串的信息,任意一条从 开始的路径,把转移的字母连起来,都会得到一个 的子串,每个 的子串也可以用这样一条路径表示。我们称这种子串和路径的关系为“对应”,下文可能出现路径对应了子串,或者子串对应了路径,就是这个意思。SAM 中到达一个状态的路径可能不止一条,我们称这个状态对应可以到达它的字符串的集合,这个集合的每个元素对应一条到达这个状态的路径。
我们举个简单的例子,对于字符串 建出的 SAM 如下:
根据上面的定义,我们可以说路径 对应了子串 。在这个 SAM 中,有两条从 到 的路径,也就是说状态 对应了字符串集合 。但为什么可以这么对应,这两个字符串有什么共同点呢?我们继续往下讲。
结束位置
对于字符串 的所有非空子串 ,我们记 为在 中 的所有结束位置。例如对于字符串 ,有 。
我们定义集合 为字符串 所有非空子串的集合,定义等价关系 为 相等,这样字符串 的所有非空子串都可以根据 划分为若干等价类。
则 SAM 的每个状态都对应了一个等价类,也就是一个或多个 相同的子串。在上面那个例子里, 和 的 均为 。
根据上面的定义,我们有一些引理:
引理一:字符串 的两个非空子串 满足 且 ,当且仅当 在 中所有出现位置都是 的后缀。
显然成立。
引理二:字符串 的两个非空子串 满足 ,则满足:
证明:若 ,则 至少一次同时出现,类似于引理一可知 是 后缀,则 每一次出现 都会出现,即 。
引理三:对于一个 等价类,不存在两个等长子串;且把属于这一等价类的所有子串按长度递增顺序排序,则前一个子串是后一个子串的后缀,这些子串的长度覆盖一个整数区间 。
证明:若等价类中只有一个元素,显然成立。
否则,由引理一,较短子串总是较长子串的真后缀,等价类中没有等长的子串。
记等价类中最长、最短的子串为 ,则 。考虑长度在 间的 的后缀 ,由引理一知 是 的后缀,则 是 的一个后缀。由引理二知 ,又因为 ,所以 , 也在这个等价类中。所有长度在 间的 的后缀都在这个等价类中,因此这些子串的长度覆盖整数区间 。
后缀链接
对于 SAM 中一个不是 的状态 ,假设 是状态 这个等价类中最长的字符串,则根据引理三,其他字符串都是 的后缀。同样根据引理三,我们还知道把 的所有后缀按长度降序排序,则前几个后缀一定在等价类 中,且至少有一个后缀(可以是空串 )在其他等价类中。我们记 为在其他等价类中的 的最长后缀,则后缀链接 连接到 所在的等价类。
我们假设空串 包含于状态 中,为了方便我们令 。
引理四:所有后缀链接构成一棵根节点为 的内向树。
证明:对于任意不是 的状态 ,根据后缀链接的定义和引理三,沿后缀链接移动都会到达严格更短的字符串,因此一直沿后缀链接移动总能到达空串 对应的状态 。
引理五:以 集合的包含关系作为父子关系构建出的树,与后缀链接构建出的树完全相同。
证明:由引理二,我们可以用 集合构建出一棵树。
对于不是 的状态 ,由后缀链接和引理二可知 。这里是 不是 ,因为如果 ,则他们在同一个等价类中,应当被合并为同一个节点。
结合前面的引理,可知后缀链接构建出的树就是 构建出的树。
例如,下面是 的 SAM,红色边就是后缀链接:
状态 的最长串为 ,但是 和 的 endpos 也为 ,属于同一个等价类,后缀链接指向 对应的状态 。
小结&记号引入
我们已经知道了:
- 的子串可以根据 的不同分为若干等价类。
- SAM 中的状态包括初始状态 (对应空串 )和每个 等价类对应的状态。
- 对于每个状态 ,有若干个子串与它匹配。我们记 为等价类中最长的字符串,;记 为等价类中最短的字符串,。那么状态 对应的所有字符串都是 的后缀,且长度覆盖了整数区间 。
- 对任意不是 的状态 ,后缀链接 连接到字符串 的长度为 的后缀对应的状态,即 。所有后缀链接构成了根节点为 的一棵内向树,这棵树也表示 的包含关系。
- 从任意状态 开始沿后缀链接遍历,总会到达初始状态 ,路径上所有状态 的整数区间 的交集为 ,并集为 。
SAM 的构建
SAM 的构建的复杂度为 。与 ACAM 的构建不同的是,这个算法是在线算法,可以每次在串尾(只有一个串)插入一个字符;而 ACAM 是离线算法,必须插入所有字符串后进行处理。
为了保证复杂度为线性,我们只会维护 和 的值和每个状态的转移,而不会(也不能)动态维护终止标记(即接受状态)。但是如果需要的话,我们可以在全部插完后标记出终止节点。以下将 简写为 。
SAM 的初始化 init()
非常简单,只需要 并 。我们用 号点表示状态 , 号点是虚拟出的不合法的状态。
下面考虑 SAM 的插入字符 extend(c)
操作。
在前面举的 SAM 的例子中,你可能会注意到两类节点:一些在最底下排成一条链(可能有不规则的连边),还有一些在上面连得乱七八糟。这个直觉是有道理的,我们会在下面讲解这两种点是怎么产生的。我们暂且称他们为“地上的点”和“天上的点”。
我们记录 表示“地上的点”中最靠右的点,显然初始 。然后进行以下步骤:
- 创建一个新的状态 ,令 ,此时 尚未知。
- 从状态 开始遍历后缀链接,如果还没有字符 的转移,就添加一个到 的字符 的转移。如果已经有了字符 的转移,就停下来不再遍历,记这个状态为 。
- 如果没有找到这样的状态,即遍历到了虚拟状态 ,我们就令 并退出。
- 否则,我们记 的节点为 (请注意 上面字母的格式,若为 形式则代表是这个字母,若为 形式则代表是一个变量),进行分类讨论:
- 如果 ,我们令 并退出。
- 否则就有点复杂,我们需要复制状态 的后缀链接和转移(不复制 ),得到一个新的状态 。我们令 ,然后我们令 且 。最后我们从 开始遍历后缀链接,如果存在 ,就把这个转移重定向成 。
- 以上所有的情况过后,我们都将 。
这些步骤感觉很乱啊,下面我们来讲一下每一步的意义。对 的转移,如果满足 ,则称这是连续转移,否则称这是不连续转移。
- 没啥好说的,插了个字符,整个字符串长度增加了,显然要创建节点。这个节点是地上的点。
- 多了一个字符,我们要把所有新的后缀(即新的子串)添加进 SAM 里面。遍历后缀链接就是遍历所有的原来的后缀,添加转移就是在原来的后缀接一个新的字符。
- 如果所有的新后缀都没出现过,都加完了,就做完了,改一下后缀链接就行。
- 否则这个后缀之前出现过,那么原来的 会发生变化,可能需要重构 SAM 结构,就是下面的两个小情况。
- 如果这个转移是连续的,这种情况比较简单,只需要连一下后缀链接,因为 会同步变化。
- 如果转移不连续,假设原来的字符串是 ,那么新字符串就是 ,找到了最长的 的后缀 使得 在 中出现过,此时的 值应当等于 ,但实际上并不等于,不存在这样的状态。因此我们创建了状态 ,本质上其实是把原来的状态 拆成了两个,因为添加了字符 后导致原来的一个 等价类被拆成了两个。这个节点是天上的点。
- 把 更新成新的地上的点 ,注意 是天上的点不能更新。
容易发现, 的每一个前缀都对应了一个地上的点,并且是这个地上的点的 。
我们只需要把 的每一个字符按顺序插入 SAM 就做完了。
如果需要知道终止节点(接受状态)是哪些,怎么办呢?根据定义,所有后缀对应接受状态。我们只需要把所有后缀对应的状态标为接受即可。显然 是最长的后缀(就是原串),根据后缀链接的定义,我们从 遍历后缀链接,把经过的状态标为接受状态即可。
说了这么多,我们来演示一下字符串 的 SAM 的构建,可以对着上面的步骤手玩一下。
首先初始化 SAM:
插入 :
插入 :
插入 :
插入 ,注意这时候出现了一个天上的点,SAM 的结构有些变化:
插入 ,注意这时候又出现了一个天上的点,SAM 的结构有些变化:(这里 两个点的编号标反了,但问题不大就懒得改了)
SAM 的状态数和转移数
我们假设字符集大小为常数 ,插入一个长度为 的字符串 。
根据上面的步骤,SAM 每次插入最多增加两个状态,显然状态数不超过 ,可以在 取到。
假设 ,转移数不会超过 。
证明:首先估计连续转移数量。考虑 开始的最长路径的生成树,生成树只包含连续边,连续转移数不超过状态数,最大为 。
然后估计不连续转移数量。设不连续转移为 ,它对应的字符串为 ,其中 对应 到 的最长路径, 对应 到任意接受状态的最长路径。一方面,因为 仅由完整的转移构成,所以每一个不完整的字符串对应的 不同;另一方面,由接受状态定义, 为 的后缀,而 只有 个非空后缀,且 本身一定对应 个连续转移,因此不连续转移不超过 个。
上面两部分相加得到上界 ,然而状态数最多只在 取到,此时转移数量少于 ,因此转移数上界为 ,可以在 取到。
SAM 的两种实现和复杂度证明
两种实现其实没啥差别,就是存转移的时候是用平衡树(map)来存还是用一个大小为 的数组来存。前者的时间复杂度为 ,空间复杂度为 ;后者的时间复杂度为 ,空间复杂度为 。我个人认为后者更简单易懂,但有时字符集达到了 量级必须用前者。
我们将认为字符集大小为常数,也就是搜索转移、添加转移、查找下一个转移的复杂度均为 。
考虑算法的过程,有三处的时间复杂度不明显为线性:
- 遍历 的后缀链接,添加字符 。
- 复制状态 。
- 修改指向 的转移,重定向到 。
我们已经证明了 SAM 的状态数和转移数均为 ,易知前面两处总复杂度显然为线性,下面估计第三处的复杂度。
我们将指向 的转移重定向到 ,记 , 是 的后缀,每次迭代长度递减。在迭代前如果 在后缀链接树中距离 深度为 ,则最后一次迭代后 会成为路径上第二个从 出发的后缀链接,它将成为新的 。
循环中每次迭代都会使作为当前字符串后缀的字符串 的位置单调递增,循环最多执行不超过 次迭代。
第二种实现的代码:
struct State {
int len, link, nxt[26];
};
struct SAM {
State st[N<<2];
int sz, lst;
void init() {
st[0].len = 0;
st[0].link = -1;
sz = lst = 0;
}
void extend(char ch) {
int u = ++sz, c = ch - 'a';
st[u].len = st[lst].len + 1;
int p = lst;
for(;p!=-1&&!st[p].nxt[c];p=st[p].link) st[p].nxt[c] = u;
if(p == -1) st[u].link = 0;
else {
int q = st[p].nxt[c];
if(st[p].len + 1 == st[q].len) st[u].link = q;
else {
int v = ++sz;
st[v].len = st[p].len + 1;
st[v].link = st[q].link;
memcpy(st[v].nxt, st[q].nxt, sizeof(st[q].nxt));
for(;p!=-1&&st[p].nxt[c]==q;p=st[p].link) st[p].nxt[c] = v;
st[q].link = st[u].link = v;
}
}
lst = u;
}
}sam;
SAM 练习题。
从洛谷找了份题单。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现