后缀自动机 SAM

1 概述及定义

后缀自动机(SAM)是一个强有力的数据结构,可以解决很多经典字符串问题,例如:

  • 线性复杂度进行字符串匹配。
  • 线性复杂度求出一个字符串的所有不同子串个数。

那么我们定义一个字符串 \(S\) 的 SAM 是一个可以接受 \(S\) 所有后缀的最小 DFA(确定性有限状态自动机)。

也就是说,SAM 存储的是字符串 \(S\) 的所有后缀信息。而严谨的讲,上面那句话的意思如下:

  • SAM 是一张 DAG,节点被称为状态,边称为转移。每个转移上标有字母,每个节点出发的转移都不同。
  • SAM 存在一个源点 \(t_0\),其他所有节点都可由 \(t_0\) 走到。
  • 存在一个或多个终止状态,如果从 \(t_0\) 出发,走到一个终止状态,走过的转移连起来一定是 \(S\) 的一个后缀;同时,\(S\) 的所有后缀都可以用 \(t_0\) 到终止状态的路径描述。

当然只有这三条是不够的。如果只有这三条,我建一颗包含 \(S\) 所有后缀的 Trie 树也可以叫 SAM 了。所以还有一条:

  • SAM 是满足上述条件的自动机中,节点数量最少的。

由于其存储的是所有后缀,所以查询子串信息需要看后缀的前缀。放到 SAM 上讲,从 \(t_0\) 开始出发的任意一条路径对应这 \(S\) 中的一个子串;反过来,\(S\) 中的所有子串都对应从 \(t_0\) 开始出发的任意一条路径。因此 SAM 存储的其实不仅是后缀信息,而是所有子串信息。

下面举一个例子,对于字符串 abbb,其 SAM 如下(绿色节点为终止状态):

oi-wiki.org/string/images/SAM/SAabbb.svg

最后一点,SAM 的强大之处不仅在于它存储了所有子串的信息,还在于它存储这些信息的空间复杂度仅仅是 \(O(n)\),构建的复杂度也是 \(O(n)\)

2 parent 树

显然在上面的说法中,我们可以求出每一个子串,却无法确定这个子串的其他信息:位置、长度等。因此我们需要引入一个新的结构——parent 树来统计子串信息。

parent 树是 SAM 的一部分,类比的话就相当于 AC 自动机中的 fail 树。而这也能说明 parent 树的重要地位,实际上,大多数 SAM 的题都是基于 parent 树的性质来做的。

2.1 结束位置 endpos 与 endpos 等价类

2.1.1 定义与实际意义

考虑一个字符串 \(S\) 的任意子串 \(s\),我们记 \(\text{endpos}(t)\) 表示 \(s\)\(S\) 中每次出现的结束位置。例如对于 \(S=\) abcbc,子串 bc\(\text{endpos}\) 就是 \(\{2,4\}\)

而对于两个子串 \(s_1,s_2\),它们的 \(\text{endpos}\) 集合可能是相等的。例如上面例子中子串 bcc\(\text{endpos}\) 集合都是 \(\{2,4\}\)。由此我们可以将 \(S\) 的所有子串根据 \(\text{endpos}\) 分成若干个等价类。我们定义一个 \(\text{endpos}\) 等价类记作 \(E\),其对应的字符串集合为 \(\text{endpos}(E)\),当中第 \(i\) 个字符串为 \(E_i\)

现在考虑回到 SAM 上来,如果我们求出了子串 \(s\)\(\text{endpos}\),那么当我们在 SAM 上找到这个子串时,就可以知道它在哪出现过、出现了多少次。这样我们就补充了上面 SAM 遗漏的子串信息。

同时,SAM 中的每一个状态都对应着一个或多个 \(\text{endpos}\) 相同的子串。也就是说,SAM 上每一个节点 \(x\) 都对应一个 \(\text{endpos}\) 等价类,而这个等价类的大小就是从 \(t_0\) 出发走到 \(x\)​ 的路径条数之和。

下面我们会运用 \(\text{endpos}\) 的性质来构建 SAM,但是我们还需要一些引理。

2.1.2 相关引理和证明

引理 \(1\):对于字符串的两个非空子串 \(s_1,s_2\)(令 \(|s_1|\ge |s_2|\))的 \(\text{endpos}\) 相同,当且仅当 \(s_2\) 每一次在 \(S\) 中出现都以 \(s_1\) 的后缀形式出现。

