第18章
プランニングでラッシュアワー問題を解く
(やさしい版)
Pearls of Functional Algorithm Design(関数プログラミングによるアルゴリズム設計の真珠)
どんな話?
この章は、有名なパズル ラッシュアワー(Rush Hour)を、関数型プログラミングで賢く解く、という話です。
ラッシュアワーとは?
6 × 6 の駐車場に何台もの車がぎゅうぎゅうに詰まっていて、他の車を上下左右にずらしながら、自分の車(特別な車)を出口から脱出させるスライドブロック・パズル。Nob Yoshigahara(吉川乃ぶ)氏が考案し、Think Fun 社から発売されています。1
この章のねらい
- まず幅優先探索(BFS)で解ける、という素朴な方法を見る(bfsolve)。
- 次にプランニング(planning)という「人間っぽい」戦略を導入し、桁違いに速くする(psolve)。
- 盤面が n × n になった一般化版は PSPACE 完全と分かっているので、指数時間より速いソルバは望み薄。でも工夫の余地は大きい。
パズルを抽象化する
状態と手の3つ組
まずはラッシュアワーに限らない、抽象的なパズルを考えます。状態の有限集合と手の有限集合を用意し、次の3つの関数を与えます。
moves :: State → [Move]
move :: State → Move → State
solved :: State → Bool
Dart
// パズルの抽象インターフェース:状態 State と手 Move はジェネリック型
typedef Moves<S, M> = List<M> Function(S q);
typedef MoveFn<S, M> = S Function(S q, M m);
typedef Solved<S> = bool Function(S q);
- moves q:状態 q で指せる合法手のリスト。
- move q m:状態 q で手 m を指した結果の新しい状態。
- solved q:状態 q がゴール(解けている)かどうか。
この形で書けば、パズルは決定性有限オートマトンそのものです。「解く」とは、初期状態から solved になる状態まで手をつなぐ列を見つけることで、できれば最短のものが望ましい。
solve :: State → Maybe [Move]
Dart
// solve は解が無ければ null、あれば手順のリストを返す
typedef Solve<S, M> = List<M>? Function(S q);
solve q の値は、解が無ければ Nothing、あれば手順のリストを Just ms で返す。ここで ms は solved (foldl move q ms) を満たします。
方法1:幅優先探索(BFS)
基本の実装
まず、パス(今までの手の列と現在の状態のペア)と、それを待ち行列として並べたフロンティアを定義します。
type Path = ([Move], State)
type Frontier = [Path]
Dart
// Path は「これまでの手」と「現在の状態」の組
typedef Path<S, M> = (List<M> ms, S q);
typedef Frontier<S, M> = List<Path<S, M>>;
幅優先探索は、フロンティアをキューとして使って、初期状態からの距離が短いパスから順に調べます。
bfsearch :: [State] → Frontier → Maybe [Move]
bfsearch qs [ ] = Nothing
bfsearch qs (p@(ms, q) : ps)
| solved q = Just ms
| q ∈ qs = bfsearch qs ps
| otherwise = bfsearch (q : qs) (ps ++ succs p)
Dart
// 基本の BFS。qs は訪問済み集合、ps はキュー
List<M>? bfsearch<S, M>(
List<S> qs,
Frontier<S, M> ps,
Moves<S, M> moves,
MoveFn<S, M> move,
Solved<S> solved,
) {
while (ps.isNotEmpty) {
final p = ps.removeAt(0);
final (ms, q) = p;
if (solved(q)) return ms;
if (qs.contains(q)) continue;
qs = [q, ...qs];
ps.addAll(succs(p, moves, move));
}
return null;
}
succs :: Path → [Path]
succs (ms, q) = [(ms ++ [m], move q m) | m ← moves q]
Dart
// 現在のパスから、合法手を1つ足した後続パスを列挙
List<Path<S, M>> succs<S, M>(
Path<S, M> p,
Moves<S, M> moves,
MoveFn<S, M> move,
) {
final (ms, q) = p;
return [for (final m in moves(q)) ([...ms, m], move(q, m))];
}
やっていることを1文で言うと、パスの先頭を取り出し、
- ゴールならその手順を返す。
- すでに見た状態なら捨てる。
- そうでなければ後続パスをキュー末尾に足して続ける。
大事な性質
BFS は解があれば必ず最短の解を見つける。ただし現在のフロンティアが(DFS と比べて)指数関数的に長くなり得る点は要注意。
DFS との違いは1か所だけ
深さ優先探索(DFS)にしたいなら、後続を末尾ではなく先頭に足すだけ。つまり ps ++ succs p を succs p ++ ps に書き換える。フロンティアがスタック扱いになり、あるパスの子から先に潜っていきます。DFS も解があれば見つけますが、最短とは限りません。
速くするためのカギ:累積引数
上の bfsearch は ps ++ succs p のところで、フロンティアの長さに比例した時間がかかってしまう。そこで累積引数を1つ足して bfsearch′ を作ります。
bfsearch′ qs pss ps = bfsearch qs (ps ++ concat (reverse pss))
Dart
// 累積引数版の定義式:pss を逆順に連結して ps の末尾に足すと元の bfsearch と一致
List<M>? bfsearchPrimeSpec<S, M>(
List<S> qs,
List<Frontier<S, M>> pss,
Frontier<S, M> ps,
Moves<S, M> moves,
MoveFn<S, M> move,
Solved<S> solved,
) {
final tail = [for (final fr in pss.reversed) ...fr];
return bfsearch([...qs], [...ps, ...tail], moves, move, solved);
}
簡単な計算で、次の形が得られます。
bfsearch′ :: [State] → [Frontier] → Frontier → Maybe [Move]
bfsearch′ qs [ ] [ ] = Nothing
bfsearch′ qs pss [ ] = bfsearch′ qs [ ] (concat (reverse pss))
bfsearch′ qs pss (p@(ms, q) : ps)
| solved q = Just ms
| q ∈ qs = bfsearch′ qs pss ps
| otherwise = bfsearch′ (q : qs) (succs p : pss) ps
Dart
// pss(後続フロンティアの束)を溜めておき、ps が尽きたら平坦化してリセットする版
List<M>? bfsearchPrime1<S, M>(
List<S> qs,
List<Frontier<S, M>> pss,
Frontier<S, M> ps,
Moves<S, M> moves,
MoveFn<S, M> move,
Solved<S> solved,
) {
while (true) {
if (ps.isEmpty) {
if (pss.isEmpty) return null;
ps = [for (final fr in pss.reversed) ...fr];
pss = [];
continue;
}
final p = ps.removeAt(0);
final (ms, q) = p;
if (solved(q)) return ms;
if (qs.contains(q)) continue;
qs = [q, ...qs];
pss = [succs(p, moves, move), ...pss];
}
}
もっとシンプルに、累積引数の型を [Frontier] ではなく Frontier にしてしまう版もあります。
bfsearch′ :: [State] → Frontier → Frontier → Maybe [Move]
bfsearch′ qs [ ] [ ] = Nothing
bfsearch′ qs rs [ ] = bfsearch′ qs [ ] rs
bfsearch′ qs rs (p@(ms, q) : ps)
| solved q = Just ms
| q ∈ qs = bfsearch′ qs rs ps
| otherwise = bfsearch′ (q : qs) (succs p ++ rs) ps
Dart
// より簡潔な版:累積は単一の Frontier rs。ps が空になったら rs を新 ps に
List<M>? bfsearchPrime<S, M>(
List<S> qs,
Frontier<S, M> rs,
Frontier<S, M> ps,
Moves<S, M> moves,
MoveFn<S, M> move,
Solved<S> solved,
) {
while (true) {
if (ps.isEmpty) {
if (rs.isEmpty) return null;
ps = rs;
rs = [];
continue;
}
final p = ps.removeAt(0);
final (ms, q) = p;
if (solved(q)) return ms;
if (qs.contains(q)) continue;
qs = [q, ...qs];
rs = [...succs(p, moves, move), ...rs];
}
}
この版は、隣り合うフロンティアを「左→右」と「右→左」で交互にたどるという動きをしますが、それでも解があれば最短解を見つけます。
最後にラッパを1つ。
bfsolve q = bfsearch′ [ ] [ ] [([ ], q)]
Dart
// エントリ関数:初期状態から空手順で開始
List<M>? bfsolveGeneric<S, M>(
S q,
Moves<S, M> moves,
MoveFn<S, M> move,
Solved<S> solved,
) {
return bfsearchPrime(<S>[], <Path<S, M>>[], [(<M>[], q)], moves, move, solved);
}
方法2:プランニング
「片っ端から試す」の限界
BFS は結局のところ「あらゆる手の並びをしらみつぶしに試す」戦略です。これは人間の解き方ではありません。人間はプラン(計画)を立てます。ここでは、うまく実行できればゴールに至るような手の列をプランと呼ぶことにします。
type Plan = [Move]
Dart
// プランは「これから指すつもりの手の列」
typedef Plan<M> = List<M>;
プランの決まりごと
- プラン内に同じ手を繰り返してはいけない(実行できなくなる)。
- 手 m を指すためにまた m を先に必要とするようなプランは論外。
- プランが空ならその時点で成功。
準備手:premoves
プランの先頭手 m が今の状態でそのまま指せるなら指す。指せないなら、先に片づけておかないといけない準備手を挙げる関数 premoves :: State → Move → [[Move]] を使う。premoves q m の各要素 pms は「この pms をまず全部やれば m を指せる」という候補の集まりです。
pms の中の手にもさらに準備手が要るかもしれないので、premoves を繰り返し適用してプランを拡張していきます。
newplans :: State → Plan → [Plan]
newplans q ms = mkplans ms
where
mkplans ms | null ms = [ ]
| m ∈ qms = [ms]
| otherwise = concat [mkplans (pms ++ ms) |
pms ← premoves q m,
all (∉ ms) pms]
where m = head ms; qms = moves q
Dart
// 抽象版:先頭手が合法になるまで premoves で前置して展開
typedef PreMoves<S, M> = List<List<M>> Function(S q, M m);
List<Plan<M>> newplansGeneric<S, M>(
S q,
Plan<M> ms,
Moves<S, M> moves,
PreMoves<S, M> premoves,
) {
final qms = moves(q);
List<Plan<M>> mkplans(Plan<M> ms) {
if (ms.isEmpty) return [];
final m = ms.first;
if (qms.contains(m)) return [ms];
return [
for (final pms in premoves(q, m))
if (pms.every((x) => !ms.contains(x)))
...mkplans([...pms, ...ms]),
];
}
return mkplans(ms);
}
newplans q ms は、「先頭の手が今の状態で必ず指せる」非空プランのリストを返します(空リストの場合もあり)。
探索のスタートには、状態 q における「ゴールに向かうたたき台のプラン」goalmoves q(型は State → Plan)が与えられていると仮定します。
拡張パスと探索本体
プランを持ち歩くために、パスの三つ組を用意します。
type APath = ([Move], State, Plan)
type AFrontier = [APath]
Dart
// APath は「これまでの手」「現在の状態」「残りのプラン」の3組
typedef APath<S, M> = (List<M> ms, S q, Plan<M> plan);
typedef AFrontier<S, M> = List<APath<S, M>>;
三つ組の中身は「これまでに指した手」「現在の状態」「残りの手のプラン」です。探索本体は、プランのどれかが成功するか全滅するまで、順に調べます。
psearch :: [State] → AFrontier → Maybe [Move]
psearch qs [ ] = Nothing
psearch qs (p@(ms, q, plan) : ps)
| solved q = Just ms
| q ∈ qs = psearch qs ps
| otherwise = psearch (q : qs) (asuccs p ++ ps ++ bsuccs p)
Dart
// プランニング付き探索の骨格:asuccs を先頭に、bsuccs を末尾に配置
List<M>? psearchGeneric<S, M>(
List<S> qs,
AFrontier<S, M> ps,
List<APath<S, M>> Function(APath<S, M>) asuccs,
List<APath<S, M>> Function(APath<S, M>) bsuccs,
Solved<S> solved,
) {
while (ps.isNotEmpty) {
final p = ps.removeAt(0);
final (ms, q, _) = p;
if (solved(q)) return ms;
if (qs.contains(q)) continue;
qs = [q, ...qs];
ps = [...asuccs(p), ...ps, ...bsuccs(p)];
}
return null;
}
asuccs, bsuccs :: APath → [APath]
asuccs (ms, q, plan)
= [(ms ++ [m], move q m, plan′) | m : plan′ ← newplans q plan]
bsuccs (ms, q, _)
= [(ms ++ [m], q′, goalmoves q′) | m ← moves q, let q′ = move q m]
Dart
// asuccs:プランを延ばして進む/bsuccs:合法手を1つ指してプランを立て直す
typedef GoalMoves<S, M> = Plan<M> Function(S q);
List<APath<S, M>> asuccsGeneric<S, M>(
APath<S, M> p,
MoveFn<S, M> move,
List<Plan<M>> Function(S, Plan<M>) newplans,
) {
final (ms, q, plan) = p;
return [
for (final np in newplans(q, plan))
if (np.isNotEmpty)
([...ms, np.first], move(q, np.first), np.sublist(1)),
];
}
List<APath<S, M>> bsuccsGeneric<S, M>(
APath<S, M> p,
Moves<S, M> moves,
MoveFn<S, M> move,
GoalMoves<S, M> goalmoves,
) {
final (ms, q, _) = p;
return [
for (final m in moves(q))
(() {
final qp = move(q, m);
return ([...ms, m], qp, goalmoves(qp));
})(),
];
}
なぜ bsuccs が要るの?
asuccs は「今のプランを延ばして進む」動き、bsuccs は「合法手を1つ指してプランを立て直す」動きです。プランは貪欲に実行されるので、解があってもプランだけで通せるとは限りません。bsuccs を混ぜておくことで、探索の完全性(解があれば必ず見つける)を保てます。
累積引数バージョンとエントリ関数
psearch′ :: [State] → AFrontier → AFrontier → Maybe [Move]
psearch′ qs [ ] [ ] = Nothing
psearch′ qs rs [ ] = psearch′ qs [ ] rs
psearch′ qs rs (p@(ms, q, plan) : ps)
| solved q = Just (reverse ms)
| q ∈ qs = psearch′ qs rs ps
| otherwise = psearch′ (q : qs) (bsuccs p ++ rs) (asuccs p ++ ps)
Dart
// 累積引数版:asuccs は現行 ps に先付け、bsuccs は次回用 rs に溜める
List<M>? psearchPrime<S, M>(
List<S> qs,
AFrontier<S, M> rs,
AFrontier<S, M> ps,
List<APath<S, M>> Function(APath<S, M>) asuccs,
List<APath<S, M>> Function(APath<S, M>) bsuccs,
Solved<S> solved,
) {
while (true) {
if (ps.isEmpty) {
if (rs.isEmpty) return null;
ps = rs;
rs = [];
continue;
}
final p = ps.removeAt(0);
final (ms, q, _) = p;
if (solved(q)) return ms; // Haskell の reverse は実装上不要(末尾追加のため)
if (qs.contains(q)) continue;
qs = [q, ...qs];
rs = [...bsuccs(p), ...rs];
ps = [...asuccs(p), ...ps];
}
}
psolve :: State → Maybe [Move]
psolve q = psearch′ [ ] [ ] [([ ], q, goalmoves q)]
Dart
// 抽象版のエントリ:初期プランは goalmoves(q)
List<M>? psolveGeneric<S, M>(
S q,
List<APath<S, M>> Function(APath<S, M>) asuccs,
List<APath<S, M>> Function(APath<S, M>) bsuccs,
Solved<S> solved,
GoalMoves<S, M> goalmoves,
) {
return psearchPrime(
<S>[],
<APath<S, M>>[],
[(<M>[], q, goalmoves(q))],
asuccs,
bsuccs,
solved,
);
}
psolve はプランニングで solve を実装したもの。プランを幅優先に調べる版も作れますが、詳細は読者への宿題です。解があれば必ず見つけますが、最短の保証はありません。
ラッシュアワーを実装する
盤面と車両の表し方
ラッシュアワーの盤面は 6 × 6 の 36 マス。マスには乗用車(2 マス)かトラック(3 マス)が乗っています。横向きの車は左右、縦向きの車は上下にしか動けません。盤面右辺の上から3マス目が出口マスで、その左隣に置かれた特別な車(車両 0)を出口へ動かすのが目的です。
マス番号の付け方(7 の倍数を壁に使う小技)
マスを 2 次元座標ではなく、次のように飛び番号で並べます。
1 2 3 4 5 6
8 9 10 11 12 13
15 16 17 18 19 20
22 23 24 25 26 27
29 30 31 32 33 34
36 37 38 39 40 41
この番号付けのうれしい点
- 左右の壁:7 で割り切れるマス(7, 14, 21, …)が自然に壁になる。
- 上の壁:番号が負のマス。
- 下の壁:42 以上のマス。
- 境界チェックが数式1つで済み、BFS の状態表現が省スペースになる(BFS では大量の状態を持ち回るので効いてくる)。
出口マスは 20 です。
盤面の状態は、車両ごとに「後端マスと前端マスのペア」を並べたリスト。リスト内の位置がそのまま車両番号で、先頭は特別な車両 0 です。図 18.1 の盤面は次のようになります。
g1 = [(17,18),(1,15),(2,9),(3,10),(4,11),(5,6),(12,19),
(13,27),(24,26),(31,38),(33,34),(36,37),(40,41)]
Dart
// 図 18.1 の初期盤面(先頭は特別な車両 0)
final Grid g1 = [
(17, 18), (1, 15), (2, 9), (3, 10), (4, 11), (5, 6), (12, 19),
(13, 27), (24, 26), (31, 38), (33, 34), (36, 37), (40, 41),
];
type Cell = Int
type Grid = [(Cell, Cell)]
type Vehicle = Int
type Move = (Vehicle, Cell)
type State = Grid
Dart
// ラッシュアワー用の型別名
typedef Cell = int;
typedef Grid = List<(Cell r, Cell f)>;
typedef Vehicle = int;
typedef RMove = (Vehicle v, Cell c); // Move は組み込み型と紛らわしいので RMove
占有マス/空きマス/合法手
各車両が占めるマスを埋めていき、マージすれば占有マスの昇順リスト。
occupied :: Grid → [Cell]
occupied = foldr (merge · fillcells) [ ]
fillcells (r, f) = if r > f−7 then [r .. f] else [r, r+7 .. f]
Dart
// 車両1台が占めるマスを列挙。横向きは連番、縦向きは +7 刻み
List<Cell> fillcells((Cell, Cell) rf) {
final (r, f) = rf;
if (r > f - 7) {
return [for (var c = r; c <= f; c++) c]; // 横向き
} else {
return [for (var c = r; c <= f; c += 7) c]; // 縦向き
}
}
List<Cell> occupied(Grid g) {
final s = <Cell>{};
for (final rf in g) {
s.addAll(fillcells(rf));
}
final xs = s.toList()..sort();
return xs;
}
ここで「(r, f) が横向き」なのは r > f−7 のとき、「縦向き」なのは r ≤ f−7 のとき。
freecells :: Grid → [Cell]
freecells g = allcells \\ occupied g
Dart
// 1..41 のうち 7 の倍数(右壁)を除いた全マス
final List<Cell> allcells = [
for (var c = 1; c <= 41; c++) if (c % 7 != 0) c,
];
List<Cell> freecells(Grid g) {
final occ = occupied(g).toSet();
return [for (final c in allcells) if (!occ.contains(c)) c];
}
ここで allcells = [c | c ← [1..41], c mod 7 ≠ 0]。merge と順序付きリストの差 \\ は標準的なので省略します。
moves :: Grid → [Move]
moves g = [(v, c) | (v, i) ← zip [0..] g, c ← adjs i, c ∈ fs]
where fs = freecells g
adjs (r, f) = if r > f−7 then [f+1, r−1] else [f+7, r−7]
Dart
// 各車両の隣接マス(横向きは前後、縦向きは上下)
List<Cell> adjs((Cell, Cell) rf) {
final (r, f) = rf;
return (r > f - 7) ? [f + 1, r - 1] : [f + 7, r - 7];
}
List<RMove> movesGrid(Grid g) {
final fs = freecells(g).toSet();
return [
for (var v = 0; v < g.length; v++)
for (final c in adjs(g[v]))
if (fs.contains(c)) (v, c),
];
}
手 (v, c) が合法なのは、マス c が空いていて、しかも車両 v の現在位置に正しい軸方向で隣接しているとき、その時のみ。1 手はちょうど 1 マス動かす操作です。
手を打つ/盤面を更新する
move g (v, c) = g1 ++ adjust i c : g2
where (g1, i : g2) = splitAt v g
Dart
// 車両 v の区間を adjust で書き換えた新しい Grid を返す(純関数的)
Grid moveGrid(Grid g, RMove m) {
final (v, c) = m;
return [
...g.sublist(0, v),
adjust(g[v], c),
...g.sublist(v + 1),
];
}
adjust (r, f) c
| r > f−7 = if c > f then (r+1, c) else (c, f−1)
| otherwise = if c < r then (c, f−7) else (r+7, c)
Dart
// 車両の区間 (r,f) を、目標マス c の側へ1マスだけずらす
(Cell, Cell) adjust((Cell, Cell) rf, Cell c) {
final (r, f) = rf;
if (r > f - 7) {
// 横向き
return (c > f) ? (r + 1, c) : (c, f - 1);
} else {
// 縦向き
return (c < r) ? (c, f - 7) : (r + 7, c);
}
}
算術は素直なので詳細は省きます。ゴール条件は「車両 0 の前端が出口マス 20 に来た」。
solved :: Grid → Bool
solved g = snd (head g) == 20
Dart
// 車両 0(先頭)の前端 f が出口マス 20 に来た
bool solvedGrid(Grid g) => g.first.$2 == 20;
bfsolve :: Grid → Maybe [Move]
bfsolve g = bfsearch′ [ ] [ ] [([ ], g)]
Dart
// ラッシュアワー用のエントリ関数
List<RMove>? bfsolve(Grid g) {
return bfsolveGeneric<Grid, RMove>(g, movesGrid, moveGrid, solvedGrid);
}
ゴールプランと「じゃま者どかし」
psearch を使うには、goalmoves と premoves を用意すればよい。goalmoves は簡単で、「車両 0 を今いる位置から出口まで一歩ずつ前進させる手のリスト」。
goalmoves :: Grid → Plan
goalmoves g = [(0, c) | c ← [snd (head g) + 1 .. 20]]
Dart
// 車両 0 を今の前端から出口 20 まで、1マスずつ前進させる手順
Plan<RMove> goalmoves(Grid g) {
final start = g.first.$2 + 1;
return [for (var c = start; c <= 20; c++) (0, c)];
}
premoves は、指したい手 m の目標マス c がすでに他の車両で埋まっているときに使う。そのとき、c を含む区間を持つ車両 (v, i) は唯一存在します。それを見つけるのが blocker。
blocker :: Grid → Cell → (Vehicle, (Cell, Cell))
blocker g c = search (zip [0..] g) c
search ((v, i) : vis) c = if covers c i then (v, i) else search vis c
covers c (r, f) = r ≤ c ∧ c ≤ f ∧ (r > f−7 ∨ (c−r) mod 7 == 0)
Dart
// マス c を含む車両(唯一)を探す
bool covers(Cell c, (Cell, Cell) rf) {
final (r, f) = rf;
return r <= c && c <= f && (r > f - 7 || (c - r) % 7 == 0);
}
(Vehicle, (Cell, Cell)) blocker(Grid g, Cell c) {
for (var v = 0; v < g.length; v++) {
if (covers(c, g[v])) return (v, g[v]);
}
throw StateError('no blocker at $c');
}
じゃま者 v(区間 i = (r, f))は、マス c を空けるまで左か右、あるいは上か下に必要な手数だけ動かせばよい。それを列挙するのが freeingmoves。
freeingmoves :: Cell → (Vehicle, (Cell, Cell)) → [[Move]]
freeingmoves c (v, (r, f))
| r > f−7 = [[(v, j) | j ← [f+1 .. c+n]] | c+n < k+7] ++
[[(v, j) | j ← [r−1, r−2 .. c−n]] | c−n > k]
| otherwise = [[(v, j) | j ← [r−7, r−14 .. c−m]] | c−m > 0] ++
[[(v, j) | j ← [f+7, f+14 .. c+m]] | c+m < 42]
where (k, m, n) = (f−f mod 7, f−r+7, f−r+1)
Dart
// じゃま者を c から追い出す準備手の候補(右/左、上/下)を列挙
List<List<RMove>> freeingmoves(Cell c, (Vehicle, (Cell, Cell)) blk) {
final (v, rf) = blk;
final (r, f) = rf;
final result = <List<RMove>>[];
if (r > f - 7) {
// 横向き:長さ n
final n = f - r + 1;
final k = f - f % 7; // 同じ行の右端の壁の左(7 の倍数)
if (c + n < k + 7) {
result.add([for (var j = f + 1; j <= c + n; j++) (v, j)]); // 右へ
}
if (c - n > k) {
result.add([for (var j = r - 1; j >= c - n; j--) (v, j)]); // 左へ
}
} else {
// 縦向き:長さ m = 7*n
final m = f - r + 7;
if (c - m > 0) {
result.add([for (var j = r - 7; j >= c - m; j -= 7) (v, j)]); // 上へ
}
if (c + m < 42) {
result.add([for (var j = f + 7; j <= c + m; j += 7) (v, j)]); // 下へ
}
}
return result;
}
- 横向き(r > f−7):長さ n = f−r+1。c を空けるには、右にずらして c+n まで、または左にずらして c−n まで動かす(盤内に収まる範囲で)。
- 縦向き:長さ n = (f − r) div 7 + 1。上にずらして c−7n まで、または下にずらして c+7n まで動かす(同上)。この場合 m = 7 × n。
premoves :: Grid → Move → [[Move]]
premoves g (v, c) = freeingmoves c (blocker g c)
Dart
// 手 (v, c) を指すために、マス c を先に空けるための準備手
List<List<RMove>> premoves(Grid g, RMove m) {
final (_, c) = m;
return freeingmoves(c, blocker(g, c));
}
要注意:ラッシュアワー用に newplans を作り直す
なぜ改造が必要?
ゴール手が [(0, 19), (0, 20)] だとして、車両 0 が今 [17, 18] にいるとします。(0, 19) の準備手 pms の中に(0, 16)(車両 0 を1マス左に動かす)が混ざっているかもしれない。pms を全部実行したあと、車両 0 は [15, 16] にいるので、(0, 19) は「2 マス一気に前へ」となりもう 1 手では実行不能。だから (0, 19) を [(0, 18), (0, 19)] のように1 マスずつの手に展開し直す必要があります。
newplans :: Grid → Plan → [Plan]
newplans g [ ] = [ ]
newplans g (m : ms) = mkplans (expand g m ++ ms)
where mkplans ms = if m ∈ gms then [ms] else
concat [mkplans (pms ++ ms) |
pms ← premoves g m,
all (∉ ms) pms]
where m = head ms; gms = moves g
Dart
// ラッシュアワー版:先頭手を expand で 1マス刻みに展開してから mkplans
List<Plan<RMove>> newplans(Grid g, Plan<RMove> plan) {
if (plan.isEmpty) return [];
final head = plan.first;
final tail = plan.sublist(1);
final expanded = [...expand(g, head), ...tail];
final gms = movesGrid(g);
List<Plan<RMove>> mkplans(Plan<RMove> ms) {
if (ms.isEmpty) return [];
final m = ms.first;
if (gms.contains(m)) return [ms];
return [
for (final pms in premoves(g, m))
if (pms.every((x) => !ms.contains(x)))
...mkplans([...pms, ...ms]),
];
}
return mkplans(expanded);
}
「無効かもしれない手」を「1 マスずつの有効な手の列」に展開するのが expand。
expand :: Grid → Move → [Move]
expand g (v, c)
| r > f−7 = if c > f then [(v, p) | p ← [f+1 .. c]]
else [(v, p) | p ← [r−1, r−2 .. c]]
| otherwise = if c > f then [(v, p) | p ← [f+7, f+14 .. c]]
else [(v, p) | p ← [r−7, r−14 .. c]]
where (r, f) = g !! v
Dart
List<RMove> expand(Grid g, RMove m) {
final (v, c) = m;
final (r, f) = g[v];
if (r > f - 7) {
// 横向き車両: 1 マスずつ
if (c > f) {
return [for (var p = f + 1; p <= c; p++) (v, p)];
} else {
return [for (var p = r - 1; p >= c; p--) (v, p)];
}
} else {
// 縦向き車両: 7 マスずつ
if (c > f) {
return [for (var p = f + 7; p <= c; p += 7) (v, p)];
} else {
return [for (var p = r - 7; p >= c; p -= 7) (v, p)];
}
}
}
psolve :: Grid → Maybe [Move]
psolve g = psearch′ [ ] [ ] [([ ], g, goalmoves g)]
ここで psearch′ は前節そのままで、ただし newplans だけ上の改訂版に差し替えます。
実験結果
表 18.1 4 つのラッシュアワー問題を解くのに要した時間
| 問題 |
bfsolve |
手数 |
psolve |
手数 |
dfsolve |
手数 |
| 1 | 9.11 | 34 | 0.23 | 38 | 3.96 | 1228 |
| 2 | 4.71 | 18 | 0.04 | 27 | 3.75 | 2126 |
| 3 | 1.70 | 55 | 0.91 | 75 | 0.97 | 812 |
| 4 | 9.84 | 81 | 2.36 | 93 | 2.25 | 1305 |
実測から分かること
- Pentium 3、1000MHz、GHCi 上での実測(単位は秒)。
- psolve は bfsolve より約 2 倍〜100 倍高速。
- ただし psolve は最短保証なしで、手数は BFS より少し多めになる(例:問題 1 は 34 → 38 手)。
- DFS は解を早く見つけることもあるが、手数が桁違いに多い(1000 手超)ので実用的な「解答」としては使えない。
Dart
// 動作確認: 簡易な盤面(車両0が(18,19)にいて、あと1マス右にずらせば
// 出口マス20に到達)を作り、bfsolve を呼び出す。
void main() {
// 盤面: 車両0 = 出口の1マス手前(18-19)。他の車両なし。
// セル番号は 1..41 の 6x7 座標系(7の倍数は右壁で除外)、出口=20。
final Grid g0 = [(18, 19)];
print('初期盤面: $g0');
print('解けている? ${solvedGrid(g0)}'); // false: まだ出口ではない
final moves = bfsolve(g0);
print('BFS 解: $moves'); // [(0, 20)]: 車両0を20へ動かす1手
if (moves != null) {
var g = g0;
for (final m in moves) { g = moveGrid(g, m); }
print('最終盤面: $g, 解けた? ${solvedGrid(g)}'); // true
}
}
むすび
ラッシュアワーが PSPACE 完全である、という結果は Flake and Baum (2002) にあります。著者は 2008 年の Advanced Functional Programming Summer School(Jones, 2008)でラッシュアワーを知り、Mark Jones の講義で「関数的な考え方の威力」に触れたそうです。Jones は BFS 解を示したうえで「もっと速い解を作ってみないか」と参加者に投げかけ、この章はその挑戦への回答として生まれました。
参考文献
Flake, G. W. and Baum, E. B. (2002). Rush Hour is PSPACE-complete, or “Why you should generously tip parking lot attendants”. Theoretical Computer Science 270 (1), 895–911.
Jones, M. P. (2008). Functional thinking. Advanced Functional Programming Summer School, Boxmeer, The Netherlands.