算法总结 - CYC2022

· · 算法·理论

算法总结 - CYC2022

前言

为什么会有这个东西呢?主要是我在做这个题单的时候,发现省选的时候这些算法都过过一遍,但是掌握情况相当的差。其他的题单也有部分的知识是我没有掌握到的。

所以,为了更好的完成这些题单,也为了为之后在省选有完备的的资料可查,或者为之后的人铺平道路,整理以下的内容。

$2024.11.28

为了能够及时更新,你会在我拖更的地方看到这样一句友情提示:

这里有你没写完的东西,杂鱼...

大纲

动态规划

动态DP

图论

最短路

floyd

spfa

dijkstra

johnson

差分约束

最小生成树

prim

kruskal

最近公共祖先(LCA)

tarjan(连通分量问题)

scc(强连通分量)

割边-edcc(边双连通分量)

割点-vdcc(点双连通分量)

二分图

网络流

FF-EK-Dinic

费用流

上下界流

模拟费用流

2-SAT

二分图最大权匹配

最小树形图

仙人掌与圆方树

数据结构

树状数组

线段树

序列线段树

值域线段树

可持久化线段树

线段树合并

线段树分裂

ZKW线段树

并查集

普通并查集

扩展域并查集

权值并查集

莫队

普通莫队

带修莫队

回滚莫队

树上莫队

笛卡尔树

树套树

线段树二分/树状数组倍增

多树二分

整体二分

CDQ分治

线段树分治

重链剖分

长链剖分

平衡树

Splay

FHQ-Treap

L_iC_{hao}T_{ree}(李超线段树)

L_{ink}C_{at}T_{ree}(动态树)

FFT(快速傅里叶变换)

NTT(快速数论变换)

Prufer序列

可并堆

斜堆

随机堆

左偏树

KDT

数学

质数的判定

暴力

埃氏筛

欧拉筛

Miller–Rabin

卡特兰数

威尔逊定理

错排列

圆排列

多重集排列组合

BSGS与EXBSGS

生成函数

普通生成函数

指数生成函数

狄利克雷生成函数

狄利克雷卷积

莫比乌斯反演

亚线性筛法

杜教筛

PN筛

min_25筛

第一类斯特林数

第二类斯特林数

斯特林反演

二项式反演

Burnside定理

Polya定理

博弈论

二次剩余

线性基

字符串

字符串哈希

KMP

EXKMP

ACAM^{[1]}(AC自动机)

SAM(后缀自动机)

SA(后缀数组)

tricks

None

注:

