// algorytmy sortowania

Algorytmy sortowania danych

Algorytmy sortowania danych są doskonałym przykładem tego jak coś można zrobić lepiej, a wystarczy tylko pomysł i odrobina chęci do jego realizacji. Materiały poniżej opracowane przez dr inż. R. Roberta Gajewskiego z Politechniki Warszawskiej.

Wiadomości wstępne

Sortowanie to proces ustawiania zbioru obiektów w określonym porządku, stosowany w celu ułatwienia późniejszego wyszukiwania elementów. Rozróżniamy metody wewnętrzne (tablice w pamięci operacyjnej) i zewnętrzne (pliki). Metody proste wymagają rzędu n² porównań, dobre algorytmy — n·log₂(n). Miarami efektywności są: liczba porównań kluczy Po i liczba przesunięć Pr.

1. Sortowanie przez proste wstawianie

Metoda stosowana powszechnie przez grających w karty. Ciąg dzielimy na wynikowy i źródłowy — w każdym kroku i-ty element źródłowego wstawiamy we właściwe miejsce wynikowego.

procedure ProsteWstawianie;
var i,j : Indeks; x : Obiekt;
begin
  for i := 2 to n do
  begin
    x    := a[i];     { element do wstawienia }
    a[0] := x;        { wartownik }
    j    := i - 1;
    while x.Klucz < a[j].Klucz do
    begin
      a[j+1] := a[j]; { przesunięcie o jeden w prawo }
      j := j - 1
    end;
    a[j+1] := x       { wstawienie na właściwe miejsce }
  end
end;

Przykład: 44 55 12 42 94 18 06 67 → 06 12 18 42 44 55 67 94

2. Wstawianie połówkowe (bisekcja)

Usprawnienie prostego wstawiania — miejsce wstawienia szukamy metodą bisekcji. Zmniejsza liczbę porównań, ale nie liczbę przesunięć, więc zysk jest marginalny w praktyce.

procedure WstawianiePolowkowe;
var i,j,l,p,m : Indeks; x : Obiekt;
begin
  for i := 2 to n do
  begin
    x := a[i]; l := 1; p := i - 1;
    while l <= p do
    begin
      m := (l + p) div 2;
      if x.Klucz < a[m].Klucz then p := m - 1
      else l := m + 1
    end;
    for j := i - 1 downto l do
      a[j+1] := a[j];
    a[l] := x
  end
end;

3. Sortowanie przez proste wybieranie

W każdym kroku wybieramy obiekt o najmniejszym kluczu i wymieniamy go z pierwszym. Przeważnie lepsze od prostego wstawiania, choć przy częściowo posortowanych danych wstawianie może być szybsze.

procedure ProsteWybieranie;
var i,j,k : Indeks; x : Obiekt;
begin
  for i := 1 to n-1 do
  begin
    k := i; x := a[i];
    for j := i+1 to n do
      if a[j].Klucz < x.Klucz then
      begin k := j; x := a[j] end;
    a[k] := a[i]; a[i] := x  { zamiana }
  end
end;

4. Sortowanie bąbelkowe

Powtarzamy przejście przez tablicę, przesuwając za każdym razem najmniejszy element na lewy koniec. Jak bąbelki w wodzie — lekkie idą w górę. Istnieje powiedzenie: absolutnie każdy program można poprawić...

procedure SortowanieBabelkowe;
var i,j : Indeks; x : Obiekt;
begin
  for i := 2 to n do
    for j := n downto i do
      if a[j-1].Klucz > a[j].Klucz then
      begin
        x := a[j-1]; a[j-1] := a[j]; a[j] := x
      end
end;

5. Sortowanie mieszane (Cocktail Sort)

Trzy ulepszenia bąbelkowego: zapamiętanie czy dokonano zmian, zapamiętanie pozycji ostatniej zamiany, zmiana kierunku przejść. Efekt? Marginalny, bo przesunięcia wciąż dominują nad porównaniami.

procedure SortowanieMieszane;
var j,k,l,p : Indeks; x : Obiekt;
begin
  l := 2; p := n; k := n;
  repeat
    for j := p downto l do    { przejscie "w gore" }
      if a[j-1].Klucz > a[j].Klucz then
      begin x:=a[j-1]; a[j-1]:=a[j]; a[j]:=x; k:=j end;
    l := k + 1;
    for j := l to p do           { przejscie "w dol" }
      if a[j-1].Klucz > a[j].Klucz then
      begin x:=a[j-1]; a[j-1]:=a[j]; a[j]:=x; k:=j end;
    p := k - 1
  until l > p
end;

6. Sortowanie Shella (Donald Shell, 1959)

Proste wstawianie ma jedną wadę: element, który powinien trafić na sam początek, wędruje tam po jednym miejscu. Shell zauważył, że jeśli najpierw poukładamy elementy odległe o h pozycji, a potem stopniowo zmniejszamy h aż do jedynki, to ostatni przebieg (zwykłe wstawianie) zastanie tablicę już prawie posortowaną — a wtedy jest bardzo szybki.

Cała sztuka tkwi w doborze ciągu odstępów. Najlepszy znany praktycznie jest ciąg Ciury: 1, 4, 10, 23, 57, 132, 301, 701, dalej mnożony przez ok. 2,25.

procedure SortowanieShella;
var i,j,h,k : Indeks; x : Obiekt;
begin
  for k := LiczbaOdstepow downto 1 do
  begin
    h := Odstep[k];              { 701, 301, 132, ... 4, 1 }
    for i := h+1 to n do
    begin
      x := a[i]; j := i;
      while (j > h) and (a[j-h].Klucz > x.Klucz) do
      begin
        a[j] := a[j-h]; j := j - h
      end;
      a[j] := x
    end
  end
end;

Kilka linijek więcej niż proste wstawianie, a przy 100 000 elementów jest od niego ponad 70 razy szybsze. Nie wymaga przy tym ani jednego bajtu dodatkowej pamięci — to wciąż najlepszy wybór, gdy pamięci brakuje.

7. Sortowanie przez scalanie (John von Neumann, 1945)

Dzielimy tablicę na pół, sortujemy rekurencyjnie każdą połowę, a potem scalamy dwie posortowane części w jedną — przebiegając je równolegle i biorąc za każdym razem mniejszy element. Scalanie dwóch uporządkowanych ciągów jest liniowe, a poziomów podziału jest log₂(n), stąd n·log(n).

procedure Scalaj(l, m, p : Indeks);
var i,j,k : Indeks;
begin
  i := l; j := m + 1; k := l;
  while (i <= m) and (j <= p) do
  begin
    if a[i].Klucz <= a[j].Klucz then
    begin b[k] := a[i]; i := i + 1 end
    else
    begin b[k] := a[j]; j := j + 1 end;
    k := k + 1
  end;
  while i <= m do begin b[k]:=a[i]; i:=i+1; k:=k+1 end;
  while j <= p do begin b[k]:=a[j]; j:=j+1; k:=k+1 end;
  for k := l to p do a[k] := b[k]
end;

procedure Scalanie(l, p : Indeks);
var m : Indeks;
begin
  if l < p then
  begin
    m := (l + p) div 2;
    Scalanie(l, m);
    Scalanie(m+1, p);
    Scalaj(l, m, p)
  end
end;

Ma dwie cechy, których Quicksort nie ma. Po pierwsze jest stabilny — elementy o równych kluczach zachowują swoją pierwotną kolejność, co ma znaczenie przy sortowaniu po wielu polach naraz. Po drugie n·log(n) jest gwarantowane w każdym przypadku, także pesymistycznym. Cenę płaci się pamięcią: potrzebna jest dodatkowa tablica b wielkości n.

To także jedyny z omawianych algorytmów, który naturalnie działa na plikach — nie trzeba mieć wszystkich danych w pamięci naraz, wystarczy czytać dwa strumienie i pisać trzeci.

8. Sortowanie kopcowe — heapsort (J.W.J. Williams, 1964)

Ulepszenie prostego wybierania. Tam szukanie najmniejszego elementu kosztowało n porównań; tutaj budujemy z tablicy kopiec — drzewo binarne, w którym rodzic jest zawsze większy od swoich dzieci. Znalezienie największego elementu to wtedy zajrzenie do korzenia, a naprawienie kopca po jego usunięciu kosztuje tylko log(n).

Piękno rozwiązania polega na tym, że drzewo trzymamy w tej samej tablicy: dziećmi elementu i są elementy 2i oraz 2i+1.

procedure Przesiej(i, n : Indeks);
var c : Indeks; x : Obiekt;
begin
  x := a[i];
  while 2*i <= n do
  begin
    c := 2*i;                    { lewe dziecko }
    if (c < n) and (a[c+1].Klucz > a[c].Klucz) then
      c := c + 1;                { prawe jest wieksze }
    if a[c].Klucz <= x.Klucz then Break;
    a[i] := a[c]; i := c
  end;
  a[i] := x
end;

procedure Kopcowe;
var i : Indeks; x : Obiekt;
begin
  for i := n div 2 downto 1 do   { budowa kopca }
    Przesiej(i, n);
  for i := n downto 2 do
  begin
    x := a[1]; a[1] := a[i]; a[i] := x;   { korzen na koniec }
    Przesiej(1, i-1)                      { napraw kopiec }
  end
end;

Heapsort nie ma przypadku pesymistycznego — zawsze n·log(n), i to bez dodatkowej pamięci. W praktyce bywa nieco wolniejszy od Quicksorta, bo skacze po tablicy w sposób nieprzyjazny dla pamięci podręcznej procesora. Właśnie dlatego służy dziś głównie jako zabezpieczenie: gdy Quicksort wpadnie w zbyt głęboką rekurencję, biblioteki przełączają się na heapsort.

9. Quicksort — sortowanie szybkie (C.A.R. Hoare, 1961)

Wybieramy element środkowy x, przeglądamy tablicę od lewej znajdując a[i]>x i od prawej znajdując a[j]<x, zamieniamy je i kontynuujemy. Wynik: tablica podzielona na dwie części do dalszego sortowania rekurencyjnego.

procedure Sortuj(l, p : Indeks);
var i,j : Indeks; x,y : Obiekt;
begin
  i := l; j := p;
  x := a[(l+p) div 2]; { element srodkowy jako pivot }
  repeat
    while a[i].Klucz < x.Klucz do i := i + 1;
    while x.Klucz < a[j].Klucz do j := j - 1;
    if i <= j then
    begin
      y:=a[i]; a[i]:=a[j]; a[j]:=y;
      i := i+1; j := j-1
    end
  until i > j;
  if l < j then Sortuj(l, j);
  if i < p then Sortuj(i, p)
end;

Procedura wywołuje siebie rekurencyjnie. Ten sam algorytm można zapisać iteracyjnie, zastępując rekurencję przez stos jawny.

10. Sortowanie pozycyjne — radix sort

Wszystkie dotychczasowe metody porównują klucze ze sobą, a dla takich algorytmów udowodniono, że nie da się zejść poniżej n·log(n). Radix sort omija to ograniczenie, bo w ogóle nie porównuje elementów — rozdziela je do kubełków według kolejnych cyfr klucza, od najmniej znaczącej do najbardziej.

Dla liczb 32-bitowych przetwarzanych po bajcie potrzebne są cztery przebiegi po 256 kubełków — czas rośnie liniowo z liczbą elementów.

procedure Pozycyjne;
var i, przesuniecie, cyfra, suma, c : Integer;
    licznik : array[0..255] of Integer;
begin
  przesuniecie := 0;
  while przesuniecie < 32 do
  begin
    for i := 0 to 255 do licznik[i] := 0;

    { ile elementow trafia do kazdego kubelka }
    for i := 1 to n do
    begin
      cyfra := (a[i].Klucz shr przesuniecie) and 255;
      Inc(licznik[cyfra])
    end;

    { zamiana licznikow na pozycje poczatkowe }
    suma := 1;
    for i := 0 to 255 do
    begin
      c := licznik[i]; licznik[i] := suma; suma := suma + c
    end;

    { rozrzut do tablicy pomocniczej - zachowuje stabilnosc }
    for i := 1 to n do
    begin
      cyfra := (a[i].Klucz shr przesuniecie) and 255;
      b[licznik[cyfra]] := a[i];
      Inc(licznik[cyfra])
    end;

    for i := 1 to n do a[i] := b[i];
    przesuniecie := przesuniecie + 8
  end
end;

W pomiarach poniżej to bezkonkurencyjny zwycięzca — przy milionie liczb jest pięciokrotnie szybszy od Quicksorta. Ma jednak istotne ograniczenie: działa tylko dla kluczy, które da się rozłożyć na cyfry o stałej długości (liczby całkowite, daty, teksty stałej długości). Do sortowania po dowolnym kryterium porównawczym się nie nadaje, a przy małych zbiorach narzut na kubełki nie zdąży się zwrócić.

11. Co się stosuje dzisiaj

Żadna z bibliotek standardowych nie używa dziś jednego algorytmu w czystej postaci. Wszystkie są hybrydami, które przełączają metodę zależnie od tego, co zastaną w danych:

Wniosek jest ten sam, co dwadzieścia lat temu: nie ma algorytmu najlepszego zawsze. Jest algorytm najlepszy do konkretnych danych — i to ich znajomość, a nie szybkość procesora, decyduje o wyniku.

Porównanie efektywności — pomiar współczesny

Poniższe czasy zmierzyłem w sierpniu 2026 na serwerze z procesorem Intel Xeon 2,10 GHz, implementacje w C skompilowane gcc 13 z optymalizacją -O2, dane losowe, dla każdego rozmiaru wynik najlepszy z kilku powtórzeń. Kreska oznacza pomiar pominięty — przy tych rozmiarach metody kwadratowe liczyłyby zbyt długo.

Algorytmzłożoność1 00010 00030 000100 0001 000 00010 000 000
BąbelkoweO(n²)1,98 ms226,8 ms2,65 s32,6 s
Mieszane (cocktail)O(n²)1,98 ms242,3 ms2,39 s27,7 s
Proste wybieranieO(n²)0,407 ms36,2 ms325,3 ms3,72 s
Proste wstawianieO(n²)0,104 ms9,18 ms82,2 ms912,1 ms
Wstawianie połówkoweO(n²)0,072 ms1,48 ms15,2 ms214,7 ms
Shell~O(n^1,3)0,064 ms0,956 ms3,34 ms12,9 ms165,9 ms1,96 s
Kopcowe (heapsort)O(n log n)0,062 ms0,833 ms2,81 ms10,6 ms141,2 ms2,07 s
Przez scalanieO(n log n)0,060 ms0,803 ms2,67 ms10,1 ms124,2 ms1,49 s
qsort (biblioteka C)O(n log n)0,076 ms1,04 ms3,58 ms13,4 ms161,5 ms2,00 s
QuicksortO(n log n)0,053 ms0,689 ms2,20 ms8,34 ms102,0 ms1,06 s
Pozycyjne (radix)O(n·k)0,010 ms0,120 ms0,337 ms1,12 ms20,7 ms224,3 ms

Liczby mówią same za siebie. Przy 100 000 elementów sortowanie bąbelkowe potrzebuje ponad pół minuty, a sortowanie pozycyjne — nieco ponad jedną milisekundę. To różnica blisko trzydziestotysięczna, na tym samym procesorze, na tych samych danych.

Warto też zauważyć, jak zmieniają się proporcje wraz z rozmiarem. Przy tysiącu elementów wszystkie metody mieszczą się poniżej dwóch milisekund i wybór algorytmu praktycznie nie ma znaczenia. Dopiero od kilkudziesięciu tysięcy różnica staje się dramatyczna — i to jest właśnie moment, w którym warto wiedzieć, co się wybiera.

