Złożoność obliczeniowa/Wykałd 2: Inne modele dla złożoności: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Pitab (dyskusja | edycje)
Pitab (dyskusja | edycje)
Nie podano opisu zmian
 
(Nie pokazano 8 wersji utworzonych przez 2 użytkowników)
Linia 1: Linia 1:
==Inne modele dla złożoności==


W poprzednim rozdziale przedstawiliśmy bardzo prosty, ale zarazem potężny model
obliczeń - maszynę Turinga. Przekonaliśmy się, że można na niej zaimplementować
proste operacje takie jak dodawanie; nietrudno uwierzyć w to, że za jej pomocą
można rozwiązywać również dużo bardziej skomplikowane problemy programistyczne.
Maszyna Turinga ma jednak zasadniczą wadę - programowanie na niej jest bardzo
mało wygodne. W tym module poznamy dwa modele obliczeń dużo bardziej
przypominające wygodne współczesne mikrokomputery takie jak ZX Spectrum.
===Maszyna RAM===
Maszyna RAM (ang. Random Access Machine - maszyna o dostępie swobodnym) jest
modelem obliczeń bardziej
złożonym od maszyny Turinga. Składa się ona z programu - skończonego ciągu
rozkazów definiujących jej działanie - oraz z potencjalnie nieograniczonego
zbioru rejestrów, z których każdy może przechowywać dowolną liczbę naturalną.
Rejestry indeksujemy liczbami naturalnymi, a więc ich wartości będziemy
zapisywać jako <math>R_0, R_1, R_2, \ldots</math>. Rejestr o indeksie 0 zwyczajowo nazywany jest akumulatorem i ma pewne specjalne (opisane poniżej) własności. Zbiór rejestrów zazwyczaj kolektywnie nazywamy pamięcią operacyjną lub po prostu pamięcią.
Oprócz programu i pamięci operacyjnej maszyna RAM posiada również dwie taśmy:
wejściową i wyjściową. Podobnie jak taśmy maszyny Turinga składają się one z
komórek, mogących zawierać symbole pewnego skończonego alfabetu; w przypadku
maszyny RAM na taśmach wejściowej i wyjściowej znajdują się liczby naturalne
z zakresu <math>[0, k]</math>, gdzie <math>k</math> to liczba ustalona z góry dla danej maszyny.
Różnicą w stosunku do maszyny Turinga
jest to, że po dokonaniu zapisu na komórce taśmy wyjściowej głowica przesuwana
jest w ustalonym kierunku (dla ustalenia uwagi w prawo) i nie można
już do tej komórki powrócić; taśma wyjściowa nie może zatem służyć jako miejsce
wykonywania obliczeń. To na programiście spoczywa odpowiedzialność zapisania
fragmentów wyjścia w bezpiecznym miejscu, jeżeli planuje w przyszłości z nich
skorzystać.
[[ZO-2-1. Schemat maszyny RAM]]
'''Zestaw instrukcji'''
W przeciwieńswie do maszyny Turinga, gdzie wszystkie wykonywane operacje były
tego samego typu, maszyna RAM posiada stosunkowo złożony zestaw instrukcji.
Warto przy tym zauważyć, że zestaw podany poniżej jest jednym z wielu możliwych
zestawów instrukcji,
które można znaleźć w literaturze. Zestawy te są zasadniczo równoważne (tzn.
program napisany dla jednego z nich można przetłumaczyć na równoważny program
dla innego zestawu instrukcji), mogą jednak wystąpić pewne niuanse przy
rozważaniu złożoności obliczeniowej.
{| border=1
|+ <span style="font-variant:small-caps">Uzupelnij tytul</span>
|-
|
Kod || Symbol || Argument || Operacja
|-
|
1 || LOAD || =n || <math>R_0 := n</math>
|-
| 2 || LOAD || n || <math>R_0 := R_n</math>
|-
| 3 || LOAD || <math>\uparrow</math>n || <math>R_0 := R_{R_n}</math>
|-
| 4 || STORE || n || <math>R_n := R_0</math>
|-
| 5 || STORE || <math>\uparrow</math>n || <math>R_{R_n} := R_0</math>
|-
| 6 || READ || n || <math>R_n := I</math>
|-
| 7 || READ || <math>\uparrow</math>n || <math>R_{R_n} := I</math>
|-
| 8 || WRITE || n || <math>O := R_n</math>
|-
| 9 || WRITE || <math>\uparrow</math>n || <math>O := R_{R_n}</math>
|-
| 10 || NEXT ||  || przesunięcie głowicy taśmy wejściowej w prawo
|-
| 11 || PREV ||  || przesunięcie głowicy taśmy wejściowej w lewo
|-
| 12 || ADD || =n || <math>R_0 := R_0 + n</math>
|-
| 13 || ADD || n || <math>R_0 := R_0 + R_n</math>
|-
| 14 || ADD || <math>\uparrow</math>n || <math>R_0 := R_0 + R_{R_n}</math>
|-
| 15 || SUB || =n || <math>R_0 := \max(R_0 - n, 0)</math>
|-
| 16 || SUB || n || <math>R_0 := \max(R_0 - R_n, 0)</math>
|-
| 17 || SUB || <math>\uparrow</math>n || <math>R_0 := \max(R_0 - R_{R_n}, 0)</math>
|-
| 18 || MUL || =n || <math>R_0 := R_0 \cdot n</math>
|-
| 19 || MUL || n || <math>R_0 := R_0 \cdot R_n</math>
|-
| 20 || MUL || <math>\uparrow</math>n || <math>R_0 := R_0 \cdot R_{R_n}</math>
|-
| 21 || DIV || =n || <math>R_0 := R_0 / n</math>
|-
| 22 || DIV || n || <math>R_0 := R_0 / R_n</math>
|-
| 23 || DIV || <math>\uparrow</math>n || <math>R_0 := R_0 / R_{R_n}</math>
|-
| 24 || JUMP || n || skok do instrukcji o numerze n
|-
| 25 || JZERO || n || skok do instrukcji o numerze n gdy <math>R_0 = 0</math>
|-
| 26 || JNZERO || n || skok do instrukcji o numerze n gdy <math>R_0 \not= 0</math>
|-
| 27 || HALT ||  || zakończenie działania maszyny
|-
|
|}
W powyższej tabeli symbol I oznacza zawartość komórki taśmy wejściowej,
znajdującej się pod głowicą. Analogicznie O to zawartość odpowiedniej komórki
na taśmie wyjściowej.
'''Działanie maszyny RAM'''
W chwili początkowej konfiguracja maszyny wygląda następująco:
*wszystkie rejestry mają wartość 0,
*na taśmie wejściowej umieszczone jest słowo wejściowe; głowica taśmy wejściowej ustawiona jest na pierwszej (skrajnie lewej) komórce słowa wejściowego,
*licznik rozkazów wskazuje na pierwszą instrukcję programu.
W kolejnych krokach maszyna wykonuje instrukcje wskazywane przez licznik
rozkazów. W przypadku instrukcji 1-23 po wykonaniu operacji na rejestrach lub
taśmach licznik rozkazów zwiększany jest o jeden. W przypadku instrukcji skoku
licznik rozkazów modyfikowany jest zgodnie z wartością argumentu (lub zwiększany o jeden gdy skok jest warunkowy i warunek nie jest spełniony). Wykonanie instrukcji HALT kończy działanie programu. Dodatkowo instrukcja WRITE
przesuwa głowicę taśmy wyściowej o jedną komórkę w prawo, a instrukcje
NEXT i PREV przesuwają odpowiednio głowicę taśmy wejściowej.
{{uwaga|[na marginesie]||
Można się w tym momencie zastanawiać, co się stanie, gdy maszyna wykona
ostatnią instrukcję programu i nie będzie to instrukcja HALT ani instrukcja
skoku; albo też co się stanie gdy jedna z instrukcji reprezentuje skok za
ostatni rozkaz programu. Dla porządku przyjmiemy, że program zachowa się tak,
jakby wykonał instrukcję HALT. Można też jednak przyjąć, że program dopuszczający taką sytuację jest niepoprawnie napisany i należy go poprawić
- co nie jest zadaniem trudnym.
}}
Wynikiem działania programu dla zadanego słowa wejściowego nazywamy ciąg
liczb wypisanych na taśmę wyjściową od rozpoczęcia działania programu aż do
wykonania instrukcji HALT.
{{przyklad|||
Poniższy program liczy największy wspólny dzielnik dwóch liczb naturalnych
podanych na wejściu.
<math>\verb''
1. READ 1
2. NEXT
3. READ 2
4. LOAD 2
5. JZERO 19
6. SUB 1
7. JNZERO 12
8. LOAD 1
9. SUB 2
10. STORE 1
11. JUMP 4
12. LOAD 2
13. STORE 3
14. LOAD 1
15. STORE 2
16. LOAD 3
17. STORE 1
18. JUMP 4
19. WRITE 1
20. HALT''
</math>
Jest on generalnie równoważny poniższemu programowi w języku Pascal:
<math>\verb''function gcd(a:integer; b:integer):integer;
var
    c:integer;
begin
  while b <> 0 do        {linie 4-5}
  begin
    if a >= b then        {linie 6-7}
      a := a - b          {linie 8-10}
    else
    begin
      c := b;            {linie 12-13}
      b := a;            {linie 14-15}
      a := c;            {linie 16-17}
    end;
  end;
  gcd := a;
end;''</math>
[[Animacja we flashu przedstawiajaca dzialanie algorytmu dla dwoch ustalonych
liczb, np. 15 i 35. - mozna by tez zrobic dla dowolnych liczb wpisanych
przez uzytkownika, ale chyba nie warto - nie o to chodzi w ZO]]
}}
'''Rozpoznawanie języków'''
Większość problemów omawianych w ramach tego kursu jest natury decyzyjnej
- to znaczy dla zadanego słowa wejściowego program ma udzielić odpowiedzi
''tak'' lub ''nie''. Warto zatem umówić się, w jaki sposób maszyna
ma nam zakomunikować swoją odpowiedź.
{{definicja|||
Mówimy, że maszyna RAM akceptuje słowo wejściowe ''w'' wtedy i tylko wtedy
gdy po uruchomieniu jej ze słowem ''w'' umieszczonym na taśmie wejściowej
maszyna wypisuje na taśmę wyjściową dokładnie jedną liczbę - 1 - i
zatrzymuje się po skończonej ilości kroków. Językiem akceptowanym przez
maszynę RAM nazywamy zbiór wszystkich skończonych słów akceptowanych przez
tą maszynę.
}}
{{cwiczenie|1||
Uzasadnij, czemu żądamy, żeby rejestry maszyny RAM mogły przechowywać dowolnie
duże liczby naturalne.
}}
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Wskazówka </span><div class="mw-collapsible-content" style="display:none"> 
Zastanów się z jakiej funkcjonalności maszyny RAM korzystamy, jeśli do wykonania
obliczenia potrzebujemy ilości pamięci rosnącej wraz ze wzrostem wielkości
wejścia.
</div></div>
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Rozwiązanie </span><div class="mw-collapsible-content" style="display:none"> 
Kluczem do odpowiedzi na postawione pytanie jest zastosowanie w maszynie RAM
''adresowania pośredniego'' - trybu adresowania, oznaczanego powyżej za pomocą
symbolu <math>\uparrow</math>. Warto zauważyć, że jeśli nasz program nie korzysta z adresowania pośredniego, to w trakcie swojego działania używa ustaloną z góry, skończoną liczbę rejestrów; dla przykładu powyższy program, liczący największy wspólny dzielnik, korzysta tylko z rejestrów o numerach 0-3. Jeżeli jednak do wykonania programu potrzebujemy potencjalnie nieograniczonej ilości pamięci, będziemy zmuszeni do skorzystania z adresowania pośredniego.
</div></div>
Adresowanie pośrednie jest kluczową cechą maszyny RAM; poniższe ćwiczenie
ma na celu zaznajomienie Cię z jego działaniem.
{{cwiczenie|2||
Zasymuluj program maszyny Turinga z ćwiczenia ''(tu numer ćwiczenia z
modułu 1, polegającego na dodaniu dwóch liczb unarnych)'' na maszynie RAM.
Oblicz, ile najwięcej kroków wykonuje skonstruowana przez Ciebie maszyna RAM
podczas jednego kroku symulowanej maszyny Turinga. Jeśli liczba takich kroków
nie jest ograniczona, popraw maszynę w ten sposób, aby była.
}}
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Wskazówka </span><div class="mw-collapsible-content" style="display:none"> 
Zauważ, że w trakcie działania wspomnianej maszyny Turinga głowica nigdy nie
znajduje się na lewo od swojej pozycji początkowej. ''(mam nadzieję, że tak
faktycznie będzie - jeśli nie to to poprawię)''
</div></div>
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Rozwiązanie </span><div class="mw-collapsible-content" style="display:none"> Powyższe ćwiczenie ilustruje ważną własność - każdy program dla jednotaśmowej, jednostronnie ograniczonej (czyli posiadającej nieskończoną taśmę tylko w jednym kierunku) maszyny Turinga można w prosty sposób przekształcić na równoważny program maszyny RAM. Łatwo się przekonać, że maszyna Turinga z obustronnie nieograniczoną taśmą nie jest potężniejsza od maszyny z taśmą jednostronnie ograniczoną; aby przekształcić program napisany dla pierwszej z nich wystarczy umówić się, że komórki o nieparzystych numerach będą reprezentowały komórki oryginalnej maszyny o ujemnych numerach, a komórki o
parzystych numerach będą reprezentowały komórki oryginalnej maszyny o numerach
dodatnich; dodatkowo należy w dość prosty sposób zmodyfikować program.
</div></div>
'''Złożoność obliczeniowa w modelu RAM'''
Zastanówmy się teraz, w jaki sposób można określić czas i pamięć zużyte
w trakcie działania maszyny RAM. Pierwszy - zapewne wyglądający na najbardziej
naturalny - sposób definiowania tych wielkości to tak zwane ''kryterium
jednostajne''. Jego założenia są następujące:
*uznajemy, że każdy rozkaz maszyny jest wykonywany w pewnym z góry ustalonym, stałym czasie,
*uznajemy, że miarą pamięci "zużytej" przez program w trakcie działania jest ilość rejestrów, do których program się odwoływał (w postaci odczytu lub zapisu).
Takie podejście po chwili zastanowienia nie wydaje się jednak właściwe; co
gorsza - założenie o wykonywaniu operacji arytmetycznych na dowolnie dużych
liczbach w stałym czasie nie jest obecnie możliwe do zrealizowania, i nie
wygląda na to, aby mogło zostać zrealizowane również w przyszłości.
Będziemy zatem stosować inne miary dla czasu i pamięci, zwane ''kryterium
logarytmicznym''. Jego założenia są następujące:
*czas wykonania operacji na liczbie <math>n</math> jest proporcjonalny do
<math>\lfloor \log n \rfloor + 1</math>. Czas wykonania operacji na dwóch liczbach <math>m</math> i <math>n</math> jest proporcjonalny do <math>\lfloor \log (m + n) \rfloor + 1</math> (oczywiście trzeba pamiętać, że logarytm liczby <math>0</math> jest niezdefiniowany; koszt wykonania operacji w tym przypadku równy jest <math>0</math>),
*jeżeli operacja używa adresowania pośredniego, to czas dostępu do <math>k</math>-tej komórki jest proporcjonalny do <math>\lfloor \log k \rfloor + 1</math>,
*do czasu wykonania każdej operacji doliczana jest pewna stała wielkość,
mogąca reprezentować koszt pobrania i rozpoznania instrukcji. Chodzi tutaj
głównie o to, by programy wykonujące same instrukcje skoku lub trywialne
(czyli w tym przypadku operujące na samych zerach) operacje arytmetyczne
miały niezerowy czas działania,
*jako miarę pamięci używanej przez program w danym momencie uznajemy
sumę logarytmów wartości wszystkich rejestrów, do których odwoływał się
program w trakcie swojego dotychczasowego działania.
Tak zdefiniowane miary ilości zasobów dużo lepiej odpowiadają rzeczywistości
współczesnych komputerów - do zapisania w pamięci liczby <math>n</math> potrzebujemy <math>\lfloor \log n \rfloor + 1</math> cyfr w systemie binarnym, natomiast dodanie dwóch liczb <math>m</math> i <math>n</math> zajmuje czas <math>O(\log(m + n))</math>. Niedoszacowany może się wydawać czas mnożenia i dzielenia (takie same jak dodawania) - jednak jak się okaże dla naszych celów taka definicja zupełnie wystarczy.
'''Porównanie czasu działania maszyny Turinga i maszyny RAM'''
{{uwaga|[na marginesie]|| 
Umówmy się, że czas działania określamy tylko dla maszyn z własnością stopu
- zarówno w przypadku maszyn Turinga i maszyn RAM.
}} 
W jednym z poprzednich ćwiczeń symulowaliśmy działanie maszyny Turinga na
maszynie RAM; stwierdziliśmy, że liczba kroków wykonywanych przez maszynę
RAM jest proporcjonalna do liczby kroków wykonanych przez symulowaną maszynę
Turinga. Jeżeli zatem <math>T(n)</math> określa największą ilość kroków wykonywaną przez
daną maszynę Turinga przy słowie wejściowym o długości <math>n</math>, a <math>T'(n)</math> jest analogicznie zdefiniowaną maksymalną ilością kroków dla maszyny RAM, to zachodzi następująca własność:
  <math>T'(n) = O(T(n))</math>
{{uwaga|[na marginesie]||
W rzeczywistości w tym momencie powinniśmy zapisać:
  <math>T'(n) = O(T(n) + n)</math>
Skąd się wzięło <math>n</math>? Otóż maszyna Turinga może dać odpowiedź jeszcze przed przeczytaniem całego słowa wejściowego. Maszyna RAM, skonstruowana w
zaprezentowany we wspomnianym ćwiczeniu sposób, symulująca działanie
maszyny Turinga przed rozpoczęciem symulacji przepisze całe słowo wejściowe do
rejestrów, po czym dopiero rozpocznie właściwe działanie. Można jednak łatwo
wyobrazić sobie schemat, w którym maszyna RAM sprowadza znaki z wejścia niejako
"na żądanie", zapamiętując jednocześnie liczbę sprowadzonych wcześniej
znaków. Taki schemat postępowania sprawia, że własność
  <math>T'(n) = O(T(N))</math>
naprawdę zachodzi.
}}
Zauważmy, że powyższa definicja funkcji czasu <math>T'(n)</math> dokładnie odpowiada definicji złożoności czasowej przy zastosowaniu kryterium jednostajnego. Warto się zastanowić, jak będzie wyglądała złożoność obliczeniowa w przypadku zastosowania kryterium logarytmicznego.
{{cwiczenie|3||
Oszacuj czas działania maszyny RAM, symulującej działanie maszyny Turinga,
przy zastosowaniu kryterium logarytmicznego.
}}
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Wskazówka </span><div class="mw-collapsible-content" style="display:none">
Wskaż instrukcje maszyny RAM, które nie są wykonywane w stałym czasie i oszacuj ich czas działania.
</div></div>
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Rozwiązanie </span><div class="mw-collapsible-content" style="display:none"> Należy zauważyć, że jedynym rejestrem, którego zawartości nie można a priori oszacować przez stałą, jest rejestr przechowujący pozycję symulowanej głowicy. Pozycję tą można jednak w prosty sposób oszacować od góry - nie będzie ona nigdy większa niż <math>T(n)</math>. Operacje o zmiennym czasie działania - czyli zwiększanie i zmniejszanie wskaźnika pozycji głowicy oraz dostęp za pomocą adresowania pośredniego do komórki pod głowicą - odbywają się w czasie <math>O(log T(n))</math>. Jeżeli zatem jako <math>T''(n)</math> oznaczymy pesymistyczny czas działania maszyny RAM w zależności od wielkości wejścia, to możemy zapisać następujący fakt:
  <math>T''(n) = O(T(n) log T(n))</math>
