Ludzie od wieków grupują wszystko, co napotkają na swojej drodze. Każdy człowiek znajduje się w wielu grupach: paczce przyjaciół, rodzinie, klasie, szkole itd. W matematyce zamiast "grupy" będziemy mówić zbiory. Na dzisiejszej lekcji nauczymy się:
Każdemu zbiorowi przypiszemy dokładnie jednego reprezentanta. Mówiąc o reprezentancie elementu będę miał na myśli reprezentanta zbioru, do którego należy.
Struktura zbiorów rozłącznych, inaczej Find & Union, umożliwia wykonywanie tych operacji. Zanim przejdziemy do jej omówienia, zdefiniujemy kilka pojęć:
rep[x]
- jeśli jest swoim reprezentantem rep[x] = x
; w przeciwnym wypadku rep[x]
będzie "starać się" wskazywać na reprezentanta ile[x]
- liczba elementów w zbiorze, którego reprezentantem jest Find(a)
- funkcja, która zwraca reprezentanta elementu Union(a,b)
- funkcja, która łączy zbiory, w których znajduje się i Żeby sprawdzić czy i są w tym samym zbiorze wystarczy sprawdzić, czy Find(a)=Find(b)
. Zauważmy, że mając funkcje Find(a)
i Union(a,b)
, będziemy już umieli zrobić wszystko, czego chcieliśmy się dzisiaj nauczyć.
Na samym początku żadne dwa elementy nie są w tym samym zbiorze - każdy z nich tworzy jednoelementowy zbiór. Reprezentantem jest rep[x]=x
, a ile[x]=1
Załóżmy, że dla każdego rep[i]
wskazuje na reprezentanta i. Union(2, 5)
void Union (int a,int b) {
a = Find(a);
b = Find(b);
rep[a] = b;
ile[b] += ile[a];
}
Niech i będą reprezentantami kolejno i Żeby połączyć dwa zbiory ustawimy rep[a]
na i zaktualizujemy wartość ile[b]
dodając do niej ile[a]
.
Zauważmy, że rep[a]
"stara się" być reprezentantem - wskazuje albo na niego, albo na element, który kiedyś nim był. Co więcej rep[rep[a]], rep[rep[rep[a]]]
itd. również się "starają", a każdemu z nich wychodzi to lepiej.
Jako, że łączymy w zbiory skończenie wiele elementów, istnieje takie rep[rep[rep[...rep[x]]]]
będące reprezentantem Wiemy również, że wskazuje on na samego siebie. Oznacza to, że możemy go znaleźć prostą rekurencją:
int Find (int a) {
if (rep[a] == a)
return a;
return Find(a);
}
Wyżej opisane Find & Union działa poprawnie, ale istnieją przypadki, dla których będzie wolne. Zauważmy, że złożoność Union(a,b)
jest taka sama, jak Find(a)
, która z kolei zależy tylko i wyłącznie od liczby wywołań rekurencyjnych. Chcielibyśmy aby była jak najmniejsza. Niestety przy obecnej implementacji może się zdarzyć, że będzie trwać nawet . Dzieje się tak w przypadku, gdy wszystkie elementy są w jednym zbiorze i wartości rep[i]
są parami różne. Wówczas Find(x)
dla który nie reprezentuje nikogo będzie kosztował nas operacji.
Pomimo, że pojedyncze zapytanie o nie jest jeszcze aż tak czasochłonne, to jeśli spytamy się o niego wiele razy będziemy mieli duży problem.
Jeżeli zawsze będziemy przypinać mniejszy zbiór do większego, to złożoność Find(x)
dla dowolnego spadnie do .
Dowód: Zauważmy, że jeśli rep[x] != x
, to wcześniej musieliśmy wywołać funkcję Union(x,rep[x])
. \mbox{Oznacza to,} że liczba elementów w zbiorze rep[x]
była nie mniejsza niż w zbiorze ile[rep[x]]
jest dwukrotnie większe od ile[x]
. Wartości ile[i]
nie mogą przekroczyć więc kroków rekurencyjnych może być maksymalnie .
void Union (int a, int b) {
if (ile[a] > ile[b])
swap(a, b);
rep[a] = b;
ile[b] += ile[a];
}
Kiedy raz znajdziemy reprezentanta a możemy od razu zaktualizować rep[a]
. W ten sposób pominiemy konieczność wywoływania takich samych rekurencji wiele razy.
int Find (int a) {
if (rep[a] != a)
rep[a] = Find(rep[a]);
return rep[a];
}
Sumaryczna złożoność wszystkich zapytań wyniesie , gdzie alpha jest odwrotnością funkcji Aackermana i rośnie bardzo wolno. dla ekstremalnie dużych wartości, więc Find & Union działa prawie liniowo. Pozwolę sobie pominąć dowód tego faktu. Tak napisana struktura zbiorów rozłącznych jest superszybka i w dodatku przyjemnie się ją implementuje.