Tablice z haszowaniem - efektywna metoda reprezentacji słowników (zbiorów dynamicznych, na których zdefiniowane są operacje Insert, Search i Delete) - jest uogólnieniem zwykłej tablicy - przyspiesza operacje wyszukiwania w porównaniu wyszukiwaniem na liście z dowiązaniami - inne nazwy haszowania: mieszanie, rozpraszanie Tablice z adresowaniem bezpośrednim Załóżmy, że używamy zbioru dynamicznego, gdzie elementy mogą mieć klucze ze zbioru U={0, 1,..., m-1}, gdzie m nie jest zbyt duże. Dodatkowo załóżmy, że żadne dwa elementy nie mogą mieć identycznych kluczy. 1
Przy takich założeniach możemy zbiór dynamiczny reprezentować za pomocą tablicy T[0,..., m-1], w której każdej pozycji będzie odpowiadał klucz należący do zbioru U. Na pozycji k w tablicy znajduje się wskaźnik do elementu o kluczu k. Jeżeli do zbioru nie należy żaden element o kluczu k, to T[k]==NIL. 2
Implementacja operacji słownikowych na zbiorze: DIRECT-ADDRESS-SEARCH(T,k): zwróć T[k] DIRECT-ADDRESS-INSERT(T,x): T[key[x]] = x DIRECT-ADDRESS-DELETE(T,x): T[key[x]] = NIL Wszystkie te operacje działają w czasie O(1). Czasem elementy zbioru mogą być przechowywane w samej tablicy, szczególnie, gdy nie są z nimi związane inne dane. Kluczem elementu może być np. indeks pozycji elementu w tablicy. Wady podejścia: jeżeli zbiór U jest duży, to trzeba przechowywać całą tablicę o rozmiarze U, nawet gdy elementów będzie niewiele. 3
Tablice z haszowaniem W tablicy z haszowaniem, element o kluczu k trafia na pozycję h(k) (a nie na pozycję k, jak przy adresowaniu bezpośrednim), gdzie h jest tzw. funkcją haszującą. Funkcja haszująca h odwzorowuje zbiór kluczy U na zbiór pozycji w tablicy z haszowaniem T[0,..., m-1]. h: U {0,..., m-1}. 4
Zalety: ogromna oszczędność pamięci Wada: możliwość kolizji, tzn. sytuacji, w której funkcja haszująca przypisuje to samo miejsce w tablicy dwu różnym kluczom. Metody rozwiązywania kolizji 1. Unikanie kolizji Sposobem na minimalizację prawdopodobieństwa zajścia kolizji jest dobry wybór funkcji haszującej. Musi to być funkcja bardzo bliska funkcji losowej, ale oczywiście deterministyczna (tzn. dla danego k zawsze dawać ten sam wynik h(k)). Jednak ze względu na to, że może zachodzić U >m, w U muszą się w takiej sytuacji znaleźć dwa klucze, dla których funkcja haszująca da tę samą wartość całkowite uniknięcie kolizji nie jest więc możliwe. 5
2. Metoda łańcuchowa Wszystkie elementy, którym odpowiada ta sama pozycja w tablicy umieszczamy na jednej liście. Na pozycji j w tablicy pamiętamy wskaźnik do początku listy tych wszystkich elementów, dla których funkcja h daje wartość j. Jeżeli w zbiorze nie ma takich elementów, to pozycja j ma wartość NIL. 6
Implementacja: CHAINED-HASH-INSERT(T,x): wstaw x na początek listy T[h(key[x])] Działa zawsze w czasie O(1). CHAINED-HASH-SEARCH(T,k): wyszukaj element o kluczu k na liście T[h(k)] Pesymistyczny czas proporcjonalny do długości listy. CHAINED-HASH-DELETE(T,x): usuń element x z listy T[h(key[x])] Jeżeli listy są dwukierunkowe czas O(1). Jeżeli jednokierunkowe, to najpierw trzeba odszukać element na liście aby zaktualizować pole next jego poprzednika czas jak przy wyszukiwaniu. 7
Analiza wydajności wyszukiwania przy metodzie łańcuchowej Współczynnik zapełnienia tablicy o m pozycjach, w której znajduje się n elementów: α=n/m. Najgorszy przypadek: wszystkich n kluczy jest odwzorowanych na tę samą pozycję w tablicy, tworząc listę o długości n. Pesymistyczny czas wyszukiwania w takim przypadku: Θ(n) plus czas na obliczenie wartości funkcji haszującej (zwykle O(1)) brak poprawy dla jednej listy dla wszystkich elementów. Proste równomierne haszowanie: losowo wybrany element z jednakowym prawdopodobieństwem trafia na każdą z m pozycji, niezależnie od tego, gdzie trafiają inne elementy. 8
Twierdzenie W tablicy z haszowaniem wykorzystującym metodę łańcuchową rozwiązywania kolizji, przy założeniu o prostym równomiernym haszowaniu, średni czas działania procedury wyszukiwania zakończonego porażką wynosi Θ(1+α). Dowód: Każdy klucz k jest z jednakowym prawdopodobieństwem odwzorowywany na każdą z m pozycji w tablicy. Średni czas wyszukiwania zakończonego porażką jest więc równy średniemu czasowi przejścia do końca jednej z m list. Średnia długość takiej listy jest równa współczynnikowi zapełnienia tablicy α=n/m. Stąd oczekiwana liczba sprawdzonych elementów jest równa α, całkowity czas wynosi więc (po uwzględnieniu czasu potrzebnego na obliczenie h(k) oraz dostęp do pozycji T[h(k)]) wynosi Θ(1+α). 9
Twierdzenie W tablicy z haszowaniem wykorzystującym metodę łańcuchową rozwiązywania kolizji, przy założeniu o prostym równomiernym haszowaniu, średni czas działania procedury wyszukiwania wynosi Θ(1+α). Dowód dla wyszukiwania zakończonego sukcesem: Klucz, którego szukamy, to (z jednakowym prawdopodobieństwem) dowolny spośród n kluczy znajdujących się w tablicy. Załóżmy też dla uproszczenia dowodu, że procedura CHAINED- HASH-INSERT wstawia elementy na końcu, a nie na początku listy (można wykazać, że średni czas wyszukiwania jest w obu przypadkach identyczny). Wniosek: jeżeli liczba pozycji w tablicy jest co najmniej proporcjonalna do liczby elementów w tablicy (czyli zachodzi n=o(m)), to jednocześnie α = n/m = O(m)/m = O(1). 10
3. Adresowanie otwarte Wszystkie elementy przechowujemy wprost w tablicy. Każda pozycja tablicy zawiera więc albo element zbioru dynamicznego, albo NIL. Aby nie nastąpiło przepełnienie tablicy, współczynnik zapełnienia α nie może przekroczyć 1. Wstawianie do tablicy wymaga znalezienia w niej wolnej pozycji, przy czym kolejność sprawdzania pozycji w tablicy powinna zależeć od wstawianego klucza. Wyszukanie elementu polega na systematycznym sprawdzaniu pozycji w tablicy aż zostanie znaleziony szukany element albo wiadomo na pewno, że nie znajduje się on w tablicy. Nie są używane żadne dodatkowe listy. Przeszukiwanie odbywa się tą samą ścieżką, jak wstawianie elementu do tablicy. Inne metody: adresowanie liniowe, kwadratowe, dwukrotne itp. 11
Funkcje haszujące Cechy dobrej funkcji haszującej - powinna spełniać założenie prostego równomiernego haszowania: losowo wybrany klucz trafia z jednakowym prawdopodobieństwem na każdą z m pozycji. Niech P(k) będzie prawdopodobieństwem wybrania klucza k. Wtedy mamy: h: h( k ) = j P( k) = 1/ m dla j=0, 1,..., m-1 (warunek ten jest jednak trudno sprawdzić zwykle nie znamy P(k)) - powinna minimalizować szansę, że niewiele różniące się symbole ze zbioru U będą odwzorowane na tę samą pozycję w tablicy z haszowaniem. - powinna być maksymalnie niezależna od występujących tendencji w zbiorze danych 12
Utożsamienie kluczy z liczbami naturalnymi Dziedziną większości funkcji haszujących jest zbiór liczb naturalnych {0, 1, 2,...}. Jeżeli klucze nie są liczbami naturalnymi, należy ustalić ich odwzorowanie w zbiór liczb naturalnych. Przykład: mamy klucz w postaci ciągu tekstowego: pi. W kodzie ASCII p=112, i=105. Potraktujmy napis jako zapis liczby w układzie o podstawie 128, otrzymując: (112*128)+105=14441. A zatem pi 14441. 13
Haszowanie modularne Funkcja haszująca na kluczu k daje wartość będącą resztą z dzielenia k przez m, gdzie m jest liczbą pozycji w tablicy: h(k) = k mod m Przykład: m=12 (12 pozycji w tablicy z haszowaniem), klucz k=100; h(k)=4. Haszowanie modularne wymaga tylko jednego dzielenia całkowitego. W metodzie należy unikać pewnych wartości m. Na przykład m nie powinno być potęgą 2, ponieważ jeżeli m=2 p, to h(k) jest liczbą powstającą z p najmniej znaczących bitów liczby k (a funkcja haszująca powinna zależeć od całego klucza a nie jego części). 14
Podobnie, należy unikać potęg 10, jeżeli klucze są liczbami dziesiętnymi (wtedy również nie będą uwzględniane wszystkie cyfry dziesiętne klucza). Można też wykazać, że jeżeli m=2 p -1 oraz k jest ciągiem interpretowanym jako liczba o podstawie 2 p, to wartości funkcji haszującej na dwóch ciągach, różniących się tylko odwróceniem kolejności dwu sąsiednich znaków, są sobie równe. Przykład: p=4; podstawa układu liczbowego: 2 p =16; pojemność tablicy m=2 p - 1 =15; dla k=123456789 oraz k=213456789 otrzymujemy h(k)= k mod m = 9. Dobre wartości m: liczby pierwsze niezbyt bliskie potęgom 2. Przykład: chcemy przechowywać w tablicy z haszowaniem ok. 2000 ciągów znaków, przy czym każdy znak składa się z 8 bitów. Kolizje chcemy rozwiązywać metodą łańcuchową przy założeniu, że średnia długość list wyniesie 3 (czyli α=3). Wybieramy m=701. Uzasadnienie: 701 to liczba pierwsza leżąca blisko 2000/α = 2000/3 a zarazem leży daleko od potęg dwójki. Jeżeli każdy klucz k będzie interpretowany jako liczba, to funkcją haszującą będzie h(k)= k mod 701. 15
Haszowanie przez mnożenie Krok 1: mnożymy klucz k przez stałą A z przedziału 0<A<1 i wyznaczamy część ułamkową iloczynu ka. Krok 2: mnożymy otrzymaną wartość przez m oraz zaokrąglamy ją w dół. h(k) = m(ka mod 1) (ka mod 1 oznacza ułamkową część ka, tzn. ka- ka ) Wybór m jest w zasadzie dowolny. Najczęściej jednak wybieramy m jako pewna potęgę 2: m=2 p ze względu na łatwość implementacji. 16
Przykład liczbowy: k=123456 m=10000 A=0.61803 h(k) = 10000*(123456*0.61803 mod 1) = = 10000*(76300.0041151 mod 1) = = 10000*0.0041151 = = 41.151 = = 41 17
Haszowanie uniwersalne W przypadku ustalonej funkcji haszującej, zawsze istnieje możliwość odwzorowania wszystkich kluczy na tę samą pozycję w tablicy (przez złośliwy wybór kluczy), przez co wyszukiwanie będzie działało w czasie liniowym. Jedyny sposób na uniknięcie takiego zagrożenia losowe dobieranie funkcji haszującej w sposób niezależny od wstawianych kluczy. Podejście takie nazywa się haszowaniem uniwersalnym. (analogia do sortowania szybkiego) Losowość wyboru funkcji haszującej sprawia, że algorytm może się różnie zachowywać nawet przy tych samych danych. Średnie czasy działania operacji na tablicach z haszowaniem pozostają małe. 18
Definicja Niech H będzie skończoną rodziną funkcji haszujących, odwzorowujących zbiór kluczy U w zbiór {0,1,...,m-1}. Taką rodzinę nazywamy uniwersalną, jeżeli dla każdej pary różnych kluczy x, y U liczba funkcji haszujących h H, dla których h(x)=h(y) jest równa dokładnie H /m. Innymi słowy, jeżeli losowo wybierzemy funkcję haszującą, to prawdopodobieństwo kolizji wynosi dokładnie 1/m. Twierdzenie Niech h będzie funkcją wybraną losowo z uniwersalnej rodziny funkcji haszujących. Jeśli haszujemy za jej pomocą n kluczy w tablicy o rozmiarze m, gdzie n m, to oczekiwana liczba kolizji, w które wchodzi ustalony klucz x jest mniejsza od 1. 19