a rozluźniając nieco oszacowanie:
  <math>T''(n) = O(T(n)^2)</math>
</div></div>
'''Symulowanie maszyny RAM na wielotaśmowej maszynie Turinga'''
Wyniki z poprzedniego paragrafu nie są specjalnie zaskakujące; gołym okiem
widać, że maszyna RAM jest narzędziem co najmniej równie potężnym jak
maszyna Turinga. W tym paragrafie zajmiemy się zagadnieniem odwrotnym
- pokażemy, że maszynę RAM da się symulować na maszynie Turinga i oszacujemy
wydajność takiej symulacji.
Aby tego jednak dokonać uprościmy sobie najpierw definicję maszyny RAM.
Będziemy zatem tylko rozpatrywać maszyny RAM spełniające następujące warunki:
*wejście i wyjście stanowią słowa nad alfabetem <math>\{ 0, 1 \}</math>,
*maszyna nie używa instrukcji MUL ani DIV,
*jedynymi operacjami, dopuszczającymi adresowanie pośrednie, są instrukcje LOAD i STORE.
Maszynę RAM będziemy symulować na wielotaśmowej maszynie Turinga, posiadającej:
*taśmę wejściową nad alfabetem <math>\{ 0, 1 \}</math>,
*taśmę wyjściową nad alfabetem <math>\{ 0, 1 \}</math>,
*cztery taśmy robocze nad alfabetem <math>\{ 0, 1, \$, *, \% \}</math>
Maszyna Turinga będzie symulowała po kolei rozkazy maszyny RAM, przy czym
zakładamy, że na początku symulacji każdego rozkazu głowice taśm roboczych
znajdują się w takim położeniu, jak na początku działania programu. Taśma
robocza nr 1 będzie służyła do reprezentacji aktualnej zawartości rejestrów
maszyny RAM. Taśma robocza nr 2 będzie służyła do przechowywania jednego
adresu komórki. Taśmy robocze 3 i 4 będą służyły do wykonywania operacji
arytmetycznych. Najciekawiej wygląda taśma nr 1; sposób reprezentacji pamięci
RAM jest przedstawiony na poniższym schemacie:
<math>\verb''$011*01*11*101**1011$''</math>
Na taśmie znajdują się pary liczb, zapisane w postaci binarnej, zaczynając
od najmniej znaczącej cyfry. Powyższy zapis mówi, że:
*rejestr 6 ma wartość 2,
*rejestr 3 ma wartość 5,
*rejestr 0 ma wartość 13 (zakładamy, że reprezentacja binarna liczby 0 ma 0 cyfr).
Prześledźmy teraz działanie maszyny dla operacji ADD n (przypominamy, że
nie jest tutaj stosowane adresowanie pośrednie, zatem <math>n</math> jest stałą określoną
w czasie pisania programu). Maszyna Turinga zachowa się w następujący sposób:
*na taśmie numer 2 zapisze ciąg symboli <math>\$ \langle reprezentacja\_binarna\_liczby\_n \rangle \$</math> po czym przesunie głowicę tej taśmy do położenia początkowego,
*przesuwając się w prawo na taśmie nr 1, maszyna będzie szukać reprezentacji komórki o numerze <math>n</math>; w tym celu będzie porównywać numery komórek, zapisane na taśmie nr 1, z adresem zapisanym na taśmie nr 2. Jeśli adresy się nie będą zgadzały maszyna wycofa głowicę taśmy nr 2 do położenia początkowego, zignoruje zawartość komórki na taśmie nr 1 i przejdzie do następnej pary adres-zawartość,
*jeżeli maszyna znajdzie odpowiedni adres, to przepisze zawartość komórki na taśmę nr 3. W przeciwnym wypadku - gdy dojdzie do symbolu '' - zapisze na taśmie nr 3 reprezentację liczby 0,
*podobna procedura zostanie zastosowana do przekopiowania na taśmę nr 4
zawartości akumulatora (rejestru o numerze 0),
*maszyna doda liczbę zawartą na taśmie nr 3 do liczby z taśmy nr 4 za pomocą algorytmu pisemnego dodawania liczb binarnych,
*maszyna zapisze wynik dodawania na taśmie nr 1.
Ostatni krok może nastręczyć pewnych problemów: Nie wiemy co powinniśmy zrobić,
jeśli wynik dodawania zajmuje więcej bitów niż oryginalna liczba zawarta w
akumulatorze. Rozwiązanie w tym przypadku jest bardzo brutalne: Nasza maszyna
po prostu zapisze wynik na samym końcu taśmy nr 1; jeżeli po drodze na koniec
taśmy napotka parę adres-wartość, odpowiadającą akumulatorowi, to zarówno adres
jak i wartość oraz występujący między nimi separator <math>\'\*\'</math> zamaże wpisując w ich miejsca symbole <math>\%</math>. Na poniższym schemacie przedstawiony jest stan taśmy nr 1 po wykonaniu operacji ADD 3 (dodanie do
akumulatora zawartości komórki 3)
<math>\verb''$011*01*11*101**%%%%**01001$''</math>
Operacja <math>LOAD \uparrow n</math> nie jest dużo bardziej skomplikowana; trzeba znaleźć zawartość komórki <math>n</math>, następnie znaleźć zawartość komórki <math>R_n</math>, a na końcu zapisać wynik w akumulatorze (w razie potrzeby zamazując jego poprzednią zawartość).
Oszacujemy teraz czas potrzebny do zasymulowania maszyny RAM. Wykonanie operacji
arytmetycznych (pomijając czas dostępu do pamięci) zajmuje czas <math>O(log(m+n))</math>, gdzie <math>m</math> i <math>n</math> to argumenty operacji. Operacja kopiowania zawartości jednego rejestru do drugiego zajmuje czas <math>O(log n)</math>, gdzie <math>n</math> to kopiowana wartość. Stwierdzamy zatem, że samo wykonanie operacji na maszynie Turinga jest ,,wolniejsze'' od wykonania tych operacji na maszynie RAM o stały czynnik. Innymi słowy jeśli <math>T(n)</math> to czas wykonania programu dla danych o wielkości <math>n</math> na maszynie RAM, a <math>T'(n)</math> to czas wykonania (pomijając dostępy
do pamięci) tych samych operacji na maszynie Turinga, to zachodzi następująca
własność:
  <math>T'_1(n) = O(T(n))</math>
Pozostaje zatem kwestia oszacowania, ile zajmuje wyszukiwanie (i ewentualne
zamazywanie) komórek na taśmie nr 1. Łatwo jednak zauważyć, że w każdym
momencie symulacji ilość symboli na taśmie 1 jest co najwyżej proporcjonalna
do czasu działania symulowanej maszyny RAM. Zatem czas łącznie spędzony przez
maszynę Turinga na dostępy do pamięci można oszacować w następujący sposób:
  <math>T'_2(n) = O(T(n)\cdot T(n)) = O(T(n)^2)<math>
Zatem łączny czas działania maszyny Turinga - a więc czas dostepu do pamięci
plus czas właściwych operacji - spełnia własności:
  <math>T'(n) = O(T(n)^2)</math>
{{cwiczenie|4||
Oszacuj zależność między złożonością czasową programu dla maszyny RAM a
złożonością czasową programu maszyny Turinga, symulującego działanie maszyny
RAM, przy założeniu, że program maszyny RAM może używać wszystkich rozkazów
}}
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Wskazówka </span><div class="mw-collapsible-content" style="display:none"> Przekształć wywołania ,,zabronionych'' rozkazów maszyny RAM na sekwencje rozkazów ,,dozwolonych'' i oszacuj czas wykonania tych sekwencji.
</div></div>
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Rozwiązanie </span><div class="mw-collapsible-content" style="display:none"> Jest jasne, że adresowanie pośrednie dla operacji innych niż LOAD i STORE jest łatwe do zasymulowania: Wystarczy zastąpić te operacje poprzez sekwencje wykorzystujące akumulator i być może pewne z góry ustalone tymczasowe rejestry, w przypadku gdy trzeba będzie odtworzyć zawartość akumulatora.
Pozostają więc operacje MUL i DIV. Zaczniemy od MUL =2 i DIV =2.
Widać, że operacja MUL =2 jest prosta do zaimplementowania za pomocą dodawania.
Operację DIV =2 można zaimplementować w sposób opisany za pomocą poniższego
pseudokodu:
<math>\verb''function div2(n:integer):integer;
var
    a, b, c, res:integer;
