Złożoność O(n log n) bez tajemnic - sortowanie i rekurencja

Wykres pokazuje zależność czasu wykonania algorytmu od rozmiaru danych 'n'. Czas rośnie wraz z 'n', a linia trendu n log n idealnie dopasowuje się do danych.

Autor

Alex Jabłoński

Opublikowano

2 lis 2026

Spis treści

Gdy program zaczyna przetwarzać tysiące lub miliony elementów, samo „działa” przestaje wystarczać. Właśnie wtedy złożoność O(n log n) staje się ważnym punktem odniesienia, bo opisuje algorytmy wyraźnie szybsze od kwadratowych, a jednocześnie wystarczająco uniwersalne do pracy z dużymi zbiorami danych. Pokażę, skąd bierze się ten wynik, gdzie spotkasz go w kodzie i jak nie pomylić dobrej klasy z gwarancją konkretnego czasu wykonania.

To praktyczny kompromis między prostotą a wydajnością

  • O(n log n) oznacza wzrost kosztu szybszy niż liniowy, ale znacznie wolniejszy niż kwadratowy.
  • Najczęściej spotkasz tę klasę przy sortowaniu, dzieleniu problemu na części i pracy ze strukturami drzewiastymi.
  • Dla miliona elementów wartość n log₂ n wynosi około 20 milionów operacji jednostkowych.
  • Merge sort i heap sort mają gwarancję tego rzędu, a quicksort osiąga ją średnio.
  • Podstawa logarytmu zwykle nie ma znaczenia dla klasy złożoności, ale pamięć, stałe i dane wejściowe nadal wpływają na praktyczną szybkość.

Wykres porównuje złożoność algorytmów, pokazując, jak O(N log N) rośnie wolniej niż O(N²) i O(2^N).

Co naprawdę oznacza złożoność liniowo-logarytmiczna

Litera n oznacza rozmiar danych wejściowych, na przykład liczbę elementów tablicy. Logarytm opisuje liczbę etapów, w których problem zmniejsza się wielokrotnie, zwykle przez podział na połowy. Iloczyn tych dwóch wartości mówi, że algorytm wykonuje pracę liniową w każdym z około logarytmicznych poziomów.

Dobrym przykładem jest sortowanie przez scalanie. Tablica zostaje podzielona na coraz mniejsze fragmenty, aż pozostaną pojedyncze elementy. Później fragmenty są scalane w uporządkowany sposób. Na każdym poziomie trzeba przejrzeć łącznie około n elementów, a liczba poziomów wynosi mniej więcej log₂ n.

Dlaczego podstawa logarytmu zwykle nie zmienia wyniku

W analizie asymptotycznej logarytm o podstawie 2, 10 czy e różni się tylko stałym mnożnikiem. Dlatego zapis z różnymi podstawami nadal należy do tej samej klasy wzrostu. W realnym programie stały mnożnik może mieć znaczenie, ale przy porównywaniu klas najważniejszy jest sposób, w jaki koszt rośnie wraz z n.

Dla przykładu, gdy mamy 1024 elementy, podział na połowy daje 10 poziomów, ponieważ 210 = 1024. Przy milionie elementów jest ich około 20. To właśnie sprawia, że dodatkowy czynnik logarytmiczny rośnie stosunkowo wolno.

Gdzie najczęściej pojawia się ten wynik

Najbardziej znanym zastosowaniem jest sortowanie. Algorytmy oparte na porównywaniu elementów nie mogą w ogólnym przypadku sortować dowolnych danych szybciej niż w granicy Ω(n log n). Nie oznacza to, że każdy problem sortowania musi mieć taki koszt, ponieważ specjalne dane, ograniczony zakres wartości lub dodatkowe założenia mogą pozwolić użyć metod liniowych.

Sortowanie przez scalanie

Merge sort ma czas działania rzędu O(n log n) w najlepszym, średnim i najgorszym przypadku. To jego największa zaleta, szczególnie gdy przewidywalność jest ważniejsza niż minimalne zużycie pamięci. Wadą pozostaje zwykle dodatkowa pamięć potrzebna do scalania fragmentów.

W praktyce sięgam po tę metodę wtedy, gdy potrzebuję stabilnego sortowania. Stabilność oznacza, że elementy o równych kluczach zachowują wcześniejszą kolejność, co ma znaczenie na przykład przy sortowaniu rekordów najpierw po nazwisku, a potem po dacie.

Quicksort

Quicksort działa średnio w klasie O(n log n), ale przy niekorzystnym wyborze elementu rozdzielającego może zejść do O(n²). Dlatego współczesne implementacje stosują strategie ograniczające ryzyko złego podziału, takie jak losowanie pivota albo warianty hybrydowe.

To dobry przykład różnicy między średnim a najgorszym przypadkiem. W zadaniu rekrutacyjnym nie wystarczy powiedzieć, że quicksort ma „n log n”. Trzeba doprecyzować, że chodzi najczęściej o średnią złożoność, podczas gdy gwarancja zależy od konkretnej implementacji.

Heap sort i drzewa

Heap sort zapewnia O(n log n) także w najgorszym przypadku i może działać bez dużego bufora pomocniczego. Z kolei operacje na zrównoważonych drzewach wyszukiwania, takich jak dodawanie, usuwanie czy wyszukiwanie, często mają koszt O(log n) dla jednej operacji. Jeśli wykonujemy je dla n elementów, łączny koszt może wynieść właśnie O(n log n).

Trzeba jednak sprawdzić, czy drzewo rzeczywiście pozostaje zrównoważone. Zwykłe drzewo binarne przy pechowej kolejności danych może zamienić się w strukturę przypominającą listę, a wtedy operacje spadają do O(n).

Jak ta klasa wypada na tle innych algorytmów

Sama etykieta złożoności nie mówi, czy program będzie szybki na każdej maszynie. Daje jednak bardzo użyteczne wyobrażenie o tym, jak zachowa się po zwiększeniu danych. Właśnie dlatego porównuję przede wszystkim tempo wzrostu, a dopiero później szczegóły implementacji.

Klasa Intuicja Przykład Koszt dla 1 000 000 elementów
O(1) Stała liczba operacji Odczyt elementu po indeksie około 1
O(log n) Problem szybko się zmniejsza Wyszukiwanie binarne około 20
O(n) Jedno przejście po danych Zliczanie elementów 1 000 000
O(n log n) Około n pracy na każdym z log n poziomów Merge sort około 20 000 000
O(n²) Każdy element może być porównany z wieloma innymi Bubble sort 1 000 000 000 000

Liczby w tabeli są przybliżeniem liczby operacji, a nie pomiarem czasu w sekundach. Jedna operacja może oznaczać prosty odczyt, ale może też obejmować kosztowne porównanie tekstu. Mimo tego różnica między 20 milionami a bilionem szybko staje się praktycznie odczuwalna.

Dla małych tablic algorytm kwadratowy nie musi być zły. Insertion sort bywa bardzo szybki dla kilkunastu lub kilkudziesięciu prawie uporządkowanych elementów, ponieważ ma małe narzuty i dobrze wykorzystuje lokalność danych. Problem zaczyna się wtedy, gdy ktoś używa go bezrefleksyjnie dla dziesiątek tysięcy rekordów.

Jak rozpoznać tę złożoność w kodzie

Najprościej szukać dwóch elementów. Pierwszy to przejście po wszystkich danych, a drugi to proces, który powtarza się przez około log n poziomów. Taki schemat często pojawia się w rekurencji, sortowaniu przez podział albo przetwarzaniu zrównoważonej struktury.

sortuj(fragment):
    jeśli fragment ma najwyżej jeden element:
        zakończ

    podziel fragment na dwie części
    sortuj lewą część
    sortuj prawą część
    scal obie części

Jedno wywołanie rekurencyjne nie wykonuje tu pracy liniowej, ale cała warstwa scalania przechodzi przez elementy fragmentu. Rekurencja ma około log n poziomów, a praca na poziomach sumuje się do około n, więc otrzymujemy wynik liniowo-logarytmiczny.

Uważaj na pozornie niewinne pętle

Dwie pętle zagnieżdżone zwykle sugerują O(n²), ale nie zawsze. Jeśli wewnętrzna zmienna za każdym razem podwaja albo dzieli swoją wartość, jej liczba obrotów może wynosić O(log n). Na przykład pętla wykonująca operację dla wartości 1, 2, 4, 8 i tak dalej działa logarytmicznie.

i = 1
while i < n:
    wykonaj_operację()
    i = i * 2

Jeśli taki fragment znajduje się wewnątrz pętli przechodzącej po n elementach, wynik może wynieść O(n log n). Nie należy jednak zgadywać na podstawie samego wyglądu kodu. Trzeba sprawdzić, ile razy wykonuje się każda pętla i czy zakres pracy faktycznie zależy od n.

Rekurencja nie zawsze oznacza logarytm

Podział problemu na dwie części może prowadzić do dobrego wyniku, ale tylko wtedy, gdy każda warstwa nie powiela całej pracy w niekontrolowany sposób. Rekurencja T(n) = 2T(n/2) + O(n) prowadzi do O(n log n), natomiast T(n) = 2T(n/2) + O(1) ma koszt O(n).

Najczęstszy błąd początkujących polega na zauważeniu słowa „dzielimy na pół” i automatycznym wpisaniu O(log n). Tymczasem trzeba jeszcze policzyć, ile łącznie pracy wykonują wszystkie gałęzie. Sam podział to za mało, by określić wynik.

Co może zmienić praktyczną wydajność

Złożoność asymptotyczna opisuje zachowanie dla dużych danych i pomija stałe. Dwa algorytmy z tej samej klasy mogą więc działać zauważalnie różnie. Jeden może wykonywać mniej porównań, lepiej wykorzystywać pamięć podręczną procesora albo korzystać z gotowych operacji bibliotecznych.

