技术文档DFS & BFS

DFS & BFS

算法
内容

二叉树遍历

  • 和二叉树有关的数据结构有Binary Heap和Binary Search Tree
  • 我们通常从(二叉)树的最重要的顶点:节点 开始。
  • 如果给定的树不是"rooted",我们可以选择任何一个顶点并将其指定为根。并让中立向下拉动其余部分之后,我们有一个有根的(向下)树。
  • 二叉树的遍历:前序(中左右),中序(左中右),后序(左右中)

一般图 vs 二叉树

在一般图中,我们没有根节点的概念。相反,我们需要选择一个不同的节点作为遍历的起始点,即源点 s 。 我们还有一个节点的 0, 1, ..., k 个邻点,而不仅仅是 ≤ 2。 我们可能或者实际上很可能)在我们的一般的图具有循环,无论是像 u → v → u 那样的简单的环,还是像 a → b → c → a 这样的不简单的环。 但不用担心,图的遍历是一个具有两种经典算法的简单问题:DFS 和 BFS。

spanning Tree生成树 也就是说,生成树是在不破坏原图(无向连通图)连通性的前提下,选出的一组边,使得:

  • 没有环(避免冗余的连接);
  • 能连接所有节点(确保通信/连接性);
  • 边的数量是固定的:对于有 n 个顶点的图,生成树总有 n-1 条边。

DFS

Graph实现方法

def dfs(graph, start, target):
    stack = [start]  # 用栈来模拟深度优先
    while stack:
        node = stack.pop()  # 每次取栈顶元素
        print(f"Visiting: {node}")
        if node == target:
            print(f"Found {target}!")
            return True
        # 把相邻节点放进去(注意,这里可以逆序放保证顺序一致)
        stack.extend(reversed(graph[node]))
    print(f"{target} not found.")
    return False

避免循环

DFS的基本形式用一个大小为 V 个顶点的数组 status[u] 来确定两种情况 分别为 u 已经被访问过了 或者没有被访问过。只有当 u 还没有被访问过的时候 DFS才可以访问顶点 u.

记住路径

DFS 用另一个大小为 V 个顶点数组 p[u] 来记住在DFS遍历路径上每一个顶点 uparent/predecessor/previous(父/祖先/前)顶点。 最开始的顶点的祖先也就是 p[s] 被设定为-1也就是说它没有祖先 (因为最低的顶点是顶点0).

时间复杂度

DFS 的时间复杂度是 O(V+E) ,因为:

  1. 每个节点只访问过一次,因为 DFS 将仅递归地探索节点 u 如果 status[u] = unvisited — O(V)
  2. 每次访问完一个节点,都会探索其所有 k 个邻点,因此在访问所有节点之后,我们已检查了所有 E 边 — (O(E) ,因为i每个节点的邻点总数等于 E)。
  • V 表示图中的顶点数(Vertices),用 E 表示图中的边数(Edges)
  • ==Adjecent list==保证了时间复杂度, Graph里面有介绍

BFS

想象一下静止的水,然后你扔石头。石头撞击水面的第一个位置是源点的位置,并且随后在水面上的波纹效应类似于 BFS 遍历模式。

实现方法

BFS 从源点 s 开始,但它在更深入之前使用 queue 尽最宽可能地将访问序列排序。

from collections import deque

def bfs(graph, start, target):
    queue = deque([start])  # 用队列来模拟广度优先
    while queue:
        node = queue.popleft()  # 每次取队头元素
        print(f"Visiting: {node}")
        if node == target:
            print(f"Found {target}!")
            return True
        queue.extend(graph[node])  # 把邻居们加到队列尾部
    print(f"{target} not found.")
    return False

# 测试
bfs(Graph, 'A', 'E')

时间复杂度

BFS的时间复杂度是 O(V+E),因为:

  1. 每一个顶点都被访问一次 因为它们只能进入队列一次— O(V)
  2. 每当一个顶点从队列中出队时,所有它的 k 个邻居都会被探索 所以当所有的顶点都被访问过后,我们一共探索了 E 条路径 — (O(E) 因为每个顶点的邻居总数为 E). 对于DFS来说 O(V+E) 只有在用邻接表时和DFS分析相同

