箱詰めパズル#

12種類の両面ペントミノを特定の形の箱に詰めるパズルです。CP-SATなどのソルバーを使用すれば、簡単に解くことができます。必要な制約条件は add_exactly_one() だけです。本章では、箱詰めパズルの解き方を説明します。

from ortools.sat.python import cp_model
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.cm import tab20
from matplotlib.collections import PatchCollection

ペントミノのリスト#

ペントミノは、位数5のポリオミノです。同じ大きさの正方形を5つ、辺に沿ってつなげた形を指します。回転や鏡映の操作によって同じ形とみなされるものをまとめると、全部で12種類ございます。これらを総称して「ペントミノ」と呼びます。

次のプログラムは、12種類のペントミノを定義し、それらを同じグラフ上に並べて表示します。

pentominos = dict(
F=[" **",    # F
   "** ",
   " * "],
P=["***",    # P
   "** "],
I=["*****"], # I
N=["**  ",   # N
   " ***"],
L=["****",   # L
   "   *"],
T=["***",    # T
   " * ",
   " * "],
U=["* *",    # U
   "***"],
V=["*  ",    # V
   "*  ",
   "***"],
W=["*  ",    # W
   "** ",
  " **"],
X=[" * ",    # X
   "***",
   " * "],
Y=["  * ",   # Y
   "****"],
Z=["** ",    # Z
   " * ",
   " **"]
)
def plot_pentominos(pentominos):
    fig, ax = plt.subplots()
    ax.set_aspect('equal')
    rectangles = []    
    for i, (name, block) in enumerate(pentominos.items()):
        r, c = np.where(block == '*')
        dx, dy = i % 4 * 6, i // 4 * 4
        for x, y in zip(c + dx, r + dy):
            rectangles.append(plt.Rectangle((x, y), 1, 1, facecolor=tab20.colors[i], edgecolor="white"))
        # ax.text(dx + 1, dy - 1, name)
    collection = PatchCollection(rectangles, match_original=True)
    ax.add_collection(collection)
    ax.autoscale_view()
    ax.axis('off')    

def as_array(b):
    return np.array([list(row) for row in b])

pentominos_arr = {key:as_array(val) for key, val in pentominos.items()}
plot_pentominos(pentominos_arr)
_images/cbb17ebe203103a8fb59cbc9aae0bdbc271f62191352f950e3dea5dd25cff56b.png

ペントミノは、2次元のNumPy文字配列として表します。* はブロックがあることを示し、 (空白)はブロックがないことを示します。

pentominos_arr['W']
array([['*', ' ', ' '],
       ['*', '*', ' '],
       [' ', '*', '*']], dtype='<U1')

箱詰めを解くコード#

次の5種類の箱を定義します。箱はNumPyの2次元配列で表し、1 はブロックを配置できる箇所、0 は空白のままにしてよい箇所を示します。

board1 = np.ones((8, 8), dtype=np.uint8)
board1[3:5, 3:5] = 0
board2 = np.ones((3, 20), dtype=np.uint8)
board3 = np.ones((4, 15), dtype=np.uint8)
board4 = np.ones((5, 12), dtype=np.uint8)
board5 = np.ones((6, 10), dtype=np.uint8)

boards = [board1, board2, board3, board4, board5]
print(board1)
[[1 1 1 1 1 1 1 1]
 [1 1 1 1 1 1 1 1]
 [1 1 1 1 1 1 1 1]
 [1 1 1 0 0 1 1 1]
 [1 1 1 0 0 1 1 1]
 [1 1 1 1 1 1 1 1]
 [1 1 1 1 1 1 1 1]
 [1 1 1 1 1 1 1 1]]

ペントミノの配置可能な場所#

次のコードでは、すべてのペントミノが配置可能な場所を算出します。

ペントミノは pentomino[:, ::step] を用いて左右反転し、さらに np.rot90(fliped_pentomino, r) を使用して 90° 回転を4回行うことで、合計8種類の状態を生成します。それらを board のすべての位置に試し、配置に成功した場合は、そのペントミノの番号と配置したマスの座標を記録します。

重複を除去するために set を用いて配置情報を保存し、最後に sorted() を使用してリストに変換します。

from itertools import product
from collections import defaultdict, namedtuple

Location = namedtuple('Location', 'pentomino cells')