证明:

先证 \(\Rightarrow\)。由于两者 \(\text{endpos}\) 相同,那么说明它们都是在几个相同位置的长度不同的后缀。又由于 \(|s_1|\ge|s_2|\),所以 \(s_2\) 每一次出现都以 \(s_1\)​ 的后缀形式出现。

再证 \(\Leftarrow\)。由于 \(s_2\) 每次出现都以 \(s_1\) 的后缀形式出现,说明两者的结束位置一定相同,即两者 \(\text{endpos}\) 相同。

引理 \(2\):对于字符串的两个非空子串 \(s_1,s_2\)(令 \(|s_1|\ge |s_2|\)),若 \(s_2\)\(s_1\) 后缀,则 \(\text{endpos}(s_1)\subseteq \text{endpos}(s_2)\);否则 \(\text{endpos}(s_1)\cap \text{endpos}(s_2)=\varnothing\)

证明:

  1. 由于 \(s_2\)\(s_1\) 的后缀,那么 \(s_1\) 出现时 \(s_2\) 必然出现;但是 \(s_2\) 出现时 \(s_1\) 未必出现,于是就得到 \(\text{endpos}(s_2)\) 包含 \(\text{endpos}(s_1)\)

  2. \(\text{endpos}(s_1)\)\(\text{endpos}(s_2)\) 有相同元素,则代表此时 \(s_1,s_2\) 同时出现。又由于 \(|s_1|\ge|s_2|\),那么自然就得到 \(s_2\)\(s_1\) 后缀,与题意矛盾!因此两个集合不可能有相同元素,即交集为空集。

引理 \(3\):对于一个 \(\text{endpos}\)​ 等价类当中的任意两子串,较短者一定是较长者的后缀。

证明:

显然任意两子串的 \(\text{endpos}\) 相同,由引理 \(1\) 可得,两个子串中较短者每一次都以较长者的后缀形式出现。也就是说较短者一定是较长者的后缀。

引理 \(4\):设 \(l,r\) 分别为一个 \(\text{endpos}\) 等价类 \(E\) 中最短和最长子串的长度。则 \(\bigcup|E_i|=\{l\le x\le r,x\in \mathbb{N}^*\}\)​。

证明:

考虑等价类中最短的字符串 \(s_1\) 和最长的字符串 \(s_2\)。由引理 \(1\) 得,\(s_1\) 每一次在 \(S\) 中出现都以 \(s_2\) 的后缀形式出现。

现在考虑 \(s_2\) 的任意一个后缀 \(s\),满足 \(|s_1|\le|s|\le|s_2|\)。那么显然 \(s_2\) 每次出现都会伴随着 \(s\) 出现,\(s\) 出现会伴随着 \(s_1\) 出现。又由于 \(s_1\)\(s_2\) 的出现位置是相同的,那么自然 \(s\) 的出现位置与 \(s_1,s_2\) 都相同,即 \(s\)\(\text{endpos}\)\(s_1,s_2\)\(\text{endpos}\) 相同,于是 \(s\) 就属于这个 \(\text{endpos}\) 等价类 \(E\)

于是任意长度在 \([l,r]\) 之间的后缀 \(s\) 都属于该 \(\text{endpos}\) 等价类,即该等价类满足 \(\bigcup|E_i|=\{l\le x\le r,x\in \mathbb{N}^*\}\)

考虑 SAM 中某个不是 \(t_0\) 的状态 \(v\)。我们已经知道,\(v\) 会对应一个 \(\text{endpos}\) 等价类。如果定义 \(S\) 为这些字符串中最长的,则当中其他所有字符串都是 \(S\) 的后缀(引理 \(3\))。

我们还知道字符串 \(S\) 的后缀按长度排序后,前面几个都包含于这个等价类(引理 \(4\)),且所有其它后缀都在其它等价类中。我们记 \(t\) 为最长的这样的后缀,将 \(v\) 连到 \(t\) 代表的等价类节点上,这就是 \(v\) 的后缀链接。

形式的讲,后缀链接 \(\text{link}(v)\) 连接到 \(S\) 的在另一个 \(\text{endpos}\) 等价类的最长后缀,其等价类对应的状态。

