5月停课字符串做题记录
前言
max 给我们讲了两次 SAM,前一次把 SAM 讲得非常透彻,后一次让我们见了很多困难题,也算是让我们开了眼。现在(2025.5.14 9:50)我已经把讲过的题目全部做完,为了强化巩固我的思维,故写做题记录。
题目顺序为 max 讲课的课件中题目顺序。
熟悉的文章
不用说都知道对 \(m\) 个串建广义 \(\text{SAM}\),看到求串长最小值的最大值果断二分答案,考虑每段长度都有 \(\ge\text{mid}\) 的限制时最多能拼多长的字符串。
这个问题我们用 dp 解决。设 \(f_i\) 表示前 \(i\) 个字符串的答案,考虑转移。如果不用当前的字符,有 \(f_i\leftarrow f_{i-1}\);否则就要找一个 \(j\) 转移。考虑合法的 \(j\) 满足什么性质?假设我们找到一个 \(\text{minpos}\) 表示 \(i\) 在 \(\text{SAM}\) 中出现的最长串的左端点,那么 \(j\) 的范围就是 \([\text{minpos}-1,i-\text{mid}]\)。然后对于每个 \(i\) 的 \(\text{minpos}\) 我们其实是可以预处理的,并且容易发现其还是单调的,所以在二分答案里面 dp 的时候可以单调队列维护决策点。
点击查看代码
int chk(int l0){
q[h = 1] = t = 0;
for(int i = 1; i <= n; ++i){
f[i] = f[i - 1]; int j = i - l0;
if(j >= 0){while(h <= t and j - f[j] <= q[t] - f[q[t]])--t; q[++t] = j;}
if(h <= t and q[h] < g[i] - 1)++h;
if(h <= t)f[i] = max(f[i], f[q[h]] + i - q[h]);
}
return f[n];
}
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> m >> n;
for(int i = 1; i <= n; ++i){
cin >> s; for(char c : s)ins(c ^ 48);
ins(2);
}
while(m--){
cin >> s; n = s.size(); int lim = (n * 9 - 1) / 10 + 1, l = 0, r = n, res = 0;
for(int i = 1, p = 1; i <= n; ++i){
for(; p and ! ch[p][s[i - 1] ^ 48]; p = fa[p]);
g[i] = max(g[i - 1], i - len[p]); p = ch[p][s[i - 1] ^ 48];
if(! p)p = 1, g[i] = i + 1;
}
while(l <= r){
int mid = l + r >> 1;
if(chk(mid) >= lim)res = mid, l = mid + 1; else r = mid - 1;
}
cout << res << endl;
}
return 0;
}
字符串
这道题我是用 \(\text{SA}\) 做的。
这里提供两种做法和一种做法的代码,但其实这两种做法都大同小异,本质上是相同的。
SA
首先选择的右端点抵上限不会对答案造成影响,于是变成选择一个后缀。
答案满足单调性可以二分答案,假设当前分到的为 \(\text{mid}\),我们只需要判断此时是否合法即可。我们套路性地把 \(\text{height}\) 求出来,并处理 st 表,把 lcp 转成求 \(\text{height}\) 的区间最小值。现在我们要在 \([l1,r1]\) 中选出一个点要求 lcp 最长。考虑把所有位置放在 sa 上。现在我有一些离散的点 \(a_i\) 和一个目标点 \(x\),我需要从离散的点中选择一个点 \(y\) 满足 \(\min\limits_{i 在 x 和 y 之间}(\text{height}_i)\) 最大。这满足支配的性质,所以我们找前驱和后继即可。
具体实现的时候我们对 sa 建主席树,在二分答案里面可以使用二分法找到所有点满足条件的前驱后继。现在我有了一个区间,我就在询问区间的主席树中查找刚刚得到的区间是否有数,如果有就说明满足条件,此时的答案可行。
点击查看代码
namespace tree{
int rt[N], t[N * 60], nd, ls[N * 60], rs[N * 60];
inline void mdf(int &x, int y, int l, int r, int pos){
if(! x)x = ++nd; t[x] = t[y] + 1; int mid = l + r >> 1; if(l == r)return;
if(pos <= mid)rs[x] = rs[y], mdf(ls[x], ls[y], l, mid, pos);
else ls[x] = ls[y], mdf(rs[x], rs[y], mid + 1, r, pos);
}
inline int qry(int x, int y, int l, int r, int ql, int qr){
if(ql <= l and r <= qr)return t[y] - t[x]; int mid = l + r >> 1, res = 0;
if(ql <= mid)res = qry(ls[x], ls[y], l, mid, ql, qr);
if(mid < qr)res += qry(rs[x], rs[y], mid + 1, r, ql, qr);
return res;
}
inline int ask(int l, int r, int ql, int qr){return qry(rt[l - 1], rt[r], 1, n, ql, qr);}
}
using namespace tree;
bool chk(int len, int ql, int qr, int p){
int l = 1, r = rk[p], lp = 0, rp = 0;
while(l < r){
int mid = l + r >> 1;
if(get(mid + 1, rk[p]) < len)l = mid + 1; else r = mid;
}
lp = r; l = rk[p], r = n;
while(l < r){
int mid = l + r + 1 >> 1;
if(get(rk[p] + 1, mid) < len)r = mid - 1; else l = mid;
}
rp = r;
return ask(lp, rp, ql, qr - len + 1) > 0;
}
const string FileName = "";
signed main(){
scanf("%d %d %s", &n, &q, s + 1); SA(); init();
for(int i = 1; i <= n; ++i)mdf(rt[i], rt[i - 1], 1, n, sa[i]);
for(int i = 1, l, r, ql, qr; i <= q; ++i){
scanf("%d %d %d %d", &l, &r, &ql, &qr);
int lp = 0, rp = min(r - l + 1, qr - ql + 1), res = 0;
while(lp <= rp){
int mid = lp + rp >> 1;
if(chk(mid, l, r, ql))res = mid, lp = mid + 1; else rp = mid - 1;
}
printf("%d\n", res);
}
return 0;
}
SAM
没写过尝试口胡一下。
首先翻转原串变成求 lcs,然后将要求的东西写成数学的形式:
最外面的 \(\min\) 不用管它,里面最大最小直接二分答案。然后把后面的 lcs 看成 parent tree 上 lca 的 len,于是就把问题转化成求一些点到某个点的 lca 要求深度最大。这不就满足支配的性质了吗?于是我去找 \(\text{dfn}\) 的前驱后继即可。
你的名字
首先我们可以进行一个简单的容斥,用所有本质不同减去出现过的。对于前者,我们可以用 sa 随便做一下。现在考虑后者。
对于 \(T\) 的每个 \(i\),我们可以找到最小的 \(j\) 使 \(T[j\dots i]\) 在 \(S[l\dots r]\) 中出现过。显然 \(j\) 具有单调性所以双指针扫过去即可。
具体做法就是对 SAM 上的点维护 endpos 集合,这个可以可持久化线段树合并预处理做到在线。然后就去双指针扫 \(T\),同时记录一个 p 表示当前在 sam 的 p 点处。每次就查询 p 中的信息即可。
点击查看代码
struct tree{
#define lson ls[x], l, mid
#define rson rs[x], mid + 1, r
int rt[N], nd, ls[N * 50], rs[N * 50], t[N * 50];
bool tg[N * 50];
void mdf(int &x, int l, int r, int pos, int y){
tg[x = ++nd] = true; t[x] = y;
if(l == r)return; int mid = l + r >> 1;
pos <= mid ? mdf(lson, pos, y) : mdf(rson, pos, y);
}
void mrg(int &x, int l, int r, int y, int z){
if(! x or ! y)return x |= y, void();
if(t[x] ^ z)t[++nd] = t[x], ls[nd] = ls[x], rs[nd] = rs[x], tg[nd] = tg[x], x = nd;
int mid = l + r >> 1; mrg(lson, ls[y], z); mrg(rson, rs[y], z);
tg[x] = tg[ls[x]] | tg[rs[x]];
}
bool qry(int x, int l, int r, int ql, int qr){
if(! x or ql <= l and r <= qr)return tg[x]; int mid = l + r >> 1;
return (ql <= mid and qry(lson, ql, qr)) or (mid < qr and qry(rson, ql, qr));
}
}T;
struct sam{
int fa[N], ch[N][26], len[N];
int tot = 1, ep = 1;
vector < int > g[N];
void ins(int c, int i){
int u = ep; ep = ++tot; len[ep] = len[u] + 1;
T.mdf(T.rt[ep], 1, n, i, ep);
for(; u and ! ch[u][c]; u = fa[u])ch[u][c] = ep;
if(! u)return fa[ep] = 1, void();
int v = ch[u][c];
if(len[u] + 1 == len[v])return fa[ep] = v, void();
int x = ++tot; len[x] = len[u] + 1; fa[x] = fa[v];
for(int i = 0; i < 26; ++i)ch[x][i] = ch[v][i];
fa[v] = fa[ep] = x;
for(; u and ch[u][c] == v; u = fa[u])ch[u][c] = x;
}
void dfs(int u){for(int v : g[u])dfs(v), T.mrg(T.rt[u], 1, n, T.rt[v], u);}
void init(){
for(int i = 1; i <= n; ++i)ins(s[i] - 'a', i);
for(int i = 2; i <= tot; ++i)g[fa[i]].push_back(i);
dfs(1);
}
}s1;
...
bool qry(int x, int l, int r){return l <= r and T.qry(T.rt[x], 1, n, l, r);}
signed main(){
// freopen("tst.in", "r", stdin);
// freopen("tst.out", "w", stdout);
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> s + 1 >> q; n = strlen(s + 1); s1.init();
while(q--){
int l, r; cin >> t + 1 >> l >> r;
m = strlen(t + 1); s2.init(); ll res = 0;
for(int i = 1, j = 1, p = 1; i <= m; ++i){
j = max(j, i);
if(j - i == s1.len[s1.fa[p]])p = s1.fa[p]; if(! p)p = 1;
for(; j <= m; ++j){
int x = s1.ch[p][t[j] - 'a'];
if(! qry(x, l + j - i, r))break; p = x;
}
res += min(m - j + 1, m - i + 1 - s2.h[s2.rk[i]]);
}
cout << res << endl;
}
return 0;
}
Repeats
设一个串 \(T\) 由长度为 \(x\) 的串重复 \(y\) 次得到,有 \(T[1\dots x(y-1)]=T[x+1\dots xy]\)。所以如果在 \(i\) 和 \(j\) 处都出现了 \(T\),其答案为 $\left \lfloor len+i-j\over i-j\right \rfloor $。所以我们需要维护 \(\text{endpos}\) 中最近的两个点,这个可以线段树合并直接做。
线段树上肯定要维护答案。考虑合并,一个区间的答案可能来自其中一个子区间,还可能是左边的最右点和右边的最左点。所以还要维护区间左右点。
点击查看代码
struct tree{
#define lson ls[x], l, mid
#define rson rs[x], mid + 1, r
int rt[N], nd, ls[N * 50], rs[N * 50], ma[N * 50], mi[N * 50], res[N * 50];
inline void upd(int x){
mi[x] = mi[ls[x]] ? mi[ls[x]] : mi[rs[x]];
ma[x] = ma[rs[x]] ? ma[rs[x]] : ma[ls[x]];
if(res[ls[x]])res[x] = res[ls[x]];
if(res[rs[x]])res[x] = min(res[x] ? res[x] : inf, res[rs[x]]);
if(ma[ls[x]] and mi[rs[x]])res[x] = min(res[x] ? res[x] : inf, mi[rs[x]] - ma[ls[x]]);
}
void mdf(int &x, int l, int r, int pos){
x = ++nd; if(l == r)return ma[x] = mi[x] = pos, void(); int mid = l + r >> 1;
pos <= mid ? mdf(lson, pos) : mdf(rson, pos); upd(x);
}
int mrg(int x, int y){
if(! x or ! y)return x | y;
int nw = ++nd;
ls[nw] = mrg(ls[x], ls[y]); rs[nw] = mrg(rs[x], rs[y]);
return upd(nw), nw;
}
void clr(){for(int i = 1; i <= nd; ++i)rt[i] = ls[i] = rs[i] = ma[i] = mi[i] = res[i] = 0; nd = 0;}
}T;
喵星球上的点名
使用了投机取巧的广义 SAM 构建方法(用分隔符隔绝两个串),并对于 SAM 主链上的点染色。
第一问相当于是查询一个点的子树内有多少种颜色,第二问是一个颜色对多少询问有贡献。我们可以把树拍平成序列,在 dfn 上做扫描线。
第一问查询区间内颜色数,我们套路性的记录一个 las 表示上一个颜色与 \(i\) 相同的位置。新加入一个点后,只有 \(col_r\) 的贡献方式会改变。我们将左端点在 \((las_r,r]\) 的区间进行一个标记代表可以贡献答案,这个用树状数组就能做。
第二问依旧考虑扫描线。扫到一个询问的左端点就在 ds 对应的地方标记,扫到右端点就去掉标记。这样我们存的信息就是所有左端点到当前没结束的询问。我们查询当前点有哪些贡献,贡献只能在 \((las,r]\) 中。因为我们要考虑一个询问最先碰到的颜色,这样才能不重不漏。
点击查看代码
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> n >> m;
for(int i = 1, x, y; i <= n; ++i)for(int j = 0; j < 2; ++j){
cin >> x;
while(x--)cin >> y, ins(y), col[ep] = i;
ins(- 1);
}
for(int i = 2; i <= tot; ++i)g[fa[i]].push_back(i);
dfs(1);
for(int i = 1; i <= tot; ++i)pr[i] = bb[col[id[i]]], bb[col[id[i]]] = i;
for(int i = 1, x, p; i <= m; ++i){
cin >> x; p = 1;
bool o = false;
for(int j = 0; j < x; ++j){
int c; cin >> c;
if(! ch[p][c])o = true; else p = ch[p][c];
}
if(o)continue;
q1[dfn[p] + sz[p] - 1].push_back(make_pair(i, dfn[p]));
q2[dfn[p]].push_back(dfn[p]); q2[dfn[p] + sz[p]].push_back(- dfn[p]);
}
for(int i = 1; i <= tot; ++i){
if(col[id[i]]){upd(i, 1); if(pr[i] > 1)upd(pr[i], - 1);}
for(pii j : q1[i])ans[j.first] = ask(j.second, i);
}
for(int i = 1; i <= m; ++i)cout << ans[i] << endl, ans[i] = 0;
for(int i = 0; i <= tot; ++i)t[i] = 0;
for(int i = 1; i <= tot; ++i){
for(int j : q2[i])if(j > 0)upd(j, 1); else upd(- j, - 1);
ans[col[id[i]]] += ask(pr[i] + 1, i);
}
for(int i = 1; i <= n; ++i)cout << ans[i] << ' ';
return 0;
}
String Journey
这里提供 \(O(n)\) 做法。
考虑从小到大和从大到小等价,所以我们从小到大。观察可发现一个优美的性质:我们选出来的串长度是从 1 开始连续递增的,因为如果有多的字符就可以删去,这样更优。考虑 dp,设 \(f_i\) 表示以 \(i\) 结尾选了一个串最多选多少段。
这里有一个关键性质:\(i-1-f_{i-1}+1\le i-f_i+1\)。证明考虑有 \(f_{i-1}+1\le f_i\),因为每往后多加入一个位置最多只会让答案加一,否则与 f 定义矛盾。这个性质就说明了我们转移点是单调的,所以考虑双指针,并且同时维护当前在 SAM 上的状态 p。
每次 r 向后时,我们需要判断当前的答案是否合法,假设当前在 \([l,r]\),我们就要看在 \(S[1\dots l-1]\) 中是否有合法的、且与 \(S[l,r-1]\) 或者 \(S[l+1,r]\) 相等的子串。
考虑对于 SAM 上的点记录一个 \(g\) 表示这个点的最长合法子串长度,并且我们要求 \(g\) 只考虑小于 \(l\) 的时候,因为我们想要查询的状态是 \([1,l)\) 里的。所以每次就看最大的满足要求的 \(g\)(上一段所述情况)是否大于等于当前答案。如果满足条件就说明可以更新答案,否则就在 SAM 上走。注意我们的 \(l\) 发生改变需要更新 \(g\),注意到 \(g\) 是子树求和,所以每次更新需要跳“父亲”(*)。但是这就是 \(\log\) 了啊,如何 \(O(n)\)?
因为一个点的子树中的等价类的 \(len\) 一定大于自己的,所以当下面的节点发生更新操作时,这个点一定会被更新,但是每个地方的 \(g\) 最大只能顶着 \(len\) 这个上界,所以当有 \(g\) 抵着上界就说明前面的全部顶上界,不需要继续更新。所以每个点只会更新 2 次,均摊复杂度 \(O(n)\)。
(*):注意这里的“父亲”指的是一个点的合法状态从哪里转移来的,因为 \(g\) 的定义是合法子串的最长长度,所以修改 \(g\) 的时候其前缀信息也会改变。
点击查看代码
inline void upd(int u, int t){
if(g[u] >= t)return; g[u] = t, u = fa[u];
for(; u and g[u] != len[u]; u = fa[u])g[u] = len[u];
}
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> n >> s + 1; for(int i = 1; i * 2 <= n; ++i)swap(s[i], s[n - i + 1]);
for(int i = 1; i <= n; ++i)ins(s[i] - 'a');
int l = 1, p = 1;
for(int r = 1; r <= n; ++r){
int lp = p, rp = fa[p = ch[p][s[r] - 'a']];
while(l < r){
if(max(max(g[lp], g[rp]), g[p]) >= r - l)break;
upd(jp[l], f[l]); ++l;
if(len[fa[lp]] >= r - l)lp = fa[lp];
if(len[rp] > r - l)p = fa[p], rp = fa[rp];
}
jp[r] = p; res = max(res, f[r] = r - l + 1);
}
cout << res;
return 0;
}
Border 的四种求法
考虑暴力。我们需要枚举 i,去看前缀 i 和 r 的 lcs,写成 \(\max\) 的形式就是:
我们把后面的东西看成对 parent tree 上 lca 的限制做,此时需要枚举 lca。相当于现在确定一个点,我们枚举从根到其的路径上的点 \(u\)(作为 lca),限制为 \(i-len_u<l\)。对于询问从根到点的路径信息,可以考虑树剖。
先考虑重链上的点,如果其作为 lca 说明我们在其轻子树中选择了一个点或者选择自己,我们就暴力维护所有信息。也许你会有疑问:直接维护所有信息不会炸吗?考虑一个点被加的次数等于其到根的轻重链切换数,这是 \(\log\) 的,所以这部分时间复杂度是 \(O(n\log^2n)\)。考虑查询,首先我们是查询重链的前缀信息,所以我们可以离线然后 dfs 扫一遍;我们用线段树维护下标,查询的时候就带着两个限制在线段树上面二分就行。
还有轻重链切换的情况,这时候上一段重链“结束”的点 \(u\) 情况特殊,因为新的一条重链一定是在原来的点的轻子树中。如果我们要分类统计就 T 飞了,所以直接算整个子树。这样为什么是对的呢?考虑如果统计到一个点 \(x\) 是在 \(r\) 所在的 \(u\) 的子树中,那么我们后面还会对其进行统计。后面的统计是合法的,现在的统计是非法的(雾),可以发现合法的统计的时候 \(len\) 会更大,所以第二个限制会更加严格,于是便不会影响答案了。实际做的时候可以线段树合并把这部分预处理出来。线段树的查询与上面相同,故不再赘述。
实际写线段树的时候可以用一个结构体,因为这两棵线段树的操作类似。注意查询的时候要带两个限制,并且判断边界也要判两个东西,不然时间复杂度就假了。
点击查看代码
struct tree{
int rt[N << 1], ls[N * 50], rs[N * 50], mi[N * 50], nd;
inline tree(){for(int i = 0; i < N * 50; ++i)mi[i] = inf;}
inline void upd(int x){mi[x] = min(mi[ls[x]], mi[rs[x]]);}
void mdf(int &x, int l, int r, int pos, int y){
if(! x)x = ++nd; if(l == r)return mi[x] = min(mi[x], y), void(); int mid = l + r >> 1;
pos <= mid ? mdf(ls[x], l, mid, pos, y) : mdf(rs[x], mid + 1, r, pos, y); upd(x);
}
int mrg(int x, int y){
if(! x or ! y)return x | y; int nw = ++nd;
ls[nw] = mrg(ls[x], ls[y]); rs[nw] = mrg(rs[x], rs[y]);
return upd(nw), nw;
}
int qry(int x, int l, int r, int pos, int lim){
if(pos <= l or ! x or mi[x] >= lim)return 0; if(l == r)return mi[x] < lim ? l : 0;
int mid = l + r >> 1, res = qry(rs[x], mid + 1, r, pos, lim);
if(! res)res = qry(ls[x], l, mid, pos, lim); return res;
}
}t1, t2;
其中 t1 记录子树信息,t2 记录重链前缀信息。对于 t1 因为我们的限制里已经确定了 \(l\) 和 \(len\) (\(len\) 就是当前点的 \(len\))所以只用维护所有下标;对于 t2 我们不清楚 lca 是谁所以维护 \(i-len\)。实现 t1 就是果的线段树合并预处理,然后每次询问就跳重链然后查“链底”。对 t2 是离线做,考虑每次条链我们都要查询一段前缀,于是我们把每个询问挂到前缀的终点处,之后 dfs 的时候就把所有信息挂到当前的 top 处查询即可。
点击查看代码
void dfs1(int u){
if(id[u])t1.mdf(t1.rt[u], 1, n, id[u], id[u]);
sz[u] = 1;
for(int v : g[u]){
dfs1(v); t1.rt[u] = t1.mrg(t1.rt[u], t1.rt[v]);
sz[u] += sz[v]; if(sz[son[u]] < sz[v])son[u] = v;
}
}
void dfs2(int u, int tp){
top[u] = tp; lp[u] = ++tim; rk[tim] = u;
if(id[u])ms[u].push_back(make_pair(id[u], id[u] - len[u]));
if(son[u])dfs2(son[u], tp);
for(int v : g[u])if(v ^ son[u]){
dfs2(v, v);
for(int i = lp[v]; i <= rp[v]; ++i)
if(id[rk[i]])ms[u].push_back(make_pair(id[rk[i]], id[rk[i]] - len[u]));
}
rp[u] = tim;
}
void dfs3(int u){
for(pii i : ms[u])t2.mdf(t2.rt[top[u]], 1, n, i.first, i.second);
for(qay i : qs[u])ans[i.id] = max(ans[i.id], max(0, t2.qry(t2.rt[top[u]], 1, n, i.r, i.l) - i.l + 1));
for(int v : g[u])dfs3(v);
}
int tim0, tim1, tim2, tim3, tim4;
signed main(){
// freopen("tst.in", "r", stdin);
// freopen("tst.out", "w", stdout);
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
tim0 = clock();
cin >> s + 1; n = strlen(s + 1);
for(int i = 1; i <= n; ++i)ins(s[i] - 'a', i);
for(int i = 2; i <= tot; ++i)g[fa[i]].push_back(i);
tim1 = clock(); cerr << (tim1 - tim0) * 1.0 / CLOCKS_PER_SEC << endl;
dfs1(1); dfs2(1, 1); cin >> q; tim2 = clock();cerr << (tim2 - tim1) * 1.0 / CLOCKS_PER_SEC << endl;
for(int i = 1, l, r, u; i <= q; ++i){
cin >> l >> r; qay nw = {i, l, r}; u = pos[r];
while(u){
ans[i] = max(ans[i], t1.qry(t1.rt[u], 1, n, r, l + len[u]));
qs[u].push_back(nw); u = fa[top[u]];
}
ans[i] = max(0, ans[i] - l + 1);
}
tim3 = clock();cerr << (tim3 - tim2) * 1.0 / CLOCKS_PER_SEC << endl;
dfs3(1);
tim4 = clock();cerr << (tim4 - tim3) * 1.0 / CLOCKS_PER_SEC << endl;
for(int i = 1; i <= q; ++i)cout << ans[i] << endl;
return 0;
}
珠宝商
考虑暴力怎么做?我们枚举树上的点作为起点,然后在 \(\text{dfs}\) 的过程中同时在 \(\text{SAM}\) 上 走。如果 \(\text{SAM}\) 上走不下去就说明串已经不在 \(S\) 中,否则就加上 \(\text{SAM}\) 上当前状态的 \(\text{endpos}\) 集合大小。这样是 \(O(n^2)\) 的。
考虑我们要统计树上所有的路径于是点分治启动!若当前分治中心为 \(u\),我们需要统计出跨过 \(u\) 的答案。串从分治中心分成两个部分,相当于要求出这两个串在原串中在一起的方案数。这两个东西显然独立,我们设 \(f_i\) 表示有多少个一个点到根的字符串以 \(i\) 结尾,设 \(g_i\) 表示有多少个一个点到根的字符串以 \(i\) 开头,答案为 \(\sum\limits_{i=1}^mf_i\times g_i\)。
对 \(g\) 的处理相当于是在反串上处理 \(f\),所以只考虑 \(f\)。求 \(f\) 的过程还是 \(\text{dfs}\),只是每次我们在原来的字符串前面加字符,然后这个位置对应 \(\text{SAM}\) 上的点的 \(\text{cnt}\) 就加一。因为 \(\text{SAM}\) 上的点相对其子树是一个后缀,所以 \(\text{dfs}\) 结束后还要在 \(\text{SAM}\) 上 \(\text{dfs}\) 一遍将 \(\text{cnt}\) 下放。现在先讲怎么处理在原来的字符串前面加字符。
假设现在在 \(u\),我们需要往当前串的前面加入 \(c\),此时要分两种情况讨论。因为一个点代表了若干的串,而我们对一个点只记录了 \(\text{maxlen}\),所以要讨论字符串长度与其的关系:
- 当前串长等于 \(\text{maxlen}\),说明我再往前加一个字符就会跳到其他点,我们就需要找一个点 \(v\) 满足 \(fa_v=u\) 且 \(S[\text{rpos}_v-\text{len}_u]=c\),其中 \(\text{rpos}_v\) 表示 \(v\) 的 \(\text{endpos}\) 中任意一点(任一点都是等价的)。如果有就能够跳,否则新串就不是 \(S\) 的子串了。
- 如果当前串长小于 \(\text{maxlen}\),我们就看 \(S[\text{rpos}_u-|T|]\) 是否等于 \(c\),其中 \(T\) 为原串,\(|T|\) 表示其长度。如果不等于就说明新串就不是 \(S\) 的子串,否则不变。
我们对所有的情况一都预处理出来,存在 \(\text{son}\) 中即可。
最后还要讲一下容斥。就是说我们的 \(f\) 和 \(g\) 的贡献有都来自同一个子树的,我们需要减去这部分贡献。具体的,我们去枚举 \(u\) 的每个子树 \(v\),正常计算答案即可。只不过我们处理的时候已经确定了最开始两个字符(也就是 \(u,v\) 对应的字符)所以要在 \(\text{SAM}\) 上先走一步。
分治加上遍历树是 \(O(n\log n)\),但是每次我们统计答案的时候都枚举了 \(m\),所以还有一个 \(O(nm)\),这样我们的复杂度成功劣于 \(O(n^2)\) 暴力。
因为在 \(n\) 很小的时候暴力更优秀,而 \(n\) 较大的时候点分治更好,所以我们把两个算法结合一下,我们分治的时候判一下当前分治部分的大小,如果小于 \(B\) 就跑暴力,否则正常点分治。其中 \(B\) 的设定是根号级别的,复杂度我不太会证只会感性理解现在再次分析复杂度。
我们知道每次分治点数会减半,考虑第 \(k\) 层区间能用暴力计算,用暴力计算的区间有 \(\le\sqrt n\) 个,所以暴力带来的总复杂为 \(O(n\sqrt n)\),而上面的 \(k-1\) 层一共 \(O(\sqrt n)\) 个区间,所以分治的复杂度为 \(O(m\sqrt n+n\log n)\)。
最后要注意的就是容斥的时候也要考虑根号分治,否则如果图是菊花图就 T 飞了。
因为代码太长就讲一下实现。因为要维护 \(f\) 和 \(g\) 所以我们需要两个 \(\text{SAM}\),于是就写成结构体的形式。注意每个 \(\text{SAM}\) 都要对 \(\text{parent tree}\) 建边,所以存边的东西要放在结构体内部!
然后就是普通的暴力和容斥的暴力应该怎么写。普通的暴力就是外层一个 \(\text{dfs}\) 去遍历所有起点,内层的就是从一个起点开始不停往后加字符;但是在写容斥的暴力的时候,我们一定要明白我们需要做什么?我们要把前缀和后缀在同一个子树内的贡献减去。也就是需要找到所有形如 \(T1a_va_ua_vT2\) 的字符串,其中 \(a\) 为树上的点对应的字符,\(T1,T2\) 是任意的子串。所以我们外层搜索的时候就有了限制,我们考虑固定两个点然后去搜索,每次内层统计答案的时候就固定从 \(v\) 开始算答案。这段有些抽象,可结合代码理解。然后分治做法就类比即可。
点击查看代码
/*
* @Author: Nekopedia
* @Date: 2025-05-12 14:24:26
* @Last Modified by: Nekopedia
* @Last Modified time: 2025-05-12 18:21:50
*/
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N = 1e5 + 5, inf = 1e9;
const ll INF = 2e18;
int hd[N], cnt, n, m, sn;
struct edge{int nxt, to;}e[N];
inline void add(int u, int v){e[++cnt] = {hd[u], v}; hd[u] = cnt;}
char s1[N], s2[N], a[N];
ll ans;
int rt, mi = inf, sz[N], sum[N];
bitset < N > tg;
struct sam{
vector < int > g[N];
int ch[N][26], fa[N], son[N][26], len[N], sz[N], pos[N], rp[N];
int tot, ep, s[N];
ll cnt[N];
sam(){tot = ep = 1;}
inline void clr(){for(int i = 1; i <= tot; ++i)cnt[i] = 0;}
void ins(int c){
int u = ep; ep = ++tot;
rp[ep] = len[ep] = len[u] + 1; sz[ep] = 1;
for(; u and ! ch[u][c]; u = fa[u])ch[u][c] = ep;
if(! u)return fa[ep] = 1, void();
int v = ch[u][c];
if(len[u] + 1 == len[v])return fa[ep] = v, void();
int x = ++tot; len[x] = len[u] + 1;
fa[x] = fa[v]; fa[v] = fa[ep] = x;
for(int i = 0; i < 26; ++i)ch[x][i] = ch[v][i];
for(; u and ch[u][c] == v; u = fa[u])ch[u][c] = x;
}
void dfs(int u){
for(int v : g[u]){
dfs(v);
sz[u] += sz[v]; rp[u] = rp[v];
son[u][s[rp[v] - len[u]]] = v;
}
}
void init(){
for(int i = 1; i <= m; ++i)ins(s[i]), pos[i] = ep;
for(int i = 2; i <= tot; ++i)g[fa[i]].push_back(i);
dfs(1);
}
void upd(int u, int ff, int p, int l){
if(len[p] == l)p = son[p][a[u] - 'a'];
else if(s[rp[p] - l] ^ (a[u] - 'a'))p = 0;
if(! p)return; ++cnt[p];
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == ff or tg[v])continue;
upd(v, u, p, l + 1);
}
}
void pd(int u){cnt[u] += cnt[fa[u]]; for(int v : g[u])pd(v);}
void pt(){
cout << tot << endl;
for(int i = 1; i <= tot; ++i)cout << sz[i] << ' ' << rp[i] << endl;
for(int i = 1; i <= m; ++i)cout << s[i]; cout << endl;
}
}sp, sf;
void getrt(int u, int fa, int tot){
sz[u] = 1; int ma = - inf;
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa or tg[v])continue;
getrt(v, u, tot); sz[u] += sz[v];
ma = max(ma, sz[v]);
}
ma = max(ma, tot - sz[u]);
if(ma < mi)mi = ma, rt = u;
}
void dfs1(int u, int fa, int p, ll o){
p = sp.ch[p][a[u] - 'a']; if(! p)return;
ans += o * sp.sz[p];
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa or tg[v])continue;
dfs1(v, u, p, o);
}
}
void dfs2(int u, int fa){
dfs1(u, 0, 1, 1);
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa or tg[v])continue;
dfs2(v, u);
}
}
void dfs3(int u, int fa){
sum[u] = 1;
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa or tg[v])continue;
dfs3(v, u); sum[u] += sum[v];
}
}
int st;
void dfs4(int u, int fa, int p, int l){
if(sp.len[p] == l)p = sp.son[p][a[u] - 'a'];
else if(sp.s[sp.rp[p] - l] ^ (a[u] - 'a'))p = 0;
dfs1(st, rt, p, - 1);
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa or tg[v])continue;
dfs4(v, u, p, l + 1);
}
}
void sol(int u, int fa, ll o){
sp.clr(); sf.clr();
if(fa)
sp.upd(u, 0, sp.son[1][a[fa] - 'a'], 1),
sf.upd(u, 0, sf.son[1][a[fa] - 'a'], 1);
else sp.upd(u, 0, 1, 0), sf.upd(u, 0, 1, 0);
sf.pd(1); sp.pd(1);
for(int i = 1; i <= m; ++i)ans += o * sp.cnt[sp.pos[i]] * sf.cnt[sf.pos[m - i + 1]];
}
void dfs(int u, int tot){
if(tot < sn)return dfs2(u, 0), void();
tg.set(u); sol(u, 0, 1); dfs3(u, 0);
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(tg[v])continue;
if(sz[v] < sn)st = v, dfs4(v, u, sp.son[1][a[u] - 'a'], 1); else sol(v, u, - 1);
mi = inf, rt = 0; getrt(v, u, sum[v]); dfs(rt, sum[v]);
}
}
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> n >> m; sn = 600;
for(int i = 1, u, v; i < n; ++i)cin >> u >> v, add(u, v), add(v, u);
cin >> a + 1 >> s1 + 1;
for(int i = 1; i <= m; ++i)sp.s[i] = sf.s[m - i + 1] = s1[i] - 'a', s2[m - i + 1] = s1[i];
sp.init(); sf.init();
getrt(1, 0, n); dfs(rt, n);
cout << ans;
return 0;
}
区间本质不同子串个数
套路题,考虑离线扫描线。扫右端点 \(r\),维护 \(f_l\) 表示 \([l,r]\) 的答案。现在我们枚举所有以 \(r\) 结尾的串,更新这些串上次出现的位置直到 \(r\) 的 \(f\),因此我们只需要枚举 parent tree 上点到根的路径即可,并且对 SAM 上每个点维护 \(las_i\) 表示上次被更新是多久。因为是一段连续的后缀所以我们要维护若干等差数列的加,这直接差分变成区间加即可。
然后因为树上会有一些东西的 \(las\) 相同,我们可以一起处理。就有点像颜色段均摊一样,虽然我不会证()。我们可以用一个果的 lct 维护,然后就做完了。
点击查看代码
namespace bit{
ll t[N], d[N];
inline void mdf(int x, int y){for(ll w = 1ll * x * y; x < n + 2; x += x & - x)t[x] += y, d[x] += w;}
inline ll qry(int x){ll s = 0, w = 0; for(int y = x; y; y ^= y & - y)s += t[y], w += d[y]; return s * (x + 1) - w;}
inline void upd(int l, int r, int x){mdf(l, x), mdf(r + 1, - x);}
inline ll ask(int l, int r){return qry(r) - qry(l - 1);}
}
namespace lct{
int fa[N], ch[N][2], val[N], las[N], tg[N], dis[N];
#define ls ch[x][0]
#define rs ch[x][1]
void init(){val[0] = inf; for(int i = 1; i <= sam :: tot; ++i)val[i] = dis[i] = sam :: len[fa[i] = sam :: fa[i]] + 1;}
inline bool id(int x){return x == ch[fa[x]][1];}
inline bool get(int x){return x == ch[fa[x]][id(x)];}
inline void upd(int x){val[x] = min(val[ls], val[rs]); val[x] = min(dis[x], val[x]);}
inline void rot(int x){
int y = fa[x], z = fa[y], k = id(x), u = ch[x][! k];
if(get(y))ch[z][id(y)] = x; if(u)fa[u] = y;
fa[y] = x; fa[x] = z; ch[x][! k] = y; ch[y][k] = u; upd(y), upd(x);
}
inline void add(int x, int y){las[x] = tg[x] = y;}
inline void pd(int x){if(tg[x])add(ls, tg[x]), add(rs, tg[x]); tg[x] = 0;}
inline void pu(int x){if(get(x))pu(fa[x]); pd(x);}
inline void spl(int x){for(pu(x); get(x); rot(x))if(get(fa[x]))rot(id(x) == id(fa[x]) ? fa[x] : x);}
inline void ac(int x, int nw){
for(int z = x, y = 0; z; z = fa[y = z]){
spl(z); ch[z][1] = y; upd(z);
if(las[z])bit :: upd(las[z] - sam :: len[z] + 1, las[z] - val[z] + 1, - 1);
}
spl(x); add(x, nw); bit :: upd(nw - sam :: len[x] + 1, nw, 1);
}
#undef ls
#undef rs
}
ll ans[N];
char s[N];
int pos[N];
#define pii pair < int , int >
vector < pii > qs[N];
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
cin >> s + 1 >> q; n = strlen(s + 1);
for(int i = 1; i <= n; ++i)sam :: ins(s[i] - 'a'), pos[i] = sam :: ep;
for(int i = 1, l, r; i <= q; ++i)cin >> l >> r, qs[r].push_back(make_pair(i, l));
lct :: init();
for(int i = 1; i <= n; ++i){
lct :: ac(pos[i], i);
cerr << "------------------\n";
for(pii j : qs[i])ans[j.first] = bit :: ask(j.second, i);
}
for(int i = 1; i <= q; ++i)cout << ans[i] << endl;
return 0;
}
后缀树节点数
后缀树节点数就是反串的 SAM 节点数。考虑建 SAM 的过程是把所有前缀当成主链,然后分裂出支链。考虑先对整个串建 SAM,如果我现在建了一个区间的 SAM,首先区间的主链一定被包含,考虑区间的支链。因为 SAM 可以跑出所有子串,所以我们一定能在整个串的 SAM 上找到对应的点与区间的支链对应。
现在考虑如何算一个点对区间的贡献。我们其实只需要看这个点对应的最长串能不能对区间有贡献。寻找充要条件,对于一个 endpos 集合中的两个点 \(x,y\),如果 \(s[x-len]\neq s[y-len],l+len\le x,y\le r\) 就说明要进行一次分裂。我们先只统计分裂出的点,然后加上只满足前缀对应的点。相当于我要去枚举 SAM 上的点,判断能不能在两个子树内选出上文的 \(x,y\)。
还是离线扫描线,每次移动扫描线相当于是更新 parent tree 上一条从根到点的路径,我们只需要维护一个 endpos 集合最近两次被更新的位置,判断如果在区间内就有贡献,树状数组维护贡献即可。
最后就是要找出满足分裂出去的点且是前缀对应的点。如果一个位置 \(i\) 满足条件,那么 \(\forall j, j\in[l,i)\) 也满足条件。因为如果一个串已经出现过需要分裂,说明其前缀已经被分裂。
于是我们用哈希处理出每个点对应的串和每个位置对应的点,并二分找合法位置。如果一个点上次出现的位置在区间中那么合法。时间复杂度 \(O(m\log^2n+m\log n)\)。
点击查看代码
namespace lct{
int fa[N], ch[N][2], las[N], tg[N];
#define ls ch[x][0]
#define rs ch[x][1]
inline void init(){for(int i = 1; i <= sam :: tot; ++i)fa[i] = sam :: fa[i];}
inline bool id(int x){return x == ch[fa[x]][1];}
inline bool get(int x){return x == ch[fa[x]][id(x)];}
inline void rot(int x){
int y = fa[x], z = fa[y], k = id(x), u = ch[x][! k];
if(get(y))ch[z][id(y)] = x; if(u)fa[u] = y;
fa[y] = x; fa[x] = z; ch[x][! k] = y; ch[y][k] = u;
}
inline void add(int x, int y){las[x] = tg[x] = y;}
inline void pd(int x){if(tg[x])add(ls, tg[x]), add(rs, tg[x]); tg[x] = 0;}
inline void pu(int x){if(get(x))pu(fa[x]); pd(x);}
inline void spl(int x){for(pu(x); get(x); rot(x))if(get(fa[x]))rot(id(x) == id(fa[x]) ? fa[x] : x);}
inline void ac(int x, int nw){
int y;
for(y = 0; x; x = fa[y = x]){
spl(x); if(rs and x != 1 and las[x]){
if(f[x])bit :: upd(f[x], - 1);
bit :: upd(f[x] = las[x] - sam :: len[x], 1);
}
rs = y;
}
add(y, nw);
}
#undef ls
#undef rs
}
signed main(){
ios :: sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr);
sam :: init(); lct :: init();
for(int i = 1, l, r; i <= q; ++i)cin >> l >> r, qs[n - l + 1].push_back(make_pair(i, n - r + 1));
for(int i = 1; i <= n; ++i){
lct :: ac(pos[i], i);
cerr << "--------------\n";
for(pii j : qs[i]){
int l, r = i - 1, res;
ans[j.first] = bit :: qry(l = j.second) + i; res = l - 1;
while(l <= r){
int mid = l + r >> 1; int p = mp[get(j.second, mid)];
if(p and f[p] + sam :: len[p] > mid)res = mid, l = mid + 1; else r = mid - 1;
}
ans[j.first] -= res;
}
}
// sam :: pt();
// for(int i = 1; i <= sam :: tot; ++i)cout << f[i] << ' '; cout << endl;
for(int i = 1; i <= q; ++i)cout << ans[i] << endl;
return 0;
}
后记
最后几道题对我来说确实算是非常难的了,我都花了很多时间去做一道题。有的题思维稍简单但是代码难度极高,有的题代码难度低一点但是对思维的要求又高。现在我差不多熟悉了 SAM 的一些应用,了解了一些常见的套路以及 SAM 和二分答案,dp,线段树合并,lct 等算法的运用,感觉收获颇丰!也希望这篇做题记录能对以后复习 SAM 的我有一点作用吧。

浙公网安备 33010602011771号