Sortowanie to problem, z którym programiści stykają się niejednokrotnie. Z racji ludzkiej chęci do życia w skatalogowanym, uporządkowanym świecie, algorytmy sortowania odgrywają dużą rolę wśród operacji stosowanych na danych. Jednak zwykle przy porządkowaniu danych pojawiają się pytania. Jaką metodę sortowania zastosować? Czy ma być ona szybka, czy można pozwolić sobie na dokładne, ale wolniejsze sortowanie? Dla jakich tablic, jaki algorytm będzie najbardziej optymalny?
Ze względu na dużą różnorodność tego typu algorytmów, najłatwiej przeprowadzić wybór wśród tych najpopularniejszych, biorąc za kryterium ich złożoność czasową i stopień trudności w implementowaniu, pamiętając jednocześnie o wielkości tablicy i ilości danych do posortowania. Jest to bardzo istotny fakt, weźmy dla przykładu sortowanie mieszkańców większego miasta wg ich numeru PESEL.
Z roczników statystycznych wiadomo, że liczba mieszkańców Gliwic na rok 1998 to 215 658. Drugą wartością, która przyda się w przykładzie, to prędkość z jaką mój komputer (na którym oczywista prowadziłam te testy) przelicza operacje elementarne, a jest to
(przy średnio obciążonym systemie, procesor z zegarem 2GHz).
Na dane posortowane algorytmem o klasie złożoności O(N2) (jakim jest np. sortowanie bąbelkowe) trzeba czekać
, natomiast, gdy klasa złożoności wynosi O(N logN) (jak w Quicksorcie) 
Pierwszy trwa niecałą godzinę, drugi – niecałą dziesiętną część sekundy. To chyba spora różnica.
W dalszej części tekstu zajmę się bardziej szczegółowym omówieniem tych, można powiedzieć, sztandarowych algorytmów sortowania. Przedstawię ich implementację w języku C++[1], złożoność czasową i postaram się określić wydajnościowe plusy i minusy.
Sortowanie przez wstawianie
„Ulubiona” metoda sortowania graczy w karty, podczas układania kart tuż po rozdaniu. Ogólna idea algorytmu jest następująca: w danym momencie w ręku trzymamy zarówno karty posortowane, jak i przygotowane do sortowania. Z tych nieposortowanych bierzemy pierwszą z brzegu kartę i wstawiamy w odpowiednie miejsce w grupie posortowanych. Jak można się łatwo domyśleć, czynność tą powtarzamy aż do skończenia się kart w pakiecie nieposortowanych.
Proces ten zobrazuję paroma rysunkami krokowymi.

[kliknij obrazek, aby powiększyć]
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void InsertSort(int *tab)
{
for(int i=1; i<n; i++)
{
int j=1;
int temp=tab[j];
while ((j>0) && (tab[j-1]>temp))
{
tab[j]=tab[j-1];
j--;
}
tab[j]=temp;
}
} |
W pesymistycznym wypadku każdy element będziemy przesuwać na pierwszą pozycję.
Przesunięcie jednego elementu wykona się n razy. Dla n elementów złożoność będzie wynosić n ∙ n.
Z tego wynika, że algorytm sortowania przez wstawianie jest klasy O(n2).
Ze względu na dużą złożoność algorytmu, zupełnie nie nadaje się on do sortowania większych tablic (jak można sobie wyobrazić na przykładzie porządkowania liczby mieszkańców Gliwic), gdyż trwałoby to stanowczo za długo. Jednak wydaje się być wymarzony do sortowania mniejszej ilości danych – przy jego implementacji trudno jest o pomyłkę i jest doskonały w swej prostocie.
Sortowanie bąbelkowe
Swoją intrygującą nazwę wzięło z analogii do pęcherzyków powietrza. Poziomo ustawiona tablica to pojemnik z wodą, a wpisane w nią dane to pęcherzyki powietrza. Najlżejsze bąbelki – czyli najmniejsze liczby, ulatują do góry, podczas gdy te cięższe – pozostają na końcu.
- Przypatrzmy się implementacji
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void bubble(int *tab)
{
for (int i=1; i<n; i++)
{
for (int j=n-1; j>=i; j--)
{
if (tab[j]<tab[j-i])
{ //zamiana
int tmp=tab[j-1];
tab[j-1]=tab[j];
tab[j]=tmp;
}
}
}
} |
Dla przykładu przedstawię analizę sortowania algorytmem bąbelkowym 7-elementowej tablicy. Zacieniowany element, to ten który w pojedynczym przebiegu głównej pętli „uleciał” do góry. W algorytmie zawsze analizowane są dwa sąsiadujące elementy (przebieg pętli j). Jeśli element niżej jest mniejszy niż wyżej, następuje ich zamiana. Operacja ta jest za pierwszym razem wykonywana na całej długości tablicy – i za każdym kolejnym razem zmniejsza się o 1 (o element, który został ustawiony jako najmniejszy na początku tablicy w poprzednim przebiegu pętli i).
|
|
etapy sortowania |
| indeks w tablicy |
|
0 |
1 |
2 |
3 |
4 |
5 |
6 |
| 0 |
40 |
2 |
2 |
2 |
2 |
2 |
2 |
| 1 |
2 |
40 |
4 |
4 |
4 |
4 |
4 |
| 2 |
39 |
4 |
40 |
6 |
6 |
6 |
6 |
| 3 |
6 |
39 |
6 |
40 |
18 |
18 |
18 |
| 4 |
18 |
6 |
39 |
18 |
40 |
20 |
20 |
| 5 |
4 |
18 |
18 |
39 |
20 |
40 |
39 |
| 6 |
20 |
20 |
20 |
20 |
39 |
39 |
40 |
Możemy przyjąć, że operacją dominującą w algorytmie jest operacja porównania dwóch elementów. W pierwszym wywołaniu pętli głównej porównań tych jest n. W następnej n-1 (tak jak wspominałam wcześniej – liczba porównań została zmniejszona o element, który został już uporządkowany), potem n-2 itd. aż do n-(n-1), czyli przedostatniego elementu tablicy
Wyprowadzamy zatem wzór ogólny:
.
Jak widać operacji wykonywanych w pętli głównej w sumie jest n, a za każdym razem liczba porównań jest zmniejszana o pewne zmienne C.

Jak i w algorytmie sortowania przez wstawianie, złożoność czasowa jest bardzo duża. Jest także parę kwestii, które działają na niekorzyść algorytmu.
- Są to na przykład „puste przebiegi” – kiedy nie jest dokonywana żadna zamiana, bo dane są już posortowane.
- Algorytm jest także bardzo wrażliwy na konfiguracje danych:
- 4 | 2 | 6 | 18 | 20 | 39 | 40 // ta tablica będzie wymagała jednej zamiany sąsiadujących elementów
- 4 | 6 | 18 | 20 | 39 | 40 | 2 // a ta aż sześciu
Plusem algorytmu jest natomiast jego prostota.
Quicksort – szybkie sortowanie
Jak sama nazwa wskazuje, dzięki tej metodzie sortowania operacja ta znacznie zyskała na szybkości. Ogólna procedura dzieli się na dwie części: część do właściwego sortowania, której zadaniem jest wywoływanie samej siebie, i część do rozdzielania elementów tablicy względem wartości pewnej jej komórki (będącej osią podziału). Rozdzielanie dokonuje procesu sortowania, natomiast proces rekurencji służy poskładaniu wyników cząstkowych w całą posortowaną tablicę.
Jak to działa? Najpierw następuje odczyt elementu, który będzie służył za oś podziału P – najczęściej jest to pierwszy element analizowanej tablicy. Następnie po lewej stronie od P ustawiają się wszystkie elementy mniejsze od P, po prawej – większe. Symbolicznie:
< P || P || ≥ P
Kolejnym etapem jest po prostu rekurencyjne odtworzenie procedury na elementach z lewej i z prawej strony. Wynikiem – posortowana tablica
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| void Quicksort(int *tab, int left, int right)
{
if (left < right)
{
int m=left;
// Podziel tablicę względem elementu osiowego 'P'
// ('P' może być np. pierwszym elementem tablicy,
// czyli tab[left]
// Pod koniec podziału element 'P' zostanie
// przeniesiony do komórki o numerze 'm'
for (int i=left+1; i<=right; i++)
if (tab[i]<tab[left])
swap(tab[++m],tab[i]);
swap(tab[left],tab[m]);
Quicksort(tab, left, m-1);
Quicksort(tab, m+1, right);
}
}
/**
Oznaczenia:
left – lewy skrajny indeks aktualnego fragmentu tablicy
right – prawy skrajny indeks aktualnego fragmentu tablicy
P – wartość osiowa
i – indeks przechodzący po indeksach tablicy od left do right
m – poszukiwany indeks komórki tablicy,
w której umieścimy element osiowy
*/ |
- Dla zobrazowania działania algorytmu, jego przykład dla tablicy
3 | 2 | 0 | 5 | 8 | 3 | 4 | 1 | 3 | 2

[kliknij obrazek, aby powiększyć]
W przypadku optymistycznym, gdy za każdym razem wybieramy medianę tablicy, liczbę porównań
można opisać rekurencyjnym wzorem

dla dużych n:

co daje

Przypadek pesymistyczny zakłada wybieranie zawsze najmniejszego/największego elementu w sortowanym fragmencie tablicy

stąd wynika

