题目地址:
https://leetcode.com/problems/graph-valid-tree/
判断一个图是否是一棵树。一个图是一棵树有很多等价条件,这里采用编程容易判断的一种,即顶点数等于边数 + 1并且无环。第一个条件非常好判断,第二个条件无环,判断起来则有一些挑战。
法1:DFS。可以先用邻接表建图,接着从某一个点开始做DFS。首先注意到题目里说,图里是没有平行边的,所以如果有环,其长度必然至少是 3 3 3。那么我们可以从某个点开始做DFS,每访问一个顶点就做一下visited的标记,同时访问的时候,函数参数里要把从哪个点出发访问的这个点,也需要记录进去(比如当前要访问 v v v,上一层递归访问的是 w w w,那么 w w w也要写进函数的signature中)。
如果遍历到某个时刻发现,下一个要访问的顶点已经被访问过,但这个顶点并不是上一层递归访问的点,那就说明有环了。
举个例子,我们从顶点 a a a开始做DFS,如果在某层递归中,我们当前访问的是 v v v,在访问 v v v之前访问的是 u u u, v v v之后接下来要访问 w w w,如果我们发现 w w w已经访问过,但 w ≠ u w\ne u w=u,那就说明存在这样的两条路径从 a a a到 v v v, a , . . . , u , v a,...,u,v a,...,u,v和 a , . . . , w , v a,...,w,v a,...,w,v,因为 w ≠ u w\ne u w=u,所以这两条路径一定是不同的,这样就说明有环了,具体构造方法是,取这两条路径中最后一个相等的点(显然这两条路径起点是相同的,都是 a a a,这里取一下最后一个相等的点),设为 x x x,这样 x , . . . , u , v , w , . . . , x x,...,u,v,w,...,x x,...,u,v,w,...,x就是一个环。代码如下:
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class Solution {
public boolean validTree(int n, int[][] edges) {
// 如果顶点和边不满足关系式,则直接返回false
if (n - 1 != edges.length) {
return false;
}
// 用邻接表建图
Map<Integer, Set<Integer>> graph = new HashMap<>();
for (int i = 0; i < n; i++) {
graph.put(i, new HashSet<>());
}
for (int i = 0; i < edges.length; i++) {
graph.get(edges[i][0]).add(edges[i][1]);
graph.get(edges[i][1]).add(edges[i][0]);
}
boolean[] visited = new boolean[n];
for (int i = 0; i < n; i++) {
// 如果顶点i未被访问过,则对其进行DFS。
// 本次DFS后,与i连通的点将都会被访问掉。DFS返回一个boolean类型,代表有没有环
if (!visited[i] && dfs(graph, visited, i, i)) {
// 有环则返回false
return false;
}
}
return true;
}
// 从s出发做DFS,parent指访问s之前上一个访问的节点。返回值是有没有环,有则返回true,没有返回false
private boolean dfs(Map<Integer, Set<Integer>> graph, boolean[] visited, int s, int parent) {
// 先标记s为访问过
visited[s] = true;
// 遍历s的邻居,逐个访问
for (int neighbor : graph.get(s)) {
if (!visited[neighbor]) {
dfs(graph, visited, neighbor, s);
} else if (neighbor != parent) {
// 如果发现某个邻居访问过,但不是在s之前刚刚访问的,就说明有环,返回true
return true;
}
}
// 否则说明无环,返回false
return false;
}
}
时空复杂度 O ( N + E ) O(N+E) O(N+E)。
法2:并查集。基本思想是这样的,构造一个并查集,每次遍历一条边的时候先查一下两个顶点是否已经属于同一个集合了,如果是,那就说明有环。
具体证明如下:如果 u u u和 v v v同属一个集合,说明有一条从 u u u到 v v v的路径。接下来,如果某一条边正好是 ( u , v ) (u,v) (u,v),说明存在两条从 u u u到 v v v的路径,其中一条是走一步,另一条是从并查集的树根绕过去,这两条路径必然是不同的。根据法1相同的推理可以知道,有环。代码如下:
public class Solution {
public boolean validTree(int n, int[][] edges) {
if (n - 1 != edges.length) {
return false;
}
// 记录顶点i的parent顶点
int[] parent = new int[n];
for (int i = 0; i < parent.length; i++) {
// 初始化每个i的parent为自己
parent[i] = i;
}
for (int i = 0; i < edges.length; i++) {
int pRoot = find(parent, edges[i][0]);
int qRoot = find(parent, edges[i][1]);
// 如果发现同属于一个集合,说明有环,返回false
if (pRoot == qRoot) {
return false;
}
// 否则做union操作
parent[pRoot] = qRoot;
}
return true;
}
// 这里采取了路径压缩。在找树根的同时压缩路径
private int find(int[] parent, int p) {
while (p != parent[p]) {
// 如果p不是树根,就把它的树根挪上去,同时p自己向上走一步
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
}
空间复杂度 O ( N ) O(N) O(N),时间复杂度 O ( N + E log V ) O(N+E\log V) O(N+ElogV),初始化parent需要 O ( N ) O(N) O(N),接着每次find需要 O ( log V ) O(\log V) O(logV),一共find了 O ( 2 E log V ) O(2E\log V) O(2ElogV)次。