Um Maze Solver em Java

Um Maze Solver em Java

1. Introdução

Neste artigo, exploraremos maneiras possíveis de navegar em um labirinto usando Java.

Considere o labirinto como uma imagem em preto e branco, com pixels pretos representando paredes e pixels brancos representando um caminho. Dois pixels brancos são especiais, um sendo a entrada para o labirinto e outra saída.

Dado esse labirinto, queremos encontrar um caminho da entrada até a saída.

2. Modelando o Labirinto

Vamos considerar o labirinto como uma matriz de inteiros 2D. O significado dos valores numéricos na matriz será conforme a seguinte convenção:

  • 0 → Estrada

  • 1 → Parede

  • 2 → Entrada de labirinto

  • 3 → Saída do labirinto

  • 4 → Célula parte do caminho da entrada à saída

We’ll model the maze as a graph. Entrada e saída são os dois nós especiais, entre os quais o caminho deve ser determinado.

Um gráfico típico possui duas propriedades, nós e arestas. Uma aresta determina a conectividade do gráfico e vincula um nó a outro.

Portanto, vamos assumir quatro arestas implícitas de cada nó, ligando o nó dado ao seu nó esquerdo, direito, superior e inferior.

Vamos definir a assinatura do método:

public List solve(Maze maze) {
}

A entrada para o método é ummaze, que contém a matriz 2D, com a convenção de nomenclatura definida acima.

A resposta do método é uma lista de nós, que forma um caminho do nó de entrada para o nó de saída.

3. Backtracker recursivo (DFS)

3.1. Algoritmo

Uma abordagem bastante óbvia é explorar todos os caminhos possíveis, que finalmente encontrarão um caminho, se existir. Mas essa abordagem terá complexidade exponencial e não será bem dimensionada.

No entanto, é possível personalizar a solução de força bruta mencionada acima, retrocedendo e marcando os nós visitados, para obter um caminho em um tempo razoável. Este algoritmo também é conhecido comoDepth-first search.

Este algoritmo pode ser descrito como:

  1. Se estivermos na parede ou em um nó já visitado, retornar falha

  2. Caso contrário, se formos o nó de saída, retornaremos com sucesso

  3. Senão, adicione o nó na lista de caminhos e viaje recursivamente nas quatro direções. Se a falha for retornada, remova o nó do caminho e retorne a falha. A lista de caminhos conterá um caminho único quando a saída for encontrada

Vamos aplicar este algoritmo ao labirinto mostrado na Figura-1 (a), onde S é o ponto de partida e E é a saída.

Para cada nó, percorremos cada direção na ordem: direita, inferior, esquerda, superior.

Em 1 (b), exploramos um caminho e atingimos o muro. Em seguida, retornamos até encontrar um nó que não tenha vizinhos de parede e exploramos outro caminho, como mostrado em 1 (c).

Novamente atingimos a parede e repetimos o processo para finalmente encontrar a saída, conforme mostrado em 1 (d):

imageimage imageimage

3.2. Implementação

Vamos agora ver a implementação Java:

Primeiro, precisamos definir as quatro direções. Podemos definir isso em termos de coordenadas. Essas coordenadas, quando adicionadas a qualquer coordenada, retornarão uma das coordenadas vizinhas:

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

Também precisamos de um método utilitário que adicione duas coordenadas:

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

Podemos agora definir a assinatura do métodosolve.The logic here is simple - se houver um caminho da entrada à saída, retorne o caminho, caso contrário, retorne uma lista vazia:

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

Vamos definir o métodoexplore referenciado acima. Se houver um caminho, então retorne verdadeiro, com a lista de coordenadas no argumentopath. Este método possui três blocos principais.

Primeiro, descartamos nós inválidos, ou seja, os nós que estão fora do labirinto ou fazem parte da parede. Depois disso, marcamos o nó atual como visitado para que não visitemos o mesmo nó repetidamente.

Por fim, movemos recursivamente em todas as direções se a saída não for encontrada:

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;
}

Esta solução usa o tamanho da pilha até o tamanho do labirinto.

4. Variante - caminho mais curto (BFS)

4.1. Algoritmo

O algoritmo recursivo descrito acima encontra o caminho, mas não é necessariamente o caminho mais curto. Para encontrar o caminho mais curto, podemos usar outra abordagem de percurso de gráfico conhecida comoBreadth-first search.

No DFS, um filho e todos os seus netos foram explorados primeiro, antes de passar para outro filho. Whereas in BFS, we’ll explore all the immediate children before moving on to the grandchildren. Isso garantirá que todos os nós a uma distância particular do nó pai sejam explorados ao mesmo tempo.

O algoritmo pode ser descrito da seguinte maneira:

  1. Adicione o nó inicial na fila

  2. Enquanto a fila não estiver vazia, pop um nó, faça o seguinte:

    1. Se atingirmos a parede ou o nó já estiver visitado, pule para a próxima iteração

    2. Se o nó de saída for alcançado, retorne do nó atual até o nó inicial para encontrar o caminho mais curto

    3. Senão, adicione todos os vizinhos imediatos nas quatro direções na fila

One important thing here is that the nodes must keep track of their parent, i.e. from where they were added to the queue. Isso é importante para encontrar o caminho quando o nó de saída for encontrado.

A animação a seguir mostra todas as etapas ao explorar um labirinto usando esse algoritmo. Podemos observar que todos os nós na mesma distância são explorados primeiro antes de passar para o próximo nível:

image

4.2. Implementação

Vamos agora implementar esse algoritmo em Java. Vamos reutilizar a variávelDIRECTIONS definida na seção anterior.

Vamos primeiro definir um método utilitário para voltar atrás de um determinado nó para sua raiz. Isso será usado para rastrear o caminho quando a saída for encontrada:

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

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

    return path;
}

Vamos agora definir o método centralsolve. Vamos reutilizar os três blocos usados ​​na implementação do DFS, ou seja, validar nó, marcar o nó visitado e percorrer os nós vizinhos.

Faremos apenas uma pequena modificação. Em vez de travessia recursiva, usaremos uma estrutura de dados FIFO para rastrear vizinhos e iterar sobre eles:

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. Conclusão

Neste tutorial, descrevemos dois principais algoritmos de gráficos: Pesquisa em profundidade e Pesquisa em profundidade para resolver um labirinto. Também abordamos como o BFS fornece o caminho mais curto desde a entrada até a saída.

Para uma leitura mais aprofundada, procure outros métodos para resolver um labirinto, como o algoritmo A * e Dijkstra.

Como sempre, o código completo pode ser encontradoover on GitHub.