图算法趣味学——图的表示

221 阅读24分钟

图是一种抽象数据类型,可以通过多种数据结构来实现。本章介绍图的基本组成部分——节点(Node)和边(Edge),然后展示如何构建两种最常见的图的表示方法:邻接表(adjacency lists)和邻接矩阵(adjacency matrices)。理解图的结构和组成对于充分利用图的能力以及设计高效算法至关重要。

为了实现图结构,我们定义了 Edge、Node 和 Graph 三个类,几乎本书中所有算法都依赖于这三个类。我们会讨论这些类存储了哪些信息,并提供用于操作这些对象的函数。同时,也会探讨不同实现方式的权衡,以及可能的替代方案和混合方案。

图的结构

图由两个部分组成:节点和边。节点(也称为顶点 vertex)表示图中的一个位置或项。节点通常用来建模具体的实体,比如人、城市或计算机。边(也称为链接 link 或弧 arc)连接成对的节点,定义图中的相对关系。边既可以表示具体的物理连接,比如城市之间的道路,也可以表示抽象的关系,比如两个人之间的友谊。

图 1-1 展示了一个包含五个节点和七条边的示例图。我们采用标准的图形表示方法,用圆圈表示节点,用连接两个圆圈的线条表示边。书中许多图示还会在每个圆圈内添加标签,以标识各个节点的身份。

image.png

用数学符号描述图时,我们用 VV 表示节点集合,用 EE 表示边集合。节点和边的数量用集合大小的数学符号表示,即节点数量为 V|V|,边的数量为 E|E|

利用这些简单的组成部分,我们可以表示大量现实世界的系统,并解决各种实际问题。例如,图可以用来建模以下场景:

  • 交通网络
    节点表示城市,边表示路径。我们可以计算两个点之间的最短路径,或者寻找可能导致网络某部分与另一部分断开的单点故障。
  • 迷宫
    节点表示交叉点,边表示连接这些交叉点的通道。我们可以搜索迷宫中的路径。
  • 教育主题
    每个节点代表一个主题,边连接两个相关主题。我们可以根据先修知识对主题进行排序。
  • 社交网络
    节点表示人,边表示他们之间的友谊连接。我们可以模拟信息在网络中的传播,预测谣言如何扩散。

通过允许边提供额外信息,比如方向性和权重,我们可以进一步增强图的表现力,这将在后续小节和章节中详细讨论。

加权边

在几乎所有现实的交通网络中,不同边的通行成本不同。比如我们可能用距离或油费来衡量成本;无论哪种方式,从波士顿开车到纽约的花费要比开车到洛杉矶少。成本的衡量也可以更复杂,比如考虑通过狭窄蜿蜒的山路时的压力。或者,在某些问题中,我们可能考虑成本的倒数,比如节点间连接的强度或沿某条边行进的收益。准确考虑边的成本或收益对于解决许多图论问题(如找到两个地点之间的最短或“最少恐怖”路径)至关重要。

加权边不仅捕捉节点之间的连接,还反映了沿这些连接通行的成本或收益。在某些应用中,权重显而易见且易于获得,比如城市间的距离。例如,匹兹堡到克利夫兰的边权重可以设置为 133,代表两城间有 133 英里的高速公路。在其他场景中,权重可能代表更抽象的概念,如友谊的强度。蒂娜(Tina)和鲍勃(Bob)之间连接的权重为 10,可能表示他们是最好的朋友,而蒂娜与爱丽丝(Alice)之间的权重为 1,则表示他们只是普通熟人。通常可以根据上下文判断权重代表的是成本还是收益。

带有加权边的图称为加权图(weighted graphs),不带权重边的图称为无权图(unweighted graphs)。我们通常用数字标签标注在表示边的线条旁边来直观展示边的权重。例如,在图 1-2 中,三条边的权重为 1.0,一条边权重为 2.0,其余三条边的权重为 3.0。

image.png

如果有必要,我们也可以用加权图来模拟无权边,比如给所有边统一赋予相同的权重(如1.0),或者在算法中忽略权重属性。

有向边

在某些系统中,节点之间的连接并不是对称的。举例来说,考虑一栋建筑中从热水器到厨房水龙头的水管。除非管道严重损坏,否则水是不可能从水龙头流回热水器的。

