Eric Spencer

projects


← back to index

Recursive Maze Solver Example

Java recursion and graph-search maze-solver examples.


Recursive maze solver. A transcript of an exchange with ChatGPT about maze-solving code in Java, framed around a CS2 data structures course. In the maze notation used throughout, '*' is a wall, '.' an unvisited square, '-' a dead-end, '0' the starting point, and '+' a visited space.

User:

A CS2 course is one that covers the top data structures in Java, following the ACM 2020 curricular guidelines. In the context of a CS2 course, what are some coding examples of a recursive maze solver? Assume this maze has walls marked with *'s, unvisited squares marked with '.'s, dead-ends marked with '-', the starting point marked with '0', and visited spaces marked with '+'s. Explain each step as you go.

ChatGPT:

A recursive maze solver in Java:


public class MazeSolver {
    private char[][] maze;
    private int rows;
    private int cols;
    private int startRow;
    private int startCol;

    public MazeSolver(char[][] maze) {
        this.maze = maze;
        this.rows = maze.length;
        this.cols = maze[0].length;
        findStart();
    }

    private void findStart() {
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (maze[i][j] == '0') {
                    startRow = i;
                    startCol = j;
                    return;
                }
            }
        }
    }

    public void solve() {
        solveMaze(startRow, startCol);
    }

    private boolean solveMaze(int row, int col) {
        if (row < 0 || col < 0 || row >= rows || col >= cols) {
            return false;  // outside of maze boundary
        }

        if (maze[row][col] == '*' || maze[row][col] == '+' || maze[row][col] == '-') {
            return false;  // wall or visited space
        }

        if (maze[row][col] == '.') {
            maze[row][col] = '+';  // mark as visited

            if (row == 0 || col == 0 || row == rows - 1 || col == cols - 1) {
                return true;  // reached the exit
            }

            // Recursive calls in all four directions
            boolean foundExit = solveMaze(row - 1, col) ||  // up
                    solveMaze(row + 1, col) ||  // down
                    solveMaze(row, col - 1) ||  // left
                    solveMaze(row, col + 1);    // right

            if (foundExit) {
                return true;  // found the exit
            } else {
                maze[row][col] = '-';  // mark as dead-end
                return false;  // backtrack
            }
        }

        return false;  // obstacle or already visited
    }
}

The code step by step:

  1. The MazeSolver class holds the maze-solving functionality. It takes a 2D character array (maze) as input, which represents the maze structure with the symbols above.
  2. findStart() locates the starting position marked with '0'.
  3. solve() is the entry point. It calls solveMaze() with the starting position as the initial parameters.
  4. solveMaze() performs the recursive algorithm. It takes the current row and column as input.
  5. The first conditions in solveMaze() check whether the current position is outside the maze boundaries, a wall ('*'), a visited space ('+'), or a dead-end ('-'). Any of these returns false and backtracks.
  6. If the current position is an unvisited square ('.'), the method changes it to '+' to mark it visited.
  7. It then checks whether the position sits on a maze boundary, which indicates the exit. In that case it returns true.
  8. Otherwise it recurses in all four directions, up, down, left, and right, and combines the results with the logical OR operator (||).
  9. If any recursive call returns true, an exit was found and the method returns true.
  10. If all recursive calls return false, the position is a dead-end. The method changes it to '-' and returns false to backtrack.
  11. Recursion continues until either an exit is found or all possible paths have been explored.

An instance of MazeSolver is constructed with the maze as input, and solve() finds the solution. The maze is modified in place: '+' marks the path to the exit, '-' marks dead-ends.

The solver assumes exactly one starting position ('0') and exactly one path to the exit. A maze that does not satisfy those assumptions may not solve correctly.

User:

What does an example maze look like, and what would it output?

ChatGPT:

An example maze as a 2D character array:


char[][] maze = {
    {'*', '*', '*', '*', '*', '*', '*', '*', '*'},
    {'*', '0', '.', '.', '*', '.', '*', '.', '*'},
    {'*', '*', '*', '.', '*', '.', '*', '.', '*'},
    {'*', '.', '.', '.', '.', '.', '*', '.', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '.', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '.', '*'},
    {'*', '.', '.', '.', '.', '.', '.', '.', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '*', '*'}
};

Solving it with the MazeSolver class:


MazeSolver solver = new MazeSolver(maze);
solver.solve();

The maze array is modified in place. The result:


