迷路を自動で作って、自動で解く、そんなプログラムを作りたいと前々から思っていました。

試行錯誤しながら作ってみると、なんと、作るのも解くのも同じアルゴリズムでできることが分かりました。

youtube 動画にもupしました。こちらは迷路のサイズを変えながら、迷路を作って、それを解く、を繰り返します。

プログラムは、こちら
https://github.com/itoshin-tech/maze

迷路を生成する方法

それでは迷路を作る方法を説明します。まず、fieldという2次元の配列変数を考え、ここに道を作っていくとします。

fieldの要素は初めは全て0です。0をブランクと呼ぶことにします。ここに、道のid(2, 3, 4…)で書き換えることで道を作っていきます。idが2からなのは、迷路を解く方法と整合性を得るためで深い意味はありません。

まず、スタート地点をランダムに決め、その要素を2にします。そして、上下左右方向に一つ飛ばした先のセルを調べます。セルがブランク(0)であれば、そちらに道を伸ばすことができます。進める方向が複数ある場合には、ランダムで1つの方向を選びます。

選んだ方向に進みます。伸ばした道にはidである2を代入します。ここからまた同様に、4方向を調べ、ブランクの方向に道を伸ばします。

これを繰り返しながら道を伸ばしていきますが、いずれ進めなくなります。

進めなくなったら、作ってきた道で、分岐方向を選んできた座標を確認します(オレンジの枠)。そして、矢印で示した(x=1, y=5)の位置から分岐を伸ばそうと考えます。

ここで少しプログラムのことを考えます。

作る関数は、以下のようなfill_with_road()という関数です。この関数は、(x, y)の地点から道を伸ばし、0の領域を道で満たす、という働きを想定しています。

はじめから考えて、x=3, y=3 のスタート地点をセットして、fill_with_road(x=3, y=3, id)を実行したと想定します。そして、はじめの一本道を伸ばし終えて、(x=1, y=5)から分岐を伸ばすフェーズになっているとします。まだ、fill_with_road(x=3, y=3, id)の中です。

(x=1, y=5)から分岐を作るために、この箇所をスタート地点としたfill_with_road(x=1, y=5, id=id+1)を実行します。idは今のidから一つ増やすします(id を変えるのは単にアルゴリズムの動きを知るためです)。しつこいようですが、これはfill_with_road(x=3, y=3, id)の中で実行します。

同様に、fill_with_road(x=3, y=3, id)の中で、全ての分岐ができる可能性のあるオレンジの箇所から、fill_with_roadを実行します。

これでアルゴリズムの概要は終わりです。

このようにすることで、分岐の先でも、さらなる分岐が起き、その先でも分岐を起こすことができるのです。理論上は、何段階でも分岐を起こすことができるのです。

ややこしいのですが面白いですよね。このように、関数の中で自分自身の関数を呼ぶ方法を再帰と呼びます。

今の例の続きを考えると、3の道が作られ、更に下のオレンジの枠で分岐が起こり、4の道が伸びていきます。ここで新しい道を作るスペースがなくなるので迷路が完成します。

あとは、field の要素で1以上の部分を全て1に直して迷路が完成です。

1を迷路の道、0を壁と解釈します。スタート地点とゴール地点はどこにおいても成り立ちます。

実は、迷路を解くときにもfill_with_road()が使えます。1の部分をbrankと解釈し、そこに道を充填していけばよいのです。

迷路を解く方法

迷路を解く方法です。左上がスタート、右下がゴールだとします。今度は、1をブランクとします。1の上にid=2, 3, 4, … の道を作っていくことを考えます。

迷路生成時ではブランクの空間は広い長方形でしたが、今回のブランクは迷路のくねくねした廊下の形状です。しかし、fill_with_roadは、単に隙間があったらそこに道を伸ばしていくアルゴリズムなので、ブランクの形状には関係なく動作します。

よって、ここでも、迷路生成時とほとんど同じアルゴリズムで道を伸ばしていけば、いつかゴールにまで道(?)が到達します。

ただし、前回使ったfill_with_roadの以下の部分だけ、変更します。

隣り合った4方向でbrank=1の方向に道を進めます。行ける方向が分かったら、2マス進めます。この要領で道を長くしていきます。

結果、以下のような行き止まりにたどり着きました。ここからは、方向を決定した箇所全てにおいて分岐を試します。

分岐をするときにはidに1を加えるとすれば、以下のような道が生成され、ゴールにたどり着くことができます。

以上で迷路の作り方・解き方の説明は終わりです。読んでいただきありがとうございました。

プログラムは github にもupload しています。
https://github.com/itoshin-tech/maze

ファイルは1つなので以下にも出しておきます。

