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:
- Compressão de caminho — enquanto
findsobe até a raiz, reaponta todo nó pelo qual passa diretamente para essa raiz. A próxima busca por qualquer um desses nós é então um único salto. - União por rank —
unionsempre anexa a árvore mais baixa sob a raiz da árvore mais alta, em vez de anexar arbitrariamente, o que impede as árvores de crescerem em altura desde o início.
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
- Precisa enumerar quais elementos estão em um conjunto, ou iterar pelos membros de um conjunto? O union-find só responde "mesmo conjunto ou não" — ele não tem noção do conteúdo ou do tamanho de um conjunto além disso, por design.
- Precisa desfazer uma união (separar um conjunto de volta em dois)? A compressão de caminho e a união por rank fazem a estrutura da árvore perder informação sobre a ordem original das uniões — essa estrutura foi projetada para mesclagem em uma única direção, não para remoção ou rollback.
- Tem o grafo completo disponível de antemão e precisa dos caminhos mais curtos reais ou da ordem de travessia, não apenas de conectividade? Uma travessia de grafo de verdade (BFS/DFS, ou o módulo Dijkstra deste repositório) é a ferramenta certa — o union-find descarta deliberadamente a informação de arestas para se manter tão barato.
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 < 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 > 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");
}
}