八数码问题求解
这是一个人工智能的算法作业,仅供参考、仅供参考!
留个赞,彦祖亦非们,如果能点个关注就更好了
八数码问题是否有解
这里先给出有解的判定结论:将八数码问题中的八个数字(这里空格我使用红框代替)按照九宫格中的位置随机的填入,若初始排列状态与目标排列状态的奇偶性相同,则八数码问题有解。
排列、逆序数、对换的定义
这里就做一个简单的介绍和讲解,因为这主要是线性代数的知识。如果有N个不同的元素,将其按照一定的顺序排成一行,那么这个排列顺序就会成为这N个元素的一个排列。N个不同元素的排列有n!种。
对于N个自然数的某一个排列,如果一个较大的数排在一个较小的数的前面,那么这两个数将构成一个逆序。一个排列所产生逆序的个数也称逆序数。
如一个4阶排列3412的逆序是(3,1),(3,2),(4,1),(4,2),逆序数为4。
逆序数为奇数称为奇排列,逆序数为偶数则为偶排列。自然数123⋯n的逆序数为0,是一个偶排列。
在一个排列种,将任意两个数的位置调换变成另一个排列的过程称为进行一次对换,相邻两个数之间的对换也称为相邻对换。
排列的性质
一个排列中的任意两个数对换后,排列改变奇偶性。即排列经过一次对换后,奇排列变成偶排列,偶排列变成奇排列。证明过程并不难,这边我简单给出证明过程。
首先考虑相邻的对换。如有这么一个排列p_1 p_2⋯p_m stq_1 q_2⋯q_n,对换s和t的位置变成p_1 p_2⋯p_m tsq_1 q_2⋯q_n,容易得到其排列的逆序会增加1或者减少1,逆序数的奇偶性会改变。注意到s和t的对换不会影响其他数的序关系,但是s和t之间的关系会导致整体逆序数有如下改变:若s<t,交换后原本的逆序(s,t)在新排列中p_1 p_2⋯p_m tsq_1 q_2⋯q_n就不成立了,逆序数会减少1,类似的,若s>t,交换后逆序数会增加1。
接下来,考虑一般情况,排列p_1 p_2⋯p_m s r_1 r_2⋯r_l t〖 q〗_1 q_2⋯q_n,交换s和t后,其逆序数奇偶性又会如何改变呢?这边延用之前相邻对换所得到的结论,进行一次相邻对换,逆序数的奇偶性会改变。那么可以考虑先进行如下变换p_1 p_2⋯p_m st r_1 r_2⋯r_l 〖 q〗_1 q_2⋯q_n,其过程是将t进行了l次的相邻对换,在进行如下变换p_1 p_2⋯p_m t r_1 r_2⋯r_l sq_1 q_2⋯q_n,将s进行了l+1次相邻对换。总体看来,整个变换过程进行了2l+1次(奇数次)的相邻变换,结果显而易见了,若原本的排列是奇排列,经过变换后变为偶排列,反之亦然。
八数码问题有解和排列奇偶性间的关系及证明
(必要性)八数码问题有解则初始和目标状态的排列奇偶性相同
由定义可知,容易注意到:八数码问题中,将空格进行左移和右移操作都不会改变排列,而上移和下移操作会导致排列中某个元素前移2格或后移2格。
根据之前排列的性质,前移2格和后移2格都相当于做了两次相邻的对换,奇偶性不变。
由此可知,八数码问题中空格的操作不会改变排列的奇偶性,即八数码问题有解,则初始状态和目标状态的排列的奇偶性都相同。部分操作示例如下图所示
(充分性)初始和目标状态的排列奇偶性相同则八数码问题有解
任意一个n阶排列都可以经过与该排列有相同奇偶性的对换次数变成自然排列,这个性质的证明并不难,这里不赘述。由此得到结论,经过偶数次的对换可以让一个排列到达其他任意一个与其奇偶性相同的排列。即有:若两个状态的排列奇偶性相同,则一定可以经过偶数次相邻对换使初始状态达到目标状态。
在排列顺序不变的前提下,八数码问题对于空格在任意位置的状态都是有解的。
空格可以在不改变排列顺序的前提下,在行内任意移动;在行间任意移动。
这里给出在行间任意移动的简单的示例,如下图。
经过验证,空格经过11步操作(这边不要和之间相邻对换次数搞混,行内移动没有产生相邻对换,行间移动了5次共产生10次相邻对换),可以由图左上的初始状态变换到左下的目标状态,且第三行并未受到任何影响,那么同样的可以从第二行变换到第三行。可以得到结论,在不改变排列顺序的前提下,空格可以在任意位置。
针对于空格的行内、行间的移动,其本质就是在进行偶数次的相邻对换,因此,若初始状态和目标状态的排列奇偶性相同,那么八数码问题有解。
综上可得结论,初始状态和目标状态的排列奇偶性相同,那么八数码问题是有解的,八数码有解,那么其初始状态和目标状态的排列奇偶性相同。八数码问题有解的条件到此证明完毕。
八数码问题模型建立
八数码问题通过空格的移动会产生不同的状态,共有9!=362880 个状态(8个数字填入9个空格)。若要使问题有解,那么奇偶对半,一共是181440个状态。该问题可以看作是树模型,根节点代表初始状态,节点会有子节点,每个节点代表一个状态。这里使用一个简单的二叉树作为分析的模型,如图2-1。A代表初始状态,通过对空格进行行内、行间的移动,产生不同的状态(子节点),通过遍历树的所有状态(节点),就可以找到初始状态所能达到的所有目标状态。如果找到了目标状态就说明有解,若找不到就说明无解。
现在问题就转化了,一个状态,通过空格的左右、上下移动可以转化成其他状态,也就是生成子节点的过程。只要通过算法实现整个状态之间的转化和遍历所有的状态就可以求得解。
八数码问题的求解
深度优先遍历(DFS)
主要思路是从初始状态(根节点)开始,沿着一条路一直走到底,不撞南墙不回头,一条路走到黑,直到没有路了,然后从这条路尽头的节点回退到上一个节点,再从另一条路开始走到底,不断递归重复此过程,直到所有的顶点都遍历完成。还是以之前的模型分析遍历过程,如图
广度优先遍历(BFS)
广度优先遍历,指的是从一个节点出发,遍历访问这个节点的子节点,再依次遍历子节点的子节点。该算法按照层级遍历整棵树,每次访问一个节点都会将其子字节点加入之后需要访问的一个顺序。如下图3-2的示例,从A开始遍历,A有子节点B和C,先访问A后将B、C加入访问的队列(遵循先进先出的原则),然后B取出访问,加入B的子节点D,由于之前的C还处在队列中,因此会先去访问C(产生子节点E、F排在D后)然后再轮到D。
A*启发式算法
A算法,指的是如果在搜索过程中,每一步都可以利用估价函数f(n)=g(n)+h(n)对Open表(存放待扩展的节点)中的节点进行排序,由于估价函数带有问题自身的启发性的信息,因此,A算法也称为启发式搜索算法。就以八数码问题来举例,g(n)表示从初始状态到当前状态走的步数,也可以看作树搜索中的深度,h(n)表示从当前状态到目标状态的代价估计。g(n)、h(n)共同构成估价函数f(n)。
若对估价函数进行一定限制就得到了A算法。在该问题中对于估价函数做如下限制,g(n)>0;g(n)看作从初始状态到当前状态的走的步数;h(n)≤h^ (n);预估代价h(n)小于等于当前到目标的实际代价h^* (n),需要保证其是有效的、可纳的。从初始节点到目标节点有路径存在,也就是保证八数码问题有解,如果搜索算法能在有限步数内找到一条从初始节点到目标节点的最佳路径,则A算法可纳。
若启发式函数中h(n)=0,那么估价函数f(n)=g(n),意味着每个节点的估价只取决于从起点到该节点的实际成本,而对于剩余距离到目标的估计无关。那么A算法就变成了宽度优先搜索(BFS)算法。
若g(n)=0,估价函数就变成了f(n)=h(n),只关心当前与目标的剩余距离,这时候就变成贪婪算法了。
从这算法还能看出人生哲理,f=g,只低头走不抬头看,f=h,只在乎眼前,不考虑之前,有时候容易走火入魔误入歧途。走一条好的路,既要时刻追随目标,也不能忘记出发时的初心。
基于以上算法的介绍和是否有解问题的分析,针对于该问题,我尝试选择A启发式算法求解。不同估价函数的选择对算法的效率可能产生不同的影响。尤其是h(n)的选择,在该问题中,我选择了曼哈顿距离之和作为h(n),当然也可以选择错位格子数作为h(n),当然这可能导致搜索的次数不同。当h(n)越接近h^ (n),那么扩展的节点越少。
算法实现流程
以下图为例子,从初始状态到目标状态,通过启发式函数f(n)=g(n)+h(n),g(n)可看作层数,h(n)表示当前状态距离目标状态的曼哈顿距离。
整体流程如下:判断初始状态和目标状态之间是否存在路径可以实现转化,若不存在则输出无解,算法结束判断;若存在,则可以通过A*算法进行求解,具体来说需要定义节点的表示方法,建立Open表和Close表存储需要进行访问的节点和已经访问过的节点,Open表中需要对访问的节点进行一个排序,通过启发式函数f(n)计算估价函数,将估价函数较小的排在前面,然后取出节点与目标节点进行比对,若未达到目标节点,则生成子节点,并将已经访问的当前的节点放入Close列表,新生成的节点更新放入Open列表。之后开始新一轮循环,直到找到目标节点,结束算法。整体示意流程如图所示。
逆序数求解
在进行算法求解之间,还需要确定问题是否有解,需要确定初始状态和目标状态排序的奇偶性。需要计算排列的逆序数,常用的方法可以通过两个循环比对两个数的大小来计算逆序数,但是这种方法计算的时间复杂度较高,我这里尝试使用归并排序解决逆序数求解问题。
所谓归并排序算法,就是通过分治法将问题规模不断的缩小,然后在进行合并的过程。具体示例流程如图所示。
在该示例中,对一个无序的数组进行不断的对半划分成更小的数组,直到最小的数组元素个数为0或1,然后再对划分的数组元素进行重新排序,每一次排序都会得到一个有序的数组,最后将这些数组合并起来,得到一个整体有序的数组。
该算法可以通过递归思想和简单的比对就可以实现,通过代码实现一下。
1. # 并归类,有解否判断 使用并归排序分析逆序数
2. class Merge_Sort:
3. num = 0
4. def __init__(self,lists):
5. self.lists = lists
6. # 用一个递归实现 # 这里的排列多余了*—*
7. def MergeSort(self,lists): #传入 list ,访问属性num即可
8. if len(lists) <= 1:
9. return lists
10. num = len(lists) // 2
11. left = self.MergeSort(lists[:num])
12. right = self.MergeSort(lists[num:])
13. return self.Merge(left,right)
14.
15. def Merge(self,left,right):
16. result=[]
17. while len(left) > 0 and len(right) > 0:
18. if left[0] <= right[0]:
19. result.append(left.pop(0))
20. else: # 这里一定要注意 ***
21. self.num += len(left) # 左列表中第一个数(最小)都比右边的大
22. result.append(right.pop(0)) # 表明左列表中剩余的数都会和right[0]产生逆序
23. if len(left) > 0:
24. result.extend(left)
25. else:
26. result.extend(right)
27. return result
28. lst = [2,0,8,3,1,5,6,7,4]
29. if __name__ == "__main__":
30. m1 = Merge_Sort(lst)
31. m1.MergeSort(lst)
32. print(m1.num) # 输出结果:12 如果你有地方不清楚,强烈建议你动笔仿照流程操作一下
A*算法实现
在判断问题确实是存在解之后就可以正式进入算法求解部分了,需要列出状态节点的相关属性,定义状态的表示方法,以及启发式函数的确立等等。详细部分如下所示
1. # ---------- A* 算法求解 ------------
2. class Node:
3. def __init__(self,state,parent,move,depth,cost):
4. self.state = state # 当前状态(元组)
5. self.parent = parent # 父节点
6. self.move = move # 方向 up down left right
7. self.depth = depth # 已走过的步数 g(n)
8. self.cost = cost # 总代价 f(n) = g(n) + h(n)
9.
10. def __lt__(self,other): # 比较,使得堆中始终是cost小的在前
11. return self.cost < other.cost
12. # 位置记录函数 将目标状态 位置记录如下形式
13. """
14. targetPosition = {
15. 1: (0, 0), 2: (0, 1), 3: (0, 2),
16. 4: (1, 0), 5: (1, 1), 6: (1, 2),
17. 7: (2, 0), 8: (2, 1), 0: (2,2)
18. }
19. """
20. def PositionRecord(state):
21. dic = {
22. state[0]:(0, 0), state[1]: (0, 1), state[2]: (0, 2),
23. state[3]: (1, 0), state[4]: (1, 1), state[5]: (1, 2),
24. state[6]: (2, 0), state[7]: (2, 1), state[8]: (2, 2)
25. }
26. return dic
27. #获得子节点
28. def get_children(node):
29. childeren = []
30. state = node.state
31. idx = state.index(0) # 获得空格(0)的位置
32. row, col = idx // 3, idx % 3 # 获得0所在的行和列
33.
34. moves = (
35. ('up', -1, 0), # 执行up操作会导致 row -1
36. ('down', 1, 0),
37. ('left', 0, -1),
38. ('right', 0, 1)
39. )
40.
41. for move, dr, dc in moves:
42. new_row = row + dr
43. new_col = col + dc
44. if 0 <= new_row < 3 and 0 <= new_col < 3: # 边界的检查
45. new_idx = new_row * 3 + new_col # 得到0新的索引
46. new_state = list(state)
47. new_state[idx], new_state[new_idx] = new_state[new_idx], new_state[idx] # 位置的交换
48. childeren.append((tuple(new_state),move))
49.
50. return childeren
51. # 计算曼哈顿距离
52. def manhattan_distance(init_state,target_state):
53. totalDistance = 0
54. tarPos = PositionRecord(target_state)
55. for idx, num in enumerate(init_state):
56. if num == 0:
57. continue
58. init_state_row, init_state_col = idx // 3, idx % 3
59. target_row, target_col = tarPos[num]
60. totalDistance += abs(init_state_row - target_row) + abs(init_state_col - target_col)
61. return totalDistance
62. # 路径 get 函数 将移动方向记录下来方便后续的可视化操作
63. def get_path(node):
64. path = []
65. while node.parent is not None:
66. path.append(node.move) # 记录移动方向
67. node = node.parent
68. return path[::-1]
以上就是算法的基本实现流程和思想,可以初步的实现八数码问题。
结果分析和总结
通过以上对八数码问题的分析和实现了解到了问题有解无解的结论:一个状态表示成一维的形式,(空格用0代替),求出除0外所有的逆序数,若两个状态的逆序数奇偶性相同,则可相互达到(有解),否则不可相互到达。
若把问题推广到4×4,有会出现什么情况呢?
看一个示例,还是按照之前分析的思路来看逆序变化情况。(10~15用字母代替)
从变种问题看出,空格在行内移动依然不改变逆序,而当空格上下移动的时候,相当于一个数字跨过了三个数,其经过了3次相邻对换,相邻对换会改变奇偶性,那么就有:若原来是奇排列则变为偶排列,原来是偶排列则变为奇排列。从示例容易看出,两个状态的逆序数都是0,奇偶性是相同的,但是由于空格行所处位置不同,左边的空格行从第四行移动到第三行必然会伴随奇偶性的改变,因此可以得出结论,这两个状态之间无法转换,该问题是无解的。
看一个有解的例子,下图所示。
左边状态的逆序数为0,右边状态的逆序数为1,两个状态的奇偶性不同,空格位置分别在第四行,第三行。由于空格从第四行移动到第三行,奇偶性改变,因此这两个状态可达,问题有解。
通过观察,可以得出结论,有一个N×N棋盘的转化问题,若N为奇数,则只需要初始和目标状态的奇偶性相同就有解;若N为偶数,有解需要满足条件:初始空格所在行到目标空格所在行的距离(不记左右)的奇偶性需契合初始状态和目标状态的奇偶性。即两个状态逆序数的奇偶性相同且空格距离为偶或逆序数奇偶性不同且空格距离为奇数。
效果演示