Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第18章

第18章用图连接一切

假设我们正在构建一个允许人们互相成为朋友的社交网络。这些友谊是相互的,所以如果 Alice 和 Bob 是朋友,那么 Bob 也是 Alice 的朋友。

我们如何最好地组织这些数据呢?

一个基本的方法可能是使用一个二维数组来存储友谊的列表:

friendships = [
    ["Alice", "Bob"],
    ["Bob", "Cynthia"],
    ["Alice", "Diana"],
    ["Bob", "Diana"],
    ["Elise", "Fred"],
    ["Diana", "Fred"],
    ["Fred", "Alice"]
]

在这里,每个包含一对姓名的子数组表示两个人之间的“友谊”。

不幸的是,使用这种方法,我们没有快速查看 Alice 的朋友是谁的方法。如果我们仔细查看,我们可以看到 Alice 与 Bob、Diana 和 Fred 是朋友。但是对于计算机来确定这一点,它需要检查列表中的所有关系,因为 Alice 可能出现在任何一个关系中。这是 O(N) 的时间复杂度,非常慢。

幸运的是,我们可以做得比这好得多。通过一种称为图的数据结构,我们可以在 O(1) 的时间内找到 Alice 的朋友。

图是一种专门处理关系的数据结构,它能轻松传达数据之间的连接关系。

以下是我们社交网络的可视化图形展示:

在这里插入图片描述

每个人都由一个节点表示,每条线表示与另一个人的友谊关系。举个例子,如果你看 Alice,你可以看到她与 Bob、Diana 和 Fred 是朋友,因为她的节点有连接到他们节点的线条。

图VS树

你可能已经注意到,图看起来与我们在过去几章中处理的树相似。事实上,树是图的一种。这两种数据结构都由相互连接的节点组成。

那么,图和树之间有什么区别呢?

事实是:虽然所有的树都是图,但并非所有的图都是树。
具体来说,要使图被视为树,它不能有循环,并且所有节点必须连接。让我们看看这意味着什么。
图可以有形成循环的节点,也就是说,节点之间形成循环引用。在先前的例子中,Alice 与 Diana 是朋友,Diana 与 Bob 相连,而 Bob 又与 Alice 相连… 这三个节点形成了一个循环。

另一方面,树是不允许有循环的。如果一个图有循环,那么它就不是树。

树的另一个特点是,每个节点都与其他每个节点以某种方式连接,即使这些连接是间接的。然而,一个图可能并非完全连接。

看下面的这个图示:

在这里插入图片描述

在这个社交网络中,我们有两对朋友。然而,任何一对朋友都与另一对朋友中的任何人没有关系。此外,我们可以看到 Vicky 还没有朋友,也许她刚刚几分钟前加入了这个社交网络。然而,在树中,永远不会有一个节点与树的其余部分断开连接。

图论术语

图论有其独特的专业术语。我们习惯称每个数据块为节点,但在“图论术语”中,每个节点被称为顶点(vertex)。顶点之间的线,嗯,也就是连接它们的部分,在图论中有自己的名字,称为边(edge)。通过边连接的顶点被称为相邻的。有些人也将相邻的顶点称为邻居。

在我们的第一个图中,“Alice”和“Bob”的顶点是相邻的,因为它们共享一条边。

我之前提到过,图中可能存在某个顶点与其他顶点没有任何连接的情况。然而,如果一个图中所有的顶点以某种方式连接在一起,这样的图被称为连通图。

图的基本实现

为了代码组织的方便,我们将使用面向对象的类来表示我们的图,但值得注意的是,我们也可以使用基本的哈希表(参见《哈希表的高速查找》)来表示一个基本的图。以下是一个简单的 Ruby 实现,使用哈希表来表示我们的社交网络:

friends = {
  "Alice" => ["Bob", "Diana", "Fred"],
  "Bob" => ["Alice", "Cynthia", "Diana"],
  "Cynthia" => ["Bob"],
  "Diana" => ["Alice", "Bob", "Fred"],
  "Elise" => ["Fred"],
  "Fred" => ["Alice", "Diana", "Elise"]
}

有了图,我们可以在 O(1) 时间内查找 Alice 的朋友,因为我们可以用一步来查找哈希表中任何键的值:

friends["Alice"]

这会立即返回包含 Alice 所有朋友的数组。

有向图

在一些社交网络中,关系不是相互的。例如,一个社交网络可能允许 Alice “关注” Bob,但 Bob 不必回关注 Alice。让我们构建一个新的图来展示谁关注了谁:

在这里插入图片描述

这被称为有向图。在这个例子中,箭头表示关系的方向。Alice 关注 Bob 和 Cynthia,但没有人关注 Alice。我们还可以看到 Bob 和 Cynthia 互相关注。

我们仍然可以使用我们简单的哈希表实现来存储这些数据:

followees = {
  "Alice" => ["Bob", "Cynthia"],
  "Bob" => ["Cynthia"],
  "Cynthia" => ["Bob"]
}

这里唯一的区别是我们使用数组来表示每个人所关注的人。

面向对象的图实现

我演示了如何使用哈希表来实现图,但在接下来,我们将使用面向对象的方法。

以下是一个使用 Ruby 开始的面向对象图实现:

class Vertex
  attr_accessor :value, :adjacent_vertices

  def initialize(value)
    @value = value
    @adjacent_vertices = []
  end

  def add_adjacent_vertex(vertex)
    @adjacent_vertices << vertex
  end
end

Vertex 类有两个主要属性,值(value)和相邻顶点数组(adjacent_vertices)。在我们的社交网络示例中,每个顶点代表一个人,值可能是包含个人姓名的字符串。对于更复杂的应用,我们可能希望在一个顶点内部存储多个数据,例如个人的附加资料信息。

adjacent_vertices 数组包含此顶点连接到的所有顶点。我们可以使用 add_adjacent_vertex 方法将新的相邻顶点添加到给定的顶点。

这是如何使用这个类构建一个有向图,代表图中谁关注了谁的情况:

在这里插入图片描述

alice = Vertex.new("alice")
bob = Vertex.new("bob")
cynthia = Vertex.new("cynthia")

alice.add_adjacent_vertex(bob)
alice.add_adjacent_vertex(cynthia)
bob.add_adjacent_vertex(cynthia)
cynthia.add_adjacent_vertex(bob)

如果我们正在为社交网络构建一个无向图(其中所有的友谊关系都是相互的),如果我们将 Bob 添加到 Alice 的朋友列表中,那么自动将 Alice 添加到 Bob 的朋友列表中会有意义。为此,我们可以修改我们的 add_adjacent_vertex 方法如下:

def add_adjacent_vertex(vertex)
  return if adjacent_vertices.include?(vertex)
  @adjacent_vertices << vertex
  vertex.add_adjacent_vertex(self)
end

假设我们正在调用此方法来将 Bob 添加到 Alice 的朋友列表中。与以前版本相同,我们使用 @adjacent_vertices << vertex 将 Bob 添加到 Alice 的 @adjacent_vertices 列表中。然后,我们在 Bob 的顶点上调用此方法,即 vertex.add_adjacent_vertex(self)。这样也会将 Alice 添加到 Bob 的朋友列表中。

但是,这将导致一个无限循环,因为 Alice 和 Bob 将继续无限调用彼此的 add_adjacent_vertex 方法。因此,我们添加了一行代码,return if adjacent_vertices.include?(vertex),如果 Alice 已经在她的朋友列表中有了 Bob,此行将终止方法执行。

为了简化处理,我们将继续使用连通图(意味着所有顶点在某种方式上相互连接)。在这样的图中,我们可以使用一个 Vertex 类来实现所有未来的算法。总的思路是,如果我们只能访问一个顶点,我们可以从那里找到所有其他顶点,因为所有的顶点都是相连的。然而,值得指出的是,如果我们处理的是一个不连通的图,可能无法仅从一个顶点发现所有的顶点。在这种情况下,我们可能需要将所有图的顶点存储在一些额外的数据结构中,例如数组,以便我们可以访问所有的顶点。(通常会看到图的实现使用一个单独的 Graph 类来包含这个数组。)

[!邻接列表VS邻接矩阵]
我们的图实现使用简单的列表(以数组形式)来存储顶点的相邻顶点。这种方法被称为邻接表实现。然而,值得知道的是,还有另一种实现方式,它使用二维数组而不是列表。这种替代方法被称为邻接矩阵,在特定情况下可以提供一些优势。
这两种方法都很受欢迎,我决定坚持使用邻接表,因为我觉得它更直观。但我建议你也研究一下邻接矩阵,因为它可能很有用,而且特别有趣。

图搜索

图中最常见的操作之一是搜索特定的顶点。在处理图时,“搜索”一词可能有几种含义。简单来说,对于图而言,“搜索”意味着在图中找到特定的顶点。这类似于在数组中搜索值或在哈希表中搜索键值对。

然而,当应用于图时,术语“搜索”通常具有更具体的含义,即:如果我们可以访问图中的一个顶点,我们必须找到与此顶点以某种方式相连的另一个特定顶点。

