珍珠 Masyu:用边写出圆圈的约束

珍珠 Masyu:用边写出圆圈的约束#

本章的内容#

珍珠是让一条回路穿过白圆与黑圆的谜题,回路的形状可以用 add_circuit() 造出,而圆的前后要转弯还是直行,这一条件需要自己写。

  • 把有向边,即回路用的边,与无向边,即约束用的边对应起来

  • 用横线条数减竖线条数判断是否转弯

  • 白圆直行、黑圆转弯,黑圆前后相邻的格又不转弯

圆的条件写成约束之后,判断与画线都交给求解器。

规则#

  1. 在盘面上画线,整体构成一个环。

  2. 线经过格的中央,不交叉也不分叉。

  3. 线要穿过所有白圆与黑圆。

  4. 穿过白圆的线,在白圆所在的格必定直行,并在相邻的格直角转弯,任一侧转弯即可。

  5. 穿过黑圆的线,在黑圆所在的格必定直角转弯,而在相邻的格不转弯。

规则里的“白圆直行、黑圆转弯”是唯一需要自己翻译的部分,其余都由 add_circuit() 负责。

珍珠谜题的网站

https://ja.puzzle-masyu.com/

用 helper.puzzle.extract_masyu() 可以取得这个网站上的谜题盘面。打开浏览器的开发者工具,把表示盘面的 <div> 标签的代码复制下来,交给这个函数执行,盘面就会以数组的形式取得。

盘面#

1 是白圆,2 是黑圆,0 是什么都没有的格。盘面以二维数组给出,数字与圆的对应只由这三个取值定义。

from ortools.sat.python import cp_model
import numpy as np

puzzle = np.loadtxt('data/masyu01.txt', dtype=np.uint8)
print(puzzle)
[[0 1 0 0 1 0 0 2]
 [0 0 0 0 0 1 0 0]
 [0 0 2 0 0 1 0 0]
 [0 0 1 0 1 1 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 1 0 0]
 [0 2 0 0 1 0 0 0]
 [0 0 0 1 0 0 0 2]]

思路#

回路要穿过所有圆,因此可以用 add_circuit();对于回路不经过的格,则加上自环 (i, i)。

add_circuit() 的边是有向的。相邻的格 a 与 b 用 a → b 与 b → a 两条边连接,回路只选其中一条。

另一方面,圆的条件,即转弯与直行,希望不区分方向来写,因此由有向边 e1、e2 造出无向边 e。

model.add(e == e1 + e2)   # 只要其中一条为真,无向边就“有线段”

这样,圆所在格周围的线就可以按上下左右四条无向线处理。无向边的真假由两条有向边决定,转换只在一处发生。

对圆所在的格,把一格之外的线记为 \(U_1, D_1, L_1, R_1\),两格之外的线记为 \(U_2, D_2, L_2, R_2\)。

是否转弯可以用下面的式子判断。

\[ L_1 + R_1 - U_1 - D_1 \]
  • 直行,左右:\(1+1-0-0 = 2\)

  • 直行,上下:\(0+0-1-1 = -2\)

  • 直角,例如左加上:\(1+0-1-0 = 0\)

也就是等于 0 表示转弯,不等于 0 表示直行,这个式子只看四条边的真假,不必分方向讨论。

圆

条件

白圆

\(L_1 + R_1 - U_1 - D_1 \neq 0\),即直行

黑圆

\(L_1 + R_1 - U_1 - D_1 = 0\),即直角

黑圆还要求相邻的格不转弯,因此要加上 \(L_1 \rightarrow L_2\) 这样的蕴含,即从黑圆引出的线在下一格直行。四个方向各要一条这样的蕴含,黑圆前后的格才都保持直行。

def solve_masyu(puzzle):
    """求解珍珠谜题,返回用到的有向边列表"""
    h, w = puzzle.shape
    nodes = np.arange(h * w).reshape(h, w)

    # 有向边(用于回路)和无向边(用于圆的条件)
    directed_edges = []
    edges = []
    for (r, c), n1 in np.ndenumerate(nodes):
        if c + 1 < w:
            n2 = nodes[r, c + 1]
            directed_edges.extend([(n1, n2), (n2, n1)])
            edges.append((n1, n2))
        if r + 1 < h:
            n2 = nodes[r + 1, c]
            directed_edges.extend([(n1, n2), (n2, n1)])
            edges.append((n1, n2))

    model = cp_model.CpModel()
    directed_edge_variables = {e: model.new_bool_var(f'dedge_{e}') for e in directed_edges}
    edge_variables = {e: model.new_bool_var(f'edge_{e}') for e in edges}

    # 没有圆的方格可以脱离回路(自环)
    dummy_variables = {(i, i): model.new_bool_var(f'dummy_{i}')
                       for (r, c), i in np.ndenumerate(nodes) if puzzle[r, c] == 0}

    model.add_circuit(
        [(n1, n2, v) for (n1, n2), v in directed_edge_variables.items()] +
        [(n1, n2, v) for (n1, n2), v in dummy_variables.items()])

    # 无向边 = 两条有向边中的一条
    for (n1, n2), b in edge_variables.items():
        model.add(b == directed_edge_variables[n1, n2] + directed_edge_variables[n2, n1])

    directs = dict(u=(-1, 0), d=(1, 0), l=(0, -1), r=(0, 1))

    for (r, c), num in np.ndenumerate(puzzle):
        node = nodes[r, c]
        step1 = {}
        step2 = {}
        for name, (dr, dc) in directs.items():
            r1, c1 = r + dr, c + dc
            r2, c2 = r + dr * 2, c + dc * 2
            if 0 <= r1 < h and 0 <= c1 < w:
                n1, n2 = sorted([node, nodes[r1, c1]])
                step1[name] = edge_variables[n1, n2]
                if 0 <= r2 < h and 0 <= c2 < w:
                    n1, n2 = sorted([nodes[r1, c1], nodes[r2, c2]])
                    step2[name] = edge_variables[n1, n2]

        # 通向盘外的方向当作 0(没有线)
        l, r_, u, d = [step1.get(direction, 0) for direction in 'lrud']
        expr = l + r_ - u - d

        if num == 1:      # 白圆:直行
            model.add(expr != 0)
        elif num == 2:    # 黑圆:直角
            model.add(expr == 0)
            for direction in "lrud":
                if direction in step2:
                    # 从黑圆出来的线,在下一格不转弯
                    model.add_implication(step1[direction], step2[direction])
                elif direction in step1:
                    # 两格之外在盘外,就不能往这个方向走
                    model.add_bool_and(~step1[direction])

    solver = cp_model.CpSolver()
    if solver.solve(model) not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        raise ValueError("没有找到解")

    return [key for key, v in directed_edge_variables.items() if solver.value(v)]