有向边用于表示两个节点之间连接的方向性。我们采用与现实交通网络相对应的术语:有向边起点所在的节点称为起点(origin)或起始节点(from_node),有向边指向的节点称为终点(destination)或目标节点(to_node)。

虽然有向边可以表示物理上的方向性,比如单行道,但我们也可以用它们来建模更抽象的方向性,比如教育机构中的先修课程关系。如果每个节点代表一门课程,有向边可能表示“计算机科学导论”是“高级图算法”的先修课,如图1-3所示。

image.png

我们称带有有向边的图为有向图。没有有向边的图(如图1-1和图1-2所示)则称为无向图,边称为无向边。

我们可以用有向边来扩展之前的社交网络模型。理想情况下,所有友谊都是双向互惠的,但现实中情况并非总是如此。比如,蒂娜(Tina)和鲍勃(Bob)可能互相称对方为最好的朋友。然而,虽然爱丽丝(Alice)认为蒂娜是亲密好友,蒂娜却只是把爱丽丝当作工作上的一个熟人。

图1-4展示了一个带有有向边的示例图,其中每条有向边用一个箭头表示其方向。

image.png

我们可以通过成对使用有向边(每对边方向相反)来表示有向图中节点之间的对称或无向关系,如图1-4中底部两个节点之间所示。这样,我们就能对包含有向和无向关系混合的系统进行建模。例如,现实世界的交通网络中既有单行道也有双向道路,许多社交网络中也包含相互的友谊关系。通过使用有向图及对应的边对,我们可以完整地建模这些系统。

具有权重和方向的边

为了最大化图的表示能力,我们可以将加权边和有向边结合使用,如图1-5所示。这种表示方式使图能够同时捕捉每个连接的方向性以及其成本或收益。

image.png

我们必须为两个节点之间的每条有向边指定单独的权重,但正如图1-5中底部两个节点的例子,这些权重不必相等。例如,在模拟通过一条道路的成本时,我们可能会选择对上坡方向赋予远高于下坡方向的成本。根据不同的应用场景,比如规划骑行路线,上坡路段的成本可能会显著更高。同样,蒂娜(Tina)和爱丽丝(Alice)对她们的友谊赋予不同的重要程度。

在本书中,我们将使用同时支持权重和方向的图实现。如果有必要,这些数据结构依然可以用来存储没有权重或无向边的简单图。虽然这种通用性给数据结构带来了一定的复杂度,并可能为不使用全部信息的算法增加一些额外开销,但这种方式使得数据结构更加灵活,能够被多种算法使用。

邻接表表示法

本书大部分内容采用的图表示方法是邻接表表示法,它将图的结构存储为每个节点对应的一组邻居列表。这让我们能够模拟现实中的现象,比如在社交网络中,每个节点维护它与本地连接的节点的信息,比如个人保存了他们直接好友的联系信息。

实现邻接表表示法的方法多种多样。节点和边可以通过关联隐式表示,也可以显式地作为独立的数据结构表示。在最简单的实现中,我们可以用一个列表的列表来隐式存储图,每个节点有一个数字ID,列表中的每个条目对应该节点的邻居。例如,考虑下面这一行代码:

g: list = [[1,3,4], [0,2,4], [1,4], [0,4], [0,1,2,3]]

这个邻接表 g 表示了图1-6中展示的具有5个节点和7条边的无向无权图。列表中第一个条目表示节点0有三个邻居:节点1、3和4。每条无向边在两个不同的邻居列表中都有表示,分别对应边两端的节点。

image.png

或者,我们可以创建一个包含邻接表以及辅助信息的节点(Node)数据结构。这些辅助信息可能包括用于标识节点的标签(label)、表示节点是否已处理的布尔值(Boolean),或者记录首次访问该节点时间的整数(integer)。我们还可以通过定义包含方向性和权重信息的边(Edge)数据结构,使表示更加详细,然后在每个节点中存储相邻边的列表。

对于任何具体的使用场景,最优的数据结构表示高度依赖于其用途。在内存有限的大型图中,类似图1-6中列表的稀疏表示可能是理想选择。然而,在模拟更复杂的问题时,比如考虑不同道路条件下的定向交通流量,我们可能需要存储更多信息。

