Notatki - Drzewa licznikowe
Autor:[samek]
Data informacji: 2002.11.27



Notatki do Olimpijskiego Kółka Informatycznego OFEK

Autor: Andrzej Gąsienica-Samek

Drzewa Licznikowe

Drzewo licznikowe to struktura danych, która umożliwia przechowywanie liczb całkowitych z niewielkiego przedziału (np. od 0 do 8 388 607). Ogromną zaletą drzew licznikowych jest ich prostota. Niech n oznacza wielkość przedziału. Na drzewie licznikowym możemy wykonywać następujące operacje:

Operacja Koszt Opis
Inicjuj O(n) Tworzy nowe drzewo dla przedziału 0..n-1
Wstaw(x) O(log n)  
Usuń(x) O(log n)
IleRównych(x) O(1) Ile liczb równych x znajduje się w strukturze
IleMniejszych(x) O(log n) Ile liczb mniejszych od x znajduje się w strukturze
ElementNumer(k) O(log n) Znajdź (k+1)-szy co do wielkości element
Lewo(x), Prawo(x) O(log n) Znajdź najbliższy element mniejszy (większy) od x

Jak widać koszt operacji nie zależy od liczby aktualnie przechowywanych elementów, a jedynie od rozmiaru przedziału. Pamięć zajmowana przez drzewo licznikowe nie zmienia się w czasie i jest proporcjonalna do n.

Budowę drzewa licznikowego zaczynamy od określenia przedziału. Przedział zawsze zaczyna się od 0, a liczba elementów jest pewną potęgą dwójki. Załóżmy, że chcemy przechowywać liczby z przedziału 0..15. W tym celu definiujemy jedną stałą i jedną tablicę:

   4: const
   5:     BASE = 16(* Musi być potęgą dwójki *)
   7: var
   8:     Tab: array [1..2*BASE-1of Longint;

Tak zdefiniowana struktura pozwoli na przechowywanie elementów z przedziału 0..BASE-1. O prostocie tej struktury może świadczyć fakt, że są to wszystkie potrzebne deklaracje. Tablicy Tab będziemy używali jak drzewa, w taki sam sposób jak w przypadku stogów (zajrzyj do notatek o stogach). Korzeniem będzie element numer 1. Element numer k będzie miał dwóch synów 2*k i 2*k+1. Zauważmy, że w naszym przypadku mamy do czynienia z pełnym drzewem binarnym. Liście mają numer BASE..2*BASE+1. W liściu o numerze k będziemy przechowywali informację o liczbie elementów równych k-BASE. W węźle wewnętrznym k będziemy przechowywali informację o liczbie wszystkich elementów przechowywanych w jego poddrzewie. Mając tak zdefiniowaną strukturę bardzo prosto możemy ją zainicjować:

  11: procedure Inicjuj;
  12: var
  13:     i: Longint;
  14: begin
  15:     for i := 1 to 2*BASE-1 do
  16:         Tab[i] := 0;
  17: end;

Działanie tej procedury jest bardzo proste. Po jej wykonaniu uzyskujemy strukturę zawierającą z liściach i węzłach wewnętrznych same zera, a więc nie zawierającą żadnych elementów. Wstawienie elementu do struktury jest równie proste:

  20: procedure Wstaw(x, ile: Longint);
  21: begin
  22:     Inc(x, BASE);
  23:     while x > 0 do
  24:     begin
  25:         Inc(Tab[x], ile);
  26:         x := x shr 1;
  27:     end;
  28: end;

Ta procedura wstawia ile elementów x. Zauważ, że może być używana zarówno do wstawienia pojedynczego elementu (ile=1), jak również do usunięcia elementu (ile=-1). Jej działanie polega na przejściu od liścia do korzenia i zmianie wszystkich wartości na ścieżce. Najbardziej skomplikowaną procedurą jest liczenie elementów mniejszych od danego:

  31: function IleMniejszych(x: Longint): Longint;
  32: var
  33:     r: Longint;
  34: begin
  35:     r := 0;
  36:     Inc(x, BASE);
  37:     (* UWAGA!!! nie liczymy korzenia! *)
  38:     while x > 1 do
  39:     begin
  40:         if x and 1 = 1 then
  41:             Inc(r, Tab[x-1]);
  42:         x := x shr 1;
  43:     end;
  44:     IleMniejszych := r;
  45: end;

Pomysł polega na przejściu od liścia do korzenia. Dla każdego prawego elementu ze ścieżki powiększamy licznik o wartość jego lewego brata. Kolejną operacją jest znalezienie (k+1)-szego co do wielkości elementu (elementu numer k w drzewie). Wykonuje to następująca procedura:

  48: function ElementNumer(k: Longint): Longint;
  49: var
  50:     x: Longint;
  51: begin
  52:     x := 1;
  53:     while x < BASE do
  54:     begin
  55:         x := x shl 1;
  56:         if k >= Tab[x] then
  57:         begin
  58:             Dec(k, Tab[x]);
  59:             Inc(x);
  60:         end;
  61:     end;
  62:     ElementNumer := x - BASE;
  63: end;

Dla odmiany tym razem idziemy od korzenia w dół do pewnego liścia. Idąc w dół sprawdzamy, czy szukany element znajduje się z lewym, czy w prawym poddrzewie. Jeśli w prawym to schodząc w dół pomniejszamy k o liczbę elementów w lewym poddrzewie. Teraz do zrealizowania pozostały trzy bardzo proste funkcje:

  65: function IleRownych(x: Longint): Longint;
  66: begin
  67:     IleRownych := Tab[BASE + x];
  68: end;
  69: 
  70: function Prawo(x: Longint): Longint;
  71: begin
  72:     Prawo := ElementNumer(IleMniejszych(x) + IleRownych(x));
  73: end;
  74: 
  75: function Lewo(x: Longint): Longint;
  76: begin
  77:     Lewo := ElementNumer(IleMniejszych(x) - 1);
  78: end;

Ćwiczenia

  1. Zaimplementuj procedurę Ile(x,y) liczącą elementy w strukturze należące do przedziału x..y .
  2. Inicjacja struktury wymaga czasu O(n). Można jej użyć również do usunięcia wszystkich elementów ze struktury. Czy potrafisz napisać procedurę usuwającą wszystkie elementy w czasie O(m log m), gdzie m to liczba elementów znajdujących się w strukturze.
  3. * Czy można użyć drzewa licznikowego do przechowywania niewielkiej liczby dowolnych liczb (np. 100000 liczb rzeczywistych) ?
  4. ** Procedura Prawo potrzebuje czasu O(log n), aby znaleźć kolejny element. Z tego powodu wypisanie wszystkich elementów za jej pomocą zajmuje czas O(m*log n). Czy potrafisz usprawnić procedurę prawo, aby wypisanie elementów działało w czasie O(min(m*log n, n)) ?

KONIEC