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(r2-l2+1,\max_{l1\le i\le r1}\min(i-l1+1),\text{lcs}(i,r2)) \]

最外面的 \(\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\) 的形式就是:

\[\max_{l \le i<r,lcs(pre_i,pre_r)\ge i-l+1}i \]

我们把后面的东西看成对 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}\),所以要讨论字符串长度与其的关系:

  1. 当前串长等于 \(\text{maxlen}\),说明我再往前加一个字符就会跳到其他点,我们就需要找一个点 \(v\) 满足 \(fa_v=u\)\(S[\text{rpos}_v-\text{len}_u]=c\),其中 \(\text{rpos}_v\) 表示 \(v\)\(\text{endpos}\) 中任意一点(任一点都是等价的)。如果有就能够跳,否则新串就不是 \(S\) 的子串了。
  2. 如果当前串长小于 \(\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 的我有一点作用吧。

posted @ 2025-05-15 12:41  Lyrella  阅读(27)  评论(1)    收藏  举报