图算法趣味学——深度优先搜索

106 阅读22分钟

当我们考虑图搜索算法时,会自然产生几个问题:为什么我们要搜索图?我们在寻找什么?创建图时不是已经找到了所有节点了吗?某种程度上,术语“图搜索”未能充分体现这些算法的通用性。图搜索算法提供了一种系统遍历图中所有节点的机制。我们可以利用这种能力搜索特定节点,比如在迷宫中寻找隐藏的宝藏,或者枚举并分析整个图结构。

我们将从深度优先搜索开始探讨图搜索。这种算法通过从当前节点出发,沿着一条边深入探索图中的节点,不断深入直到无法继续,然后回溯尝试其他路径。它可以说是本书中最强大、灵活且实用的图算法之一,是后续更高级算法的核心逻辑基础。

深度优先搜索之所以如此有用,是因为它简单且易于适应。它可以用相对简单的递归函数实现,并且稍作扩展,就能收集大量关于图的信息。正因如此,它就像是厨房里的多功能搅拌机,既能帮我们做简单的面包,也能做华丽的婚礼蛋糕。

本章将介绍深度优先搜索的应用场景,接着讲解递归版和基于栈的实现方法。我们还将展示如何利用深度优先搜索确定图的连通分量,并讨论两个有用的扩展:深度优先搜索树和迭代加深搜索。

应用场景

为了帮助理解深度优先搜索的工作原理及其用途,我们来看几个生活中的例子。

探索树篱迷宫

想象你站在一座庞大树篱迷宫的入口。紧张感油然而生,但你告诉自己这不是传说中的希腊迷宫,没有牛头怪在等待攻击无辜的冒险者。你面对的只是一个考验空间感和导航能力的多英亩挑战。无聊的青少年管理员嘀咕着一句不那么安慰的话:“他们通常会在关门前巡视迷宫,找回迷路的游客。”

在这里,图搜索对应于从入口节点出发,搜索图中一个特殊节点——出口。如图 4-1 所示,有多种方式可以将迷宫表示为图。图 4-1(a) 展示了迷宫的形状。图 4-1(b) 中,将迷宫空间划分为多个小格子,每个格子是一个节点,节点间通过可达的相邻格子连接形成边。或者如图 4-1(c) 所示,仅为入口、出口和决策点创建节点,这些特殊节点之间的路径即为图中的边。

image.png

我们将在本章中多次回到这个迷宫的例子,作为一种有趣且直观的方式,帮助我们想象自己在图中漫游。迷宫示例还为标记节点或选择哪条边提供了生动的现实类比。更重要的是,我们可以在任何节点添加怪物增加刺激——毕竟,最精彩的迷宫都少不了一些危险。

学习新学科

学习一门新学科也可以看作是一个图的探索问题。每个节点代表一个感兴趣的子主题,边则表示它们之间的引用关系。图搜索则代表了在各个子主题之间的学习旅程。目标不在于到达某个特定节点,而是尽可能覆盖相关的知识图谱部分。

例如,考虑地质学这一大主题。一位勇敢的学生决心尽可能多地了解这门学科,从岩石这一主题开始。随着他们对每个子主题的学习,他们构建了一个相关知识的图,如图 4-2 所示。学习过程深入挖掘细节。对火成岩的引用引发了对黑曜石的兴趣,随后是火山,接着是经典的“苏打粉与醋火山”科学项目。另一条路径则带他们了解变质岩和大理石,进而探索室内装饰和地板安装的相关知识。

image.png

图 4-2 中的图远远不完整。不仅许多有趣的主题(例如俯冲带和铝土矿)被遗漏了,而且主题之间的联系也比图中展示的要多得多。许多不同的岩石都会与共同的元素或矿物相连接。要探索这一领域的整个知识图谱可能需要一生时间。正如我们将在本章和下一章看到的,不同的搜索方式会对我们探索主题的顺序产生深远影响。

检查可达性

在日常生活中,我们常常想知道从某个起点节点是否存在通往某个目标节点的路径。例如,我们可能用航班图检查两座城市之间是否可以旅行,或者用社交网络图来判断谣言是否能从一个人传到另一个人。

考虑图 4-3 所示的人际网络。每个节点代表一个人,节点 uu 到节点 vv 的边表示人 uu 愿意与人 vv 分享信息。由节点 0 发现的有用信息可以通过图传播给节点 1、3 和 4。对于节点 3 来说,这条信息首先通过节点 1 和节点 4 传递。然而,节点 2 和节点 5 完全被排除在外,因为没有通往他们的信息分享路径。