Mimo swojego rodzaju trudności w implementacji i wielu rekurencji, Quicksort jest w praktyce bardzo dobrym algorytmem do stosowania na dużych tablicach, ze względu na swoją małą złożoność czasową i co za tym idzie – szybkość sortowania. Niech liczby powiedzą same za siebie. Czas wykonywania algorytmu dla zbioru 10 000 danych (przeprowadzono 6 prób) na komputerze o procesorze 2.4GHz wynosił 0,911 ± 0,018 [s].
Heap Sort – sortowanie przez kopcowanie
Algortym sortowania przez kopcowanie wykorzystuje strukturę zwaną kopcem lub stertą. Struktura ta jest drzewem binarnym zawierającym liczby zbioru, gdzie możliwe jest ich uporządkowanie. Pewną cechą szczególną kopca jest jego kształt przypominający lekko wybrakowaną piramidkę.

Kolejną cechą kopca jest uporządkowanie – wartości w węzłach niższych są mniejsze od tych w wyższych węzłach. Czyli T[ojciec(i)] ≥ T[i].
Zawartość kopca układa się w następujący sposób:
- wierzchołek kopca wstawiamy do T[0]
- dla dowolnego węzła w T[i] jego lewy syn jest w T[2i+1], a prawy w T[2i+2]
- Reprezentacja graficzna kopca

[kliknij obrazek, aby powiększyć]
1. Ułóż dane w kopiec (dane znajdują się w tablicy o rozmiarze n)
2. Usuń wierzchołek kopca, poprzez zamianę go z ostatnim liściem drzewa (n- -)
3. Przywróć uporządkowanie kopca dla pozostałych elementów, czyli
a) Jeśli wierzchołek jest większy od obojga dzieci – KONIEC
b) Zamień wierzchołek z większym dzieckiem
c) Przywróć własność kopca w tej części kopca, w której nastąpiła zamiana
4. Idź do pkt 2.
Dla większego zobrazowania sobie procedury z punktu 3, podaję przykład jej działania na poniższych rysunkach. Jak łatwo zauważyć, pogrubiony węzeł zaburza warunek T[ojciec(i)] ≥ T[i], zatem trzeba go uporządkować.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| void przywroc(int T[], int k, int n)
{
int i,j;
i = T[k-1];
while (k <= n/2)
{
j=2*k;
if ((j<n) && (T[j-1]<T[j])) j++;
if (i >= T[j-1])
break;
else
{
T[k-1] = T[j-1];
k=j;
}
}
T[k-1]=i;
}
// sortowanie tablicy T
void heapsort(int T[], int n)
{
int k, swap;
for(k=n/2; k>0; k--) przywroc(T,k,n);
do
{
swap=T[0];
T[0]=T[n-1];
T[n-1]=swap;
n--;
przywroc(T,1,n);
}
while (n>1);
}
int main()
{
int i, T[14]={12,3,-12,9,34,23,1,81,45,17,9,23,11,4};
for(i=0; i<14; i++)
cout << ' ' << T[i];
cout << endl;
heapsort(T,14);
for(i=0; i<14; i++)
cout << ' ' << T[i];
} |
Algortym w wewnętrznej pętli for wykonuje co najwyżej
porównań pomiędzy elementami danego ciągu, a w sumie daje to
.
Zatem złożoność czasowa algorytmu to
.
Choć w praktyce algorytm jest wolniejszy od Quicksorta, jego zdecydowanym atutem jest to, że nawet w pesymistycznym wypadku, wciąż ma złożoność
. Z tego jasno wynika, że algorytm ten jest dobrym wyborem do sortowania dużych ilości danych.
Wnioski
Dla porównania algorytm O(n2) przy tych samych danych (10 000 elementów, procesor 2,4GHz) wykonywałby się szacunkowo 2,89 godzin. Wniosek nasuwa się prosty, o czym wspominałam już we wstępie – algorytmy sortowania należy dobierać indywidualnie do swoich potrzeb, biorąc za wyznacznik wielkość/ilość danych do posortowania i czas, który możemy na to sortowanie zagospodarować (wszak mogą istnieć osoby lubujące się w długich, ciągnących się w nieskończoność programach sortowania ☺).
Jednak diabeł zawsze tkwi w szczegółach – Quicksort, mimo pozornego zwycięstwa nad sortowaniem bąbelkowym czy przez wstawianie, ma swoje mankamenty. Może wystąpić przepełnienie stosu dla ogromnej liczby danych – algorytm wykorzystuje proces rekurencji. Także jeśli nieszczęśliwie trafimy na pesymistyczny przypadek sortowania, czas, jaki komputer poświęci na uporządkowanie danych, nie wiele będzie się różnił niż jak w bąbelkowym. Algorytmy, które przedstawiłam są, jak mówiłam, sztandarowe, tak też istnieje ich wiele, wiele więcej. W gestii programisty pozostaje, który z nich dobrać, jako optymalny, w swoim programie.
[1] W domyśle, w każdym kodzie umieszczone jest const int n=wartosc określające rozmiar tablicy do posortowania