Maze solving is a classical search problem, involving the following basic concerns:
- Generating one or more solution 'paths'. Where...
- Each path is a list of visited locations from the Start to the Exit. Where...
- At each step, we explore the possible next steps in some order of preference and we never consider moves that would cycle back to a previously visited location. Where...
- Next steps can be generated from the current location along the path by sampling all the locations around the current location and determining which adjacent locations are navigable (not a Wall!)
So the algorithm is basically a multistep 'generate and test'.
The most basic ability to provide our virtual mouse, is the ability for it to determine which adjacent locations can can be visited. For a given x y location, we will need to consider the eight possible adjacent locations (we'll allow our mouse to move vertically, horizontally and diagonally around the grid to any non-Wall location).
What are the rules for generating 'legal' pairs of (x,y) move-candidate locations around a current location?
- The location can only be -1, 0 or 1 squares removed in either x and y coordinates.
- ... but the location may not be 0 squares removed in both x and y, as this is the current location.
- The location must be legal in the maze (no smaller than 0 x or y, and smaller than width in x, smaller than height in y).
- The location must not be a Wall
Given that we're generating another list (of candidate locations around the mouse), this is another job for a list comprehension. Here's the code:
possibleMoves maze x y = [(x',y') | x' <- [x, x - 1, x + 1], y' <- [y, y - 1, y + 1],
not (x' == x && y' == y),
x' >= 0, y' >= 0, x' < mazeXMax, y' < mazeYMax,
materialAt maze x' y' /= Wall
(mazeXMax, mazeYMax) = mazeSize maze
Recall that a list comprehension has list constructor syntax (square brackets), with the first term in the brackets (up to a vertical bar) being the constructor for individual elements of the list. So, we're going to be building a list of (x',y') pairs. The apostrophes perhaps look unusual, but they are just characters that Haskell allows in identifiers that are borrowed from mathematics. You can read them as "derived" i.e. we're constructing a pair "derived x" and "derived y". Mathematicians call the apostrophe "prime", by the way, e.g. x' is pronounced "x-prime".
The rest of the clauses in the list comprehension match the rules we outlined above, that generate and test possible new locations, given the original location x y that are provided as arguments.
To begin with, we generate possible derived x and y candidates by taking the current coordinate, the immediately preceding coordinate (one space to the left/up) and the immediately succeeding coordinate (one space to the right/down).
Then, we ensure that this candidate location is not the current location (where x' == x and y' == y).
Next, we check that this location lies properly within the bounds of the maze. We use names "mazeXMax" and "mazeYMax" that are bound in a where clause at the end of the function definition, again using our mazeSize function to obtain this. This is another potential inefficiency, as we'll be recalculating the maze size every time possibleMoves gets called, but this doesn't cost that much ;-)
Finally, we test to ensure that the candidate location is not a Wall using materialAt. In the current implementation, only the Wall Material is not traversable in the maze.
Now, our mouse can 'see' around him and discern possible directions to travel in the maze. However, he still has no idea which direction might be best. We could provide no distinction at all between the possible moves the mouse could make, in which case the mouse would have to make an essentially 'random walk' through the maze (albeit being able to refrain from doubling back on himself). Alternatively, we could provide the mouse with some ability to sense a better direction to travel - perhaps he can smell the pile of cheese lying just beyond the maze exit, or maybe he has some general sense of the direction of the exit.
Let's give the mouse a general sense of direction to the Exit, rather than model any particular physical manifestation that might indicate the direction of the exit. We'll allow our virtual mouse to know an approximate distance to the Exit from any location (thus, the possible locations of a move). This will help discriminate between possible moves, but it will not help the mouse avoid the obstruction of maze walls, as distance is independent of such obstacles.
OK, so let's create a distance function. This will return some distance metric for any two locations (given by two pairs of x,y coords). A true distance would involve the full Pythagorean equation, e.g.:
distance = sqrt(deltaX^2 + deltaY^2)
However, we don't really care about the exact distance. We only need a distance score with which we can order a set of locations. Consequently, it is sufficient to use:
distanceSqr = deltaX^2 + deltaY^2
or frankly, even the sum of absolute distances in x and y would do.
or frankly, even the sum of absolute distances in x and y would do.
The actual code should be pretty straightforward by now:
distanceSqr (x,y) (x',y') = deltaX * deltaX + deltaY * deltaY
deltaX = x - x'
deltaY = y - y'
Note that we're again unpacking two data structures (the coordinate pairs) in the argument list (LHS) of the function definition.
Now that we have our distance function, we can specialise possibleMoves with a new function orderedMoves that will sort the locations from possibleMoves using their distance from the Exit.
orderedMoves maze x y = sortWith (\location -> distanceSqr location $ exitLocation maze) $
possibleMoves maze x y
possibleMoves maze x y
The sortWith function uses the result of a provided function as the property on which to sort any element (in this case a location). Sorting will be ascending, which is what we want. The nearest locations will end up toward the beginning of the resulting list, and the more distant locations at the end.
Armed with some orderedMoves to try on each step along the path toward the Exit, we're now ready to generate paths through the maze. We'll create a function that returns an arbitrary number of possible paths that the mouse could take given the navigation rules we're coding. Again, this will be a 'generator' kind of function, and later on we can write functions that, for instance, just take the first path found, or take some number of generated paths and return the shortest.
Let's write the type declaration for a function mousePaths:
mousePaths :: Maze -> Int -> Int -> [[(Int, Int)]]
This says that mousePaths will take a maze, two integers (actually an x and y starting location) and will return a list of lists of pairs of integers. The inner list here is of course a path (a list of locations defined as (x,y) coordinates).
Actually, that last type is a bit ugly, but we can clean it up by saying that the inner part has a name "Path":
type Path = [(Int, Int)]
in which case the type of mousePaths can be rendered:
mousePaths :: Maze -> Int -> Int -> [Path]
... which is a lot more readable.
Let's now think about how to generate paths.
Clearly, a path can be constructed by recursively choosing a new location from the end of the current path. If the initial path is just the starting location, then we'll 'grow' a path from this point, using orderedMoves to select a new location from the last location.
One important rule that we'll build into the path generation is that we won't let a path double back on itself. This is easily achieved by disallowing any proposed new location that already exists in the path list we've already build (the locations previously visited).
We know when we have a new complete path to return when the next location is in fact the exit location.
Here's a definition for mousePaths, that captures the above strategy:
mousePaths maze x y =
exit = exitLocation maze
mousePaths' path = do
let (thisX, thisY) = head path
next@(nextX, nextY) <- orderedMoves maze thisX thisY
guard $ not $ elem next path
let newPath = next : path
if next == exit then return newPath else mousePaths' newPath
map reverse $ mousePaths' [(x,y)]
A lot of the discussed rules should be recognisable right away, but there are a few things to pull out for discussion.
The mousePaths function is provided with the maze and an initial x and y value. We need to define a recursive 'helper' function that deals exclusively in the paths that are being built (again, a path being a list of (x,y) location coordinates). This local helper function (mousePaths') is defined in a let block along with a definition to obtain the exit location from the given maze.
The outer function, simply kicks off the search for mouse paths by calling mousePaths' with an initial path containing only the current location, constructed from the starting x and y coords provided as arguments. Because paths are built up as lists (with the last location visited being the head of the list) we will need to reverse any paths that are returned by the mousePaths function; hence the "map reverse" applied to the list of paths returned by mousePaths'.
Clearly, the real meat of this function lies with the helper function mousePaths'. While the type of this function is not provided in the source, it is quite interesting:
mousePaths' :: Path -> [Path]
You can copy this type declaration into the source above the definition of mousePaths' if you want to verify this (or indeed to better annotate the source!).
This means that for any Path that this function is given, it will return a bunch of potential Paths. In other words, mousePaths' represents the strategy of generating alternative new Paths, given some rules, from some initial Path. In this case, we'll be extending the initial Path with new locations from the orderedMoves list on each iteration.
As a side note, this is an example of a branching computation. From some initial 'seed' path, containing only the starting location, we are continually splitting into new Paths one per legal move, each of which in turn split again (on recursion). Thus, we 'concurrently' explore all the possible paths that are allowed by the branching rules.
The body of the mousePaths' helper function defines the branching rules for exploring multiple Paths. First, we simply get the last location of the current path we're processing. This is the head of the path list. The coordinates thisX and thisY are extracted from the location coordinate pair.
Now we need to determine the potential next locations to which we could move. We use orderedMoves to do this, so that the 'best' locations are tried first. Note that the left arrow in this line works exactly the same as the left arrow in a list comprehension (in fact we're really doing another list comprehension, just in a different format!). Each item returned by orderedMoves will therefore be bound to symbols on the left hand side of the arrow, which in this case is a pattern match to extract the location details nextX, nextY and well as preserving the original location pair returned by orderedMoves as next. The commercial-at sign (@) used in pattern matching simply allows us to bind a name for the whole value, at the same time that we use pattern matching to extract details from the value.
Just like the earlier list comprehensions, once we have a candidate value for returning, we can test to see if it conforms to some rules. In this case, the only extra criterion is that the new location must not be any of the locations previously visited so far in this path. To do that, we simply assert that the next location is not an element (elem) of the current path. The 'elem' function is from the standard list library. Whereas we were able to express predicates like this directly within a list comprehension, outside of this we must use the guard function to apply a predicate and decide whether to reject the current proposed next location (and therefore path under construction). The guard function is from the standard Control.Monad library.
If our proposed next location survives the guard, then we consider it fit for extending the current path. Commensurately, we build a newPath value by prepending the existing path with the next location. Again, we're building up the path at the head of the list, because lists are only cheaply modifiable in their initial value.
Having determined the new path, all that remains is to decide whether we have 'arrived' at the Exit location. If we have then this defines a complete path, which is returned as a 'result' Path from mousePaths'. Otherwise, this partially constructed path to the Exit still needs further 'moves' to extend it to the Exit. Of course, the function that extends any single Path is mousePaths' itself, so we just make a recursive call, but with newPath.
Hopefully, you were able to follow all the logic here, but it may take a few reading passes.
There is a little 'magic' operating beneath the hood here, but it's exactly the same as a list comprehension. This magic is simply that lists in Haskell belong to a Type Class that implements a branching computation strategy, where a single initial value (e.g. Path) can expand into a list of values (e.g. Paths). The 'guard' function requires this membership, as it is used to prune away rejected paths (which are represented as empty lists). We only ever get to see lists in the result that have not been 'rejected' in this way. In any case, this behaviour (and of course list comprehensions) are absolutely standard features of Haskell and its core libraries.
The final addition in this episode will be to simply wrap the mousePaths function with function that provides the start location and only returns a set number of Path solutions.
mousePathsFromStart :: Maze -> Int -> [Path]
mousePathsFromStart maze n = take n $ mousePaths maze startX startY
(startX, startY) = startLocation maze
There shouldn't be anything surprising in this definition, given the foregoing.
So, that's a wrap.
In the next instalment, we'll add some console IO to plot mazes and the movement of the mouse step-by-step.