← Todas as estruturas

Graph: BFS & DFS

Grafos · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Graphs

O problema

Toda estrutura em outro lugar deste repositório responde "encontre o valor para esta chave" — uma hash table, uma BST, uma B-tree, todas indexam entradas individuais. Nenhuma delas responde a um tipo diferente de pergunta: dado como um conjunto de coisas está conectado entre si, quais delas podem ser alcançadas a partir de um ponto de partida, seguindo quantos saltos forem necessários? Uma Hash Table pode dizer se a conta A tem uma aresta direta para a conta B. Ela não pode dizer se a conta A está conectada à conta F através de três contas intermediárias, porque essa é uma pergunta sobre a forma de um grafo de relacionamentos, não sobre uma única chave.

A solução

Modele os relacionamentos como uma lista de adjacência — Map<T, List<T>> — em que a lista de cada chave é tudo o que está diretamente conectado a ela, e percorra-a sistematicamente para que nenhum vértice alcançável seja perdido e nenhum seja visitado duas vezes. Este módulo implementa as duas ordens clássicas de percurso:

Ambas descobrem exatamente o mesmo conjunto de vértices alcançáveis a partir de um ponto de partida — apenas a ordem difere — e ambas fazem isso em O(V + E): cada vértice é visitado uma vez, e cada aresta é examinada no máximo duas vezes (uma vez a partir de cada extremidade).

flowchart LR
    A((A)) --- B((B))
    A --- C((C))
    B --- D((D))
    C --- D
    E((E)) --- F((F))

Iniciando um percurso a partir de A acima: BFS visita A, B, C, D (um salto, depois dois); DFS visita A, B, D, C (até o fim de um caminho, depois retrocede). Nenhuma das duas jamais alcança E ou F — eles são um componente conectado separado, inalcançável a partir de A não importa qual percurso seja usado.

Operação Custo Por quê
addEdge / addVertex O(1) amortizado adiciona a uma lista de adjacência, ou insere uma nova entrada no mapa
bfs / dfs O(V + E) cada vértice alcançável é visitado uma vez, cada aresta é examinada no máximo duas vezes

Exemplo clássico

classic/Graph é um grafo não direcionado e não ponderado, construído sobre uma lista de adjacência Map<T, List<T>> feita à mão — addEdge liga as duas direções, e bfs/dfs retornam a ordem de visita como uma List<T>. GraphTest constrói um grafo com um ciclo (de modo que ambos os percursos sejam forçados a descartar um vizinho já visitado pelo menos uma vez) mais um componente desconectado (de modo que ambos os percursos sejam verificados para nunca entrar nele), e cobre o caso de falha de vértice inicial desconhecido tanto para bfs quanto para dfs.

Exemplo aplicado: percurso de rede AML

applied/AmlNetworkTraversal modela relações de transação entre contas como um grafo para uma investigação de compliance de prevenção à lavagem de dinheiro: dada uma conta sinalizada, o BFS a partir dela encontra toda outra conta alcançável através de quantos saltos de transação forem necessários — o cluster conectado completo potencialmente envolvido no mesmo esquema, não apenas as contrapartes diretas da conta sinalizada, o que uma consulta mais simples do tipo "com quem esta conta transacionou" deixaria passar por completo. AmlNetworkTraversalTest cobre um cluster de múltiplos saltos, confirma que contas mais próximas aparecem antes das mais distantes, e confirma que contas fora da rede sinalizada nunca aparecem no resultado.

Benchmark

./gradlew :graphs:graph-bfs-dfs:jmh

Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 iterações de medição, 1 fork). A contagem de vértices e arestas cresce junto em uma densidade fixa (4 arestas adicionadas por vértice, com semente Random(42)), então, se a afirmação de O(V + E) for válida, o tempo total de percurso deve crescer aproximadamente na mesma proporção que a contagem de vértices. O @Setup de cada execução confirmou que o grafo inteiro permaneceu como um único componente conectado em todos os tamanhos (tanto BFS quanto DFS visitaram todos os V vértices a partir do vértice inicial, nos três tamanhos):

Benchmark (percurso completo) size=1,000 size=10,000 size=100,000
bfsTraversal 240,532 ns 8.9 ms 175.0 ms
dfsTraversal 336,907 ns 5.8 ms 148.4 ms

Normalizado por vértice, isso é aproximadamente 240-340 ns/vértice em size=1,000, 580-895 ns/vértice em size=10,000, e 1,480-1,750 ns/vértice em size=100,000 — crescendo, mas longe do crescimento de ~10x por década que um percurso O(V^2) mostraria com densidade de arestas constante; está bem aquém até de uma ordem de grandeza de crescimento no custo por vértice ao longo de duas ordens de grandeza na contagem de vértices, o que é consistente com O(V + E). O número por vértice não é perfeitamente estável como o de um benchmark verdadeiramente O(1) em outro lugar deste repositório, e os intervalos de confiança em size=10,000 e 100,000 são largos (o ruído de JVM/GC na casa de milissegundos de um único dígito domina nessa contagem de iterações) — ambos os percursos alocam um novo conjunto de visitados e uma nova lista de resultado a cada única invocação aqui, então parte desse crescimento é, realisticamente, pressão de GC/alocação que escala com a pegada de heap, não o próprio algoritmo de grafo se tornando menos linear.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :graphs:graph-bfs-dfs:jacocoTestReport

Relatório em graphs/graph-bfs-dfs/build/reports/jacoco/test/html/index.html.

Testes unitários