例如,看看这个社交网络示例:

在这里插入图片描述

假设我们目前可以访问到 Alice 的顶点。如果我们说要搜索 Irena,这意味着我们试图从 Alice 到达 Irena。

有趣的是,你可以看到我们可以有两种不同的路径从 Alice 到达 Irena。较短的路径在下图图表中显示得很明显。

在这里插入图片描述

也就是说,我们可以按照以下顺序从 Alice 到达 Irena:
Alice -> Derek -> Gina -> Irena

然而,我们也可以选择稍微长一点的路径到达 Irena:

在这里插入图片描述

这个更长的路径是:
Alice -> Elaine -> Derek -> Gina -> Irena

术语“路径”是图中的一个官方术语,意味着从一个顶点到另一个顶点的特定边序列。

现在,搜索图(你现在知道意味着从一个顶点到另一个顶点)可以用于各种用例。

也许图搜索最明显的应用之一是在连通图中搜索特定的顶点。在这种情况下,搜索可以用来找到整个图中的任何顶点,即使我们只能访问一个随机的顶点。

图搜索的另一个用途是发现两个顶点是否相连。例如,我们可能想知道在这个网络中,Alice 和 Irena 是否以某种方式相连。搜索可以给我们答案。

即使我们不是在寻找一个特定的顶点,搜索也可以使用。也就是说,我们可以使用图搜索仅遍历图,如果我们想要对图中的每个顶点执行操作,这可能会很有用。你很快就会看到这是如何工作的。

深度优先搜索

有两种广为人知的图搜索方法:深度优先搜索和广度优先搜索。这两种方法都能完成任务,但在特定情况下各自提供独特的优势。我们将从深度优先搜索开始,也称为DFS,因为它实际上与我们在《二叉搜索树遍历》中讨论的二叉树遍历算法非常相似。事实上,它也是我们在《文件系统遍历》中看到的相同基本算法。

如前所述,图搜索可以用于查找特定的顶点,也可以用于简单地遍历图。我们将从使用深度优先搜索来遍历图开始,因为该算法稍微简单一些。

任何图搜索算法的关键是跟踪我们到目前为止访问过的顶点。如果我们不这样做,就可能陷入无限循环。举个例子,看看下面的图:

在这里插入图片描述

在这里,Mohammed 与 Felicia 是朋友。而 Felicia 也与 Zeina 是朋友。但 Zeina 与 Mohammed 是朋友。因此,除非我们跟踪记录我们已经遍历过的顶点,否则我们的代码会陷入循环。

当我们处理树(或文件系统遍历)时,这个问题并不会出现,因为树不可能有循环。但由于图可以有循环,我们现在需要解决这个问题。

跟踪我们访问过的顶点的一种方式是使用哈希表。当我们访问每个顶点时,我们将顶点(或其值)添加为哈希表中的一个键,并分配一个任意的值,比如布尔值true。如果一个顶点存在于哈希表中,这意味着我们已经访问过它。

考虑到这一点,深度优先搜索算法的工作方式如下:

  1. 从图中的任何随机顶点开始。
  2. 将当前顶点添加到哈希表中,标记为已访问过。
  3. 遍历当前顶点的相邻顶点。
  4. 对于每个相邻顶点,如果相邻顶点已经被访问过,则忽略它。
  5. 如果相邻顶点尚未被访问,则对该顶点递归执行深度优先搜索。

深度优先搜索演练

让我们看看这个实例。

在这个演示中,我们将从 Alice 开始。在以下的图表中,带有边框的顶点表示当前的顶点。勾号表示我们已经正式标记了这个顶点为已访问(并将其添加到哈希表中)。

步骤 1:我们从 Alice 开始,并给她打上勾号,表示我们已经正式访问了这个顶点。

在这里插入图片描述

接下来,我们将使用循环迭代 Alice 的邻居,它们是 Bob、Candy、Derek 和 Elaine。首先访问哪个邻居的顺序并不重要,所以我们就从 Bob 开始。他看起来很友好。

步骤 2:现在我们对 Bob 进行深度优先搜索。请注意,这是一个递归调用,因为我们已经在对 Alice 进行深度优先搜索了。对于所有的递归,计算机需要记住仍在进行中的函数调用,因此首先将 Alice 添加到调用栈中。

在这里插入图片描述

现在我们可以开始对 Bob 进行深度优先搜索,这使得 Bob 成为当前顶点。我们标记他为已访问:

在这里插入图片描述

然后我们遍历 Bob 的相邻顶点。这些顶点是 Alice 和 Fred。
步骤 3:Alice 已经被访问过了,所以我们可以忽略她。
步骤 4:那么唯一剩下的邻居是 Fred。我们调用了对 Fred 顶点进行深度优先搜索的函数。计算机首先将 Bob 添加到调用栈中以记住它实际上仍在搜索 Bob:

在这里插入图片描述

我们现在对 Fred 执行深度优先搜索。他现在是当前顶点,所以我们将其标记为已访问,就像图表中显示的那样。

在这里插入图片描述

接下来,我们迭代 Fred 的相邻顶点,即 Bob 和 Helen。
步骤5:Bob 已经被访问过,因此我们忽略他。
步骤6:唯一剩下的相邻顶点是 Helen。我们首先对 Helen 执行递归的深度搜索,因此计算机首先将 Fred 添加到调用栈中。

在这里插入图片描述

我们现在开始对 Helen 进行深度优先搜索。她是当前顶点,因此我们标记她为已访问:

在这里插入图片描述

Helen 有两个相邻的顶点:Fred 和 Candy。
步骤 7:我们已经访问了 Fred,所以可以忽略他。
步骤 8:Candy 还没有被访问过,因此我们对 Candy 执行深度优先搜索。不过在此之前,Helen 被添加到调用栈中:

在这里插入图片描述

我们对 Candy 执行深度优先搜索。她现在是当前顶点,我们将其标记为已访问:

在这里插入图片描述

我们对 Candy 执行了深度优先搜索。她有两个相邻的顶点:Alice 和 Helen。

步骤 9:Alice 已经被访问过,所以我们可以忽略她。
步骤 10:Helen 也已经被访问过,所以我们也可以忽略她。

由于 Candy 没有其他相邻顶点,我们在 Candy 上的深度优先搜索已经完成。此时,计算机开始解开调用栈。首先,它弹出了 Helen。我们已经遍历了她的所有相邻顶点,因此对 Helen 的深度优先搜索已经完成。计算机接着弹出了 Fred。我们也已经遍历了他的所有相邻顶点,因此搜索 Fred 也完成了。接着计算机弹出了 Bob,但我们也已经完成了对 Bob 的搜索。计算机随后将 Alice 弹出调用栈。在我们搜索 Alice 时,我们正在循环遍历 Alice 的所有相邻顶点。现在,这个循环已经遍历了 Bob。(这是步骤 2。)这样就只剩下 Candy、Derek 和 Elaine。

步骤 11:Candy 已经被访问过,所以不需要再对她进行搜索。但是,我们还没有访问 Derek 或 Elaine。

步骤 12:我们继续对 Derek 执行深度优先搜索。计算机再次将 Alice 添加到调用栈:

在这里插入图片描述

Derek 的深度优先搜索现在开始。Derek 是当前顶点,所以我们将其标记为已访问:

在这里插入图片描述

Derek 有三个相邻顶点:Alice、Elaine 和 Gina。
步骤 13:Alice 已经被访问过,所以不需要再对她进行搜索。
步骤 14:接下来让我们访问 Elaine,通过对她的顶点递归执行深度优先搜索。在执行之前,计算机将 Derek 添加到调用栈:

在这里插入图片描述

我们现在对 Elaine 执行深度优先搜索。我们标记 Elaine 为已访问:

在这里插入图片描述

Elaine 有两个相邻顶点:Alice 和 Derek。
步骤 15:Alice 已经被访问过,所以不需要再对她进行搜索。
步骤 16:Derek 也已经被访问过。
由于我们已经遍历了 Elaine 的所有相邻顶点,所以完成了对 Elaine 的搜索。
计算机现在从调用栈中移除 Derek,并循环遍历他的剩余相邻顶点。在这种情况下,Gina 是最后一个要访问的邻居。
步骤 17:我们之前没有访问过 Gina,所以我们对她的顶点进行递归的深度优先搜索。不过,在此之前,计算机再次将 Derek 添加到调用栈中:

在这里插入图片描述

我们开始对 Gina 进行深度优先搜索,并将其标记为已访问,如图中所示。
Gina 有两个邻居:Derek 和 Gina。
步骤 18:Derek 已经被访问过。

在这里插入图片描述

步骤 19:Gina 有一个未访问的相邻顶点,即 Irena。为了递归地对 Irena 进行深度优先搜索,Gina 被添加到调用栈中:

在这里插入图片描述

我们开始对 Irena 进行搜索,并将其标记为已访问:

在这里插入图片描述

我们遍历 Irena 的相邻顶点。Irena 只有一个邻居:Gina。
步骤 20:Gina 已经被访问。

