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.
Spis treści [schowaj] · 3 Proste słowniki: drzewa poszukiwań binarnych o 5.1 Rozwiązywanie kolizji metodą łańcuchową o 5.2 Analiza metody łańcuchowej |
Jeśli nie dysponujemy żadną dodatkową wiedzą na temat zbioru, który chcemy przeszukiwać, niestety musimy sprawdzić wszystkie jego elementy.
1 function Szukaj(x, A[1..n])
2 begin
3 for i:=1 to n do
4 if A[i]=x return i;
5 return brak poszukiwanego elementu;
6 end
Oczywiście w pesymistycznym przypadku (np. gdy zbiór nie zawiera poszukiwanego elementu) koszt czasowy, to .
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ę.
1 function WyszukiwanieBinarne(x, A[1..n])
2 { zakładamy, że tablica A jest uporządkowana rosnąco }
3 begin
4 l:=1;r:=n;
5 while (l<=r) do begin
6 { niezmiennik: poszukiwany element może znajdować się w zakresie A[l..r] }
7 m:=(l+r) div 2;
8 if (A[m]<x) then l:=m+1
9 else if (A[m]>x) then r:=m-1
10 else return m; { ponieważ A[m]=x }
11 end;
12 return brak poszukiwanego elementu;
13 end;
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 prawym poddrzewie węzła x mają wartości większe lub równe niż klucz węzła x.
Dodatkowe wymaganie dotyczące kluczy umożliwia nam efektywne wyszukiwanie elementów w drzewie.
1 function Szukaj(węzeł, klucz)
2 if (węzeł==nil)
3 return BRAK ELEMENTU
4 if (węzeł.klucz=klucz) then
5 return ELEMENT ISTNIEJE
6 else if (klucz < węzeł.klucz) then
7 return Szukaj(węzeł.lewePoddrzewo, klucz)
8 else if (klucz > węzeł.klucz) then
9 return Szukaj(węzeł.prawPoddrzewo, klucz)
10 end;
Wstawianie do drzewa jest bardzo zbliżone do wyszukiwania: musimy przejść po drzewie (rozpoczynając w korzeniu), aby odnaleźć wolne miejsce, w którym możemy dodać nową wartość.
1 procedure Dodaj(węzeł, klucz)
2 if (klucz < węzeł.klucz) then
3 if węzeł.lewePoddrzewo=nil then
4 utwórz nowy węzeł z wartością klucz
5 wskaźnik do nowego węzła zapisujemy w węzeł.lewePoddrzewo
6 else
7 Dodaj(węzeł.lewePoddrzewo, klucz)
8 else if (klucz >= węzeł.klucz) then
9 if węzeł.prawePoddrzewo=nil then
10 utwórz nowy węzeł z wartością klucz
11 wskaźnik do nowego węzła zapisujemy w węzeł.prawePoddrzewo
12 else
13 Dodaj(węzeł.prawePoddrzewo, klucz)
14 end;
Możemy również usuwać wartości z drzewa, niestety ta operacja jest bardziej skomplikowana.
1 procedure Usuń(węzeł, klucz)
2 if (klucz < węzeł.klucz) then
3 Usuń(węzeł.lewePoddrzewo, klucz)
4 else if (klucz > węzeł.klucz) then
5 Usuń(węzeł.prawePoddrzewo, klucz)
6 else begin { klucz = węzeł.klucz
7 if węzeł jest liściem, then
8 { usuń węzeł z drzewa }
9 UsunProstyPrzypadek(węzeł)
10 else
11 if węzeł.lewePoddrzewo <> nil then
12 niech x oznacza skrajnie prawy węzeł w poddrzewie węzeł.lewePoddrzewo
13 wezel.klucz:=x.klucz;
14 UsunProstyPrzypadek(x);
15 else
16 analogiczne postępowanie dla węzeł.prawPoddrzewo
17 (jednak poszukujemy węzła na skrajnie lewej ścieżce)
18 end
Procedura UsunProstyPrzypadek oznacza usuwanie z drzewa węzła, który ma co najwyżej jednego syna.
1 procedure UsunProstyPrzypadek(węzeł)
2 begin
3 poddrzewo:=nil;
4 ojciec:=węzeł.ojciec;
5 if węzeł.lewePoddrzewo <> nil then
6 poddrzewo:=węzeł.lewePoddrzewo;
7 else
8 poddrzewo:=węzeł.prawePoddrzewo;
9 if ojciec=nil then
10 korzen:=poddrzewo;
11 else if ojciec.lewePoddrzewo=węzeł then { węzeł jest lewym synem }
12 ojciec.lewePoddrzewo:=poddrzewo;
13 else { węzeł jest prawym synem }
14 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 ).
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.
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, nie możemy zastosować metody adresowania bezpośredniego. Jednak możemy wybrać funkcję haszującą:
Funkcja każdemu elementowi 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.
1 procedure Inicjalizacja;
2 begin
3 for i:=0 to m-1 do A[i]:=nil;
4 end;
Dodanie elementu do tablicy wymaga odczytania jego adresu z funkcji haszującej, a następnie dodania go na początek listy
1 procedure Dodaj(x);
2 begin
3 dodaj element na początek listy
4 end;
Sprawdzenie, czy element jest w zbiorze, wymaga w pesymistycznym przypadku sprawdzenia całej listy
1 function Szukaj(x);
2 begin
3 l:=A[h(x)];
4 while (l!=nil) do begin
5 if (l.wartość=x) then return element istnieje
6 l:=l.nast;
7 end;
8 return brak elementu
9 end;
Usuwanie elementu z tablicy jest bardzo podobne do wyszukiwania i również w pesymistycznym przypadku wymaga sprawdzenia całej listy.
1 procedure Usuń(x);
2 begin
3 l:=A[h(x)];p:=nil;
4 while (l!=nil) do begin
5 if (l.wartość=x) then begin
6 { usuwamy element l z listy A[h(x)] }
7 if p=nil then A[h(x)]:=l.nast;
8 else p.nast:=l.nast;
9 return;
10 end
11 p:=l; l:=l.nast;
12 end;
13 end;
Haszowanie to metoda, która bardzo dobrze sprawdza się w praktyce, zastanówmy się przez chwilę, czy dobre własności tej metody można udowodnić.
Niech:
· m oznacza rozmiar tablicy,
· n oznacza liczbę elementów, które przetrzymujemy w tablicy,
· przez oznaczamy współczynnik zapełniania tablicy
Załóżmy, że dysponujemy idealną funkcją haszującą, dla której przypisanie każda wartość jest równie prawdopodobna, czyli .
Przy takim założeniu oczekiwana długość listy elementów ma długość (oczekiwana liczba sukcesów w n próbach Bernoulli'ego).
Stąd oczekiwana złożoność operacji Szukaj, Dodaj, Usuń wynosi:
Od wyboru dobrej funkcji haszującej w dużej mierze zależy efektywność naszej 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ą);
· (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, rozsądnym rozwiązaniem jest wybór funkcji dla losowych wartości .
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. Każda komórka tablicy zawiera teraz wartość NIL lub element zbioru.
Niech oznacza rozmiar tablicy haszującej. Zdefiniujmy funkcję , wyznaczającą listę pozycji , na 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ę .
1 function Szukaj(x);
2 begin
3 k:=0;
4 while (A[H(x,k)!=nil) do begin
5 if (A[H(x,k)].wartość=x) then return element istnieje
6 k:=k+1;
7 end;
8 return brak elementu
9 end;
Wstawianie elementów możemy wykonać przez analogiczne modyfikacje; usuwanie jest zwykle znacznie trudniejsze.
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.
1 procedure Dodaj(x);
2 begin
3 for i:=1 to k do A[]:=1;
4 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. Jeśli jednak dobrze dobierzemy funkcje, a wypełnienie tablicy nie będzie zbyt duże, to taka struktura będzie dobrze sprawować się w praktyce.
1 function Szukaj(x);
2 begin
3 for i:=1 to k do
4 if A[]=0 then return brak elementu
5 return element istnieje
6 end;