Rush Hour from ThinkFun is a nice logic puzzle where you need to move back and forth horizontally or vertically placed cars so that you can move your own car to the exit.
The board is a 6x6 grid; the exit is at the right wall on the 3rd
row, i.e., near (6,3). All cars are labeled with a letter,
x
is yours. The cars labeled o
,
p
, q
and r
are trucks that occupy
three consecutive cells, all other cars cover only two.
A typical puzzle looks like this:
1 2 3 4 5 6
+-----------+
1 |o . . a . .|
2 |o . . a b b|
3 |o x x c . .
4 |. . p c d d|
5 |. . p e e f|
6 |. . p . . f|
+-----------+
The objective is to get x
out with a minimal number of
car movements. Moving one car 2+ cells forward or backward is regarded
as a single move, but cars shouldn’t move more than they need to.
This suggests a breadth-first search solution. Since it is easy to produce the same board with different sets or permutations of moves, it is imperative that we take precautions not to visit the same state twice. This is easily done with dynamic rules, as we will see.
The state of the board consists of the cars’ positions. Unfortunately cars with the same letter can occur both horizontally or vertically, so we need to store the orientation, as well. A car would be represented by
car(Name, Orientation, Position)
where Name
is a single letter, Orientation
is either h
or v
(for horizontal and vertical,
respectively), and Position
is an X-Y
pair of
coordinates describing the anchor position (the
topmost/leftmost cell) of the car. For example, car p
in
the diagram above would be represented by car(p,v,3-4)
.
The board is a sorted list of cars - sorting is needed because we need to test for equivalence between states.
First we setup a dynamic rule to account for the boards seen so far:
:- dynamic(seen/1).
Our main rule first clears seen/1
of remaining data from
previous runs, sorts the given board (so the input can be unsorted), and
calls the solver. The moves will be accumulated in reverse order, which
will be corrected and printed on the screen:
Puzzle) :-
rush(retractall(seen(_)),
Puzzle, Board),
sort(Board,[])], Moves),
solve([state(Moves, Solution),
reverse(Solution). show(
The solver takes a queue of state(Board,Moves)
structures containing a state of the board and the moves leading to that
state, and returns the solution as a (reversed) list of moves:
Board,Moves)|_], Moves) :-
solve([state(,h,5-3), Board).
member(car(xBoard,Moves)|Queue], Solution) :-
solve([state(Board1,[Move|Moves]),
findall(state(Board, Move, Board1), List),
move(Queue, List, Queue1),
append(Queue1, Solution). solve(
The first rule says that if car x
is at (5,3)
then we are done. The second rule looks at all possible moves from the
first state in the queue, and generates a list of corresponding next
states, which are all added to the end of the queue, then recurses.
The move(Board, Move, Board1)
rule is true if
Move
gets from Board
to Board1
.
For efficiency (and for taking care of cycles in the graph), it does
some additional bookkeeping: it only allows moves not seen so far, and
also records the new states as seen. (Yes, this harms transparency, but
it is much more convenient than the alternatives.)
Board, move(Name,Dir,Len), Board1) :-
move(Name,Ort,_), Board),
member(car(Ort, Dir),
direction(1, Board, Name, Dir, Candidates),
try(Len-Board0, Candidates),
member(\+ seen(Board1),
asserta(seen(Board1)).
First it takes a car from the board with a given orientation
(Ort
), chooses an applicable direction, and tries to move
it. The try/5
rule tries to move the car as far in the
given direction as it can, returning a list of candidate length-board
pairs. We then take one of these, check if the board is yet unvisited,
and then record it in seen
.
It may occur to the reader that it would be simpler if the move
length was also generated here with between(1, 5, Len)
, and
then try
should only check if that concrete move is valid
or not. Turns out that this would be more complicated, because
intervening steps should be checked in any case.
Choosing an appropriate direction is trivial:
, left).
direction(h, right).
direction(h, up).
direction(v, down). direction(v
The most complex rule in this program may be try/5
:
N, Board, Name, Dir, [N-Board1|Candidates]) :-
try(N < 6, N1 is N + 1,
Name,Ort,Pos), Board, Board0),
select(car(Pos, Dir, Pos1),
step(\+ invalid(Board0, Name, Dir, Pos1),
Name,Ort,Pos1)|Board0], Board1),
sort([car(N1, Board1, Name, Dir, Candidates), !.
try(_, _, _, _, []). try(
It takes out the named car from Board
, and moves it one
place in the given direction. It then checks that the new position is
not invalid, adds it to the board (retaining sortedness), and tries to
move further. When it cannot, it returns with an empty list of
candidates.
Taking one step is easy:
X-Y, left, X1-Y) :- X1 is X - 1.
step(X-Y, up, X-Y1) :- Y1 is Y - 1.
step(X-Y, right, X1-Y) :- X1 is X + 1.
step(X-Y, down, X-Y1) :- Y1 is Y + 1. step(
A single step can be invalid only if the newly occupied position is either (i) outside the board, or (ii) already covered by another car.
First we need to compute the newly occupied position from the new anchor position:
_, Pos, left, Pos).
front(_, Pos, up, Pos).
front(Name, X-Y, right, X1-Y) :- size(Name, S), X1 is X + S - 1.
front(Name, X-Y, down, X-Y1) :- size(Name, S), Y1 is Y + S - 1.
front(
Name, 3) :- member(Name, [o,p,q,r]), !.
size(_, 2). size(
The two cases of invalidity listed above are simple enough to formulate:
Board, Name, Dir, Pos) :-
invalid(Name, Pos, Dir, X-Y),
front(X < 1 ; X > 6 ; Y < 1 ; Y > 6 ;
( Car, Board), occupies(Car, X-Y)
member(. )
Finally we need to check if a car occupies a given cell:
Name,h,X1-Y), X-Y) :-
occupies(car(Name, S), X1 =< X, X =< X1 + S - 1.
size(Name,v,X-Y1), X-Y) :-
occupies(car(Name, S), Y1 =< Y, Y =< Y1 + S - 1. size(
We will generate an output similar to the solution on the back of the puzzle cards: a move is shown as the car label followed by an arrow and the number of cells to move. We separate moves by a comma, and conclude the output by a newline:
.
show([])Name,Dir,Len)|Moves]) :-
show([move(Dir, Char),
dir_char(write(Name), write(Char), write(Len),
Moves = [] -> nl
( ; write(', ')
,
)Moves). show(
Unicode arrow characters are assigned by dir_char/2
:
, '←').
dir_char(left, '↑').
dir_char(up, '→').
dir_char(right, '↓'). dir_char(down
Puzzle #1:
?- rush([car(o,v,3-1),car(a,h,5-1),car(x,h,1-3),car(p,h,1-4),
car(q,v,6-4)]).
a←1, q↑3, p→3, o↓3, x→3, o↑3, p←1, q↓3, x→1
Puzzle #26:
?- rush([car(o,v,1-1),car(x,h,2-3),car(a,v,4-1),car(b,h,5-2),
car(c,v,4-3),car(d,h,5-4),car(p,v,3-4),car(e,h,4-5),
car(f,v,6-5)]).
o↓3, x←1, p↑3, e←2, c↓2, d←3, c↑2, f↑2, e→3, c↓2, d→2, p↓3, ⤸
x→3, o↑3, p↑3, d←3, p↓3, x←2, a↓2, b←3, a↑2, f↑2, x→3
Puzzle #40:
?- rush([car(a,v,1-1),car(b,h,2-1),car(c,h,3-2),car(d,v,5-2),
car(e,v,3-3),car(f,h,1-4),car(g,h,4-4),car(h,h,2-5),
car(i,v,4-5),car(o,h,4-1),car(p,v,6-4),car(q,h,1-6),
car(x,h,1-3)]).
c←1, h←1, e↓1, x→1, a↓1, b←1, o←1, p↑3, g→1, i↑3, g←1, p↓1, ⤸
o→1, b→1, a↑1, q→3, x←1, e↑1, h→3, e↓2, f→1, x→1, a↓4, b←1, ⤸
c←1, f←1, o←1, p↑1, x←1, e↑3, g←1, h←2, q←2, d↓3, g→2, f→1, ⤸
a↑1, q←1, i↓3, g←1, p↓3, o→1, e↑1, x→4
:- dynamic(seen/1).
Puzzle) :-
rush(retractall(seen(_)),
Puzzle, Board),
sort(Board,[])], Moves),
solve([state(Moves, Solution),
reverse(Solution).
show(
Board,Moves)|_], Moves) :-
solve([state(,h,5-3), Board).
member(car(xBoard,Moves)|Queue], Solution) :-
solve([state(Board1,[Move|Moves]),
findall(state(Board, Move, Board1), List),
move(Queue, List, Queue1),
append(Queue1, Solution).
solve(
Board, move(Name,Dir,Len), Board1) :-
move(Name,Ort,_), Board),
member(car(Ort, Dir),
direction(1, Board, Name, Dir, Candidates),
try(Len-Board1, Candidates),
member(\+ seen(Board1),
asserta(seen(Board1)).
N, Board, Name, Dir, [N-Board1|Candidates]) :-
try(N < 6, N1 is N + 1,
Name,Ort,Pos), Board, Board0),
select(car(Pos, Dir, Pos1),
step(\+ invalid(Board0, Name, Dir, Pos1),
Name,Ort,Pos1)|Board0], Board1),
sort([car(N1, Board1, Name, Dir, Candidates), !.
try(_, _, _, _, []).
try(
, left).
direction(h, right).
direction(h, up).
direction(v, down).
direction(v
X-Y, left, X1-Y) :- X1 is X - 1.
step(X-Y, up, X-Y1) :- Y1 is Y - 1.
step(X-Y, right, X1-Y) :- X1 is X + 1.
step(X-Y, down, X-Y1) :- Y1 is Y + 1.
step(
Board, Name, Dir, Pos) :-
invalid(Name, Pos, Dir, X-Y),
front(X < 1 ; X > 6 ; Y < 1 ; Y > 6 ;
( Car, Board), occupies(Car, X-Y)
member(.
)
_, Pos, left, Pos).
front(_, Pos, up, Pos).
front(Name, X-Y, right, X1-Y) :- size(Name, S), X1 is X + S - 1.
front(Name, X-Y, down, X-Y1) :- size(Name, S), Y1 is Y + S - 1.
front(
Name,h,X1-Y), X-Y) :-
occupies(car(Name, S), X1 =< X, X =< X1 + S - 1.
size(Name,v,X-Y1), X-Y) :-
occupies(car(Name, S), Y1 =< Y, Y =< Y1 + S - 1.
size(
Name, 3) :- member(Name, [o,p,q,r]), !.
size(_, 2).
size(
.
show([])Name,Dir,Len)|Moves]) :-
show([move(Dir, Char),
dir_char(write(Name), write(Char), write(Len),
Moves = [] -> nl
( ; write(', ')
,
)Moves).
show(
, '←').
dir_char(left, '↑').
dir_char(up, '→').
dir_char(right, '↓'). dir_char(down