{
    {'*', '*', '*', '*', '*', '*', '*', '*', '*'},
    {'*', '0', '+', '+', '*', '+', '*', '+', '*'},
    {'*', '*', '*', '+', '*', '+', '*', '+', '*'},
    {'*', '-', '-', '-', '-', '+', '*', '+', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '+', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '+', '*'},
    {'*', '+', '+', '+', '+', '+', '+', '+', '*'},
    {'*', '*', '*', '*', '*', '*', '*', '*', '*'}
}

The path from the starting position ('0') to the exit is marked with '+'. The dead-ends are marked with '-'.

User:

Explain Dijkstra's algorithm with respect to this maze solver

ChatGPT:

Dijkstra's algorithm is a graph search algorithm that finds the shortest path between a starting node and all other nodes in a weighted graph. Applied to the maze, it finds the shortest path from the starting position ('0') to the exit.

  1. Initialize a priority queue to hold the nodes to be visited. Each node carries a distance value, the shortest known distance from the starting node. The starting node begins at 0 and every other node at infinity.
  2. While the priority queue is not empty, extract the node with the minimum distance value.
  3. For each neighbor of the extracted node that is not a wall ('*'), calculate the tentative distance from the starting node: the weight of the current node plus the weight of the edge to the neighbor. If that tentative distance is less than the neighbor's recorded distance, update the distance value and add the neighbor to the priority queue.
  4. Repeat steps 2 and 3 until the priority queue is empty.
  5. On termination, the distance value at each node is the shortest path from the starting position ('0') to that node.

Folding this into the maze solver takes additional data structures and changes to solveMaze():

  1. Replace the recursive approach with an iterative one. Dijkstra's algorithm is normally implemented iteratively.
  2. Add a distance array holding the shortest distance from the starting position to each node, initialized to infinity except at the start, which is 0.
  3. Add a priority queue of nodes to visit, each with its distance value.
  4. Instead of marking visited positions with '+', mark them with their distance value.
  5. Rewrite solveMaze() to select the minimum-distance node from the priority queue and update the distances of its neighbors.
  6. Adjust the termination condition.

User:

How can the maze-solving process be visualized step by step?

ChatGPT:

Step-by-step visualization needs a delay between steps and a display of the maze after each modification. In Java, Thread.sleep() pauses execution for a set duration.

This version of MazeSolver adds a visualizeSolve() method:


import java.util.concurrent.TimeUnit;

public class MazeSolver {
    // Other class members and methods...

    public void visualizeSolve() {
        solveMazeVisualization(startRow, startCol);
    }

    private void solveMazeVisualization(int row, int col) {
        if (row < 0 || col < 0 || row >= rows || col >= cols) {
            return;  // outside of maze boundary
        }

        if (maze[row][col] == '*' || maze[row][col] == '+' || maze[row][col] == '-') {
            return;  // wall or visited space
        }

        if (maze[row][col] == '.') {
            maze[row][col] = '+';  // mark as visited

            if (row == 0 || col == 0 || row == rows - 1 || col == cols - 1) {
                visualizeMaze();  // Display the maze after modification
                return;  // reached the exit
            }

            try {
                TimeUnit.MILLISECONDS.sleep(500);  // Pause for 500 milliseconds (adjust as needed)
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            visualizeMaze();  // Display the maze after modification

            solveMazeVisualization(row - 1, col);  // up
            solveMazeVisualization(row + 1, col);  // down
            solveMazeVisualization(row, col - 1);  // left
            solveMazeVisualization(row, col + 1);  // right

            maze[row][col] = '-';  // mark as dead-end

            try {
                TimeUnit.MILLISECONDS.sleep(500);  // Pause for 500 milliseconds (adjust as needed)
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            visualizeMaze();  // Display the maze after modification
        }
    }

    private void visualizeMaze() {
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                System.out.print(maze[i][j] + " ");
            }
            System.out.println();
        }
        System.out.println();
    }
}

visualizeSolve() pauses for 500 milliseconds via TimeUnit.MILLISECONDS.sleep(500) before and after each recursive call. The duration is adjustable.

visualizeMaze() displays the current state of the maze after each modification. It iterates over the maze array and prints the symbols for walls, visited spaces, and dead-ends.

User:

Can you explain how to handle different types of maze configurations, such as loops or multiple solutions?

ChatGPT:

Loops and multiple solutions each need handling in the algorithm.

Loops in the maze

Multiple solutions

Complexity and performance vary with the maze configuration and the approach chosen.

