图算法趣味学——破解迷题

84 阅读18分钟

图搜索算法的应用不仅限于在物理空间中寻找路径,或在虚拟对象之间寻找链接,它们还广泛应用于更抽象的问题,比如破解谜题或设计游戏策略。

许多谜题可以用一组离散的状态来表示,这些状态捕捉了谜题的不同配置。解决谜题的过程,就是通过一系列步骤,从初始状态转移到预设的目标状态。比如在河内塔谜题中移动圆盘,在渡河问题中移动人物,或者在拼图游戏中重新排列方块。在本章中,我们将这些谜题转化为图搜索问题,把谜题的每个状态建模为图中的节点,状态之间的转移建模为边,然后通过寻找通往目标状态的路径来解决谜题。

状态空间与图

本节介绍我们三个经典谜题的状态空间表示和图表示。

河内塔

河内塔谜题包含三根柱子,以及一组不同直径的圆盘,这些圆盘可以堆叠在柱子上。初始时,所有圆盘都位于最左边的柱子上,并且按直径从大到小依次堆叠,如图6-1所示(三个圆盘的情况),其中最大圆盘在最底部,最小圆盘在最上面。谜题的目标是通过一次移动一个圆盘,将所有圆盘按相同顺序移动到最右边的柱子上。

image.png

这个谜题有两个限制使其变得有趣。首先,你只能移动每个柱子上最顶端的圆盘。其次,在每一步操作后,每个柱子上的圆盘都必须保持按大小顺序排列。你永远不能把一个较大的圆盘放在较小圆盘的上面。例如,图6-2展示了从初始状态移动到合法的第二个状态的过程。

image.png

我们可以用多种方式来表示圆盘的不同配置作为状态。为说明这一点,假设用三个向量分别跟踪每根柱子上按顺序排列的圆盘集合,三个向量组合起来表示谜题的整体状态。状态用尖括号表示。图6-1中的状态可以表示为 <[3,2,1],[],[]>,而图6-2中的状态表示为 <[3,2],[1],[]>。

我们可以将这些状态视为枚举谜题所有可能的配置。当前圆盘的排列可能是 <[3],[],[2,1]>,但如果我们做了不同的移动,排列可能是 <[3],[1],[2]>。我们可以想象这个状态空间中丰富的可能选项、状态之间的转换路径以及未来移动的影响。

通过将谜题的状态用向量枚举,我们可以把问题转化成一个无向图,每个节点表示一个状态,每条边表示有效的操作。这在图6-3中有示意,针对三圆盘的汉诺塔问题,节点代表解题过程中的潜在状态。一个节点代表起始状态 <[3,2,1],[],[]>,一个节点代表目标状态 <[],[],[3,2,1]>,中间还有一系列其他状态。

image.png

由于我们可以通过将最小的圆盘从第一根柱子移动到第二根柱子,从状态 <[3,2,1],[],[]> 转移到状态 <[3,2],[1],[]>,所以这两个节点之间存在一条无向边。相比之下,我们无法通过移动一个圆盘从状态 <[3,2,1],[],[]> 直接转移到状态 <[2,1],[3],[]>,因此这两个状态之间没有边。

到目前为止,本书中的示例图节点大多表示具体的实体,比如物理位置、计算机节点、网页、任务或人员。但现在我们的节点代表的是世界的潜在配置。这带来的直接后果是节点数量的爆炸式增长。汉诺塔问题中可能的圆盘配置远远多于谜题中实际的圆盘或柱子数量,这意味着我们可能会遇到远比以前更大的图,算法的性能变得更加重要。

过河谜题

过河谜题是一类脑筋急转弯,要求将一组人或动物通过一条船(容量有限)运送过河。难点在于限制哪些实体可以单独留在河岸上。

我们来看一个经典的过河谜题,称为“囚犯与守卫”谜题。三名守卫和三名囚犯需要过河,船最多能载两人。囚犯被铐住,单独放着无法逃跑。但是,如果某岸囚犯人数多于守卫人数,囚犯会联合起来抢守卫的钥匙。因此,每一岸上守卫人数必须不少于囚犯人数。