image.png

我们将在本章后面探讨无向图的可达性和连通分量的概念,并在第12章讨论针对有向图的相关算法。目前,了解图搜索如何回答可达性问题就足够了。给定起点节点 SS 和目标节点 GG,我们只需从节点 SS 开始搜索,检查是否能找到节点 GG。如果找到了,则说明两者之间存在某条路径。

递归深度优先搜索(DFS)

我们通常将深度优先搜索实现为递归算法,其核心功能针对每个节点调用一次。本节展示该搜索的代码,并演示其在示例图上的执行过程。

代码示例

清晰版的递归深度优先搜索代码见下:

def dfs_recursive_basic(g: Graph, ind: int, seen: list): 
    seen[ind] = True  # ❶ 标记当前节点已访问
    current: Node = g.nodes[ind]

    for edge in current.get_edge_list():
        neighbor: int = edge.to_node
        if not seen[neighbor]:  # ❷ 若邻居未被访问,递归搜索邻居节点
            dfs_recursive_basic(g, neighbor, seen)

def depth_first_search_basic(g: Graph, start: int):
    seen: list = [False] * g.num_nodes
    dfs_recursive_basic(g, start, seen)

清单4-1:递归深度优先搜索核心函数

递归辅助函数接收图 gg、当前探索节点索引 indind 以及一个记录每个节点是否访问过的列表 seenseen。代码首先标记当前节点为已访问 ❶,并获取该节点的数据结构。随后遍历节点的所有边,对未访问的邻居节点递归调用搜索 ❷。

外层函数负责初始化 seenseen 列表,并从指定起始节点 startstart 启动深度优先搜索。该函数仅从单个起点开始搜索,因此只访问能从该点到达的节点。若要访问图中所有节点,需要对每个未访问节点启动搜索。如下清单4-2所示,外层函数扩展为遍历所有节点,对每个未访问节点调用递归搜索:

def depth_first_search_basic_all(g: Graph): 
    seen: list = [False] * g.num_nodes
    for ind in range(g.num_nodes):
        if not seen[ind]:  # ❶ 对每个未访问节点启动递归搜索
            dfs_recursive_basic(g, ind, seen)

清单4-2:访问图中所有节点的深度优先搜索

代码先初始化 seenseen 列表,然后遍历所有节点索引,检查该节点是否在之前的搜索中访问过 ❶,若没有,则从该节点开始新的深度优先搜索。

虽然此代码完成了深度优先搜索,但没有记录任何路径信息。这就像在迷宫里悠闲地散步,却不记录解决方案。接下来我们考虑一个简单的改进:记录路径。这相当于带着笔记本进入迷宫,记录每次选择的方向。

下面的代码展示了如何在深度优先搜索中记录路径,使用一个额外的列表 lastlast,表示当前节点前面访问的节点索引:

def dfs_recursive_path(g: Graph, ind: int, seen: list, last: list): 
    seen[ind] = True
    current: Node = g.nodes[ind]

    for edge in current.get_edge_list():
        neighbor: int = edge.to_node
        if not seen[neighbor]:
            last[neighbor] = ind  # ❶ 记录邻居节点的前驱节点
            dfs_recursive_path(g, neighbor, seen, last)

def depth_first_search_path(g: Graph) -> list: 
    seen: list = [False] * g.num_nodes
    last: list = [-1] * g.num_nodes

    for ind in range(g.num_nodes):
        if not seen[ind]:
            dfs_recursive_path(g, ind, seen, last)
    return last

递归函数的开始与前述基本版本相同。当前节点被标记为已访问,获取该节点数据,并检查每个邻居是否已访问。只有访问新节点时,行为才有不同——在递归调用前记录该邻居节点的前驱是当前节点 ❶。正如第三章讨论的,lastlast 列表包含了重建搜索路径所需的全部信息。

外层函数同样初始化并传入该前驱节点列表 lastlast,其中 1-1 表示没有前驱节点。搜索结束后值为 1-1 的节点即为各次深度优先搜索的起点节点。

一个示例

图4-4展示了在一个包含10个节点的无向图上进行递归深度优先搜索的示例。每个子图都显示了当前节点在函数中被标记为“已访问”后的状态。虚线圆圈表示当前正在探索的节点。阴影节点表示已经访问过(因此被标记为“已访问”)的节点。last 向量显示了搜索过程中图中路径的演变。

