箱入り娘パズルをRubyで解く

Rubyによる情報科学入門」(久野靖著)の第10章にあった箱入り娘パズルをやってみた。スライドパズルの一種。掲載されているコードをそのまま打ち込むのも芸がないし、例によって、久野先生のコードが、どうもCっぽく見えることもあって、自分で書き直してみた。

事前に状態空間全体をツリーやグラフとして構成するのが現実的でない、組み合わせの爆発が起こるような問題では、状態を順次作りつつダイナミックに探索を行う。このとき深さ優先と幅優先の2つの戦略がある。

ある状態と隣接する状態を作ってスタックに積み、これを上から取り出して探索すれば、深さ優先になる。逆にキューに突っ込んでFIFOで処理すれば、幅優先になる。RubyのArrayはpopもshiftもできるスタック兼キューだったりするので、コード中の1カ所で「stack.pop」を「stack.shift」に変えるだけで幅優先の探索もできる。

Koma = Struct.new(:width, :height, :name, :y, :x)
INIT_STATE = 
  [Koma.new(2, 2, "M", 0, 1), 
   Koma.new(1, 2, "v", 0, 0),
   Koma.new(1, 2, "v", 0, 3),
   Koma.new(1, 2, "v", 2, 0),
   Koma.new(1, 2, "v", 2, 3),
   Koma.new(2, 1, "w", 2, 1),
   Koma.new(1, 1, "s", 3, 1),
   Koma.new(1, 1, "s", 3, 2),
   Koma.new(1, 1, "s", 4, 0),
   Koma.new(1, 1, "s", 4, 3),]

class State
  attr_accessor :koma
  @@visited = {}

  def initialize(s=nil)
    @prev = s
    if @prev == nil
      @koma = INIT_STATE
    else
      @koma = Marshal.load(Marshal.dump(s.koma))
    end
    @b = Array.new(5) do Array.new(4, -1) end
    place
  end

  def place
    @b.each{|y| y.fill(-1)}
    @koma.each_with_index do |k, i|
      k.width.times do |w|
        k.height.times do |h|
          @b[k.y + h][k.x + w] = i
        end
      end
    end
  end

  def isgoal?
    musume = @koma.find{|k| k.name == "M"}
    return musume.x == 1 && musume.y == 3
  end

  def visited
    @@visited[self.to_s] = true
  end

  def visited?
    @@visited[self.to_s] == true
  end

  def to_s
    str = "-------\n"
    @b.each do |line| 
      line.each do |num|
        if num == -1 then
          str += ". "
        else
#          str += @koma[num].name + " "
          str += num.to_s + " "
        end
      end
      str += "\n"
    end
    return str
  end

  def generate_moves
    @koma.each_with_index do |k, i|
      [[-1, 0], [1, 0], [0, -1], [0, 1]].each do |dy, dx|
        if ((0 > k.y + dy || 0 > k.x + dx) ||
            ((4 < k.y + k.height - 1 + dy) ||
             (3 < k.x + k.width  - 1 + dx))) then
          next
        end
        if movable?(i, dy, dx) then
          s2 = State.new(self)
          s2.koma[i].x += dx; s2.koma[i].y += dy
          s2.place
          yield s2
        end
      end
    end
  end

  def movable?(i, dy, dx)
    y = @koma[i].y + dy
    x = @koma[i].x + dx
    @koma[i].height.times do |h|
      @koma[i].width.times do |w|
        num = @b[y + h][x + w]
        if (num != -1) && (num != i) then 
          return false
        end
      end
    end
    true
  end

  def traceback
    if @prev != nil then @prev.traceback end
    puts self.to_s
  end

end

# main

stack = []
checked = 0

s = State.new(nil)
stack << s
puts "initial state:"
puts s.to_s
puts

Thread.new do |t|
  while true
    sleep 3
    print "checked: #{checked}\n"
    print "stacked: #{stack.size}\n"
    print "sample :\n#{stack.last.to_s}\n"
  end
end

while !(stack.empty?) do
  s = stack.pop
  s.visited
  s.generate_moves do |s2|
    if s2.isgoal? then
      print "found the answer:\n", s2.to_s
      print "process:\n"
      s2.traceback
      exit
    elsif !s2.visited? then
      stack << s2
      checked += 1
    end
  end
end

t.kill
print "checked #{checked} states\n"

キモは一番最後のwhileループで、

  • 初期状態を作ってスタックに積む
  • スタックが空でない限りwhileを回す
  • スタックからpop
  • popした状態をチェック済みマーク
  • 隣接状態を作って、それらがゴールか調べる
  • ゴールでなく、かつチェック済みでないならスタックに積む

というのが状態空間探索の基本動作。

ある局面でのコマの配置状態を保持するStateクラスを作ったのはいいけど、何でもかんでもこのクラスに突っ込んでしまったのがいいのか悪いのかよく分からない。コマの配置を実際の盤面でのデータ表現にマップさせるようなことは、別のクラスにしたほうが良かったのか、とか。しかし分けるほど別じゃない。ていうか、クラスとか大げさなのか。

