CS 70

Finding the Shortest Path with BFS

Now that our CoordVector supports all the necessary operations to be used as a queue, we can implement breadth-first search (BFS) to find the shortest path through our mazes!

BFS: The Level-by-Level Approach

While DFS dives deep into one path before trying others, BFS explores all paths simultaneously, one step at a time. Think of it like ripples spreading out from a stone dropped in water—we explore everything at distance 1, then distance 2, then distance 3, and so on.

  • Duck speaking

    So BFS will always find the shortest path?

  • LHS Cow speaking

    In an unweighted maze where each step costs the same, yes! BFS finds the path with the fewest steps.

  • Cat speaking

    The key difference is using a queue instead of a stack, right?

  • LHS Cow speaking

    Exactly! With a queue (FIFO), we process cells in the order we discovered them, ensuring we explore by distance from the start.

  • Rabbit speaking

    Fun fact, we don't just find the shortest path to the exit, we actually find the shortest way to get to every cell in the maze!

Your Task, Step 1: Try the Provided BFS

We're providing you with a mostly-complete BFS implementation. Replace your placeholder solveMazeBFS function in solver.cpp with this code (click to copy):

bool solveMazeBFS(Maze& maze) {
    // Queue stores coordinates for BFS traversal
    CoordVector toDoQueue;
    toDoQueue.pushBack(maze.getStart());
    Coord end = maze.getEnd();

    // BFS to find path from start to end
    while (!toDoQueue.isEmpty()) {
        Coord current = toDoQueue.front();
        toDoQueue.popFront();

        if (maze.hasState(current, Maze::VISITED)) {
            continue;  // Already visited
        }

        // Mark current cell as visited
        maze.setState(current, Maze::VISITED);

        // Check if we've reached the end
        if (current == end) {
            break;
        }

        // All ways we can go from here get pushed onto the queue
        for (size_t dir = 0; dir < NUM_DIRECTIONS; ++dir) {
            Coord newCoord = current + DIRECTIONS[dir];

            if (maze.hasState(newCoord, Maze::PATH)) {
                toDoQueue.pushBack(newCoord);
                // Use the ACTIVE state to show items in the queue,
                // which also prevents them from being added multiple times
                maze.setState(newCoord, Maze::ACTIVE);
            }
        }
    }

    // Clear out any remaining faux ACTIVE states that didn't get used
    while (!toDoQueue.isEmpty()) {
        Coord current = toDoQueue.front();
        toDoQueue.popFront();
        maze.setState(current, Maze::PATH);
    }

    // Bail out if we didn't reach the end
    if (!maze.hasState(end, Maze::VISITED)) {
        return false;
    }

    // Unfortunately, BFS doesn't leave us with a path marked ACTIVE
    // TODO: Fix that!

    return true;
}

Test It Out

Compile and run on the cracked maze:

make
./amaze --bfs mazes/maze-79x23-cracked.txt
  • Horse speaking

    Hay! It finds the exit, but there's no path marked! (Fun to watch the animation, though! The little os were like explorers spreading out into the unknown.)

  • LHS Cow speaking

    Right! This version uses the ACTIVE state differently—cells are active while they're in the queue, not as part of the final solution.

  • Goat speaking

    Meh. So we found the exit but don't know how to get there? Talk about weak.

  • LHS Cow speaking

    We need to track where we came from as we explore, then reconstruct the path at the end.

The Missing Piece: Tracking the Path

To reconstruct the shortest path, for each cell we visit, we need to remember which cell we came from. So we'll create a “parent pointer” tree that we can follow backwards from the end to the start.

  • Hedgehog speaking

    Oh, no, more pointers?! My head hurts.

  • LHS Cow speaking

    Actually, while conceptually each cell is saying where where we came from to get there, we're not going to use actual pointers. Instead, we'll use a 2-D grid of Coord values, where each cell holds the coordinates of its parent cell.

  • Dog speaking

    So we get to use our Coord class again! In fact, we'll finally be using indexing! Woo hoo!

Your Task, Step 2: Add Path Tracking

Add the CoordGrid Helper

First, add the following helper class to solver.cpp right before the solveMazeDFS function (after the DIRECTIONS array but still in the anonymous namespace):

// This is a tiny private helper class to manage a CoordVector as a 2D grid
// of coordinates.  It just does the index arithmetic for us.  Because it
// is just a tiny wrapper, it uses inside-the-class implementation rather than
// separate header and implementation files.

