Лабиринт в Java

Решатель лабиринта на Яве

1. Вступление

В этой статье мы рассмотрим возможные способы перемещения по лабиринту с помощью Java.

Рассмотрим лабиринт как черно-белое изображение с черными пикселями, представляющими стены, и белыми пикселями, представляющими путь. Особое значение имеют два белых пикселя, один из которых является входом в лабиринт, а другой - выходом.

Учитывая такой лабиринт, мы хотим найти путь от входа до выхода.

2. Моделирование лабиринта

Мы будем рассматривать лабиринт как двумерный целочисленный массив. Значение числовых значений в массиве будет соответствовать следующему соглашению:

  • 0 → Дорога

  • 1 → Стена

  • 2 → Вход в лабиринт

  • 3 → Выход из лабиринта

  • 4 → Клеточная часть пути от входа до выхода

We’ll model the maze as a graph. Вход и выход - это два специальных узла, между которыми необходимо определить путь.

Типичный граф имеет два свойства, узлы и ребра. Ребро определяет связность графа и связывает один узел с другим.

Следовательно, мы примем четыре неявных ребра от каждого узла, связывая данный узел с его левым, правым, верхним и нижним узлами.

Определим сигнатуру метода:

public List solve(Maze maze) {
}

Входными данными для метода являетсяmaze,, который содержит двумерный массив с определенным выше соглашением об именах.

Ответ метода - это список узлов, который формирует путь от узла входа до узла выхода.

3. Рекурсивный Backtracker (DFS)

3.1. Алгоритм

Один довольно очевидный подход - исследовать все возможные пути, которые в конечном итоге найдут путь, если он существует. Но такой подход будет иметь экспоненциальную сложность и не будет хорошо масштабироваться.

Тем не менее, можно настроить вышеупомянутое решение методом грубой силы, выполняя поиск с возвратом и отмечая посещенные узлы, чтобы получить путь за разумное время. Этот алгоритм также известен какDepth-first search.

Этот алгоритм может быть изложен как:

  1. Если мы находимся у стены или уже посещенного узла, вернуть отказ

  2. Иначе, если мы являемся выходным узлом, возвращаем успех

  3. Иначе, добавьте узел в список путей и рекурсивно перемещайтесь во всех четырех направлениях. Если возвращается ошибка, удалите узел из пути и верните ошибку. Список путей будет содержать уникальный путь при выходе

Давайте применим этот алгоритм к лабиринту, показанному на рисунке 1 (а), где S - начальная точка, а E - выход.

Для каждого узла мы пересекаем каждое направление в следующем порядке: справа, снизу, слева, сверху.

В 1 (б) мы исследуем путь и врезаемся в стену. Затем мы возвращаемся назад до тех пор, пока не будет найден узел, у которого есть не пристенные соседи, и исследуем другой путь, как показано в 1 (с).

Мы снова ударяемся о стену и повторяем процесс, чтобы наконец найти выход, как показано в 1 (d):

imageimage imageimage

3.2. Реализация

Теперь посмотрим на реализацию Java:

Во-первых, нам нужно определить четыре направления. Мы можем определить это в терминах координат. Эти координаты при добавлении к любой заданной координате вернут одну из соседних координат:

private static int[][] DIRECTIONS
  = { { 0, 1 }, { 1, 0 }, { 0, -1 }, { -1, 0 } };

Нам также понадобится служебный метод, который добавит две координаты:

private Coordinate getNextCoordinate(
  int row, int col, int i, int j) {
    return new Coordinate(row + i, col + j);
}

Теперь мы можем определить сигнатуру методаsolve.The logic here is simple - если есть путь от входа до выхода, тогда вернуть путь, иначе вернуть пустой список:

public List solve(Maze maze) {
    List path = new ArrayList<>();
    if (
      explore(
        maze,
        maze.getEntry().getX(),
        maze.getEntry().getY(),
        path
      )
      ) {
        return path;
    }
    return Collections.emptyList();
}

Давайте определим методexplore, упомянутый выше. Если есть путь, верните true со списком координат в аргументеpath. Этот метод имеет три основных блока.

Сначала мы отбрасываем неверные узлы, т.е. узлы, которые находятся за пределами лабиринта или являются частью стены. После этого мы помечаем текущий узел как посещенный, чтобы не посещать один и тот же узел снова и снова.

Наконец, мы рекурсивно движемся во всех направлениях, если выход не найден:

private boolean explore(
  Maze maze, int row, int col, List path) {
    if (
      !maze.isValidLocation(row, col)
      || maze.isWall(row, col)
      || maze.isExplored(row, col)
    ) {
        return false;
    }

    path.add(new Coordinate(row, col));
    maze.setVisited(row, col, true);

    if (maze.isExit(row, col)) {
        return true;
    }

    for (int[] direction : DIRECTIONS) {
        Coordinate coordinate = getNextCoordinate(
          row, col, direction[0], direction[1]);
        if (
          explore(
            maze,
            coordinate.getX(),
            coordinate.getY(),
            path
          )
        ) {
            return true;
        }
    }

    path.remove(path.size() - 1);
    return false;
}

Это решение использует размер стека до размера лабиринта.

4. Вариант - Кратчайший путь (BFS)

4.1. Алгоритм

Описанный выше рекурсивный алгоритм находит путь, но это не обязательно кратчайший путь. Чтобы найти кратчайший путь, мы можем использовать другой подход обхода графа, известный какBreadth-first search.

В DFS один ребенок и все его внуки были исследованы в первую очередь, а затем перешли к другому ребенку. Whereas in BFS, we’ll explore all the immediate children before moving on to the grandchildren. Это гарантирует, что все узлы на определенном расстоянии от родительского узла будут исследованы одновременно.

Алгоритм может быть изложен следующим образом:

  1. Добавить начальный узел в очередь

  2. Пока очередь не пуста, вставьте узел, сделайте следующее:

    1. Если мы дойдем до стены или узел уже посещен, перейдите к следующей итерации

    2. Если достигнут выходной узел, вернитесь назад от текущего узла к начальному узлу, чтобы найти кратчайший путь

    3. Иначе, добавьте всех непосредственных соседей в четырех направлениях в очереди

One important thing here is that the nodes must keep track of their parent, i.e. from where they were added to the queue. Это важно, чтобы найти путь при обнаружении выходного узла.

Следующая анимация показывает все этапы исследования лабиринта с использованием этого алгоритма. Мы можем наблюдать, что все узлы на одном и том же расстоянии исследуются в первую очередь, прежде чем перейти на следующий уровень:

image

4.2. Реализация

Теперь давайте реализуем этот алгоритм в Java. Мы будем повторно использовать переменнуюDIRECTIONS, определенную в предыдущем разделе.

Давайте сначала определим служебный метод для возврата от данного узла к его корню. Это будет использоваться для отслеживания пути после обнаружения выхода:

private List backtrackPath(
  Coordinate cur) {
    List path = new ArrayList<>();
    Coordinate iter = cur;

    while (iter != null) {
        path.add(iter);
        iter = iter.parent;
    }

    return path;
}

Теперь давайте определим основной методsolve.. Мы повторно используем три блока, используемые в реализации DFS, т.е. проверить узел, отметить посещенный узел и пройти соседние узлы.

Сделаем одно небольшое изменение. Вместо рекурсивного обхода мы будем использовать структуру данных FIFO для отслеживания соседей и перебора их:

public List solve(Maze maze) {
    LinkedList nextToVisit
      = new LinkedList<>();
    Coordinate start = maze.getEntry();
    nextToVisit.add(start);

    while (!nextToVisit.isEmpty()) {
        Coordinate cur = nextToVisit.remove();

        if (!maze.isValidLocation(cur.getX(), cur.getY())
          || maze.isExplored(cur.getX(), cur.getY())
        ) {
            continue;
        }

        if (maze.isWall(cur.getX(), cur.getY())) {
            maze.setVisited(cur.getX(), cur.getY(), true);
            continue;
        }

        if (maze.isExit(cur.getX(), cur.getY())) {
            return backtrackPath(cur);
        }

        for (int[] direction : DIRECTIONS) {
            Coordinate coordinate
              = new Coordinate(
                cur.getX() + direction[0],
                cur.getY() + direction[1],
                cur
              );
            nextToVisit.add(coordinate);
            maze.setVisited(cur.getX(), cur.getY(), true);
        }
    }
    return Collections.emptyList();
}

5. Заключение

В этом руководстве мы описали два основных алгоритма графа поиска в глубину и поиска в ширину для решения лабиринта. Мы также коснулись того, как BFS дает кратчайший путь от входа до выхода.

Для дальнейшего чтения найдите другие методы решения лабиринта, такие как алгоритм A * и Dijkstra.

Как всегда, полный код можно найти вover on GitHub.