begin
res := 0;
while n >= 2 do
begin
  a := 4;
  b := 2;
  c := 1;
  while n >= a do
  begin
  a := 2 * a;
  b := 2 * b;
  c := 2 * c;
  end;
  n := n - b;
  res := res + c;
end;
div2 := res;
end;''</math>
W skrócie działanie tej procedury można opisać następująco: procedura znajduje
największą potęgę dwójki nie większą od argumentu (czyli najbardziej znaczący
bit) i dodaje do wyniku tą liczbę podzieloną przez 2 (czyli bit przesunięty
o jedną pozycję ,,w prawo''). Każda pętla wykonuje się co najwyżej <math>O(log n)</math> a operacje wewnątrz nich zajmują czas <math>O(log n)</math> - zatem powyższa procedura działa w czasie <math>O(log^3 n)</math>. Warto też zauważyć, że używana jest tutaj stała liczba zmiennych tymczasowych, o rozmiarze porównywalnym do rozmiaru liczby <math>n</math>.
Powyższa procedura pozwala na prostą implementację funkcji MOD = 2 - czyli
funkcji zwracającej najmniej znaczący bit liczby. Z wykorzystaniem tej funkcji
możemy zaimplementować mnożenie dwóch dowolnych liczb:
<math>\verb''function multiply(n:integer; m:integer):integer;
begin
res := 0;
while n > 0 do
begin
  if n mod 2 = 1 then
  res := res + m;
  n := n div 2;
  m := m mul 2;