"""
迷路生成、迷路解きプログラム
"""
import numpy as np
import random
import cv2
import matplotlib.pyplot as plt
import seaborn as sns
import sys


class MazeCreater():
    """
    迷路を作るクラス
    """
    def __init__(self, size_w=10, size_h=5):
        """
        Parameters
        ----------
        size_w: int
            迷路の横サイズ(分岐できる点の数)
        size_h: int
            迷路の縦サイズ
        """

        self.size_w = size_w
        self.size_h = size_h

        # 道生成クラスのインスタンス生成
        # 引数 max_col は画像出力時の色の数
        self.roadfiller =RoadFiller(
            max_col=size_w,
            )
    
    def generate_maze(self, is_show=True, unit=10, delay=100):
        """
        迷路を生成する

        内部で使用するRoadFillerクラスが、実際に迷路を生成している

        Parameters
        ----------
        unit: int
            画像生成時のセルサイズ
        delay: int
            アニメーション時の遅延(ms)

        Returns
        -------
        field_out: 2d numpuy.array
            生成した迷路
            0が壁
            1が通路
        """

        # field のサイズ
        n_w = 2 * self.size_w + 1
        n_h = 2 * self.size_h + 1

        # field をブランク0で満たす
        field = np.zeros((n_h, n_w), dtype=np.uint8)

        # 道の開始時のid (画面表示の都合上2から開始)
        road_id = 2

        # スタート地点をランダムで決める
        x = 2* np.random.randint(0, self.size_w) + 1
        y = 2* np.random.randint(0, self.size_h) + 1

        # 道の生成
        field = self.roadfiller.fill_with_road(
            field, x, y, id=road_id, mode='create',
            is_show=is_show,
            delay=delay, unit=unit,
            )
        
        # 道のid を全て1に変換して出力
        field_out = field.copy()
        field_out[field > 0] = 1

        return field_out


class MazeSolver:
    """
    迷路を解くクラス
    """
    def __init__(self, field, start, goal):
        self.field = field.copy()
        self.start_xy = start
        self.goal_xy = goal
        self.xy = self.start_xy
        max_col = int(self.field.shape[1] / 2)
        self.roadfiller =RoadFiller(
            max_col=max_col,
            goal_xy=goal,
            start_xy=start,
            )

    def solve_maze(self, is_show=True, unit=10, delay=100):
        """
        通路を0、壁を1とする
        通路に沿って進む、分岐点に来たらゴールが近くなる方を選ぶ

        迷路生成クラスと同じ、RoadFillerクラスを内部で使用

        Parameters
        ----------
        unit: int
            画像生成時のセルサイズ
        delay: int
            アニメーション時の遅延(ms)

        Returns
        -------
        field_out: 2d numpuy.array
            解いた迷路
            0が壁
            1が通路
            2以上が通った軌跡
        """

        field = self.field.copy()
        x, y = self.start_xy

        # 道のidは2から開始
        road_id = 2
        field[y, x] = road_id

        # 道の生成ds
        field_out = self.roadfiller.fill_with_road(
            field, x, y, id=road_id, mode='solve',
            is_show=is_show,
            delay=delay, unit=unit,
            )
        return field_out
   
        
