Nieformalnie, algorytm
jest skończonym, uporządkowanym zbiorem
ściśle określonych operacji, które prowadzą do
rozwiązania zadanego problemu obliczeniowego.
Algorytm przekształca dane wejściowe
zdefiniowane w opisie problemu w dane wyjściowe
stanowiące jego rozwiązanie.
Algorytmy można przedstawiać za pomocą języka naturalnego
lub różnych notacji formalnych.
W niniejszej książce będziemy korzystać
z pseudokodu
(rys. 1.1), który umożliwia opis algorytmu
na różnych poziomach abstrakcji, czyli z pominięciem większej
lub mniejszej liczby szczegółów.
Operacje algorytmu powinny być zdefiniowane na tyle precyzyjnie,
aby nie budziły wątpliwości w czasie ich implementacji.
Algorytm zazwyczaj cechuje ogólność.
Na przykład, zamiast projektować algorytm rozwiązujący
konkretne równanie 2?x2
-13?x+5
=0
, możemy opracować
algorytm dla ogólnej klasy równań kwadratowych
a?x2
+b?x
+c
=0
, przy założeniu, że współczynniki
a, b i c będą danymi wejściowymi.
Graficznym sposobem przedstawienia algorytmu jest schemat
działania, zwany również schematem blokowym lub schematem
przepływu sterowania (ang. flowchart) (rys. 1.2).
Tabela 1.1: Przykładowa lista instrukcji procesora.
Argument a może
mieć postać =i, i lub ?i, gdzie i oznacza liczbę całkowitą,
w?(a) oznacza wartość argumentu a zdefiniowaną następująco:w(
=i)
=i,
w?(i)=Mi,w(?i)=MMi,
gdzie '?' oznacza adresowanie
pośrednie, w którym argument jest pobierany z komórki pamięci o adresie zawartym
w komórce Mi.
Argument b może mieć postać i lub ?i, o znaczeniach jak powyżej,
k jest indeksem rejestru arytmetycznego.
Argument c może mieć postać =l lub l, gdzie w?(c) jest
zdefiniowane jako:
w(
=l)
=l oraz w?(l)=Rl
Operacja
Argument
Znaczenie
LOAD
k,a
Rk
:= w?(a)
STORE
k,b
Mw?(b) := Rk
ADD
k,c
Rk
:= Rk+w?(c)
SUB
k,c
Rk
:= Rk-w?(c)
MULT
k,c
Rk
:= Rk×w
?(c)
DIV
k,c
Rk
:= ?Rk/w
?(c)?
JUMP
i
L
:= i
JPOS
k,i
if Rk>0 then L
:= i
else L
:= L+1
JZERO
k,i
if Rk=0 then L
:= i
else L
:= L+1
JNEG
k,i
if Rk<0 then L
:= i
else L
:= L+1
READ
k
Wczytaj daną z urządzenia zewnętrznego
do rejestru Rk
WRITE
k
Wypisz daną zawartą w rejestrze Rk
na urządzeniu zewnętrznym
HALT
Zakończ obliczenia
Model obliczeń sekwencyjnych
Zanim przedstawimy model obliczeń sekwencyjnych,
pokrótce omówimy, jak obecnie działają programy tego typu.
Współczesne komputery wyposażone są w procesory, które wykonując
program sekwencyjny, mogą realizować niektóre
operacje programu równolegle.
Przykładowo, rozważmy następujące instrukcje:
x:=a+b;
(1.1)
y:=c?d.
W wykonaniu sekwencyjnym tych instrukcji najpierw przebiega
operacja dodawania, a po jej zakończeniu, po przypisaniu
wyniku do zmiennej x, wykonywana jest operacja mnożenia
i przypisanie wyniku do zmiennej y.
Instrukcje te mogą być wykonane równolegle, ponieważ
współczesne procesory potrafią przetwarzać więcej
niż jedną instrukcję w jednym cyklu pracy
(ang. instruction-level parallelism).
W tym celu procesory wyposaża się w odpowiednią liczbę
jednostek funkcjonalnych, które umożliwiają potokowe
wykonanie instrukcji
(ang. pipelining).
Załóżmy, że proces wykonywania instrukcji składa się z sześciu
kolejno wykonywanych operacji: pobrania instrukcji z pamięci,
jej dekodowania, wyznaczenia adresów argumentów,
pobrania argumentów, wykonania operacji określonej przez kod
instrukcji oraz zapisania otrzymanego wyniku do pamięci.
Ciąg tych operacji tworzy potok.
Poszczególne operacje w potoku nazywamy etapami,
zatem rozważamy potok 6-etapowy.
Dla uproszczenia przyjmujemy, że każdy etap potoku
wykonany jest w jednym cyklu pracy
procesora2.
Załóżmy, że procesor wyposażony jest w jednostki
funkcjonalne J1, J2, ..., J6, z których
każda odpowiada za wykonanie jednego etapu potoku.
Wykonanie potokowe kolejnych instrukcji pokazano
na rys. 1.4.
W pierwszym cyklu pracy jednostka J1 pobiera
z pamięci instrukcję i1 i przekazuje ją
jednostce J2 do dekodowania.
Rysunek 1.4: Potokowe wykonywanie strumienia instrukcji:
i1, i2, i3, ..., itd.
W drugim cyklu jednostka J2 dekoduje instrukcję i1,
a J1 pobiera instrukcję i2.
Pod koniec cyklu jednostki J1 i J2
przekazują wyniki swego działania odpowiednio do
jednostek J2 oraz J3, i tak dalej.
Zauważmy, że przy ciągłym strumieniu instrukcji kolejne
z nich opuszczają potok w każdym cyklu pracy procesora.
Na przykład, na rys. 1.4 instrukcja i1 jest
wykonana w cyklu 6, instrukcja i2 w cyklu 7, i tak dalej.
Wykonując instrukcje (1.1) sekwencyjnie,
potrzebowalibyśmy 2?6
=12
cykli.
Dalszy wzrost szybkości obliczeń umożliwiają procesory
superskalarne,
które pozwalają na jednoczesne wykonywanie wielu instrukcji w jednym cyklu pracy.
W takich procesorach zwielokrotnia się jednostki
funkcjonalne, na przykład arytmetyczno-logiczne dla liczb
zmiennopozycyjnych i całkowitych, a także inne
wyspecjalizowane jednostki.
Ważnym elementem procesora superskalarnego jest
specjalna jednostka pobierania instrukcji,
zdolna do pobierania z pamięci wielu instrukcji jednocześnie,
pracując być może w sposób potokowy.
Jednostka ta przekazuje instrukcje do jednostki
dekodującej, która określa, czy instrukcje
mogą być wykonane jednocześnie (czyli są niezależne),
czy też istnieją między nimi zależności uniemożliwiające
ich równoległe wykonanie.
Jednym z głównych problemów projektowania architektur
komputerów jest różnica w szybkości działania procesorów
i pamięci operacyjnej.
Współczesne procesory są znacznie szybsze niż pamięć
główna.
Rozwiązaniem tego problemu jest zastosowanie hierarchicznego
systemu pamięci, składającego się z pamięci głównej oraz
dwóch pamięci podręcznych
L1 i L2 (ang. cache memory)3.
Pamięci podręczne mają znacznie mniejszą objętość od
pamięci głównej, ale są wielokrotnie szybsze.
Niższy poziom (L1) jest szybszy, ale mniejszy od
poziomu wyższego (L2), który jest nieco wolniejszy, ale ma większą pojemność.
Szybsze przetwarzanie danych przy użyciu pamięci
podręcznych jest możliwe dzięki zjawisku
lokalności odwołania.
Jeśli w programie wystąpiło odwołanie do pewnej komórki
pamięci, to istnieje duże prawdopodobieństwo, że
w niedalekiej przyszłości nastąpi kolejne odwołanie do tej
samej lub sąsiadującej komórki.
Wówczas do pamięci podręcznej przenoszony jest
cały blok komórek zawierający adres wykorzystywany
aktualnie przez program.
Pamięci podręczne przechowują instrukcje oraz dane
programu, które mogą być z nich pobierane szybciej niż z pamięci
głównej, a równocześnie
z wszystkich modułów podręcznych, co dodatkowo przyspiesza obliczenia.
W tej książce będziemy określać złożoność czasową algorytmów,
rozumianą jako liczbę operacji
elementarnych
wykonywanych przez algorytm, aby rozwiązać dany problem.
Jak pokazaliśmy wcześniej, wiele operacji może być wykonanych
równolegle dzięki potokom i zwielokrotnionym jednostkom funkcjonalnym.
Dzięki pamięci podręcznej procesor może jednocześnie pobierać
dane z różnych poziomów pamięci.
Czy da się to uwzględnić w wyznaczaniu liczby operacji elementarnych?
W praktyce jest to bardzo trudne, a na ogół - niemożliwe.
Komplikację stanowi również to, że nie wiemy, na
jakim komputerze algorytm będzie wykonywany.
Architektury komputerów, a w szczególności procesory,
są dziś bardzo zróżnicowane.
Producenci sprzętu konkurują, tworząc coraz lepsze rozwiązania
o wyższej mocy obliczeniowej.
Podsumowując, aby możliwe było precyzyjne określanie
złożoności czasowej algorytmów, konieczne jest przyjęcie
prostego, ale uniwersalnego modelu obliczeń sekwencyjnych.
Model to obiekt teoretyczny lub fizyczny, którego analiza
pozwala lepiej zrozumieć dane zjawisko lub proces.
Model upraszcza rzeczywistość, uwzględniając jedynie jej
najważniejsze cechy.
Dzięki temu jest łatwiejszy do analizy niż oryginalny
system.
Nas interesują modele komputerów, które
umożliwiają analizę procesów obliczeniowych.
Są one znane jako
modele obliczeń
i stosuje się je zarówno do analizy, jak i projektowania algorytmów,
oraz określania ich złożoności.
Najpowszechniej akceptowanym
modelem
obliczeń sekwencyjnych jest maszyna o swobodnym dostępie do pamięci
(ang. random access machine, RAM).
Model
RAM4
składa się z procesora oraz pamięci złożonej
z potencjalnie nieograniczonej liczby komórek pamięci
oznaczanych jako Mi, i=1
, 2,
3, ...(rys. 1.5).
Każda komórka pamięci, identyfikowana przez swój adres i,
może przechowywać dowolną, skończoną liczbę całkowitą
w zapisie binarnym, nawet bardzo dużą.
Zakłada się, że czas odczytu lub zapisu wartości z lub
do komórki Mi jest niezależny od jej
adresu i stały, co implikuje dostęp
swobodny, w odróżnieniu
od dostępu cyklicznego,
charakterystycznego dla pamięci dyskowych.
Procesor
realizuje instrukcje z określonego, skończonego zbioru,
przypominające te spotykane we współczesnych komputerach
(tab. 1.1).
W celu realizacji instrukcji, procesor zawiera zbiór
rejestrów arytmetycznych
R1, R2, ..., Rq, gdzie q>1
,
które mogą przechowywać -
podobnie jak komórki pamięci Mi - dowolne, skończone liczby
w zapisie binarnym.
Dodatkowo, procesor jest wyposażony
w rejestr sterujący L, zwany licznikiem
instrukcji.
Po wykonaniu każdej instrukcji, z wyjątkiem
JUMP, JPOS, JZERO oraz JNEG, jego wartość zwiększa
się o 1.
Dla tych instrukcji licznik przyjmuje nową
wartość lub zwiększa się o 1, zgodnie z warunkiem
instrukcji.
Rysunek 1.5: Model obliczeń sekwencyjnych RAM
Program RAM
to ciąg instrukcji sterujących pracą procesora.
Zapisywany jest w jednostce sterującej modelu,
a nie w pamięci, co oznacza, że nie może być modyfikowany
w czasie wykonywania.
Podczas analizowania programu RAM wygodnie jest przyjąć,
że składa się on
z kroków obliczeniowych,
z których każdy podzielony jest na trzy fazy.
Dla analizy programu RAM przyjmujemy,
że jest on złożony z kroków obliczeniowych,
z których każdy składa się z trzech faz:
(1) pobrania argumentów operacji z pamięci do rejestrów,
(2) wykonania operacji (arytmetycznej, logicznej,
sterującej lub wej/wyj),
(3) zapisania wyniku operacji do określonej komórki
pamięci.
Niektóre fazy kroku obliczeniowego mogą być pominięte,
jeśli są niepotrzebne w danej instrukcji.
Przykład 1.1.
Problem obliczenia sum prefiksowych polega na wyznaczeniu,
dla zadanego ciągu elementów?a1
, a2, ...,
an?oraz operatora "+", następujących wartości
zwanych
prefiksami:
s1=a1
,
s2=a1+a2,
s3=a1+a2+a3,
...
sn=a1+a2+a3+...+an.
Przyjmijmy, że elementy ai są liczbami.
Na rysunku a przedstawiono program RAM,
który przekształca ciąg?a1
, a2, ...,
an?w ciąg sum prefiksowych?s1
, s2, ...,
sn?
.
W komórce M1 zapisany jest
rozmiar ciągu wejściowego n, n>0
,
a same elementy ai przechowywane są
w komórkach M5, M6, ..., Mn+4.
Komórki M2, M3 i M4 pełnią funkcję pomocniczą.
(a)
1 LOAD 1,=5 {krok 1}
14 ADD 1,2
2 STORE 1,2
15 STORE 1,?
3
3 LOAD 1,=6 {krok 2}
16 LOAD 1,2 {krok 7}
4 STORE 1,3
17 ADD 1,=1
5 LOAD 1,1 {krok 3}
18 STORE 1,2
6 ADD 1,=4
19 LOAD 1,3 {krok 8}
7 STORE 1,4
20 ADD 1,=1
8 LOAD 1,4 {krok 4}
21 STORE 1,3
9 LOAD 2,3
22 LOAD 1,4 {krok 9}
10 SUB 1,2
23 LOAD 2,3
11 JNEG 1,26 {krok 5}
24 SUB 1,2
12 LOAD 1,?
2 {krok 6}
25 JUMP 11 {krok 10}
13 LOAD 2,?
3
26 HALT {krok 11}
M_{1}M1
M_{2}M2
M_{3}M3
M_{4}M4
M_{5}M5
M_{6}M6
...
M_{n+4}Mn+4
n
pom.
pom.
pom.
a1
a2
...
an
Rysunek 1.6: (a) Program RAM obliczania sum prefiksowych;
(b) przyporządkowanie zmiennych do komórek pamięci, gdzie
pom. oznacza komórkę pomocniczą
1 M2 := 5 {ustal początkowe adresy}
2 M3 := 6 {elementow ai-1 oraz ai}
3 M4 := M1+4
{ostatni adres elementu ai}
4 R1 := M4-M35 if R1<0
then go to 11
6 MM3 := MM2+MM3
{ai=ai-1+ai}
7 M2 := M2+1
{zwiększ adresy elementow}
8 M3 := M3+1
{ai-1 oraz ai}
9 R1 := M4-M310 go to 5
11 stop {koniec obliczen}
Rysunek 1.7: Program obliczania sum prefiksowych zapisany na
nieco wyższym poziomie abstrakcji w porównaniu do programu
RAM z rys. 1.6a. Każdy krok programu składa
się z trzech faz: pobrania argumentów, wykonania operacji
oraz zapisania wyniku, przy czym niektóre fazy mogą być pominięte
Programy RAM,
takie jak na rys. 1.6a,
są precyzyjne i bliskie językowi maszynowemu,
ale mało przyjazne dla użytkownika.
Aby poprawić czytelność, można je przedstawić
na wyższym poziomie abstrakcji z wykorzystaniem kroków
obliczeniowych (rys. 1.7).
Przy analizie złożoności programu zakładamy, że każdy krok
obliczeniowy trwa tyle samo -
umowną jednostkę czasu.
Zwróćmy uwagę, że sumy prefiksowe są istotne
w sortowaniu przez zliczanie (rys. 8.1)
oraz w zastosowaniach praktycznych, takich jak ekonomia
czy statystyka (rys. 3.15).
-
Podsumowując, model RAM jest modelem uniwersalnym,
ponieważ umożliwia opis i analizę obliczeń sekwencyjnych
dla dowolnego współczesnego komputera z procesorem
jednowątkowym.