# Zajęcia 08.03.2021 - Union-find ###### tags: `Informatyka 1PR` Mamy dane $n$ elementów (np. wierzchołków grafu). Początkowo elementy nie są ze sobą w żaden sposób związane. Chcemy na nich skonstruować strukturę zbiorów rozłącznych. Co to znaczy? Początkowo każdy z elementów należy do "swojego" zbioru, do którego nie należy żaden inny wierzchołke. Naszym celem będzie skonstruowanie struktury danych, która będzie realizowała następujące operacje: - $find(v)$ - zwróć **reprezentanta** zbioru, w którym jest $v$, - $union(u, v)$ - połącz ze sobą zbiory, w których znajdują się $u$ oraz $v$. Co mamy na myśli przez **reprezentanta** zbioru? W zasadzie to dokładnie to co oznacza to słowo - dla każdego zbioru będziemy w pewien sposób wybierać który element go reprezentuje i dla wszystkich każdego elementu chcielibyśmy umieć szybko znajdować reprezentanta zbioru, do którego należy [ten element]. Reprezentantami zbioru będą dokładnie te wierzchołki $v$, dla których zachodzi zależność `repr[v] = v`. Obie te operacje można wykonywać w bardzo prosty sposób: utrzymujemy globalną tablicę `repr[1..n]` która będzie utrzymywać informację o reprezentacie każdego elementu (elementy utożsmamiamy z liczbami od $1$ do $n$). Początkowo `repr[i] = i` dla każdego $i$ od $1$ do $n$ (tj. każdy element jest reprezentantem swojego zbioru). Wtedy: - `union(u, v)` mozemy zaimplementować w następujący sposób: ```cpp= void union(u, v) { u = find(u); v = find(v)l repr[u] = v; } ``` Nie dzieje się za wiele - union jedynie zmienia (będziemy mówić *przepina*) reprezentanta zbioru, w którym jest $u$, teraz jest nim reprezentant zbioru, w którym jest $v$. - `find(v)` implementujemy podobnie jak implementowaliśmy naiwne sprawdzanie, czy jeden wierzchołek jest przodkiem innego: ```cpp= int find(int v) { if (repr[v] == v) return v; return find(repr[v]); } ``` Implementacja wynika z prostej zależności: albo ja jestem swoim reprezentantem, albo jest to reprezentant elementu, na który wskazuje `repr[v]`. ```graphviz digraph G { rankdir="BT" { rank=same node [shape=circle]; 1 7 10 } { rank=same node [shape=circle]; 2 3 } { rank=same node [shape=circle]; 8 6 } subgraph clusterA{ pencolor=white node [shape=circle]; 1 -> 1 3 -> 1 2 -> 1 6 -> 2 8 -> 2 4 -> 8 } subgraph clusterB { pencolor=white node [shape=circle] 7 -> 7 5 -> 7 9 -> 5 } subgraph clusterC{ pencolor=white node[shape=circle] 10->10 15->10 14->10 13->10 12->10 11->10 } label="Rysunek 1. Przykładowa struktura naszych zbiorów. Strzałka z u do v oznacza,\nże repr[u] = v.Teraz słowo \"przypinanie\" ma więcej sensu. Można myśleć o tym\ntak, że przepinamy strzałki (ale tylko z tych wierzchołków, które wskazują na siebie)." } ``` Niestety, taka realizacja naszej struktury jest zdecydowanie za wolna. Nietrudno znaleźć przykład, w którym operacja `find(v)` będzie zabierać czas liniowy względem liczby elementów, a co za tym idzie, `union(u, v)` będzie działał równie wolno (bo korzysta z `find`). Spróbuj sam(a) pomyśleć nad takim przykładem! Spróbujmy sobie powiedzieć, kiedy dokładnie `find` działa *wolno* i jak można to naprawić. Zobaczmy, że `find` będzie przechodził po krawędziach drzewa, dopóki nie dojdzie do reprezentanta zbioru (myślmy o nim jak o korzeniu). Zatem rozmiar drzewa nie ma dla nas dużego znaczenia. Gorzej dla nas jest wtedy, kiedy drzewo jest *wysokie*, tj. odległości od korzenia do liści są duże. Naszym celemem jest takie zmodyfikowanie funkcji `union`, żeby zapewnić, że wysokości powstałych drzew są niskie. W tym celu wprowadzamy pojęcie **rangi** drzewa. Rangą drzewa będziemy nazywać liczbę opisującą długość najdłuższej *gałęzi* drzewa, czyli jakiejś ścieżki od korzenia do liścia (liczymy liczbę krawędzi na tej ścieżce). Przykładowo, na rysunku 1. ranga lewego drzewa to 3, ranga środkowego drzewa to 2, a ranga prawego drzewa to 1. Od teraz `union` będzie działał w następujący sposób: :::info `union(u, v)` wyznacza reprezentantów `u` oraz `v` i sprawdza które z drzew reprezentujących zbiory `u` oraz `v` jest wyższe. Następnie przypina reprezentanta niższego drzewa do reprezentanta wyższego drzewa. ::: Jak zaraz się okaże, taka optymalizacja znacząco ogranicza możliwą wysokość otrzymywanych drzew. Zanim to, musimy się jeszcze zastanowić jak sprawdzać jakie są wysokości drzew? Najprostszym i naturalnym sposobem będzie utrzymywanie dodatkowej tablicy `ranga[1..n]`, dla której zachodzi zależność `ranga[v] = ranga drzewa v`. Początkowo `ranga[i] = i` dla wszystkich $i$ od $1$ do $n$. Jak aktualizować taką tablcię? Jeśli chcemy przypiąć wierzchołek $u$ do wierzchołka $v$, to zachodzą dwie możliwości: - `ranga[u] > ranga[v]`. Wtedy nie chcemy przypinać $u$ do $v$, tylko $v$ do $u$. - `ranga[u] < ranga[v]`. Nietrudno wtedy zauważyć, że ranga $v$ nie ulegnie zmianie. - `ranga[u] == ranga[v]`. Wtedy ranga $v$ zwiększy się o dokładnie $1$. ```graphviz digraph G { rankdir="BT" node [shape=circle] { rank=same 1 9 } { rank=same 2 3 } { rank=same 6 5 4 } subgraph clusterA{ pencolor=white 1 -> 1 3 -> 1 4 -> 3 5 -> 3 2 -> 1 [color=red, style=dashed] 6 -> 2 7 -> 6 } subgraph clusterB { pencolor=white 9 -> 9 8 -> 9 10 -> 8 12 -> 11 13 -> 11 11 -> 9 [color=red, style=dashed] } label="Rysunek 2. Na lewo łączymy drzewa tych samych wysokości, zatem wysokość otrzymanego\ndrzewa jest większa o 1. Na lewo dodajemy drzewo niższe to wyższego, więc\notrzymane drzewo ma taką samą wysokość." } ``` Jak się okazuje, otrzymujemy bardzo dobre ograniczenie na wysokości takich drzew: teraz wynosi ona $\lceil\log n\rceil$. Pokażemy to korzystając z indukcji matematycznej, udowadniając następujące twierdzenie: :::success **Twierdzenie 1.** Jeśli drzewo utworzone przez `union` ma wysokość $r$, to należy do niego co najmniej $2^r$ wierzchołków. ::: Zastanówmy się najpierw jakie są konsekwencje tego twierdzenia. Powiedzmy, że połączyliśmy ze sobą już wszystkie wierzchołki, tj. wszystkie elementy należą do jednego zbioru. Gdyby wysokość utworzonego drzewa byłaby większa niż $\lceil\log n\rceil$, to wg twierdzenia liczba elementów takiego drzewa musiałaby wynosić więcej niż $2^{\log n} = n$, co jest oczywistą sprzecznością. Zatem w żadnym momencie działania algorytmu żadne z drzew nie przekracza tej wysokości. **Dowód twierdzenia.** Dowód przebiega indukcyjnie względem rozmiaru drzewa. - Baza indukcji: Widzimy, że jeśli wysokość drzewo ma rozmiar $1$, to jego wysokość wynosi $1$, zatem twierdzenie jest prawdziwe. - Krok indukcyjny: Załóżmy, że twierdzneie jest prawdziwe dla drzew o liczbie wierzchołków mniejszych niż $n$. Weźmy teraz dowolne drzewo $n$ wierzchołkowe i załóżmy, że jego wysokość wynosi $r$. To drzewo musiało powstać przez `union` dwóch mniejszych drzew. Mamy dwa przypadki: - Jedno drzewo miało wysokość $r$, a drugie mniejsze niż $r$: wtedy podpięliśmy drugie drzewo do pierwszego. Ponadto z założenia indukcyjnego wynika, że pierwsze drzewo miało co najmniej $2^r$ wierzchołków, a zatem $n > 2^r$ (no bo nasze drzewo musi mieć więcej wierzchołków, niż drzewa z których je utworzyliśmy). - Oba drzewa miały tę samą wysokość $r - 1$. Wtedy arbitralnie wybraliśmy, które drzewo podpiąć do którego. Jednakże, z założenia indukcyjnego oba drzewa musiały mieć co najmniej $2^{r-1}$ wierzchołków, a zatem nasze drzewo ma co najmniej $2^{r-1} + 2^{r-1} = 2^r$ wierzchołków. $\blacksquare$ <!-- Załóżmy teraz, że mamy dwa drzewa, oba wysokości $r$ (jeśli jedno byłoby niższe, to wysokość się nie zmienia, a liczba wierzchołków rośnie, co działa jedynie na naszą korzyść). Z założenia indukcyjnego liczba wierzchołków w obu drzewach wynosi co najmniej $2^r$. Gdy połączymy te drzewa, to jego wysokość będzie wynoscić $r + 1$. Jednakże liczba wierzchołków w tym drzewie to teraz co najmniej $2^r + 2^r = 2\cdot 2^r = 2^{r + 1}$, a zatem teza nadal jest spełniona, co dowodzi jej prawdziwości. --> Zmieniona operacja `union` wygląda teraz następująco: ```cpp= void union(int v, int u) { u = find(u); v = find(v); if (ranga[v] < ranga[u]) repr[v] = u; else repr[u] = v; if (ranga[v] == ranga[u]) ranga[v]++; } ``` --- Działanie procedury `find` można przyspieszyć jeszcze bardziej jedną, bardzo prostą optymalizacją. Za każdym razem, kiedy wykonujemy operację `find(v)`, możemy aktualizować wartość `repr[v]` na faktycznego reprezentanta wierzchołka `v`: ```cpp= int find(int v) { if (repr[v] == v) return v; return repr[v] = find(repr[v]); } ``` Tę optymalizację nazywamy **kompresją ścieżek**. Połączenie obu tych optymalizacji sprawia, że wysokości drzew stają się jeszcze mniejsze. Dokładna analiza złożoności takiej wersji procedur jest trudna, ale warto wiedzieć, że dla danych z którymi kiedykolwiek się spotkacie (a nawet z którymi kiedykolwiek spotka się ludzkość), nie przekroczy $5$ ([dla ciekawskich](https://pl.wikipedia.org/wiki/Funkcja_Ackermanna#Odwrotna_funkcja_Ackermanna)).