User:

What can be done to the maze solver to make it run better and faster? Is it already optimized? Are there certain tradeoffs for making one aspect of the maze better?

ChatGPT:

The code above is a basic recursive implementation. It solves mazes correctly, but several optimizations apply:

  1. Memoization. Cache the results of previous computations and reuse them instead of recalculating. The shortest distance from the starting position to each node can be cached and not recomputed during the solve.
  2. Iterative approach. Convert the solver to an iterative traversal managed by a stack or queue. This removes the overhead of function calls and gives more control over the flow of the algorithm.
  3. A* search or other heuristics. Informed search algorithms such as A* use an estimate of the remaining distance to the goal to guide the search, which improves the efficiency of finding the shortest path in some maze configurations.
  4. Multi-threading. For a large maze, split the work across multiple threads to use multiple CPU cores.

The trade-offs:

  1. Optimizing for speed can require more complex data structures or algorithms, which raises code complexity and maintenance overhead.
  2. Some techniques, memoization and heuristic-based algorithms among them, trade accuracy for speed. They may not guarantee the optimal solution in every case.
  3. Multi-threading does not always produce a significant speedup. Managing threads and synchronizing data carries overhead that can offset the gains from parallel execution.

User:

convert this maze to A* search

ChatGPT:

A* search needs a heuristic function that estimates the cost from each node to the goal. For a maze, the Manhattan distance or Euclidean distance between two points is the common choice. A* evaluates nodes on their combined cost and heuristic value and explores the most promising paths first.

This version of MazeSolver uses A* search with the Manhattan distance heuristic:


import java.util.*;

public class MazeSolver {
    // Other class members and methods...

    public void solveAStar() {
        PriorityQueue<Node> openList = new PriorityQueue<>();
        Set<Node> closedList = new HashSet<>();

        Node startNode = new Node(startRow, startCol, 0);
        startNode.setHeuristic(calculateHeuristic(startRow, startCol));
        openList.add(startNode);

        while (!openList.isEmpty()) {
            Node currentNode = openList.poll();
            int row = currentNode.getRow();
            int col = currentNode.getCol();

            if (row == exitRow && col == exitCol) {
                reconstructPath(currentNode);
                return;
            }

            closedList.add(currentNode);

            exploreNeighbors(row - 1, col, currentNode, openList, closedList); // up
            exploreNeighbors(row + 1, col, currentNode, openList, closedList); // down
            exploreNeighbors(row, col - 1, currentNode, openList, closedList); // left
            exploreNeighbors(row, col + 1, currentNode, openList, closedList); // right
        }

        System.out.println("No solution found.");
    }

    private void exploreNeighbors(int row, int col, Node currentNode, PriorityQueue<Node> openList, Set<Node> closedList) {
        if (row < 0 || col < 0 || row >= rows || col >= cols) {
            return; // outside of maze boundary
        }

        if (maze[row][col] == '*' || closedList.contains(new Node(row, col))) {
            return; // wall or already explored
        }

        int tentativeGScore = currentNode.getGScore() + 1; // assuming all moves have the same cost

        Node neighbor = new Node(row, col, tentativeGScore);
        neighbor.setHeuristic(calculateHeuristic(row, col));
        neighbor.setParent(currentNode);

        if (!openList.contains(neighbor) || tentativeGScore < neighbor.getGScore()) {
            openList.remove(neighbor); // remove the neighbor if it's already in the open list with a higher g-score
            openList.add(neighbor); // add the neighbor to the open list
        }
    }

    private int calculateHeuristic(int row, int col) {
        // Calculate the Manhattan distance heuristic from the current position to the exit position
        return Math.abs(exitRow - row) + Math.abs(exitCol - col);
    }

    private void reconstructPath(Node node) {
        List<Node> path = new ArrayList<>();
        Node current = node;

        while (current != null) {
            path.add(0, current);
            current = current.getParent();
        }

        for (Node pathNode : path) {
            int row = pathNode.getRow();
            int col = pathNode.getCol();
            maze[row][col] = '+'; // mark the path
        }
    }

    private static class Node implements Comparable<Node> {
        private final int row;
        private final int col;
        private int gScore;
        private int heuristic;
        private Node parent;

        public Node(int row, int col, int gScore) {
            this.row = row;
            this.col = col;
            this.gScore = gScore;
            this.heuristic = 0;
            this.parent = null;
        }

        // Getters and Setters...

