ホームページへ戻る 開発ノートメニューへ戻る

11パズルの最適解が最長手数となる面の探索


 幅優先探索はメモリ量との戦いになるケースが多くあります。ここでは広井さんのHP「M.Hiroi's Home Page」にある Guest Book で2000年12月頃に話題となった完全ハッシュ関数とその逆関数を使うことによりメモリ量をギリギリに抑えて11パズルの最適解が最長手数となる配置を探索してみます。
 11パズルとは有名なサムロイドの15パズルを4×3のボードに縮小したものです。局面の展開数は空マスを含めて12種類のコマの順列なので12!=479,001,600通りになりますが、「スライドパズルの偶奇性」により実際の展開数はその半分の239,500,800通りになります。
 3×3のボードの8パズルなら181,440通りなのに対して極端に増大してしまいますね。これだけ展開数が多いと通常のパソコンでは対応出来そうにありませんが完全ハッシュ関数とその逆関数を利用することによりメモリ量をギリギリに抑えて解くことが出来ます。

■ 最小完全ハッシュ関数

 幅優先探索では局面からハッシュ値を求めてその番地に局面を保存するのが普通です。ここで、異なる局面が同じハッシュ値になって衝突してしまうようなことが絶対に無いように作ったハッシュ関数を完全ハッシュ関数といいます。特にハッシュ値が[0]から[局面数−1]の範囲に隙間なく生成されるものをここでは「最小完全ハッシュ関数」と呼ぶことにします。この関数の魅力は衝突による対処法を考慮する必要が無いばかりでなく、メモリを最も無駄なく使うことが出来るという利点があります。
 さらにまた、このハッシュ値から逆算して元の状態を復元出来る逆関数を用意しておくと局面を保存しておく場所すら不要になります。
 ここでは順列型と組合せ型の完全ハッシュ関数を用いますが、この基本関数の作り方は、「M.Hiroi's Home Page」にある Guest Book[最小完全ハッシュ関数の作り方]をご覧下さい。

■ スライドパズルの偶奇性

 スライドパズルに偶奇性があることは良く知られていますが、具体的にはどのようなものでしょうか。
 それを説明する為に、先ず4×3のボードに対して左上端のマスから開始して1マスずつ右へ、次に2段目の左端から右にというように順序付けして各コマの数字の並び順に着目します。そしてコマの並びがこの順序通りになって無くて順序がひっくり返っているところが何ヶ所あるかを数えます。これを「転倒数」と言います。転倒数は最終的には0(偶数)になります。
 さて、あるコマを左右に動かしてもコマの順序は変わりませんので転倒数は変化しません。しかし上下に動かすと3コマを飛び越すことになるので転倒数は3つ増減します。つまり奇数個の増減が生じることになります。
 次に視点を変えて、コマを動かすという行為を空きマスの位置が移動すると考えると空きマスは盤上を動き回って最後には右下隅にたどり着いて終ります。空きマスが初期状態の時に何段目にあったかで上下動の回数は偶数回か奇数回か決まってしまう訳です。
 転倒数は最終的には偶数(0)になりますから、空きマスに着目した上下動の回数が偶数回なら初期状態での転倒数は偶数でなければなりません。また逆に奇数回なら初期状態での転倒数も奇数である必要があります。これがスライドパズルの偶奇性です。
 なお、横幅のマス数が奇数なら上下動で転倒数の偶奇性が変化することはありませんので、空きマスの位置に関係なく転倒数は初期状態から必ず偶数でなければなりません。

 参考文献:「数理パズル」(中央公論社)池野信一、高木茂男、土橋創作、中村義作 著

■ 11パズルのための最小完全ハッシュ関数

 順列型の最小完全ハッシュ関数をそのまま使ったのではスライドパズルの偶奇性から最小完全ハッシュ関数にはならずハッシュ値領域の半分が無駄になってしまいます。
 全くデタラメにコマを配置した状態からは最終的に下図のどちらかに帰着します。この2つは全く異なる展開系列を持っていて互いに局面展開が行き交うことはありません。

1011
1110

 そこで、[10]と[11]のコマを区別しないことにします。つまり[10]のコマが2枚あり[11]のコマは無いことにします。
 このようにすると展開系列は1つになり無駄となる半分の領域を解消することが出来ます。この方法は決していい加減ではありません。何故ならあくまでも2枚の[10]の内どちらかが本当は[11]のコマであり、それは偶奇性を調べることでいつでも識別可能だからです。
 2枚の[10]の配置は12C2=66通りの組合せ、残りのコマの配置は10!=3,628,800通りの順列となり全体では、66×3,628,800=239,500,800通りで12!/2の値と一致します。
 このルールに基づいて組合せ型と順列型を複合させた最小完全ハッシュ関数を作ることにします。また、この逆の手順による逆関数も作っておきます。