搜索从图4-4(a)中的节点0开始,节点0有三个邻居:节点1、节点5和节点7。我们可以将其想象成一个探险者在迷宫中探险(比树篱迷宫更刺激一点)。节点0代表探险者站在第一个路口,正在考虑前方的三条分叉路。他们并不知道哪条路通向出口,哪条路是死胡同。

搜索选择了第一个邻居节点1,递归触发深度优先搜索。在图4-4(b)中探索节点1时,我们可以看到last已经更新,表明节点1是从节点0到达的。就像探险者从路口0走到路口1时,会在笔记本上记录这一步,为后续探险保留信息。

image.png

深度优先搜索总是从一个节点移动到与之直接相连的另一个节点。类似地,我们的探险者会沿着一条路一直走,直到遇到死胡同。然后,他们可能满是恐慌和惶恐,回溯尝试其他路径,同时拼命希望不要遇到怪物或讽刺的迷宫管理员。因为回溯就是返回到相邻的房间,这在物理上是合理的。

搜索会遍历整个图,按照节点索引递增的顺序递归探索邻居。在迷宫的例子中,这对应于探险者在迷宫中深入探索,并在遇到死路时回溯。由于图的结构和深度优先搜索的特性,到达某个节点的路径不一定是最短的。例如,虽然节点5可以直接从节点0到达,但搜索是通过路径 [0, 1, 2, 4, 9, 8, 5] 才遇到它的。

在这个例子中,所有节点都可以从单个起始节点访问到。但正如上一节所指出的情况,这不一定总是如此。如代码清单4-2所示,我们可能需要从不同的起始节点启动多次深度优先搜索,以便完全覆盖整个图。

深度优先搜索的简单性也可能是缺点。决定探索哪个邻居是任意的(这里是基于索引顺序),而不是基于我们可能掌握的世界信息。如果我们的探险者所在的迷宫出口在西边,他们可能会优先向西走而不是向东。我们将在第8章看到一些引入启发式信息的方法。

使用栈的深度优先搜索

我们也可以不用递归,而是通过栈来实现迭代版的深度优先搜索。

代码

代码清单4-3使用Python标准列表作为栈(append方法充当传统的栈的push操作)。

def depth_first_search_stack(g: Graph, start: int) -> list: 
    seen: list = [False] * g.num_nodes
    last: list = [-1] * g.num_nodes
    to_explore: list = []

    ❶ to_explore.append(start)
    ❷ while to_explore:
        ❸ ind = to_explore.pop()
        if not seen[ind]:
            current: Node = g.nodes[ind]
            seen[ind] = True

            ❹ all_edges: list = current.get_sorted_edge_list()
            ❺ all_edges.reverse()
            for edge in all_edges:
                neighbor: int = edge.to_node
                if not seen[neighbor]:
                    last[neighbor] = ind
                    to_explore.append(neighbor)
    return last

清单4-3:基于栈的深度优先搜索

迭代深度优先搜索的代码从初始化辅助数据结构开始。除了 seenlast 列表外,函数还使用一个名为 to_explore 的栈来跟踪将来需要探索的节点索引。函数一开始将初始节点压入 to_explore 栈中 ❶。

函数的大部分工作是在一个 while 循环中完成的,该循环不断迭代 to_explore 栈中的元素,直到栈为空 ❷。每次迭代时,从栈顶弹出一个索引 ❸,如果该节点尚未访问,则进行探索。与递归函数一样,代码获取节点的数据结构并将该索引标记为已访问。随后获取所有边的列表 ❹。通过一个 for 循环遍历所有出边;代码为尚未访问的节点设置其 last 值,并将它们压入栈中。

为了与其他示例保持顺序一致,代码在清单4-3中将边列表反转,以按邻居索引递减顺序检查邻居节点 ❺。但这并不是算法的必要步骤。

示例

图4-5展示了使用栈执行的迭代深度优先搜索的过程。与图4-4类似,当前节点用虚线圆圈标示,已访问节点为阴影。

image.png

基于栈的实现与递归实现有两个有趣的区别。首先,last 数组会在节点被访问之前更新,反映通向该节点的最新路径。在图4-5(b)中,last 列表显示节点5的路径来自节点0,因为搜索时发现节点5是节点0的邻居。然而,随着深度优先搜索的进行,节点5对应的条目被更新了。在图4-5(d)中,搜索通过节点2找到了通向节点5的更近路径。在图4-5(h)中,又通过节点8找到了一条路径。

