Problem Set 9: Flow and Friends
Assigned: Tuesday December 1, 2015
Due: Friday December 11, 2015
Points: 12 Points up to 16 Points
Thanks to Prof. Bill Ames for suggesting this problem.
This is a pair project. You can work with one partner
if you would like to. If you would like an assist in finding a partner
you can ask a course staffer or you can use the Piazza partner finding
tool.
The game of Flow involves
working with a 2D grid of cells. Some cells are initially empty
while others contain colored circles.
|
        |
|
The object of the game is for the player to connect all of the
like-colored circles leaving no empty cells. The connections formed
between two like-colored
circles are made up of sequences of "line pieces" of the matching
color.
This problem set involves writing an OCaml program that solves
a given flow grid.
This problem set has plenty of room for extra credit. See below.
Overview
This problem is an example of a
search problem:
your progam will implement an algorithm that searches for an ordering of
pieces in the open cells that solves the problem.
Pieces
There are 7 piece shapes:
   
   
   
   
   
   
The images of the pieces that you see above are just gif/png
images. These images are provided and managed by the harness code. In
solving this problem, your code can manipulate pieces using the
functions provided in the harness code. These are described below.
Of the 7 pieces, only the first 6 are playable by the
program --- the circular end-pieces are fixed at the outset.
Since pieces come in 9 different colors, there are a total of 7 x 9 =
63 different pieces and 6 x 9 = 54 different playable pieces. To get
an idea of the stupendous size of the search space, since there are 6
x 6 = 36 cells, there are 6336 possible configurations of
pieces on the board. This number is reasonbly close to (in fact,
larger than) 6432 = (26)32 =
2192. Now that's clearly a big number but it's hard to get
intuition about it so here's what it is:
2192 = 6277101735386680763835789423207666416102355444464034512896
So any program that is going to sort through all sequences of 36
items, each of which can hold 63 values, well, it's going to take
a while.
Fortunately, there are many factors working in our favor that cut
down the search space to something actually manageable. First, there
are only 54 playable pieces and there will be somewhat fewer than
36 slots --- some of them will be occupied by circles. This may
reduce it to something on the order of 5430, still
intractable. But there are significant physical and color constraints
that greatly restrict the search space. For example, lets say there
is an orange corner in the upper left cell:
Then only 3 of the 63 pieces would fit in the cell to the right of
it:
   
   
Just this one constraint eliminates 60 * 6334 cases.
And moreover, our Flow program isn't trying to inspect all solutions,
it should quit when it finds the first sequence of pieces that solves
the problem. With these constraints, our search space will be down to
a manageable few 10s of thousands of configurations to be tried.
Backtracking
The Flow problem can be solved using a
backtracking algorithm. The idea is to work our way through
the board, visiting empty cells from left to right, top to bottom.
For each empty cell, we'll try each piece to see if it is
compatible with the ones already placed. If it is, it will be
placed on the board and the placement will be recorded on a stack
recording the play. In particular, the stack should record the
following information for each of the decision points:
- The (row, col) of the piece played,
- The pieces that have not yet been tried
at (row, col).
The harness code defines the following two types to support this:
type decision = {row:int; col:int; pieces:Piece.t list}
type stack_t = decision Stack.t
The application of the algorithm here is not unlike the one that we
discussed in solving the 8 Queens problem. (Code solving the 8
Queens problem will be posted on
the Notes page.) Like the 8
Queens problem, we're concerned with placing pieces in a 2D grid. In
that problem there was only one piece, a queen, and we needed only one
queen per row. In this problem there are many pieces and every cell
needs to be filled.
Getting Started
Download the harness code and unpack
it. Executing an ls in a unix shell reveals the following files:
Makefile* board.mli images/ piece.mli util/
board.ml flow.ml piece.ml solved*
- The file flow.ml is a harness for the main part of the
program, it implements the control and interacts with
the World.big_bang function to update and display the board
and to determine when to quit.
- The pair of files piece.mli and piece.ml are
(resp.) a module signature and a module structure
implementating an OCaml module named Piece. A module
signature is a specification of what is required in an
implemention of the module. Together, the signature and structure
define a new type -- an abstract data type (ADT) for pieces.
The OCaml compiler will check to ensure that implementation
file (e.g., piece.ml) contains everything specified in
its companion interface file (e.g., piece.mli).
- The pair of files board.mli and board.ml are
(resp.) the signature (or specification) and structure (or
implementation) of a Board module, an ADT for the board.
- The file solved is an executable of a solution. You can
run it by type >./solved in the unix shell and then clicking
on the window to start it.
- The folders images/ and util/ contain support code
that you won't need to modify.
Your modifications will be restricted to the implementation
files: piece.ml, board.ml and flow.ml.
In particular, your tasks include:
- To finish the implementation of the Piece ADT in
file piece.ml. All of the definitions specified
in piece.mli are provided in the harness file
piece.ml except for those marked with (* ** *)
below.
type t
val empty : t
val wall : t
val isEmpty : t -> bool
(*
* The call (isEmpty piece) returns true if the piece is empty.
*)
val isWall : t -> bool
(*
* The call (isWall piece) returns true if the piece is a wall.
*)
val toInt : t -> int
val pieceName : t -> string
val isEndPiece : t -> bool
val getEndPiece : int -> t
val makePieces : unit -> t list
val getPlayablePieces : t list -> t list
val goesUp : t -> bool
(*
* The call (goesUp piece) returns true if the piece goes upward.
* See the code.
*)
val goesDown : t -> bool (* ** *)
val goesLeft : t -> bool (* ** *)
val goesRight : t -> bool (* ** *)
val isAllowedAbove : t -> t -> bool
(*
* The call (isAllowedAbove piece1 piece2) returns true if piece1
* is allowed above piece2.
*)
val isAllowedBelow : t -> t -> bool (* ** *)
val isAllowedLeft : t -> t -> bool (* ** *)
val isAllowedRight : t -> t -> bool (* ** *)
NB: you can test the implementation of the Piece module
independently of the other parts by compiling it from a unix command shell
as in
> ocamlc piece.mli piece.ml
- To implement the Board ADT in the file
board.ml. The required definitions are specified
in the signature file board.mli:
type t
val get : t -> int -> int -> Piece.t
(*
* The call (get board row col) returns the piece at (row, col).
*)
val put : Piece.t -> t -> int -> int -> unit
(*
* The call (put piece board row col) puts piece at (row, col).
*)
val make : (t -> t) -> int -> t
(*
* The call (make sample n) makes a new board of size nxn initializing
* all cells to Piece.empty and placing walls on all sides. Then the
* sample function is applied to install endpoints.
*)
val print : t -> unit
(*
* The call (print board) prints the board to stdout.
*)
Note that none of the definitions specified in the
signature file board.mli are provided in the harness
file board.ml, you'll have to implement the whole thing.
The key step in this subtask is to define a type t to
represent the board. I suggest that you use a 2D array
of Piece.t, i.e., the definition of the type
t in your board.ml file might be:
type t = (Piece.t array) array
...
Then the get, put, make and print
functions can be implemented.
NB: you can test the implementation of the Board module
independently of the flow.ml code by compiling it from the unix
command shell as in
> ocamlc piece.mli piece.ml board.mli board.ml
- To implement several of the control functions in the main program
flow.ml. The flow.ml file defines several new types
to support the control code:
type decision = {row:int; col:int; pieces:Piece.t list}
type stack_t = decision Stack.t
type state_t = Start | Go
type world_t = {board:Board.t; stack:stack_t; solved:bool; state:state_t}
The top-level task is to implement the function
val move : world_t -> Piece.t list -> world_t World.t
Given the call (move world pieces), the move function
attempts to find the next open cell to make a play. If there are no
open cells left, then the game is solved and the move function
can immediately return a new version of world with
the solved field having the value true.
If there is an open cell is at (row, col), then the
move function should try each playable piece in turn to see
if they are compatible with the pieces already placed on the board
(i.e., on world.board).
If such a piece is found, use the Board.put function to place
the piece on the board at (row, col), record the row,
column and remaining pieces on the stack (i.e., on world.stack)
so that this decision can be revisted later, if needed, and then
return the resulting world.
If no compatible piece is found, then we're going to have to use the
information stored in the stack to backtrack to the most recent
decision point. If the stack is empty, there are no previous decision
points -- the game is unsolvable. Otherwise pop the previous
decision {row; col; pieces} from the stack,
place Piece.empty in the board at (row, col) and
then recursively call move to reconsider the decision made at
cell (row, col).
In implementing the move function, we'll depend on several
other functions. Implement:
- val findFirstOpenSpot : Board.t -> (int * int) option
The call (findFirstOpenSpot board) starts from
row = 1, col = 1, and scans the board from
left to right, top to bottom until the first cell
containing open is found. If the first occurrence
of open was at indices (row, col),
then Some(row, col) is returned. if no open spot is found
then findFirstOpenSpot returns None. Hint: use
the provided next function to index through the indices.
-
val tryAllPieces : Piece.t list -> int -> int -> (Piece.t * Piece.t list) option
Given the call (tryAllPieces pieces row col), the
tryAllPieces function should index through the provided
pieces, trying each in turn on the board. By "trying each", we
mean to use the Board.put function to (tentatively) place
the piece on the board and then check the compatability. (More on
compatibility below.) If the piece checks out, it can be left on
the board and then tryAllPieces should
return Some(piece, pieces) where piece is the
compatible piece and pieces is the list of remaining ones
that haven't (yet) been tried.
Note that the type given for tryAllPieces above assumes
that it is defined within the scope of a variable providing access
to the board. If you'd prefer to define this function at the top
level, you'll have to provide the board as an additional parameter.
Returning to the question of whether or not the tentative placement of
a piece is OK, the harness code comes with a function that performs the
check:
(* isOK : Board.t -> int -> int -> bool
*
* The call (isOK board row col) returns true if the current cell
* checks out and all of the end pieces on the board are OK.
*)
let isOK board row col = (checkCell board row col) && (endPieceEntriesOK board)
A piece is OK if it checks out with respect to its immediate neighbors
and if it hasn't interfered with any of the end pieces on the board.
These properties are checked by the checkCell and
endPieceEntriesOK functions. Implement these functions.
- val checkCell : Board.t -> row -> col -> bool
Given the call (checkCell board row col), is the piece
at (row, col), i.e., is
(Board.get board row col), compatible with all of the
neighbors? Well, it suffices to show that the piece above it
is
Piece.isAllowedAbove, the piece below it is
Piece.isAllowedBelow and so forth.
- val endPieceEntriesOK : Board.t -> bool
The call (endPieceEntriesOK board) determines whether or not
all of the end pieces in board are compatible
with the cells around them.
- val endPieceEntryOK : Board.t -> row -> col -> bool
The call (endPieceEntryOK board row col) determines
whether or not the end piece at (row, col) is
compatible with the cells around it. It suffices to show that
it has zero or one piece entering it from neighboring cells. If
it has zero entering pieces it must otherwise have some empty
neighbors.
Our Algorithm is Broken!
The algorithm that we are using has a logical error in it that allows
for "solutions" like the following:
We don't expect you to solve this problem.
Extra Credit
Here are a few ideas.
- (1 Point) Count the number of pieces tried and print the
result when the board is solved.
- (2 Points) Our simple solution tries playable pieces of
colors that don't match the colors of circles on the board.
They cannot possibly work. Implement the code so that it
doesn't try pieces that cannot appear in a solution.
- (LOTS of Points) Solve the problem cited above relating
to lines that do not end at circles.
- (Middling Points) Develop an algorithm that solves the
board more efficiently that the naive backtracking algorithm.
|