Wyjaśnienie złożoności czasowej i przestrzennej algorytmu

Wyjaśnienie złożoności czasowej i przestrzennej algorytmu

Wyobraź sobie dwa programy, które wykonują dokładnie to samo zadanie. Podaj każdemu z nich tysiąc rekordów i nie będziesz w stanie ich rozróżnić. Podaj im jednak miliard, a jeden skończy, zanim puścisz klawisz Enter, podczas gdy drugi będzie nadal pracował w następny wtorek.

Ten sam laptop. Te same dane. To, co je dzieli, to złożoność czasowa, czyli po prostu nazwa określająca, jak szybko obciążenie algorytmu rośnie wraz z ilością danych wejściowych.

Omówimy, co właściwie oznacza ten termin, jak zapisuje się go w notacji Wielkiego O, jak wywnioskować go z prawdziwego fragmentu kodu i gdzie pojawia się złożoność przestrzenna. Następnie, na końcu, przejdziemy do miejsca, w którym to wszystko przestaje być pracą domową, a zaczyna kosztować realne pieniądze: inteligentnych kontraktów.

Czym jest złożoność czasowa algorytmu?

Zapomnij na chwilę o sekundach. Złożoność czasowa liczy kroki. Porównania, zadania, trochę arytmetyki tu i tam, a sumę zapisujesz jako funkcję n, czyli rozmiaru danych wejściowych.

Oto najnudniejsza funkcja, jaką można sobie wyobrazić:

python def print_all(items): dla elementu w elementach: print(item)

Dziesięć przedmiotów? Dziesięć przejść przez pętlę. Dziesięć tysięcy? Dziesięć tysięcy przejść.

Praca porusza się w linii prostej wzdłuż wejścia, dlatego nazywamy to O(n).

No cóż, może każda podróż rzeczywiście kosztuje trzy małe operacje zamiast jednej. Dobra, to 3n. Szczerze mówiąc, nikogo to nie obchodzi, bo podwojenie listy i tak podwaja pracę, a ta krzywa wzrostu to właśnie to, czego nam potrzeba. Dokładna liczba jest celowo pomijana.

Dlatego też każdy kurs DSA i połowa rozmów kwalifikacyjnych z kodowaniem kładzie nacisk na złożoność czasową. Jeśli potrafisz odczytać złożoność algorytmu, patrząc na niego, możesz odgadnąć, jak zachowa się na danych wejściowych, których nigdy nie testowałeś. A w środowisku produkcyjnym stanowią one większość danych.

Dlaczego czas wykonania jest słabą miarą algorytmu

Dlaczego więc po prostu nie uruchomić tego z timerem? Wiele osób tak robi i rozumiem to. Problem w tym, że czas wykonania wciąga masę rzeczy, z którymi twój algorytm nie ma nic wspólnego:

  • Sprzęt. Mój pięcioletni laptop i nowiutki cloud box dadzą ci zupełnie różne wyniki dla tego samego skryptu.
  • Język programowania. Napisz tę samą pętlę w C i Pythonie, a zobaczysz przerwę 10-krotną, a nawet większą.
  • Obciążenie systemu. Uruchomił się jakiś aktualizator w tle, wybudził się moduł zbierający śmieci, pamięć podręczna jest zimna. Twój wynik się zmienia.
  • Niezależnie od tego, jakie dane wejściowe udało Ci się zebrać. Sortowanie, które działa na prawie posortowanych danych, może działać również na potasowanych danych.

Stoper daje ci jeden pomiar na jednym polu z jednym wejściem. Zapytaj go o dane 100 razy większe, a nic nie znajdzie.

Liczba kroków nie jest chwiejna. Ten sam algorytm, te same dane wejściowe i otrzymujesz tę samą liczbę kroków, niezależnie od tego, czy to Rust na serwerze rack, czy JavaScript na czyimś telefonie. Dlatego złożoność czasowa to uczciwa walka między dwoma rozwiązaniami, zanim cokolwiek przetestujesz. Ja i tak bym to później porównał, jeśli to coś znaczy. To po prostu inna kwestia.