end;
multiply := res;
end;''</math>
Czas działania powyższej procedury można oszacować od góry jako <math>O(log^4 (n+m))</math>.
Do zaimplementowania pozostało nam już zatem tylko dzielenie; można tą procedurę skonstruować w następujący sposób:
<math>\verb''function divide(n:integer; m:integer):integer;
var
    lo, hi, med:integer;
begin
lo := 0;
hi := 1;
while m * hi <= n do
  m := 2 * m;
while (hi - lo) > 1 do
begin
  med := (lo + hi) div 2;
  if med * hi <= n then
  lo := med
  else
  hi := med;
  end;
divide := lo;
end;''</math>
Tutaj złożoność czasową można oszacować przez <math>O(log^5 (n+m))</math>.
Widzimy zatem, że każdą ,,zabronioną'' operację arytmetyczną można zasymulować
w czasie <math>O(log^5 (n+m))</math>. W związku z tym każdy program maszyny RAM działający w czasie <math>O(T(n))</math> można przekształcić w równoważny program nie korzystający z ,,zabronionych'' operacji i działający w czasie <math>O(T(n)^5)</math>.
W powyższych procedurach zdecydowanie większa waga była przyłożona do
prostoty rozwiązania niż do jego wydajności; również przedstawione powyżej
odgórne oszacowanie jest bardzo zgrubne - jest ono prawdziwe w przypadku gdy
w programie często używane jest dzielenie dużych liczb.
</div></div>
Powyższe paragrafy pokazały zależność pomiędzy złożonościami czasowymi
równoważnych programów dla maszyn RAM i Turinga. Taką zależność pomiędzy
modelami obliczeń nazywamy <math>\textit wielomianową równoważnością.</math>
{{definicja|||
Mówimy, że modele obliczeń <math>\mathcal A</math> i <math>\mathcal B</math> są wielomianowo równoważne (w sensie złożoności czasowej) wtedy i tylko wtedy, gdy dla dowolnego języka <math>L \subseteq \Sigma^{\star}</math> (gdzie <math>\Sigma</math> jest skończonym alfabetem):
* istnienie programu dla maszyny modelu <math>\mathcal A</math> o złożoności czasowej <math>f(n)</math> implikuje istnienie programu dla maszyny modelu <math>\mathcal B</math> o złożoności czasowej <math>O(w(f(n)))</math>, gdzie <math>w(x)</math> jest wielomianem,
* zachodzi własność symetryczna.
}}
Wielomianowa równoważność sprawia, że jeśli dla danego problemu na maszynie
jednego typu istnieje algorytm o wielomianowej złożoności czasowej, to również
na drugiej maszynie istnieje algorytm o wielomianowej złożoności czasowej.
===Obwody logiczne===
Rozważymy teraz model obliczeń w znaczący sposób różniący się od dwóch
poprzednio opisanych -- obwody logiczne. Obwód logiczny można formalnie
zdefiniować jako acykliczny graf skierowany o następujących własnościach:
* każdy wierzchołek jest wierzchołkiem wejściowym, wierzchołkiem wyjściowym, koniunkcją, alternatywą albo negacją,
* w zależności od typu wierzchołka spełnione są ograniczenia na jego stopnie wejściowe i wyjściowe:
** wierzchołek wejściowy - stopień wejściowy 0,
** wierzchołek wyjściowy - stopień wejściowy 1, stopień wyjściowy 0,
** alternatywa i koniunkcja - stopień wejściowy 2,
** negacja - stopień wejściowy 1.
Węzły wewnętrzne - koniunkcję, alternatywę i negację - zwyczajowo nazywa
się bramkami. Ich semantyka zgodna jest z powszechnie przyjętym w logice
zwyczajem. Załóżmy, że obwód logiczny ma <math>m</math> wejść i <math>n</math> wyjść, z pewnym ustalonym na nich porządkiem. Możemy wtedy patrzeć na układ logiczny jako na funkcję następującego typu:
  <math>f: \{ 0, 1 \}^m \rightarrow \{ 0, 1 \}^n</math>
{{cwiczenie|5||
Skonstruuj układ logiczny, obliczający funkcję XOR.
}}
<div class="mw-collapsible mw-made=collapsible mw-collapsed"><span class="mw-collapsible-toogle mw-collapsible-toogle-default style="font-variant:small-caps">Rozwiązanie </span><div class="mw-collapsible-content" style="display:none"> Jeden z możliwych układów przedstawiony jest na poniższym rysunku.
[[ZO-2-3. Implementacja funkcji XOR.]]
</div></div>
Stopień skomplikowania układu można mierzyć na dwa sposoby:
* poprzez ilość bramek w układzie logicznym,
* poprzez ilość bramek na najdłuższej ścieżce występującej w układzie (''głębokość'' układu logicznego).
W dalszej części tego rozdziału będziemy się zajmować pierwszą z tych miar.
W odróżnieniu od dwóch poprzednich modeli, każdy układ logiczny ma z góry
określoną wielkość wejścia i wyjścia, a co za tym idzie skończoną liczbę
możliwych zestawów danych wejściowych. Dla takiej rodziny układów będziemy
określać ''miarę złożoności ilościowej'':
{{definicja|[Miara złożoności ilościowej]||
Niech <math>\mathcal R</math> będzie rodziną układów logicznych taką, że dla każdej liczby naturalnej <math>n</math> rodzina zawiera dokładnie jeden układ o <math>n</math> węzłach wejściowych. Miarą złożoności ilościowej nazwiemy wtedy funkcję <math>f: \mathbb N \rightarrow \mathbb N</math>, która każdej liczbie naturalnej <math>n</math> przyporządkowuje liczbę bramek zawartych w jedynym układzie z <math>\mathcal R</math>, posiadającym <math>n</math> wejść.
}}
Pokażemy teraz, jaki jest związek między złożonością czasową obliczenia na
maszynie Turinga (rozpoznającej pewien język <math>L</math>), a złożonością czasową rodziny układów logicznych rozpoznających ten sam język. W tym celu zasymulujemy działanie jednotaśmowej maszyny Turinga dla słów wejściowych o ustalonej długości.
Załóżmy zatem, że maszyna Turinga <math>M</math> rozpoznaje język <math>L</math>, jej alfabet taśmowy zawiera <math>k</math> elementów, a zbiór stanów zawiera <math>s</math> elementów. Załóżmy dodatkowo, że maszyna w trakcie swojego działania nie musi w każdym ruchu przesuwać głowicy - może ją pozostawiać w miejscu; ponadto zażądajmy, by maszyna po ustaleniu wyniku działania wracała do położenia początkowego i przechodziła do stanu akceptującego (a dokładniej, żeby się w tym stanie "zapętlała"). Łatwo zauważyć, że każdą maszynę można "zmusić" do tego, aby spełniała te założenia nie zwiększając jej czasu działania więcej niż dwukrotnie. Złożoność czasową tej maszyny oznaczmy jako <math>T(n)</math>.
Ustalmy teraz pewną długość słowa wejściowego - <math>n</math>; ponieważ znamy złożoność czasową maszyny <math>M</math>, możemy jeszcze przed rozpoczęciem działania stwierdzić, ile najdłużej może jej zająć rozwiązywanie problemu decyzyjnego i ile najwięcej może zużyć miejsca podczas tego procesu. Skonstruujemy zatem następujący układ logiczny, z <math>\lceil \log k \rceil \cdot n</math> wejściami, symulujący maszynę <math>M</math> - jego schemat przedstawiony jest na poniższym rysunku:
[[ZO-2-4. Układ logiczny symulujący maszynę Turinga.]]
Na powyższym schemacie prostokąty reprezentują pewne podukłady logiczne.
"Wyjścia" (czyli wartości węzłów na "dolnej" warstwie) prostokątów
na <math>i</math>-tej warstwie reprezentują konfigurację maszyny po <math>i</math> krokach. Wyjście <math>j</math>-tego prostokąta na <math>i</math>-tej warstwie składa się z <math>w = \lceil log k \rceil + \lceil log (s+1) \rceil </math> węzłów (bitów). Pierwsza część wyjścia oznacza symbol, który znajduje się w <math>j</math>-tej komórce taśmy maszyny Turinga po <math>i</math> krokach. Pozostała część mówi, czy w <math>i</math>-tym kroku
głowica znajduje się nad <math>j</math>-tą komórką, i, jeśli tak, w jakim stanie znajduje się maszyna (robimy to dodając jeden "sztuczny" stan, mówiący, że w danym miejscu nie ma głowicy; stąd na zapisanie stanu potrzebne jest
<math>\lceil log (s+1) \rceil </math> bitów). Zastanówmy się teraz, jakie powinno być zachowanie typowego "prostokąta" (oznaczonego symbolem A). Wynik działania tego podukładu zależy tylko i wyłącznie od wyników działania trzech podukładów z poprzedzającej go warstwy; jego funkcja jest zdefiniowana przez zachowanie maszyny Turinga, to znaczy:
* jeśli żadna z trzech komórek poprzedniej warstwy nie zawierała głowicy, to należy przepisać symbol taśmy z prostokąta położonego bezpośrednio nad rozpatrywanym układem,
* jeśli prostokąt położony bezpośrednio nad rozpatrywanym układem posiadał symulowaną głowicę, to należy odpowiednio zmodyfikować otrzymany od niego symbol taśmowy, i - w zależności od tego czy głowica się przesuwa czy nie - zapisać stan symulowanej maszyny lub zapisać sztuczny stan "brak głowicy",
* analogiczne zasady (oczywiście odpowiednio zmodyfikowane) dla sytuacji, gdy głowica na <math>i-1</math>-szej warstwie znajdowała się w jednej z dwóch sąsiadujących z rozpatrywaną komórek.
Prostokąty A reprezentują więc pewne z góry określone funkcje typu <math>\{ 0, 1 \}^{3w} \rightarrow \{ 0, 1 \}^w</math>. Wiedząc, że układ operatorów <math>\{ \wedge, \vee, \neg \}</math> jest układem funkcjonalnie pełnym, można stwierdzić, że prostokąt A jest implementowany przez określoną z góry, stałą liczbę bramek (oznaczmy ją <math>c_1</math>; stała ta jest zależna tylko od funkcji przejścia - w szczególności nie jest w żaden sposób zależna od <math>n</math>). Rozumując analogicznie możemy też stwierdzić, że prostokąty B, C, D i E reprezentują pewne funkcje logiczne i możemy je zaimplementować z użyciem co najwyżej <math>c_2</math> bramek.
Pozostaje nam tylko ustalić, jak obliczamy wartość wyjścia układu logicznego;
tutaj postępowanie jest proste z powodu wymagań, jakie nałożyliśmy na maszynę:
Maszyna <math>M</math> akceptuje słowo wtedy i tylko wtedy, gdy po <math>T(n)</math> krokach głowica znajduje się w pozycji początkowej i maszyna jest w ustalonym stanie akcpetującym. Oba te warunki można sprawdzić na podstawie wyjścia podukładu reprezentowanego przez środkowy prostokąt <math>T(n)</math>-tej warstwy.
Przedstawiliśmy zatem sposób, w jaki dla algorytmu o złożoności czasowej <math>T(n)</math> można skonstruować rodzinę układów logicznych o
  <math>c_1 (2 T(n) - 1) \cdot T(n) + c_2 (4 T(n) + 1) = O(T(n)^2)</math>
bramkach.
{{wniosek|||
Jeżeli dla określonego problemu decyzyjnego istnieje algorytm dla maszyny
Turinga o wielomianowej złożoności czasowej, to istnieje rodzina układów
logicznych o wielomianowej złożoności ilościowej, rozwiązująca ten sam
problem decyzyjny.
}}
Kontrapozycja tego wniosku jest czasami używana do pokazywania, że dla danego
problemu nie istnieje algorytm wielomianowy.

Aktualna wersja na dzień 17:47, 8 sie 2006