图论.I

前言

大部分图片取自 oi-wiki,拜谢 oi-wiki

连通性问题

Tarjan 算法主要用于求解强连通分量,边双点双等经典问题.

1.强连通分量

对于有向图 \(G = (V, E)\),我们称 \(G\) 是强连通的,仅当 \(G\) 中任意二元点对 \((x, y)\),满足 \(x\)\(y\) 之间可以互相到达.

对于任意有向图 \(G\),其不一定满足强连通,但其一定存在若干个极大子图,满足强连通性质,称为图 \(G\) 的强连通分量(\(\textsf{scc}\)).

考虑将所有强连通分量表示为一个单点,则更改后的图 \(G'\) 上无环,即 \(\textsf{DAG}\). \(\textsf{DAG}\) 的优秀性质能让我们很好的处理图上的计数与 \(\textsf{dp}\) 等问题.

1.1 有向图的 dfs 生成树

对于一般的图,考虑普通的 \(\textsf{dfs}\),节点的访问顺序我们可以简略的表示为一颗 \(\textsf{dfs}\) 树,与若干非树边的结构.

对于非树边,我们可以简单的分为以下三类:

  • 返祖边:指向当前节点的祖先.
  • 前向边:指向当前节点的子树.
  • 横叉边:从某个结点指向非祖先、非后代且已访问的结点的边.

1.2 Tarjan(强连通分量)

考虑强连通分量在 \(\textsf{dfs}\) 生成树上的性质.

考虑节点 \(u\) 是某个强连通分量在 \(\textsf{dfs}\) 过程中遇到的第一个节点,那么必然强连通分量的其余节点一定在 \(u\) 的子树内,证明见 \(\textsf{oi-wiki}\).

辅助标记:

  • \(dfn_i\):节点 \(i\)\(\textsf{dfs}\) 过程中被访问到的顺序.
  • \(low_i\):节点 \(i\) 的子树中通过返祖与横叉边能访问到的最早的节点的 \(dfn\).

由强连通分量在 \(\textsf{dfs}\) 生成树上的性质可得,若某个节点满足 \(dfn_u = low_u\),那么其一定是 \(u\) 所在强连通分量被访问到的第一个节点,此时将其子树内还未标记过的全部加入此 \(\textsf{scc}\) 即可.

Tarjan算法流程

初始化一个栈,用于储存访问过且还不属于任何一个 \(\textsf{scc}\) 的节点.

对于当前访问到的节点 \(u\),入栈,赋值 \(dfn_u\).

对于其儿子节点 \(v\)

  1. \(v\) 未被访问过,则递归访问 \(v\),并且用 \(low_v\) 更新 \(low_u\).
  2. \(v\) 访问过,且在栈中,用 \(dfn_v\) 更新 \(low_u\).
  3. \(v\) 访问过,且不在栈中,不进行任何操作.

当所有 \(v\) 都被处理过后,若当前节点 \(u\) 满足 \(dfn_u = low_u\),新建一个 \(\textsf{scc}\),将栈中 \(u\)\(u\) 以上的节点全部出栈,加入此 \(\textsf{scc}\).

Tarjan模板题

P3387【模板】缩点 / 强连通分量

对于所有强连通分量缩点,由于节点可重复经过,简单的在 \(\textsf{DAG}\)\(\textsf{dp}\) 即可.

#include<bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
int n, m, a[N];
vector<int> e[N], se[N];
int top, dfntop, st[N], dfn[N], low[N];
bool vis[N];
int scc[N], scctop, val[N];
int Ans, f[N];
void Tarjan(int p){
	dfn[p] = low[p] = ++dfntop;
	vis[p] = true, st[++top] = p;
	for(auto v : e[p]){
		if(!vis[v])Tarjan(v), low[p] = min(low[p], low[v]);
		else if(!scc[v])low[p] = min(low[p], dfn[v]);
	}
	if(dfn[p] == low[p]){
		scctop++;
		do{
			scc[st[top]] = scctop;
			val[scctop] += a[st[top]];
		}while(st[top--] != p);
	}
	return void();
}
bool work[N];
int dp(int p){
	if(work[p])return f[p];
	work[p] = true;
	for(auto v : se[p])dp(v), f[p] = max(f[p], f[v]);
	f[p] += val[p];
	Ans = max(Ans, f[p]);
	return f[p];
}
signed main(){
	cin >> n >> m;
	for(int i = 1; i <= n; i++)cin >> a[i];
	for(int i = 1; i <= m; i++){
		int u, v;
		cin >> u >> v;
		e[u].push_back(v);
	}
	for(int i = 1; i <= n; i++)if(!vis[i])Tarjan(i);
	for(int i = 1; i <= n; i++)
		for(auto v : e[i]){
			if(scc[v] == scc[i])continue;
			se[scc[i]].push_back(scc[v]);
		}
	for(int i = 1; i <= scctop; i++)dp(i);
	cout << Ans << endl;
	return 0;
} 

2.双连通分量

和强连通分量不同的是,此时的图可以为无向图,方便讨论,求解双连通分量时默认图为无向图.

对于图 \(G = (V, E)\),我们称它是边双连通的,当且仅当去除图中任意一条边后,图仍然连通.

对于图 \(G = (V, E)\),我们称它是边点连通的,当且仅当去除图中任意一条点与其相连的边后,图仍然连通.

而对于边/点双连通分量,其定义是和 \(\textsf{scc}\) 类似的.

特别的若某一条边删去后图不连通,则称此边为割边,或者桥,对于某一个点删去后图不连通,则称此节点为割点.

2.1 Trajan(点双连通分量)

在求解点双连通分量之前,考虑如何求出原图的所有割点.

依然考虑割点在 \(\textsf{dfs}\) 生成树上的性质,若某个节点存在儿子节点 \(v\),满足 \(low_v \le dfn_u\),则 \(u\) 为割点.

需要注意的是对于当前 \(\textsf{dfs}\) 生成树上的根节点一定有 \(low_v \le dfn_n\),但不一定是割点.$\ \ $若它是割点,则其充要条件为对 \(\textsf{dfs}\) 生成树任意换根后,它始终不是叶子节点.$\ \ $更简单的,由于无向图的子树独立性,若根节点存在 \(2\) 个及以上的儿子节点时,该根节点为割点.

割点模板

P3388 【模板】割点(割顶)

按照上述过程进行 Tarjan 即可.

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m;
vector<int> e[N];
int dfn[N], dfntop, low[N], cut[N];
int root, Ans;
void Tarjan(int p){
	dfn[p] = low[p] = ++dfntop;
	int son = 0;
	for(auto v : e[p]){
		if(!dfn[v]){
			son++;
			Tarjan(v);
			low[p] = min(low[p], low[v]);
			if(low[v] >= dfn[p] && p != root)Ans += !cut[p], cut[p] = true;
		}
		else low[p] = min(low[p], dfn[v]); 
	}
	if(son >= 2 && p == root)Ans += !cut[p], cut[p] = true;
	return void(); 
}
signed main(){
	cin >> n >> m;
	for(int i = 1; i <= m; i++){
		int u, v;
		cin >> u >> v;
		e[u].push_back(v);
		e[v].push_back(u);
	}
	for(int i = 1; i <= n; i++)if(!dfn[i])root = i, Tarjan(root);
	cout << Ans << endl;
	for(int i = 1; i <= n; i++)if(cut[i])cout << i << " ";
	return 0;
}

更进一步的,我们考虑点双连通分量的求解,和 \(\textsf{scc}\) 类似,需要注意的是当根节点为孤立点是,也算一个单独的边双连通分量.

点双连通分量模板

P8435 【模板】点双连通分量

#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
vector<int> pcc[N];
int pcctop;
int root, dfn[N], low[N], dfntop, st[N], top;
int n, m;
vector<int> e[N]; 
void tarjan(int p){
	if(p == root && e[p].empty())return pcc[++pcctop].push_back(p), void();
	dfn[p] = low[p] = ++dfntop;
	st[++top] = p;
	for(auto v : e[p]){
		if(!dfn[v]){
			tarjan(v);
			low[p] = min(low[p], low[v]);
			if(low[v] >= dfn[p]){
				pcctop++;
				do pcc[pcctop].push_back(st[top--]);
				while(st[top + 1] != v);
				pcc[pcctop].push_back(p);
			}
		}
		else low[p] = min(low[p], dfn[v]);
	}
	return void();
}
int main(){
	cin >> n >> m;
	for(int i = 1; i <= m; i++){
		int u, v;
		cin >> u >> v;
		if(u == v)continue;
		e[u].push_back(v);
		e[v].push_back(u);
	}
	for(int i = 1; i <= n; i++, top = 0)if(!dfn[i])root = i, tarjan(i);
	cout << pcctop << endl;
	for(int i = 1; i <= pcctop; i++){
		cout << pcc[i].size() << " ";
		for(auto v : pcc[i])cout << v << " ";
		puts("");
	}
	return 0;
}

2.2 Tarjan(边双连通分量)

依然是考虑桥的求解,类比割边的求解,对于生成树上的一条树边 \((u, v)\),其中 \(v\)\(u\) 的子节点,若满足 \(low_v > dfn_u\),则该边为桥.

需要特殊处理的是,若原图中有重边,例如 \((u, v)\) 在原图中出现了两次及以上,那么每一条 \((u, v)\) 都不会是桥,这种情况下特殊处理一下 \(low_v\) 即可.

而对于边双连通分量的求解也是更简单的,在求出全部的桥后,将所有的桥断开,统计连通块即可.

边双连通分量模板

P8436 【模板】边双连通分量

#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
const int M = 2e6 + 5;
vector<int> ecc[N];
int ecctop;
struct edge{
	int to, rev;
	bool state;
};
vector<edge> e[N];
bool vis[N];
int dfn[N], low[N], dfntop;
int n, m;
void tarjan(int p, int fa){
	dfn[p] = low[p] = ++dfntop;
	bool flag = false;
	for(auto &v : e[p]){
		if(!dfn[v.to]){
			tarjan(v.to, p);
			low[p] = min(low[p], low[v.to]);
			if(low[v.to] > dfn[p])v.state = e[v.to][v.rev].state = true;
		}
		else{
			if(v.to != fa || flag)low[p] = min(low[p], dfn[v.to]);
			else flag = true;
		}
	}
	return void();
}
void dfs(int p){
	vis[p] = true;
	ecc[ecctop].push_back(p);
	for(auto v : e[p]){
		if(vis[v.to] || v.state)continue;
		dfs(v.to);
	}
	return void();
}
int main(){
	cin >> n >> m;
	for(int i = 1; i <= m; i++){
		int u, v;
		cin >> u >> v;
		e[u].push_back({v, e[v].size(), false});
		e[v].push_back({u, e[u].size() - 1, false});
	}
	for(int i = 1; i <= n; i++)if(!dfn[i])tarjan(i, 0);
	for(int i = 1; i <= n; i++)if(!vis[i])ecctop++, dfs(i);
	cout << ecctop << endl;
	for(int i = 1; i <= ecctop; i++){
		cout << ecc[i].size() << " ";
		for(auto v : ecc[i])cout << v << " ";
		puts("");
	}
	return 0;
}

圆方树

1. 定义

圆方树最初是处理仙人掌图(每条边被包含在至多一个简单环中的无向图)的一种工具,不过它本质是在维护点双连通分量,因此其使用范围也可拓展到一般无向图上.

对于一张一般无向图 \(G = (V, E)\),我们称 \(V\) 中所有节点为圆点,考虑将 \(G\) 按点双缩点,原图形成一棵树,我们称这棵树上的节点为方点. 将方点向对应点双连通分量中的所有圆点连边(菊花图的形状)后,原图变为由若干圆点与方点组成的一颗圆方树(森林).


2. 构建

圆方树的建立是简单的,Tarjan求出所有点双连通分量,建立对应的方点,连边即可,代码就不贴了.

Practice

I.SP2878 Knights of the Round Table

骑士之间的相互仇恨不好做,考虑将互不仇恨的骑士之间连边,构成图 \(G\). 此时对于任一节点 \(u\),若存在经过 \(u\) 的简单奇环,则 \(u\) 合法.

考虑怎样维护每个节点的合法性.

考虑 \(G\) 中所有的点双连通分量,有性质:若在一点双连通分量中存在一个简单奇环,则该点双内每一个节点都被包含在一简单奇环内.

证明:考虑点双内任意一个简单奇环,该奇环上任意两点间有一条长度为奇数和一条长度为偶数的路径,若此时删去环得到连通块 \(G'\),因为点双性质,\(G'\) 一定存在路径 \((x, y)\),满足 \(x, y\) 都在环上,根据 \(dis_{x, y}\) 的奇偶性结合原环补成奇环即可.

Add.奇环二分图染色即可判断.

II.P5234 [JSOI2012] 越狱老虎桥

边双内部的边是无效的,考虑按边双缩点后的桥,将所有的桥按边权排序,依次加入,若当前加入的所有桥都被包含在树上的一条链上,则继续加入即可.

Add.判断链很神秘.

III.P6335 [COCI 2007/2008 #1] STAZA

圆方树维护仙人掌,由于割边走了之后是无法返回的,考虑记录 \(f_i, g_i\),表示从节点 \(i\) 出发,向下走,能/不能回到 \(i\) 的最大路径长.

由于原题空间很小,不能建出圆方树,考虑 \(\textsf{Tarjan}\) 同时处理每个环上的 \(f_i, g_i\) 即可.

IV.P4320 道路相遇

建出圆方树,倍增或树剖求路径上割点数量即可.

V.P10517 国土规划

前置题目 \(\textsf{P3320 [SDOI2015] 寻宝游戏}\).

本质来说就是动态加入或删除关键点,维护关键点在圆方树上的虚树上的圆点数量.

考虑对于一颗静态虚树如何维护答案,记所有关键点按 \(dfs\) 序升序排列后为 \(p_1 \sim p_k\),它们共同的 \(LCA\)\(t\),特别的,我们令 \(p_0 = 0, fa_1 = 0\),则这颗虚树的答案为 \((\sum_{i = 1}^k dis_{p_i} - dis_{LCA(p_{i - 1}, p_i)}) - dis_{fa_t}\),其中 \(dis_k\) 表示从 \(k\) 到根节点路径上的圆点数量.

对于动态加点删点,\(\textsf{set}\) 维护即可.

posted @ 2026-08-02 10:28  Methyl  阅读(9)  评论(0)    收藏  举报