我们可以将这个谜题表示为状态空间图,每个节点表示谜题的一个状态。状态包含三条信息:左岸守卫人数、左岸囚犯人数,以及船的位置(左岸或右岸)。右岸的守卫和囚犯人数可以从左岸人数推算,所以无需显式存储。初始状态为 <3,3,L>,表示所有人和船都在左岸。

图中的每个节点表示一个合法状态,边连接能通过一步合法移动到达的状态。合法移动包括用船运送一人或两人:两守卫、一守卫、一守卫加一囚犯、两囚犯或一囚犯。船不能空载,因为必须有人划船。由于我们总能通过将相同组合送回去撤销任何移动,图是无向的。

图6-4展示了包含16个可能状态的完整图。每个状态是一个节点,图中同时以图形和文字表示该状态。字母 G 和 P 分别表示守卫和囚犯的位置。船的位置用状态底部的 R 或 L 表示。许多状态只有两个合法移动,但有些状态有多个选择。

image.png

这个谜题展示了状态表示的应用,使用了一个相对较小的图,使得我们能够轻松地可视化和分析可达的状态集合。在后续章节中,我们将利用这个谜题演示如何通过编程方式创建图,并搜索解决方案。

滑块谜题

滑块谜题由一个带有方格的棋盘组成,其中有一个方格是空缺的,形成一个空位。通过将相邻的方块滑入空缺的位置,可以实现方块和空位互换位置。谜题的目标是将每个方块移动到正确的位置。根据不同的谜题,任务可能是排列一串数字,或者拼凑还原一张图片。经典的例子是15格滑块,如图6-5所示,每个方块上标有1到15的数字,正确的状态是所有方块按从左上到右下的升序排列。

image.png

滑块谜题非常适合用图来表示。每一种可能的方块排列都是一个独特的谜题状态,可以用图中的一个节点来表示。边则表示状态之间可能的转移。每个状态最多有四条无向边,分别对应通过将该状态空缺位置填入四个相邻方块中的一个而达到的邻居状态。我们可以将这些边称为上(Up)、下(Down)、左(Left)和右(Right)。

图6-6展示了一个示例状态及其四个邻居。通过在图中搜索从初始状态节点到目标状态节点的路径,我们可以找到解决谜题的一系列移动步骤。

image.png

15 数字方块谜题 展示了状态空间如何迅速爆炸。这个看似简单的谜题拥有超过 20 万亿种状态,其中许多状态在我们寻找解答的路径上根本无需访问。

使用搜索构建图

前几章介绍的图搜索算法都要求我们事先提供一个完整指定的图。对于谜题问题,这通常不可行。我们不想在开始搜索之前,费力地手动列举出成千上万个状态。这样不仅耗时,还很可能出错,甚至可能产生“作弊”的效果——即错误地添加允许非法移动的边。更糟糕的是,我们会浪费大量精力生成那些根本不会被使用或不可达的状态。

相反,我们可以利用搜索算法动态创建图,通过扩展广度优先搜索(BFS)和深度优先搜索(DFS)算法来探索状态空间,边走边添加节点和边。每当发现一个新状态,就添加对应的节点;每当测试两个状态间的移动,就添加对应的边。与之前对节点或边的遍历不同,这里搜索构建图是遍历谜题状态及从每个状态出发的所有合法移动。

接下来这部分,我们将以“过河问题”作为示例,说明如何从初始状态 <3,3,L> 开始逐步向外探索。每一步,我们会问:“如果我们把某种组合的人(两个守卫、一个守卫、一个守卫加一个囚犯、两个囚犯或一个囚犯)送过河,下一状态会是什么?”通过简单的算术计算新状态,并根据谜题规则检查其有效性。若新状态合法,则将其和对应的边加入图中。接下来的章节,我们将逐步构建代码,存储状态空间、定义合法转换并构建图。虽然重点是过河谜题,但方法适用于各种谜题。