I obserwacja, która nie zestarzała się przez dwadzieścia lat: procesor jest dziś tysiące razy szybszy niż 386DX, ale bąbelkowe nadal przegrywa z Quicksortem tak samo dotkliwie. Wzrost mocy obliczeniowej przesuwa granicę, przy której problem staje się odczuwalny — ale jej nie likwiduje. Podstawowe rezerwy nadal tkwią w sposobie wykonywania obliczeń, nie w zegarze procesora.

Te same dane na wykresie

Rozpiętość wyników jest tak duża (od setnych części milisekundy do trzydziestu sekund), że na zwykłej skali wszystko poza sortowaniem bąbelkowym zlałoby się w jedną linię przy zerze. Dlatego obie osie są logarytmiczne — każda kreska oznacza dziesięciokrotność.

0,01 ms 0,1 ms 1 ms 10 ms 100 ms 1 s 10 s 100 s 1 tys. 10 tys. 30 tys. 100 tys. 1 mln 10 mln liczba elementów czas sortowania Bąbelkowe Mieszane (cocktail) Proste wybieranie Kopcowe qsort (bibl. C) Shell Przez scalanie Quicksort Proste wstawianie Pozycyjne (radix) Wstawianie połówkowe Kolory wg klasy złożoności: O(n²) — metody proste O(n log n) — metody szybkie ~O(n^1,3) — Shell O(n·k) — bez porównań Obie osie w skali logarytmicznej. Pomiar: Intel Xeon 2,10 GHz, C / gcc 13 -O2, dane losowe, sierpień 2026. Linie proste na takim wykresie oznaczają wzrost wielomianowy — im większe nachylenie, tym gorsze skalowanie.

Na wąskim ekranie wykres można przewinąć w bok.

Taki wykres czyta się przez nachylenie linii, nie przez jej położenie. Metody kwadratowe (odcienie czerwieni i żółci) biegną wyraźnie stromo: dziesięciokrotny wzrost liczby danych oznacza stukrotny wzrost czasu. Metody n·log(n) (odcienie błękitu i zieleni) mają nachylenie niemal takie jak przekątna — czas rośnie prawie proporcjonalnie do liczby elementów.

Widać też, dlaczego przy małych zbiorach spór o algorytm jest jałowy. Po lewej stronie wykresu, przy tysiącu elementów, wszystkie linie mieszczą się w pasie dwóch rzędów wielkości i nawet bąbelkowe kończy w dwie milisekundy. Linie rozjeżdżają się dopiero w prawo — i to rozejście jest nieodwracalne, bo wynika z samej postaci algorytmu, a nie z jakości implementacji.

Ciekawy jest też ścisk w środku wykresu: Shell, heapsort, scalanie, qsort i Quicksort biegną niemal jedną linią. Różnice między nimi to kwestia dwóch, najwyżej trzech razy — podczas gdy przepaść między nimi a metodami kwadratowymi sięga czterech rzędów wielkości. Wybór „który z szybkich" ma więc znaczenie drugorzędne wobec pytania „czy w ogóle szybki".

Pomiar historyczny — mikrokomputer 386DX 40 MHz

Tabela z pierwszej wersji tej strony, z czasów gdy powstawała. Warto ją zestawić z pomiarem powyżej: 386DX potrzebował na 10 000 elementów sortowaniem bąbelkowym prawie dwóch minut, dzisiejszy procesor robi to w ćwierć sekundy. Ale proporcje między algorytmami zostały te same — i to jest w tym zestawieniu najciekawsze.

Algorytm101001 0005 00010 00020 00030 000
Proste wstawianie0.000.000.449.8340.75162.53
Wstawianie połówkowe0.000.000.338.2832.84132.59
Proste wybieranie0.000.000.7117.4169.53278.09
Bąbelkowe0.000.001.1629.33117.60
Mieszane0.000.000.9924.67100.29
Quicksort0.000.000.000.110.270.610.98

Jeszcze raz potwierdziła się prawda, że o efektywności programu decyduje przede wszystkim algorytm obliczeń. Można używać coraz szybszych procesorów — są to jedynie półśrodki. Podstawowe "rezerwy" tkwią w sposobie wykonywania obliczeń.