Algorytmy i struktury danych/Wyszukiwanie

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

Wyszukiwanie


W niniejszym wykładzie opiszemy podstawowe techniki dotyczące wyszukiwania. Zajmiemy się również prostymi strukturami słownikowymi, które oprócz wyszukiwania, umożliwiają dodawanie i usuwanie elementów.


Wyszukiwanie liniowe

Jeśli nie dysponujemy żadną dodatkową wiedzą na temat zbioru, który chcemy przeszukiwać, to niestety musimy sprawdzić wszystkie jego elementy.

function Szukaj(x, A[1..n])
begin
  for i:=1 to n do
    if A[i]=x return i;
  return brak poszukiwanego elementu;
end

Oczywiście w pesymistycznym przypadku (np. gdy zbiór nie zawiera poszukiwanego elementu) koszt czasowy, to .

Wyszukiwanie binarne

W niektórych przypadkach czas poszukiwania, możemy znacząco zmniejszyć. Dzieje się tak na przykład, gdy przeszukiwany zbiór przechowujemy w rosnąco uporządkowanej tablicy. W takim przypadku, wystarczy jedynie operacji, by odnaleźć poszukiwany element, lub stwierdzić jego brak.

Algorytm utrzymuje zakres , w którym może znajdować się element, przy każdym porównaniu zakres zostaje zmniejszony o połowę.

function WyszukiwanieBinarne(x, A[1..n])
{ zakładamy, że tablica A, jest uporządkowana rosnąco }
begin
  l:=1;r:=n;
  while (l<=r) do begin
    { niezmiennik, poszukiwany element, może znajdować się w zakresie A[l..r] }
    m:=(l+r) div 2;
    if (A[m]<x) then l:=m+1
    else if (A[m]>x) then r:=m-1
    else return m; { ponieważ A[m]=x } 
  end;
  return brak poszukiwanego elementu;
end;

Proste słowniki: drzewa poszukiwań binarnych

Podstawowe operacje słownika to wyszukiwanie, wstawianie i usuwanie klucza. Drzewa poszukiwań binarnych (bez dodatkowych specjanych wymagań) mogą być traktowane jako prosty słownik. Sa to zwykłe drzewa binarne, których klucze spełniają następujące własności:

Dla dowolnego węzła x:

  • wszystkie klucze w lewym poddrzewie węzła x, mają wartości mniejsze niż klucz węzła x,
  • wszystkie klucze w lewym poddrzewie węzła x, mają wartości większe lub równe niż klucz węzła x.
Wyszukiwanie.1.png

Dodatkowe wymaganie dotyczące kluczy, umożliwia nam efektywne wyszukiwanie elementów w drzewie.

 function Szukaj(węzeł, klucz)
    if (węzeł==nil) 
       return BRAK ELEMENTU
    if (węzeł.klucz=klucz) then
       return ELEMENT ISTNIEJE
     else if (klucz < węzeł.klucz) then
       return Szukaj(węzeł.lewePoddrzewo, klucz)
    else if (klucz > węzeł.klucz) then
       return Szukaj(węzeł.prawPoddrzewo, klucz)
 end;

Wstawianie do drzewa jest bardzo zbliżone do wyszukiwania, musimy przejść po drzewie (rozpoczynająć w korzeniu) aby odnaleźć wolne miejsce w którym możemy dodać nową wartość.

 procedure Dodaj(węzeł, klucz)
    if (klucz < węzeł.klucz) then
       if węzeł.lewePoddrzewo=nil then
           utwórz nowy węzeł z wartością klucz
           wskaźnik do nowego węzła zapisujemy w węzeł.lewePoddrzewo
       else
           Dodaj(węzeł.lewePoddrzewo, klucz)
    else if (klucz >= węzeł.klucz) then
       if węzeł.prawePoddrzewo=nil then
           utwórz nowy węzeł z wartością klucz
           wskaźnik do nowego węzła zapisujemy w węzeł.prawePoddrzewo
       else
           Dodaj(węzeł.prawePoddrzewo, klucz)
 end;

