华容道问题-程序求解,python实现

华容道问题-程序求解,python实现

1、需求来源

某天娃拿着华容道板块来,喊她爹我求解,大概如图这样的一个东西,我:…,一把年纪了,和你玩这个破东西?自己一边玩去。

花了一支烟时间想了下,算了,帮你写一个程序来破解吧,后面别来烦我就行了。

2、初步建模

面板大小为 4 X 5 的数组,角色我们称为卡片。
我们观察角色有:曹操、关羽、张飞、黄忠、马超和4个卒(为了表示方便,四个卒我们认为是四个不同角色)。
用一个二维数组表示游戏状态,为每个角色定义一个值。
角色值定义,卒用小写字母表示(谁叫你不是大佬?),大将们用大写字母表示,空白用 0 表示。(其实如何定义都没关系,主要是为了方便阅读和同名不冲突)
如下:

 # "0":空
 # "a":卒1
 # "b":卒2
 # "c":卒3
 # "d":卒4
 # "M":马超
 # "H":黄忠
 # "Z":张飞
 # "Y":赵云
 # "G":关羽
 # "C":曹操

除了 0 值,其他相邻的相同值,我们认为是不可分割的一块,根据定义,我们构造其中一个初始化局面如下:

[
  ["H","C","C","Y"],
  ["H","C","C","Y"],
  ["Z","G","G","M"],
  ["Z","b","c","M"],
  ["a","0","0","d"]
]

从初始状态开始,遍历每个可以移动的卡片进行下一个状态生成,得到一个状态树。

最终生成树下如果把“曹操”角色移动到(1,3)、(2,3)(1,4)、(2,4)(如下图,蓝色数据移动到红色位置)表示结束,拿到答案了。

3、抽象对象化

在状态树图中,如何从一个状态生成另一个状态?如果用上面的一个二维数组来做逻辑处理,比较复杂,如:
1) 除了0,相同的值的移动需要一起移动;
2) 移动向的领域不能超出4X5区域范围并且目标要是0(空白);
3) 离开的区域,需要设置为0 等等。

基于二维数组做这些状态表逻辑处理,略微复杂,将它们抽象简化下。

细想华容道游戏,包含一个面板,多个角色卡片,和两个空白位置这几种不同数据。我们如何把”面板”、”角色卡片”、”空白点”这三类有机的组合起来?步骤如下:

1)从卡片开始,我们定义一个Card 对象,它有角色值,有位置,有大小;

class Card:
    def __init__(self, role, x, y, width, height):
        self.role = role

        self.x = x
        self.y = y
        self.width = width
        self.height = height

2)空白点较为简单,定义为(x,y) 这样的一个元组即可,不过多设计了。

3)面板上包含属性有卡片,和空白点。卡片角色值是唯一的,用一个dir(role:Card) 表示角色;还有两个空白点,由于空白点也是唯一的,为了运算方便,我们把它定义到集合set 里面。

面板模型代码:

class Board:
    '''
    cards: dir{role:card}
    blanks:set((x,y))
    '''
    def __init__(self, cards, blanks):
        self.cards = {card.role:card for card in cards}
        self.blanks = blanks

目前的 Board 对象和前面的状态state数组,它们表示是完全等价的。对象化之后,操作逻辑大大简化了,例如面板上移动一个卡片,就是两个步骤:
1)对card 进行x,y进行加减操作(如Card提供 move 函数);
2)对进入区域和离开区域的空白位置的替换。

class Card:
    # 若干代码
    def move(self, offset_x, offset_y):
        self.x = self.x + offset_x
        self.y = self.y + offset_y

4、主要逻辑
再来整理下建模游戏的主要逻辑,由于华容道答案是期望寻求最短路径的,所以需要一个广度优先算法。
从初始化局面(根局面)开始:
1)枚举每个卡片,对每个卡片进行上下左右可移动判断,并生成所有可能状态局面,记录到列表;
2)对每个生成状态局面进行判断,如果是答案,则返回“演化”历史;如果不是,则记录到“下一层”遍历列表;
3)对“下一层”遍历列表每个状态局面进行递归处理,回到步骤 1。

