后缀自动机

后缀自动机

很毒瘤的字符串数据结构,但是我觉得多在脑子里梳理几遍后,比后缀数组好用多了(

这里跳过几乎所有证明,除了对后面理解有影响的,因为我认为这些不重要也学不会

主要记录对做题有帮助的核心性质

字符串的 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\}\)

性质

  1. 如果 \(endpos(u)=endpos(v)\),则其中一个必然为另一个的后缀

    证明显然,反证法即可,感性理解更容易(

  2. \(endpos(u)\)\(endpos(v)\) 只有两种关系: \(endpos(u)\subseteq endpos(v)\)\(endpos(u)\cap endpos(v)=\varnothing\)

    为 1 的逆命题,这为 parent tree 的建造打下了基础

  3. 将同一 \(endpos\) 等价类中的子串按长度从小到大排序,则长度为连续的一段区间,记为 \([minlen,len]\)

  4. \(endpos\) 等价类的个数为 \(O(n)\)

  5. 反串 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)\) 则为最短串长度

  1. \(fa\) 对应的所有字符串为 \(x\) 对应的所有字符串的后缀

  2. \(minlen(x)=len(fa)+1\),也就是说,从 \(x\) 不断走向 parent tree 上的祖先节点,不重不漏的覆盖了串长 \([1,len(x)]\)

  3. 原串两个前缀的 LCS(最长公共后缀),为它们代表的终止节点在 parent tree 上的 LCA 的 \(len\)

    这里需要理解一下,由于一个串的后缀的 \(endpos\) 必定包含它,因此其实是找到 \(endpos\) 集合包含这两个节点的节点,就是 parent tree 上的 LCA 及它的祖先,而且要让串长最大,就选 LCA 的 \(len\)

    实际应用时,可以对反串建 SAM,就能快速得出后缀的 LCP,可以代替后缀数组

    本质上就是建了后缀树在用

  4. 每个节点的 \(endpos\) 为它子树内所有终止节点 \(endpos\) 的并集

    很多时候可以用线段树合并之类的数据结构维护

  5. 设每个状态 \(endpos\) 的大小为 \(siz(x)\),初始时只有终止节点的 \(siz(x)=1\),每个节点的 \(siz\) 是子树内节点的 \(siz\) 之和

  6. \(endpos\) 集合的大小,就代表这个等价类中,每个串在原串中出现了 \(siz\)

  7. 每个状态包含的本质不同子串个数:\(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\) 大的子串

P3975 [TJOI2015] 弦论

首先看不同位置出现算多次的情况

找到 \(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;
}
posted @ 2024-02-14 21:50  KellyWLJ  阅读(4)  评论(0编辑  收藏  举报