图论
图论那些事
存图那些事
由于vector会自带24字节的空间大小,导致了有一些毒瘤出题人喜欢卡空间。
怎么办啊???????
这时候,我们就要找东西替代了。
既然动态数组不行,那就用静态数组!
链式前向星闪耀登场!
听这名字不难想出这是用链表模拟。
标准的链式前向星一般有三个数组。
head、to和next(一般写nxt)
三个数组的作用
head : 存储每个顶点作为起点时,最后一条加入的边的编号
to : 这条边的终点
nxt : 与这条边同起点的上一条边的编号(类似于链表的 next 指针)
而加入边就很简单了。
下面 cnt 的定义为第 cnt 条边
先让 to[cnt] 指向 v (记录终点)
再让 nxt[cnt] 指向 head[u] (将当前边的下一条边指向 u 当前的头节点)
最后让 head[u] 更新为当前编号。 (更新头节点为当前边编号)
戳我看模版代码
int nxt[N], head[N], to[N], cnt;
void add_edge(int u, int v, int w) {
to[++cnt] = v; // 记录终点
nxt[cnt] = head[u]; // 将当前边的下一条边指向 u 当前的头节点
head[u] = cnt++; // 更新头节点为当前边编号,cnt 自增
}
一、 基础概念与预备知识
1. DFS 树与边分类
在图论中,从某个点出发进行深度优先搜索(DFS),经过的路径会构成一棵 DFS 树。图中的边根据 DFS 树的结构可分为以下核心类别:
- 树边 (Tree Edge):DFS 过程中实际经过并发现未访问节点的边。
- 返祖边 / 后向边 (Back Edge):指回 DFS 树中已访问过的祖先节点的边。在无向图中,存在返祖边即意味着图中有环。
- 横叉边 (Cross Edge)(仅有向图):指向分支中已访问过、但非当前节点祖先的节点的边。
- 前向边 (Forward Edge)(仅有向图):指向其子树中后代节点的非树边。
- 注:无向图的 DFS 树中不存在横叉边和前向边。
2. Tarjan 算法的核心机制:dfn 与 low
Tarjan 算法的核心在于为每个节点维护两个关键的时间戳:
dfn[u](时间戳):节点 \(u\) 在 DFS 过程中被访问到的绝对次序号(从 1 开始递增)。low[u](追溯值):从节点 \(u\) 出发,经过 DFS 树的子树 以及 至多一条返祖边(或有效横叉边) 所能到达的节点的最小dfn值。
二、 有向图 Tarjan 算法:强连通分量 (SCC)
1. 定义
- 强连通图:在有向图中,若任意两个不同的点 \(A, B\) 之间都双向可达(即存在 \(A \to B\) 且存在 \(B \to A\) 的路径),则该图为强连通图。
- 强连通分量 (SCC):有向图中的极大强连通子图。
2. 核心算法逻辑
使用一个栈 stk 来维护当前路径上及可能构成 SCC 的节点:
-
当访问到节点 \(u\) 时,令
dfn[u] = low[u] = ++timer,并将 \(u\) 入栈。 -
遍历 \(u\) 的所有出边 \((u, v)\):
-
若 \(v\) 未访问,则递归调用
tarjan(v),之后更新low[u] = min(low[u], low[v])。 -
若 \(v\) 已访问且仍在栈中,更新
low[u] = min(low[u], dfn[v])。 -
判定与退栈:当遍历完所有出边后,若发现
dfn[u] == low[u],说明 \(u\) 是该 SCC 的“根”。从栈顶不断弹出节点直到弹出 \(u\) 为止,弹出的节点归属于同一个 SCC。
3. 应用:缩点 (Tarjan -> DAG)
将每一个 SCC 缩为一个虚拟的“点”,只保留 SCC 之间的边,重建后的新图必然是一个有向无环图 (DAG)。在 DAG 上可以高效地执行拓扑排序、记忆化搜索与动态规划(DP)。
三、 无向图 Tarjan 算法:割点、桥与双连通分量
1. 割点与点双连通分量 (v-BCC)
-
割点定义:无向连通图中,删去某个顶点 \(u\) 及相关边后,原图分裂为两个及以上不相连的子图。
-
判定法则:
-
非根节点:若存在子节点 \(v\) 满足 \(low[v] \ge dfn[u]\),则 \(u\) 为割点。
-
根节点:在 DFS 树中拥有两个及以上的独立子树分支。
-
点双连通分量 (v-BCC):没有割点的极大连通子图。一个割点可以同时属于多个不同的 v-BCC。
-
核心收集机制:回溯时若满足 \(low[v] \ge dfn[u]\),则从栈顶弹出节点直到弹出 \(v\) 为止,弹出的节点与当前节点 \(u\) 共同构成一个 v-BCC。注意:当前节点 \(u\) 绝对不能出栈,必须留着与其他分支构建新的分量。
2. 桥与边双连通分量 (e-BCC)
- 桥定义:若删去无向连通图中的某条边后,原图分裂为两个不相连的子图,该边称为桥。
- 桥判定法则:对于树边 \((u, v)\),其中 \(u\) 是 \(v\) 的父亲,若满足:
含义是 \(v\) 及其子树连 \(u\) 都回不去,切断 \((u, v)\) 后子树将直接孤立。
- 边双连通分量 (e-BCC):没有桥的极大连通子图。直观上,把图里所有的桥全部剪断,剩下的每一个孤立连通块就是一个 e-BCC。
- 核心收集机制(单次 Tarjan + 栈):
在 DFS 过程中将节点压入栈。回溯时若满足low[u] == dfn[u],说明 \(u\) 与父亲之间的边是一条桥。此时 \(u\) 及其栈中的子孙节点构成一个 e-BCC,执行出栈操作直到弹出 \(u\)。 - 缩点:遍历原图中的边 \((u, v)\),若
ecc[u] != ecc[v](属于不同边双),则在新图中为超级节点ecc[u]和ecc[v]连接一条代表“桥”的边,缩点后的结构是一棵树。
戳我看核心代码
vector<int> g[N];
int dfn[N], low[N], dfn_clock, st[N], top;
int ecc[N], ecc_cnt;
vector<vector<int> > ecc_block;
void dfs(int u, int fa) {
dfn[u] = low[u] = ++dfn_clock, st[++top] = u;
for (auto v : g[u]) {
if (v == fa) continue;
if (!dfn[v]) dfs(v, u), low[u] = min(low[u], low[v]);
else low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
vector<int> point;
ecc_cnt++;
do ecc[st[top]] = ecc_cnt, point.push_back(st[top]);
while (st[top--] != u);
ecc_block.push_back(point);
}
}
四、 圆方树
1. 核心与概念
圆方树的核心是将复杂的无向图转化为树。
- 圆点:原图中的真实节点。
- 方点:为原图中每一个找出的 v-BCC 创建的虚拟节点。
- 连边规则:每个方点与它所属的 v-BCC 内部包含的所有圆点连一条无向边。最大节点数不超过 \(2n\),空间需要开到 \(2 \times N\)。
2. 路径与必经点
在圆方树上,任意两个圆点 \(S\) 和 \(T\) 之间的简单路径是唯一的。
- 路径上经过的所有圆点,即为原图中 \(S \to T\) 的所有可能路径的必经割点。
- Luogu P3854 应用:判断删除 \(M\) 点是否让 \(S, T\) 断连,等价于判定圆点 \(M\) 是否在圆方树 \(S \to T\) 的路径上。可用 LCA 倍增预处理,通过树上距离公式 \(dist(S, M) + dist(M, T) == dist(S, T)\) 实现 \(O(1)\) 快速判定。
五、 状态维度扩展:分层图建模
1. 应用场景与痛点
在标准的图论最短路(Dijkstra/SPFA)中,状态仅由当前“到达哪个点”决定。但在实际题目中,常常附加 “有限次的特权操作机会”,例如:
- 有 \(K\) 次机会让经过的某条边权变为 0(免费机票)。
- 允许 \(K\) 次操作将走过的边权减半或改变。
- 有向图中允许最多逆行单行道 \(K\) 次。
为了在决策中维护“当前还剩几次特权”这个额外维度,需要应用分层图思想。
2. 核心构建机制
分层图采用空间换时间策略,将原图复制 \(K+1\) 份(第 \(0\) 层 到第 \(K\) 层):
- 层内边(常规移动):代表“不发动特权,正常行走”。对原图边 \((u, v, w)\),在所有层 \(i \in [0, K]\) 中连接同层边:\(u_i \to v_i\),权值为 \(w\)。
- 层间边(发动特权):代表“消耗 1 次特权机会”。从当前层跨越到下一层:\(u_i \to v_{i+1}\),权值为特权改变后的值(如免费则权值为 \(0\))。
3. 显式建图 vs 隐式建图
| 特性 | 显式建图 | 隐式建图(多维状态 Dijkstra) |
|---|---|---|
| 核心 | 算法复用。直接修改拓扑建图逻辑,后续跑标准 Dijkstra。 | 图复用。保持原图不变,在 Dijkstra 状态转移时动态管理维度。 |
| 空间复杂度 | \(O(K \cdot N + K \cdot M)\) | \(O(K \cdot N + M)\) |
| 时间复杂度 | \(O(K \cdot M \log(K \cdot N))\) | \(O(K \cdot M \log(K \cdot N))\) |
| 节点映射 | 映射公式:id = u + i * n |
二维距离数组:dist[u][used_k] |
4. 显式分层图(二维 Dijkstra)核心模板
戳我看P4568 [JLOI2011] 飞行路线代码
vector<pii> g[N];
int n, m, k;
int dis[N];
void add(int u, int v, int w) {
for (int i = 0; i <= k; i++) {
g[u + i * n].push_back({v + i * n, w});
g[v + i * n].push_back({u + i * n, w});
if (i < k) {
g[u + i * n].push_back({v + (i + 1) * n, 0});
g[v + i * n].push_back({u + (i + 1) * n, 0});
}
}
}
signed main() {
n = re, m = re, k = re;
int s = re, t = re;
for (int i = 1; i <= m; i++) {
int u = re, v = re, w = re;
add(u, v, w);
}
memset(dis, 0x3f, sizeof dis);
priority_queue<pii, vector<pii>, greater<pii> > q;
dis[s] = 0;
q.push({0, s});
while (!q.empty()) {
auto X = q.top(); q.pop();
int dist = X.first, u = X.second;
if (dist > dis[u]) continue;
for (auto XX : g[u]) {
int v = XX.first, w = XX.second;
if (dis[v] > dis[u] + w) dis[v] = dis[u] + w, q.push({dis[v], v});
}
}
int ans = dis[t];
for (int i = 0; i <= k; i++) ans = min(ans, dis[t + i * n]);
wr(ans), endl;
}
六、 综合进阶案例:分层图 + Tarjan 缩点(CF1137C)
1. 场景碰撞:周期性状态的分层拆分
当图的收益或边权随着“时间周期 \(d\)”发生循环时,单靠原图无法维护状态。
- 分层策略:将原图每个点 \(u\) 拆分为 \(d\) 个状态节点 \((u, t)\),其中 \(0 \le t < d\),代表“在时间 \(t\) 到达城市 \(u\)”。
- 跨层规则:原图存在 \(u \to v\),每走一步花费 1 单位时间,则连边:
2. 环的消除与强连通分量(SCC)判定
因为存在周期,旅行者可以在原图的环中不断“兜圈消磨时间”直到目标状态开放。
- 定理:若分层图上 \((i, t_1)\) 能单向到达 \((i, t_2)\),则因周期循环,它们必然能够互相到达。即:同一城市在分层图上的两个可达状态,天生属于同一个 SCC。
- 算法闭环:
- 按照跨层规则建立大小为 \(N \times d\) 的分层图。
- 跑 Tarjan 算法将分层图缩点为 DAG。在统计新 SCC 的点权时,需对同一个城市去重(每个城市最多贡献 1 的收益)。
- 由于本题节点及边规模可能极高,用
std::vector会严重卡空间与时间,强烈建议复用链式前向星手写建图,并配合std::bitset完成去重优化。
七、 环境与输入特判
在处理大流量图论题目读入时,必须警惕 C++ 输入流的边界效应:
- CRLF(
\r\n)跨平台死锁:
- Windows 格式下的文本换行符为
\r\n,Linux 下为\n。 - 当在 Linux 评测机下使用
getline(cin, s)整行读入含有 Windows 换行的字符串时,末尾会残留看不见的\r。若有if(s == "0") break;等退出特判,由于实际为"0\r"从而导致特判失效、程序死循环。 - 解法:在利用
getline提取数据时,务必利用s[i] >= '0' && s[i] <= '9'过滤掉所有空白和非数字前导/后置干扰。
cin.ignore()与cin >> ws的细微差异:
- 读入节点总数 \(N\) 后,若要整行读入字符串,不能盲目使用
cin.ignore()(它仅仅只吃掉一个字符,若遇到尾部多空格会失效)。 - 推荐使用
getline(cin >> ws, line),ws机制能强行“吃掉”当前位置流里连续的所有空白符(空格、回车、制表符等),直到撞见第一个真正的数据字符,能完美规避整行读取的 bug。

浙公网安备 33010602011771号