图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点
图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点
参考:《算法竞赛进阶指南》李煜东
目录
一、最近公共祖先(LCA)
给定一棵有根树,若节点 \(z\) 既是节点 \(x\) 的祖先,也是节点 \(y\) 的祖先,则称 \(z\) 是 \(x, y\) 的公共祖先。在 \(x, y\) 的所有公共祖先中,深度最大的一个称为 \(x, y\) 的最近公共祖先,记为 \(\text{LCA}(x, y)\)。
\(\text{LCA}(x, y)\) 是 \(x\) 到根的路径与 \(y\) 到根的路径的交会点,它也是 \(x\) 与 \(y\) 之间路径上深度最小的节点。
1. 树上倍增法
树上倍增法是 LCA 最经典、最实用的算法,也是竞赛中最常用的写法。
核心思想
设 \(dp[x, k]\) 表示 \(x\) 的 \(2^k\) 辈祖先,即从 \(x\) 向根节点走 \(2^k\) 步到达的节点。
- \(dp[x, 0]\) 就是 \(x\) 的父节点(\(2^0 = 1\) 辈祖先)
- 递推关系:\(dp[x, k] = dp[\ dp[x, k-1]\ ,\ k-1\ ]\)
- 即:\(x\) 的 \(2^k\) 辈祖先 = \(x\) 的 \(2^{k-1}\) 辈祖先的 \(2^{k-1}\) 辈祖先
预处理时,对树做 DFS(或 BFS),在遍历过程中计算每个节点的 \(dp\) 数组。预处理时间复杂度 \(O(n \log n)\)。
查询时,用二进制拆分的思想,把两个点"跳"到同一深度,再同时往上跳,每次跳 \(2^k\) 步。每次查询时间复杂度 \(O(\log n)\)。
算法步骤
查询 \(\text{LCA}(x, y)\):
- 设 \(d[x]\) 表示 \(x\) 的深度。不妨设 \(d[x] \ge d[y]\),否则交换 \(x, y\)。
- 把 \(x\) 向上调整到与 \(y\) 同一深度(从大到小枚举 \(k\),能跳就跳)。
- 若此时 \(x = y\),说明已经找到了 LCA,返回 \(x\)。
- 否则,把 \(x, y\) 同时向上调整,保持深度一致且二者不相会。
- 最终 \(x, y\) 只差一步就相会,它们的父节点 \(dp[x, 0]\) 就是 LCA。
代码实现
const int N = 500010;
int head[N], ver[N * 2], nxt[N * 2], tot;
int d[N]; // 节点深度
int dp[N][23]; // dp[x][k] 表示 x 的 2^k 辈祖先
int n, m, root;
// 加边(无向图)
void add(int x, int y) {
ver[++tot] = y;
nxt[tot] = head[x];
head[x] = tot;
}
// DFS 预处理
void dfs(int x, int fa) {
d[x] = d[fa] + 1;
dp[x][0] = fa;
// 预处理倍增数组
for (int i = 1; (1 << i) <= d[x]; i++)
dp[x][i] = dp[dp[x][i - 1]][i - 1];
// 遍历子节点
for (int i = head[x]; i; i = nxt[i])
if (ver[i] != fa)
dfs(ver[i], x);
}
// 查询 LCA
int LCA(int x, int y) {
// 保证 x 比 y 深
if (d[x] < d[y]) swap(x, y);
// 第一步:把 x 跳到和 y 同一深度
for (int i = 22; i >= 0; i--)
if (d[y] + (1 << i) <= d[x])
x = dp[x][i];
// 已经在同一个点上
if (x == y) return x;
// 第二步:同时往上跳,直到 LCA 的子节点
for (int i = 22; i >= 0; i--) {
if (dp[x][i] != dp[y][i]) {
x = dp[x][i];
y = dp[y][i];
}
}
return dp[x][0];
}
使用说明
// 主程序中
dfs(root, 0); // 预处理,root 为根节点编号
int ans = LCA(x, y); // 查询 x 和 y 的最近公共祖先
注意:数组第二维的大小要根据节点数确定。一般 \(2^{20} \approx 10^6\),所以开 23 足够(下标 0~22)。
2. DFS 序求 LCA(科技)(O(1) 查询)
该技巧最早由 skip2004 提出。DFS 序求 LCA 无论是在时间常数、空间常数还是好写程度上,都优于欧拉序的做法。
概念区分
先明确两个概念:
- DFS 序:对一棵树进行深度优先搜索得到的节点序列(每个节点只在进入时记录一次)
- 时间戳
dfn[x]:每个节点在 DFS 序中的位置
DFS 序的长度为 \(n\)(每个节点恰好出现一次),而欧拉序长度为 \(2n-1\)。这是 DFS 序做法的核心优势之一——空间减半。
核心定理
定理:设 \(u, v\) 是树上两点,不妨设 \(dfn[u] < dfn[v]\):
- 若 \(u\) 是 \(v\) 的祖先(即 \(v\) 在 \(u\) 的子树内),则 \(\text{LCA}(u, v) = u\)
- 否则,\(\text{LCA}(u, v)\) = DFS 序上位置在 \([dfn[u]+1, dfn[v]]\) 中深度最小的节点的父亲
证明与理解
DFS 序的基本性质:祖先先于后代遍历。即若 \(u\) 是 \(v\) 的祖先,则 \(dfn[u] < dfn[v]\)。
现在考虑 \(dfn[u] < dfn[v]\) 且 \(u\) 不是 \(v\) 的祖先的情况:
DFS 的顺序是:从根下降到 \(u\),遍历完 \(u\) 的子树后回溯,再下降到 \(v\)。因此,在 DFS 序中 \(dfn[u]\) 到 \(dfn[v]\) 之间的节点,包含了:
- \(u\) 子树中的部分节点
- 回溯过程中经过的一些节点
- \(v\) 所在路径上的节点
关键观察:考察 \(u\) 在 LCA 方向上的第一个儿子(即 LCA 的儿子中,子树包含 \(u\) 的那个)。根据 DFS 的顺序,这个儿子一定出现在 \([dfn[u]+1, dfn[v]]\) 这个区间内。
因此,在 DFS 序的 \([dfn[u]+1, dfn[v]]\) 区间中,深度最小的节点一定是 LCA 的某个儿子。它的父亲就是 LCA。
对于祖先-后代关系:如果 \(u\) 是 \(v\) 的祖先,查询 \([dfn[u], dfn[v]]\) 区间,\(u\) 本身就是深度最小的。如果我们查询的是 \([dfn[u]+1, dfn[v]]\),那深度最小的节点是 \(u\) 的某个后代,它的父亲可能不是 LCA。
不过,有一个更统一的处理方式:令查询区间为 \([dfn[u]+1, dfn[v]]\),当 \(u = v\) 时直接特判返回 \(u\)。
- 情况 1(祖先-后代):\(u\) 是 \(v\) 的祖先,区间 \([dfn[u]+1, dfn[v]]\) 中深度最小的节点是 \(u\) 的某个儿子,它的父亲是 \(u\),即 LCA ✓
- 情况 2(一般情况):区间中深度最小的节点的父亲是 LCA ✓
这样就用统一的方式处理了两种情况,唯一需要特判的是 \(u = v\)。
极简实现
基于上述结论,可以写出非常简洁的代码。ST 表中直接存储节点的父亲,比较时取 dfn 较小的节点(因为深度越小的节点,在对应位置的 dfn 也越小——其实比较的是节点对应的深度,这里用一个技巧:直接比较 dfn 大小)。
实际上更直接的做法:ST 表存储节点编号,比较时按深度比较取最小值。但下面这个模板更为经典,代码极短:
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 5;
int n, m, R, dn;
int dfn[N]; // dfn[x]:节点 x 的时间戳
int mi[19][N]; // ST 表,mi[k][i] 存节点编号
vector<int> e[N];
// 取 dfn 较小的节点
int get(int x, int y) {
return dfn[x] < dfn[y] ? x : y;
}
// DFS 求 dfn 和 mi[0](ST 表第一层)
void dfs(int id, int f) {
mi[0][dfn[id] = ++dn] = f; // 记录时间戳,ST 表底层存父亲
for (int it : e[id])
if (it != f) dfs(it, id);
}
// 求 LCA
int lca(int u, int v) {
if (u == v) return u;
if ((u = dfn[u]) > (v = dfn[v])) swap(u, v);
// 查询区间 [u+1, v] 的最小值对应的节点
int d = __lg(v - u++);
return get(mi[d][u], mi[d][v - (1 << d) + 1]);
}
int main() {
scanf("%d %d %d", &n, &m, &R);
for (int i = 2, u, v; i <= n; i++) {
scanf("%d %d", &u, &v);
e[u].push_back(v), e[v].push_back(u);
}
dfs(R, 0);
// 预处理 ST 表
for (int i = 1; i <= __lg(n); i++)
for (int j = 1; j + (1 << i) - 1 <= n; j++)
mi[i][j] = get(mi[i-1][j], mi[i-1][j + (1 << i-1)]);
// 处理询问
for (int i = 1, u, v; i <= m; i++) {
scanf("%d %d", &u, &v);
printf("%d\n", lca(u, v));
}
return 0;
}
注:上面的
get函数比较的是 dfn 而不是深度。为什么 dfn 小等价于深度小?因为在 ST 表的每一层中,我们存储的是"该区间内对应答案的节点",而根据之前的结论,答案是深度最小的节点。用 dfn 比较是一个等价的技巧——具体来说,ST 表中维护的是区间内「作为答案更优」的节点,而比较 dfn 恰好能选出正确的那个。如果你想更直观,也可以用dep[x] < dep[y]来比较,效果是一样的。
复杂度分析
| 操作 | 复杂度 |
|---|---|
| DFS 求 dfn | \(O(n)\) |
| 建立 ST 表 | \(O(n \log n)\) |
| 单次查询 | \(O(1)\) |
各种 LCA 算法对比
| 算法 | 预处理 | 查询 | 空间 | 好写程度 | 备注 |
|---|---|---|---|---|---|
| 倍增法 | \(O(n \log n)\) | \(O(\log n)\) | \(O(n \log n)\) | ★★★★☆ | 最常用,代码短 |
| DFS 序 + ST 表 | \(O(n \log n)\) | \(O(1)\) | \(O(n \log n)\) | ★★★★★ | 推荐,常数小、空间优 |
| 欧拉序 + ST 表 | \(O(n \log n)\) | \(O(1)\) | \(O(2n \log n)\) | ★★★☆☆ | 空间大、预处理慢 |
| 树剖 | \(O(n)\) | \(O(\log n)\) | \(O(n)\) | ★★☆☆☆ | 需要树剖,代码长 |
为什么 DFS 序做法更优
- 最大优点:\(O(1)\) 处理每次查询
- 空间减半:DFS 序长度为 \(n\),欧拉序为 \(2n-1\),ST 表空间也相应减半
- 预处理更快:DFS 是线性的,而且 ST 表只有 \(n\) 个元素,预处理时间常数减半
- 更好写:代码非常简洁,不需要额外记录
seq、pos等数组 - 无需担心空间:不需要开两倍空间,对于 \(n = 5 \times 10^5\) 也毫无压力
总结:DFS 序求 LCA 是一个被低估的技巧。它代码短、常数小、空间优,在绝大多数情况下都优于欧拉序的做法。推荐掌握。
二、树的直径
给定一棵树,树中两点之间的距离定义为连接两点的路径上的边权之和。树中最远的两个节点之间的距离被称为树的直径,连接这两个点的路径被称为树的最长链。
1. 两次 DFS 法
算法步骤
- 从任意节点 \(p\) 出发,通过 DFS 找到距离 \(p\) 最远的点 \(u\)。
- 从点 \(u\) 出发,再次通过 DFS 找到距离 \(u\) 最远的点 \(v\)。
- \(u\) 到 \(v\) 的路径即为树的直径,长度为 \(u\) 到 \(v\) 的距离。
正确性证明(反证法)
假设树的直径为 \(s \to t\),而从 \(p\) 出发找到的最远点是 \(u\)。
- 若 \(p\) 在直径 \(s \to t\) 上,则 \(u\) 一定是 \(s\) 或 \(t\) 之一,否则与直径定义矛盾。
- 若 \(p\) 不在直径上,设从 \(p\) 到直径的路径交直径于 \(q\),则 \(u\) 一定是 \(s\) 或 \(t\) 中离 \(q\) 更远的那个点,否则可以构造出比直径更长的路径,矛盾。
因此,从 \(u\) 出发找到的最远点 \(v\) 就是直径的另一个端点。
优缺点
- 优点:思路简单、代码短,且容易求出直径上的具体节点。
- 缺点:只能处理边权非负的树(实际上绝大多数题都是非负权)。
2. 树形 DP 法
核心思想
设 \(d[x]\) 表示从节点 \(x\) 出发,往以 \(x\) 为根的子树中走,能够到达的最远节点的距离。
对于节点 \(x\),枚举它的所有子节点 \(y\):
- 在进入子树 \(y\) 之前,\(d[x]\) 保存的是 \(x\) 到已经遍历过的子树中的最远距离
- 此时可以用 \(d[x] + \text{边权}(x,y) + d[y]\) 来更新直径(经过 \(x\) 的最长链)
- 然后用 \(d[y] + \text{边权}(x,y)\) 更新 \(d[x]\)
const int N = 100010;
int head[N], ver[N * 2], edge[N * 2], nxt[N * 2], tot;
int d[N]; // d[x] 表示从 x 出发往子树走的最远距离
int ans; // 直径长度
void dp(int x, int fa) {
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (y == fa) continue;
dp(y, x);
// 用"已遍历子树的最远距离 + 当前子树的最远距离 + 边权"更新答案
ans = max(ans, d[x] + d[y] + edge[i]);
// 更新 d[x]
d[x] = max(d[x], d[y] + edge[i]);
}
}
优缺点
- 优点:可以处理带负权边的树,扩展性强(很多树形 DP 都基于这个框架)。
- 缺点:不太方便记录直径上的具体节点。
三、树的重心
定义
对于一棵树的某个节点,如果把这个节点删除后,树会分成若干个连通块。若最大连通块的节点数最小,则称这个节点为树的重心。
换句话说,设 \(size[x]\) 表示以 \(x\) 为根的子树大小。删除节点 \(x\) 后,产生的若干个连通块的大小为:
- 每个子树的大小:\(size[y_1], size[y_2], \dots, size[y_k]\)
- 上方部分:\(n - size[x]\)
取这些值的最大值,这个最大值最小的节点就是重心。
性质
树的重心有很多优美的性质,是树形 DP 和点分治的重要基础:
性质 1:树的重心如果不唯一,则至多有两个,且这两个重心相邻。
性质 2:以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。
这是重心最重要的性质,也是点分治的理论基础——每次选重心作为分治中心,可以保证递归深度不超过 \(O(\log n)\)。
性质 3:树中所有点到某个点的距离和中,到重心的距离和是最小的。
性质 4:把两棵树通过一条边相连,新的树的重心在原来两棵树重心的连线上。
性质 5:一棵树添加或者删除一个叶子,树的重心最多只移动一条边的位置。
求法
两次 DFS 即可求出。
int siz[N], maxS[N], root, maxPart;
// 第一次 DFS:计算子树大小
void dfs1(int u, int f) {
siz[u] = 1;
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v == f || vis[v]) continue;
dfs1(v, u);
siz[u] += siz[v];
}
}
// 第二次 DFS:计算最大子树大小并找重心
void dfs2(int u, int f, int total) {
maxS[u] = total - siz[u]; // 向上的子树
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v == f || vis[v]) continue;
maxS[u] = max(maxS[u], siz[v]);
dfs2(v, u, total);
}
if (maxS[u] < maxPart) {
maxPart = maxS[u];
root = u;
}
}
// 使用示例(以 1 为根,整棵树大小 n)
maxPart = INF;
dfs1(1, 0);
dfs2(1, 0, n);
// root 即为重心
四、二分图
定义与判定
如果一张无向图的 \(N\) 个节点可以分成 \(A, B\) 两个非空集合,其中 \(A \cap B = \emptyset\),并且在同一集合内的点之间都没有边相连,则称这张无向图为二分图。
定理:一张无向图是二分图,当且仅当图中不存在奇环(长度为奇数的环)。
染色法判定
用 DFS 或 BFS 对图进行黑白染色:
- 从任意未染色的点开始,染成黑色
- 遍历它的邻居,染成白色
- 再遍历邻居的邻居,染成黑色
- 依此类推,如果发现相邻两点颜色相同,则存在奇环,不是二分图
二分图匹配
给定二分图 \(G = (V, E)\),\(A, B\) 是它的两个部。我们称一组两两没有公共端点的边集 \(M \subseteq E\) 为二分图的一组匹配,边数最多的匹配被称为最大匹配。
匈牙利算法(增广路算法)
增广路:若 \(P\) 是图中一条连通两个未匹配点的路径,并且属于 \(M\) 的边和不属于 \(M\) 的边在 \(P\) 上交替出现,则称 \(P\) 为一条增广路。
沿着增广路把匹配状态取反(匹配边变非匹配,非匹配边变匹配),匹配数就会加一。
定理:一个匹配是最大匹配,当且仅当图中不存在增广路。
匈牙利算法就是不断寻找增广路,直到找不到为止。
五、Tarjan 求强连通分量与缩点
基本概念
给定一张有向图,若对于图中任意两个节点 \(x, y\),既存在从 \(x\) 到 \(y\) 的路径,也存在从 \(y\) 到 \(x\) 的路径,则称该有向图是强连通图。
有向图的极大强连通子图被称为强连通分量(Strongly Connected Component,SCC)。
缩点:把每个强连通分量缩成一个点,原图就转化为一个有向无环图(DAG)。缩点后可以在 DAG 上做拓扑排序、DP 等操作。
Tarjan 算法原理
Tarjan 算法基于深度优先遍历,在 \(O(n + m)\) 的时间内求出有向图的所有强连通分量。
维护两个关键数组:
dfn[x]:节点 \(x\) 的时间戳(第一次被访问的顺序)low[x]:节点 \(x\) 能够追溯到的最早的栈中节点的时间戳
并用一个栈维护当前搜索路径上的节点。
核心步骤:
- 访问节点 \(x\) 时,记录
dfn[x] = low[x] = ++timestamp,并将 \(x\) 入栈。 - 遍历 \(x\) 的每条出边 \((x, y)\):
- 若 \(y\) 未被访问过:递归访问 \(y\),回溯时用
low[y]更新low[x]。 - 若 \(y\) 已被访问过且在栈中:用
dfn[y]更新low[x]。
- 若 \(y\) 未被访问过:递归访问 \(y\),回溯时用
- 遍历完所有出边后,若
dfn[x] == low[x],说明 \(x\) 是某个强连通分量的根。此时从栈顶到 \(x\) 的所有节点构成一个强连通分量。
代码实现
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
vector<int> e[N]; // 原图邻接表
vector<int> ne[N]; // 缩点后的新图邻接表
int a[N]; // 节点权值
int dfn[N], low[N]; // Tarjan:dfn 时间戳,low 追溯值
int stk[N]; // 栈,存储当前搜索路径上的节点
int S[N]; // 节点所属强连通分量编号
int sum[N]; // 每个强连通分量的权值和
bool ins[N]; // 节点是否在栈中
int T, C, top; // T:时间戳计数器, C:SCC 数量, top:栈顶指针
// Tarjan 求强连通分量
void dfs(int u) {
dfn[u] = low[u] = ++T;
stk[++top] = u;
ins[u] = true;
for (auto v : e[u]) {
if (dfn[v] == 0) {
dfs(v);
low[u] = min(low[u], low[v]);
}
else if (ins[v])
low[u] = min(low[u], dfn[v]);
}
// 如果当前节点 u 是某个强连通分量的根节点
if (dfn[u] == low[u]) {
++C;
// 弹出栈中元素,直到遇到 u
while (stk[top] != u) {
int p = stk[top--];
ins[p] = false;
S[p] = C;
sum[C] += a[p];
}
int p = stk[top--];
ins[p] = false;
S[p] = C;
sum[C] += a[p];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
e[u].push_back(v);
}
// Tarjan 求所有 SCC
for (int i = 1; i <= n; i++)
if (!dfn[i]) dfs(i);
// 缩点:建新图
set<pair<int, int>> edges; // 去重,避免重边
for (int u = 1; u <= n; u++) {
for (int v : e[u]) {
int x = S[u], y = S[v];
// 如果 u 和 v 不在同一个 SCC 中,且这条边尚未添加
if (x != y && edges.find({x, y}) == edges.end()) {
edges.insert({x, y});
ne[x].push_back(y); // 在缩点后的图中添加 x -> y
}
}
}
// 此时 ne[1..C] 就是缩点后的 DAG,可以在上面做拓扑排序、DP 等
// ...
return 0;
}
缩点与建图
缩点后的图是一个 DAG,常见的后续操作有:
- 拓扑排序:按拓扑序 DP。
- 最长路 / 最短路:DAG 上的最长路可以拓扑序 DP 求。
- 统计入度出度:比如求至少加多少条边使图强连通 = max(入度为0的点数, 出度为0的点数)。
技巧:用
set<pair<int,int>>去重是一种简单的写法,但复杂度略高(\(O(m \log m)\))。如果对复杂度要求高,可以先存所有边再排序去重,或者用unordered_set等方法。
六、图论小 trick:时光倒流法(删边改加边)
这是一个非常经典的离线处理技巧,常用于并查集、连通性、最小生成树等问题中。
核心思想
并查集(DSU)有一个特点:加边(合并)很容易,删边(分裂)很难做。
所以当题目要求你"动态删除边,并查询连通性/连通块大小"时,正着做非常困难。这时可以考虑反过来做:
- 先读入所有操作(离线)
- 从最终状态开始:把最后保留下来的边全部加进并查集
- 倒着处理询问:把"删除边"变成"加边",把"加边"变成"删边"
- 最后把答案反过来输出
就像时光倒流一样——删除操作变成了添加操作,而添加操作正是并查集擅长的。
适用场景
- 动态删边 + 查询两点连通性
- 动态删边 + 查询连通块大小
- 动态删边 + 最小生成树维护
- 一切"正着做困难,倒着做简单"的问题
例题:P10238 [yLCPC2024] F. PANDORA PARADOXXX
经验总结
- 看到"删边"想"时光倒流":只要题目可以离线处理,优先考虑倒着做
- 先算最终状态:想清楚最后剩下什么,从最终状态出发
- 答案倒过来存:因为是倒着处理的,答案要倒过来输出
- 不只是并查集:这个思想很通用,凡是"删除难、添加易"的问题都可以考虑
这个技巧的本质是离线 + 逆序处理,是竞赛中非常高频的思维方式。不止图论,在数据结构、DP 中也经常用到。
部分由 AI 润色
原创·转载请注明出处
✦
浙公网安备 33010602011771号