Wyjaśnienie Big O i innych notacji asymptotycznych

W podręcznikach nazywa się to notacją asymptotyczną, co brzmi gorzej niż jest w rzeczywistości. Pozwalasz, by n stało się ogromne i przestajesz przejmować się drobiazgami, więc stałe mnożniki i niewielkie nakłady wypadają, a przetrwa tylko krzywa wzrostu. Warto znać trzy z nich:

Notacja Co to opisuje Zwykłe znaczenie Przykład
Duże O, O(f(n)) Górna granica Algorytm nie rośnie szybciej niż f(n) Przeszukiwanie liniowe ma czas O(n)
Duża Omega, Ω(f(n)) Dolna granica Algorytm wymaga co najmniej pracy f(n) Każde sortowanie porównawcze jest Ω(n log n)
Duża Theta, Θ(f(n)) Ścisłe wiązanie Górna i dolna granica są zgodne Sortowanie przez scalanie to Θ(n log n)

W praktyce notację Big O usłyszysz w około 95% przypadków. Ustawia ona górną granicę, co oznacza, że czas wykonania nie będzie rósł szybciej niż tempo podane w nawiasach. Podczas mojej pierwszej recenzji kodu w prawdziwej pracy starszy programista napisał „O(n²), proszę poprawić” obok zagnieżdżonej pętli, co oznaczało, że złożoność czasowa w najgorszym przypadku była kwadratowa, a lista miała się znacznie wydłużyć.

Omega i Theta głównie uczęszczają na zajęcia uniwersyteckie z analizy algorytmów i chyba od lat nie pisałem żadnego z nich w pracy. Big O wygrywa, ponieważ odpowiada na pytanie, o które ludzie pytają na spotkaniu, czyli: „Jak źle się to skończy, jeśli ruch się potroi?”.

Jest jeden szczegół techniczny, który przez jakiś czas mnie zastanawiał. Ponieważ Big O to tylko sufit, funkcja O(n) jest również O(n²), ściśle rzecz biorąc, tak samo jak osoba o wzroście 1,5 metra ma „poniżej 2 metrów”. Prawda i bezużyteczna. Każdy podaje najwęższą granicę, jaką potrafi uzasadnić, i ja zrobię to samo.

Złożoność czasowa i złożoność przestrzenna

Typowe klasy złożoności czasowej z przykładami

W prawdziwym kodzie nie ma zbyt wielu klas, może siedem, jeśli liczyć te przerażające. Oto mniej więcej, ile kosztuje każda z nich przy n = 1000:

Klasa Nazwa Typowy przykład Operacje przy n = 1000
O(1) Stały Indeks tablicy, wyszukiwanie w tablicy skrótów 1
O(log n) Logarytmiczny Wyszukiwanie binarne ~10
NA) Liniowy Skanowanie listy 1000
O(n log n) Liniowy Sortowanie przez scalanie, sortowanie szybkie (średnie) ~10 000
O(n²) Kwadratowy Pętle zagnieżdżone, sortowanie bąbelkowe 1 000 000
O(2ⁿ) Wykładniczy Naiwny rekurencyjny Fibonacci Więcej niż atomy we wszechświecie
NA!) Silnia Sprzedawca podróżujący z użyciem siły Jeszcze większy

O(1): Stały czas

Niezależnie od tego, czy Twój słownik Pythona ma dziesięć, czy dziesięć milionów kluczy, wyciągnięcie jednego po kluczu kosztuje mniej więcej tyle samo, podobnie jak odczytanie elementów[5] z listy. Ta płaskość stanowi istotę stałego czasu.

O(log n): Czas logarytmiczny

Myślę o nich jak o algorytmach typu „zgadnij liczbę od 1 do 100”, gdzie każde odgadnięcie wyklucza połowę tego, co pozostało. Wyszukiwanie binarne działa dokładnie tak samo na posortowanej liście:

python def binary_search(arr, target): lo, hi = 0, len(arr) - 1 while lo <= hi: mid = (lo + hi) // 2 jeśli arr[mid] == cel: zwróć mid jeśli arr[mid] < cel: lo = mid + 1 w przeciwnym razie: hi = mid - 1 zwróć -1

Przy miliardzie posortowanych elementów potrzeba około 30 porównań, natomiast przeglądanie ich jeden po drugim może zająć cały miliard. I nigdy nie przestało mnie to śmieszyć.

O(n) i O(n log n)

Algorytm liniowy odwiedza każdy element ustaloną liczbę razy, tak jakbyś szukał na oko największej liczby w kolumnie arkusza kalkulacyjnego. O(n log n) to podstawa porządnego sortowania. Sortowanie przez scalanie dzieli listę na pojedyncze elementy (log n poziomów podziału), a następnie wykonuje jedno przejście liniowe na każdym poziomie podczas scalania, dzięki czemu czas działania pozostaje taki sam, niezależnie od tego, czy dane docierają posortowane, czy pomieszane.

O(n²) i gorzej

Kod kwadratowy prawie zawsze ma pętlę ukrytą w innej pętli, jak ta kontrola duplikatów, która porównuje każdą parę:

python def has_duplicate(elementy): dla i w zakresie(len(elementy)): dla j w zakresie(i + 1, len(elementy)): jeśli elementy[i] == elementy[j]: zwróć wartość True zwróć wartość False

Wzrost wykładniczy i czynnikowy są o niebo gorsze. Podręcznikowy rekurencyjny ciąg Fibonacciego ciągle rozwiązuje te same podproblemy, więc każde +1 przy n mniej więcej podwaja pracę. Przy n = 20 nie zauważysz tego; przy n = 60 możesz ugotować obiad, zjeść go, pozmywać, a i tak nie będzie gotowy.

Jak obliczyć złożoność czasową krok po kroku

Prawie nigdy nie potrzebujesz formalnego dowodu, aby obliczyć złożoność czasową. Przerabiam w myślach tę listę i pokrywa ona większość kodu, jaki widzę:

  1. Zdefiniuj n. Określ, jaki jest rozmiar danych wejściowych: długość listy, liczba węzłów w grafie, liczba cyfr w liczbie.
  2. Policz pracę w każdym wierszu. Proste instrukcje (przypisania, porównania, arytmetyka) kosztują O(1).
  3. Dodaj bloki sekwencyjne. Dwie pętle jedna po drugiej, każda po n, dają n + n = 2n.
  4. Mnożenie zagnieżdżonych bloków. Pętla po n wewnątrz innej pętli po n daje n × n = n².
  5. Usuń stałe. 2n zmienia się w n, a 500 zmienia się w 1.
  6. Zachowaj tylko wyraz dominujący. W n² + n, część n² przyćmiewa resztę w miarę wzrostu n.

Szybko, policzyłem. Wynik to T(n) = 3n² + 5n + 2. Wprowadź milion: 3n² to trzy biliony, 5n to pięć milionów. Błąd zaokrąglenia. Powiedzmy, że to O(n²), gotowe.

Pętle, które pomijają elementy zamiast przechodzić obok nich, są bardziej podstępne:

python def count_halvings(n): kroki = 0, podczas gdy n > 1: n //= 2 kroki += 1 return steps

Każde przejście zmniejsza n o połowę, więc ta pętla while poddaje się po około log₂(n) rundach. Co w zasadzie stanowi pełną odpowiedź na pytanie „kiedy złożoność czasowa wynosi O(log n)?”. Jeśli to, co pozostaje, zmniejsza się o stały współczynnik w każdym kroku, to jesteś u celu.