■ 探索に必要なメモリ量 (2004年2月更新 86MB→58MB)

 逆関数を持たせることで局面の保存場所は不要になりますが、同一局面のチェックテーブルとキューに相当するものは必要です。
 同一局面のチェックには1ビットのフラグがあれば充分なので239,500,800÷8=29,937,600バイト(1バイト=8ビット)必要になります。
 次にキューですが最低限必要な量は見当がつきません。仮に総局面数の1/10は必要だとしてキューにハッシュ値(4バイト値)を格納することにすると239,500,800÷10×4=95,800,320バイト必要になります。
 合計で125,737,920バイトになりますが、その他の細々としたデータ値やOSなどがメモリに常駐している分も考慮すると全体で128MBはきついかも知れません。メモリをもっと多く積んでいるマシンならこの方法がベストでしょうが私のマシンのメモリは128MBです。
 そこで探索時間を犠牲にすることになりますが同一局面のチェックテーブルと全く同じフラグテーブルを、もうひとつ用意します。
 このふたつのフラグテーブルを、TABLE1,TABLE2 としましょう。どちらも最初はすべてのフラグが0になっています。まず、TABLE1に対して、完成形のフラグ位置を立てます(1にする)。TABLE2は0のままです(これを[1,0]と表現することにします)。
 ようするに、これが最初の0手目グループです。TABLE1とTABLE2を足並みそろえて順に見ていって、[1,0]であれば逆関数を使ってその局面を復元します。それに1手加えた局面を作り、その局面のフラグ位置が[0,0]であれば初めて出現した局面ということなので、そのフラグ位置を[0,1]に書き換えます。この処理をフラグテーブルの最後まで行うと[0,1]になっているものすべてが1手目グループの要素ということになります。
 次に、2手目グループを作りたいのですが、ここでTABLE3を用意することはしません。もういちどTABLE1とTABLE2を見直して、[1,0]となっているものを[1,1]に書き換え、[0,1]となっているものは[1,0]に書き換ます。[1,1]はn−1手目以前に出現している局面全体の集合と理解するのです(下表)。これならフラグテーブルは2つで充分です。
 メモリ量は全体で29,937,600バイト×2=59,752,200バイト(58MB)に抑えることが出来ます。さらにもう一つの利点はキューの最低限必要な容量を考える必要がないことです。(下の追記も読んで下さい)

TABLE1TABLE2摘要(識別内容)
まだ出現していない局面
n+1手目グループの局面
n手目グループの局面
n-1手目グループの局面
n-2手目グループの局面
    U
1手目グループの局面
0手目グループの局面

■ ソースコードと実行結果

 C言語によるソースコードは[ここ(slide2.c)]にあります。そして実行結果、11パズル(4×3)の最長手数は53手(18通り)でした。


1110

1110

1110

1110

1110

1110
10

11
10

11
11

10

11
10

11
10

11
10
11

10

1110

1110

1110

11
10
11
10


ボードサイズ(横×縦)最適解の最長手数その他
 2×2 (3パズル)  6手 ( 1通り) 詳細
 3×2 (5パズル) 21手 ( 1通り) 詳細
 4×2 (7パズル) 36手 ( 1通り) 詳細
 3×3 (8パズル) 31手 ( 2通り) 詳細
 5×2 (9パズル) 55手 ( 2通り) 詳細
 6×2 (11パズル) 80手 ( 2通り) 詳細
 4×3 (11パズル) 53手 (18通り) 詳細
※※※※ 実行環境 ※※※※
CPU = AMD Athlon 1.0GHz
メモリ = 128MB (PC133,CL=3)
O S = Windows Me (DOS窓)
コンパイラ=Visual C++ 5.0 (実行速度で最適化)

■ 追記(2004/02/04)

 探索を2手ずつ進めることにすると、空きマスの位置は下の図のように12ケ所から6ケ所に減るので、フラグテーブルの大きさを半分にすることができる、という大変興味深い方法をdeepgreenさんから教えていただきました。

  * ○ * ○   ○:管理対象とする空きマスの位置(偶数手の局面)
  ○ * ○ *   *:管理対象外とする空きマスの位置(奇数手の局面)
  * ○ * ○

 尚、2手ずつの探索では、最長手数がn、(n+1)のいずれかを確定する処理が必要となります。探索の結果、(n+2)手の局面が存在しない場合は、以下の手続きで最長手数とその局面を求めることができます。

 n手の局面から1手進めた局面をaとします。aは、(n+1),(n-1)のいづれかの筈ですから、aを基準にして1手進めた局面はn,(n-2) のいづれかになります。
 aを基準にして生成される局面がすべてn手の局面であれば、aは(n+1)手の局面と判明します。下図のbのように(n-2)手の局面が1つでも生成される場合は、(n-1)手の局面です。
 尚、この操作の結果(n+1)手の局面が全く存在しない場合は、最長手数は、n手になります。

   (n+1)手の局面 --->   a
                 / \
      n 手の局面 --->  X   Y
                 \ /
   (n−1)手の局面 --->    b
                 / \
   (n−2)手の局面 --->  P   Q

 とても面白い方法ですね。これならメモリが64MBしかないマシンでも実行可能です。deepgreenさん、ありがとうございます。
しかも、さらにもっとメモリを節約する方法が「プログラミングパズル雑談コーナー」にありますのでご覧下さい。


ホームページへ戻る 開発ノートメニューへ戻る