tarjan 学习笔记
tarjan 学习笔记
- 求解强联通分量
我们从一个点开始建立 dfs 树,有如下四种边:
- 树边
若 \(u\) 到 \(v\) 有边,且满足 \(v\) 没有被访问过,则这条边为树边
- 返祖边
若 \(u\) 到 \(v\) 有边,且满足 \(v\) 已被访问过,则这条边为返祖边
- 横叉边
若 \(u\) 到 \(v\) 有边,且满足 \(u\) 和 \(v\) 不是儿子祖先关系,则这条边为横叉边
- 没用边
若 \(u\) 到 \(v\) 有边,且满足 \(u\) 可以通过一定的路径到 \(v\),则这条边没有任何作用
如下图为一个dfs树:黑色为树边,红色为返祖边,蓝色为横叉边,紫色为没用边
我们根据这些边求解强联通
明显的,没用边没用,我们考虑横叉边有没用
由于是 dfs ,所以横叉边一定是后访问的点到先访问的点,那是因为如果前访问的点到后访问的点,那这条边即为树边
所以横叉边必定是单向边
树边一定是往下的,所以也构不成强联通,那么只有返祖边满足了
如果一个边要到达另一个不是自己儿子的点,那么只能通过返祖边通过祖先做跳板了
那我们的任务就为:探究一个点能往上走多少个点
我们维护两个数组分别为:
- \(dfn_x\) 表示 \(x\) 是 dfs 第 \(dfn_x\) 访问到的点,也就是 dfs 序
- \(low_x\) 表示 \(x\) 内所有子树,包括它自己能够到达的 \(dfn\) 最小的点
我们开始分析代码步骤:
假设 \(u\) 为当前节点,\(v\) 为 \(u\) 可以到的边
- \(u->v\) 为树边
递归 \(v\) 之前更新 \(low\) 数组:\(low_u=\min(low_u,low_v)\)
- \(u->v\) 为横叉边
通常,这种边是可以不用管的,但是当 \(v\) 连的祖先可以到 \(u\),这就可以更新,那怎么判断呢?
我们开个栈,里面存的是强联通分量的点,注意:同一个强联通分量在一起,如果这个强联通分量找完了,应该弹出里面 \(low\) 为最顶上的点
因为 \(v\) 在 \(u\) 之前被访问,所以 \(v\) 肯定是遍历过的,若果它在栈中,就说明它所在的强联通分量还没找完,也就是 \(u,v\) 在同一个强联通分量里面,这就可以更新 \(low\) 数组:\(low_u=\min(low_u,low_v)\)
- \(u->v\) 为返祖边
容易发现,这种情况在上种情况已经考虑进去了,不必再次考虑
最后,当遍历完所有儿子之后,发现 \(low_u=dfn_u\) 就说明这个为强联通的头,弹出所有 \(low=u\) 的点即可
Code
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e5 + 7, MAXM = 1e6 + 7;
struct Node {
int nxt, to;
}Edge[MAXM];
int H[MAXN], E_cnt;
void add(int from, int to) {
Edge[++ E_cnt] = Node{H[from], to};
H[from] = E_cnt;
}
int n, m, Value[MAXN];
int Dfn[MAXN], Low[MAXN], Scc[MAXN], scc, cnt;
bool V[MAXN];
int Sum[MAXN];
stack <int> T;
void tarjan(int u) {
Dfn[u] = ++ cnt; Low[u] = Dfn[u];
V[u] = true; T.push(u);
for (int i = H[u]; i; i = Edge[i].nxt) {
int v = Edge[i].to;
if (!Dfn[v]) tarjan(v), Low[u] = min(Low[u], Low[v]);
else if (V[v]) Low[u] = min(Low[u], Low[v]);
}
if (Dfn[u] == Low[u]) {
Scc[u] = ++ scc; Sum[scc] = Value[u];
while (!T.empty() && T.top() != u) V[T.top()] = 0, Scc[T.top()] = scc, Sum[scc] += Value[T.top()], T.pop();
T.pop(); V[u] = 0;
}
}
int X[MAXM], Y[MAXM], In[MAXN], ans, Dp[MAXN];
int topu() {
queue <int> Q;
for (int i = 1; i <= scc; i ++) {
if (!In[i]) Q.push(i);
}
while (!Q.empty()) {
int now = Q.front(); Q.pop();
Dp[now] += Sum[now];
for (int i = H[now]; i; i = Edge[i].nxt) {
int v = Edge[i].to;
Dp[v] = max(Dp[v], Dp[now]);
if (-- In[v] == 0) Q.push(v);
}
ans = max(ans, Dp[now]);
}
return ans;
}
int main () {
cin >> n >> m;
for (int i = 1; i <= n; i ++) cin >> Value[i];
for (int i = 1; i <= m; i ++) {
cin >> X[i] >> Y[i]; add(X[i], Y[i]);
}
for (int i = 1; i <= n; i ++)
if (!Dfn[i]) tarjan(i);
memset(H, 0, sizeof(H)); E_cnt = 0;
for (int i = 1; i <= m; i ++) {
int x = Scc[X[i]], y = Scc[Y[i]];
if (x == y) continue;
add(x, y); In[y] ++;
}
cout << topu();
return 0;
}
2.求解边双联通分量
因为在无向图里,所以没有横叉边了
我们到这里可以发现,SCC和边双联通本质上就是通过 tarjan 求环,所以代码基本无异
注意一点:如果一条边走了,那么这条班边的反向边也不能走了
还需要改的地方为:因为没有横叉边,那么用于判断横叉边的标记数组 \(V\) 就没必要存在了
Code
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 5e5 + 7, MAXM = 2e6 + 7;
struct Node {
int nxt, to;
}Edge[2 * MAXM];
int H[MAXN], E_cnt = 1;//注意
void add(int from, int to) {
Edge[++ E_cnt] = Node{H[from], to};
H[from] = E_cnt;
}
int n, m;
int Dfn[MAXN], Low[MAXN], cnt;
stack <int> T;
vector <vector<int> > Ans;
void tarjan(int u, int e) {
Dfn[u] = ++ cnt; Low[u] = Dfn[u]; T.push(u);
for (int i = H[u]; i; i = Edge[i].nxt) {
if (i == (e ^ 1)) continue;
int v = Edge[i].to;
if (!Dfn[v]) tarjan(v, i), Low[u] = min(Low[u], Low[v]);
else Low[u] = min(Low[u], Low[v]);
}
if (Dfn[u] == Low[u]) {
vector <int> v; v.push_back(u);
while (!T.empty() && T.top() != u) v.push_back(T.top()), T.pop();
T.pop(); Ans.push_back(v);
}
}
int main () {
cin >> n >> m;
for (int i = 1, x, y; i <= m; i ++) {
cin >> x >> y, add(x, y); add(y, x);
}
for (int i = 1; i <= n; i ++)
if (!Dfn[i]) tarjan(i, 0);
cout << Ans.size() << '\n';
for (auto w : Ans) {
cout << w.size() << ' ';
for (auto i : w) cout << i << ' ';
cout << '\n';
}
return 0;
}
3.求解点双联通问题
我们发现一个性质:如果 \(a\) 和 \(b\) 点双联通,\(b\) 和 \(c\) 点双联通,则不一定有 \(a\) 和 \(c\) 点双联通
根据这个性质,我们发现,一个点可以同时被编进多个点双联通,这也就导致我们不能像前两个一样对他进行普通弹栈
先考虑怎么求割点
我们分两种情况:
- \(u\) 不为根节点
把 \(u\) 放进 dfs 上,如果它不为根节点,则它是把子树和自己的祖先割开了,则一定满足如下不等式:
则如果满足这个,这个点为割点
- \(u\) 为根节点
如果 \(u\) 有1个孩子以上,那么 \(u\) 一定为割点
好,我们割点求出来了,看看怎么求点双联通
不难发现,每个点双之间的分割点就是割点,如果我们找到了一个割点,就把栈中和它是一个环的弹出来,但是注意不要弹出它本身!!
为什么能这样做呢?那是因为除了第一个点双其他的点双都是以割点为顶点的,如下图:
容易发现除了起点 1,2,3,4 不以 4 为顶点,其他的点双都以 4 为顶点
所以我们就可以进行上述操作
注意,假设 \(u\) 为顶点, \(u->v\) 为一条树边,回溯的时候,栈内存的是所有顶点为 \(u\) 的点双,那就会误判,所以要解决这个问题
我们发现,我们第一次找到的 \(v\) 是最先入栈的,所以启发我们,弹到 \(v\) 为至!
于是在弹栈的时候稍加处理,就可以了
但是还有一种情况我们没有考虑到,就是如果这个点为孤点咋办,直接加入点双即可
Code
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 5e5 + 7, MAXM = 2e6 + 7;
struct Node {
int nxt, to;
}Edge[2 * MAXM];
int H[MAXN], E_cnt = 1;//注意
void add(int from, int to) {
Edge[++ E_cnt] = Node{H[from], to};
H[from] = E_cnt;
}
int n, m;
vector <vector <int> > Ans;
int Dfn[MAXN], Low[MAXN], cnt, rt;
stack <int> T;
void tarjan(int u, int e) {
Dfn[u] = ++ cnt; Low[u] = Dfn[u]; T.push(u);
int son = 0;
for (int i = H[u]; i; i = Edge[i].nxt) {
if (i == (e ^ 1)) continue;
int v = Edge[i].to;
if (!Dfn[v]) {
tarjan(v, i);
son ++; Low[u] = min(Low[u], Low[v]);
if (Low[v] >= Dfn[u]) {//注意这里是根也可以
vector <int> V; V.push_back(u);
while (!T.empty() && T.top() != v) V.push_back(T.top()), T.pop();//注意
V.push_back(T.top()); T.pop();
Ans.push_back(V);//不用吧u弹掉
}
}
else Low[u] = min(Low[u], Dfn[v]);//注意!!
}
if (u == rt && son == 0) {
vector <int> V; V.push_back(u);
Ans.push_back(V);
}
}
int main () {
cin >> n >> m;
for (int i = 1, x, y; i <= m; i ++) {
cin >> x >> y, add(x, y); add(y, x);
}
for (int i = 1; i <= n; i ++)
if (!Dfn[i]) rt = i, tarjan(i, 0);
cout << Ans.size() << '\n';
for (auto w : Ans) {
cout << w.size() << ' ';
for (auto v : w) {
cout << v << ' ';
}
cout << '\n';
}
return 0;
}
- 扩展:圆方树
我们看下面那个图:
基本步骤就是:
- 求出点双
- 在每个点双内部构造一个方点
- 每个点双内部的点都连向这个方点
- 把全部边都删掉
于是乎,我们得到了一颗树,树有很好的性质,这颗树同样有很好的性质
由于是点双,所以它有跟点双一样的性质:点双内任意两点的路径并集为整个点双
我们来看一道题:[APIO2018] 铁人两项
题目要求有多少个三元组满足 \(<s,c,f>\) 满足 \(s->f\) 这条简单路径上有 \(c\)
我们固定 \(s\) 和 \(f\) 现尝试求出有多少个 \(c\)
明显的,\(c\) 的数量为 \(s->f\) 这条简单路径的并集的点,于是就有了一个思路:给圆方点复赋值!
既然我们要求 \(s->f\) 的简单路径的并集的点 - 2(不包括 \(s\) 和 \(f\)),那我们不妨给方点赋值为整个点双的大小,每个原点赋值为 \(-1\),那我们跑一遍 \(dfs\) 把 \(s\) 到 \(f\) 上的点,把路径和加起来就行啦
问题转换成统计圆方树上 \(\sum_{u,v\in G}u->v\),求路径实在太过麻烦,可以转换为求点对答案的贡献,注意要乘2,因为 \(s->t=t->s\)
注意:这里不用求孤点,因为它对答案毛贡献都没有
Code
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int MAXN = 1e5 + 7;
int n, m;
vector <int> G[MAXN], T[MAXN * 2];//懒了
int Dfn[MAXN], Low[MAXN], cnt, Val[2 * MAXN], dfc, num, ans;
int t[2 * MAXN], tp;
void tarjan(int u) {
Dfn[u] = Low[u] = ++ dfc; t[++ tp] = u;
num ++;
for (auto v : G[u]) {
if (!Dfn[v]) {
tarjan(v);
Low[u] = min(Low[u], Low[v]);
if (Low[v] >= Dfn[u]) {
Val[++ cnt] = 0;
T[cnt].push_back(u); T[u].push_back(cnt); Val[cnt] ++;
for (int x = 0; x != v; tp --) {
x = t[tp];
T[cnt].push_back(x); T[x].push_back(cnt);
Val[cnt] ++;
}//注意,这里将v也弹掉了
}
} else Low[u] = min(Low[u], Dfn[v]);
}
}
int Vis[MAXN * 2], Siz[MAXN * 2];
void dfs(int u, int f) {
Vis[u] = 1, Siz[u] = (u <= n);//注意
for (auto v : T[u]) {
if (v == f) continue;
dfs(v, u);
ans += 2ll * (Val[u] * Siz[u] * Siz[v]);//子树内
Siz[u] += Siz[v];
}
ans += 2ll * (Val[u] * Siz[u] * (num - Siz[u]));//子树外
}
signed main () {
cin >> n >> m;
for (int i = 1; i <= n; i ++) Val[i] = -1;
cnt = n;
for (int i = 1, u, v; i <= m; i ++) {
cin >> u >> v;
G[u].push_back(v); G[v].push_back(u);
}
for (int i = 1; i <= n; i ++)
if (! Dfn[i]) {
num = 0; tp --;
tarjan(i); dfs(i, 0);
}
cout << ans << '\n';
return 0;
}