        @Override
        public int compareTo(Node other) {
            int fScore = this.gScore + this.heuristic;
            int otherFScore = other.gScore + other.heuristic;
            return Integer.compare(fScore, otherFScore);
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null || getClass() != obj.getClass()) {
                return false;
            }
            Node other = (Node) obj;
            return row == other.row && col == other.col;
        }

        @Override
        public int hashCode() {
            return Objects.hash(row, col);
        }
    }
}

A Node class represents each position in the maze. It holds the row, column, g-score (the cost from the start node), heuristic value, and parent node.

solveAStar() implements the search. A PriorityQueue called openList holds the nodes still to be explored, sorted on total cost (g-score plus heuristic value). The closedList is a set of the nodes already explored.

exploreNeighbors() explores the neighboring nodes of the current node. It calculates the tentative g-score, creates a new Node for the neighbor, and checks whether it belongs in the openList.

calculateHeuristic() calculates the Manhattan distance from a given position to the exit position.

reconstructPath() walks the parent-child relationship stored in each node back from the exit position to the start position, and marks the path in the maze with '+' symbols.

This implementation assumes every move costs the same (1). A maze with different costs for different moves, diagonal moves for instance, needs a different g-score calculation.

User:

Can you explain the cost system of the A* search algorithm?

ChatGPT:

The cost system in A* search has two components, the g-score and the heuristic.

G-score (path cost)

The g-score is the cost of reaching a particular node from the starting node along the current path. In a maze it is normally the number of steps taken to reach the node from the starting position. It is updated as the search progresses and nodes are explored.

Heuristic (estimated cost)

The heuristic function estimates the cost from a given node to the goal. In a maze, common heuristic functions include the Manhattan distance and the Euclidean distance between two positions. The heuristic guides the search toward nodes that appear closer to the goal.

The total cost of a node is the sum of the g-score and the heuristic value. That total determines priority in the open list, and nodes with lower total costs are explored earlier. The algorithm expands nodes by examining their neighbors and updating g-scores and heuristic values. Combining path cost with estimated cost is what balances exploration of promising paths against optimality, and the objective is the path with the lowest total cost from the starting node to the goal node.

User:

Can you provide a Memoization maze search method?

ChatGPT:

A memoized maze search method, which avoids redundant calculations:


import java.util.*;

public class MazeSolver {
    private int rows;
    private int cols;
    private int startRow;
    private int startCol;
    private int exitRow;
    private int exitCol;
    private char[][] maze;
    private boolean[][] visited;
    private List<String> path;

    public MazeSolver(char[][] maze) {
        this.maze = maze;
        this.rows = maze.length;
        this.cols = maze[0].length;
        this.visited = new boolean[rows][cols];
        this.path = new ArrayList<>();
    }

    public List<String> solveMemoized() {
        // Clear the visited state and path
        for (int i = 0; i < rows; i++) {
            Arrays.fill(visited[i], false);
        }
        path.clear();

        // Call the memoized search method from the starting position
        memoizedSearch(startRow, startCol);

        return path;
    }

    private boolean memoizedSearch(int row, int col) {
        // Base case: if the current position is outside the maze boundaries or is a wall
        if (row < 0 || row >= rows || col < 0 || col >= cols || maze[row][col] == '*') {
            return false;
        }

        // Base case: if the current position is the exit position
        if (row == exitRow && col == exitCol) {
            path.add(getPositionString(row, col));
            return true;
        }

        // Check if the current position has already been visited
        if (visited[row][col]) {
            return false;
        }

        // Mark the current position as visited
        visited[row][col] = true;

        // Recursive exploration in all four directions
        if (memoizedSearch(row - 1, col)) { // up
            path.add(getPositionString(row, col));
            return true;
        }
        if (memoizedSearch(row + 1, col)) { // down
            path.add(getPositionString(row, col));
            return true;
        }
        if (memoizedSearch(row, col - 1)) { // left
            path.add(getPositionString(row, col));
            return true;
        }
        if (memoizedSearch(row, col + 1)) { // right
            path.add(getPositionString(row, col));
            return true;
        }

        return false;
    }

    private String getPositionString(int row, int col) {
        return "(" + row + ", " + col + ")";
    }
}

solveMemoized() is the entry point. It resets the visited state, clears the path list, then calls memoizedSearch() to start from the starting position.

memoizedSearch() is a recursive function that explores the maze depth-first. It checks the base cases, reaching the exit or hitting a wall, then explores the neighboring positions recursively.

