Przeskocz do treści

Delta mi!

Mała Delta

O co chodzi w złożoności czasowej

Jakub Radoszewski

o artykule ...

  • Publikacja w Delcie: lipiec 2016
  • Publikacja elektroniczna: 1 lipca 2016
  • Wersja do druku [application/pdf]: (217 KB)

Jeśli mam przybliżyć komuś pojęcie złożoności czasowej, zazwyczaj opowiadam mu następujący problem. Dany jest ciąg liczb całkowitych |a1;:::;an; a naszym celem jest znaleźć jego fragment - czyli spójny podciąg - którego suma elementów jest jak największa.

Dokładniej, należy napisać program, który wczytuje ciąg liczb całkowitych i wyznacza sumę elementów w takim maksymalnym fragmencie. Zadanie to można zinterpretować, na przykład, tak: dla określonego ciągu transakcji na koncie (wpłaty, wypłaty) chcemy wyznaczyć przedział czasu, w którym bilans transakcji był możliwie najkorzystniejszy.

Nasz ciąg najwygodniej reprezentować w tablicy. Pierwsze przychodzące na myśl rozwiązanie może polegać na przejrzeniu wszystkich fragmentów ciągu i sprawdzeniu, który z nich ma maksymalną sumę. W języku Pascal taka funkcja może wyglądać następująco:

obrazek

Czy to jest dobre rozwiązanie? Można je chwilę potestować i stwierdzić, że dla kilku przykładów daje poprawne wyniki. Ale dobre to nie tylko znaczy poprawne. Czy ten program jest szybki? Sprawdźmy!

obrazek

Na potrzeby tego zadania wygenerowaliśmy trzy testy zawierające ciągi o długościach 100 , 10 000 i 1 000 000. Gdy uruchamiamy na tych testach program oparty na powyższej funkcji, to na pierwszym z nich działa w ułamku sekundy, ale dla drugiego i trzeciego trudno doczekać się końca jego działania.

Nie trzeba było jednak uruchamiać tego programu, żeby domyślić się, że tak właśnie będzie. Spróbujmy ustalić, ile operacji wykonuje ten program dla danych wejściowych rozmiaru n. W programie występuje cała gama różnych operacji - przypisania, operacje arytmetyczne, warunki, pętle… - i trudno byłoby to tak dokładnie policzyć. Warto więc ustalić, która operacja jest operacją dominującą, czyli którą operację wykonujemy najczęściej - i tę operację zliczać. Łatwo zauważyć, że w tym przypadku będzie to zwiększanie wartości zmiennej suma. Ile razy ma to miejsce? Dla każdego fragmentu |ai,...,a j wykonujemy  j− i + 1 takich operacji, a zatem łącznie będzie ich:

n n Q Q ( j− i + 1). i 1 j i

Po pracowitym przeliczeniu tej sumy, którego oglądania darujemy Czytelnikowi, wychodzi:

1n3 + 1n2 +-1n. 6 2 3

Nietrudno teraz sprawdzić, że dla |n = 100,10000,1000000 otrzymujemy, odpowiednio, |171700, mniej więcej |1,7⋅1011 i mniej więcej |1,7⋅1017 operacji. Biorąc pod uwagę, że obecnie komputery mogą wykonać maksymalnie | 9 10 bardzo prostych operacji na sekundę, widzimy, dlaczego nasz program działa tak wolno.

W takim razie warto zastanowić się nad tym, czy nie ma szybszego rozwiązania. Najbardziej znaczący składnik w powyższym wzorze to, oczywiście, składnik z n3 i to on w decydującym stopniu wpływa na to, że nasz program działa tak wolno. Z tego względu w analizie liczby operacji wykonywanej przez program (czy raczej algorytm) pomija się składniki niższego rzędu, co w przypadku wielomianów oznacza, że interesuje nas tylko jednomian o najwyższym wykładniku. Dalszym uproszczeniem jest pominięcie, zazwyczaj niedużych, stałych czynników. W ten sposób uzyskujemy funkcję, która z niezłym przybliżeniem określa czas działania wynikowego programu. Powiemy więc, że nasz algorytm ma złożoność czasową |O(n3). Przykładowo, dla trzech podanych wartości n | funkcja |n3 przyjmuje wartości 106,1012 i 1018, co nieźle przybliża dokładnie obliczone liczby operacji. W dalszej części artykułu spróbujemy przekonać się, że pojęcie złożoności czasowej pomaga w projektowaniu szybkich programów.