简单说说可移动判断逻辑,比如一个卡片想往上移动,它能往上移动的条件是什么?
1)卡片本身不能在最贴顶部;
2)卡片顶边的上方,每个格子都没有其他卡片,看状态二维数组,就是状态为“0”,在 Board 对象来看,就是上方的(x,y)点,都在 Board.blanks 内。

整理好思路后,逻辑就简单了:

class Card:
    # 判断是否已经到顶部
    def isTopMost(self):
        return self.y == 0

    # 求顶边各个点
    def getTopLines(self):
        return [(self.x + i, self.y) for i in range(self.width)]

    # ... 其他很多代码

向上移动,如果是顶部了,则不能往上移动;否则,获取顶部各点的上方一格,并且判断上一个是空白,则可以移动了。
还需要抽象一个game 来做游戏总控制。

class Game:
    def __init__(self):
        pass

    # 返回移动后的状态,None 表示非法移动
    def moveUp(self, card, board):
        if card.isTopMost():
            return None

        # 进入区域为顶边偏移-1,离开区域为底边,偏移值得 为(0,-1)
        above_tops = [(x, y - 1) for (x, y) in card.getTopLines()]
        return self.move(card, board, above_tops, card.getBottomLines(), 0, -1)

    def move(self, card, board, enter_points, leave_points, offset_x, offset_y):
        # 进入的空间要求为空白
        if not board.isAllBlanks(enter_points):
            return None

        new_card = card.clone()
        new_card.move(offset_x, offset_y)

        new_board = board.clone()
        new_board.updateCard(new_card)
        new_board.updateBlanks(enter_points, leave_points)

        return new_board

递归算法,采用广度优先遍历,枚举每个局面,核心代码就是这么一点了:

    def dfs(self, forest, traversed):
        next_forest = []

        for tree in forest:
            child_boards = self.nextBoards(tree.board)
            for child_board in child_boards:
                child_tree = Tree(tree, child_board)

                if self.isAnswer(child_board):
                    return child_tree.sourceBoard()

                next_forest.append(child_tree)

        if len(next_forest) == 0:
            return []

        return self.dfs(next_forest, traversed)

由于答案需要回缩演化历史,也就是需要从最后答案状态,需要往上回溯树父节点一直到根节点(初始状态),所以需要设计一种倒挂树——不记录子节点,而记录父亲节点:

class Tree:
    def __init__(self, parent, board):
        self.parent = parent
        self.board = board

    def sourceBoard(self):
        if self.parent == None:
            return [self.board]

        sources = self.parent.sourceBoard()
        sources.append(self.board)

        return sources

5、算法优化

1)由于移动卡片时候,移动先后两个卡片几次后,可能会出现两个面板是一样的,所以针对每个局面进行hash 记录,历史上出现过了,不需要再做便利,可以大大减少局面遍历量。
2)形状相同的两个卡片,如果出现了位置调换,两个盘面其实是等价的,我们认为两个卡片是等价的,所以他们hash 记录理应一样,省去不必要的遍历。

代码见Board.hash() 相关逻辑。
相同的两个状态,没有优化的情况下,跑一个小时还没有出结果,做了这两点优化后,约10秒出结果。优化效果是比较客观的。

6、输出界面

但考虑娃看不懂这种“高级”的幽默,我不得不又花了她爸的一个多小时去抠图,然后编码…刷刷刷~弄了个6岁儿童能看得懂的界面效果:

7、完整代码分享

不多说了,上代码吧。

github 链接:https://github.com/yuccnx/klotski

(全文完)

(欢迎转载本站文章,但请注明作者和出处 云域 – Yuccn

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注