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.

C# złożoność O(n) quicksort scalanie 60 min
CEL LEKCJI

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ą Stopwatch i 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.

TEORIA

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).

ZapisNazwaPrzykładn = 1 000n = 1 000 000
O(1)stałaodczyt t[5], t.Length11
O(log n)logarytmicznawyszukiwanie binarne~10~20
O(n)liniowasuma elementów, wyszukiwanie liniowe1 0001 000 000
O(n log n)liniowo-logarytmicznasortowanie szybkie i przez scalanie~10 000~20 000 000
O(n²)kwadratowasortowanie bąbelkowe, przez wybór1 000 00010¹²

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.

TEORIA

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.

definicje metod
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.

TEORIA

Sortowanie szybkie (quicksort)

Tu też dzielimy zadanie, ale inaczej: wybieramy jeden element — (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ą.

definicja metody
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);
}
AlgorytmNajgorszyŚredniPamięć dodatkowaStabilny
BąbelkoweO(n²)O(n²)O(1)tak
Przez wybórO(n²)O(n²)O(1)nie
Przez scalanieO(n log n)O(n log n)O(n)tak
SzybkieO(n²)O(n log n)O(log n) — stosnie
Array.SortO(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.

TEORIA

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.

definicja metody
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] > wstawiany nie 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ścieNa 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

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.

Program.cs
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)}");
    }
}
wynik w konsoli
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.
  • Stopwatch mierzy czas rzeczywisty. Wynik w milisekundach zależy od komputera — porównuj rząd wielkości, nie konkretną liczbę.
  • SequenceEqual potwierdza, że oba sposoby dały ten sam wynik. Bez takiego sprawdzenia szybki algorytm z błędem wygląda po prostu na jeszcze szybszy.
  • Clone daje kopię tablicy; bez niej drugi algorytm dostałby dane już posortowane i pomiar byłby bez sensu.
ELEMENTY WBUDOWANE

Użyte w tej lekcji elementy

ElementZwracaDziałanie i ograniczenia
Stopwatch.StartNew()StopwatchTworzy i uruchamia zegar. Wymaga using System.Diagnostics;. Stop() zatrzymuje, Restart() zeruje i startuje ponownie.
zegar.ElapsedMillisecondslongZmierzony 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()objectPłytka kopia tablicy; wynik trzeba rzutować, np. (int[])t.Clone().
{liczba,12:N0}tekstFormatowanie 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.
CZĘSTE BŁĘDY

Na co uważać

ZapisProblem
Pomiar bez rozgrzewkiPierwsze uruchomienie metody obejmuje kompilację JIT. Zmierz ten sam kod dwa razy i bierz drugi wynik.
Porównywanie algorytmów na różnych danychKaż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 posortowanychNajgorszy 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

Zadania

ZAD 1Tabela wzrostu★☆☆

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.

ZAD 2Licznik w trzech algorytmach★☆☆

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.

ZAD 3Scalanie od zera★★☆

Zaimplementuj sortowanie przez scalanie samodzielnie, bez zaglądania do przykładu, i sprawdź je na tablicy pustej, jednoelementowej, posortowanej i odwrotnie posortowanej.

ZAD 4Oś ma znaczenie★★☆

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ń.

ZAD 5Stabilność w praktyce★★☆

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.

ZAD 6Dziesięciokrotny test★★★

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.

PODSUMOWANIE

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.