Informática – Ideia 05

Escrito por Lúcio Figueiredo e Thiago Mota

Conhecimento prévio necessário:

  1. Union Find

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 $$N$$ vértices e $$M$$ arestas, além de $$Q$$ operações. Em cada operação, uma aresta será removida do grafo. Após cada umas das $$Q$$ 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 $$Q$$ arestas serem removidas. A quantidade de componentes conexas neste grafo já nos dá a resposta para a $$Q$$-ésima operação. Adicionando a última aresta removida (de índice $$Q$$) neste grafo final, o grafo resultante será o mesmo obtido ao remover as $$Q-1$$ 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 $$Q$$ posições, onde a $$i$$-ésima posição guarda a resposta após a $$i$$-é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á $$O(Q \cdot \alpha(n))$$, onde $$O(\alpha(n))$$ é 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 é $$N$$, a quantidade de operações é $$Q$$, e a complexidade de unir conjuntos é $$O \big (f(N) \big )$$ (por exemplo, com um set do C++, $$f(n) = \log n$$), uma possível solução simples para o problema teria complexidade $$O(N \cdot f(N) \cdot Q)$$.

Iremos realizar a seguinte modificação nas operações: ao juntar dois conjuntos $$S_1$$ e $$S_2$$, onde $$|S_1| \leq |S_2|$$, sempre vamos inserir os elementos de $$S_1$$ em $$S_2$$. Ou seja, em cada operação, vamos iterar apenas pelos elementos de $$S_1$$ e inseri-los em $$S_2$$. Vamos, então, observar o que acontece com cada elemento do menor conjunto, $$S_1$$. Definindo $$T(a)$$ como o tamanho do conjunto ao qual o elemento $$a$$ pertence, é possível notar que para todo elemento $$a \in S_1$$, ao ser inserido em $$S_2$$, o seu novo valor $$T(a)$$ irá, no mínimo, duplicar, já que $$T(a) = |S_2| + |S_1| \geq 2 \cdot |S_1|$$.

Seja $$C(i)$$ a quantidade de vezes que o elemento $$i$$ 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 $$Q$$ operações, multiplicado for $$f(N)$$. Pórem, isto é análogo à $$f(N)$$ vezes a soma de $$C(i)$$ para cada $$1 \leq i \leq N$$. 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 $$C(i) \leq \log_2 N$$, pois como $$T(i)$$ é sempre menor ou igual a $$N$$, poderemos duplicar o tamanho do conjunto de $$i$$ no máximo $$\log_2 N$$ vezes. Assim, a complexidade final será igual a $$O \big(f(N) \cdot \sum\limits_{i=1}^{N} C(i) \big ) = O(N \cdot \log N \cdot f(N))$$.

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 $$Find(x)$$ e $$Join(x, y)$$ sem nenhuma otimização. Se utilizarmos a técnica demonstrada anteriormente na função $$Join$$, ou seja, sempre definirmos o pai do conjunto resultante como o pai do maior conjunto, o Union Find possuirá complexidade amortizada de $$O(\log N)$$, pelo mesmo argumento acima. Além disso, é provado que ao utilizar esta otimização em conjunto com a otimização do $$Find(x)$$, atingiremos a mesma complexidade $$O(\alpha(N))$$ do Union Find.

Para implementar a nova otimização do Union Find, é suficiente guardar apenas os vetores $$pai[i]$$ e $$peso[i]$$, onde $$pai[i]$$ tem o mesmo significado que no UF original e $$peso[i]$$ 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 $$Join(x, y)$$ com complexidade $$O(\log N \cdot f(N))$$, onde $$f(n)$$ é a complexidade respectiva da estrutura utilizada. Se guardarmos, por exemplo, um set em cada conjunto, podemos juntá-los em $$O(\log^2 N)$$. Confira o código abaixo que demonstra essa técnica:

Rollback no Union Find

Vamos olhar o seguinte problema, dado um grafo de $$N$$ 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 $$O(N + M)$$ sendo $$M$$ o número de arestas no grafo atual a complexidade final seria $$O(Q \cdot (N+M))$$ sendo $$Q$$ o número de operações, essa complexidade seria muito lenta caso os limites de $$N$$ e $$Q$$ fossem maiores ou iguais a $$10^5$$.

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 $$Find$$ e apenas a otimização no $$Join$$, a motivação por trás disso é que a otimização no $$Find(x)$$ modifica todos os elementos no caminho de $$x$$ até o mais acima, neste caso seria necessário guardar muitos elementos alterados, remover a otimização deixa a complexidade do Union Find em $$O(\log N)$$ como demonstrado na parte de Small-To-Large.

Toda vez que utilizarmos a operação $$Join(x, y)$$ apenas o pai do “pai da componente” de $$x$$ ou de $$y$$ 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 $$rank$$ (altura) das componentes de $$x$$ e $$y$$ são as mesmas, nesse caso nós adicionamos $$1$$ na altura do novo pai da componente, logo precisamos guardar para cada modificação o $$x$$, o $$y$$ e um $$bool$$ afirmando se a altura foi ou não alterada.

E com isso podemos criar uma função chamada $$rollback()$$ que volta exatamente uma operação, ela pega a operação no topo da pilha e desfaz ela, alterando pai do $$y$$ de volta para $$-1$$ e diminuindo a altura do $$x$$ 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: