找到解决有色水分拣游戏的最短路径。

6

我的阿姨现在玩这个很流行的手机游戏,如下图所示。她卡在了某个关卡,问我能否解决它。我知道我不够聪明,无法找到解决它的模式或策略,只懂Python的基础知识,所以我想尝试通过编写脚本来解决它,并且这是学习新东西的绝佳机会,于是我开始了为期两周的旅程。

game example

游戏由许多瓶子组成,装满了不同颜色的瓶子层,通常有一个或两个空瓶子,目标是使所有瓶子颜色均匀。一次移动包括将至少有一层的瓶子倒入另一个瓶子中。主要规则是您可以在空瓶中倒入任何颜色,并且只能将一层倒在另一层上,如果它们是相同的颜色。
我解决这个问题的第一种方法是创建一个新的瓶子类,在其中实现所有规则,这意味着因为我只知道基础知识,所以花费了很多时间,而且真的没有优化(我不知道堆栈,必须写下如此多的if.. elif.. else语句来指定瓶子何时可以倒入另一个瓶子中)。完成后,我尝试编写一些代码来解决它。我对代码如何知道选择哪个瓶子和倒入哪里没有太多想法,所以我选择了简单的解决方案:随机选择。它可以立即解决(有点)少量瓶子的问题,但是对于15个或更多瓶子,它无法解决。
所以我第二个想法是计算每一个可能性和移动,即制作游戏树,然后使用搜索算法找到最短路径,我阅读了一些关于游戏树和图形搜索算法的内容,并决定使用广度优先搜索(之后我学习到我使用的图形是有向无环图,因此最好使用拓扑排序,但我不确定)。游戏树中的节点是倒入彼此后瓶子所处的不同状态,边是将您从一个状态带到另一个状态的移动。以下是我如何在某种伪代码中生成游戏树:
take the first bottle A
create a list of bottles, list A, that bottle A can pour in
for each bottle B in list A, we pour bottle A in bottle B, and get a new state of bottles C
check if state C is already a node in the graph, or a permutation of a node(see explanation after the code) and add it to the graph if not
do what we did to bottle A, to all other bottles in the current node
then move to the children nodes and do the same

我所说的检查节点的排列方式是指,例如(bottle(1,2,1), bottle(0,0,0), bottle(2,1,2))与(bottle(1,2,1), bottle(2,1,2), bottle(0,0,0))不同,因此当我保留排列时,原本只有3000个节点的图形变得庞大,达到了200000个节点。
我编写的代码问题在于生成游戏树需要太长时间。上次尝试时,为16个瓶子的等级生成游戏树需要5小时,我认为这太多了,可以更快地完成。我的一个朋友建议使用Numpy,因此图中的每个状态或节点都将是单个矩阵,并且由于Numpy可以更快地执行操作,这可能是解决问题的方法。但由于我对Numpy不熟悉,我想在这里询问通用的良好做法,以帮助我解决问题,例如如何检查节点是否已经在图形中,或者如果它的排列方式,这种情况下,它将检查两个矩阵是否相等,直到列的排列方式相同或类似。
所以我的问题是:如果你站在我的角度,你会怎样解决这个问题,并且你认为我如何优化我的代码?非常感谢您的任何建议。

1
你需要摆脱试图暴力破解每一组可能的移动。这是O(n!),并且会非常快地变得不合理。 - BTables
1
为此,我会考虑研究A搜索算法。适合作为适应度函数的候选项可能是组合的层数。 https://en.wikipedia.org/wiki/A_search_algorithm - Peter Mølgaard Pallesen
1
你可以看一下Raymond Hettinger的演讲,关于解谜游戏的。 - Lukas S
3个回答

5
非常有趣的问题!我有一些建议。
首先,在我看来,如果没有明确的想法,搜索这样一个庞大的配置树最多只能算是低效。为什么不优先选择“方向”,以便在同一颜色中更多地填充或更高地填充更多瓶子。请参见下面的 __iter__
我认为,如果瓶子的顺序不同,但颜色相同,则将谜题的两个状态视为相同可能会很有价值。您可以通过使用整数元组表示瓶子中的颜色并保存这些元组的集合(因为集合不保存顺序)来实现这一点。请参见下面的 set_rep
我忍不住要编写代码了。作为基础,我使用了 Raymond Hettinger 的通用谜题解决器。特别是我的 solve 方法受到了他的影响。
实际代码:
import numpy as np
from collections import deque


class ColoredWater:
    def __init__(self, pos):
        self.pos = pos

    @staticmethod
    def get_first_non_zero(arr):
        try:
            return arr[arr != 0][0]
        except IndexError:
            return 0

    @staticmethod
    def get_first_non_zero_index(arr):
        try:
            return np.where(arr != 0)[0][0]
        except IndexError:
            return 3

    @staticmethod
    def get_last_zero_index(arr):
        try:
            return np.where(arr == 0)[0][-1]
        except IndexError:
            return 3

    def get_legal_moves_to(self, moveable_to):
        first_non_zero = self.first_non_zero
        n = first_non_zero.shape[0]
        if first_non_zero[moveable_to] == 0:
            return np.where((first_non_zero != 0) & (np.arange(n) != moveable_to))[0], moveable_to
        else:
            return np.where((first_non_zero == first_non_zero[moveable_to]) & (np.arange(n) != moveable_to))[0], moveable_to

    def swap(self, i, j):
        out = self.pos.copy()
        idx_from = (self.get_first_non_zero_index(self.pos[:, i]), i)
        idx_to = (self.get_last_zero_index(self.pos[:, j]), j)
        out[idx_from], out[idx_to] = out[idx_to], out[idx_from]
        return ColoredWater(out)

    def isgoal(self):
        return np.array_equiv(self.pos, self.pos[0])

    def __iter__(self):
        self.first_non_zero = np.apply_along_axis(self.get_first_non_zero, 0, self.pos)
        moveable_to = np.where(self.pos[0] == 0)[0]
        legal_moves = tuple(map(self.get_legal_moves_to, moveable_to))

        out = [self.swap(origin, target)
               for origins, target in legal_moves
               for origin in origins]   

        def number_of_full_stacks(pos):
            return np.sum(np.all((pos == [pos[0]]), axis=0))

        def fillings_of_stacks(game):
            pos = game.pos
            return number_of_full_stacks(pos), number_of_full_stacks(pos[1:]), number_of_full_stacks(pos[2:])

        return iter(sorted(out, key=fillings_of_stacks, reverse=True))

    def set_rep(self):
        return frozenset(map(tuple, self.pos.T))

    def __repr__(self):
        return repr(self.pos)

    def solve(pos, depthFirst=False):
        queue = deque([pos])
        trail = {pos.set_rep(): None}
        solution = deque()
        load = queue.append if depthFirst else queue.appendleft

        while not pos.isgoal():
            for m in pos:
                if m.set_rep() in trail:
                    continue
                trail[m.set_rep()] = pos
                load(m)
            pos = queue.pop()

        while pos:
            solution.appendleft(pos)
            pos = trail[pos.set_rep()]

        return list(solution)

如何运行

首先在您的示例上运行。使用 depthFirst=True 运行,它在 376 毫秒内给出了 117 步的解决方案。当我使用 depthFirst=False 运行时,它在 9.42 秒内给出了最优解的 55 步。

from ColoredWater import ColoredWater
import numpy as np

ColoredWater(np.array([[ 0,  1,  0,  5,  8,  9,  7,  4,  2,  8,  2,  5,  5, 10, 12],
                       [ 0,  2,  0,  6,  3, 10,  9,  7, 11,  3, 11, 12,  3,  6, 13],
                       [ 0,  3,  0,  7,  4,  2, 11, 11,  6, 12, 12, 13,  1, 13,  1],
                       [ 0,  4,  0,  5,  9,  9,  7,  6,  8,  8, 13,  1,  4, 10, 10]]))\
.solve(depthFirst=True) 

我还在一个“随机”的示例上进行了测试:

def sort_zeros(x):
    return sorted(x, key=lambda x:x != 0)


n = 15

arr = np.broadcast_to(np.array([0,0,*range(n)]),(4,n+2)).copy()
np.random.shuffle(arr.reshape(-1))
arr = np.apply_along_axis(sort_zeros,0,arr)
print(ColoredWater(arr).solve(depthFirst=True))

