简介
图是最有用的数据结构之一。它们几乎可以用来模拟一切--对象关系和网络是最常见的。一张图片可以被表示为像素的网格状图,而句子可以被表示为单词的图。图被用于各个领域,从地图学到社会心理学,当然,它们也被广泛用于计算机科学。
由于它们的广泛使用,图的搜索和遍历起到了重要的计算作用。用于图搜索和遍历的两种基本的、互补的和介绍性的算法是深度优先搜索(DFS)和宽度优先搜索(BFS)。
在这篇文章中,我们将介绍该算法背后的理论以及广义优先搜索和遍历的Python实现。首先,我们将专注于节点搜索,然后再深入研究使用BFS算法的图遍历,这是你可以采用它的两个主要任务。
广度优先搜索--理论
**广度优先搜索(BFS)**系统地逐级遍历图形,沿途形成一棵BFS树。
如果我们从节点v
(我们图或树数据结构的根节点)开始搜索,BFS算法将首先访问节点v
(它的子节点,在第一层)的所有邻居,顺序是邻接列表中给出的。接下来,它将考虑这些邻居的子节点**(第二层**),以此类推。
这种算法可以用于图的遍历和搜索。当搜索满足某个条件的节点(目标节点)时,从起始节点到目标节点的距离最短的路径。该距离被定义为穿越的分支数。
广度优先搜索可用于解决许多问题,如寻找两个节点之间的最短路径,确定每个节点的等级,甚至解决益智游戏和迷宫。
虽然它不是解决大型迷宫和谜题的最有效算法--它被Dijkstra算法和A*等算法所超越--但它仍然在这群人中发挥着重要作用,而且根据手头的问题--DFS和BFS可以超越它们的启发式表亲。
如果你想了解更多关于Dijkstra算法或A*的信息--请阅读我们的Python中的图。Dijkstra算法和Graphs in Python。A*搜索算法!
广度优先搜索 - 算法
当实现BFS时,我们通常使用一个FIFO结构,如Queue
,来存储接下来要访问的节点。
**注意:**要在Python中使用一个Queue
,我们需要从queue
模块中导入相应的Queue
类。
我们需要注意不要因为重复访问相同的节点而陷入无限循环,这在有循环的图中很容易发生。考虑到这一点,我们将跟踪已经访问过的节点。这些信息不需要明确地保存,我们可以简单地跟踪父节点,这样我们就不会在一个节点被访问后意外地回到该节点。
总结一下这个逻辑,BFS算法的步骤是这样的。
- 将根/起始节点添加到
Queue
。 - 对于每一个节点,设置它们没有定义的父节点。
- 直到
Queue
是空的。- 从
Queue
的开头提取节点。 - 进行输出处理。
- 对于当前节点的每一个没有定义父节点(未被访问)的邻居,将其添加到
Queue
,并设置当前节点为其父节点。
- 从
输出处理的执行取决于图搜索背后的目的。当搜索一个目标节点时,输出处理通常是测试当前节点是否等于目标节点。这是你可以发挥创造力的步骤!
广度优先搜索的实现--目标节点搜索
首先让我们从搜索开始--搜索目标节点。除了目标节点,我们还需要一个起始节点。预期的输出是一条将我们从开始节点引向目标节点的路径。
考虑到这些,并考虑到算法的步骤,我们可以实现它。我们将定义一个Graph
类来 "包裹 "BFS搜索的实现。
Graph
该类包含一个图的表示--在这种情况下是一个邻接矩阵,以及你在处理图的时候可能需要的所有方法。我们将把BFS搜索和BFS遍历作为该类的方法来实现。
from queue import Queue
class Graph:
# Constructor
def __init__(self, num_of_nodes, directed=True):
self.m_num_of_nodes = num_of_nodes
self.m_nodes = range(self.m_num_of_nodes)
# Directed or Undirected
self.m_directed = directed
# Graph representation - Adjacency list
# We use a dictionary to implement an adjacency list
self.m_adj_list = {node: set() for node in self.m_nodes}
# Add edge to the graph
def add_edge(self, node1, node2, weight=1):
self.m_adj_list[node1].add((node2, weight))
if not self.m_directed:
self.m_adj_list[node2].add((node1, weight))
# Print the graph representation
def print_adj_list(self):
for key in self.m_adj_list.keys():
print("node", key, ": ", self.m_adj_list[key])
**注意:**对于Graph
类的更深入的概述,你应该阅读我们的文章"Graphs in Python:在代码中表示图形"一文。
在实现了一个封装类之后,我们可以将BFS搜索作为其方法之一来实现。
def bfs(self, start_node, target_node):
# Set of visited nodes to prevent loops
visited = set()
queue = Queue()
# Add the start_node to the queue and visited list
queue.put(start_node)
visited.add(start_node)
# start_node has not parents
parent = dict()
parent[start_node] = None
# Perform step 3
path_found = False
while not queue.empty():
current_node = queue.get()
if current_node == target_node:
path_found = True
break
for (next_node, weight) in self.m_adj_list[current_node]:
if next_node not in visited:
queue.put(next_node)
parent[next_node] = current_node
visited.add(next_node)
# Path reconstruction
path = []
if path_found:
path.append(target_node)
while parent[target_node] is not None:
path.append(parent[target_node])
target_node = parent[target_node]
path.reverse()
return path
当我们重建路径时(如果找到的话),我们从目标节点向后走,通过它的父节点,一路重走到起始节点。此外,我们为了我们自己的直觉**,**从start_node
,走向target_node
,虽然,这一步是可选的。
另一方面,如果没有路径,该算法将返回一个空列表。
有了前面解释的实现,我们可以通过在示例图上运行BFS搜索来测试它。
让我们用我们的Graph
类重新创建这个图。它是一个有6个节点的无向图,所以我们将它实例化为。
graph = Graph(6, directed=False)
接下来,我们需要将该图的所有边添加到我们所创建的Graph
类的实例中。
graph.add_edge(0, 1)
graph.add_edge(0, 2)
graph.add_edge(0, 3)
graph.add_edge(0, 4)
graph.add_edge(1, 2)
graph.add_edge(2, 3)
graph.add_edge(2, 5)
graph.add_edge(3, 4)
graph.add_edge(3, 5)
graph.add_edge(4, 5)
现在,让我们看看Graph
类是如何在内部表示我们的示例图的。
graph.print_adj_list()
这将打印用于表示我们所创建的图形的邻接列表。
node 0 : {(3, 1), (1, 1), (4, 1), (2, 1)}
node 1 : {(0, 1), (2, 1)}
node 2 : {(0, 1), (1, 1), (5, 1), (3, 1)}
node 3 : {(0, 1), (5, 1), (4, 1), (2, 1)}
node 4 : {(0, 1), (5, 1), (3, 1)}
node 5 : {(3, 1), (4, 1), (2, 1)}
此刻,我们已经创建了一个图,并理解了它是如何被存储为一个邻接矩阵的。考虑到这些,我们可以自己进行搜索了。假设我们想从节点 0
开始搜索节点 5
。
path = []
path = graph.bfs(0, 5)
print(path)
运行这段代码的结果是。
[0, 3, 5]
在快速浏览了示例图之后,我们可以看到0
和5
之间的最短路径确实是[0, 3, 5]
。虽然,你也可以穿越[0, 2, 5]
和[0, 4, 5]
。从根本上说,这些替代路径与[0, 3, 5]
的距离是一样的--但是,考虑到BFS是如何对节点进行比较的。它从左到右进行 "扫描",3
是邻接列表左边第一个通往5
的节点,所以这条路径被采取,而不是其他的。
这是BFS的一个特点,你要预见到。它将从左到右进行搜索--如果在第一条路径的 "后面 "发现了同样有效的路径,它就不会找到。
注意:在有些情况下,两个节点之间的路径无法被找到。这种情况在断开连接的图形中很典型,其中至少有两个节点没有路径连接。
下面是一个断开连接的图的样子。
如果我们试图在这个图中的节点0
和3
之间进行搜索,搜索将是不成功的,将返回一个空路径。
广度优先的实现--图的遍历
广度优先遍历是宽度优先搜索的一个特例,它遍历整个图,而不是搜索一个目标节点。该算法与我们之前的定义相同,不同的是我们不检查目标节点,也不需要找到通往目标节点的路径。
这大大简化了实现方式--我们只需打印出被遍历的每个节点,以获得对其如何通过节点的直观感受。
def bfs_traversal(self, start_node):
visited = set()
queue = Queue()
queue.put(start_node)
visited.add(start_node)
while not queue.empty():
current_node = queue.get()
print(current_node, end = " ")
for (next_node, weight) in self.m_adj_list[current_node]:
if next_node not in visited:
queue.put(next_node)
visited.add(next_node)
**注意:**这个方法应该作为之前实现的Graph
类的一部分来实现。
现在,让我们按照之前显示的方式定义下面的示例图。
# Create an instance of the `Graph` class
# This graph is undirected and has 5 nodes
graph = Graph(5, directed=False)
# Add edges to the graph
graph.add_edge(0, 1)
graph.add_edge(0, 2)
graph.add_edge(1, 2)
graph.add_edge(1, 4)
graph.add_edge(2, 3)
最后,让我们运行这段代码。
graph.bfs_traversal(0)
运行这段代码将按照BFS扫描的顺序打印节点。
0 1 2 4 3
一步一步来
让我们更深入地研究这个例子,看看这个算法是如何一步步进行的。当我们从起始节点0
开始遍历时,它被放入了visited
集合,同时也被放入了queue
。当我们在queue
中仍有节点时,我们提取第一个节点,打印它,并检查其所有的邻居。
在检查邻居时,我们检查它们中的每一个是否被访问过,如果没有,我们就把它们添加到queue
,并把它们标记为被访问。
步骤 | 排队 | 已访问 |
---|---|---|
添加起始节点0 | [0] | {0} |
访问0,将1和2添加到队列中 | [1, 2] | {0} |
访问1,将4添加到队列中 | [2, 4] | {0, 2} |
访问2,将3添加到队列中 | [4, 3] | {0, 1, 2} |
访问4,没有未访问的邻居 | [3] | {0, 1, 1, 4} |
访问3,没有未访问的邻居 | [ ] | {0, 1, 2, 4, 3} |
时间复杂度
在广度优先遍历过程中,每个节点正好被访问一次,如果是有向图,每个分支也被查看一次,即如果是无向图,则被查看两次。因此,BFS算法的时间复杂度为 ***O(|V|+|E|)***其中,V是图形节点的集合,E是由其所有分支(边)组成的集合。
总结
在本指南中,我们已经解释了宽度优先搜索算法背后的理论,并定义了它的步骤。
我们描述了广义优先搜索和广义优先遍历的Python实现,并在示例图上测试了它们是如何一步步工作的。最后,我们解释了这种算法的时间复杂性。