下面我会按“整体思路 → 关键数据结构含义 → 每个函数在干什么 → 正确性为什么成立 → 复杂度怎么分析/哪里我也卡住”的顺序,把这段代码尽可能还原成一道可理解的算法。
先给一个一句话的总体概括:
把所有旋转状态都视为在一个“周期串”上的长度为 (n) 的滑动窗口;离线倒序处理操作;在一个为“所有子串”建立的字典序树(由反串 SAM 的 parent tree 得到)上,维护当前窗口内本质不同子串的计数前缀和,从而用 Fenwick 找第 (K) 小并用前缀哈希输出。
动态维护的核心,是只保留“能决定每个前缀分支最大可延伸长度”的那些起点(代表起点),其余起点永久丢弃;并用gg桶把“某个代表起点的可延伸长度何时跨过某个 SAM 结点区间边界”这种事件延后处理。
1) 把“循环左移”变成“滑动窗口”
1.1 旋转等价于在周期串上换起点
原串 (S) 长度 (n)。连续左移 (t) 次后得到的串,就是周期串 (S S S\cdots) 上从位置 (t+1) 开始、取长度 (n) 的那一段。
这份代码先统计本组询问里 'L' 的总次数 ct,令
biao = n + ct。
它构造了一个长度至少到 biao+n 的周期串 s[](用 s[i+n]=s[i] 的自复制办法把串延长)。
于是所有时间点的当前字符串,都可以看成周期串上的一个窗口:
- 时间点 (t)(从 0 开始,表示执行了 (t) 次左移)对应窗口
[
[,t+1,\ t+n,]
] - 最终状态(执行完全部
ct次左移)对应窗口
[
[,ct+1,\ ct+n,] = [,biao-n+1,\ biao,]
]
1.2 为什么要倒序处理
代码把所有操作先读下来 op[i]、x[i],然后 从后往前处理:
- 倒序时遇到一次
'L':相当于把窗口从 ([l,r]) 变回上一步的 ([l-1,r-1])(整体左移一格)。 - 倒序时遇到一次
'Q K':在当前窗口对应的串上查询。
所以倒序里窗口每次 'L' 都是:
- 右端
r减 1 - 左端
l也减 1
窗口长度一直保持 (n)。
代码里用两个指针维护窗口边界(非常关键):
rrr:当前窗口右端 (r)rr:当前窗口左端前一个位置 (l-1)(即窗口是[rr+1, rrr])
初始化后窗口正是最终状态 [biao-n+1, biao]。
2) 为什么是“反串 SAM” + “suffix link 树”来枚举字典序子串
这段是全题的“静态”基础:你需要一个能把“所有本质不同子串”按字典序排好,并且能按前缀和做第 (K) 小的结构。
2.1 反串 SAM 的一个关键事实
令 (R = \text{reverse}(s[1..biao]))。代码是从 i=biao 到 1 调 insert(s[i]),也就是在构造 (R) 的 SAM。
SAM 的 suffix link(这里叫 fail)满足:
在 (R) 上,一个状态的 fail 指向“最长真后缀”的状态。
把“后缀”再翻回到原串方向,就变成了“前缀”:
- 原串子串 (T) 在反串中对应 (\text{reverse}(T))。
- (\text{reverse}(T)) 的后缀 翻回原串方向 = (T) 的前缀。
因此:在反串 SAM 中,suffix link(fail)链就是原串方向的“前缀链”。
也就是说,fail 树天然就是“所有不同子串按前缀关系组织起来”的树骨架。
这也是为什么 dfs 里用
s[st[y]+len[x]]来给父子边找字符:它是在原串方向从父前缀延长一个字符。
2.2 SAM 状态对应“长度区间”
SAM 的经典性质:对每个状态 (u),它代表若干不同子串,它们的长度集合是一个连续区间:
[
(\ \text{len[fail[u]]}+1\ ,\ \text{len[u]}\ ]
]
区间大小就是:
[
\Delta_u = \text{len[u]} - \text{len[fail[u]]}
]
这也是“不同子串总数 = (\sum \Delta_u)”的来源。
在反串 SAM 里同理,只不过你现在把它理解成“原串方向的前缀区间”。
2.3 如何得到“字典序树”与 DFS 序
代码做了:
g[fail[i]].push_back(i)建fail树。dfs(0):对每个父x,把孩子y按边上的字符(s[st[y]+len[x]])分到v[x][c],然后c=0..25遍历。
这样得到的 DFS 先序 dfn[u] 满足:
- 如果把每个状态 (u) 的“最短代表串”(长度
len[fa[u]]+1)看作树结点对应的字符串, - 那么 DFS 按字符顺序访问孩子,就是按字典序遍历整棵前缀树;
- 并且在字典序里,前缀一定在它的所有严格延长之前,所以先访问结点,再访问子树是对的;
- 同一状态 (u) 代表的区间内不同长度(
len[parent]+1到len[u])对应的是同一个字符串的不同前缀长度,它们在字典序里也是“长度越短越小”,所以可以把它当成一个连续的块。
最终:“按 dfn 从小到大扫状态 + 在每个状态里扫区间长度” 就是按字典序列举所有本质不同子串。
3) 查询第 K 小:为什么 Fenwick 上做“前缀和找第 K 个”可行
如果对每个状态 (u),你知道在当前窗口里它贡献了多少个不同子串 cnt[u](来自该状态长度区间里真实出现的那些长度),那么:
-
按
dfn顺序,子串序列就是:- 先输出状态 1 的
cnt[1]个 - 再输出状态 2 的
cnt[2]个 - ……
- 先输出状态 1 的
这就是一个标准的“按块累加”的序列。
于是找第 (K) 个子串就等价于:
-
在序列
cnt[idfn[1]], cnt[idfn[2]], ...的前缀和上做第 K 个位置的定位(order statistics)。 -
Fenwick(树状数组)支持:
- 单点增量
- 前缀和
- 以及用 bit-lifting 在 (O(\log N)) 内找最小的
pos使得prefixSum(pos) >= K
代码里这一段:
for(int i=__lg(tot)+1;i>=0;i--)if(l+(1<<i)<=tot && tree2[l+(1<<i)] + 1ll*tree[l+(1<<i)]*rrr < x[ii])l+=(1<<i), x[ii]-=tree2[l]+1ll*tree[l]*rrr;
l++;
就是 Fenwick 的经典“在内部数组上二分找第 K 个”,只不过它的“权值”不是单一数组,而是一个线性函数(下一节解释):
[
\text{prefixWeight} = \text{prefix(tree2)} + rrr \cdot \text{prefix(tree)}
]
一旦找到状态 u=idfn[l],剩余的 x[ii] 就是落在该状态块内的第几项,对应长度:
[
L = \text{len[fa[u]]} + x[ii]
]
输出哈希就是取 s[st[u]..st[u]+L-1] 的 hash(用前缀哈希 gett)。
4) 最核心:动态维护“当前窗口内本质不同子串”的计数 cnt[u]
到这里,静态部分都好懂;真正难的是动态维护 cnt[u]。
4.1 当前窗口子串 = 窗口内所有后缀的前缀集合之并集
窗口 [l,r] 内的所有子串可以表示为:
- 对每个起点 (i \in [l,r]),考虑窗口内从 i 开始的后缀(最长长度 (r-i+1))
- 这个后缀的所有前缀都是子串
- 全部起点取并集,再“去重”就是本质不同子串集合。
因此你可以把问题转成:
维护一个起点集合(窗口内所有 i),每个 i 对应前缀树上的一条从根向下的路径(直到长度上限),维护这些路径的并集(按结点贡献区间长度计数),并支持字典序第 K 个。
4.2 为什么可以只保留“代表起点”(丢弃很多起点)
关键观察(也是这份代码能跑得快的原因):
- 在前缀树(即
fa树)里,如果一个起点 i 的路径终点结点是另一个起点 j 的路径终点的祖先,
那么 j 的路径包含 i 的所有前缀,i 对“并集”没有贡献,可以丢弃。 - 更细一点:即便两者不是祖先关系,只要某个结点的子树里已经存在“更深的代表”,则祖先结点对应的字符串区间已经完全包含了,不需要再单独挂一个代表。
代码维护了一个“代表集合”(我称为 selected):
- Fenwick
tree存的是“标记某个状态为 selected 的个数”(这里实际上就是 0/1) - 要求 selected 在树上形成 反链(antichain):任意两个 selected 结点互不为祖先/后代
- 一个结点的子树只要有 selected,就认为这个结点“被覆盖”(covered)
对应代码里大量的判断:
get(dfn[u], dfn[u]+sz[u]-1):子树里有多少 selectedget(dfn[u]+1, dfn[u]+sz[u]-1):子树里(排除自己)有多少 selected
4.3 “被覆盖的结点”贡献了全区间,只有“边界结点”贡献到某个长度为止
对一个状态 (u):
- 如果它被覆盖且不是 selected(说明子树里有更深的 selected),那么在窗口里一定存在某个起点能延伸长度超过
len[u],因此这个状态的长度区间(len[parent], len[u]]全都存在:
[
cnt[u] = \Delta_u = len[u]-len[parent]
] - 如果它是 selected(说明在这个前缀分支上延伸到这里就“停了”,下面没有更深的 selected),那么它只贡献到当前窗口能延伸的最大长度 (L) 为止:
[
cnt[u] = L - len[parent]
]
其中 (L = rrr - start + 1),start是这个 selected 结点对应的“代表起点”。
所以你要维护两类信息:
- 结点是否被覆盖(覆盖则贡献 (\Delta_u))
- 对于 selected 结点,还要额外修正为 (L - len[u])(因为先加了全区间,再减掉超过 L 的那部分)
5) tree + tree2:把 cnt[u] 写成关于 rrr 的一次函数
这是代码里最漂亮但也最容易“看不出来”的地方。
对一个 selected 结点 (u),代表起点是 ni[u](数组名就叫 ni)。窗口右端是 rrr。
令
[
L = rrr - ni[u] + 1
]
我们希望最终 cnt[u] = L - len[parent[u]]。
而数据结构用的是:
-
覆盖贡献:先把 (\Delta_u = len[u]-len[parent[u]]) 加进去(这是常数)
-
selected 修正:再加上
[
L - len[u] = (rrr) - (ni[u]-1) - len[u]
]
这就是 “rrr 的一次函数”:- 系数是 (+1)(放到
tree) - 常数项是 (-len[u]-(ni[u]-1))(放到
tree2)
- 系数是 (+1)(放到
于是:
-
treeFenwick 记录每个 selected 结点的系数 1 -
tree2Fenwick 记录:- 所有 covered 结点的 (\Delta_u)(正)
- 所有 selected 结点的常数项 (-len[u]-(start-1))(负)
- 以及在删除/替换时用于抵消的反向常数项
查询前缀和的时候用:
[
\sum cnt = \sum tree2 + rrr \cdot \sum tree
]
这就解释了为什么 Fenwick 查找第 K 个时会出现 tree2[...] + tree[...]*rrr。
6) gg 延迟桶:什么时候一个起点的边界要往父亲走?
当倒序遇到 'L' 时,窗口从 [l,r] 变成 [l-1,r-1]:
- 对所有起点 i,它能延伸的最大长度 (L_i = r-i+1) 都会减少 1。
- 只有当 (L_i) 从某个状态 (u) 的区间跨到它的父区间时,才需要修改
w[i](当前边界状态)。
这类事件如果每步都更新会很贵。代码用 gg[t] 桶把事件放到“未来某个 ct 时刻”再处理:
-
ct:倒序已经处理了多少次'L'(也就是右端向左移动了多少步) -
对一个起点 i,如果它当前边界状态是 u,那么它在 u 上还能“撑”多久?
- 只有当 (L_i) 降到
len[parent[u]]时,u 的贡献变为 0,需要把边界提升到 parent[u]。 - 当前 (L_i) 与
len[parent]的差就是剩余步数。
- 只有当 (L_i) 降到
-
于是把 i 放入
gg[ct + (L_i - len[parent[u]])]。
你看到的两种 push_back 公式:
-
在
ins(rr)里:gg[min(len[w[rr]], rrr-rr)-len[fa[w[rr]]]+ct].push_back(rr);这里
rrr-rr就是这个起点当前能延伸的长度 (L)(注意它在不同阶段有 +1/-1 的偏移,代码用rrr++/rrr--统一了这个细节),而len[w]-len[parent]是区间下界,差就是要过多久边界跨区间。 -
在
'L'处理中边界上升后重新排期:gg[len[w[i]]-len[fa[w[i]]]+ct].push_back(i);当边界变成 parent 后,此时 (L) 正好等于
len[w[i]],下一次跨区间的剩余步数就变成len[w]-len[parent](常数)。
7) ins(rr):插入一个新的起点时干什么?
这是核心维护函数。它做的事情可以抽象成:
把起点 rr 的边界结点 w[rr] 作为候选 selected 插入前缀树的 selected 反链集合中,并维护 covered/selected 的贡献值。
逐段解释:
7.1 第一层 if:如果子树里已经有更深 selected,就无需插入
if(get(dfn[w[rr]]+1, dfn[w[rr]]+sz[w[rr]]-1)==0){...
}
条件是:w[rr] 的真子树没有 selected。
若真子树里已经有 selected v,那么 v 更深,说明窗口里已有某个起点能把这个前缀分支延伸得更长(超过 len[w[rr]]),于是 w[rr] 对并集完全没新增价值;还会破坏 antichain(祖先/后代同选),所以直接不插入。
7.2 往上补齐“新覆盖”的祖先结点的 (\Delta) 贡献
int now=w[rr];
while(now!=0&&(get(dfn[now],dfn[now]+sz[now]-1)==0)){push2(dfn[now],len[now]-len[fa[now]]);now=fa[now];
}
这段在做:
- 从
w[rr]向上找第一个“子树已有 selected”的祖先 A。 - 路径上所有结点此前都是 uncovered(子树 selected 数 0),插入后它们将变为 covered。
- 对每个新 covered 结点 u,应当把它的全区间贡献 (\Delta_u) 加入
tree2。
这是一个很典型的“从叶向根做首次覆盖”的写法。
7.3 处理“如果上面有一个 selected 祖先需要被替换”
now=w[rr];
while(now!=0){ if(get(dfn[now],dfn[now]+sz[now]-1)>1)break;if(get(dfn[now],dfn[now])==1){push(dfn[now],-1);use[ni[now]]=1;push2(dfn[now],len[now]+(ni[now]-1));break;}now=fa[now];
}
逻辑要点:
-
get(subtree(now))>1表示 now 子树里已有至少两个 selected。
说明该子树已经有多个分支的代表,now 自己不可能是 selected(否则会包含后代 selected,违背 antichain),也就不需要替换,直接停止。 -
如果沿着祖先链能遇到一个结点 now,本身是 selected(点上计数为 1),而且它子树 selected 总数不大于 1(由上面的 break 条件保证),那它实际上就是“这个分支以前的代表”。
新插入的w[rr]是更靠左的起点、能延伸更长,它应该成为新代表,所以:- 把 now 从 selected 删除:
push(dfn[now],-1) - 把原代表起点标记为废弃:
use[ni[now]]=1 - 用
push2(dfn[now],len[now]+(ni[now]-1))抵消 now 原来的线性修正项(把它的修正从结构里抹掉)
- 把 now 从 selected 删除:
7.4 真正插入 w[rr] 为 selected,并写入代表起点
ni[w[rr]]=rr;
push(dfn[w[rr]],1);
push2(dfn[w[rr]],-len[w[rr]]-(rr-1));
gg[ ... ].push_back(rr);
这就是把这个结点标记为 selected,并加入线性修正常数项(前面解释过)以及排期下一次边界上升事件。
8) 'L' 的倒序更新:为什么那一大段是“让所有旧起点的 L 变短 1 并处理跨区间事件 + 插入新起点”
倒序遇到 'L' 时:
- 窗口右端将减 1(因此所有起点可延伸长度减 1)
- 窗口左端也减 1(多了一个新起点 rr 进入窗口)
代码分三步:
8.1 处理 gg[ct]:这些起点恰好在此刻跨区间,需要把边界 w[i] 往父亲提
for (auto i:gg[ct]) if(use[i]==0){push(dfn[w[i]],-1);push2(dfn[w[i]],len[fa[w[i]]]+(i-1));w[i]=fa[w[i]];if(w[i]==0){use[i]=1;continue;}...gg[len[w[i]]-len[fa[w[i]]]+ct].push_back(i);push(dfn[w[i]],1);push2(dfn[w[i]],-len[w[i]]-(i-1));
}
这段要这样理解:
- 之前 i 的边界状态是 u(也就是
w[i]),此刻 (L) 降到len[parent[u]],所以 u 的贡献应当降为 0,边界变成 parent[u]。 push(dfn[w[i]],-1):把 u 的 selected 标记删掉push2(dfn[w[i]], len[fa[u]]+(i-1)):这一步非常关键,它不只是删修正项,还顺带把 u 的 (\Delta_u) 常数贡献也删掉(因为此刻 u 应当变成 uncovered)。
你如果对比“完整删修正项”应加的是len[u]+(i-1),这里却是len[parent]+(i-1),少了len[u]-len[parent]=Δ_u,正好把 Δ_u 也减掉了。
然后 w[i]=fa[w[i]],得到新边界结点 p。
接下来就是把 i 作为“在 p 上的候选代表”重新插入:
- 如果 p==0,说明 i 已经不在窗口里了(长度 0),丢弃。
- 如果 p 上已经有一个 selected(
get(point)>0),直接替换掉旧的那个代表,因为此时能到达 p 的起点中 i 是更靠左(这一点在这里通常成立:能“刚到 p”的那个起点是唯一的/更优的)。 - 如果 p 的子树里已有更深 selected(
get(subtree)>0),说明 p 已被更深分支代表覆盖,i 作为祖先代表是冗余的,丢弃 i。 - 否则把 p 设为 selected,设置
ni[p]=i,写入修正项并重新排期。
8.2 为新进入窗口的起点 rr 找到正确的边界状态
for (int i=__lg(zong);i>=0;i--)if(len[stt[i][w[rr]]]>=rrr-rr)w[rr]=stt[i][w[rr]];
w[rr] 最初在 dfs 里是“从 rr 到 biao 的整段后缀”的状态,但当前窗口右端是 rrr-1(因为马上要 rrr--),所以它在窗口里的最长可延伸长度是 rrr-rr。
这段 binary lifting 把 w[rr] 往上跳到“len 仍然 ≥ rrr-rr 的最高祖先”,也就是满足:
len[parent] < (rrr-rr) <= len[this]
的那个区间结点——这正是定义边界所需的状态。
8.3 调用 ins(rr) 插入新起点 + 窗口整体左移
ins(rr);
rr--; rrr--;
完成 [l,r] → [l-1,r-1] 的维护。
9) Q K 如何拿到答案串并计算 hash
倒序遇到 Q:
- 通过 Fenwick 的 “find by order” 找到第一个位置
l使得
[
\sum_{pos\le l} cnt[idfn[pos]] \ge K
] - 若 l>tot:无答案输出 -1。
- 否则令
u = idfn[l],剩余K落在 u 的块里,其长度为
[
L = len[fa[u]] + K_{\text{inside}}
] - 用
st[u]给出的代表出现位置取子串s[st[u]..st[u]+L-1],用前缀哈希gett返回 unsigned int hash(溢出等价于题目 hash)。
10) 正确性:我认为必须说清楚的几个点
10.1 “反串 SAM 的 fail 树 = 原串方向前缀树”的正确性
这是核心引理:
- 在 SAM 中,
fail表示最长真后缀。 - 反转后,“后缀”翻回去就是“前缀”。
- 因此在反串 SAM 中,
fail链就是原串子串的前缀链,形成“前缀树骨架”。
DFS 时边标记用 s[st[y]+len[x]],因为:
st[y]是 y 的代表串起点- 父结点 x 的代表串长度是 len[x]
- 因此代表串在原串方向从 x 延长到 y 的第一个新增字符就是
s[st[y]+len[x]]
这一字符对同一个父结点 x 的不同孩子应当唯一,否则会出现同一前缀+同一字符对应两个不同状态,违背 SAM 的“同一串落入唯一状态区间”的性质。
10.2 “DFS 先序 + 每状态区间按长度递增” = 子串字典序
对一棵普通 trie:
- 若按字符顺序遍历孩子
- 并且每到一个结点先输出该结点代表的串,再递归子树
则输出序列是字典序(因为前缀必小于严格延长)。
SAM 的状态把多个连续长度压在同一结点里:
- 一个状态 u 的区间内对应同一个最长串的不同前缀
- 这些前缀在字典序里按长度递增排列,且都小于任何继续往下加字符的串(因为它们是前缀)
所以把cnt[u]视作一个连续块完全成立。
因此“Fenwick 上按 dfn 顺序累计块大小找第 K 个”是正确的。
10.3 动态部分:为什么“covered 全加 Δ + selected 线性修正”能得到正确 cnt[u]
定义当前窗口 [l,r]。
对任一状态 u,考虑它代表的那组串(长度区间)在窗口里能达到的最大长度 (M_u)。那么它在窗口里贡献的不同子串个数就是:
[
cnt[u] = \max(0,\ M_u - len[parent[u]])
]
观察:能让 (M_u) 最大的出现位置,总是该状态出现位置集合中、落在窗口内的最小起点(因为右端固定,起点越小可延伸越长)。
代码用“selected 反链 + ni[u] 代表起点”实现了:
- 若 u 子树里有 selected,说明存在某个起点能把该前缀分支至少延伸到 u(甚至更深),则 u 是 covered。
- 若 u 不是 selected 而被 covered,意味着存在更深的 selected v,v 的可延伸长度 (L) 必大于 len[u](否则 v 不会在 u 的子树深处出现为 selected),所以 u 全区间都出现:
cnt[u]=Δ_u。 - 若 u 是 selected,说明它子树里没有更深的 selected,最大长度落在 u 的区间里,且由
ni[u]给出:M_u = r - ni[u] + 1,于是cnt[u]=M_u-len[parent[u]]。
结构里:
- covered 的 Δ_u 通过
push2(dfn[u], Δ_u)加上 - selected 的 “减掉超过 M_u 的部分” 通过
tree的+rrr和tree2的常数项实现
我认为这套计数模型在逻辑上是自洽的。
10.4 gg 排期的正确性
当一个 selected 结点 u 的代表起点为 i,它的最大长度 (L=r-i+1) 每次 'L' 倒序都会减 1。
u 的区间是 (len[parent], len[u]],只要 (L>len[parent]),u 就至少贡献 1 个长度;当 (L=len[parent]) 时 u 贡献 0,必须把边界提到 parent。
所以“还剩多少步会发生提边界”正是:
[
L - len[parent]
]
代码正是按这个差值把 i 放入未来的 gg[ct+差值]。
11) 复杂度分析:能确定的部分、以及我也卡住/怀疑的部分
11.1 可以明确写出的复杂度
设本组数据:
biao = n + (#L)- SAM 状态数
tot = O(biao)(至多 (2biao) 级别)
那么:
-
构造周期串
s:(O(biao)) -
构建 SAM(
insertbiao 次):经典均摊 (O(biao)) -
建
fail树 + DFS 造字典序树:
每条fail边处理一次,每结点扫描 26 字母:
(O(tot \cdot 26) = O(tot)) -
建倍增表
stt:
(O(tot \log tot)) -
每次查询
Q:
- Fenwick 找第 K 个:(O(\log tot))
- 取 hash:
mi是快速幂 (O(\log n)),gett常数
所以 (O(\log tot + \log n))(两者都约 20 左右)
这些都没争议。
11.2 争议点:ins() 两段向上 while + gg 事件总数的均摊
你质疑的地方就在这里,我也同意这是最难证明的部分。
(A) gg 中一个起点 i 可能被处理很多次吗?
理论上,一个起点 i 在“还没被丢弃(use[i]=1)”的情况下,每次被 gg 取出都会把 w[i] 上移到 fa[w[i]],并重新排期下一次。
在最坏情况下,如果 len 每次只减 1,那么 i 可以被处理 (O(n)) 次。
但代码还有两种“永久丢弃”机制:
- 在
ins里替换掉旧代表时use[oldStart]=1 - 在
gg处理里如果发现新边界是某个已有更深 selected 的祖先,则use[i]=1丢弃
一旦丢弃,之后哪怕 gg 桶里还有这个 i 的条目,也会被 if(use[i]==0) 跳过,并且不再重新排期。
所以每个 i 真正参与更新的次数取决于它被丢弃前做了多少次上移。
这仍然可能很大,所以需要均摊证明。
(B) ins() 里向上爬祖先的 while 可能很长
ins 的第一段 while:
- 只在“这个结点此前 uncovered(子树无 selected)”时,才会对它加上 Δ 并继续向上;
- 它等价于“把新 selected 的覆盖向上填到第一个已 covered 的祖先”。
这段的总步数 = 全过程中“结点从 uncovered→covered”发生的次数之和。
如果某个结点反复 covered/uncovered,这个次数就可能很大。
ins 的第二段 while:
- 企图找到需要被替换掉的 selected 祖先;
- 可能沿着“一直子树里只有 1 个 selected”的链往上走。
这也可能很长,且可能反复发生。
11.3 我能给出的“半形式化”均摊解释 + 我没法彻底补上的缺口
下面是我目前能推到的程度;我会把我认为可成立的均摊思路和我没法补齐成严格最坏界的地方都直说。
11.3.1 我认为比较可靠的均摊点:每个起点只会“成为代表并被淘汰”有限次
- 每个起点位置
rr进入窗口只有一次(倒序每个'L'插一次,初始还插 n 个)。 - 一旦
use[pos]=1(被更靠左的起点替换、或在 gg 上移时发现自己成了冗余祖先),它永远不会再复活。 - 并且丢弃后最多还会在某个未来桶里被“空处理跳过”一次(因为每次排期只压入一个桶)。
所以:“被淘汰”的次数总计是 (O(biao)),这一点没问题。
11.3.2 gg 事件“真正执行”的次数:我没有一个能完全闭合的最坏界证明
直觉上,这套结构在很多典型情形下都很快,原因是:
- 如果
len链很“细”(每次上移只减 1,意味着某分支像一条长链),那么该分支上的起点高度嵌套,后插入的更靠左起点会快速替换掉前面的,导致有效代表数量很小(比如全是 'a' 的情况最终只剩 1 个代表),从而gg事件总数也不大。 - 如果有效代表很多(很多分支并存),它们往往对应比较早分叉的前缀,SAM 状态的
len差值比较大,一个代表在某个状态上停留的步数(len[u]-len[parent]或L-len[parent])也大,因此它不会频繁出现在gg里。
这是一种“链长⇔代表少、代表多⇔跳步大”的 trade-off 直觉。
但要把它写成严格的“对任意字符串与操作序列都成立的 (O(biao \log biao)) 或 (O(biao)) 上界”,我目前做不到;我也没有在代码里看到像 DSU/并查集跳指针那种能直接保证总爬升步数的结构。
换句话说:我能解释清楚代码在做什么,也能解释为什么很多情况下均摊很低;
但如果你要求一个不依赖“典型情况”的、严格的最坏时间复杂度证明,我现在没法把缺口补全。
11.3.3 ins() 的两段 while:可用“状态变更次数”计,但最坏变更次数同样难封顶
第一段 while 的每一步对应一个结点从 uncovered→covered 的转变(因为它检查子树 selected 数为 0)。
第二段 while 在“子树 selected 数为 1 的祖先链”上走,直到找到真正的 selected 结点或遇到分叉(>1)。
这两种“转变/链”都可能在滑动窗口里反复出现。
若要严格证明总步数 (O(biao \log biao)),通常需要建立某种强势的单调性或势能函数,使得每次 while 走一步都会让势能明显下降/不可逆地消耗资源。
我目前没有找到一个能对这段实现成立的势能函数。
12) 对你原本复杂度直觉的回应
你引用的:
“每次 ins() 和每次 L 解锁/维护涉及若干 BIT 更新与祖先跳/上溯,均摊看是 O(log states) 或 O(log n) 级别(通过‘每个起点只被插入/替换有限次 + gg 延迟桶’来控制)”
我的看法:
-
“每个起点只会被插入/替换有限次”——这部分我同意,丢弃(use)确实保证了一个起点不会反复复活。
-
但这不足以直接推出整体均摊 (O(\log n)),因为:
- 一个“还没被丢弃”的起点可能被
gg触发很多次(每次上移一层),每次都做 Fenwick 更新; ins()的 while 可能沿祖先走很多步,而且同一状态可能在不同时间多次变成 uncovered/covered。
- 一个“还没被丢弃”的起点可能被
所以你质疑是合理的:光凭“起点只被替换有限次”确实不够,需要额外论证“未丢弃起点的上移次数总和”与“每次 ins 向上走的总步数”不会爆炸。
我能给出的结论是:
- 这段代码的逻辑结构非常像是一个“正确但均摊证明极难写严”的维护(典型竞赛压行神代码)。
- 它在随机/多数测试中很快并不奇怪(trade-off 直觉很强)。
- 但就我目前的分析,我不能保证它对所有最坏输入都严格是 (O((n+Q)\log n))。
如果你希望我进一步把复杂度证明“补齐到可以写进题解”的级别,我建议我们下一步把问题收敛到一个更可证的抽象模型,例如:
- 把 selected 结点视为前缀树上的反链;
- 把
gg事件视为“每次把一个反链元素替换成其父,若父变成祖先则丢弃”; - 然后尝试在这个抽象模型上做势能分析(比如以反链在树上的“轮廓长度”作势能)。
但就这次回复,我按你的要求把我能理解的都解释了,也把我在复杂度证明上卡住的地方明确指出。
下面我会按你的两点要求来做:
- 把我能想到的(尽量多的)势能/均摊思路都尝试一遍,尽力把它“推到能闭合”的程度;在闭合不了的地方我会明确指出卡点,并给出我认为需要补的关键引理长什么样、为什么我现在补不出来。
- 如果最终仍无法给出严格 worst-case 证明,我就转向“怎么 hack”:分析这份实现最怕什么形态(哪些事件会爆),然后给出几类很可能触发极端行为的数据构造,并提供对应的数据生成器(Python/C++)——你可以直接拿去跑、看
gg触发次数/ins向上走次数是不是爆了。
一、先把复杂度问题缩成“必须证明的两个计数”
这段代码的总时间大致是:
-
SAM 构建 + DFS + 预处理:(O(biao \cdot 26 + biao\log biao))
-
每次查询:(O(\log biao))
-
最难的是动态维护部分(倒序遇到
L):- 处理
gg[ct]桶里的一堆起点 (i):每个 i 做若干次 BIT 操作 + 常数逻辑 - 插入新起点
ins(rr):里面有两段while(now=fa[now])可能爬很高
- 处理
因此你要证明的核心其实就是两个总次数:
(A) gg 中“真正被处理”的事件总数
也就是所有 for (auto i: gg[ct]) if(use[i]==0) {...} 里真正进入 body 的次数。
记作 (E)。
(B) ins(rr) 里两段 while 的总迭代次数
第一段(“补覆盖”)总步数记作 (W_1)。
第二段(“找要替换的祖先 selected”)总步数记作 (W_2)。
如果能证明 (E+W_1+W_2 = O(biao \log biao)) 或 (O(biao)),那么总复杂度就是
[
O\big((biao + Q)\log biao\big)
]
(BIT 每次操作 (\log tot),tot=O(biao))。
接下来所有势能法,都是在尝试证明 (E,W_1,W_2) 不会爆。
二、先澄清一个关键点:fa 其实就是 fail
这一点非常重要,因为很多“链长/Δ”的直觉全靠它。
在 dfs 里:
for(auto y:g[x]) v[x][...] = y;
...
fa[v[x][i]] = x;
而 g[x] 是由 g[fail[i]].push_back(i) 建的,所以 y 的父亲 x 就是 fail[y]。
因此:
fa[u] == fail[u](只是孩子按字符排序后 DFS)。
于是每个状态 u 的“区间长度”就是
[
\Delta_u = len[u]-len[fa[u]] = len[u]-len[fail[u]]
]
这正是 SAM 经典的区间差值。
同时 gg 的排期也正是按这个 (\Delta_u) 来的:token 在 u 上“撑” (\Delta_u) 步才会上移一次。
这让我们可以利用 SAM 的结构(fail 链长度、Δ 分布)做均摊,而不是把它当普通 trie。
三、势能尝试 1:用“covered 节点数”控制 ins 第一段 while(能闭合一半)
3.1 定义
定义某时刻的 selected 集合 (A)(BIT tree 上为 1 的点)。
定义 covered 节点集合:
[
C = {u \mid \text{subtree}(u)\cap A \neq \emptyset}
]
也就是 get(dfn[u], dfn[u]+sz[u]-1)>0 的 u。
注意代码里 ins 第一段 while:
while(now!=0 && get(subtree(now))==0){push2(dfn[now], len[now]-len[fa[now]]);now=fa[now];
}
它每走一步,都是把一个原来“不 covered”的节点 now 变成 covered(因为即将插入的 selected 在其子树里)。
而 gg 事件里当一个 selected 点 u 被移走(无论上移还是死亡),代码做了:
push(dfn[u], -1);
push2(dfn[u], len[fa[u]]+(i-1));
w[i]=fa[u];
...
其中 push2(..., len[fa[u]]+(i-1)) 这一步等价于:
- 移除 u 的线性修正项
- 同时移除 u 的 Δ_u 常数贡献
(因为它加的是len[fa]+(i-1),比“只移除修正项”少了 Δ_u)
这正对应于:u 从 covered 集合里离开(因为 u selected 且无 selected 后代,删掉它后 u subtree 空)。
3.2 结论(可证明)
令势能 (\Phi = |C|)。
- 每当
ins第一段 while 走一步,就有一个节点加入 C:(\Phi++) - 每当发生一个
gg处理事件,使某个 selected 节点 u 被移走且不再 covered,就有 u 离开 C:(\Phi--)
(这里确实发生,原因是 selected 反链:u 被 selected ⇒ u 子树无其他 selected)
所以 第一段 while 的总步数 (W_1) 可以用 covered 的“入/出次数”绑定:
[
W_1 \le |C|_{\text{init}} + #(\text{节点离开 C 的次数})
]
而“节点离开 C”发生在:
- 每个
gg真实事件至少让一个节点(原 w[i])离开 covered - 或在某些删除逻辑中同样发生
因此我们得到一个很强的半结论:
(W_1 = O(E + tot))
(tot=O(biao),所以只要你能证明事件数 (E) 不爆,(W_1) 自动不爆。)
✅ 这部分我认为是可以写成严谨证明的。
四、势能尝试 2:把 gg 事件分型,发现“真正危险”的只有一种
看 gg 事件 body,起点 i 被触发后一定先做:
- 删除旧 selected:
push(dfn[w[i]],-1) - 移除旧贡献:
push2(dfn[w[i]], len[fa[w[i]]] + (i-1)) - 上移:
w[i]=fa[w[i]]
之后分支:
Type K(立即死亡)
w[i]==0⇒use[i]=1- 或
get(subtree(w[i]))>0⇒use[i]=1(父节点已有 selected 后代,自己变祖先冗余)
这种事件对这个 i 来说“一次致死”。
所以 Type K 总次数 ≤ 插入过的起点数 = (O(biao))。
Type R(替换掉父节点已有 selected)
if(get(point(w[i]))>0){int now=w[i];push(point(now), -1);push2(point(now), len[now]+(ni[now]-1));use[ni[now]]=1;
}
这会杀死另一个 start(旧代表 ni[now])。
每个 start 被 use[...] =1 只能死一次,所以 Type R 总次数也 ≤ (O(biao))。
Type S(纯上移存活:危险类型)
也就是:
- 父节点点上没有 selected
- 父节点子树没有 selected
- 然后 i 成为父节点新的 representative:
ni[w[i]]=i;
gg[len[w[i]]-len[fa[w[i]]]+ct].push_back(i);
push(point(w[i]),1);
push2(point(w[i]),-len[w[i]]-(i-1));
这种事件没有杀任何 start,只是让同一个 start i 的 token 从 u 移到 parent(u)。
如果这种 Type S 对很多 i 多次发生,总事件数 (E) 就可能炸。
所以复杂度证明的核心被缩成一句话:
证明 Type S 的总发生次数是 (O(biao \log biao)) 或 (O(biao))。
接下来所有势能法,本质都是在想办法给 Type S 记账。
五、势能尝试 3:用 “selected 的 len 总和” 给 Type S 记账(失败:插入能把势能加爆)
尝试势能:
[
\Phi = \sum_{u\in A} len[u]
]
Type S:u→fa[u],selected 节点 len 严格下降,势能下降至少 1。
所以如果没有插入,Type S 次数 ≤ 初始势能。
但插入(ins(rr))可能把一个很深的 u 加入 selected,势能可以被加到 (Θ(n)) 级别,每步都可能加,累计 (Θ(nQ))。
而且你没法把它简单地用“杀了一个旧 representative”抵消,因为可能是“新增 selected”而不是替换。
因此这个势能法不能闭合出 (O(biao \log biao)) worst-case 上界。
我可以构造抽象层面的反例:每次插入都让 selected 多一个深节点,同时 Type S 把它慢慢上移;这种会让 (\sum len) 累计很大,而 Type S 次数也大。
所以:❌ 失败。
六、势能尝试 4:用 “每次 Type S 都需要吞掉大量‘空子树’”做倍增(失败:唯一性链条不保证倍增)
直觉:Type S 成立时,parent 的子树里除了这一个 selected 没有别的 selected,所以好像 token 在向上“占据更大范围”。如果每次向上都能让“被它独占的范围”至少翻倍,就能得出每个 start 上移 (O(\log))。
但是这里的“范围”你可以选很多种:
- 以 fail-tree 的 节点数子树规模 sz[u] 为范围:
Type S 从 child 到 parent,并不保证sz[parent] >= 2*sz[child]。fail-tree 可能是长链,sz只 +1。 - 以 SAM 的 endpos 大小 为范围:
上移 fail 会让 endpos 集变大,但增长可以是 +1(比如a^k那种),并不保证倍增。 - 以字符串长度 len 为范围:上移只是减少长度,不是倍增对象。
因此,“倍增记账”不能无条件成立。
你可以在 SAM fail 链里出现很多连续状态,endpos 大小每次只增 1,这就破坏了倍增。
所以:❌ 失败。
七、势能尝试 5:重型轻型分解(HLD)来界定上移次数(半成功但缺关键补充)
这是我认为最有希望补成严谨 proof 的路线,但目前我也没把最后一环补上。
7.1 HLD 基本事实
对 fail-tree 做重轻分解,以 子树节点数 sz[u] 选 heavy child。
性质:任意节点到根路径上,穿过 light edge 的次数 ≤ (O(\log tot))。
如果能证明:
- Type S 主要发生在 light edges 上(或者 heavy edges 总穿越次数也可控)
就能得到总 Type S 次数 (O(biao\log biao))。
7.2 “在任意时刻,每条 heavy path 上最多一个 selected”
因为 selected 是反链,而 heavy path 是一条祖先链,所以同一 heavy path 上不能同时有两个 selected。
这很好:它表明同时并发不会在同一 heavy path 上叠加。
7.3 卡点:同一 heavy path 上“串行”发生的 Type S 次数仍可能很大
虽然同一时刻只有一个 token 在一条 heavy path 上,但不同时间可以有很多不同 start 轮流在这条重链上上移。
要完成证明,需要一个额外引理,大概形式像:
某条 heavy path 上 token 的“向上流动次数”可用“进入该 path 的 start 次数”或“发生替换/死亡的次数”线性控制。
我现在没能从代码逻辑里提取出这个引理的必然性。
换句话说:HLD 给了你 “light edge 很少”,但还需要你证明 “heavy edge 不会被反复刷爆”。
所以:⚠️ 半成功但不闭合。
八、势能尝试 6:把 Type S 看成“唯一覆盖链”上的游走,用 kill 次数绑定(失败:存在“长期不被替换”的链)
另一个想法:
- Type S 发生时,parent 子树里只有这一条 selected 链(subtree selected count =1)。
ins第二段 while 会在插入新节点时把这条“唯一链上的某个祖先 selected”删掉(替换),并 kill 一个 start。- 所以猜测:每做很多次 Type S,就应该伴随很多次替换 kill,从而总 Type S ≤ O(#kills)=O(biao)。
但问题是:完全可能出现长时间区间里,这条唯一链上没有任何新插入落到它的子树,所以不会替换。那它可以靠 gg 一直慢慢上移很多次而不发生 kill。
在这种情况下,你没法用 kill 次数支付 Type S。
所以:❌ 失败。
九、到这里:我无法给出严格 worst-case 复杂度证明
我能严格闭合的只有:
- (W_1 = O(E + tot))(covered 势能)
- Type K / Type R 的
gg事件总数 (O(biao))
真正危险的是:
Type S(纯上移存活)事件总数是否可控?
我尝试了多种势能(len 和、倍增、HLD、kill 绑定)都没把它在 worst-case 下闭合掉。
因此我只能诚实地说:
就我目前的推导能力,我无法证明这份实现的 worst-case 一定是 (O((n+Q)\log n))。
它极有可能是对的,但缺少那条关键均摊引理;也不排除它在某些构造下会退化。
十、转向 hack:这份实现最怕什么?(如何构造“可能爆 Type S”的数据)
既然危险点明确是 Type S,我们要让它爆,就要同时满足:
- 有很多 start i 会存活(use[i]==0),不会被 Type K/R 早早杀掉
- 这些 start 会经历多次
w[i] -> fa[w[i]]的上移(而不是一次就死) - 上移后还能继续存活(父子树里仍没有其他 selected,避免被 Type K 杀)
- 同时还要让每次上移的 (\Delta_u = len[u]-len[fa[u]]) 尽量小(最好=1),这样上移会频繁发生(桶间隔短)
换句话说,你要造一种字符串,使得 fail-tree 里存在很多小Δ的链,并且滑窗过程中会出现很多“唯一 selected 链”不断上移的情况。
下面我给出几类我认为最可能逼出最坏行为的构造,并提供生成器。
说明:因为我没能完成严谨 worst-case 证明,我也不能保证这些一定能把它卡爆;但它们是围绕 Type S 机制“对症下药”的构造。
十一、Hack 方案 A:纯 L 最大化更新量(基础框架)
共同框架:让动态更新最多、查询最少。
- 取 (T=1)
- 取 (n \approx 50000), (Q \approx 50000)
- 操作全是
L(没有Q),这样倒序时每步都要做gg处理 +ins(rr),而不会被输出 IO 干扰。
样例输出为空行(允许)。
十二、Hack 方案 B:de Bruijn 循环(小字母表 + 高“局部唯一性”)
目标直觉:
- de Bruijn 序列(循环)让“长度 = k 的子串”几乎全唯一;
- 小字母表(2 或 3)又保证整体高度重复,容易出现很多 Δ=1 或小Δ 状态;
- 这种“局部唯一 + 全局重复”的组合,是最容易造出怪异 SAM fail 结构的。
生成器(Python)
# debruijn generator for alphabet size a and subsequence length k
def debruijn(a, k):alphabet = list(range(a))a_seq = [0] * (a * k)seq = []def db(t, p):if t > k:if k % p == 0:seq.extend(a_seq[1:p+1])else:a_seq[t] = a_seq[t - p]db(t + 1, p)for j in range(a_seq[t - p] + 1, a):a_seq[t] = jdb(t + 1, t)db(1, 1)return seq # length a^kdef gen_case_debruijn(n, q, a=2):# choose k so that a^k >= n, then truncatek = 1while a ** k < n:k += 1seq = debruijn(a, k)# map to lettersmp = [chr(ord('a') + i) for i in range(a)]s = ''.join(mp[x] for x in seq)[:n]print(1)print(n, q)print(s)for _ in range(q):print("L")# example:
# n = 65536 (2^16), q = 34464 -> n+q = 100000
gen_case_debruijn(65536, 34464, a=2)
你也可以把 a=3 试一试(但 (3^k) 很快爆到 >1e5,k 比较小)。
十三、Hack 方案 C:构造“a 链 + 多分叉叶子”的 comb-like 模式
目标直觉:想做出类似“主链很长、并且在很多深度上都有独立分叉”的 fail-tree 形态,从而可能产生大量可选叶子 + 小Δ。
一种常见尝试是拼接“逐渐增长的 a-run 后跟分隔符”的块:
[
a^1 b\ a^2 b\ a^3 b\ \cdots\ a^m b
]
这会制造大量形如 (a^k b) 的子串,它们两两之间不互为前缀(因为分岔位置不同),有机会同时成为 selected(反链)。
生成器(Python)
def gen_comb(n, q, sep='b'):s = []k = 1while len(s) + k + 1 <= n:s.extend(['a'] * k)s.append(sep)k += 1# padwhile len(s) < n:s.append('a')s = ''.join(s)print(1)print(n, q)print(s)for _ in range(q):print("L")# example:
# n=50000, q=50000
gen_comb(50000, 50000, sep='b')
变体:把 sep 轮换成 'b','c','d',...,增加分叉多样性:
def gen_comb_multi_sep(n, q):seps = [chr(ord('b') + i) for i in range(25)]s = []k = 1idx = 0while len(s) + k + 1 <= n:s.extend(['a'] * k)s.append(seps[idx % len(seps)])idx += 1k += 1while len(s) < n:s.append('a')s = ''.join(s)print(1)print(n, q)print(s)for _ in range(q):print("L")gen_comb_multi_sep(50000, 50000)
十四、Hack 方案 D:小字母表随机(提高小Δ状态比例)
直觉:大字母表随机往往导致 suffix link 跳很大(Δ大),反而减少事件。
小字母表(2~3)随机会产生更多重复,可能让 Δ 变小、fail 链更长,从而让 gg 触发更频繁。
生成器(Python)
import randomdef gen_random_small_alpha(n, q, alpha="ab", seed=1):random.seed(seed)s = ''.join(random.choice(alpha) for _ in range(n))print(1)print(n, q)print(s)for _ in range(q):print("L")# example
gen_random_small_alpha(50000, 50000, alpha="ab", seed=12345)
你也可以批量跑多个 seed 找最慢的。
十五、Hack 方案 E:周期很短但不纯(例如 “ababa… + 少量扰动”)
直觉:纯周期串(如全 a、或纯 ab)可能让 selected 数很小,反而不爆。
更危险的是:“大部分周期 + 少量扰动”,可能同时做到:
- fail 链不短(因为重复)
- 又有足够多分叉(因为扰动),使 selected 不至于收缩到 1
生成器(Python)
def gen_period_with_noise(n, q, period="ab", noise_rate=0.01, seed=1):import randomrandom.seed(seed)base = (period * (n // len(period) + 1))[:n]s = list(base)for i in range(n):if random.random() < noise_rate:s[i] = chr(ord('a') + random.randint(0, 25))s = ''.join(s)print(1)print(n, q)print(s)for _ in range(q):print("L")gen_period_with_noise(50000, 50000, period="ab", noise_rate=0.02, seed=7)
十六、如果你想“验证是否真的能 hack”,我建议的检查指标
你可以在代码里加几个计数器(不需要改变逻辑):
cnt_event++:每次进入if(use[i]==0)的ggbodycnt_typeS++:记录纯上移存活的次数(即没走use[i]=1分支也没触发父点替换 kill)cnt_w1++:ins第一段 while 每走一步 ++cnt_w2++:ins第二段 while 每走一步 ++
然后在每组数据结束输出这些统计量。
如果你看到:
cnt_typeS接近 (Θ(n^2)) 或远大于 (n\log n)
那复杂度确实可能有问题。
如果在上述构造下它依然是 (O(biao\log biao)) 量级,那就更像是“代码确实有隐藏均摊”。
十七、我目前最清晰的结论
- 我能严谨证明:
ins第一段 while 总步数 (W_1 = O(E+biao)),并且gg里的“会 kill 某个 start 的事件”总数是 (O(biao))。 - 我没能证明:
gg里的 纯上移存活(Type S) 总数一定是 (O(biao\log biao));这正是你质疑的复杂度均摊点,也是我同样卡住的地方。 - 因此更务实的下一步就是:用我给的构造家族去做 stress/hack,用统计量观察 Type S 是否可能爆。
如果你愿意把你跑出来的统计结果(尤其是 cnt_typeS 随 n 的增长趋势)贴我,我可以再基于数据特征反推更精确的势能(有时真的需要看到“最慢样例”的结构,才能猜到正确的均摊不变量)。