# 动态规划 ## 期望与概率DP 首先想一下能不能从高斯消元入手。比如说:小明在一个 $[1,n]$ 的序列上随机游走,每次会随机走到距离 $\le1$ 的一个位置,求走到 $n$ 的期望步数。 我们可以写出他的转移方程: $ $。 这时候,我们把它写成矩阵,玩高斯消元,获得一个 $O(n^3)$ 的算法。 但是这类随机游走的核心就在于他的矩阵非常稀疏。比如这道题,我们大致只有 $3\times n$ 个非零值。 ###### 这里有你没写完的东西,杂鱼... # 图论 ## 最短路 ### floyd floyd 是一种可以求全源最短路的算法,其时间复杂度为 $O(n^3)$。其思路比较接近动态规划。 具体地说,我们顺序枚举一个中转点 $k$,然后去更新每一对的 $(i,j)$ 的最短路为 $dis_{i,j}=\min(dis_{i,j},dis_{i,k}+dis_{k,j})$。这样子更新完之后,$dis_{i,j}$ 实际上变成了经过 ${i,j}\cup[1,k]$ 的点时的 $(i,j)$ 之间的最短路。 一定要注意不要把 $k$ 放到 $i,j$ 里面枚举,因为这样子不满足 $dis_{i,j}$ 是经过 ${i,j}\cup[1,k]$ 的点时的 $(i,j)$ 之间的最短路。不过有研究表明把这样的循环执行三次也会有正确性? 代码如下: ```cpp for (int k = 1; k <= n; ++k) for (int i = 1; i <= n; ++i) for (int j = 1; j <= n; ++j) if (i != j && i != k && j != k) //这一行实际上可以不要 tmin(dis[i][j], dis[i][k] + dis[k][j]); ``` ### spfa spfa 的思路是逐步松弛,缩小点的最短路长度。 具体的来说,我们先将每个点的最短路长度设置为极大值,然后将起始点的最短路长度设为 $0$,将其放入队列中。 然后我们不停的取出队首,遍历其所有能到的点,如果有的点的最短路长度比从队首过来的更长,那就更新那个点的最短路大小。如果那个点不在队列中那就加入队列。 这个算法可以判断负环。显然如果一个点被更新了超过 $n$ 次,那就一定有负环。时间复杂度是 $O(kn)$ 的,随机数据下 $k=2$,但是实际上可以轻松卡到其上界限 $O(nm)$。代码如下: ```cpp memset(dis, 0x3f, sizeof dis); dis[s] = 0; q.emplace(s); while (q.size()) { int tp = q.front(); q.pop(); viq[tp] = 0; for (const node& sp : son[tp]) if (dis[sp.p] > dis[tp] + sp.v) if (dis[sp.p] = dis[tp] + sp.v, !viq[sp.p]) if (q.emplace(sp.p), ++tc[sp.p] > n) return -1; } return dis[t]; ``` ### dijkstra 如果一幅图没有负权边,那么我们从小的向大的扩展一定不会错。因此,我们可以使用优先队列来维护最短路最短的没有扩展的点,并类似于 spfa 一样松弛其他的点。只不过因为没有负边权,所以第一次访问到就是最优解。代码如下: ```cpp memset(dis, 0x3f, sizeof dis); q.emplace(s, dis[s] = 0); while (q.size()) { node tp = q.top(); q.pop(); if (dis[tp.p] < tp.v) continue; for (const node& sp : son[tp.p]) if (dis[sp.p] > dis[tp.p] + sp.v) q.emplace(sp.p, dis[sp.p] = dis[tp.p] + sp.v); } return dis[t]; ``` ### johnson johnson 是另一种可以处理负边权的全源最短路算法。他的思路是 通过将每一个点“抬高一点”,将边权转化为正权,然后处理。 我们引入一个物理上常见的概念,叫做“势能”。我们新建一个超级源点向每一个点连一条边,权值为 $0$,随后用 spfa 跑出到每一个点的最短距离 $h_p$,视为每一个点的“势能”。随后我们将 $(i,j)$ 的边权重新标注为 $w_{i,j}+h_i-h_j$。由最短路的三角形不等式可得这个边权是恒正的。 最后在求 $p\to i$ 的最短路时,$d_i=d^\prime_i+h_t-h_s$。其中 $d^\prime_i$ 是 diskstra 跑出来的虚拟距离。 为什么这样是正确的呢?我们注意到,在求 $i\to j$ 的最短路的过程中,势能的变化量永远是 $h_j-h_i$,和物理意义上的势能很像:势能绝对大小和选定的参考点有关,但相对大小与之无关,两点之间的势能差只与两端点有关,和中间怎么走的无关。 因此我们通过保证在任意两点之间的每一条路径的增加量相同的方式,保证了我们跑出来的最短路转化后仍然是最短路。 ## 差分约束 差分约束可以用来求解形如 $\forall i\in m,x_{a_i}-x_{b_i}\ge/\le y_i$ 的不等式方程组。 具体的来说我们可以先通过调换顺序将不等号的方向同一,比如全部统一成 $\ge$,随后我们思考在最长路中的三角形不等式,发现 $d_i-d_j\ge E_{j\to i}$。形式一样。 于是 $x_i-x_j\ge d$ 可以转化为 $j$ 到 $i$ 有一条长度为 $d$ 边,然后随便找一个源点作起始点跑一下最长路就行了。 显然如果出现了正环,那么就无解,否则每个点的 $d$ 就是一组合法的解。 ## 最小生成树 ### prim prim 的思路是加点。每一次我们选出和当前已选择的点集最近的点,并将它加入已选择的点集当中。 具体地说,最开始的时候,我们随意指定一个节点作为已选择的点集中的初始元素,并计算出每一个节点和他的距离。随后我们扩展 $n-1$ 轮。每一轮我们找出距离最近的,加上他的距离,并将它纳入已选择的点集中。随后再用这个点去重新计算别的点的最短距离。这样扩展完之后 $n$ 个点就都在已选择的点集中了。 时间复杂度 $O(n^2)$,动用人类智慧的话就不清楚能做到多少了。 ### kruskal kruskal 的思路是加边。每一次我们选出边权最小的边,并检查两个端点,如果两个端点不在同一个集合中,就把二者合并,并在贡献中加上这条边。否则不做处理。显然这个贪心策略不会出任何问题。 ## 最近公共祖先 最常用的有 $3$ 种解法:倍增,tarjan,dfs 序。 倍增法就是预处理出每一个结点的 $2^i$ 级祖先。先将两个节点的深度调整一样,然后不停往上倍减着跳,最终二者相同的位置就是答案。参考代码如下: ```cpp inline int lca(int l, int r) { if (dep[l] < dep[r]) swap(l, r); for (int i = 19; i >= 0; i--) (dep[fa[l][i]] >= dep[r]) && (l = fa[l][i]); if (l == r) return l; for (int i = 19; i >= 0; i--) (fa[l][i] != fa[r][i]) && (l = fa[l][i], r = fa[r][i]); return fa[l][0]; } ``` tarjan 是一种可以做到严格线性的离线算法。通常情况下复杂度为 $O(m\alpha(m+n,n)+n)$。 算法流程是这样的:我们先将询问同时记录在那**两个**点上,然后我们从根节点开始遍历。 每一次到一个节点 $u$ 时,我们先去遍历他的所有子节点 $v$ 并将 $v$ 节点的并查集合并到 $u$ 上。然后将 $u$ 标记为遍历过。 最后检查 $u$ 节点的所有查询 $v$。如果 $v$ 已经遍历过了那么答案就是 $v$ 所在的并查集,否则不做任何操作。参考代码如下: ```cpp inline void dfs(int p, int f) { for (int sp : son[p]) if (sp != f) dfs(sp, p), merge(sp, p); vis[p] = 1; for (const node& q : que[p]) if (vis[q.p]) ans[q.id] = find(q.p); } ``` 接下来是 dfs 序。dfs 满足一些神奇的性质,比如说 $[dfs_u+1,dfs_v]$ 中深度最小的节点的深度恰为 $lca$ 的深度 $-1$,如果 $u$ 的深度比 $v$ 小。 其实这个我不是很想证明,因为分讨一下 $u$ 是不是 $lca$ 就 证明完毕了。这样子你就可以用 ST 表做到 $O(1)$ 查询了。 ## tarjan tarjan 的思路并不复杂,我们先按照 $dfs$ 序进行遍历,然后处理每一个边。具体的来说,我们给每个节点两个标记:$dfn,low$。$dfn$ 记录他的 $dfs$ 序中的编号,$low$ 则是在一定的规则下通过至多一条不在搜索树上的边能到达的 dfs 序的最小值。 ### 强连通分量 强连通分量是针对于有向图说的。具体的,如果同时存在 $a\to b,b\to a$ 的路径,那么 $a,b$ 强连通,而一个强连通分量 $S$ 就是一个满足 $\forall i,j\in S$,$i,j$ 强连通的极大子图。 那么我们在 tarjan 的时候具体怎么办呢?到每一个点 $p$ 时,我们会将 $dfn_n$ 和 $low_p$ 均设置为 $p$。首先,如果一个点 $p$ 发现了一个没有遍历过的点 $sp$,那么我们就可以先继续遍历 $sp$,随后将 $low_p$ 取为 $\min(low_p,low_{sp})$。这一种显然没有问题。 如果发现了一个遍历过的点那就要分一下情况了: 如果这个点已经被标记在某一个强连通分量中了,那说明这个点无法到达自己,肯定和自己不是同一个强连通分量内的。 但是如果没有被标记过呢?那么有两种情况:一种是访问到自己的祖先了,一种是访问到了自己的子节点。总之我们发现我们的这个边不是树边,而且因为那个节点截至目前仍然没有被纳入强连通分量,所以一定能到达自己,可以更新。 说白了,在强连通分量下,这个规则就是双方互相可达。但是似乎上面的最后一句的“一定能到达自己”不是很说的走呢? 并不是的。当我们在遍历完了 $p$ 的所有子节点之后发现 $dfn_p=low_p$,那么不难想到这个点及其子树中的所有点其实都无法逃出 $p$ 的魔爪,否则就会更新到 $low$ 中。因此没被更新就是可以访问到自己。 还差最后一个问题:我怎么知道该标记哪些点是自己这个连通分量内的?当 $dfn_p=low_p$ 的时候,我们会去标记在 $p$ 的子树之下而且可达到 $p$ 的节点, ### 割边-边双连通分量 ### 割点-点双连通分量 ## 二分图 二分图是这一个图 $(V,E)$,可以分出两个点集 $V_1,V_2$,满足 $V_1\cap V_2=\emptyset,V_1\cup V_2=V$ 且 $\forall(l,r)\in E,l\in V_1,r\in V_2$ 或者 $l\in V_2,r\in V_1$。 ### 判定 有多种办法可以判定,常用的有染色法,扩展域并查集等。 染色法就是尝试给每个节点分配集合。显然如果 $l\in V_1$,那么 $\forall(l,r)\in E,r\in V_2$,换个集合仍然成立。 所以就随便钦定一个点在某个集合里,能分出来就有,分不出来就没有。 扩展域并查集类似,如果二者有就将 $l_1,r_2$ 和 $l_2,r_1$ 分别绑定一下。 ### 最大匹配 就是给你一张二分图,问你能从里面选出多少边,满足这些边的左右部点都不重。 这个常用网络流中的 Dinic 或者匈牙利算法解决。 匈牙利算法就是不停的强制让某个点匹配上,如果某个右部点已经匹配上了就把他抢过来,直到成功匹配到没人的或者确实配不上。 网络流的话每一个左部点从源点牵一个流量一的,右部点连一个流量一的到汇点,中间的流量一就行。 ## 霍尔定理 令 $L,R$ 分别表示左右点集,那么定义 $p\in L,D(p)=\{u|u\in R,(p,u)\in E\}$。也就是说 $D(p)$ 是左部点 $p$ 所直连的右部点的集合。 再定义 $S\subseteq L,D(S)=\cup_{p\in S}D(p)$。也就是说 $D(S)$ 是左部点子集 $S$ 中存在点直连的右部点集合。 再约定 $|S|$ 表示集合 $S$ 的大小。 那么,$\forall S\in L,|D(S)|\ge|S|\iff$ 存在 $L$ 向 $R$ 的完全匹配。 显然和匈牙利算法的证明等价。必要性显然,充分性反证: 设G中不存在完全匹配,取 $G$ 的一个最大匹配 $M$,则 $L$ 中至少有一个点不在 $M$ 上,且该点必至少与一条不在 $M$ 中的边相连。 该边的另一个顶点若也为 $M-$ 非饱和点,则与 $M$ 为最大匹配矛盾,若另一个顶点为 $M-$ 饱和点,则考察在 $M$ 中与该顶点相邻的点,利用饱和点去考察在 $M$ 中相邻的饱和点(交错地考察,即交错地通过 $M$ 中的边和非M中的边),直至考察完毕。 由相异性条件知,最后必考察至非饱和点,此时出现一条增广路,又与假设矛盾($M$不是最大匹配),故充分性成立。[refer](https://blog.csdn.net/feynman1999/article/details/76037603)。 扩展定理: 1. $\forall S\in L,|D(S)|\ge|S|-A\iff L$ 中最大匹配的情况下最多有 $A$ 个点失配。 2. $\forall S\in L,|D(S)|\ge k\times|S|\iff L$ 中每个点在匹配 $k$ 个右部点的情况下仍然存在满最大匹配。 3. 如果左部点 $i$ 最多能匹配 $r_i$ 个右部点,那么 $\forall S\in L,|D(S)|\ge\sum_{i\in S}r_i\iff$ 存在满最大匹配。 证明的话,第一个可以让每个左部点同时连上 $A$ 个赛博右部点,转化为原定理,得证。 第二个和第三个可以让第 $i$ 个人裂成 $r_i(=k?)$ 份去分别和几个右部点连边,转化为原定理,得证。 但是解题是有技巧的,限制数是 $2^n$ 级别的,我们需要去抓最紧的限制。也就是将有效限制个数缩到最小。从而求解问题。 ## 网络流 ### FF-EK-Dinic #### FF(Ford–Fulkerson) 增广思路 FF,即 Ford–Fulkerson 增广。该方法运用贪心的思想,通过寻找增广路来更新并求解最大流。 其中有一个概念叫做增广路,说白了就是所有边剩余流量大于零的路径。使用这条边显然可以使答案增加,但是直接贪心是错的。 接下来,我们进行反悔贪心。当我们给 $x\to y$ 这条边增加 $v$ 的流量时,就将 $x\to y$ 的剩余流量减去 $v$,而将 $y\to x$ 的剩余流量增加 $v$。 正如 OI-wiki 的一张图: ![](https://oi-wiki.org/graph/flow/images/flow2.png) 可以严格证明,这里略去。~~反正不考证明~~。其本质上就是 [最大流-最小割](https://oi-wiki.org/graph/flow/max-flow/#%E6%9C%80%E5%A4%A7%E6%B5%81%E6%9C%80%E5%B0%8F%E5%89%B2%E5%AE%9A%E7%90%86) 定理。 #### EK 算法 使用 BFS时,FF 增广表现为 EK 算法。其本质上就是将找出任意一条变成了找出最短的一条,每次使用 BFS 找出最短的增广路并增广。时间复杂度为 $O(nm^2)$。时间复杂度劣,而且和 Dinic 难度大致相当,不附代码。 #### Dinic 算法 他的优化就在于他同时对多条增广路进行处理,具体的流程如下: 首先,对当前的图做一次 BFS,求出每个点的“深度”。 然后,进行 DFS。每次 DFS 时,记录当前节点和当前携带流量。随后,一直向深度大 $1$ 的子节点分配尽可能多的流量,直到没有子节点或者自己流量用完。贡献就是所有被分配到的子节点的贡献之和。 这里还常用一个“当前弧”优化,其实说白了,就是之前已经被分配满的子节点以后不可能再分配了,因此记录一个已枚举位置就行。参考代码如下: ```cpp //就这你还想看?没更新呢! ``` ### 费用流 对于没有负圈的普通费用流,常用 SSP(Successive Shortest Path) 进行求解,思路类似于 FF,每一次找一条或多条单位费用最大/小的路进行增广。 负圈是指单位费用为负的圈。 注意费用流的时间复杂度是 $O(nmf)$ 的,$f$ 是最大流。可以被卡到 $O(n^32^{\frac{n}{2}})$。 给出基于 Dinic 的参考代码如下: ```cpp struct SSP { struct node { int p, f, v; }tmp; vector<node>e; vector<int>h[5005]; inline void ins(int l, int r, int f, int v) { tmp.p = r; tmp.f = f; tmp.v = v; h[l].emplace_back(e.size()); e.emplace_back(tmp); tmp.p = l; tmp.f = 0; tmp.v = -v; h[r].emplace_back(e.size()); e.emplace_back(tmp); } //加边函数 int d[5005]; bitset<5005>vis; inline bool spfa() { vis.reset(); fill(d + 1, d + n + 1, 0x3f3f3f3f3f3f3f3f); queue<int>q; q.emplace(s); d[s] = 0; while (q.size()) { int tp = q.front(); q.pop(); vis[tp] = 0; for (int i : h[tp]) if (e[i].f && d[e[i].p] > d[tp] + e[i].v) if (d[e[i].p] = d[tp] + e[i].v, !vis[e[i].p]) q.emplace(e[i].p), vis[e[i].p] = 1; } return d[t] != 0x3f3f3f3f3f3f3f3f; } //分层,这里的“距离”从 $1$ 变成了边权,因为有负权,所以需要 spfa int pr[5005]; inline int dfs(int p, int f) { if (p == t || !f) return f; int ret = 0, v; vis[p] = 1; for (int& i = pr[p];i != h[p].size();++i) { node& sp = e[h[p][i]]; if (!vis[sp.p] && sp.f && d[sp.p] == d[p] + sp.v) { v = dfs(sp.p, min(f, sp.f)); if (!v) d[sp.p] = 0x3f3f3f3f3f3f3f3f; sp.f -= v; e[h[p][i] ^ 1].f += v; ret += v; f -= v; ans += v * sp.v; //要在这里统计费用,边权不一致。 if (!f) return ret; } } vis[p] = 0; return ret; } inline int que() { while (spfa()) { fill(pr + 1, pr + n + 1, 0); //当前弧优化 for (int v;v = dfs(s, 0x3f3f3f3f3f3f3f3f);) mxf += v; } return mxf; } //que 返回最大流,ans 存最小费用 }ssp; ``` ### 上下界流 正如其名,原先只有上界的网络流也有下界拉!一篇较好的[博客](https://blog.csdn.net/EQUINOX1/article/details/135953172)专讲基础上下界流。 #### 存在性判定 对于每一个点,我们令 $cin_i=\sum f(j,i),cout_i=\sum f(i,j),rd_i=cin_i-cout_i$,然后思考: 1. 对于 $rd_i=0$ 的点,其入度的下界与出度的下界相同,也就是说这些边我们可以将上界重写为 $fmax-fmin$,仍满足流量守恒。 2. 对于 $rd_i<0$ 的点,他若直接将上界重写为 $fmax-fmin$,则不满足流量守恒,出度过小。需要向超级汇点连一条容量上界为 $-rd_i$ 的边。 3. 对于 $rd_i>0$ 的点,他若直接将上界重写为 $fmax-fmin$,则不满足流量守恒,入度过小。需要从超级源点连一条容量上界为 $rd_i$ 的边。 此时,若从超级源点到超级汇点满流,则存在,反之不存在,由源汇点的流量与原图的流量守恒关系不难证得。 #### 有源状态下的判定 从 $t$ 向 $s$ 连一条容量无限的边,就完美地将有源强转成了无源,然后就能做了。 #### 最大/小流(有源) 我们还是先有源强转无源跑判定,判定完了之后记录 $s$ 到 $t$ 的最大流,删除 $t$ 到 $s$ 的边,然后分情况: 如果是最大流,就跑出 $s$ 到 $t$ 的最大流和开始的那个最大流相加得到结果。 如果是最小流,就跑出 $t$ 到 $s$ 的最大流和开始的那个最大流相减得到结果。 问什么正确呢?其实判定的本质就是强制流量守恒,也就是强制将上下界流改成普通流。 判定如果成功的话,那么所有的点都在同一个“势能面”上了,多余的就是在推流/反流。 比如针对两个的[缝合体]()红石科技,我们可以这样写: ```cpp #include<bits/stdc++.h> using namespace std; #define int long long ...//定义变量 struct ULBNF { ...//定义变量与其他函数 inline bool dneflow(int sm) { ans = 0; while (bfs()) fill(cur, cur + n + 4, 0), ans += dfs(s, 1e18); return ans != sm; } inline int maxflow() { while (bfs()) fill(cur, cur + n + 4, 0), ans += dfs(s, 1e18); return ans; } inline int minflow() { while (bfs()) fill(cur, cur + n + 4, 0), ans -= dfs(s, 1e18); return ans; } }net; //upper-lower bound network flow signed main() { ios::sync_with_stdio(0); cin >> n >> m >> ys >> yt; s = n + 1; t = n + 2; for (int i = 0, a, b, c, d;i != m;++i) cin >> a >> b >> c >> d, net.ins(a, b, d - c, 0), rd[a] -= c, rd[b] += c; bool ext = 0; for (int i = 1;i <= n;++i) if (rd[i] > 0) sm += rd[i], net.ins(s, i, rd[i], 0), ext = 1; else if (rd[i] < 0) net.ins(i, t, -rd[i], 0), ext = 1; // :) net.ins(yt, ys, 1e18, 0); if (net.dneflow(sm)) return puts("A clever xzy~~~"), 0; ans = net.e.back().f; net.dellast(yt, ys); t = ys, s = yt; cout << (ext ? net.minflow() - 1 : -1) << ' '; s = ys, t = yt; cout << net.maxflow() + 1 << endl; } ``` #### 最大/小费用可行流 基于我们前面所讲的判定的本质就是强制流量守恒,我们不管在建出来的辅助图上跑网络流还是费用流,反正只要限定是最大流,就能对应出原图的一个可行流。 所以说,我们建出辅助图并且提前计算砍去下界少算的费用,辅助图的最大/小费用最大流就是原图的最大/小费用可行流。比最大/小流(有源)还少一步。 核心代码如下: ```cpp struct ULBNF { ... int d[505]; bool vis[505]; inline bool spfa() { fill(vis, vis + t + 4, 0); memset(d, 0x0f, sizeof d); vis[s] = 1; d[s] = 0; queue<int>q; q.push(s); while (q.size()) { int tp = q.front();q.pop(); vis[tp] = 0; for (int i = 0;i != h[tp].size();++i) { node& sp = e[h[tp][i]]; if (sp.f && d[sp.p] > d[tp] + sp.v) if (d[sp.p] = d[tp] + sp.v, !vis[sp.p]) q.push(sp.p), vis[sp.p] = 1; } } return d[t] < 1e8; } int cur[505]; inline int dfs(int p, int a) { if (p == t || a == 0) return a; int ret = 0, f; vis[p] = 1; for (int& i = cur[p]; i != h[p].size(); ++i) { node& sp = e[h[p][i]]; if (!vis[sp.p] && d[sp.p] == d[p] + sp.v && sp.f) { f = dfs(sp.p, min(a, sp.f)); if (!f) d[sp.p] = 0x0f0f0f0f; sp.f -= f; e[h[p][i] ^ 1].f += f; ret += f; a -= f; miv += sp.v * f; if (!a) return ret; } } vis[p] = 0; return ret; } inline int micflow() { while (spfa()) { fill(cur, cur + t + 4, 0); for (int v;v = dfs(s, 12);) ans += v; } return miv; } }net; ``` 当然还是别忘了要给原图中的 $t\to s$ 建一条没费用的无限流。 ### 模拟费用流 思路类似于手动消元,说白了就是利用建出来的图的性质进行针对性的优化,从而加速计算。[例题1](https://vjudge.net/problem/QOJ-7185#author=luzchancc)。 在这道题中,建模其实非常的裸,但是点数过大,完全不能跑费用流,因此需要手动最短路。 那怎么做呢?我们注意到原图近似是一个二分图,也就是说任意一条路径可以视为 $s\to l\to r$,任意多条 $r\to l\to r$ 和 $r\to t$ 拼接而成的。第三段费用为 $0$,所以只用分别维护第一二段从每个 $r$ 出发的最优边。 给出一份仅保留核心的参考代码: ```cpp inline void enable(int lp) { if (lus[lp]) for (int i = 0;i != m;++i) if (rls[lp][i]) sh[i].emplace(c[lp][i], lp); //这里是在处理添加左点对 slr 路径的影响 for (int i = 0;i != m;++i) for (int j = 0;j != m;++j) if (i != j && rls[lp][j] && !rls[lp][i]) hh[i][j].emplace(c[lp][j] - c[lp][i], lp); //这里是在处理添加左点对 rlr 路径的影响 } inline void disable(int lp) { if (lus[lp]) for (int i = 0;i != m;++i) if (rls[lp][i]) sh[i].erase(sh[i].find(node(c[lp][i], lp))); //这里是在处理删除左点对 slr 路径的影响 for (int i = 0;i != m;++i) for (int j = 0;j != m;++j) if (i != j && rls[lp][j] && !rls[lp][i]) hh[i][j].erase(hh[i][j].find(node(c[lp][j] - c[lp][i], lp))); //这里是在处理删除左点对 rlr 路径的影响 } inline void solvpas(int l, int r, vector<int>& p) { if (v[l][r] == pv[l][r]) { p.emplace_back(l); return; } solvpas(l, pp[l][r], p); // p.emplace_back(pp[l][r]); // 实际上查询子区间时总会找到这些“叶子”节点并加入队列中。 solvpas(pp[l][r], r, p); } inline void delt1(int p) { //传入的是右侧点的编号。暂未更改,可以倒推出左侧点编号 int lp = sh[p].begin()->r; disable(lp); lus[lp] = 0; rls[lp][p] ^= 1; enable(lp); } inline void delt2(int ap, int bp) { //同上 int cp = hh[ap][bp].begin()->r; disable(cp); rls[cp][bp] ^= 1; rls[cp][ap] ^= 1; enable(cp); } signed main() { ios::sync_with_stdio(0); m = 3; memset(rls, 1, sizeof rls); //初始全部可用 for (int i = 0;i != m;++i) cin >> a[i], n += a[i]; for (int i = 1;i <= n;++i) for (int j = 0;j != m;++j) cin >> c[i][j]; for (int i = 1;i <= n;++i) lus[i] = 1, enable(i); for (int tms = 1;tms <= n;++tms) { //总计 n 次增广;m 是源点,m+1 是汇点 memset(v, 0xcf, sizeof v); memset(pp, -1, sizeof pp); for (int i = 0;i != m;++i) for (int j = 0;j != m;++j) if (i != j && hh[i][j].size()) v[i][j] = hh[i][j].begin()->l; for (int i = 0;i != m;++i) if (sh[i].size()) v[m][i] = sh[i].begin()->l; for (int i = 0;i != m;++i) if (fl[i] < a[i]) v[i][m + 1] = 0; for (int i = 0;i != m + 2;++i) for (int j = 0;j != m + 2;++j) pv[i][j] = v[i][j]; for (int k = 0;k != m + 2;++k) for (int i = 0;i != m + 2;++i) for (int j = 0;j != m + 2;++j) if (v[i][j] < v[i][k] + v[k][j]) v[i][j] = v[i][k] + v[k][j], pp[i][j] = k; vector<int>pas; ans += v[m][m + 1]; solvpas(m, m + 1, pas); //处理路径,因为要开始增删边了。 for (int i = 1;i != pas.size() - 1;++i) delt2(pas[i], pas[i + 1]); delt1(pas[1]); fl[pas.back()]++; } cout << ans << endl; } \\然而并不能过这一道题。 ``` [例题2](https://codeforces.com/problemset/problem/724/E),同样非常好建模,但是不难发现边太多了,没法玩。 所以我们可以从最大流-最小割定理入手,算他的最小割。显然,因为图只有一层,即每一个城市对应的点都直连源汇,所以每一个点要么断源,要么断汇。 显然断汇的点仍有能力向后面的城市流货,而断源的不能,所以写一个 DP 出来就完了。核心代码如下: ```cpp for (int j = 1;j <= i;++j) dp[i & 1][j] = dp[i - 1 & 1][j] + p[i] + j * m, tmin(dp[i & 1][j], dp[i - 1 & 1][j - 1] + s[i]); ``` ## 2-SAT 记得我们曾经学过一个东西叫做“扩展域并查集”,说白了就是令每一坨 $[(k-1)\times n+1,k\times n]$ 的区间中的点 $i=(k-1)\times n+t$ 对应表示 $t$ 在 $k$ 状态下与别的状态的联系。两个点在一起就认为两个点对应的状态必定同时满足。 那这玩意这么好用,能不能用到 2-SAT 上呢? 很可惜,需要改一下题才能用得到。把或改成异或就行了。 思考一下并查集的底层逻辑:两个点对应的状态必定同时满足。换言之,你只能加双向边。 然而 2-SAT 并不是这样的,他用的是或,也就是说硬性的约束条件是:$x_i=\neg v_i\implies x_j=v_j$ 和 $x_j=\neg v_j\implies x_i=v_i$。都是单向的约束。 那我们思考一下这些限制中的强连通分量意味着什么? 显然,这意味着这些点对应的状态成立与否是相同的。同时我们就推出了如果 $x_i$ 与 $\neg x_i$ 在同一个强连通分量内,那么必定无解。 强连通分量怎么求呢?Kosaraju 和 tarjan 你选一个吧。 ```cpp for (int i = 1, a, b, c, d;i <= m;++i) a = io.read(), b = io.read(), c = io.read(), d = io.read(), son[a + b * n].emplace_back(c + (d ^ 1) * n), son[c + d * n].emplace_back(a + (b ^ 1) * n); //模板题约束条件示例 ``` 当然了,2-SAT 只是一个最直接的应用。其实他给我们提供的是一种单向限制的处理思路,适用性比 2-SAT 本身要更广很多。 顺带讲两个常用优化技巧: ### 线段树优化建图 T1[rt](https://www.luogu.com.cn/problem/AT_arc069_d),[弱化版](https://www.luogu.com.cn/problem/UVA1146)。 翻译一下就是有 $n$ 个数 $a_i$ 和 $n$ 个数 $b_i$,$n$ 次从 $a_i$ 或 $b_i$ 中选出一个数放入 $c$,排序后询问最小的 $\min_{i=1}^{n-1}c_{i+1}-c_i$。 我们可以假定 $a_i<b_i$。解法大概率会是二分答案。首先如果 $|a_j-a_i|<d$,那么这俩不能同时出现。以此来建边进行求解,复杂度 $O(n^2\log V)$。可以过弱化版。 但是这样并不够优,究其原因是我们需要大量的向一个区间内的点建边。 而这,我们显然可以使用线段树优化建边来飞过去。具体方式可以看下面这幅图: 比如本来是 $2$ 要向 $[1,2]$ 和 $[4,6]$ 两个区间建边。 ![](https://www.helloimg.com/i/2025/04/25/680b7a4451dd4.png) 现在我们可以对原本的 $[1,6]$ 区间建一颗线段树如图: ![](https://www.helloimg.com/i/2025/04/25/680b7a4f25685.png) 那这时候 $7$ 向 $8$ 连等同于向 $[1,2]$ 连,向 $11$ 连等同于向 $[4,6]$ 连。 显然这样下来每次要连的边数锐减至 $O(\log n)$ 条。总复杂度降至 $O(n\log n\log V)$。 比较核心的就是这坨: ```cpp struct seg_tree { struct node { int l, r; }re[10005 << 3]; inline static int segid(int p) { return p + 2 * n; } inline void build(int l, int r, int p) { re[p].l = l; re[p].r = r; if (l == r) return void(son[segid(p)].push_back(ref(a[l].id))); build(l, (l + r >> 1), p << 1); build((l + r >> 1) + 1, r, p << 1 | 1); son[segid(p)].push_back(segid(p << 1)); son[segid(p)].push_back(segid(p << 1 | 1)); } inline void link(int l, int r, int lp, int p) { if (l <= re[p].l && re[p].r <= r) return void(son[lp].push_back(segid(p))); if (l <= re[p << 1].r) link(l, r, lp, p << 1); if (r > re[p << 1].r) link(l, r, lp, p << 1 | 1); } }sgt; //注意只对区间连边有效 ``` ### 前缀优化建图 T2[rt](https://www.luogu.com.cn/problem/P6378)。 题意简单明了,转化也非常的明显,但是问题也就出在了这里。 相当于说在同一个部分内的 $x\neq y$,一定要 $x_1$ 到 $y_0$ 连一条边。只要我把所有的点放在同一个部分内,你的边数就是 $n^2$ 的。包炸的。 所以现在就是要去优化这个东西。我们不难发现,其实如果我们将这个部分内的点任意排序,那第 $i$ 个点一定要向第 $[1,i-1]\cup[i+1,size]$ 都连上边。 线段树优化建图不太好使,因为数据范围真的够大。但是思考如下结构: ![](https://www.helloimg.com/i/2025/04/25/680b44ed7af63.png) 不难注意到,如果我们给整个部分添加两个辅助的侧链~~基团~~,那么这时候指向 $x_l$ 就间接指向了 $[1,x]$,指向 $x_r$ 就间接指向了 $[x,size]$。 非常妙,此题可解。我们称这种建图方式为前缀优化建图。比较核心的代码就是这坨: ```cpp for (int i = 1, sz;i <= k;++i) { for (int j = sz = io.read();j > 0;--j) vl[j] = io.read(), son[id(vl[j], stat_lp)].emplace_back(id(vl[j], stat_0)), son[id(vl[j], stat_rp)].emplace_back(id(vl[j], stat_0)); for (int j = 1;j != sz;++j) son[id(vl[j], stat_rp)].emplace_back(id(vl[j + 1], stat_rp)), son[id(vl[j], stat_1)].emplace_back(id(vl[j + 1], stat_rp)); for (int j = sz;j > 1;--j) son[id(vl[j], stat_lp)].emplace_back(id(vl[j - 1], stat_lp)), son[id(vl[j], stat_1)].emplace_back(id(vl[j - 1], stat_lp)); } ``` ## 二分图最大权匹配 从最基础的二分图最大权完美匹配讲起走: 最常用的是 KM 算法。他的思路是这样的: 首先我们假定原图存在完美匹配。然后,我们给每个点分配顶标 $h_i$,满足 $\forall (i,j)\in E,h_i+h_j\ge V_{(i,j)}$。 我们再取出 $E^\prime\in E$,满足 $\forall (i,j)\in E^\prime,h_i+h_j=V_{(i,j)}$,那么这时候,当 $E^\prime$ 中存在完美匹配时,$\sum h_p$ 就是答案。 正确性:$h_i+h_j\ge V_{(i,j)}$ 限定了每一条边都会具有的单调性,从而限定了整体的单调性(感性理解)。 算法流程: 首先令 $h_{l_i}=\max_{(l_i,r_j)\in E}V_{(l_i,r_j)},h_{r_i}=0$。显然满足限制条件。 接下来尝试扩大 $E^\prime$。在匈牙利算法中,从一个节点寻找然后匹配失败所遍历的树被称为交错树。显然,其中匹配上的边与没匹配上的边交错出现。 我们如果能在交错树上找到一条的路径,并将这条路径上的左部点 $h-=\Delta$,右部点 $h+=\Delta$,那么不会让交错树上原有的边消失,而且可能会出现新的匹配,从而增大 $E^\prime$。 为了防止一次性减小过多,$\Delta$ 应取为 $\min h_i+h_j-V_{(i,j)}$,其中 $i$ 在交错树上,$j$ 不在交错树上。 不难发现这样操作,交错树原有边不变,而且至少多一个新的边。也就是说理论上最多会需要 $m$ 轮。 那如果我们能将每次增广的时间复杂度降到 $O(n)$ 就可解了。 那很显然,如果我们使用 bfs 版的匈牙利算法,那么他每一次都是 $O(n^2)$ 的,然后再带上至多 $n$ 次增广($n$ 个节点肯定已经有匹配了),总复杂度 $O(n^3)$。 代码如下: ```cpp #include<bits/stdc++.h> using namespace std; #define int long long int n, m, v[505][505], pl[505], pr[505]; int lm[505], rm[505], ext[505], lp[505], ans; bool vil[505], vir[505]; inline void tmax(int& l, const int& r) { (l < r) && (l = r); } inline void tmin(int& l, const int& r) { (l > r) && (l = r); } inline void flip(int p) { while (p) pr[p] = lp[p], swap(pl[lp[p]], p); //我去旧地方,你来我这里 } inline void bfs(int sp) { //将 sp 作为非匹配起点,bfs 版匈牙利找增广路 memset(ext, 0x0f, sizeof ext); memset(lp, 0, sizeof lp); memset(vil, 0, sizeof vil); memset(vir, 0, sizeof vir); queue<int>q; q.emplace(sp); while (1) { while (q.size()) { int np = q.front(); q.pop(); vil[np] = 1; //随便取出来一个左点,认为他被增广到了 for (int i = 1;i <= n;++i) if (!vir[i] && lm[np] + rm[i] - v[np][i] < ext[i]) { //右部点 i 可以取到并且比增量小 ext[i] = lm[np] + rm[i] - v[np][i]; lp[i] = np; if (!ext[i]) //已经增广到相等子图内(lv+rv=ve),扩展 if (vir[i] = 1, !pr[i]) return flip(i); else q.emplace(pr[i]); //类似 dfs,没有就标记返回,有就尝试让对方让 } } //没有可行增广路,缩边权 int mind = 1e18; for (int i = 1;i <= n;++i) if (!vir[i]) tmin(mind, ext[i]); //这个点此次没有增广到,希望被增广 if (mind > 1e12) return; // 确实没有能增广的了 for (int i = 1;i <= n;++i) if (vil[i]) lm[i] -= mind; for (int i = 1;i <= n;++i) if (vir[i]) rm[i] += mind; else ext[i] -= mind; //本质上就是改了修改 rm 带来的对 ext 的影响 for (int i = 1;i <= n;++i) if (!vir[i] && !ext[i]) if (vir[i] = 1, !pr[i]) return flip(i); else q.emplace(pr[i]); //和 bfs 时的 "可扩展" 处理方式一样 } } inline void km() { for (int i = 1;i <= n;++i) for (int j = 1;j <= n;++j) tmax(lm[i], v[i][j]); //每个左部点初始化为连边最大值,满足顶标要求 for (int i = 1;i <= n;++i) bfs(i); for (int i = 1;i <= n;++i) ans += lm[i] + rm[i]; cout << ans << endl; for (int i = 1;i <= n;++i) cout << pr[i] << " "; } signed main() { ios::sync_with_stdio(0); memset(v, 0xcf, sizeof v); cin >> n >> m; //默认完全图,不足的要极小值补齐 for (int i = 1, l, r;i <= m;++i) cin >> l >> r, cin >> v[l][r]; km(); } ``` 然后我们偶遇了一个 trick: 抓大放小,[rt](https://vjudge.net/problem/HDU-3315#author=DeepSeek_zh)。 其实这个名字起的听生动的。说白了,就是给更重要的那部分提权,使得它的影响力严格优于次要部分。 在这道题中,我们需要在满足最大匹配的前提下使得换位置的元素个数最少。 我们令没有交换位置的匹配权值大 $1$,并且原先匹配的贡献乘上 $100$,这时候对最大匹配的影响至少为 $100$,而总的不交换的贡献严格小于 $100$,从而优先满足最大匹配,然后满足最少交换。 ## 最小树形图 主要讲三个东西:朱刘,tarjan,堆优化朱刘。 ### 朱刘算法 朱刘算法思路很简单:不停的找环,把环缩成一个大点,然后接着找,直到形成一个 DAG。 更大的思路是除了根节点,每一个点尽可能选取最小的入边。显然在一个环中,我们不能直接抛弃最大的边,而是将连向这个环的那个边减去环上随后的那个边的权值。 也就是在反悔贪心。同时你也就可以反应过来为什么不能暴力选最大边。 所以你要是暴力模拟这个过程的话,你就得到了朱刘算法。代码如下: ```cpp inline int vap() { while (1) { for (int i = 1;i <= n;++i) mv[i] = 1e9, f[i] = lp[i] = rp[i] = 0; //清空最小边,vis(lp) 标记,所在环(rp) 标记 for (int i = 1;i <= m;++i) if (l[i] != r[i] && v[i] < mv[r[i]]) mv[r[i]] = v[i], f[r[i]] = l[i]; //1. 不是自环才有效; 2. 边权更小 mv[k] = 0; cnt = 0; for (int i = 1;i <= n;++i) if (mv[i] == 1e9) return -1; //有一个非根点没有父节点 else ans += mv[i]; for (int p = 1, np = 1;p <= n;p++, np = p) { while (np != k && lp[np] != p && !rp[np]) lp[np] = p, np = f[np]; //vis(lp) 标记(避开上一次的flag,减小常数),不停跳,直到有访问过的或者创到 root if (!rp[np] && np != k) { rp[np] = ++cnt; //在最新的环上转一圈 for (int i = f[np];i != np; i = f[i]) rp[i] = cnt; }//只有在他不在已知环上而且非 rt 的时候可以重新标记 } if (!cnt) return ans; //没有环 for (int i = 1;i <= n;++i) if (!rp[i]) rp[i] = ++cnt; //孤立点处理 for (int i = 1;i <= m;++i) v[i] -= mv[r[i]], l[i] = rp[l[i]], r[i] = rp[r[i]]; //重新计算边权 n = cnt; k = rp[k]; } } ``` ### tarjan/堆优化朱刘 他们的优化思路很简单,就是堆优化。 那么 tarjan 是怎么去求解的呢?它分为 **收缩** 与 **伸展** 两个过程。说白了就是先将整个图按照强连通分量缩成一个点,然后再根据记录的信息给把树重新建出来。 收缩怎么收缩呢?我们每一次任意选一个在堆中没有入边的的节点,并将它的最小入边加入到堆中。如果新加入的这条边使堆中的边形成了环,那么将构成环的那些结点收缩。我们不停的收缩,直到整张图被收缩为一个节点。 堆中的边总是会形成一条路径 $v_1,v_2,\dots,v_k$,由于图是强连通的,这个路径必然存在。我们每一次选一条最小入边 $v_k\gets u$。如果 $u$ 不在 $v$ 中就将 $v_{k+1}$ 定为 $u$。如果在就形成了一个环。我们将这个环缩掉,显然还在最后。我们接着处理这个点就完了。 当然了,收缩的过程中需要记录一些信息。伸展过程是相对简单的,以原先要求的根节点 $r$ 为起始点,对 $r$ 到收缩树的根上的每一个环进行伸展。再以 $r$ 的祖先结点 $f_r$ 为起始点,将其到根的环展开,直到遍历完所有的结点。 一种实现: ```cpp inline void merge() { for (int i = 1;i <= n;++i) { for (const edge& e : son[i]) q.emplace(new node(e)); while (q.size() > 1) { hptr p1 = q.front(); q.pop(); hptr p2 = q.front(); q.pop(); q.emplace(merge(p1, p2)); } //大小期望为 1,1,...,1,2,2,...,2,4,4,...,4,... hp[i] = q.front(); q.pop(); //其实是线性建堆 } vis[1] = 1; //“随机”选了一个点,100% 选中 1 点 for (int p = 1, np = 1;hp[p];vis[np = p] = 1) { do lfp[p] = top(hp[p]), p = mss[lfp[p].l]; while (p == np && hp[p]); if (p == np) break; if (!vis[p]) continue; int cnp = ++n; for (p = np; p != n;p = cnp) { mss.f[p] = lp[p] = n; if (hp[p]) hp[p]->tg -= lfp[p].v; hp[n] = merge(hp[n], hp[p]); cnp = mss[lfp[p].l]; rinxtp[cnp == n ? np : cnp] = p; } } } inline int expand(int rt, int ep) { int ret = 0; for (;rt != ep;rt = lp[rt]) { for (int sp = rinxtp[rt];sp != rt;sp = rinxtp[sp]) if (lfp[sp].orv >= 1e16) return 1e16; else ret += expand(lfp[sp].r, sp) + lfp[sp].orv; if (ret >= 1e16) return 1e16; } return ret; } inline int dmst() { merge(); int ret = expand(rt, n); if (ret >= 1e16) ret = -1; return ret; } ``` 堆优化朱刘相对而言可能会更好理解一点,真的就是给朱刘套上了和 tarjan 类似的可并堆优化。但是要注意处理指向环内的边。这些边对 tarjan 正确性没有影响,但是对朱刘有影响。给出完整的代码如下: ```cpp int n, m, rt; struct edge { int p, v; edge(int pi = 0, int vi = 0) :p(pi), v(vi) {}; }; vector<edge>son[100005]; inline void adde(int l, int r, int v) { son[r].push_back(edge(l, v)); } struct mss { signed h[100005]; inline signed operator[](signed p) { return h[p] != p ? h[p] = (*this)[h[p]] : p; } }fr, inc; struct m_incl_heap { struct node { edge e; signed ls, rs; int tg; }re[1000005]; signed cnt, h[100005]; inline node& ls(signed p) { return re[re[p].ls]; } inline node& rs(signed p) { return re[re[p].rs]; } inline void pud(signed p) { if (re[p].ls) ls(p).tg += re[p].tg, ls(p).e.v += re[p].tg; if (re[p].rs) rs(p).tg += re[p].tg, rs(p).e.v += re[p].tg; re[p].tg = 0; } inline signed merge(signed l, signed r) { if (l == r) return l; if (!l || !r) return l | r; if (re[l].e.v > re[r].e.v) swap(l, r); pud(l); re[l].rs = merge(re[l].rs, r); swap(re[l].ls, re[l].rs); return l; } inline void clear() { memset(re, 0, sizeof re); memset(h, 0, sizeof h); cnt = 0; } inline void pop(signed& p) { pud(p); p = merge(re[p].ls, re[p].rs); } inline void noselfptr(int tp, int r) { while (h[tp] && inc[re[h[tp]].e.p] == r) pop(h[tp]); } inline void addev(int p, int v) { re[p].e.v += v; re[p].tg += v; } }mih; queue<int>q; edge lfp[100005]; inline void solve() { mih.clear(); memset(lfp, 0, sizeof lfp); for (signed i = 1;i <= n;++i) { for (signed j = 0;j != son[i].size();++j) mih.re[++mih.cnt].e = son[i][j], q.push(mih.cnt); while (q.size() > 1) { signed p1 = q.front(); q.pop(); signed p2 = q.front(); q.pop(); q.push(mih.merge(p1, p2)); } if (q.size()) mih.h[i] = q.front(), q.pop(); } for (signed i = 1;i <= n;++i) fr.h[i] = inc.h[i] = i; for (signed i = 1;i <= n;++i) { if (i == rt) continue; if (!mih.h[i]) { puts("-1"); return; } mih.noselfptr(i, i); lfp[i] = mih.re[mih.h[i]].e; if (fr[inc[lfp[i].p]] == i) { int rsm = 0, p = i; do rsm += lfp[p].v; while ((p = inc[lfp[p].p]) != i); do mih.pop(mih.h[p]); while ((p = inc[lfp[p].p]) != i); do mih.addev(mih.h[p], rsm - lfp[p].v); while ((p = inc[lfp[p].p]) != i); do mih.h[i] = mih.merge(mih.h[i], mih.h[p]), fr.h[p] = inc.h[p] = i; while ((p = inc[lfp[p].p]) != i); mih.noselfptr(i, i); i--; } else fr.h[i] = lfp[i].p; } int ans = 0; for (signed i = 1;i <= n;++i) if (inc[i] == i) ans += lfp[i].v; cout << ans << endl; } signed main() { ios::sync_with_stdio(0); n = io.read(); m = io.read(); rt = io.read(); for (signed i = 1, l, r;i <= m;++i) l = io.read(), r = io.read(), adde(l, r, io.read()); solve(); return 0; } ``` 见的不多,而且目前来看遇到了基本上就挺板的,不再多说了。 ## 仙人掌与圆方树 [模板题](https://www.luogu.com.cn/problem/P5236)显然就是一个狭义圆方树求仙人掌上两点之间的距离的板子题。 先说一下什么是仙人掌:仙人掌就是一种特殊的图,满足每一个边最多在一个简单环上。 我们会使用倍增 LCA 和树上前缀和来快速的求解树上的两点之间的距离。那如果我们可以将这个图转变成一棵树的话是不是就做完了?狭义圆方树就干了这么一点事。 那我们想一想这个圆方树都要处理一些什么呢?显然,成环的地方就是我们要重点考虑的。正是这些环,使得原先的图不是树。我们考虑如下结构: ![](https://www.helloimg.com/i/2025/05/17/6827f86266d29.png) 肉眼可见的,我们通过添加一个点使得一个环变成了一个菊花一样的东西。我们处理完后一定是一棵树。而树上除了根以外别的点都一定是有父节点的。只要我们指定一个圆点为根节点,那每一个这样的环都会有一个最靠上的节点。 我们把这个节点视为这个环的起点,像任意方向绕着环转一圈,并把每个点的“距离”标记为第一次遇到他时走过的路程。接下来,我们把环的起点到这个环的方点的边权设置为 $0$,把这个环的方点到其他点的权值设为本环的起点到那个点的**最短路的长度**。 这意味着我们还需要处理这个环的长度。因为最短路是这个点的“距离”和环长减去“距离”的最小值。就像这样: ![](https://www.helloimg.com/i/2025/05/17/6827fce24eb9d.png) 建出来了树了之后,分成两种大的情况: 第一种:两个点的 LCA 是一个圆点。这种情况相当的好,答案就是两个点在树上的距离。 第二种:两个点的 LCA 是一个方点。这意味着这两个点不停往上跳,最后跳到了一个环的不同的两个点上。这时候我们要处理出二者在环上的最小距离。于是我们之前记录的信息就发挥作用了。两个点之间有两个不同的种路径,一种是按照“距离”的行走方向的那段弧,长度为二者的“距离”的差,另一种是走另一段弧,长度是环长减第一种的长度。 至此,狭义圆方树求仙人掌上两点之间的距离讲解完毕。代码如下: ```cpp #include<bits/stdc++.h> using namespace std; struct node { int p, v, id; node(int pi = 0, int vi = 0, int ii = 0) : p(pi), v(vi), id(ii) { } }; vector<node>son[10005], nsn[20005]; int n, m, k, cnt, dfn[20005], low[20005], cln[20005], dp[20005]; int f[20005][16], v[20005], fe[20005], d[20005], vcn, dis[20005], fl, fr; inline void tmin(int& l, int r) { (l > r) && (l = r); } inline void mkcir(int ep, int sp, int sv) { for (int p = sp; p != ep; p = f[p][0]) d[p] = sv, sv += v[p]; d[ep] = cln[ep] = sv; nsn[ep].emplace_back(++vcn); for (int p = sp; p != ep; p = f[p][0]) cln[p] = sv, nsn[vcn].emplace_back(p, min(d[p], sv - d[p])); } inline void tarjan(int p, int id) { dfn[p] = low[p] = ++cnt; for (const node& sp : son[p]) if (!dfn[sp.p]) { f[sp.p][0] = p; v[sp.p] = sp.v; fe[sp.p] = sp.id; tarjan(sp.p, sp.id), tmin(low[p], low[sp.p]); if (dfn[p] < low[sp.p]) nsn[p].emplace_back(sp.p, sp.v); } else if (id != sp.id) tmin(low[p], dfn[sp.p]); for (const node& sp : son[p]) if (dfn[p] < dfn[sp.p] && fe[sp.p] != sp.id) mkcir(p, sp.p, sp.v); } inline void dfs(int p, int fa) { f[p][0] = fa; dp[p] = dp[fa] + 1; for (int i = 1;i <= 15;++i) f[p][i] = f[f[p][i - 1]][i - 1]; for (const node& sp : nsn[p]) dis[sp.p] = dis[p] + sp.v, dfs(sp.p, p); } inline int lca(int l, int r) { if (dp[l] < dp[r]) swap(l, r); for (int i = 15;i >= 0;i--) (dp[f[l][i]] >= dp[r]) && (l = f[l][i]); if (l == r) return l; for (int i = 15;i >= 0;i--) (f[l][i] != f[r][i]) && (l = f[l][i], r = f[r][i]); return fl = l, fr = r, f[l][0]; } signed main() { ios::sync_with_stdio(0); cin >> n >> m >> k; vcn = n; for (int i = 1, l, r, v;i <= m;++i) cin >> l >> r >> v, son[l].emplace_back(r, v, i), son[r].emplace_back(l, v, i); tarjan(1, 0); dfs(1, 0); for (int i = 1, l, r, la;i <= k;++i) if (cin >> l >> r, (la = lca(l, r)) > n) { int lln = abs(d[fl] - d[fr]), rln = cln[fl] - lln; cout << (dis[l] - dis[fl]) + (dis[r] - dis[fr]) + min(lln, rln) << endl; } else cout << dis[l] + dis[r] - dis[la] * 2 << endl; } ``` 同学提出了一个问题,我觉得有必要讲一下:一个点有可能会在多个环里,为什么我们不需要记录这个点在每一个环中的“距离”和环长? 我们想一下我们询问的过程:首先如果 LCA 就是圆点的话那没什么影响,如果是方点的话那才会用到“距离”和环长。但是很显然,一个环的起点是骑在这个方点的上面的,也就是我们永远也不会用到一个环的起点在这个环上的信息。 而很显然,我们在建树的过程中,一个点永远也不会成为多个环的“终点”,而且最后一次处理这个点就是在他是“终点”的那棵树上。故不需要记录一个点在每个环上的信息。 那么,一个比较常见的应用就是圆方树上 DP,虽然我们不一定要把树真正的建出来。[如题](https://www.luogu.com.cn/problem/P10779)。 面对仙人掌的题,我们最基本的思路就是转化成树上问题求解。而很显然仙人掌本质上就是一棵树上面挂了一些边不相交的基环。 先看第一道题:[没有上司的舞会](https://www.luogu.com.cn/problem/P1352)。没错,这就是同一道题,只不过仙人掌上没有环而已。 为什么思考这个问题呢?因为这就是在推最基础的 DP 式子。显然: $dp_{i,0}=\sum_{s\texttt{ is son of }i}\max(dp_{s,0},dp_{s,1}),dp_{i,1}=1+\sum_{s\texttt{ is son of }i}dp_{s,0}$。 再看第二道题:[城市环路](https://www.luogu.com.cn/problem/P1453),不过请自己弱化一下,每一个点的人流量都是 $1$。没错,这就是同一道题,只不过仙人掌上有且仅有一个环而已。 那我们想一下面对基环树时的套路呢?先随便选择两个相邻的点,指定他们的状态,然后进行 DP。 现在看回这一道题。现在“树”上有一堆子基环了,我们怎么办? 仙人掌的典型套路:把每一个环拆出来,逐个击破。剩下的非环边正常处理就完了。 所以面对基环树 DP,思考大抵是有点套路的:思考树上怎么做,思考怎么处理基环,套仙人掌找环处理环的板子。 代码如下: ```cpp #include<bits/stdc++.h> using namespace std; struct node { int p, id; node(int pi = 0, int vi = 0) :p(pi), id(vi) {}; }; int n, m, dp[50005][2], cnt, dfn[50005], low[50005]; vector<node>son[50005]; int f[50005], v[50005], fe[50005]; inline void tmin(int& l, int r) { (l > r) && (l = r); } inline void mkdp(int fp, int p) { int v0 = 0, v1 = 0, t0, t1; for (int i = p;i != fp;i = f[i]) t0 = v0 + dp[i][0], t1 = v1 + dp[i][1], v0 = max(t0, t1), v1 = t0; //选自己就选不了前一个,不选自己就任意选 dp[fp][0] += v0; v0 = 0; v1 = -1e9; //只要选上一个贡献够小,就一定没人会选上一个 for (int i = p;i != fp;i = f[i]) t0 = v0 + dp[i][0], t1 = v1 + dp[i][1], v0 = max(t0, t1), v1 = t0; dp[fp][1] += v1; } inline void tarjan(int p, int id) { dfn[p] = low[p] = ++cnt; dp[p][1] = 1; for (const node& sp : son[p]) if (!dfn[sp.p]) { f[sp.p] = p; fe[sp.p] = sp.id; tarjan(sp.p, sp.id), tmin(low[p], low[sp.p]); if (low[sp.p] > dfn[p]) dp[p][0] += max(dp[sp.p][0], dp[sp.p][1]), dp[p][1] += dp[sp.p][0]; //非环边正常处理 } else if (id != sp.id) tmin(low[p], dfn[sp.p]); //严禁当场返祖 for (const node& sp : son[p]) if (dfn[p] < dfn[sp.p] && fe[sp.p] != sp.id) mkdp(p, sp.p); //找到一个底下的环 } signed main() { ios::sync_with_stdio(0); cin >> n >> m; for (int i = 1, l, r;i <= m;++i) cin >> l >> r, son[l].emplace_back(r, i), son[r].emplace_back(l, i); tarjan(1, 0); cout << max(dp[1][0], dp[1][1]) << endl; } ``` # 数据结构 ## 线段树 线段树是 OI 当中最常用的数据结构之一,务必认真掌握。接下来讲解的是比较常用的多种线段树及其变体,大致按难度排序。 ###### 这里有你没写完的东西,杂鱼... ## ZKW线段树 很神奇的卡常用的线段树,但是支持面远远比不上普通线段树。 思路就是更改递归为循环。我们可以注意到,在线段树中,$fa_i=\lfloor\frac{i}{2}\rfloor$,因此,我们维护一个数组,使其也满足这样的性质,每次循环向上更新信息,就完成了。 查询的时候我们从区间的左右部分的端点外的一点开始操作。对于一个端点,当我们向内跳的时候,统计右儿子的贡献,当向外跳的时候,不统计贡献,直到两端点重合。 但是区间修改使用标记永久化,无法维护标记有顺序要求的情况。 参考代码如下: ```cpp #include<bits/stdc++.h> using namespace std; #define lc(i) (i << 1) #define rc(i) (i << 1 | 1) int n, m, a[2000005], s; inline void ins(int p, int v) { do a[p] += v; while (p >>= 1); } inline int que(int l, int r) { int v = 0; do v += (~l & 1 ? a[l ^ 1] : 0) + (r & 1 ? a[r ^ 1] : 0); while ((l >>= 1) ^ (r >>= 1) ^ 1); return v; } signed main() { ios::sync_with_stdio(0); cin >> n >> m; for (s = 1;s < n + 2;s <<= 1); for (int i = 1;i <= n;++i) cin >> a[i + s]; for (int i = s - 1;i > 0;i--) a[i] = a[lc(i)] + a[rc(i)]; for (int i = 1, o, l, r;i <= m;++i) if (cin >> o >> l >> r, o & 1) ins(l + s, r); else cout << que(l + s - 1, r + s + 1) << endl; } ``` ## 并查集 ### 普通并查集 并查集要维护的操作主要就是“合并”和“查询”。具体来说,他要能够支持将两个集合合并,并查询每一个元素在哪一个集合,比如判断二者在不在同一个集合中。 不难想到我们可以用类似于树的结构来实现这一点,具体的来说,我们要合并 $l,r$ 所在的集合的时候,我们找到这两个集合的代表元素 $f_l,f_r$,并将其中一者的父节点设置为另一个。 但显然这样做如果给你整成链状的你就炸了,因此我们有诸多技巧来维护其时间复杂度正确。 第一种是路径压缩,也就是每一次查询完某个节点之后,我们将这个节点到他的代表节点这一条链上的所有节点的直接父节点设为代表。这样做时间复杂度均摊是 $O(\log)$ 的。显然不是特别优。如果你再把他们按照大小合并,也就是将大小更小的那一坨的父亲设为更大的那一坨,时间复杂度进一步降至 $O(\alpha(n))$。其中 $\alpha$ 是反阿克曼函数。 实际应用中,根本没人去卡那个 $\log$,所以更常见的情况下你并不需要按大小合并。 还有另一种是按秩合并,也就是树高更低的合并到树高更高的上面。时间复杂度显然是 $O(\log)$ 的。 ### 扩展域并查集 又名种类并查集,思路就是拆点。举[食物环](https://www.luogu.com.cn/problem/P2024)为例题。 在这道题中,每一个生物对应有三种状态:是 A/B/C 类生物。于是我们将每个生物拆成三个点,比如说第 $i$ 个生物就会被拆成三个不同的点 $p_{i,A},p_{i,B},p_{i,C}$,其中 $p_{i,X}$ 表示 $i$ 生物是 $X$ 类生物这个陈述为真。而我们将并查集视为一种等价关系,即如果 $p_{i,X},p_{j,Y}$ 在同一个集合内,我们就认为这两个命题要么都成立,要么都不成立。 那么对应到这一道题,如果两个生物是同类,则 $p_{i,A},p_{j,A}$,$p_{i,B},p_{j,B}$,$p_{i,C},p_{j,C}$ 应该分别进行合并。显然判断条件是 $i,j$ 不能相互啃食,比如 $p_{i,A},p_{j,B}$ 或 $p_{i,B},p_{j,A}$ 等共计 $6$ 个条件。二者相互啃食的类似,不再赘述。 可以给出核心代码如下: ```cpp if (cin >> t >> l >> r, t & 1) { if (l > n || r > n) { ans++; continue; } bool cn = 0; cn |= (find(id(l, 0)) == find(id(r, 1))); cn |= (find(id(l, 1)) == find(id(r, 2))); cn |= (find(id(l, 2)) == find(id(r, 0))); cn |= (find(id(l, 0)) == find(id(r, 2))); cn |= (find(id(l, 1)) == find(id(r, 0))); cn |= (find(id(l, 2)) == find(id(r, 1))); if(cn) { ans++; continue; } merge(id(l, 0), id(r, 0)); merge(id(l, 1), id(r, 1)); merge(id(l, 2), id(r, 2)); } else { if (l > n || r > n || l == r) { ans++; continue; } bool cn = 0; cn |= (find(id(l, 0)) == find(id(r, 0))); cn |= (find(id(l, 1)) == find(id(r, 1))); cn |= (find(id(l, 2)) == find(id(r, 2))); cn |= (find(id(l, 1)) == find(id(r, 0))); cn |= (find(id(l, 2)) == find(id(r, 1))); cn |= (find(id(l, 0)) == find(id(r, 2))); if (cn) { ans++; continue; } merge(id(l, 0), id(r, 1)); merge(id(l, 1), id(r, 2)); merge(id(l, 2), id(r, 0)); } ``` ### 权值并查集 这类并查集会需要维护多维的信息,比如和代表的实际距离。[例题](https://www.luogu.com.cn/problem/P1196)。 一般情况下,我们会考虑在重定向父亲的时候先去除父节点造成的影响,然后在路径压缩的过程中再逐步去合并信息。比如说当有一种信息是针对整个并查集改的的时候,我们会在合并的时候先将合并者的信息“减去”被合并者的信息,然后再在后面恢复过来。当然了,这个例题体现不出来。 一定要注意路径压缩时对信息进行合并的时候要记录好原先的父亲,不要随意拿着新的“父亲”去更新信息。 可以给出核心代码如下: ```cpp inline int find(int p) { if (f[p] == p) return p; int fp = f[p]; f[p] = find(f[p]); v[p] += v[fp]; return f[p]; } ``` ## 莫队 这绝对称得上是离线算法中的代表之一。话说,彼时的 $\text{OI}$ 界 $\text{RMQ}$ 问题并起,而我们的暴力却不能骗到太多分,于是 $\text{CF}$ 高手圈内流行起了一种快速处理离线问题的算法,并由莫涛整理,这种算法便被称之为莫队。后人便在此基础上加以拓展,统称为莫队算法。 算法的实现就是询问区间依靠 $l,r$ 指针反复横跳。他要求我们能够在较优的时间复杂度内(一般为 $O(1)$)用 $[l,r]$ 的答案及状态转移出 $[l\pm1,r]$ 或 $[l,r\pm1]$ 的答案及状态。这时候,我们通过一定的方式找到一个较优的询问序列,使得其左右横跳的次数较为少。 这时候莫队的神奇重排询问方式发挥了作用。首先我们先这 $n$ 个点顺序平均分成 $\sqrt n$ 个块,然后询问间排序时以左端点所在的块编号为第一关键字,右端点为第二关键字。这时候,右端点右移距离不超过 $n\sqrt n$,左端点移动次数在 $m\sqrt n$ 的级别控制内,均有不超过 $2$ 的理论常数(~~像黑塔那样~~左右乱滚)。于是总复杂度在 $O((n+m)\sqrt n)$。 ### 普通莫队 显然,上面的就是普通莫队。 以[袜子](https://www.luogu.com.cn/problem/P1494)为例,核心操作就是这样的: ```cpp inline void add(int p) { ak -= get(cnt[a[p]]); cnt[a[p]]++; ak += get(cnt[a[p]]); } inline void del(int p) { ak -= get(cnt[a[p]]); cnt[a[p]]--; ak += get(cnt[a[p]]); } while (nr < q[i].r) add(++nr); while (nl > q[i].l) add(--nl); while (nr > q[i].r) del(nr--); while (nl < q[i].l) del(nl++); ``` 特别注意,我们应尽量保证区间长度不为负,所以我们总是先扩展再收缩。 ### 带修莫队 核心思路就是加上一维时间戳,因为 $l,r$ 同在 $[1,n]$ 的区间内,所以我们把 $l$ 的排序方式强加在 $r$ 上,然后 $t$ 按照原来 $r$ 的排序方式升序排序。当块长取 $\frac{n^{\frac{2}{3}}t^{\frac{1}{3}}}{m^{\frac{1}{3}}}$ 时时间复杂度为最优的 $O(n^{\frac{2}{3}}m^{\frac{2}{3}}t^{\frac{1}{3}})$。但是一般情况下 $n,m,t$ 同阶,所以时间复杂度常被视为 $O(n^\frac{5}{3})$。 带修莫队有他的局限性。只支持单点修改,时间复杂度也已经只比暴力优了 $n^\frac{1}{3}$,可能要卡常。 ### 回滚莫队 回滚怎么滚?为什么要滚?问题很简单,有的信息在增加或删除的时候很难在一个能接受的时间复杂度内处理出来。于是,我们就只让他扩展。 具体的,我们暴力处理完块内的询问后,块间的我们让他的左端点固定在 $block_r+1$ 的位置,保持右端点单增,每到一个询问,记录当前的状态,让左端点扩展,到之后记录答案,然后快速滚回到 $block_r+1$。 可以参见[野史的研究](https://www.luogu.com.cn/problem/AT_joisc2014_c)的代码。 ```cpp #include<bits/stdc++.h> using namespace std; inline int read() { int r = 0; char c = getchar(); while (c < '0' || c>'9') c = getchar(); while (c >= '0' && c <= '9') r = r * 10 + (c ^ 48), c = getchar(); return r; } inline void write(long long x) { if (x > 9) write(x / 10); putchar(x % 10 ^ 48); return; } inline void writi(long long args) { write(args); putchar(10); } struct node { int l, r, id; }q[100005]; int n, m, v[100005], sz, tot; long long ans[100005], res; unordered_map<int, int>tv; int t[100005], vlt[100005]; #define blk(x) (x/sz) inline bool cmp(const node& l, const node& r) { if (blk(l.l) ^ blk(r.l)) return blk(l.l) < blk(r.l); return l.r < r.r; } inline void add(int p) { t[p]++; res = max(res, vlt[p] * 1ll * t[p]); } signed main() { n = read(), m = read(); sz = ceil(sqrt(m)); for (int i = 1; i <= n; ++i) { v[i] = read(); if (!tv[v[i]]) tv[v[i]] = ++tot, vlt[tot] = v[i]; v[i] = tv[v[i]]; } for (int i = 1; i <= m; ++i) q[i].l = read(), q[i].r = read(), q[i].id = i; sort(q + 1, q + m + 1, cmp); for (int k = 1; k <= m;) { int pt = k, rt, lp, rp; while (pt <= m && blk(q[pt].l) == blk(q[k].l))pt++; rt = blk(q[k].l) * sz + sz; while (k < pt && q[k].r < rt) { res = 0; int id = q[k].id, l = q[k].l, r = q[k].r; for (int i = l; i <= r; ++i) add(v[i]); k++; ans[id] = res; for (int i = l; i <= r; ++i)t[v[i]]--; } res = 0; lp = rt; rp = rt - 1; while (k < pt) { int id = q[k].id, l = q[k].l, r = q[k].r; while (rp < r) add(v[++rp]); long long bak = res; while (lp > l) add(v[--lp]); ans[id] = res; while (lp < rt) t[v[lp++]]--; res = bak; k++; } memset(t, 0, sizeof t); } for (int i = 1; i <= m; ++i) writi(ans[i]); return 0; } ``` ### 树上莫队 经典的序列问题上树。处理方式无非就两种:把树拍成序列;重链剖分吃掉这棵树。很显然,对于莫对这种喜欢~~像黑塔那样~~左右乱滚的神奇算法来说,第一个方式会更友善一些。 最终,我们选择了欧拉序,即进子树记录一次,出子树记录一次。这样子,我们可以通过一个点在序列中出现的次数轻松的知道他到底在不在序列上。 一般的,我们会将其拆成 $[l,lca]$ 和 $[lca,r]$ 两条链来处理。由于欧拉序的特殊性,对于不满足一者在另一者的子树内时,我们需要记录 $lca$。 然后就是类似于普通莫队的思路了。下面给一个[树上莫队](https://www.luogu.com.cn/problem/SP10707)的代码: ```cpp #include<bits/stdc++.h> using namespace std; namespace indata { int n, m, v[100005], tot, l2g[40005], ans[100005]; unordered_map<int, int>um; inline void lsh() { for (int i = 1; i <= n; ++i) { if (!um[v[i]]) um[v[i]] = ++tot; v[i] = um[v[i]]; } } inline void getlog2() { for (int i = 1; i <= n; ++i) l2g[i] = l2g[i - 1] + (1 << l2g[i - 1] == i); } } using indata::n; using indata::m; using indata::v; using indata::l2g; using indata::ans; namespace tree { inline int getlca(int, int); }; namespace mque { int olq[80005], tot, fp[40005], lp[40005], sz; bool st[40005];;int cnt[40005], res; struct node { int l, r, p, id; }q[100005]; inline void inque() { for (int i = 1; i <= m; ++i) { int a, b, lcp; cin >> a >> b; if (fp[a] > fp[b]) swap(a, b); lcp = tree::getlca(a, b); if (a == lcp) q[i].id = i, q[i].l = fp[a], q[i].r = fp[b]; else q[i].id = i, q[i].l = lp[a], q[i].r = fp[b], q[i].p = lcp; //cerr << i << " query " << q[i].l << " to " << q[i].r // << " special " << q[i].p << endl; } } inline int blk(int x) { return x / sz; } inline bool cmp(const node& l, const node& r) { if (blk(l.l) ^ blk(r.l)) return blk(l.l) < blk(r.l); return l.r < r.r; } inline void add(int p) { //cerr << "add " << p << " val " << v[p] << endl; st[p] ^= 1; if (!st[p]) { cnt[v[p]]--; if (!cnt[v[p]]) res--; } else { if (!cnt[v[p]]) res++; cnt[v[p]]++; } } inline void solve() { for (int k = 1, l = 1, r = 0; k <= m; ++k) { int id = q[k].id, lp = q[k].l, rp = q[k].r, np = q[k].p; while (r < rp) add(olq[++r]); while (r > rp) add(olq[r--]); while (l < lp) add(olq[l++]); while (l > lp) add(olq[--l]); if (np) add(np); ans[id] = res; if (np) add(np); } } } using mque::olq; using mque::q; using mque::lp; using mque::fp; namespace tree { vector<int>son[40005]; int a, b; int dep[40005], fa[40005][16]; inline void in_tree() { for (int i = 1; i ^ n; ++i) cin >> a >> b, son[a].emplace_back(b), son[b].emplace_back(a); } inline void dfs(int pos, int fap) { fa[pos][0] = fap; dep[pos] = dep[fap] + 1; for (int i = 1; i <= l2g[dep[pos]]; ++i) fa[pos][i] = fa[fa[pos][i - 1]][i - 1]; olq[++mque::tot] = pos; fp[pos] = mque::tot; for (int i = 0; i < son[pos].size(); ++i) if (son[pos][i] != fap) dfs(son[pos][i], pos); olq[++mque::tot] = pos; lp[pos] = mque::tot; } inline int getlca(int lp, int rp) { if (lp == rp) return lp; if (dep[lp] < dep[rp]) swap(lp, rp); while (dep[lp] > dep[rp]) lp = fa[lp][l2g[dep[lp] - dep[rp]] - 1]; if (lp == rp) return lp; for (int k = l2g[dep[lp]] - 1; k >= 0; k--) if (fa[lp][k] != fa[rp][k]) lp = fa[lp][k], rp = fa[rp][k]; return fa[lp][0]; } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin >> n >> m; indata::getlog2(); for (int i = 1; i <= n; ++i) cin >> v[i]; indata::lsh(); tree::in_tree(); tree::dfs(1, 0); //for (int i = 1; i <= mque::tot; ++i) cerr << olq[i] << " "; cerr << endl; mque::inque(); mque::sz = ceil(sqrt(2 * n)); sort(q + 1, q + m + 1, mque::cmp); mque::solve(); for (int i = 1; i <= m; ++i) cout << ans[i] << endl; return 0; } ``` ## 笛卡尔树 笛卡尔树是一种二叉树,每一个节点由一个键值二元组 $(k,w)$ 构成。要求 $k$ 满足二叉搜索树的性质,而 $w$ 满足堆的性质。一般我们会需要构造一个笛卡尔树,编号为 $i$ 的点权值为 $(i,a_i)$,$a_i$ 为给定的值。一般用栈解决。方法如下: 我们先按照下标顺序依次加入这棵树中,显然,为了维护二叉搜索树的性质,这个节点势必会在树的右链上。 这时候我们维护其堆的性质就行了。从下往上比较右链节点与当前节点 $u$ 的 $w$,如果找到了一个右链上的节点 $x$ 满足 $w_x<w_u$,就把 $u$ 接到 $x$ 的右儿子上,而 $x$ 原本的右子树就变成 $u$ 的左子树。 复杂度线性。可能视情况会需要树上 $DP$,四毛子等。 ## 树套树 首先给出警告:树套树时空常数大,代码复杂,一定要静下来调,线段树中不要记录当前节点的区间端点,在函数中下传! 其实修正一个对树套树的认知错误:树套树实际上只有最内层在维护实际有效信息,外层只是为了减少内层维护次数,从而降低复杂度,而不做信息的合并。 在这基础之上,就不容易出错了。主要补充一些技巧。其中第二个板块有线段树套平衡树的参考代码。接下来讲一下相对好写而且支持面较广的树状数组套线段树。 ###### 这里有你没写完的东西,杂鱼... ### 线段树二分/树状数组倍增 前者就是线段树上二分。因为线段树是分治型结构,所以复杂度单 $\log$。方法就是在每一个节点选择往左/右子树走。 后者如其名,因为树状数组是倍增型结构,所以复杂度单 $\log$。方法就是跳 $2^n$ 个,$n$ 越来越小。这时候,前面的贡献不会被重复统计,达到效果。 ### 多树二分 不难注意到,在树套树[模板题](https://www.luogu.com.cn/problem/P3380)中,我给出了如下代码: ```cpp #include<bits/stdc++.h> using namespace std; using cir = const int&; inline int read() { int r = 0; char c = getchar(); while (c < '0' || c>'9') c = getchar(); while (c >= '0' && c <= '9') r = r * 10 + (c ^ 48), c = getchar(); return r; } inline void write(int x) { if (x > 9) write(x / 10); putchar(x % 10 ^ 48); return; } inline void writi(int args) { if (args < 0) args = ~args + 1, putchar('-'); write(args); putchar(10); } int n, m, v[50005], o, l, r, k; struct bal_node { int v, sz, rv, lc, rc; }re[10000005]; int cnt; //inline int max(cir l, cir r) { return l > r ? l : r; } //inline int min(cir l, cir r) { return l < r ? l : r; } class bal_tree { int rt, r1, r2, r3; inline void pup(cir p) { re[p].sz = re[re[p].lc].sz + re[re[p].rc].sz + 1; } inline void blyat(int p, int v, int& l, int& r) { if (!p) { l = r = 0; return; } if (re[p].v > v) r = p, blyat(re[p].lc, v, l, re[p].lc); else l = p, blyat(re[p].rc, v, re[p].rc, r); pup(p); return; } inline int merge(int l, int r) { if (!l || !r) return l | r; if (re[l].rv < re[r].rv) { re[l].rc = merge(re[l].rc, r); pup(l); return l; } else { re[r].lc = merge(l, re[r].lc); pup(r); return r; } } public: inline int newn(cir v) { ++cnt; re[cnt].rv = rand(); re[cnt].sz = 1; re[cnt].v = v; return cnt; } inline void ins(cir vl) { blyat(rt, vl, r1, r2); rt = merge(r1, merge(newn(vl), r2)); } inline void del(cir vl) { blyat(rt, vl, r1, r3); blyat(r1, vl - 1, r1, r2); r2 = merge(re[r2].lc, re[r2].rc); rt = merge(merge(r1, r2), r3); } inline void build(cir l, cir r) { for (int i = l; i <= r; ++i) ins(v[i]); } inline int getrk(cir vl) { blyat(rt, vl - 1, r1, r2); int ans = re[r1].sz + 1; rt = merge(r1, r2); return ans; } inline int fndrk(cir p, cir rk) { if (rk <= re[re[p].lc].sz) return fndrk(re[p].lc, rk); if (rk == re[re[p].lc].sz + 1) return re[p].v; return fndrk(re[p].rc, rk - re[re[p].lc].sz - 1); } inline int prev(cir vl) { blyat(rt, vl - 1, r1, r2); int ans; if (re[r1].sz) ans = fndrk(r1, re[r1].sz); else ans = -INT_MAX; rt = merge(r1, r2); return ans; } inline int next(cir vl) { blyat(rt, vl, r1, r2); int ans; if (re[r2].sz) ans = fndrk(r2, 1); else ans = INT_MAX; rt = merge(r1, r2); return ans; } }bt[50005 << 2]; #define mid (l + r >> 1) struct bal_seg_tree { inline void build(cir l, cir r, cir p) { bt[p].build(l, r); if (l == r) return; build(l, mid, p << 1); build(mid + 1, r, p << 1 | 1); } inline int getrk(cir l, cir r, cir p, cir sl, cir sr, cir vl) { if (sl <= l && r <= sr) return bt[p].getrk(vl) - 1; int ret = 0; if (sl <= mid) ret += getrk(l, mid, p << 1, sl, sr, vl); if (sr > mid) ret += getrk(mid + 1, r, p << 1 | 1, sl, sr, vl); return ret; } inline int fndrk(cir l, cir r, cir rk) { int lp = 0, rp = 1e8, mv, ans = -1; while (lp <= rp) if (getrk(1, n, 1, l, r, (mv = lp + rp >> 1)) < rk) ans = mv, lp = mv + 1; else rp = mv - 1; return ans; } inline void chg(cir l, cir r, cir p, cir cp, cir cv) { bt[p].del(v[cp]); bt[p].ins(cv); if (l != r) if (cp <= mid) chg(l, mid, p << 1, cp, cv); else chg(mid + 1, r, p << 1 | 1, cp, cv); } inline int prev(cir l, cir r, cir p, cir sl, cir sr, cir vl) { if (r < sl || sr < l) return -INT_MAX; if (sl <= l && r <= sr) return bt[p].prev(vl); return max(prev(l, mid, p << 1, sl, sr, vl), prev(mid + 1, r, p << 1 | 1, sl, sr, vl)); } inline int next(cir l, cir r, cir p, cir sl, cir sr, cir vl) { if (r < sl || sr < l) return INT_MAX; if (sl <= l && r <= sr) return bt[p].next(vl); return min(next(l, mid, p << 1, sl, sr, vl), next(mid + 1, r, p << 1 | 1, sl, sr, vl)); } }bst; map<tuple<int, int, int>, int>ans; signed main() { n = read(); m = read(); srand((unsigned)time(0)); for (int i = 1; i <= n; ++i) v[i] = read(); bst.build(1, n, 1); while (m--) if (o = read(), o == 1) l = read(), r = read(), k = read(), writi(bst.getrk(1, n, 1, l, r, k) + 1); else if (o == 2) { l = read(), r = read(), k = read(); if (ans.count(make_tuple(l, r, k))) writi(ans[make_tuple(l, r, k)]); else writi(ans[make_tuple(l, r, k)] = bst.fndrk(l, r, k)); } else if (o == 3) l = read(), r = read(), bst.chg(1, n, 1, l, r), v[l] = r, ans.clear(); else if (o == 4) l = read(), r = read(), k = read(), writi(bst.prev(1, n, 1, l, r, k)); else l = read(), r = read(), k = read(), writi(bst.next(1, n, 1, l, r, k)); } ``` 那么请看第 $135$ 到 $137$ 行,这一部分就是为了防被卡。仔细看会发现这个操作是 $O(\log^3n)$ 的。这时候就需要多树二分了。 多树二分,一句话说,就是将会涉及到的线段树树上的节点给全部放一起,然后一起二分。这时候,节点个数是 $\log$ 级别,二分是 $\log$ 的,整体双 $\log$。以 lby 的代码作为实现思路: ```cpp inline int getkth(int l,int r,int k){ id.clear(); find(l,r);//找到树 int x=0,y=1e8; vector<node*>pt; for(int i=0;i<id.size();i++) pt.push_back(t[id[i]].root);//提出树根 if(k==0) return -2147483647;//特判边界 if(r-l+1<k) return 2147483647; while(x!=y){//线段树二分 int sum=0; for(int i=0;i<id.size();i++) sum+=pt[i]->l->val; int mid=x+y>>1; if(k>sum){ k-=sum,x=mid+1; for(int i=0;i<id.size();i++) pt[i]=pt[i]->r; } else{ y=mid; for(int i=0;i<id.size();i++) pt[i]=pt[i]->l; } } return x; } ``` ## 严格次小生成树 还挺简单的,能直接口胡出思路来,就是建出最小生成树,然后在上面换边。只涉及到了倍增和最小生成树相关的。给出相对简短的实现: ```cpp #include<bits/stdc++.h> using namespace std; #define int long long struct node { int p, v; node(int pi = 0, int vi = 0) :p(pi), v(vi) {}; }; vector<node>son[100005]; struct edge { int l, r, v; edge(int li = 0, int ri = 0, int vi = 0) : l(li), r(ri), v(vi) {}; friend bool operator<(const edge& l, const edge& r) { return l.v < r.v; } }et; vector<edge>e; bitset<300005>vis; int n, m, f[100005], cn, mav[100005][18], nmv[100005][18], dep[100005], lg[100005], fa[100005][18], msz, ap; inline int find(int p) { return f[p] != p ? f[p] = find(f[p]) : p; } inline bool merge(int l, int r) { l = find(l), r = find(r); if (l == r) return 0; f[l] = r; return 1; } inline void dfs(int p, int f, int v) { dep[p] = dep[f] + 1; mav[p][0] = v; fa[p][0] = f; for (int i = 1; i <= lg[dep[p]]; ++i) { fa[p][i] = fa[fa[p][i - 1]][i - 1]; mav[p][i] = max(mav[p][i - 1], mav[fa[p][i - 1]][i - 1]); if (mav[p][i - 1] == mav[fa[p][i - 1]][i - 1]) nmv[p][i] = max(nmv[p][i - 1], nmv[fa[p][i - 1]][i - 1]); else nmv[p][i] = min(mav[p][i - 1], mav[fa[p][i - 1]][i - 1]); } for (const node& sp : son[p]) if (sp.p != f) dfs(sp.p, p, sp.v); } inline int getlca(int l, int r) { if (dep[l] < dep[r]) swap(l, r); for (int i = 17; i >= 0; i--) if (dep[fa[l][i]] >= dep[r]) l = fa[l][i]; if (l == r) return l; for (int i = 17; i >= 0; i--) if (fa[l][i] != fa[r][i]) l = fa[l][i], r = fa[r][i]; return fa[l][0]; } inline int getmv(int p, int d, int lim) { int mit; mit = -1e9; for (int i = 0; i <= 17; ++i) if (d & (1ll << i)) { if (mav[p][i] != lim) mit = max(mit, mav[p][i]); else mit = max(mit, nmv[p][i]); p = fa[p][i]; } return mit; } signed main() { ios::sync_with_stdio(0); cin >> n >> m; for (int i = 1, a, b, c; i <= m; ++i) cin >> a >> b >> c, e.emplace_back(edge(a, b, c)); for (int i = 1; i <= n; ++i) f[i] = i, lg[i] = lg[i - 1] + (1ll << lg[i - 1] == i); sort(e.begin(), e.end()); for (int i = 0; i != e.size(); ++i) if (cn == n - 1) break; else if (et = e[i], merge(et.l, et.r)) cn++, msz += et.v, vis[i] = 1, son[et.l].emplace_back(node(et.r, et.v)), son[et.r].emplace_back(node(et.l, et.v)); memset(nmv, 0xcf, sizeof nmv); dfs(1, 0, 0); ap = 0x3f3f3f3f3f3f3f3f; for (int i = 0; i != e.size(); ++i) { if (vis[i]) continue; et = e[i]; if (et.l == et.r) continue; int lp = getlca(et.l, et.r); int val = max(getmv(et.l, dep[et.l] - dep[lp], et.v), getmv(et.r, dep[et.r] - dep[lp], et.v)); if (val > -1e9) ap = min(ap, et.v - val); } cout << msz + ap << endl; } ``` ## 整体二分 正如其名字,我们将所有的询问放到一起来二分,这样可以节省中间重复的判断的时间。思路很简单,结合题目去想一下能不能高效维护就是了。 [例题](https://www.luogu.com.cn/problem/P3527)核心代码: ```cpp inline void solve(const int& la, const int& ra, const int& lq, const int& rq) { if (la == ra) { for (int i = lq; i <= rq; ++i) ans[v[i].p] = la; return; } int mid = la + ra >> 1, c1(0), c2(0); for (int i = la; i <= mid; ++i) ta.add(l[i], c[i]), ta.add(r[i] + 1, -c[i]); for (int i = lq; i <= rq; ++i) { int tv = 0; for (int sp : son[v[i].p]) { tv += ta.que(sp) + ta.que(sp + m); if (tv >= v[i].v) break; } if (tv >= v[i].v) ql[++c1] = v[i]; else v[i].v -= tv, qr[++c2] = v[i]; } for (int i = la; i <= mid; ++i) ta.add(l[i], -c[i]), ta.add(r[i] + 1, c[i]); for (int i = 1; i <= c1; ++i) v[lq + i - 1] = ql[i]; for (int i = 1; i <= c2; ++i) v[lq + i - 1 + c1] = qr[i]; solve(la, mid, lq, lq + c1 - 1); solve(mid + 1, ra, lq + c1, rq); } ``` 只是题必须满足以下性质: 1. 询问的答案具有可二分性 2. 修改对判定答案的贡献互相独立,修改之间互不影响效果 3. 修改如果对判定答案有贡献,则贡献为一确定的与判定标准无关的值 4. 贡献满足交换律,结合律,具有可加性 5. 题目允许使用离线算法 ## 陈丹琦分治 CDQ 分治其实更像是一种思想。CDQ 的经典例题处理的是三维偏序问题,即满足 $a_i<a_j,b_i<b_j,c_i<c_j$ 的整数对 $(i,j)$ 的个数。 这个东西,一看就可以 KDTree(大雾)。 显然不优,CDQ 可以做到 $O(n\log^2n)$。 核心思路就是先按照第一维排序,随后归并排第二维的序列。归并的时候,左部点仍然满足 $a$ 比右部点小。此时我们依次归并 $b$,当 $b$ 来自右部点是计算比该点 $c$ 值更小的已归并的数量之和,这可以用树状数组快速维护。 求解完成。[模板题](https://www.luogu.com.cn/problem/P3810)核心代码: ```cpp void solve(int lp, int rp) { if (lp >= rp) return; int mid = lp + rp >> 1; solve(lp, mid); solve(mid + 1, rp); for (int lt = lp, rt = mid + 1, ip = lp; ip <= rp; ++ip) if (rt > rp || lt <= mid && v[lt].b <= v[rt].b) ta.ins(v[lt].c, v[lt].cnt), tmp[ip] = v[lt++]; else v[rt].ret += ta.que(v[rt].c), tmp[ip] = v[rt++]; for (int i = lp; i <= mid; ++i) ta.ins(v[i].c, -v[i].cnt); for (int i = lp; i <= rp; ++i) v[i] = tmp[i]; } ``` 可以求解类似[动态逆序对](https://www.luogu.com.cn/problem/P3157),[二维数点](https://www.luogu.com.cn/problem/P2163)等的部分问题。 但是这只是非常狭义的 CDQ 分治。我们现在要去研究其核心思想,也就是对于更广的一类题目,我们到底怎么处理: 从上述过程中,我们可以窥见 CDQ 的核心思路。假设现在我们要求出 $[l,r]$ 的贡献,那么我们将其分成两半 $[l,mid]$ 和 $[mid+1,r]$,然后统计 $[l,mid]$ 对 $[mid+1,r]$ 的贡献。 事实上,中间贡献的计算不一定是像三位偏序那样的简单的数据结构,它可以是线段树,平衡树,kd-tree,甚至是另一层 CDQ 分治! 一个典型的例题是[四维偏序](https://qoj.ac/problem/21566),但是我们换一个略有挑战性的[TATT](https://www.luogu.com.cn/problem/P3769)进行讲解。 在经典的三维偏序中,我们每一次二分的时候可以视为将左区间的 $(x,y,z)$ 拍扁成了 $(0,y,z)$,右区间的拍扁成了 $(1,y,z)$。在保证 $y_l<y_r$ 的前提下,只有 $(0,y,z)$ 能对 $(1,y,z)$ 产生贡献。 在四维偏序中,我们可以使用类似的思路:我们在第一层将 $(a,b,c,d)$ 拍扁成 $(0/1,b,c,d)$,然后在第二层将他进一步拍扁成 $(0/1,0/1,c,d)$,然后只统计当 $c_l<c_r$ 时 $(0,0,c,d)$ 对 $(1,1,c,d)$ 的贡献。仍然使用树状数组。 下面给出[TATT](https://www.luogu.com.cn/problem/P3769)中的 CDQ 部分的参考代码: ```cpp inline void cdq_in(int l, int r) { if (l == r) return; int mid = l + r >> 1, lp = l; cdq_in(l, mid); sort(q + l, q + mid + 1, cmp<2>()); sort(q + mid + 1, q + r + 1, cmp<2>()); //cdq_in 外保证 [l,mid].b<[mid+1,r].b,现在按照 c 合并,按照 d 统计贡献 for (int i = mid + 1; i <= r; ++i) { while (lp <= mid && q[lp].v[2] <= q[i].v[2]) { if (q[lp].t) bit.ins(q[lp].v[3], q[lp].ans); lp++; } if (!q[i].t) tmax(q[i].ans, bit.que(q[i].v[3]) + q[i].c); } //统计完恢复案发现场 for (int i = l; i != lp; ++i) if (q[i].t) bit.cls(q[i].v[3]); for (int i = l; i <= r; ++i) tmp[p2[q[i].p]] = q[i]; for (int i = l; i <= r; ++i) q[i] = tmp[i]; cdq_in(mid + 1, r); } inline void cdq_out(int l, int r) { if (l == r) return; int mid = l + r >> 1; cdq_out(l, mid); for (int i = l; i <= mid; ++i) q[i].t = 1; for (int i = mid + 1; i <= r; ++i) q[i].t = 0; sort(q + l, q + r + 1, cmp<1>()); for (int i = l; i <= r; ++i) p2[q[i].p] = i; //cdq_out 外保证 [l,mid].a<[mid+1,r].a,现在按照 b 合并,不统计贡献 cdq_in(l, r); for (int i = l; i <= r; ++i) tmp[q[i].p] = q[i]; for (int i = l; i <= r; ++i) q[i] = tmp[i]; cdq_out(mid + 1, r); } ``` ## 重链剖分 重链剖分是树链剖分中最强大也最常用的一种。重链剖分是这样的: 对于每一个节点,我们选出他的一个儿子,满足以这个儿子为根的子树大小不比别的子节点小。我们称这个儿子为重儿子。其他的我们称为轻儿子。连接重儿子和父节点的边称为重边,其余的称为轻边。这时候,一个极长的只由重边构成的链称为重链。显然一棵树会被剖分成许多的重链。 随后我们使用数据结构对重链进行维护。一种常见的做法是使用线段树。不过这要求链上的节点的编号是连续的。我们按照优先遍历重儿子的方式对整棵树上的节点重新标号为其 dfs 序。这时候同一重链上的节点的编号连续,同一子树内的结点的编号连续,因此我们可以快速维护部分对路径或子树进行修改或者查询的操作。 有点抽象,还是举个例子。比如[模板题](https://www.luogu.com.cn/problem/P3384),他要求维护子树加,子树求和,路径加,路径求和。确实模板,我们主要说一下路径的维护方式。 树上的任意一条路径本质上都是一堆重链的部分被一些轻边串了起来,同时也是 $l\to\operatorname{lca}(l,r)$ 和 $r\to\operatorname{lca}(l,r)$ 串起来。因此,我们可以考虑通过不停的跳重链的方式维护路径信息。 具体的,如果 $l,r$ 还不在同一条重链上,我们就选择**链顶**深度最大的那个节点去跳,先合并链顶到那个节点的信息,然后让那个节点跳到链顶的父节点。当 $l,r$ 到了同一个重链上之后,我们就可以直接合并 $l\to r$ 的信息。 代码其实挺简单的,但是我们还没有证明时间复杂度。 我们发现一个性质:我们每跳一次轻边,以当前节点为根的子树大小至少能够 $\times 2$,因为有重儿子压着你的嘛。也就是说,一个点只要是在向上跳,那就至多只能够跳 $\log_2^n$ 次。跳到 $\operatorname{lca}$ 上其实也是同理的。 所以单次操作时间复杂度就是 $\log^2n$ 的。核心代码如下: ```cpp #include<bits/stdc++.h> using namespace std; #define int long long vector<int>son[100005]; int n, m, s, mod, v[100005]; int sz[100005], hs[100005], idc, f[100005]; inline void spl_lnk(int p, int fa) { for (int sp : son[p]) if (sp != fa) spl_lnk(sp, p), sz[p] += sz[sp], (sz[sp] > sz[hs[p]]) && (hs[p] = sp); sz[p]++; f[p] = fa; } int lnk[100005], nid[100005], nv[100005], d[100005]; inline void down_he(int p, int t) { lnk[p] = t; nid[p] = ++idc; nv[idc] = v[p]; d[p] = d[f[p]] + 1; if (!hs[p]) return; down_he(hs[p], t); for (int sp : son[p]) if (!lnk[sp]) down_he(sp, sp); } struct seg_tree { struct node { int l, r, v, t; }re[100005 << 2]; inline int sz(int p) { ... } inline void pup(int p) { ... } inline void pud(int p) { ... } inline void build(int l, int r, int p) { ... } inline void ins(int l, int r, int v, int p) { ... } inline int que(int l, int r, int p) { ... } }sgt; inline void lins(int l, int r, int v) { while (lnk[l] != lnk[r]) { if (d[lnk[l]] < d[lnk[r]]) swap(l, r); sgt.ins(nid[lnk[l]], nid[l], v, 1); l = f[lnk[l]]; } if (d[l] > d[r]) swap(l, r); sgt.ins(nid[l], nid[r], v, 1); } inline int lque(int l, int r) { int ret = 0; while (lnk[l] != lnk[r]) { if (d[lnk[l]] < d[lnk[r]]) swap(l, r); ret += sgt.que(nid[lnk[l]], nid[l], 1); l = f[lnk[l]]; } if (d[l] > d[r]) swap(l, r); ret += sgt.que(nid[l], nid[r], 1); return ret % mod; } signed main() { ios::sync_with_stdio(0); cin >> n >> m >> s >> mod; for (int i = 1; i <= n; ++i) cin >> v[i]; for (int i = 1, l, r; i != n; ++i) cin >> l >> r, son[l].emplace_back(r), son[r].emplace_back(l); spl_lnk(s, 0); down_he(s, s); sgt.build(1, n, 1); for (int i = 1, o, l, r, v; i <= m; ++i) if (cin >> o >> l, o == 1) cin >> r >> v, lins(l, r, v); else if (o == 2) cin >> r, cout << lque(l, r) << endl; else if (o == 3) cin >> r, sgt.ins(nid[l], nid[l] + sz[l] - 1, r, 1); else cout << sgt.que(nid[l], nid[l] + sz[l] - 1, 1) << endl; } ``` ## 长链剖分 虽然和重链剖分一样都是树链剖分的一种,但是面对的主要问题却很不一样。长链剖分主要是解决一些和深度相关的 DP 问题。长链剖分是这样的: 定义长子节点表示其子节点中子树深度最大的子结点。定义短子节点表示剩余的子结点。从这个结点到长子节点的边为长边。到其他轻子节点的边为短边。若干条首尾衔接的长边构成长链。 它本身并不是很适合维护链修改操作,毕竟他最多会跳 $O(\sqrt n)$ 次短边,远远劣于重链剖分。但是对于深度相关的 DP 问题却有着很大的优化。比如[这道题](https://www.luogu.com.cn/problem/CF1009F): DP 式子其实非常好想,难就难在转移。你可以通过线段树合并等等复杂的技巧去维护,但是这是不必要的,我们可以用长链剖分用简短的代码做到线性复杂度。 具体的来说,我们让一个节点直接继承长儿子的 DP 数组,并在前面插入一个数 $1$,表示在相对深度为 $0$ 的位置有且仅有一个节点,然后暴力合并所有短儿子的 DP 数组。做完了。 没错!做完了。我们分析一下时空复杂度。 首先对于每一个长链,我们仅在链顶的时候被暴力合并一次,而且显然有效的位置最多只会有其长度那么多个,因此合并的时间复杂度是 $O(n)$ 的。空间呢?我们发现我们可以通过指针预留空间的方式,让一整个链完全基于同一个数组进行修改,不同的链也可以通过相应的偏移给别的链预留足够的空间,从而空间也是线性的。 代码如下: ```cpp #include<bits/stdc++.h> using namespace std; vector<int>son[1000005]; int n, a[1000005], d[1000005], ls[1000005]; inline void spl_lnk(int p, int f) { for (int sp : son[p]) if (sp != f) spl_lnk(sp, p), (d[sp] > d[ls[p]]) && (ls[p] = sp); d[p] = d[ls[p]] + 1; } int* v[1000005], * lp, ans[1000005]; inline void cmp(int* v, int& a, int b) { if (v[a]<v[b] || v[a] == v[b] && a>b) a = b; } inline void dps(int p, int f) { v[p][0] = 1; ans[p] = 0; if (!ls[p]) return; v[ls[p]] = v[p] + 1; dps(ls[p], p); ans[p] = ans[ls[p]] + 1; for (int sp : son[p]) if (sp != f && sp != ls[p]) { v[sp] = lp; lp += d[sp]; dps(sp, p); for (int i = 1; i <= d[sp]; ++i) v[p][i] += v[sp][i - 1], cmp(v[p], ans[p], i); } cmp(v[p], ans[p], 0); } signed main() { ios::sync_with_stdio(0); cin >> n; for (int i = 1, l, r; i != n; ++i) cin >> l >> r, son[l].emplace_back(r), son[r].emplace_back(l); spl_lnk(1, 0); v[1] = lp = a; lp += d[1]; dps(1, 0); for (int i = 1; i <= n; ++i) cout << ans[i] << endl; } ``` 有的时候不得不佩服一下发明这种简洁美丽的算法的人。这才是数据结构的美之所在。 ## 李超线段树 要求在平面直角坐标系下维护两个操作: 1. 在平面上加入一条线段。记第 $i$ 条被插入的线段的标号为 $i$。 2. 给定一个数 $k$,询问与直线 $x=k$ 相交的线段中,交点纵坐标最大的线段的编号。 我们发现,传统的线段树无法很好地维护这样的信息。这种情况下,**李超线段树**便应运而生。 区间修改,最常见的办法就是给每个节点一个懒标记。对于每个区间,维护在 $m=\frac{l+r}{2}$ 处取值最大的直线的信息。 现在我们需要插入一条线段 $f$,在这条线段完整覆盖的线段树节点代表的区间中,某些区间的最优线段可能发生改变。 考虑某个被新线段 $f$ 完整覆盖的区间,若该区间无最优线段,则该线段可以直接成为最优线段。 否则,设该区间的中点为 $m$,我们拿新线段 $f$ 在中点处的值与原最优线段 $g$ 在中点处的值作比较。 如果新线段 $f$ 更优,则将 $f$ 和 $g$ 交换。那么现在考虑在中点处 $f$ 不如 $g$ 优的情况: 1. 若在左端点处 $f$ 更优,那么 $f$ 和 $g$ 必然在左半区间中产生了交点,递归到左儿子中进行插入; 2. 若在右端点处 $f$ 更优,那么 $f$ 和 $g$ 必然在右半区间中产生了交点,递归到右儿子中进行插入。 3. 若在左右端点处 $g$ 都更优,那么 $f$ 不可能成为答案,不需要继续下传。 由于仅有一个交点,所以两边子区间最多会递归一个。复杂度 $O(\log n)$。但是需要注意,因为每一次我们都需要对全覆盖的区间都进行修改,而这样的区间是 $\log$ 级别的,因此单次修改的复杂度为 $O(\log^2n)$。 查询 $x=k$ 答案时,从根走到 $[x,x]$ 节点记录的所有最优直线在 $x=k$ 时取值的答案极值即为所求。这里是运用了**标记永久化**的思想。查询的复杂度就为 $O(\log n)$。 特别需要注意,对于不优的三种情况的讨论中,如果二者在端点同样优,那么一定要视为 $g$ 更优。这样才能保证对于两个重复的线段不会一直走 $3$ 操作,从而导致时复错误。 核心代码如下: ```cpp constexpr double eps = 1e-9; inline int cmp(double l, double r) { if (l > r + eps) return 1; if (r > l + eps) return -1; return 0; } inline double quep(int id, int x) { return l[id].b + l[id].k * x; } inline void chg_intv(int nl, int nr, int cp, int p) { int& lp = ans[p], mid = nl + nr >> 1; int ty = cmp(quep(cp, mid), quep(lp, mid)); if (ty == 1 || !ty && cp < lp) swap(cp, lp); int lcn = cmp(quep(cp, nl), quep(lp, nl)), rcn = cmp(quep(cp, nr), quep(lp, nr)); if (lcn == 1 || !lcn && cp < lp) chg_intv(nl, mid, cp, p << 1); if (rcn == 1 || !rcn && cp < lp) chg_intv(mid + 1, nr, cp, p << 1 | 1); } inline void fnd_intv(int nl, int nr, int cl, int cr, int cp, int p) { if (nl >= cl && nr <= cr) return chg_intv(nl, nr, cp, p); int mid = nl + nr >> 1; if (cl <= mid) fnd_intv(nl, mid, cl, cr, cp, p << 1); if (cr > mid) fnd_intv(mid + 1, nr, cl, cr, cp, p << 1 | 1); } inline pode que(int nl, int nr, int cp, int p) { if (nr<cp || nl>cp) return pode(0, 0); int mid = nl + nr >> 1; double val = quep(ans[p], cp); if (nl == nr) return pode(val, ans[p]); return max(pode(val, ans[p]), que(nl, mid, cp, p << 1), que(mid + 1, nr, cp, p << 1 | 1)); } ``` Q1:为什么一定要在路径上取 $\max$ 而不是一直走到底,返回最底部的状态? 标记永久化。 实际上,对于一条线段,他在中点最优常常意味着他在全局都是比较优的。尤其是对于第一次存入的区间。 也就是说,对于相当大的一部分线段,他其实是整个区间的子区间的最优解。但是如果真的更改整个子树,其复杂度退化为 $O(n)$。 所以说,与其更改整个子树,不如直接标记永久化,最后再在这些**可能的最大值**中刨出真正的最大值。 Q2:李超线段树合并细节? 其实不难想象,对于李超线段树这种有永久化标记的树,其合并方式不太一样。 不过,对于同一层,其实我们只需要将其中一个的最优线段合并到另一个当中,然后递归合并就行了。 特别需要注意一点,对于[【模板】李超树合并](https://www.luogu.com.cn/problem/CF932F),其时间复杂度实际上是 $O(n\log n)$ 的。这一点可用势能分析得出。参见第一篇题解,非常玄妙。连[官方题解](https://codeforces.com/blog/entry/57796)都分析错了。 典型应用: 1. 优化 DP,也就是动态维护斜率优化 DP 的凸包,支持任意地方插入。 2. 李超线段树合并,主要也是优化 DP,主要是在对于整个子树的最优信息的合并。 例题:[T0:P4097,省选/NOI-,模板题](https://www.luogu.com.cn/problem/P4097);[T1:P4254,省选/NOI-,模板题](https://www.luogu.com.cn/problem/P4254);[T2:P4655,省选/NOI-](https://www.luogu.com.cn/problem/P4655);[T3:CF932F,省选/NOI-](https://www.luogu.com.cn/problem/CF932F);[T4:P4069,省选/NOI-](https://www.luogu.com.cn/problem/P4069)。 ## 动态树 动态树,顾名思义,这种树是动态的,可能删边或加边。$\text{LCT}$ 可以动态维护其中的路径信息,以[模板题](https://www.luogu.com.cn/problem/P3690)为例。 首先,你需要学会:文艺 $\text{splay}$ 树,实链剖分。 实链剖分?没见过。但是,我们可以管他叫乱链剖分,因为他确实是随便选一个儿子当成实儿子,其余的当成虚儿子。 这有什么用呢?他非常的灵活。不像重链或者长链一样改一个上面全得改。 剖分完了,然后呢?然后,我们将被一堆实边连到一起的树甩到文艺 $\text{Splay}$ 里面,构成一颗树。 那虚边怎么办?我们采用一种神奇的方法:认父不认子!我们正好满足了高效维护虚边和只向上移动的需求。 又因为我们有一堆的 $\text{Splay}$ 树,构成了一个森林,我们管这个森林叫做辅助树。一个辅助树对应原森林中的一棵树。 那辅助树需要满足什么性质呢?每一个 $\text{Splay}$ 的中序遍历满足按照深度排序。这样,我们就完成了原森林到辅助森林的转化。 接下来,$\text{Splay}$树中都有哪些函数和变量呢? ```cpp class splaytree { int sn[sz][2], f[sz], v[sz], r[sz]; //sn(子节点),f(父节点),v(子树异或值),r(反转标记) inline bool bs(int p); inline void pup(int p); inline bool nor(int p); inline void swn(int p); inline void pud(int p); inline void ron(int p); inline void rot(int p); inline void splay(int p); inline void access(int p); inline void mkrt(int p); inline int fnrt(int p); inline void tlnk(int l, int r); public: inline void link(int l, int r); inline int val(int l, int r); inline void chg(int p, int v); }re; ``` 首先说最基础的 `pup`,`pud`,`rot` 和 `splay`。 `pup` 即 `pushup`,将子节点的信息上传上来。这里,我们需要维护每一个节点在 $\text{Splay}$ 中的子树中的所有结点的异或值。 `pud` 即 `pushdown`,将旋转标记下传,毕竟是文艺 $\text{Splay}$嘛。 `rot` 即 `rotate`,将一个节点向上旋转,$\text{Splay}$ 的基础操作。但是要做一定的修改。 首先,一定要严格判断父节点是不是当前 $\text{Splay}$树的树根。如果不是,一定不要让祖父认这个儿子,因为认父不认子只出现在虚边,而一颗 $\text{Splay}$ 维护的是实边树。 其次,一定要严格判断子节点是否存在,不要让 $0$ 有了父节点。 别的大致没有什么好说的,注意先 `pup(fp)` 再 `pup(p)`,因为这时候 $fp$ 已经成为 $p$ 的子节点了。 `splay` 就是你想的那个函数,但是因为在 $\text{LCT}$ 中只涉及到了将一个点转到当前 $\text{Splay}$ 树的根节点这一种操作,所以省略掉了第二个参数。 然后说一下 $\text{LCT}$ 特有的函数。 `nor` 即 `not_root`,判断一个节点是不是当前 $\text{Splay}$ 的根节点。判断方式很简单,看他是不是父亲的儿子,因为认父不认子。 `access` 是 $\text{LCT}$ 的核心操作,它会将 $p$ 在原树中到根节点的链变为实链。可是我们不是不维护原树吗? 其实我们仔细想一下,因为他们在同一个实链当中,所以他们在 `access` 完了之后只会在同一颗 $\text{Splay}$ 中。所以我们只需要将路径上的 $\text{Splay}$ 间的边设为实边就行了。 具体的,我们不停地将当前节点旋到树根,让父亲认这个儿子(原先的儿子因为认父不认子而自动降为了虚儿子)为右儿子(中序遍历满足深度递增),再将节点跳到父亲上就完了。 `swn` 即 `swap_node`,即将这个节点区间反转。 `ron` 即 `rotate_node`,将这个点到根节点的区间反转标记下传。 `mkrt` 即 `make_root`,将这个节点设为原树的根。也很简单,打通他和根节点之间的路径(`access`),旋到树根,区间反转。 为什么区间反转呢?中序遍历按深度递增。反转之前他只会是整个 $\text{Splay}$ 中最下的节点,旋到树根后所有节点都在他的左子树,区间反转一下它就成为最低的节点,也就是根节点了。 `fnrt` 即 `find_root`,找他在原树中的根。我们只需要 `access`,`splay`,然后边下传标记边向左子树走,最靠边的就是根节点。 `tlnk` 即 `to_link`,就是用一颗 $\text{Splay}$ 树专门来维护 $l,r$ 之间的链。显然我们只需要 `mkrt(l)`,`access(r)` 之后 `splay(r)` 就行了。 `link` 就是 $\text{LCT}$ 中 $L$ 对应的操作。他就是连接一条 $l,r$ 的边。我们只需要让 $l$ 成为原树树根,然后 $fa[l]=r$ 就行了。 `cut` 就是 $\text{LCT}$ 中 $C$ 对应的操作。他就是断开 $l,r$ 的边。我们只需要让 $l$ 成为原树的根,将 $r$ 转到 $l$ 边上,不认父不认子就完了。 剩下的两个就是对题的函数了,不用管他。 代码如下: ```cpp #include<bits/stdc++.h> using namespace std; constexpr int sz = 1e5 + 5; int n, m, t[sz]; class tree { //变量:sn(子节点),f(父节点),v(子树异或值),r(反转标记) int sn[sz][2], f[sz], v[sz], r[sz]; //是不是右儿子 inline bool bs(int p) { return sn[f[p]][1] == p; } //更新答案(v) inline void pup(int p) { v[p] = v[sn[p][1]] ^ v[sn[p][0]] ^ t[p]; } //not - root = nor inline bool nor(int p) { return sn[f[p]][0] == p || sn[f[p]][1] == p; } //swn:swap - node = swn inline void swn(int p) { swap(sn[p][0], sn[p][1]), r[p] ^= 1; } //pud:下传反转标记 inline void pud(int p) { if (r[p]) { if (sn[p][0]) swn(sn[p][0]); if (sn[p][1]) swn(sn[p][1]); r[p] = 0; } } //ron:rotate - node(实际上是下传了到链顶的标记) inline void ron(int p) { if (nor(p)) ron(f[p]); pud(p); } /* rot:最根本的旋转操作,但是和普通的 splay 有区别 1. 要判断 f[p] 是不是当前 splay 的根,不是才能更新 f[f[p]] 的子节点信息 2. 要判断 sn[p][...] 的存在性,存在才更新 f[sn[p][...]],否则 f[0] 会被改 */ inline void rot(int p) { int fp = f[p], pp = f[fp]; bool t = bs(p); int sp = sn[p][!t]; if (nor(fp)) sn[pp][bs(fp)] = p; sn[p][!t] = fp; sn[fp][t] = sp; if (sp) f[sp] = fp; f[fp] = p; f[p] = pp; pup(fp); pup(p); } /* splay:最基本的操作,但是也有不同,更像文艺 splay 要下传从 splay 树的树根开始的反转懒标记,但是只反转这条链的。 */ inline void splay(int p) { ron(p); while (nor(p)) { int fp = f[p], pp = f[fp]; if (nor(fp)) rot((sn[fp][0] == p) ^ (sn[pp][0] == fp) ? p : fp); rot(p); } } /* access:LCT 的根本操作 作用:在原树中将 p 到“树根”的边置为实边 方式: 1. 将 p 转到当前 splay 树的根 2. p 作为父亲认前一个节点 ap 互认父子 3. 更新 p 点答案,p 跃到其父节点上。 4. 循环直至 p 变为 0 (虚无令使)。 解释:如果 2 中 p 有右节点,那么置右节点只会将原有的一条实边变为虚边。 这时候,因为认父不认子,修边完成。 */ inline void access(int p) { for (int ap = 0; p; p = f[ap = p]) splay(p), sn[p][1] = ap, pup(p); } /* mkrt:make_root 作用:将 p 变为原树的根节点 方式:打通路径,旋至顶点,交换儿子 交换儿子干了什么呢? access 会使他成为 splay 树中最深的节点。 上去了之后他会成为中序最后,但是交换完左右就成为中序最前了。 根据其特殊性质,他变相地成为了原树的根节点。 */ inline void mkrt(int p) { access(p); splay(p); swn(p); } /* fnrt:find_root 作用:找这个原树的根节点 方式:打通路径,旋至顶点,走左子树,重新旋顶,返回答案。 */ inline int fnrt(int p) { access(p); splay(p); while (pud(p), sn[p][0]) p = sn[p][0]; splay(p); return p; } /* tlnk:to_link 作用:将两个点的路径提取为一个 splay 方式:提为树根,打通路径,旋至树顶 access 链底,mkrt 必为链顶。 */ inline void tlnk(int l, int r) { mkrt(l); access(r); splay(r); } public: //连接边:先作为根,判断不同树后单向认父(虚边)即可。 inline void link(int l, int r) { if (mkrt(l), fnrt(r) != l) f[l] = r; } //断开边:先作为根,判断有边后双向断边即可。 inline void cut(int l, int r) { if (mkrt(l), fnrt(r) == l && f[r] == l && !sn[r][0]) f[r] = sn[l][1] = 0, pup(l); } //其余针对题目的支持函数:查询路径&&修改点权 inline int val(int l, int r) { return tlnk(l, r), v[r]; } inline void chg(int p, int v) { splay(p); t[p] = v; pup(p); } }re; int main() { ios::sync_with_stdio(0); cin >> n >> m; for (int i = 1; i <= n; ++i) cin >> t[i]; for (int i = 1, o, l, r; i <= m; ++i) if (cin >> o >> l >> r, !o) cout << re.val(l, r) << endl; else if (o == 1) re.link(l, r); else if (o == 2) re.cut(l, r); else re.chg(l, r); } //私は猫です ``` ## 快速傅里叶变换 快速傅里叶变换还是太超模了。FFT 可以在 $O(n\log n)$ 的时间复杂度内解决两个次数不高于 $n$ 次的多项式的卷积。 首先,多项式最直观的表示方式是系数表示,但是 FFT 转而使用点值表示。对于一个 $n$ 次多项式,可由 $n+1$ 或更多个不同点的位置确定。这一点在此不作证明。 接下来,我们假定我们有两个 $n$ 次的多项式 $f_1(x)$ 和 $f_2(x),g(x)=f_1(x)\times f_2(x)$,那么假设我们算出了 $2\times n+1$ 个点的值,分别为 $(x_0,f_1(x_0)),(x_1,f_1(x_1)),\dots,(x_{2\times n},f_1(x_{2\times n}))$ 和 $(x_0,f_2(x_0)),(x_1,f_2(x_1)),\dots,(x_{2\times n},f_2(x_{2\times n}))$,那么 $g(x)$ 必过 $(x_0,f_1(x_0)\times f_2(x_0)),(x_1,f_1(x_1)\times f_2(x_1)),\dots,(x_{2\times n},f_1(x_{2\times n})\times f_2(x_{2\times n}))$。 就是这么一坨东西。显然,我们如果能够快速的在点值和多项式之间转化,那么我们就能够快速的求出两个多项式的卷积。问题就在于我们如何在二者之间快速转化。 神奇的地方就在于此。让我们做一个小小的变化。假设 $F_o(x)$ 表示 $F$ 中 $x$ 的偶数次方项之和,$F_e(x)$ 表示 $F$ 中 $x$ 的奇数次方项提出 $x$ 的公因数之后的式子,那么不难发现,$F_o,F_e$ 都只剩偶数次方项了。 这有什么呢?不难注意到,$F(x)=F_o(x)+xF_e(x),F(-x)=F_o(x)-xF_e(x)$。初现端倪,我们递归此过程的话,是不是就可以做到 $O(n\log n)$ 了呢? 似乎并没有,接下来你在求 $F_o(x)$ 的时候,你无法再使用正负点对来加速了,毕竟 $x$ 的平方一定是恒正的......吗? 不一定是啊!你回忆了一下你不知道学没学过的复数,想到单位根正好满足这个要求。 最底层的时候我们求最底下的那个多项式(其实已经只剩单项式了)在 $x=\omega_x=\cos\frac{2\pi\times x}{n+1}+i\sin\frac{2\pi\times x}{n+1}$,其中 $x$ 取值范围为 $[0,n]$。 那么你再观察一下,当 $x^\prime=x^2$ 的时候,$\omega_x^2=\omega_{x+\frac{n+1}{2}}^2$。也就是说,只要我们能让 $n$ 是一个形如 $2^k-1$ 的数,我们就能一直将这个过程进行下去。 那很显然,不足的位我们直接补 $0$ 就完事了。 于是我们成功地将多项式在 $n\log n$ 的复杂度内变为了点值表示。 那么,怎么从点值变回多项式呢? 我们这样来看:FFT 实际上是快速的求出了 $b_x=\sum_{i=0}^na_i\times \omega_{ix}$。那么经过一些神奇的推导,我们很难发现:$a_x=\frac{1}{n+1}\sum_{i=0}^nb_i\omega_{-ix}$。这一点可以通过直接的数论推导或者 FFT 中各步骤对应的矩阵乘法形式通过逆矩阵证得。 此时你又定睛一看,发现如果你令 $\omega^\prime_{x}=\frac{\omega_{-x}}{n+1}$,那么 $a_x=\sum_{i=0}^nb_i\times \omega^\prime_{ix}$,FFT 不就能求了吗? 因此,我们求出了两个多项式的卷积,复杂度 $O(n\log n)$。 更多优化与应用在 NTT 讲。 ## 快速数论变换 也算是为数不多的几个完全不需要掌握其所以然的算法了。可以从 FFT 那里变过来。 NTT 本质上就干了一件事:把复数映射到了整数集上,然后接着干。 怎么映射呢?原根。对于质数 $p=qn+1$,且 $n=2^m$,那么原根 $g$ 满足 $g^{qn}\equiv1\pmod p$。此时我们可以将 $g_n=g^q\pmod p$ 看作 $\omega_n$ 的等价。 说白了,在模 $p$ 意义下可以认为 $g\equiv e^{\frac{2\pi}{q}}$。常见的 $(p,g)$ 有 $(167772161,3),(998244353,3),(1004535809,3)$。 所以你只需要把代码中对应的那些位置替换一下就行了。 ### 若干优化 逐步看。这是 FFT 比较劣的一个版本,模板题要跑 2.77s。 ```cpp using cpx = complex<double>; int n, m, lim, rev; const double pi2 = acos(-1) * 2; inline void fft(const int& lim, vector<cpx>& v) { if (lim == 1) return; vector<cpx>vl(lim + 4 >> 1), vr(lim + 4 >> 1); for (int i = 0; i <= lim; i += 2) vl[i >> 1] = v[i], vr[i >> 1] = v[i + 1]; fft(lim >> 1, vl); fft(lim >> 1, vr); cpx tm(cos(pi2 / lim), sin(pi2 / lim) * rev), tv(1, 0); for (int i = 0; i != lim >> 1; ++i, tv *= tm) v[i] = vl[i] + tv * vr[i], v[i + (lim >> 1)] = vl[i] - tv * vr[i]; } ``` 我们知道,复数的运算其实很耗时间,所以能少计算一次,尤其是乘法,会有一定的优化。所以我们将 `tv * vr[i]` 的结果存下来。这样就能优化到 2.65s。代码如下: ```cpp using cpx = complex<double>; int n, m, lim, rev; const double pi2 = acos(-1) * 2; inline void fft(const int& lim, vector<cpx>& v) { if (lim == 1) return; vector<cpx>vl(lim + 4 >> 1), vr(lim + 4 >> 1); for (int i = 0; i <= lim; i += 2) vl[i >> 1] = v[i], vr[i >> 1] = v[i + 1]; fft(lim >> 1, vl); fft(lim >> 1, vr); cpx tm(cos(pi2 / lim), sin(pi2 / lim) * rev), tv(1, 0), tmp; for (int i = 0; i != lim >> 1; ++i, tv *= tm) tmp = tv * vr[i], v[i] = vl[i] + tmp, v[i + (lim >> 1)] = vl[i] - tmp; } ``` 这不是重点,接下来要介绍一下非递归版的 FFT。 我们在运行的时候会不停的涉及到交换位置,导致最后算出来的东西出现乱序,根本用不了。但是如果我们预先将这些位置预先换了,是不是就没有那么多问题了? 确实。不过一个悲催的问题是我没有搞懂他为什么是代码中的那个位置。所以死记吧。这样能直接优化到 1.65s。代码如下: ```cpp inline void fft(vector<cpx>& v) { for (int i = 0; i < lim; ++i) if (i < sp[i]) swap(v[i], v[sp[i]]); for (int ml = 1; ml < lim; ml <<= 1) { tv = cpx(cos(pi / ml), sin(pi / ml) * rev);//因为是枚举一半的长度,所以不需要pi*2了。 for (int p = 0; p < lim; p += ml << 1) { vl = cpx(1, 0); for (int i = 0; i < ml; ++i, vl *= tv) tmp = v[p + i + ml] * vl, v[p + i + ml] = v[p + i] - tmp, v[p + i] += tmp; } } } int main() { n = read(); m = read(); vector<cpx>f(n + m + 1 << 1), g(n + m + 1 << 1); for (int i = 0; i <= n; ++i) f[i] = cpx(read(), 0); for (int i = 0; i <= m; ++i) g[i] = cpx(read(), 0); lim = rev = 1; while (lim <= n + m) lim <<= 1, tl++; for (int i = 0; i < lim; ++i) sp[i] = (sp[i >> 1] >> 1) | ((i & 1) << (tl - 1)); fft(f); fft(g); rev = -1; for (int i = 0; i <= lim; ++i) f[i] *= g[i]; fft(f); for (int i = 0; i <= n + m; ++i) writi(f[i].real() / lim + 0.5); return 0; } ``` ### 应用 重要的是卷积形式。这又是什么呢?就是形如 $A(x)=\sum_{i=0}^xF(i)G(x-i)$ 的式子,其中 $F,G$ 均不与 $x$ 相关。显然从卷积的暴力计算方式来看,就不难发现 $F*G$ $x^n$ 对应的系数就是要求的 $A(n)$。 于是,只要你能化出卷积形式,就可以用 FFT/NTT 做掉了。典型例题有[第二STL行](https://www.luogu.com.cn/problem/P5395)。EG: $$ \begin{aligned} & \{_m^n\}\\ =&\frac{1}{m!}\sum_{i=1}^m(_i^m)(-1)^{m-i}i^n\\ =&\sum_{i=1}^m\frac{(_i^m)(-1)^{m-i}i^n}{m!}\\ =&\sum_{i=0}^m\frac{i^n}{i!}\times\frac{(-1)^{m-i}}{(m-i)!} \end{aligned} $$ 所以就能得出核心代码如下: ```cpp class prev_stl { arr cp, a, b, ny; int jc, lim, tl, rev, tv, tmp; inline void ntt(int* v) { for (int i = 0;i < lim;++i) if (i < cp[i]) swap(v[i], v[cp[i]]); for (int ml = 1; ml < lim; ml <<= 1) { tv = qpow(rev ? 3 : siv3, (mstl - 1) / ml / 2, mstl); for (int p = 0; p < lim; p += ml << 1) for (int i = 0, vl = 1; i < ml; ++i, vl = vl * tv % mstl) tmp = v[p + i + ml] * vl % mstl, v[p + i + ml] = (v[p + i] - tmp + mstl) % mstl, v[p + i] = (v[p + i] + tmp) % mstl; } if (!rev) { int inv = qpow(lim, mstl - 2, mstl); for (int i = 0;i < lim;++i) v[i] = v[i] * inv % mstl; } } public: inline void init() { for (int i = jc = 1;i <= k;++i) jc = jc * i % mstl; ny[k] = qpow(jc, mstl - 2, mstl); for (int i = k;i >= 1;i--) ny[i - 1] = ny[i] * i % mstl; for (int i = 0;i <= k;++i) a[i] = (i & 1) ? mstl - ny[i] : ny[i], b[i] = qpow(i, k, mstl) * ny[i] % mstl; lim = rev = 1; while (lim <= (k * 2)) lim <<= 1, tl++; for (int i = 0; i < lim; ++i) cp[i] = (cp[i >> 1] >> 1) | ((i & 1) << (tl - 1)); ntt(a); ntt(b); for (int i = 0;i < lim;++i) a[i] = a[i] * b[i] % mstl; rev = 0; ntt(a); for (int i = k + 1;i < lim;++i) a[i] = 0; } inline int solve(int v) { return a[v]; } }prs; ``` ## prufer序列 这个也是相当的重要的。Prufer 序列可以实现无根树和值域为 $[1,n]$ 的长度为 $n-2$ 的整数序列之间的转化。 那么怎么转化的呢?先看第一种:树转 prufer。 每一次我都选择当前编号最小的叶子节点,将它的父节点的编号存到序列中,然后删掉这个节点,直到只剩下两个节点。 显然,用堆可以 $O(n\log n)$ 做。但是如果你仔细想一下就会发现,每删一个点最多再加上一个点。因此我可以顺序枚举点,如果能删就一直删,直到非叶子节点或者编号比初始位置大。时复 $O(n)$。 再看第二种:prufer 转树。 首先由于 prufer 序列的一个非常显然的性质:每个节点在序列中出现的次数就是其度数减一。所以我们每一次选择编号最小的度数为 $1$ 的节点与当前枚举到的 prufer 序列中的点连接起来,将二者的“剩余度数”减一。当 prufer 序列枚举完后就剩下了两个点,连起来就行。 但是由于和树转 prufer 类似的构造方式,显然仍然可以用类似的方式来线性构造。 有什么用呢?首先他明显证明了 Cayley 定理:$n$ 个节点的带标号的形态不同的无根树有 $n^{n−2}$ 个。 其次,按照 LZJ 所说,会有一些题要用这个序列和一些数据结构搓一下,但是似乎到现在为止他也没有给出相关例题,就先放着吧。 ## 可并堆 ### 斜堆 斜堆应该是非常好实现的一种可并堆。假设我们有 $n$ 个堆,每一个堆初始大小都是 $O(1)$ 的。 斜堆每次合并时先比较一下两个正在合并的堆的堆顶,视情况选取更小的那个堆,并将那个堆的左儿子/右儿子和另一个堆接着合并。随后直接交换左右儿子。非常潦草,但是操作均摊下来就是 $O(\log)$ 的。感性证明不难,不会严格证明,想了解的[看这里](https://www.luogu.com.cn/article/5bxl56nu)。模板二代码如下: ```cpp class m_incline_heap { struct node { int f, s[2], v; }re[1000005]; inline int merge(int l, int r) { if (!l || !r) return l | r; if (re[l].v > re[r].v) swap(l, r); re[re[l].s[1] = merge(re[l].s[1], r)].f = l; swap(re[l].s[0], re[l].s[1]); return l; }//合并操作 public: inline void chg(int& r, int p, int v) { int f = re[p].f; if (f) re[f].s[re[f].s[0] != p] = re[p].f = 0; else r = re[p].f = 0; //断开与父节点的边 re[p].v = v; //改权值 r = merge(r, p); //合并回去 }//修改操作 inline void init() { for (int i = 1;i <= n;++i) cin >> re[i].v, rt[i] = i; } inline void del(int h, int p) { chg(rt[h], p, INT_MIN); //设置要删除的节点权值极小,也就是置于堆顶 re[rt[h] = merge(re[rt[h]].s[0], re[rt[h]].s[1])].f = 0; //删除堆顶 }//从堆 h 中删去元素 p inline int top(int h)const { return re[rt[h]].v; } inline void merh(int l, int r) { re[rt[l] = merge(rt[l], rt[r])].f = 0; }//合并两个堆 }mih; ``` ### 随机堆 更潦草了,直接在合并的时候随机向左子树或者右子树合并,期望树高是 $O(\log)$ 的,对吧? ### 左偏树 这个稍微科学一点。我们定义一个节点的 $dist$ 为其到子树中最近的外节点所经过的边的数量。空节点的 $dist=0$。我们让这棵树保持左偏,即每个节点左儿子的 $dist$ 都 $\ge$ 右儿子的 $dist$。因此左偏树每个结点的 $dist$ 都等于右儿子的 $dist+1$。 所以一种常见的写法是将要合并的和右儿子合并,并且判断满不满足左偏性质,不满足就交换一下。复杂度证明参见[这里](https://oi.wiki/ds/leftist-tree/#复杂度证明)。 需要提醒一点,上面三种树理论上讲深度可能达到 $O(n)$,找一个点所在的堆顶要用并查集维护,不能直接暴力跳父亲。 ### 线性建堆 这个会在图论的最小树形图中遇到,所以还是要讲一下。 其实很常见的写法是将要用到的数全部放到一个数组里,然后类似线段树建树的方式给他递归下去建树。 不过我们还有另一种略短一点的写法。我们将所有的值全部建出节点来,放到一个队列里面,然后不停的合并队列的前两个元素,直到只剩一个元素。 虽然还是很潦草,但是理想状态下队首的大小是 $1,1,\dots,1,2,2,\dots,2,4,4,\dots,4,8,\dots$。懂吧?如果不完全是 $2^n$ 形的数差的也不会太大。 ## kd-tree K-D Tree 是一种可以高效处理 $k$ 维空间信息的数据结构。 先讲建树: k-D Tree 具有二叉搜索树的形态,二叉搜索树上的每个结点都对应 $k$ 维空间内的一个点。其每个子树中的点都在一个 $k$ 维的超长方体内,这个超长方体内的所有点也都在这个子树中。若当前超正方体只有一个点,就返回这个点,否则的话轮流选一个维度,并将这个维度的中位数作为这一维的分割标准。按照这一维的大小分成左右两部分。Eg.: ![](https://oi-wiki.org/ds/images/kdt1.jpg) ![](https://oi-wiki.org/ds/images/kdt2.jpg) 轮流选择多个维度可以使得树高降到 $\log n+O(1)$。正确性显然。查询复杂度是 $O(n^{1-\frac{1}{k}})$ 的。可见效果拔群(雾)。 那如果我们要修改呢?一种常见但是时间复杂度错误的写法是替罪羊树,这样会使得树高降到 $O(\log)$ 级别。而查询复杂度是建立在树高为 $\log n+O(1)$ 的基础上的。 有一种复杂度正确而且相对好写的方法叫做二进制分组。具体的,我们将这些树分成大小为 $2^0,2^1,\dots$ 的许多棵树,查询的时候每棵树查询一次,然后合并结果。如果某一层的大小超了就不停向上合并。均摊 $O(n\log^2n)$。可以证明查询复杂度仍然是 $O(n^{1-\frac{1}{k}})$ 的。 模板题参考代码如下: ```cpp #include<bits/stdc++.h> using namespace std; int n, o, xa, ya, xb, yb, v; inline void tmin(int& l, const int r) { (l > r) && (l = r); } inline void tmax(int& l, const int r) { (l < r) && (l = r); } using ini = initializer_list<int>; template<const int wd> struct pos { int p[wd]; pos<wd>() { memset(p, 0, sizeof p); } pos<wd>(ini pv) { memcpy(p, pv.begin(), pv.size() << 2); } inline bool operator<(const pos<wd>& r) const{ for (int i = 0;i != wd;++i) if (p[i] != r.p[i]) return p[i] < r.p[i]; return 1; } inline bool operator==(const pos<wd>& r) const{ for (int i = 0;i != wd;++i) if (p[i] != r.p[i]) return 0; return 1; } }; template<const int wd> inline bool ninter(const pos<wd>& alu, const pos<wd>& ard, const pos<wd>& blu, const pos<wd>& brd) { for (int i = 0;i != wd;++i) { if (blu.p[i] > ard.p[i] || alu.p[i] > brd.p[i]) return 1; } return 0; } template<const int wd> inline bool finter(const pos<wd>& alu, const pos<wd>& ard, const pos<wd>& blu, const pos<wd>& brd) { for (int i = 0;i != wd;++i) if (blu.p[i] < alu.p[i] || brd.p[i] > ard.p[i]) return 0; return 1; } template<const int wd> class kdtree { struct node { pos<wd> np, lu, rd; int ls, rs, v, sm, f; node() :ls(0), rs(0), v(0), sm(0), f(0) {}; }re[200005]; int rt[50]; stack<int>ps; vector<pair<pos<wd>, int>>mt; inline void ret(int p) { ps.emplace(p); } inline int get() { int p = ps.top(); ps.pop(); memset(&re[p], 0, sizeof(re[p])); return p; } inline void pup(int p) { re[p].sm = re[re[p].ls].sm + re[re[p].rs].sm + re[p].v; re[p].lu = re[p].rd = re[p].np; for (int i = 0;i != wd;++i) { if (re[p].ls) tmin(re[p].lu.p[i], re[re[p].ls].lu.p[i]), tmax(re[p].rd.p[i], re[re[p].ls].rd.p[i]); if (re[p].rs) tmin(re[p].lu.p[i], re[re[p].rs].lu.p[i]), tmax(re[p].rd.p[i], re[re[p].rs].rd.p[i]); } } inline void getall(int p) { mt.emplace_back(re[p].np, re[p].v); if (re[p].ls) getall(re[p].ls); if (re[p].rs) getall(re[p].rs); ret(p); } inline int build(int l, int r, int cf) { if (l > r) return 0; int p = get(); if (l == r) { re[p].np = mt[l].first; re[p].v = mt[l].second; re[p].f = cf; pup(p); return p; } int md = l + r >> 1; nth_element(mt.begin() + l, mt.begin() + md, mt.begin() + r + 1, [&](const pair<pos<wd>, int>& l, const pair<pos<wd>, int>& r) { return l.first.p[cf] < r.first.p[cf]; }); re[p].np = mt[md].first; re[p].v = mt[md].second; re[p].f = cf; cf = (cf + 1) % wd; re[p].ls = build(l, md - 1, cf); re[p].rs = build(md + 1, r, cf); pup(p); return p; } inline int que(const pos<wd>& lu, const pos<wd>& rd, int p) { if (!p || ninter(lu, rd, re[p].lu, re[p].rd)) return 0; if (finter(lu, rd, re[p].lu, re[p].rd)) return re[p].sm; int ret = 0; if (finter(lu, rd, re[p].np, re[p].np)) ret += re[p].v; ret += que(lu, rd, re[p].ls); ret += que(lu, rd, re[p].rs); return ret; } public: inline void init() { memset(rt, 0, sizeof rt); for (int i = 2e5;i;i--) ret(i); } inline void add(int x, int y, int v) { int lp = 0; mt.emplace_back(ini{ x, y }, v); while (rt[lp]) getall(rt[lp]), rt[lp] = 0, lp++; sort(mt.begin(), mt.end()); int j = -1; for (int i = 0;i != mt.size();++i) if (j != -1 && mt[i].first == mt[j].first) mt[j].second += mt[i].second; else mt[++j] = mt[i]; rt[lp] = build(0, j, 0); mt.clear(); } inline int que(int xa, int ya, int xb, int yb) { int ret = 0; for (int i = 0;i != 20;++i) ret += que(ini{ xa,ya }, ini{ xb,yb }, rt[i]); return ret; } }; kdtree<2>kdt; signed main() { ios::sync_with_stdio(0); cin >> n; int la = 0; kdt.init(); while (cin >> o, o != 3) if (o & 1) cin >> xa >> ya >> v, xa ^= la, ya ^= la, v ^= la, kdt.add(xa, ya, v); else { cin >> xa >> ya >> xb >> yb; xa ^= la, ya ^= la, xb ^= la, yb ^= la; cout << (la = kdt.que(xa, ya, xb, yb)) << endl; } } ``` 不得不说真的非常屎山,考试的时候最好不要遇到。 # 数学 ## 质数的判定 ### 暴力 对于一个合数 $n$,他一定会有一个 $\lfloor\sqrt n\rfloor$ 以下的因数,由唯一分解定理,正确性显然。所以我们检验 $2$ 到 $\lfloor\sqrt n\rfloor$ 是不是 $n$ 的因数就完了。不给代码了。 ### 埃氏筛 一个质数除自己以外的其他倍数一定是合数。所以我们从 $2$ 开始枚举,如果这个数没有标记就把他的其他倍数全部标记了。复杂度是 $O(n\log\log n)$ 的。代码如下: ```cpp for (long long i = 2; i <= 1e6; ++i) if (!isp[i]) for (int j = i; i * j <= 1e6; ++j) isp[i * j] = 1; //其实效率很高,手写 bitset 的话 3e8 能在 1.56s 内判完 ``` ### 欧拉筛 又名线性筛,真的是线性的。埃氏筛的复杂度不是严格线性,问题就在于同一个合数会所有的质数给标记。线性筛可以做到只被最小的质数标记。 我们思考对于每一个合数 $n=\prod_{i=1}^kp_i^{c_i},p_i<p_{i+1}$。那么他其实只有可能被 $n=\prod_{i=1}^kp_i^{c_i-[i=now]}$ 给标记到。这正是埃氏筛。但是因为有 `i % p[j] == 0 -> break`,所以说当 $now>1$ 的时候其标记到尝试会被更靠前的质数给禁用掉。所以只有在 $now=1$ 的时候才会被标记。从而是线性的。 代码如下: ```cpp for (long long i = 2; i <= 1e7; ++i) { if (!isp[i]) p[++pc] = i; for (int j = 1; j <= pc && i * p[j] <= 1e7; ++j) if (isp[i * p[j]] = 1, i % p[j] == 0) break; } //这个略微复杂一点,但是可以处理的真的很多!简单提一下: //对于可以快速求出 f(p^k) 的积性函数,他可以做到线性复杂度求出 [1,n] 内的所有值 ``` ### Miller–Rabin 如果 $n$ 为素数,则 $\forall a<n$,设 $n−1=d\times 2^r,d$ 为奇数,则以下两个命题至少有一个成立: 1. $a^d\equiv 1\pmod n
  1. \exist i\in[0,r-1],a^{d\times2^i}\equiv -1\pmod n

