文章

算法刷题系列(三十八):图论 DFS——遍历连通分量和结构

算法刷题系列(三十八):图论 DFS——遍历连通分量和结构

算法刷题系列(三十八):图论 DFS——遍历连通分量和结构

深度优先搜索(DFS)沿一条路径尽可能向前,走不动时回退。它可以遍历图、计算无向图的连通分量,也能通过访问状态识别有向图中的回边和环。不同图类型的“遇到访问过的点”含义不同,不能套用同一个判环条件。

无向图的连通分量

无向图中,从任意尚未访问的顶点启动一次 DFS,就能访问它所在的整个连通分量。外层循环扫过所有顶点,每启动一次就得到一个新的分量。标记要在递归前完成,避免邻接边让搜索重复进入同一顶点。

Java:分量计数与有向环检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.List;

public class GraphDfs {
    public static int countUndirectedComponents(List<List<Integer>> graph) {
        boolean[] visited = new boolean[graph.size()];
        int count = 0;
        for (int node = 0; node < graph.size(); node++) {
            if (!visited[node]) {
                count++;
                visitUndirected(graph, node, visited);
            }
        }
        return count;
    }

    private static void visitUndirected(
        List<List<Integer>> graph, int node, boolean[] visited
    ) {
        visited[node] = true;
        for (int neighbor : graph.get(node)) {
            if (!visited[neighbor]) {
                visitUndirected(graph, neighbor, visited);
            }
        }
    }

    public static boolean hasDirectedCycle(List<List<Integer>> graph) {
        int[] color = new int[graph.size()]; // 0=未访问,1=当前路径,2=已完成
        for (int node = 0; node < graph.size(); node++) {
            if (color[node] == 0 && directedDfs(graph, node, color)) {
                return true;
            }
        }
        return false;
    }

    private static boolean directedDfs(
        List<List<Integer>> graph, int node, int[] color
    ) {
        color[node] = 1;
        for (int neighbor : graph.get(node)) {
            if (color[neighbor] == 1) {
                return true;
            }
            if (color[neighbor] == 0 && directedDfs(graph, neighbor, color)) {
                return true;
            }
        }
        color[node] = 2;
        return false;
    }
}

Python 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def count_undirected_components(graph: list[list[int]]) -> int:
    visited = [False] * len(graph)

    def visit(node: int) -> None:
        visited[node] = True
        for neighbor in graph[node]:
            if not visited[neighbor]:
                visit(neighbor)

    count = 0
    for node in range(len(graph)):
        if not visited[node]:
            count += 1
            visit(node)
    return count

def has_directed_cycle(graph: list[list[int]]) -> bool:
    color = [0] * len(graph)  # 0=未访问,1=当前路径,2=已完成

    def visit(node: int) -> bool:
        color[node] = 1
        for neighbor in graph[node]:
            if color[neighbor] == 1:
                return True
            if color[neighbor] == 0 and visit(neighbor):
                return True
        color[node] = 2
        return False

    for node in range(len(graph)):
        if color[node] == 0 and visit(node):
            return True
    return False

assert count_undirected_components([[1], [0], [3], [2], []]) == 3
assert has_directed_cycle([[1], [2], [0]])
assert not has_directed_cycle([[1], [2], []])

判环为什么要区分图类型?

在有向图中,回到当前递归路径上的灰色节点(颜色 1)说明存在有向环;回到已经完成的黑色节点(颜色 2)只说明两条路径汇合,不一定有环。

在无向图中,邻接表会包含父子边的反向边,看到“已访问的邻居”本身不能证明有环:必须排除刚刚经过的那条父边。简单无向图可用 neighbor != parent 判断;含平行边的多重图则应记录边编号,因为同一对端点间的第二条边会构成环。上面用三色 DFS 的判环函数特意处理有向图,不应拿它直接代替无向图判环。

正确性、复杂度与注意事项

外层循环只在未访问顶点上启动遍历,DFS 又恰好沿可达边访问所有邻点,因此每个连通块计数一次。三色算法中,灰色节点表示当前 DFS 路径祖先;沿有向边回到祖先即可构成闭合有向路径。黑色节点不在当前路径上,不足以判环。

邻接表下,DFS 时间为 O(V+E),访问数组和递归栈总空间 O(V)。递归深度在极长链图中可达 V,可能超过语言栈限制;生产代码或大规模输入可改用显式栈,或用迭代模拟进入/退出状态。

连通分量仅对无向图按通常定义成立;有向图要讨论强连通分量或弱连通分量,需要不同算法或不同建图方式。

本文由作者按照 CC BY 4.0 进行授权