作者:光火
UC Berkeley
开设的课程CS188: Introduction to AI
结构清晰,内容详实,是AI
入门的不二之选。作为其搜索算法章节的作业,Pac Man
更是独具匠心,设计精巧,值得反复研究与思考。考虑到目前掘金稀土上相关的文章寥若晨星,因此笔者计划发布一系列文章详细地解析Pac Man
的各项任务,方便各位读者学习与理解人工智能及搜索算法。
拿到源码后,首先是要理解项目的整体结构:
assets
文件夹用于存放静态资源,初始状态下,里面只有一张demo.png
图片
-
layouts
文件夹用于存放地图资源,并允许我们自制地图进行测试。对于一个具体的.lay
文件,它主要包含如下几种元素:%
: 代表墙体.
: 可以加分的豆子o
: 可以使怪物恐慌的胶囊P
: 玩家(吃豆人)的初始位置G
: 怪物的初始位置(支持放置多个怪物)
通过摆放以上几种元素的位置,我们就可以自己创造一张地图。对于小游戏而言,这种利用文本文件存储地图的方式颇为常见,
.lay
和普通的.txt
其实本质上没什么区别。 -
需要我们阅读的代码文件:
- 里面实现了
Stack
、Queue
、PriorityQueue
、PriorityWithFunction
、Counter
等数据结构。其中PriorityQueue
是基于小顶堆实现的,其内部元素是一个三元组,但我们只需关注元素item
和它的优先级priority
即可。PriorityQueueWithFunction
则是继承了PriorityQueue
,允许用户传入自定义的评估函数。 - 定义了
GameState
类并提供了一系列接口,通过它们,你不仅可以获知吃豆人和怪物的位置及数量,还可以得到指定agent
在执行特定的动作后所产生的子状态(这在博弈问题中非常关键)。当然,食物、胶囊、得分同样支持访问。因此,总的来说,通过GameState
类的接口,你可以得到游戏的全状态。 - 定义了
Pac Man
游戏的一些基础类,需要阅读的部分在源码中已经有所标记。其中,要特别注意的是Grid
类,在后面增加启发式,重写评价函数时会用到。
- 里面实现了
-
需要我们编写的代码文件:
- 在此,我们应当实现
DFS
、BFS
、UCS
、A*
算法,并将其应用于寻径问题。 - 在此,我们应自定义启发式函数,并针对两个具体的迷宫,通过修改代价函数,让吃豆人尽可能地获取高分。
- 在此,我们应当实现
Minimax
算法、Alpha-Beta
剪枝、并修改评价函数,最终完成一款智能的吃豆人小游戏。
- 在此,我们应当实现
倘若你对上述的一些名词感到陌生,不要担心,我们在后文中会由浅入深,更为详细地讲解算法原理和代码结构。
暂不考虑怪物,分别实现
DFS
、BFS
、UCS
、A*
四种搜索算法,让Pac Man
吃到迷宫里的一个食物。
该任务需要我们在search.py
中进行代码的编写。实际上,该文件已经声明了如上四个函数,它们应当返回一个动作序列,吃豆人会依据这个动作序列进行活动。
四个函数都需要接收一个problem
参数,这个problem
实则就是searchAgents.py
中PositionSearchProblem
类的一个对象。通过它,我们可以获知吃豆人的当前状态及是否到达了终点。
根据源码注释的提示,通过打印problem.getStartState()
,我们发现所谓的state
,指的就是吃豆人当前所处的位置(x,y)
。考虑到吃豆人移动的灵活性,我们应当使用图搜索,引入探索集避免展开同一节点。
深度优先搜索
def depthFirstSearch(problem):
explored = set()
result = util.Stack()
frontier = util.Stack()
result.push([])
frontier.push(problem.getStartState())
while True:
if frontier.isEmpty():
return []
node = frontier.pop()
action = result.pop()
if problem.isGoalState(node):
return action
explored.add(node)
children = problem.expand(node)
for child in children:
if child[0] not in explored and child[0] not in frontier.list:
frontier.push(child[0])
result.push(action + [child[1]])
- 四种搜索算法都可以通过上述模式进行实现,只是采用的数据结构不同。对于
DFS
,我们习惯将其写成递归形式,这本质上是在利用程序栈。倘若我们利用迭代来实现DFS
,则需要手动开一个Stack
模拟程序栈的行为。 - 本题的难点在于如何有效地记录搜索路径,因为我们最终需要返回的是一个动作序列,该序列应当指导吃豆人自起点移动至终点。通过阅读
problem.expand
函数的源码,可知该函数的返回值为一个list
,而list
中的每个元素是一个(child, action, stepCost)
三元组,其中action
就代表自parent
移动至child
所需要采取的步骤,这就是我们需要记录的。因此,一种直截了当的做法是,就把这个三元组加入到frontier
中,然后逐层维护action
,让其代表从起点开始移动该位置所需要的步骤。这个方法是通用的,我们会在UCS
中采用该做法。 - 不过,此处我们使用了一个额外的
result
栈,用于追踪frontier
的进出。实际上,记录路径的核心点,就在于我们要将parent
的一部分内容移到child
中来,然后再加上从parent
怎么到的child
,路径就记录好了。这也是代码中result.push(action + [child[1]]
的含义,action
就是此前parent
的内容,代表自起点如何到达parent
,[child[1]]
则表示parent
到child
的方法,将两者拼接起来,就是自起点到达当前child
的动作序列。 - 将
result
的数据类型选为和Stack
,就可以同步frontier
中元素的进出栈过程,保证当最终状态被搜索到后,result pop
出的action
也是自起点到达终点的路径。
宽度优先搜索
def breadthFirstSearch(problem):
explored = set()
result = util.Queue()
frontier = util.Queue()
result.push([])
frontier.push(problem.getStartState())
while True:
if frontier.isEmpty():
return []
node = frontier.pop()
action = result.pop()
if problem.isGoalState(node):
return action
explored.add(node)
children = problem.expand(node)
for child in children:
if child[0] not in explored and child[0] not in frontier.list:
frontier.push(child[0])
result.push(action + [child[1]])
- 如上所述,
BFS
的实现方式和DFS
如出一辙,只是将LIFO
的Stack
替换为了FIFO
的Queue
。由于我们普遍习惯利用迭代来实现BFS
,所以上述代码看起来更为自然。 - 相较于
DFS
,逐层搜索BFS
可以保证找到全局最优解。因此,实际运行时可以发现,利用BFS
获得的分数要比DFS
高一些。但是另一方面,BFS
在平均意义下,耗时更长,内存占用也更高。 - 我们使用了一个
Queue
来同步追踪frontier
的入队及出队情况。对于有类似需求的场景,以上代码可作为模板程序。
一致代价搜索
def uniformCostSearch(problem):
explored = set()
frontier = util.PriorityQueue()
initial = (problem.getStartState(), [], 0)
frontier.push(initial, 0)
while True:
if frontier.isEmpty():
return []
(node, result, value) = frontier.pop()
if problem.isGoalState(node):
return result
explored.add(node)
children = problem.expand(node)
for child, action, cost in children:
if child not in explored:
temp = value + cost
frontier.push((child, result + [action], temp), temp)
- 由提出的一致代价搜索
UCS
可以理解为等值线意义下的BFS
,因为它是依据根点到当前节点的cost
进行扩展的。这个cost
是真实,确定的,应与后文中我们利用启发式函数得到的评估值进行区分。 - 既然要依据
cost
进行节点的出队及子节点的扩展,那么传统的Queue
已经无法满足我们的需求了,因此我们使用由小顶堆实现的PriorityQueue
,每层都扩展frontier
中代价最低的节点。当然,由于优先级队列的数据结构已经在源码中实现了,我们直接调用即可。这里附上PriorityQueue
的源码,我个人认为实现得相当精彩。
class PriorityQueue:
"""
Implements a priority queue data structure. Each inserted item
has a priority associated with it and the client is usually interested
in quick retrieval of the lowest-priority item in the queue. This
data structure allows O(1) access to the lowest-priority item.
"""
def __init__(self):
self.heap = []
self.count = 0
def push(self, item, priority):
entry = (priority, self.count, item)
heapq.heappush(self.heap, entry)
self.count += 1
def pop(self):
(_, _, item) = heapq.heappop(self.heap)
return item
def isEmpty(self):
return len(self.heap) == 0
def update(self, item, priority):
# If item already in priority queue with higher priority, update its priority and rebuild the heap.
# If item already in priority queue with equal or lower priority, do nothing.
# If item not in priority queue, do the same thing as self.push.
for index, (p, c, i) in enumerate(self.heap):
if i == item:
if p <= priority:
break
del self.heap[index]
self.heap.append((priority, c, item))
heapq.heapify(self.heap)
break
else:
self.push(item, priority)
- 需要注意的是
priority
和cost
是负相关的,cost
越低,priority
越高,因此在update
函数中,倘若我们发现原有的p
比新传来的参数priority
要低,则证明原有路径更优,因此直接break
,不去更新。 - 回到
UCS
的代码实现,这次我们push
到frontier
的元素是一个三元组,如此做的目的当然还是记录路径:
initial = (problem.getStartState(), [], 0)
frontier.push(initial, 0)
- 之所以不采用上文中
DFS
和BFS
的记录方式,是因为除了action
,路径代价cost
同样需要累加。不过在采用这种记录方法后,就不必调用源码中的update
函数了,因为即便state
相同,path
和cost
也不同,所以直接push
到优先级队列即可。
A*搜索
def aStarSearch(problem, heuristic=nullHeuristic):
explored = set()
frontier = util.PriorityQueue()
initial = problem.getStartState()
tot = heuristic(initial, problem)
frontier.push((initial, [], tot), tot)
while True:
if frontier.isEmpty():
return []
(node, result, value) = frontier.pop()
if problem.isGoalState(node):
return result
explored.add(node)
children = problem.expand(node)
for child, action, cost in children:
if child not in explored:
tmp = value + cost + heuristic(child, problem)
frontier.push((child, result + [action], tmp), tmp)
A*
搜索是UCS
和Greedy Search
的结合体 (所谓Greedy Search
,就是完全依据启发式进行搜索。该方法无法保证最优先和完备性)。A*
算法的代码结构和UCS
基本相同,只是额外引入了启发式函数heuristic
。笔者首次接触到启发式这个概念,是在大一学习八数码的时候,这是一个相对基本的问题。不过,启发式是无处不在的,就连运筹学的运输规划都会利用启发式来快速获得初始基可行解。优良的启发式函数能够在常数上大幅优化原算法,加快搜索速度。不过即便如此,A*
算法仍旧是指数复杂度的,只是在状态空间庞大的问题中,它会比前文中的几种朴素搜索算法快捷得多。- 倘若我们不调用
heuristic
,那么这里的A*
算法就退化成了UCS
。因此,我们需要自行设计启发式函数,对于A*
算法而言,启发式函数需要满足两条性质:Admissibility
:对于任一节点而言,启发式函数所得的估计值,应当该点到达终止状态的真实路径代价,即;Consistency
:其英文解释见下,相当于三角不等式。
for every node and every successor of generated by any action , the estimated cost of reaching the goal from is no greater than the step cost of getting to plus the estimated cost of reaching the goal from
- 在不超过真实路径代价的前提下,启发式函数所计算的值越大越好。因此对于
Pac Man
而言,采用哈密顿距离要优于欧氏距离。
def yourHeuristic(position, problem, info={}):
goal = problem.goal
return abs(position[0] - goal[0]) + abs(position[1] - goal[1])
至此,Pac Man
作业的任务一就完成了。目前,我们利用四种搜索算法,解决了一个简单的寻径问题。在任务二中,我们将会对代价函数和项目源码有更进一步的了解,而在任务三中,我们将会接触到博弈问题,利用Minimax
算法、Alpha-Beta
剪枝、启发式评价函数实现一款真正智能的吃豆人小游戏。