本节剩余部分介绍了一种结构化程度较高的邻接表表示方法,重点在于通用性和易理解性,以便在本书不同算法中反复使用。我们使用 Edge 和 Node 对象来方便地存储各种辅助信息。每个 Node 对象维护着自身的相邻 Edge 对象列表,这些 Edge 对象存储了编码权重和方向性所需的信息。

此实现的一个重要方面是,每个节点都有一个唯一的数字索引,用以指示其在整体图结构中的位置。在本书中,我们会将节点和其索引相对等同地使用。例如,我们称索引为0的节点为“节点0”。当函数返回节点索引列表时,我们也可能说它返回了访问过的节点列表。

正如本书后续所示,这种图表示方式非常适合逐节点遍历图的算法,这也是本书大部分算法的工作方式。虽然该实现适合演示多种图算法,但读者也可能希望针对具体问题采用更节省内存或计算效率更高的表示方法。

边(Edges)

我们将 Edge 对象定义为一个存储有向和带权边信息的容器:

  • to_node (int) 存储边的目标节点索引
  • from_node (int) 存储边的起始节点索引
  • weight (float) 存储边的权重。对于无权边的情况,我们会使用值1来表示

如图1-7所示,Edge 对象存储了我们可能需要独立于其他类处理边的所有信息。Edge 类中包含 from_node 似乎有些多余,因为边存储在每个节点的列表中,可以从节点处获取该信息。但显式地存储这条信息使我们能够使用本书后面介绍的、能够独立于节点处理边集合的算法。

image.png

使用 Edge 类的属性,我们定义了一个构造函数来复制这些数据:

class Edge:
    def __init__(self, from_node: int, to_node: int, weight: float):
        self.from_node: int = from_node
        self.to_node: int = to_node
        self.weight: float = weight

由于 Edge 类主要用于存储数据,因此不包含其他函数,属性可以直接访问。我们可以通过存储每个节点对应的一对有向边来模拟图中的无向边。也就是说,节点 A 和节点 B 之间的无向边,会表现为从节点 A 到节点 B 的有向边和从节点 B 到节点 A 的有向边。虽然这会使无向图中存储的边数量翻倍,但强调了灵活性,也允许我们用同一个类来支持多种应用场景。

Edge 类展示了我们在代码中如何使用节点的数字标识符。我们并不存储指向节点的显式链接,而是存储对应节点的整数索引 to_nodefrom_node。当需要访问节点中的其他属性时,我们通过这些索引直接从图的节点列表中查找相应的 Node 对象。

节点(Nodes)

我们定义 Node 对象来存储与节点相关的信息,并提供对这些信息的基本操作。每个 Node 对象包含以下属性:

  • index (int) 存储节点的数字索引
  • edges (dict) 存储从该节点出发的边
  • label (int, string, or object) 可选标签,用于标识节点或标记其当前状态

我们没有使用列表存储边,而是用字典(dict)存储,字典的键为目标节点的整数索引,值为对应的 Edge 对象。此表示法使得我们可以高效地判断“节点 A 和节点 B 之间是否存在边”,而不需要遍历节点 A 的所有边。

可以把 Node 对象想象成高中社交网络中的学生节点。每个学生是一个节点,其学生ID作为索引。edges 字典代表该学生的好友列表。正如之前提到的,每个 Edge 对象可以有方向性和权重,用以完整表达高中同学间复杂的联盟与恩怨。label 字符串可以存储学生的状态信息,比如是否听过最新的谣言。

和 Graph 与 Edge 类一样,我们定义了构造函数以初始化节点状态,并提供一系列辅助函数:

class Node:
    def __init__(self, index: int, label=None): 
        self.index: int = index
        self.edges: dict = {}
        self.label = label

    def num_edges(self) -> int: 
        return len(self.edges)

    def get_edge(self, neighbor: int) -> Union[Edge, None]: 
        if neighbor in self.edges:
            return self.edges[neighbor]
        return None

    def add_edge(self, neighbor: int, weight: float): 
        self.edges[neighbor] = Edge(self.index, neighbor, weight)

    def remove_edge(self, neighbor: int): 
        if neighbor in self.edges:
            del self.edges[neighbor]

    def get_edge_list(self) -> list: 
        return list(self.edges.values())

    def get_sorted_edge_list(self) -> list: 
        result = []
        neighbors = list(self.edges.keys())
        neighbors.sort()

        for n in neighbors:
            result.append(self.edges[n])
        return result

