问题描述
给定一个无向图,包含 nnn 个节点和 mmm 条边。有 qqq 个查询,每个查询给出两条边 SSS 和 TTT,要求找出从边 SSS 到边 TTT 的所有路径 中必须经过的节点数量。
数据范围:
- 0<n≤100000 < n \leq 100000<n≤10000
- 0<m≤1000000 < m \leq 1000000<m≤100000
- 0<q≤100000 < q \leq 100000<q≤10000
问题建模与分析
直观理解
想象一个交通网络,每条边代表一条道路,每个查询问:从道路 SSS 到道路 TTT,无论走哪条路线都必然经过的十字路口有哪些?
关键观察
-
必经节点一定是割点:如果某个节点不是割点,那么删除它后图仍然连通,意味着存在不经过该节点的替代路径。
-
点双连通分量内部无必经点:在同一个点双连通分量内,任意两点间都有至少两条点不交的路径,因此分量内部没有必经点。
-
必经点出现在连接不同分量的路径上:必经点实际上是连接不同点双连通分量的"桥梁"。
算法框架
我们的解决方案分为四个主要步骤:
- Tarjan\texttt{Tarjan}Tarjan 算法求点双连通分量
- 构建圆方树
- 树链剖分预处理
- 查询处理
下面我们详细讲解每个步骤。
步骤一:Tarjan\texttt{Tarjan}Tarjan 算法求点双连通分量
点双连通分量定义
点双连通图:一个无向图,如果删除任意一个顶点后,图仍然保持连通。
点双连通分量:极大的点双连通子图。
Tarjan\texttt{Tarjan}Tarjan 算法原理
Tarjan\texttt{Tarjan}Tarjan 算法通过深度优先遍历(DFS\texttt{DFS}DFS)来寻找点双连通分量,核心是维护两个数组:
- dfn[u]dfn[u]dfn[u]:节点 uuu 的深度优先遍历序号(时间戳)
- low[u]low[u]low[u]:从 uuu 出发能访问到的最早的祖先节点的时间戳
算法流程
cpp
void tarjan(int u) {
dfn[u] = low[u] = ++dfsClock;
stack.push(u);
for (每个邻接边 (u, v)) {
if (边已访问) continue;
标记边为已访问;
edgeStack.push(边ID);
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
if (low[v] >= dfn[u]) {
// 发现点双分量
创建新方点;
弹出节点直到 v,连接方点;
弹出边直到当前边,记录边到方点映射;
}
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
正确性分析
当 low[v]≥dfn[u]low[v] \geq dfn[u]low[v]≥dfn[u] 时,说明从 vvv 出发无法绕过 uuu 到达 uuu 的祖先,因此 uuu 是一个割点,我们找到了一个以 uuu 为"顶部"的点双分量。
步骤二:构建圆方树
圆方树概念
圆方树是一种将无向图转化为树结构的方法:
- 圆点:原图中的节点
- 方点 :每个点双连通分量(编号从 n+1n+1n+1 开始)
- 连接规则:方点连接它包含的所有圆点
构建过程
对于每个点双分量 BBB:
- 创建一个新的方点 squaresquaresquare
- 对于 BBB 中的每个圆点 uuu:
- 添加边 (u,square)(u, square)(u,square)
- 添加边 (square,u)(square, u)(square,u)
示例
考虑以下图结构:
1---2
| / |
3---4
点双分量:
- B1={1,2,3}B_1 = \{1,2,3\}B1={1,2,3}(三角形)
- B2={2,3,4}B_2 = \{2,3,4\}B2={2,3,4}(另一个三角形)
圆方树:
1
|
B1
/ | \
1 2 3
| \
B2 \
/ | \ \
2 3 4 4
注意 :节点 222 和 333 同时属于两个点双分量,在圆方树中它们会与两个方点相连。
圆方树的性质
- 原图节点间的路径 ↔ 圆方树中对应圆点间的路径
- 必经点 ↔ 圆方树路径上的圆点
- 点双内部无必经点 ↔ 方点内部没有关键信息
步骤三:树链剖分求 LCA\texttt{LCA}LCA
为什么需要 LCA\texttt{LCA}LCA?
在圆方树中,我们需要快速计算两个方点之间路径上的圆点数量。由于树中任意两点间只有唯一简单路径,我们可以通过 LCA\texttt{LCA}LCA(最近公共祖先)来高效计算路径属性。
树链剖分概念
树链剖分将树分解为若干条链,支持高效路径查询:
- 重儿子 :节点 uuu 的所有儿子中子树大小最大的那个
- 轻儿子:除重儿子外的其他儿子
- 重链:由重儿子连接形成的链
- 轻边:连接不同重链的边
树链剖分过程
第一次 DFS\texttt{DFS}DFS:计算基本信息
cpp
void dfs1(int u, int fa) {
parent[u] = fa;
depth[u] = depth[fa] + 1;
size[u] = 1;
heavySon[u] = 0;
for (v in tree[u]) {
if (v == fa) continue;
dfs1(v, u);
size[u] += size[v];
if (size[heavySon[u]] < size[v]) {
heavySon[u] = v;
}
}
}
第二次 DFS\texttt{DFS}DFS:划分重链
cpp
void dfs2(int u, int chainTop) {
top[u] = chainTop;
if (heavySon[u]) {
dfs2(heavySon[u], chainTop); // 重儿子延续当前链
}
for (v in tree[u]) {
if (v != parent[u] && v != heavySon[u]) {
dfs2(v, v); // 轻儿子开始新链
}
}
}
LCA\texttt{LCA}LCA 查询
cpp
int lca(int u, int v) {
while (top[u] != top[v]) { // 在不同链上
if (depth[top[u]] < depth[top[v]]) swap(u, v);
u = parent[top[u]]; // 跳转到链顶的父亲
}
return depth[u] < depth[v] ? u : v; // 在同一条链上
}
复杂度分析
- 预处理 :O(n)O(n)O(n)
- 每次查询 :O(logn)O(\log n)O(logn)
步骤四:查询处理
查询公式
对于查询边 SSS 到边 TTT:
- 找到边 SSS 和 TTT 对应的方点 uuu 和 vvv
- 计算 lca=LCA(u,v)lca = \texttt{LCA}(u, v)lca=LCA(u,v)
- 计算路径上的圆点数量:
cpp
int result = roundNodeCount[u] + roundNodeCount[v]
- 2 * roundNodeCount[lca] + (lca <= n ? 1 : 0);
公式正确性证明
设 roundNodeCount[x]roundNodeCount[x]roundNodeCount[x] 表示从根节点到 xxx 路径上的圆点数量。
- roundNodeCount[u]roundNodeCount[u]roundNodeCount[u]:根到 uuu 的圆点数量
- roundNodeCount[v]roundNodeCount[v]roundNodeCount[v]:根到 vvv 的圆点数量
- roundNodeCount[lca]roundNodeCount[lca]roundNodeCount[lca]:根到 lcalcalca 的圆点数量
路径 u→vu \to vu→v 的圆点数量 = 路径 u→lcau \to lcau→lca 的圆点 + 路径 lca→vlca \to vlca→v 的圆点
由于:
- 路径 u→lcau \to lcau→lca 的圆点 = roundNodeCount[u]−roundNodeCount[lca]roundNodeCount[u] - roundNodeCount[lca]roundNodeCount[u]−roundNodeCount[lca]
- 路径 lca→vlca \to vlca→v 的圆点 = roundNodeCount[v]−roundNodeCount[lca]roundNodeCount[v] - roundNodeCount[lca]roundNodeCount[v]−roundNodeCount[lca]
- lcalcalca 本身被减了两次,需要加回一次(如果 lcalcalca 是圆点)
因此:
answer=roundNodeCount[u]+roundNodeCount[v]−2×roundNodeCount[lca]+[lca≤n] answer = roundNodeCount[u] + roundNodeCount[v] - 2 \times roundNodeCount[lca] + [lca \leq n] answer=roundNodeCount[u]+roundNodeCount[v]−2×roundNodeCount[lca]+[lca≤n]
其中 [lca≤n][lca \leq n][lca≤n] 是指示函数,当 lcalcalca 是圆点时值为 111,否则为 000。
完整算法复杂度
- Tarjan\texttt{Tarjan}Tarjan 求点双 :O(n+m)O(n + m)O(n+m)
- 构建圆方树 :O(n+m)O(n + m)O(n+m)
- 树链剖分预处理 :O(n)O(n)O(n)
- 每个查询 :O(logn)O(\log n)O(logn)
- 总复杂度 :O(n+m+qlogn)O(n + m + q \log n)O(n+m+qlogn)
总结
本问题通过以下关键步骤解决:
- 问题转化:将边到边的必经点问题转化为点双分量间的路径问题
- 图分解:使用 Tarjan 算法找到点双连通分量
- 结构转换:构建圆方树,将图结构转化为树结构
- 高效查询 :使用树链剖分支持快速的 LCA\texttt{LCA}LCA 查询和路径计算
这种解法充分利用了点双连通分量的性质和树结构的优势,将复杂的图论问题转化为高效的树上操作,是一个经典的图论问题解决思路。
参考代码
cpp
// Traffic Real Time Query System
// UVa ID: 1464
// Verdict: Accepted
// Submission Date: 2025-10-30
// UVa Run Time: 0.080s
//
// 版权所有(C)2025,邱秋。metaphysis # yeah dot net
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 20010; // 扩大范围,包含圆方树节点
const int MAXM = 200010;
int n, m, totalNodes;
int edgeToBlock[MAXM]; // 边对应的点双分量ID
struct Graph {
struct Edge {
int to, next;
} edges[MAXM * 2];
int head[MAXN], edgeCount;
void init() {
memset(head, 0, sizeof(head));
edgeCount = 1; // 从1开始,方便异或操作
}
void addEdge(int u, int v) {
edges[++edgeCount] = {v, head[u]};
head[u] = edgeCount;
}
void addUndirected(int u, int v) {
addEdge(u, v);
addEdge(v, u);
}
};
Graph originalGraph, blockCutTree;
// Tarjan相关
int dfn[MAXN], low[MAXN], dfsClock;
stack<int> nodeStack;
stack<int> edgeStack;
bool visited[MAXM * 2];
void tarjan(int u) {
dfn[u] = low[u] = ++dfsClock;
nodeStack.push(u);
for (int i = originalGraph.head[u]; i; i = originalGraph.edges[i].next) {
if (visited[i]) continue;
visited[i] = visited[i ^ 1] = true;
int edgeId = i >> 1;
edgeStack.push(edgeId);
int v = originalGraph.edges[i].to;
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
if (low[v] >= dfn[u]) {
// 找到点双分量,创建方点
int blockId = ++totalNodes;
blockCutTree.addUndirected(u, blockId);
// 弹出节点直到v
while (true) {
int x = nodeStack.top(); nodeStack.pop();
blockCutTree.addUndirected(x, blockId);
if (x == v) break;
}
// 弹出边直到当前边
while (!edgeStack.empty()) {
int e = edgeStack.top(); edgeStack.pop();
edgeToBlock[e] = blockId;
if (e == edgeId) break;
}
}
} else {
low[u] = min(low[u], dfn[v]);
}
}
}
// 树链剖分LCA
int depth[MAXN], parent[MAXN], size[MAXN], heavySon[MAXN], top[MAXN];
int roundNodeCount[MAXN];
void dfs1(int u, int fa) {
parent[u] = fa;
depth[u] = depth[fa] + 1;
size[u] = 1;
roundNodeCount[u] = roundNodeCount[fa] + (u <= n ? 1 : 0);
heavySon[u] = 0;
for (int i = blockCutTree.head[u]; i; i = blockCutTree.edges[i].next) {
int v = blockCutTree.edges[i].to;
if (v == fa) continue;
dfs1(v, u);
size[u] += size[v];
if (size[heavySon[u]] < size[v]) {
heavySon[u] = v;
}
}
}
void dfs2(int u, int chainTop) {
top[u] = chainTop;
if (!heavySon[u]) return;
dfs2(heavySon[u], chainTop);
for (int i = blockCutTree.head[u]; i; i = blockCutTree.edges[i].next) {
int v = blockCutTree.edges[i].to;
if (v != parent[u] && v != heavySon[u]) {
dfs2(v, v);
}
}
}
int lca(int u, int v) {
while (top[u] != top[v]) {
if (depth[top[u]] < depth[top[v]]) swap(u, v);
u = parent[top[u]];
}
return depth[u] < depth[v] ? u : v;
}
int query(int u, int v) {
int x = lca(u, v);
return roundNodeCount[u] + roundNodeCount[v] - 2 * roundNodeCount[x] + (x <= n ? 1 : 0);
}
int main() {
cin.tie(0), cout.tie(0), ios::sync_with_stdio(false);
while (cin >> n >> m) {
if (n == 0 && m == 0) break;
// 初始化
totalNodes = n;
originalGraph.init();
blockCutTree.init();
memset(dfn, 0, sizeof(dfn));
memset(low, 0, sizeof(low));
memset(visited, 0, sizeof(visited));
memset(edgeToBlock, 0, sizeof(edgeToBlock));
dfsClock = 0;
while (!nodeStack.empty()) nodeStack.pop();
while (!edgeStack.empty()) edgeStack.pop();
// 读入图
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
originalGraph.addUndirected(u, v);
}
// Tarjan算法求点双
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i);
// 处理栈中剩余节点
if (!nodeStack.empty()) {
int blockId = ++totalNodes;
while (!nodeStack.empty()) {
int x = nodeStack.top(); nodeStack.pop();
blockCutTree.addUndirected(x, blockId);
}
}
// 处理栈中剩余边
while (!edgeStack.empty()) {
int e = edgeStack.top(); edgeStack.pop();
if (edgeToBlock[e] == 0) {
edgeToBlock[e] = totalNodes;
}
}
}
}
// 构建树链剖分
memset(depth, 0, sizeof(depth));
memset(parent, 0, sizeof(parent));
memset(size, 0, sizeof(size));
memset(heavySon, 0, sizeof(heavySon));
memset(top, 0, sizeof(top));
memset(roundNodeCount, 0, sizeof(roundNodeCount));
for (int i = 1; i <= totalNodes; i++) {
if (depth[i] == 0) {
dfs1(i, 0);
dfs2(i, i);
}
}
int q;
cin >> q;
while (q--) {
int s, t;
cin >> s >> t;
cout << query(edgeToBlock[s], edgeToBlock[t]) << endl;
}
}
return 0;
}