计算机随后逐个解开调用栈。但是,由于调用栈上的每个顶点已经遍历了其所有邻居,计算机对每个顶点没有更多的事情可做了。
这意味着我们完成了!

代码实现:深度优先搜索

以下是深度优先遍历的实现代码:

def dfs_traverse(vertex, visited_vertices={})
  # Mark vertex as visited by adding it to the hash table:
  visited_vertices[vertex.value] = true
  # Print the vertex's value, so we can make sure our traversal really works:
  puts vertex.value
  # Iterate through the current vertex's adjacent vertices:
  vertex.adjacent_vertices.each do |adjacent_vertex|
    # Ignore an adjacent vertex if we've already visited it:
    next if visited_vertices[adjacent_vertex.value]
    # Recursively call this method on the adjacent vertex:
    dfs_traverse(adjacent_vertex, visited_vertices)
  end
end

我们的 dfs_traverse 方法接受一个顶点作为参数,也可以选择性地接受 visited_vertices 散列表。第一次调用此函数时,visited_vertices 开始为空。随着我们访问顶点,我们会用访问过的顶点填充这个散列表,并将其与每个递归调用一起传递。

函数内部的第一件事是标记当前顶点为已访问状态。我们通过将顶点的值添加到散列表中来实现此操作:

visited_vertices[vertex.value] = true

然后,我们可选择打印顶点的值,以确保我们确实遍历了它:

puts vertex.value

接下来,我们遍历当前顶点的所有相邻顶点:

vertex.adjacent_vertices.each do |adjacent_vertex|
  # 如果我们正在迭代的相邻顶点已经被访问过,则跳过循环的下一轮迭代:
  next if visited_vertices[adjacent_vertex.value]
  # 否则,对相邻顶点递归调用 dfs_traversal 方法:
  dfs_traverse(adjacent_vertex, visited_vertices)
end

再次强调,我们也会传递 visited_vertices 散列表,以便后续的调用可以访问它。

如果我们要使用深度优先搜索来实际搜索特定的顶点,我们可以使用前面函数的修改版本:

def dfs(vertex, search_value, visited_vertices={})
  # 如果原始顶点恰好是我们要搜索的顶点,则返回原始顶点:
  return vertex if vertex.value == search_value

  visited_vertices[vertex.value] = true
  vertex.adjacent_vertices.each do |adjacent_vertex|
    next if visited_vertices[adjacent_vertex.value]
    
    # 如果相邻顶点是我们要搜索的顶点,则直接返回该顶点:
    return adjacent_vertex if adjacent_vertex.value == search_value
    
    # 通过对相邻顶点进行递归调用来尝试找到我们要搜索的顶点:
    vertex_were_searching_for = dfs(adjacent_vertex, search_value, visited_vertices)
    
    # 如果通过上述递归能够找到正确的顶点,则返回该正确的顶点:
    return vertex_were_searching_for if vertex_were_searching_for
  end
  
  # 如果我们尚未找到要搜索的顶点:
  return nil
end

这个实现也会对每个顶点进行递归调用,但是如果找到正确的顶点,它会返回 vertex_were_searching_for

广度优先搜索

广度优先搜索(Breadth-First Search,常缩写为BFS),是搜索图的另一种方式。与深度优先搜索不同,广度优先搜索不使用递归。相反,该算法围绕我们的老朋友——队列展开。回忆一下,队列是一种先进先出(FIFO)的数据结构,先进去的元素先出来。

以下是广度优先搜索的算法。与我们对深度优先搜索的遍历进行的讲解一样,我们将专注于使用广度优先搜索进行图的遍历。也就是说,我们将访问来自我们示例社交网络中的每个顶点。

广度优先遍历的算法如下:

  1. 从图中的任意顶点开始。我们将其称为“起始顶点”。
  2. 将起始顶点添加到哈希表中,标记为已访问。
  3. 将起始顶点添加到队列中。
  4. 开始一个循环,当队列不为空时运行。
  5. 在此循环中,从队列中移除第一个顶点。我们称之为“当前顶点”。
  6. 遍历当前顶点的所有相邻顶点。
  7. 如果相邻顶点已经被访问过,则忽略它。
  8. 如果相邻顶点尚未被访问,则将其标记为已访问,添加到哈希表中,并将其加入队列。
  9. 重复此循环(从第4步开始),直到队列为空。

广度优先搜索演练

这并不像看起来那么复杂。让我们一步步地进行遍历。首先,让我们以Alice作为我们的起始顶点。

在这里插入图片描述

我们将标记她为已访问并将她加入队列:接下来我们开始核心算法。

第1步:我们从队列中移除第一个顶点,并将其作为当前顶点。由于目前队列中只有Alice,所以当前顶点就是Alice。因此,在这一点上,队列实际上是空的。

由于Alice是当前顶点,我们继续迭代Alice的相邻顶点。
第2步:我们从Bob开始。我们将标记他为已访问,并将他加入队列:

在这里插入图片描述

请注意,Alice仍然是当前顶点,如她周围的线所示。但是,我们已经将Bob标记为已访问,并将他加入队列。

第3步:我们继续处理Alice的其他相邻顶点。让我们选择Candy,我们将标记她为已访问,并将她加入队列:

在这里插入图片描述

第4步:然后我们标记Derek为已访问,并将他加入队列。

在这里插入图片描述

第5步:我们也对Elaine做同样的操作。

在这里插入图片描述

第6步:现在我们已经迭代了当前顶点(Alice)的所有相邻顶点,我们从队列中移除第一个项目并将其设为当前顶点。在我们的例子中,Bob在队列的最前面,因此我们将他出队,并将他设为当前顶点,如页面352上的图所示。

在这里插入图片描述

第7步:Alice已经被访问过,所以我们忽略她。

第8步:Fred还没有被访问过,所以我们将他标记为已访问,并将他加入队列。

在这里插入图片描述

第9步:Bob没有更多相邻的顶点。这意味着我们从队列中取出第一个项目并将其作为当前顶点。这个顶点是Candy:

在这里插入图片描述

我们遍历Candy的相邻顶点。
第10步:Alice已经被访问过,所以我们再次忽略她。
第11步:另一方面,Helen还没有被访问过。我们标记Helen为已访问,并将她加入队列:

在这里插入图片描述

第12步:我们完成了对Candy相邻顶点的迭代,所以我们从队列中取出第一个项目(Derek),并将其作为当前顶点:

在这里插入图片描述

Derek有三个相邻的顶点,所以我们对它们进行迭代。
第13步:Alice已经被访问过,所以我们忽略她。
第14步:Elaine也是如此。
第15步:这样我们就留下了Gina,因此我们标记她为已访问,并将她加入队列:

在这里插入图片描述

Step 16: 我们访问了Derek的所有直接朋友,所以我们从队列中移除了Elaine,并将她指定为当前顶点。

在这里插入图片描述

Step 17: 我们迭代Elaine的相邻顶点,从Alice开始。但是她已经被访问过了。
Step 18: Derek也已经被访问过了。
Step 19: 我们从队列中取下下一个人(Fred),并将他设为当前顶点。

在这里插入图片描述

Step 20: 我们遍历了Fred的邻居。Bob已经被访问过了。
Step 21: Helen也已经被访问过了。
Step 22: 由于Helen位于队列的最前面,我们将她出队,并将她设为当前顶点。

在这里插入图片描述

Step 23: Helen有两个相邻的顶点。我们已经访问了Fred。
Step 24: 我们也已经访问了Candy。
Step 25: 我们从队列中移除了Gina,并将她设为当前顶点。

在这里插入图片描述

Step 26: 我们遍历了Gina的邻居。Derek已经被访问过了。
Step 27: Gina有一个未访问的邻居,名叫Irena,所以我们访问了Irena,并将她加入队列:

在这里插入图片描述

现在我们完成了对Gina邻居的遍历。
Step 28: 我们移除了队列中的第一个(也是唯一一个)人,即Irena。她成为当前顶点:

在这里插入图片描述

Step 29: Irena只有一个邻居顶点,即Gina,但Gina已经被访问过了。
我们现在应该移除队列中的下一个项目,但是队列是空的!
这意味着我们的遍历已经完成。

代码实现:广度优先搜索

这是我们的广度优先遍历的代码:

def bfs_traverse(starting_vertex)
  queue = Queue.new
  visited_vertices = {}
  visited_vertices[starting_vertex.value] = true
  queue.enqueue(starting_vertex)
  
  # While the queue is not empty:
  while queue.read
    # Remove the first vertex off the queue and make it the current vertex:
    current_vertex = queue.dequeue
    
    # Print the current vertex's value:
    puts current_vertex.value
    
    # Iterate over current vertex's adjacent vertices:
    current_vertex.adjacent_vertices.each do |adjacent_vertex|
      # If we have not yet visited the adjacent vertex:
      if !visited_vertices[adjacent_vertex.value]
        # Mark the adjacent vertex as visited:
        visited_vertices[adjacent_vertex.value] = true
        # Add the adjacent vertex to the queue:
        queue.enqueue(adjacent_vertex)
      end
    end
  end
end

bfs_traverse 方法接受一个 starting_vertex,这是我们开始搜索的顶点。

