Tarjan(连通性相关) 笔记

概念有点多

点(vertex)边(edge)

  • 无向图中

若图中存在两点可以到达,则称这两个点是 连通的(connected)

若图中任意两点都连通,则称该无向图为 连通图(connected graph)

若图 \(G\) 中存在一个连通子图 \(H\)\(H\subseteq G\)),没有严格更大的连通子图 \(I\) 使 \(H \varsubsetneqq I\),则称 \(H\)\(G\)连通分量(connected component)(极大连通子图)


若图中删去某一条边,会使这个连通图分裂成两个不相连的子图,则称这条边为 桥 / 割边,类似地,若删去某一个点(以及与它相连的边),使所在的连通图分裂,则称这个点为 割点

(有割点不一定有桥,有桥不一定有割点)

image

若图中不存在割边,则称该图为 边双连通图(edge double connected graph)

若图中不存在割点,则称该图为 点双连通图(vertex double connected graph)

无向图的极大边双连通子图称为 边双连通分量(edge double connected component,e-DCC)

无向图的极大点双连通子图称为 点双连通分量(vertex double connected component,v-DCC)

  • 有向图中

若图中存在单向路径 u -> v,则称 u 可达 v

若图中任意两点都互相可达,则称该有向图为 强连通图(strongly connected graph)

若不强连通的有向图把有向边变为无向边的无向图为连通图,则称该图为 弱连通图(weakly connected graph)

与无向图的连通分量类似,有 强连通分量(strongly connected component,SCC)(极大强连通子图)、弱连通分量(weakly connected component)(极大弱连通子图)


dfs 搜索一个有向图 \(G\),产生一颗搜索树,对于 \(G\) 中的边有分类:

  • 树枝边(tree edge),在搜索树中就有的边,即搜索过程经过的边
  • 反祖边(back edge),指向祖先的边
  • 前向边(forword edge),指向子树的边(实际上没屁用的概念 qwq,因为显然这种边放到搜索树中不可能形成环)
  • 横叉边(cross edge),右子树的点指向左子树的点的边(左子树指向未访问过的右子树,实际上就是一条树枝边)

强连通分量 scc

若一个点在一个 scc 中,则它一定在一个环路中,若搜索树中要形成环,只有两种情况

  • 一是直接有返祖边
  • 二是经过横叉边,再向上经过反祖边

引入两个变量:

dfn[u] 表示 u 的时间戳;low[u] 表示 从 u 开始往下走,能达到的最小时间戳

判定:low[u] = dfn[u]

此时该点是所属 scc 的“最高点”,因为若还能往上走形成更大的环,则一定有 low[u] < dfn[u]

在这里也说明,在 dfs 时,我们一定要更新的是 low,避免 scc 的节点提前被判定(单个点是显然成立的)

时间复杂度 \(O(n + m)\)

code
void tarjan(int u)
{
	low[u] = dfn[u] = ++ idx;
	in_stk[u] = true;
	stk.push(u);
	
	for (re i = h[u]; i; i = e[i].next)
	{
		int v = e[i].to;
		
		if (!dfn[v]) // tree edge
		{
			tarjan(v);
			low[u] = min(low[u], low[v]);
		}
		else if (in_stk[v]) // back edge / cross edge
			low[u] = min(low[u], low[v]);
	}
	
	if (low[u] == dfn[u])
	{
		int ver;
		do
		{
			ver = stk.top();
			stk.pop();
			in_stk[ver] = false;
			
			... // id / size ...
			
		}while (ver != u); // (先执行再判断)多执行一次,方便将 u 也一同弹出
		cnt ++; 
	}
}

scc 缩点

因为每个 scc 里的点都是可以互相到达的,所以很多时候都可以把环看成一个点

发现一般的有向图缩点后,会形成 DAG,在 DAG 上就可以进行很多别的操作啦

image

想要实现缩点也很 easy

只要再遍历一遍所有点以及它的邻点,若两点分属不同的 scc,则将两 scc 的 id 连一条边即可。

还有 DAG 上求拓扑序的问题,有一个结论:scc 的递减 id 序列就是 DAG 的拓扑序

简单理解一下就是,从上往下递归,到叶子节点赋予小的 scc id,往上回溯时 scc id 变大。


割边/桥 -> 边双连通分量 e-DCC

有性质:e-DCC 中的任意两点都存在有两条没有公共边的路径


要求 e-DCC,也就是把图中的割边求出来,剩下的连通块就是 e-DCC

那么,对于一条边 x -> y,若它是桥,则,桥判定:dfn[x] < low[y]

感性理解一下就是,y 这个点,除了 x -> y 这条边,没有别的边可以让 low[y] 变得更小了,也就是桥,那么 y 所在的子树也就是 e-DCC

注意:在跑无向图构成的搜索树中,显然是没有 横叉边 和 返祖边的存在的

求完桥,dfs 搜一遍即可。

tip:对于无向图,用边的编号判重边,用 ^1 时,链式前向星存图边计数器 idx 初始化 idx = 1,这样存双向边就能 2, 3 4, 5 ..... 成对编号

code 1
void tarjan(int u, int id)
{
	low[u] = dfn[u] = ++ idx;
	
	for (re i = h[u]; i; i = e[i].next)
	{
    	int v = e[i].to;
		
		if (!dfn[v])
		{
			tarjan(v, i);
			low[u] = min(low[u], low[v]);
			if (dfn[u] < low[v])
				bridge[i] = bridge[i ^ 1] = true;
		}
		else if (i != (id ^ 1))
			low[u] = min(low[u], dfn[v]);
	}
}

