Złożoność algorytmów
Złożoność algorytmów
Literatura: 1. The Euclidean Algorithm, Michael
Monagan, monagan@inf.ethz.ch;
2. W.M. Turski, Propedeutyka
Złożoność obliczeń
Podamy kilka przykładów algorytmów i omówimy ich złożoność,
efektywność, w sensie czasu wykonania.
Algorytm
Turski: Algorytm - informatyczny opis rozwiązania.
Nazwa algorytm pochodzi od nazwiska arabskiego matematyka
Muhammeda ibn Musy al-Chorezmi (z Chorezmu), który opisał system
dziesiętnego kodowania liczb i sztukę liczenia w tym systemie.
Było to ok. roku 820 n. e.
Obecnie przez algorytm rozumie się opis obiektów wraz z opisem
czynności, które należy wykonać z tymi obiektami, aby osiągnąć
określony cel.
Opisy obiektów występujących w algorytmie będziemy nazywać
deklaracjami, a czynności - instrukcjami.
Algorytm Euklidesa. Co to jest złożoność algorytmu
Cel jaki stawiamy przed sobą to poznanie algorytmu Euklidesa
znajdowania największego wspólnego dzielnika
(nwd) dwóch liczb
całkowitych dodatnich i szczegółowe opracowanie tego prostego
algorytmu.
Co to jest nwd? Jeśli zapiszemy dwa ułamki 64/20 i
16/5 to różnica między nimi polega na tym, że pierwszy
ma licznik i mianownik większe od drugiego o czynnik 4. Oba są jednak
takie same. Ułamek drugi jest skróconą wersją pierwszego. Wspólnym
czynnikiem przez które podzieliliśmy licznik i mianownik jest liczba
4. Jest to największa z liczb, przez które obie liczby 64 i 20 dzielą
się bez reszty.
Można rozłożyć obie liczby na czynniki pierwsze i napisać
Widzimy, że maksymalny czynnik wspólny wynosi (2)2=4. Metoda
rozkładu liczb na czynniki pierwsze nie jest najlepszą z metod
znajdowania nwd. Załóżmy bowiem, że liczby, których nwd szukamy są
liczbami o ok. 100 cyfrach. Kiedy znajdziemy tą metodą wszystkie
pierwsze czynniki obu liczb?
Około 300 lat p.n.e. Euklides, który nie znał żadnego języka
programowania, zapisał w języku greckim algorytm znajdowania nwd.
(I hope that what follows is not a Greek to you . Monagan, M.,
ETHZ). Algorytm Euklidesa jest prosty. Załóżmy, że mamy dwie dodatnie,
całkowite liczby a i b. nwd posiada następujące własności
- nwd(a,0)=a,
- nwd(a,b)=nwd(b,a),
- nwd(a,b)=nwd(a−b,b).
(Trzecia własność: Jeżeli g=nwd(a,b) i h=nwd(a−b,b) to g=h.
Najpierw pokażemy, że g dzieli h (co zapiszemy g|h), a następnie,
że h|g. W ten sposób h=g.
1. (pokażemy, że g|h) Mamy g=nwd(a,b), a więc g|a i g|b.
Stąd a−b=g*(a/g)−g*(b/g)=g*(a′−b′), tzn. g|(a−b).
Stąd g|h=nwd(a−b,b).
2. (pokażemy, że h|g) Mamy h=nwd(a−b,b). Stąd h|(a−b) i h|b,
a więc h|a i dlatego h=nwd(a,b)→ h|g.)
Algorytm Euklidesa oparty jest na fakcie, że nwd(a,b)=nwd(a−b,b)
Jeżeli a ≥ b to korzystamy z własności nwd(a,b)=nwd(b,a) i
przezywamy liczby a ↔ b, a w przypadku b=0 mamy
nwd(a,0)=a.
Algorytm obliczania nwd wygląda więc następująco.1
START
w razie, gdy b=0 -> a
w razie, gdy a<b -> nwd(b,a)
-> nwd(a-b,b)
KONIEC
Algorytm jest rzeczywiście prosty.
A oto wyniki pośrednie otrzymane na drodze zastosowania tego
algorytmu dla liczb a=64, b=20:
(64,20) ->
(44,20) ->
(24,20) ->
(4,20) ->
(20,4) ->
(16,4) ->
(12,4) ->
(8,4) ->
(4,4) ->
(0,4) ->
(4,0) => 4.
Zapiszemy jeszcze inną wersję słowną algorytmu Euklidesa:
START
dopóki a <> b, dopóty wykonuj
jeśli a > b to a-b -> a
w przeciwnym wypadku b-a -> a;
wypisz(a)
KONIEC
Złożoność algorytmu
Czy algorytm Euklidesa w przedstawionej postaci jest efektywny?
Pomyślmy co się stanie gdy a=1010 a b=10. W tym przypadku
będą wykonywane kolejne odejmowania, aż otrzymamy zero.
Odejmowań będzie 1010, czyli 1010 kroków
albo elementarnych instrukcji. Licząc, że w czasie 1 sec
wykonywanych
będzie 1 milion takich operacji na wynik obliczeń przyjdzie czekać
kilka godzin. To co wykonuje algorytm to odejmowanie
b od a, aż do chwili, gdy mamy a < b. Inaczej mówiąc algorytm
jest algorytmem obliczania reszty z dzielenia a przez b. Oznaczmy
ją przez mod(a,b). Zamiast korzystać z nwd(a,b)=nwd(a−b,b)
można więc wykorzystać związek nwd(a,b)=nwd(mod(a,b),b).
Najczęściej jest tak, że różne funkcje, takie jak mod, są
częścią języka wyższego poziomu (np. Pascala).
Algorytm obliczania nwd można teraz przepisać do postaci
START
w razie, gdy b=0 -> a
w razie, gdy a<b -> nwd(b,a)
-> nwd(mod(a,b),b)
KONIEC
Inną wersją tego algorytmu, która wykorzystuje instrukcję
"dopóki W, dopóty R" jest
START
c:=a, d:=b
dopóki d>0, dopóty wykonuj (* pętla dd *)
r:=mod(c,d)
c:=d
d:=r
koniec pętli dd
KONIEC
Ile operacji wykonuje algorytm w czasie jego realizacji dla
liczb a i b mniejszych od n?
Parametr ten można też nazwać złożonością algorytmu.
Algorytm Euklidesa ma złożoność rzędu n2. Będziemy zapisywać
to w postaci Θ(n2). Oznacza to, że liczba wykonanych operacji
jest równa
Jeśli więc podwoimy liczby a, b to czas obliczeń, który jest proporcjonalny do ilości operacji,
a więc do złożoności algorytmu, staje się czterokrotnie
dłuższy.
Efektywność przedstawionego algorytmu Euklidesa jest taka sama jak
efektywność mnożenia liczb
całkowitych.
(W sprawie LA patrz: Knuth, The art of computer programming, t II).
Z najgorszym przypadkiem spotykamy się wtedy, gdy liczby a i b
są liczbami Fibonacciego.2
Są to liczby ciągu 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ... Zadane są one związkiem rekurencyjnym
f1=0, f2=1, fn=fn−1+fn−2, n=3, 4, ... . |
|
Chcąc policzyć nwd(fn, fn−1) musimy policzyć
nwd(fn−1,fn−2), ...,
czyli musimy policzyć wszystkie liczby Fibonacciego w odwrotnym porządku.
Nasz algorytm zastosowany do liczb a=55 i b=34 daje
c | d | r |
55 | 34 | 21 |
34 | 21 | 13 |
21 | 13 | 8 |
13 | 8 | 5 |
8 | 5 | 3 |
5 | 3 | 2 |
3 | 2 | 1 |
2 | 1 | 0 |
1 | 0 | → 1 |
Czy można wyznaczyć nwd(a,b) za pomocą mniejszej liczby
operacji niż Θ(n2)? Okazuje się, że tak, ale nie można zrobić
tego tak szybko jak w przypadku dodawania liczb całkowitych
dla którego złożoność jest Θ(n).
Zakończmy ten fragment uwagą o tzw. "Binarnym algorytmie nwd".
Ten nadaje się szczególnie dobrze dla maszyn cyfrowych. Jest on też
rzędu n2. Jego przewaga nad poprzednim polega na tym, że jeżeli
a i b zapisać w systemie dwójkowym to pewne operacje można
wykonywać szybciej. Liczba operacji może spaść o połowę.
Zaobserwujmy mianowicie, że nwd(2a,2b)=2nwd(a,b)
oraz, że nwd liczb nieparzystych jest liczbą nieparzystą,
a różnica liczb nieparzystych jest parzysta.
Algorytm zaczyna się od zapisania
gdzie a′, b′ są nieparzyste. Stąd
nwd(a,b)=2min(i,j)nwd(a′,b′). Wyznaczanie i, j i dzielenie przez 2i
lub 2j jest bardzo proste gdy liczby całkowite zapisze się
w postaci dwójkowej.
A oto cały algorytm.
nwd(a,b):=
START
b = 0 -> a
a = 0 -> b
mod(a,2)=0 i mod(b,2)=0 -> 2*nwd(a/2,b/2)
mod(a,2)=0 -> nwd(a/2,b)
mod(b,2)=0 -> nwd(a,b/2)
a < b -> nwd(b,a)
-> nwd(a-b,b)
KONIEC
Procedura ta jest szybsza niż oryginalna wersja algorytmu Euklidesa
ponieważ w dwu krokach dzieli przynajmniej jedną z liczb przez dwa.
Dzieje się tak nawet wtedy gdy a i b są nieparzyste gdyż
odejmując je od siebie dostajemy wynik podzielny przez dwa.
Złożoność algorytmu potęgowania
Literatura: M. Davies, Czym jest obliczanie, w Matematyka Współczesna,
Dwanaście esejów, red. L. A. Steen, WNT, Warszawa, 1983.
Przypomnijmy jak wyglądał algorytm programu Turinga-Posta
podwajania liczby jedynek umieszczonych na tasiemce papierowej
lub magnetycznej. Jak pracuje program podwajania?
1 DRUKUJ 0 (* jeśli jest 1 *)
2 IDŹ W LEWO
3 IDŹ DO KROKU 2 JEŚLI PRZECZYTAŁEŚ 1
4 DRUKUJ 1
5 IDŹ W PRAWO
6 IDŹ DO KROKU 5 JEŚLI PRZECZYTAŁEŚ 1
7 DRUKUJ 1
8 IDZ W PRAWO
9 IDZ DO KROKU 1 JESLI PRZECZYTAŁEŚ 1
10 ZATRZYMAJ SIE
Liczbę jedynek podwaja się poprzez ich kopiowanie jedna za drugą.
Każda jedynka jest chwilowo zastępowana przez zero, które odgrywa role znacznika
miejsca (krok 1). Dalej obliczenia przesuwaja sie na lewo przez wszystkie
jedynki (wraz z tymi, które w procesie obliczeń zostały wydrukowane na nowo),
aż do nieużywanego (pustego) kwadracika (powtarzane kroki 2, 3).
W kroku 4 jedynka zostaje skopiowana. Nastepnie obliczenia idą na prawo aż do
napotkania zera, które zajmuje miejsce właśnie skopiowanej jedynki (kroki 5, 6 -
powtarzane). Skopiowana jedynka jest ponownie zapisana (krok 7). Obliczenia
idą dalej w prawo w poszukiwaniu nowej jedynki, którą trzeba skopiowac (krok 8).
Jesli istnieje nowa jedynka to obliczenia przenosza sie do kroku 1. W przeciwnym
razie idą do kroku 10 i zatrzymują sie (kroki 9, 10).
Algorytm ten jest wygodny dla celów dyskusji
którą chcemy teraz przeprowadzić.
Zauważmy, że algorytm ten i każdy jego fragment, można powtarzać
dowolną liczbę razy dzięki instrukcji IDŹ DO, która
powoduje powrót do wcześniej wykonanego polecenia. Taka sama instrukcja
IDŹ DO pozwala też przeskoczyć do przodu grupę
poleceń.
Widzieliśmy, że kod zero-jedynkowy tego programu (tablica kodowania jest podana
poniżej) ma postać
1 000 010 110 110 001 011 110111110 001 011 11010 100 111
i pomijając znaki przestankowe: 1 na początku i 111 na końcu
oraz nieznaczące znaki puste, które zostawiliśmy dla przejrzystości
zapisu, liczy 41 znaków.
|
Kod | Instrukcja |
| |
000 | DRUKUJ 0 |
001 | DRUKUJ 1 |
010 | IDZ w LEWO |
011 | IDZ w PRAWO |
1010 ... 0i1 | IDZ do KROKU i JESLI PRZECZYTALES 0 |
1101 ... 1i0 | IDZ do KROKU i JESLI PRZECZYTALES 1 |
100 | ZATRZYMAJ SIE (stop=100p) |
| |
|
Zastanowimy się teraz nad problemem jak złożony musi być program
Turinga-Posta, aby w wyniku jego działania otrzymać dany wynik.
Rozpatrzymy tylko przypadek, gdy na taśmie w chwili zakończenia
obliczeń znajdują się co najmniej dwie jedynki. Wynik składa się
wtedy z ciągu zer i ciągu jedynek położonych
pomiędzy skrajnymi jedynkami na taśmie (znakami przestankowymi).
Załóżmy, że jako wynik chcemy otrzymać ciąg 1022 jedynek. Jeśli
dodamy do tego dwie jedynki ograniczające to taśma w chwili
zakończenia obliczeń ma zawierać 1024 jedynki. Reszta taśmy ma
być pusta. Możemy wprost zapisać te 1024 jedynki i uważać, że
zrobiliśmy co należy. Możemy jednak postąpić lepiej. Wystarczy
napisać na taśmie 512 jedynek i uruchomić program podwajania
opisany przed chwilą. Jak wiemy, program ten składa się z 41 bitów
(bit oznacza z ang. binary digit czyli cyfrę dwójkową).
Mamy zatem opis ciągu 1022 jedynek, który używa 41+512=553 bity.
Można postąpić jeszcze lepiej. 1024=210, a więc można zastosować
program podwajania dziesięciokrotnie zaczynając od jednej jedynki.
Poniżej (Davies) przedstawiony jest 22 krokowy program, który
to robi jeszcze inaczej. Pierwszych 9 kroków pokrywa się z programem
podwajania. Proces obliczeń rozpoczyna się od danych
Po wykonaniu obliczeń na taśmie znajdzie się blok 2n+1 jedynek.
1 DRUKUJ 0
2 IDŹ W LEWO
3 IDŹ DO KROKU 2 JEŚLI PRZECZYTAŁEŚ 1
4 DRUKUJ 1
5 IDŹ W PRAWO
6 IDŹ DO KROKU 5 JEŚLI PRZECZYTAŁEŚ 1
7 DRUKUJ 1
8 IDŹ W PRAWO
9 IDŹ DO KROKU 1 JESLI PRZECZYTAŁEŚ 1
10 IDŹ W PRAWO
11 IDŹ DO KROKU 22 JEŚLI PRZECZYTAŁEŚ 0
12 IDŹ W PRAWO
13 IDŹ DO KROKU 12 JESLI PRZECZYTAŁEŚ 1
14 IDŹ W LEWO
15 DRUKUJ 0
16 IDŹ W LEWO
17 IDZ DO KROKU 16 JESLI PRZECZYTAŁEŚ 1
18 IDŹ W LEWO
19 IDZ DO KROKU 18 JESLI PRZECZYTAŁEŚ 1
20 IDŹ W PRAWO
21 IDZ DO KROKU 1 JESLI PRZECZYTAŁEŚ 1
22 ZATRZYMAJ SIE
Opiszmy pokrótce działanie tego programu. Kroki 1-9 to poznany wcześniej
program podwajania. Część ta podwaja liczbę jedynek znajdujących
się na lewo od zera. Kroki 10-21 wymazują jedną jedynkę znajdującą
się na prawo od zera i następuje powrót do kroku 1. Gdy program
wymaże wszystkie jedynki znajdujące się na prawo od zera to w kroku
11 zostanie przeczytane zero. Wtedy nastąpi skok do kroku 22
(do przodu) i program się zatrzyma. Liczba jedynek, które znajdują
się początkowo na lewo od zera, jest podwajana tyle razy ile jedynek
znajduje się początkowo na prawo od tego zera. Kod całego programu
zajmuje 155 bitów. (Zadanie: Sprawdzić to przez kodowanie
zgodne z poprzednio zdefiniowanym.)
Aby otrzymać żądany ciąg 1024 jedynek, potrzebny jest ciąg
danych 10111111111. Prowadzi to do 155+11=166 bitów co stanowi
znaczną poprawę w stosunku do 553 bitów.
Podamy teraz pewną definicję. Przez w oznaczymy dowolny ciąg bitów.
Mówimy, że w posiada złożoność n (lub pojemność
informacji n, co zapisujemy jako I(w)=n, jeżeli
- Istnieje program P i ciąg v, takie, że długość kod(P)
i długość v wynoszą razem n, oraz takie, że jeśli
program P rozpocznie obliczenia na v, to zatrzyma się ostatecznie
z wynikiem w (tzn. z ciągiem 1w1) zajmującym niepustą część
taśmy oraz
- Nie istnieje liczba mniejsza od n, dla której to samo zachodzi.
Jeżeli w oznacza ciąg 1022 jedynek, to pokazaliśmy, że
I(w) ≤ 166. Ogólnie można pokazać, że dla ciągu bitów (zer i
jedynek) o długości n złożoność I(w) ≤ n+9 W
szczególności, jeśli program P składa się tylko z jednej
instrukcji: Zatrzymaj się to ponieważ nic on nie robi, więc
rozpoczynając obliczenia z danymi 1w1 zatrzyma się natychmiast i
pozostawi na taśmie niezmieniony ciąg 1w1. Ponieważ
kod(P)=1100111, więc prawdą jest, że I(w) musi być mniejsze lub
równe długości ciągu 11001111w1, tj. mniejsze lub równe n+9.
Liczba 9 wynika z przyjętej metody kodowania i nie posiada żadnego
teoretycznego znaczenia.
Policzymy teraz ciągi o długości n takie, że I(w) ≤ n−10?
(Zakładamy n > 10.) Każdy taki ciąg jest związany z programem P
i ciągiem danych v takich, że kod(P)v jest ciągiem bitów o
długości mniejszej lub równej n−1. Ponieważ łączna liczba
ciągów bitów o długości i wynosi 2i, to istnieje tylko
ciągów bitów o długości ≤ n−10. Jak
łatwo policzyć jest to liczba równa 2n−9−2 (suma ciągu
geometrycznego). Ponieważ istnieje 2n ciągów bitów o długości
n to stosunek liczby ciągów o długości n i o złożoności
≤ n−10, do liczby wszystkich ciągów o długości n, nie
przekracza liczby
Jest to mniej niż 0.2%, a więc więcej niż 99.8% wszystkich
ciągów o długości n ma złożoność I(w) > n−10.
Literatura: W. M. Turski, Propedeutyka informatyki, Warszawa, 1989
n - charakterystyczny rozmiar zadania. Często, zamiast trudnej do
ustalenia dokładnej złożoności obliczeniowej wprowadza się
oszacowanie w postaci pewnej funkcji f(n), takiej że ∃n0∀n > n0 {LA(n) ≤ f(n)}.
W roli f(n) występują takie funkcje jak n, n3/2. ...,
logn, nlogn, ..., 2n,..., pomnożone czasami przez
stałe współczynniki. Ponieważ zależy nam na tym by jak najlepiej
oszacować złożoność obliczeniową danego zadania, to spośród
funkcji, które można przyjąć za oszacowanie wybieramy tę,
której wzrost wraz z n jest najwolniejszy. Jeżeli od pewnego n0
zachodzi LA ≤ n, a od pewnego n1 mamy LA ≤ n2 to za
f(n) bierzemy n.
Mówimy: złożoność obliczeniowa jest rzędu f(n) (np. mnożenie
liczb posiada złożoność rzędu n2, a dodawanie rzędu n).
Znajomość złożoności obliczeniowej danego algorytmu pozwala
oszacować czas wykonywania zadania.
Przykład.
Jak już wiemy, ciągiem Fibonacciego nazywamy ciąg liczb zadany
zależnościami:
f1=0 , f2=1 , fi=fi−1+fi−2 , dla i > 2. |
| (1) |
Problem polega na znalezieniu n-tego wyrazu tego ciągu. Za rozmiar
charakterystyczny można przyjąć numer n wyrazu.
Algorytm 1
Wykorzystujemy bezpośrednio związek rekurencyjny (!) (1).
Obliczamy f1, f2 i dalej kolejne wyrazy ciągu, aż do chwili gdy
obliczymy interesujący nas wyraz n-ty. Wykonanie jednego kroku polega na
wykonaniu jednego dodawania. Złożoność algorytmu jest więc rzędu n.
Algorytm 2 (patrz : Turski)
Można sprawdzić (przez indukcję), że
Rozwinięcie dwójkowe liczby n jest:
n=cl 2l+cl−12l−1+...+c12+c0 , |
| (3) |
gdzie każde ci jest równe 0 lub 1.
Krok przygotowawczy algorytmu polega na znalezieniu współczynników
rozwinięcia (3) i ustaleniu bazy dalszych obliczeń
W kolejnych krokach (i=1,...,l) postępujemy zgodnie z następującym
schematem.
- Z wzorów (2) obliczamy nową bazę
gdzie numery k i k+1 są numerami wyrazów ciągu Fibonacciego
uznanych za poprzednią bazę.
- Sprawdzamy jaki jest współczynnik cl−i rozwinięcia dwójkowego
(3). Jeśli cl−i=0 to kończymy krok i-ty, w przeciwnym
wypadku obliczamy nową bazę
wykorzystując związek
(patrz definicja ciągu Fibonacciego (1)).
Znaleziona baza stanowi podstawę wykonania następnego kroku ((i+1) -szego).
Jeśli i=l to algorytm jest zakończony, a obliczony w nim pierwszy element
bazy, b1 jest poszukiwanym wyrazem ciągu kn.
Przykład.
Znajdziemy f23. Mamy 23=1·24+0·23+1·22+ 1·21+1, a więc c4=1 , c3=0 , c2=1 , c1=1 , c0=1 oraz l=4.
A oto kolejne kroki.
Krok 0
Krok 1
Krok 2
Krok 3
Krok 4
b2=f23=(2f11+f12)f12=17711 , |
|
c0=1 , f24=f23+f22=28657 , |
|
Wynik f23(=b1)=17711.
Zadanie 1: Złożonośc algorytmu 2.
Pokazać metodą indukcji słuszność algorytmu 2 znajdowania wyrazów ciągu
Fibonacciego.
Złożoność obliczeniowa algorytmu 2 jest rzędu logn, a więc jest
dużo mniejsza od złożoności obliczeniowej algorytmu 1. Wykonanie algorytmu
2 przebiega w l-krokach, przy czym l ≈ log2 n, a na każdym kroku
wykonuje się 4 mnożenia i 3 dodawania. Czynności wstępne polegające na
znalezieniu rozwinięcia liczby n wymagają również rzędu log2n
operacji, bo wykonuje się je znajdując reszty z l+1 dzieleń przez dwa
liczby n. Stąd liczba wszystkich operacji jest rzędu 8log2n
Do tego trzeba dodać działania organizacyjne związane z przezywaniem
bazy itd. Złożoność algorytmu drugiego jest więc znacznie mniejsza
niż pierwszego.
Jedyna komplikacja, która pojawia się w algorytmie 2, polega na zwiększeniu
liczby obiektów, na których operuje algorytm. Jest to często koszt jaki
należy ponieść przy zmniejszaniu liczby operacji.
Jeszcze o złożoności. Przeszukiwanie tablic.
Rozpatrzmy problem znajdowania numeru elementu X z tablicy A[1..N].
Schemat Nassi-Schneidermana algorytmu wygląda następująco:
+-------------------------------------+
| i:=1; |
|-------------------------------------|
| X <> A[i] |
| |
| +-----------------------------|
| | i:=i+1; |
| | |
+-------------------------------------|
| Wypisz(i); |
+-------------------------------------+
Ile razy wykonywana jest instrukcja dopóki-dopóty?
Jeśli nic nie wiemy o ciągu elementów z A to pętla wykonuje
się "jakąś" liczbę razy, która zawarta jest między zerem,
a N−1. Średnia liczba (przy dużej liczbie przebiegów programu)
jest równa (N−1)/2=N/2−1/2 ≈ N/2. Algorytm, który
tu zastosowaliśmy jest liniowy. Czas jego wykonania
jest liniową funkcją N.
Załóżmy teraz, że elementy A są uporządkowane rosnąco.
Algorytm przeszukiwania binarnego polega na tym, że proces
przeszukiwania rozpoczynamy od porównania X z elementem środkowym
tablicy. Jeśli są one jednakowe to proces kończy się. Jeśli
X jest mniejszy od elementu środkowego powtarzamy przeszukiwanie
w tablicy A[1..N/2]. Jeśli X jest większy od elementu
środkowego przeszukujemy część górną: A[N/2+1..N].
Czynimy to aż znajdziemy numer X. Liczba
połowień jest w przybliżeniu równa log2N. Algorytm
przeszukiwania binarnego jest więc algorytmem logarytmicznym.
Przedstawmy to w tablicy.
| | |
N | N/2 | ⎡log2N ⎤ |
| | |
10 | 5 | 4 |
100 | 50 | 7 |
1 000 | 500 | 10 |
10 000 | 5 000 | 14 |
100 000 | 50 000 | 17 |
1 000 000 | 500 000 | 20 |
Wielkość ⎡log2N ⎤ oznacza funkcję sufit,
która daje wartość argumentu zaokrągloną do najbliższej
większej liczby całkowitej.
Widzimy, że dla małych N różnica czasów jest niewielka,
lecz dla dużych rośnie ona bardzo szybko. Algorytm N/2
nie ma szans przy N rzędu miliona. Złożoność opisanych
algorytmów oznaczamy przez Θ(N) oraz Θ(logN).
Zadanie 2: Grafiki złożoności.
Narysować grafiki Θ(f(N)) dla algorytmów:
stałego Θ(k), logarytmicznego Θ(logN), liniowego Θ(N),
N logN, kwadratowego Θ(N2), sześciennego Θ(N3)
i eksponencjalnego Θ(2N) dla N=1...200.
Przykład.
Załóżmy, że mamy wybrać algorytm sortowania. Mamy do dyspozycji
algorytm A, którego czas porządkowania wynosi 5N2μs
i algorytm B, którego czas pracy jest dany formułą:
50NlogNμs. Czynnik 50N przed logarytmem wskazuje,
że algorytm B jest bardziej skomplikowany niż A, który przed N2
ma czynnik 5.
| | |
Tablica | 5N2μs | 50 N logNμs |
| | |
| | |
10 | 0.0005 s | 0.002 s |
100 | 0.05 s | 0.03 s |
1 000 | 5 s | 0.5 s |
10 000 | 500 s | 6.6 s |
100 000 | 50 000 s=14 h | 83 s |
1 000 000 | 5×106 s = 58 d | 993 s=17 min |
| | |
Zadanie 3: Arytmetyka Θ.
Można udowodnić słuszność następujących reguł arytmetyki Θ:
W wyrażeniach poniżej wstawić w miejscu znaku
? znak > , <
lub = .
- Θ(5) ? Θ(23)
- Θ(N+M) ? Θ(N−15/2)
- Θ(logN) ? Θ(N)
- Θ(N2+N) ? Θ(N3)
- Θ(N logN+N3) ? Θ(2N)
- Θ(2N) ? Θ(5N)
- Θ(logN) ? Θ(log10N)
I jeszcze ... sortowanie przez wstawianie
Literatura: Cormen, Leiserson, Rivest, Wprowadzenie do algorytmów,
WNT, Warszawa, 1998, wydanie II.
Rozważmy algorytm porządkowania ciągu liczb zwany sortowaniem przez
wstawianie.
Problem
Dane wejściowe: Ciąg n liczb: A= < a1,a2,a3, ... an > .
Wynik: Spermutowany ciąg < a1′,a2′,a3′, ... an′ >
otrzymany z A taki, że a1′ ≤ a2′ ≤ a3′... ≤ an′.
Sortowania dokonamy za pomocą następującego algorytmu.
Sort_wstaw(A)
1 for j <- 2 to length[A]
2 do klucz <- A[j]
3 (* wstaw A[j] w posortowany ciąg A[1, 2, ... , n] *)
4 i <- j-1
5 while i>0 and A[i]>klucz
6 do A[i+1] <- A[i]
7 i <- i-1
8 A[i+1] <- klucz
Poniższy, konkretny przykład zilustruje działanie programu.
Przykład.
Niech A= < 5, 2, 4, 6, 1, 3 > . Działanie algorytmu Sort_wstaw
sprowadza się do wykonania następujących kroków.
5 | 2 | 4 | 6 | 1 | 3 |
2 | 5 | 4 | 6 | 1 | 3 |
2 | 4 | 5 | 6 | 1 | 3 |
2 | 4 | 5 | 6 | 1 | 3 |
2 | 4 | 5 | 6 | 1 | 3 |
1 | 2 | 4 | 5 | 6 | 3 |
1 | 2 | 3 | 4 | 5 | 6
|
Pozycje o indeksie i zostały wyróżnione. W każdym następnym kroku
algorytm powoduje przeniesienie wyróżnionej liczby w odpowiednie miejsce
w ciągu.
Czas działania algorytmu zależy od liczby elementarnych operacji
potrzebnych do jego wykonania.
W celu oszacowania go założymy, że czas wykonania pojedynczego
wiersza algorytmu jest zawsze jednakowy i wynosi ci.
Przez tj oznaczymy liczbę sprawdzeń warunku wejścia do pętli
while.
Możemy sporządzić następującą tablicę kosztów.
| | |
Instrukcja (wiersz) | Koszt | Liczba wykonań |
| | |
| | |
1 for j <- 2 to length[A] | c1 | n |
2 do klucz <- A[j] | c2 | n−1 |
3 (* ... *) | c3=0 | n−1 |
4 i <- j-1 | c4 | n−1 |
5 while i>0 and A[i]>klucz | c5 | ∑j=2n tj |
6 do A[i+1] <- A[i] | c6 | ∑j=2n (tj−1) |
7 i <- i-1 | c7 | ∑j=2n (tj−1) |
8 A[i+1] <- klucz | c8 | n−1 |
| | |
Czas działania całego algorytmu jest sumą czasów cząstkowych.
| |
|
c1n+c2(n−1)+0(n−1)+c4(n−1)+c5 |
n ∑
j=2
|
tj |
| |
| |
|
+c6 |
n ∑
j=2
|
(tj−1)+c7 |
n ∑
j=2
|
(tj−1)+c8(n−1) . |
| |
|
Oszacujmy minimalny czas działania algorytmu. Dzieje się to w przypadku
posortowanego ciągu A. Dla każdego j=2, 3, ..., n w wierszu 5
mamy wtedy A[i] ≤ klucz dla każdego początkowego i równego j−1.
Zatem tj=1 dla j=2, 3, ..., n i minimalny czas działania jest równy
| |
|
c1n+c2(n−1)+c4(n−1)+c5(n−1)+c8(n−1) |
| |
| |
|
(c1+c2+c4+c5+c8)n−(c2+c+c4+c5+c8) |
| |
| |
|
| |
|
gdzie a i b są stałymi.
Widzimy, że Θ(n) jest liniową funkcją n, liczby elementów ciągu.
Najgorsza sytuacja ma miejsce gdy tablica jest posortowana odwrotnie.
Musimy wtedy porównać każdy element A[j] tablicy z elementami podtablicy
A[1... j−1], a więc tj=j dla j=2, 3, ..., n. Ponieważ
więc
| |
|
c1n+c2(n−1)+c4(n−1)+c5[n(n+1)−1] |
| |
| |
|
+ c6[n(n−1)/2]+c7[n(n−1)/2+c8(n−1) |
| |
| |
|
| |
| |
|
+ (c1+c2+c4+c5/2−c6/2+c7/2+c8)n−(c2+c4+c5+c8) |
| |
| |
|
| |
|
Tym razem otrzymaliśmy funkcję kwadratową względem n.
W realnych przypadkach czas wykonania algorytmu leży gdzieś pomiędzy
czasem optymistycznym i pesymistycznym. Jest on najczęściej
zbliżony do czasu pesymistycznego.
Odrzucając w Θ(n) wszystkie człony oprócz wiodącego otrzymamy
to co nazywamy złożonością algorytmu. Dla naszego przypadku mamy więc
złożoność Θ(n2).
... sortowanie przez scalanie
W przypadku sortowania przez wstawianie stosowaliśmy metodę przyrostową:
mając posortowaną tablicę A[1... j−1] wstawialiśmy pojedynczy element
A[j] we właściwe miejsce otrzymując większą tablicę A[1...j].
Rozpatrzymy teraz inną metodę zwaną dziel i zwyciężaj.
Algorytmy, które stosują tę metodę mają najczęściej strukturę
rekurencyjną. W metodzie tej problem dzielony jest na mniejsze problemy
podobne do poprzedniego, problemy te są rozwiązywane rekurencyjnie,
a następnie ich rozwiązania są łączone w rozwiązanie pełnego
problemu.
Opis algorytmu sortowania przez scalanie jest następujący.
- Dzielimy ciąg n elementowy na dwa podciągi o n/2 elementach każdy (DZIEL).
- Sortujemy je rekurencyjnie przez scalanie (ZWYCIĘŻAJ).
- Łączymy posortowane podciągi w jeden posortowany ciąg (POŁĄCZ).
W kroku trzecim potrzebna będzie procedura, która łączy dwa podciągi
posortowane A[p... q] i A[q+1... r] w jeden A[p... r].
Nazwiemy ją Merge. Jej algorytm wygląda tak
Merge(A, p, q, r)
1 i <- p; j <- q+1; (* wskaźniki do el A *)
2 while i<= q or j <= r
3 do
4 if C <= A[j]
5 i <- i+1
6 else przesuń(A, i, j)
7 A[i] <- C
8 i <- i+1
9 j <- j+1
gdzie przesun(A,i,j) przemieszcza elementy
części tablicy A[i..j-1] w prawo o 1 miejsce. W powstałą
lukę wstawiana jest następnie zapamiętana wcześniej
wartość C (niszczona w procesie przesuwania).
Np.
for l:=j downto i+1 do A[l]:=A[l-1] |
.
A oto dpowiedni program w Pascalu.
procedure Merge(var A: List; p, q, r: integer);
(*
Sklada dwie uporzadkowane czesci tablicy A
w jedna uporzadkowana calosc;
A[p..q] -lewa; A[q+1..r] - prawa
*)
var i, j, l: integer;
C: integer;
begin
i:=p; (* wskaznik do czesci lewej *)
j:=q+1; (* wskaznik do czesci prawej *)
while (i <= p) or (j <= r) do begin
C := A[j];
if A[i] <= C then begin
i := i+1;
end
else begin
(* przesun w prawo o 1 poczynajac od i *)
for l:=j downto i+1 do A[l]:=A[l-1];
A[i]:=C;
i:=i+1;
j:=j+1;
end;
end;
end;
|
Procedura Merge działa w miejscu, tzn
korzysta tylko z tablicy A. Dlatego potrzebujemy
przesuwania, które zajmuje sporo (!) czasu obliczeń.
Efektywnywniejszy czasowo, ale rozbudowany algorytm
otrzymamy jeśli wyniki scalania będziemy umieszczać
w posredniej tablicy B.
procedure Merge(var A, B: List; p, q, r: integer);
(*
Sklada dwie uporzadkowane czesci tablicy A
w jedna uporzadkowana calosc B[p..r];
A[p..q] - lewa; A[q+1..r] - prawa
*)
var i, j, k: integer;
begin
i:=p; (* wskaznik do czesci lewej *)
j:=q+1; (* wskaznik do czesci prawej *)
k:=1; (* wskaznik do el. tablicy B *)
while (i <= q) and (j <= r ) do begin
if A[i] < A[j] then begin
B[k]:=A[i];
i := i+1;
end
else begin
B[k] := A[j];
j:=j+1;
end;
k:=k+1;
end;
(* Kopiowanie uporzadkowanej reszty tablicy A *)
if i > q then (* wyczerpano liste z lewej; przepisuj prawa czesc *)
repeat
B[k] := A[j];
j := j+1;
k := k+1;
until k>r
else
repeat
B[k] := A[i];
i := i+1;
k := k+1;
until k>q;
end;
|
Następną procedurą jest Merge_Sort(A, p, r). Procedura ta sortuje
elementy w podtablicy A[p... q]. Kiedy p ≥ r podciąg jest posortowany.
W przeciwnym razie znajdujemy q, które dzieli ją na dwie podtablice
A[p... q] o ⎡n/2⎤ elementach i drugą A[q+1... r] o
⎣n/2⎦ elementach.3
Merge_Sort(A, p, r)
1 if p<r
2 then q -> floor(p+r)/2
3 Merge_Sort(A, p, q)
4 Merge_Sort(A, q+1, r)
5 Merge(A, p, q, r)
W celu posortowania ciągu A= < A[1], A[2], ..., A[n] > należy wywołać
procedurę Merge_Sort(A, 1, length(A)) gdzie length(A)
oznacza długość A i wynosi n.
Drzewo sortowań (przypadek n=2m, gdzie m=3) pokazane jest na rysunku.
Złożoność algorytmu sortowania przez scalanie
Algorytm sortowania przez scalanie jest algorytmem rekurencyjnym.
Jego czas działania można więc zapisać jako rekurencyjną
zależność zawierającą czas realizacji podproblemów mniejszych.
Jeśli problem został podzielony na a mniejszych podproblemów
o rozmiarach n/b każdy to czas jego wykonania jest dany zależnością
Tutaj Θ(1) jest w przybliżeniu stałym czasem wykonania
elementarnego podproblemu o rozmiarze n ≤ c,
D(n) jest czasem dzielenia problemu, a S(n) jest czasem scalania
podproblemów.
Dla algorytmu Sort (przy założeniu, że n=2m) mamy
D(n)=Θ(1), S(n)=Θ(n),
a T(n)=Θ(1) dla n=1 oraz T(n)=Θ(n/2)+Θ(n) dla n > 1.
Można pokazać, że rozwiązaniem tego problemu rekurencji jest
Ponieważ algorytm sortowania przez wstawianie ma czas pesymistyczny
Θ(n2), wiąc widzimy, że algorytm sortowania przez scalanie
jest szybszy gdyż
Koniec.
Zadanie 4: Złożoność rekurencji.
Pokazać stosując indukcję matematyczną, że rozwiązaniem równania
rekurencyjnego
jest T(n)=n log2 n.
Zadanie 5: Złożoność sortowania przez wstawianie.
Napisz równanie rekurencyjne na czas sortowania przez wstawianie.
Zadanie 6: Porównanie złożoności.
Jaka jest najmniejsza wartość n, dla której algorytm wykonujący
100 n2 operacji działa szybciej niż algorytm wykonujący 2n operacji
na tym samym komputerze?
Problem
Dla funkcji f(n) i czasu t z poniższej tabeli wyznacz największy
rozmiar n problemu, który może być rozwiązany w czasie t,
zakładając, że algorytm działa w czasie f(n) μsekund.
f \ t | 1 sec | 1 min | 1 godz | 1 dzień | 1 miesiąc | 1 rok | 1 wiek |
log2 n | ? | ? | ? | ? | ? | ? | ? |
√n | ? | ? | ? | ? | ? | ? | ? |
n | ? | ? | ? | ? | ? | ? | ? |
n log2 n | ? | ? | ? | ? | ? | ? | ? |
n2 | ? | ? | ? | ? | ? | ? | ? |
n3 | ? | ? | ? | ? | ? | ? | ? |
2n | ? | ? | ? | ? | ? | ? | ? |
n! | ? | ? | ? | ? | ? | ? | ? |
Footnotes:
1W zapisie
jaki tu stosujemy konstrukcja w razie gdy b=0 -> a oznacza, że
ten fragment programu daje wynik a jeżeli b=0.
2Fibonacci, właściwie Filius
Bonacci czyli syn Bonacciego. Studiował Al Chorezmiego.
W roku 1202 opublikował
Liber abaci, w której to książce rozpatrywał m. in. problem
rozmnażania się królików, prowadzący do liczb jego imienia.
3Wielkość ⎡x⎤ oznacza najmniejszą
liczbę całkowitą nie mniejszą niż x (ceiling(x) lub sufit(x)),
a ⎣x⎦ oznacza największą liczbę całkowitą nie większą niż x
(floor(x) lub podłoga(x)).
File translated from
TEX
by
TTH,
version 4.08.
On 11 Jan 2016, 16:12.