其次,待探索的节点栈中会出现重复元素,比如图4-5(h)中节点5出现了三次。这是因为,正如前面所述,深度优先搜索在深入探索时可能会发现通向同一节点的多条路径。这些重复不会影响算法的准确性,因为每次从栈中弹出索引时都会检查该节点是否未被访问过。但它们会增加内存消耗。通过修改代码并付出额外的运行时间代价,我们可以扩展代码,保证栈中只保留索引的最高一份。

递归实现和基于栈的实现的差异,就像我们的探险者记录自己在迷宫中行进的方式。在两种方法中,探险者都会在“已访问”笔记本里记录访问过的房间。但在基于栈的方法中,探险者还会维护第二本笔记本“待探索”。与递归方法中只走入未访问的相邻房间不同,基于栈的方法中,探险者会仔细记录当前房间所有未访问的相邻房间,出发前会检查笔记本里最近记录的房间,然后前往那个房间。

查找连通分量

我们可以用深度优先搜索来找到无向图中的连通分量集合。如第3章所述,无向图中的连通分量是一组节点,其中任意节点都能到达该组内的任意其他节点。如果我们从图中的某个节点开始深度优先搜索,它只会访问该起点能到达的节点集合。在无向图中,这些访问到的节点组成了一个连通分量。通过对任意未访问节点重新启动深度优先搜索(如清单4-2所示),我们可以映射出图中所有的连通分量。

代码

下面的代码从每个未访问的节点开始深度优先搜索,同时维护每个节点所属连通分量的信息:

def dfs_recursive_cc(g: Graph, ind: int, component: list, curr_comp: int):  
    ❶ component[ind] = curr_comp
    current: Node = g.nodes[ind]

    for edge in current.get_edge_list():
        neighbor: int = edge.to_node
        ❷ if component[neighbor] == -1:
            dfs_recursive_cc(g, neighbor, component, curr_comp)

def dfs_connected_components(g: Graph) -> list:
    component: list = [-1] * g.num_nodes
    curr_comp: int = 0

    for ind in range(g.num_nodes):
        if component[ind] == -1:
            ❸ dfs_recursive_cc(g, ind, component, curr_comp)
            curr_comp += 1

    return component

代码修改了递归深度优先搜索函数,使其用一个列表 component 同时跟踪节点是否访问过,以及它所属的连通分量编号。递归函数开始时为当前节点标记分量编号 ❶。在探索邻居前,会检查邻居是否已经属于某个分量(即是否访问过) ❷。

外层函数初始化辅助数据结构,包括节点到分量编号的映射列表 component 和当前分量编号计数器 curr_comp。和清单4-2中的遍历全图搜索类似,代码遍历每个节点,检查是否访问过。如果没访问过,就从该节点开始深度优先搜索 ❸。每次搜索都会填充更多的 component 列表条目。

示例

图4-6展示了该算法在一个有三个连通分量的图上的执行情况。阴影圆圈表示每次迭代后已访问的节点,虚线圆圈表示该轮搜索的起始节点。

image.png

图4-6(a)显示了第一次搜索前图的状态,此时所有节点都未被访问,也未被分配连通分量编号。正如图4-6(b)所示,第一次搜索从节点0开始,找到了连通分量{0, 1, 2, 4}。图4-6(c)显示第二次搜索从第一个未被访问的节点3开始,找到了连通分量{3, 7}。最后一次搜索如图4-6(d)所示,从节点5开始,找到了连通分量{5, 6}。

深度优先搜索树和森林

如果我们保存深度优先搜索过程中遍历的边,就可以捕捉关于搜索结构和图本身结构的有用信息。上一节中的连通分量只是其中一种类型的信息。考虑对一个无向图(如图4-7(a)所示)进行搜索。

image.png

搜索遍历节点(及边)的顺序定义了一种树结构,称为深度优先搜索树(有时简称为深度优先树),用来总结这次搜索过程。搜索过程中遍历的每条边都会被包含在这棵树中。节点的层级关系由深度优先搜索遇到它们的顺序决定。如果搜索从节点 u 进展到一个未访问过的节点 v,那么在树中 u 就是 v 的父节点。或者,结合本章代码中的 last 数组概念,树中索引为 i 的节点的父节点是 last[i]。图4-7(b)展示了从节点0开始搜索时对应的深度优先搜索树。

深度优先搜索树并非唯一,它依赖于搜索的起始节点,如图4-8所示。在图4-8(a)的同一无向图中,如果从节点2开始搜索,会得到图4-8(b)所示的不同深度优先搜索树。

image.png

如前所述,单次深度优先搜索可能无法遍历整个图,这意味着为了完整覆盖,我们可能需要执行多次深度优先搜索。这就引出了深度优先搜索森林的概念(或简称深度优先森林),其中每次单独的深度优先搜索都会生成一个以起始节点为根的树结构。整个森林就是这些单独树的集合。如图4-9所示,对于无向图中存在不连通的部分时,这种情况自然产生。图4-9(a)中不连通的两个部分 {0, 1, 2, 3, 4, 6} 和 {5, 7, 8} 在图4-9(b)中分别形成了两棵不同的树。

image.png

在有向图中,是否需要进行多次搜索取决于我们选择的起始节点。图4-10展示了一个有向图及其深度优先搜索森林的示例。图4-10(a)显示了原始的有向图,而图4-10(b)展示了按节点索引递增顺序遍历时得到的深度优先搜索森林。

image.png

因为我们是按索引递增的顺序进行检查,所以会先检查节点5,而不是节点7或8。这导致在我们的森林中,节点5形成了一个独立的树,因为从节点5无法到达其他节点。然而,节点5是可以被节点7和8到达的。如果我们先检查节点7或8中的任意一个,那么节点5就会包含在它们对应的树中。森林的结构既由图的结构决定,也由我们搜索节点的顺序决定。

在后续章节中,我们将利用深度优先搜索树的结构来帮助理解深度优先搜索本身的行为。现在,你只需知道这些树捕捉了深度优先搜索在给定图中遍历过程中的信息。

迭代加深(Iterative Deepening)

深度优先搜索的一个主要缺点是,当存在更近的目标状态时,它可能会浪费大量时间在很长(或很深)的死胡同里。想象一下,一个洞穴探险者在地下洞穴系统中迷路了。前方分叉出多条路径,有些通向地面,有些则越走越深。为了活着出去,他们希望采用一种搜索策略,不至于需要走十英里远的地下路才碰上死路,然后被迫返回尝试另一条路。

迭代加深是一种限制深度优先搜索中过深路径的策略。算法不是沿着一条路径一直走到底,而是在预设的最大深度处停止探索。如果整个搜索没有找到目标,迭代加深会增加最大深度并重新开始搜索。这个过程持续进行,直到找到目标或遍历完整个图。

想象迷路的洞穴探险者用一条固定长度的绳索系住自己。绳索限制了他们愿意深入洞穴的最大距离。他们沿着一条路径走,直到绳索尽头。即便前方还有通道,他们也会回头,去探索绳索范围内能到达的其他路径。只有当所有可能的路径都走过后,他们才会换用更长的绳索。这防止了他们在错误方向上走得太远,而忽略了其他选择。

乍一看,迭代加深似乎非常浪费时间。因为它会多次探索附近的节点(随着最大深度逐渐增加),距离起始节点一步的节点会在每次迭代中被反复探索。同样,我们的洞穴探险者也会多次访问第一个分叉点。

然而,这种方法在某些情况下却非常有用。比如考虑如图4-11所示的树状图。普通的深度优先搜索会沿着单一分支一直深入到底,如果树很深,这可能涉及很多节点。

image.png

相比之下,如图4-12所示,迭代加深有效地按层级逐层搜索这棵树。在第一次迭代(最大深度为1)中,只探索了三个节点;在第二次迭代(最大深度为2)中,探索了七个节点,其中包括四个新节点。

image.png

对于像图4-11中那样的平衡完全二叉树,每次迭代所花费的时间大约是前一次的两倍,探索的节点数也约是前一次的两倍。正如我们将在下一章看到的,迭代加深的搜索模式与广度优先搜索非常相似。

为什么这很重要

深度优先搜索是一个核心的图算法,我们将在本书后续章节中广泛使用它,并基于其简单的递归形式构建许多扩展。在图算法领域,这种搜索是一个基础的构建模块。后续章节中,我们将利用图搜索算法(包括多种深度优先搜索的变体)来揭示有向图中节点的内在顺序,或用于图着色中的节点标记。

不幸的是,深度优先搜索并非总是完美的解决方案。它在选择下一个要探索的节点时不使用启发式信息,更糟糕的是,它容易在冗长的死胡同中浪费时间。

后续章节将介绍基于深度优先搜索的技术,以及一些避免其缺点的替代搜索算法。但首先,我们将探讨另一种搜索方式:广度优先搜索。