The memoization is the visited array, which tracks visited positions. Before exploring a position, the method checks whether it has already been visited.

When a successful path to the exit is found, the positions are added to the path list in reverse order, from the exit back to the starting position.

This implementation assumes a rectangular grid represented by a 2D character array, where '*' is a wall and other characters are open spaces or the start and exit positions. A different maze representation needs adjustments.

User:

How is memoization different than regular DFS search?

ChatGPT:

Memoization and regular depth-first search (DFS) differ on four points.

Approach

Redundancy

Time complexity

Space complexity

In maze solving, memoization caches the results of visiting specific positions so that previously explored paths are not revisited. Regular DFS explores all possible paths exhaustively without storing previous results, potentially revisiting positions and recomputing paths. Memoization trades additional space complexity for improved time complexity.

User:

Provide a method for multi-threading search

ChatGPT:

A multi-threaded maze search method using Java's ExecutorService and Callable interface:


import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class MazeSolver {
    private char[][] maze;
    private int rows;
    private int cols;
    private int startRow;
    private int startCol;
    private int exitRow;
    private int exitCol;
    private List<String> path;
    private ExecutorService executorService;

    public MazeSolver(char[][] maze) {
        this.maze = maze;
        this.rows = maze.length;
        this.cols = maze[0].length;
        this.path = new ArrayList<>();
        this.executorService = Executors.newCachedThreadPool();
    }

    public List<String> solveMultiThreaded() {
        // Clear the path
        path.clear();

        // Submit the initial search task
        Future<Boolean> future = executorService.submit(new MazeSearchTask(startRow, startCol));

        try {
            // Wait for the result of the search task
            boolean found = future.get();

            if (found) {
                // Path found, return the result
                return path;
            } else {
                // No path found
                return new ArrayList<>();
            }
        } catch (InterruptedException | ExecutionException e) {
            // Exception occurred, handle accordingly
            e.printStackTrace();
            return new ArrayList<>();
        } finally {
            // Shutdown the executor service
            executorService.shutdown();
        }
    }

    private class MazeSearchTask implements Callable<Boolean> {
        private int row;
        private int col;

        public MazeSearchTask(int row, int col) {
            this.row = row;
            this.col = col;
        }

        @Override
        public Boolean call() throws Exception {
            // Base cases
            if (row < 0 || row >= rows || col < 0 || col >= cols || maze[row][col] == '*') {
                return false;
            }

            if (row == exitRow && col == exitCol) {
                path.add(getPositionString(row, col));
                return true;
            }

            // Recursive exploration in all four directions
            List<Future<Boolean>> results = new ArrayList<>();
            results.add(executorService.submit(new MazeSearchTask(row - 1, col))); // up
            results.add(executorService.submit(new MazeSearchTask(row + 1, col))); // down
            results.add(executorService.submit(new MazeSearchTask(row, col - 1))); // left
            results.add(executorService.submit(new MazeSearchTask(row, col + 1))); // right

            for (Future<Boolean> result : results) {
                if (result.get()) {
                    path.add(getPositionString(row, col));
                    return true;
                }
            }

            return false;
        }
    }

    private String getPositionString(int row, int col) {
        return "(" + row + ", " + col + ")";
    }
}

A MazeSearchTask class implements the Callable interface. Each task represents the search for a specific position in the maze. solveMultiThreaded() submits the initial search task to the ExecutorService and waits for the result.

MazeSearchTask explores the maze recursively, as the single-threaded version does. But instead of invoking the method recursively, it submits a new MazeSearchTask to the executor service for each neighboring position, so multiple search tasks run concurrently in different threads.

The Future<Boolean> objects hold the results of each search task, retrieved by calling get() on each Future. The method returns as soon as a path to the exit is found.

The example calls Executors.newCachedThreadPool(), which creates an executor service whose thread pool adjusts its size to the number of submitted tasks.

User:

Can you explain in further detail how the algorithm works? What does singular threading mean in the original recursive function and what changes to make it multi-threading?

ChatGPT:

Single-threaded recursive function

Multi-threaded approach

Changes for multi-threading

Each thread explores a portion of the search space independently. Thread creation, synchronization, and context switching add overhead. How much multi-threading helps depends on the size of the maze, the number of available cores, and the complexity of the search space.

Thread generated with ChatGPT [https://chat.openai.com] and downloaded with Botrush [https://botrush.io]