src/test/java/com/datastructures/graphs/graphbfsdfs/classic/GraphTest.java
package com.datastructures.graphs.graphbfsdfs.classic;

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class GraphTest {

    @Test
    void startsEmpty() {
        Graph<String> graph = new Graph<>();

        assertThat(graph.isEmpty()).isTrue();
        assertThat(graph.size()).isZero();
    }

    @Test
    void addVertexAddsAnIsolatedVertexWithNoEdges() {
        Graph<String> graph = new Graph<>();

        graph.addVertex("A");

        assertThat(graph.isEmpty()).isFalse();
        assertThat(graph.size()).isEqualTo(1);
        assertThat(graph.bfs("A")).containsExactly("A");
        assertThat(graph.dfs("A")).containsExactly("A");
    }

    @Test
    void addingAVertexTwiceIsANoOpAndDoesNotResetItsEdges() {
        Graph<String> graph = new Graph<>();
        graph.addEdge("A", "B");

        graph.addVertex("A");

        assertThat(graph.size()).isEqualTo(2);
        assertThat(graph.bfs("A")).containsExactlyInAnyOrder("A", "B");
    }

    @Test
    void addEdgeLinksBothVerticesInBothDirections() {
        Graph<String> graph = new Graph<>();

        graph.addEdge("A", "B");

        assertThat(graph.size()).isEqualTo(2);
        assertThat(graph.bfs("A")).containsExactly("A", "B");
        assertThat(graph.bfs("B")).containsExactly("B", "A");
    }

    @Test
    void bfsOnAnUnknownStartVertexThrows() {
        Graph<String> graph = new Graph<>();
        graph.addEdge("A", "B");

        assertThatThrownBy(() -> graph.bfs("Z"))
                .isInstanceOf(NoSuchElementException.class)
                .hasMessageContaining("Z");
    }

    @Test
    void dfsOnAnUnknownStartVertexThrows() {
        Graph<String> graph = new Graph<>();
        graph.addEdge("A", "B");

        assertThatThrownBy(() -> graph.dfs("Z"))
                .isInstanceOf(NoSuchElementException.class)
                .hasMessageContaining("Z");
    }

    /**
     * A       B
     * |\     /|
     * | \   / |
     * |  \ /  |
     * |   X   |
     * |  / \  |
     * | /   \ |
     * C ----- D    E - F (disconnected component)
     *
     * Built with a cycle (A-B-C-D-A plus both diagonals) so that both bfs and dfs are forced to
     * discard an already-visited neighbor at least once - the exact branch that only fires when
     * a vertex has more than one path leading back to it.
     */
    private Graph<String> cyclicGraphWithDisconnectedComponent() {
        Graph<String> graph = new Graph<>();
        graph.addEdge("A", "B");
        graph.addEdge("A", "C");
        graph.addEdge("A", "D");
        graph.addEdge("B", "C");
        graph.addEdge("B", "D");
        graph.addEdge("C", "D");
        graph.addEdge("E", "F");
        return graph;
    }

    @Test
    void bfsVisitsEveryVertexInTheConnectedComponentExactlyOnceInLayerOrder() {
        Graph<String> graph = cyclicGraphWithDisconnectedComponent();

        List<String> visitOrder = graph.bfs("A");

        assertThat(visitOrder).containsExactly("A", "B", "C", "D");
    }

    @Test
    void bfsNeverReachesADisconnectedComponent() {
        Graph<String> graph = cyclicGraphWithDisconnectedComponent();

        List<String> visitOrder = graph.bfs("A");

        assertThat(visitOrder).doesNotContain("E", "F");
    }

    @Test
    void dfsVisitsEveryVertexInTheConnectedComponentExactlyOnceInDepthFirstOrder() {
        Graph<String> graph = cyclicGraphWithDisconnectedComponent();

        List<String> visitOrder = graph.dfs("A");

        assertThat(visitOrder).containsExactly("A", "B", "C", "D");
    }

    @Test
    void dfsNeverReachesADisconnectedComponent() {
        Graph<String> graph = cyclicGraphWithDisconnectedComponent();

        List<String> visitOrder = graph.dfs("A");

        assertThat(visitOrder).doesNotContain("E", "F");
    }

    @Test
    void bfsFromTheOtherDisconnectedComponentOnlyReachesItsOwnVertices() {
        Graph<String> graph = cyclicGraphWithDisconnectedComponent();

        assertThat(graph.bfs("E")).containsExactly("E", "F");
    }

    @Test
    void dfsOnALinearChainVisitsInDepthFirstOrderNotBreadthFirstOrder() {
        Graph<String> graph = new Graph<>();
        graph.addEdge("A", "B");
        graph.addEdge("A", "C");
        graph.addEdge("B", "D");

        List<String> visitOrder = graph.dfs("A");

        // A recursive DFS from A would go A -> B -> D (dead end, backtrack) -> C.
        assertThat(visitOrder).containsExactly("A", "B", "D", "C");
    }
}
src/test/java/com/datastructures/graphs/graphbfsdfs/applied/AmlNetworkTraversalTest.java
package com.datastructures.graphs.graphbfsdfs.applied;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class AmlNetworkTraversalTest {

    @Test
    void flaggedAccountReachesEveryAccountInItsTransactionCluster() {
        AmlNetworkTraversal aml = new AmlNetworkTraversal();
        // ACC-001 (flagged) -> ACC-002 -> ACC-003, plus a direct ACC-001 -> ACC-003 shortcut.
        aml.recordTransaction("ACC-001", "ACC-002");
        aml.recordTransaction("ACC-002", "ACC-003");
        aml.recordTransaction("ACC-001", "ACC-003");

        List<String> reachable = aml.accountsReachableFrom("ACC-001");

        assertThat(reachable).containsExactlyInAnyOrder("ACC-001", "ACC-002", "ACC-003");
    }

    @Test
    void closestAccountsComeFirstInTheReachableOrder() {
        AmlNetworkTraversal aml = new AmlNetworkTraversal();
        aml.recordTransaction("ACC-001", "ACC-002");
        aml.recordTransaction("ACC-002", "ACC-003");

        List<String> reachable = aml.accountsReachableFrom("ACC-001");

        // ACC-002 is one hop away, ACC-003 is two - BFS must surface the closer one first.
        assertThat(reachable).containsExactly("ACC-001", "ACC-002", "ACC-003");
    }

    @Test
    void accountsOutsideTheFlaggedNetworkAreNeverIncluded() {
        AmlNetworkTraversal aml = new AmlNetworkTraversal();
        aml.recordTransaction("ACC-001", "ACC-002");
        // Unrelated pair of accounts transacting only with each other.
        aml.recordTransaction("ACC-100", "ACC-101");

        List<String> reachable = aml.accountsReachableFrom("ACC-001");

        assertThat(reachable).doesNotContain("ACC-100", "ACC-101");
    }
}

Ver relatório completo de cobertura JaCoCo →