Zaawansowane struktury danych/Struktury danych dla liczb całkowitych I: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Linia 106: Linia 106:


== Drzewa van Emde Boasa ==
== Drzewa van Emde Boasa ==
Nasz rozważania na temat problemu poprzednika rozpoczniemy od drzew
van Emde Boasa. Drzewa ta realizują wszystkie operacje problemu
poprzednika w czasie ''O(log w = log log |U|)'', a więc niezależnym
od ''n'' a tylko od rozmiaru uniwersum.
Patrząc na złożoność jaką chcemy osiągnąć, można zgadnąć jak będzie
działały operacje na naszej strukturze danych -- będą one wykonywały
"wyszukiwanie binarne" na reprezentacji bitowej ''x''.
Dla dowolnej liczby ''k'' bitowej ''x'', niech high(''x'') będzie
''k/2''-bitową bardziej znaczącą częścią ''x'', a low(''x'') ''k/2''-bitową
mniej znaczącą częścią ''x''. Algorytm operacji PRED(''x'') mógłby
wyglądać na przykład tak:
# sprawdź czy ''S'' zawiera jakieś elementy ''y'' takie, że high(''y'')=high(''x''), w szczególności czy najmniejszy z tych elementów jest mniejszy niż ''x'',
# jeśli nie, to musimy znaleźć ''h''=PRED(high(''x'')) w zbiorze $high(''S'') i jako odpowiedź zwrócić najwiekszy element ''y ∈ S'' taki,że high(''y'')=''h'',
# jeśli tak, to szukamy ''l''=PRED(low(''x'')) w zbiorze {low(''y''):''y'' ∈ ''S'', high(''y'')=high(''x'')} i zwracamy high(''x'') ''l''.
Tak właśnie działają drzewa (kolejki) van Emde Boasa. Drzewo van Emde Boasa typu VEB(''u'') dla podzbioru ''S'' uniwersum ''U={0,...,u-1}'' zawiera:
# tablicę ''L[0,...,√u]'' drzew van Emde Boasa typu VEB(√''u''),
# ''H'' -- drzewo typu VEB(√''u''),
# min -- wartość najmniejszego elementu ''S'',
# max -- wartość największego elementu ''S''.
Drzewo ''H'' odpowiada zbiorowi high(''S''), natomiast drzewo ''L[i]'' zbiorowi {low(''y'') : ''y'' ∈ S, high(''y'')=''i''}. Algorytm PRED możemy teraz zapisać następująco:
<Source>
PRED(x)
if ((high(x) not in H) or (L[high(x)].min >= x))
  y = H.PRED(high(x))
  return sqrt(u)*y+L[y].max
else
  return sqrt(u)*high(x) + L[high(x)].PRED(low(x))
</Source>
'''Ćwiczenie 2'''
Spróbuj zapisać algorytmy INSERT i DELETE. Jaką mają one złożoność?
<div class="mw-collapsible mw-made=collapsible mw-collapsed">'''Wskazówka:'''<div class="mw-collapsible-content" style="display:none">
Spróbuj "buforować" wstawienia.
</div></div>
<div class="mw-collapsible mw-made=collapsible mw-collapsed">'''Rozwiązanie'''<div class="mw-collapsible-content" style="display:none">
Problem z operacjami INSERT i DELETE polega na tym, że jeśli
dodajemy/usuwamy element z podstruktury H, to w zasadzie musimy
wykonać dwa rekurencyjne wywołania -- jedno dla H i jedno dla
odpowiedniego L. Aby uniknąć tego problemu umawiamy się, że element
zapamiętany jako min NIE JEST przechowywany w odpowiedniej
podstrukturze L. W ten sposób pozbywamy się jednego wywołania
rekurencyjnego.
Złożoność wszystkich operacji jest ''O(log w)''.
</div></div>
'''Ćwiczenie 3'''
Jaka jest złożoność pamięciowa drzew van Emde Boasa?
<div class="mw-collapsible mw-made=collapsible mw-collapsed">'''Rozwiązanie'''<div class="mw-collapsible-content" style="display:none">
<math>\[ S(u) = 1 + \sqrt{u} + (\sqrt{u}+1)*S(\sqrt{u}). \]</math>
Sprawdzamy, że <math>S(u) \le au+b:</math>
<math>\[ S(u) = 1 + \sqrt{u} + au+\sqrt{u}(b+1)-b=au+\sqrt{u}(b+2)+(1-b). \]</math>
Weźmy ''b''=-3 i ''a'' wystarczająco duże, żeby dało się udowodnić podstawę indukcji.
</div></div>
'''Ćwiczenie 4'''
Jaka będzie złożoność pamięciowa jeśli niepuste podstruktury będziemy
trzymali w (dynamicznej) tablicy hashującej?
<div class="mw-collapsible mw-made=collapsible mw-collapsed">'''Rozwiązanie'''<div class="mw-collapsible-content" style="display:none">
Tym razem dostajemy równanie:
<math>\[ S(w,n) = 1 + O(n) + S(w/2,k)+ \sum_{i=1}^k S(w/2,n_i), \]</math>
gdzie ''k'' jest liczbą elementów w ''H'', a ''n<sub>i</sub>'' jest liczbą elementów w ''i''-tej niepustej podstrukturze. Sprawdzamy, że <math>S(w,n) \le c(n-1)\log w</math>:
<math>\[ S(w,n) = 1 + O(n) + c(k-1)(\log w-1) + c(n-k)(\log w-1) \le c(n-1)\log w. \]</math>
Aby przekonać się, że złożoność ta faktycznie jest ''&Omega;(n log w)'' a nie, na przykład ''O(n)'', wystarczy rozważyć zbiór elementów postaci ''0<sup>2<sup>i</sup></sup>1<sup>w-2<sup>i</sup></sup>''.
</div></div>
==y-szybkie drzewa==

