Kopiec, programowanie dynamiczne, problem plecakowy, haszowanie
Download
Report
Transcript Kopiec, programowanie dynamiczne, problem plecakowy, haszowanie
Algorytmy i Struktury Danych
Wykład 4
Kopiec
Kopiec [ang. heap]
Struktura drzewa binarnego
Wartość każdego węzła jest większa bądź
równa wartości węzłów potomnych
innymi słowy rodzic (węzeł nadrzędny)
jest starszy niż dzieci (węzły podrzędne).
Inne nazwy to sterta, stóg.
Kopiec implementacja tablicowa
oraz indeksy do wierzchołków
korzeń – 1,
ojciec dowolnego
wierzchołka i – i/2
(dzielenie całkowite),
lewy syn wierzchołka
i – 2*i,
prawy syn
wierzchołka i –
2*i+1.
Przywracanie własności kopca (ang.
heapify,).
void heapify(int heap[], int i)
{
/*
heap[0] przechowuje rozmiar kopca
maks - indeks do najwiekszego elementu
i – indeks do ojca
l- indeks do lewego syna,
p – indeks do prawego syna
*/
/*
Szukamy największego elementu spośród
heap[i], heap[l], heap[p]. Jego indeks zapamiętujemy z
zmiennej maks.
Jednocześnie sprawdzamy czy l oraz p nie przekroczyły
rozmiaru tablicy heap[]
Jeśli maks różne od i to zamieniamy heap[maks] z heap[i]
i wywołujemy procedurę
heapify(heap, maks)
*/
}
1
2
4
8
3
9
4
9
3
8
5
2
1
10
6
11
1
2
4
8
3
8
2
10
1
6
9
6
11
5
=
7
3
5
7
1
3
4
4
9
4
6
3
7
1
7
5
=
1
2
4
8
3
8
4
9
3
4
5
2
1
10
6
11
1
2
4
8
3
6
2
10
1
6
9
6
11
5
=
7
3
5
7
1
3
8
4
9
9
4
3
7
1
7
5
=
Budowanie kopca (Build heap)
void build_heap(int heap[])
{
Elementowi heap[0]
przypisz rozmiar kopca
Zaczynając od ojca
ostatniego elementu idąc do
korzenia
wywołaj procedurę heapify
dla każdego elementu
}
1
2
1
4
3
5
1
4
3
8
8
9 2
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
5
3
6
7
9
8
2
1
7
3
5
1
6
5
10 1
6
4
8
1
9
6
5
2
1
4
3
8
9
=
7
11 7
liczba elementów
2
7
1
10
6
11
7
9
=
7
7
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
5
3
6
7
9
8
2
1
7
liczba elementów
1
2
8
4
3
4
9
8
3
7
5
2
1
1
10
6
11
5
1
7
9
=
7
6
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
5
3
7
7
9
8
2
1
6
liczba elementów
1
2
4
8
4
8
9
3
3
7
5
2
1
1
10
6
11
5
1
7
9
=
7
6
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
5
8
7
7
9
3
2
1
6
liczba elementów
1
4
2
4
8
8
3
7
5
2
9
3
1
1
10
6
11
9
1
7
5
=
7
6
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
9
8
7
7
5
3
2
1
6
3
9
1
liczba elementów
1
1
2
4
8
8
4
9
3
7
5
2
1
10
6
11
7
5
=
7
6
0
1
2
3
4
5
6
7
8
9
10
11
11
1
8
9
4
7
7
5
3
2
1
6
liczba elementów
1
2
4
8
8
4
9
3
3
7
5
2
9
1
10
6
11
7
7
1
5
=
7
6
0
1
2
3
4
5
6
7
8
9
10
11
11
9
8
7
4
7
1
5
3
2
1
6
liczba elementów
Sortowanie przez kopcowanie
(heapsort)
void heapsort(int heap[])
{
Zbuduj kopiec
Zaczynając od ostatniego
elementu kontynuuj do pierwszego
Zamień ostatni element z
pierwszym,
Usuń ostatni element z
kopca
Przywróć własność kopca
zaczynając od korzenia.
}
1
2
4
8
8
4
3
7
5
2
9
3
6
1
10
6
11
7
7
1
5
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
6
8
7
4
7
1
5
3
2
1
9
liczba elementów
1
2
4
8
7
4
9
3
3
6
5
2
8
1
10
6
11
7
7
1
5
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
8
7
7
4
6
1
5
3
2
1
9
liczba elementów
1
2
4
8
7
3
4
6
5
2
9
3
1
8
10
6
11
7
7
1
5
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
7
7
4
6
1
5
3
2
8
9
liczba elementów
1
2
4
8
6
4
9
3
3
1
5
2
7
8
10
6
11
7
7
1
5
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
7
6
7
4
1
1
5
3
2
8
9
liczba elementów
1
2
4
8
6
4
3
1
5
7
9
3
2
8
10
6
11
7
7
1
5
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
2
6
7
4
1
1
5
3
7
8
9
liczba elementów
1
2
4
8
6
4
9
3
3
1
5
7
7
8
10
6
11
5
7
1
2
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
7
6
5
4
1
1
2
3
7
8
9
liczba elementów
1
2
4
8
6
4
3
1
5
7
9
7
3
8
10
6
11
5
7
1
2
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
3
6
5
4
1
1
2
7
7
8
9
liczba elementów
1
2
4
8
4
3
9
7
3
1
5
7
6
8
10
6
11
5
7
1
2
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
6
4
5
3
1
1
2
7
7
8
9
liczba elementów
1
2
4
8
4
3
3
1
5
7
9
7
2
8
10
6
11
5
7
1
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
2
4
5
3
1
1
6
7
7
8
9
liczba elementów
1
2
4
8
4
3
9
7
3
1
5
7
5
8
10
6
11
2
7
1
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
5
4
2
3
1
1
6
7
7
8
9
liczba elementów
1
2
4
8
4
3
3
1
5
7
9
7
1
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
4
2
3
1
5
6
7
7
8
9
liczba elementów
1
2
4
8
3
1
9
7
3
1
5
7
4
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
4
3
2
1
1
5
6
7
7
8
9
liczba elementów
1
2
4
8
3
1
3
4
5
7
9
7
1
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
3
2
1
4
5
6
7
7
8
9
1
3
liczba elementów
2
4
8
1
1
9
7
3
4
5
7
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
3
1
2
1
4
5
6
7
7
8
9
liczba elementów
1
2
4
8
1
3
3
4
5
7
9
7
1
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
1
2
3
4
5
6
7
7
8
9
3
1
7
liczba elementów
1
2
4
8
1
3
9
7
4
5
7
2
8
10
6
11
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
1
2
3
4
5
6
7
7
8
9
liczba elementów
1
2
4
8
1
3
3
4
5
7
9
7
1
8
10
6
11
2
7
5
6
=
7
9
0
1
2
3
4
5
6
7
8
9
10
11
11
1
1
2
3
4
5
6
7
7
8
9
1
1
liczba elementów
2
4
8
1
3
9
7
3
4
5
7
8
1
0
6
1
1
5
6
=
7
9
0
1
2
3
4
5
6
7
11
1
1
2
3
4
5
6
liczba elementów
2
7
8
7
9
10
11
7
8
9
Kolejka priorytetowa typu max
To struktura danych służąca do
reprezentowania zbioru S elementów, z
których każdy ma przyporządkowana
wartość zwaną kluczem. Kolejka
priorytetowa typu max to kolejka
wykorzystująca kopiec typu max
(własność rodzic jest starszy niż dzieci)
Operacje
INSERT(S, x) – wstawia element x do zbioru
S. Równoważny zapis S=SU{x},
MAXIMUM(s) – zwraca element zbioru S o
największym kluczu
EXTRACT-MAX(S) – usuwa i zwraca
jednocześnie element zbioru S o
największym kluczu.
INCREASE-KEY(S, x, k) – zmienia wartość
klucza elementu x na nową wartość k, o
której zakłada się, że jest nie mniejsza niż
aktualna wartość klucza x.
Operacje - pseudokod
void HEAP-MAXIMUM(int heap[])
{
return heap[1];
}
Czas działania: (1).
void HEAP-EXTRACT-MAX(int heap[])
{
If heap[0]<=0
cout<<”Kopiec jest pusty”;
max=kopiec[1];
kopiec[1]=kopiec[kopiec[0]];
kopiec[0]--;
HEAPIFY(kopiec,1);
return MAX;
}
Czas działania: (lgn).
Operacje cd. - pseudokod
void HEAP-INCREASE-KEY-(int heap, int i, int key)
{
If key<kopiec[i];
cout<<”nowy klucz jest mniejszy od
aktualnego klucza”;
kopiec[i]=key;
while (i>1) i (kopiec[ojciec(i)]<kopiec[i])
zamień kopiec[i] z kopiec[ojciec(i)]
i=ojciec(i)
}
void MAX-HEAP-INSERT(int kopiec[], int key)
{
kopiec[0]++;
kopiec[kopiec[0]]=-∞;
HEAP-INCREASE-KEY(kopiec,kopiec[0],key)
}
Czas działania: (lgn).
MAX-HEAP-INSERT
1
2
4
8
8
4
3
6
5
2
9
3
10
1
10
6
11
7
1
3
5
=
7
4
0
1
2
3
4
5
6
7
8
9
10
11
11
10
8
7
4
6
3
5
3
2
1
4
liczba elementów
1
2
4
8
8
4
9
3
3
6
5
9
10
1
10
6
11
7
1
3
5
=
7
4
0
1
2
3
4
5
6
7
8
9
10
11
11
10
8
7
4
6
3
5
3
9
1
4
liczba elementów
1
2
4
8
8
9
9
3
3
6
5
4
10
1
10
6
11
7
1
3
5
=
7
4
0
1
2
3
4
5
6
7
8
9
10
11
11
10
8
7
9
6
3
5
3
4
1
4
liczba elementów
1
2
4
8
9
8
9
3
3
6
5
4
10
1
10
6
11
7
1
3
5
=
7
4
0
1
2
3
4
5
6
7
8
9
10
11
11
10
9
7
8
6
3
5
3
4
1
4
liczba elementów
Kolejka priorytetowa typu min
Operacje:
◦
◦
◦
◦
INSERT,
MINIMUM
EXTRACT-MIN
DECREASE-KEY
Wykorzystanie jako symulator zdarzeń
Algorytmy zachłanne
Dokonuje wyboru, który w danej chwili
wydaje się najkorzystniejszy.
Lokalnie jest to optymalny wybór z
nadzieją, że doprowadzi to do globalnego
optymalnego rozwiązania.
Nie zawsze jednak udaje się znaleźć
optymalne rozwiązanie, chociaż dla wielu
problemów jest ono wystarczające
Podstawy strategii zachłannej
1.
2.
3.
Przedstawiamy problem optymalizacyjny w
sposób, by dokonanie wyboru pozostawiało
jeden podproblem do rozwiązania.
Udowadniamy, że istnieje zawsze rozwiązanie
optymalne pierwotnego problemu, za pomocą
wyboru zachłannego, co oznacza, że jest to
wybór bezpieczny.
Demonstrujemy własność optymalnej struktury,
pokazując, że wybierając w sposób zachłanny, to,
co pozostaje, jest podproblemem, dla którego
optymalne rozwiązanie wraz z dokonanym
przez nas wyborem daje optymalne rozwiązanie
pierwotnego problemu.
Własności
Podczas gdy w dynamicznym programowaniu w
każdym kroku podejmuje się decyzje, które zależą od
rozwiązań lokalnych problemów w programowaniu
zachłannym wybiera się opcję, która wydaję się
najlepsza w danym kroku i dopiero potem rozwiązuje
się podproblem. Stąd wybory podejmowane w
zachłannych algorytmach mogą zależeć od
dotychczasowych decyzji ale nie będą uzależnione od
przyszłych wyborów ani od rozwiązań
podproblemów.
Optymalna podstruktura oznacza, że optymalne
rozwiązanie problemu zawiera w sobie optymalne
rozwiązania prodproblemów. Jest ona ważna zarówno
dla algorytmów dynamicznych jak i zachłannych.
Schemat działania
void greedy(W)
{
Rozw=;
while(nie Znalazlem(Rozw) i W≠)
{
Xi=Wybierz(W);
if Pasuje(X)
Rozw=Rozw{X};
}
If Znalazlem(Rozw) return Rozw;
else
cout<<”nie ma rozwiązania”;
}
Gdzie:
W– zbiór danych wejściowych,
Rozw – zbiór, który posłuży do konstrukcji rozwiązania,
X- element zbioru
Wybierz(D) – funkcja wybierająca optymalnie element ze zbioru D i usuwająca go z niego,
Pasuje(X) – Czy wybierając X uda się skompletować rozwiązanie cząstkowe, aby odnaleźć, co
najmniej jedno rozwiązanie globalne?
Znalazlem(S) – Czy S jest rozwiązaniem zadania?
PROGRAMOWANIE
DYNAMICZNE A
ZACHŁANNE
Problem plecakowy
Dyskretny Po ang. (0-1 knapsack problem). Mamy do
dyspozycji n przedmiotów. Każdy przedmiot i jest
wart vi złotych i waży wi kilogramów. Zarówno vi jak i
wi są nieujemne i całkowite. Nasze zadanie polega na
spakowaniu do plecaka jak najbardziej wartościowych
przedmiotów ale tak by nie przekroczyć W
kilogramów. Uwaga przedmioty pakujemy w całości,
więc musimy zdecydować czy dany przedmiot
bierzemy (tak – 1) do plecaka czy nie bierzemy (nie –
0). Ponadto możemy użyć każdy przedmiot tylko
jeden raz.
Ciągły Ponownie mamy do dyspozycji n
przedmiotów o odpowiednich wartościach i wadze. I
tak samo jak poprzednio ładujemy plecak. Jednak tym
razem możemy zabierać części ułamkowe
przedmiotów (np. gdy mamy czekoladę to zamiast
całej tabliczki bierzemy kilka jej kostek).
vi
wi
vi/wi
60
10
6
100
20
5
120
30
4
Plecak
Przedmiot 3
Przedmiot 2
Przedmiot 1
PROBLEM PLECAKOWY
n.d.
50
n.d.
DYSKRETNY PROBLEM
PLECAKOWY
vi
wi
ilość
60
10
1
100
20
1
2/3*120
2/3*30
2/3
Plecak
Przedmiot 3
Przedmiot 2
Przedmiot 1
CIĄGŁY PROBLEM
PLECAKOWY
240
50
Kody Huffmana
Kody Huffmana służą do kompresji danych i dają
oszczędności rzędu od 20% do 90%.
Do kodowania wykorzystuje się częstotliwość
występowania znaków. Częstotliwość zapisuje się
w postaci liczby całkowitej lub w postaci ułamka
oznaczającego prawdopodobieństwo wystąpienia
liczby. Można też wykorzystać tabelę z obliczoną
częstotliwością występowania liter dla danego
języka np. polskiego lub angielskiego. Takie tabele
wykorzystuje się też przy łamaniu szyfrów.
Kod Huffmana jest kodem prefiksowym. Oznacza
to, że żadne słowo kodowe nie może być
prefiksem innego słowa kodowego .
Kodowanie znaków - przykład
Mamy dany plik z 100 znakami i danymi częstotliwościami występowania
poszczególnych liter. Każdy znak możemy zakodować za pomocą kodu
binarnego (w skrócie kodu) w postaci ciągu bitów zwanego słowem
kodowym.
znak
częstość
słowo
kodowe o
stałej
długości
słowo
kodowe o
zmiennej
długości
a
46
000
b
11
001
c
12
010
d
17
011
e
8
100
f
6
101
0
100
101
110
1110
1111
Kod Huffmana
Podany kod będziemy reprezentować za pomocą
drzewa binarnego, którego liście będą zawierać
zakodowane znaki, natomiast binarne słowo
kodowe dla znaku będzie odczytane jako prosta
ścieżka od korzenia do danego znaku. Przyjmiemy,
że 0 będzie oznaczać, „przejście do lewego syna”
a 1 „przejście do prawego syna” w drzewie.
Optymalny kod jest zawsze reprezentowany
przez regularne drzewo binarne, w którym każdy
węzeł wewnętrzny ma dwóch synów. Jeśli nasz
alfabet oznaczymy przez C to drzewo
optymalnego kodu prefiksowego ma dokładnie |C|
liści, po jednym dla każdej litery alfabetu oraz
dokładnie |C|-1 węzłów wewnętrznych.
Wzór na liczbę bitów potrzebnych
do zakodowanie pliku:
𝐵 𝑇 = 𝑐∈𝐶 𝑧𝑐 ∙ 𝑑 𝑇 (𝑐)
gdzie:
zc – częstotliwość wystąpienia znaku c,
dT(c) oznacza głębokość liścia znaku c w
drzewie T, jest to także długość słowa
kodowego dla znaku c.
c – znak z alfabetu C
Obliczanie kodu Huffmana
Przyjmijmy, że C jest zbiorem n znaków i że każdy
znak cC jest obiektem o znanej częstotliwości
(liczbie wystąpień) zc. Algorytm buduje drzewo T,
odpowiadające optymalnemu kodowi metodą
wstępującą – od liści do korzenia. Rozpoczynamy
od zbioru |C| liści i wykonujemy |C|-1 „scaleń” aż
otrzymamy końcowe drzewo. W algorytmie
korzystamy z kolejki priorytetowej Q typu min, z
atrybutami z w roli kluczy, do wyznaczania dwóch
obiektów o najmniejszej liczbie wystąpień, które
należy scalić. W wyniku tego działania
otrzymujemy nowy obiekt, którego liczba
wystąpień to suma liczby wystąpień jego
składowych
znak
a
b
c
d
e
f
częstość
46
11
12
17
8
6
Pseudokod
void Huffman (string C)
{
n=|C|
Q=C
for(int i=1; i<n; i++)
utwórz nowy węzeł t
t.left=x=EXTRACT-MIN(Q)
t.right=y=EXTRACT-MIN(Q)
t.z=x.z+y.z
INSERT(Q)
return EXTRACT-MIN(Q)
}
Dekodowanie
Przykładowy tekst:
00111110010000
0 | 0 | 1111 | 100 | 100 | 0 | 0
a afbbbaa
Tablice z haszowaniem
Haszowanie- często nazywane też jest mieszaniem lub
rozpraszaniem.
W kompilatorach utrzymuje się tablicę symboli, w której
kluczami elementów są dowolne ciągi znaków odpowiadające
identyfikatorom. Tablica z haszowaniem jest strukturą
służącą do reprezentacji słowników. Wyszukiwanie elementu
w tablicy z haszowaniem może trwać tyle i le wyszukiwanie
na liście z dowiązaniami - (n) w najgorszym wypadku to w
praktyce daje znakomite wyniki.
Tablica z haszowaniem jest uogólnieniem zwyczajnej tablicy.
Adresowanie bezpośrednie umożliwia uzyskanie dostępu do
dowolnej pozycji w tablicy w czasie O(1). Jednak rozmiar
takiej tablicy zależny jest od każdej możliwej wartości klucza.
Natomiast w tablicy z haszowaniem rozmiar zwykle jest
proporcjonalny do liczby elementów, które zawiera. Indeks
tablicy, pod którym znajduje się element, nie jest jego
kluczem ale może być obliczony na podstawie klucza.
Tablice z adresowaniem
bezpośrednim
Adresowanie bezpośrednie jest prostą metodą,
która jest skuteczna przy małej liczbie kluczy.
Załóżmy że mamy klucze w zbiorze (Uniwersum
kluczy) U={0,1,…,m-1} wówczas możemy tablicę
reprezentować jako tablicę T[0..m-1], W której
każdej pozycji odpowiada klucz należący do
zbioru U. Na pozycji k w tablicy znajduje się
wskaźnik do elementu o kluczu k. Jeśli do zbioru
nie należy żaden element o kluczu k, to T[k]=NIL.
Przy dużym uniwersum kluczy U
przechowywanie w pamięci komputera tablicy T o
rozmiarze |U| może okazać się nie możliwe.
Operacje
void DIRECT-ADDRESS-SEARCH(int T[m], int k)
{
return T[k]
}
void DIRECT-ADDRESS-INSERT(int T[m], int k)
{
T[x.key]=x
}
void DIRECT-ADDRESS-DELETE(int T[m], int k)
{
T[x.key]=NIL
}
Każda z tych operacji działa w czasie O(1).
Tablice z haszowaniem
Pamięć zajmowaną przez tablicę z haszowaniem
można ograniczyć do (|K|), a wyszukiwanie w takiej
tablicy nadal będzie działać w czasie O(1).
W tablicy z adresowaniem bezpośrednim,
element o kluczu k umieszcza się na pozycji o
indeksie k. Natomiast w tablicy z haszowaniem
element ten trafia na pozycję h(k) – gdzie h to funkcja
haszująca, która oblicza pozycję klucza k. Funkcja ha
odwzorowuje uniwersum kluczy U w zbiór pozycji
tablicy z haszowaniem T[0..m-1] w sposób:
h:U{0,1,…,m-1},
gdzie przeważnie rozmiar m tablicy z haszowaniem
jest znacznie mniejszy niż |U|. Mówimy, że element o
kluczu k jest haszowany na pozycję h(k); mówimy
także, że h(k) jest wartością haszującą klucza k.
TABLICE Z
HASZOWANIEM
ROZWIĄZYWANIE
KOLIZJI METODĄ
ŁAŃCUCHOWĄ
Operacje słownikowe tablicy T
void CHAINED-HASH-INSERT(int t[], int x)
wstaw x na początek listy
T[h(x.key)]
void CHAINED-HASH-SEARCH(int T[], int k)
wyszukaj element o kluczu k na
liście T[h(k)]
void CHAINED-HASH-DELETE(int T[], int x)
usuń x z listy T[h(x.key)]
Niech będzie dana tablica T o m pozycjach,
w której znajduje się n elementów. Wówczas
jej współczynnik zapełnienia określamy
jako n/m tj. średnia liczba elementów w
łańcuchu.
Jeżeli losowo wybrany element z
jednakowym prawdopodobieństwem trafia
na każdą z m pozycji, niezależnie od tego,
gdzie trafiają inne elementy to nazywa się to
prostym równomiernym haszowaniem.
Funkcje haszujące - cechy
Funkcja haszująca powinna przede wszystkim spełniać założenie
prostego równomiernego haszowania: losowo wybrany klucz jest z
jednakowym prawdopodobieństwem odwzorowywany na każdą z
m pozycji, niezależnie od odwzorowań innych kluczy. W praktyce
rzadko udaje się spełnić ten warunek gdyż rzadko znamy rozkład
prawdopodobieństwa pojawiania się kluczy.
Jeśli znamy rozkład prawdopodobieństwa pojawiania się kluczy
możemy je wykorzystać do wyboru funkcji haszującej. Z reguły
funkcje haszujące wybiera się tak, by jej wartości były maksymalnie
nie zależne od możliwych wzorców mogących występować w
danych.
Ponadto czasem wymaga się silniejszych warunków niż tylko proste
równomierne haszowanie. Na przykład wymaga się aby „bliskim”
(w pewnym sensie) kluczom odpowiadały znacznie od siebie
oddalone wartości funkcji haszującej.
Większość funkcji haszujących ma dziedzinę będącą zbiorem liczb
naturalnych N={0,1,2,…}. Jeśli zaś kucze nie są liczbami naturalnymi
to należy ustalić ich odwzorowanie w zbiór liczb naturalnych. Jeśli
mamy klucze w postaci ciągów znaków to można każdemu znakowi
przyporządkować odpowiadający mu kod ASCII.
Haszowanie modularne
Dla klucza k daje wartość będącą resztą z dzielenie k
przez m, gdzie m to liczba pozycji w tablicy. Można to
zapisać wzorem:
h(k)=k mod m.
Jeśli w tablicy z haszowaniem jest m=12 pozycji, to dla
klucza k=100 mamy h(k) =4.
Należy dobrze wybrać wartość m. Jeśli m będzie
potęgą 2czyli m=2p, to h(k) będzie liczbą, która
powstaje z p najmniej znaczących bitów liczby k.
Również wybór m=2p-1 jest zły, ponieważ permutacja
znaków w k nie mienia wartości funkcji haszującej.
Dlatego lepiej na m wybierać liczby pierwsze, które
nie są zbyt bliskie potęgom 2.
Haszowanie przez mnożenie
Obliczenia przeprowadza się w dwóch krokach. Najpierw klucz k
mnożymy przez stałą A z przedziału 0<A<1 i wyznaczamy część
ułamkową liczby kA. Potem mnożymy uzyskaną wartość przez m a
z uzyskanego wyniku wyciągamy wartość funkcji podłoga.
Zapiszemy to następująco:
h(k)=m(kA mod 1)
gdzie kA mod 1 to część ułamkowa kA, czyli kA-kA.
Wartość m nie ma szczególnego znaczenia. Zwykle, ze względu na
łatwość implementacji komputerowej, wybiera się ją jako pewną
potęgę 2, czyli m=2p dla pewnej liczby naturalnej p. Niech słowo
maszynowe ma długość w bitów oraz k mieści się w jednym słowie.
Natomiast niech A będzie ułamkiem postaci s/2w, gdzie s jest liczbą
całkowitą z przedziału 0<s<2w. W pierwszej kolejności mnoży się k
przez w-bitową liczbę całkowitą s=A*2w. Wynik stanowi 2w-bitowa
liczba r12w+r0, gdzie r1 jest bardziej znaczącym, a r0 mniej
znaczącym słowem iloczynu. Szukana p-bitowa wartość funkcji
haszującej składa się z p najbardziej znaczących bitów liczby r0.
Adresowanie otwarte
W metodzie adresowania otwartego wszystkie
elementy przechowuje się wprost w tablicy. Każda
pozycja w tablicy zawiera wiec albo element
zbioru albo wartość NIL. Wyszukiwanie elementu
związane jest z systematycznym przeszukiwaniem
tablicy element po elemencie. Efektem będzie
znaleziony element lub informacja że go nie ma w
tablicy. Brak jest dodatkowych list oraz
przechowywania elementów poza tablicą. W
adresowaniu otwartym, współczynnik zapełnienia
nie może nigdy przekroczyć 1, aby nie nastąpiło
przepełnienie tablicy.
Funkcja haszującą będzie miała postać:
h:U{0,1,…,m-1}{0,1,…,m-1}
o własności takiej , że dla każdego klucza
k ciąg pozycji (ciąg kontrolny klucza k)
<h(k,0), h(k,1), …, h(k,m-1)> jest permutacją
pozycji <0,1, …, m-1>.
Dodawanie
void HASH-INSERT(int T[], int k)
{
i=0
do{
j=h(k,i)
if T[j]==NIL
T[j]=k
return j
else i++
}while(i!=m)
cout<< „tablica z haszowaniem jest
pełna”;
}
Wyszukiwanie
int HASH-SEARCH(int T[], int k)
{
i=0
do{
j=h(k,i)
if T[j]==k
return j
i++
}while (T[j]!=NIL)||(i!=m)
return NIL
}
Gdy mamy do czynienia z adresowaniem
otwartym w tablicach z haszowaniem, nie
możemy po prostu usunąć klucza z pozycji i
oznaczyć ją jako wolną przez wpisanie adresu NIL.
Gdybyśmy tak zrobili to odcięlibyśmy dostęp do
kluczy k, przy wstawianiu których odwiedzona
została pozycja i i była wówczas zajęta. Problem
można rozwiązać wpisując na tę pozycję specjalną
stałą DELETED zamiast NIL. Procedura HASHSEARCH gdy natknie się na pozycję DELETED
powinna dalej kontynuować poszukiwania,
natomiast procedura HASH-INSERT może
potraktować pozycję jako wolną i wstawić w nią
nowy klucz.
Sposoby obliczania ciągów
kontrolnych
1.
2.
3.
adresowanie liniowe,
adresowanie kwadratowe
haszowanie dwukrotne.
Adresowanie liniowe
W metodzie adresowania liniowego dla zwykłej funkcji
haszującej h’:U{0,1,…,m-1}, nazywanej pomocniczą
funkcją haszującą, stosuje się funkcję:
h(k,i)=(h’(k)+i) mod m
dla i=0,1,…,m-1. Dla danego klucza k jego ciąg kontrolny
zaczyna się od pozycji T[h’(k)], czyli od pozycji wyznaczonej
przez funkcję pomocniczą. następną pozycją w tym ciągu jest
T[h’(k)+1] itd. aż do pozycji T[m-1]. Dalej występują pozycje
T[0], T[1], …, T[h’(k)-1]. Ponieważ pierwsza pozycja w ciągu
wyznacza cały ciąg jednoznacznie, w metodzie adresowania
liniowego jest więc generowanych tylko m różnych ciągów
kontrolnych.
Ten sposób adresowania jest łatwy w realizacji ma jednak
wadę polegającą na tendencji do grupowania się pozycji
zajętych(tzw. grupowanie pierwotne).
Adresowanie kwadratowe
Adresowanie kwadratowe wykorzystuje funkcję haszująco
postaci:
h(k,i)=(h’(k)+c1i+c2i2) mod m,
gdzie h’ jest pomocniczą funkcją haszującą, c1 i c2 są pewnymi
dodatnimi stałymi, a i=0,1,…,m-1. Pierwszą odwiedzoną
pozycją jest T[h’(k)]; kolejno rozpatrywane pozycje są
oddalone od początkowej o wielkość zależną od kwadratu
numeru pozycji i w ciągu kontrolnym. Chociaż metoda ta jest
lepsza od adresowania liniowego to trzeba narzucić pewne
warunki na liczby c1, c2 i m. Jeśli jednak dwa klucze mają takie
same początkowe pozycje, to i całe ich ciągi kontrolne są
równe, ponieważ z h(k1,0)=h(k2,0) wynika, że h(k1,i)=h(k2,i).
Jest to kolejne grupowanie zwane grupowaniem
wtórnym.
Haszowanie dwukrotne
Funkcja haszująca w haszowaniu dwukrotnym ma
postać:
h(k,i)=(h1(k)+ih2(k)) mod m,
gdzie h1 i h 2 są pomocniczymi funkcjami haszującymi.
Pierwszą pozycją w ciągu kontrolnym klucza k jest
T[h1(k)]; kolejna pozycja jest oddalona od poprzedniej
o h2(k) modulo m. Ciąg kontrolny zależy tutaj na dwa
sposoby od k, to znaczy, że zależy zarówno od pozycji
początkowej jak i od kroku, z jakim przeglądamy
tablicę.
Aby mieć gwarancję, że w razie potrzeby przeszukana
zostanie cała tablica, musimy zapewnić, że wartość
h2(k) jest względnie pierwsza z rozmiarem tablicy m.
Bibliografia
Cormen Thomas; Leiserson Charles; Rivest
Ronald; Stein Clifford, „Wprowadzenie do
Algorytmów”,
Wydawnictwo Naukowe PWN, Warszawa
2012,
Wróblewski Piotr, „Algorytmy, Struktury
Danych i Techniki Programowania”,
Wydawnictwo Helion, Gliwice 2010
Banachowski Lech, Diks Krzysztof, Rytter
Wojciech, „Algorytmy i Struktury danych”,
Wydawnictwa Naukowo-Techniczne,
Warszawa 1996.