AI实验1——八数码问题
第一次写博客,如有不足,请多指教(综合代码在文末)
一、实验目的与要求
实验目的:
1 . 熟悉状态空间表示法;
2.掌握深度优先、广度优先和等代价无信息搜索算法;
3.掌握启发式函数设计,实现面向实际问题的A*搜索算法;
二、实验内容与方法
实验内容:
- 利用无信息搜索算法实现八数码难题求解;
- 设计启发式信息函数,利用A*搜索实现八数码难题求解;
三、实验步骤与过程
1,问题分析
在八数码难题中,我们使用状态空间表示法,将八数码矩阵(即矩阵的状态)设置为一个节点类(Node),各个节点之间通过操作集(Operater)[‘U’, ‘D’, ’R’, ‘L’]进行相互转化。从一个起始节点(初始矩阵)出发,借助操作的遍历形成一棵状态空间树,当然对于树这样的数据结构来说,我们应当保证各个子节点只能有一个父节点,在本问题中,就是要通过适当的方法对可能出现的“死循环”加以避免。
综上所述,为了实现八数码问题的求解,其关键在于:
(1)如何表示状态
(2)如何定义操作集,使得一个状态可以转化到另一个状态
(3)如何遍历状态空间树(这个在后面的内容中会加以详细说明)
下面来对表示状态(State)的类进行编写(code1):
class Node(object):
def __init__(self, state:'List', directionFlag = None, parent = None):
self.State = state
self.direction = ['U', 'D', 'L', 'R']
if directionFlag:
self.direction.remove(directionFlag)
self.Parent = parent
self.Depth = 0
#上个节点到此节点的操作符
self.PreOperate = 'None'
code 1 Node类的编写
这里的Node类参考了课上同学展示的代码,定义了一个操作字符列表self.diretion,在后面扩展节点生成子节点函数中显得很简洁(操作了一个之后,对应子节点的directionFlag就添加上与该操作相反的操作字符,比如说我通过‘U(Move Up)’操作使得空格(其实是零元)向上滑移(其实是零元与在其上面一个位置的元素交换位置),则得到的新状态得到的节点就会通过把逆操作添加进directionFlag之中来禁止在两个状态之间无限循环)。
起初我想用numpy库来实现矩阵的操作,后来发现太麻烦,而且实现过程中出现了许多奇奇怪怪的BUG,便转而使用更为简单的列表来进行操作,所以这里使用List表示self.State.
为了后面表示步数,特意在节点类中添加了Depth属性,初始化时为0,每生成一个子节点,其子节点都会在父节点的基础上将Depth属性(int)加一。
还有个属性self.PreOperate是用来存储从父节点到当前节点所需进行的操作,在操作函数中对其进行赋值(str)。(代码中暂时还没有真正使用这个属性,本来想着在打印的时候顺便把操作加上,现在待后面优化的时候再去添加了,这个并不影响代码功能完整性)
如code2所示,我们来定义操作集(这里只展示一个操作:空格(零元)向左移动):
def GenerateSpring(self):
if not self.CurrentNode.direction:
return []
subStates = []
zero_index = self.CurrentNode.State.index(0)
#进行操作
if 'L' in self.CurrentNode.direction and (zero_index % 3 != 0):
sub_matrix = self.CurrentNode.State.copy()
#swap一下
sub_matrix[zero_index], sub_matrix[zero_index-1] = sub_matrix[zero_index-1], sub_matrix[zero_index]
#生成新节点,深度加1,加上前操作,把它添加到孩子列表中
NewState = Node(sub_matrix,'R',self.CurrentNode)
NewState.Depth = self.CurrentNode.Depth + 1
NewState.PreOperate = 'L'
subStates.append(NewState)
if 'R' in self.CurrentNode.direction and ((zero_index + 1) % 3 != 0):
sub_matrix = self.CurrentNode.State.copy()
#swap一下
sub_matrix[zero_index], sub_matrix[zero_index+1] = sub_matrix[zero_index+1], sub_matrix[zero_index]
NewState = Node(sub_matrix,'L',self.CurrentNode)
NewState.Depth = self.CurrentNode.Depth + 1
NewState.PreOperate = 'R'
subStates.append(NewState)
if 'U' in self.CurrentNode.direction and (zero_index > 2):
sub_matrix = self.CurrentNode.State.copy()
#swap一下
sub_matrix[zero_index], sub_matrix[zero_index-3] = sub_matrix[zero_index-3], sub_matrix[zero_index]
NewState = Node(sub_matrix,'D',self.CurrentNode)
NewState.Depth = self.CurrentNode.Depth + 1
NewState.PreOperate = 'U'
subStates.append(NewState)
if 'D' in self.CurrentNode.direction and (zero_index < 6):
sub_matrix = self.CurrentNode.State.copy()
#swap一下
sub_matrix[zero_index], sub_matrix[zero_index+3] = sub_matrix[zero_index+3], sub_matrix[zero_index]
NewState = Node(sub_matrix,'U',self.CurrentNode)
NewState.Depth = self.CurrentNode.Depth + 1
NewState.PreOperate = 'D'
subStates.append(NewState)
#返回的是操作后生成的子节点
return subStates
# 这部分是树类的一个方法
code2 操作集生成子节点
在生成子节点的函数中,我们首先判断当前是否还有可以扩展的节点(可操作集是否为空),若没有则返回空的子状态。然后通过列表的.index()方法查找里面的零元(当然numpy里面也有对应的函数来查找矩阵中零元坐标的方法numpy.where(),取[0][0]与[1][0]分别为横坐标与纵坐标,但是总体来说还是感觉列表更加简洁)。
后面的操作过程实际上就是遍历在操作集中的操作,并根据这个来生成子节点。要加以注意的是,操作合法性不仅仅要求这个操作在我们在节点类中定义的操作集中,还需要在移动的过程中不会越界(以图2中的空格向左移动为例,在3*3的矩阵中空格不可以在最左边的一侧,放到列表之中(列表下标是从0开始的)也就是0,3,6号元素不可以是零元)。这个操作的结果就是零元与左侧的元素交换,在python里实现swap()的效果只需要写作–(a, b = b, a),确实很便捷。后面的语句就是给子节点添加属性,像添加指向父节点的指针以便回溯打印,还有深度加一之类的,最后在子节点列表之中加入这个子节点,完成四个操作遍历之后返回子节点列表。
2,利用无信息搜索算法实现八数码难题的求解
(1)下面先定义一个树类(Tree)(code3)
class Tree(object):
def __init__(self, start:'List', goal:'List'):
self.CurrentNode = Node(start)
self.Goal = goal
code3 树类的定义
树类用于存储与遍历状态空间,在初始化的时候先将起始节点和目标节点确定,下面的搜索是该树类的方法。
在搜索的过程中,我们用到了openTable和closeTable两个列表(见code4),其中openTable是用来存放已经扩展到却还没有遍历到(也就是还没来得及进行再次扩展)的节点,另一个closeTable是用来存放已经扩展过的节点。
if self.UnSolvable():
print('No Solution')
return
#构建open表与close表
openTable = [self.CurrentNode]
closeTable = []
code4 openTable和closeTable的构建
为了避免重复遍历造成死循环(因为通过上下左右的操作难免会再次回到之前已经遍历过的节点),我们每次只会去遍历openTable中的节点。每当扩展openTable里面的节点时,产生的子节点如果不在close表里面,则表示还没有扩展过,当然这还要分成两种情况:一种是在openTable里面的,另一种则是不在,在下面的搜索中我们会看到不同的处理方法。
正如code4所示,我在正式开始进行搜索之前首先进行了解的存在性判定,下面来展示一下判定解存在性的代码块(code5)
#搜索开头判断是否有解
def UnSolvable(self):
cond_start = 0
cond_goal = 0
#计算逆序数(0当作空格处理)
startList = self.CurrentNode.State.copy()
startList.remove(0)
goalList = self.Goal.copy()
goalList.remove(0)
#计算起始矩阵逆序数
for i in range(0, 8):
for j in range(i, 8):
if startList[i] > startList[j]:
cond_start += 1
#计算目标矩阵逆序数
for i in range(0, 8):
for j in range(i, 8):
if goalList[i] > goalList[j]:
cond_goal += 1
#判断奇偶性是否相同(不同则无解,返回TRUE直接终止并打印无解)
if (cond_start % 2) != (cond_goal %2):
return True
return False
code5 可解性判定
在参考资料[3]中提到,我们可以利用起始矩阵和目标矩阵的逆序数是否相等来判定解是否存在,当然值得注意的是,0不能算在其中也就是说计算逆序数的时候一定要把零元先remove()掉。
(2)BFS(广度优先搜索)与DFS(深度优先搜索)
BFS称为广度优先搜索,从起始节点出发,借助操作函数扩展子节点进入下一层,一层一层地扩展节点,完全不利用已知信息,进行盲目搜索,直到搜到结果。code6展示了如何通过BFS来实现八数码问题的求解。
while len(openTable) > 0:
#BFS没有用上F的启发函数
self.CurrentNode = openTable.pop(0)
closeTable.append(self.CurrentNode)
spring = self.GenerateSpring()
for child in spring:
#一旦发现子节点为目标节点,立即跳出,回溯打印
if child.State == self.Goal:
self.TraceBack(child)
return
#如果已经遍历过这个节点,则跳过(child是一个对象)这个对于BFS很重要,为了防止形成死循环
if sum([child.State == ch.State for ch in closeTable]):
continue
#现在BFS根本不考虑代价
#如果不在openTable里面,则加入之
if not sum([child.State == ch.State for ch in openTable]):
openTable.append(child)
print('error')
code6 BFS八数码问题求解搜索过程
在BFS搜索中我们用到了队列的数据结构,根据队列**先进先出(FIFO)**的原则,可以使用列表List进行模拟,每次遍历完一个在openTable中的节点之后使用pop(0)的列表方法来弹出列表中的第一个元素,放进closeTable中。
当openTable不是空表的时候,对于每一个在当前节点生成的子节点列表中的元素,如果发现这个子节点的状态(矩阵)和目标节点的相同,则进行回溯并打印,退出循环;如果不是,则判断这个状态矩阵是否已经在closeTable中已经出现(这个使用了一个小技巧,在总结中详细说明),在的话就直接进行下一个循环。如果也不在openTable中,则在列表末尾加上这个子节点。(在openTable里的话直接pass过去,因为早晚会遍历到这个节点,除非在这之前已经找到和目标节点矩阵相同的子节点)
最后如果遍历了所有节点都没有找到,那么就打印报错信息,便于DEBUG。
下面是DFS的代码块(code7):
while len(openTable) > 0:
#DFS没有用上F的启发函数
self.CurrentNode = openTable.pop()
closeTable.append(self.CurrentNode)
spring = self.GenerateSpring()
for child in spring:
#一旦发现子节点为目标节点,立即跳出,回溯打印
if child.State == self.Goal:
self.TraceBack(child)
return
#如果已经遍历过这个节点,则跳过(child是一个对象)这个对于DFS很重要,为了防止形成死循环
if sum([child.State == ch.State for ch in closeTable]):
continue
#现在DFS根本不考虑代价
#如果不在openTable里面,则加入之
if not sum([child.State == ch.State for ch in openTable]):
openTable.append(child)
print('error')
code7 DFS八数码问题求解过程
DFS称为深度优先遍历,从起始节点出发,像扎根一样一头扎到状态空间树的叶子顶端,找到Goal则退出。换句话说,深度优先遍历就是一个纵向的遍历,而前面所说的广度优先遍历则是一个横向的遍历。在深度优先遍历中,我们采取栈的数据结构,先进后出(FILO),同样使用List(openTable)进行模拟。每次扩展的都是栈顶端的元素,扩展时pop()弹出栈顶,收到closeTable里面。其他地方完全与BFS相同,不再赘述。(openTable和closeTable使得DFS不会形成环,所以这算是优化了的DFS,虽然跑起来还是很慢)
(3)UCS(一致代价搜索)
UCS又称一致代价搜索,它考虑的是代价问题,每走一步都需要一定的代价,那么这个搜索方法相对就是一个比较保守的算法,它优先扩展代价最小的节点,直到找到最终的目标节点状态。下面code8便展示了这个算法的实现:
while len(openTable) > 0:
#UCS没有用上F的启发函数,但是还是要加上G函数为key进行排序
openTable.sort(key=self.G_Val)
self.CurrentNode = openTable.pop(0)
closeTable.append(self.CurrentNode)
spring = self.GenerateSpring()
for child in spring:
#一旦发现子节点为目标节点,立即跳出,回溯打印
if child.State == self.Goal:
self.TraceBack(child)
return
#如果已经遍历过这个节点,则跳过(child是一个对象)这个对于UCS很重要,为了防止形成死循环
if sum([child.State == ch.State for ch in closeTable]):
continue
#如果不在openTable里面,则加入之
if not sum([child.State == ch.State for ch in openTable]):
openTable.append(child)
print('error')
code8 UCS实现八数码问题的求解
相对于前面的BFS与DFS,整体框架其实差不多,不同的是,在每次循环开始之时,应当对openTable中的已遍历到的待扩展节点进行排序,其依据为节点的G_Val(这是表示代价的函数,每扩展一次节点就在父节点的基础上加一,其实就是我们之前在定义Node类的时候添加的Depth属性)每次pop出来函数值最小的节点。
关于各种代价函数的描述,我们会在下面统一给出
3,设计启发式信息函数,利用A*搜索实现八数码难题的求解
(1)借助已知信息对搜索进行优化,构造启发式函数
A*算法瞻前又顾后,可以看作是一致代价搜索(顾后)与贪婪搜索(瞻前)的综合应用,同时具有二者的优点,前者能够让搜索的代价降低,后者则能够让搜索更具有方向性。
在此我们构造启发式函数F = G