构造函数将整数索引 index 设置为给定值,创建一个空字典 self.edges = {} 用于存储后续的边,标签初始为空(self.label = None)。

Node 类包含各种辅助函数方便操作节点。当实现图时,Edge 类需要先定义,再定义 Node 类。为了支持示例代码中的可选类型提示,我们还需要从 Python 的 typing 库中导入 Union(即在文件开头加上 from typing import Union)。

前两个函数访问节点的边。num_edges() 返回边的数量,get_edge() 返回指定的边或 None(如果该边不存在)。这样可以将查找和存在性检查合并为一个函数。

接下来的两个函数修改节点的连接关系。add_edge() 接收目标索引和权重,创建并插入对应的 Edge 对象;如果该邻居索引已经存在则覆盖,实现更新边权重。remove_edge() 会删除字典中存在的边。

最后两个函数是方便函数,返回节点的边列表。get_edge_list() 返回字典中的边顺序,适合算法访问边列表时使用;get_sorted_edge_list() 返回按邻居索引升序排列的边列表,主要用于本书示例保持统一顺序。

虽然这里用字典存储节点的边(以目标节点索引为键),也可以改为用列表存储。仅存储出边的紧凑列表更节省内存,但查找特定边时速度较慢。反之,为了加快查找速度,每个节点可以存储长度为 |V| 的列表,每个位置对应一个可能的边,不存在的边用 None 表示。字典方法平衡了这两者的优缺点。

图(Graph)类

本书大部分使用的 Graph 类由 Node 对象列表和一些简化计算的辅助信息组成:

  • nodes (list) 存储图的所有节点
  • num_nodes (int) 存储图中节点总数
  • undirected (bool) 标识图是有向还是无向

num_nodesundirected 可从图结构中计算得出,但这里为了方便而存储。

我们总是存储有向边,使用布尔变量 undirected 来调整在有向和无向图上的操作行为。特别是,在后面章节“访问、构建和修改图”中会演示,当图是无向时,我们用 undirected 来插入一对相反方向的有向边。其他常见实现要么用不同函数(如 insert_undirected_edge()),要么为有向和无向图分别写不同实现。我们这里优先考虑数据结构的通用性而非纯粹优化。

基于以上信息,我们可以写出一个简单构造函数,用于构建一个指定节点数、无边的图:

class Graph:
    def __init__(self, num_nodes: int, undirected: bool=False): 
        self.num_nodes: int = num_nodes
        self.undirected: bool = undirected
        self.nodes: list = [Node(j) for j in range(num_nodes)]

构造函数初始化辅助变量,并创建节点列表,但不创建任何边。该实现隐含了每个节点有唯一数字标识符,对应其在 nodes 列表中的位置。

Graph 类还包括多种函数用于创建、搜索、访问和处理图。后续章节将逐步介绍这些通用函数,而不是一次性提供大量代码。

访问、构建和修改图

为了方便访问边,我们接下来在 Graph 类中定义一系列辅助函数:

def get_edge(self, from_node: int, to_node: int) -> Union[Edge, None]: 
    if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError
    return self.nodes[from_node].get_edge(to_node)

def is_edge(self, from_node: int, to_node: int) -> bool: 
    return self.get_edge(from_node, to_node) is not None

def make_edge_list(self) -> list:
    all_edges: list = []
    for node in self.nodes:
        for edge in node.edges.values():
            all_edges.append(edge)
    return all_edges

get_edge() 函数接收起点索引和终点索引,返回对应的边(如果存在)。函数先进行基本的索引有效性检查,然后调用起点节点对应的 get_edge() 函数获取边,若无此边则返回 None。is_edge() 函数仅检查给定起点和终点是否存在边。最后,make_edge_list() 函数动态构造并返回图中所有边的列表。

Graph 类的构造函数会分配指定数量的节点,但不会创建任何边。显然,这样的图还远不能解决实际问题。为了构建有实际意义的图,我们需要同时包含节点和边。因此,我们在 Graph 类中添加一些用于创建和修改邻接图表示的函数。首先,提供根据起点和终点索引添加和删除边的功能:

def insert_edge(self, from_node: int, to_node: int, weight: float): 
  ❶ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

    self.nodes[from_node].add_edge(to_node, weight)
  ❷ if self.undirected:
        self.nodes[to_node].add_edge(from_node, weight)

def remove_edge(self, from_node: int, to_node: int): 
  ❸ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

    self.nodes[from_node].remove_edge(to_node)
  ❹ if self.undirected:
        self.nodes[to_node].remove_edge(from_node)

insert_edge()remove_edge() 函数流程相同:首先检查起点和终点索引是否在图的节点范围内 ❶ ❸,若无效则抛出 IndexError

索引有效时,函数修改起点节点的邻接边列表,插入时调用节点的 add_edge() 函数,删除时调用节点的 remove_edge() 函数。由于我们用同一个类表示有向图和无向图,因此在无向图情况下还需要对应添加 ❷ 或删除 ❹ 逆向边。

我们可以利用这些函数动态构建图。例如,以下代码创建一个包含五个节点的有向图,并插入八条加权边:

g: Graph = Graph(5, undirected=False)
g.insert_edge(0, 1, 1.0)
g.insert_edge(0, 3, 1.0)
g.insert_edge(0, 4, 3.0)
g.insert_edge(1, 2, 2.0)
g.insert_edge(1, 4, 1.0)
g.insert_edge(3, 4, 3.0)
g.insert_edge(4, 2, 3.0)
g.insert_edge(4, 3, 3.0)

这将生成图 1-8 所示的结构。

image.png

虽然我们在构造函数中提供了预分配节点的能力,但对于某些算法来说,探索图的过程中需要动态插入新节点。为方便这一操作,我们还提供了一个用于插入新节点的函数:

def insert_node(self, label=None) -> Node: 
    new_node: Node = Node(self.num_nodes, label=label)
    self.nodes.append(new_node)
    self.num_nodes += 1
    return new_node

insert_node() 函数创建一个新节点,并自动分配下一个索引号作为其标识。然后将该节点追加到节点列表中,节点计数加一,最后返回新节点对象。

本节中的函数为构建图提供了基础构件,但若手动通过大量的 insert_node()insert_edge() 调用来构建较大图,操作将非常繁琐。附录 A 将介绍几个示例算法,这些算法基于此初始函数,能够通过文件或常见问题规范自动生成图。

复制图

最后,我们还在 Graph 类中定义了一个辅助函数,用于生成当前图的一个副本,方便在需要修改图的算法中使用:

def make_copy(self): 
    g2: Graph = Graph(self.num_nodes, undirected=self.undirected)
    for node in self.nodes:
      ❶ g2.nodes[node.index].label = node.label
        for edge in node.edges.values():
          ❷ g2.insert_edge(edge.from_node, edge.to_node, edge.weight)
    return g2

make_copy() 先创建一个新的 Graph 对象 g2,节点数量和无向标志与当前图相同。接着通过两层循环遍历每个节点及其所有出边。对每个节点,复制其标签 ❶;对每条边,将对应的等效边插入到 g2 中 ❷。

复制图的操作使得我们可以应用那些会破坏性修改图结构的算法。例如,在第16章,我们将介绍一种为图节点分配颜色的算法,该算法会迭代地从图中移除节点。

邻接矩阵表示法

另一种强大的图表示法是邻接矩阵。虽然本书的大多数算法主要依赖之前介绍的邻接表表示法,但邻接矩阵表示法对一整类基于数学的算法非常重要。许多算法可以通过矩阵运算来描述或分析。第13章中讨论图上的随机游走时,我们将用到矩阵形式。

图的邻接矩阵表示使用一个矩阵来表示每对节点之间的边的权重。矩阵中第 ii行第 jj 列的值表示从节点 ii 到节点 jj 的边的权重,值为00表示不存在这样的边。用列表嵌套列表表示,下面的矩阵会创建一个五个节点、七条边的无向无权图:

g = [[0, 1, 0, 1, 1],
     [1, 0, 1, 0, 1],
     [0, 1, 0, 0, 1],
     [1, 0, 0, 0, 1],
     [1, 1, 1, 1, 0]]