class RoadFiller:
    """
    指定したidの領域を、道を分岐させながら道で満たすクラス
    """
    def __init__(self, max_col=10, mode='create', goal_xy=None, start_xy=None):
        self.render = Render(
            max_col=max_col, mode=mode, 
            goal_xy=goal_xy, start_xy=start_xy,
            )
        self.reach_goal = False
        self.goal_xy = goal_xy
        self.start_xy = start_xy

    def fill_with_road(
        self, field, x, y, id=1, mode='create',
        is_show=True, delay=100, unit=10,
        ):
        """
        0の領域を道で満たす
        迷路を生成する本体
        迷路を解くときにも使用

        Parameters
        ----------
        field: 2d numpy.array
            迷路のフィールド
            0の領域に道が作られる
        x, y: int
            スタート地点
        id: int
            作る道のid 
        mode: str
            'create': 迷路を作る
            'solve': 迷路を解く
        is_show: bool
            True: アニメーションを出す
        
        Returens
        --------
        field: 2d numpy.array
            道が生成されたフィールド
        """

        # ゴールに到達したら終了
        if self.reach_goal is True:
            return field

        self.field = field.copy()
        self.delay = delay
        self.unit = unit
        
        # 開始地点が0なら id とする
        if self.field[y, x] == 0:
            self.field[y, x] = id

        # xy の履歴 (B)で使用
        xy_history = [(x, y)]

        # (A)
        while True:
            # x, y の周囲を調べて、道が伸ばせるならば伸ばす
            res, x, y, fields = self.extend_road(
                self.field, x, y, id, mode=mode,
                )
            self.field = fields[-1]

            if res == 'stretched':
                # 道を伸ばした場合は、xy を記録して繰り返す
                xy_history.append((x, y))

                # 描画
                if is_show is True:
                    if self.delay > 50:
                        for ff in fields:
                            self.render.draw(
                                ff, delay=self.delay, unit=self.unit,
                                )
                    else:
                        self.render.draw(
                            fields[-1], delay=self.delay, unit=self.unit,
                            )

                # ゴール判定
                if mode == 'solve':
                    if x == self.goal_xy[0] and y == self.goal_xy[1]:
                        # ゴールに辿り着いたら終了
                        self.reach_goal = True
                        return self.field

                continue

            # 行き止まりに来たら
            # ループから抜けて(B)へ
            if res == 'deadend':
                break
        
        # (B)
        # 履歴を一つずつもどった地点から、行き止まりまで道を伸ばす
        xy_history.pop(-1)
        xy_history.reverse()
        for xy in xy_history:
            x = xy[0]
            y = xy[1]
            self.fill_with_road(
                self.field, x, y, id=id + 1, mode=mode,
                is_show=is_show,
                delay=self.delay, unit=self.unit,
                )
        
        return self.field

    def extend_road(
        self, field, x, y, id, mode='create',
        ):
        """
        fieldの(x,y)を中心として、
        上下左右の4方向を調べ、
        id_brank があったら道を伸ばす
        'create'のときid_brank = 0
        'solve'のときid_brank = 1

        Parameters
        ----------
        field: 2d numpy.array
            フィールド
        x, y: int
            開始地点
        id: int
            道のid
        mode: str
            'create': 迷路を作る
            'solve': 迷路を解く
        
        Returens
        --------
        res: str
            'stretched': 道を伸ばした
            'deaded': 行き止まりだった
        next_x, next_y: int
            'streatched' で伸ばした先の座標
        fields: list of 2d numpy.array
            段階的に道を伸ばしたときのfieldのリスト
            (アニメーション用)
        """
        if mode == 'create':
            id_brank = 0
        elif mode == 'solve':
            id_brank = 1
        else:
            raise ValueError('modeが違います')

        # 4方向のx, y の増加分
        dd = [
            (1, 0),
            (-1, 0),
            (0, 1),
            (0, -1),
        ]

        # 変数準備        
        n_h, n_w = field.shape[:2]
        next_field = field.copy()
        pre_field = field.copy()

        # 4方向の状態を調べる
        dd_res = [''] * 4 # 結果の格納変数
        for id_dir in range(4):
            x1 = x + dd[id_dir][0] * 2
            y1 = y + dd[id_dir][1] * 2
            x0 = x + dd[id_dir][0]
            y0 = y + dd[id_dir][1]

            if x1 < 0 or n_w <= x1:
                # 横方向のはみ出し
                dd_res[id_dir] = 'out'
                continue

            if y1 < 0 or n_h <= y1:
                # 縦方向のはみ出し
                dd_res[id_dir] = 'out'
                continue

           
            if (mode == 'create' and field[y1, x1] == id_brank) or \
                (mode == 'solve' and field[y0, x0] == id_brank): 
                # ブランク(道を伸ばせる)
                dd_res[id_dir] = 'brank'
                continue
                

            # 道を伸ばせない
            dd_res[id_dir] = 'no'

        # ブランクあったらそこに道を伸ばす
        ids_dir = [i for i, x in enumerate(dd_res) if x == 'brank']
        if len(ids_dir) > 0:
            res = 'stretched'
            if mode == 'create':
                # ランダムで選ぶ
                id_dir = random.sample(ids_dir, 1)[0]
            elif mode == 'solve':
                # ゴールに近くなる方を選ぶ
                dist = []
                for i, id_dir in enumerate(ids_dir):
                    x0 = x + dd[id_dir][0]
                    y0 = y + dd[id_dir][1]
                    dist.append((x0 - self.goal_xy[0])**2 + (y0 - self.goal_xy[1]) ** 2)
                iid = dist.index(min(dist)) # 最小の要素のindex を返す
                id_dir = ids_dir[iid]
            else:
                raise ValueError('modeが違います')

            x1 = x + dd[id_dir][0] * 2
            y1 = y + dd[id_dir][1] * 2
            x0 = x + dd[id_dir][0]
            y0 = y + dd[id_dir][1]
            pre_field[y0, x0] = id  # アニメーション用の途中状態
            next_field[y1, x1] = id
            next_field[y0, x0] = id # 最終的な状態
            next_x = x1
            next_y = y1
            fields = [pre_field, next_field]

            return res, next_x, next_y, fields

        # 行き止まり
        res = 'deadend'
        next_x = None
        next_y = None
        fields = [pre_field, next_field]
        return res, next_x, next_y, fields


class Render():
    """
    画像生成、表示
    """
    def __init__(
        self, max_col=10, 
        goal_xy=None, start_xy=None, mode='create',
        ):
        self.max_col = max_col # 色の種類の上限
        self.goal_xy=goal_xy
        self.start_xy=start_xy
        self.mode=mode

        self.colorpalette = sns.color_palette(
            "hls", n_colors = self.max_col,
            )

    def draw(
        self,
        field,
        unit=10,
        is_show=True, delay=0,
        unicol=None,
        start_xy=None,
        goal_xy=None,
        ):
        """
        迷路の画像生成
        """

        if start_xy is not None:
            self.start_xy = start_xy
        if goal_xy is not None:
            self.goal_xy = goal_xy
    
        # field をunit分拡大
        val = field
        max_val = np.max(val)
        val = val.astype(dtype=np.uint8)
        val = cv2.resize(
            val,
            dsize=(0, 0),
            fx=unit, fy=unit,
            interpolation=cv2.INTER_NEAREST,
            )
        
        #--- for video fix size
        """
        val_bak = np.ones((440, 840), dtype=np.uint8) * 80
        h, w = val.shape[:2]
        val_bak[:h, :w] = val
        val = val_bak
        """
        # ----

        img_r = val.copy()
        img_g = val.copy()
        img_b = val.copy()

        # 道をid毎に色を付けて描画
        for v in range(max_val + 1):
            if self.mode == 'create':
                if v == 0: # ブランク(道を伸ばす)
                    col = (80, 80, 80)
                elif v == 1: # 壁(道を伸ばす)
                    col = (255, 255, 255)
                else:
                    ic = v % self.max_col
                    col = np.array(self.colorpalette[ic]) * 255
            elif self.mode == 'solve':
                if v == 0: # 壁
                    col = (80, 80, 80)
                elif v == 1: # ブランク
                    col = (255, 255, 255)
                else:
                    ic = v % self.max_col
                    col = np.array(self.colorpalette[ic]) * 255
            else:
                raise ValueError('mode が違います')

            img_r[val == v] = col[0]
            img_g[val == v] = col[1]
            img_b[val == v] = col[2]

        h, w = val.shape
        img = np.zeros((h, w, 3), dtype=np.uint8)
        img[:, :, 0] = img_b
        img[:, :, 1] = img_g
        img[:, :, 2] = img_r

        # スタート地点の目印
        if self.start_xy is not None:
            x = int(self.start_xy[0] * unit + unit / 2)
            y = int(self.start_xy[1] * unit + unit / 2)
            r = int(0.45 * unit)
            col = (100, 100, 200)
            img = cv2.circle(img, (x, y), r, col, -1)

        # ゴール地点の目印
        if self.goal_xy is not None:
            x = int(self.goal_xy[0] * unit + unit / 2)
            y = int(self.goal_xy[1] * unit + unit / 2)
            r = int(0.45 * unit)
            col = (100, 200, 100)
            img = cv2.circle(img, (x, y), r, col, -1)

        # アニメーション表示
        if is_show:
            cv2.imshow('img', img)
            INPUT = cv2.waitKey(delay) & 0xFF
            if INPUT == ord('q'):
                sys.exit()

        return img


if __name__ == '__main__':

        prms = {
            0: (10, 5, 40, 100),
            1: (20, 10, 20, 50),
            2: (40, 20, 10, 1),
        }

        # for video
        """
        cv2.imshow('img', np.ones((440, 840, 3), dtype=np.uint8) * 80)
        cv2.waitKey(0)
        """

        for i in range(100):
            # パラメータの取得
            w, h, u, d = prms[i % len(prms)]

            # 迷路生成クラスのインスタンス生成
            maze = MazeCreater(size_w=w, size_h=h)

            # 迷路をアニメーションさせながら生成
            field = maze.generate_maze(is_show=True, unit=u, delay=d)

            # スタートとゴール地点を作成
            f_h, f_w = field.shape[:2]
            start_xy = (1, 1)
            goal_xy = (f_w - 2, f_h - 2)

            # 完成した迷路を表示
            img = maze.roadfiller.render.draw(
                field, unit=u, delay=1000, unicol=(255, 255, 255),
                start_xy=start_xy, goal_xy=goal_xy,
                )

            # 迷路解きのインスタンス生成
            solver = MazeSolver(
                field, start=start_xy, goal=goal_xy)
            # 迷路をアニメーションさせながら解く
            map = solver.solve_maze(is_show=True, unit=u, delay=d)
            # 解いた迷路を表示
            solver.roadfiller.render.draw(map, unit=u, delay=1000)