← Todas as estruturas

Union-Find

Grafos · ver código-fonte no GitHub

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

Categoria: Graphs

O problema

"Estas duas coisas estão conectadas, direta ou transitivamente, dado tudo que já liguei até agora?" surge o tempo todo — e surge de forma incremental, uma nova ligação descoberta por vez, não como um grafo único entregue de uma só vez. Reexecutar uma travessia completa do grafo (BFS/DFS) do zero a cada nova ligação para responder a uma única pergunta de conectividade é correto, mas desperdiçador: a maior parte do grafo não mudou entre uma ligação e a próxima.

A solução

Rastreie conjuntos disjuntos em vez de um grafo completo. Cada conjunto é uma árvore; todo elemento aponta para um pai, e a raiz de uma árvore é o representante canônico daquele conjunto. find sobe até a raiz; union mescla dois conjuntos apontando uma raiz para a outra; connected é apenas "esses dois elementos têm a mesma raiz?". Nada disso precisa armazenar arestas — apenas ponteiros de pai — o que é o que torna union e connected tão baratos em comparação a manter e retravessar um grafo explícito.

Essa versão simples tem uma fraqueza real: nada impede que uma árvore cresça em altura. Uma sequência de uniões que sempre anexa o elemento mais novo à mesma cadeia crescente — union(0,1), union(1,2), union(2,3), ... — produz uma linha reta, e find na ponta distante precisa percorrer cada salto. Duas correções independentes e combináveis fecham essa lacuna:

Combinadas, o custo amortizado por operação é limitado pela função de Ackermann inversa — efetivamente uma constante pequena para qualquer tamanho de entrada que possa existir na prática.

flowchart TB
    subgraph Naive["naive: sequential unions, no fixes"]
        direction TB
        n0["0"] --> n1["1"] --> n2["2"] --> n3["3"] --> n4["4"]
    end
    subgraph Optimized["optimized: same unions, path compression + union by rank"]
        direction TB
        r["0 (root)"]
        r --> o1["1"]
        r --> o2["2"]
        r --> o3["3"]
        r --> o4["4"]
    end
Operação Ingênua (sem correções) Otimizada (compressão de caminho + união por rank)
find / union / connected O(n) pior caso O(α(n)) amortizado — efetivamente O(1)

Exemplo clássico

classic/NaiveUnionFind é a estrutura de livro-texto sem nenhuma das duas otimizações: union sempre anexa a raiz do primeiro argumento diretamente sob a do segundo, sem considerar a altura da árvore. classic/UnionFind adiciona tanto compressão de caminho (em find) quanto união por rank (em union) sobre exatamente a mesma API. NaiveUnionFindTest e UnionFindTest exercitam ambas uma cadeia de uniões sequenciais — o pior caso da versão ingênua —, com o teste otimizado percorrendo adicionalmente cada ramo da união por rank (rank menor anexa sob rank maior, ranks iguais escolhem uma raiz e a incrementam, um par já unido é uma operação sem efeito) e a compressão de caminho de find em uma árvore de múltiplos saltos.

Exemplo aplicado: detecção de clusters de fraude

applied/FraudRingDetector une incrementalmente contas e os sinais identificadores com os quais elas foram observadas — uma impressão digital de dispositivo, um número de telefone — à medida que essas ligações são descobertas em tempo real, sem necessidade de recomputação em lote. Responder "essas duas contas fazem parte do mesmo anel de fraude?" é então uma única verificação connected, mesmo quando as duas contas nunca compartilharam um sinal diretamente e estão ligadas apenas transitivamente através de várias contas/dispositivos intermediários. FraudRingDetectorTest cobre ligação direta e transitiva, dois clusters genuinamente separados, um identificador desconhecido em qualquer um dos lados da verificação, e a ultrapassagem da capacidade de entidades configurada do detector.

Benchmark

./gradlew :graphs:union-find:jmh

Execução real (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 iterações de medição, 1 fork). Ambas as estruturas passam pela mesma sequência de uniões de pior caso — union(0,1), union(1,2), union(2,3), ... — e então find é medido no mesmo elemento do meio:

Custo de find tamanho=100 tamanho=1,000 tamanho=10,000
ingênua (sem correções) 147.17 ns 995.29 ns 8,486.65 ns
otimizada (compressão de caminho + união por rank) 2.47 ns 2.57 ns 2.79 ns

O custo da versão ingênua sobe com o tamanho — aproximadamente o crescimento que uma travessia de cadeia O(n) prevê, cerca de 58x mais lenta em tamanho=10,000 do que em tamanho=100. A versão otimizada praticamente não se move ao longo desse mesmo aumento de tamanho de 100x (2.47ns para 2.79ns, ~13% — dentro do ruído de medição/JIT): o find de um único elemento neste benchmark atinge seu pior ponto (um caminho de 2-3 saltos) já na primeira chamada e permanece efetivamente estável depois disso, já que a união por rank sozinha manteve a árvore rasa nesta exata sequência adversarial de uniões, e a compressão de caminho achata qualquer profundidade restante. Em tamanho=10,000 a estrutura ingênua é mais de 3,000x mais lenta que a otimizada para a operação idêntica, na mesma sequência de entrada idêntica — essa diferença é a razão de existirem as duas otimizações clássicas do union-find.

Quando não usar

Cobertura de testes

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

./gradlew :graphs:union-find:jacocoTestReport

Relatório em graphs/union-find/build/reports/jacoco/test/html/index.html.

Testes unitários

src/test/java/com/datastructures/graphs/unionfind/classic/NaiveUnionFindTest.java
package com.datastructures.graphs.unionfind.classic;

import org.junit.jupiter.api.Test;

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

class NaiveUnionFindTest {

    @Test
    void everyElementStartsInItsOwnSet() {
        NaiveUnionFind uf = new NaiveUnionFind(4);

        for (int i = 0; i < 4; i++) {
            assertThat(uf.find(i)).isEqualTo(i);
        }
        assertThat(uf.connected(0, 1)).isFalse();
        assertThat(uf.size()).isEqualTo(4);
    }

    @Test
    void unionMergesTwoSets() {
        NaiveUnionFind uf = new NaiveUnionFind(3);

        uf.union(0, 1);

        assertThat(uf.connected(0, 1)).isTrue();
        assertThat(uf.connected(0, 2)).isFalse();
    }

    @Test
    void unioningTwoElementsAlreadyInTheSameSetIsANoOp() {
        NaiveUnionFind uf = new NaiveUnionFind(2);
        uf.union(0, 1);

        uf.union(0, 1);

        assertThat(uf.connected(0, 1)).isTrue();
    }

    /**
     * Sequential unions (0-1, 1-2, 2-3, 3-4) build the naive structure's worst case: a straight
     * chain with no path compression to flatten it. find(0) has to walk every hop to the root.
     */
    @Test
    void findWalksTheFullChainAfterASequenceOfUnions() {
        NaiveUnionFind uf = new NaiveUnionFind(5);
        uf.union(0, 1);
        uf.union(1, 2);
        uf.union(2, 3);
        uf.union(3, 4);

        assertThat(uf.find(0)).isEqualTo(uf.find(4));
        assertThat(uf.connected(0, 4)).isTrue();
    }
}
src/test/java/com/datastructures/graphs/unionfind/classic/UnionFindTest.java
package com.datastructures.graphs.unionfind.classic;

import org.junit.jupiter.api.Test;

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

class UnionFindTest {

    @Test
    void everyElementStartsInItsOwnSet() {
        UnionFind uf = new UnionFind(4);

        for (int i = 0; i < 4; i++) {
            assertThat(uf.find(i)).isEqualTo(i);
        }
        assertThat(uf.connected(0, 1)).isFalse();
        assertThat(uf.size()).isEqualTo(4);
    }

