文章目录

图论: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)\)

  1. \(d[x]\) 表示 \(x\) 的深度。不妨设 \(d[x] \ge d[y]\),否则交换 \(x, y\)
  2. \(x\) 向上调整到与 \(y\) 同一深度(从大到小枚举 \(k\),能跳就跳)。
  3. 若此时 \(x = y\),说明已经找到了 LCA,返回 \(x\)
  4. 否则,把 \(x, y\) 同时向上调整,保持深度一致且二者不相会。
  5. 最终 \(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]\) 之间的节点,包含了:

  1. \(u\) 子树中的部分节点
  2. 回溯过程中经过的一些节点
  3. \(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 序做法更优

  1. 最大优点:\(O(1)\) 处理每次查询
  2. 空间减半:DFS 序长度为 \(n\),欧拉序为 \(2n-1\),ST 表空间也相应减半
  3. 预处理更快:DFS 是线性的,而且 ST 表只有 \(n\) 个元素,预处理时间常数减半
  4. 更好写:代码非常简洁,不需要额外记录 seqpos 等数组
  5. 无需担心空间:不需要开两倍空间,对于 \(n = 5 \times 10^5\) 也毫无压力

总结:DFS 序求 LCA 是一个被低估的技巧。它代码短、常数小、空间优,在绝大多数情况下都优于欧拉序的做法。推荐掌握。


二、树的直径

给定一棵树,树中两点之间的距离定义为连接两点的路径上的边权之和。树中最远的两个节点之间的距离被称为树的直径,连接这两个点的路径被称为树的最长链

1. 两次 DFS 法

算法步骤

  1. 从任意节点 \(p\) 出发,通过 DFS 找到距离 \(p\) 最远的点 \(u\)
  2. 从点 \(u\) 出发,再次通过 DFS 找到距离 \(u\) 最远的点 \(v\)
  3. \(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\) 能够追溯到的最早的栈中节点的时间戳

并用一个栈维护当前搜索路径上的节点。

核心步骤

  1. 访问节点 \(x\) 时,记录 dfn[x] = low[x] = ++timestamp,并将 \(x\) 入栈。
  2. 遍历 \(x\) 的每条出边 \((x, y)\)
    • \(y\) 未被访问过:递归访问 \(y\),回溯时用 low[y] 更新 low[x]
    • \(y\) 已被访问过且在栈中:用 dfn[y] 更新 low[x]
  3. 遍历完所有出边后,若 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,常见的后续操作有:

  1. 拓扑排序:按拓扑序 DP。
  2. 最长路 / 最短路:DAG 上的最长路可以拓扑序 DP 求。
  3. 统计入度出度:比如求至少加多少条边使图强连通 = max(入度为0的点数, 出度为0的点数)。

技巧:用 set<pair<int,int>> 去重是一种简单的写法,但复杂度略高(\(O(m \log m)\))。如果对复杂度要求高,可以先存所有边再排序去重,或者用 unordered_set 等方法。


六、图论小 trick:时光倒流法(删边改加边)

这是一个非常经典的离线处理技巧,常用于并查集、连通性、最小生成树等问题中。

核心思想

并查集(DSU)有一个特点:加边(合并)很容易,删边(分裂)很难做

所以当题目要求你"动态删除边,并查询连通性/连通块大小"时,正着做非常困难。这时可以考虑反过来做

  1. 先读入所有操作(离线)
  2. 从最终状态开始:把最后保留下来的边全部加进并查集
  3. 倒着处理询问:把"删除边"变成"加边",把"加边"变成"删边"
  4. 最后把答案反过来输出

就像时光倒流一样——删除操作变成了添加操作,而添加操作正是并查集擅长的。

适用场景

  • 动态删边 + 查询两点连通性
  • 动态删边 + 查询连通块大小
  • 动态删边 + 最小生成树维护
  • 一切"正着做困难,倒着做简单"的问题

例题:P10238 [yLCPC2024] F. PANDORA PARADOXXX

经验总结

  1. 看到"删边"想"时光倒流":只要题目可以离线处理,优先考虑倒着做
  2. 先算最终状态:想清楚最后剩下什么,从最终状态出发
  3. 答案倒过来存:因为是倒着处理的,答案要倒过来输出
  4. 不只是并查集:这个思想很通用,凡是"删除难、添加易"的问题都可以考虑

这个技巧的本质是离线 + 逆序处理,是竞赛中非常高频的思维方式。不止图论,在数据结构、DP 中也经常用到。


部分由 AI 润色

posted @ 2026-09-02 21:58  lvwangshu  阅读(20)  评论(0)    收藏  举报