Busca em Profundidade

O algoritmo de busca em profundidade é percorrer todos os caminhos de um grafo de forma sistemática. Grosso modo, o algoritmo funciona assim. Começando por um vértice qualquer, e indo "o mais fundo possível". Sempre que encontramos um vértice já visitado, retornamos da busca.

Labirinto

A intuição aqui é parecida com a procura de um caminho entre a entrada e a saída de um labirinto. A cada ponto, escolhemos um caminho que pode nos levar até a saída. Se chegarmos a um beco sem saída ou a um caminho já visitado, precisamos retornar e tentar outro caminho.

Os algoritmos explicados nesta e nas próximas seções assumirão que o grafo de entrada é conectado e foi implementado usando listas de adjacência. Tomaremos como base a seguinte representação, discutida na seção anterior:

grafo = [ [1],           # Vizinhos do vértice 0.
          [2, 3],        # Vizinhos do vértice 1.
          [1, 4],        # Vizinhos do vértice 2.
          [0],           # Vizinhos do vértice 3.
          [1]            # Vizinhos do vértice 4.
        ]

O código acima representa o grafo como um dicionário de listas. Ele nos diz que existe uma aresta do vértice 0 para o vértice 1, uma aresta do vértice B1 para os vértices 2 e 3, e assim por diante. Nesta implementação, para iterar sobre cada vértice v de um grafo, precisamos simplesmente fazer for v in grafo. E para iterar sobre os vizinhos de um vértice v, basta fazer for u in grafo[v].

A figura abaixo mostra o grafo representado pelo código acima.

Grafo Implícito

Tomando como base essa implementação de grafo e assumindo que exista uma estrutura de dados que nos permita manter registro dos vértices já visitados, discutiremos a seguir como implementar busca em profundidade neste grafo.

Porém, antes de partirmos para a implementação propriamente dita, tentaremos explicar de forma mais técnica o funcionamento da busca em profundidade. Ela começa a partir de um vértice arbitrário $r$, que é chamado de raiz da busca (esse vértice é também algumas vezes chamado de vértice fonte). O vértice $r$ é marcado como visitado. A seguir, seleciona-se um vértice qualquer $r_1$, dentre os vizinhos de $r$ que ainda não tenham sido marcados visitados, e uma nova DFS é iniciada recursivamente a partir dele. A recursão termina ao encontrar um vértice $v$ cujos vizinhos estejam todos marcados como visitados. Se após a DFS em $r_1$ terminar todos os outros vértices adjacentes a $r$ já tiverem sido marcados como visitados, a DFS em $r$ também termina. Caso contrário, seleciona-se um outro vértice arbitrário $r_2$ adjacente a $r$ e ainda não marcado como visitado. Uma nova DFS é iniciada em $r_2$. Esse procedimento é repetido até que todos os vértices do grafo tenham sido marcados como visitados.

Vejamos como implementar esse algoritmo que acabamos de descrever.

def dfs_recursiva(grafo, vertice, visitados):
    visitados.add(vertice)
    for vizinho in grafo[vertice]:
        if vizinho not in visitados:
            dfs_recursiva(grafo, vizinho, visitados)

Note que, na implementação acima, passamos o conjunto visitados como parâmetro da função dfs_recursiva. É possível criar uma função adicional para encapsular esse parâmetro (e outros que viermos a precisar). Isso tornará nossa função mais elegante e genérica. Vejamos como criar essa função adicional.

def dfs(grafo, vertice):
    visitados = set()
    dfs_recursiva(grafo, vertice, visitados)

Se quisermos melhorar ainda mais nossa implementação, podemos declarar a função dfs_recursiva dentro da função dfs. Ao fazer isso, não precisamos passar visitados como parâmetro para dfs_recursiva porque essa função já terá acesso ao conjunto de vértices visitados (como ambos estão dentro da função dfs, eles são visíveis dentro dessa função). A implementação pode ser feita como mostrado abaixo:

def dfs(grafo, vertice):
    visitados = set()

    def dfs_recursiva(grafo, vertice):
        visitados.add(vertice)
        for vizinho in grafo[vertice]:
            if vizinho not in visitados:
                dfs_recursiva(grafo, vizinho)

    dfs_recursiva(grafo, vertice)

Assim, a busca em profundidade pode ser invocada da seguinte forma:

dfs(grafo, 'A')

Se quisermos começar uma busca em profundidade de cada vértice do grafo que ainda não foi visitado, podemos modificar a função acima. Isso é útil, por exemplo, caso o grafo não seja conexo, ou seja, caso existam vértices no grafo que não podem ser alcançados a partir do vértice fonte, mas ainda assim desejamos visitá-los.

Veja como ficaria a função:

def dfs(grafo):
    def dfs_recursiva(grafo, vertice):
        visitados.add(vertice)
        for vizinho in grafo[vertice]:
            if vizinho not in visitados:
                dfs_recursiva(grafo, vizinho)

    visitados = set()
    for vertice in grafo:
        if not vertice in visitados:
            dfs_recursiva(grafo, vertice)

Assim, a busca em profundidade pode ser invocada da seguinte forma:

dfs(grafo)

Nos exemplos abaixo, mostraremos como realizar busca em profundidade começando de um vértice específico, mas caso você deseje, pode modificá-los, seguindo o exemplo que acabamos de mostrar, para que a busca seja realizada em todo o grafo.

Podemos também implementar a função que realiza a busca em profundidade de forma iterativa, por questões de eficiência (funções iterativas tendem a ser mais eficientes). Veja abaixo uma sugestão de implementação:

def dfs_iterativa(grafo, vertice_fonte, visitados):
    visitados.add(vertice_fonte)
    falta_visitar = [vertice_fonte]
    while falta_visitar:
        vertice = falta_visitar.pop()
        for vizinho in grafo[vertice]:
            if vizinho not in visitados:
                visitados.add(vizinho)
                falta_visitar.append(vizinho)

A implementação acima usa uma pilha (falta_visitar) para manter registro da ordem em que devemos visitar os vértices. Uma forma de enxergar a função dessa pilha é como simulando a pilha de chamadas recursivas feitas ao invocarmos a função dfs_recursiva. Podemos também encapsular essa função dentro de outra para tornar a implementação mais elegante:

def dfs(grafo, vertice):
    visitados = set()

    def dfs_iterativa(grafo, vertice_fonte):
        visitados.add(vertice_fonte)
        falta_visitar = [vertice_fonte]
        while falta_visitar:
            vertice = falta_visitar.pop()
            for vizinho in grafo[vertice]:
                if vizinho not in visitados:
                    visitados.add(vizinho)
                    falta_visitar.append(vizinho)

    dfs_iterativa(grafo, vertice)

Em sua essência, a busca em profundidade funciona como mostrado na implementação acima. Porém, se observarmos bem o código acima, veremos que ele não faz nada muito interessante. Os vértices são visitados em uma ordem específica, e essa ordem é o que caracteriza a busca em profundidade. É importante frisar isso: a busca em profundidade determina uma ordem na qual os vértices do grafo são visitados.

Entretanto, dependendo do problema específico que desejamos resolver, podemos querer manter registro de certas coisas ao visitar os vértices. Por exemplo, na implementação acima nós não mantemos registro da ordem em que os vértices são visitados, mas isso poderia ser útil em alguns casos. Ou poderíamos retornar um valor específico caso chegássemos a um vértice pré-determinado (por exemplo, para dizer que um vértice é alcançável a partir de um vértice inicial). Nesses casos, temos duas opções: ou criamos estruturas de dados adicionais para armazenar essas informações (como fizemos ao criar uma estrutura para armazenar os vértices visitados), ou criamos uma classe para encapsular essas estruturas adicionais juntamente com o grafo.

Playground

# Use este espaço para praticar o que você aprendeu nesta seção. # Basta digitar o código no espaço abaixo e clicar 'run'.