Rekurencja potrzebuje jeszcze jednego narzędzia – rekurencji. Sortowanie przez scalanie wykonuje dwa zadania o połowę mniejsze, a następnie scalanie, więc T(n) = 2T(n/2) + n. Twierdzenie główne to ściągawka dotycząca rekurencji tego typu, a w tym przypadku zwraca O(n log n).

I praktyczna zasada z programowania konkursowego, na której często się opieram: normalna maszyna wykonuje około 10⁸ prostych operacji na sekundę. Bardzo to uproszczone, ale wystarcza do sprawdzenia złożoności czasowej rozwiązania przed kliknięciem „Wyślij”. Przy n równym 10⁵, O(n²) wynosi około 10¹⁰ operacji, więc limit czasu zostaje przekroczony. O(n log n) wynosi poniżej 2 milionów. Przechodzi gładko.

Analiza najgorszego, najlepszego i przeciętnego przypadku

Wprowadź do tego samego algorytmu różne dane wejściowe, a ilość pracy może gwałtownie wzrosnąć. Przeszukiwanie liniowe dobrze to pokazuje:

  • W najlepszym przypadku: chcesz być na samym czele. Jedno porównanie i odpadasz.
  • W najgorszym przypadku jest na samym końcu albo w ogóle go nie ma. To tyle porównań.
  • Przeciętny przypadek: jeśli istnieje jakiekolwiek prawdopodobieństwo, że będzie to gdziekolwiek, sprawdzisz średnio około n/2. Nadal O(n), pamiętaj.

Zapytaj programistę o „złożoność” czegoś, a prawie zawsze poda ci najgorszy możliwy scenariusz złożoności czasowej, ponieważ ta liczba to obietnica, którą można uwzględnić w planach.

Załóżmy, że Twoje API obsługuje żądanie w najgorszym przypadku z szybkością O(n). Świetnie, teraz wiesz, jak zachowuje się najwolniejsze żądanie, a dodatkowo jesteś zabezpieczony, gdy ktoś celowo wysyła Ci śmieci. Ludzie po prostu tak robią: jeśli atakujący odkryje, które klucze powodują kolizję w Twojej tablicy skrótów, to przez jakiś czas będą widoczne tylko te klucze.

Warto przyjrzeć się przeciętnemu przypadkowi, gdy zły scenariusz rzadko się zdarza. Quicksort jest tu sztandarowym przykładem, uśredniając O(n log n), ale spadając do O(n²), jeśli naiwnie wybierzesz pivoty na danych, które są już posortowane. Biblioteki standardowe radzą sobie z tym, używając losowych pivotów lub przełączając się na inny algorytm sortowania, gdy rekursja osiągnie podejrzanie dużą głębokość.

Czym jest złożoność przestrzenna i przestrzeń pomocnicza?

Pamięć to kolejny rachunek, jaki generuje algorytm. Złożoność pamięci śledzi, ile jej potrzebujesz w miarę wzrostu rozmiaru danych wejściowych, i zapisuje się ją tą samą notacją Wielkiego O, co czas.

Część tej pamięci to samo wejście, za które płacisz niezależnie od tego, co z nim zrobisz. Część, którą faktycznie kontrolujesz, nazywa się przestrzenią pomocniczą, czyli dodatkowymi elementami przydzielanymi przez kod podczas działania, takimi jak zmienne tymczasowe i nowe tablice, a także stos wywołań, o którym nikt nie pamięta, dopóki go nie ugryzie.

Kilka szybkich przypadków:

  • Zamiana dwóch zmiennych wymaga niezależnie od wszystkiego kilku slotów, więc jest to przestrzeń pomocnicza O(1).
  • Skopiowanie listy n elementów do nowej listy kosztuje Cię O(n) dodatkowej pamięci.
  • Rekursja na poziomie n powoduje, że n ramek pozostaje na stosie, co stanowi przestrzeń O(n), nawet jeśli nigdy nie zbudowałeś tablicy.