class CoordGrid {
 public:
    CoordGrid(int width, int height)
        : width_(width), height_(height), vector_(width * height) {
        // Nothing else to do
    }

    Coord& at(const Coord& cell) {
        if (cell.x < 0 || cell.x >= width_ || cell.y < 0 || cell.y >= height_) {
            throw std::out_of_range("CoordGrid index out of range");
        }
        return vector_[cell.y * width_ + cell.x];
    }

 private:
    int width_;
    int height_;
    CoordVector vector_;
};

This little class is basically a 2-D array of Coord values implemented using our CoordVector, doing all the annoying index calculations for you.

  • Duck speaking

    Why do we need this?

  • LHS Cow speaking

    For each cell in the maze, we'll store which cell we came from to get there. It's like leaving a trail of breadcrumbs, but backwards.

  • Hedgehog speaking

    And why is it in that weird anonymous namespace thingy?

  • LHS Cow speaking

    Just like sometimes you might write a little helper function to make things easier, in C++ we can also make little helper classes. Putting it in an anonymous namespace is like saying “this is only for use in this file, don't try to use it anywhere else”.

Modify the BFS Function

Now modify solveMazeBFS to track and reconstruct the path:

  1. Create a tracking grid (right after creating toDoQueue):

    CoordGrid cameFrom(maze.width(), maze.height());
    
  2. Track where we came from (when adding a neighbor to the queue): After the line maze.setState(newCoord, Maze::ACTIVE);, add

    cameFrom.at(newCoord) = current;
    
  3. Reconstruct the path (replace the TODO: Fix that! comment):

    You need to loop, starting from end, following the cameFrom coordinates back to the start, marking each cell as Maze::ACTIVE.

  • Cat speaking

    Wait, how do we know when to stop the do-while loop?

  • LHS Cow speaking

    There are a couple of ways. You could check to see if the cell you're checking is equal to the start coordinate.

  • RHS Cow speaking

    Or we could note that the start cell is special—it's the only one that doesn't have a “came from” cell. The CoordGrid initializes all cells to Coord() (which is (-1,-1)), and we never set cameFrom for the start. So when we reach it, step becomes (-1,-1) and we stop.

Test Your Solution

Rebuild and test on various mazes. Here we've turned off the display to show the answer instantly rather than animating it:

make
./amaze --bfs -n mazes/maze-79x23.txt          # Original maze, same as DFS
./amaze -n mazes/maze-79x23-cracked.txt        # Cracked maze with DFS
./amaze --bfs -n mazes/maze-79x23-cracked.txt  # Cracked maze with BFS

Scroll back in your terminal to contrast the DFS and BFS results, or use the -o option to save the output to files and compare them side-by-side.

  • Pig speaking

    MORE efficiency! BFS found a different path. It's shorter, so MORE efficient!

  • LHS Cow speaking

    That's the power of BFS—it always finds the shortest path in an unweighted graph.

  • Goat speaking

    Meh. DFS worked fine for me.

  • LHS Cow speaking

    DFS is great for many problems! But when you need the shortest path, BFS is your friend.

Comparing the Algorithms

Feel free to run more comparisons or just watch the animations. Notice that

  • DFS might find a path faster but it's often a longer path.
  • BFS always finds the shortest path but explores more cells.
  • DFS uses less memory (just a stack of the current path).
  • BFS uses more memory (queue + parent pointers for all visited cells), but the queue is often smaller than the full stack DFS might build up.

There's opportunity for more investigation in the next part of the assignment!

Helpful Hints

Debugging BFS

Add status messages to see what's happening:

maze.showStatusText("Queue size: " + std::to_string(toDoQueue.size()));

Understanding the Parent Tracking

Think of cameFrom as arrows pointing backwards along the path:

End ← ... ← Cell3 ← Cell2 ← Cell1 ← Start

We follow these arrows backwards from End to Start, marking each cell as ACTIVE.

Common Issues

  • Forgetting to track cameFrom when enqueueing.
  • Infinite loop in path reconstruction (check your stop condition!).
  • Not clearing remaining queue items (they show as 'o' if not cleaned up).

To Complete This Part of the Assignment...

You'll know you're done with this part when you've done all of the following:

(When logged in, completion status appears here.)