图论

图论那些事

存图那些事

由于vector会自带24字节的空间大小,导致了有一些毒瘤出题人喜欢卡空间。

怎么办啊???????

这时候,我们就要找东西替代了。

既然动态数组不行,那就用静态数组!

链式前向星闪耀登场!

听这名字不难想出这是用链表模拟。

标准的链式前向星一般有三个数组。

headtonext(一般写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 算法的核心在于为每个节点维护两个关键的时间戳:

  1. dfn[u](时间戳):节点 \(u\) 在 DFS 过程中被访问到的绝对次序号(从 1 开始递增)。
  2. 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\) 的父亲,若满足:

\[low[v] > dfn[u] \]

含义是 \(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 单位时间,则连边:

\[(u, t) \to (v, (t + 1) \bmod d) \]

2. 环的消除与强连通分量(SCC)判定

因为存在周期,旅行者可以在原图的环中不断“兜圈消磨时间”直到目标状态开放。

  • 定理:若分层图上 \((i, t_1)\) 能单向到达 \((i, t_2)\),则因周期循环,它们必然能够互相到达。即:同一城市在分层图上的两个可达状态,天生属于同一个 SCC
  • 算法闭环
  1. 按照跨层规则建立大小为 \(N \times d\) 的分层图。
  2. 跑 Tarjan 算法将分层图缩点为 DAG。在统计新 SCC 的点权时,需对同一个城市去重(每个城市最多贡献 1 的收益)。
  3. 由于本题节点及边规模可能极高,用 std::vector 会严重卡空间与时间,强烈建议复用链式前向星手写建图,并配合 std::bitset 完成去重优化。

七、 环境与输入特判

在处理大流量图论题目读入时,必须警惕 C++ 输入流的边界效应:

  1. 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' 过滤掉所有空白和非数字前导/后置干扰。
  1. cin.ignore()cin >> ws 的细微差异
  • 读入节点总数 \(N\) 后,若要整行读入字符串,不能盲目使用 cin.ignore()(它仅仅只吃掉一个字符,若遇到尾部多空格会失效)。
  • 推荐使用 getline(cin >> ws, line)ws 机制能强行“吃掉”当前位置流里连续的所有空白符(空格、回车、制表符等),直到撞见第一个真正的数据字符,能完美规避整行读取的 bug。
posted @ 2026-06-05 19:36  OiLight  阅读(15)  评论(0)    收藏  举报