这对应于图 1-9 所示的图,其中节点0的三个连接由矩阵中对应的非零项表示。

image.png

连接矩阵可以使用任意数值。浮点数条目可以用来表示带权重的边。无向边通过一对匹配的数值表示,使得无向图的连接矩阵是对称的。

为了创建和存储邻接图,我们将在本节中使用基本的 GraphMatrix 类。和 Graph 类一样,我们优化表示方式以便于理解,而非追求计算成本或内存使用的最优。

我们的 GraphMatrix 类包含三个信息:

  • connections(列表的列表)——存储邻接矩阵
  • num_nodes(整数)——存储图中节点的总数
  • undirected(布尔值)——指示这是有向图还是无向图

Graph 数据结构类似,我们允许 GraphMatrix 表示有向图和无向图。通过 undirected 属性指定图的边类型。我们定义一个简单的构造函数,用于构建给定节点数且无边的图:

class GraphMatrix:
    def __init__(self, num_nodes: int, undirected: bool=False): 
        self.num_nodes: int = num_nodes
        self.undirected: bool = undirected
        self.connections = [[0.0] * num_nodes for _ in range(num_nodes)]

该代码将 connections 中的每个条目初始化为0,创建一个没有任何边的图。

我们还定义了一个获取两个节点之间连接权重的函数:

def get_edge(self, from_node: int, to_node: int) -> float: 
    if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError
    return self.connections[from_node][to_node]

代码会检查起点和终点索引是否有效,如果有效,则返回矩阵中对应的浮点数值。

虽然我们用列表嵌套列表存储邻接矩阵以简化说明,但通常更推荐使用针对矩阵运算优化的表示方式,比如流行的 numpy 包。数值计算包会更快并提供各种辅助函数。我们将使用 numpy 或类似数学库实现 GraphMatrix 留给读者作为练习。

不同于 Graph 类,新的 GraphMatrix 对象会预先分配所有存储边信息的空间在主连接矩阵中。我们可以直接设置矩阵中的条目来添加或删除边:

def set_edge(self, from_node: int, to_node: int, weight: float):
  ❶ if from_node < 0 or from_node >= self.num_nodes:
        raise IndexError
    if to_node < 0 or to_node >= self.num_nodes:
        raise IndexError

  ❷ self.connections[from_node][to_node] = weight
  ❸ if self.undirected:
        self.connections[to_node][from_node] = weight

代码检查起点和终点索引是否有效,如无效则抛出错误 ❶。索引有效时,函数设置对应边的矩阵条目 ❷。如果是无向图,函数还会修改矩阵中的对称条目 ❸。

我们可以用 set_edge() 函数添加、删除或修改边。通过设置非零权重添加新边,若两个节点间已有边则更新权重,设置为0则删除边。例如,我们可以用以下代码创建图1-8所示的图:

g: GraphMatrix = GraphMatrix(5, undirected=False)
g.set_edge(0, 1, 1.0)
g.set_edge(0, 3, 1.0)
g.set_edge(0, 4, 3.0)
g.set_edge(1, 2, 2.0)
g.set_edge(1, 4, 1.0)
g.set_edge(3, 4, 3.0)
g.set_edge(4, 2, 3.0)
g.set_edge(4, 3, 3.0)

为什么这很重要

图结构及其底层实现构成了本书中所有算法的基础,并推动了这些算法的发展。决定使用哪种表示方法,需要我们根据具体任务的需求,在内存使用、计算效率和复杂度之间进行权衡。例如,当我们只需遍历一个节点的直接邻居时,邻接表表示可能是最佳选择,因为它允许我们独立访问邻居列表。相反,对于更偏数学性质的算法,我们可能更倾向于使用矩阵表示,这样可以利用现有的数学库。

本章介绍这些实现的目的,不是为了提供一种唯一的标准方法,而是为了引入多种思考图结构的方式以及它们各自内在的权衡。我们还可以基于本章介绍的实现,采用各种混合方法或进一步调整,以针对具体问题优化图的表示。

在接下来的章节中,我们将引入一系列可以通过图来解决的问题,结合本章所介绍的概念和代码展开。对于每个问题,我们都会展示几种实用算法,这些算法能在现实场景中直接应用。下一章我们将从引入“邻居节点”的概念开始,利用算法构建邻域。