Escrito por Lúcio Figueiredo e Thiago Mota
Conhecimento prévio necessário:
O Union Find é bastante usado em diversos problemas relacionados a grafos ou conjuntos. Nesta Ideia, vamos demonstrar algumas aplicações interessantes desta estrutura de dados.
Union Find "De trás para frente"
A motivação segue do seguinte problema: É dado um grafo com vértices e
arestas, além de
operações. Em cada operação, uma aresta será removida do grafo. Após cada umas das
operações, imprima a quantidade de componentes conexas presentes no grafo.
Perceba que esse problema é o oposto da aplicação clássica do Union Find - ao invés de adicionar arestas, estamos removendo-as. Como remover arestas de um grafo não parece ser uma tarefa fácil, podemos tentar imaginar o contrário, ou seja, pensar no problema de de trás para frente.
Imagine que vamos começar com o grafo resultante após todas as arestas serem removidas. A quantidade de componentes conexas neste grafo já nos dá a resposta para a
-ésima operação. Adicionando a última aresta removida (de índice
) neste grafo final, o grafo resultante será o mesmo obtido ao remover as
primeiras arestas. Perceba, então, que podemos adicionar as arestas que foram removidas em ordem contrária, partindo do grafo final. Desta maneira, teremos a resposta para todas as operações. No entanto, como estamos obtendo-as em uma ordem diferente da ordem pedida no problema, teremos que guardar as respostas e imprimi-las depois de processar todas elas - podemos fazer isso utilizando um vetor com
posições, onde a
-ésima posição guarda a resposta após a
-ésima operação.
Assim, o problema foi reduzido para a aplicação comum do Union Find: adicionar arestas a um grafo e computar a quantidade de componentes conexas presentes neste grafo. A complexidade final será , onde
é a complexidade de cada operação do UF. Confira o código abaixo para melhor entendimento:
Em geral, ao lidar com operações aparentemente complicadas em um problema, é uma boa ideia imaginar cada uma delas ao contrário, com o objetivo de simplificá-las.
Small to Large
Imagine o seguinte problema geral: Possuímos diversos conjuntos (como um set do C++) e realizamos várias operações que consistem em unir dois conjuntos quaisquer em um só. Se o total de elementos nos conjuntos é , a quantidade de operações é
, e a complexidade de unir conjuntos é
(por exemplo, com um set do C++,
), uma possível solução simples para o problema teria complexidade
.
Iremos realizar a seguinte modificação nas operações: ao juntar dois conjuntos e
, onde
, sempre vamos inserir os elementos de
em
. Ou seja, em cada operação, vamos iterar apenas pelos elementos de
e inseri-los em
. Vamos, então, observar o que acontece com cada elemento do menor conjunto,
. Definindo
como o tamanho do conjunto ao qual o elemento
pertence, é possível notar que para todo elemento
, ao ser inserido em
, o seu novo valor
irá, no mínimo, duplicar, já que
.
Seja a quantidade de vezes que o elemento
fez parte do menor conjunto de uma operação. Note que a complexidade do algoritmo é igual à soma do tamanho do menor conjunto em cada uma das
operações, multiplicado for
. Pórem, isto é análogo à
vezes a soma de
para cada
. Como citado anteriormente, em cada operação, o tamanho do conjunto resultante será maior ou igual ao dobro do tamanho do menor conjunto. Logo, teremos que
, pois como
é sempre menor ou igual a
, poderemos duplicar o tamanho do conjunto de
no máximo
vezes. Assim, a complexidade final será igual a
.
Está técnica de sempre inserir o menor conjunto dentro do maior conjunto é conhecida como Small to Large e possui diversas aplicações. Uma delas é com o próprio Union Find. Imagine que estamos realizando as funções e
sem nenhuma otimização. Se utilizarmos a técnica demonstrada anteriormente na função
, ou seja, sempre definirmos o pai do conjunto resultante como o pai do maior conjunto, o Union Find possuirá complexidade amortizada de
, pelo mesmo argumento acima. Além disso, é provado que ao utilizar esta otimização em conjunto com a otimização do
, atingiremos a mesma complexidade
do Union Find.
Para implementar a nova otimização do Union Find, é suficiente guardar apenas os vetores e
, onde
tem o mesmo significado que no UF original e
representa o tamanho de cada conjunto. Segue o código abaixo para melhor entendimento:
Outra aplicação interessante do Small to Large no Union Find é a possibilidade de manter uma estrutura de dados em cada conjunto, e juntá-las na função com complexidade
, onde
é a complexidade respectiva da estrutura utilizada. Se guardarmos, por exemplo, um set em cada conjunto, podemos juntá-los em
. Confira o código abaixo que demonstra essa técnica:
Rollback no Union Find
Vamos olhar o seguinte problema, dado um grafo de vértices podemos realizar as seguintes operações: Adicionar uma aresta, remover a última aresta adicionada e checar se existe um caminho entre dois vértices, a maneira mais fácil de resolver este problema é guardar uma lista com todas as arestas e montar o grafo das arestas atuais sempre que for checar a existência de um caminho, mas como montar o grafo e realizar a DFS é em
sendo
o número de arestas no grafo atual a complexidade final seria
sendo
o número de operações, essa complexidade seria muito lenta caso os limites de
e
fossem maiores ou iguais a
.
Para resolver o problema de forma otimizada utilizaremos um Union Find, de forma que guardaremos sempre a última operação feita no Union Find em uma pilha, para isso teremos que utilizar uma versão levemente modificada do Union Find onde não utilizaremos a otimização do e apenas a otimização no
, a motivação por trás disso é que a otimização no
modifica todos os elementos no caminho de
até o mais acima, neste caso seria necessário guardar muitos elementos alterados, remover a otimização deixa a complexidade do Union Find em
como demonstrado na parte de Small-To-Large.
Toda vez que utilizarmos a operação apenas o pai do "pai da componente" de
ou de
será alterado, por exemplo:
Logo precisamos apenas guardar o vértice que está sendo alterado na nossa pilha de modificações.
Também precisamos notar o caso em que o (altura) das componentes de
e
são as mesmas, nesse caso nós adicionamos
na altura do novo pai da componente, logo precisamos guardar para cada modificação o
, o
e um
afirmando se a altura foi ou não alterada.
E com isso podemos criar uma função chamada que volta exatamente uma operação, ela pega a operação no topo da pilha e desfaz ela, alterando pai do
de volta para
e diminuindo a altura do
caso a altura tenha sido alterada, e depois removemos a operação da pilha.
Essa técnica é conhecida como Rollback no Union Find, onde a ideia é desfazer a última modificação feita, segue o código para melhor entendimento: