图算法趣味学——强连通分量

130 阅读19分钟

前几章使用无向图的连通分量来回答诸如“我们能从这里到达某个特定地点吗?”或“移除这条边会破坏图的连通性吗?”之类的问题。一旦引入方向性,这类问题以及相应的算法就变得更复杂。在研究有向图的可达性时,仅仅说“我们可以从 A 到 B”已经不够。我们还需要了解是否可以从 B 回到 A,如果不能回去,这又会如何影响图中的通行。

本章探讨强连通分量的概念,即有向图中一组节点,使得集合中的任意节点都可以到达集合中的任意其他节点。这些分量有助于我们理解图的结构以及如何遍历图。我们将从形式上介绍强连通分量的概念,并提供示例代码来检查一组节点是否强连通。随后,我们描述强连通分量的一些实际应用,包括模拟计算机程序在某些状态中卡住的情况,以及信息在社交网络中的流动方式,最后展示一个用于识别图中强连通分量的示例算法。

强连通分量的定义

有向图中强连通分量的正式定义是:在有向图中,V' ⊆ V 是一个极大节点集合,满足对任意两个节点 u ∈ V' 和 v ∈ V',都存在从 u 到 v 的有向路径。换句话说,从同一个强连通分量中的任意节点出发,都可以到达该分量中的任意其他节点。如果有向图中的每个节点都属于同一个强连通分量,则称该图为强连通图。

我们可以通过交通网络来形象理解强连通分量的重要性。回到第 11 章介绍的邪恶巫师设计的魔法迷宫,为了阻碍冒险者乱闯,巫师使用单向门连接迷宫的各个房间,如图 12-1 所示。这样每条路径都有预设的流向。例如,冒险者可以使用房间 A 与 B 之间的门从 A 到 B,但无法反向通行。

image.png

这种方法的效果超出了巫师的预期。他们原本只是希望阻止冒险者回头从背后袭击手下,没想到结果是某些房间从其他房间变得无法到达。迷宫中包含多个强连通分量:{A, B, D, E}、{C} 和 {F}。冒险者可以在房间 A → B → E → D → A 的路径上自由游走而不受单向门的限制。然而,一旦他们需要离开该分量,就会陷入困境。进入房间 C 的冒险者很快发现无法返回房间 A、B、D 或 E。随着时间推移,冒险者最终被引导到房间 F,从而使巫师能够将他们困在该房间,并设置一个 Boss 级怪物。

确定节点间的可达性

理解并构建强连通分量的关键在于确定哪些节点是相互可达的。首先,我们回顾一下“节点 v 可从节点 u 到达”意味着什么。正如第 3 章“可达性”中所述,节点 v 可从节点 u 到达,当且仅当存在一条由有向边组成的连续路径从节点 u 出发并最终到达节点 v。在此定义下,每个节点都可以到达自身——使用空边集即可;我们总能到达当前所在的位置,只需不移动即可。

我们可以定义一个辅助函数 get_reachable(),它使用广度优先搜索(BFS)来获取从给定起始节点(index)在有向图 g 中可到达的所有其他节点集合。我们使用集合数据结构同时跟踪可达节点和当前搜索过程中已访问的节点,如清单 12-1 所示。

def get_reachable(g: Graph, index: int) -> set:  
    seen: set = set()
    pending: queue.Queue = queue.Queue()

    ❶ seen.add(index)
    pending.put(index)

    ❷ while not pending.empty():
        current_index: int = pending.get()
        current: Node = g.nodes[current_index]
        for edge in current.get_edge_list():
            neighbor: int = edge.to_node
            ❸ if neighbor not in seen:
                pending.put(neighbor)
                seen.add(neighbor)

    return seen

清单 12-1:获取从指定节点可到达的节点索引集合

代码首先设置了数据结构:一个用于记录已访问并可达节点的集合 seen,以及一个用于存储待探索节点索引的队列 pending。注意,由于使用了队列数据结构,需要在文件中引入 import queue。初始节点 index 被加入 seen 集合和待探索队列中 ❶。

代码使用 BFS 发现图中所有其他可达节点。在队列不为空的情况下 ❷,代码从队列中取出下一个节点索引,获取对应节点,并通过循环检查它的邻居。如果某邻居之前未访问过,则将其索引加入队列和 seen 集合 ❸。所有节点探索完成后,返回 seen 集合,其中包含从起始节点可达的所有节点。

这个算法就像一个有地图的冒险者计划穿越魔法迷宫一样。冒险者维护一个房间列表(队列)来评估,初始只包含入口房间。每一步,他们取出列表顶部房间,将其标记为已检查,在地图上找到该房间,并检查相邻房间。任何未探索的邻居被加入列表底部,然后在地图上标记勾选。重复此操作,直到列表为空。

判断节点是否强连通

我们可以使用清单 12-1 中的可达性函数定义一个暴力方法,检查一组节点(由索引列表 inds 给出)是否强连通,如下所示:

def check_strongly_connected(g: Graph, inds: list) -> bool: 
    for i in inds:
        reachable = get_reachable(g, i)
        for other in inds:
            if other not in reachable:
                return False
    return True

该代码使用嵌套循环检查组件中每个节点是否都可从其他节点到达。对于 inds 中的每个节点索引,调用 get_reachable() 生成可达节点集合,再检查 inds 中的每个节点是否都在可达集合中。如果有任何节点不可达,则返回 False;否则返回 True

尽管 check_strongly_connected() 函数很简单,仅包含两层循环和一个辅助搜索函数,但效率不高。我们在这里介绍它是因为它提供了一个直观易懂的概览,说明一组节点要强连通需要满足什么条件。这相当于一个冒险者从迷宫的每个可能房间开始新的探索,并记录可达的目的地。对于 |V| 个节点,该函数需要进行 |V| 次搜索,并检查其他 |V|–1 个节点是否在可达集合中。

更糟的是,check_strongly_connected() 函数无法告诉我们是否还有节点缺失于强连通分量。请记住,强连通分量是节点相互可达的极大集合。该函数仅判断列表中每对节点是否相互可达,但不能告知还有哪些节点可能属于该集合。

用例

在图中识别强连通分量对于理解整个图的可能移动路径至关重要。本节提供了一些识别强连通分量的现实应用示例:分析程序中的操作流程、分析网络中的八卦传播、以及评估交通网络的可通行性。

建模计算机程序状态

我们可以将计算机程序的状态建模为有向图。启动状态可能是单个节点,并有边指向加载初始数据、初始化变量、检查网络连接等状态。例如,图 12-2 展示了一个视频游戏中程序状态的示意图。程序的各个状态对应处理用户输入和渲染屏幕等操作。虚线表示核心游戏循环,它形成了一个强连通分量,其中每个状态都可以从其他状态到达。然而,像加载初始数据文件或退出游戏这样的状态并不属于这个分量;一旦加载了初始数据文件,程序就不会再返回到该状态。

image.png

计算机程序可能包含多个强连通分量,每个分量封装不同的动作或逻辑。例如,一个数据分析程序可能有一个强连通分量用于批量处理文件中的数据,另一个强连通分量则用于支持用户交互。

理解八卦网络

识别图的强连通分量的能力可以帮助我们判断信息在有向通信网络中能传播多远。对于这样的网络,强连通分量就是这样一组人:如果其中一个人知道某件事,整个组的人都能知道。也就是说,信息可以从组内的任意一个节点传递到同一组内的所有其他节点。

考虑图 12-3 中的社交网络示意图,每个节点表示一个个体。从节点 u 到节点 v 的一条边表示 u 会将关于新图算法书发布的有趣八卦告诉 v。若不存在边,则表示没有这样的信息传递。如果任何节点分享了一个八卦,这条信息就会传到从起始节点可达的所有节点。

image.png

强连通分量能够帮助我们洞察哪些群体可以完全共享信息。在图 12-3 中,节点 0、1、3 和 4 构成一个强连通分量。任何由节点 4 分享的信息,最终都会被该分量内的其他节点获知。

请记住,信息仍然可以在不同强连通分量之间流动。在图 12-3 中,节点 5 分享的任何信息最终都会通过节点 4 传到节点 0。然而,反之则不成立。由于节点 4 不会将信息传给节点 5,节点 5 和节点 2 将无法获知来自图左侧的秘密,直到图算法书正式发布,他们才会知道相关信息。

规划旅行网络

在规划现实世界的旅行网络时,理解涉及的强连通分量至关重要。在旅行网络中,强连通分量意味着旅行者能够完成往返。如果两个地点不在同一个强连通分量中,例如巫师迷宫中的单向门,穿越网络的人可能会被困在某些位置子集内。

例如,在航空网络中,从城市 u 可达的任意城市 v 必须属于同一个强连通分量。否则,第一分量的飞机和乘客都可能被困在第二分量。如果航空公司提供从多伦多飞往珀斯的航班,则必须提供另一套航班,将飞机和乘客送回多伦多。

注意,航空网络不必严格形成单一连通分量,因为航空公司可以用两个不同的强连通分量服务两个独立市场。例如,它可能运营一个服务佛罗里达城市的通勤网络,另一个网络服务新英格兰地区。但每个子网络必须是强连通的。

同样的考虑也适用于任何旅行网络的设计。如果某个城市区域只有单行道进路而没有出口,将会是一场灾难。通勤者会开车进入该区域,却无法离开,区域很快会因车辆拥堵和持续鸣笛而陷入混乱。

Kosaraju-Sharir 算法

Kosaraju-Sharir 算法(或简称 Kosaraju 算法)是一种实用、易理解且易于可视化的强连通分量查找算法。在其著作《Data Structures and Algorithms》(Addison-Wesley, 1983)中,Aho、Hopcroft 和 Ullman 描述了该方法由计算机科学家 S. Rao Kosaraju 和 M. Sharir 独立发明。该算法通过一对深度优先搜索结合图的反向操作来识别强连通分量。

Kosaraju-Sharir 算法首先对图进行一次深度优先搜索,以回答“从这个起始节点我能到达哪些节点?”在搜索过程中,它记录每个节点的完成时间(也称后序索引),即记录搜索完成处理某节点的顺序。第一个完成的节点记录为 0,第二个为 1,依此类推。在迷宫示例中,这相当于巫师按照深度优先搜索走迷宫,只通过单向门,并记录离开每个房间的最终时间。

回到图 12-1 中升级后的迷宫,巫师在初次检查时禁用了单向门法术,以便自由巡视迷宫。巫师从房间 A 开始检查,依次走到 B、C、F,直到遇到第一个死胡同。由于没有出口,F 的完成顺序为 0。巫师回溯到 C(门保持开放,因为巫师控制迷宫),发现无新路径可走,将 C 的完成顺序设为 1。直到回溯到 B 才找到新路径,依次探索 E、D,完成 B 前需探索此路径。最终完成时间顺序为:F=0,C=1,D=2,E=3,B=4,A=5。第一次搜索的顺序不仅取决于图结构,还取决于起始节点。若巫师从 D 开始,D 将最后完成,顺序为:F=0,C=1,E=2,B=3,A=4,D=5。

算法第二阶段将图边方向反转,再次进行深度优先搜索。这样就从“我能从起始节点到哪些节点?”转变为“如果我想到达这个节点,可以从哪里出发?”这相当于巫师逆着单向门巡视迷宫,深度优先搜索沿着反向门走,确定哪些房间可以到达当前房间。

以图 12-4(a) 的简单图及其反向图 12-4(b) 为例。从节点 0 开始的深度优先搜索将找到从节点 0 可达的节点(此例中为节点 1)。然而,在反向图 12-4(b) 上的相同搜索,将找到可以到达原图中节点 0 的节点(即节点 2)。

image.png