DFS&BFS 应用

可达性检测

测试图中的节点 s 和一个(不同的)节点 t 是否可达,即直接连接(通过一条边)或间接连接(通过简单的非环路径),则可以调用 O(V+E) DFS(s) (或 BFS(s)) 并检查是否 status[t] = visited

显示遍历路径

  • 使用 DFS(深度优先搜索)BFS(广度优先搜索) 来遍历一个图。在遍历过程中,每当从某个顶点 u 到达一个新顶点 v,你就可以记录路径,比如:p[v]=u
  • 遍历结束后,你想打印从起点到某个目标点 t 的路径。你可以使用一个递归函数来"倒着走",从 t 一步步回溯到起点.(回溯法)
method backtrack(u)
  if (u == -1) stop
  backtrack(p[u])
  output vertex u

假设你从 s = 0 出发,目标是 t = 4

  1. 用 DFS 或 BFS 得到前驱数组 p
  2. 调用 backtrack(4),它会打印出路径,比如:0 → 1 → 3 → 4

分辨/计数/标记 一个无向图的连通分量(CCs)

通过简单地调用 O(V+E) DFS(s) (或 BFS(s)) ,并枚举所有 status[v] = visited 的节点 v,来枚举从无向图中的节点 s 可到达的所有节点。 status[{0,1,2,3,4}] = visited,因此它们都是从节点 0 可到达的节点,即它们形成一个连通分量(CC)

CC = 0
for all u in V, set status[u] = unvisited
for all u in V
  if (status[u] == unvisited)
    CC++ // 我们可以用CC计数器的数量来作为CC的标记
    DFS(u) // 或者 BFS(u), 来标记它的成员为已访问
output CC // 上面的示例图的答案是3
// CC 0 = {0,1,2,3,4}, CC 1 = {5}, CC 2 = {6,7,8}

探测图是否有圈(cyclic)

为了判断是否有环,我们引入一个变量:status[u],用于记录每个顶点在 DFS 中的状态。

  • 未访问(unvisited)
    • 初始状态。DFS 还没有访问过这个节点。
  • 探索中(exploring)
    • DFS 现在访问到了这个节点,正在递归地探索它的邻居。
    • 说明我们正沿着某条路径往下探,当前在处理 u
  • 已探索完(explored)
    • 这个节点的所有邻居也都已经被递归访问完了,DFS 已经从它返回了。
    • 也就是说,整条路径探索完毕,u 可以"放心退休"了 207-课程表-DFS解法
def has_cycle(V, adj_list):
    # 状态数组:0=未访问,1=探索中,2=已探索
    status = [0] * V  # 初始都未访问

    def dfs(u):
        status[u] = 1  # 标记为探索中
        for v in adj_list[u]:
            if status[v] == 1:
                # 发现回边,说明有环
                return True
            elif status[v] == 0: # 只判断了0和1 跳过了完全探索的2
                if dfs(v):
                    return True
        status[u] = 2  # 所有邻居都探索完了
        return False

    # 处理可能不是连通图的情况
    for i in range(V):
        if status[i] == 0:
            if dfs(i):
                return True

    return False

拓扑排序(只在有向无圈图 DAG中)

什么是拓扑排序

拓扑排序是:对一个有向无环图(DAG)中的所有顶点排序,使得每一条有向边 u → v 中,u 都出现在 v 的前面。 亦可理解为对某点 v 而言,只有当 v 的所有源点均出现了,v 才能出现。

  • 如果图表示的是"先做这个,再做那个"的任务依赖关系,
  • 那么拓扑排序就是安排任务的一个合法顺序,先做前置任务,再做后续任务。

应用举例

  1. 课程安排(有前置课程的课程计划)

  2. 编译依赖(文件 A 依赖于 B,先编译 B)

  3. 任务调度(某任务必须在另一任务之后)