学习Python中的广度优先搜索(BFS)算法

103 阅读9分钟

简介

图是最有用的数据结构之一。它们几乎可以用来模拟一切--对象关系和网络是最常见的。一张图片可以被表示为像素的网格状图,而句子可以被表示为单词的图。图被用于各个领域,从地图学到社会心理学,当然,它们也被广泛用于计算机科学。

由于它们的广泛使用,图的搜索和遍历起到了重要的计算作用。用于图搜索和遍历的两种基本的、互补的和介绍性的算法是深度优先搜索(DFS)宽度优先搜索(BFS)

在这篇文章中,我们将介绍该算法背后的理论以及广义优先搜索和遍历的Python实现。首先,我们将专注于节点搜索,然后再深入研究使用BFS算法的图遍历,这是你可以采用它的两个主要任务。

广度优先搜索--理论

**广度优先搜索(BFS)**系统地逐级遍历图形,沿途形成一棵BFS树。

如果我们从节点v (我们图或树数据结构的根节点)开始搜索,BFS算法将首先访问节点v (它的子节点,在第一层)的所有邻居顺序是邻接列表中给出的。接下来,它将考虑这些邻居的子节点**(第二层**),以此类推。

这种算法可以用于图的遍历搜索。当搜索满足某个条件的节点(目标节点)时,从起始节点到目标节点的距离最短的路径。该距离被定义为穿越的分支数。

BFS animated

广度优先搜索可用于解决许多问题,如寻找两个节点之间的最短路径,确定每个节点的等级,甚至解决益智游戏和迷宫。

虽然它不是解决大型迷宫和谜题的最有效算法--它被Dijkstra算法和A*等算法所超越--但它仍然在这群人中发挥着重要作用,而且根据手头的问题--DFS和BFS可以超越它们的启发式表亲。

如果你想了解更多关于Dijkstra算法或A*的信息--请阅读我们的Python中的图。Dijkstra算法Graphs in Python。A*搜索算法!

广度优先搜索 - 算法

当实现BFS时,我们通常使用一个FIFO结构,如Queue ,来存储接下来要访问的节点。

**注意:**要在Python中使用一个Queue ,我们需要从queue 模块中导入相应的Queue 类。

我们需要注意不要因为重复访问相同的节点而陷入无限循环,这在有循环的图中很容易发生。考虑到这一点,我们将跟踪已经访问过的节点。这些信息不需要明确地保存,我们可以简单地跟踪节点,这样我们就不会在一个节点被访问后意外地回到该节点。

总结一下这个逻辑,BFS算法的步骤是这样的。

  1. 将根/起始节点添加到Queue
  2. 对于每一个节点,设置它们没有定义的父节点。
  3. 直到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搜索来测试它。

search graph

让我们用我们的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]

在快速浏览了示例图之后,我们可以看到05 之间的最短路径确实[0, 3, 5] 。虽然,你也可以穿越[0, 2, 5][0, 4, 5] 。从根本上说,这些替代路径与[0, 3, 5] 的距离是一样的--但是,考虑到BFS是如何对节点进行比较的。它从左到右进行 "扫描",3 是邻接列表左边第一个通往5 的节点,所以这条路径被采取,而不是其他的。

这是BFS的一个特点,你要预见到。它将从左到右进行搜索--如果在第一条路径的 "后面 "发现了同样有效的路径,它就不会找到。

注意:在有些情况下,两个节点之间的路径无法被找到。这种情况在断开连接的图形中很典型,其中至少有两个节点没有路径连接。

下面是一个断开连接的图的样子。

disconnected graph

如果我们试图在这个图中的节点03 之间进行搜索,搜索将是不成功的,将返回一个空路径。

广度优先的实现--图的遍历

广度优先遍历是宽度优先搜索的一个特例,它遍历整个图,而不是搜索一个目标节点。该算法与我们之前的定义相同,不同的是我们不检查目标节点,也不需要找到通往目标节点的路径。

这大大简化了实现方式--我们只需打印出被遍历的每个节点,以获得对其如何通过节点的直观感受。

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 类的一部分来实现。

现在,让我们按照之前显示的方式定义下面的示例图。

traversal 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实现,并在示例图上测试了它们是如何一步步工作的。最后,我们解释了这种算法的时间复杂性。