CMSC15100 Autumn 2010: Project 3

Due Wednesday, December 8 at 10pm.


Setup

This assignment should be done using the Advanced student language.

Overview

In this project you will implement an animated version of the A* search algorithm. A* is a classic Artificial Intellegence algorithm that is often used for puzzle solving and for pathfinding in computer games. You will use A* to find a path through a maze.

This project will require significant use of imperative data structures in your implementation.

Make sure that your submitted solution compiles; i.e., that there are no syntax errors or undefined variables. Submissions that do not compile will get 0 points!!!

The A* algorithm

The A* algorithm is a directed search algorithm that uses a best-first strategy to find the lowest cost path from a start node to a goal node. The algorithm works by partitioning the graph nodes into three sets:

  1. Closed nodes are nodes for which the algorithm has already determined the shortest path from the start node.
  2. Open nodes are nodes that are neighboring closed nodes, but which have not yet been expanded. These nodes are the frontier of the search.
  3. Unknown nodes are all other nodes.

Initially, the set of closed nodes is empty, the set of open nodes consists of the start node, and all other nodes are unknown.

The open nodes are kept in a priority queue, which is ordered by the cost function f(nd) = g(nd) + h(nd), where

For this project, the cost of moving from a node to its neighbor is one, thus g(nd) measures the length of the path from the start node to nd.

A single step of the A* algorithm proceeds as follows:

  1. If the set of open nodes is empty, then the search has failed.
  2. Otherwise, remove the open node (nd) that has the lowest value of f(nd) from the open set.
  3. Mark nd as closed.
  4. If nd is the goal node, then the search is successful.
  5. Otherwise, for each neighbor nd' of nd do the following:
    1. If nd' is unknown, then add it to the open set
    2. In nd' is an open node and g(nd)+1 < g(nd'), then we have found a shorter path to nd' and we need to update it.
    3. otherwise do nothing

Program organization

Your program will consist of four parts: the concrete representation of the part of the graph that the algorithm has explored, the world data structure that holds the state of the search, the code to implement a single step of the A* search, and the user interface. We describe each of these parts below.

The graph

Your implementation of A* will be searching over an abstract graph that is defined by a maze data structure (see below). Each node in the graph corresponds to a tile in the maze. You will need to define additional data structures to represent the part of the graph that has been explored.

Tile node status

A tile-node's status is one of 'open, 'closed.

Tile nodes

A tile-node consists of the tile's location (location), status (tile-node-status), cost or g() value (num), and previous node (one of #f or tile-node). Note that by traversing the previous node links from the goal node, we can determine the path that was discovered by the search.

The open set

The open set is represented by a priority queue of tile-nodes (see below). As described above, the priority function is the sum of the cost of the path from the start to the node, which is stored in the node, and a heuristic estimate of the cost of the path from the node to the goal. For A* to return the shortest path, it is necessary for the heuristic to be admissible, which means that it cannot over estimate the cost. We will use the Manhattan distance to the goal as our heuristic. This heuristic is both admissible and monotonic (or consistent); this latter property allows us to use a simpler version of the A* algorithm that does not reopen closed nodes.

Known nodes

We will not keep a representation of the closed nodes; rather, we will keep track of all of the nodes that have been visited (i.e., the union of the open and closed sets). For this purpose, we will use a hash table that maps the location of a tile to the graph node that represents it.

The world

A world consists of five fields:

  1. a maze,
  2. a priority queue of open nodes,
  3. a hash table that maps locations to tile nodes,
  4. the count of the number of A* steps taken so far, and
  5. the current world mode.

where the world mode is one of 'stepping, 'running, 'success, or 'stuck.

You should define two helper functions on worlds. The first is used to detect when the simulation has finished; either because it is successful or because it failed.

;; world-done? : world -> boolean ;; return true if the search is finished; i.e., the mode is 'success or 'stuck. (define (world-done? wrld) ...)

