M.Hiroi's Home Page
http://www.geocities.jp/m_hiroi/

Common Lisp Programming

Common Lisp 入門:番外編

[ PrevPage | Common Lisp | NextPage ]

フリップ・イット (Flip It)

Common Lisp 入門 の番外編です。今回はパズル「フリップ・イット」の解法プログラムを Common Lisp で作ってみましょう。なお、このドキュメントは拙作のページ Memorandum で取り上げたパズル「フリップ・イット」をまとめたものです。内容は重複していますが、ご了承くださいませ。

●パズルの説明

「フリップ・イット (Flip It)」は芦ヶ原伸之氏が考案されたパズルで、すべての駒を裏返しにするのが目的です。今回はリバーシの駒を使うことにしましょう。次の図を見てください。

  0  1  2  3  4  5        0  1  2  3  4  5
┌─┬─┬─┬─┬─┬─┐    ┌─┬─┬─┬─┬─┬─┐  
│  │●│●│●│●│●│    │●│○│○│○│○│  │  
└─┴─┴─┴─┴─┴─┘    └─┴─┴─┴─┴─┴─┘  
                      │                │
  ┌─────────┘                └─────┐
  ↓                                                ↓
┌─┬─┬─┬─┬─┬─┐    ┌─┬─┬─┬─┬─┬─┐  
│●│○│○│○│○│  │    │●│○│  │●│●│○│  
└─┴─┴─┴─┴─┴─┘    └─┴─┴─┴─┴─┴─┘  
  5の駒が0へ跳んだ場合        2の駒が5へ跳んだ場合

            図:フリップ・イットのルール

フリップ・イットのルールは簡単です。ある駒は他の駒を跳び越して空き場所へ移動することができます。空き場所の隣にある駒は、跳び越す駒がないので移動できません。このとき、跳び越された駒は裏返しにされますが、跳んだ駒はそのままです。図では 5 の位置にある駒が 0 へ跳び、それから 2 の駒が 5 へ跳んだ場合を示しています。このあと 0 -> 2, 5 -> 0 と跳ぶと、すべての駒を白にすることができます。それでは問題です。

    ┌─┬─┬─┬─┬─┬─┐  
(A) │●│●│  │●│●│●│  
    └─┴─┴─┴─┴─┴─┘  
    ┌─┬─┬─┬─┬─┬─┬─┐  
(B) │●│  │○│●│●│●│●│  
    └─┴─┴─┴─┴─┴─┴─┘  
    ┌─┬─┬─┬─┬─┬─┬─┬─┐  
(C) │●│  │○│○│○│●│●│●│  
    └─┴─┴─┴─┴─┴─┴─┴─┘  

    問題:フリップ・イット

参考文献 [1] の問題は 4 つの駒を使っているので、ここでは駒の個数を増やしてみました。すべての駒を白にする最短手順を求めてください。

-- 参考文献 --------
[1] 芦ヶ原伸之,『ブルーバックス B-1377 超々難問数理パズル 解けるものなら解いてごらん』, 講談社, 2002

●プログラムの作成

それではプログラムを作りましょう。アルゴリズムは単純な反復深化を使います。盤面は Lisp らしくリストで表しましょう。要素はシンボルで、nil が黒、t が白、space が空き場所を表します。

最初に、駒を動かして新しい盤面を生成する関数 move-piece を作ります。次のリストを見てください。

リスト:駒の移動