通过在反向图上运行第二次搜索,并按完成时间递减顺序选择起始节点,Kosaraju-Sharir 算法将两个问题结合起来:“从这个起始节点我能到达哪些节点?”以及“如果我想到达这个节点,可以从哪里出发?”在算法的第二阶段,它从每个之前未访问的节点开始新的搜索(在反向图上),并记录哪些节点被新访问到。每次搜索中访问的节点集合就构成了图中的一个强连通分量。

尽管 Kosaraju-Sharir 算法执行了多次深度优先搜索,但每个节点最多只被访问两次:一次在原图中,一次在反向图中。在每次访问期间,算法仅检查当前节点的出边一次,每次搜索所需时间与 |V| + |E| 成正比。反转图需要再次遍历所有节点及其出边,时间复杂度同样与 |V| + |E| 成正比。因此,算法的总体运行时间复杂度为 |V| + |E|。

为什么该算法有效的原理稍复杂,完整证明超出本书范围。有兴趣的读者可以参考《Data Structures and Algorithms》或 Sedgewick 与 Wayne 的《Algorithms, 4th edition》(Addison-Wesley, 2011)等算法教材。现在,我们来看该算法如何在第 4 章和第 11 章的基础上解决一个新问题。

转置图

Kosaraju-Sharir 算法的核心步骤之一是在边方向反转后的图上执行深度优先搜索,这种图称为转置图。该术语来源于矩阵转置操作,即将每条边的方向反转;我们稍后会详细讨论。

对于图的邻接表表示,我们定义 make_transpose_graph() 函数,通过遍历原图的每条边,将逆向边添加到新图中,从而生成转置图:

def make_transpose_graph(g: Graph) -> Graph:  
  ❶ g2: Graph = Graph(g.num_nodes, undirected=g.undirected)
    for node in g.nodes:
        for edge in node.get_edge_list():
            g2.insert_edge(edge.to_node, edge.from_node, edge.weight)
    return g2

make_transpose_graph() 代码首先创建一个空图(g2),节点数与原图相同,并复制原图的无向设置 ❶。然后遍历原图的每个节点及其出边,为每条边在新图中添加反向边,最后返回新图。

可以将该函数在迷宫示例中想象为:一个邪恶巫师的学徒,为了建立自己的声望,决定将迷宫中每扇门的方向反转。他们拿着新地图,参考旧地图的每扇门,将其以反向方向添加到新地图。处理完所有门后,将结果提交给导师,等待必然的赞扬。

注意,虽然 make_transpose_graph() 技术上支持无向图(复制原图的无向设置),但它主要用于有向图。无向图的转置与原图等价。

代码实现

Kosaraju-Sharir 算法使用一个辅助函数来执行每阶段所需的搜索,在未访问节点上执行修改版深度优先搜索,并将每个新访问的节点按完成时间顺序添加到指定列表中:

def add_reachable(g: Graph, index: int, seen: list, reachable: list): 
    seen[index] = True
    current = g.nodes[index]

    for edge in current.get_edge_list():
        if not seen[edge.to_node]:
            add_reachable(g, edge.to_node, seen, reachable)
    reachable.append(index)

函数除了图(g)和当前节点索引(index)外,还接收一个布尔列表(seen)表示节点是否已访问,以及一个节点索引列表(reachable)记录完成顺序。代码先将当前节点标记为已访问,然后遍历未访问的邻居递归搜索,搜索完成后将节点添加到 reachable 列表末尾。递归完成后,列表按完成时间递增排序。

完整 Kosaraju-Sharir 算法代码如下:

def kosaraju_sharir(g: Graph) -> list: 
    seen1: list = [False] * g.num_nodes
    finish_ordered: list = []
  ❶ for ind in range(g.num_nodes):
        if not seen1[ind]:
            add_reachable(g, ind, seen1, finish_ordered)

  ❷ gT: Graph = make_transpose_graph(g)

    seen2: list = [False] * g.num_nodes
    components: list = []
  ❸ while finish_ordered:
        start: int = finish_ordered.pop()
        if not seen2[start]:
            new_component: list = []
          ❹ add_reachable(gT, start, seen2, new_component)
            components.append(new_component)

    return components
  • 第一步:设置第一次搜索的数据结构,seen1 标记节点是否访问,finish_ordered 存储按完成时间排序的节点索引 ❶。通过遍历节点索引,对每个未访问节点执行深度优先搜索。
  • 第二步:反转图边 ❷,创建 seen2 记录第二轮搜索的访问情况,components 保存所有强连通分量。
  • 第三步:按完成时间递减顺序遍历 finish_ordered 列表 ❸,每次从未访问节点开始新搜索 ❹,将新强连通分量的节点索引添加到 components

