Union-Find (Disjoint Set Union - DSU)
1. O Canivete Suíço da Conectividade
O problema de Conectividade Dinâmica pergunta: 1. Os elementos \(A\) e \(B\) estão conectados? 2. Por favor, conecte \(A\) e \(B\) agora.
Diferente de BFS/DFS que analisam um grafo estático, o Union-Find é perfeito para quando as arestas são adicionadas dinamicamente. - É a estrutura por trás do Kruskal. - É usado para detectar ciclos em tempo real. - É usado em processamento de imagens (rotulação de componentes).
2. A Estrutura Básica (Floresta)
Imagine cada elemento como um nó. Cada conjunto é uma árvore. A Raiz da árvore é o “Líder” (Representante) do conjunto. - Se Find(A) == Find(B), então eles têm o mesmo líder, logo estão conectados. - Para fazer Union(A, B), fazemos a raiz de A apontar para a raiz de B (ou vive-versa).
Arrays Necessários
parent[i]: Armazena o pai do nói. Separent[i] == i, ele é a raiz.
3. As Duas Otimizações Críticas
Sem otimizações, a árvore pode virar uma linha reta (Linked List), e o Find fica \(O(N)\). Com elas, fica praticamente \(O(1)\).
3.1. Path Compression (Achatamento de Caminho)
Ao subir de um nó até a raiz durante o Find, aproveitamos para reconectar todos os nós visitados diretamente à raiz. Isso achata a árvore brutalmente.
public int find(int i) {
if (parent[i] == i)
return i;
// Mágica Recursiva: Atualiza o pai para ser a raiz suprema
return parent[i] = find(parent[i]);
}3.2. Union by Rank/Size (União Inteligente)
Ao unir duas árvores, sempre pendure a menor (menos profunda ou com menos nós) na maior. Isso evita que a árvore cresça desnecessariamente em altura.
// Variáveis globais
int[] parent;
int[] size; // ou 'rank'
public void union(int i, int j) {
int rootI = find(i);
int rootJ = find(j);
if (rootI != rootJ) {
// Otimização: A menor entra na maior
if (size[rootI] < size[rootJ]) {
int temp = rootI; rootI = rootJ; rootJ = temp;
}
parent[rootJ] = rootI; // rootJ agora é filho de rootI
size[rootI] += size[rootJ];
}
}4. Implementação Completa e Segura
public class DSU {
private int[] parent;
private int[] size;
private int numSets; // Útil para saber quantos componentes restam
public DSU(int n) {
parent = new int[n];
size = new int[n];
numSets = n;
for (int i = 0; i < n; i++) {
parent[i] = i; // Cada um é seu próprio pai
size[i] = 1;
}
}
public int find(int i) {
if (parent[i] == i) return i;
return parent[i] = find(parent[i]); // Path Compression
}
public void union(int i, int j) {
int rootI = find(i);
int rootJ = find(j);
if (rootI != rootJ) {
// Union by Size
if (size[rootI] < size[rootJ]) {
// i é menor, j manda
parent[rootI] = rootJ;
size[rootJ] += size[rootI];
} else {
// j é menor, i manda
parent[rootJ] = rootI;
size[rootI] += size[rootJ];
}
numSets--; // Dois conjuntos viraram um
}
}
public boolean isSameSet(int i, int j) {
return find(i) == find(j);
}
public int getSize(int i) {
return size[find(i)];
}
}5. Análise de Complexidade: A Função de Ackermann
Com as duas otimizações (Path Compression + Union by Rank), a complexidade amortizada de cada operação é \(O(\alpha(N))\). \(\alpha(N)\) é a inversa da função de Ackermann. Para ter uma ideia: - \(Ackermann(4, 4)\) é um número maior que o número de átomos no universo observável. - Logo, \(\alpha(N) \le 4\) para qualquer dado que caiba num computador.
Na prática, consideramos \(O(1)\) (tempo constante). É uma das estruturas mais eficientes da Ciência da Computação.