(defun move-piece (n board piece start end)
  (cond ((null board) nil)
        ((or (= n start) (= n end))
         (cons (if (eq (car board) 'space) piece 'space)
               (move-piece (1+ n) (cdr board) piece start end)))
        ((< start n end)
         (cons (not (car board))
               (move-piece (1+ n) (cdr board) piece start end)))
        (t (cons (car board)
                 (move-piece (1+ n) (cdr board) piece start end)))))

引数 n が盤面の位置、board が盤面、piece が移動する駒の種類、start と end (start < end) が移動する駒の位置と空き場所の位置です。move-piece は board をコピーするとともに、start と end の間の駒を裏返しにして、start と end の位置にある piece と space を入れ替えます。駒の裏返しは not を使えば簡単です。

次は、反復深化で最短手順を探索する関数 solve-id を作ります。

リスト:反復深化による探索

(defun solve-id (n limit board space history)
  (if (= limit n)
      (when (zerop (count nil board))
        (print-answer board history)
        (throw 'find-answer t))
      (dotimes (x (length board))
        (when (and (not (eql (cdar history) x))
                   (or (< x (1- space)) (< (1+ space) x)))
          ; 移動可能
          (solve-id (1+ n)
                    limit
                    (move-piece 0 board (nth x board) (min x space) (max x space))
                    x
                    (cons (cons x space) history))))))

引数 n が手数、limit が反復深化の上限値、board が盤面、space が空き場所の位置、history が移動手順を表すリストです。history の要素はドット対 (動かす駒の位置 . 空き場所の位置) です。

手数 n が上限値 limit になったならば、駒がすべて白になったかチェックします。関数 count で nil の個数を数え、その値が 0 であれば黒の駒はありません。関数 print-answer で手順を表示してから、throw で大域脱出して探索を終了します。

フリップ・イットは、同じ駒を続けて動かすと元の状態に戻ってしまいます。そこで、動かす駒の位置 x が 1 手前の空き場所の位置 (cdar history) と同じ場合は、その駒を動かさないようにします。history の初期値は nil なので、比較には eql を使っています。このチェックがないと実行時間がとても遅くなります。ご注意くださいませ。

それから、フリップ・イットのルールでは、空き場所の隣の駒は動かすことができません。この条件を (or (< x (1- space)) (< (1+ space) x)) でチェックしています。ルールを「空き場所の隣の駒を動かしてもよい」ことに変更する場合は、(or ... ) の部分を (/= x space) に修正してください。

最後に、solve-id を呼び出す関数 flip-it-solver と手順を表示する関数 print-answer を作ります。

リスト:「フリップ・イット」解法プログラム

; 盤面を表示
(defun print-board (board)
  (let ((code '((nil . "●") (t . "○") (space . "_"))))
    (dolist (piece board (terpri))
      (format t "~A " (cdr (assoc piece code))))))

; 手順を表示
(defun print-answer (board history)
  (let ((s (caar history))
        (p (cdar history)))
    (if history
        (print-answer (move-piece 0 board (nth p board) (min p s) (max p s))
                      (cdr history)))
    (print-board board)))

; フリップ・イットを解く
(defun flip-it-solver (start)
  (catch 'find-answer
    (dotimes (limit 20)
      (format t "***** ~D 手を探索 *****~%" (1+ limit))
      (solve-id 0 (1+ limit) start (position 'space start) nil))))

flip-it-solver は、反復深化の上限値 limit を 1 手ずつ増やして solve-id を呼び出すだけです。print-answer は history から盤面を再現して手順を表示します。引数 board が現在の盤面で history が移動手順です。再帰呼び出しで最初の状態に戻してから print-board で盤面を表示します。盤面 board を 1 手前に戻すとき、history の先頭要素を (s . p) とすると、board の p の位置に駒があり、s の位置が空き場所であることに注意してください。

これでプログラムは完成です。詳細は プログラムリスト1 をお読みください。

●フリップ・イットの解答

それでは、「フリップ・イット」の解答を示します。図では空き場所を _ で表しています。

         (A)                 (B)                    (C)
 0: ● ● _ ● ● ●    ● _ ○ ● ● ● ●    ● _ ○ ○ ○ ● ● ●  
 1: _ ○ ● ● ● ●    ● ● ● _ ● ● ●    ● ○ ● _ ○ ● ● ●  
 2: ● ● ○ _ ● ●    _ ○ ○ ● ● ● ●    _ ● ○ ● ○ ● ● ●  
 3: ● ● ○ ● ○ _    ○ ● _ ● ● ● ●    ○ ○ _ ● ○ ● ● ●  
 4: ● ● _ ○ ● ○    ○ ● ● ○ _ ● ●    ○ ○ ○ ○ _ ● ● ●  
 5: _ ○ ● ○ ● ○    ○ ● ● ○ ● ○ _    ○ ○ ○ ○ ● ○ ○ _  
 6: ○ ● ○ _ ● ○    ○ ● ● _ ○ ● ○    _ ● ● ● ○ ● ● ○  
 7: ○ _ ● ● ● ○    ○ ● ● ● ● _ ○    ○ ○ ○ ○ _ ● ● ○  
 8: ○ ○ ○ ○ ○ _    _ ○ ○ ○ ○ ○ ○    ○ ○ ○ ○ ○ ○ ○ _  

                        図:フリップ・イットの解答

(A), (B), (C) ともに最短手数は 8 手になりました。実は、これが最長手数の局面となります。ちなみに、駒の個数が 4 つの場合だと、最長手数は 10 手と長くなります。また、最後の白石の位置を限定すると、手数が長くなる場合もあります。たとえば、(A) の問題でゴールを "_ ○ ○ ○ ○ ○" とすると、最短手数は 9 手になります。興味のある方は、いろいろと試してみてください。


●フリップ・イットの最長手数

次は、最長手数の局面を幅優先探索で求めてみましょう。最初に、キューの大きさを決めるため石の置き方が何通りあるか数えます。これは空き場所の配置から考えた方が簡単です。盤面の大きさを N とすると、空き場所の配置は N 通りあります。残りは黒石か白石のどちらかなので、石の配置は 2 N - 1 通りあります。したがって、全体では N * 2 N - 1 通りになります。

実際に計算してみると、N = 6 で 192 通り、N = 7 で 448 通り、N = 8 で 1024 通りになります。大きな数ではないので、同一局面のチェックは線形探索でいいでしょう。プログラムは次のようになります。

リスト:「フリップ・イット」最長手数の探索

; 解の表示
(defun print-answer-max (n move-table state-table)
  (let ((max (aref move-table n)))
    (format t "最長手数 ~D 手~%" max)
    (loop
      (print (aref state-table n))
      (decf n)
      (if (/= max (aref move-table n)) (return)))))

; 最長手数の探索
(defun solve-max (board-size)
  (let* ((max-state (* (expt 2 (1- board-size)) board-size))
         (state-table (make-array max-state))    ; 盤面
         (space-table (make-array max-state))    ; 空き場所の位置
         (move-table  (make-array max-state))    ; 手数
         (rear 0)
         (front 0)
         board new-board space)
    ; キューの初期化
    (dotimes (x board-size)
      (setf board (make-list board-size :initial-element t)
            (nth x board) 'space
            (aref state-table rear) board
            (aref space-table rear) x
            (aref move-table  rear) 0)
      (incf rear))
    ; 探索
    (while (< front rear)
      (setq board (aref state-table front)
            space (aref space-table front))
      (dotimes (x board-size)
        (when (or (< x (1- space)) (< (1+ space) x))
          ; 移動可能
          (setq new-board (move-piece 0 board (nth x board) (min x space) (max x space)))
          (unless (find new-board state-table :test #'equal)
            ; キューに登録
            (setf (aref state-table rear) new-board
                  (aref space-table rear) x
                  (aref move-table rear)  (1+ (aref move-table front)))
            (incf rear))))
      (incf front))
    ; 解の表示
    (format t "状態数 ~D 個~%" rear)
    (print-answer-max (1- rear) move-table state-table)))

関数 solve-max には盤面のサイズを渡します。最初に、キューの大きさを計算して変数 max-state にセットします。キューはベクタを使って定義します。state-table が盤面、space-table が空き場所の位置、move-table が移動手数を格納します。それぞれ make-array でベクタを生成して変数にセットします。

次に、キューを初期化します。ゴールの条件である「すべての石が白の盤面」を生成してキューにセットすれば OK です。make-list で要素が t のリストを生成して変数 board にセットし、空き場所の位置 x の要素を space に書き換えます。そして、盤面 board を state-table に、空き場所の位置 x を space-table に、手数 0 を move-table にセットします。キューを管理する変数 rear をインクリメントすることもお忘れなく。

あとは単純な幅優先探索です。同一局面のチェックには関数 find を使っています。比較するデータはリストなので、キーワード :test には #'equal を指定します。あとは特に難しいところはないでしょう。詳細は プログラムリスト2 をお読みくださいませ。

実行結果は次のようになりました。

  (solve-max 5)            (solve-max 6)
  状態数 80 個             状態数 192 個
  最長手数 10 手           最長手数 8 手
  ● ● ○ _ ●           ● _ ○ ● ● ● 
  ● ● _ ● ●           ● ● _ ● ● ● 
  ● _ ○ ● ●           ● ● ● ○ _ ● 
  nil                      ● ● ● _ ● ● 
                           nil

  (solve-max 7)            (solve-max 8)
  状態数 448 個            状態数 1024 個
  最長手数 8 手            最長手数 8 手
  ● ● ● ● ○ _ ●     ● ● _ ● ● ● ● ●  
  ● ● ● ● _ ● ●     ● _ ○ ● ● ● ● ●  
  ● ● _ ● ● ● ●     ● _ ○ ○ ○ ● ● ●  
  ● _ ○ ● ● ● ●     ● ● ● ● ● ○ _ ●  
  nil                      ● ● ● ● ● _ ● ●  
                           ● ● ● ○ ○ ○ _ ●  
                           nil

        図:フリップ・イットの最長手数

フリップ・イットの場合、盤面を大きくしたからといって、最長手数が長くなるとは限らないようです。興味のある方は、より大きな盤面で試してみてください。


●ルールの変更

ところで、フリップ・イットのルールでは空き場所の隣の駒を動かすことはできません。ルールを「空き場所の隣の駒を動かしてもよい」ことに変更して最長手数の局面を求めてみたところ、結果は次のようになりました。

  (solve-max 5)    (solve-max 6)       (solve-max 7)          
  状態数 80 個     状態数 192 個       状態数 448 個          
  最長手数 6 手    最長手数 6 手       最長手数 7 手          
  ● ● _ ● ●   ● ● _ ● ● ●   ● ● ● _ ● ● ●   
  nil              ● ● ○ _ ● ●   ● ● ● ○ _ ● ●   
                   ● _ ○ ● ● ●   ● ● _ ○ ● ● ●   
                   ● ● ● _ ● ●   nil                    
                   ● ● ● ○ _ ●   
                   ● ● _ ○ ● ●   
                   nil

        図:フリップ・イットの最長手数(別ルール)

(solve-max 8) の結果は、局面が多数あるため省略しましたが、最長手数は 7 手になりました。どうやら、このルールの方が簡単に解くことができるようです。


●プログラムリスト1

;
; flip_it.l : フリップ・イット 反復深化による解法
;
;             Copyright (C) 2002,2003 Makoto Hiroi
;

;
; 盤面を表示
;
(defun print-board (board)
  (let ((code '((nil . "●") (t . "○") (space . "_"))))
    (dolist (piece board (terpri))
      (format t "~A " (cdr (assoc piece code))))))

;
; 手順を表示
;
(defun print-answer (board history)
  (let ((s (caar history))
        (p (cdar history)))
    (if history
        (print-answer (move-piece 0 board (nth p board) (min p s) (max p s))
                      (cdr history)))
    (print-board board)))

;
; 駒を動かす
;
(defun move-piece (n board piece start end)
  (cond ((null board) nil)
        ((or (= n start) (= n end))
         (cons (if (eq (car board) 'space) piece 'space)
               (move-piece (1+ n) (cdr board) piece start end)))
        ((< start n end)
         (cons (not (car board))
               (move-piece (1+ n) (cdr board) piece start end)))
        (t (cons (car board)
                 (move-piece (1+ n) (cdr board) piece start end)))))

;
; 反復深化による探索
;
(defun solve-id (n limit board space history)
  (if (= limit n)
      (when (zerop (count nil board))
        (print-answer board history)
        (throw 'find-answer t))
    (dotimes (x (length board))
      (when (and (not (eql (cdar history) x))
                 (or (< x (1- space)) (< (1+ space) x)))
        ; 移動可能
        (solve-id (1+ n)
                  limit
                  (move-piece 0 board (nth x board) (min x space) (max x space))
                  x
                  (cons (cons x space) history))))))

;
; フリップ・イットを解く
;
(defun flip-it-solver (start)
  (catch 'find-answer
    (dotimes (limit 20)
      (format t "***** ~D 手を探索 *****~%" (1+ limit))
      (solve-id 0 (1+ limit) start (position 'space start) nil))))

●プログラムリスト2

;
; flip_max.l : フリップ・イット 最長手数の探索
;
;              Copyright (C) 2002,2003 Makoto Hiroi
;

;
; 駒を動かす
;
(defun move-piece (n board piece start end)
  (cond ((null board) nil)
        ((or (= n start) (= n end))
         (cons (if (eq (car board) 'space) piece 'space)
               (move-piece (1+ n) (cdr board) piece start end)))
        ((< start n end)
         (cons (not (car board))
               (move-piece (1+ n) (cdr board) piece start end)))
        (t (cons (car board)
                 (move-piece (1+ n) (cdr board) piece start end)))))

;
; 盤面を表示
;
(defun print-board (board)
  (let ((code '((nil . "●") (t . "○") (space . "_"))))
    (dolist (piece board (terpri))
      (format t "~A " (cdr (assoc piece code))))))

;
; 解の表示
;
(defun print-answer-max (n move-table state-table)
  (let ((max (aref move-table n)))
    (format t "最長手数 ~D 手~%" max)
    (loop
      (print-board (aref state-table n))
      (decf n)
      (if (/= max (aref move-table n)) (return)))))

;
; 最長手数の探索
;
(defun solve-max (board-size)
  (let* ((max-state (* (expt 2 (1- board-size)) board-size))
         (state-table (make-array max-state))    ; 盤面
         (space-table (make-array max-state))    ; 空き場所の位置
         (move-table  (make-array max-state))    ; 手数
         (rear 0)
         (front 0)
         board new-board space)
    ; キューの初期化
    (dotimes (x board-size)
      (setf board (make-list board-size :initial-element t)
            (nth x board) 'space
            (aref state-table rear) board
            (aref space-table rear) x
            (aref move-table  rear) 0)
      (incf rear))
    ; 探索
    (while (< front rear)
      (setq board (aref state-table front)
            space (aref space-table front))
      (dotimes (x board-size)
        (when (or (< x (1- space)) (< (1+ space) x))
          ; 移動可能
          (setq new-board (move-piece 0 board (nth x board) (min x space) (max x space)))
          (unless (find new-board state-table :test #'equal)
            ; キューに登録
            (setf (aref state-table rear) new-board
                  (aref space-table rear) x
                  (aref move-table rear)  (1+ (aref move-table front)))
            (incf rear))))
      (incf front))
    ; 解の表示
    (format t "状態数 ~D 個~%" rear)
    (print-answer-max (1- rear) move-table state-table)))

Copyright (C) 2003 Makoto Hiroi
All rights reserved.

[ PrevPage | Common Lisp | NextPage ]