此时根据引理 \(4\),从某个等价类 \(E\) 开始不断跳 \(\text{link}\) 直到初始节点 \(t_0\),就可以访问 \(S\) 的所有后缀。这时候应该要敏锐的发现,所有的 \(\text{link}\) 似乎可以构成一种结构,于是有了下面的结论:

引理 \(4\):所有 \(\text{link}\) 构成一颗根节点为 \(t_0\) 的树。

2.2.2 parent 树

首先先给出 parent 树的定义:通过 \(\text{endpos}\) 集合构建的树,满足每个子节点的集合都包含在父节点的集合中。由引理 \(2\) 可得,两个 \(\text{endpos}\) 集合要么一个包含另一个,要么完全没有交集,因此按照上面的构造方式一定可以构造出一颗树。

那么为什么将 \(\text{link}\) 和 parent 树放在一起呢?实际上你可能已经猜到了。

引理 \(5\):由 \(\text{link}\)​ 构成的树与 parent 树相同。

证明:

首先考虑一个不是 \(t_0\) 的状态 \(v\),以及它的后缀链接 \(\text{link}(v)\)。由引理 \(2\) 及后缀链接定义可得,\(\text{endpos}(v)\varsubsetneq \text{endpos}(\text{link}(v))\)

注意这里的 \(\varsubsetneq\) 表示后面的集合至少有一个不属于前面的集合。首先由引理 \(2\) 可得 \(\text{endpos}(v)\subseteq \text{endpos}(\text{link}(v))\),但是若 \(\text{endpos}(v)= \text{endpos}(\text{link}(v))\),这两个节点就应该被合并成一个节点了,所以需要使用 \(\varsubsetneq\)

然后根据 parent 树的定义,我们就可以发现这与定义完全一致。所以 parent 树就是 \(\text{link}\) 构成的树。证毕。

最后给出一个例子,对于字符串 abcbc,构建的 DAG 和 parent 树分别如下(绿色节点为终止状态):

oi-wiki.org/string/images/SAM/SA_suffix_links.svg

所以最后通过这么一番倒腾,我们就弄出了 parent 树需要的所有东西。接下来我们将它们整合一下,准备开始构造 SAM。

2.3 小结

我们对上面 2.1、2.2 小节的内容进行整理,为后面的构建进行铺垫。

  • \(S\) 的子串看可以根据 \(\text{endpos}\) 划分为若干等价类。
  • SAM 由初始状态 \(t_0\) 和与每一个 \(\text{endpos}\) 等价类对应的状态组成。
  • 对于每一个状态 \(v\),有一个或多个子串与之对应。我们记 \(\text{lest}(v)\) 表示其中最长的字符串,用 \(\text{maxl(v)}\) 表示其长度。类似的,使用 \(\text{sest}(v)\) 表示其中最短的字符串,长度为 \(\text{minl}(v)\)
  • 于是这个状态中所有的字符串都是 \(\text{lest}(v)\) 的不同后缀,且所有字符串长度覆盖区间 \([\text{minl}(v),\text{maxl}(v)]\)
  • 对于状态 \(v\),定义后缀链接 \(\text{link(v)}\) 表示连接到对应字符串 \(\text{lest}(v)\) 的长度为 \(\text{minl}(v)-1\) 的后缀的等价类状态。所有后缀链接可以构成一棵树,且与 parent 树相同。
  • 对于状态 \(v\),显然会有如下式子:\(\text{minl}(v)=\text{maxl}(\text{lenk}(v))+1\)
  • 从任意状态 \(v\) 开始跳后缀链接,总会到达初始状态 \(t_0\),且走到的所有状态对应的所有后缀长度正好覆盖 \([0,\text{maxl(v)}]\)

3 构建 SAM

3.1 流程

与 PAM 等自动机一致,SAM 也是用增量的构建方式,即逐个加入字符维护。

一开始 SAM 只有一个状态 \(t_0\),编号为 \(0\)。我们令它的 \(\text{maxl}=0,\text{link}=-1\)

现在考虑给当前字符串添加字符 \(c\)。令 \(last\) 为添加字符 \(c\) 之前,整个字符串对应的状态。

创建一个新的状态 \(cur\),令 \(\text{maxl}(cur)=\text{maxl}(last)+1\)。现在我们从状态 \(lst\) 开始跳后缀链接,如果没有字符 \(c\) 的转移,创建一条到 \(cur\) 的字符 \(c\) 的转移;否则我们将这个状态标记为 \(p\),并停止跳后缀链接。

