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: