后缀自动机
后缀自动机
很毒瘤的字符串数据结构,但是我觉得多在脑子里梳理几遍后,比后缀数组好用多了(
这里跳过几乎所有证明,除了对后面理解有影响的,因为我认为这些不重要也学不会
主要记录对做题有帮助的核心性质
字符串的 SAM 可以理解为这个字符串的所有子串的压缩形式
定义
SAM 是一个接受且仅接受字符串的所有后缀的最小 DFA
- SAM 是 DAG,状态为节点,边上有字母,为转移
- \(root\) 为初始节点,构建时当前代表整个串的末尾节点为终止节点(在最终,\(root\) 走到它们代表原串的前缀)
- 从一个节点出发的所有转移不同,从初始状态出发走到终止状态,形成的字符串与原串的每个子串一一对应
- 可能到达某状态的路径不止一条,所以一个节点其实代表了某些字符串的集合(后面会具体说明是哪些)
- 正串 SAM 建出的 parent tree 是反串的后缀树
endpos 集合
这是后缀自动机的关键之处
字符串的子串个数是 \(O(n^2)\) 的,它为什么只有 \(O(n)\) 个状态和转移?
定义
\(endpos(t)\):对于某个子串 \(t\),它在原串 \(s\) 中出现位置的右端点的集合
举个例子,\(ababac\) 中,\(endpos(ab)=\{2,4\},endpos(abac)=\{6\}\)
这样所有的子串都能根据 \(endpos\) 划分为若干等价类,特别的,\(endpos(root)=\{1,2,\dots n\}\)
性质
-
如果 \(endpos(u)=endpos(v)\),则其中一个必然为另一个的后缀
证明显然,反证法即可,感性理解更容易(
-
\(endpos(u)\) 与 \(endpos(v)\) 只有两种关系: \(endpos(u)\subseteq endpos(v)\) 或 \(endpos(u)\cap endpos(v)=\varnothing\)
为 1 的逆命题,这为 parent tree 的建造打下了基础
-
将同一 \(endpos\) 等价类中的子串按长度从小到大排序,则长度为连续的一段区间,记为 \([minlen,len]\)
-
\(endpos\) 等价类的个数为 \(O(n)\)
-
反串 SAM 上,找出正串 SAM 上节点 \(x\) 对应的节点 \(y\),则 \(y\) 的 \(endpos\) 集合代表了原串左端点集合,恰好是 \(x\) 代表的串对应的左端点集合
感性理解,代表的串的集合是一样的
parent tree
根据上面的性质,如果 \(endpos(u)\subset endpos(v)\),则 \(endpos(v)\) 代表节点设为 \(endpos(u)\) 的父节点
由 2 得兄弟节点之间的 \(endpos\) 不交
这样建出了一棵树,根节点为 \(root\)
SAM 的节点其实就是这棵树,从源点出发到达点 \(x\) 的任意一条路径形成的字符串的 \(endpos\) 集合都是节点 \(x\) 所代表的
我喜欢用这棵树,因为它有很好的性质
性质
这里设当前节点为 \(x\),\(fa\) 为它的父亲,\(len(x)\) 为 \(x\) 代表的 \(endpos\) 集合中最长串的长度,\(minlen(x)\) 则为最短串长度
-
\(fa\) 对应的所有字符串为 \(x\) 对应的所有字符串的后缀
-
\(minlen(x)=len(fa)+1\),也就是说,从 \(x\) 不断走向 parent tree 上的祖先节点,不重不漏的覆盖了串长 \([1,len(x)]\)
-
原串两个前缀的 LCS(最长公共后缀),为它们代表的终止节点在 parent tree 上的 LCA 的 \(len\)
这里需要理解一下,由于一个串的后缀的 \(endpos\) 必定包含它,因此其实是找到 \(endpos\) 集合包含这两个节点的节点,就是 parent tree 上的 LCA 及它的祖先,而且要让串长最大,就选 LCA 的 \(len\)
实际应用时,可以对反串建 SAM,就能快速得出后缀的 LCP,可以代替后缀数组
本质上就是建了后缀树在用
-
每个节点的 \(endpos\) 为它子树内所有终止节点 \(endpos\) 的并集
很多时候可以用线段树合并之类的数据结构维护
-
设每个状态 \(endpos\) 的大小为 \(siz(x)\),初始时只有终止节点的 \(siz(x)=1\),每个节点的 \(siz\) 是子树内节点的 \(siz\) 之和
-
\(endpos\) 集合的大小,就代表这个等价类中,每个串在原串中出现了 \(siz\) 次
-
每个状态包含的本质不同子串个数:\(len(x)-len(fa)\)
根据 1 可得出,也可以理解为 \(x\) 扣掉了与 \(fa\) 重复的子串
SAM 的构建
SAM 的构建是在线的,依次插入原串末尾的字符,增量构造
SAM 上节点的 \(endpos\) 集合,代表着从初始节点走到它,形成的所有字符串在原串中出现位置的右端点集合
设当前正在插入字符串中第 \(n\) 个字符
步骤:
-
先在上一个终止节点 \(p\) 后新建节点 \(np\),作为新的终止节点,显然 \(len(np)=len(p)+1\)
-
在 parent tree 上不断令 \(p=fa(p)\),\(p\) 对应的字符串加上字符 \(c\) 后为新串后缀,如果 \(trie(p,c)\) 为空,则意味着从它们走到 \(np\) 节点,\(endpos(np)\) 目前都为 \(\{n\}\),直接连边
-
如果 \(p\) 跳出了树,则说明字符 \(c\) 第一次出现,直接将 \(fa(np)\) 设为 \(root\)
-
否则令 \(q=trie(p,c)\),此时 \(len(q)\ge len(p)+1\)
- 如果 \(len(q)=len(p)+1\),说明 \(q\) 代表的所有字符串为新串后缀,那么令 \(fa(np)=q\)
- 否则 \(q\) 中不是所有节点都是新串后缀,此时要把是的拆出来,它们的 \(endpos\) 多了 \(n\),新建节点 \(nq\),转移(即边)和 \(q\) 相同,\(endpos(nq)=endpos(q)\cup \{n\}\),\(fa(nq)=fa(q)\),\(fa(q)=fa(np)=nq\),还要把原来连向 \(q\) 的改为连向 \(nq\)
如果需要求出每个节点的 \(endpos\) 大小,则在新建 \(np\) 时令 \(siz(np)=1\),然后每个节点的 \(siz\) 就是 parent tree 上子树中 \(siz\) 的和
struct SAM
{
void add(int c)
{
int p = las, np = las = ++idx;
siz[np] = 1, len[np] = len[p] + 1;
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = np; // 从长到短遍历原来串的所有后缀
if(!p) fa[np] = root; // case 1:之前没有 c字符
else
{
int q = ch[p][c]; // len[q] >= len[p] + 1
if(len[q] == len[p] + 1) fa[np] = q; // case 2:说明 q代表的所有子串 endpos集合中增加 n
else // case 3:q中 len > len[p]+1的子串 endpos没有 n,把增加 n的拆出来,成为 q的父亲
{
int nq = ++idx; // endpos(q) \in endpos(nq)
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; p && ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
} // 增加 c后,这些 p的 endpos中也多 n,连到 nq上
}
}
void dfs(int x)
{
for(int y : edge[x]) dfs(y), siz[x] += siz[y];
if(siz[x] != 1) ans = max(ans, 1ll * len[x] * siz[x]);
}
void build()
{
for(int i = 2; i <= idx; ++i) edge[fa[i]].pb(i);
dfs(1);
}
}sam;
将 SAM 上的节点拓扑排序,可以直接用基数排序
注意 SAM 上不是节点编号越大的拓扑序越大,在第三种情况中显然不满足,但 \(len\) 大的在 parent tree 上一定不是长度小的祖先
所以将点按 \(len\) 排序,就是 SAM 上节点的一个拓扑序,同时满足 parent tree 上每个点的子树内节点一定出现在它后面
void radixsort()
{
for(int i = root; i <= idx; ++i) ++buc[len[i]];
for(int i = 1; i <= idx; ++i) buc[i] += buc[i - 1];
for(int i = idx; i > 0; --i) dfn[buc[len[i]]--] = i;
}
应用
判断串 \(T\) 是否为串 \(S\) 的子串
对 \(S\) 建立 SAM,从初始节点沿着自动机的边匹配 \(T\),如果跑出了自动机则不是
求本质不同子串个数
直接求 \(\sum_{x} len(x)-len(fa_x)\),在 DAG 上 DP 也可以
求字典序第 \(k\) 大的子串
首先看不同位置出现算多次的情况
找到 \(siz\) 后,可以设 \(f(x)\) 表示走到 \(x\) 后还能表示的子串个数(包括就在 \(x\) 停止),在 DAG 上 DP
初始为 \(siz(root)=0,f(x)=siz(x)\),转移为 \(f(x)\gets \sum_{trie(x,c)=y}f(y)\)
因为走到 \(x\) 时,前面已经确定了,只是在字符串中有 \(siz(x)\) 种可能的位置,而后面的不确定
然后从初始节点开始,按字典序,试着走每条边
-
如果 \(f(trie(x,c))>k\),说明还有多的,\(k\to k-f(trie(x,c))\)
-
否则 \(f(trie(x,c))\le k\),则字符串中插入 \(c\),\(x=trie(x,c)\)
注意每到一个新节点时,\(k\) 减去这个节点的 \(siz\),表示要往后走,不在当前点停留
如果 \(k\le 0\) 则退出
如果不同位置出现只算一次,那就让所有 \(siz(x)=1\) 即可
注意判断无解
struct SAM
{
void add(int c)
{
int p = las, np = las = ++idx;
siz[np] = 1, len[np] = len[p] + 1;
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) fa[np] = root;
else
{
int q = ch[p][c];
if(len[q] == len[p] + 1) fa[np] = q;
else
{
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; p && ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
}
}
void dfs(int x)
{
for(int y : edge[x]) dfs(y), siz[x] += siz[y];
}
void dfssam(int x)
{
book[x] = 1;
for(int i = 0; i < 26; ++i)
if(ch[x][i])
{
if(!book[ch[x][i]]) dfssam(ch[x][i]);
f[x] += f[ch[x][i]];
}
}
void build()
{
for(int i = 2; i <= idx; ++i) edge[fa[i]].pb(i);
dfs(root);
if(!t)
for(int i = 1; i <= idx; ++i) siz[i] = f[i] = 1;
else for(int i = 1; i <= idx; ++i) f[i] = siz[i];
dfssam(root), siz[root] = 0;
}
void findans(int u, ll k)
{
k -= siz[u];
if(k <= 0) return;
for(int i = 0; i < 26; ++i)
if(ch[u][i])
{
if(k > f[ch[u][i]]) k -= f[ch[u][i]];
else
{
putchar(i + 'a'), findans(ch[u][i], k);
return;
}
}
}
}sam;
int main()
{
ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
cin >> (s + 1) >> t >> k, n = strlen(s + 1);
for(int i = 1; i <= n; ++i) sam.add(s[i] - 'a');
sam.build();
if(!t && k > f[root]) {printf("-1"); return 0;}
if(t && k > 1ll * n * (n - 1) / 2) {printf("-1"); return 0;}
sam.findans(root, k);
return 0;
}
多组询问 \(T\) 在给定串 \(S\) 中的出现次数
在 SAM 上直接通过转移边匹配 \(T\),答案为最后走到的节点的 \(siz\),单次查询复杂度为 \(O(|T|)\)
多个字符串的最长公共子串
对其中一个建 SAM,将其它的放在 SAM 上匹配
能沿着转移走就走,匹配的长度 \(+1\),否则跳 \(fa\),直到能走 \(trie(x,c)\) 匹配,匹配长度为 \(len(x)+1\)
如果一个状态能匹配到,则它在 parent tree 上的祖先节点也可以,因为一个子串如果被匹配到了,那么它的后缀也一定被匹配到
对于当前串,每个状态匹配长度的最大值为子树中的最大值,记录它为 \(sum_i\)
记录每个状态其它所有串在它上面匹配时 \(sum_i\) 的最小值 \(mn_i\),答案为所有状态 \(mn_i\) 的最大值
例题
P7361 「JZOI-1」拜神
查询子串相关问题考虑建出 SAM
答案显然可二分,如果我们二分的答案为 \(x\),就要求 \([l+x-1,r]\) 内出现两个结尾
在 SAM 上即对应 \(endpos\) 集合
现在问题变成了 SAM 上要找出一个对应 \(len\) 最大的节点,使它代表的 \(endpos\) 集合中有两个位置出现在指定区间内
考虑什么样的位置对会对答案造成贡献
在 parent tree 上,子节点的 \(len\) 比父节点要大,如果子节点子树内,已经有两个位置被区间包含了,那么在父节点处,这对不贡献
所以现在目标变成了对于树上每个点 \(x\) 找出这样的支配对,使得这两点必须来自 \(x\) 的不同子树,且序列上不完全包含其它的
维护 \(endpos\) 集合,使用 set
启发式合并,每次合并时,找到新插入点的前驱和后继,则它们构成支配对
不难由启发式合并的复杂度分析,得出支配对总共有 \(O(n\log n)\) 对
那么把这些支配对左端点位置在右端点所在主席树上更新贡献,查询时先二分答案,再在主席树上二分判定
时空复杂度均为 \(O(n\log^2n)\),支持在线
struct SAM
{
int ch[N][26], root = 1, idx = 1, las = 1, fa[N], len[N], id[N];
vector<int> edge[N]; set<int> ep[N];
void insert(int pos, int c)
{
int p = las, np = ++idx; las = np;
len[np] = len[p] + 1; ep[np].insert(pos);
for(; !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) {fa[np] = root; return;}
int q = ch[p][c];
if(len[q] == len[p] + 1) {fa[np] = q; return;}
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[nq])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
void merge(int &x, int &y, int l)
{
if(ep[x].size() < ep[y].size()) swap(x, y);
for(iter it = ep[y].begin(); it != ep[y].end(); ++it)
{
iter pre = ep[x].lower_bound(*it), nxt = ep[x].upper_bound(*it);
if(nxt != ep[x].end()) zpd[*nxt].pb({*it, l});
if(pre != ep[x].begin()) zpd[*it].pb({*prev(pre), l});
}
ep[x].insert(ep[y].begin(), ep[y].end());
}
void dfs(int x)
{
for(int y : edge[x])
{
dfs(y);
if(x != root) merge(id[x], id[y], len[x]);
}
}
void build()
{
for(int i = 1; i <= idx; ++i) edge[fa[i]].pb(i), id[i] = i;
dfs(root);
}
}sam;
struct chairmantree
{
int ls[M], rs[M], mx[M], idx;
int update(int pre, int id, int val, int l, int r)
{
int p = ++idx;
ls[p] = ls[pre], rs[p] = rs[pre], mx[p] = max(mx[pre], val);
if(l == r) return p;
int mid = (l + r) >> 1;
if(mid >= id) ls[p] = update(ls[p], id, val, l, mid);
else rs[p] = update(rs[p], id, val, mid + 1, r);
return p;
}
int query(int l, int r, int nl, int nr, int p)
{
if(!p) return 0;
if(l <= nl && nr <= r) return mx[p];
int mid = (nl + nr) >> 1, res = 0;
if(mid >= l) res = max(res, query(l, r, nl, mid, ls[p]));
if(mid < r) res = max(res, query(l, r, mid + 1, nr, rs[p]));
return res;
}
}tree;
int main()
{
cin >> n >> q >> (s + 1);
for(int i = 1; i <= n; ++i) sam.insert(i, s[i] - 'a');
sam.build();
for(int i = 1; i <= n; ++i)
{
root[i] = root[i - 1];
for(pii x : zpd[i])
root[i] = tree.update(root[i], x.fi, x.se, 1, n);
}
for(int i = 1; i <= q; ++i)
{
cin >> li >> ri;
int l = 0, r = ri - li + 1;
while(l < r)
{
int mid = (l + r + 1) >> 1;
if(tree.query(li + mid - 1, ri, 1, n, root[ri]) >= mid) l = mid;
else r = mid - 1;
}
cout << l << "\n";
}
return 0;
}
CF1063F String Journey
DP 好题
首先翻转序列,变为选有序串组使前一个为后一个的子串
性质 1:答案不超过 \(O(\sqrt n)\)
个数最多时,串长为 \(1,2,3,\dots\),最多只有 \(O(\sqrt n)\) 个
性质 2:最优方案中串的长度肯定是 \(1,2,\dots,k\)
如果某两个串中间空了一个长度,那么把后面一个串删掉一个字母不影响答案,且可能匹配后面的更多字符串
性质 3:\(t_i\) 一定由 \(t_{i-1}\) 在首/尾增加一个字母得到
由性质 2 得长度增加 1
现在已经可以得到 \(O(n\sqrt n)\) 的做法,直接暴力哈希记录每种长度的字符串,优化卡常即可
但是此题可以线性(忽略 \(|\Sigma|\))
建出翻转后串的 SAM,我们考虑 DP,设 \(f_i\) 表示 \(t_k\) 以 \(i\) 结尾方案中最大的 \(k\)
性质 4:\((i-1)-f_{i-1}+1\le i-f_i+1\),即 \(i\) 处 \(t_k\) 的开头比 \(i-1\) 处的更靠后
反证法,如果不是,则能把 \(i-1\) 的开头往左移至 \(i-f_i+1\),使 \(f_{i-1}\) 更大
既然右端点递增的同时左端点不降,就可以双指针
\(r\) 向右一位,设它在 SAM 上对应的状态为 \(p\)
它前一个字符串应该是 \([l+1,r]\) 或 \([l,r-1]\),以 \([l,r-1]\) 为例,它在 SAM 上对应状态的 \(endpos\) 集合中,存在 \(<l\) 的位置 \(i\),\(f_i\ge r-l\),当前检查这两个串,看它们是否能满足条件,若不满足则 \(l\gets l+1\)
由于 \(l\) 单调移动,因此每次 \(l+1\) 时,我们把 \(f_l\) 对应字符串在 SAM 上更新,暴力从它对应的最优串的状态开始,一路跳 \(fa\),它的每个后缀合法,且 \(f\) 应该取到了对应状态的 \(len\),因为 \(len\le f_l\)
记录每个状态对应的这样的最大 \(f\) 为 \(g_x\),尝试将 \(g_x\) 更新,如果遇到了更大的 \(g\) 或者是 \(g\) 已经与 \(len\) 相等就停止,因为上面的若 \(g\) 更大显然不用更新,若 \(g_x=len_x\) 则祖先处也满足 \(g_x=len_x\),注意最先开始的状态不一定满足 \(g=len\),要用 \(f_l\) 去更新
这样发现 SAM 上每个节点被访问到常数次,时间复杂度均摊 \(O(n)\)
struct SAM
{
int ch[N][26], len[N], fa[N], f[N], idx = 1, las = 1, root = 1;
void insert(int c)
{
int p = las, np = ++idx; las = np;
len[np] = len[p] + 1;
for(; !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) {fa[np] = root; return;}
int q = ch[p][c];
if(len[q] == len[p] + 1) {fa[np] = q; return;}
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
void upd(int p, int val)
{
f[p] = max(f[p], val);
for(p = fa[p]; p && f[p] < len[p]; p = fa[p]) f[p] = len[p]; // fa[p] should all be full
}
void work()
{
int l = 1, p = root, q = root;
for(int r = 1; r <= n; ++r) // p:[l,r] fa[p]:[l+1,r] q:[l,r-1]
{
q = p, p = ch[p][s[r] - 'a'];
while(l < r)
{
if(max({f[p], f[fa[p]], f[q]}) >= r - l) break;
upd(abl[l], ans[l]), ++l; // enable [l-ans[l]+1,l]
while(p && len[fa[p]] > r - l) p = fa[p]; // find new state
while(q && len[fa[q]] >= r - l) q = fa[q];
}
ans[r] = r - l + 1, abl[r] = p, sum = max(sum, ans[r]);
}
}
}sam;
int main()
{
cin >> n >> (s + 1);
reverse(s + 1, s + n + 1);
for(int i = 1; i <= n; ++i) sam.insert(s[i] - 'a');
sam.work();
cout << sum;
return 0;
}
CF700E Cool Slogans
先分析性质,发现前一个串肯定是后一个串的 border,不然可以把后一个串开头结尾多余部分去掉,显然更优
建出 SAM,考虑在 parent tree 上 DP,设 \(f_i\) 表示以 \(i\) 节点代表最长串为结尾时的最大答案
如果父亲所代表的最后一层的串在它内出现了 \(\ge 2\) 次,它就可以接在父亲后,答案 \(+1\),否则不行,每个串的 \(endpos\) 集合可以用线段树合并维护,出现 \(2\) 次可以看作是先找到第一个出现位置,然后看后面还有没有
转移时同时记录一下当前最后放的是哪个节点,时间复杂度 \(O(n\log n)\)
为什么一定取这个节点的最长串?万一更短的后面还能接上更多呢?
由于 endpos 集合相同,不会有能包含短串但不能扩展长度后包含长串的,相当于更短的被长的完全「包含」了
此时取最长串能尽可能包含其它的,肯定更优
struct segtree
{
int sum[V], ls[V], rs[V], idx;
int newnode(int x) {return x ? x : ++idx;}
void pushup(int x) {if(x) sum[x] = sum[ls[x]] + sum[rs[x]];}
int update(int pos, int val, int l, int r, int p)
{
p = newnode(p);
if(l == r) return sum[p] += val, p;
int mid = (l + r) >> 1;
if(mid >= pos) ls[p] = update(pos, val, l, mid, ls[p]);
else rs[p] = update(pos, val, mid + 1, r, rs[p]);
pushup(p); return p;
}
int merge(int x, int y, int l, int r)
{
if(!x || !y) return x + y;
int p = newnode(0), mid = (l + r) >> 1;
if(l == r) return sum[p] = sum[x] + sum[y], p;
ls[p] = merge(ls[x], ls[y], l, mid), rs[p] = merge(rs[x], rs[y], mid + 1, r);
pushup(p); return p;
}
int find(int l, int r, int nl, int nr, int p)
{
if(l > r || r < 1 || !p) return 0;
if(nl == nr) return sum[p] ? nl : 0;
int mid = (nl + nr) >> 1, res = 0;
if(mid >= l && sum[ls[p]]) res = find(l, r, nl, mid, ls[p]);
if(mid < r && sum[rs[p]] && !res) res = find(l, r, mid + 1, nr, rs[p]);
return res;
}
}tree;
struct SAM
{
int root = 1, idx = 1, las = 1, buc[M], ch[M][27], len[M], f[M], fa[M], dfn[M], fr[M], cnt;
void insert(int pos, int c)
{
int p = las, np = las = ++idx;
len[np] = len[p] + 1;
rt[np] = tree.update(pos, 1, 1, n, rt[np]);
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) return void(fa[np] = root);
int q = ch[p][c];
if(len[q] == len[p] + 1) return void(fa[np] = q);
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; p && ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
void radixsort()
{
for(int i = root; i <= idx; ++i) ++buc[len[i]];
for(int i = 1; i <= idx; ++i) buc[i] += buc[i - 1];
for(int i = idx; i > 0; --i) dfn[buc[len[i]]--] = i;
}
void dfs()
{
radixsort();
for(int i = idx; i > root; --i) rt[fa[dfn[i]]] = tree.merge(rt[fa[dfn[i]]], rt[dfn[i]], 1, n);
f[root] = 0, fr[root] = root;
for(int i = root + 1; i <= idx; ++i)
{
int x = dfn[i], y = fr[fa[x]];
int pos = tree.find(1, n, 1, n, rt[x]);
if(y == root || tree.find(pos - len[x] + len[y], pos - 1, 1, n, rt[y])) f[x] = max(f[x], f[y] + 1), fr[x] = x;
else f[x] = max(f[x], f[y]), fr[x] = fr[y];
ans = max(ans, f[x]);
}
}
}sam;
int main()
{
cin >> n >> (s + 1);
for(int i = 1; i <= n; ++i) sam.insert(i, s[i] - 'a');
sam.dfs();
cout << ans;
return 0;
}
CF963D Frequency of String
经典结论:总长为 \(m\) 的互不相同的串,在长度为 \(n\) 的串建出的 SAM 上 \(endpos\) 集合大小为 \(O(n\sqrt m)\)
证明:根号分治,长度 \(>\sqrt m\) 的串,总数不超过 \(\sqrt m\) 个,长度 \(\le \sqrt m\) 的串,它们只有 \(O(\sqrt m)\) 种长度,而每种长度在 SAM 上的 \(endpos\) 集合总大小为 \(O(n)\)
这个好像可以在串随机时用
于是建出 SAM,然后维护出每个节点的 \(endpos\) 集合,找出相距最近的 \(k\) 个即可,复杂度 \(O(n\sqrt m)\)
UOJ608【UR #20】机器蚤分组
首先用 dilworth 定理,最长反链等于最小链覆盖,那么把字符串的包含关系抽象成一张 DAG,那么题目所求的等于选出最多的互不包含的字符串
推些性质
首先,这些互不相同的字符串长度相等
如果长度不同,找出最小的一个,那么它在末尾或开头增加一个字符得到的串,一定不在所选集合内,否则它就被包含了
那么它就可以把长度变长,以此类推
我们可以猜测一下:答案 \(\ge n-k+1\) 当且仅当长度为 \(k\) 的字符串互不相同
充分性显然,必要性则是反证,如果最长反链长度 \(\ge n-k+1\),则只用考虑长度为 \(x\le k\) 的串,已知有两个长为 \(k\) 的字符串相同了,则说明至少有 \(k-x+1\) 个长为 \(x\) 的字符串与另一个相同,于是得到答案 \(\le n-x+1-(k-x+1)=n-k<n-k+1\),产生矛盾
于是问题变成了找出最长的相等的两个串,转化为原串 \([l,r]\) 的后缀的最长公共前缀,注意端点不能超过 \(r\)
那么建出反串 SAM,两个后缀的最长公共前缀就是它们对应的节点在 parent tree 上的 lca 的 \(len\),于是想让 \(len\) 尽可能大,即 \(lca\) 尽可能深
这又是一个经典的问题了,采用第一个问题的解法,启发式合并维护 \(endpos\) 集合,找出 \(O(n\log n)\) 对支配对后扫描线
注意这里还要单独维护求出 LCP 后端点 \(>r\) 的情况
struct SAM
{s
int idx = 1, root = 1, las = 1, ch[M][27], fa[M], len[M], buc[M], ord[M], fr[M], rmq[20][M], dfn[M], lg[M], cnt;
vector<int> edge[M];
set<pii> enp[M];
void insert(int pos, int c)
{
int p = las, np = las = ++idx;
len[np] = len[p] + 1, fr[np] = pos;
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) return void(fa[np] = root);
int q = ch[p][c];
if(len[q] == len[p] + 1) return void(fa[np] = q);
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq;
for(; p && ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
void dfs(int x)
{
dfn[x] = ++cnt, ord[cnt] = x, rmq[0][cnt] = fa[x];
for(int y : edge[x]) dfs(y);
}
int lca(int x, int y)
{
if(dfn[x] > dfn[y]) swap(x, y);
if(x == y) return x;
int l = dfn[x] + 1, r = dfn[y], v = lg[r - l + 1];
return dfn[rmq[v][l]] < dfn[rmq[v][r - (1 << v) + 1]] ? rmq[v][l] : rmq[v][r - (1 << v) + 1];
}
void merge()
{
for(int i = 1; i <= idx; ++i) edge[fa[i]].pb(i);
dfs(root), lg[0] = -1;
for(int i = 1; i <= idx; ++i) lg[i] = lg[i >> 1] + 1;
// for(int i = 1; i <= idx; ++i) cerr << fr[i] << " " << dfn[i] << "\n";
// cerr << "\n";
for(int i = 1; i <= idx; ++i)
if(fr[i]) enp[i].insert({fr[i], i});
for(int j = 1; j <= 17; ++j)
for(int i = 1; i + (1 << j) - 1 <= idx; ++i)
{
int ln = rmq[j - 1][i], rn = rmq[j - 1][i + (1 << (j - 1))];
rmq[j][i] = dfn[ln] < dfn[rn] ? ln : rn;
}
auto ins = [&](int x, int y) -> void
{
if(fr[x] > fr[y]) swap(x, y);
upd[fr[y]].pb({fr[x], fr[y], len[lca(x, y)]});
};
for(int i = idx; i > 1; --i)
{
int x = ord[i], y = fa[x];
if(enp[x].size() > enp[y].size()) swap(enp[x], enp[y]);
for(iter it = enp[x].begin(); it != enp[x].end(); ++it)
{
iter nw = enp[y].lower_bound(*it);
if(nw != enp[y].end()) ins(it -> se, nw -> se);
if(nw != enp[y].begin()) ins(prev(nw) -> se, it -> se);
}
enp[y].insert(enp[x].begin(), enp[x].end());
}
}
}sam;
struct info {int mx, mnr;};
info operator + (const info &x, const info &y) {return {max(x.mx, y.mx), min(x.mnr, y.mnr)};}
struct segtree
{
int rel[N];
info node[N << 2];
int ls(int x) {return x << 1;}
int rs(int x) {return x << 1 | 1;}
void pushup(int x) {node[x] = node[ls(x)] + node[rs(x)];}
void build(int l, int r, int p)
{
if(l == r) return node[p] = {0, n + 1}, rel[l] = n + 1, void();
int mid = (l + r) >> 1;
build(l, mid, ls(p)), build(mid + 1, r, rs(p));
pushup(p);
}
void update(int pos, int val, int l, int r, int p)
{
if(l == r) return void(node[p].mx = max(node[p].mx, val));
int mid = (l + r) >> 1;
if(mid >= pos) update(pos, val, l, mid, ls(p));
else update(pos, val, mid + 1, r, rs(p));
pushup(p);
}
void modify(int pos, int val, int l, int r, int p)
{
if(l == r) return void(rel[pos] = node[p].mnr = val);
int mid = (l + r) >> 1;
if(mid >= pos) modify(pos, val, l, mid, ls(p));
else modify(pos, val, mid + 1, r, rs(p));
pushup(p);
}
info query(int l, int r, int nl, int nr, int p)
{
if(l <= nl && nr <= r) return node[p];
int mid = (nl + nr) >> 1; info res = {0, n + 1};
if(mid >= l) res = res + query(l, r, nl, mid, ls(p));
if(mid < r) res = res + query(l, r, mid + 1, nr, rs(p));
return res;
}
}tree;
int main()
{
cin >> n >> q >> (s + 1);
for(int i = 1; i <= q; ++i) cin >> u >> v, ask[v].pb({u, i}), ans[i] = v - u + 1;
for(int i = n; i > 0; --i) sam.insert(i, s[i] - 'a');
sam.merge();
for(int i = 1; i <= n; ++i) minr[i].insert(n + 1);
tree.build(1, n, 1);
for(int i = 1; i <= n; ++i)
{
for(node x : upd[i])
{
if(x.r + x.len - 1 > i)
{
upd[x.r + x.len - 1].pb(x), minr[x.l].insert(x.r);
if(tree.rel[x.l] > *minr[x.l].begin()) tree.modify(x.l, *minr[x.l].begin(), 1, n, 1);
}
else
{
if(minr[x.l].find(x.r) != minr[x.l].end())
{
minr[x.l].erase(minr[x.l].find(x.r));
if(tree.rel[x.l] < *minr[x.l].begin()) tree.modify(x.l, *minr[x.l].begin(), 1, n, 1);
}
tree.update(x.l, x.len, 1, n, 1);
}
}
for(pii x : ask[i])
{
info tmp = tree.query(x.fi, n, 1, n, 1);
ans[x.se] -= max(tmp.mx, i - tmp.mnr + 1);
}
}
for(int i = 1; i <= q; ++i) print(ans[i]), putchar('\n');
return 0;
}
【ULR #1】打击复读
建出正反串的 SAM,修改左权值,考虑对答案的增量,在反串 SAM 上 endpos 集合包含此位置的节点 \(x\),答案会增加 \(v\times \sum_{i\in endpos_x} wr_i\times siz_x\),即这个位置所在节点 parent tree 上到根的路径上节点答案都会增加
初始计算答案时,节点 \(x\) 处答案为 \(\sum wl\times \sum wr\times siz_x\),需要知道反串 SAM 上节点对应的正串 SAM 上的节点
那么正串 SAM 上节点的 endpos 集合就是反串 SAM 上左端点 endpos 集合对应的所有右端点,毕竟它们代表的是同一字符串集合
如果倍增找出对应节点并计算答案,每次进行链加,复杂度为 \(O(n\log n+m)\)
此题还可以做到线性
对正串 SAM 来说,往字符串后面加一个字符,是走一条转移边,往前面加一个字符,是走到 parent tree 上的一个子节点
考虑反串 SAM 上节点 \(x\) 究竟对应正串 SAM 上哪些节点,发现它应该是正串 SAM 上自动机的一条链,因为 \(x\) 的 endpos 代表字符串长度在一个区间,是每次增加右端点形成的
但一条链看起来更不好找,其实找 \(x\) 对应的节点往下走的过程中正串 SAM 上不会发生 endpos 集合的分裂,一旦自动机上某个点有多条转移边,意味着它的后继都不是 \(x\) 对应的节点,因为 endpos 集合分裂,反串 SAM 上对应的左端点集合也会不同
于是维护这样的单链的 \(\sum wr\times siz\),在反串 SAM 的 parent tree 上 dfs,走到子节点时就意味着走到自动机分岔处对应的节点
这样就能做到 \(O(n)\) 计算答案,维护答案增量还是同上,复杂度 \(O(n+m)\)
把正串 SAM 上 DAG 的单链缩起来,发现它长得跟反串 SAM 的 parent tree 差不多
太深刻
struct SAM
{
int idx = 1, las = 1, root = 1, ch[M][4], num[M], book[M], ed[N], len[M], fa[M], fr[M], nxt[M], buc[M], ord[M];
ull swl[M], swr[M], siz[M];
vector<int> edge[M];
void insert(int pos, int c)
{
int p = las, np = las = ++idx;
len[np] = len[p] + 1, fr[np] = pos, ed[pos] = np, siz[np] = 1;
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = np;
if(!p) return void(fa[np] = root);
int q = ch[p][c];
if(len[q] == len[p] + 1) return void(fa[np] = q);
int nq = ++idx;
memcpy(ch[nq], ch[q], sizeof(ch[q])), len[nq] = len[p] + 1;
fa[nq] = fa[q], fa[q] = fa[np] = nq, fr[nq] = fr[q];
for(; p && ch[p][c] == q; p = fa[p]) ch[p][c] = nq;
}
void dfs(int x)
{
if(siz[x] == 1) swl[x] = wl[fr[x]], swr[x] = wr[fr[x]];
for(int y : edge[x])
dfs(y), swl[x] += swl[y], swr[x] += swr[y], siz[x] += siz[y];
}
void build()
{
for(int i = 1; i <= idx; ++i) edge[fa[i]].pb(i);
dfs(root);
int p = ed[n]; // 有代表原串后缀的点,一定是整个串结尾所在点 parent tree 上的祖先
for(; p; p = fa[p]) book[p] = 1;
}
void compress() // 压缩只有一条出边的点
{
for(int i = 1; i <= idx; ++i) ++buc[len[i]];
for(int i = 1; i <= idx; ++i) buc[i] += buc[i - 1];
for(int i = idx; i > 0; --i) ord[buc[len[i]]--] = i;
for(int i = idx; i > 0; --i)
{
int x = ord[i], num = 0, y = 0;
swr[x] *= siz[x]; // 多个相同串算多次
for(int j = 0; j < 4; ++j)
if(ch[x][j]) ++num, y = ch[x][j];
if(num != 1 || book[x]) nxt[x] = x; // 有多个后继,反串 sam 上它会导致 endpos 集合分裂;若包含串后缀,则不能再往后面添加字符
else nxt[x] = nxt[y], swr[x] += swr[y];
}
}
}sam1, sam2;
void Dfs(int x, int p) // 在反串 sam 上 x,正串 sam 上 p,dfs 正串后缀树
{
for(int y : sam2.edge[x])
{
int c = a[sam2.fr[y] + sam2.len[x]], np = sam1.ch[p][c];
f[y] = f[x] + sam1.swr[np];
Dfs(y, sam1.nxt[np]);
}
}
int main()
{
cin >> n >> m >> (s + 1);
to['A'] = 0, to['C'] = 1, to['G'] = 2, to['T'] = 3;
for(int i = 1; i <= n; ++i) a[i] = to[s[i]];
for(int i = 1; i <= n; ++i) cin >> wl[i];
for(int i = 1; i <= n; ++i) cin >> wr[i];
for(int i = 1; i <= n; ++i) sam1.insert(i, a[i]);
for(int i = n; i > 0; --i) sam2.insert(i, a[i]);
sam1.build(), sam2.build();
sam1.compress();
Dfs(sam2.root, sam1.root);
for(int i = 1; i <= n; ++i) ans += f[sam2.ed[i]] * wl[i];
print(ans), putchar('\n');
for(int i = 1; i <= m; ++i)
{
cin >> u >> v;
ans += f[sam2.ed[u]] * (v - wl[u]), wl[u] = v;
print(ans), putchar('\n');
}
return 0;
}