diff --git a/src/main/java/lib/maze/Maze.java b/src/main/java/lib/maze/Maze.java index 859fd529..ba292528 100644 --- a/src/main/java/lib/maze/Maze.java +++ b/src/main/java/lib/maze/Maze.java @@ -16,7 +16,7 @@ public class Maze { private final Cell[][] grid; @Builder - public Maze( + private Maze( final int rows, final int columns, @NonNull final MazeLocation start, @@ -40,6 +40,16 @@ public Maze( grid[goal.getRow()][goal.getColumn()] = Cell.GOAL; } + public static Maze randomMaze( + final int rows, + final int columns, + @NonNull final MazeLocation start, + @NonNull final MazeLocation goal, + final double sparseness + ) { + return new Maze(rows, columns, start, goal, sparseness); + } + private void randomlyFill(double sparseness) { for (int row = 0; row < rows; row++) { for (int column = 0; column < columns; column++) { @@ -157,4 +167,34 @@ public List successors(@NonNull final MazeLocation location) { return successors; } + public void mark(@NonNull final List path) { + for (final MazeLocation mazeLocation : path) { + grid[mazeLocation.getRow()][mazeLocation.getColumn()] = Cell.PATH; + } + grid[start.getRow()][start.getColumn()] = Cell.START; + grid[goal.getRow()][goal.getColumn()] = Cell.GOAL; + } + + public void clear(@NonNull final List path) { + for (final MazeLocation mazeLocation : path) { + grid[mazeLocation.getRow()][mazeLocation.getColumn()] = Cell.EMPTY; + } + grid[start.getRow()][start.getColumn()] = Cell.START; + grid[goal.getRow()][goal.getColumn()] = Cell.GOAL; + } + + public double euclideanDistance(@NonNull final MazeLocation location) { + int xDistance = location.getColumn() - goal.getColumn(); + int yDistance = location.getRow() - goal.getRow(); + + return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2)); + } + + public double manhattanDistance(@NonNull final MazeLocation location) { + int xDistance = Math.abs(location.getColumn() - goal.getColumn()); + int yDistance = Math.abs(location.getRow() - goal.getRow()); + + return xDistance + yDistance; + } + } diff --git a/src/main/java/lib/maze/Node.java b/src/main/java/lib/maze/Node.java new file mode 100644 index 00000000..3f961889 --- /dev/null +++ b/src/main/java/lib/maze/Node.java @@ -0,0 +1,86 @@ +package lib.maze; + +import lombok.Getter; +import lombok.NonNull; +import org.springframework.lang.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Getter +public class Node implements Comparable> { + + @NonNull + private final T state; // DFS only + @NonNull + private final Optional> parent; // DFS only + private final double cost; // used in A* not in DFS + private final double heuristic;// used in A* not in DFS + + // DFS Node + private Node(@NonNull final T state, @Nullable final Node parent) { + this.state = state; + this.parent = Optional.ofNullable(parent); + this.cost = 0; + this.heuristic = 0; + } + + // A* Node + private Node(@NonNull final T state, @Nullable final Node parent, final double cost, final double heuristic) { + this.state = state; + this.parent = Optional.ofNullable(parent); + this.cost = cost; + this.heuristic = heuristic; + } + + public static Node astarStartNode( + @NonNull final T state, + final double heuristic + ) { + return new Node<>(state, null, 0, heuristic); + } + + public static Node astarNode( + @NonNull final T state, + @Nullable final Node parent, + final double cost, + final double heuristic + ) { + return new Node<>(state, parent, cost, heuristic); + } + + public static Node dfsStartNode(@NonNull final T state) { + return new Node<>(state, null); + } + + public static Node dfsNode( + @NonNull final T state, + @NonNull final Node parent + ) { + return new Node<>(state, parent); + } + + @NonNull + public static List nodeToPath(@NonNull final Node node) { + final List path = new ArrayList<>(); + path.add(node.getState()); + + // work backwards from end to front + Node currentNode = node; + while (currentNode.getParent().isPresent()) { + currentNode = currentNode.getParent().get(); + path.add(0, currentNode.getState()); + } + + return path; + } + + @Override + public int compareTo(@NonNull final Node other) { + final Double mine = cost + heuristic; + final Double theirs = other.cost + other.heuristic; + return mine.compareTo(theirs); + } + +} diff --git a/src/main/java/lib/maze/searchstrategy/AstarStrategy.java b/src/main/java/lib/maze/searchstrategy/AstarStrategy.java new file mode 100644 index 00000000..a94b7a3f --- /dev/null +++ b/src/main/java/lib/maze/searchstrategy/AstarStrategy.java @@ -0,0 +1,55 @@ +package lib.maze.searchstrategy; + +import lib.maze.Node; +import lombok.NonNull; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.ToDoubleFunction; + +/** + * A* Search + */ +public class AstarStrategy { + + @NonNull + public Optional> search( + @NonNull final T initialState, + @NonNull final Predicate goalTest, + @NonNull final Function> successors, + @NonNull final ToDoubleFunction heuristic + ) { + // frontier is where we've yet to go + final PriorityQueue> frontier = new PriorityQueue<>(); + frontier.offer(Node.astarStartNode(initialState, heuristic.applyAsDouble(initialState))); + + // explored is where we've already been + final Map explored = new HashMap<>(); + explored.put(initialState, 0.0); + + // keep going while there is more to explore + while (!frontier.isEmpty()) { + final Node currentNode = frontier.poll(); + final T currentState = currentNode.getState(); + + // if we found the goal, we're done + if (goalTest.test(currentState)) { + return Optional.of(currentNode); + } + + // check where we can go next and haven't explored + for (final T child : successors.apply(currentState)) { + // cost=1 here assumes a simple grid, need a cost function for more sophisticated apps + final double newCost = currentNode.getCost() + 1; + if (!explored.containsKey(child) || explored.get(child) > newCost) { + explored.put(child, newCost); + frontier.offer(Node.astarNode(child, currentNode, newCost, heuristic.applyAsDouble(child))); + } + } + } + + return Optional.empty(); // never found the goal + } + +} diff --git a/src/main/java/lib/maze/searchstrategy/BSFStrategy.java b/src/main/java/lib/maze/searchstrategy/BSFStrategy.java new file mode 100644 index 00000000..71bd48cf --- /dev/null +++ b/src/main/java/lib/maze/searchstrategy/BSFStrategy.java @@ -0,0 +1,52 @@ +package lib.maze.searchstrategy; + +import lib.maze.Node; +import lombok.NonNull; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Breadth First Search + */ +public class BSFStrategy implements GoalSearchableStrategy { + + @NonNull + public Optional> search( + @NonNull final T initialState, + @NonNull final Predicate goalTest, + @NonNull final Function> successors + ) { + // frontier is where we've yet to go + final Queue> frontier = new LinkedList<>(); + frontier.offer(Node.dfsStartNode(initialState)); + + // explored is where we've already been + final Set explored = new HashSet<>(); + explored.add(initialState); + + // keep going while there is more to explore + while (!frontier.isEmpty()) { + final Node currentNode = frontier.poll(); + final T currentState = currentNode.getState(); + + // if we found the goal, we're done + if (goalTest.test(currentState)) { + return Optional.of(currentNode); + } + + // check where we can go next and haven't explored + for (final T child : successors.apply(currentState)) { + if (explored.contains(child)) { + continue; // skip children we already explored + } + explored.add(child); + frontier.offer(Node.dfsNode(child, currentNode)); + } + } + + return Optional.empty(); // never found the goal + } + +} diff --git a/src/main/java/lib/maze/searchstrategy/DSFStrategy.java b/src/main/java/lib/maze/searchstrategy/DSFStrategy.java new file mode 100644 index 00000000..6c6c4e9e --- /dev/null +++ b/src/main/java/lib/maze/searchstrategy/DSFStrategy.java @@ -0,0 +1,52 @@ +package lib.maze.searchstrategy; + +import lib.maze.Node; +import lombok.NonNull; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Depth First Search + */ +public class DSFStrategy implements GoalSearchableStrategy { + + @NonNull + public Optional> search( + @NonNull final T initialState, + @NonNull final Predicate goalTest, + @NonNull final Function> successors + ) { + // frontier is where we've yet to go + final Stack> frontier = new Stack<>(); + frontier.push(Node.dfsStartNode(initialState)); + + // explored is where we've already been + final Set explored = new HashSet<>(); + explored.add(initialState); + + // keep going while there is more to explore + while (!frontier.isEmpty()) { + final Node currentNode = frontier.pop(); + final T currentState = currentNode.getState(); + + // if we found the goal, we're done + if (goalTest.test(currentState)) { + return Optional.of(currentNode); + } + + // check where we can go next and haven't explored + for (final T child : successors.apply(currentState)) { + if (explored.contains(child)) { + continue; // skip children we already explored + } + explored.add(child); + frontier.push(Node.dfsNode(child, currentNode)); + } + } + + return Optional.empty(); // never found the goal + } + +} diff --git a/src/main/java/lib/maze/searchstrategy/GoalSearchableStrategy.java b/src/main/java/lib/maze/searchstrategy/GoalSearchableStrategy.java new file mode 100644 index 00000000..195bdcce --- /dev/null +++ b/src/main/java/lib/maze/searchstrategy/GoalSearchableStrategy.java @@ -0,0 +1,20 @@ +package lib.maze.searchstrategy; + +import lib.maze.Node; +import lombok.NonNull; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; + +public interface GoalSearchableStrategy { + + @NonNull + Optional> search( + @NonNull final T initialState, + @NonNull final Predicate goalTest, + @NonNull final Function> successors + ); + +} diff --git a/src/test/java/lib/maze/searchstrategy/AstarStrategyTest.java b/src/test/java/lib/maze/searchstrategy/AstarStrategyTest.java new file mode 100644 index 00000000..445bf137 --- /dev/null +++ b/src/test/java/lib/maze/searchstrategy/AstarStrategyTest.java @@ -0,0 +1,100 @@ +package lib.maze.searchstrategy; + +import lib.maze.Maze; +import lib.maze.MazeLocation; +import lib.maze.Node; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.fail; + +class AstarStrategyTest { + + @Test + void maze1() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [S][ ][ ][ ][ ][ ][ ][ ][ ][ ][G] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final AstarStrategy searchStrategy = new AstarStrategy(); + final Optional> dsfSolution1 = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors, testMaze::manhattanDistance); + + if (dsfSolution1.isEmpty()) { + fail("No solution found!"); + } else { + final List path1 = Node.nodeToPath(dsfSolution1.get()); + testMaze.mark(path1); + System.out.println(testMaze); + } + } + + @Test + void maze2() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][ ][ ][ ][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][X][ ] + [X][X][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][X][ ] + [S][X][X][X][ ][X][ ][X][ ][X][G] + [ ][ ][ ][X][ ][X][ ][X][ ][X][X] + [X][X][ ][X][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][X][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final AstarStrategy searchStrategy = new AstarStrategy(); + final Optional> dsfSolution1 = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors, testMaze::manhattanDistance); + + if (dsfSolution1.isEmpty()) { + fail("No solution found!"); + } else { + final List path1 = Node.nodeToPath(dsfSolution1.get()); + testMaze.mark(path1); + System.out.println(testMaze); + } + } + + @Test + void randomMaze() { + // Arrange + final double sparseness = 0.35; // 0.2 means 20% of the maze will be blocked + + final Maze testMaze = Maze.randomMaze(11, 11, MazeLocation.builder().row(7).column(0).build(), MazeLocation.builder().row(3).column(10).build(), sparseness); + + // Act + final AstarStrategy searchStrategy = new AstarStrategy(); + final Optional> astarSolution = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors, testMaze::manhattanDistance); + + if (astarSolution.isEmpty()) { + System.out.println("#################################"); + System.out.println("# Probably not solvable #"); + System.out.println("#################################"); + System.out.println(testMaze); + } else { + final List path = Node.nodeToPath(astarSolution.get()); + testMaze.mark(path); + System.out.println(testMaze); + } + } + +} \ No newline at end of file diff --git a/src/test/java/lib/maze/searchstrategy/BSFStrategyTest.java b/src/test/java/lib/maze/searchstrategy/BSFStrategyTest.java new file mode 100644 index 00000000..fc5e10b9 --- /dev/null +++ b/src/test/java/lib/maze/searchstrategy/BSFStrategyTest.java @@ -0,0 +1,77 @@ +package lib.maze.searchstrategy; + +import lib.maze.Maze; +import lib.maze.MazeLocation; +import lib.maze.Node; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.fail; + +class BSFStrategyTest { + + @Test + void maze1() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [S][ ][ ][ ][ ][ ][ ][ ][ ][ ][G] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final BSFStrategy searchStrategy = new BSFStrategy(); + final Optional> bsfSolution = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors); + + if (bsfSolution.isEmpty()) { + fail("No solution found!"); + } else { + final List path = Node.nodeToPath(bsfSolution.get()); + testMaze.mark(path); + System.out.println(testMaze); + } + } + + @Test + void maze2() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][ ][ ][ ][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][X][ ] + [X][X][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][X][ ] + [S][X][X][X][ ][X][ ][X][ ][X][G] + [ ][ ][ ][X][ ][X][ ][X][ ][X][X] + [X][X][ ][X][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][X][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final BSFStrategy searchStrategy = new BSFStrategy(); + final Optional> bsfSolution = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors); + + if (bsfSolution.isEmpty()) { + fail("No solution found!"); + } else { + final List path = Node.nodeToPath(bsfSolution.get()); + testMaze.mark(path); + System.out.println(testMaze); + } + } + +} \ No newline at end of file diff --git a/src/test/java/lib/maze/searchstrategy/DSFStrategyTest.java b/src/test/java/lib/maze/searchstrategy/DSFStrategyTest.java new file mode 100644 index 00000000..0dd5950f --- /dev/null +++ b/src/test/java/lib/maze/searchstrategy/DSFStrategyTest.java @@ -0,0 +1,77 @@ +package lib.maze.searchstrategy; + +import lib.maze.Maze; +import lib.maze.MazeLocation; +import lib.maze.Node; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.fail; + +class DSFStrategyTest { + + @Test + void maze1() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [S][ ][ ][ ][ ][ ][ ][ ][ ][ ][G] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final DSFStrategy searchStrategy = new DSFStrategy(); + final Optional> dsfSolution = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors); + + if (dsfSolution.isEmpty()) { + fail("No solution found!"); + } else { + final List path = Node.nodeToPath(dsfSolution.get()); + testMaze.mark(path); + System.out.println(testMaze); + } + } + + @Test + void maze2() { + // Arrange + final String mazeMap = """ + [ ][ ][ ][ ][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][ ][ ][ ][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][X][ ] + [X][X][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][X][ ] + [S][X][X][X][ ][X][ ][X][ ][X][G] + [ ][ ][ ][X][ ][X][ ][X][ ][X][X] + [X][X][ ][X][ ][X][ ][ ][ ][X][ ] + [ ][ ][ ][X][ ][X][ ][X][ ][ ][ ] + [ ][ ][ ][X][ ][ ][ ][ ][ ][ ][ ] + [ ][ ][ ][ ][ ][X][ ][X][ ][ ][ ] + """.stripIndent(); + final Maze testMaze = Maze.fromString(mazeMap); + + // Act + final DSFStrategy searchStrategy = new DSFStrategy(); + final Optional> dsfSolution = searchStrategy.search(testMaze.getStart(), testMaze::goalTest, testMaze::successors); + + if (dsfSolution.isEmpty()) { + fail("No solution found!"); + } else { + final List path = Node.nodeToPath(dsfSolution.get()); + testMaze.mark(path); + System.out.println(testMaze); + } + } + +} \ No newline at end of file