Złożoność obliczeniowa i sortowanie szybkie
Znasz już kilka sposobów sortowania. Teraz nauczysz się je porównywać liczbą operacji, a nie wrażeniem, i poznasz dwa algorytmy, które radzą sobie z danymi, przy których sortowanie bąbelkowe się poddaje.
Czego się dziś nauczysz
- Oszacujesz liczbę operacji algorytmu w zależności od rozmiaru danych
- Rozpoznasz złożoności O(1), O(log n), O(n), O(n log n) i O(n²)
- Zaimplementujesz sortowanie przez scalanie i sortowanie szybkie
- Wyjaśnisz, czym jest sortowanie stabilne i kiedy to ma znaczenie
- Zmierzysz czas działania klasą
Stopwatchi poprawnie zinterpretujesz wynik
Przygotowanie: lekcje 01–27. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.
Ile pracy wykonuje algorytm
Czas działania mierzony stoperem zależy od komputera, od tego, co jeszcze jest uruchomione, i od wersji .NET. Dlatego algorytmy porównujemy inaczej: liczymy operacje w zależności od rozmiaru danych, oznaczanego zwykle literą n.
Weź wyszukiwanie liniowe z poprzedniej lekcji. Dla tablicy stu elementów w najgorszym przypadku wykonuje sto porównań, dla tysiąca — tysiąc. Liczba porównań rośnie tak samo szybko jak n. Mówimy, że algorytm ma złożoność O(n).
Wyszukiwanie binarne po każdym kroku odrzuca połowę zakresu: 1000 → 500 → 250 → … → 1. To około dziesięć kroków dla tysiąca elementów i dwadzieścia dla miliona. Złożoność O(log n).
| Zapis | Nazwa | Przykład | n = 1 000 | n = 1 000 000 |
|---|---|---|---|---|
O(1) | stała | odczyt t[5], t.Length | 1 | 1 |
O(log n) | logarytmiczna | wyszukiwanie binarne | ~10 | ~20 |
O(n) | liniowa | suma elementów, wyszukiwanie liniowe | 1 000 | 1 000 000 |
O(n log n) | liniowo-logarytmiczna | sortowanie szybkie i przez scalanie | ~10 000 | ~20 000 000 |
O(n²) | kwadratowa | sortowanie bąbelkowe, przez wybór | 1 000 000 | 10¹² |
Co pomijamy
W notacji O pomijamy stałe i składniki wolniej rosnące: 3n + 20 to nadal O(n), a n² + 5n to O(n²). Interesuje nas tempo wzrostu, a nie dokładna liczba instrukcji. Algorytm O(n²) bywa szybszy od O(n log n) dla dziesięciu elementów — różnica pojawia się dopiero przy dużych danych.
Najgorszy, średni i optymistyczny
Sortowanie bąbelkowe z flagą kończy pracę po jednym przebiegu, jeśli tablica jest już posortowana — to przypadek optymistyczny O(n). Najgorszy przypadek to nadal O(n²). Podając złożoność, zawsze mów, o który przypadek chodzi.
Dziel i zwyciężaj: sortowanie przez scalanie
Pomysł jest rekurencyjny: podziel tablicę na pół, posortuj każdą połowę (tą samą metodą!), a potem scal dwie posortowane części w jedną. Scalanie znasz z zadania z lekcji 24 bierzemy zawsze mniejszy z dwóch pierwszych elementów.
static void SortujScalaniem(int[] t, int od, int doo)
{
if (od >= doo)
{
return; // zero albo jeden element - juz posortowane
}
int srodek = (od + doo) / 2;
SortujScalaniem(t, od, srodek); // lewa polowa
SortujScalaniem(t, srodek + 1, doo); // prawa polowa
Scal(t, od, srodek, doo); // zlaczenie dwoch posortowanych czesci
}
static void Scal(int[] t, int od, int srodek, int doo)
{
int[] pomoc = new int[doo - od + 1];
int i = od, j = srodek + 1, k = 0;
while (i <= srodek && j <= doo)
{
pomoc[k++] = (t[i] <= t[j]) ? t[i++] : t[j++];
}
while (i <= srodek) pomoc[k++] = t[i++];
while (j <= doo) pomoc[k++] = t[j++];
for (int x = 0; x < pomoc.Length; x++)
{
t[od + x] = pomoc[x];
}
}
Podziałów jest około log₂ n, a na każdym poziomie scalamy łącznie n elementów — stąd O(n log n) w każdym przypadku, także najgorszym. Ceną jest pamięć: potrzebna jest tablica pomocnicza.
Warunek t[i] <= t[j], nie <
Znak równości sprawia, że przy równych wartościach pierwszy bierzemy element z lewej połowy. Dzięki temu sortowanie jest stabilne — elementy o tej samej wartości zachowują pierwotną kolejność. To ważne, gdy sortujesz uczniów najpierw po imieniu, a potem po punktach.
Sortowanie szybkie (quicksort)
Tu też dzielimy zadanie, ale inaczej: wybieramy jeden element — oś (ang. pivot) — i przestawiamy tablicę tak, żeby po lewej były wartości mniejsze, a po prawej większe. Oś trafia wtedy na swoje docelowe miejsce. Potem to samo robimy z lewą i prawą częścią.
static void SortujSzybko(int[] t, int od, int doo)
{
if (od >= doo) return;
int os = t[doo]; // os: ostatni element zakresu
int granica = od; // pierwsze miejsce dla wartosci mniejszej od osi
for (int i = od; i < doo; i++)
{
if (t[i] < os)
{
(t[i], t[granica]) = (t[granica], t[i]); // zamiana
granica++;
}
}
(t[doo], t[granica]) = (t[granica], t[doo]); // os na swoje miejsce
SortujSzybko(t, od, granica - 1);
SortujSzybko(t, granica + 1, doo);
}
| Algorytm | Najgorszy | Średni | Pamięć dodatkowa | Stabilny |
|---|---|---|---|---|
| Bąbelkowe | O(n²) | O(n²) | O(1) | tak |
| Przez wybór | O(n²) | O(n²) | O(1) | nie |
| Przez scalanie | O(n log n) | O(n log n) | O(n) | tak |
| Szybkie | O(n²) | O(n log n) | O(log n) — stos | nie |
Array.Sort | O(n log n) | O(n log n) | O(log n) | nie |
Najgorszy przypadek sortowania szybkiego
Gdy osią zawsze zostaje wartość skrajna — na przykład przy tablicy już posortowanej i osi wybieranej z końca — podział daje jedną część pustą, a drugą krótszą o jeden. Wtedy quicksort schodzi do O(n²) i dodatkowo zużywa n poziomów stosu. Praktyczne implementacje wybierają oś ze środka albo losowo; Array.Sort pilnuje tego sam i przy zbyt głębokiej rekurencji przechodzi na inny algorytm.
Sortowanie przez wstawianie
Trzeci klasyczny algorytm kwadratowy działa tak, jak porządkuje się karty w ręku: bierzesz kolejną kartę i wsuwasz ją na właściwe miejsce wśród już ułożonych.
static void SortujPrzezWstawianie(int[] t)
{
for (int i = 1; i < t.Length; i++)
{
int wstawiany = t[i];
int j = i - 1;
// przesuwamy w prawo wszystko, co wieksze od wstawianego
while (j >= 0 && t[j] > wstawiany)
{
t[j + 1] = t[j];
j--;
}
t[j + 1] = wstawiany;
}
}
Złożoność w najgorszym przypadku to O(n²), ale ten algorytm ma dwie cechy, które czynią go użytecznym mimo tego:
- Na danych prawie posortowanych działa niemal liniowo — pętla wewnętrzna prawie nic nie robi. Sortowanie przez wybór wykona tyle samo porównań zawsze.
- Jest stabilny, bo warunek
t[j] > wstawianynie przesuwa elementów równych.
Dlatego biblioteki go używają
Praktyczne implementacje sortowania szybkiego przełączają się na sortowanie przez wstawianie, gdy podzielony fragment ma kilkanaście elementów — dla małych porcji danych jest po prostu szybsze niż kolejne wywołania rekurencyjne. Tak działa również Array.Sort.
Dwie rodziny algorytmów, o które pyta egzamin
Poza sortowaniem warto znać dwa pojęcia z projektowania algorytmów:
| Podejście | Na czym polega |
|---|---|
| Dziel i zwyciężaj | Dziel zadanie na mniejsze takie same zadania, rozwiąż je i połącz wyniki. Tak działa sortowanie przez scalanie, sortowanie szybkie i wyszukiwanie binarne. |
| Zachłanne | W każdym kroku wybieraj to, co wygląda najlepiej teraz, i nigdy nie cofaj decyzji. Klasyczny przykład: wydawanie reszty — bierz zawsze największy nominał, który się mieści. Dla polskich nominałów daje wynik optymalny, ale dla dowolnego zestawu nominałów już nie — i to jest główna słabość tego podejścia. |
| Heurystyczne | Gdy dokładne rozwiązanie jest zbyt kosztowne, szukamy wystarczająco dobrego. Sztandarowy przykład to problem komiwojażera: znaleźć najkrótszą trasę odwiedzającą n miast. Sprawdzenie wszystkich tras to n! możliwości — dla 20 miast więcej, niż komputer policzy do końca świata. Heurystyka „jedź zawsze do najbliższego nieodwiedzonego miasta” daje trasę w ułamku sekundy, choć zwykle nie najkrótszą. |
Przykład: porównanie na tych samych danych
Program losuje dane z ustalonym ziarnem, żeby każdy algorytm dostał identyczną tablicę, i liczy porównania oraz czas.
using System.Diagnostics;
class Program
{
static long porownania;
static int[] Losowa(int ile)
{
Random los = new Random(42);
int[] t = new int[ile];
for (int i = 0; i < ile; i++) t[i] = los.Next(0, 100000);
return t;
}
static void Babelkowe(int[] t)
{
for (int i = 0; i < t.Length - 1; i++)
{
bool zamiana = false;
for (int j = 0; j < t.Length - 1 - i; j++)
{
porownania++;
if (t[j] > t[j + 1])
{
(t[j], t[j + 1]) = (t[j + 1], t[j]);
zamiana = true;
}
}
if (!zamiana) break;
}
}
static void Main()
{
int[] dane = Losowa(20000);
int[] kopia = (int[])dane.Clone();
porownania = 0;
Stopwatch zegar = Stopwatch.StartNew();
Babelkowe(dane);
zegar.Stop();
Console.WriteLine($"babelkowe : {porownania,12:N0} porownan, {zegar.ElapsedMilliseconds} ms");
zegar.Restart();
Array.Sort(kopia);
zegar.Stop();
Console.WriteLine($"Array.Sort: {zegar.ElapsedMilliseconds} ms (porownan nie liczymy)");
Console.WriteLine($"Wyniki zgodne: {dane.SequenceEqual(kopia)}");
}
}
babelkowe : 199 990 000 porownan, 640 ms
Array.Sort: 1 ms (porownan nie liczymy)
Wyniki zgodne: True
Co dzieje się po kolei
- Liczba porównań jest bliska
n²/2— dla 20 000 elementów to około 200 milionów. Zwiększenie tablicy dwukrotnie da czterokrotnie więcej pracy. Stopwatchmierzy czas rzeczywisty. Wynik w milisekundach zależy od komputera — porównuj rząd wielkości, nie konkretną liczbę.SequenceEqualpotwierdza, że oba sposoby dały ten sam wynik. Bez takiego sprawdzenia szybki algorytm z błędem wygląda po prostu na jeszcze szybszy.Clonedaje kopię tablicy; bez niej drugi algorytm dostałby dane już posortowane i pomiar byłby bez sensu.
Użyte w tej lekcji elementy
| Element | Zwraca | Działanie i ograniczenia |
|---|---|---|
Stopwatch.StartNew() | Stopwatch | Tworzy i uruchamia zegar. Wymaga using System.Diagnostics;. Stop() zatrzymuje, Restart() zeruje i startuje ponownie. |
zegar.ElapsedMilliseconds | long | Zmierzony czas w milisekundach. Właściwość — bez nawiasów. Dla bardzo szybkich operacji użyj Elapsed.TotalMicroseconds. |
(a, b) = (b, a) | — | Krotka: zamiana dwóch wartości bez zmiennej pomocniczej. Prawa strona jest obliczana w całości przed przypisaniem. |
t.Clone() | object | Płytka kopia tablicy; wynik trzeba rzutować, np. (int[])t.Clone(). |
{liczba,12:N0} | tekst | Formatowanie z lekcji 13: pole szerokości 12 znaków, separator tysięcy, zero miejsc po przecinku. |
Array.Sort(t) | — | Wbudowane sortowanie o złożoności O(n log n). W praktyce zawsze używaj go zamiast własnego — własne piszemy, żeby zrozumieć, jak działa. |
Na co uważać
| Zapis | Problem |
|---|---|
| Pomiar bez rozgrzewki | Pierwsze uruchomienie metody obejmuje kompilację JIT. Zmierz ten sam kod dwa razy i bierz drugi wynik. |
| Porównywanie algorytmów na różnych danych | Każdy algorytm musi dostać identyczną tablicę — użyj Clone i stałego ziarna generatora. |
if (od > doo) return; zamiast >= | Dla jednoelementowego zakresu scalanie zapętli się albo wykona zbędną pracę. |
| Oś z końca przy danych posortowanych | Najgorszy przypadek O(n²) i głęboka rekurencja. Wybieraj oś ze środka albo losowo. |
| „Mój algorytm jest szybszy, bo działał 2 ms krócej” | Dla małych danych mierzysz szum. Zwiększ rozmiar dziesięciokrotnie i sprawdź, jak zmienia się czas. |
Zadania
Dla n równego 10, 100, 1 000 i 10 000 policz i wypisz w tabeli wartości n, n·log₂n oraz n². Skomentuj jednym zdaniem, od którego rozmiaru różnica przestaje być do zaakceptowania.
Dodaj licznik porównań do sortowania bąbelkowego, przez wybór i przez scalanie. Uruchom każdy na tej samej tablicy 1 000 elementów i zestaw wyniki.
Zaimplementuj sortowanie przez scalanie samodzielnie, bez zaglądania do przykładu, i sprawdź je na tablicy pustej, jednoelementowej, posortowanej i odwrotnie posortowanej.
Uruchom sortowanie szybkie na tablicy już posortowanej, licząc wywołania rekurencyjne. Następnie zmień wybór osi na element środkowy i porównaj liczbę wywołań.
Posortuj tablicę uczniów (imię i punkty) najpierw po imieniu, potem po punktach — raz sortowaniem stabilnym, raz przez wybór. Pokaż na konkretnym przykładzie, czym różnią się wyniki.
Zmierz czas sortowania bąbelkowego dla 1 000, 2 000, 4 000 i 8 000 elementów. Zapisz iloraz kolejnych czasów i sprawdź, czy odpowiada przewidywaniu dla O(n²). Powtórz pomiar dla Array.Sort.
Co trzeba zapamiętać
- Złożoność opisuje tempo wzrostu liczby operacji, a nie czas w sekundach.
- O(1), O(log n), O(n), O(n log n) i O(n²) wystarczą do opisania wszystkich algorytmów z tego kursu.
- Sortowanie przez scalanie ma O(n log n) także w najgorszym przypadku, ale potrzebuje dodatkowej pamięci.
- Sortowanie szybkie jest zwykle najszybsze, lecz przy złym wyborze osi schodzi do O(n²).
- Sortowanie stabilne zachowuje kolejność elementów o równych kluczach — bywa to wymaganie, a nie szczegół.
- Pomiar czasu ma sens tylko na identycznych danych i po rozgrzewce; do produkcji i tak używa się
Array.Sort.
Dokumentacja: Microsoft Learn — temat tej lekcji.