Wersja z 08:17, 4 lis 2006

Modele obliczeń

Kilka kolejnych wykładów poświęcimy strukturom danych które umożliwiają wykonanywanie różnego rodzaju operacji na zbiorach liczb całkowitych.

Okazuje się, że jeśli ograniczymy się do przypadku liczb całkowitych (a nie, na przykład, elementów dowolnego liniowego porządku), to tradycyjnie używane modele, take jak RAM, czy drzewa decyzyjne, nie mają zbyt wiele sensu.

Przypomnijmy, że w modelu drzew decyzyjnych, algorytm jest drzewem porównań (można też rozpatrywać bogatsze modele, które umożliwiają wykonywanie operacji arytmetycznych na elementach danych wejściowych, tzw. algebraiczne drzewa decyzyjne). Z jednej strony ten model jest bardzo silny, ponieważ ignorujemy strukturalny aspekt algorytmu, w Szczególności program może mieć wykładniczy rozmiar (!!). Z drugiej strony w modelu tym brakuje dostępu do lokacji w pamięci adresowanej przez zmienną (kluczowe w hashowaniu). Co więcej, gdybyśmy chcieli uwzględnić w tym modelu operacje możliwe tylko w dziedzinie liczb całkowitych, np. przesunięcia bitowe, tracimy najważniejszą zaletę tego modelu, tzn. względną łatwość uzyskiwania dolnych granic.

Model RAM jest jakby przeciwieństwem modelu drzew decyzyjnych. Bardzo dobrze oddaje on strukturalny aspekt algorytmów, dostępne w nim są wszystkie naturalne operacje, także te możliwe tylko w dziedzinie liczb całkowitych. Ceną, jaką za to płacimy jest oczywiście ogromna komplikacja modelu, a w konsekwencji problemy z dowodzeniem dolnych granic. Nawet model RAM nie opisuje jednak realistycznych obliczeń idealnie. Podstawowym zarzutem jaki można mu postawić jest jednostkowy czas wykonywania operacji arytmetycznych na argumentach dowolnych (!!) rozmiarów.

Przykład/Ćwiczenie 1:

Pokaż, że w modelu RAM można posortować n liczb całkowitych w czasie O(n).

Wskazówka

Ten przykład pokazuje, że jeśli interesują nas liczby całkowite, musimy w jakiś sposób ograniczyć rozmiar argumentów, przy którym operacje arytmetyczne wykonują się w czasie jednostkowym. Problem ten z reguły rozwiązuje się następująco. Zakładamy, że nasz "komputer" może wykonywać operacje arytmetyczne w czasie stałym tylko na liczbach w-bitowych, dla pewnego ustalonego w, które będziemy nazywali długością słowa. Arytmetykę większych liczb musimy sobie sami zaimplementować, co oznacza, że jest ona odpowiednio droższa. Ten model nazywamy w-bitowym WordRAM. Zwróćmy uwagę, że zdefiniowaliśmy tu całą rodzinę modeli, sparametryzowaną przez w. Rozwiązując w modelu WordRAM konkretny problem, złożoność naszego algorytmu będziemy uzależniać zarówno od rozmiaru danych n jak i długości słowa w. Zakładamy przy tym, że w >= log n, co ma sens, bo musimy przecież jakoś adresować dane.

Uwaga Wiekszość znanych algorytmów, na przykład grafowych, ma w tym modelu złożoność taką samą jak w modelu RAM, niezależnie od parametru w. Perwersyjny algorytm z Ćwiczenia 1 jest tu wyjątkiem.