    /**
     * Exercises all three branches of union-by-rank in sequence:
     * <ol>
     *   <li>union(0,1), union(2,3): equal ranks (0 vs 0), so the second argument's root wins
     *       arbitrarily and its rank increments.</li>
     *   <li>union(4,0): rank[4]=0 &lt; rank[0]=1, so the lower-rank root (4) attaches under 0.</li>
     *   <li>union(0,2): rank[0]=1 == rank[2]=1, equal-rank case again, rank[0] becomes 2.</li>
     *   <li>union(0,5): rank[0]=2 &gt; rank[5]=0, so the lower-rank root (5) attaches under 0.</li>
     *   <li>union(1,3): 1 and 3 are already in the same set by this point (both under root 0),
     *       so this is the no-op branch.</li>
     * </ol>
     */
    @Test
    void unionByRankAttachesTheLowerRankTreeUnderTheHigherRankRootInEveryCase() {
        UnionFind uf = new UnionFind(7);

        uf.union(0, 1); // equal ranks (0,0) -> parent[1]=0, rank[0]=1
        uf.union(2, 3); // equal ranks (0,0) -> parent[3]=2, rank[2]=1
        uf.union(4, 0); // rank[4]=0 < rank[0]=1 -> parent[4]=0
        uf.union(0, 2); // rank[0]=1 == rank[2]=1 -> parent[2]=0, rank[0]=2
        uf.union(0, 5); // rank[0]=2 > rank[5]=0 -> parent[5]=0
        uf.union(1, 3); // 1 and 3 already share root 0 -> no-op

        assertThat(uf.connected(4, 5)).isTrue();
        assertThat(uf.connected(1, 3)).isTrue();
        assertThat(uf.connected(0, 6)).isFalse();
    }

    /**
     * Builds a tree three levels deep (3 -> 2 -> 0) so find(3) has to both walk a multi-hop
     * path to the root and then compress it, while find(0) (already the root) exercises the
     * zero-iteration path for both of find's internal loops.
     */
    @Test
    void findCompressesAMultiHopPathAndLeavesAnAlreadyDirectPathUntouched() {
        UnionFind uf = new UnionFind(4);
        uf.union(0, 1); // parent[1]=0, rank[0]=1
        uf.union(2, 3); // parent[3]=2, rank[2]=1
        uf.union(0, 2); // equal ranks -> parent[2]=0, rank[0]=2; now 3 -> 2 -> 0 (depth 2)

        assertThat(uf.find(3)).isEqualTo(0);
        assertThat(uf.find(0)).isEqualTo(0);
        assertThat(uf.connected(1, 3)).isTrue();
    }
}
src/test/java/com/datastructures/graphs/unionfind/applied/FraudRingDetectorTest.java
package com.datastructures.graphs.unionfind.applied;

import org.junit.jupiter.api.Test;

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

class FraudRingDetectorTest {

    @Test
    void entitiesLinkedDirectlyOrTransitivelyLandInTheSameCluster() {
        FraudRingDetector detector = new FraudRingDetector(10);
        detector.linkObservedTogether("account-1", "device-A");
        detector.linkObservedTogether("device-A", "account-2");
        // account-2 was already assigned an index above: reuses it (existing-entity branch).
        detector.linkObservedTogether("account-2", "phone-555");

        assertThat(detector.sameFraudCluster("account-1", "account-2")).isTrue();
        assertThat(detector.sameFraudCluster("account-1", "phone-555")).isTrue();
    }

    @Test
    void entitiesInDifferentClustersAreNotTreatedAsLinked() {
        FraudRingDetector detector = new FraudRingDetector(10);
        detector.linkObservedTogether("account-1", "device-A");
        detector.linkObservedTogether("account-2", "device-B");

        assertThat(detector.sameFraudCluster("account-1", "account-2")).isFalse();
    }

    @Test
    void anUnknownIdentifierOnEitherSideCanNeverShareACluster() {
        FraudRingDetector detector = new FraudRingDetector(10);
        detector.linkObservedTogether("account-1", "device-A");

        assertThat(detector.sameFraudCluster("ghost", "account-1")).isFalse();
        assertThat(detector.sameFraudCluster("account-1", "ghost")).isFalse();
    }

    @Test
    void exceedingTheConfiguredEntityCapacityThrows() {
        FraudRingDetector detector = new FraudRingDetector(2);
        detector.linkObservedTogether("account-1", "device-A"); // uses both available slots

        assertThatThrownBy(() -> detector.linkObservedTogether("account-2", "device-B"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("capacity");
    }
}

Ver relatório completo de cobertura JaCoCo →