人工智能搜索实用指南-全-
人工智能搜索实用指南(全)
原文:
annas-archive.org/md5/eedb2e7332615aca26871aa14bb4e602
译者:飞龙
前言
随着大数据和现代技术的兴起,人工智能(AI)在许多领域变得越来越重要。自动化需求的增加推动了 AI 在机器人技术、预测分析和金融等领域的广泛应用。
本书将帮助你理解什么是人工智能(AI)。它详细解释了基本的搜索方法:深度优先搜索(DFS)、广度优先搜索(BFS)和 A*搜索,这些方法在已知初始状态、目标状态和可能的行动时,可以用来做出智能决策。对于这类问题,可以找到随机解决方案或贪心解决方案,但它们在空间或时间上并非最优,本书将探讨高效的空间和时间方法。我们还将学习如何表述一个问题,这包括识别它的初始状态、目标状态,以及每个状态下可能的行动。同时,我们还需要了解在实现这些搜索算法时所涉及的数据结构,因为它们构成了搜索探索的基础。最后,我们将探讨什么是启发式,因为这决定了某个子解决方案相对于另一个子解决方案的适用性,并帮助你决定采取哪一步。
本书适用对象
本书适合那些有意开始学习 AI 并开发基于 AI 的实用应用程序的开发者。想要将普通应用程序升级为智能应用程序的开发者会发现本书很有用。本书假设读者具备基本的 Python 知识和理解。
本书内容
第一章《理解深度优先搜索算法》,通过搜索树的帮助,实践讲解了 DFS 算法。该章节还深入探讨了递归,这消除了显式堆栈的需要。
第二章《理解广度优先搜索算法》,教你如何使用 LinkedIn 连接功能作为示例,按层次遍历图。
第三章《理解启发式搜索算法》,带你深入了解优先队列数据结构,并解释如何可视化搜索树。该章节还涉及与贪心最佳优先搜索相关的问题,并介绍 A*如何解决该问题。
如何最大化利用本书
运行代码所需的软件要求如下:
-
Python 2.7.6
-
Pydot 和 Matplotlib 库
-
LiClipse
下载示例代码文件
你可以从你在www.packtpub.com的账户中下载本书的示例代码文件。如果你在其他地方购买了本书,可以访问www.packtpub.com/support并注册,将文件直接发送到你的邮箱。
你可以通过以下步骤下载代码文件:
-
登录或注册 www.packtpub.com。
-
选择“支持”标签页。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名并按照屏幕上的指示操作。
一旦文件下载完成,请确保使用最新版本的以下工具解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址为 https://github.com/PacktPublishing/Hands-On-Artificial-Intelligence-for-Search。如果代码有更新,将会在现有的 GitHub 仓库中更新。
我们还提供来自我们丰富书籍和视频目录的其他代码包,您可以访问 github.com/PacktPublishing/
进行查看!
下载彩色图像
我们还提供了一份包含本书中截图/图表彩色图像的 PDF 文件。您可以在此下载:www.packtpub.com/sites/default/files/downloads/HandsOnArtificialIntelligenceforSearch_ColorImages.pdf
。
使用的约定
本书中使用了多种文本约定。
CodeInText
:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。示例如下:“State
类必须为每个应用程序进行更改,即使搜索算法相同。”
代码块将按如下方式呈现:
def checkGoalState(self):
"""
This method checks whether the person is Jill.
"""
#check if the person's name is Jill
return self.name == "Jill"
当我们希望引起您注意某个代码块的特定部分时,相关行或项目将以粗体显示:
#create a dictionary with all the mappings
connections = {}
connections["Dev"] = {"Ali", "Seth", "Tom"}
connections["Ali"] = {"Dev", "Seth", "Ram"}
connections["Seth"] = {"Ali", "Tom", "Harry"}
connections["Tom"] = {"Dev", "Seth", "Kai", 'Jill'}
connections["Ram"] = {"Ali", "Jill"}
任何命令行输入或输出都将以如下形式呈现:
$ pip install pydot
粗体:表示新术语、重要单词或您在屏幕上看到的词语。例如,菜单或对话框中的词语会以这种形式出现在文本中。示例如下:“从管理面板中选择系统信息。”
警告或重要提示以此形式显示。
提示和技巧通常会以这种形式呈现。
联系我们
我们总是欢迎读者的反馈。
一般反馈:发送电子邮件至[email protected]
,并在邮件主题中注明书名。如果您有关于本书的任何问题,请通过[email protected]
与我们联系。
勘误表:虽然我们已经尽力确保内容的准确性,但错误仍然会发生。如果您在本书中发现任何错误,感谢您向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击“勘误表提交表单”链接,并输入相关细节。
盗版:如果您在互联网上遇到我们作品的任何非法复制品,感谢您向我们提供相关位置地址或网站名称。请通过[email protected]
与我们联系并附上材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有意撰写或参与编写一本书,请访问 authors.packtpub.com。
评论
请留下评论。在你阅读并使用完这本书后,为什么不在你购买书籍的站点上留下评论呢?潜在的读者可以看到并根据你的公正意见做出购买决策,我们在 Packt 可以了解你对我们产品的看法,作者也能看到你对他们书籍的反馈。谢谢!
欲了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:理解深度优先搜索算法
搜索算法在工业和研究领域的人工智能解决方案中有广泛的应用,涉及计算机视觉、机器学习和机器人技术。在本书的后续章节中,我们将教你如何在搜索应用程序中使用人工智能。搜索是我们每天都会进行的操作,无论是搜索文件系统中的歌曲、在社交网络中寻找朋友或同事,还是寻找前往目的地的最佳路线。在这一章中,你将学习 深度优先搜索(DFS)算法并开发一个文件搜索应用程序。
在本章中,我们将涵盖以下主题:
-
安装并设置库
-
介绍文件搜索应用程序
-
搜索问题的表述
-
使用节点构建搜索树
-
堆栈与 DFS
-
递归 DFS
安装并设置库
在我们深入探讨搜索的基本概念之前,我们将先了解以下需要安装的库以及如何在 Windows 中安装它们:
-
Python:你可以根据操作系统,从
www.python.org/downloads/
下载并安装 Python 库。 -
Graphviz:这款开源图形可视化软件可以从
graphviz.org/download/
下载。 -
Pip:用于安装 Python 包的工具如下:
-
Pydot:Graphviz 的 DOT 语言的 Python 接口
-
Matplotlib:这是一款 Python 2D 绘图库。
-
执行以下部分中的步骤来安装前述库。
设置 Python
设置 Python 的步骤如下:
-
对于本书中的应用程序,我们将使用 Python 2.7.6,可以从
www.python.org/downloads/
下载。 -
下载适当的安装程序后,双击它并继续使用默认选项进行安装。
-
根据你的操作系统,选择合适的 Python 安装程序进行下载,如下截图所示:
图 1
- 以下截图显示了 Python 将被安装的位置;请记下此位置:
图 2
现在,Python 将会安装。
-
下一步是将 Python 的路径添加到 Path 环境变量中。在系统属性 | 高级标签下,点击“环境变量...”按钮。
-
在“环境变量…”窗口中,进入系统变量 | Path,并添加你在步骤 4 中记下的 Python 位置(在我们的例子中是
C:\Python27
)。 -
现在,检查 Python 是否工作,打开命令提示符并输入
python -- version
命令。你将看到以下输出:
图 3
上面的截图显示的输出确认 Python 已成功安装。
根据你的操作系统,Python 可能已经安装好了。
设置 Graphviz
以下步骤描述如何设置 Graphviz:
-
我们可以从
graphviz.gitlab.io/download/
下载图形可视化软件。 -
由于我们使用的是 Windows 操作系统,我们选择标有“稳定 2.38 Windows 安装包”的选项,如下图所示:
图 4
选择可下载的 .msi
文件,如下所示:
图 5
- 一旦 Graphviz 可执行文件下载完成,继续安装该文件并选择默认选项;再次记录安装路径,如下图所示:
图 6
- 现在,我们将像安装 Python 时一样,将 Graphviz 的
bin
文件夹添加到路径变量中。然后,复制 Graphviz 安装的路径,并在后面加上\bin
,如下图所示:
图 7
- 为了验证这个库是否已正确安装,打开一个新的命令提示符窗口,输入
dot -V
命令,你将看到以下结果:
图 8
上述截图显示的输出确认 Graphviz 已成功安装。
安装 pip
安装 pip
的步骤如下:
-
为了安装
pip
,你需要从bootstrap.pypa.io/get-pip.py
下载get-pip.py
文件,并记录该文件所在的路径。在我的例子中,文件位于Documents\ai\softwares
。 -
打开命令提示符,并使用
cd
命令进入Documents\ai\softwares
文件夹,如下图所示:
图 9
- 使用
dir
命令查看该文件夹的内容,你将看到get-pip.py
,如下图所示:
图 10
-
接下来,我们将运行
python get-pip.py
命令。 -
现在,让我们将 Python 的
scripts
文件夹添加到 Path 环境变量中。 -
打开另一个命令提示符窗口,通过输入
pip --version
命令来测试pip
的安装。如果安装成功,你将看到以下输出:
图 11
- 一旦
pip
安装完成,你可以通过运行以下命令来安装pydot
:
pip install pydot
- 最后,通过执行以下命令安装
matplotlib
:
pip install matplotlib
- 我们可以通过在 Python 解释器中使用
import
命令来检查库是否已正确安装,如下图所示:
图 12
现在,我们已经在 Windows 中安装好了本书所需的所有库。接下来,我们将探讨如何开发一个文件搜索应用。
文件搜索应用介绍
在文件管理器中,文件搜索用于查找特定名称的文件;在集成开发环境(IDE)中,文件搜索用于查找包含特定代码文本的程序文件。
在本主题中,我们将开发第一个示例,以便查找一个名为f211.txt
的文件。文件夹结构如下截图所示:
图 13
这个文件夹结构可以表示为树形结构,如下图所示;我们要寻找的文件用绿色边框标出:
图 14
让我们看看文件搜索是如何工作的,以便找到这个文件:
-
文件搜索从当前目录开始;它打开第一个文件夹(d1),然后打开d1中的第一个文件夹(d11)。在d11中,它会比较所有的文件名。
-
由于d11中没有更多内容,算法退出d11,进入d1,然后去下一个文件夹,即d12,并比较所有文件。
-
现在,它离开了d12,去访问d1中的下一个文件夹(f11),然后是下一个文件夹(f12)。
-
现在,搜索算法已经覆盖了d1文件夹中的所有内容。于是,它退出d1,进入当前目录中的下一个文件夹,即d2。
-
在d2中,它打开第一个文件夹(d21)。在d21中,它比较所有文件,并找到了我们要找的f211文件。
如果你参考前面的文件夹结构,你会发现一个被反复使用的模式。当我们到达f111时,算法已经探索了树的最左边部分,直到它的最大深度。一旦达到了最大深度,算法就会回溯到上一级,然后开始探索右侧的子树。再次地,在这种情况下,子树的最左边部分被探索,当我们达到最大深度时,算法会去下一棵子树。这个过程会一直重复,直到找到我们正在搜索的文件。
现在我们已经理解了搜索算法的逻辑功能,在下一个主题中,我们将探讨执行搜索所需的搜索主要组成部分,这些组成部分在本应用中被用来进行搜索。
基本搜索概念
为了理解搜索算法的功能,我们首先需要了解一些基本的搜索概念,如状态、搜索的组成部分和节点:
-
状态:状态被定义为搜索过程发生的空间。它基本上回答了这个问题——我们在搜索什么? 例如,在一个导航应用中,状态是一个地点;在我们的搜索应用中,状态是一个文件或文件夹。
-
搜索的元素:搜索算法中有三个主要元素。这些元素如下,以寻宝为例:
-
初始状态:这是回答问题——我们从哪里开始搜索? 在我们的例子中,初始状态就是我们开始寻宝的地点。
-
后继函数:这是回答问题——我们如何从初始状态开始探索? 在我们的例子中,后继函数应该返回我们开始寻宝时的所有路径。
-
目标函数:这是回答问题——我们如何知道何时找到了解决方案? 在我们的例子中,目标函数返回 true,如果你找到了标记为宝藏的地方。
-
搜索元素在下图中进行了说明:
图 15
- 节点:节点是树的基本单元。它可以包含数据或指向其他节点的链接。
制定搜索问题
在文件搜索应用程序中,我们从当前目录开始搜索,因此我们的初始状态是当前目录。现在,让我们编写状态和初始状态的代码,如下所示:
图 16
在前面的截图中,我们创建了两个 Python 模块,State.py
和StateTest.py
。State.py
模块将包含前一部分提到的三个搜索元素的代码。StateTest
模块是一个可以用来测试这些元素的文件。
让我们继续创建一个构造函数和一个返回初始状态的函数,如下所示的代码:
....
import os
class State:
'''
This class retrieves state information for search application
'''
def __init__(self, path = None):
if path == None:
#create initial state
self.path = self.getInitialState()
else:
self.path = path
def getInitialState(self):
"""
This method returns the current directory
"""
initialState = os.path.dirname(os.path.realpath(__file__))
return initialState
....
在前面的代码中,以下内容适用:
-
我们有构造函数(构造函数名称),并且我们创建了一个名为
path
的属性,用于存储状态的实际路径。在前面的代码示例中,我们可以看到构造函数将path
作为参数。if...else
块表示如果没有提供路径,则将状态初始化为初始状态;如果提供了路径,则会创建具有该特定路径的状态。 -
getInitialState()
函数返回当前工作目录。
现在,让我们继续创建一些示例状态,如下所示:
...
from State import State
import os
import pprint
initialState = State()
print "initialState", initialState.path
interState = State(os.path.join(initialState.path, "d2", "d21"))
goalState = State(os.path.join(initialState.path, "d2", "d21", "f211.txt"))
print "interState", interState.path
print "goalState", goalState.path
....
在前面的代码中,我们创建了以下三个状态:
-
initialState
,指向当前目录 -
interState
,是指向d21
文件夹的中间函数 -
goalState
,指向f211.txt
文件夹
接下来,我们将看一下successor
函数。如果我们在某个特定文件夹中,successor
函数应该返回该文件夹内的文件夹和文件。如果你当前查看的是一个文件,它应该返回一个空数组。考虑下图,如果当前状态是d2
,它应该返回指向d21
和d22
文件夹的路径:
图 17
现在,让我们按照以下代码创建前面的函数:
...
def successorFunction(self):
"""
This is the successor function. It generates all the possible
paths that can be reached from current path.
"""
if os.path.isdir(self.path):
return [os.path.join(self.path, x) for x in
sorted(os.listdir(self.path))]
else:
return []
...
前述函数检查当前路径是否为目录。如果是目录,它会获取目录中所有文件夹和文件的排序列表,并将当前路径添加到它们前面。如果是文件,它会返回一个空数组。
现在,让我们用一些输入来测试这个函数。打开StateTest
模块,查看初始状态和中间状态的后继状态:
...
initialState = State()
print "initialState", initialState.path
interState = State(os.path.join(initialState.path, "d2", "d21"))
goalState = State(os.path.join(initialState.path, "d2", "d21", "f211.txt"))
print "interState", interState.path
print "goalState", goalState.path
...
如前所示,当前目录(或初始状态)的后继状态是 LiClipse 项目文件和文件夹d1
、d2
和d3
,而中间状态的后继状态是f211.txt
文件。
运行前述代码的输出结果如下图所示:
图 18
最后,我们来看看目标函数。那么,我们如何知道我们已经找到了目标文件f211.txt
呢?我们的目标函数应该对d21
文件夹返回False
,对f211.txt
文件返回True
。我们来看看如何在代码中实现这个函数:
...
def checkGoalState(self):
"""
This method checks whether the path is goal state
"""
#check if it is a folder
if os.path.isdir(self.path):
return False
else:
#extract the filename
fileSeparatorIndex = self.path.rfind(os.sep)
filename = self.path[fileSeparatorIndex + 1 : ]
if filename == "f211.txt":
return True
else:
return False
...
如前所示,checkGoalState()
函数是我们的目标函数;它检查当前路径是否为目录。现在,因为我们要找的是文件,如果是目录,它会返回False
。如果是文件,它会从路径中提取文件名。文件名是从路径中最后一个斜杠到字符串结尾的子串。所以,我们提取文件名并与f211.txt
进行比较。如果匹配,我们返回True
;否则,返回False
。
再次测试这个函数,测试我们创建的状态。为此,打开StateTest
模块,如下图所示:
图 19
如你所见,该函数对当前目录返回False
,对d21
文件夹返回False
,对f211.txt
文件返回True
。
现在我们已经理解了搜索算法中的三个要素,在接下来的章节中,我们将讨论如何使用节点构建搜索树。
使用节点构建树
在本节中,您将学习如何使用节点创建搜索树。我们将讨论状态和节点的概念、节点类的属性和方法,并展示如何用节点对象创建一棵树。在我们的应用中,状态是我们正在处理的文件或文件夹的路径(例如,当前目录),而节点是搜索树中的一个节点(例如,当前目录节点)。
一个节点有许多属性,其中之一是状态。其他属性如下:
-
深度:这是节点在树中的层级
-
对父节点的引用:这包括指向父节点的链接
-
对子节点的引用:这包括指向子节点的链接
让我们看一些例子,以便更清楚地理解这些概念:
-
这些概念在当前目录节点中的示例如下:
-
深度:0
-
父节点引用:无
-
子节点引用:d1、d2、d3
-
图 20
-
这些概念在节点d3中的一个示例如下:
-
深度:1
-
父节点引用:当前目录节点
-
子节点引用:f31
-
图 21
-
这些文件节点f111概念的一个示例如下:
-
深度:3
-
父节点引用:d11
-
子节点引用:[]
-
图 22
让我们创建一个名为Node
的类,其中包含我们刚才讨论的四个属性:
...
class Node:
'''
This class represents a node in the search tree
'''
def __init__(self, state):
"""
Constructor
"""
self.state = state
self.depth = 0
self.children = []
self.parent = None
...
如上面代码所示,我们创建了一个名为Node
的类,这个类有一个构造函数,接受state
作为参数。state
参数被分配给该节点的state
属性,其他属性初始化如下:
-
深度设置为
0
-
子节点的引用被设置为空数组
-
父节点引用设置为
None
该构造函数为搜索树创建一个空节点。
除了构造函数,我们还需要创建以下两个方法:
-
addChild()
:该方法在父节点下添加一个子节点 -
printTree()
:该方法打印树形结构
请参考以下addChild()
函数的代码:
def addChild(self, childNode):
"""
This method adds a node under another node
"""
self.children.append(childNode)
childNode.parent = self
childNode.depth = self.depth + 1
addChild()
方法将子节点作为参数;子节点被添加到子节点数组中,并且子节点的父节点被设置为其父节点。子节点的深度是父节点深度加一。
让我们以块图的形式来看这个,以便更清晰地理解:
图 23
假设我们要在节点d3下添加节点f31。那么,f31将被添加到d3的children
属性中,并且f31的父节点属性将被设置为d3。此外,子节点的深度将比父节点深度多一。在这里,节点d3的深度是1,因此f31的深度是2。
让我们看看printTree
函数,如下所示:
def printTree(self):
"""
This method prints the tree
"""
print self.depth , " - " , self.state.path
for child in self.children:
child.printTree()
首先,该函数打印当前节点的深度和状态;然后,遍历所有子节点,并对每个子节点调用printTree
方法。
让我们尝试创建下图所示的搜索树:
图 24
如前图所示,作为根节点,我们有当前目录节点;在该节点下,有d1、d2和d3节点。
我们将创建一个NodeTest
模块,它将创建示例搜索树:
...
from Node import Node
from State import State
initialState = State()
root = Node(initialState)
childStates = initialState.successorFunction()
for childState in childStates:
childNode = Node(State(childState))
root.addChild(childNode)
root.printTree()
...
如前面的代码所示,我们通过创建一个没有参数的 State
对象来创建初始状态,然后将这个初始状态传递给 Node
类的构造函数,进而创建根节点。为了获取文件夹 d1
、d2
和 d3
,我们在初始状态上调用 successorFunction
方法,并遍历每个子状态(从每个子状态创建一个节点);我们将每个子节点添加到根节点下。
当我们执行上述代码时,我们将得到如下输出:
图 25
在这里,我们可以看到当前目录的深度为 0
,它的所有内容的深度为 1
,包括 d1
、d2
和 d3
。
这样,我们已经成功地使用 Node
类构建了一个示例搜索树。
在下一个主题中,你将学习栈数据结构,这将帮助我们创建 DFS 算法。
栈数据结构
栈 是一堆物体,一个叠一个地放置(例如,一堆书、一堆衣服或一堆纸)。栈有两个操作:一个用于将元素添加到栈中,另一个用于从栈中移除元素。
用于将元素添加到栈中的操作叫做 push,而移除元素的操作叫做 pop。元素是按压入顺序的逆序弹出的;这就是为什么这个数据结构叫做 后进先出(LIFO)的原因。
让我们在 Python 中实验栈数据结构。我们将使用列表数据结构作为栈来进行操作。在 Python 中,我们将使用 append()
方法将元素压入栈中,并使用 pop()
方法将它们弹出:
...
stack = []
print "stack", stack
#add items to the stack
stack.append(1)
stack.append(2)
stack.append(3)
stack.append(4)
print "stack", stack
#pop all the items out
while len(stack) > 0:
item = stack.pop()
print item
print "stack", stack
...
如前面的代码所示,我们创建了一个空栈并打印出来。我们一个接一个地将数字 1
、2
、3
和 4
添加到栈中并打印它们。然后,我们一个接一个地弹出这些元素并打印出来;最后,我们打印剩余的栈。
如果我们执行上述代码,Stack.py
,我们将得到如下输出:
图 26
最开始,我们有一个空栈,当元素 1
、2
、3
和 4
被压入栈时,栈顶为 4
。现在,当你弹出元素时,第一个弹出的将是 4
,然后是 3
,接着是 2
,最后是 1
;这正是入栈顺序的逆序。最后,我们会得到一个空栈。
现在我们清楚了栈的工作原理,让我们将这些概念应用到实际的 DFS 算法创建中。
DFS 算法
现在你已经理解了搜索的基本概念,我们将通过使用搜索算法的三大基本要素——初始状态、后继函数和目标函数,来了解 DFS 是如何工作的。我们将使用栈数据结构。
让我们首先用流程图来表示 DFS 算法,以便更好地理解:
图 27
前面流程图中的步骤如下:
-
我们使用初始状态创建根节点,并将其添加到我们的栈和树中。
-
我们从栈中弹出一个节点。
-
我们检查它是否具有目标状态;如果它具有目标状态,我们就停止搜索。
-
如果步骤 3 中的条件答案是否,那么我们找到弹出节点的子节点,并将它们添加到树和栈中。
-
我们重复步骤 2 到 4,直到找到目标状态或搜索树中的所有节点都被耗尽。
让我们将前面的算法应用到我们的文件系统,如下所示:
图 28
-
我们创建根节点,将其添加到搜索树,并将其添加到栈中。我们从栈中弹出一个节点,它是当前目录节点。
-
当前目录节点没有目标状态,因此我们找到它的子节点,并将它们添加到树和栈中。
当我们将节点添加到栈中时,必须按相反的顺序添加,以确保栈顶的节点在搜索树的最左侧。
-
我们从栈中弹出一个节点(d1);它没有目标状态,因此我们找到它的子节点,并将它们添加到树和栈中。
-
我们从栈中弹出一个节点(d11);它没有目标状态,因此我们找到它的子节点,并将它们添加到树和栈中。
-
我们弹出一个节点(f111);它没有目标状态,而且也没有子节点,因此我们继续。
-
我们弹出下一个节点,d12;我们找到它的子节点,并将它们添加到树和栈中。
-
我们弹出下一个节点,f121,它没有任何子节点,因此我们继续。
-
我们弹出下一个节点,f122,它没有任何子节点,因此我们继续。
-
我们弹出下一个节点,f11,我们遇到了相同的情况(没有子节点),所以我们继续;f12也一样。
-
我们弹出下一个节点,d2,我们找到它的子节点,并将它们添加到树和栈中。
-
我们弹出下一个节点,d21,我们找到它的子节点,并将它添加到树和栈中。
-
我们弹出下一个节点,f211,我们发现它具有目标状态,因此我们在这里结束搜索。
让我们尝试将这些步骤实现为代码,如下所示:
...
from Node import Node
from State import State
def performStackDFS():
"""
This function performs DFS search using a stack
"""
#create stack
stack = []
#create root node and add to stack
initialState = State()
root = Node(initialState)
stack.append(root)
...
我们创建了一个名为StackDFS.py
的 Python 模块,它有一个名为performStackDFS()
的方法。在这个方法中,我们创建了一个空栈,它将存储我们所有的节点,initialState
,一个包含initialState
的根节点,最后我们将这个根节点添加到栈中。
记住,在Stack.py
中,我们写了一个while
循环来处理栈中的所有项目。因此,在这种情况下,我们将编写一个while
循环来处理栈中的所有节点:
...
while len(stack) > 0:
#pop top node
currentNode = stack.pop()
print "-- pop --", currentNode.state.path
#check if this is goal state
if currentNode.state.checkGoalState():
print "reached goal state"
break
#get the child nodes
childStates = currentNode.state.successorFunction()
for childState in childStates:
childNode = Node(State(childState))
currentNode.addChild(childNode)
...
如上面的代码所示,我们从栈顶弹出节点并将其称为 currentNode()
,然后打印它,这样我们就可以看到节点处理的顺序。我们检查当前节点是否具有目标状态,如果有,我们在此处结束执行。如果没有目标状态,我们会找到它的子节点并将其添加到 currentNode
下,就像我们在 NodeTest.py
中所做的那样。
我们还将这些子节点按相反顺序添加到栈中,使用以下的 for
循环:
...
for index in range(len(currentNode.children) - 1, -1, -1):
stack.append(currentNode.children[index])
#print tree
print "----------------------"
root.printTree()
...
最后,当我们退出 while
循环时,我们打印出树。代码成功执行后,我们将得到以下输出:
图 29
输出显示了节点处理的顺序,我们可以看到树的第一个节点。最终,我们遇到了我们的目标状态,搜索停止:
图 30
上面的截图显示了搜索树。请注意,上面的输出和之前的输出几乎相同。唯一的区别是,在上面的截图中,我们可以找到两个节点,d22
和 d3
,因为它们的父节点已经被探索过了。
递归 DFS
当一个函数调用自身时,我们说这个函数是递归函数。让我们看看斐波那契数列的例子。它的定义如下:f(1)
等于 1
,f(2)
等于 1
,对于 n
大于 2
,f(n)
等于 f(n-1) + f(n-2)
。让我们看看这个函数在代码中的实现,代码如下:
...
def fibonacci(n):
if n <= 2:
return 1
else:
return fibonacci(n-1) + fibonacci(n-2)
print "fibonacci(5)", fibonacci(5)
...
在上面的代码中,我们创建了我们的函数,fibonacci
,它接受一个数字 n
作为输入。如果 n
小于或等于 2
,它返回 1
;否则,它返回 fibonacci(n-1) + fibonacci(n-2)
。在代码的末尾,我们计算了 fibonacci(5)
的值,即 5
。
运行上面代码的输出如下截图所示:
图 31
如果我们想可视化 fibonacci
函数的递归树,可以访问 visualgo.net/en/recursion
。这个网站提供了各种数据结构和算法的可视化。
递归树的可视化如下:
图 32
如上面的截图所示,我们在这里得到的输出与我们用代码得到的输出相同,并且节点被探索的顺序与深度优先搜索(DFS)相似。
所以,当函数 1 调用函数 2 时会发生什么? 程序会在程序栈上添加一个栈帧。栈帧包含函数 1 的局部变量、传递给函数 1 的参数,以及函数 2 和函数 1 的返回地址。
让我们再次看看斐波那契数列的例子:
图 33
如您所见,斐波那契代码为了更清晰而进行了修改。假设程序正在执行加粗的那一行,val2 = fibonacci(n-2)。那么,创建的栈帧将包含以下值——局部变量为 val1,传递的参数为 n,返回地址为加粗部分代码的地址。
这意味着返回地址指向未处理的曲线。因为在递归中,程序栈保持一堆未处理的调用,而不是将节点存储在栈中,我们会递归地对子节点调用 DFS,这样栈就间接地得到了维护。
让我们看一下递归 DFS 在以下图示中的步骤:
图 34
上述图示中的步骤解释如下:
-
我们创建了一个初始状态。
-
我们使用这个初始状态创建了一个根节点。
-
我们将根节点添加到搜索树中,并对根节点调用 DFS。
-
递归 DFS 定义如下:检查节点是否具有目标状态。如果是,则返回路径;如果没有,则 DFS 会找到子节点,对于每个子节点,DFS 会将该节点添加到树中,最后在子节点上递归调用自身。
现在,我们将上述算法应用于我们的文件系统,步骤如下:
图 35
-
我们创建根节点并将其添加到搜索树中,并对该根节点调用 DFS。
-
当我们对这个根节点调用 DFS 时,函数会检查该节点是否具有目标状态,如果没有,它会找到其子节点(d1,d2 和 d3)。它选取第一个节点 d1,将其添加到搜索树中,并对该节点调用 DFS。
-
当对 d1 调用 DFS 时,函数会创建一个程序。当对 d1 调用 DFS 时,程序会创建一个栈帧并将其添加到程序栈中。在这种情况下,我们将在
for
循环中展示剩余需要处理的节点。这里,我们将 d2 和 d3 添加到程序栈中。 -
当对 d1 调用 DFS 时,它会找到子节点 d11,d12,f11 和 f12,并将 d11 添加到搜索树中。
-
它对 d11 调用 DFS,当它这么做时,它会在程序栈中创建一个包含未处理节点的条目。现在,当对 d11 调用 DFS 时,它会找到子节点 f111,将 f111 添加到搜索树中,并对该节点调用 DFS。
-
当对 f111 调用 DFS 时,它没有子节点,所以它会返回;当这种情况发生时,程序栈会被解开,意味着程序会返回执行并处理栈中最后一个未处理的节点。在这种情况下,它开始处理节点 d12。因此,程序将节点 d12 添加到搜索树中,并对 d1 调用 DFS。
-
当对 d12 调用 DFS 时,它会找到子节点 f121 和 f122。它将节点 f121 添加到搜索树,并对其调用 DFS。当对 f121 调用 DFS 时,它将未处理的节点 f122 添加到栈中。
-
当对f121调用 DFS 时,它没有子节点,因此栈会被解开。然后,我们处理节点f122。这个节点被添加到搜索树中,并对其调用 DFS。接着,我们继续处理最后一个节点f11,将其添加到搜索树中,并对其调用 DFS。
-
当我们对f11调用 DFS 时,它没有子节点,因此栈再次被解开。我们继续处理节点f12,将其添加到搜索树中,并对其调用 DFS。我们遇到了这种情况,然后继续处理节点d2。我们将其添加到搜索树中,并对d2调用 DFS。
-
当我们对d2调用 DFS 时,发现它有子节点:d21和d22。我们将d21添加到搜索树中,并对d21调用 DFS;当我们对d21调用 DFS 时,它会为d22创建一个入口。在程序栈中,当对d21调用 DFS 时,发现它有一个子节点f211。这个节点被添加到搜索树中,并对f211调用 DFS。
-
当对f211调用 DFS 时,我们意识到它包含目标状态,搜索过程在此结束。
让我们看看如何实现递归 DFS,代码如下:
...
from State import State
from Node import Node
class RecursiveDFS():
"""
This performs DFS search
"""
def __init__(self):
self.found = False
...
如前面的代码所示,我们创建了一个名为RecursiveDFS.py
的 Python 模块。它包含一个名为RecursiveDFS
的类,在构造函数中有一个名为found
的属性,用来表示是否已找到解决方案。稍后我们将查看found
变量的重要性。
让我们来看一下以下几行代码:
...
def search(self):
"""
This method performs the search
"""
#get the initial state
initialState = State()
#create root node
rootNode = Node(initialState)
#perform search from root node
self.DFS(rootNode)
rootNode.printTree()
...
这里,我们有一个名为search
的方法,在其中我们创建了initialState
,并在rootNode
上调用 DFS 函数。最后,在执行 DFS 搜索后,我们打印出树的内容,如下所示:
...
def DFS(self, node):
"""
This creates the search tree
"""
if not self.found:
print "-- proc --", node.state.path
#check if we have reached goal state
if node.state.checkGoalState():
print "reached goal state"
#self.found = True
else:
#find the successor states from current state
childStates = node.state.successorFunction()
#add these states as children nodes of current node
for childState in childStates:
childNode = Node(State(childState))
node.addChild(childNode)
self.DFS(childNode)
....
DFS
函数可以定义如下:
-
如果未找到解决方案,则打印当前正在处理的节点。
-
我们检查节点是否包含目标状态,如果包含,我们就打印出已经达成目标状态。
-
如果它没有目标状态,我们就寻找子状态;接下来,我们为每个子状态创建子节点,将它们添加到树中,并对每个子节点调用
DFS
。
让我们执行程序,我们将得到以下输出:
图 36
当我们处理f211
时,达到了目标状态,但这里有三行多余的输出;这是因为这些节点已经被添加到程序栈中。为了去除这些行,我们创建了一个名为found
的变量,当目标状态被找到时,变量将被设置为True
。一旦遇到f211
,程序栈中的剩余节点将不会被处理:
图 37
让我们再次运行这段代码,看看会发生什么:
图 38
如你所见,一旦我们处理了f211
并达到了目标状态,节点处理就停止了。printTree
函数的输出与我们在StackDFS.py
中存储的内容相同。
现在你已经理解了如何将 DFS 实现为递归函数,在下一个主题中,我们将讨论一个你可以自己开发的应用。
自己动手
在本节中,我们将讨论一个你可以自己开发的应用程序。我们将看看一个新的应用,并讨论需要进行的修改。在文件搜索应用介绍部分,我们讨论了两种文件搜索的应用;现在,我们将开发第二种类型的示例。我们的目标是开发一个能够查找包含特定程序文本的程序文件的搜索应用。
在递归 DFS 的代码中,我们主要使用了三个类,如下所示:
-
状态(State):包含搜索过程的三个主要要素
-
节点(Node):用于构建搜索树
-
递归 DFS:包含实际的算法实现
假设我们想要将这个代码或文件搜索应用程序适配到新的应用程序中。我们需要修改三个方法:getInitialState
、successorFunction
和checkGoalState
。对于新的程序搜索应用,你只需要修改一个方法:checkGoalState
。
在你的新checkGoalState
函数中,你需要打开文件,逐行读取文件内容,并进行子字符串检查或正则表达式检查。最后,根据检查结果返回 true 或 false。
那么,去试试看吧!
小结
在本章中,我们讨论了与搜索相关的四个基本概念:状态(state),即我们搜索过程的当前状态;节点(node),用于构建搜索树;栈(stack),用于帮助遍历搜索树,并决定遍历节点的顺序;递归(recursion),它消除了显式栈的需求。你还学习了深度优先搜索(DFS),它以深度优先的顺序探索搜索树。
在下一章中,你将学习广度优先搜索(BFS),它以广度优先的顺序探索搜索树。到时候见!
请参考链接 www.packtpub.com/sites/default/files/downloads/HandsOnArtificialIntelligenceforSearch_ColorImages.pdf
,查看本章的彩色图片。
第二章:理解广度优先搜索算法
广度优先搜索(BFS)算法是一种遍历算法,首先从选定的节点(源节点或起始节点)开始,按层次遍历图,探索相邻节点(与源节点直接连接的节点)。然后,继续向下一层的相邻节点移动。
在本章中,你将学习 BFS 算法,并开发 LinkedIn 的连接功能。你将学习如何通过 BFS 算法计算二度连接。
本章我们将涵盖以下主题:
-
理解 LinkedIn 的连接功能
-
图数据结构
-
队列数据结构
-
BFS 算法
-
DFS 与 BFS
理解 LinkedIn 的连接功能
如你所知,LinkedIn 是一个社交网络,用户通过一度或二度连接与他人建立联系。为了更好地理解这个概念,可以参考以下图示:
图 1
假设我想找到一个名为Jill的熟人并与她建立连接。当我查看她的个人资料时,我发现她是二度连接,这意味着我们有一个共同的同事。让我们来看看这个度数是如何计算的。为此,我们将创建一个连接树:
- 我们从个人资料节点Dev开始,并将其添加到连接树中:
图 2
- 现在,我将找到我的同事并把他们添加到我的节点下。因此,我将Ali和Tom添加到Dev节点下:
图 3
- 现在,对于Ali和Tom,我找到他们的同事并把他们添加到各自的节点下。因此,在Ali节点下,我添加了Dev、Seth和Ram;在Tom节点下,我添加了Dev、Seth、Kai和Jill:
图 4
- 现在,对于这些节点,我们找到它们的连接并将其添加进去:
图 5
在上面的图中,已经添加了与Dev的连接(由于空间限制,未显示)。对于Seth,我们找到他的连接(Ali、Tom和Harry),并将它们添加到他的名字下。对于Ram,我们添加了Ali和Jill。类似地,由于空间限制,未显示Dev和Seth的连接,因为它们已经在图中显示。对于Kai,我们添加了他的连接Tom。最后,当我们到达Jill节点(以添加她的连接)时,我们发现这个节点已经到达目标状态,因此我们结束了搜索。
你可能注意到Jill作为与Ram的连接出现在树的底部;但是,如果你考虑底部节点,连接度为3,这不是最小值。然而,由于 BFS 搜索按层次逐级处理搜索树,我们能够找到最短路径解决方案。我们还可以看到在这个连接树中,有些人出现了多次。例如,Dev、Ali和Tom每个人都出现了三次,而Seth和Jill每个人出现了两次。
所以,我们将保留连接树中的节点的第一个条目,并移除其他实例;下图展示了搜索树的样子:
图 6
当我们将节点添加到搜索树时,我们应该检查它是否已经存在于搜索树中。
在第一章,理解深度优先搜索算法中,您学到了State
类表示搜索过程的状态。您还学到了,即使搜索算法相同,State
类也需要针对每个应用进行更改。现在,让我们看看为了这个应用,我们需要对State
类进行的更改。
首先,我们需要一个属性来追踪搜索的状态。在这个应用中,该属性是当前正在考虑的人。然后,我们有四个相同的方法——constructor()
、getInitialState()
、successorFunction()
和checkGoalState()
。
让我们详细查看这三种成分。为了找到初始状态,我们应该问自己一个问题,我们从哪里开始搜索? 在这个应用中,我们从我的个人资料开始搜索。为了找到后继函数,我们应该问自己,我们如何从当前状态进行探索? 在这个应用中,函数应该返回与当前人相关联的人员。因此,对于Ali,它应该返回他的所有同事。最后,为了找到目标函数,我们应该问一个问题,我们如何知道何时找到了解决方案? 如果当前人是Jill
,目标函数应该返回 true。如果当前人是 Harry,函数应该返回 false,如果当前人是Jill
,则返回 true。
让我们看看这个应用中的State
类代码,如下所示:
...
from GraphData import *
class State:
'''
This class retrieves state information for social connection
feature
'''
def __init__(self, name = None):
if name == None:
#create initial state
self.name = self.getInitialState()
else:
self.name = name
def getInitialState(self):
"""
This method returns me.
"""
initialState = "Dev"
return initialState
def successorFunction(self):
"""
This is the successor function. It finds all the persons
connected to the current person
"""
return connections[self.name]
...
如前面的代码所示,在这个模块State.py
中,我们从GraphData
导入了所有变量。GraphData
的作用将在图形数据结构部分中进行解释。在构造函数中,传递了name
参数。如果name
参数为None
,则会创建初始状态;如果提供了名称,则该名称将赋值给名称属性。initialState
属性的值为Dev
,而successorFunction
方法返回与当前人相关联的所有人员。为了获取与该人相关联的人员,我们使用来自 GraphData 的连接:
def checkGoalState(self):
"""
This method checks whether the person is Jill.
"""
#check if the person's name is Jill
return self.name == "Jill"
checkGoalState
函数返回当前人的名字是否是Jill
。
现在,你应该了解了连接度是如何计算的,以及State
类在这个应用中的变化。
在接下来的部分,我们将看看如何将社交网络数据表示为图。
图数据结构
图是一种非线性数据结构,包含一组称为节点(或顶点)的点,以及一组称为边的连接,如下图所示:
图 7
连接到同一个节点的边被称为循环。如前面的图示所示,节点a和b通过两条路径连接;一条是通过边a-b,另一条是通过边a-d和d-b。树是一种特殊的图形,在这种图形中没有循环,且两个节点之间只有一条路径。
在 Python 中,我们可以使用字典结构来表示图。字典是一种数据结构,其中许多键映射到值。对于表示图的字典,键是节点,而这些节点的值是它们连接的节点:
图 8
在前面的图示中,我们可以看到以下内容:
-
对于键 a,值是b和c
-
对于键 b,值是c和a
-
对于键 c,值是a和b
现在,让我们创建一个字典来展示上一节中提到的社交网络的图结构:
...
#create a dictionary with all the mappings
connections = {}
connections["Dev"] = {"Ali", "Seth", "Tom"}
connections["Ali"] = {"Dev", "Seth", "Ram"}
connections["Seth"] = {"Ali", "Tom", "Harry"}
connections["Tom"] = {"Dev", "Seth", "Kai", 'Jill'}
connections["Ram"] = {"Ali", "Jill"}
connections["Kai"] = {"Tom"}
connections["Mary"] = {"Jill"}
connections["Harry"] = {"Seth"}
connections["Jill"] = {"Ram", "Tom", "Mary"}
...
在 Python 模块GraphData.py
中,我们创建了一个名为connections
的字典。字典的键是社交网络中的人物,值则是他们连接的人。现在,connections
字典在State.py
中使用。它在successorFunction
函数中使用,如以下代码所示:
...
def successorFunction(self):
"""
This is the successor function. It finds all the persons
connected to the current person
"""
return connections[self.name]
...
在这里,我们可以通过使用connections
字典来获取与某人连接的其他人,以此人的名字作为键。我们可以通过使用connections
对象来获取与该人连接的其他人。
现在,让我们来看一下如何遍历这个图数据结构,以便创建一个搜索树:
-
我们将从我在图中的个人资料开始,并将Dev节点添加到搜索树和已访问节点列表中。
-
从我在图中的节点出发,我们可以找到连接的人员,Ali和Tom;我们将这些节点添加到搜索树和已访问节点列表中。
-
对于Ali和Tom,我们通过使用图形数据结构找出他们连接的节点,并将这些节点添加到搜索树和访问节点列表中,如果它们之前没有被访问过。Ali与Dev、Seth和Ram连接。Dev已经被访问过,所以我们忽略这个节点。Seth和Ram没有被访问过,所以我们将这些节点添加到搜索树和访问节点列表中。Tom与Dev、Seth、Kai和Jill连接。Dev和Seth已经被访问过,所以我们忽略这些节点,添加Kai和Jill到列表中,因为它们之前没有被访问过。
-
我们重复将子节点添加到搜索树和访问节点列表的过程(如果它们之前没有被访问过)。Seth与Ali、Tom和Harry连接。Ali和Tom已经被访问过,所以我们忽略它们,添加Harry到搜索树和访问节点列表中。Ram与Ali和Jill连接,而这两个节点之前都已经被访问过。接下来,Kai与Tom连接,而他也已经被访问过。当我们处理Jill节点时,发现它具有目标状态,搜索结束。
你现在已经学会了如何使用访问节点的列表将图形作为树进行探索,结果将如下所示:
图 9
在接下来的章节中,你将学习队列数据结构,它与深度优先搜索(DFS)方法中的栈类似,是节点反转的基础。
队列数据结构
队列是一系列等待处理的人或物体。举几个例子,包括排队等候在柜台前的人们,准备跳入游泳池的游泳者队列,以及播放列表中的歌曲队列:
图 10
就像栈一样,队列也有两种操作——一种是将项目插入队列,另一种是从队列中移除项目。当一个人排队时,他或她必须站在最后一个人后面。向队列添加项目的操作称为入队。队列中第一个被处理的人是站在前面的人。从队列中移除项目的操作称为出队。队列操作可以通过以下图示表示:
图 11
由于第一个插入的对象是第一个被移除的,这种数据结构称为先进先出(FIFO)。在 Python 中,我们可以使用deque
类来创建queue
对象。deque
类提供了两种方法——一种方法是append
,用于插入项目,另一种方法是popleft
,用于移除项目:
...
from collections import deque
queue = deque([])
print queue
queue.append("1")
queue.append("2")
queue.append("3")
queue.append("4")
print queue
...
在上面的代码中,我们创建了一个空队列,并向其中添加了 1
、2
、3
和 4
,随后我们将逐一从队列中删除这些项目。代码成功执行后,我们将得到以下输出:
图 12
如上图所示,我们最初有一个空队列,添加了 1
、2
、3
和 4
后,你可以看到这些项目在队列中,1
在前,4
在后。当我们从队列中移除一个项目时,第一个被移除的是 1
,因为它在前,接着是 2
、3
和 4
,最后队列为空。
现在你已经理解了队列的工作原理,我们将查看 BFS 算法中的步骤,以及图和队列数据结构是如何使用的。
BFS 算法
在本节中,我们将了解 BFS 算法的流程,队列如何使用,以及图数据如何影响算法。BFS 算法的流程与 DFS 类似,但不同之处在于,BFS 使用的是队列数据结构,而不是栈数据结构。
BFS 算法的流程图可以如下所示:
图 13
-
我们首先创建一个具有初始状态的根节点,并将其添加到队列和树中。
-
一个节点从队列中出队,我们检查它是否具有目标状态。如果有,我们结束搜索。如果没有,我们找到该节点的子节点并将它们添加到队列中。
-
这个过程会一直重复,直到我们找到目标状态或者所有节点都已被搜索完。
-
由于我们的连接数据采用图结构,因此我们必须检查每个节点是否已被访问过。
-
因此,我们将根节点添加到已访问节点列表中,子节点被添加到队列、树和已访问列表中(如果子节点之前未被访问)。
让我们通过在图形图示中实现这些步骤来详细了解它们,这是我们在 理解 LinkedIn 连接功能 部分中讨论的内容:
-
我们首先将我的个人资料节点添加到搜索树、队列和已访问节点列表中。然后我们将 Dev 节点从队列中出队。
-
由于 Ali 节点尚未被访问,我们将这个节点添加到搜索树、队列和已访问节点列表中。同样,由于 Tom 节点未被访问,我们也将它添加到搜索树、队列和已访问节点列表中。
-
我们从队列中出队 Ali 节点,并且由于它没有目标状态,我们找到它的子节点:Dev、Seth 和 Ram。Dev 节点已被访问,因此我们忽略该节点。Seth 节点未被访问,因此我们将该节点添加到搜索树、队列和已访问节点列表中。同样,我们将 Ram 节点添加到搜索树、队列和已访问节点列表中。
-
我们从队列中出队Tom节点,并找到它的子节点:Dev、Seth、Kai和Jill。Dev节点已经被访问过,因此我们忽略该节点,Seth节点也是如此。Kai节点尚未被访问,因此我们将该节点添加到搜索树、队列和已访问节点列表中;Jill节点也同样处理。我们从队列中出队Seth节点,并找到它的子节点:Ali、Tom和Harry。Ali和Tom节点已经被访问,因此我们忽略这些节点。我们将Harry节点添加到搜索树、队列和已访问节点列表中。
-
我们从队列中出队Ram节点,并找到它的子节点,Ali和Jill,这两个节点都已被访问;因此,我们继续。
-
我们从队列中出队Kai节点,并找到它的子节点Tom,该节点已被访问,因此我们继续。
-
我们从队列中出队Jill节点,发现它是目标状态,因此我们结束搜索。
一旦完成上述步骤,我们将得到以下树形结构:
图 14
让我们用以下代码实现上述算法:
...
def performQueueBFS():
"""
This function performs BFS search using a queue
"""
#create queue
queue = deque([])
#since it is a graph, we create visited list
visited = []
#create root node
initialState = State()
root = Node(initialState)
#add to queue and visited list
queue.append(root)
visited.append(root.state.name)
...
在 Python 模块QueueBFS.py
中,我们创建了一个名为performQueueBFS
的方法,其中有一个空队列用于存放节点,并有一个已访问节点的列表。我们使用initialState
创建根节点,并将该根节点和已访问节点列表一起添加到队列中。我们逐个从队列中出队元素;我们将出队的节点称为currentNode
:
...
while len(queue) > 0:
#get first item in queue
currentNode = queue.popleft()
print "-- dequeue --", currentNode.state.name
#check if this is goal state
if currentNode.state.checkGoalState():
print "reached goal state"
#print the path
print "----------------------"
print "Path"
currentNode.printPath()
break
...
我们打印当前节点的名称,并检查它是否具有目标状态。如果是,我们打印从根节点到当前节点的路径并跳出循环。如果没有目标状态,我们找到当前状态的子状态,对于每个更高的状态,我们构建子节点并检查该节点是否已被访问。
现在,已访问节点的列表中保存了节点的名称。因此,在下面的代码中,我们已经添加了根节点的名称:
visited.append(root.state.name)
我们在下面的代码中做了同样的事情:
...
#check if node is not visited
if childNode.state.name not in visited:
#add this node to visited nodes
visited.append(childNode.state.name)
#add to tree and queue
currentNode.addChild(childNode)
queue.append(childNode)
#print tree
print "----------------------"
print "Tree"
root.printTree()
...
在上面的代码中,我们检查节点名称是否未被访问。因为我们在检查唯一的名称,如果节点没有被访问,我们就将该子节点的名称添加到已访问节点列表中,并将该子节点添加到搜索树和队列中。最后,我们打印队列。
让我们运行代码,看看会发生什么:
图 15
在上面的截图中,我们可以看到节点处理的顺序。我们从Dev
节点开始,然后处理连接的Ali
和Tom
,接着是Ali
、Ram
和Seth
的连接,再然后是Tom
、Kai
和Jill
的连接。当我们处理到Jill
节点时,发现已经达到了目标状态,我们结束了搜索。
在前面的截图中,我们可以看到从初始状态到目标状态的路径通过Tom
节点打印出来。从中可以看到Jill
是二级连接。我们还可以看到到目前为止构建的搜索树。
现在你已经了解了 BFS 的步骤,我们将对比 BFS 和 DFS 算法。
BFS 与 DFS
在本节中,我们将研究 DFS 和 BFS 算法之间的区别。我们将从多个因素来比较这些差异。
遍历顺序
在 DFS 中,优先考虑子节点,这意味着在探索完节点a和节点b后,接着探索完节点b和节点c,当遇到死胡同时,我们会回溯到上一级。这意味着我们回到节点b,然后继续探索它的下一个子节点,即节点c。
在 BFS 中,节点按层级覆盖,优先考虑兄弟节点。这意味着在探索完节点a后,接着探索节点b和e,然后再探索节点c、d和f,如下面的图所示:
图 16
数据结构
在 DFS 中,使用栈数据结构,而在 BFS 中,使用队列,如下图所示:
图 17
内存
当递归调用 DFS 时,节点c的隐式栈会存储两个条目——一个是节点e,另一个是节点c和d。因此,所使用的内存是按d的顺序,其中d是搜索树的深度。
当 BFS 方法被调用在节点c时,队列包含四个条目——节点c、d、f和g。因此,所使用的内存是按b^d的顺序,其中b是分支因子,d是搜索树的深度。这里,分支因子为2,因为每个内部节点有两个子节点:
图 18
最优解
假设有两个可能的解决方案——节点d和e。在这种情况下,e是最优解,因为它从根节点a到达的路径最短。这里,DFS 首先找到子最优解d,然后才找到最优解e。BFS 在遇到节点d之前,先找到了最优解e:
图 19
我们已经看到 DFS 使用的内存比 BFS 少,而 BFS 能找到最优解。所以,算法的选择取决于搜索空间的大小(在这种情况下,你会选择 DFS),以及是否需要找到最优解(在这种情况下,BFS 更为合适)。
接下来,我们将看看一个你可以尝试自己开发的应用程序。
自己动手试试
在上一节中,我们讨论了 DFS 和 BFS 算法的区别。在本节中,我们将介绍一个应用程序,你可以尝试自己开发。我们将讲解你需要开发的应用程序以及为此所需的更改。
你的目标是开发一个大学导航应用程序,如下图所示:
图 20
假设这是大学的布局,学生可以沿着水平或垂直线路行走。在这个应用程序中,用户需要输入起点和终点。对于这个具体的情况,我们假设一名新生想要从 公交车站 找到 人工智能实验室。
你可以参考我们为 LinkedIn 连接功能开发的类,具体如下:
图 21
为了将代码适配到这个应用程序,我们需要修改 State
类和图数据。在 State
类中,name
属性被替换为 place
属性,NavigationData
包含地点之间的连接:
图 22
让我们详细看看搜索的三个要素。为了得到初始状态的答案,我们可以问自己一个问题,我们从哪里开始搜索? 在这个例子中,是 公交车站。因此,successorFunction
应该返回所有连接的地点。例如,如果当前地点是 停车场,那么该函数应该返回 图书馆、商店 和 数学楼。为了得到目标函数的答案,我们应该问自己一个问题,我们怎么知道何时找到了解决方案? 对于这个应用程序,如果地点是 人工智能实验室,函数应该返回 true;例如,如果当前地点是 餐厅,则返回 false;如果当前地点是 人工智能实验室,则返回 true。
去试试看吧!
总结
在本章中,为了帮助你理解 BFS 算法,我们重新回顾了状态和节点的概念。你了解了图和队列数据结构,并且我们讨论了 DFS 和 BFS 算法的区别。
在下一章中,你将学习启发式搜索算法。与优先考虑子节点或兄弟节点不同,这种方法优先考虑距离目标状态最近的节点;术语 启发式 指的是衡量节点与目标状态之间距离的度量。
请参考链接 www.packtpub.com/sites/default/files/downloads/HandsOnArtificialIntelligenceforSearch_ColorImages.pdf
获取本章的彩色图片。
第三章:理解启发式搜索算法
启发式搜索是一种利用启发式方法来实现功能的人工智能搜索技术。启发式是一个通常能引导出答案的一般性指导原则。启发式在搜索策略中扮演着重要角色,因为大多数问题的规模是指数级的。启发式帮助减少从指数级选项中筛选出多项可能性,从而减少搜索空间。 在人工智能(AI)中,启发式搜索具有普遍重要性,同时也有其特定的重要性。一般而言,启发式一词用于指代任何通常有效,但在每种情况下并不保证成功的做法。在启发式搜索设计中,启发式通常指的是启发式评估函数的特殊实例。
在本章中,我们将讨论以下主题:
-
重访导航应用
-
优先队列数据结构
-
可视化搜索树
-
贪心最佳优先搜索(BFS)
-
A* 搜索
-
优秀启发式的特征
重访导航应用
在第二章《理解广度优先搜索算法》中,我们看到了大学导航应用程序,其中我们想要从公交车站找到到达人工智能实验室的路径。在 BFS 方法中,我们假设连接地点之间的距离是 1(即相同)。然而,实际情况并非如此。现在,让我们假设大学是按如下方式设计的:
图 1
绿色值表示连接地点之间的实际距离。接下来,我们创建一个字典,用于存储这些地点的位置:
...
#connections between places
connections = {}
connections["Bus Stop"] = {"Library"}
connections["Library"] = {"Bus Stop", "Car Park", "Student Center"}
connections["Car Park"] = {"Library", "Maths Building", "Store"}
connections["Maths Building"] = {"Car Park", "Canteen"}
connections["Student Center"] = {"Library", "Store" , "Theater"}
connections["Store"] = {"Student Center", "Car Park", "Canteen", "Sports Center"}
connections["Canteen"] = {"Maths Building", "Store", "AI Lab"}
connections["AI Lab"] = {"Canteen"}
connections["Theater"] = {"Student Center", "Sports Center"}
connections["Sports Center"] = {"Theater", "Store"}
...
在 Python NavigationData.py
模块中,我们创建了一个名为 connections
的字典;该字典存储了地点之间的连接。这些连接与我们在第二章《理解广度优先搜索算法》中看到的 LinkedIn 连接功能应用中的人际连接类似:
...
#location of all the places
location = {}
location["Bus Stop"] = [2, 8]
location["Library"] = [4, 8]
location["Car Park"] = [1, 4]
location["Maths Building"] = [4, 1]
location["Student Center"] = [6, 8]
location["Store"] = [6, 4]
location["Canteen"] = [6, 1]
location["AI Lab"] = [6, 0]
location["Theater"] = [7, 7]
location["Sports Center"] = [7, 5]
...
我们还拥有 location
字典,用于存储地点的位置。location
字典的键是地点,值是这些地点的 x 和 y 坐标。
在 DFS 中,搜索树的探索优先考虑子节点;在 BFS 中,优先考虑兄弟节点。在启发式搜索中,优先考虑启发式值较低的节点。
现在,让我们来看一下术语启发式。启发式是节点类别的一个属性。它是对哪个节点能比其他节点更快到达目标状态的猜测或估计。这是一种用于减少探索节点数量并更快到达目标状态的策略:
图 2
例如,假设我们位于前面图示中的红色节点,它有两个子节点——黄色节点和绿色节点。绿色节点似乎离目标状态更近,因此我们会选择这个节点进行进一步探索。
在本章接下来的内容中,我们将看到以下两种启发式搜索算法:
-
贪心广度优先搜索算法
-
A* 搜索算法
优先队列数据结构
优先队列是一种队列,其中每个元素都有一个优先级。例如,当乘客排队等候登机时,带有小孩的家庭和商务舱乘客通常优先登机;然后,经济舱乘客登机。我们来看另一个例子。假设有三个人在服务台排队等候,而一位老人走到了队伍的最后面。考虑到他的年龄,队中的人可能会给他更高的优先级,让他先走。通过这两个例子,我们可以看到优先队列中的元素具有优先级,它们按优先级顺序处理。
就像在排队中一样,我们有操作可以将元素插入到优先队列中。插入操作会以特定优先级插入一个元素。考虑下图,说明插入操作:
图 3
在前面的图示中,元素A的优先级是5,因为优先队列为空,元素保持在前端。在 Python 中,优先级较低的元素排列在队列的前面,而优先级较高的元素排列在优先队列的后面。这意味着优先级较低的元素先被处理,因为它们位于优先队列的前面。现在,假设元素B需要以优先级10插入。由于10大于5,元素B被插入到元素A之后。现在,假设元素C需要以优先级1插入。因为1小于5,它被排在元素A前面。接下来,元素D需要以优先级5插入;这里,元素A和D的优先级都是5,但由于A先插入,它具有更高的优先级。这意味着D被插入在A之后,B之前。
在队列中,我们有一个操作叫做出队,它从队列前端移除一个元素。同样地,在优先队列中,我们有一个操作叫做获取前端元素,它从优先队列的前端移除一个元素。因此,调用这个操作四次,应该首先移除C,然后是A,接着是D,最后是B。
在 Python 中,我们有 Queue
类用于优先队列数据结构。它有一个 PriorityQueue
方法,该方法以 maxsize
作为参数来创建优先队列。如果 maxsize
小于 0
或等于 0
,则队列的大小是无限的。在我们的案例中,我们将不带参数地调用该方法,因为默认参数是 0
。在 PriorityQueue
中,元组的元素是 priority_number
和 data
。Queue
类有一个 empty()
方法,如果队列为空,则返回 True
,否则返回 False
。它还有一个 put()
方法,用于插入一个元组形式的项:(priority_number, data)
。最后,我们有一个 get()
方法,用于返回队列前端的元素。接下来,让我们尝试这些方法,如下所示:
...
import Queue
pqueue = Queue.PriorityQueue()
print pqueue.qsize()
pqueue.put((5, 'A'))
pqueue.put((10, 'B'))
pqueue.put((1, 'C'))
pqueue.put((5, 'D'))
print pqueue.qsize()
while not pqueue.empty():
print pqueue.get()
print pqueue.qsize()
...
我们创建了一个名为 PriorityQueue.py
的 Python 模块,并且导入了 Queue
类。我们还创建了一个优先队列,并逐个插入具有特定优先级的元素。
如前面的代码所示,我们插入了一个元组,其中优先级数字为 5
,数据为 A
;然后,我们插入了优先级为 10
的元素 B
,优先级为 1
的元素 C
,以及优先级为 5
的元素 D
。我们还检查了优先队列是否为空,当队列不为空时,我们逐一打印出所有元素,如下所示:
图 4
如你所见,在前面的输出中,优先队列最初是空的。插入四个元素后,队列的长度变为 4
;当我们获取前端元素时,第一个元素是 C
,接下来是 A
,然后是 D
,最后是 B
。
可视化搜索树
在上一章中,你学习了 图 是一个节点通过边连接的结构。树 是一种特殊类型的图,其中没有循环,并且两个节点通过一条路径连接。为了可视化树,我们将使用 pydot
Python 库,这是 Graphviz 的 DOT 语言的 Python 接口。在 第一章《理解深度优先搜索算法》中,我们了解到 Graphviz 是开源的图形可视化软件,它提供了 DOT 语言用于创建有向图的分层图形。此外,我们还将使用 matplotlib
库来显示最终渲染的图像。
现在,让我们使用这些库来可视化以下简单的树。它有一个根节点,并且根节点下有三个子节点:
图 5
请考虑以下代码:
...
import pydot
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
#create graph object
graph = pydot.Dot(graph_type='graph', dpi = 300)
#create and add root node
rootNode = pydot.Node("0 Root", style="filled", fillcolor = "#00ee11", xlabel = "0")
graph.add_node(rootNode)
...
我们创建了一个名为TreePlotTest.py
的 Python 模块,并导入了pydot
库和matplotlib
中所需的类。使用pydot
的Dot()
方法,我们可以创建一个graph
对象,该对象将容纳图形的节点和边。我们还在此情况下指定了图像的dpi
为300
。我们可以使用pydot
的Node()
方法来创建一个节点。我们通过传递标签为0 Root
,并使用style
参数filled
和fillcolor
参数#00ee11
来创建rootNode
;xlabel
为0
。
fillcolor
参数以十六进制格式指定。浏览到www.w3schools.com/colors/colors_picker.asp
选择颜色并查看其十六进制代码;稍后,你将了解为什么使用xlabel
:
...
rootNode = pydot.Node("0 Root", style="filled", fillcolor = "#00ee11", xlabel = "0")
graph.add_node(rootNode)
for i in range(3):
#create node and add node
childNode = pydot.Node("%d Child" % (i+1), style="filled", \
fillcolor = "#ee0011", xlabel = "1")
graph.add_node(childNode)
#create edge between two nodes
edge = pydot.Edge(rootNode, childNode)
#add the edge to graph
graph.add_edge(edge)
...
创建好rootNode
后,它将被添加到graph
对象中,我们将三次创建childNode
并为其指定适当的名称。style
参数将为filled
并使用另一种颜色,xlabel
为1
。我们还将此节点添加到图中。接着,我们将创建一个从rootNode
到新创建的childNode
的边,并将这条边添加到graph
对象中。以下代码块末尾的代码片段用于全屏显示图形:
...
#show the diagram
graph.write_png('graph.png')
img=mpimg.imread('graph.png')
plt.imshow(img)
plt.axis('off')
mng = plt.get_current_fig_manager()
mng.window.state('zoomed')
plt.show()
...
让我们运行前面的代码,看看会发生什么:
图 6
在成功执行代码后,我们将看到四个节点:根节点,然后是它下面的三个子节点。我们可以看到0
和1
的 xlabel 值,它们是节点的附加注释。
现在,让我们尝试修改childNode
的名称。我们将从子节点的名称中删除数字值,使得这三个节点具有相同的名称:
...
for i in range(3):
#create node and add node
childNode = pydot.Node("%d Child", style="filled", \
fillcolor = "#ee0011", xlabel = "1")
graph.add_node(childNode)
...
在修改了childNode
的名称后,我们将看到如下所示:
图 7
由于这三个节点具有相同的名称,pydot
将它们视为同一个节点。因此,我们应该尽量在搜索树中的节点使用唯一的名称。下面的图示显示了一个搜索树的示例:
图 8
在前面的图示中,我们希望可视化一个启发式搜索。每个节点都有一个启发式值。在这个例子中,公交车站出现了两次,因此我们使用索引值来区分多个实例。每个节点还有一个颜色编码。绿色节点已经被探索过;在这种情况下,公交车站和图书馆将被探索。红色节点已经被选择进行探索;在这种情况下,停车场被选择进行探索。蓝色节点是未探索的,形成了一个边缘,它们按启发式值的降序排列在优先队列中。边缘是一个按启发式值排序的未探索节点的优先队列。
在我们的例子中,数学大楼排在第一位,因为它的启发式值最小(2.2),接下来是商店,它的启发式值为4;然后是学生中心,启发式值为8;图书馆,启发式值为8.2;最后是公交站,启发式值为8.9。
在深度优先搜索(DFS)中,我们使用栈数据结构,优先考虑子节点。在广度优先搜索(BFS)中,我们使用队列数据结构,优先考虑兄弟节点。在启发式搜索中,我们将使用优先队列;这将优先选择距离目标最近的未探索节点,该节点是优先队列中的第一个节点:
图 9
需要对Node
类做一些修改,以适应启发式搜索和可视化过程。引入了一个新的属性fringe
,用于指示节点是否是边缘的一部分。引入了一个新的属性heuristic
,构造函数也发生了变化,新增了一个参数parentNode
。addChild
方法被修改为setParent
方法,我们还新增了一个名为computeHeuristic
的方法。现在,让我们来看一下Node
类的代码,如下所示:
...
def __init__(self, state, parentNode):
"""
Constructor
"""
self.state = state
self.depth = 0
self.children = []
#self.parent = None
self.setParent(parentNode)
self.fringe = True
#self.heuristic
self.computeHeuristic()
def setParent(self, parentNode):
"""
This method adds a node under another node
"""
if parentNode != None:
parentNode.children.append(self)
self.parent = parentNode
self.depth = parentNode.depth + 1
else:
self.parent = None
...
在这里,我们已经注释掉了将父节点设置为None
的代码。取而代之的是setParent
方法,它将父节点作为参数并设置该属性。我们有一个名为fringe
的属性,默认为True
,还有一个新属性heuristic
,它由computeHeuristic
函数设置。如前所述,addChild
已被设置为setParent
,它接受parentNode
作为参数。我们检查父节点是否不是None
;如果不是None
,则将节点添加到父节点的children
属性中,并将当前节点的parent
属性设置为parentNode
;当前节点的深度等于parentNode.depth + 1
。如果parentNode
是None
,则parent
属性设置为None
:
...
def computeHeuristic(self):
"""
This function computes the heuristic value of node
"""
#find the distance of this state to goal state
#goal location
goalLocation = location["AI Lab"]
#current location
currentLocation = location[self.state.place]
#difference in x coordinates
dx = goalLocation[0] - currentLocation[0]
#difference in y coordinates
dy = goalLocation[1] - currentLocation[1]
...
还有一个新方法叫做computeHeuristic
。这个函数将heuristic
属性设置为一个值。我们将在贪心 广度优先搜索和A 搜索*部分中看到这个函数如何实际工作,以及它计算的内容:
...
class TreePlot:
"""
This class creates tree plot for search tree
"""
def __init__(self):
"""
Constructor
"""
# create graph object
self.graph = pydot.Dot(graph_type='graph', dpi = 500)
#index of node
self.index = 0
def createGraph(self, node, currentNode):
"""
This method adds nodes and edges to graph object
Similar to printTree() of Node class
"""
# assign hex color
if node.state.place == currentNode.state.place:
color = "#ee0011"
elif node.fringe:
color = "#0011ee"
else:
color = "#00ee11"
...
在 Python 的TreePlot.py
模块中,我们创建了一个名为TreePlot
的类,用于创建Node
类的树形可视化。这个类有两个属性:一个是graph
对象,另一个是节点的index
。它有一个名为createGraph
的方法,用于将节点和边添加到graph
对象中。这个方法的流程类似于printTree
,因为它会递归地在子节点上调用。这个方法接收当前正在处理的节点和currentNode
作为参数。currentNode
是图中图 8中红色显示的节点,停车场。createGraph
方法检查我们正在处理的节点是否与currentNode
的状态相同,如果相同,则将其颜色设置为红色。如果它是边缘的一部分,则设置为蓝色。如果该节点已被探索,则将其颜色设置为绿色:
...
#create node
parentGraphNode = pydot.Node(str(self.index) + " " + \
node.state.place, style="filled", \
fillcolor = color, xlabel = node.heuristic)
self.index += 1
#add node
self.graph.add_node(parentGraphNode)
...
在为节点分配了十六进制颜色后,我们将创建该节点并将其命名为parentGraphNode
。该节点的标签是索引值和节点状态的组合,xlabel
是节点的启发式值。在创建完这个节点后,索引值将递增,节点将被添加到图中:
...
#call this method for child nodes
for childNode in node.children:
childGraphNode = self.createGraph(childNode, currentNode)
#create edge
edge = pydot.Edge(parentGraphNode, childGraphNode)
#add edge
self.graph.add_edge(edge)
return parentGraphNode
...
对于每个childNode
对象,我们调用self.createGraph
方法并传入childNode
和currentNode
。所以,当我们在childNode
上调用时,它应该返回相应的pydot
节点。然后,我们可以在parentGraphNode
和childGraphNode
之间创建一条边。创建完这条边后,我们可以将其添加到我们的graph
对象中:
...
def generateDiagram(self, rootNode, currentNode):
"""
This method generates diagram
"""
#add nodes to edges to graph
self.createGraph(rootNode, currentNode)
#show the diagram
self.graph.write_png('graph.png')
img=mpimg.imread('graph.png')
plt.imshow(img)
plt.axis('off')
mng = plt.get_current_fig_manager()
mng.window.state('zoomed')
plt.show()
...
这个类还有一个方法,叫做generateDiagram
,它接收rootNode
和currentNode
作为参数。首先,通过调用createGraph
方法生成包含所有节点和边的graph
对象,rootNode
作为第一个参数,currentNode
作为第二个参数。然后,我们会看到与之前展示图形时相同的代码片段。所以,如果你想可视化一个搜索树,你需要实例化一个TreePlot
对象,并调用generateDiagram
方法:
...
from Node import Node
from State import State
from TreePlot import TreePlot
initialState = State()
root = Node(initialState)
childStates = initialState.successorFunction()
for childState in childStates:
childNode = Node(State(childState))
root.addChild(childNode)
treeplot = TreePlot()
treeplot.generateDiagram(root, root)
...
在 Python 的TreePlotTest2.py
模块中,我们导入了必要的类——Node
、State
和TreePlot
,并且正在创建一个包含根节点和第一层子节点的示例树。我们还创建了一个TreePlot
对象,并调用了generateDiagram
方法,参数是root
和root
:
图 10
在前面的图中,我们可以看到根节点和第一层的子节点。
现在你已经学会了如何可视化树结构,在接下来的部分,你将学习贪心最佳优先搜索。
贪心 BFS
在重新审视导航应用部分,你已经学到启发式值是节点的一个属性,它是一个猜测或估计,表示哪个节点能比其他节点更快地到达目标状态。这是一种用来减少探索节点数量并更快达到目标状态的策略。在贪心 BFS中,启发式函数计算到达目标状态的估计代价。对于我们的应用,启发式函数可以计算到目标状态的直线距离,如下所示:
图 11
如你所见,在上面的图中,初始状态是Bus Stop。从Bus Stop节点出发,我们有一个通道,即Library节点。假设我们现在在Library,从Library节点出发,有三个子节点:Car Park、Bus Stop和Student Center。在现实生活中,我们更倾向于去Car Park,因为它似乎离目标状态更近,且到达AI Lab的几率也更高:
...
#connections between places
connections = {}
connections["Bus Stop"] = {"Library"}
connections["Library"] = {"Bus Stop", "Car Park", "Student Center"}
connections["Car Park"] = {"Library", "Maths Building", "Store"}
connections["Maths Building"] = {"Car Park", "Canteen"}
connections["Student Center"] = {"Library", "Store" , "Theater"}
...
让我们使用这四个地点(Library
、Car Park
、Bus Stop
和 Student Center
)的位置信息来计算这三个节点的启发式值。当你计算这三个节点的启发式值时,你会发现Car Park
的值是6.4
,Bus Stop
的值是8.9
,Student Center
的值是8.0
。根据这些启发式值,我们将选择边界中的第一个值,也就是启发式值最小的节点(Car Park
):
...
def computeHeuristic(self):
"""
This function computes the heuristic value of node
"""
#find the distance of this state to goal state
#goal location
goalLocation = location["AI Lab"]
#current location
currentLocation = location[self.state.place]
#difference in x coordinates
dx = goalLocation[0] - currentLocation[0]
#difference in y coordinates
dy = goalLocation[1] - currentLocation[1]
#distance
distance = math.sqrt(dx ** 2 + dy ** 2)
print "heuristic for", self.state.place, "=", distance
self.heuristic = distance
...
让我们来看一下前面的computeHeuristic
函数。Node
类有一个名为computeHeuristic
的方法。该函数通过计算从当前状态到目标状态的距离来计算节点的启发式值。你可以通过使用导航数据中的location
字典,并以 AI 实验室作为键,来找到目标位置。你可以通过使用location
字典,并以当前位置作为键,来找到当前地点。我们通过以下方式计算x
坐标的差值:dx = goalLocation[0] - currentLocation[0]
。我们通过以下方式计算y
坐标的差值:dy = goalLocation[1] - currentLocation[1]
。最后,我们通过计算dx
的平方加dy
的平方的平方根来得到距离,并将这个距离赋值给Node
类的heuristic
属性:
图 12
现在你已经理解了这个启发式函数,我们来看看贪心 BFS 算法的流程。该算法的流程类似于 BFS,不同的是它使用的是优先队列而不是队列,我们将计算节点的启发式值,并将节点和启发式值一起加入优先队列中:
-
我们首先创建根节点并将其添加到树中,然后将该节点及其启发式值一起添加到优先队列中。
-
我们从优先队列中获取前一个节点,并检查它是否有目标状态。如果有,我们就结束搜索;如果没有目标状态,我们就找到它的子节点,添加它们到树中,然后将它们连同启发式值一起添加到优先队列中。
-
我们继续这个过程,直到找到目标状态或搜索流中的所有节点都已被遍历完。
让我们尝试按以下方式编写贪婪 BFS 算法:
...
def performGreedySearch():
"""
This method performs greedy best first search
"""
#create queue
pqueue = Queue.PriorityQueue()
#create root node
initialState = State()
#parent node of root is None
root = Node(initialState, None)
#show the search tree explored so far
treeplot = TreePlot()
treeplot.generateDiagram(root, root)
#add to priority queue
pqueue.put((root.heuristic, root))
while not pqueue.empty():
#get front node from the priority queue
_, currentNode = pqueue.get()
#remove from the fringe
#currently selected for exploration
currentNode.fringe = False
print "-- current --", currentNode.state.place
...
在 Python GreedySearch.py
模块中,我们创建了一个performGreedySearch()
方法,该方法将执行贪婪 BFS。在这个方法中,我们创建了一个空的优先队列来存储节点。通过initialState
,我们创建了一个根节点,正如之前提到的,构造性节点已经改变;父节点中多了一个额外的参数。对于根节点,父节点是None
。
我们正在创建一个TreePlot
对象并调用其generateDiagram()
方法来可视化当前的搜索树。在这种情况下,搜索树将只包含根节点。我们将根节点及其启发式值添加到优先队列中。我们检查优先队列是否为空;如果不为空,我们获取队列的前一个元素并将其命名为currentNode
。如前所述,优先队列的格式是一个包含启发式值和节点的元组。由于我们只关心节点,因此会忽略启发式值。我们将currentNode
的fringe
属性设置为False
,因为它当前已被选中进行探索:
...
#check if this is goal state
if currentNode.state.checkGoalState():
print "reached goal state"
#print the path
print "----------------------"
print "Path"
currentNode.printPath()
#show the search tree explored so far
treeplot = TreePlot()
treeplot.generateDiagram(root, currentNode)
break
#get the child nodes
childStates = currentNode.state.successorFunction()
for childState in childStates:
#create node
#and add to tree
childNode = Node(State(childState), currentNode)
#add to priority queue
pqueue.put((childNode.heuristic, childNode))
#show the search tree explored so far
treeplot = TreePlot()
treeplot.generateDiagram(root, currentNode)
...
我们检查当前节点是否具有目标状态;如果它有目标状态,我们将打印从初始状态到目标状态的路径。我们通过调用treeplot.generateDiagram
方法来显示当前的搜索树。如果没有目标状态,我们会找到当前节点的子状态,并为每个childState
使用新的构造函数创建childNode
。在这个新的构造函数中,我们将父节点作为currentNode
传递,并将子节点及其启发式值添加到优先队列中;然后我们显示当前的搜索树。
所以,我们实际上在每一步都显示搜索树,每当搜索树的一层被添加时。在这种情况下,搜索树只包含根节点。当添加一层搜索树时,我们会显示搜索树;最后,当我们到达目标状态时,我们会准备并显示已经探索过的搜索树:
图 13
正如你在前面的输出中看到的,我们的搜索树中有一个启发式值为8.9
的根节点。Bus Stop
节点已被选中进行探索,它的子节点Library
已经被添加到搜索树中。Library
的启发式值是8.2
,低于Bus Stop
的启发式值8.9
。由于这是唯一的“fringe”节点,它将在稍后被选中进行探索:
图 14
如上图所示,Library
已被选中进行探索,并且Library
节点的子节点已被添加。我们可以看到,对于边缘上的三个子节点,Bus Stop
的启发式值为8.9
,Car Park
的启发式值为6.4
,而Student Center
的启发式值为8.0
。在这三个节点中,Car Park
的启发式值最低,因此它将被选中进行探索:
图 15
现在,Car Park
已被选中进行探索,并且其子节点已被添加到优先队列中。此时,边缘中有五个节点。Bus Stop
的启发式值为8.9
,Maths Building
的启发式值为2.2
,Library
的值为8.2
,Store
的值为4
,Student Center
的值为8.0
。这五个节点中,Maths Building
的启发式值最低(2.2
),因此它将被选中进行探索:
图 16
现在,Maths Building
已被选中进行探索,并且其子节点已被添加到搜索树中。在边缘中的节点中,Canteen
的启发式值最低(1.0
),因此它将被选中进行探索:
图 17
现在,Canteen
节点已被选中进行探索,并且其子节点已被添加到搜索树和边缘。所有蓝色节点中,AI Lab
的启发式值最低(0.0
),因此它将被选中进行探索:
图 18
最终,AI Lab
被选中进行处理,我们发现我们已经达到了目标状态,因此我们在这里结束搜索。最优路径由绿色节点和红色节点表示。最优路径是Bus Stop
、Library
、Car Park
、Maths Building
、Canteen
和AI Lab
。
当我们从初始状态到达目标状态时,我们可以观察到启发式值在减少。Bus Stop
的值为8.9
,Library
的值为8.2
,Car Park
的值为6.4
,Maths Building
的值为2.2
,Canteen
的值为1
,AI Lab
的值为0
。这意味着,当我们遍历搜索树时,我们正逐渐接近目标状态。在贪心 BFS 算法中,启发式值随着我们朝目标状态前进而减少。
现在你已经了解了贪心 BFS 算法的启发式函数,在接下来的部分,你将学习贪心 BFS 算法的问题,并且你将看到 A*搜索如何解决这些问题。
A* 搜索
在前面的部分,你学到了贪心 BFS 找到的路径如下:
图 19
总覆盖距离为14.24。然而,实际的最优解如下图所示:
图 20
总行驶距离是12。这意味着贪心 BFS 算法并非最优。问题在于,启发式函数没有考虑到已经发生的成本。A*搜索提出了一种新的启发式函数,它计算了已经发生的成本与到目标状态的预计成本之和。
对于我们的应用,启发式函数可以计算从根节点到当前节点的已行驶距离与到目标状态的直线距离之和。让我们看一下我们在上一节中看到的例子,并为三个节点停车场、公交车站和学生中心计算这个新的启发式函数:
图 21
对于停车场,已经行进的距离是2 + 5,到目标状态的距离是6,所以新的启发式值为13.4。对于公交车站,已经行进的距离是2 + 2,即4,到目标状态的距离是8.9,因此公交车站的新的启发式函数值为4 + 8.9,即12.9。对于学生中心,已经行进的距离是2 + 2,即4,到目标状态的距离是8,所以学生中心的新的启发式函数值为4 + 8,即12。根据这些新的启发式值,我们将选择学生中心进行进一步探索:
图 22
除了我们在可视化搜索树部分看到的对Node
类的更改外,我们将引入一个名为costFromRoot
的属性和一个名为computeCost
的方法。costFromRoot
属性表示从根节点到当前节点的行驶距离,这个值将通过computeCost
函数计算得出:
图 23
让我们看看computeCost
方法是如何工作的。如前面的图所示,我们有三个节点:公交车站、图书馆和停车场。公交车站到图书馆的距离是2,图书馆到停车场的距离是5。由于公交车站是初始状态,该节点的成本是0。对于图书馆,根节点到它的成本是2,对于停车场,costFromRoot
是2 + 5,即7。这也是它的父节点的成本加上父节点与当前节点之间的距离。所以,我们可以写出如下公式:
costFromRoot = 父节点的 costFromRoot + 父节点到当前节点的距离
让我们看一下这个方法的代码。在查看computeCost
方法之前,我们先看一下computeDistance
方法:
...
def computeDistance(self, location1, location2):
"""
This method computes distance between two places
"""
#difference in x coordinates
dx = location1[0] - location2[0]
#difference in y coordinates
dy = location1[1] - location2[1]
#distance
distance = math.sqrt(dx ** 2 + dy ** 2)
return distance
...
这个方法计算两个位置之间的距离,它将location1
和location2
作为参数。它通过计算 x 坐标的差值得到 dx
,即 dx = location1[0] - location2[0]
,并通过计算 y 坐标的差值得到 dy
,即 dy = location1[1] - location2[1]
。然后它通过计算 dx
的平方加 dy
的平方的平方根来得到距离,并返回这个距离:
...
def computeCost(self):
"""
This method computes distance of current node from root node
"""
if self.parent != None:
#find distance from current node to parent
distance = self.computeDistance(location[self.state.place], \
location[self.parent.state.place])
#cost = parent cost + distance
self.costFromRoot = self.parent.costFromRoot + distance
else:
self.costFromRoot = 0
...
computeCost
方法计算当前节点到根节点的距离。所以,我们检查 parent
属性是否为 None
。然后,我们计算从当前节点到父节点的距离,并计算 costFromRoot
为父节点的 costFromRoot
加上我们刚刚计算的距离;如果父节点是 None
,则 costFromRoot
为 0
,因为这是根节点:
...
def computeHeuristic(self):
"""
This function computes the heuristic value of node
"""
#find the distance of this state from goal state
goalLocation = location["AI Lab"]
currentLocation = location[self.state.place]
distanceFromGoal = self.computeDistance(goalLocation,
currentLocation)
#add them up to form heuristic value
heuristic = self.costFromRoot + distanceFromGoal
print "heuristic for", self.state.place, "=",
self.costFromRoot, distanceFromGoal, heuristic
self.heuristic = heuristic
...
现在,我们来看一下computerHeuristic
方法。就像在贪心 BFS 中一样,我们找出目标位置为AI 实验室
的地点,并且找出当前地点与目标地点之间的距离。接着,我们计算启发式值,将costFromRoot
和distanceFromGoal
相加,并将heuristic
属性赋值为这个启发式值:
图 24
如前面的图所示,A* 搜索的流程实际上与贪心 BFS 相同。那么,我们来看一下 A* 搜索的代码,如下所示:
...
def performAStarSearch():
"""
This method performs A* Search
"""
#create queue
pqueue = Queue.PriorityQueue()
#create root node
initialState = State()
root = Node(initialState, None)
#show the search tree explored so far
treeplot = TreePlot()
treeplot.generateDiagram(root, root)
#add to priority queue
pqueue.put((root.heuristic, root))
...
在 Python 的 AStar.py
模块中,我们创建了一个名为 performAStarSearch
的方法,它包含了 A* 搜索的代码;这段代码与贪心 BFS 的代码完全相同:
图 25
最初,我们有一个启发式值为8.9
的根节点,并选择公交车站
节点进行扩展;它的图书馆
子节点被添加,并且该节点的启发式值为10.2
。由于这是唯一在边界的节点,它将被选中进行探索:
图 26
现在图书馆
节点被选中进行探索,它的三个子节点被添加。公交车站
的启发式值为12.9
,停车场
的启发式值为13.4
,学生中心
的启发式值为12
。在这三者中,学生中心
的启发式值最低,因此它将被选中进行探索:
图 27
现在学生中心
被选中进行探索,它的三个子节点被添加到边界。在边界的五个节点中,商店
的启发式值最低,因此它将被选中进行探索:
图 28
现在商店
被选中进行探索,它的四个子节点被添加。在边界的八个节点中,食堂
的启发式值最低,因此它将被选中进行探索:
图 29
现在Canteen
已被选中进行探索,其子节点被添加到搜索树和边界中。在所有边界中的节点中,AI Lab
具有最低的启发式值,因此该节点将被选中进行探索:
图 30
当AI Lab
被选中进行探索时,我们发现已经遇到了目标状态,因此停止搜索。
最优路径由绿色节点和红色节点表示。最优路径是从Bus Stop
到Library
,再到Student Center
,然后到Store
,最后到Canteen
,最终到AI Lab
。当我们从根节点遍历到目标节点时,我们发现启发式函数的值要么保持不变,要么增加。因此,Bus Stop
的值为9
,Library
的值为10.2
,Student Center
的值为12.0
,Store
的值为12.0
,Canteen
的值为12.0
,最后AI Lab
的值为12.0
。因此,在这个例子中,我们了解到启发式函数在从初始状态到目标状态的过程中是增加或保持不变的,并且我们还观察到 A* 搜索是最优的。我们看到贪婪的 BFS 不是最优的,现在我们能理解为什么。我们看到了一种新的启发式函数,这使得 A* 是最优的。在下一节中,我们将探讨一个好的启发式函数应该具备哪些特点。
什么是好的启发式函数?
为了回答这个问题,为什么需要一个好的启发式函数? 我们将把深度优先搜索(DFS)和广度优先搜索(BFS)方法与启发式搜索方法进行比较。在 DFS 和 BFS 中,所有边的成本都等于1,DFS 会探索所有子节点,而 BFS 会探索所有兄弟节点。在启发式搜索中,边的成本是不同的,启发式搜索根据启发式函数选择要探索的节点。
通过使用启发式函数,我们可以减少所使用的内存,并且可以在更短的时间内达到解决方案。接下来需要回答的问题是,为什么需要一个好的启发式函数? 答案是为了找到最优解。在我们的 A* 搜索示例中,我们通过使用更好的启发式函数,说明了我们能够找到最优解;很明显,A* 探索的节点数量最少。现在,让我们来看看一个好的启发式函数的属性。
好的启发式函数的属性
一个好的启发式函数的属性将在接下来的章节中详细说明。
可接受性
一个好的启发式函数应该是可接受的,这意味着启发式函数的值应该小于或等于到达目标的真实成本:
图 31
假设节点1是根节点,节点5是目标状态,而我们正在为节点2计算启发式函数;以下内容适用:
-
d12是从1到2的路径成本
-
d24是从节点2到4的路径成本
-
d45是从节点4到节点5的代价
-
d23是从节点2到节点3的代价
-
d35是从节点3到节点5的代价
然后,为了使我们的函数是可接受的,必须满足以下条件:
-
节点2的启发式函数值应该小于或等于d24 + d45
-
节点2的启发式函数值应该小于或等于d23 + d35
图 32
在前面的例子中,节点1是根节点,节点5是目标状态。红色值表示估计到目标状态的代价,绿色值表示边的真实代价:
-
假设我们已经探索过节点1,并将节点2和3添加到了边缘。接下来,我们将计算节点2和3的启发式函数值。
-
节点2的启发式值为3 + 9,即12,节点3的启发式值为10 + 1,即11;基于这些值,我们选择节点3进行进一步探索。
-
我们将节点3的子节点,即节点5,添加到边缘。边缘包含节点2和节点5。我们之前已经计算了节点2的启发式函数值为12,节点5的启发式函数值为10 + 10 + 0,即20。所以,基于这些值,我们将选择节点2进行探索。
-
我们将节点2的子节点,即节点4,添加到边缘。现在,边缘包含节点4和节点5。我们之前已经计算了节点5的启发式函数值为20,并将计算节点4的启发式函数值为3 + 3 + 1,即7。基于这些值,我们将选择节点4进行进一步探索。
-
我们将节点4的子节点,即节点5,添加到边缘。边缘通过路径[1-3-5]包含节点5,并且通过路径[1-2-4-5]也包含节点5。我们之前已经计算了节点5通过路径[1-3-5]的启发式函数值为20。所以,我们计算节点5通过路径[1-2-4-5]的启发式函数值为3 + 3 + 3 + 0,即9。基于这些值,我们选择路径[1-2-4-5]上的节点5;当我们处理这个节点时,我们看到已经到达了目标状态,并在此结束搜索。
在这个例子中,你看到在搜索过程中,我们偏离了路径来探索节点3。后来,我们发现最优解是[1-2-4-5]。因此,一个可接受的启发式函数确保找到了最优解。
一致性
一个好的启发式函数应具备的第二个属性是它应该是一致的,这意味着它应该是非递减的:
图 33
例如,节点3的启发式函数应该大于(或等于)节点2的启发式函数,而节点4的启发式函数值应该大于(或等于)节点2的启发式函数值。让我们通过以下图示来看看为什么会这样:
图 34
假设节点1和2是中间节点,节点5是目标状态。首先,x1是从节点1到节点5的估计成本,x2是从节点2到目标状态的估计成本;d12是从节点1到节点2的成本。
假设节点2离目标状态比节点1更近;这意味着以下陈述适用:
x2 < x1
假设以下陈述为真:
x2 =100
x1= 101
d12 >= 1
上述代码意味着x1 <= d12 + x2
。
假设TC1
是从根节点到节点1的真实成本;那么,节点1的启发式函数如下:
h(1) =TC1 + x1
节点2的启发式函数如下:
h(2) = TC1 + d12 + x2
这是因为d12 + x2 >= x1
;节点2的启发式值大于或等于节点1的启发式函数值(即,h(2)>=h(1)
)。
总结
你现在应该理解什么是启发式函数,以及优先队列数据结构。在本章中,你学会了如何可视化搜索树。你了解了贪心最佳优先搜索的启发式函数和该算法的步骤。我们还讨论了与贪心最佳优先算法相关的问题,以及 A*搜索是如何解决这些问题的。最后,你了解了一个好的启发式函数所需的属性。
请参阅链接www.packtpub.com/sites/default/files/downloads/HandsOnArtificialIntelligenceforSearch_ColorImages.pdf
以获取本章的彩色图像。