首先,我们创建推动算法的队列:

queue = Queue.new

然后,我们创建一个 visited_vertices 哈希表,用于跟踪我们已经访问过的顶点:

visited_vertices = {}

接下来,我们标记 starting_vertex 为已访问并将其加入队列:

visited_vertices[starting_vertex.value] = true
queue.enqueue(starting_vertex)

我们开始一个循环,只要队列不为空就继续运行:

while queue.read

我们从队列中取出第一个顶点并将其设为当前顶点:

current_vertex = queue.dequeue

然后,我们打印当前顶点的值,以确保我们的遍历在控制台中正常工作:

puts current_vertex.value

接着,我们遍历当前顶点的所有相邻顶点:

current_vertex.adjacent_vertices.each do |adjacent_vertex|

对于每个尚未访问的相邻顶点,我们将其添加到哈希表中标记为已访问,并将其添加到队列中:

if !visited_vertices[adjacent_vertex.value]
  visited_vertices[adjacent_vertex.value] = true
  queue.enqueue(adjacent_vertex)
end

这就是其主要内容。

深度优先VS广度优先

如果你仔细观察广度优先搜索的顺序,你会注意到我们首先遍历了Alice的所有直接连接。然后,我们螺旋向外,逐渐远离Alice。然而,使用深度优先搜索,我们立即尽可能远离Alice,直到被迫返回到她。

因此,我们有两种搜索图的方法:深度优先和广度优先。有一种方法比另一种更好吗?

正如你可能已经注意到的那样,这取决于你的具体情况。在某些情况下,深度优先可能更快,而在其他情况下,广度优先可能是更好的选择。

通常,确定使用哪种算法的主要因素之一是你要搜索的图的性质以及你正在搜索的内容。关键在于,如前所述,广度优先搜索在移动得更远之前会先遍历距起始顶点最近的所有顶点。另一方面,深度优先搜索会立即尽可能远离起始顶点。只有当搜索到达死胡同时,它才会返回到起始顶点。

所以,假设我们想要找到社交网络中一个人的所有直接联系。例如,我们可能想要在先前的示例图中找到Alice的所有真正的朋友。我们对她朋友的朋友不感兴趣,我们只想要她的直接联系列表。

如果你看一下广度优先的方法,你会发现我们在转到她的“二度”联系之前立即找到了Alice的所有直接朋友(Bob、Candy、Derek和Elaine)。

然而,当我们使用深度优先算法遍历图时,我们最终接触到了Fred和Helen(两个并非Alice的朋友的人)之后才找到了Alice的其他朋友。在一个更大的图中,我们可能会浪费更多时间遍历许多不必要的顶点。

但是,让我们考虑一个不同的情景。假设我们的图表示一个家谱,可能如下所示:

在这里插入图片描述

这个家谱展示了Great-Grandma Ruby的所有后代,一个美好家族的自豪女族长。假设我们知道Ruth是Ruby的曾孙女,并且我们想在图中找到Ruth。

现在,关键在于,如果我们使用广度优先搜索,我们将不得不在到达第一个曾孙女之前遍历所有Ruby的子女和孙子。然而,如果我们使用深度优先搜索,我们将立即沿着图向下移动,在几步内就能到达第一个曾孙女。虽然我们可能需要在找到Ruth之前遍历整个图,但我们至少有机会迅速找到她。然而,使用广度优先搜索,我们别无选择,只能在开始检查曾孙女之前遍历所有非曾孙女。

因此,我们需要始终询问的问题是,我们是想在搜索过程中保持靠近起始顶点,还是我们明确想要远离起始顶点。广度优先搜索适用于保持接近,而深度优先搜索则是迅速远离的理想选择。

图搜索的效率

让我们分析使用大O符号表示法的图搜索的时间复杂性。

在深度优先搜索和广度优先搜索中,我们在最坏的情况下遍历了所有顶点。最坏的情况可能是我们打算执行完整的图遍历,或者我们可能正在搜索一个在图中不存在的顶点。或者,我们正在搜索的顶点可能恰好是我们检查的图中的最后一个顶点。

在任何情况下,我们都会触及图中的所有顶点。乍一看,这似乎是O(N),其中N是顶点的数量。

然而,在这两种搜索算法中,对于我们遍历的每个顶点,我们还要迭代其所有相邻的顶点。如果相邻的顶点已经被访问过,我们可能会忽略它,但我们仍然需要花费一步来检查该顶点,看看我们是否已经访问过它。

因此,对于我们访问的每个顶点,我们还需要花费步骤来检查顶点的每个相邻的邻居。这似乎很难用大O符号表示,因为每个顶点可能有不同数量的相邻顶点。

让我们通过分析一个简单的图来澄清这一点:

在这里插入图片描述

在这里,顶点A有四个邻居。相比之下,B、C、D和E每个都有三个邻居。让我们计算搜索该图所需的步骤。

至少,我们必须访问这五个顶点。这单独需要五个步骤。

然后,对于每个顶点,我们要迭代其每个邻居。

这将增加以下步骤:
A: 遍历4个邻居需要4步
B: 遍历3个邻居需要3步
C: 遍历3个邻居需要3步
D: 遍历3个邻居需要3步
E: 遍历3个邻居需要3步

这总共是16次迭代。

因此,我们有访问这五个顶点,再加上16次迭代相邻邻居。总共是21步。

但是,这里有另一个包含五个顶点的图:

在这里插入图片描述

这个图有五个顶点,但是对相邻邻居的迭代次数如下:
V: 遍历4个邻居需要4步
W: 遍历1个邻居需要1步
X: 遍历1个邻居需要1步
Y: 遍历1个邻居需要1步
Z: 遍历1个邻居需要1步

这总共是8次迭代。

因此,我们有两个包含五个顶点的图。然而,搜索其中一个需要21步,而搜索另一个只需要13步。

显然,我们不能只计算图中有多少个顶点。相反,我们还需要考虑每个顶点有多少个相邻的邻居。

因此,为了有效地描述图搜索的效率,我们需要使用两个变量。一个用于表示图中顶点的数量,另一个用于表示每个顶点总共有多少个相邻的邻居。

O(V+E)

有趣的是,大O符号并未使用变量N来描述这两个因素。相反,它使用变量V和E。

V更容易理解。V代表顶点,表示图中的顶点数量。

而有趣的是,E代表边,表示图中的边的数量。

现在,计算机科学家将图搜索的效率描述为O(V + E)。这意味着步骤数是图中顶点的数量加上图中边的数量。让我们看看为什么这是图搜索的效率,因为这并不是立即直观的。

具体而言,如果你看一下我们之前的两个示例,你会注意到V + E似乎并不准确。

在A-B-C-D-E图中,有五个顶点和八条边。这将是总共13个步骤。然而,我们注意到实际上总共有21个步骤。

而在V-W-X-Y-Z图中,有五个顶点和四条边。O(V + E)表示图搜索将有九个步骤。但是我们看到实际上有13个步骤。

这种差异的原因是,尽管O(V + E)只计算边的数量一次,但在现实中,图搜索会多次触及每条边。

例如,在V-W-X-Y-Z图中,只有四条边。但是V和W之间的边被使用了两次。也就是说,当V是当前顶点时,我们使用该边找到其相邻的邻居W。但当W是当前顶点时,我们使用同一边找到其相邻的顶点V。

考虑到这一点,在V-W-X-Y-Z图中描述图搜索效率的最准确方式是计算五个顶点,再加上:
2 * V和W之间的边
2 * V和X之间的边
2 * V和Y之间的边
2 * V和Z之间的边
因此,这等同于V + 2E,因为V为5,每条边都被使用两次。

然而,为什么我们只称之为O(V + E)的答案是,因为大O符号省略了常数。虽然实际上步骤数是V + 2E,但我们将其简化为O(V + E)。

因此,虽然O(V + E)最终只是一个近似值,但它足够好,就像大O的所有表达式一样。

不过,绝对清楚的是,增加边的数量将增加步骤的数量。毕竟,A-B-C-D-E和V-W-X-Y-Z图都有五个顶点,但由于A-B-C-D-E图具有更多的边,因此需要更多的步骤。

在一天结束时,图搜索在最坏的情况下是O(V + E),其中我们搜索的顶点是我们找到的最后一个顶点(或者根本不存在于图中)。而这对于广度优先搜索和深度优先搜索都是成立的。

然而,正如我们之前所看到的,根据图的形状和我们搜索的数据,选择广度优先还是深度优先可能会优化我们的搜索,希望在遍历整个图之前就能找到我们的顶点。也就是说,正确的搜索方法可以帮助我们增加我们不会陷入最坏情况的几率,并且我们会尽早找到顶点。

在下一节中,您将了解一种特定类型的图,该图带有其自己的搜索方法,可用于解决一些非常复杂但有用的问题。

