tarjan 学习笔记

tarjan 学习笔记

  1. 求解强联通分量

我们从一个点开始建立 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 上,如果它不为根节点,则它是把子树和自己的祖先割开了,则一定满足如下不等式:

\[low_v\ge dfn_u \]

则如果满足这个,这个点为割点

  • \(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;
}

  • 扩展:圆方树

我们看下面那个图:

基本步骤就是:

  1. 求出点双
  2. 在每个点双内部构造一个方点
  3. 每个点双内部的点都连向这个方点
  4. 把全部边都删掉

于是乎,我们得到了一颗树,树有很好的性质,这颗树同样有很好的性质

由于是点双,所以它有跟点双一样的性质:点双内任意两点的路径并集为整个点双

我们来看一道题:[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;
}
posted @ 2023-07-15 23:44  Phrvth  阅读(33)  评论(0编辑  收藏  举报