Intuicyjnie złożoność O(n wzięła się w powyższym programie stąd, że rozpatrujemy wszystkie fragmenty ciągu, których jest z grubsza |n2, i obliczamy sumę każdego z nich, co wymaga maksymalnie n dodawań - łącznie, mniej więcej, | 3 n dodawań. Gdyby dało się szybciej obliczać sumę fragmentu, udałoby nam się wykonywać jedynie mniej więcej |n2 operacji…

Okazuje się, że jest to możliwe. W rozwiązaniu przydaje się pomysł z zakresu księgowości: aby łatwo sprawdzać, na ile dobry był dany okres na koncie, wystarczy po każdej operacji przechowywać łączny bilans z wszystkich dotychczas wykonanych operacji. W ten sposób do określenia sumy danego okresu wystarczy nam znajomość bilansu na koniec tego okresu oraz bilansu tuż przed jego rozpoczęciem. Kod odpowiedniej funkcji znajduje się poniżej.

obrazek

Zbadajmy, na ile szybkie jest to rozwiązanie. Uruchomienie go na naszych trzech testach wykazuje znaczącą poprawę: dla pierwszych dwóch testów wyniki otrzymujemy prawie natychmiast, choć dla trzeciego znów nie udaje się doczekać na odpowiedź. A co wykazuje analiza "teoretyczna"? Operacją dominującą będzie teraz dowolna z operacji wykonywanych wewnątrz dwóch zagnieżdżonych pętli. Każda z nich wykonywana jest tyle razy, ile jest możliwych wyborów indeksów |1⩽ i⩽ j⩽ n, czyli

 n ( ) = 12n2 + 12n 2

razy, co odpowiada złożoności czasowej O(n2). Obliczenia wstępne polegają na wykonaniu zaledwie |n− 1 dodawań, więc można je pominąć w opisie złożoności. Dla n = 102,104,106 mamy zatem mniej więcej |104,108 i 1012 operacji.

Nie moglibyśmy jednak tego wszystkiego pozostawić, nie przedstawiając rozwiązania, które poradzi sobie z naszym największym testem. Znów, intuicyjnie, złożoność czasowa |O(n2) powyższego rozwiązania bierze się stąd, że rozpatrujemy wszystkie początki i końce fragmentów, a każdych z nich jest n. Postawmy śmiałe pytanie: czy dałoby się zamiast tego rozważać np. tylko początki albo tylko końce fragmentów?… Okazuje się, że tak!

W tym celu wystarczy zapytać, jak dla ustalonego końca fragmentu dobrać początek fragmentu, tak aby suma fragmentu była maksymalna. Będzie to, oczywiście, taki indeks operacji, przed wykonaniem której bilans na koncie jest możliwie najmniejszy!

obrazek

Tym razem główna pętla wykonuje jedynie kilka prostych operacji, więc standardowo z pominięciem stałej określamy złożoność czasową ostatecznego rozwiązania jako |O(n). I rzeczywiście, jak można się już domyślić, to rozwiązanie bez problemu radzi sobie z wszystkimi trzema testami.

W tym artykule spróbowaliśmy w kilku słowach opowiedzieć, na czym polega analiza złożoności czasowej algorytmów. Celowo pominęliśmy kwestie takie jak dobór odpowiednich typów danych (czy typ LongInt wystarcza?) oraz złożoność pamięciową, czyli - znów przybliżone - określenie zużycia pamięci przez program.


  • Programy i testy opisane w artykule są dostępne na stronie tutaj.