これを実行すると所要時間22分、700MBほどのメモリを消費して確かに解を発見したように見える。検査した状態は80万ぐらい。終了時にスタックに積まれた状態は40万ぐらい。組み合わせパズルの状態数を甘く見積もっていたらしい。

最後はエラーで終わってしまった。単方向連結リストとして記録しておいた手順を、初期状態から逆順に表示するために、最後に再起(Stack#traceback)しているところで、stack level too deepが出て落ちてしまった。どうも8000手順ぐらいで解けているらしい。あいや、8000手順さかのぼったところでエラーが出てるだけで、実際はもっと深い可能性もあるのか。またもう1度20分も走らせる気がしない……。

どっちみち視認で正解かどうかを確認するのは非現実的だし、まあいいかという感じ。結果を見ると確かに娘はゴールにたどり着いている。

:
:

checked: 818500
stacked: 421515
sample :
-------
3 2 . 6
3 2 4 1
8 7 4 1
. 9 0 0
5 5 0 0

checked: 819544
stacked: 422014
sample :
-------
2 9 8 1
2 7 6 1
. 3 0 0
4 3 0 0
4 5 5 .

checked: 820358
stacked: 422418
sample :
-------
4 6 . 1
4 9 7 1
3 2 . 8
3 2 0 0
5 5 0 0

found the answer:
-------
7 6 4 1
2 3 4 1
2 3 5 5
. 0 0 9
. 0 0 8
process:
hako.rb:105:in `traceback': stack level too deep (SystemStackError)
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
        from hako.rb:105:in `traceback'
         ... 8719 levels...
        from hako.rb:105:in `traceback'
        from hako.rb:137:in `block in <main>'
        from hako.rb:133:in `each'
        from hako.rb:133:in `<main>'

実際にはこのパズル、最短だと81手順で解けるらしい。むかし自分でやった感覚だと、もっと短い気もするけど、それは、ある種の一連の操作を人間は一塊に認識するからかもしれない。

途中経過を3秒おきに表示するようにしているのだけど、これが見ていると結構おもしろい。10分ほど経過したとき、明らかに「娘」のコマがゴール付近をうろついていて、もう少し! もう少し! とドキドキした。結局、「番頭」が邪魔になって「娘」は外に出られなかったりして、ああ、なるほど、その枝方向には解がなかったのかな、とか。

さらに幅優先でやってみているけど、一体どのぐらい時間がかかるのか分からない。80手程度で正解にたどり着けるのだとすれば、案外たいしたことはないのかもしれないように思ったけど、数時間かかって100万状態をチェックした今、娘のコマは微動だにしていない……。下のほうでゴチャゴチャとコマを動かして似たような(しかし同一ではない)状態をしらみつぶしにしているらしい様子が分かる。組み合わせの爆発っていうけど、まさにそんな感じなのか。

自分でやってみると本当にいろいろと分かるもので、スタックやキューを使った深さ優先と幅優先の意味がよく分かった。

もう1つ、勉強というか教訓になったのは、直前の状態から新規の状態を作るときに、コマの配置をコピーするのを忘れて、State#initializeで、

@koma = s.koma

とやったりしてハマったこと。Ruby初心者の典型的なミスとして知っていたはずだけど、最初は気づかなかった。これだと新規のStateオブジェクトが、直前のコマの状態を共有してしまって意図しない書き換えが起こる。そのために探索がある深さまで行って一瞬で終わる。で、次に

@koma = s.koma.dup

としてみたけど、やっぱりどうも探索が一瞬で終わってしまう。s.komaの中身はStructクラスで作ったKomaオブジェクトのArrayだったりするので、dupだけではシャローコピーになってしまう。なので正しくは、

@koma = Marshal.load(Marshal.dump(s.koma))

とマーシャリングしてディープコピーしないといけない。これはオブジェクトを扱う上でけっこう本質的なハマりだった気がする。

たいした必要性がないのに無駄にクラスを作って入れ子とかにすると、人間に分かりやすくはなっても、実行効率が落ちるし「コピー」という単純な概念も入れ子になってしまって、デメリットもバカにならない。Komaオブジェクトとか、やめておけば良かった。コマを1つずつ調べるループを抽象化したつもりが、結局インデックスを使わないと隣接する状態間で「このコマ」という指定ができないので、eachじゃなくて、each_with_indexと本末転倒なことをやっていたりする。だったら最初から数値のインデックスだけでいいじゃんとか。

そもそも効率優先で考えれば、盤面の状態はint5個で充分だし、各コマの場所やサイズだってint1つ、20bitもあれば足りそう。あ、ビットで盤面の配置を表せばビット演算で重なり判定もできて、速そうだ。

8000手順を超えるような解が発見できてもうれしくないので、せめて200手順ぐらいのものを見てみたい。幅探索で適当に「枝をかる」のとか、どうやってやるんだろうか。