def get_all_locations(pentominos, board):
    ymax, xmax = board.shape
    locations = set()
    for i, pentomino in enumerate(pentominos):
        for step in (1, -1):  # 左右反転
            flipped_pentomino = pentomino[:, ::step]
            for r in range(4):  # 90°回転を4回実行
                rotated_tile = np.rot90(flipped_pentomino, r)
                locy, locx = np.where(rotated_tile == "*")
                for y, x in product(range(ymax), range(xmax)):
                    locy2 = locy + y
                    locx2 = locx + x
                    try:
                        if np.all(board[locy2, locx2] == 1):  # 配置可能か確認
                            loc = tuple(sorted(zip(locy2.tolist(), locx2.tolist())))
                            locations.add(Location(i, loc))
                    except IndexError:
                        pass  # 配置がボード外に出た場合はスキップ

    return sorted(locations)

次のコードでは、0番のペントミノの最初の5つの配置場所を表示します。

locations = get_all_locations(pentominos_arr.values(), boards[0])
for i in range(5):
    print(locations[i])
Location(pentomino=0, cells=((0, 0), (0, 1), (1, 1), (1, 2), (2, 1)))
Location(pentomino=0, cells=((0, 0), (1, 0), (1, 1), (1, 2), (2, 1)))
Location(pentomino=0, cells=((0, 1), (0, 2), (1, 0), (1, 1), (2, 1)))
Location(pentomino=0, cells=((0, 1), (0, 2), (1, 2), (1, 3), (2, 2)))
Location(pentomino=0, cells=((0, 1), (1, 0), (1, 1), (1, 2), (2, 0)))

解を求める#

次の solve_board() 関数を使用して、箱詰めパズルの解を求めます。まず、locations リストから次の2つの辞書を作成します。

  • pentomino_locations:各ペントミノに対して、配置可能な場所のインデックスを対応付けた辞書。

  • cell_locations:各マスに対して、そのマスを含む配置のインデックスを対応付けた辞書。

さらに、locations のすべての要素に対応するブール変数 flags を作成します。flags の値が True の場合、その Location は有効であることを意味します。

これらの pentomino_locationscell_locations に基づき、次の2つの制約条件を設定します。

  1. ペントミノの使用回数の制約pentomino_locations

    • 各ペントミノは1回だけ使用できるようにします。

  2. マスの充填制約cell_locations

    • 各マスは1つのペントミノでのみ埋めることができます。

この制約を model.add_exactly_one()を用いて設定し、最適な解を求めます。

def solve_board(board):
    locations = get_all_locations(pentominos_arr.values(), board)
    
    # pentomino -> location index
    pentomino_locations = defaultdict(list)
    # cell_id -> location index
    cell_locations = defaultdict(list)
    
    for idx, (tile_id, location) in enumerate(locations):
        pentomino_locations[tile_id].append(idx)
        for loc in location:
            cell_locations[loc].append(idx)
    
    model = cp_model.CpModel()
    
    flags = np.array([model.new_bool_var("b{}".format(i)) for i in range(len(locations))])
    
    for d in (pentomino_locations, cell_locations):
        for index in d.values():
            selected_flags = flags[index].tolist()
            model.add_exactly_one(selected_flags)
    
    solver = cp_model.CpSolver()
    solver.solve(model)
    return [locations[i][1] for i, flag in enumerate(flags) if solver.value(flag)]

def plot_solution(solution):
    h, w = np.array(solution).reshape(-1, 2).max(axis=0) + 1
    fig, ax = plt.subplots(figsize=(w * 0.25, h * 0.25))
    ax.set_aspect('equal')

    rectangles = []
    
    for i, block in enumerate(solution):
        for y, x in block:
            rect = plt.Rectangle((x, y), 1, 1, facecolor=tab20.colors[i],edgecolor='white')
            rectangles.append(rect)
            
    collection = PatchCollection(rectangles, match_original=True)
    ax.add_collection(collection)
    ax.autoscale_view()
    ax.invert_yaxis()
    ax.axis('off')
    return fig    

次に、すべての箱についてsolve_board()を実行し、それぞれの解を求め、グラフにします。

for board in boards:
    solution = solve_board(board)
    plot_solution(solution)
_images/9d4eba2055ce4eeba950556d2866d5fea2a99d3930181c935ed8103c99919166.png _images/a67639f6e76fbae6001c7a94fd2ee70bfe33445ad1f3bc9bc8f025128580327d.png _images/641d27ad60d21aab70b296f393d858b70a729153b726ec46e3a52cb66563bc04.png _images/942f75a0a6a171db899b537e284a194b8910cd5c0268faeb78c543394dd05f31.png _images/55953f4a96d0ba8c8c690216d8d9094d63483091c10c4c887abfaaf73a8c20fe.png