绘制#

画出盘面与回路,圆与线的位置要与盘面对齐。

from matplotlib import pyplot as plt
from matplotlib.collections import LineCollection


def plot_masyu_board(puzzle, route):
    """画出盘面和回路"""
    h, w = puzzle.shape
    fig, ax = plt.subplots(figsize=(w * 0.3, h * 0.3))

    segments = []
    for n1, n2 in route:
        r1, c1 = n1 // w, n1 % w
        r2, c2 = n2 // w, n2 % w
        segments.append([(c1, r1), (c2, r2)])
    ax.add_collection(LineCollection(segments, color='black'))

    r, c = np.where(puzzle == 1)
    ax.scatter(c, r, s=100, marker='o', facecolor="none", edgecolor="black")
    r, c = np.where(puzzle == 2)
    ax.scatter(c, r, s=100, marker='o', facecolor="black", edgecolor="black")

    ax.set_aspect('equal')
    ax.invert_yaxis()
    ax.axis('off')
    return fig, ax

简单的盘面#

puzzle = np.loadtxt('data/masyu01.txt', dtype=np.uint8)
route = solve_masyu(puzzle)
plot_masyu_board(puzzle, route);
_images/436e151af744cdb83aa65eb6a79145a8e901e023fb91bde2bb79eab61049176a.png

最难的盘面#

puzzle = np.loadtxt('data/masyu02.txt', dtype=np.uint8)
route = solve_masyu(puzzle)
plot_masyu_board(puzzle, route);
_images/403f6c3c42cf9200ddb9450d30a3d03a30be4cce00e44089c9b70304e1b3e832.png

验算#

确认解出的回路是否满足各个圆的条件,验算时把每个圆的类型与通过方向逐一对照。

def check_masyu(puzzle, route):
    """确认圆所在的格是否直行或直角,以及回路是否只有一条"""
    h, w = puzzle.shape
    adj = {}
    for n1, n2 in route:
        adj.setdefault(n1, []).append(n2)
        adj.setdefault(n2, []).append(n1)

    def step(n1, n2):
        r1, c1 = divmod(n1, w)
        r2, c2 = divmod(n2, w)
        return r2 - r1, c2 - c1

    # 1. 是否经过圆所在的格,并满足条件
    for (r, c), v in np.ndenumerate(puzzle):
        if v == 0:
            continue
        node = r * w + c
        if node not in adj or len(adj[node]) != 2:
            return False, f"圆 {(r, c)} 没有被经过"
        d1, d2 = (step(node, adj[node][0]), step(node, adj[node][1]))
        horizontal = d1[0] == 0 and d2[0] == 0
        vertical = d1[1] == 0 and d2[1] == 0
        if v == 1 and not (horizontal or vertical):
            return False, f"白圆 {(r, c)} 处不是直行"
        if v == 2 and (horizontal or vertical):
            return False, f"黑圆 {(r, c)} 处不是直角"

    # 2. 回路是否只有一条
    start = next(iter(adj))
    prev, cur, count = None, start, 0
    while True:
        count += 1
        nxt = adj[cur][0] if adj[cur][0] != prev else adj[cur][1]
        prev, cur = cur, nxt
        if cur == start:
            break
    if count != len(adj):
        return False, "回路不是一条"
    return True, f"{count} 个方格组成的一条回路"


print(check_masyu(puzzle, route))
(True, '544 个方格组成的一条回路')

小结#

  • 回路用 add_circuit(),不经过的格用自环 (i, i) 表示

  • 把两条有向边用 e == e1 + e2 合成无向边之后,圆的条件就容易写了

  • 是否转弯可以用 L + R - U - D 是否等于 0 判断

  • 黑圆还要求相邻的格不转弯,这条约束要用 add_implication() 明确写出

  • 通向盘外的方向按 0 处理,即视为没有线,代入式子即可