Znaczenie ma też pamięć. Merge sort często potrzebuje dodatkowego miejsca proporcjonalnego do n, podczas gdy heap sort może pracować niemal w miejscu. W aplikacji webowej wybór może zależeć nie tylko od czasu procesora, lecz także od limitu pamięci, opóźnień serwera i tego, czy dane trzeba sortować jednorazowo, czy wielokrotnie.

Nie zakładałbym również, że ręcznie napisane sortowanie będzie lepsze od funkcji bibliotecznej. Standardowe implementacje są zwykle długo testowane i często używają hybryd, które dla małych fragmentów przełączają się na prostsze algorytmy. Własny kod ma sens wtedy, gdy masz konkretny powód, profil danych i pomiar potwierdzający problem.

Przeczytaj również: System dwójkowy dla programistów - Zrozum bity i bajty!

Kiedy metoda liniowa może wygrać

Granica dla sortowania porównaniowego nie obowiązuje wszystkich możliwych metod. Counting sort, radix sort albo bucket sort mogą osiągać koszt bliski O(n), jeśli dane spełniają określone warunki, na przykład mają ograniczony zakres lub dają się rozłożyć na cyfry.

To nie jest jednak darmowe przyspieszenie. Trzeba zapłacić dodatkowymi założeniami, pamięcią albo bardziej skomplikowaną implementacją. Dla dowolnych obiektów, tekstów i niestandardowych reguł porównywania uniwersalne algorytmy o koszcie liniowo-logarytmicznym zwykle pozostają rozsądnym wyborem.

Jak wykorzystać tę wiedzę podczas nauki programowania

Na początku nie próbowałbym zapamiętywać samych oznaczeń. Lepiej przy każdym algorytmie odpowiedzieć sobie na trzy pytania: ile razy przechodzę przez dane, czy problem maleje przez podział oraz czy liczba gałęzi rekurencji rośnie. Taki sposób myślenia szybciej prowadzi do poprawnej analizy niż nauka tabeli na pamięć.

  • Najpierw określ, co oznacza n w danym zadaniu.
  • Policz pracę jednej iteracji, jednej warstwy lub jednego wywołania.
  • Sprawdź, ile razy powtarza się ten etap.
  • Oddziel czas działania od zużycia pamięci.
  • Rozważ najlepszy, średni i najgorszy przypadek.
  • Jeśli wybierasz między algorytmami, wykonaj prosty benchmark na realistycznych danych.

Najważniejsza lekcja jest prosta. O(n log n) to zazwyczaj bardzo dobry wybór dla ogólnego sortowania i wielu problemów opartych na dzieleniu danych, ale nie jest magiczną obietnicą szybkości. Patrzę na tę klasę jak na bezpieczny punkt wyjścia, a ostateczną decyzję opieram na danych wejściowych, pamięci, gwarancjach najgorszego przypadku i pomiarach.

FAQ - Najczęstsze pytania

Algorytm wykonuje około n pracy na każdym z mniej więcej log n poziomów. Dla miliona elementów wartość n log₂ n wynosi około 20 milionów operacji jednostkowych.

Merge sort i heap sort mają złożoność O(n log n) także w najgorszym przypadku. Quicksort osiąga ją średnio, ale przy niekorzystnym podziale może mieć koszt O(n²).

Trzeba policzyć pracę wszystkich gałęzi rekurencji oraz koszt wykonywany na każdej warstwie. Rekurencja T(n) = 2T(n/2) + O(n) prowadzi do O(n log n), natomiast T(n) = 2T(n/2) + O(1) ma koszt O(n).

Counting sort, radix sort i bucket sort mogą osiągać koszt bliski O(n), jeśli dane mają odpowiednie cechy, na przykład ograniczony zakres wartości albo strukturę pozwalającą na rozłożenie ich na cyfry. W zamian wymagają dodatkowych założeń, pamięci lub bardziej złożonej implementacji.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi:

złożoność obliczeniowa sortowanie rekurencja quicksort drzewa binarne

Udostępnij artykuł

Alex Jabłoński

Alex Jabłoński

Nazywam się Alex Jabłoński i od 14 lat zajmuję się programowaniem webowym. Moja przygoda z tą dziedziną zaczęła się od prostych projektów, które stopniowo przerodziły się w pasję do tworzenia złożonych aplikacji internetowych. Interesuje mnie nie tylko kodowanie, ale także dzielenie się wiedzą, co skłoniło mnie do pisania artykułów, które pomagają innym zrozumieć zasady programowania. W swoich tekstach koncentruję się na podstawach, ale także na bardziej zaawansowanych zagadnieniach, starając się w przystępny sposób wyjaśnić trudne tematy. Przy pisaniu zwracam szczególną uwagę na aktualność informacji oraz ich zrozumiałość. Regularnie śledzę nowinki w branży, porównuję różne podejścia i staram się uprościć skomplikowane koncepcje, aby były dostępne dla każdego, niezależnie od poziomu zaawansowania. Moim celem jest dostarczenie wartościowych treści, które pomogą czytelnikom w ich drodze do kariery w programowaniu webowym.

Napisz komentarz