Algorytm Quicksort
Transkrypt
Algorytm Quicksort
Algorytm Quicksort Agnieszka Miśkowiec i Tomasz Skucha Akademia Górniczo-Hutnicza Wydział Elektroniki, Automatyki, Informatyki i Elektrotechniki Informatyka II Rok http://mops.uci.agh.edu.pl/∼ miskow/mownit http://mops.uci.agh.edu.pl/∼ skucha/mownit Streszczenie W referacie będziemy starali się opisać algorytm sortowania szybkiego: Quicksort, który jest prawdopodobnie najczęściej używanym spośród wszystkich algorytmów sortowania. Algorytm stanowił przedmiot wielu szczegółowych analiz matematycznych i jeśtesmy w stanie przedstawić fakty dotyczące jego działania. Analizy potwierdzily się w wielu testach empirycznych, a algorytm zyskał status preferowanego dla zagadnień z szerokiego spektrum zastosowań praktycznych. Dlatego też w naszym opracowaniu przyjrzymy się dokładniej sposobom jego efektywnego zaimplementowania. Omówimy rownież różne modyfikacje Quicksort’a, które zwiększają wydajność omawianego algorytmu. Nie trzymając czytelnika dłużej w napięciu, rozpocznijmy więc naszą podróż w krainę algorytmu wartego większej uwagi, w krainę Quicksorta... 1 Wprowadzenie Podstawowa wersja algorytmu Quicksort została wynaleziona w 1960 roku przez C.A.R.Hoare’a. Algorytm jest popularny, bo nietrudno go zaimplementowac, działa sprawnie na danych różnego typu i często zużywa mniej zasobów niż jakikolwiek inny algorytm. Zalety: • działa w miejscu, czyli ”in situ” (używa tylko niewielkiego stosu pomocniczego) • do posortowania n elementów wymaga średnio czasu proporcjonalnego do n ∗ logn • ma wyjątkowo skromną pętlę wewnetrzą Wady: • jest niestabilny • zabiera okolo n2 operacji w najgorszym przypadku • jest wrażliwy (prosty niezauważony bląd w implementacji może powodować niewlaściwe działanie w przypadku niektórych danych) 2 Charakterystyka algorytmu Quicksort 2.1 Zasada działania Algorytm Quicksort jest metodą typu ”dziel i rządz” (ang. divide and conquer). Bowiem jej dzialanie opiera się na dzieleniu tablicy na dwie części, które następnie sortowane są niezależnie. Warto zaznaczyć, iż początkowy porządek elementów w wejściowym zbiorze danych ma wplyw na to, jak będzie przebiegał podział. Należy wiedzieć, że to właśnie proces podziału jest sednem metody. Możemy go opisać nastepująco: dzielimy zbiór danych, wstawiając pewien element rozgraniczający na jego własciwe miejsce, zmieniając porządek danych w tablicy tak, aby elementy mniejsze od niego znalazly się z jego lewej strony, a większe - z prawej. Potem sortujemy rekurencyjnie lewą i prawą częsc tablicy. Ponieważ zawsze w wyniku procedury tworzącej podział przynajmniej jeden element trafia na właściwe miejsce, zatem takie rekurencyjne postępowanie w końcu doprowadzi do posortowania danych. Ogólna strategia podziału: 1. wybieramy element rozgraniczający (wyznaczający podział), ten który trafi na właściwe miejsce 2. przeglądamy tablicę od jej lewego końca, aż znajdziemy element większy niż rozgraniczający 3. przeglądamy tablicę od jej prawego końca, aż znajdziemy element mniejszy niż rozgraniczający 4. oba elementy, które zatrzymują przeglądanie tablicy, są na pewno nie na swoich miejscach w tablicy, więc je zamieniamy ze sobą Postepując w ten sposób upewniamy się, że żaden element po lewej stronie lewego wskaźnika nie jest większy od elementu rozgraniczającego oraz żaden z prawej strony prawego wskaźnika nie jest mniejszy niż element rozgraniczający, tak jak to widzimy na poniższym rysunku: Na powyższym rysunku X oznacza wartość elementu rozgraniczającego, i jest lewym wskaźnikiem, a j-prawym. Najlepiej jest zatrzymać przeglądanie tablicy z lewej strony dla elementów większych lub równych elementowi rozgraniczającemu, a z prawej strony dla elementow mniejszych od niego lub mu równych. Kiedy wskaźniki i oraz j miną się, do zakończenia całego procesu dzielenia tablicy trzeba już tylko zamienić X ze skrajnycm lewym elementem prawego podzbioru (element wskazywany przez lewy wskaźnik). Wewnętrzna pętla sortowania szybkiego zwiększa wskaźnik i porównuje element tablicy z ustaloną wartością. To własnie ta prostota sprawia,że quicksort jest tak szybki: trudno o prostrzą pętlę wewnętrzną algorytmu sortowania. Zilustrujmy to prostym przykładem: W zbiorze wybieramy jeden z elementów na element środkowy Pozostałe elementy umieszczamy odpowiednio po lewej stronie wybranego elementu, jeśli są od niego mniejsze lub równe lub po prawej stronie, jeśli są od niego większe. Otrzymujemy w ten sposób dwa przedzialy A i B Te same operacje wykonujemy w każdym przedziale. Odpowiada to rekurencyjnemu wywołaniu algorytmu dla każdego z nich Po przeniesieniu elementów na odpowiednie strony pozostanie tylko jednen przedzial dwuelementowy B2, który może jeszcze być nieposortowany. Pozostałe przedzialy A1, A2, B1 są jednoelementowe i nie sortujemy ich dalej Wybieramy element środkowy - liczba 8 Podział daje przedzial jednoelementowy zawierający liczbę 9. Zatem algorytm kończymy, zbiór został uporządkowany 3 Implementacje algorytmu Quicksort Poniżej przedstawiono implementację algorytmu sortowania szybkiego w języku Pascal. 3.1 Wersja rekurencyjna PROCEDURE QUICKSORT; procedure sort(l,p: index); var i,j: index; x,w: object; BEGIN i:=l, j:=p; x:= a[(l+p) div 2]; repeat while (a[i].key < x.key) do i:= i+1; while (x.key < a[j].key) do j:= j-1; if i<=j then begin w:=a[i]; a[i]:=a[j]; a[j]:=w; i:= i+1; j:= j-1 end until i>j; if (l<j) then sort(l,j); if (i<p) then sort(i,p) END; begin sort(1,n) end {QUICKSORT}. 3.2 Wersja iteracyjna PROCEDURE _QUICKSORT; const m=12; var j,j,l,p: index; x,w: object; s: 0..m; stack: array[1..m] of record l,p: index end; BEGIN s:=1; stack[1].l:= 1; stack[1].p:= n; repeat {weź rządanie z wierzchołka stosu} l:= stack[s].l; p:= stack[s].p; s:= s-1; repeat {dziel a[l]..a[p]} i:= l; j:= p; x:= a[(l+p) div 2]; repeat while (a[i].key < x.key) do i:= i+1; while (x.key < a[j].key) do j:= j-1; if (i<=j) then begin w:=a[i]; a[i]:=a[j]; a[j]:=w; i:= i+1; j:= j-1; end until (i>j); if (i<p) then begin {żądanie posortowania prawej części} s:= s+1; stack[s].l:= i; stack[s].p:= p; end; p:=j until (l>=p) until (s=0) END {_QUICKSORT} 4 Złożoność algorytmu Quicksort Zarówno czas działania algorytmu sortowania szybkiego jak również zapotrzebowanie na pamięć są uzależnione od postaci tablicy wejściowej. Od tego zależy, czy podziały dokonywane w algorytmie są zrównoważone, czy też nie, a to z kolei zależy od wybranego klucza podziału. W przypadku, gdy podziały są zrównoważone, algorytm jest równie szybki jak np. sortowanie przez scalanie. Gdy natomiast podziały są niezrównoważone, sortowanie może przebiegać asymptotycznie tak wolno, jak sortowanie przez wstawianie. Złożoność algorytmu Quicksort możemy rozpatrywać analizując trzy możliwości: 4.1 Przypadek optymistyczny Z wersją optymistyczną mamy do czynienia, gdy za każdym razem jako klucz podziału wybrana zostaje mediana z sortowanego aktualnie fragmentu tablicy, czyli gdy każdy podział daje równe podzbiory danych. Wówczas liczba porównań niezbędnych do uporządkowania n-elementowgo fragmentu jest rzędu: T (n) = O(n log n) Jest to związane z tym, że równanie rekurencyjne opisujące czas działąnia wygląda natępująco: n T (n) = 2T ( ) + O(n) 2 i na podstawie twierdzenia o rekurencji uniwersalnej otrzymujemy rozwiązanie (1) Jednocześnie złożoność pamięciowa jest w tym przypadku rzędu: M (n) = O(log n) 4.2 Przypadek pesymistyczny Pomimo wielu zalet, wersja podstawowa omawianego algorytmu w pewnych przypadkach okazuje się być nieefektywną. Jednym z nich jest sytuacja, w której dane wejściowe są już posortowane, lub gdy są uporządkowane w odwrotnej kolejności. W przypadku pesymistycznym, objawiającym się każdorazowym wyborem elementu najmniejszego, bądź największego w sortowanym fragmencie tablicy, złożność obliczeniową przyjmuje postać: T (n) = O( n2 ) 2 Wynika to stąd, że liczba wymaganych porównań przeprowadzanych dla tablicy o uporządkowanych danych wejściowych wynosi: (n + 1)n 2 n + (n − 1) + (n − 2) + ... + 2 + 1 = Natomiast równanie rekurencyjne wygląda następująco: T (n) = T (n − 1) + O(n) = n X O(k) = O( k=1 n X k) = O(n2 ) k=1 W tym przypadku otrzymujemy liniową złożoność pamięciową: M (n) = O(n) 4.3 Przypadek przeciętny Średni czas działania algorytmu jest bliski najlepszemu przypadkowi. Quicksort W wielu sytuacjach podziały przeciętnie wypadają w połowie. Dla tak równomiernego rozkładu prawdopodobieństwa wyboru elementu, względem którego dokonujemy podziału, algorytm sortowania szybkiego wymaga czasu działania: T (n) = O(2n ln n) Wzór rekurencyjny dla liczby porównań przeprowadzanych w algorytmie sortowania szybkiego dla n losowo ułożonych elementów jest postaci: T (n) = n+1+ 0 1 n Pn k=1 (T (k − 1) + T (n − k)) dla n 2 dla 0 ¬ n ¬ 1 Ponieważ T(0) + T(1) + ... + T(n-1) jest dokłądnie tym samym, co T(n-1) + ... + T(1) + T(0) można napisac: n 2X T (n − 1) T (n) = n + 1 + n k=1 Eliminujemy sumę mnożąc obie strony przez n i odejmując powyższy wzór dla n -1: nT (n) − (n − 1)T (n − 1) = n(n + 1) − (n − 1)n + 2T (n − 1) co jest równe: nT (n) = (n + 1)T (n − 1) + 2n Następnie dzielimy obie strony przez n(n + 1) i sprowadzamy do postaci rekurencyjnej: n T (n − 1) 2 T (2) X T (n) 2 = + = ... = + n+1 n n+1 3 k=3 k + 1 Ten wynik odpowaida sumie, którą można przybliżyć całką: Z n n X 1 1 T (n) ≈2 ≈2 dx = 2 ln n n+1 1 x k=1 k co po przemnożeniu obu stron przez n+1 daje w rezultacie: T (n) ≈ 2(n + 1) ln n ≈ 2n ln n Biorąc pod uwage fakt, iż 2n ln n w przybliżeniu równe jest 1, 39n log n możemy stwierdzić, że średnia liczba porównań jest około 39 procent większa w stosunku do liczby porównań dla przypadku optymistycznego. 5 5.1 Usprawnienia algorytmu Mediana z trzech W pewnych szczególnych przypadkach, jak na przykład sortowanie posortowanej już tablicy, najprostsza metoda podziału, bazująca na wyborze skrajnego lewego, bądź prawego elementu jak klucza podziału, okazuje się byc wyjątkowo nieefektywną, dającą złożoność kwadratową. W celu zapobiegnięcia takiej sytuacji stosuje się zmienioną postać wyboru elementu, względem którego dokonujemy podziału, polegającą na wyborze tzw. mediany z trzech. Pomysł realizuje się poprzez wstępny wybór trzech elementów z rozpatrywanego fragmentu tablicy oraz użycie jako klucza podziału tego, którego watrość leży pomiędzy wartościami dwu pozostałych elementów. Jest to spowodowane tym, że taki element rozgraniczający podzieli zbiór danych w pobliżu środka. Użycie metody mediany z trzech drastycznie zmniejsza prawdopodobieństwo wystąpienia najgorszego przypadku, eliminuje konieczność stosowania wartowników przy podziałach, redukuje sumaryczny, średni czas działania algorytmu o około 5 %. 5.2 Sortowanie krótkich fragmentów innymi metodami Usprawnienie przy użyciu innych metod sortowania opiera się na spostrzeżeniu, że około połowa wszystkich rekurencyjnych wywołań związanych jest z fragmentami jednoelementowymi. Biorąc pod uwagę fakt, iż wybór elementu dzielącego w przypadku niewielkich fragmentów okazuje się być pracochłonną operacją, bardziej efektywne wydaje się być zastosowanie prostszych algorytmów - przykładowo metody sortowania przez wstawianie. Realizuje się to poprzez użycie metod prostych w momencie, gdy uzyskane fragmenty stają się dostatecznie krótkie, rzędu co najwyżej kilkunastu. Wówczas większość elementów tablicy znajduje się blisko właściwych miejsc, przez co użycie algorytmu sortowania przez wstawianie powoduje znacznie mniejszy koszt. Inna metodą, nieco bardziej efektywną niż stosowanie sortowania przez wstawianie w chwili napotkania małych podzbiorów, jest ignorowanie podczas podziału małych przedziałów. Po podziale otrzymuje się zbiór danych prawie posortowany, który następnie porządkujemy metodą sortowania przez wstawianie. Jak wykazano, pozwala to osiągnąć oszczędność czasu wykonania nawet do 20 %. 5.3 Zmniejszenie rozmiaru stosu W przypadku, gdy sortowany fragment tablicy jest dzielony na część jednoelementową oraz część pozostałą może się zdarzyć że dla dużego rozmiaru danych program może zakończyć się błędem z powodu braku wystarczającej ilości pamięci. Aby nie dopuścić do tego rodzaju sytuacji, przed porządkowaniem elementów następuje sprawdzenie, która z części jest dłuższa, a następnie umieszcza się na stosie tę o większej długości. Dzięki temu, na każdym poziomie wywołań algorytm obsługuje fragment będący co najwyżej połową fragmentu z poprzedniego poziomu. Zapobiega to przepełnieniu stosu w przypadku dużego rozmiaru tablicy wymagającej sortowania (gdyż zamiast głębokości O(n) bedzie miał głebokość rzędu O(log n)). 5.4 QuickerSort Wariant podstawowy szybko sortuje tablicę losowych liczb, jednak w skrajnym przypadku, gdy mamy do czynienia z tablicą n identycznych elementów, calkowity czas wykonania algorytmu wynosi O(n2 )., gdyż każdy z n-1 przebiegów podziału tablicy używa czasu rzędu O(n), aby zająć się pojedyńczym elementem. W celu uniknięcia tego problemu w momencie, gdy funkcja zatrzymuje się na elementach o jednakowych wartościach, należy zamienić je ze sobą. Jest to rozwiązanie inne niż w przypadku stosowania wersji podstawowej, gdzie po napotkaniu jednakowych elementów, przechodzimy nad nimi, nie wykonując zamiany. Mimo, iż wykonujemy więcej operacji zamiany niż potrzeba, przekształcamy przypadek z czasem kwadratowym w przypadek, w którym algorytm wykonuje nlogn porównań. Pomysł polega na podziale sortowanych danych na trzy części: jedną dla kluczy mniejszych niż element rozgraniczający, jedną dla równych oraz jedną dla większych od niego. Przedstawione powyżej metody można łączyć ze sobą, np. połączenie wyboru mediany z trzech z zastępowaniem podstawowej procedury przez stosowanie sortowania przez wstawianie może poprawić czas działania algorytmu sortowania szybkiego w stosunku do wersji podstawoej nawet do 25 procent. Ponadto można rozważać możli- wość dalszych poprawek rozpatrywanego algorytmu przez usunięcie rekurencji, zastąpieniem wywołań procedur kodem typu inline itd. Aby ograniczyć stos wymagany przez algorytm korzysta się z implementacji nierekurencyjnej. Oszczędność czasu można osiągnać poprzez zakodowanie fragmentów programu w asemblerze albo języku maszynowym. 6 6.1 Wersja rekurencyjna a wersja iteracyjna Wersja rekurencyjna W przypadku użycia wersji rekurencyjnej, każde wywołanie procedury sortowania dla jakiejkolwiek podtablicy tablicy wejściowej powoduje konieczność odłożenia na stosie zapewnianym przez system wszystkich zmiennych lokalnych oraz parametrów wywołania danej funkcji. Przez to algorytm pochłania znaczną część zasobów komputera, tym większą, im więcej zagłęnień następuje w wyniku wywołań rekurencyjnych. W zdegenerowanych przypadkach ilość zagłębień może być rzędu O(n), natomiast w przypadku losowych danych maksymalny rozmiar stosu jest proporcjonalny do log n. 6.2 Wersja iteracyjna Korzystając z iteracyjnej wersji algorytmu sortowania szybkiego, gdzie stos jest realizowany jawnie w ciele procedury sortującej, odkładane na nim zmienne stanowią jedynie zmienne tymczasowe, w których pamiętane są informacje o podzbiorach przeznaczonych do posortowania. Za każdym razem, kiedy potrzebny jest podzbiór do przetwarzania, zdejmujemy jeden element ze stosu. Dzięki temu stos zajmuje pamięć proporcjonalną do długości tablicy, co w istotny sposób uszczupla zapotrzebowanie na pamięć. Porównując obie wersje, należy stwierdzić i przy niekorzystnym ułożeniu elementów tablicy wejściowej szybszą metodą okazuje się być wersja rekurencyjna. Jest ona również bardziej intuicyjna, gdyż to wynika z samej definicji algorytmu. Zaleta wersji iteracyjnej uwidacznia się w uniezależnieniu sposobu implementacji wywołań rekurencyjnych przez dany system operacyjny. 7 Probabilistyczna wersja algorytmu Zazwyczaj zakłada się, iż wszelkie permutacje elementów tablicy wejściowej są jednakowo prawdopodobne. Jednak założenie takie nie zawsze jest możliwe. Zamiast przyjmować jakieś założenia można wymusić pewien rozkład, np. przed rozpoczęciem sortowania algorytm może w sposób losowy permutować elementy tablicy, żeby sprawić, aby każda permutacja była jednakowo prawdopodobna, co uniezależnia czas działania od wstępnego uporządkowania danych wejściowych. Wersja probabilistyczna posiada tę własność, iż żadne konkretne dane wejściowe nie powodują pesymistycznego działania algorytmu, natomiast najgorszy przypadek zależy od generatora liczb losowych. Aby utworzyć wersję probabilistyczną, można np. w każdym kroku algorytmu, przed podziałem tablicy, zamienić element względem którego dokonujemy podziału z losowo wybranym elementem. Dzięki temu można oczekiwać, iż w przypadku przeciętnym podział tablicy wejściowej będzie dobrze zrównoważony. 8 Zastosowanie Quicksorta do wyznaczania mediany Mediana ze zbioru n elementów zdefiniowana jest jako element mniejszy lub równy połowie n elementów oraz większy lub równy od drugiej połowy n elementów. W celu wyznaczenia mediany z zadanego zbioru liczb można wykorzystać nieco zmodyfikowany algorytm sortowania szybkiego. Oczywiście jednym ze sposobów może być całkowite posortowanie zbioru, a następnie odczytanie środkowej wartości, jednak zdecydowanie bardziej zadawalające wyniki można osiągnąć, posługując się procesem dzielenia z algorytmu sortowania szybkiego. Wyznaczanie mediany jest szczególnym przypadkiem zagadnienia znajdowania k-tego najmniejszego elementu. Dla zadanej tablicy a algorytm polega na zastosowaniu operacji podziału z metody sortowania szybkiego dla l=1, p=n, z a[k] wybranym jako wartość dzieląca. Wartości indeksów i oraz j są wówczas takie, że: dla h < i, a[h] <= a[k] dla h > j, a[h] >= a[k] i>j Jeżeli wartość dzieląca a[k] okazuje się być za mała, wówczas proces podziału musi zostać powtórzony dla elementów a[i]...a[p]. Gdy wartość jest zbyt duża, operacja dzielenia musi zostać powtórzona dla przedziału a[l]...a[j]. Natomiast gdy indeks k zawarty jest pomiędzy indeksami i oraz j, wówczas element a[k] jest szukaną wielkością. Proes dzielenia tablicy powinien być kontynuowany do chwili wystąpienia ostatniego przypadku. Jeżeli przyjmiemy założenie, iż każdy podział dzieli tablicę na pół, wówczas liczba potrzebnych porównań wynosi: n+ n n + + ... + 1 = 2n 2 4 tzn. jest rzędu n. Analizując przypadek wyznaczania mediany w ten właśnie sposób dochodzimy do wniosku, że do jej znalezienia wymaganych jest około (2 + 2 ln 2)n porównań. 9 Porównanie wariantów algorytmu Quicksort Porównując względne czasy działania różnych wersji algorytmu szybkiego sortowania (ze względu na sposób wyboru klucza podziału) można dojść do wniosku, iż użycie implementacji, w której jako klucz podziału stosuje się medianę z trzech czy też losowy wybór elementu rozgraniczającego, daje w pewnych sytuacjach zdecydowanie lepsze rezultaty niż korzystanie z wersji podstawowej. Szczególnie jest to widoczne, gdy dane wejściowe są posortowane, czy też posortowane w odwrotnej kolejności - co powoduje, iż wszystkie podziały stają się zdegenerowane. Podobnie, testy przeprowadzane dla wariantów ’ulepszających’ algorytm wykazują, iż poprzez zastosowanie sortowania przez wstawianie odpowiednio małych fragmentów, bądź sortowania przez wstawianie zastosowanego do całej tablicy, po uprzednim ignorowaniu małych framentów, pozostawionych bez uporządkowania, czas sortowania ulega skróceniu. Efektywność algorymtu wzrasta także przy zastosowaniu wariantu z podziałem sortowanych fragmentów tablicy na trzy części, co przy istnieniu zdublowanych kluczy zdecydowanie poprawia czas działania procedury sortującej. Literatura 1. Robert Sedgewick: Algorytmy w C++, Wydawnictwo RM, Warszawa 1999 2. Thomas H.Cormen, Charles E.Leiserson, Ronald L. RivestWprowadzenie do algorytmów, Wydawnictwa NaukowoTechniczne, Warszawa 1997, 2001 3. Niklaus Wirth Algorytmy + struktury danych = programy, Wydawnictwa Naukowo-Techniczne 4. Jon Bentley Perełki oprogramowania, Wydawnictwa NaukowoTechniczne 5. L. Banachowski, K.Diks, W.Rytter Algorytmy i struktury danych, Wydawnictwa Naukowo-Techniczne, Warszawa 1996, 2001