void dfs(int u)
{
	edcc[u] = cnt;
	for (re i = h[u]; i; i = e[i].next)
	{
		int v = e[i].to;
		if (edcc[v] || bridge[i]) continue;
		dfs(v);
	}
}

同时,还有一种方法,其实在构成搜索树时,我们就把这个无向图看成了有向图,每条边至多访问一遍,被抽象为有向图的无向图中的一个强连通分量,在原图中是一定一个边双联通分量。那么可以仿照求 scc 的思路求 e-dcc

code 2
void tarjan(int u, int id)
{
	low[u] = dfn[u] = ++ idx;
	stk.push(u);
	
	for (re i = h[u]; i; i = e[i].next)
	{
		int v = e[i].to;
		
		if (!dfn[v])
		{
			tarjan(v, i);
			low[u] = min(low[u], low[v]);
		}
		else if (i != (id ^ 1))
			low[u] = min(low[u], dfn[v]);
	}
	if (dfn[u] == low[u])
	{
		...
	}
}



e-DCC 缩点

我们在求 e-DCC 时,标记了割边的编号

那么缩点就很容易了,只需便利一遍所有边,若为割边,则对连点所在的 e-DCC 连边

可以发现,缩完点后,图中只剩下割边,也就是形成了一棵树或森林


割点 -> 点双连通分量 v-DCC

对于图中某个点 x 及其儿子 y, 割点判定:

  • \(x\not = root\)\(dfn[x] \leq low[y]\)
  • \(x = root\),则应至少存在两组 \(y_1,y_2\),使 \(dfn[x] \leq low[y_1],~low[y_2]\)

因为在搜索树中,非根节点(且有儿子,非叶子节点)一定至少有两条边连着它,而根节点不一定有多个儿子节点。

tip:注意,这里求割点,对于已访问的入点,必须 low[u] = min(low[u], dfn[v]),而不能用入点的 low[v] 更新,因为如果出现了入点已访问过,那么说明出现了环,而注意到我们的判定 \(dfn[x] \leq low[y]\) 的实际含义是入点在 不经过出点 的情况下,不能绕行其他节点到达更早访问的点,则可以判定出点为割点。

所以若用 low[v] 更新,则代表 穿过出点,不合法。

求割点 code
void tarjan(int u) // 每次传入 u = root
{
	low[u] = dfn[u] = ++ idx;
	int cnt = 0;
	
	for (re i = h[u]; i; i = e[i].next)
	{
		int v = e[i].to;
		
		if (!dfn[v])
		{
			tarjan(v);
			low[u] = min(low[u], low[v]);
			if (dfn[u] <= low[v])
			{
				cnt ++;
				if (u != root || cnt > 1) cut[u] = true;
 			}
		}
		else 
			low[u] = min(low[u], dfn[v]);
	}
}
求 v-dcc code
void tarjan(int u)
{
	low[u] = dfn[u] = ++ idx;
	stk.push(u);
//	int kid = 0;
	
	if (u == root && h[u] == 0)
	{
		vdcc[++ cnt].push_back(u);
		return;
	}
	
	for (re i = h[u]; i; i = e[i].next)
	{
		int v = e[i].to;
		
		if (!dfn[v])
		{
			tarjan(v);
			low[u] = min(low[u], low[v]);
			if (dfn[u] <= low[v])
			{
//				kid ++;
//				if (u != root || kid > 1) cut[u] = true;	
				cnt ++;
				int ver;
				do
				{
					ver = stk.top(); stk.pop();
					vdcc[cnt].push_back(ver);
				}while (ver != v); // 注意这里是个易错点,不要打成 ver != u
				vdcc[cnt].push_back(u);
 			}
		}
		else 
			low[u] = min(low[u], dfn[v]);
	}
}

v-DCC 缩点

image

对割点 裂点

所以,我们其实可以得到一颗节点数为 点双数 + 割点数 的树或森林,

考虑遍历整个图,对于每个 v-DCC,把它与它包含的割点连边即可。


最后,如果一道题需要无向图缩点,而并不好确定是 点双缩点 还是 边双缩点,注意:

边双是定义在点上的,即每个点只属于一个边双;点双是定义在边上的,即每条边只属于一个点双


圆方树

这里我讨论的仅仅是用于处理一般图的广义圆方树,仙人掌?是什么,能吃吗 qwq

圆方树,可以说是一种思想,这里我看到主要是用来处理 有环无向图的必经点问题

首先,两点的必经点在无向图上,也就是割点,所以,圆方树就是定义在点双上的

具体地,把原图中的点叫作圆点,而对于每一个点双,我们可以增加一个代表点,叫做方点,

将图重新构造,令所有圆点都连向所在点双(可能多个)的方点上,可以构造出一颗树!而这颗树与原无向图是等价的,圆点维护原来点上的信息,而方点则可以维护该点双上的信息

image

有树就非常好了

这样要做必经点的问题,就相当于是求给定两点在圆方树上的简单路径上的圆点数量呗,lca 乱搞 ok

练习:

P3854 [TJOI2008] 通讯网破坏
P4320 道路相遇
P5058 [ZJOI2004] 嗅探器
P4606 [SDOI2018] 战略游戏(由两点拓展为 k 个点,可以用虚树做,但我不会,当然也可以维护一个 dfn 序做)

posted @ 2024-07-25 12:20  Zhang_Wenjie  阅读(56)  评论(0)    收藏  举报