表示谜题状态

为了定义搜索,首先需要表示谜题状态。下面代码展示了一个简单的 PGState 类(PG 代表“prisoners and guards”,囚犯和守卫),用来存储当前状态并提供辅助函数:

class PGState:
    def __init__(self, guards_left: int = 3, prisoners_left: int = 3,
                 boat_side: str = "L"):
        self.guards_left = guards_left
        self.prisoners_left = prisoners_left
        self.boat_side = boat_side

    def __str__(self) -> str:
        return (f"{self.guards_left},{self.prisoners_left},{self.boat_side}")

guards_leftprisoners_left 分别表示左岸的守卫和囚犯人数。boat_side 是字符串,表示船当前所在岸边,左岸为 "L",右岸为 "R"。__str__() 函数方便我们将状态转换为字符串,用于存储和显示。

计算移动结果

有了 PGState,我们可以编程计算移动指定人数守卫和囚犯后下一次船运的结果,代码如下:

def pg_result_of_move(state: PGState, num_guards: int,
                      num_prisoners: int) -> Union[PGState, None]: 
  ❶ if num_guards < 0 or num_prisoners < 0:
        return None
    if num_guards + num_prisoners == 0:
        return None
    if num_guards + num_prisoners > 2:
        return None

  ❷ G_L: int = state.guards_left
    G_R: int = (3 - state.guards_left)
    P_L: int = state.prisoners_left
    P_R: int = (3 - state.prisoners_left)
    if state.boat_side == "L":
        G_L -= num_guards
        G_R += num_guards
        P_L -= num_prisoners
        P_R += num_prisoners
        new_side: str = "R"
    else:
        G_L += num_guards
        G_R -= num_guards
        P_L += num_prisoners
        P_R -= num_prisoners
        new_side = "L"

  ❸ if G_L < 0 or P_L < 0 or G_R < 0 or P_R < 0:
        return None

  ❹ if G_L > 0 and G_L < P_L:
        return None
    if G_R > 0 and G_R < P_R:
        return None
    return PGState(G_L, P_L, new_side)

pg_result_of_move() 函数主要判断移动是否合法。如果非法,返回 None;否则返回表示新状态的 PGState 对象。注意这里用到了 Python 的 Union 类型提示,支持多返回类型。

  • ❶ 检查守卫和囚犯数非负,船上至少有一人且最多两人。任一不符即非法,返回 None
  • ❷ 计算左岸守卫和囚犯数 (G_L, P_L) 以及右岸守卫和囚犯数 (G_R, P_R)。根据船的位置,调整人数,并切换船边。
  • ❸ 检查人数不能为负,防止移动超过岸上人数。
  • ❹ 检查守卫与囚犯比例是否合法:只要岸上有守卫,囚犯不能多于守卫。允许某岸只有囚犯。若不满足,返回 None

若所有检查通过,返回新的状态。

生成邻居状态

pg_result_of_move() 检查并计算单次移动的结果,但我们还需要生成一个状态所有合法后继状态(邻居),用于构建图中的边。辅助函数如下:

def pg_neighbors(state: PGState) -> list: 
    neighbors: list = []
  ❶ for move in [(1, 0), (2, 0), (0, 1), (0, 2), (1, 1)]:
      ❷ n: Union[PGState, None] = pg_result_of_move(state, move[0], move[1])
        if n is not None:
            neighbors.append(n)
    return neighbors

该函数创建一个空列表 neighbors,遍历五种可能的移动组合:一守卫、两守卫、一囚犯、两囚犯、一守卫加一囚犯 ❶。每次调用 pg_result_of_move() 判断移动是否合法 ❷,合法则将新状态加入邻居列表。

这套代码为我们动态生成谜题状态图提供了基础。通过扩展搜索算法,我们能逐步构建状态图,探索解决方案。

生成图

现在我们已经具备了算法化确定当前状态邻居状态的组件,就可以用改进的广度优先搜索(BFS)来生成囚犯与守卫谜题的状态空间图。该算法从初始状态开始,沿着边探索相邻状态。我们将使用上一节的 pg_neighbors() 辅助函数,来确定当前状态的所有合法邻居状态。当邻居生成函数发现新状态时,我们将其作为新节点加入图中。

状态信息保存在 PGState 数据结构中。为了方便,我们将状态对象作为节点的标签保存,这样在搜索过程中可以方便地访问当前状态数据。我们使用 __str__() 方法将状态转换成字符串,用于辅助数据结构存储。

除了之前广度优先搜索用到的数据结构之外,这里还需要额外跟踪一个信息:状态到图中节点的映射。如果我们知道 <2,2,R><3,3,L> 之间存在边,但找不到对应的节点来创建这条边,信息就没有意义。我们用一个字典(indices)存储该映射,键为状态的字符串表示,值为图中对应节点的索引。

下面的代码将之前介绍的部分整合起来,生成囚犯与守卫的状态图:

def create_prisoners_and_guards() -> Graph: 
    indices: dict = {}
    next_node: queue.Queue = queue.Queue()
    g: Graph = Graph(0, undirected=True)

  ❶ initial_state: PGState = PGState(3, 3, "L")
    initial: Node = g.insert_node(label=initial_state)
    next_node.put(initial.index)
    indices[str(initial_state)] = initial.index

    while not next_node.empty():
      ❷ current_ind: int = next_node.get()
        current_node: Node = g.nodes[current_ind]
        current_state = current_node.label

      ❸ neighbors: list = pg_neighbors(current_state)
        for state in neighbors:
            state_str: str = str(state)
          ❹ if not state_str in indices:
                new_node: Node = g.insert_node(label=state)
                indices[state_str] = new_node.index
                next_node.put(new_node.index)
          ❺ new_ind: int = indices[str(state)]
            g.insert_edge(current_ind, new_ind, 1.0)

    return g

生成图的代码从初始化必要的数据结构开始:一个空字典 indices,一个空队列 next_node,还有一个空图 g。接着创建初始状态的 PGState 对象,通过 insert_node() 方法将其节点插入图中,把节点索引放入队列,并在字典中添加状态字符串到索引的映射 ❶。

随后,开始真正的搜索。和其他广度优先搜索一样,利用队列 next_node 来控制搜索。每次从队列中取出下一个节点索引,获取对应节点和状态 ❷。不同于之前直接用节点边列表找邻居,这里用 pg_neighbors() 函数动态生成邻居状态 ❸。

代码通过查找邻居状态字符串是否存在于 indices 字典中来判断状态是否已访问 ❹。如果字典中没有对应条目(也没有节点索引),说明这是一个新的未访问状态。此时为它创建新节点,并将索引加入队列。接着在当前节点和邻居节点之间插入边 ❺。

算法最后返回构建好的图 g。由于搜索只从初始状态向外扩展,返回的图只包含初始状态通过合法移动可达的状态。无效或不可达状态的节点不会包含在内。

图 6-7 展示了算法的前几个步骤:图 6-7(a) 显示了初始状态 <3,3,L> 对应的单个节点。图 6-7(b) 显示访问第一个节点后的结果:pg_neighbors() 找到了三个合法邻居状态,算法为每个邻居创建了新节点。随后访问 <3,1,R> 状态时,代码又创建了 <3,2,L> 节点和相应的边,如图 6-7(c) 所示。

image.png

算法在第一次遇到新节点时会生成该节点,但不一定会立刻生成该节点的所有边。这就是为什么在图 6-7(c) 中,状态 <3,2,L> 到状态 <2,2,R> 之间没有边。只有当算法访问到 <2,2,R><3,2,L> 中的任意一个节点时,才会生成这两个节点之间的边。

用搜索解决谜题

我们可以直接将前几章介绍的搜索算法应用到囚犯与守卫谜题的图上。我们把搜索函数添加到囚犯与守卫程序中,基于上一节的代码实现。

