问题描述
3×3 九宫棋盘,放置数码为1 -8的8个棋牌,剩下一个空格。指定初始状态和目标状态,要求只能通过移动空白格来形成指定棋盘。求最短步数以及移动方法。
核心问题:逆序数
在八数码问题中,两个状态的逆序数奇偶性相同,他们之间才可达。
在这个题目中,初始状态和目标状态给定且逆序数都为奇,所以初始状态是合法的。同样的,在后续尝试每一个状态时,都需要判断该状态逆序数是否为奇数,若是,则可以对该节点继续扩展,否则该节点是非法节点,一定不可能移动到目标状态。
没进行一步就求一次逆序数是不是很耗时呢?实际上,在八数码问题中这个操作是多余的。原因很简单:首先在对矩阵编码时,我们把空白格看作是数字0,则状态序列是一个从0到8的序列,但要注意计算逆序数时是不把0元素包含在内的!它只是一个空白格位置的标记。
0元素在不出界的前提下总共有上下左右四种走法
- 往左、右移动:数列1-8的相对位置没有任何变化,所以数列的逆序数不变。
- 往上、下移动:空白格在序列0-8中往前(后)移动了3个单位距离,相当于将一个数字X在数列1-8中向前(后)移动2格(3-1=2),那么跳过的这两个数,要么比数字X都要大(小),逆序数可能±2;要么一个比数字X大,一个比数字X小,逆序数不变(+1和-1正好抵消)。所以逆序数仍然不会改变
由以上可知:在八数码问题中,无论空白格怎么移动,状态逆序数的奇偶性都不会改变。但不保证在n数码问题中也有这样的现象。例如4*4棋盘,在上下移动空白格时,逆序数会±3或者±1,其奇偶性必然发生改变。
BFS解体思路
- 判断初始状态是否和目标状态一致。若一致,程序结束;否则,转入步骤2。
- 判断初始状态(自由输入)的逆序数奇偶性是否和目标状态一致。若一致,转入步骤3;否则,程序结束。
- 状态入队,进入步骤4
- 如果队为空,则结束程序;若非空,则首元素(状态)出队并找到该状态下0元素的位置,分别分析四个方向的可行性。如果当前方向可行,则进入步骤5;否则继续寻找下一个方向,如果四个方向寻找完毕,则进入步骤9
- 已找到可行的位置to,执行
swap(当前状态[zero],当前状态[to]),即交换位置。进入步骤6. - 判断该状态是否已经分析过(有标记),如果已经处理过,则进入步骤8;否则进入步骤7
- 判断该状态是否为目标状态,若是,则输出搜索深度
cnt[head]+1作为最终结果并结束整个程序;若不是则执行cnt[tail++] = cnt[head] + 1并将该状态如队并作标记。进入步骤8 。 - 执行
swap(当前状态[zero],当前状态[to])。进入步骤9 head++进入步骤4。
难点
标记深度
小思考:给字符串做标记
我们知道:每一个处理过的状态都不能再次分析。每一个状态都是一个字符串形式,那怎么才能耗最少的空间标记这个string并用最少的时间查找呢?首先想到的就是存到一个大集合里然后每次查找,太蠢肯定不行。那用索引?但字符串不能当作下标啊!有办法吗?其实是有的。在c++中,可以使用map<string,bool>m;的方法,如果出现状态now,则标记m[now]=True,然后使用if(!m[now])判断即可。我们得到启发:这就是借用键值对的功能啊,用字符串作为键,用True作为值,这不就可以高效得实现对字符串的标记吗。 那在python中怎么用呢?
python中只有字典支持键值存储,那我们就大胆使用,处理过的标记为True,未处理的标记False。但这样真的高效吗?在判断时我们有必要每一个都区分True和False吗?其实我们只需要把出现过的字符串标记为True,没出现的不用管,在判断时只判断这个键(字符串)在字典中是否存在即可,而不用管它的值是什么,这样就减少了特别多的空间占用,另一方面,因为不用管键所对应的值是什么,我们完全可以标记字符串为dict[now]='$'之类的,无任何影响。
程序实现
import sys
import queue
import numpy as np
np.set_printoptions(threshold=np.inf) # threshold 指定超过多少使用省略号,np.inf代表无限大
start = None
end = '123804765'
dict = {} # 用来做状态处理的标记 相当于open表
ans = np.zeros(400000, dtype=int) # 如果开的小,很多棋盘会解不出来
# 描述空白格移动
def swap(str, zero, to):
temp = list(str) # 在python中,字符串是不可变的变量,所以先转化为list
temp[zero], temp[to] = temp[to], temp[zero]
now = ''.join(temp)
return now
# 求初始状态的逆序数判断是否合法
def inversion(str):
sum = 0
for i in range(9): # 0-8
for j in range(i + 1, 9):
if str[j] < str[i] and str[j] != '0': # 不把0算入在内
sum = sum + 1
return sum
# 试探空白格移动的四个方向
def move(i, index):
if i == 1: # 向上
if index - 3 >= 0:
return index - 3
else:
return -1
elif i == 2: # 向下
if index + 3 <= 8:
return index + 3
else:
return -1
elif i == 3: # 向左
if index % 3 != 0:
return index - 1
else:
return -1
elif i == 4: # 向右
if index % 3 != 2:
return index + 1
else:
return -1
def BFS(start):
head, tail, flag = 0, 1, False
# 步骤3
q = queue.Queue() # 括号中是队列可容纳数据的多少,如果不设置,则可以一直增加
q.put(start)
# 步骤4
while not (q.empty() or flag):
now = q.get() # 取出的时候顺带弹出了. now现在是str类型
zero = now.find('0') # 寻找0的位置
# 向四个方向寻找
for i in range(1, 5):
to = move(i, zero)
if to == -1:
continue
# 步骤5
now = swap(now, zero, to) # 交换
# 步骤6
if now not in dict:
# 步骤7
if now == end:
print(ans[head] + 1, end=' steps') # 深度
flag = True # 用来跳出 while
break # 用来跳出 for
ans[tail] = ans[head] + 1
tail = tail + 1
dict[now] = i # 该状态已分析过,做标记
q.put(now)
# 步骤8
now = swap(now, zero, to) # 恢复现场
# 步骤9
head = head + 1
if __name__ == '__main__':
# start = input() # 输入初始状态
start = '216408753' # 283104765:4 283164705:5 216408753:18 234150768:无解
# 步骤1
if start == end:
print('已是终点状态')
sys.exit()
# 步骤2
if inversion(start) % 2 != inversion(end) % 2:
print('该状态无解')
sys.exit()
BFS(start)