Michał Kazimierz Kowalczyk rok 1, semestr 2 nr albumu indeksu: 28969 Algorytmy i struktury danych Problem połączeń
Określenie problemu Problem połączeń możemy odnaleźć w wielu dziedzinach. Dla potrzeb tego projektu zobrazujemy go na przykładzie sieci informatycznych. Każde urządzenie istniejące w sieci posiada swój identyfikator, który umożliwia nam odwołanie się doń z zewnątrz. Jednak oprócz kwestii identyfikacji elementów w sieci, musimy rozważyć inny problem sposób łączenia się z omawianymi urządzeniami, a konkretniej wyznaczenie drogi, poprzez jaką muszą popłynąć dane od adresata dostały się do odbiorcy. Istnieją dwie możliwości: 1. Stworzenie połączenia bezpośrednio między każdym elementem (metoda inwazyjna, ale najprostsza). 2. Wykorzystanie istniejących połączeń pośrednich między urządzeniami (metoda nieinwazyjna, ale skomplikowana). Zakładamy więc, że możliwość pierwsza jest tylko ostatecznością i próbujemy ją omijać dzięki możliwości drugiej. Po tym krótkim wstępie spróbujemy określić nasz problem w sposób ogólny. Posiadając n elementów wyznaczyć m połączeń między nimi, ograniczając nawiązywanie ilość nowych połączeń poprzez wykorzystywanie ścieżek pośrednich. Ustalmy nazewnictwo, które pozwoli nam w prosty sposób określać bardziej abstrakcyjne podproblemy. Elementem będziemy nazywali każdy obiekt, który będziemy chcieli połączyć, natomiast identyfikatorem numer pozwalający nam się do niego odwołać. Ścieżką nazywamy zbiór połączeń między elementami tworzącymi sieć pozwalającą połączyć dowolne elementy zawierające się w niej. Stworzenie nowego połączenia nazwiemy scaleniem. Proponowane rozwiązanie Stwórzmy algorytm opierający się na drzewiastej strukturze danych, zaimplementujmy go w językach C/C++ i sprawdźmy jego działanie na różnych danych. Poprzez modyfikacje spróbujmy odnaleźć najbardziej optymalne rozwiązanie dla naszych potrzeb. Dla wybierania dwóch elementów ze zbioru do połączenia skorzystamy z funkcji losowych zawartych w GNU Scientific Library. Wszelki kod zawarty w tym projekcie został przetestowany w środowisku programistycznym Dev-C++ uruchomionym pod Windows Vista. Zacznijmy od najprostszego rozwiązania algorytmu szybkie wyszukiwanie.
Szybkie wyszukiwanie Algorytm ten będzie przeszukiwał tablicę identyfikatorów elementów z zakresu (n - 1) maksymalna ilość scaleń (twierdzenie z teorii grafów). Istniejące połączenie między elementami będziemy określać przez jeden wybrany identyfikator obiektu z tej sieci. Podczas łączenia dwóch sieci przepiszemy główny identyfikator pierwszej z nich do drugiej. Oto kod naszego algorytmu: /*Funkcja otrzymuje następujące argumenty: tablicę elementów, ich liczbę (n), ilość scaleń, wskaźnik na obiekt generatora liczb losowych*/ void quicksearch (unsigned array[], unsigned length, unsigned max, gsl_rng *r){ unsigned i, element1, element2, commonvalue; /*Dopóki ilość pozostałych scaleń jest różna od */ while (max){ /*Wygenerowanie losowych identyfikatorów elementów*/ element1 = gsl_rng_uniform (r) * length; element2 = gsl_rng_uniform (r) * length; /*Jeśli dwa identyfikatory wskazują na elementy z tej samej sieci, pomiń ten przypadek*/ /*Połącz dwie sieci*/ if (array[element1] == array[element2]) continue; commonvalue = array[element1]; for (i = ; i < length; i++) max--; if (array[i] == commonvalue) array[i] = array[element2];
Algorytm ten pozwolił nam utworzyć wykres zależności czasu liczonego w sekundach (oś Y) względem ilości elementów (oś X). 45 4 35 3 25 2 15 1 5 5 15 25 35 45 55 65 75 85 95 1% 75% 5% 25% Na wykresie znajdują się 4 krzywe, 25 / 5 / 75 / 1 %. Wartości te oznaczają ile z możliwych scaleń miało zostać dokonanych, gdzie 1 % to (n - 1) scaleń. Jak widzimy, zależność ta jest wykładnicza a ilość scaleń zmienia wpływa na funkcje proporcjonalnie względem czasu. Jak widzimy, algorytm ten nie nadaje do bardziej rozległych sieci. Spróbujmy go zmienić.
Szybkie scalanie Algorytm ten będzie również pobierał 2 losowe identyfikatory elementów. Jednak zamiast tworzyć wydzielone sieci, będzie tworzył ścieżki. Obiekty łączone będą swoimi korzeniami, scalając dwie gałęzie. Dwie sieci będą mogły zostać połączone tylko wtedy, gdy ich korzenie będą różne. /*Funkcja otrzymuje następujące argumenty: tablicę elementów, ich liczbę (n), ilość scaleń, wskaźnik na obiekt generatora liczb losowych*/ void quickmerge (unsigned array[], unsigned length, unsigned max, gsl_rng *r){ unsigned i, element1, element2; /*Dopóki ilość pozostałych scaleń jest różna od */ while (max){ /*Wygenerowanie losowych identyfikatorów elementów*/ element1 = gsl_rng_uniform (r) * length; element2 = gsl_rng_uniform (r) * length; /*Wyszukiwanie korzenia dla elementu 1*/ while (element1!= array[element1]) element1 = array[element1]; /*Wyszukiwanie korzenia dla elementu 2*/ while (element2!= array[element2]) element2 = array[element2]; /*Jeśli dwa identyfikatory wskazują na elementy z tej samej sieci, pomiń ten przypadek*/ if (element1 == element2) continue; /*Dołącz korzeń drugiej sieci za korzeń sieci pierwszej*/ array[element2] = element1; max--;
Wykres: 12 1 8 6 4 2 5 15 25 35 45 55 65 75 85 95 1% 75% 5% 25% Algorytm ten przy m = n - 1 działa znacznie wolniej, niż algorytm pierwszy. Za to przy już niewiele mniejszych ilościach scaleń, algorytm zdaje się być wiele bardziej wydajny. Zauważmy również, że krzywe straciły swoje własności (postać wykładniczą, proporcje odległości itd.). Jego spowolnienie względem szybkiego wyszukiwania prawdopodobnie spowodowany jest budowaniem bardzo skomplikowanych struktur drzewiastych. Spróbujemy je uprościć. Zrównoważone szybkie scalanie Aby zmniejszyć rozmiar drzew, możemy je inteligentniej budować. Dotychczasowo połączenie dwóch gałęzi polegało na połączeniu jednego z drugim, bez względu na ich rozmiary. Teraz spróbujemy to trochę zróżnicować: /*Funkcja otrzymuje następujące argumenty: tablicę elementów, tablicę rozmiarów gałęzi dla danego korzenia (początkowo każda gałąź ma rozmiar: 1), ich liczbę (n), ilość scaleń, wskaźnik na obiekt generatora liczb losowych*/ void balancedquickmerge (unsigned array[], unsigned offspring[], unsigned length, unsigned max, gsl_rng *r){ unsigned i, element1, element2;
/*Dopóki ilość pozostałych scaleń jest różna od */ while (max){ /*Wygenerowanie losowych identyfikatorów elementów*/ element1 = gsl_rng_uniform (r) * length; element2 = gsl_rng_uniform (r) * length; /*Wyszukiwanie korzenia dla elementu 1*/ while (element1!= array[element1]) element1 = array[element1]; /*Wyszukiwanie korzenia dla elementu 2*/ while (element2!= array[element2]) element2 = array[element2]; /*Jeśli dwa identyfikatory wskazują na elementy z tej samej sieci, pomiń ten przypadek*/ if (element1 == element2) continue; /*Jeśli pierwsza gałąź jest większa, dołącz do niej elementy gałęzi drugiej i vice-versa*/ if (offspring[element1] > offspring[element2]){ array[element2] = element1; offspring[element1]++; else{ array[element1] = element2; offspring[element2]++; max--;
Wykres: 5 4,5 4 3,5 3 2,5 2 1,5 1,5 5 15 25 35 45 55 65 75 85 95 1% 75% 5% 25% Jak łatwo zauważyć, algorytm ten działa znacznie lepiej. Widzimy, że przy wiele większym zakresie (nie jak wcześniej 5 1 ): 5 1 elementów otrzymujemy czasy zacznie mniejsze, niż przy poprzednich algorytmach. Jednak mimo wszystko dla bardzo dużych rozwiązań algorytm ten jest nadal zbyt wolny. Zrównoważone szybkie scalanie z kompresją ścieżek przez połowienie Naszym celem jest dalsza optymalizacja algorytmu, szczególnie pod względem rozbudowania struktury drzewiastej. Od teraz zastosujemy dla każdego elementu podczas kontaktu z nim przesunięcie go o 2 miejsca w stronę drzewa. W ten sposób do jednego korzenia dochodzić będzie większa ilość gałęzi. /*Funkcja otrzymuje następujące argumenty: tablicę elementów, tablicę rozmiarów gałęzi dla danego korzenia (początkowo każda gałąź ma rozmiar: 1), ich liczbę (n), ilość scaleń, wskaźnik na obiekt generatora liczb losowych*/ void compressionbyhalving (unsigned array[], unsigned offspring[], unsigned length, unsigned max, gsl_rng *r){ unsigned i, element1, element2, n1, n2;
while (max){ /*Wygenerowanie losowych identyfikatorów elementów*/ element1 = gsl_rng_uniform (r) * length; element2 = gsl_rng_uniform (r) * length; /*Wyszukiwanie korzenia dla elementu 1 wykorzystując metodę połowienia*/ while (element1!= array[element1]){ element1 = array[element1]; array[element1] = array[array[element1]]; /*Wyszukiwanie korzenia dla elementu 2 wykorzystując metodę połowienia*/ while (element2!= array[element2]){ element2 = array[element2]; array[element2] = array[array[element2]]; /*Jeśli dwa identyfikatory wskazują na elementy z tej samej sieci, pomiń ten przypadek*/ if (element1 == element2) continue; /*Jeśli pierwsza gałąź jest większa, dołącz do niej elementy gałęzi drugiej i vice-versa*/ if (offspring[element1] > offspring[element2]){ array[element2] = element1; offspring[element1]++; else{ array[element1] = element2; offspring[element2]++; max--;
Wykres: 4 3,5 3 2,5 2 1,5 1,5 5 15 25 35 45 55 65 75 85 95 1% 75% 5% 25% Jak widzimy, czas działania dla tego samego zakresu widocznie się zmniejszył. Jednak analizując strukturę drzewiastą łatwo zauważyć, że im drzewa posiadają krótsze gałęzie, tym algorytm działa szybciej. Spróbujmy więc skrócić jak najbardziej to tylko możliwe długość gałęzi. Zrównoważone szybkie scalanie z pełną kompresją ścieżek W momencie dostępu do elementu niebędącego korzeniem drzewa, możemy w pętli poprzyklejać każdy element do tego miejsca (korzenia). W ten sposób w zależności od wylosowanej liczby, możemy za jednym razem zredukować długość gałęzi do minimum. /*Funkcja otrzymuje następujące argumenty: tablicę elementów, tablicę rozmiarów gałęzi dla danego korzenia (początkowo każda gałąź ma rozmiar: 1), ich liczbę (n), ilość scaleń, wskaźnik na obiekt generatora liczb losowych*/ void fullcompression (unsigned array[], unsigned offspring[], unsigned length, unsigned max, gsl_rng *r){ unsigned i, element1, element2, container1, container2, buffer; while (max){
/*Wygenerowanie losowych identyfikatorów elementów*/ container1 = element1 = gsl_rng_uniform (r) * length; container2 = element2 = gsl_rng_uniform (r) * length; /*Wyszukiwanie korzenia dla elementu 1*/ while (element1!= array[element1]) element1 = array[element1]; /*Wyszukiwanie korzenia dla elementu 2*/ while (element2!= array[element2]) element2 = array[element2]; /*Przyklejanie elementów gałęzi tworzonej z elementu 1 do korzenia*/ while (array[container1]!= element1){ buffer = container1; container1 = array[container1]; array[buffer] = element1; offspring[element1]++; /*Przyklejanie elementów gałęzi tworzonej z elementu 2 do korzenia*/ while (array[container2]!= element2){ buffer = container2; container2 = array[container2]; array[buffer] = element2; offspring[element2]++; /*Jeśli dwa identyfikatory wskazują na elementy z tej samej sieci, pomiń ten przypadek*/ if (element1 == element2) continue; /*Jeśli pierwsza gałąź jest większa, dołącz do niej elementy gałęzi drugiej i vice-versa*/ if (offspring[element1] > offspring[element2]){ array[element2] = element1;
offspring[element1]++; else{ array[element1] = element2; offspring[element2]++; max--; Wykres: 3,5 3 2,5 2 1,5 1,5 5 15 25 35 45 55 65 75 85 95 1% 75% 5% 25% Jak widzimy, udało nam się wprowadzić kolejne optymalizacje. Jeśli jednak wyniki nadal nie są dla nas satysfakcjonujące, można wzbogacić obecny algorytm o możliwość szybszego odnajdywania korzenia, czym jednak nie będziemy się już zajmować w tym artykule.
Porównania algorytmów Wykres przedstawia zależność czasu i ilości elementów przy m = n - 1 5 4,5 4 3,5 3 2,5 2 1,5 1,5 5 15 25 35 45 55 65 75 85 95 zrównoważone szybkie scalanie pełna kompresja ścieżek kompresja przez połowienie Jak widzimy na powyższym wykresie wszystkie złożone algorytmy wywodzące się z szybkiego scalania dla mniejszych liczb działają identycznie. Dopiero przy 25 elementów widać ich rozwarstwienie.,6,5,4,3,2,1 5 15 25 35 45 55 65 75 85 95 zrównoważone szybkie scalanie pełna kompresja ścieżek kompresja przez połowienie Na tym wykresie widzimy wyniki szybkich algorytmów dla m = ¾ (n - 1). Widzimy, że różnice między wynikami algorytmów zaczynają się zacierać, a ich zależność przybiera postać liniową.
,16,14,12,1,8,6,4,2 5 15 25 35 45 55 65 75 85 95 szybkie scalanie kompresja przez połowienie zrównoważone szybkie scalanie pełna kompresja ścieżek Natomiast powyższy wykres przedstawia algorytmy przy m = ¼ (n 1). Jak łatwo zauważyć, tworząc go wzięliśmy pod uwagę często odrzucany algorytm szybkiego scalania. Podsumowanie Analizując rozwój algorytmów pozwalających nam rozwiązać problem połączeń, możemy wywnioskować, że: 1. Im algorytm jest bardziej złożony poprzez zastosowanie odpowiedniego poziomu abstrakcji z odpowiednimi strukturami danych, przy dużych wymaganiach (takich jak duża ilość, czy też szczegółowość obliczeń) otrzymujemy programy działające o wiele szybciej, niż by się wydawało stosując prosty algorytm, zawierający teoretycznie mniejszą liczbę instrukcji. 2. Im mniejsze wymagania wobec algorytmu, względem szczegółowości wyników, czy też ilości danych do przetworzenia, tym bardziej opłacalne jest stosowanie prostych algorytmów. Literatura Robert Sedgewick, Algorytmy w C++, ReadMe, Warszawa, 1999