抱歉评论来晚了,由于一些生活原因,我直到现在才回复。自从开始阅读和尝试理解您的答案以来已经有两天了,但是由于我在编程和numpy方面的经验非常少,所以这两天我没有取得任何进展,所以如果您能帮助我解释一下,我将非常感激。这里是我在理解时卡住的一个例子:在函数get_legal_moves_to中,您定义了变量first_non_zero并使用它,但我不知道它是否应该是一个... - Matsukazi Issy
...数组或整数,所以我进入函数__iter__(self)尝试查看它是什么,但仍然无法理解,对我来说有点复杂,如果您可以提供一些关于函数的注释或类似的东西,那将非常有帮助。我知道我要求很多,所以随意忽略这个请求,我会尽力在接下来的几天里理解,并在最终理解后与您联系。 - Matsukazi Issy
1
@MatsukaziIssy 你好,不用道歉。我在这里发布这个帖子是为了让别人阅读并评论它。这就是我的意图 :). 你看过基于通用拼图求解器的演示吗?那是一个非常棒的演示,也是理解的好起点。如果你有具体问题,请在评论中随时问我。但恐怕我不想预测你的问题并回答它们,以防你可能不理解,即在我的代码中添加很多注释。 - Lukas S
1
当阅读函数 get_legal_moves_to 时,如果你想知道 first_non_zero 的类型是什么,你可以注意到我在其上调用了 .shape。这让我相当确定它是一个 numpy 数组。 - Lukas S
嘿,@user2640045 谢谢你的回答,我会尝试观看讲座并花更多时间理解每一个细节,非常感谢你的帮助。 - Matsukazi Issy
1
我认为你的解决方案不够优化,需要55步。如果你将瓶子从左到右编号为1到15,这里有一个44步的解决方案:9-> 3 11-> 3 11-> 9 15->11 13-> 1 4-> 1 12-> 1 12->11 15->12 2->15
2-> 3 13-> 2 15->13 14->15 4->14 7-> 4 6-> 7 6->15 6-> 3 7-> 6
9-> 7 14-> 9 14->12 14->15 7->14 4-> 7 4-> 1 11-> 4 12->11 13->12
8->13 8-> 7 8->14 9-> 8 5-> 9 5-> 2 5->13 5-> 6 10-> 9 10-> 5
10-> 4 10-> 9 2-> 5 2->13我的算法在这里描述: http://kociemba.org/themen/waterball/colorsort.html
- Herbert Kociemba

4

0

我也沉迷于一个彩色水瓶分类游戏中:)) 很快我就达到了一个(高)级别,在那之后游戏的创造者添加了更多颜色。在此之前,只有两个空瓶子和几个装满水的瓶子。

我想制作一个类似的游戏,并添加一个帮助按钮。 为了实现这一点,我必须有一个解决方案。

因此,有回溯和分治递归来获得解决方案。

但就像下棋一样,有一种贪心算法,没有完整的解决方案(我忘记名字了)

基本上,任何瓶子都有两种状态: -必须从最初的大部分中移除,

  • 必须添加到其中(最初为空瓶或添加颜色时)。

稍后,在更高的级别中,填有1或2滴水的空瓶可以成为一个必须清空的瓶子!例如,您有一个空瓶,在其中放置了2个红色。在某个时候,您必须将其清空到一个有蓝色和红色(并且您再加入2个红色)的瓶子中,以获得一个空瓶。在这种情况下,蓝色与3个红色非常匹配。 :)

有一个游戏,如果你观看广告,就可以增加一瓶空瓶,这将使游戏变得更容易。如果你再观看一条广告,就可以再获得一瓶空瓶(总共有4个空瓶)。在这种情况下,“问题有时候可以被一个5岁的孩子解决,有时候不行:)因此,结论是:增加空瓶会使游戏更容易解决。

另外,对于魔方,如果你拆开魔方并旋转其中一个正方形,那么通过旋转将无法解决魔方。从中得出的结论是:如果你只是随机地从瓶子里取出颜色来填充魔方,可能会变得不可能解决。

作为一个游戏创造者,我应该从正确填充的瓶子开始,并使用两个空瓶进行随机混合,以确保初始状态一定可以解决。

用贪心算法来解决问题(贪心算法并不是最好的,也不能保证结果):取最多数量的顶部颜色:3个红色,2个绿色,1-1-其他。我会选择数量最多的红色,开始用红色填充空瓶。如果失败,我会尝试记住并用其他两种颜色来填充。

如果是红色的话,我应该拿什么,因为上面有2个x,但下面也有2个红色,那我就不拿红色了。 通常我不会选择底部最多的颜色。

当我们学习递归时,我们会学到这些游戏和解决方案的“决策树”。您必须按照先序、中序、后序的顺序遍历该树。当然,对我来说编写软件比在脑海中运行它更容易 :)

所以,“解决彩色水分选游戏的最短路径”——您指的是移动次数更少。 绝对确定的是增加更多的空瓶子。 而“最短”的意思是您拥有所有正确的解决方案,并选择符合标准(移动次数更少)的解决方案。您必须生成所有解决方案,然后进行回溯。 回溯的复杂度不是O(n),而是更糟糕的(指数级别或更高)

因此,添加更多颜色将使它变得更加困难,非常非常困难!请尝试一下!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接