图论.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\):
- 若 \(v\) 未被访问过,则递归访问 \(v\),并且用 \(low_v\) 更新 \(low_u\).
- 若 \(v\) 访问过,且在栈中,用 \(dfn_v\) 更新 \(low_u\).
- 若 \(v\) 访问过,且不在栈中,不进行任何操作.
当所有 \(v\) 都被处理过后,若当前节点 \(u\) 满足 \(dfn_u = low_u\),新建一个 \(\textsf{scc}\),将栈中 \(u\) 及 \(u\) 以上的节点全部出栈,加入此 \(\textsf{scc}\).
Tarjan模板题
对于所有强连通分量缩点,由于节点可重复经过,简单的在 \(\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\) 个及以上的儿子节点时,该根节点为割点.
割点模板
按照上述过程进行 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}\) 类似,需要注意的是当根节点为孤立点是,也算一个单独的边双连通分量.
点双连通分量模板
#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\) 即可.
而对于边双连通分量的求解也是更简单的,在求出全部的桥后,将所有的桥断开,统计连通块即可.
边双连通分量模板
#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}\) 维护即可.

浙公网安备 33010602011771号