[!图数据库] 图数据库
由于图在处理涉及关系的数据(例如社交网络中的朋友关系)方面非常高效,因此在现实世界的软件应用程序中经常使用特殊的图数据库来存储此类数据。这些数据库使用您在本章中学到的概念,以及图论的其他元素,以优化围绕这种数据的操作效率。事实上,许多社交网络应用程序在幕后使用图数据库进行驱动。
一些图数据库的示例包括Neo4j,[a] ArangoDB,[b] 和Apache Giraph。[c] 如果您对了解更多关于图数据库如何工作的信息感兴趣,这些网站是一个很好的起点。
[a]: http://neo4j.com
[b]: https://www.arangodb.com
[c]: http://giraph.apache.org

带权图

我们已经看到图可以有多种不同的类型。另一种有用的图类型被称为带权图,它在图的边上添加了额外的信息。

这是一个带权图,表示美国几个主要城市的基本地图:

在这里插入图片描述

在这个图中,每条边都伴随着一个数字,表示连接边上的城市之间的距离,例如,芝加哥和纽约之间有714英里。

也有可能存在带权图,同时也是有向图。在下面的例子中,我们可以看到尽管从达拉斯飞往多伦多的费用是138美元,但从多伦多返回达拉斯的费用是216美元:

在这里插入图片描述

代码中的加权图

如果我们想要在图中添加权重,我们需要对代码进行轻微修改。一种方法是使用哈希表来表示相邻的顶点,而不是数组:

class WeightedGraphVertex
  attr_accessor :value, :adjacent_vertices
  
  def initialize(value)
    @value = value
    @adjacent_vertices = {}
  end
  
  def add_adjacent_vertex(vertex, weight)
    @adjacent_vertices[vertex] = weight
  end
end

如你所见,@adjacent_vertices 现在是一个哈希表,而不是一个数组。哈希表将包含键值对,其中每对中,相邻的顶点是键,权重(从此顶点到相邻顶点的边的权重)是值。

当我们使用 add_adjacent_vertex 方法添加相邻顶点时,我们现在传入相邻顶点以及权重。

因此,如果我们想要创建先前提到的达拉斯到多伦多的航班价格图,我们可以运行以下代码:

dallas = City.new("Dallas")
toronto = City.new("Toronto")
dallas.add_adjacent_vertex(toronto, 138)
toronto.add_adjacent_vertex(dallas, 216)

最短路径问题

加权图在建模各种数据集方面非常有用,而且还带有一些强大的算法,帮助我们充分利用这些数据。

让我们利用其中一个算法来省点钱吧。

下图展示了五个不同城市之间的航班费用。

在这里插入图片描述

现在,假设我在亚特兰大,想要飞往埃尔帕索。不幸的是,我们可以在这个图中看到,目前从亚特兰大到埃尔帕索没有直达航班。

但是,如果我愿意在途中停留在其他城市,我就可以到达那里。例如,我可以从亚特兰大飞往丹佛,然后从丹佛飞往埃尔帕索。但也有其他路径,每条路径的价格都不同。

亚特兰大-丹佛-埃尔帕索路径将花费我 300 美元,而亚特兰大-丹佛-芝加哥-埃尔帕索路径只需 280 美元。

现在的问题是:我们如何创建一个算法,找到我到达目的地需要支付的最便宜的价格?假设我们不在乎要做多少次停留;我们只是想找到最便宜的票价。

这种谜题被称为最短路径问题。这个问题也可以有其他形式。例如,如果图向我们展示了城市之间的距离,我们可能想要找到最短距离的路径。但在这里,我们要找的最短路径是最便宜的路径,因为权重表示飞行价格。

Dijkstra算法

有许多算法可以解决最短路径问题,其中一个最著名的是由 Edsger Dijkstra(发音为“迪克斯特”)于 1959 年发现的算法。毫不奇怪,这个算法被称为Dijkstra算法。

在接下来的部分,我们将使用Dijkstra算法来找到我们城市航班示例中的最便宜路径。

Dijkstra算法抽象步骤

首先要注意的是,Dijkstra算法附带一个免费奖励。在完成时,我们不仅会找到从亚特兰大到埃尔帕索的最便宜价格,还会找到从亚特兰大到所有已知城市的最便宜价格。正如您将看到的,算法就是这样运作的;我们最终会收集到所有这些数据。因此,我们将知道从亚特兰大到芝加哥的最便宜价格,以及从亚特兰大到丹佛的最便宜价格,依此类推。

为了设置,我们将创建一种存储从我们的起始城市到所有其他已知目的地的最便宜已知价格的方式。在接下来的代码中,我们将使用一个哈希表来实现这一点。然而,在我们的示例演练中,我们将使用一个可视表格,如下所示:

在这里插入图片描述

该算法将从亚特兰大顶点开始,因为这是我们当前唯一知道的城市。当我们发现新的城市时,我们将将它们添加到我们的表格中,并记录从亚特兰大到每个这些城市的最便宜价格。

一旦算法完成,表格将如下所示:

在这里插入图片描述

在代码中,这将用一个哈希表表示,如下所示:

{"Atlanta" => 0, "Boston" => 100, "Chicago" => 200, "Denver" => 160, "El Paso" => 280}

(请注意,亚特兰大也在哈希表中,其值为0。我们需要这个值来使算法正常运行,但这也是有道理的,因为从亚特兰大到亚特兰大是免费的,因为您已经在那里!)

在我们的代码中并往后看,我们将称这个表格为cheapest_prices_table,因为它存储了从起始城市到所有其他目的地的最便宜价格。

现在,如果我们只想找出到达特定目的地的最便宜价格,cheapest_prices_table将包含我们所需的所有数据。但我们可能还想知道实际路径,以获得最便宜的价格。也就是说,如果我们想从亚特兰大到埃尔帕索,我们不仅想知道最便宜的价格是280美元,还想知道为了获得这个价格,我们需要飞越亚特兰大-丹佛-芝加哥-埃尔帕索的具体路径。

为了实现这一点,我们还需要另一张表,我们将其称为cheapest_previous_stopover_city_table。该表格的目的将只在我们进入算法时变得清晰,因此我将在那时解释。不过,目前,仅需展示它在算法结束时将是什么样子即可。

在这里插入图片描述

(请注意,这张表格也将使用哈希表实现。)

Dijkstra算法具体步骤

现在一切都设置好了,下面是Dijkstra算法的步骤。为了清晰起见,我将用城市来描述算法,但你可以用“顶点”来替换“城市”,以使其适用于任何带权图。另外,请注意,在我们进行示例演示时,这些步骤会更加清晰,但我们开始吧:

  1. 我们访问起始城市,将其作为“当前城市”。
  2. 我们检查从当前城市到其每个相邻城市的价格。
  3. 如果从起始城市到一个相邻城市的价格比cheapest_prices_table中当前价格更便宜(或者该相邻城市根本不在cheapest_prices_table中):
    a. 我们更新cheapest_prices_table以反映这个更便宜的价格。
    b. 我们更新cheapest_previous_stopover_city_table,将相邻城市作为键,当前城市作为值。
  4. 然后我们访问从起始城市出发最便宜的未访问城市,将其作为当前城市。
  5. 我们重复步骤2到4,直到我们访问了每个已知城市。

再次强调,当我们进行示例演示时,这些步骤会更加清晰易懂。

Dijkstra算法步骤演练

让我们逐步走过Dijkstra算法。
首先,我们的cheapest_prices_table只包含了Atlanta:

从Atlanta到:
$0

在算法开始时,Atlanta是我们唯一可以访问的城市;我们还没有“发现”其他城市。
步骤1:我们正式访问Atlanta,并将其设为当前城市。

为了表示它是当前城市,我们将用线条将其环绕起来。为了记录我们已经访问过它,我们将添加一个勾选标记:
在这里插入图片描述

在接下来的步骤中,我们将继续检查当前城市的每个相邻城市。这就是我们“发现”新城市的方式;如果我们可以访问的一个城市有我们之前不知道的相邻城市,我们可以将它们添加到我们的地图上。

步骤2:与Atlanta相邻的一个城市是Boston。我们可以看到,从Atlanta到Boston的价格是$100。然后,我们检查cheapest_prices_table,看看这是否是从Atlanta到Boston已知的最便宜价格,但结果显示我们还没有记录从Atlanta到Boston的任何价格。这意味着这是从Atlanta到Boston已知的最便宜航班(目前为止),因此我们将其添加到cheapest_prices_table中:

从Atlanta到:Boston
$0 $100

由于我们对cheapest_prices_table进行了更改,我们现在还需要修改cheapest_previous_stopover_city_table,使相邻城市(Boston)成为键,当前城市成为值:

从Atlanta到Boston的最便宜前一停留城市:Boston
Atlanta

将这些数据添加到这个表中意味着要获得从Atlanta到Boston的已知最低价格($100),我们需要立即访问的城市是Boston之前的Atlanta。在这一点上,这是显而易见的,因为我们所知道的唯一通往Boston的方法是通过Atlanta。然而,随着我们继续进行,我们将看到为什么这第二个表格变得有用。

步骤3:我们已经检查了Boston,但Atlanta还有另一个相邻城市,Denver。我们检查价格($160)是否是从Atlanta到Denver已知的最便宜路线,但Denver目前还不在cheapest_prices_table中,所以我们将其添加为已知的最便宜航班:

从Atlanta到:Boston Denver
$0 $100 $160

然后我们也将Denver和Atlanta作为cheapest_previous_stopover_city_table中的键值对添加进去:

从Atlanta到Denver的最便宜前一停留城市:Boston Denver
Atlanta Atlanta

步骤4:到这一步,我们已经检查了Atlanta的所有相邻城市,所以是时候访问下一个城市了。但我们需要确定接下来要访问哪个城市。

现在,正如先前算法步骤中所述,我们只访问到目前为止尚未访问过的城市。此外,在尚未访问的城市中,我们总是选择首先访问从起始城市出发的已知最便宜路线的城市。我们可以从cheapest_prices_table中获取这些数据。

在我们的例子中,我们尚未访问的唯一城市是Boston或Denver。通过查看cheapest_prices_table,我们可以看到从Atlanta到Boston的价格比从Atlanta到Denver的价格更便宜,所以下一步我们将访问Boston。

步骤5:我们访问Boston,并将其指定为当前城市:

在这里插入图片描述

接下来,我们将要检查Boston的相邻城市。

步骤6:Boston有两个相邻城市,Chicago和Denver。(Atlanta不被视为相邻,因为我们无法从Boston飞往Atlanta。)

我们应该先访问哪个城市——Chicago还是Denver?同样,我们想首先访问从Atlanta飞往该城市的最便宜价格的城市。所以,让我们做个计算。
从Boston到Chicago的价格是单程$120。当查看cheapest_prices_table时,我们可以看到从Atlanta到Boston的最便宜路线是$100。这意味着从Atlanta到Chicago,以Boston为前一停留城市的最便宜航班价格将是$220。

由于这一点上,这是从Atlanta到Chicago的唯一已知价格,我们将其添加到cheapest_prices_table中。我们会按字母顺序插入到表中间:

从Atlanta到:Boston Chicago Denver
$0 $100 $220 $160

同样地,因为我们对该表进行了更改,我们还将修改cheapest_previous_stopover_city_table。相邻城市始终成为键,当前城市始终成为值,因此表变为:

从Atlanta到Boston Chicago Denver的最便宜前一停留城市:Boston Chicago Denver
Atlanta Boston Atlanta

在寻找下一个要访问的城市时,我们分析了Chicago。接下来我们将检查Denver。

步骤7:现在让我们看一下Boston和Denver之间的边缘。我们可以看到价格是$180。由于从Atlanta到Boston的最便宜航班价格再次为$100,这意味着从Atlanta到Denver,以Boston为前一停留城市的最便宜航班价格为$280。

这变得有些有趣,因为当我们检查cheapest_prices_table时,我们可以看到从Atlanta到Denver的最便宜路线是$160,比Atlanta-Boston-Denver路线便宜。因此,我们不修改任何表。也就是说,我们希望保留$160作为从Atlanta到Denver的已知最便宜路线。

我们完成了这一步,既然我们已经查看了Boston的所有相邻城市,现在我们可以访问下一个城市了。

步骤8:当前已知未访问的城市是Chicago和Denver。同样地,我们接下来访问的
城市——请特别注意——是从起始城市(Atlanta)到达的已知最便宜路径的城市。

从我们的cheapest_prices_table中看,从Atlanta到Denver($160)比从Atlanta到Chicago($220)更便宜,所以接下来我们会访问Denver:

在这里插入图片描述

接下来,我们将看一下Denver的相邻城市。

步骤9:Denver有两个相邻城市,Chicago和El Paso。我们接下来要访问哪个城市呢?为了找出答案,我们需要分析到每个城市的价格。让我们从Chicago开始。

从Denver到Chicago只需花费$40(价格不错!),这意味着从Atlanta到Chicago,以Denver为前一停留城市的最便宜航班价格将是$200,因为从Atlanta到Denver的最便宜路线是$160。当查看cheapest_prices_table时,我们可以看到从Atlanta到Chicago的当前最便宜价格是$220。这意味着我们刚刚发现的通过Denver到达Chicago的新航线更便宜,因此我们可以相应地更新cheapest_prices_table:

从Atlanta到:Boston Chicago Denver
$0 $100 $200 $160

每当我们更新cheapest_prices_table时,我们也必须更新cheapest_previous_stopover_city_table。我们将相邻城市(Chicago)设置为键,当前城市(Denver)设置为值。现在,在这种情况下,Chicago已经存在作为一个键。这意味着我们将覆盖其从Boston到Denver的值:

从Atlanta到Chicago Denver的最便宜前一停留城市:Boston Chicago Denver
Atlanta Denver Atlanta

这意味着要获得从Atlanta到Chicago的最便宜航班路径,我们需要在Denver停留,作为直接前往Chicago之前的城市。也就是说,在我们前往Chicago之前,Denver应该是倒数第二个停留点。只有这样,我们才能节省最多的钱。

这些信息对于确定从Atlanta到目的地城市的最便宜路径将会很有用,你很快就会看到。坚持住,我们快要到达了!

步骤10:Denver还有另一个相邻城市,El Paso。从Denver到El Paso的价格是$140。我们现在可以构建从Atlanta到El Paso的第一个已知价格。cheapest_prices_table告诉我们,从Atlanta到Denver的最便宜价格是$160。这意味着如果我们从Denver到El Paso,我们会再花费$140,使得从Atlanta到El Paso的总价格为$300。我们可以将其添加到cheapest_prices_table中:

从Atlanta到:Boston Chicago Denver El Paso
$0 $100 $200 $160 $300

然后,我们还必须将El Paso-Denver的键值对添加到我们的cheapest_previous_stopover_city_table中:

从Atlanta到El Paso的最便宜前一停留城市:Boston Chicago Denver El Paso
Atlanta Denver Atlanta Denver

同样地,这意味着要想在从Atlanta到El Paso的航班中节省最多的钱,我们的倒数第二个停留点应该是Denver。

我们已经看完了当前城市的所有相邻城市,所以现在是时候访问下一个城市了。

步骤11:我们有两个已知但未访问的城市,Chicago和El Paso。由于从Atlanta到Chicago的价格更便宜($200),而不是从Atlanta到El Paso($300),所以我们接下来访问Chicago,如下图图表所示。

在这里插入图片描述

步骤12:Chicago只有一个相邻城市,El Paso。从Chicago到El Paso的价格是$80(不错)。有了这个信息,我们现在可以计算假设Chicago是倒数第二个停留点时,从Atlanta到El Paso的最便宜价格。

cheapest_prices_table显示,从Atlanta到Chicago的最便宜路径是$200。加上这个价格是$80,意味着从Atlanta到El Paso,以Chicago为倒数第二个停留点的最便宜价格将为$280。

等等!这比当前已知的从Atlanta到El Paso的最便宜路径还要便宜。在我们的cheapest_prices_table中,我们看到当前已知的最便宜价格是$300。但是通过Chicago飞行时的价格是$280,更便宜。

相应地,我们需要更新cheapest_prices_table,以表明我们新发现的通往El Paso的最便宜路径:

从Atlanta到:Boston Chicago Denver El Paso
$0 $100 $200 $160 $280

我们还需要更新cheapest_previous_stopover_city_table,将El Paso作为键,Chicago作为值:

从Atlanta到El Paso的最便宜前一停留城市:Boston Chicago Denver El Paso
Atlanta Denver Atlanta Chicago

Chicago没有更多相邻城市,所以现在我们可以访问下一个城市。

步骤13:El Paso是唯一已知但未访问的城市,所以让我们将其设为当前城市,如第376页图表所示。

步骤14:El Paso只有一个出站航班,即飞往Boston的航班。那个航班价格是$100。现在,cheapest_prices_table显示,从Atlanta到El Paso的最便宜价格是$280。因此,如果我们从Atlanta到Boston旅行,以El Paso为倒数第二个停留点,我们的总费用将为$380。这比从Atlanta到Boston的已知最便宜价格($100)更昂贵,所以我们不会更新任何表格。

由于我们已经访问了所有已知的城市,现在我们拥有了寻找从Atlanta到El Paso最便宜路径所需的所有信息。

在这里插入图片描述

找到最短路径

如果我们只想知道从Atlanta到El Paso的最便宜价格,我们可以查看我们的cheapest_prices_table,发现价格是$280。但如果我们想找出确切的飞行路径以获得这个低价格,我们还有最后一件事要做。

还记得cheapest_previous_stopover_city_table吗?现在是时候实际使用这些数据了。

目前,cheapest_previous_stopover_city_table看起来是这样的:

从Atlanta到达最便宜前一停留城市:Boston Chicago Denver El Paso
Atlanta Denver Atlanta Chicago

我们可以使用这个表格来绘制从Atlanta到El Paso的最短路径——如果我们倒着来的话。

让我们看看El Paso。它对应的城市是Chicago。这意味着从Atlanta到El Paso的最便宜路线涉及在飞往El Paso之前立即在Chicago停留。让我们把这写下来:

Chicago -> El Paso