Niekiedy rozpatruje się także nieco bardziej ograniczone modele. W szczególności, bardzo naturalnym modelem jest tzw. AC0RAM. Model ten jest identyczny z WordRAM z jednym wyjątkiem, w czasie jednostkowym wykonują się w nim jedynie operacje typu AC0, tzn. takie, które można zrealizować przy pomocy obwodów logicznych stałej głębokości i wielomianowego (względem w) rozmiaru (bramki mogą mieć dowolnie duży stopień wejściowy). Pozostałe, tzn. przede wszystkim mnożenie, musimy zrealizować sami. Dlaczego nakładamy takie ograniczenie? W jakimś sensie jest ono podyktowane przez rzeczywiste ograniczenia architektury komputerów. Rozmiar obwodów realizujących operacje typu AC0 jest stały, niezależny od w. Tylko dla takich operacji ma sens założenie, że wykonują się one w czasie jednostkowym. Gdybyśmy budowali komputer 1.000.000-bitowy, to dodawanie liczb nie byłoby na nim czasowo dużo kosztowniejszy niż na komputerze 64-bitowym, ale już mnożenie tak.

Problem poprzednika

W problemie poprzednika (ew. następnika), chcemy realizować następujące operacje na zbiorze liczb całkowitych S:

  1. INSERT(x) -- dodaje x do S,
  2. DELETE(x) -- usuwa x z S,
  3. FIND(x) -- sprawdza, czy x należy do S,
  4. PREDECESSOR(x)/PRED(x) -- zwraca poprzednik x w S,
  5. SUCCESOR(x)/SUCC(x) -- zwraca następnik x w S.

Uwaga: W operacjach PRED i SUCC argument x nie musi być elementem zbioru S.

Można powiedzieć, że problem poprzednika to uporządkowana wersja problemu słownika. Zwróćmy uwagę, że "wolne" zrównoważone drzewa BST pozwalają na wykonywanie operacji PRED w czasie O(log n), ale już "szybkie" tablice hashujące potrzebują czasu O(n).

My będziemy zajmować się problemem poprzednika w modelu WordRAM/AC0RAM, a zatem założymy, że nasze elementy są liczbami w-bitowymi, tzn. liczbami z przedziału U=[0,...,2w-1]. Jeśli zakładamy, że elementy S pochodzą ze skończonego uniwersum, tak jak w naszym przypadku, to mamy do czynienia z problemem poprzednika dla skończonego uniwersum (ang. FUP = finite universe predecessor). Jak zwykle, możemy też rozważać statyczną wersję problemu, w której nie realizujemy operacji modyfikujących S, tzn. INSERT i DELETE.

Drzewa van Emde Boasa

Nasz rozważania na temat problemu poprzednika rozpoczniemy od drzew van Emde Boasa. Drzewa ta realizują wszystkie operacje problemu poprzednika w czasie O(log w = log log |U|), a więc niezależnym od n a tylko od rozmiaru uniwersum.

Patrząc na złożoność jaką chcemy osiągnąć, można zgadnąć jak będzie działały operacje na naszej strukturze danych -- będą one wykonywały "wyszukiwanie binarne" na reprezentacji bitowej x.

Dla dowolnej liczby k bitowej x, niech high(x) będzie k/2-bitową bardziej znaczącą częścią x, a low(x) k/2-bitową mniej znaczącą częścią x. Algorytm operacji PRED(x) mógłby wyglądać na przykład tak:

  1. sprawdź czy S zawiera jakieś elementy y takie, że high(y)=high(x), w szczególności czy najmniejszy z tych elementów jest mniejszy niż x,
  2. jeśli nie, to musimy znaleźć h=PRED(high(x)) w zbiorze $high(S) i jako odpowiedź zwrócić najwiekszy element y ∈ S taki,że high(y)=h,
  3. jeśli tak, to szukamy l=PRED(low(x)) w zbiorze {low(y):yS, high(y)=high(x)} i zwracamy high(x) l.

Tak właśnie działają drzewa (kolejki) van Emde Boasa. Drzewo van Emde Boasa typu VEB(u) dla podzbioru S uniwersum U={0,...,u-1} zawiera:

  1. tablicę L[0,...,√u] drzew van Emde Boasa typu VEB(√u),
  2. H -- drzewo typu VEB(√u),
  3. min -- wartość najmniejszego elementu S,
  4. max -- wartość największego elementu S.

Drzewo H odpowiada zbiorowi high(S), natomiast drzewo L[i] zbiorowi {low(y) : y ∈ S, high(y)=i}. Algorytm PRED możemy teraz zapisać następująco:

PRED(x)
if ((high(x) not in H) or (L[high(x)].min >= x))
   y = H.PRED(high(x)) 
   return sqrt(u)*y+L[y].max
else
   return sqrt(u)*high(x) + L[high(x)].PRED(low(x))

Ćwiczenie 2 Spróbuj zapisać algorytmy INSERT i DELETE. Jaką mają one złożoność?

Wskazówka:
Rozwiązanie

Ćwiczenie 3 Jaka jest złożoność pamięciowa drzew van Emde Boasa?

Rozwiązanie

Ćwiczenie 4 Jaka będzie złożoność pamięciowa jeśli niepuste podstruktury będziemy trzymali w (dynamicznej) tablicy hashującej?

Rozwiązanie

y-szybkie drzewa