如果我们没有找到 \(p\),意味着它的 \(\text{endpos}\) 集合不属于之前任意一个 \(\text{endpos}\) 集合,于是令 \(\text{link}(cur)=0\) 并结束。

否则这个 \(p\) 就会有一个 \(c\) 的转移,我们将这个转移出去的点记作 \(q\)。若 \(\text{maxl(p)}+1=\text{maxl}(q)\),那么直接令 \(\text{link(cur)}=q\) 即可。这一步与其它自动机的连边思路基本一致,通过相同的转移 \(c\) 来找后缀链接。

真正麻烦的地方在于 \(\text{maxl}(p)+1\neq \text{maxl}(q)\) 的时候。这时我们需要复制状态 \(q\),称这个复制的状态为 \(q'\),它需要保留 \(q\) 除了 \(\text{maxl}\) 以外的所有信息(即后缀链接与转移),将 \(\text{maxl}(q')\) 设为 \(\text{maxl}(p)+1\)。接下来我们将 \(q\)\(cur\)\(\text{link}\) 全部指向 \(q'\)

最后我们再从 \(p\) 开始跳后缀链接,如果有状态 \(v\) 有字符 \(c\) 的转移且转移到了 \(q\),将 \(v\to q\) 的转移重定向到 \(v\to q'\) 的转移。直到跳到 \(-1\) 或找不到通过 \(c\) 转移到 \(q\) 就停止。

通过上面三种情况的分析,我们就成功加入了这个字符 \(c\)。最后将 \(last\) 设为 \(cur\) 即可。

当然我们会发现上面的过程中没有提到终止状态。实际上,我们在整个字符串建立好 SAM 之后找到整个字符串对应的状态(此时存储在 \(last\) 中),然后开始跳它的后缀链接。容易发现,这样跳到的所有状态都代表整个字符串的后缀,将它们标记为终止状态即可。

3.2 正确性说明

说实话,这一部分并不重要,但是里面还有一些有趣的内容,所以我择出来一些写上去。

首先对于前两种情况,正确性都比较明晰,上文已经提到,不再赘述。

我们着重来看一下 \(\text{maxl}(p)+1\neq \text{maxl}(q)\),其实这就意味着 \(\text{maxl}(p)+1<\text{maxl}(q)\)。也就是说状态 \(q\) 不止对应长度为 \(\text{maxl}(p)+1\) 的后缀,还对应更长的子串。因此我们考虑将状态 \(q\) 拆成两个状态,同时第一个状态的 \(\text{maxl}\) 就是 \(\text{maxl}(p)+1\)。这就是上面提到的“复制”出的状态 \(q'\)

那么自然的,这个 \(q'\) 就要继承 \(q\) 的所有信息,同时只有 \(\text{maxl}\) 需要改变。最后我们希望将一些到 \(q\) 的转移重定向到 \(q'\),那么只需要重定向字符串 \(\text{lest}(p)+c\) 的所有后缀即可,因为只有它们是在 \(q'\)\(\text{endpos}\) 等价类中的。

既然这样我们就直接跳 \(p\)\(\text{link}\),然后看有没有 \(c\) 的转移指向 \(q\)。有的话就说明这个状态中的字符串加上 \(c\) 构成了 \(q'\) 中的串,因此需要重定向。而如果找到第一个有 \(c\) 的转移却不指向 \(q\) 的,则该状态无需重定向,同时它的后缀也无需重定向,此时直接跳出即可。

3.3 时空复杂度说明

事实上,SAM 复杂度是线性的先决条件是字符集大小 \(|\sum|\) 为常数。若 \(|\sum|\) 不为常数,则 SAM 的空间复杂度会退化为 \(O(n|\sum|)\)。此时可以利用 map 进行操作,将空间降至 \(O(n)\),但相应的代价是时间复杂度上升为 \(O(n\log |\sum|)\)​。

但是真正值得注意的是,对于一个长为 \(n\) 的字符串,其在 SAM 中的状态数最大是 \(2n-1\),转移数最大是 \(3n-4\),所以 SAM 的数组需要开到字符串长度的 \(2\) 倍。

3.4 代码

整体来讲 SAM 的代码实现并不复杂,只要按照上面的思路走就行。基础的 SAM 封装如下:

struct SAM {
	int tot, lst;
    //点数,上一个位置对应状态
	struct node {
		int len, link, son[26];
		//maxl(v),link(v),以及转移
    }sam[Maxn];
	void init() {//初始化
		for(int i = 0; i <= tot; i++) {
			sam[i].len = sam[i].link = 0;
			memset(sam[i].son, 0, sizeof sam[i].son);
		}
		sam[0].link = -1;
		lst = tot = 0;
	}
	void insert(int i) {
		sam[++tot].len = sam[lst].len + 1;//新建状态
		int pos = lst, ch = s[i] - 'a';
        lst = tot;
		while(pos != -1 && sam[pos].son[ch] == 0) {//跳后缀链接
			sam[pos].son[ch] = tot;
			pos = sam[pos].link;	
		}
		if(pos == -1) sam[tot].link = 0;
		else {
			int p = pos, q = sam[pos].son[ch];
			if(sam[p].len + 1 == sam[q].len) {
				sam[tot].link = q;
			}
			else {
				sam[++tot] = sam[q];//复制 q 状态
				sam[tot].len = sam[p].len + 1;
				sam[tot - 1].link = sam[q].link = tot;
				while(pos != -1 && sam[pos].son[ch] == q) {//跳后缀链接
					sam[pos].son[ch] = tot;//重定向
					pos = sam[pos].link;
				}
			} 
		}
	}
}SAM;

4 应用

4.1 parent 树简单应用

我们来看 SAM 的模板题:【模板】后缀自动机(SAM)。题目中要求我们求出所有出现次数大于 \(1\) 的子串出现次数乘子串长度最大值。

我们先建出 SAM,既然求的是乘积最大值,那我们就取每个节点所对应的等价类中最长的子串,接下来就是求其出现的次数了。显然它出现的次数等于该等价类中元素个数,但是我们并没有在建 SAM 的时候就将这个维护出来。

考虑在 parent 树上求解,由于 parent 树上每个节点的 \(\text{endpos}\) 等于它儿子的并(如果是前缀还要加上前缀的位置),我们就想到在 parent 树上进行树形 dp。将所有叶子节点的 \(siz\) 设为一,向根节点 dp,这样每个节点上求出的 \(siz\) 就是该状态对应 \(\text{endpos}\) 的大小。

代码:

#include <bits/stdc++.h>

using namespace std;

const int Maxn = 2e6 + 5;
const int Inf = 2e9;

int n;
string s;

int tot, lst;
struct SAM {
	int len, link, son[26];
}sam[Maxn];	

void init() {
	sam[0].link = -1;
}

long long siz[Maxn], ans;
void insert(int i) {
	sam[++tot].len = sam[lst].len + 1;
	int pos = lst, ch = s[i] - 'a';
	siz[tot] = 1;//将叶子节点的 siz 设为 1
    //注意我们不是将所有节点的 siz 都设为 1,因为那些复制出去的节点本质上还是原节点的后缀,如果将这些节点也算上会导致重复计算
	lst = tot;
	while(pos != -1 && sam[pos].son[ch] == 0) {
		sam[pos].son[ch] = tot;
		pos = sam[pos].link;
	}
	if(pos == -1) sam[tot].link = 0;
	else {
		int p = pos, q = sam[pos].son[ch];
		if(sam[p].len + 1 == sam[q].len) {
			sam[tot].link = q;
		}
		else {
			sam[++tot] = sam[q];
			sam[tot].len = sam[p].len + 1;
			sam[q].link = sam[tot - 1].link = tot;
			while(pos != -1 && sam[pos].son[ch] == q) {
				sam[pos].son[ch] = tot;
				pos = sam[pos].link;
			}
		}
	}
}
vector <int> E[Maxn];
void build() {
	init();
	for(int i = 1; i <= n; i++) {
		insert(i);
	}
	for(int i = 1; i <= tot; i++) {
		E[sam[i].link].push_back(i);
	}
}

bool vis[Maxn];
void dfs(int x) {
	for(auto i : E[x]) {
		dfs(i);
		siz[x] += siz[i];
	}
	if(siz[x] > 1) {
		ans = max(ans, siz[x] * sam[x].len);
	}
}

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> s;
	n = s.size();
	s = ' ' + s;
	build();
	dfs(0);
	cout << ans;
	return 0;
}

4.2 不同子串个数

对于静态求不同子串个数,显然在 DAG 上拓扑 dp 一下即可。设 \(dp_i\) 表示从状态 \(i\) 出发,能够走出的路径条数,则转移为:

\[dp_i=1+\sum_{(i,j)\in \text{SAM}}dp_j \]

则最后答案为 \(dp_{t_0}-1\)

然后还有动态求不同子串个数,也就是每插入一个字符就询问一次。

考虑到插入一个字符后,所增加的不同子串只会是当前的后缀。现在考虑对于当前节点 \(v\),它正好代表了当前的一部分后缀,而剩下的一部分后缀需要跳 \(\text{link}\)。考虑为什么当前的一部分后缀在 $\text{link(v)} $ 中,显然是因为 \(v\) 与其代表的 \(\text{endpos}\) 不一致,那么这就说明 \(\text{link}(v)\) 所对应的 \(\text{endpos}\) 等价类中的字符串在之前就出现过,因而这一部分都是重复的后缀。

考虑重复后缀的个数,显然就是 \(\text{maxl}(\text{link}(v))\),于是插入当前字符对答案造成的贡献就是 \(\text{maxl}(v)-\text{maxl}(\text{link}(v))\)

例题:[SDOI2016] 生成魔咒

4.3 第 k 小子串

这一问题与上一个问题有很大的联系。我们考虑每一个子串就是 SAM 上的一条路径,那么第 \(k\) 小子串就是 SAM 上第 \(k\) 小路径。按照 4.2 说的静态求不同子串个数中的 \(dp\),我们可以求出走每一个字母所对应的路径条数。那么通过前缀和就可以判断出当前排名走的是哪一个字母。

当然按在我们只是确定了走哪一个字母,还没有求出走这一个字母对排名产生的贡献。这里根据题目要求有分别,如果要求的是第 \(k\) 小本质不同子串,那么任意一个节点对于排名的贡献都是 \(1\);否则如果不要求本质不同,需要通过 4.1 的方式求出每一个节点对应的子串个数,以此计算对排名的贡献。

按照这种方式走下去,直到当前排名等于要求的排名,将走过的字符连起来就是答案。

例题:[TJOI2015] 弦论

4.4 最长公共子串

求两个字符串的最长公共子串太鸡肋了,我们要求 \(n\) 个串的最长公共子串。

其实解决这个问题的思路有很多,介绍一种最暴力的方式。我们考虑将所有串拼接起来,中间加入特殊字符,然后建 SAM。由于 \(\text{endpos}\) 等价类中的子串都在这些位置出现,所以只要当前状态对应的 \(\text{endpos}\) 集合中的位置覆盖了所有小字符串,那么这个等价类中所有子串都是公共子串,将它们取最大值即可。

现在考虑如何维护 \(\text{endpos}\) 集合。由于父节点的 \(\text{endpos}\) 集合是由子节点合并而成的,于是我们自然会想到线段树合并。当然这里直接维护 \(\text{endpos}\) 并不方便,我们考虑对于每一个状态建立一颗线段树,表示当前小字符串的覆盖情况。然后在 parent 树上自底向上合并,如果全部覆盖就可以取最大值。

这样做复杂度显然是 \(O(nm\log n)\) 的。尽管可能不是最优,但是这个做法不需要任何脑细胞,而且也能通过绝大部分题目。

例题:[SDOI2008] Sandy 的卡片

(上面的做法被称作伪广义 SAM,事实上应该使用广义 SAM,可以看这篇文章

4.5 最长公共前缀

SAM 也可以用来求最长公共前缀(是不是发现后缀数组能干的它基本都能干)。

我们发现 parent 树上每个状态对应的最长子串会满足这样一个性质:父亲的子串一定是儿子的子串的后缀。但是我们现在要求的是两个后缀的 LCP,于是我们就可以想到将字符串 \(S\) 反向插入 SAM 中,这样就可以满足父亲子串是儿子子串的前缀了。

既然满足这个性质,我们就先找出原先两个后缀对应的反转后的子串。由于要求公共前缀,也就是 parent 树上公共的祖先;又因为要求最长,所以实际上求得就是两个节点的最近共同祖先。

所以,两个后缀的 LCP 问题实际上就是反串 parent 树上的 LCA 问题。

例题:[AHOI2013] 差异

posted @ 2024-06-22 18:07  UKE_Automation  阅读(217)  评论(0)    收藏  举报