The second function is used to create and initialize a new world object for a maze. This function is also responsible for initializing the A* search (i.e., creating an initial tile-node for the start location and including it in the open set and hash table.

;; new-world : maze -> world ;; create and initialize new world for the given maze. (define (new-world maze) ...)

Implementing A*

Your implementation of A* should be designed to advance the search by one step as described above. This function has the following header:

;; A*-step : world -> world ;; Take one step of the A* algorithm. Note that we assume that the world ;; is either in 'stepping or 'running mode. (define (A*-step wrld) ...)

Note that although it returns a world, it should always return the world object that it is given and use imperative update to change the state of the world.

An important part of the A* step is the processing of neighbor nodes, which you may want to split into its own top-level function.

;; process-neghbor : world tile-node -> location -> void ;; check neighboring locations to the tile-node nd. This includes checking ;; to see if there is already a tile-node for the location and, if not, creating ;; one and adding it to the open set. (define (process-neighbor wrld nd) ...)

Because these functions are imperative, you are not expected to define check-expects for them.

One tricky issue is what to do if when processing a neighbor node, you discover a shorter path to an already open node. The way to handle this situation is to first update the cost and previous-node fields of the neighbor and then to reinsert it into the priority queue. This will result in duplicate entries in the priority queue, but you handle this issue by checking in A*-step to see if the node you remove from the open set is already closed. In that case, it is a duplicate and you should ignore it and get another node (i.e., recursively call A*-step again).

User interface

Your program should support two modes of performing the search. Initially, your program should be in 'stepping mode, where the search is advanced one step each time the user types the space key. If the user types the "r" key, then the mode should be switched to 'running, where the search steps automatically until it either finished. If the mode is 'running and the user types the "s" key, then the mode should be switched back to 'stepping.

We will use the big-bang function from the universe teachpack to drive the animation. You should package it up in a run function as follows:

;; run : string -> void ;; Run the A* animation on the maze defined by the named file. (define (run filename) (begin (big-bang (new-world (load-maze filename)) (on-tick step-on-tick 1/5) (on-key step-on-key) (to-draw render-world) (stop-when world-done?)) (void)))

The step-on-tick and step-on-key functions are used to control the stepping of the search depending on the current mode. The step-on-key function also must handle mode changes.

Your code will be responsible for rendering the state of the world at each step. This task is handled by the render-world function, which has the following header:

;; render-world : world -> image ;; render the world as an image. (define (render-world wrld) ...)

You rendering code should mark the open and closed nodes and should report on the current state of the search (i.e., how many steps have been taken, etc.). When the search has completed successfully, it should render the resulting path.

We have provided a number of definitions to help with the rendering (see below). You will also want to write a function for rendering the path from the goal node back to the start node.

;; render-path : image tile-node -> image ;; render the path from the node nd (presumably the goal) to the start node (define (render-path img nd) ...)

Here is an example of what the application looks like while the search is in progress.

The green square marks the start node and the red triangle marks the goal. Open nodes are marked by circles and blue squares signify closed nodes.

Here is an example of what it looks like after the search is complete.

The path from the goal back to the source is drawn in purple.

Support files

project3-starter.rkt

This file contains utility code and data structures that you will need for your solution. The details of this code are described below.

Test mazes

We provide several test maze files for you to run your program on. You should place these files in the same directory as your project source file; you can then test your code on a maze by the expression

(run filename)

Here is a list of the test mazes, along with the number of steps that my code takes to find the solution and the length of the shortest path. Note that your number of steps might be off by one or two, but that your code should find a path of the same length.

Maze Number of steps Path length Description
maze0 33 33 a completely open maze with no interior walls
maze1 154 73 the maze pictured above
maze2 150 53 a partially open maze with only a few walls
maze3 49 18 a partially open maze with only a few walls
maze4 82 37 is a variation on maze3
maze5 74 36 is a subtle, but significant variation on maze4

Useful data structures and operations

You will find the various types and operations described below useful in your implementation.

Priority queues

;; a (priority-queue X) is an collection of X values ;; that are ordered by priority (define-struct priority-queue (...)) ;; pq-new : (X -> num) -> (priority-queue X) ;; Create a new empty priority queue that uses priority-fn to order ;; the elements of the queue (define (pq-new priority-fn) ...) ;; pq-count : (priority-queue X) -> nat ;; return a count of the number of items in the priority queue (define (pq-count pq) ...) ;; pq-empty? : (priority-queue X) -> boolean ;; return #t if the priority queue is empty (define (pq-empty? pq) ...) ;; pq-insert : X (priority-queue X) -> void ;; insert an item into a priority queue (define (pq-insert item pq) ...) ;; pq-next : (priority-queue X) -> (oneof X #f) ;; remove and return the item with the lowest priority from the queue (define (pq-next pq) ...)

Mazes

;; A location in a maze is (define-struct location (row ;; : nat -- the location's row col)) ;; : nat -- the location's column ;; location-add : location location -> location ;; location addition (define (location-add loc1 loc2) ...)
;; a tile is either a 'wall or 'open ;; A maze is (define-struct maze (name ;; : string -- the name of the maze tiles ;; : (matrix tile) -- a matrix of tiles start ;; : location -- the start location goal)) ;; : location -- the goal location
;; neighbors : maze -> location -> (list location) ;; return a list of the non-wall neighbors of a tile (define (neighbors maze) ...)
;; load-maze : string -> maze ;; load a maze from a file (define (load-maze filename) ...)

Rendering support

The project3-starter.rkt file also contains support for rendering mazes.

;; tile icons are images that represent the state of known nodes (define open-icon ...) (define closed-icon ...) ;; render-maze : string maze -> image ;; render a maze and its title as an image (define (render-maze title maze) ...) ;; overlay-tile : image location image -> image ;; overlay image over on image under centered on the tile with the given location. (define (overlay-tile over loc under) ...) ;; render-edge : image location location -> image ;; render a path edge between the two locations over the given image (define (render-edge img loc1 loc2) ...)

Hash tables

Hash tables are part of DrRacket, but we describe the operations on them that you may find useful, since you may find the DrRacket documentation confusing.

;; make-hash : (list (list X Y)) -> (hash X Y) ;; Create a new hash table initialized by a list of key/value pairs, which ;; are represented as two-element lists. (define (make-hash assocs) ...) ;; hash-has-key? : (hash X Y) X -> boolean ;; Returns #t if the hash table has a mapping for the given key value (define (hash-has-key? hash key) ...) ;; hash-ref : (hash X Y) X -> Y ;; returns the value that the hash table maps key to. This function signals ;; an error if there is no mapping for key. (define (hash-ref hash key) ...) ;; hash-set! : (hash X Y) X Y -> void ;; imperatively add a mapping from key to value to the hash table. (define (hash-set! hash key value) ...) ;; hash-map : (hash X Y) (X Y -> Z) -> (list Z) ;; Map a function over the elements of a hash table, returning a list of the results. (define (hash-map hash f) ...)

Last revised: December 5, 2010.