这个定理的逆定理并不成立,但是如果你取的 a 足够好,并且多测几组,那么总能将复杂度降下来。事实上如果你取 2,3,5,7,11,13,17,37,那么它在 long long 范围内的正确率是 100\%

代码如下:

//2,3,5,7,11,13,17,37
//事实上下面这一组好像也是,不过这一组更好记
constexpr int ud[] = { 2,325,9375,28178,450775,9780504,1795265022 };
inline bool isprm(int n) {
    if (n < 3 || n % 2 == 0) return n == 2;//特判
    int u = n - 1, t = 0;
    while (u % 2 == 0) u /= 2, ++t;
    for (int a : ud){
        int v = qpow(a, u, n);
        if (v == 1 || v == n - 1 || v == 0) continue;
        for (int j = 1; j <= t; j++){
            v = mul(v, v, n);
            if (v == n - 1 && j != t) { v = 1; break; }
            if (v == 1) return 0;
        }
        if (v != 1) return 0;
    }
    return 1;
}

卡特兰数

卡特兰数非常常见,可应用于以下问题:

  1. 2n 个人排成一行进入剧院,入场费 50 元。其中 n 人只有一张 50 元的钞票,n 人只有一张 100 元的钞票。请问有多少种方案使得剧院总有 50 元钞票来找零。
  2. 有一个大小为 n\times n 的方格图。左下角为 (0,0),右上角为 (n,n),每次只能向左或向上走且必须满足所有经过的位置 (x,y) 满足 x\ge y
  3. 在圆上选择 2n 个点,将这些点成对连接起来使得所得到的 n 条线段不相交的方法数。
  4. 对角线不相交的情况下,将一个凸多边形区域分成三角形区域的方法数。
  5. 一个栈中顺序放入 1,2,\dots,n 的不同的合法出栈顺序数。

