文章目录
- Tarjan 算法
- 一、图的概念
- 二、Tarjan 算法
- poj1236
- 三、割点割边
- 1、离散数学中的定义
- 2、数据结构
- 3、tarjan 函数
- 四、强连通分量以及缩点技巧
- 缩点
- [1192. 查找集群内的关键连接](https://leetcode.cn/problems/critical-connections-in-a-network/)
- [1568. 使陆地分离的最少天数](https://leetcode.cn/problems/minimum-number-of-days-to-disconnect-island/)
- [1489. 找到最小生成树里的关键边和伪关键边](https://leetcode.cn/problems/find-critical-and-pseudo-critical-edges-in-minimum-spanning-tree/)
- Python 模块 tarjan
Tarjan 算法
一、图的概念
1、无向图
连通: 无向图上两点 u, v 之间存在路径,称这两个点是连通的。
连通分量: 无向图的每个极大连通块是这张无向图的连通分量。
点双连通: 无向图上任意两点之间都存在两条“点不重复的路径”,称点-双连通。
点双连通分量(点双): 无向图的子图中满足”其中的任意两点之间都有至少两条不同时经过除了这两点以外的点的简单路径”的极大子图称为点双连通分量。
特别地,一条边及其两端点在单独存在时构成一个点双连通分量。
对于点双连通分量,删除任意一点连通性不变,其中不含桥,环与环必定含有公共边,且公共点至少两个,简单圈中的点一定属于同一个点 BCC。
边双连通分量(边双): 无向图的子图中满足”其中的任意两点之间都有至少两条不同时经过同一条边的简单路径”的极大子图称为边双连通分量。
割点: 若无向图中,存在一个点,当该点被删除时,图中的连通分量数目增加。整张图不再连通。
割边(桥): 无向图中,若删除某条边后连通分量增加,这条边就是桥。
2、有向图: 强连通、强连通图、强连通分量
强连通: 两个点 a、b 可以相互到达
强连通图: 每两个点都强连通
强连通分量(SCC): 极大强连通图子图,孤立的点也是一个强连通分量。
3、DFS 搜索树
树边: DFS 搜索树上边。
前向边
后向边:返祖边
横叉边:若边的两端点位于两个不同子树中,满足起点被遍历到的时间晚于终点,否则为树边。
无向图中非树边只有返祖边
二、Tarjan 算法
1、一个栈,用于记录当前已经访问过的但是还没有被归类到任一强连通分量的节点。
2、dfn[u]:表示点 u DFS 序(第一次出现的时间),即标记点 u 是第几个被 DFS 到的的点。
3、low[u]:从 u 或者以 u 为根的子树中的节点出发通过一条返祖边或者横叉边可以到达的时间戳最小的,且能够到达 u 的节点 u 的时间戳。
强连通分量的根的判定:
节点 u 是强连通分量的根等价于 dfn[u] 和 low[u] 相等。
Tarjan 算法是一种用来求解 有向图强连通分量 的线性时间的算法。可以找 强连通分量,也可以找 缩点、割点 等。
Tarjan 算法是基于 DFS 的,每个强连通分量为搜索树的一棵子树,搜索时把当前搜索树中未处理的节点加入一个栈,回溯时判断栈顶到栈中节点是否为强连通分量。
dfn[u]: 结点 u 第一次被访问的顺序编号(时间戳)。
low[u]: 从结点 u 出发,能访问到的最早时间戳。
from collections import defaultdict
# tarjan 算法
def tarjan(x):
# 1、tarjan 求强连通分量
global time, cnt
time += 1
dfn[x] = low[x] = time # dnf 与 low 初始化,time 为当前时间戳
stack.append(x) # 标记 x 入栈
vis.add(x) # 入栈了
for y in g[x]: # 枚举所有出边
if(not dfn[y]): # y 没有被搜索过,说明这条边是树边
tarjan(y)
low[x] = min(low[x], low[y]) # 由于这一步走的是树边,走一次非树边的机会就留给了子树。
if low[y] >= dfn[x]: cut.append(x) # 2、判断割点
if low[y] > dfn[x]: print("isPridge: {x} -> {y}") # 3、判断割边
elif y in stack: # 已经被搜索过,说明这条边是非树边,
low[x] = min(low[x], dfn[y])
if dfn[x] == low[x]: # 根节点
# 如果某一个子树没有向外的连边了,那么此时子树中还在栈内的点构成了个强连通分量
vis.remove(x) # 根标记出栈,等一会儿找到根的位置后再真正出栈。
cnt += 1
bel[x] = cnt # 标记所属的强连通分量
while stack and stack[-1] != x: # 依次出栈,标记
o = stack.pop()
bel[o] = cnt
vis.remove(o)
# if x == 1 and deg[x] == 1: cut[x] # 根节点特判
if __name__ == "__main__":
g = defaultdict(list)
stack = []
vis = set()
bel = {}
cut = [] # 割点
edges = [[0,1],[1,2],[2,0],[2,1]]
n, time, cnt = len(edges), 0, 0
n = 3
dfn, low = [0] * n, [0] * n
for a, b in edges:
g[a].append(b)
g[b].append(a)
for i in range(n):
if dfn[i] == 0:
tarjan(i)
print(dfn, low, stack, bel)
poj1236
三、割点割边
1、离散数学中的定义
割点: 无向连通图中,去掉一个顶点及和它相邻的所有边,图中的连通分量数增加,则该顶点称为割点。
割边: 无向联通图中,去掉一条边,图中的连通分量数增加,则这条边,称为割边。
2、数据结构
int dfn[MAXN]; // 记录一个节点第一次被访问时的时间戳
int low[MAXN]; // 记录一个节点不经过它的父节点最高能访问到它的祖先节点中的最小时间戳。
int cut[MAXN]; // 记录该节点是否是割点, 一个割点可能多次被记录。
//这是链式前向星, 用来存储边的一个数据结构
int head[MAXN], cnt;
struct Edge {
int to;
int nxt;
} e[MAXM];
3、tarjan 函数
由于图可能不是连通图,故对每一个节点作为根节点进行 dfs 遍历。
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
tarjan(i, i);
}
}
tarjan 函数第二个参数传入的是父节点,根节点是个例外,父节点就当做它本身,便于后面的特判。
对于一个根节点,如果它有两个或者两个以上的子树, 那么去掉根节点这几颗子树就不连通了,故可以判定根节点是割点。
对于一个非根节点 u,用 dfn[u] 值和它的所有的子节点的 low 值进行比较,如果存在至少一个子节点 v 满足 low[v] >= dnf[u],说明节点 v 访问节点 u 的祖先节点,必须通过节点 u,而不存在节点 v 到节点 u 祖先节点的其它路径,所以节点 u 就是一个割点。对于没有子节点的节点,显然不会是割点。
dfn 数组,只会在节点第一次被访问的时候赋值(时间戳)。
low 数组,假设当前节点为 u,则默认 low[u] = dfn[u],即最早只能回溯到自身。有一条边(u, v),如果 v 未访问过,继续 DFS,DFS 完之后,low[u] = min(low[u], low[v]);如果 v 访问过(且 u 不是 v 的父节点),就不需要继续 DFS 了,一定有dfn[v] < dfn[u],low[u] = min(low[u], dfn[v])。
tarjan 函数传入了两个参数,当在主函数中第一次调用时,是 tarjan(i, i),第二个参数代表的是父节点,但 i 的父节点怎么会是它本身呢? 这就是一个用来特判根节点的技巧,如果对于一个顶点 u,有 u == fa,那么 u 就是根节点。
// fa 代表父节点
void tarjan (int u, int fa) {
dfn[u] = low[u] = ++id; // id 代表时间戳
int child = 0; // child 代表子树数目, 只有 u 是根节点时, 这个变量才会起作用哟。
for (int i = head[u]; i; i = e[i].nxt) { // 链式前向星的遍历操作
int to = e[i].to;
if (!dfn[to]) {
// 如果顶点 to 没有访问过, 那么继续 dfs
tarjan(to, fa); // 传入当前节点以及父节点作为参数
low[u] = min(low[u], low[to]); // 回溯的时候更新 low 数组的值
if (low[to] >= dfn[u] && u != fa) { // 注意这里特判了不是根节点
cut[u] = 1; // 标记为割点
}
if (u == fa) { // 特判是根节点
child++; // 子树数目加1
}
}
low[u] = min(low[u], dfn[to]); // 这里的更新操作不要漏掉了
}
if (child >= 2 && u == fa) { // 若根节点的子树数目大于或等于 2
cut[u] == 1; // 则根节点也是割点
}
}
然后遍历一遍 cut 数组, 更新割点的数目
for (int i = 1; i <= n; i++) {
if (cut[i]) {
ans++;
}
}
最后输出割点数即可
cout << ans << endl;
return 0;
四、强连通分量以及缩点技巧
根据离散数学中的定义
1,强连通:在一个有向图中,如果两个点可以互相到达,就称为这两个点强连通。
2,强连通图:在一个有向图中,如果任意两个点强连通,就把这个图称为强连通图。
3,强连通分量:在一个非强连通图中的最大强连通子图,称为强连通分量。
int low[MAXN];
int dfn[MAXN];
int stack_[MAXN]; // 栈
int exist[MAXN]; // 判断第 i 个元素是否在栈中
int color[MAXN]; // 用于对不同的连通分量染色的数组
//链式前向星
struct Cow {
int to;
int nxt;
} cow[MAXM];
// 注:下面这两个数组是针对模板题而言的
int num[MAXN]; //用于记录每个连通分量有多少格元素的数组
int outDgree[MAXN]; //用于记录出度的数组
tarjan(u)
{
DFN[u] = Low[u] = ++Index // 为节点 u 设定次序编号和 Low 初值
Stack.push(u) // 将节点u压入栈中
for each (u, v) in E // 枚举每一条边
if (v is not visted) // 如果节点v未被访问过
tarjan(v) // 继续向下找
Low[u] = min(Low[u], Low[v])
else if (v in S) // 如果节点v还在栈内
Low[u] = min(Low[u], DFN[v])
if (DFN[u] == Low[u]) // 如果节点u是强连通分量的根
repeat
v = S.pop // 将v退栈,为该强连通分量中一个顶点
print v
until (u== v)
}
从一个点出发,开始遍历并跟新 dfn 和 low,如果一个点 u 无路可走了,那么若 dfn[u] == low[u],就弹出栈顶到 u 的元素,这些元素是属于一个强连通分量内的。
寻找它的强连通分量
从节点 1 开始 DFS,把遍历到的节点加入栈中。搜索到节点 u = 6 时,DFN[6] = LOW[6],找到了一个强连通分量。退栈到u = v 为止,{6} 为一个强连通分量。
返回节点 5,发现 DFN[5] = LOW[5],退栈后 {5} 为一个强连通分量。
返回节点 3,继续搜索到节点 4,把 4 加入堆栈。发现节点 4 节点 1 有后向边,节点 1 还在栈中,所以 LOW[4] = 1。节点 6 已经出栈,(4,6) 是横叉边,返回 3,(3, 4) 为树枝边,所以 LOW[3] = LOW[4] = 1。
继续回到节点 1,最后访问节点 2。访问边 (2,4),4 还在栈中,所以 LOW[2] = DFN[4] = 5。返回 1 后,发现DFN[1] = LOW[1],把栈中节点全部取出,组成一个连通分量 {1,3,4,2}。
求出了图中全部的三个强连通分量{1,3,4,2},{5},{6}。
void dfs(int x) {
dfn[x] = low[x] = ++tot; //都初始化为x
stack_[++top] = x; //点x入栈
exist[x] = 1; //表示点x在栈中
for (int i = head[x]; i; i = cow[i].nxt) {
if (!dfn[cow[i].to]) {
//如果与它相连的这个点还没有被遍历
dfs(cow[i].to);
low[x] = min(low[x], low[cow[i].to]);
} else if (exist[cow[i].to]) {
//如果与它相连的这个点在栈中, 表示它们在同一个连通分量中
low[x] = min(low[x], low[cow[i].to]);
}
}//end for
if (low[x] == dfn[x]) {
//如果节点x是强连通分量的根
id++; //每个连通分量的标号
do {
color[stack_[top]] = id;
num[id]++;
exist[stack_[top]] = 0;
} while (x != stack_[top--]);
}
}
这段流程相当的清楚. 现在您应该已经懂得了如何求得强连通分量了.
缩点
- 当找到一个连通分量的时候,因为这个连通分量里的点都是强连通的,所以可以把它们看成一个点。
- 关于计算出度
在已经染色完成并用缩点法转化后, 对于一个缩点的出度只用看颜色i的顶点的出边是否是另一种颜色, 是的话颜色i的强连通分量出度+1
for (int i = 1; i <= n; i++) {
for (int j = head[i]; j; j = cow[j].nxt) {
if (color[i] != color[cow[j].to]) {
outDgree[color[i]]++;
}
}
}
- 如何判断是否有明星, 谁是明星?
首先, 判断是否有明星, 若只有一个缩点的出度为0, 那么该缩点就是明星.
若有超过一个缩点的出度不为0, 那么是没有明星的.
若一个缩点为明星, 那么代表这个缩点所代表的强连通分量内的奶牛全部是明星
for (int i = 1; i <= id; i++) {
if (outDgree[i] == 0) {
if (ans) {
cout << 0;
return 0;
}
ans = i;
}
}
cout << num[ans];
return 0;
然后就AC啦
1192. 查找集群内的关键连接
from typing import List, Tuple
'''
Trajan 算法求无向图的桥
'''
class Tarjan:
@staticmethod
def getCuttingPointAndCuttingEdge(edges: List[Tuple]):
link, dfn, low = defaultdict(list), {}, {} # link 为字典邻接表
global_time = 0
for a, b in edges:
link[a].append(b)
link[b].append(a)
dfn[a] = dfn[b] = low[a] = low[b] = 0x7fffffff
cutting_points, cutting_edges = [], []
def dfs(cur, prev, root):
nonlocal global_time
global_time += 1
dfn[cur] = low[cur] = global_time
children_cnt = 0
flag = False
for next in link[cur]:
if next != prev:
if dfn[next] == 0x7fffffff:
children_cnt += 1
dfs(next, cur, root)
if cur != root and low[next] >= dfn[cur]:
flag = True
low[cur] = min(low[cur], low[next])
if low[next] > dfn[cur]:
cutting_edges.append([cur, next] if cur < next else [next, cur])
else:
low[cur] = min(low[cur], dfn[next])
if flag or (cur == root and children_cnt >= 2):
cutting_points.append(cur)
dfs(edges[0][0], None, edges[0][0])
return cutting_points, cutting_edges
class Solution:
def criticalConnections(self, n: int, connections: List[List[int]]) -> List[List[int]]:
cutting_dots, cutting_edges = Tarjan.getCuttingPointAndCuttingEdge(connections)
return cutting_edges
class Solution {
int[] dfn, low;
List[] g;
int time = 1;
List<List<Integer>> ans;
public List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
dfn = new int[n];
low = new int[n];
ans = new ArrayList<>();
g = new List[n];
for(int i = 0; i < n; i++){
g[i] = new ArrayList<Integer>();
}
for(List<Integer> edge:connections){
int x = edge.get(0), y = edge.get(1);
g[x].add(y);
g[y].add(x);
}
dfs(0, -1);
return ans;
}
public void dfs(int x, int pre){
dfn[x] = time;
low[x] = time++;
List<Integer> list = g[x];
for(int y : list){
if(y == pre) continue;
if(dfn[y] == 0){
dfs(y, x);
if(low[y] > dfn[x]){
ans.add(List.of(x, y));
}
low[x] = Math.min(low[x],low[y]);
} else {
low[x] = Math.min(low[x],dfn[y]);
}
}
}
}
1568. 使陆地分离的最少天数
**割点:**在一个无向图中,如果有一个顶点,删除这个顶点以及这个顶点相关联的边以后,图不再连通,就称这个点为割点。
若图中没有或者有两个以上连通分量,答案是 0,否则,如果有割点,答案是 1,否则答案是 2。
并查集 + tarjan 算法找割点
1、用并查集计算有几个连通域,如果是 0 个或者 2 个以上,return 0。
2、用 tarjan 找割点,即找桥。
注意:如果连通域只有 1 个点,是没法找到割点的。
class UnionFind:
def __init__(self, n: int):
self.parent = [-1] * n
self.size = [1] * n
def find(self, x: int) -> int:
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def Union(self, x: int, y: int) -> bool:
px, py = self.find(x), self.find(y)
if px == py: return False
self.parent[px] = py
self.size[py] += self.size[px]
return True
def union(a, b):
pa, pb = find(a), find(b)
if pa == pb: return
p[pa] = pb
size[pb] += size[pa]
class Solution:
def minDays(self, grid: List[List[int]]) -> int:
# 最多 2 步,找个角落就可轻松隔离 1 个角点
# 通过并查集,计算出有几个连通域
m, n = len(grid), len(grid[0])
UF = UnionFind(m * n)
for i in range(m):
for j in range(n):
if grid[i][j] == 1:
xID = i * n + j
if UF.parent[xID] == -1:
UF.parent[xID] = xID # 正式加进并查集
for x, y in ((i - 1, j), (i + 1, j), (i , j - 1), (i, j + 1)):
if 0 <= x < m and 0 <= y < n and grid[x][y] == 1:
yID = x * n + y
if UF.parent[yID] == -1:
UF.parent[yID] = yID #正式加进并查集
UF.Union(xID, yID)
part = sum(UF.parent[i] == i for i in range(m * n))
if part == 0 or part > 1:
return 0
# 连通域为 1 是个无向连通图。再上 tarjan 算法,找割点
# tarjan 找割点
adjvex = defaultdict(set) # 邻接表
for i in range(m):
for j in range(n):
if grid[i][j] == 1:
xID = i * n + j
for x, y in ((i - 1, j), (i + 1, j), (i , j - 1), (i, j + 1)):
if 0 <= x < m and 0 <= y < n and grid[x][y] == 1:
yID = x * n + y
adjvex[xID].add(yID)
adjvex[yID].add(xID)
dfn = [0] * (m * n) # dfs 中被访问的实际时间
low = [0] * (m * n) # dfs中通过无向边可以往前回溯到的最早的时间节点
self.T = 1 # 全局时钟
self.find = False
def tarjan(x: int, parent: int) -> None:
dfn[x] = self.T
low[x] = self.T # 初始化
self.T += 1
child = 0 # 几个儿子
for y in adjvex[x]:
if dfn[y] == 0: # y 还未访问过
child += 1
tarjan(y, x) # 继续递归,dfs
low[x] = min(low[x], low[y])
if parent == -1 and child >= 2: # x 是 root,且有 2 个以上的孩子 x 是 root,是割点
self.find = True
elif parent != -1 and low[y] >= dfn[x]: # 可以回溯的最早时间点比父节点晚,x就是割点
self.find = True
else: # y 访问过了
if y != parent: # 且 y 不是 x 的父亲
low[x] = min(low[x], dfn[y])
for x in range(m * n):
if UF.parent[x] != -1: # 是陆地
if UF.size[UF.find(x)] == 1: # 只有一块陆地的情况。是无法找割点的
return 1
tarjan(x, -1)
break
if self.find == True: return 1
return 2
1489. 找到最小生成树里的关键边和伪关键边
Python 模块 tarjan
tarjan 算法的 python 实现
Tarjan 的算法将有向(可能是循环)作为输入。图形和 以拓扑顺序返回其强连接组件作为输出。
示例
tarjan({1:[2],2:[1,5],3:[4],4:[3,5],5:[6],6:[7],7:[8],8:[6,9],9:[]})
[[9], [8, 7, 6], [5], [2, 1], [4, 3]]
使用
循环依赖性
在各种情况下,依赖项可能是循环的,并且一组相互依赖的 动作必须同时执行。这种情况并不少见 模拟的执行是昂贵的。用Tarjan的算法,我们可以 确定执行相互依赖组的有效顺序 行动。
传递闭包
使用tarjan算法,可以有效地计算传递函数。 图的闭包。(给定一个图g,则g的传递闭包 是一个包含相同顶点并包含v 当且仅当存在从v到g中的w的路径时,w
传递闭包在 tarjan.tc:
中实现。
tc({1:[2],2:[1,5],3:[4],4:[3,5],5:[6],6:[7],7:[8],8:[6,9],9:[]})
{1: (1, 2, 5, 6, 7, 8, 9),
2: (1, 2, 5, 6, 7, 8, 9),
3: (3, 4, 5, 6, 7, 8, 9),
4: (3, 4, 5, 6, 7, 8, 9),
5: (8, 9, 6, 7),
6: (8, 9, 6, 7),
7: (8, 9, 6, 7),
8: (8, 9, 6, 7),
9: ()}
展开组层次结构
给定一个组图,可以使用传递闭包来确定 一个团体的所有间接成员。(某人是一个团体的间接成员, 如果它是某个组的成员,而该组的成员…是某个组的成员 组中的。)
安装
easy_install tarjan
或从此源发行版运行
python setup.py install