Widziałem, jak początkujący programista stracił całe popołudnie na to ostatnie. Jego funkcja rekurencyjna wyglądała, jakby w ogóle nie używała pamięci, ale każde niedokończone wywołanie było zaparkowane na stosie, czekając na powrót tych z głębszych warstw, a przy dużym wejściu cały system umierał z powodu przepełnienia stosu.

Wiele optymalizacji polega po prostu na wymianie pamięci na czas lub odwrotnie. Memoizacja buforuje już obliczone wyniki, co skraca czas Fibonacciego z O(2ⁿ) do O(n) w zamian za O(n) pamięci. Tabele skrótów zapewniają średnią liczbę wyszukiwań O(1) poprzez poświęcenie miejsca na kontenery. Na serwerze z 64 GB pamięci RAM taka wymiana jest zazwyczaj oczywista, ale na mikrokontrolerze lub w inteligentnym kontrakcie warto się dwa razy zastanowić.

Złożoność czasowa i złożoność przestrzenna

Złożoność czasowa i przestrzenna typowych struktur danych

Wybór odpowiedniej struktury danych zazwyczaj jest lepszy niż mikrodostrajanie tego, co znajduje się w pętli. Średnie koszty dla tych, po które najczęściej sięgasz:

Struktura danych Dostęp Szukaj Wstawić Usuwać Przestrzeń
Szyk O(1) NA) NA) NA) NA)
Lista powiązana NA) NA) O(1) O(1) NA)
Tablica mieszająca brak danych O(1) O(1) O(1) NA)
Zrównoważone drzewo poszukiwań binarnych O(log n) O(log n) O(log n) O(log n) NA)
Stos binarny O(1) dla min/maks NA) O(log n) O(log n) NA)

Dwa przypisy. Wstawianie i usuwanie na liście powiązanej ma złożoność O(1), jeśli masz już węzeł. Tablica skrótów może spaść do O(n), gdy wiele kluczy koliduje.

Algorytmy sortowania różnią się równie mocno:

Algorytm sortowania To, co najlepsze Przeciętny Najgorszy Przestrzeń pomocnicza
Sortowanie bąbelkowe NA) O(n²) O(n²) O(1)
Sortowanie przez scalanie O(n log n) O(n log n) O(n log n) NA)
Szybkie sortowanie O(n log n) O(n log n) O(n²) O(log n)
Sortowanie kopcowe O(n log n) O(n log n) O(n log n) O(1)

Sortowanie przez scalanie jest przewidywalne, ale wymaga dodatkowej pamięci. Sortowanie przez kopcowanie sortuje w miejscu i zachowuje ten sam najgorszy przypadek. Szybkie sortowanie, pomimo gorszego sufitu, w praktyce zazwyczaj wygrywa.

Złożoność czasowa w blockchainie i inteligentnych kontraktach

I tu zaczyna się zabawa. Na Twoim własnym serwerze niechlujny algorytm marnuje procesor i może denerwować użytkownika. W Ethereum lub dowolnym łańcuchu EVM marnuje pieniądze. Każdy kod operacji wykonywany przez inteligentny kontrakt kosztuje gaz, opłacany w natywnej walucie łańcucha, więc liczba operacji pojawia się jako pozycja na czyimś rachunku. Innymi słowy, zła złożoność czasowa ma swoją cenę.

Załóżmy, że masz kontrakt, który obejmuje każdego posiadacza tokena, aby wypłacić nagrody. O(n), a gaz rośnie z każdym nowym posiadaczem. W dniu premiery nikt tego nie zauważa.

Potem lista posiadaczy ciągle rośnie. Pewnego dnia wypłata wymaga więcej gazu niż limit gazu blokowego, który jest limitem ilości gazu mieszczącego się w jednym bloku. Potem transakcja nie może zostać zrealizowana. Nie chodzi o „to jest powolne”. Po prostu nigdy się nie udaje, a środki, które czekały za tą funkcją, w zasadzie utknęły. Audytorzy rejestrują takie nieograniczone pętle w ramach odmowy usługi i jest to w porządku.