现在,如果我们在cheapest_previous_stopover_city_table中查找Chicago,我们可以看到它对应的值是Denver。这意味着从Atlanta到Chicago的最便宜路线涉及在飞往Chicago之前立即在Denver停留。让我们将这个添加到我们的图中:

Denver -> Chicago -> El Paso

接着,如果我们在cheapest_previous_stopover_city_table中查找Denver,我们可以看到从Atlanta到达Denver的最便宜航班就是直接从Atlanta到Denver飞行:

Atlanta -> Denver -> Chicago -> El Paso

现在,Atlanta恰好是我们的起始城市,所以这个路线就是我们从Atlanta到El Paso获得最便宜价格的确切路径。

让我们回顾一下我们用来连接最便宜路径的逻辑。请记住,cheapest_previous_stopover_city_table包含了对于每个目的地,从Atlanta出发获得最便宜价格的倒数第二个停留点。

所以,从cheapest_previous_stopover_city_table中,我们可以看到从Atlanta到El Paso的最便宜价格意味着…

  • …我们需要直接从Chicago飞往El Paso,并且…
  • …我们需要直接从Denver飞往Chicago,并且…
  • …我们需要直接从Atlanta飞往Denver…
    这意味着我们的最便宜路径是:
    Atlanta -> Denver -> Chicago -> El Paso

这就是全部了!呼~

代码实现:Dijkstra算法

在我们实际在 Ruby 中实现算法之前,我们首先会创建一个 City 类,它类似于我们之前的 WeightedGraphVertex 类,但使用了诸如路线(routes)和价格(price)等术语。这样做将使接下来的代码(在某种程度上)更易于理解:

class City
  attr_accessor :name, :routes

  def initialize(name)
    @name = name
    @routes = {}
  end

  def add_route(city, price)
    @routes[city] = price
  end
end

这个类被设计为表示城市,具有名称和路线信息。initialize 方法用于初始化城市的名称和路线表(routes),而 add_route 方法用于添加连接的城市和它们之间的价格。

要设置我们之前的示例,你可以运行这段代码:

atlanta = City.new("Atlanta")
boston = City.new("Boston")
chicago = City.new("Chicago")
denver = City.new("Denver")
el_paso = City.new("El Paso")
atlanta.add_route(boston, 100)
atlanta.add_route(denver, 160)
boston.add_route(chicago, 120)
boston.add_route(denver, 180)
chicago.add_route(el_paso, 80)
denver.add_route(chicago, 40)
denver.add_route(el_paso, 140)

最后,这是 Dijkstra 算法的代码。它不适合轻松阅读,可能是本书中最复杂的部分。然而,如果你准备仔细研究它,可以继续阅读。

在我们的实现中,这个方法不在 City 类内部,而是在外部。该方法接受两个 City 实例,并返回它们之间的最短路径。

def dijkstra_shortest_path(starting_city, final_destination)
  cheapest_prices_table = {}
  cheapest_previous_stopover_city_table = {}
  unvisited_cities = []
  visited_cities = {}

  cheapest_prices_table[starting_city.name] = 0
  current_city = starting_city

  while current_city
    visited_cities[current_city.name] = true
    unvisited_cities.delete(current_city)

    current_city.routes.each do |adjacent_city, price|
      unvisited_cities << adjacent_city unless visited_cities[adjacent_city.name]

      price_through_current_city = cheapest_prices_table[current_city.name] + price

      if !cheapest_prices_table[adjacent_city.name] || price_through_current_city < cheapest_prices_table[adjacent_city.name]
        cheapest_prices_table[adjacent_city.name] = price_through_current_city
        cheapest_previous_stopover_city_table[adjacent_city.name] = current_city.name
      end
    end

    current_city = unvisited_cities.min do |city|
      cheapest_prices_table[city.name]
    end
  end

  shortest_path = []
  current_city_name = final_destination.name

  while current_city_name != starting_city.name
    shortest_path << current_city_name
    current_city_name = cheapest_previous_stopover_city_table[current_city_name]
  end

  shortest_path << starting_city.name
  return shortest_path.reverse
end

这段代码比较长,让我们逐步解释一下。

dijkstra_shortest_path 函数接受两个顶点,表示起始城市和最终目的地。

最终,我们的函数将返回一个字符串数组,代表最便宜的路径。对于我们的示例,该函数将返回:

[“Atlanta”, “Denver”, “Chicago”, “El Paso”]

函数首先建立了两个主要的表,这两个表支持整个算法的运行:

cheapest_prices_table = {}
cheapest_previous_stopover_city_table = {}

接着,我们建立了跟踪已访问和待访问城市的方式:

unvisited_cities = []
visited_cities = {}

可能觉得奇怪的是,unvisited_cities 是一个数组,而 visited_cities 是一个哈希表。我们之所以将 visited_cities 设计为哈希表,是因为在代码的其余部分,我们仅仅需要它来进行查找,哈希表在时间复杂度方面是一个理想的选择。

对于 unvisited_cities 最佳的数据结构选择稍显复杂。在我们接下来的代码中,我们始终访问从起始城市出发最便宜的未访问城市。理想情况下,我们希望随时能够立即访问未访问城市中最便宜的选项。使用数组比哈希表更容易实现这种访问。

实际上,优先队列是这个情况的最佳选择,因为它的整个功能是从一组项目中提供最小(或最大)值的便捷访问。正如你在 “保持优先级队列” 中学到的那样,堆通常是实现优先队列的最佳数据结构。

然而,我选择在这个实现中使用简单的数组,只是为了使代码尽可能简单和精炼,因为 Dijkstra 算法本身已经足够复杂。不过,我鼓励你尝试用优先队列替换数组。

接下来,我们向 cheapest_prices_table 添加第一个键值对,将 starting_city 作为键,值为 0。这是有道理的,因为从 starting_city 到达它自身的成本为零:

cheapest_prices_table[starting_city.name] = 0

作为设置的最后一部分,我们将 starting_city 指定为当前城市:

current_city = starting_city

接下来,我们开始算法的核心部分,这部分以一个循环的形式运行,只要我们能够访问一个 current_city 就会一直运行。在这个循环中,我们通过将其名称添加到 visited_cities 哈希表中,标记 current_city 已被访问。同时,如果当前城市在 unvisited_cities 列表中,我们也会从中删除:

while current_city
  visited_cities[current_city.name] = true
  unvisited_cities.delete(current_city)

然后,在 while 循环内部,我们开始另一个循环,遍历 current_city 的所有相邻城市:

current_city.routes.each do |adjacent_city, price|

在这个内部循环中,如果相邻的城市是我们以前从未访问过的城市,我们首先将它添加到 unvisited_cities 数组中:

unvisited_cities << adjacent_city unless visited_cities[adjacent_city.name]

在这个特定的实现中,一个城市可以在 unvisited_cities 数组中出现多次,这没关系,因为我们会使用 unvisited_cities.delete(current_city) 这行代码来删除所有这些实例。另外,我们也可以在添加之前确保 current_city 不已经存在于 unvisited_cities 中。

接下来,我们计算从起始城市到相邻城市的最便宜价格,假设 current_city 是倒数第二个停留点。我们通过使用 cheapest_prices_table 查找到达 current_city 的已知最便宜路径,然后将其加上从 current_city 到相邻城市的路径价格来完成这个计算。这个计算结果被存储在一个名为 price_through_current_city 的变量中:

price_through_current_city = cheapest_prices_table[current_city.name] + price

然后,我们在 cheapest_prices_table 中查看 price_through_current_city 是否是从起始城市到相邻城市现在已知的最便宜航班。如果相邻城市尚未出现在 cheapest_prices_table 中,那么这个价格就是目前已知的最便宜价格:

if !cheapest_prices_table[adjacent_city.name] ||
   price_through_current_city < cheapest_prices_table[adjacent_city.name]

如果 price_through_current_city 现在是从起始城市到相邻城市的最便宜路径,我们就更新了两个主要的表。也就是说,我们将相邻城市的新价格存储在 cheapest_prices_table 中。然后,我们还使用相邻城市的名称作为键,将 current_city 的名称作为值更新了 cheapest_previous_stopover_city_table

cheapest_prices_table[adjacent_city.name] = price_through_current_city
cheapest_previous_stopover_city_table[adjacent_city.name] = current_city.name

在迭代完 current_city 的所有相邻城市之后,就该访问下一个城市了。我们会选择从起始城市可达的最便宜的未访问过的城市作为新的 current_city

current_city = unvisited_cities.min do |city|
  cheapest_prices_table[city.name]
end

如果没有更多已知的未访问城市了,current_city 就会变成 nil,并且 while 循环会结束。

到这一步为止,这两个表已经完全填充了我们所需的所有数据。如果我们愿意,此时我们可以简单地返回 cheapest_prices_table,以查看从 starting_city 到所有已知城市的最便宜价格。不过,相反地,我们继续寻找到达目标 final_destination 的确切最便宜路径。

为此,我们创建一个名为 shortest_path 的数组,这将在函数末尾返回:

shortest_path = []

我们还创建了一个名为 current_city_name 的变量,它开始时是 final_destination 的名称:

current_city_name = final_destination.name

然后,我们开始一个 while 循环来填充 shortest_path。该循环从 final_destination 开始向后工作,直到达到 starting_city 为止:

while current_city_name != starting_city.name

在循环中,我们将 current_city_name 添加到 shortest_path 数组中,然后使用 cheapest_previous_stopover_city_table 找到应该紧接在 current_city_name 之前的城市。这个前一个城市现在变成了新的 current_city_name

shortest_path << current_city_name
current_city_name = cheapest_previous_stopover_city_table[current_city_name]

为了提高代码的可读性,我们使循环在达到 starting_city 时结束,因此我们现在手动将 starting_city 的名称添加到 shortest_path 的末尾:

shortest_path << starting_city.name

现在,shortest_path 包含了从 final_destinationstarting_city 的反向路径。因此,我们返回该数组的反转版本,以提供从 starting_cityfinal_destination 的最短路径:

return shortest_path.reverse

尽管我们的实现涉及城市和价格,但所有变量名称都可以更改,以处理任何加权图的最短路径。

Dijkstra算法效率

狄克斯特拉算法是一种在加权图中找到最短路径的通用描述,但它并未指定精确的代码实现方式。实际上,有许多不同的方法可以编写这个算法。

举例来说,在我们的代码演示中,我们使用了一个简单的数组来跟踪尚未访问的城市(unvisited_cities),但我提到过可以使用优先队列来代替。实际上,确切的实现方式对算法的时间复杂度有很大影响。但至少让我们来分析我们的实现方式。

当我们使用简单的数组来跟踪尚未访问的城市(unvisited_cities)时,我们的算法最多可能需要 O(V^2) 步骤。这是因为狄克斯特拉算法的最坏情况是每个顶点都有一条通往图中每个其他顶点的边。在这种情况下,对于我们访问的每个顶点,我们都需要检查从该顶点到每个其他顶点的路径权重。这就是 V 个顶点乘以 V 个顶点,也就是 O(V^2)。

其他的实现方式,比如使用优先队列而不是数组,可以提供更快的速度。同样,狄克斯特拉算法有多种变体,每种变体都需要进行精确的时间复杂度分析。

无论你选择哪种算法实现方式,它都比另一种选择更具优势,后者是找到图中的每条可能路径,然后选择最快的路径。狄克斯特拉算法为我们提供了一种可靠的方式来谨慎地遍历图,并找到最短路径。

总结

我们即将结束我们的旅程,因为本章代表着你在本书中遇到的最后一个重要数据结构。你已经看到图是处理涉及关系的数据时非常强大的工具,除了使我们的代码快速之外,它们还可以帮助解决棘手的问题。

实际上,我可以用一本书来讨论图。有许多围绕这种数据结构的有趣且有用的算法,比如最小生成树、拓扑排序、双向搜索、弗洛伊德-沃舍尔算法、贝尔曼-福特算法和图着色,仅举几例。然而,这一章应该为你探索这些额外的主题奠定基础。

在我们的旅程中,我们的主要关注点一直是我们的代码运行速度。也就是说,我们一直在衡量我们的代码在时间上的执行效率,并且我们一直在以算法所需步骤的数量来衡量它。

然而,效率不仅可以单纯地用速度来衡量。特别是,我们可能关心的是数据结构或算法可能消耗的内存量。在下一章中,你将学习如何从空间的角度分析我们代码的效率。

练习

  1. 第一个图表展示了一家电子商务网站的推荐引擎。每个顶点代表网站上可以购买的产品。边连接了每个产品与其他“相似”的产品,在用户浏览特定项目时,网站将向用户推荐这些产品。如果用户正在浏览“钉子”,会向用户推荐哪些其他产品呢?

  2. 如果我们从第二个图表上的“A”顶点开始执行深度优先搜索,我们将以什么样的顺序遍历所有顶点?假设当有多个相邻顶点可供访问时,我们首先访问字母顺序最早的节点。

在这里插入图片描述

  1. 如果我们从前面的图表开始执行广度优先搜索,以“A”顶点作为起点,我们将以什么顺序遍历所有顶点?假设当可以访问多个相邻顶点时,我们首先访问字母顺序最早的节点。

  2. 在本章中,我仅提供了广度优先遍历的代码,如在第348页的《广度优先搜索》中讨论的那样。也就是说,该代码只是简单地打印了每个顶点的值。修改代码,使其可以实际搜索提供给函数的顶点值。(我们已经对深度优先搜索做过类似的修改。)也就是说,如果函数找到了它正在搜索的顶点,则应返回该顶点的值。否则,应返回空值。

  3. 我们看到了Dijkstra算法如何帮助我们找到加权图中的最短路径。然而,在非加权图中也存在最短路径的概念。如何呢?

    经典(非加权)图中的最短路径是从一个顶点到另一个顶点所经过的顶点数最少的路径。

    这在社交网络应用中特别有用。接下来的示例网络中,

在这里插入图片描述

如果我们想知道Idris和Lina是如何连接的,我们会发现她与Lina有两种不同的连接方式。也就是说,Idris通过Kamil是Lina的二度关系,但通过Talia是Lina的五度关系。现在,我们可能更关心Idris和Lina的密切程度,因此她是五度关系这一事实并不重要,因为他们也是二度关系。

编写一个函数,接受图中的两个顶点,并返回它们之间的最短路径。该函数应返回一个包含精确路径的数组,例如[“Idris”, “Kamil”, “Lina”]。

提示:该算法可能包含广度优先搜索和Dijkstra算法的元素。

答案

  1. 如果用户正在浏览“nails”,网站将推荐“nail polish”、“needles”、“pins”和“hammer”。
  2. 深度优先搜索的顺序将是A-B-E-J-F-O-C-G-K-D-H-L-M-I-N-P,如下图所示:

在这里插入图片描述

  1. 广度优先搜索的顺序将是A-B-C-D-E-F-G-H-I-J-K-L-M-N-O-P,如下图所示:

在这里插入图片描述

  1. 下面是广度优先搜索的实现代码:
def bfs(starting_vertex, search_value, visited_vertices={})
  queue = Queue.new
  visited_vertices[starting_vertex.value] = true
  queue.enqueue(starting_vertex)
  while queue.read
    current_vertex = queue.dequeue
    return current_vertex if current_vertex.value == search_value
    current_vertex.adjacent_vertices.each do |adjacent_vertex|
      if !visited_vertices[adjacent_vertex.value]
        visited_vertices[adjacent_vertex.value] = true
        queue.enqueue(adjacent_vertex)
      end
    end
  end
  return nil
end
  1. 要在无权图中找到最短路径,我们将使用广度优先搜索。广度优先搜索的主要特点是尽可能长时间地保持接近起始顶点。这个特点将作为找到最短路径的关键。

    让我们将其应用到社交网络的例子中。因为广度优先搜索会尽可能长时间地保持接近Idris,我们会首先通过最短可能的路径找到Lina。只有在搜索的后期,我们才会通过更长的路径找到Lina。事实上,我们甚至可以在找到Lina后立即停止搜索。(我们的实现没有提前停止,但您可以修改以实现此功能。)

    当我们首次访问每个顶点时,我们知道当前顶点始终是从起始顶点到正在访问的顶点的最短路径的一部分。(请记住,在BFS中,当前顶点和正在访问的顶点不一定相同。)

    例如,当我们首次访问Lina时,Kamil 将是当前顶点。这是因为在BFS中,我们会先通过Kamil而不是Sasha找到Lina。当我们访问Lina(通过Kamil)时,我们可以在一个表中存储从Idris到Lina的最短路径将通过Kamil。这个表类似于Dijkstra算法中的cheapest_previous_stopover_city_table。

    这是我们的实现:

def find_shortest_path(first_vertex, second_vertex, visited_vertices={})
    queue = Queue.new
    # 和 Dijkstra 算法一样,我们在一个表中跟踪每个顶点的直接前置顶点。
    previous_vertex_table = {}
    # 我们使用广度优先搜索:
    visited_vertices[first_vertex.value] = true
    queue.enqueue(first_vertex)
    while queue.read
        current_vertex = queue.dequeue
        current_vertex.adjacent_vertices.each do |adjacent_vertex|
            if !visited_vertices[adjacent_vertex.value]
                visited_vertices[adjacent_vertex.value] = true
                queue.enqueue(adjacent_vertex)
                # 我们在 previous_vertex 表中以 adjacent_vertex 为键,current_vertex 为值存储数据。
                # 这表示 current_vertex 是导致 adjacent_vertex 的直接前置顶点。
                previous_vertex_table[adjacent_vertex.value] = current_vertex.value
            end
        end
    end
    # 和 Dijkstra 算法一样,我们通过 previous_vertex_table 反向构建最短路径;
    shortest_path = []
    current_vertex_value = second_vertex.value
    while current_vertex_value != first_vertex.value
        shortest_path << current_vertex_value
        current_vertex_value = previous_vertex_table[current_vertex_value]
    end
    shortest_path << first_vertex.value
    return shortest_path.reverse
end
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值