不难发现,1,2,5,7 是完全等价的,3,4 是完全等价的。实际上,3,4,6 的递推式是完全一样的。我们可以得出若干不同的式子:

$H_n=\frac{(4n-2)H_{n-1}}{n+1}=\sum_{i=1}^nH_{i-1}H_{n-i}$。 一般可以认为边界条件为 $H_0=H_1=1$。 为什么是这样的呢?先看第二个式子。显然,$C_{2n}^n$ 就是不考虑 $x\ge y$ 的从 $(0,0)$ 到 $(n,n)$ 的方案数,而 $C_{2n}^{n-1}$ 就是不考虑 $x\ge y$ 的从 $(0,0)$ 到 $(n-1,n+1)$ 的方案数。这俩有什么联系吗? 我们想一下,如果一个路径不合法,那么他一定会在某一步走到 $(x,x+1)$。这时候,我们将他在 $(x,x+1)$ 到 $(n,n)$ 的路径反转一下,就像这个样子: ![](https://www.helloimg.com/i/2025/03/22/67de34a84797e.png) 神奇的事情发生了:一个到 $(n,n)$ 的不合法路径被映射到了一个到 $(n-1,n+1)$ 的路径。随后,不难证明,每一个不合法路径都会唯一对应一个到 $(n-1,n+1)$ 的路径。那每一个到 $(n-1,n+1)$ 的路径都唯一对应一个到 $(n,n)$ 的不合法路径吗? 是的。我们直接让他在第一次越过 $x=y$ 线的时候就翻折,显然一一对应。证毕,解决 $1,2,5,7$。 第二个式子显然能够推到第一个式子,不用多说了。也可以证明出来第三个式子。那第四个呢? 从 $4$ 或 $6$ 都能够推出这个式子。$4$ 从分治的角度去想,$6$ 从树形 DP 的角度去想都能得到这个式子。证毕,解决 $3,4,6$。 那怎么证明这两个式子是恒相等的呢?数学归纳法是一个比较容易想到的方法。读者自证不难? 典型例题:[卡常难数](https://www.luogu.com.cn/problem/T147065)。 裸题[1](https://www.luogu.com.cn/problem/P1722)[2](https://www.luogu.com.cn/problem/P1044)[3](https://www.luogu.com.cn/problem/P1754)[4](https://www.luogu.com.cn/problem/P1976)[5](https://www.luogu.com.cn/problem/P1375)[6](https://www.luogu.com.cn/problem/T147065)。 应用[1](https://atcoder.jp/contests/arc145/tasks/arc145_c)[2](https://www.luogu.com.cn/problem/P2532)。 拓展[1](https://www.luogu.com.cn/problem/P1641)[2](https://www.luogu.com.cn/problem/AT_abc205_e)。 ## 威尔逊定理 $(p-1)!\equiv-1\pmod p$($p$ 为质数)。可以用逆元两两配对证。 一般情况下不考,要考则考的足够板。主要的作用可能就是优化对这类数的快速幂操作的一个 $\log$,或者[这道题](https://www.luogu.com.cn/problem/SP16211)的用途。主要是第二个。 ## 错排列 简述思路:枚举每个数位置合不合法,$2^n$ 容斥,进一步发现数量相同贡献相同,压缩贡献计算得到 $\Sigma_{i=0}^n(-1)^iC_n^i(n-i)!$,进一步化简为 $\Sigma_{i=0}^n(-1)^i\frac{n!}{i!}=\frac{n!}{0!}-\frac{n!}{1!}+\frac{n!}{2!}-\frac{n!}{3!}+\dots$。 记个结论就完了,这东西不太能考的不板。 ## 圆排列 $\frac{A_n^m}{m}$,主要是记住他的最后除掉多统计部分的思想,解决其他类似问题。 ## 多重集排列组合 以下按照这样的方式记录一个多重集 $A$。$A$ 中共有 $[A]$ 种不同元素 $A_{v_i}(i\in[1,[A]])$,其中第 $i$ 种有 $A_{c_i}$ 个。该集大小为 $|A|=\Sigma_{i=1}^{[A]}A_{c_i}$。 用到最后除掉多统计部分的思想。全排列大小为 $\frac{|A|!}{\prod_{i=1}^{[A]}A_{c_i}!}$。 如果是普通的排列组合,则需要用到容斥的思想,如下: 对于其组合,先考虑这么一个问题:有 $n=[A]$ 个篮子,把 $m=|A|$ 个元素扔进去的方案数。这种问题常见的处理方法就是隔板法。显然答案就是 $C_{n+m-1}^{n-1}$。 接下来,我们考虑对于一般的情况。一般的情况其实和上面的问题的本质区别就在于上面的相当于每种元素无限取,而实际上每种元素是有限制的。最多就是其个数。 那么我们其实真的很容易就可以想到容斥。枚举每一种元素超没有,如果第 $i$ 种元素超了就预先分配 $A_{c_i}+1$ 个给他,然后奇偶加减类容斥就完了。一般的排列也有类似的方法,这里不做赘述。 ## bsgs与exbsgs $\texttt{BS(Baby step) leads to GS(Giant staep).}

BSGS(大步小步) 可以在 O(\sqrt p) 的时间内求解出 a^x\equiv b\pmod p 的最小解,满足 p 是质数。思路如下:

\texttt{let }m=\lceil\sqrt p\rceil \\ \texttt{let }x=i\times m-j (i\in[1,m],j\in[0,m-1])\\ \therefore a^{i\times m-j}\equiv b\pmod p \\ \therefore (a^m)^i\equiv b\times a^j\pmod p

因此我们枚举 j,将 b\times a^j 最后一次出现的位置的 j 存到 b\times a^j 对应的位置(一般用 unordered_map),直接覆盖原有数据就行。

接下来,我们顺序枚举 i,一旦发现在 unordered_map 中存在 (a^m)^i,就直接返回 i\times m-val\_position[(a^m)^i]。如果 i 枚举完了还没有返回就只能是无解了。

不难注意到 x\in[1,m^2],无法枚举到 0,因此需要特判这种情况。

i 较小的情况下 j 越大 x 越小。正确性比较显然,时间复杂度也必然正确。

哈哈,数论题的拓展最喜欢的就是去除各种互质/是质数一类的限制了!他也跑不掉。思路如下:

\because a^x\equiv b \pmod p \\ \therefore a^x + py = b \\ \texttt{let }d_1 = \gcd(a,p) \\ \therefore a^{x-1}\times\frac{a}{d_1}+\frac{p}{d_1}y=\frac{b}{d_1} \\ \texttt{if }\frac{b}{d_1}\notin\mathbb{N}: \texttt{no solution}\\ \texttt{let }d_2=\gcd(a,\frac{p}{d_1}) \\ \therefore a^{x-2}\times(a/d_1)\times(a/d_2)+\frac{p}{d_1d_2}y=\frac{b}{d_1d_2} \\ \texttt{if }\frac{b}{d_1d_2}\notin\mathbb{N}: \texttt{no solution}\\ ......(until) \\ d_{k+1}=\gcd(a,\frac{p}{\prod_{i=1}^k d_i}) = 1 \\ \texttt{let }D = \prod_{i=1}^k d_i \\ \therefore a^{x-k}\times\frac{a^k}{D}+\frac{p}{D}y=\frac{b}{D} \\ \therefore a^{x-k}\times\frac{a^k}{D}\equiv\frac{b}{D} \pmod{\frac{p}{D}} \\ \texttt{let }z = x - k \\ \therefore a^z \equiv \frac{b}{D}*(\frac{a^k}{D})^{-1} \pmod{\frac{p}{D}} \\ ans = z + k

于是问题被转化为普通的 BSGS。注意上面 \frac{p}{D} 不一定是质数,因此 (\frac{a^k}{D}) 的逆元要使用 EXGCD 求解。

生成函数

一些前置概念,在后面多个板块会用到。

积性函数:满足 f(ab)=f(a)\times f(b)(\gcd(a,b)=1)

那么,知道因式分解时有:

常见积性函数: $\sigma_i$:$i=0$ 时为 $n$ 的因子数,$\sigma_i(n)=\sum_{d|n}d^i$。 $\mu_n$:莫比乌斯函数。 $\varphi_n$:欧拉函数。 $d_n$:$n$ 的约数个数。 $\epsilon_n$:$[n=1]$。 $I_n=1$。(常见写为 $1_n$)。 $id_n=n$。(常见写为 $I,Id$)。 ### 普通生成函数 对于数列 $A_i(i\in[0,n])$,其 OGF 为 $A(x)=\Sigma_{i=0}^nA_ix^i$。 看起来不知道有什么用,给个例题:有三种物品,每种物品分别有 $3,2,3$ 个,问从这三种物品里面拿出 $4$ 个的方案数有多少种? 假设 $f(i,j)$:转移到物品 $i$,已经选了 $j$ 个。 能不能更方便一些? 对每一个数生成出其生成函数($A1_i=A3_i=1,i\in[0,3],A2_i=1,i\in[0,2]$),即 $A1(x)=A3(x)=1+x+x^2+x^3$,$A2(x)=1+x+x^2$。令 $F(x)=A1(x)A2(x)A3(x)$,则答案为 $[x^4]F(x)$,即 $x^4$ 的系数。 其实生成函数的系数之间的乘法对应了你手数时每一项选择的过程。 需要注意的是,这些生成函数往往可能会涉及到无穷的累加。请别忘了我们还有展开式,无穷序列加起来可能会变成之前的分式。 典型手算例题有[《干饭》](https://www.luogu.com.cn/problem/P10780)和[《末日时在干什么》](https://www.luogu.com.cn/problem/P2000)。(不正经.jbg) 其实我们不难看出,绝大多数的情况下,$A$ 数组都只会涉及到 $0,1$ 两种值,来表示可不可取。拆解的时候多使用换元法,因式分解法,往往会有奇效。 补充一个公式:$\Sigma_{i=0}^kx^i=\frac{1-x^{k+1}}{1-x}$。 ### 指数生成函数 对于数列 $A_i(i\in[0,n])$,其 EGF 为 $A(x)=\Sigma_{i=0}^nA_i\frac{x^i}{i!}$。 对于大部分带标号的问题,可能会有类似的形式:$f(i)f(j)(_i^{i+j})=f(i+j)$。一个典型的例子就是多重集的排列。 有 $f(i)f(j)\frac{(i+j)!}{i!j!}=f(i+j)$,即 $\frac{f(i)}{i!}\times\frac{f(j)}{j!}=\frac{f(i+j)}{(i+j)!}$。 因此这么定义。这里较为常用的公式是关于 $e$ 的泰勒展开。 有一个公式需要记一下: $\Sigma_{i=0}^{ \inf}\frac{x^{2i}}{(2i)!}=\frac{e^x+e^{-x}}{2}$。证明显然,展开后就是了。 一个典型的应用就是求可重集的排列数。[如题](https://vjudge.net/problem/HDU-1521),代码如下: ```cpp #include<iostream> using namespace std; int n, m; long double v[15][205], vt[205]; inline void vset(int p, int a) { for (int i = 0, j = 1; i <= a; ++i, j *= i) v[p][i] = 1.0 / j; } inline void tim(long double* l, long double* r) { for (int i = 0; i <= 100; ++i) for (int j = 0; j <= 100; ++j) vt[i + j] += l[i] * r[j]; for (int i = 0; i <= 200; ++i) l[i] = vt[i]; memset(vt, 0, sizeof vt); } signed main() { ios::sync_with_stdio(0); while (cin >> n >> m) { memset(v, 0, sizeof v); for (int i = 1, a; i <= n; ++i) cin >> a, vset(i, a); for (int i = 2; i <= n; ++i) tim(v[1], v[i]); for (int i = 2; i <= m; ++i) v[1][m] *= i; printf("%.0Lf", v[1][m]); } } ``` ### 狄利克雷生成函数 对于**无穷**数列 $A_i(i\in[1,\infin))$,其 DGF 为 $A(x)=\Sigma_{i=1}^n\frac{A_i}{i^x}$。 如果 $A$ 满足积性,那么其 DGF 也可以由质数幂处的取值表示为 $A(x)=\prod_{p\in \mathbb{P}}(1+\frac{f_p}{p^x}+\frac{f_{p^2}}{p^{2x}}+\frac{f_{p^3}}{p^{3x}}+\dots)$。 有一些常见的 DGF,可以记一下。 黎曼函数 $\zeta$ 可用序列定义为 $[1,1,1,1,\dots]$,另一种形式为 $\zeta(x)=\prod_{p\in \mathbb{P}}\frac{1}{1-p^{-x}}$。 莫比乌斯函数的 DGF 定义为 $\tilde{M}(x)=\prod_{p\in \mathbb{P}}(1-p^{-x})$。也就是说,$\zeta(x)\tilde{M}(x)=1$。 欧拉函数的 DGF 定义为 $\tilde{\Phi}(x)=\prod_{p\in \mathbb{P}}\frac{1-p^{-x}}{1-p^{1-x}}$。也就是说 $\tilde{\Phi}(x)\zeta(x)=\zeta(x-1)$。 ## 狄利克雷卷积 若 $f=h\times g$,则令 $f(x)=\Sigma_{d|x}h(d)\times g(x/d)$。 $f,g$ 积性,则 $f\times g$ 积性。 $f\times \epsilon=f \varphi\times I=id \mu\times I=\epsilon

f\times g=\epsilon,则称 f,g 互逆。

可以做到 O(n\log\log n)g=f\times Ig=f\times\mu

对于其余的卷积,其复杂度一般为 O(n\ln n),调和级数。

莫比乌斯反演

本质:\mu\times I=\epsilon

证明:\Sigma_{d|n}\mu_d=\Sigma_iC_{\omega_n}^i(-1)^i=(1-1)^{\omega_n}=[n=1]

其中的 \omega_nn 的质因子个数。

则有 $g=f\times\mu$,$g(n)=\Sigma_{d|n}f(d)\mu(n/d)$。显然,函数的逆放到这里一定成立。 主要应用就是拆 $\gcd$。套路就是去枚举 $\gcd$ 的值,然后对取等条件作卷积。 最常用的场景就是 $[p=1]=\Sigma_{d|p}\mu(d)$,尤其是 $[\gcd(a,b)=1]$ 的形式。 一种较为常见的形式是求 $\sum_{i=1}^n\sum_{j=1}^m f(\gcd(i,j))$。这时候我们构造函数 $g$,满足 $f(x)=g*I=\sum_{d|x}g(d)$。原式转化为 $\sum_{i=1}^n\sum_{j=1}^m\sum_{d|gcd(i,j)}g(d)=\sum_{d=1}g(d)\lfloor\frac{n}{d}\rfloor\lfloor\frac{m}{d}\rfloor$。 典型例题就是[这道题](https://www.luogu.com.cn/problem/P3455)。 ## 亚线性筛法 ### 杜教筛 杜教筛是个神奇的东西,对于 $\forall \gcd(i,j)=1$,满足 $f(i)\times f(j)=f(ij)$ 的函数,也就是积性函数,它可以在 $O(n^{\frac{3}{4}})$ 的时间复杂度内求出 $\sum_{i=1}^n f(i)$。通过合适的预处理可以降到 $O(n^{\frac{2}{3}})$。 具体的,对于一个积性函数 $f(x),F(x)=\sum_{i=1}^xf(x)$ 的话,我们可以知道 $g(1)F(x)=\sum_{i=1}^n(f*g)(i)-\sum_{i=2}^ng(i)F(\lfloor\frac{n}{i}\rfloor)$。 其中 $*$ 表示的是迪利克雷卷积。 于是,我们可以使用记忆化与整除分块来做上述操作。时间复杂度证明略。注意当且仅当处理到了 $n^\frac{2}{3}$ 的时候才有 $O(n^\frac{2}{3})$ 的求解复杂度。递归求解即可。 模板题[在这里](https://www.luogu.com.cn/problem/P4213)。 ### PN筛 ### min_25筛 https://www.luogu.com.cn/article/m8uwkv2f ## 第一类斯特林数 第一类斯特林数有通用标记:$[_m^n]$。他表示的组合意义是将 $n$ 个人分配到 $m$ 个**非空**环上的本质不同的方案数。其中环不作标号区分,人做区分。 那么它怎么求呢?显然我们可以整出来一些递推式。我们怎么从 $[_?^{n-1}]$ 推到 $[_?^n]$ 呢?也就是多了一个人会多产生哪些情况呢? 首先,它可以新开一桌,从 $[_{m-1}^{n-1}]$ 推到 $[_m^n]$,系数为 $1$。 其次,他可以加入原有的位置。显然,因为是圆桌,$n-1$ 个人就会有 $n-1$ 个本质不同的空隙。因此可以从 $[_m^{n-1}]$ 推到 $[_m^n]$,系数为 $(n-1)$。 从而,$[_m^n]=[_{m-1}^{n-1}]+(n-1)\times[_m^{n-1}]$。边界条件为 $[_0^0]=[_1^1]=1,[_1^x]=[_x^1]=0(x\neq1)$。 通过其组合意义,我们可以推知如下式子: 1. $[_1^n]=(n-1)!
  1. [_{n-1}^n]=(_2^n)
  2. [_2^n]=(n-1)!\sum_{i=1}^{n-1}\frac{1}{i}
  3. [_{n-2}^n]=2\times(_3^n)+3\times(_4^n)

比如有这么一个例题。思路就是将能看到的和被他挡住的视为一个环进行求解。

第二类斯特林数

第二类斯特林数也有通用标记:\{_m^n\}。他表示的组合意义是将 n 个人分配到 m非空无序集上的本质不同的方案数。其中环不作标号区分,人做区分。

那么它怎么求呢?显然我们可以整出来一些递推式。我们怎么从 \{_?^{n-1}\} 推到 \{_?^n\} 呢?也就是多了一个人会多产生哪些情况呢?

首先,它可以新开一个集合,从 \{_{m-1}^{n-1}\} 推到 \{_m^n\},系数为 1

其次,他可以加入原有的位置。显然,本质不同的就是 m 个集合。因此可以从 \{_m^{n-1}\} 推到 \{_m^n\},系数为 m

从而,\{_m^n\}=\{_{m-1}^{n-1}\}+m\times\{_m^{n-1}\}。边界条件为 \{_0^0\}=1

通过其组合意义,我们可以推知如下式子:

  1. \{_1^n\}=1
  2. \{_2^n\}=2^{n-1}-1
  3. \{_3^n\}=\frac{1}{2}(3^{n-1}+1)-2^{n-1}
  4. \{_{n-1}^n\}=(_2^n)
  5. \{_{n-2}^n\}=(_3^n)+3\times(_4^n)
  6. \{_{n-3}^n\}=(_4^n)+10\times(_5^n)+15\times(_6^n)
  7. \{_m^n\}=\frac{1}{m!}\sum_{i=1}^m(-1)^{m-i}\times i^n\times(_i^m)

这个例题就行,一眼差不多能出思路,而且不太板。

斯特林反演

按照之前的整理,其本体其实长这样:

f_n=\Sigma_{i=0}^n\{_i^n\}g_i,则 g_n=\Sigma_{i=0}^n(-1)^{n-i}[_i^n]f_i

f_n=\Sigma_{k=n}^{ \inf}\{_n^k\}g_k,则 g_n=\Sigma_{k=n}^{ \inf}(-1)^{k-n}[_n^k]f_k

不过,这次我们着重讲的是 n^mn^{\underline{m}} 之间的转化。

所以,要下降幂干什么呢?下降幂有如下两种美丽的变化:

所以,怎么变呢? $x^{\underline{n}}=\sum_{k=0}^n[_k^n](-1)^{n-k}x^k$。 $x^n=\sum_{i=0}^n\{_i^n\}n^{\underline{i}}$。 可以通过组合意义较容易地证明出下面的那个式子。或者数学归纳法也是可以的。如图: ![](https://www.helloimg.com/i/2025/03/14/67d42b265a637.png) 变换完之后可能可以和前面的一些项合并,换位等,得到一个好处理的式子。 这两天没有自己手推出来的比较好的式子,放一个[题解](https://www.luogu.com.cn/article/6ht5dwbw)做样例吧,对应[这道题](https://www.luogu.com.cn/problem/CF1278F)。 ## 二项式反演 [flag](https://www.cnblogs.com/GDOI2018/p/14491894.html)。 若 $g_n=\Sigma_{i=0}^nC_n^if_i$,则 $f_n=\Sigma_{i=0}^nC_n^i(-1)^{n-i}g_i$。 证明:错排问题。 我们让 $g_n$ 表示 $n$ 个人随便站的方案数,$f_n$ 表示恰好 $n$ 个人站错的方案数,显然满足了第一个式子,而对于第二个式子而言也是适用的。 二项式反演可以实现“至多”/“至少”和恰好之间的转化。 同理也有:若 $g_k=\Sigma_{i=k}^nC_i^kf_i$,则 $f_k=\Sigma_{i=k}^nC_i^k(-1)^{i-k}g_i$。 上述的两个式子是比较常用的两个。从本质思想来讲呢,这两个式子都是类似于容斥的形式推出来的。 也就是说,如果我能够构造出来一对合理的 $f,g$,那他一定对应着一种组合意义下的容斥方式。 所以,二项式反演提供了一种更加快速的找容斥关系的方式,而容斥因其逻辑更加底层,反而能解决更多的题。 当然这么说有点绝对。既然是一个反演,那他一般有超出其根源的用途。在解决一些计数问题的过程中,二项式反演公式对简化问题有着重要的作用。只是例题目前不太有体现。 ## burnside定理 Burnside定理:$L=\frac{1}{|G|}\sum_{g\in G}f(g)$。其中 $G$ 是所有置换所构成的集合,$f(g)$ 是置换 $g$ 对应的本质不同的集合的个数。 怎么理解呢?例题:有 $2$ 种颜色的珠子,你要用他们来构成一个有 $4$ 个珠子的手环,问有多少种本质不同的手环。这里本质不同定义为旋转或翻转后二者仍不同。 那么,其置换包括旋转 $0/90/180/270$ 度,沿对角线/对边线翻转,共 $8$ 种。$f(g)$ 是指的置换 $g$ 对应的本质不同的集合的个数。也就是求经过置换 $g$ 后仍一样的方案数。 例如: ![](https://www.helloimg.com/i/2025/03/19/67da6f0c95a79.png) 对角线上的分别属于两组,因此总共有 $2^2=4$ 种本质不同的手环。 ![](https://www.helloimg.com/i/2025/03/19/67da6f054a141.png) 总共有三组,因此共有 $2^3=8$ 种本质不同的手环。 因此,最终的答案为 $\frac{2^4+2^1+2^2+2^1+2^2+2^2+2^3+2^3}{8}=6$ 种。分子上的每一项依次对应转 $0/90/180/270$ 度,两种沿着对边翻,两种沿着对角翻。 往往需要静下心来推一下 $f(g)$,给一道[例题](https://vjudge.net/problem/HDU-3547)。 ## polya定理 Polya 定理可以视为是 Burnside定理在较简单的问题上的特例化。其式子如下: $L=\frac{1}{|G|}\sum_{g\in G}m^{c(g)}$。 肉眼可见的,这里我们认为 $f(g)=m^{c(g)}$。而如果我告诉你 $c(g)$ 表示的是置换 $g$ 的循环节数,这里的循环节是指的有向移动形成的环,那么这个公式的意义就更加明了,甚至不证自白了。 另给一道[例题](https://www.luogu.com.cn/problem/U548811)。 ## 博弈论 博弈论主要研究具有竞争或对抗性质的对象,在一定规则下产生的各种行为。博弈论考虑游戏中的个体的预测行为和实际行为,并研究它们的优化策略。 通俗地讲,博弈论主要研究的是:在一个游戏中,进行游戏的多位玩家的策略。 在 OI 中,我们也有时会遇到这些题。其中最常遇到的是公平组合游戏。参考这篇[博客](https://www.cnblogs.com/bloodstalk/p/17432761.html)。 首先,在 OI 当中,我们常研究的是平等博弈,也就是说,可允许的操作只和当前局面的状态而和操作的玩家无关。 ### 公平组合游戏 若一个游戏满足: 1. 有两名玩家交替行动; 2. 在游戏进程的任意时刻,可以执行的合法行动与轮到哪名玩家无关; 3.不能行动的玩家判负。 则称该游戏为一个公平组合游戏。 这时候有一些比较重要的概念:有向图游戏,必胜态和必败态。 给定一个有向无环图,图中有一个唯一的起点,在起点上放有一枚棋子。两名玩家交替地把这枚棋子沿着有向边进行移动,每次可以移动一步,无法移动者判负。该游戏被称为有向图游戏。 那么不难发现,任何一个公平组合游戏都可以转化为有向图游戏。游戏状态为点,状态转移为边。 必胜态:存在一种策略,可以让剩余的状态变成必败态留给对手的局面。 必败态:不管采取什么策略,这一步行动后对手都处于必胜态的局面。 简化一下,就是三个定理,这也是博弈论的核心: - 没有后继状态的状态是必败态。 - 可以移动到必败态的是必胜态。 - 只能移动到必胜态的是必败态。 这里举出若干公平组合游戏的例子。 Nim 游戏: 地上有 $n$ 堆石子,其中第 $i$ 堆有 $a_i$ 颗石子。每人每次可从任意一堆石子里取出任意多枚石子扔掉,可以取完,不能不取。每次只能从一堆里取。最后没石子可取的人就输了。问是否有先手必胜策略。 这时候,我们似乎无从下手,那让我们从有向图游戏的角度去下手,考虑图上的必胜态,必败态。 首先,有一个必败态是剩余石子全为 $0$。不难发现他们全部异或起来为 $0$。 随后我们发现了一个不得了的可能正确的结论:当且仅当 $\bigoplus_{i=1}^na_i=0$ 时先手必败。 先证一个引理:当 $\bigoplus_{i=1}^na_i\neq0$ 时,必定存在方案,将其中一个数变小之后满足 $\bigoplus_{i=1}^na_i=0$。 因为异或是二进制下的不进位加法,也就意味着异或值的最高位一定有至少一个数是 $1$。这时候我们取其中任意一个数异或上现在这个值,他的值一定变小了,而且现在异或起来的值显然为 $0$。 首先,充分性很容易证明。当先手拿掉一些石子,使其异或值变化时,我们一定存在一些方案使得其异或值变为 $0$,也就是我们的引理。 必要性呢?当 $\bigoplus_{i=1}^na_i\neq0$ 的时候我们可以构造使得 $\bigoplus_{i=1}^na_i=0$。而 $\bigoplus_{i=1}^na_i=0$ 是必败态,从而 $\bigoplus_{i=1}^na_i\neq0$ 是必胜态,也就是说必败态只可能 $\bigoplus_{i=1}^na_i=0$。 证毕。[题目](https://www.luogu.com.cn/problem/P2197)。 台阶 Nim 游戏: 有 $n$ 级台阶,第 $i$ 级上有 $a_i$ 颗石子。每人每次可从任意一堆石子里取出任意多枚石子放到下一级台阶上,可以取完,不能不取。每次只能从一堆里取。最后没石子可取的人就输了。问是否有先手必胜策略。 首先,我们可以认为偶数级台阶上的是无效的。因为先手将若干颗移动到下一及后,后手可以再将这些石子移动到下一级从而将这堆石子再次移动到偶数级,最终移到 $0$。 于是我们发现,将石子从奇数阶移动到偶数阶就是丢弃,将石子从偶数阶移动到奇数阶如果不能使奇数阶上的石子数异或值为 $0$ 就是白给。 而我们只通过将石子移下奇数阶就可以做到使奇数阶上的石子数异或值为 $0$。 从而得出结论:$\bigoplus_{i=0}^{\lceil\frac{n-1}{2}\rceil}a_{i\times2+1}\neq0$ 时先手必胜。 证毕。[题目](https://www.luogu.com.cn/problem/T407141)。 Moore's Nimk: 两个人玩取石子游戏,共有 $n$ 堆石子,每个人每次可以从不超过 $k$ 堆石子里面取任意多个石子(不能不取),不能取的人输。问先手必胜条件。 给出结论了就可以用类似于上面的方法整出来了:将每一个数写成二进制形式,则如果每一位上 $1$ 的个数都是 $k+1$ 的倍数,则先手必败,否则先手必胜。 这个有前面的铺垫,读者自证不难?就不赘述了。 还有两个可能见到的,记一下结论就行了,因为证明有点怪怪的,有点脱离了我们之前所构建的体系,所以不详讲。 Wythoff 博弈: 有两堆石子,石子数可以不同。两人轮流取石子,每次可以在一堆中取,或者从两堆中取走相同个数的石子,数量不限,取走最后一个石头的人获胜。判定先手是否必胜。 先手必败当且仅当 $(b-a)\times\frac{\sqrt5+1}{2}=a$,非严格判等,整数部分一样即可。 [证明](https://blog.csdn.net/qq_41311604/article/details/79980882)。[例题-1](https://vjudge.net/problem/HDU-1527),[例题-2](https://www.luogu.com.cn/problem/P2252)。 Fibonacci 博弈: 一堆个数为 $n$ 的石子,有两条规则: 1. 先手不能在第一次把所有的石子取完,至少取 $1$ 颗 2. 之后每次可以取的石子数至少为 $1$,至多为对手刚取的石子数的$2$倍。 约定取走最后一个石子的人是赢家,求先手必胜态。 先手必败当且仅当石子数为斐波那契数。[题目](https://vjudge.net/problem/51Nod-1070)。 ### SG 函数 这个也是相当的重要的。 首先有一个函数 $\operatorname{mex}(S)=min{x}(x\notin S,x\in \mathbb{N})$。 假如说状态 $x$ 有 $k$ 个后继状态 $y_i$,那么 $SG(x)=\operatorname{mex}({SG(y_1),SG(y_2),\dots,SG(y_k)})$。 由于一般情况下没有后继状态的可以视为必败态,因此 $SG(x)=0$ 必败,反之必胜。因为 $SG$ 与必败必胜态极好的对应关系,其正确性不证自明。 最直接的应用就是 Bash 博弈和其拓展:集合型 Nim 游戏。 Bash 博弈: 总共有 $n$ 个石子,每次每人可以取 $[1,k]$ 个石子,问先手必胜状态。 先手必败当且仅当 $n\equiv0\pmod{k+1}$。非常裸的 SG 函数,$SG(x)=x\mod(k+1)$。递推可证。[题目](https://vjudge.net/problem/51Nod-1066)。 集合型 Nim 游戏: 给定 $m$ 个整数组成的集合 $a_i$, 给定 $n$ 堆石子的数量 $b_i$, 两位玩家轮流操作,每次操作可以从任意一堆石子中拿取石子,每次拿取的石子数量必须是集合 $a$ 中的整数,最后无法操作的人失败。判定先手是否必胜。 直接暴力推 SG 函数值就行了。[题目](https://www.luogu.com.cn/problem/T407132)。 移棋子游戏: 给定一个有 $n$ 个节点的有向无环图,图中某个节点上有棋子,两名玩家交替移动棋子。对于给定的图和棋子初始位置,双方都会采取最优的行动,询问先手必胜还是先手必败。 这样的话仍旧是非常裸的 SG,那我问你,如果图上有多个棋子呢? 答案很眼熟:当且仅当每个棋子的 SG 值异或起来为 $0$ 时先手必败证明: 不难发现这个问题和丢石子的那个问题是很像的。当你在某个非零状态下时你可以丢掉任意颗。虽然即使你一颗都不剩,你也可能可以做到增加,毕竟求的是 $\operatorname{mex}$,但是这也意味着对手恒存在一些方法能给你变回来。当你不存在增加的变换的时候你的旅途也就到此为止了,只能老老实实地减。 而又因为你在某个非零状态下时你可以丢掉任意颗,所以你总能维护异或值为 $0$。 证毕。[题目](https://www.luogu.com.cn/problem/T407130)。 相关的有许多变种,比如 Anti-SG,Multi-SG,Every-SG,可以自行参阅开头的博客。 随便给几道例题:[1](https://vjudge.net/problem/51Nod-1067),[2](https://vjudge.net/problem/51Nod-1068),[3](https://www.luogu.com.cn/problem/T234805)。 ## 二次剩余 求解同余方程 $x^2\equiv n\pmod p$。你可以假定 $x$ 有解且 $p$ 为奇素数。 若方程 $x^2 \equiv n \pmod p$ 有整数解,则称 $n$ 为模 $p$ 的二次剩余,否则称 $n$ 为模 $p$ 的二次非剩余。 先给出结论:寻找一个整数 $a$ 满足 $q=a^2-n$ 为模 $p$ 的非二次剩余,并定义 $i^2\equiv q\pmod p$。答案就是 $(a+i)^{\frac{p+1}{2}}$。 额,后面会证明出来 $(a+i)^{\frac{p+1}{2}}$ 没有“虚部”。 ### 二次剩余判定定理 $a^{\frac{p-1}{2}} \equiv 1 \pmod p$ 是 $a$ 为模 $p$ 的二次剩余的充要条件。 必要性显然,充分性: 令 $a=g^k$ ,其中 $g$ 为模 $p$ 意义下的原根。 将 $a=g^k$ 带入,得 $g^{\frac{k(p-1)}{2}} \equiv 1 \pmod p$,所以 $\varphi(p) \mid \frac{k(p-1)}{2}$,所以 $1|\frac{k}{2}$,即 $k$ 是偶数。 所以,$g^{\frac{k}{2}}$ 为原方程的解,即 $a$ 为模 $p$ 的二次剩余。证毕。 同时我们也可以发现,因为 $a^{p-1}\equiv1\pmod p$,所以 $a^{\frac{p-1}{2}}\equiv\pm1\pmod p$,也就是说,$a^{\frac{p-1}{2}}\equiv-1\pmod p$ 是 $a$ 为模 $p$ 的二次非剩余的充要条件。 ### 他至多有 $2$ 个不同的解 假定 $x_1,x_2$ 是其中任意两个解,则 $x_1^2-x_2^2\equiv 0\pmod p$,则 $(x_1+x_2)(x_1-x_2)\equiv 0\pmod p$。因此 $x_1=x_2$ 或者 $x_1+x_2=p$。因此最多只有两个不同的解。 以上两个定理保证了可解性。 接下来,我们推 $(a+i)^{p+1}$,看看是什么东西。 $$ \begin{aligned} &(a+i)^{p+1}\\ \equiv&(a+i)^p\times(a+i)\\ \equiv&(a^p+i^p)\times(a+i)+(\sum_{j=1}^{p-1}a^ji^{(p-j)}C_p^j)\\ \equiv&(a^p+i^p)\times(a+i)+(\sum_{j=1}^{p-1}a^ji^{(p-j)}\frac{p!}{j!(p-j)!})\\ \equiv&(a^p+i^p)\times(a+i)+p\times(\sum_{j=1}^{p-1}a^ji^{(p-j)}\frac{(p-1)!}{j!(p-j)!})\\ \equiv&(a^p+i^p)\times(a+i)\\ \equiv&(a+i^{p-1}\times i)\times(a+i)\\ \equiv&(a+q^\frac{p-1}{2}\times i)\times(a+i)\\ \equiv&(a-i)\times(a+i)\\ \equiv&a^2-i^2\\ \equiv&n\pmod p \end{aligned} $$ 又因为存在性定理,显然当存在解时会分别得出两个根,或者干脆就是 $0$。因此 $(a+i)^{\frac{p+1}{2}}$ 没有“虚部”。 你问我怎么找合适的 $i$ 的话,其实不难发现二次剩余和二次非剩余正好一半一半,随机化的话期望 $2$ 次就能找到。相信自己的运气不会差到抽中 $\frac{1}{2^{(10^8)}}$。 ## 线性基 线性基,其概念可以视为向量的基底。 也就是说,假设给定元素集 $P$,你可以构造出来另一个集合 $S$,使得 $P$ 中所有元素都可以用 $S$ 中的元素表示,而 $S$ 中任意一个元素都没法只使用 $S$ 的其他元素表示出来。 不管怎么说,由于任何意义下的向量的美妙的性质,我们一定要保证 $S$ 中的每一个元素的第一个非零位置不重。 而这个东西在信息中,应该能感觉到是生来就有用的。毕竟他给出了一大坨信息的一种高效压缩方式。 事实上的确如此。在信息中,我们更多研究的是异或线性基。也就是说,我们只用 $S$ 中元素进行异或操作。 [这个博客](https://www.cnblogs.com/bianchengmao/p/16713133.html)就挺好。另外,由于贪心能支持的面超过了高斯消元,且可以在 $\log^2V$ 的复杂度内转化,所以不整理高斯消元。 ### 异或线性基 贪心怎么贪呢?首先从高位向低位扫,当这个数这一位是一时,如果线性基中没有元素最高位是当前位,那就把它放到线性基中,否则把他异或上线性基中的那个元素,继续进行。 接下来,我们可以应用一下,比如我们现在要求出来一堆数的最大异或值。 显然,我们从大到小去枚举,如果当前这一位为 $0$ 并且这一位有线性基,那就异或一下,否则不动。代码如下: ```cpp class liner_base { int v[52]; public: inline void insert(int a) { for (int i = 50;a;i--) if (a >> i & 1) if (v[i]) a ^= v[i]; else return void(v[i] = a); } inline int solve() { int ret = 0; for (int i = 50;i >= 0;i--) if (!(ret >> i & 1)) ret ^= v[i]; return ret; } }lb; ``` 但是这样还没完。如果很不巧他让你求 $k$ 小异或值,那你线性基不就炸了吗? 不好意思,没炸。仔细想一下,会导致大量策略出问题的地方就在于判断这一位为 $0$ 并且这一位有线性基。如果这一位有线性基就可以毫无顾虑地异或是不是就很简单了? 欸?如果我们强制保证是最高位的那些位仅在那一位的线性基上是 $1$ 不就做完了? 所以我们只需要对每个最高位的线性基进行检查,具体的如果比它更小的线性基在这一位有值就异或上他。从小到大进行检查即可。 或者,你用小线性基去消大线性基也是一个道理。当然这还没有结束,我们还需要求 $k$ 小异或值。 解除了刚才的制约,我们真的不难想到,当你选择了更大的线性基的时候,小的线性基也无力回天。 大的严格压制小的,且不影响小的,那就和线段树上二分等等的没区别了。更加明显的,每一次选与不选恰是 $k$ 的二进制位! 唯一需要注意的就是有没有 $0$ 了。代码如下: ```cpp struct liner_base { int v[62], cnt, rv[62]; bool zer; inline void insert(int a) { \\... } inline void init(int n) { cnt = 0; for (int i = 60;i >= 0;--i) for (int j = i + 1;j <= 60;++j) if (v[j] >> i & 1) v[j] ^= v[i]; for (int i = 0;i <= 60;++i) if (v[i]) rv[cnt++] = v[i]; if (cnt != n) zer = 1; else zer = 0; } inline int calc(int k) { if (zer) k--; if (k >= (1ll << cnt)) return -1; int ret = 0; for (int i = cnt;i >= 0;i--) if (k >> i & 1) ret ^= rv[i]; return ret; } inline void res() { memset(v, 0, sizeof v); } }lb; ``` ### 前缀线性基 这就很有生活了。他可以求解出一个数组在区间内的最大异或值! 怎么做到的呢?一句话:我只要最新版本。在下标 $i$ 时,先将 $i-1$ 位置的线性基拷贝过来,然后尝试加上 $i$ 位置的数。如果遇到空位就直接填进去。 如果这一位是 $1$ 且此位有线性基,那就比较二者的最后更新时间(初始状态下当前持有值的最后更新时间为 $i$),如果持有值更新,就顶替原有值,并将手上拿的改为原有值。 做完了操作之后再异或一下手上的值(和线性基值),直到手上的值为 $0$ 或者填上坑了。 代码如下: ```cpp inline void ins(int k) { c++; int np = c; memcpy(p[c], p[c - 1], sizeof p[c]); memcpy(v[c], v[c - 1], sizeof v[c]); for (int i = 20;i >= 0;i--) if (k >> i & 1) if (v[c][i]) { if (p[c][i] < np) swap(p[c][i], np), swap(v[c][i], k); k ^= v[c][i]; } else { v[c][i] = k; p[c][i] = np; return; } } ``` 求 $[l,r]$ 的时候,第 $i$ 位的线性基有效当且仅当这一位的最后更新时间在 $l$ 及以后。 ### 数论线性基 这个名称可能不严谨,但是确实能让我们知道他和异或线性基的区别。数论线性基可以理解为一堆向量的大小最小的基底。 这甚至更简单。就是拿着贪心的思路暴力模拟就完事了。 # 字符串 ## 字符串哈希 为什么这个东西放在了最前面呢?道理很简单。这个东西很简单,但是能替代部分需要用一些略显复杂的算法才能维护的东西。 假定我们现在有一个字符串 $s$,下标从 $1$ 到 $n$。那么 $l$ 位置对应的哈希值 $h_l$ 为 $\Sigma_{i=1}^lp^{l-i}\times v(s_i)\bmod mod$。 其中,$p$ 为一个选定的至少比 $s$ 的字符集大的质数(常用 $31,37,103,10003,\dots$),$v(s_i)$ 是对 $s$ 字符集到数字的唯一对应,$mod$ 是一个指定的模数。有时用 `unsigned long long` 的自然溢出,即 $mod=2^{64}$,但是容易被卡,所以 $mod$ 也常用一些大质数(常用 $998244353,10^9+7,\dots$)。 也有递推的形式,更加常见:$h_l=p\times h_{l-1}+v(s_l)$。 那么具体怎么用呢?哈希最常用的用处就是判断两个量相不相等。用哈希来比较两个完整串显然大材小用,一般他会用来比较多个字串是否相等。 对于 $l$ 到 $r$ 的字串,其哈希值为 $h_r-h_{l-1}\times p^{r-l+1}$。 显然,对于任意的字符串,只要你选取的 $v$ 映射方式和 $mod,p$ 数一致,那么对于任意的相同子串的哈希值也是一样的。下证: 看回 $h$ 的直接计算形式,并带回哈希值计算方法,发现其值为 $(\Sigma_{i=1}^rp^{r-i}\times v(s_i)\bmod mod)-(\Sigma_{i=1}^{l-1}p^{l-1-i}\times v(s_i)\bmod mod)\times p^{r-l+1}=\Sigma_{i=l}^rp^{r-i}\times v(s_i)$。我们令这个子串为 $s^\prime$,不难发现,对 $s$ 整个串做哈希,其值为 $\Sigma_{i=1}^{r-l+1}p^{r-l+1-i}\times v(s^\prime_i)$。又因为 $s^\prime_i=s_{i+l-1}$,所以 $\Sigma_{i=1}^{r-l+1}p^{r-l+1-i}\times v(s^\prime_i)=\Sigma_{i=l}^rp^{r-i}\times v(s_i)$。 也就是说,用一个字符串 $s$ 去截取子串所得到的哈希值,等于这个子串本身的哈希值。那么任意一个串中去截取同样的一段得到的哈希值也必然是一样的。 于是,恭喜你,得到了一种快速比较两个子串是否一样的方法。其作用不用我说也是相当大的。 ## kmp 主要是最长 $\text{border}$。一般记一个串在 $i$ 位置的最长 $\text{border}$ 为 $\pi(i)$。 最长 $\text{border}$ 指以位置 $i$ 结束且是字符串前缀的子串的最大长度。$z(i)$ 表示以位置 $i$ 开始且是字符串前缀的子串的最大长度。特别约定 $z(1)=0$。 那 KMP 怎么玩 $\text{border}$ 呢? 暴力求解可以被形如 $aaaaaa\dots$ 的串卡到 $n^3$,我们需要一些性质来优化。 假设有一个串,我们来求他的 $\text{border}$,并且已经求出了前 $k$ 位的值。 在考虑 $k+1$ 位置的时候,我们不难发现,$\pi(k+1)\le\pi(k)+1$。这一点可以使用 $\text{border}$ 的性质使用反证法非常方便的证明出来,不作赘述。 但是只是用这一点的话并不能做出什么特别有效的优化,毕竟往回跳的时候会需要 $O(n)$ 的验证,整体是 $O(n^2)$ 的。 再接着思考一下,当 $\pi(k+1)=\pi(k)+1$ 的时候,$s_{\pi(k)+1}=s_{k+1}$。当 $\pi(k+1)\neq\pi(k)+1$ 时,我们则希望找出一个仍满足 $\pi$ 的性质,且长度最长的一个前缀兼后缀字串。这至少可以告诉我们,这两个字符串除最后一个字符以外的字符一定一模一样。 仔细想一下,因为现在我们有每一个前缀字串的最长公共前后缀,对于一个最长公共前后缀的最长公共前后缀,他也一定仍然是原前缀子串的公共前后缀。也就是说,在长度尽可能长的情况下,$\pi(\pi(k))$ 会是我们第二个考虑的可能的字符串。如果仍然不对,我们就一直考虑 $\pi(\pi(\dots(\pi(k))\dots))$,直到上述值为 $0$(即不存在符合要求的公共前后缀)或找到一个符合要求的值,使得 $s_{\pi(\pi(\dots(\pi(k))\dots))+1}=s_{k+1}$。 这样,因为每一次 $\pi(k)$ 都至多增大 $1$,而且后退的次数一定不大于前进次数,因此复杂度为 $O(n)$。 其实说起来复杂做起来简单。核心代码大概就是 $5$ 行的样子。那这个 $\text{border}$ 有什么用呢? 很简单,字符串匹配。对于一个主串 $s_1$ 和一个模式串 $s_2$,它可以用 $O(|s_1|+|s_2|)$ 的时间求解出来 $s_2$ 在 $s_1$ 中出现了多少次。 我们回忆一下暴力匹配的过程:从头匹配,遇到不符合就匹配下一个位置,直到匹配完匹配串。 再思考一下最长 $\text{border}$ 的性质,不难发现,如果这一个位置匹配不上,那么他的最长 $\text{border}$ 位置是最长的仍与前面的字符匹配的前缀子串。也就是说,我们没必要从头开始匹配,而是变相的从这个位置继续,改变已匹配的位置之后接着匹配。 这样做显然不会漏解,要是漏了,就与最长 $\text{border}$ 的性质相违背。 对于[【模板】KMP ](https://www.luogu.com.cn/problem/P3375)而言,他就是问你 $s_2$ 的 $\pi(i)$ 函数值和 $s_2$ 在 $s_1$ 出现的位置。可解。 ## exkmp 主要是 $\text{LCP}$ 函数。一般记一个串在 $i$ 位置的 $\text{LCP}$ 为 $z(i)$。那 EXKMP 和 $\text{LCP}$ 是什么呢? ###### 这里有你没写完的东西,杂鱼... ## 后缀自动机 SAM 虽然叫做后缀自动机,但实际上他对于所有子串的优秀的压缩效果及其用途才是我们更为关心的。直观上,字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式。实质上,字符串 $s$ 的 SAM 是一个接受 $s$ 的所有后缀的最小 DFA。 首先,SAM 本身包括这么些东西: 1. 点(状态),其实就是为了便于边的维护。 2. 边(转移),边带权,带的是字符集中的某种字符。 3. 终止状态,如果我们从初始状态 $t$ 出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是原串的一个后缀。原串的每个后缀均可用一条从 $t$ 到某个终止状态的路径构成。 仅仅考虑这些的话,SAM 就是一个 DAG。 SAM 最重要的性质是,它包含关于原串的所有子串的信息。任意从初始状态 $t$ 开始的路径,如果我们将转移路径上的权写下来,都会形成原串的一个子串。反之每个原串的子串对应从 $t$ 开始的某条路径。 到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径。 看起来好像确实非常有用,但是问题在于我们怎么用一个高效的方式给他构造出来呢?实际上,我们可以做到线性时空复杂度。我们需要两个非常重要的东西:结束位置(`endpos`)和后缀链接(`link`)。 ### 结束位置(`endpos`) 考虑字符串 $s$ 的任意非空子串 $t$,我们记 $\operatorname{endpos}(t)$ 为在字符串 $s$ 中 $t$ 的所有结束位置(假设对字符串中字符的编号从零开始)。例如,对于字符串 $\texttt{abcbc}$,我们有 $\operatorname{endpos}(\texttt{bc})=2,4$。 两个子串 $t_1$ 与 $t_2$ 的 $\operatorname{endpos}$ 集合可能相等:$\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)$。这样所有字符串 $s$ 的非空子串都可以根据它们的 $\operatorname{endpos}$ 集合被分为若干等价类。 显然,SAM 中的每个状态对应一个或多个 $\operatorname{endpos}$ 相同的子串。换句话说,SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。SAM 的状态个数等价于 $\operatorname{endpos}$ 相同的一个或多个子串所组成的集合的个数 $+1$。 `endpos` 有若干定理性质,$\texttt{oi-wiki}$ 中写了三条,但其中 $1,3$ 条均可由第 $2$ 条得到: 若 $u,v\in s,\,u,v\neq\empty,|u|\le|v|$,则: $$ \operatorname{endpos}(u)\cap\operatorname{endpos}(v)\begin{cases} \operatorname{endpos}(v) &\text{u is a suffix of v}\\ \emptyset &\text{otherwise} \end{cases} $$ 正确性显然。 ### 后缀链接(`link`) 考虑 SAM 中某个不是 $t$ 的状态 $v$。我们已经知道状态 $v$ 对应于具有相同 $\operatorname{endpos}$ 的等价类。我们如果定义 $w$ 为这些字符串中最长的一个,则所有其它的字符串都是 $w$ 的后缀。 我们还知道字符串 $w$ 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀(至少有一个——空后缀)在其它的等价类中。我们记 $p$ 为最长的这样的后缀,然后将 $v$ 的后缀链接连到 $p$ 上。 换句话说,一个后缀链接 $\operatorname{link}(v)$ 连接到对应于 $w$ 的最长后缀的另一个 $\operatorname{endpos}$ 等价类的状态。 与之相关的也有两个性质定理: 所有后缀链接构成一棵根节点为 $t$ 的树。 通过 $\operatorname{endpos}$ 集合构造的树(每个子节点的 $\texttt{subset}$ 都包含在父节点的 $\texttt{subset}$ 中)与通过后缀链接 $\operatorname{link}$ 构造的树相同。 这两个性质放一起构成了 SAM 构造中最重要的理论依据。现在我们终于可以讲构造过程了。这个算法是在线算法,我们可以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM。任务转化为实现给当前字符串添加一个字符 $c$ 的过程。一开始 SAM 只包含一个状态 $t$,编号为 $0$(其它状态的编号为 $1,2,\dots$)。为了方便,对于状态 $t$ 我们指定 $\operatorname{len}=0,\operatorname{link}=-1$($-1$ 表示虚拟状态)。算法流程如下: 1. 令 $\texttt{last}$ 为添加字符 $c$ 之前,整个字符串对应的状态(一开始我们设 $\texttt{last}=0$,算法的最后一步更新 $\texttt{last}$)。 2. 创建一个新的状态 $\texttt{cur}$,并将 $\operatorname{len}(\texttt{cur})$ 赋值为 $\operatorname{len}(\texttt{last})+1$,在这时 $\operatorname{link}(\texttt{cur})$ 的值还未知。 3. 现在我们按以下流程进行(从状态 $\texttt{last}$ 开始)。如果还没有到字符 $c$ 的转移,我们就添加一个到状态 $\texttt{cur}$ 的转移,遍历后缀链接。如果在某个点已经存在到字符 $c$ 的转移,我们就停下来,并将这个状态标记为 $p$。 4. 如果没有找到这样的状态 $p$,我们就到达了虚拟状态 $-1$,我们将 $\operatorname{link}(\texttt{cur})$ 赋值为 $0$ 并退出。 5. 假设现在我们找到了一个状态 $p$,其可以通过字符 $c$ 转移。我们将转移到的状态标记为 $q$。 6. 现在我们分类讨论两种状态,要么 $\operatorname{len}(p) + 1 = \operatorname{len}(q)$,要么不是。 7. 如果 $\operatorname{len}(p)+1=\operatorname{len}(q)$,我们只要将 $\operatorname{link}(\texttt{cur})$ 赋值为 q 并退出。 8. 否则就会有些复杂。需要复制状态 $q$:我们创建一个新的状态 $\texttt{clone}$,复制 $q$ 的除了 $\operatorname{len}$ 的值以外的所有信息(后缀链接和转移)。我们将 $\operatorname{len}(\texttt{clone})$ 赋值为 $\operatorname{len}(p)+1$。 9. 复制之后,我们将后缀链接从 $\texttt{cur}$ 指向 $\texttt{clone}$,也从 $q$ 指向 $\texttt{clone}$。 10. 最终我们需要使用后缀链接从状态 $p$ 往回走,只要存在一条通过 $p$ 到状态 $q$ 的转移,就将该转移重定向到状态 $\texttt{clone}$。 11. 以上三种情况,在完成这个过程之后,我们将 $\texttt{last}$ 的值更新为状态 $\texttt{cur}$。 算法本体讲解完毕,模板题代码: ```cpp #include<bits/stdc++.h> using namespace std; constexpr int siz = 2e6 + 5; long long ans; char c[siz >> 1]; vector<int>son[siz]; struct suf_atm { int sn[siz][26], lk[siz], ln[siz], cnt = 1, lp = 1, sz[siz]; inline void add(char c, int pv) { int p = lp; sz[lp = ++cnt] = 1; ln[lp] = pv; while (p && !sn[p][c]) sn[p][c] = lp, p = lk[p]; if (!p) return void(lk[lp] = 1); int np = sn[p][c]; if (ln[p] + 1 == ln[np]) return void(lk[lp] = np); ln[++cnt] = ln[p] + 1; lk[cnt] = lk[np]; lk[np] = lk[lp] = cnt; memcpy(sn[cnt], sn[np], sizeof sn[np]); for (int i = p; sn[i][c] == np; i = lk[i]) sn[i][c] = cnt; } inline void pre() { for (int i = 2; i <= cnt; ++i) son[lk[i]].emplace_back(i); } inline void dfs(int p) { for (int sp : son[p]) dfs(sp), sz[p] += sz[sp]; if (sz[p] != 1) ans = max(ans, sz[p] * 1ll * ln[p]); } }sam;//没有按讲的来写,p=0就是虚拟状态,t=1作为起始点 signed main() { ios::sync_with_stdio(0); cin >> (c + 1); for (int i = 1; c[i]; ++i) sam.add(c[i] - 'a', i); sam.pre(); sam.dfs(1); cout << ans << endl; } ``` 看看这个:[yutong.site/sam/](yutong.site/sam/)。 那他的应用呢?挺多的,列例题讲解吧。 [T0,【模板】后缀自动机(SAM),$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/P3804),板子题。 [T1-1,SUBLEX,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/SP7258)&&[T1-2,[TJOI2015] 弦论,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/P3975),SAM 求字典序第 $k$ 大子串。 HINT:字典序第 $k$ 大的子串对应于 SAM 中字典序第 $k$ 大的路径,因此在计算每个状态的路径数后,我们可以很容易地从 SAM 的根开始找到第 $k$ 大的路径。 [T2,【模板】最小表示法,$\color{blue}提高+/省选-$](https://www.luogu.com.cn/problem/P1368),SAM 求最小表示法。 HINT:容易发现字符串 $S+S$ 包含字符串 $S$ 的所有循环移位作为子串。所以问题简化为在 $S+S$ 对应的后缀自动机上寻找最小的长度为 $|S|$ 的路径。 Query:你还记得他的更简便的求法吗? [T3-1,LCS,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/SP1811)&&[T3-2,LONGCS,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/SP10570)&&[T3-3,LCS2,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/SP1812),SAM 求两个/多个字符串的最长公共子串。 HINT1:求两个的话,我们可以构造出一个字符串的 SAM,我们现在处理另一个字符串,对于每一个前缀,都在 SAM 中寻找这个前缀的最长后缀,求最大值即可。转移的时候,类似于 KMP 的跳 $\texttt{border}$,ACAM 的跳失配指针,我们跳后缀链接并更新长度即可。 HINT2:求多个的话,我们将所有的子串连接成一个较长的字符串 $T$,以特殊字符 $D_i$ 分开每个字符串(一个字符对应一个字符串):$T=S_1+D_1+S_2+D_2+\cdots+S_k+D_k$。然后对字符串 T 构造后缀自动机。答案就是最深的且它的子树中具有所有分节符的非叶子节点。 [T4,[SDOI2016] 生成魔咒,$\color{purple}\texttt{省选/NOI−}$](https://www.luogu.com.cn/problem/P4070),动态求本质不同子串的个数。 HINT:每次新出现的本质不同的子串个数就等于 $\large{len_{newp}-len_{link_{newp}}}$。 还有很多啊,比如出现次数,第一次出现的位置,所有出现的位置等,但是大多可以用 ACAM 吃掉。 依据[这个帖子](https://www.luogu.com/discuss/774815),SA 胜过 SAM 的地方在于 SAM 不能(或者很难)$O(1)$ 比较两个后缀的字典序(by 366338),而 SAM 中的 $\texttt{parent tree}$ 却可以用来干很多恶心人的事。不过,二者似乎可以在 $O(n|\Sigma|)$ 的时间内互相转化,因此不太存在只能用其一解的题。 Tip:有的文章会说在牺牲一点点效率的情况下可以做到严格线性的空间复杂度,带 $\log$ 或常数的时间复杂度,但是实际上,`map<int,int>` 在 gcc 下初始状态下会占 $48bit$,`unordered_map<int,int>` 更是会占到 $152bit$。前者相当于 $12$ 个 `int`,后者相当于 $38$ 个 `int`。因此,实际应用中(多为小写字母 $26$ 大小字符集),可以酌情选择前者,一定不要选择后者。 ## 后缀数组 部分进阶知识可以参见[这篇](https://github.com/OI-wiki/libs/blob/master/%E9%9B%86%E8%AE%AD%E9%98%9F%E5%8E%86%E5%B9%B4%E8%AE%BA%E6%96%87/%E5%9B%BD%E5%AE%B6%E9%9B%86%E8%AE%AD%E9%98%9F2009%E8%AE%BA%E6%96%87%E9%9B%86/11.%E7%BD%97%E7%A9%97%E9%AA%9E%E3%80%8A%E5%90%8E%E7%BC%80%E6%95%B0%E7%BB%84%E2%80%94%E2%80%94%E5%A4%84%E7%90%86%E5%AD%97%E7%AC%A6%E4%B8%B2%E7%9A%84%E6%9C%89%E5%8A%9B%E5%B7%A5%E5%85%B7%E3%80%8B/%E5%90%8E%E7%BC%80%E6%95%B0%E7%BB%84%E2%80%94%E2%80%94%E5%A4%84%E7%90%86%E5%AD%97%E7%AC%A6%E4%B8%B2%E7%9A%84%E6%9C%89%E5%8A%9B%E5%B7%A5%E5%85%B7.pdf)集训队论文。 SA,全称 $\texttt{Suffix Array}$,译为后缀数组。$sa_i$ 在所有后缀中的字典序升序排名的第 $i$ 个后缀的开始位置,表示的是$rk_i$ 表示的是后缀 $[i,n]$ 在所有后缀中的字典序升序排名。$height_i$ 表示 $\operatorname{LCP}(sa_i,sa_{i-1})$。 怎么求 $sa$ 呢?首先有一种暴力的 $O(n\log^2n)$ 的做法。我们将所有的后缀拿过来排序。这不是 $O(n^2\log n)$ 的吗?但事实上,比较两个字符串就是在比较它们第一处不一样的位置的大小。因此我们可以用 Hash+BinarySearch 优化到 $O(n\log^2n)$。 但是这样做时间复杂度仍然有些高,能不能优化到 $O(n\log n)$ 呢?可以,不过我们换一种方式。 假如说我们已经排出了这些后缀在考虑前 $2^i$ 个字符时的大小,那么怎么拓展到 $2^{i+1}$ 个字符呢?显然我们只需要对有序二元组 $(sa_p,sa_{p+2^i})$ 排序就行了。正确性显然。 但是这样不还是 $O(n\log^2 n)$ 的吗?不是。不难注意到 $sa\le n$,也就是说我们可以不基于比较进行排序。我们采用计数排序。 欸?二维的怎么计数排序?很简单啊,先把第二维升序排了再把第一维顺序排了。 那你的 $rk$ 数组怎么处理?最后处理吗?显然不是,计数排序的时候我们就需要 $rk$ 数组。但是一定要注意 $rk$ 只在两维都排完之后才会更新。毕竟两次排序本质上是一次二维排序。你中间就把排名改了能对就怪了。 这样就优化到 $O(n\log n)$ 了。还能更优吗?可以。你可以使用 [SA-IS](https://riteme.site/blog/2016-6-19/sais.html) 算法做到 $O(n)$,他要更优于 DC3。为什么?因为提高了缓存命中率(喜)。 这不重要,来看 $height$ 数组吧。首先很显然,$height$ 数组仍然可以用 Hash+BinarySearch 做到 $O(n\log n)$,但是能不能更优呢? 可以的。很难(?)注意到一个性质:$height[rk[i]]\ge height[rk[i-1]]-1$。总之,有了这个性质,我们就可以暴力模拟这个性质获得 $O(n)$ 的简短的算法。为什么是 $O(n)$ 呢?因为最多 $n$ 次减一,对应最多 $O(n)$ 次加一。 比模板题更模板的[模板题](https://www.luogu.com.cn/problem/P10469)代码如下: ```cpp #include<bits/stdc++.h> using namespace std; string s; int n, sa[1000005], rk[1000005 * 2], h[1000005]; int bc[1000005], oldrk[1000005 * 2], oldsa[1000005]; signed main() { ios::sync_with_stdio(0); cin >> s; n = s.size(); s = ' ' + s; for (int i = 1; i <= n; ++i) s[i] -= 'a' - 1; for (int i = 1; i <= n; ++i) ++bc[rk[i] = s[i]]; for (int i = 1; i <= 26; ++i) bc[i] += bc[i - 1]; for (int i = n; i >= 1; i--) sa[bc[rk[i]]--] = i; memcpy(oldrk + 1, rk + 1, sizeof(int) * n); for (int i = 1, p = 0; i <= n; ++i) rk[sa[i]] = (p += (oldrk[sa[i]] != oldrk[sa[i - 1]])); for (int w = 1; w < n; w <<= 1) { memset(bc, 0, sizeof bc); memcpy(oldsa + 1, sa + 1, sizeof(int) * n); for (int i = 1; i <= n; ++i) ++bc[rk[oldsa[i] + w]]; for (int i = 1; i <= n; ++i) bc[i] += bc[i - 1]; for (int i = n; i >= 1; i--) sa[bc[rk[oldsa[i] + w]]--] = oldsa[i]; memset(bc, 0, sizeof bc); memcpy(oldsa + 1, sa + 1, sizeof(int) * n); for (int i = 1; i <= n; ++i) ++bc[rk[oldsa[i]]]; for (int i = 1; i <= n; ++i) bc[i] += bc[i - 1]; for (int i = n; i >= 1; i--) sa[bc[rk[oldsa[i]]]--] = oldsa[i]; memcpy(oldrk + 1, rk + 1, sizeof(int) * n); for (int i = 1, p = 0; i <= n; ++i) rk[sa[i]] = (p += (oldrk[sa[i]] != oldrk[sa[i - 1]] || oldrk[sa[i] + w] != oldrk[sa[i - 1] + w])); } for (int i = 1; i <= n; ++i) cout << sa[i] - 1 << " "; cout << endl; //O(nlogn) 求 sa for (int i = 1, k = 0; i <= n; ++i) { if (k) k--; if (rk[i] == 1) continue; int j = sa[rk[i] - 1]; while (s[i + k] == s[j + k]) k++; h[rk[i]] = k; } for (int i = 1; i <= n; ++i) cout << h[i] << " "; cout << endl; //O(n) 求 height } ```