Rozwiązania te opierają się na prostym i czystym myśleniu złożonym:

  • Podziel pracę na partie o stałym rozmiarze obejmujące kilka transakcji.
  • Odwróć to tak, aby każdy użytkownik mógł odebrać własną nagrodę. Każde wywołanie jest O(1).
  • Zamień pętlę na dowód Merkle'a.

Uwielbiam dowody Merkle'a jako przykład tego, dlaczego log n ma tak duże znaczenie w łańcuchu. Drzewo Merkle'a haszuje dużą ilość danych w jeden hash główny.

Aby udowodnić, że jeden wpis należy do zbioru n, potrzeba około log₂(n) skrótów. Dla miliona wpisów to około 20. Nie milion. Airdropy opierają się na tym, a lekkie portfele Bitcoin wykorzystują tę samą sztuczkę do potwierdzania transakcji bez pobierania całego bloku.

Poza łańcuchem, to samo. Wyobraź sobie system płatności, który dopasowuje każdą przychodzącą transakcję blockchain do faktury, skanując każdą otwartą. O(n) na płatność, a wraz z rozwojem sklepu staje się to wolniejsze. Umieść faktury na mapie skrótów z kluczem adresu lub identyfikatora, a każde dopasowanie będzie miało współczynnik O(1). A jeśli wolisz nie budować i nie zarządzać tym samodzielnie, bramka płatności kryptowalutowych, taka jak Plisio, zrobi to za Ciebie.

Jakieś pytania?

Nie, ponieważ liczy operacje względem rozmiaru danych wejściowych, a sprzęt nie ma na to wpływu. To, co zobaczysz, to różnica między maszynami, czyli czas wykonania. Szybki procesor kończy zadanie O(n²) szybciej niż wolniejszy, ale podwojenie danych wejściowych na obu procesorach i tak zwiększa nakład pracy około czterokrotnie.

Stały czas, O(1), ponieważ praca nigdy nie rośnie. Logarytmiczny O(log n) zajmuje drugie miejsce i obsługuje miliard elementów w około 30 krokach. Następnie jest liniowy, potem O(n log n), następnie kwadratowy, a na końcu klasy wykładnicze.

Gdy dane wejściowe są duże, tak, ale z małymi może być różnie. Duże O ignoruje stałe czynniki, więc wywołanie O(1) kosztujące 10 000 kroków może przegrać z pętlą O(n) dla 50 elementów, dlatego skanowanie krótkiej tablicy czasami jest lepsze niż przeszukiwanie tablicy haszującej.

Złożoność czasowa opisuje, jak liczba operacji rośnie wraz z danymi wejściowymi, a złożoność przestrzenna opisuje, jak rośnie pamięć. Sortowanie przez scalanie jest szybkie, ale wymaga dodatkowej tablicy o rozmiarze O(n), podczas gdy sortowanie bąbelkowe jest powolne i wymaga przestrzeni o rozmiarze O(1), więc te dwa algorytmy nie muszą się przemieszczać jednocześnie.

Wszystko sprowadza się do tego, jak porusza się zmienna pętli. Krok o 1 do n daje O(n), podwojenie lub zmniejszenie o połowę w każdym przejściu daje O(log n), a jeśli w ciele pętli znajduje się kolejna pętla po n, mnożysz je przez siebie.

Daje możliwość porównywania algorytmów pod kątem ich skalowalności bez konieczności angażowania konkretnej maszyny. Pomijasz stałe i małe wyrażenia, zachowujesz górną granicę i otrzymujesz jasny obraz zachowania algorytmu wraz ze wzrostem danych wejściowych, co zazwyczaj jest potrzebne przy wyborze między dwoma podejściami.

Ready to Get Started?

Create an account and start accepting payments – no contracts or KYC required. Or, contact us to design a custom package for your business.

Make first step

Always know what you pay

Integrated per-transaction pricing with no hidden fees

Start your integration

Set up Plisio swiftly in just 10 minutes.