关于Tarjan算法求割点的几个细节

  • Post author:
  • Post category:其他


一、引言

可恶,昨天写的长篇大论忘记保存草稿了。算了,不写原理那些东西了,直接上代码,解释几个细节。

二、代码

class Solution {
	// 链式前向星必备:边结构、头数组、边数组
	class Edge {
		int to;
		int next;
	}

	private int[] head;
	private Edge[] edges;

	private int clk = 1; // 访问顺序,全局唯一

	private int[] dfn;
	private int[] low;

	private List<Integer> cutPoints = new ArrayList<Integer>();

	/*
	 * 算法入口,主要是处理一下传入的边,以及对所有节点进行DFS
	 * 
	 * @param n 节点的数量 edgesList 边列表,包含了一条边的正向和反向
	 * 
	 * @return 割点列表
	 */
	public List<Integer> tarjan(int n, List<int[]> edgesList) {
		if (edgesList.size() == 0)
			return cutPoints;

		int m = edgesList.size();

		dfn = new int[n + 1];
		low = new int[n + 1];
		head = new int[n + 1];
		edges = new Edge[m + 1];

		int k = 1;
		for (int[] tmp : edgesList) {
			int from = tmp[0];
			int to = tmp[1];

			// 我的edgesList里面包含了一条边的正向和反向,因为我在外部主类中预先处理过了
			// 所以这里创建链式前向星的边时,不需要正向创一条,反向创一条
			edges[k] = new Edge();
			edges[k].to = to;
			edges[k].next = head[from];
			head[from] = k++;
		}

		// 这个图可能是非连通的,因此要从每一个未走过的节点开始DFS,但是上一轮走过的节点不会在下一轮走到
		for (int i = 1; i <= n; i++) {
			if (dfn[i] == 0) {
				dfs(i, i, 0);
			}
		}

		return cutPoints;
	}

	void dfs(int root, int x, int father) {
		low[x] = dfn[x] = clk++;

		int child = 0;
		int next = head[x];
		boolean isCut = false;

		while (next != 0) {
			int to = edges[next].to;

			if (dfn[to] == 0) {
				child++; // 细节(1)

				dfs(root, to, x);
				low[x] = Math.min(low[x], low[to]);

				if ((root == x && child >= 2) || (root != x && low[to] >= dfn[x]))
					isCut = true;

			} else if (to != father && dfn[to] < dfn[x]) { // 细节(2)
				low[x] = Math.min(low[x], dfn[to]); // 细节(3)
			}

			next = edges[next].next;
		}

		if (isCut) // 细节(4)
			cutPoints.add(x);
	}
}

三、细节解释

下面每个标号对应代码注释中的细节标号

(1)这个细节的重点是,child++的位置,应该放在if(dfn[to] == 0) { } 这个代码块里。

首先,判断一个节点是不是割点有两种情况。

情况一,

如果节点u是根节点,且它有至少两个不通过本节点连通的子节点(不知道为什么有人说找子树,树不是没有环的吗?这里的子节点应该理解为DFS路径上,后到的节点为前到的节点的子节点),则它是一个割点

。这里理解为,如果本节点是大哥,两个子节点是小弟,如果两个小弟之间绕过大哥勾结在一起,那么大哥被干掉了,他们还是好兄弟。如果两个小弟没有私下勾结,那么大哥被干掉了,他们就分道扬镳了。

情况二,

如果节点u不是根节点,而它的子节点中至少有一个节点v的low[v]>=dfn[u],则它是一个割点

。这里理解为,如果本节点是大哥手下的一个小队长,这个小队长下面又有很多小弟,但是小弟不能越过小队长去联系大哥。如果小队长被干掉了,小弟们就找不到大哥了。

对于第一种情况,我们就是要计算一个节点的不通过本节点连通的子节点数,为什么child++一定要放在那个位置呢?看下图。如果从1开始走,依次走过1、2、3,则dfn[1]、dfn[2]、dfn[3]分别为1、2、3。回溯到节点1后,它尝试走3,发现3已经走过了,说明在本轮DFS中,1通过别的子节点到达过这个子节点,也就是这个子节点和1的至少另一个子节点是不通过本节点连通的。这里不用担心dfn[i] != 0是因为上一轮走过,因为在一轮DFS中,必然会把一个连通分量的所有点走完,也就是如果dfn[i] != 0是上一轮导致的,那么这一轮一定不会有一个节点会访问i。

只有当dfn[i] == 0的时候,说明找到了和别的子节点不连通的一个子节点。这时候如果把当前节点割掉,那么那些不通过当前节点连通的子节点就分开了。

(2)细节二

看了很多人的博客和代码,这个地方是最让我疑惑的。很多人都这么写,一个是to != father,另一个是dfn[to] < dfn[x]。

关于to != father,其实这一句不加也可以。很多人都说返祖边指向的节点不可以是父节点,但是我思来想去,觉得是可以的。我觉得这个to != father的作用不在于返祖边不可以指向父节点,而在于优化!也就是如果返祖边指向的是父节点,就根本不需要作low[x] = Math.min(low[x], dfn[to])这一步了,而一个 if(to != father) 的花费时间肯定比 low[x] = Math.min(low[x], dfn[to]) 要少,所以就加上了它。不知道这么理解有没有问题,欢迎指正。

关于dfn[to] < dfn[x],其实这一句也是不加也可以。它的作用也是优化。如下图,如果从1开始走,依次走过1、2、3、4,则dfn[1]=1,dfn[2]=2,dfn[3]=3,dfn[4]=4。回溯到节点2后,它将尝试走4,发现dfn[4]>dfn[2],说明4不是2的祖先,所以没必要作下一步操作了。

(3)细节三

这个地方同样很让人疑惑。很多人觉得奇怪,为什么在上面low[x] = Math.min(low[x], low[to]),但是到了下面就是low[x] = Math.min(low[x], dfn[to])。我们尝试一下全部使用low[x] = Math.min(low[x], low[to])会有什么结果。如下图,如果从1开始走,走到先依次走过1、2、3,当走到3的时候,low[3]更新为low[1],即1。接着依次走过4、5,当走到5的时候,low[5]更新为low[3],即1。那么在回溯的过程中,4、3、2、1的low全部变成了1。

某非根节点u是割点的充分条件是其存在某个子节点v,使得low[v]>=dfn[u]。

对于5、4、3、2这几个非根节点来说,它们所有的子节点的low都小于它们自身的dfn。而对于1这个根节点来说,它不存在两个及以上的不通过1连通的子节点,所以最后找不到割点。但是用肉眼都可以看出来,3是一个割点。所以不可以全部都使用low[x] = Math.min(low[x], low[to])。

(4)细节四

发现当前节点是割点以后,要等它遍历完它的所有子节点才能把它加入到cutPoints中,否则会出现重复。如下图,假如从1开始走,2会在cutPoints中出现两次,自己试一试吧。

四、总结

其实四条细节中,只有(2)和(3)具有普遍性,(1)和(4)是针对我个人的代码而言的,因为不同的编程方式具有不同的细节。如果有错误,欢迎指出。



版权声明:本文为W__M__W原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。