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