示例

接下来,我们来看 Kosaraju-Sharir 算法在图 12-5 示例图上的表现。

image.png

算法的第一阶段如图 12-6 所示,按照节点索引递增的顺序处理节点。算法从节点 0 开始执行深度优先搜索,并计算每个节点的完成顺序。节点 4 位于一个长死胡同的尽头,因此最先完成。相比之下,节点 0 直到其深度优先搜索找到另外四个节点后才完成。第一次深度优先搜索结束后,节点 1 仍未被访问,因此算法从节点 1 开始新的搜索。最终的完成顺序为 4、5、3、2、0、1,如节点外所示的完成顺序所示。

image.png

在算法的这一阶段,我们会从每个未访问的节点开始新的深度优先搜索,就像节点 1 的情况一样。那些从节点 0 无法到达的节点会被包括在后续的深度优先搜索中,并被添加到完成顺序的末尾。

在第二阶段,Kosaraju-Sharir 算法对图进行转置,使用一个新的未访问标记数组,并重复最多 |V| 次的深度优先搜索序列。与随意顺序搜索节点(例如按节点索引递增)不同,算法使用第一步完成顺序的逆序选择起始节点:1、0、2、3、5、4。最后完成的节点成为第一次搜索的起点。每当算法在外层循环中遇到未访问节点时,就从该节点开始新的深度优先搜索。该搜索过程中遇到的所有未访问节点都会被加入当前的强连通分量,并标记为已访问。

图 12-7 显示了第二阶段在示例图上进行的三次搜索:从节点 1(图 12-7(a))、节点 0(图 12-7(b)),最后是节点 5(图 12-7(c))开始。虚线圈出的节点表示每次深度优先搜索访问到的节点,而灰色节点表示当前搜索或之前搜索中已被标记为已访问的节点。

image.png

如图 12-6 所示,我们只从未访问的节点开始搜索,且每次独立的搜索不会访问之前搜索中已经访问过的节点。

第一次深度优先搜索(图 12-7(a))从节点 1 开始。在图 12-7(b) 中,转置图的深度优先搜索从节点 0 开始,依次访问节点 3 和节点 2。此时搜索遇到死路并回溯。当搜索返回节点 0 时,我们就知道在原图中,所有既能到达节点 0 又能从节点 0 到达的节点都已访问完毕。第三次也是最后一次深度优先搜索如图 12-7(c) 所示,探索节点 4 和 5。

为什么这很重要

强连通分量可以帮助我们洞察节点子集之间的相互可达性。本章的概念不仅提供了一个实用工具,用于思考现实世界问题,例如交通网络或八卦网络,同时也为理解图的基本结构奠定了基础。例如,识别强连通分量为将大型图划分为有意义的子图提供了一种机制。

本章介绍的算法基于第 4 章的基本深度优先搜索,用于分析图中的可达性并构建强连通分量。Kosaraju-Sharir 算法提供了一个既实用又可视化的方法来寻找连通分量,并展示了如何将搜索算法继续应用于更复杂的问题。

除了本章介绍的算法外,还有多种方法可用于寻找强连通分量。例如,Robert Tarjan 提出了一种只依赖单次深度优先搜索的算法,其原理与第 11 章讨论的算法类似。正如本书涉及的所有主题一样,针对同一问题存在多种方法,各有取舍。本章的目标是为理解和比较这些不同方法提供基础。

下一章将讨论图上的随机游走,基于强连通分量的概念,研究游走如何可能陷入吸收状态或无限徘徊。