为简化逻辑,先定义一个辅助函数,用来创建一个字典,将状态字符串映射到对应节点索引:

def pg_state_to_index_map(g: Graph) -> dict: 
    state_to_index: dict = {}
    for node in g.nodes:
        state: str = str(node.label)
        state_to_index[state] = node.index
    return state_to_index

这个字典方便我们直接通过状态字符串查找起点和终点的节点索引。比如,字符串 "3,3,L" 对应的起始节点索引是 0,字符串 "0,0,R" 对应目标节点索引是 14。

下面是解决该谜题的搜索代码:

def solve_pg_bfs(): 
  ❶ g: Graph = create_prisoners_and_guards()

  ❷ state_to_index: dict = pg_state_to_index_map(g)
    start_index: int = state_to_index["3,3,L"]
    end_index: int = state_to_index["0,0,R"]

  ❸ last: int = breadth_first_search(g, start_index)

  ❹ current: int = end_index
    path_reversed: list = []
    while current != -1:
        path_reversed.append(current)
        current = last[current]

  ❺ if path_reversed[-1] != start_index:
        print("No solution")
        return

  ❻ for i, n in enumerate(reversed(path_reversed)):
        print(f"Step {i}: {g.nodes[n].label}")

代码首先创建谜题的图表示 ❶,然后建立状态字符串到节点索引的映射字典 state_to_index,通过它查找起点和终点的索引 ❷。

接着用标准的广度优先搜索遍历图,得到 last 列表 ❸。最后,从终点开始向前遍历 last,直到达到起点或者遇到死胡同 ❹。如果遍历时路径未能回到起点,函数打印 “No solution” 表示无解 ❺。否则,程序按正确顺序输出路径上访问的所有状态 ❻。

图 6-8 显示了生成的图及其节点索引。每个节点的标签包含节点索引(上方)和状态字符串(下方)。

image.png

给定生成的谜题图,我们可以直接运行第5章中的广度优先搜索(BFS)。表6-1展示了每次遍历节点后,“last”向量的状态。第一行对应第0次迭代,状态为 <3,3,L>。目标状态 <0,0,R> 在第14次迭代时被访问到。

表6-1:last向量的演变

迭代步骤(节点)33L32R31R22R32L...
0 (3,3,L)-1000-1-1-1-1-1-1-1-1-1-1-1...
1 (3,2,R)-1000-1-1-1-1-1-1-1-1-1-1-1...
2 (3,1,R)-10002-1-1-1-1-1-1-1-1-1-1...
3 (2,2,R)-10002-1-1-1-1-1-1-1-1-1-1...
...
14 (0,0,R)-100024567891011111214

基于广度优先搜索产生的last向量,我们可以追踪出从初始状态到目标状态所需的移动步骤。请记住,广度优先搜索在无权图上返回的是最短路径,因此它找到的解是需要最少移动次数的解决方案。

我们也可以用类似的方法解决河内塔和滑块谜题。每次都从定义状态空间的数据结构和用于生成状态邻居的函数开始。

这为什么重要

本章介绍了如何将图搜索应用于抽象的谜题解题世界。除了本章涉及的相对简单的谜题外,我们还可以通过引入图的丰富结构(包括有向边和带权边)来建模更复杂的问题。例如,本章的谜题使用无向边来表示可逆的移动,而我们也可以用有向边表示不可回退的移动。如果我们要解决的谜题涉及过桥且桥在过一次后会坍塌,我们就无法直接回到桥完好的一侧的状态。同样,带权边允许我们考虑移动的代价。

本章代码还演示了我们无需在开始搜索前生成完整图,而是可以利用搜索过程动态创建图结构。在许多情况下,我们甚至不需要显式构建图结构。定义了状态和转换后,我们可以直接对它们应用图算法,比如广度优先搜索。在本书后续章节中,即使没有显式构建图,我们也将把问题建模称为图问题。

下一章,我们将回到图路径计算的问题,扩展到包含带权边的图,寻找代价最小的路径,超越目前为止介绍的基于搜索的方法。