220 likes | 417 Views
Haszowanie. Jakub Radoszewski. Postawienie problemu. Poszukujemy struktury danych, z pomocą której moglibyśmy efektywnie wykonywać operacje: wstawianie obiektów, usuwanie obiektów, wyszukiwanie obiektów. Obiekty : liczby, napisy, rekordy z danymi personalnymi.
E N D
Haszowanie Jakub Radoszewski
Postawienie problemu Poszukujemy struktury danych, z pomocą której moglibyśmy efektywnie wykonywać operacje: • wstawianie obiektów, • usuwanie obiektów, • wyszukiwanie obiektów. Obiekty: liczby, napisy, rekordy z danymi personalnymi. Zastosowania: bazy danych, sieci komputerowe, ...
Obiekty to liczbyz przedziału [0, N-1] • Szukana struktura to prosta tablica N-elementowa: bool t[N]; • Wstawianie: void wstaw(int n) { t[n] = true; } • Usuwanie: void usun(int n) { t[n] = false; } • Wyszukiwanie: bool wyszukaj(int n) { return t[n]; }
Przypadek ogólny • Szukamy funkcji h, przekształcającej obiekty w liczby z przedziału [0,N-1]. h to funkcja haszująca (funkcja skrótu). • Obiekty mogą być parami (klucz,wartość), np. (imię i nazwisko, informacje o przelewach bankowych). Funkcja haszująca może operować tylko na kluczach. • Mamy teraz tablicę obiektów: obiekt t[N]; • Przy wstawianiu wykorzystujemy funkcję h: void wstaw(obiekt o) { int hasz = h(o.klucz); t[hasz] = o; }
Przypadek ogólny cd. • Usuwanie wygląda podobnie: (BRAK jest jakąś stałą, oznaczającą brak obiektu) void usun(obiekt o) { int hasz = h(o.klucz); t[hasz] = BRAK; } • Wyszukiwać możemy np. po kluczu: obiekt wyszukaj(klucz k) { int hasz = h(k); return t[hasz]; } • Wniosek: o ile wyznaczanie h(klucz) jest szybkie, to wstawianie, usuwanie i wyszukiwanie też są szybkie!
Funkcje haszujące • Pytanie: skąd wziąć odpowiednią funkcję haszującą? • Załóżmy, że klucze są (dużymi) liczbami, np. z zakresu -109..109 (typ int w C++). • Przykłady wykorzystywanych funkcji haszujących: h(x) = x % p, gdzie p jest pierwsze h(x) = (x*p + r) % q, gdzie p i q są pierwsze h(x) = [((x*A) mod 1)*m], gdzie A jest z przedziału (0,1)
Problem kolizji • Liczb z zakresu -109..109 jest znacznie więcej, niż N. • Zasada szufladkowa Dirichleta podpowiada, że mogą być kolizje. • Chcemy, żeby h była bardzo „losowa”, żeby dobrze rozrzucała klucze. • Paradoks urodzin: Jeżeli w jednej sali jest co najmniej (= ok. 19) osób, to z prawdopodobieństwem ½ dwie z nich obchodzą urodziny tego samego dnia. • Wniosek: nawet przy bardzo losowej funkcji haszującej, już po wstawieniach elementów jest duża szansa na wystąpienie kolizji! • Istnieje kilka sposobów zaradzenia tej sytuacji.
Adresowanie otwarte • Pomysł: jeżeli dane miejsce w tablicy jest zajęte, to spróbuj obiekt umieścić gdzieś indziej. • Adresowanie liniowe: jeżeli pozycja h(k) jest zajęta, spróbuj umieścić obiekt na pozycji h(k)+1, w razie kolejnej porażki – na pozycji h(k)+2, ... • Adresowanie kwadratowe: jeżeli pozycja h(k) jest zajęta, to próbuj (do skutku) umieszczać dany obiekt na pozycjach h(k)+12, h(k)-12, h(k)+22, h(k)-22, h(k)+32, ... • Haszowanie dwukrotne (rehaszowanie): stanowi rozszerzenie dwóch pierwszych pomysłów. Rozważane są mianowicie pozycje h(k)+g(k), h(k)+2*g(k), ..., gdzie g jest jakąś inną funkcją haszującą.
Metoda łańcuchowa • Dla każdej wartości hasza z przedziału [0,N-1] w tablicy t przechowujemy zbiór obiektów, których klucze mają taką samą wartość hasza. • Pytanie: jak reprezentować zbiór obiektów, którego rozmiaru nie potrafimy przewidzieć? • Rozwiązanie: dynamiczne struktury danych (np. listy). • W C++ da się prościej za pomocą vectora. • vector to jeden z kontenerów z biblioteki standardowej C++ (tzw. STL). Zapewne o STL-u nieraz jeszcze usłyszycie.
vector w C++ • Potrzebujemy załadować plik nagłówkowy: #include <vector> using namespace std; • vector działa jak tablica zmiennego rozmiaru: vector<int> v; - deklaracja vectora intów (ogólnie vector<typ>) UWAGA: vector domyślnie jest pusty! v.push_back(123); - wstawienie elementu 123 na koniec vectora, co powoduje jego powiększenie v.pop_back(); - usunięcie ostatniego elementu (zmniejszenie) v[7] = 5; - działa jak dla tablicy, o ile ósmy element v istnieje int rozm = v.size(); - zwraca aktualny rozmiar vectora if (v.empty()) ... ; - czy vector jest pusty? i wiele innych operacji...
Wykorzystanie vectora • Deklaracja tablicy t: vector<obiekt> t[N]; • Wstawianie obiektu: void wstaw(obiekt o) { int hasz = h(o.klucz); t[hasz].push_back(o); } • Usuwanie obiektu: void usun(obiekt o) { int hasz = h(o.klucz); for (int i = 0; i < t[hasz].size(); i++) if (t[hasz][i] == o) { /* Przerzuć na koniec i usuń */ swap(t[hasz][i], t[hasz][t[hasz].size()-1]); t[hasz].pop_back(); } }
Wykorzystanie vectora cd. • Wyszukiwanie obiektu: obiekt wyszukaj(klucz k) { int hasz = h(k); for (int i = 0; i < t[hasz].size(); i++) if (t[hasz][i].klucz == k) return t[hasz][i]; return BRAK; } • Podsumowanie wykorzystania vectora: • korzyści: znika problem kolizji, • straty: usuwanie i wyszukiwanie mogą być wolne.
Zastosowania haszowania • Problem przechowywania haseł w komputerach: niebezpiecznie to robić w czystym tekście! • W komputerze przechowuje się tylko hasze z haseł. Funkcja haszująca jest dosyć skomplikowana (algorytm DES, będący właściwie metodą szyfrowania). • Ważne, żeby funkcja haszująca była „losowa” i „nieprzewidywalna”, aby trudno było do danego hasza podać jakiekolwiek dobre hasło (ważne w przypadku przechwycenia maszyny). Tę własność określa się także mianem jednokierukowości.
Zastosowania haszowania • Problem zakłóceń w transmisji: przy wysyłaniu dużych plików przez Internet gubią się ich fragmenty (pakiety). • Do sprawdzenia poprawności transmisji używa się dodatkowo przesłanego hasza (wyliczonego algorytmem MD5 bądź odmianą algorytmu CRC), będącego sumą kontrolną pliku. • Odbiorca wyznacza hasz z otrzymanego pliku i sprawdza, czy wyszedł taki sam jak otrzymany. • Hasz musi być małych rozmiarów, żeby zminimalizować transmisję nadmiarowych danych. • Funkcja haszująca musi być „losowa”, żeby efekt utraty kilku pakietów z dużą pewnością powodował zmianę jej wartości.
Zastosowania haszowania • Problem wyszukiwania wzorca (np. słowa, wyrażenia)w tekście. • Pojawia się w edytorach tekstu (np. Word), przeglądarkach internetowych (Internet Explorer, Mozilla Firefox, ...). • Tym zajmiemy się dokładniej. Ale najpierw pytanie: • Jak wyznaczać hasze z napisów? (np. łańcuchów w C++) • Dla napisu postaci char a[N] stosuje się np. funkcję: h(a) = (a[0] + a[1]*p + a[2]*p2 + ... + a[N-1]*pN-1) % q, gdzie p i q są dosyć dużymi liczbami pierwszymi (wtedy funkcja działa najlepiej, czyli minimalizuje liczbę kolizji).
Wyszukiwanie wzorca • Algorytm naiwny: sprawdza wszystkie pozycje tekstu, na których mógłby się zaczynać wzorzec: char w[N], t[M]; /* wzorzec i tekst */ for (int i = 0; i <= M – N; i++) { /* Szukamy wystąpienia wzorca na pozycjach i, i+1, ..., i+N-1. */ bool znalazlem = false; for (int j = 0; j < N; j++) if (w[j] != t[i + j]) { jest = false; break; /* Wczesne wyjście z pętli. */ } if (jest) printf(‘’Wystapienie na pozycji %d.\n”, i); }
Analiza algorytmu naiwnego • Dla losowych danych (w oraz t) zachowuje się świetnie: jest bardzo szybki! • Dla specyficznych danych może wykonywać ok. N*M operacji, np. dla danych typu: aaaaaaaaab - tekst aaaab - wzorzec, i=0 aaaab - i=1 aaaab - i=2 aaaab - i=3 aaaab - i=4 aaaab - sukces, i=5
Hasze pomagają wyszukiwać • Krok 1: Liczymy hasz dla wzorca w ze wzoru: hasz = (w[0] + w[1]*p + w[2]*p2 + ... + w[N-1]*pN-1) % q za pomocą takiej oto prostej pętli: int hasz = 0; for (int i = N – 1; i >= 0; i--) hasz = (hasz * p + w[i]) % q; • To działa, bo kolejno otrzymywane wartości hasza to: 0, (w[N-1])%q, (w[N-2] + w[N-1]*p)%q, (w[N-3] + w[N-2]*p + w[N-1]*p2)%q, ... • Ciekawostka: ta metoda nazywa się schematem Hornera.
Hasze pomagają wyszukiwać • Krok 2: liczymy hasze dla tekstu, ale tym razem spamiętujemy wszystkie wartości pośrednie w tablicy: int hasze[M + 1]; hasze[M] = 0; for (int i = M – 1; i >= 0; i--) hasze[i] = (hasze[i + 1] * p + t[i]) % q; • Zawartość tablicy hasze to teraz: (podobnie jak dla wzorca) hasze[0] = (t[0] + t[1]*p + t[2]*p2 + ... + t[M-1]*pM-1) % q, hasze[1] = (t[1] + t[2]*p+ ... + t[M-1]*pM-2) % q, ... hasze[M-2] = (t[M-2] + t[M-1]*p) % q, hasze[M-1] = (t[M-1]) % q, hasze[M] = 0.
Hasze pomagają wyszukiwać • Jak teraz sprawdzić, czy wzorzec w występuje w tekście t na pozycjach i, i+1, ..., i+N-1? • Musimy policzyć jakoś hasz dla słowa t[i..i+N-1] i porównać go z haszem dla w. • Szukanym haszem okazuje się być wartość: (hasze[i] – hasze[i+N]*pN) % q = ((t[i] + t[i+1]*p+ ... + t[i+N]*pN + t[i+N+1]*pN+1 + t[M-1]*pM-1-i) – (t[i+N] + t[i+N+1]*p+ ... + t[M-1]*pM-1-i-N)*pN) % q = (t[i] + t[i+1]*p + ... + t[i+N-1]*pi+N-1) % q.
Ostateczna postaćwyszukiwania • Oto ostateczny pseudokod algorytmu wyszukiwania wzorca w tekście z wykorzystaniem haszy (zakładamy, że policzyliśmy już wartość hasz dla wzorca oraz tablicę hasze dla tekstu): pN = 1; /* Chcemy, by to było równe pN */ for (int i = 1; i <= N; i++) pN = (pN * p) % q; for (int i = 0; i <= M – N; i++) { /* Szukamy wystąpienia wzorca na pozycjach i, i+1, ..., i+N-1. */ if (hasz == (hasze[i] – hasze[i + N] * pN) % q) printf(‘’Wystapienie na pozycji %d.\n”, i); } • Wyszukiwanie zajmuje około M-N operacji, a samo liczenie haszy: łącznie około N+M operacji. To dużo lepiej niż wynik algorytmu naiwnego! Za to ryzykujemy możliwość kolizji...
Uwagi końcowe • Przykład dobrych liczb pierwszych: p = 70032301, q = 1000000007 • W takim przypadku mogą wystąpić problemy przy mnożeniu (np. hasze[i + N] * pN), gdyż typ int się przepełnia (wynikiem może być liczba rzędu nawet 1018! ) • Rozwiązaniem jest zastosowanie przy mnożeniu większego typu, np. long long: (long long)(hasze[i + N]) * pN który ma dokładność jeszcze troszkę większą niż 1018.