2 树
2.1 树的定义
一个只有\(N-1\)条边,且任意两个点连通的图叫做树。通过这样定义的树往往是一棵无根树,而我们通常会任意选定一个根节点使其变成有根树。有根树可以定义“父亲和儿子”的层次关系,这往往有利于构造最优子结构,进行DP和搜索等操作。
特别的,如果在树上任意加上一条边,那么整个树上就会多出一个环。我们称这样的树是“基环树”。基环树不是树,但是它只有一个环。将整个环作为一个“广义根”,然后将根和连在环上的子树分开处理,同样可以套用树的许多算法。
2.2 树上的DP算法
通常选定一个根,然后用DFS计算。至于递归接口应该放在转移之前还是之后呢?那就看方程怎么写了。在写程序的时候,只要满足“已知推未知”的原则就行。
如果给定一棵无根树,答案要求给出最优的根使得某个值最优化,这时可以采用“换根法”。先任意选定一个根计算出规划值\(F_1\),然后从数学上推导出以任意点为根的规划值\(F_2\)。《进阶指南》上有相关的例题。
2.2.1 树的参量
子树大小size
最基础的量。转移方程简记为\(F(x)=1+\sum F(\text{son}(x))\)
树的重心
和size一样。如果子树\(x\)的大小是\(\text{size}(x)\),那么剩下树的大小就是\(N-\text{size}(x)\)。在求\(\text{size}\)时可以顺带求出。
树的直径
有一种DP方程,还有一种搜索方法。
第一种方法,设\(F_1(x)\)表示点\(x\)到它的子树最长的距离。有方程:
\[
F_1(x)=\max_{y \in \text{son}(x)}\{F(y)+d(x,y)\}
\]
然后以此推导出经过\(x\)的,在\(x\)子树内的最长链。设它为\(F_2(x)\)。
根据定义,我们在子树里面找出两条过\(x\)的路径,这两条路径最长即符合要求。有方程:
\[
F_2(x)=\max_{y_1,y_2\in \text{son}(x)}\{F_1(y_1)+d(x,y_1)+F_1(y_2)+d(x,y_2)\}
\]
这两个值,一个一定是最大值,一个一定是次大值。我们根据\(F_1\)的定义,得到:
\[
F_2(x)=\max_{y_2\in \text{son}(x),y_2 \neq y_1,F_1(x)=F(y_1)+d(x,y_1)}\{F_1(x)+F_1(y_2)+d(x,y_2)\}
\]
这样方程会相当麻烦。我们换一种思路:
设\(F_1(x)\)表示\(x\)到\(x\)子树叶子的最大距离,\(G_1(x)\)表示次大距离。这样我们有两个方程:
\[
\begin{cases}
G_1(x)=F_1(x),F_1(x)=F_1(y)+d(x,y) & F_1(x) < F_1(y)+d(x,y)\\
G_1(x)=F_1(y)+d(x,y) & \text{else if }G_1(x)<F_1(y)+d(x,y)
\end{cases}\\
y \in \text{son}(x)
\]
这样\(F_2(x)=G_1(x)+F_1(x)\)就是原来所求了。枚举最大的\(F(i)\)即可求得答案。
也可以用两次BFS或DFS。先任意一个点\(root\),搜索出离\(root\)最远的点\(p_1\);然后再搜索出离\(p\)点最远的点\(p'\)。那么两点的距离\(pp'\)就是树的直径。
2.2.2 LCA
如果节点\(u\)既是\(x\)的祖先,又是\(y\)的祖先,则\(u\)是\(x,y\)的公共祖先。当这个公共祖先深度最深时,记\(u=LCA(x,y)\)是\(x,y\)的最近公共祖先。
求LCA有若干种方法:
向上标记法\(O(qN)\)
对于要求的\(LCA(x,y)\),我们先选一个节点\(x\)走到根节点,将路径上的点全部标记。然后,我们再让\(y\)同时往上走,\(y\)遇到的第一个被标记的点就是\(LCA(x,y)\)。
树上倍增法\(O(q\log N)\)
首先我们令\(F(x,i)\)表示\(x\)的\(2^i\)辈祖先,也就是\(x\)往上走\(2^i\)步得到的节点。初始时有\(F(x,0)=\text{father}(x)\),然后以\(i\)为阶段,以\(F(x,i)=F(F(x,i - 1), i - 1)\)为转移方程,就可以处理出所有的\(F(x,i)\)。
接下来我们选择一个点往上跳(设这个点为\(x\))。可以进行交换使得\(\text{deep}(x)\geq \text{deep}(y)\)。依次尝试让\(x\)向上走\(2^{\log N},\cdots,2^2,2^1,2^0\)步,使得每一步都恰好满足\(\text{deep}(x) \geq \text{deep}(y)\)。最后一步应有\(\text{deep}(x)=\text{deep}(y)\)。如果有\(x=y\),那么\(LCA(x,y)=y\)。
否则,我们让\(x\),\(y\)同时向上跳\(2^{\log N},\cdots,2^2,2^1,2^0\)步,使得每一步都有\(x \neq y\)。在最后一步的时候,一定有\(\text{father}(x)=\text{father}(y)=LCA(x,y)\)。
树上倍增法有非常广泛的应用。举个例子,有道题就需要维护树上路径的最大值,这时就可以用“树上ST表”。这个结构就是树上倍增法的体现。
树链剖分\(O(q\log N)\)
重点是用两次dfs处理出top
数组,即每条树链的顶端。每次询问时,如果\(x\)和\(y\)都在同一条链上,则\(LCA(x,y)\)就是深度较小的那个节点。否则,我们就让深度较大的节点往它的上面的链跳,即令\(x=\text{father}(\text{top}(x))\)。
实现起来代码长度较长,会有一定常数,但是在时间复杂度上面应该还是略优于倍增法。且这种方法可扩展性强,可以配套其他的操作。
LCA的Tarjan算法\(O(q+N)\)
用并查集对向上标记法的优化。
在DFS搜索一棵树时,每个节点有三个状态:UNMARKED
,TRAVERSAL
和BACKTRACKED
,分别表示“未标记”,“已遍历”,“已回溯”。
当一个节点\(x\)正处于TRAVERSAL
状态时,其沿着父亲至根节点一定有一条TRAVERSAL
链。此时对于任意一个处于BACKTRACKED
状态的节点\(y\),\(LCA(x,y)\)就是\(y\)沿着父亲路径,遇到的第一个TRAVERSAL
节点。这是向上标记法的实质。
这里对于UNMARKED
的\(y\)是不成立的,因为TRAVERSAL
链和BACKTRACKED
链的交点相当于一个不同时刻决策的分支,是第一个使得\(x\)和\(y\)分立的节点。否则,沿着\(y\)的一段路径会标记成TRAVERSAL
,而不是BACKTRACKED
。
在这里,我们用一个并查集来维护这个路径。对于一个BACKTRACKED
节点,我们定义它的支点\(top(y)\)表示节点\(y\)沿着父亲路径,向上遇到的第一个TRAVERSAL
节点。
当一个节点\(x\)由TRAVERSAL
变成BACKTRACKED
时,它的父亲一定是TRAVERSAL
的(根据遍历回溯的顺序可以得到)。这时一定有\(top(x)=top(\text{father}(x))\)。
当我们在访问\(top(y)\)时,可以顺便进行路径压缩,即\(top(y)=top(top(y))\)。这对答案是没有影响的。这一点和并查集类似,可以用并查集的get
操作完成。
总结一下,对于每一个UNMARKED
节点\(x\),我们先标记为TRAVERSAL
,并遍历它的儿子\(y\),然后令\(top(y)=x\)。随后,对于每一个和\(x\)有关的询问\(LCA(u_i,x)\),如果\(u_i\)是BACKTRACKED
的,我们可以直接由get(u[i])
得到答案。
将询问离线处理,预处理和每个节点有关的询问,然后运行这个算法。时间复杂度\(O(q+N)\),其中并查集合并的时间复杂度可以忽略不计。
unsigned short state[MAXN]; #define UNMARKED 0 #define TRAVERSAL 1 #define BACKTRACKED 2 int top[MAXN]; inline int get(int cur) { if(cur == top[cur]) return cur; return top[cur] = get(top[cur]); } inline void init() { for(rg int i = 1; i <= N; ++ i) top[i] = i; } void DFS(int cur) { state[cur] = TRAVERSAL; for(rg int e = head[cur]; e; e = edge[e].next) { int to = edge[e].to; if(state[to] != UNMARKED) continue; DFS(to); top[to] = cur; } for(rg int i = 0; i < queryNode[cur].size(); ++ i) { int node = queryNode[cur][i], rank = queryRank[cur][i]; if(state[node] != BACKTRACKED) continue; ans[rank] = get(node); } state[cur] = BACKTRACKED; }