Możemy również usuwać wartości z drzewa, niestety ta operacja jest bardziej skomplikowana.

 procedure Usuń(węzeł, klucz) 
   if (klucz < węzeł.klucz) then
       Usuń(węzeł.lewePoddrzewo, klucz)
    else if (klucz > węzeł.klucz) then
       Usuń(węzeł.prawePoddrzewo, klucz)    
    else begin { klucz = węzeł.klucz
       if węzeł jest liściem, then
         { usuń węzeł z drzewa }
         UsunProstyPrzypadek(węzeł)
       else
         if węzeł.lewePoddrzewo <> nil then
           niech x oznacza skrajnie prawy węzeł w poddrzewie węzeł.lewePoddrzewo
           wezel.klucz:=x.klucz;
           UsunProstyPrzypadek(x);
         else
           analogiczne postępowanie dla węzeł.prawPoddrzewo 
           (jednak poszukujemy węzła na skrajnie lewej ścieżce)
    end

Procedura UsunProstyPrzypadek oznacza usuwanie z drzewa węzła, który ma co najwyżej jednego syna.

 procedure UsunProstyPrzypadek(węzeł)
   poddrzewo:=nil;
   ojciec:=węzeł.ojciec;
   if (węzeł.lewePoddrzewo) then
     poddrzewo:=węzeł.lewePoddrzewo;
   else    
     poddrzewo:=węzeł.prawePoddrzewo;    
   
   if (ojciec=nil) then
     korzen:=poddrzewo;
   else if ojciec.lewePoddrzewo=węzeł then { węzeł jest lewym synem }
     ojciec.lewePoddrzewo:=poddrzewo;
   else { węzeł jest prawym synem }
     ojciec.prawePoddrzewo:=poddrzewo;

Wszystkie podane operacje mają pesymistyczny koszt gdzie oznacza wysokość drzewa. Niestety w najgorszym przypadku drzewo może mieć bardzo dużą wysokość -- nawet (np. dla ciągu operacji Dodaj ).

Adresowanie bezpośrednie

W przypadku gdy zbiór który przechowujemy pochodzi z niewielkiego uniwersum (na przykład elementy zbioru to liczby z zakresu ), możemy wszystkie operacje słownikowe (dodaj, usuń, szukaj) wykonać znacznie szybciej i prościej.

Dla uniwersum zbiór możemy reprezentować przez tablicę -elementową. Początkowo w każdej komórce tablicy wpisujemy wartość false.

  • dodanie elementu do zbioru, wymaga jedynie ustawienia wartości -tej komórki na true,
  • analogicznie usunięcie elementu do zbioru, wymaga ustawienia wartości -tej komórki na false,
  • sprawdzenie czy element należy do zbioru wykonujemy przez sprawdzenie stanu -tej komórki.

Wszystkie powyższe operacje możemy wykonać używając stałej liczby kroków.

Haszowanie

Czy możemy wykorzystać adresowanie bezpośrednie do dowolnych zbiorów? Okazuje się, że tak. Co prawda w pesymistycznym przypadku koszt jednej operacji może wynosić nawet , jednak w praktycznych zastosowaniach ta metoda sprawuje się doskonale.

W tym rozdziale będziemy zakładać, że elementy uniwersum , to dodatnie liczby całkowite. Dodatkowo zakładamy, że dysponujemy tablicą .

Ponieważ elementami mogą być bardzo duże liczby całkowite, stąd nie możemy zastosować metody adresowania bezpośredniego. Jednak możemy wybrać funkcję haszującą:

Funkcja ta dla każdego elementu uniwersum przypisuje odpowiednie miejsce w tablicy . Jeśli , to z oczywistych względów znajdą się takie pary różnych elementów , dla których . W takim przypadku mówimy o istnieniu kolizji. Właśnie ze względu na ryzyko wystąpienia kolizji, musimy nieznacznie zmodyfikować metodę adresowania bezpośredniego - zamiast przechowywać w tablicy wartość logiczną (prawda/fałsz), musimy zapisywać wartość przechowywanego elementu.

Rozwiązywanie kolizji metodą łańcuchową

Jedną z metod rozwiązywania kolizji jest utrzymywanie w każdej komórce tablicy listy elementów do niej przypisanych. Początkowo tablica wypełniona jest wartościami nil.

procedure Inicjalizacja;
begin
  for i:=0 to m-1 do A[i]:=nil;
end;

Dodanie elementu do tablicy, wymaga odczytania jego adresu z funkcji haszującej, a następnie dodania go na początek listy

procedure Dodaj(x);
begin
  dodaj element  na początek listy 
end;

Sprawdzenie czy element istnieje, wymaga w pesymistycznym przypadku sprawdzenia całej listy

function Szukaj(x);
begin
  l:=A[h(x)];
  while (l!=nil) do begin
     if (l.wartość=x) then return element istnieje
     l:=l.nast;
  end;
  return brak elementu
end;

Usuwanie elementu z tablicy jest bardzo podobne do wyszukiwania, i również w pesymistycznym przypadku wymaga sprawdzenia całej listy.

procedure Usuń(x);
begin
  l:=A[h(x)];p:=nil;
  while (l!=nil) do begin
     if (l.wartość=x) then begin
        { usuwamy element l z listy A[h(x)] }
        if p=nil then A[h(x)]:=l.nast;
        else p.nast:=l.nast;
        return;
     end
     p:=l; l:=l.nast;
  end;
end;

Wybór funkcji haszujących

Od wyboru dobrej funkcji haszującej w dużej mierze zależy efektywność maszej struktury danych. Niestety nie można podać ścisłej procedury wyboru takiej funkcji.

Dla liczb całkowitych, możemy przykładowo wybrać jedną z następujących funkcji ( oznacza rozmiar tablicy):

  • (gdzie jest liczbą pierwszą), lub
  • (gdzie są liczbami pierwszymi).
  • (gdzie jest liczbą pierwszą, , ).

Jeśli nie dysponujemy żadną dodatkową wiedzą na temat elementów które będzie zawierać tablica, to rozsądnym rozwiązanie jest wybór funkcji dla losowych wartości .

Adresowanie otwarte

Adresowanie otwarte, jest metodą pozwalającą uniknąć utrzymywania list elementów w tablicy haszującej. Oczywiście wymaga, to opracowania innej metody rozwiązywania konfliktów. Używając tej metody, każda komórka tablicy zawiera wartość NIL lub element zbioru.

Niech ozncza rozmiar tablicy haszującej. Zdefiniujmy funkcję , która wyznacza listę pozycji w których może znajdować się element .

Mając daną funkcję możemy zdefiniować , jako:

  • --- adresowanie liniowe,
  • --- adresowanie kwadratowe,
  • --- podwójne haszowanie, przy czym jest funkcją haszującą, która przyjmuje wartości z zakresu

Wyszukiwanie elementów możemy wykonać nieznacznie modyfikując poprzednie rozwiązanie -- zamiast przeszukiwać listę elementów, musimy przeszukać ciąg pozycji zdefiniowany przez funkcję .

function Szukaj(x);
begin
  k:=0;
  while (A[H(x,k)!=nil) do begin
     if (A[H(x,k)].wartość=x) then return element istnieje
     k:=k+1;
  end;
  return brak elementu
end;

Wstawianie i usuwanie elementów możemy wykonać przez analogiczne modyfikacje.

Filtry Bloom'a

Ciekawym połączeniem adresowania bezpośredniego z haszowaniem są filtry Bloom'a. Polegają one na rozlużnieniu założeń naszej struktury:

  • ograniczamy się do operacji Dodaj i Szukaj,
  • pozwalamy na nieprawidłowe odpowiedzi dla operacji Szukaj (jednak z małym prawdopodobieństwem).

Nasza struktura to bitowa tablica o rozmiarze m, początkowo tablica jest wypełniona zerami. Zakładamy, również, że mamy do dyspozycji k funkcji haszujących .

Dodanie elementu x sprowadza się do ustawienia wszystkich bitów dla na wartość 1.

procedure Dodaj(x);
begin
  for i:=1 to k do A[]:=1;
end;

Analogicznie sprawdzenie czy element x należy do zbioru, wymaga sprawdzenia bitów dla . Jeśli wszystkie mają wartość 1, to uznajemy, że element należy do zbioru. Oczywiście powoduje to pewien odsetek błędnych odpowiedzi. Jednak, jeśli dobrze dobierzemy funkcje, a wypełnienie tablicy nie będzie zbyt duże, to taka struktura będzie dobrze sprawować się w praktyce.

function Szukaj(x);
begin
  for i:=1 to k do 
    if A[]=0 then return brak elementu
  return element istnieje
end;

Literatura

[CLRS] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein, 'Wprowadzenie do algorytmów', WNT, Warszawa 2004.


powrót do strony przedmiotu