Wyklady 1

Transkrypt

Wyklady 1
5-7 projektów, 2 tygodnie na każdy projekt, 0-10 punktów na każdy projekt, 7pkt za
projekt wykonany poprawnie, 2 pkt straty za spóźnienie. Minimalna ilość by zaliczyć ćw
to (ilość projektów -1)*5+2. Czyli jednego projektu można nie zrobić. Egzamin pisemny.
Będzie zerówka na ostatnim lub przedostatnim wykładzie.
„Projekty nie polegają na wygooglaniu rozwiązania”.
Cormen, Lieserson Wprowadzenie do algorytmów
Sedgewick Algorytm w c++
Sedgewick Algorytmy w c++.Grafy
W wikipedii angielskiej są ładnie zrobione algorytmy.
Plan wykładu:
-Przypomnienie
-Drzewa binarne i drzewa BST, słowniki, przeszukiwanie danych, drzewa binarne bardzo
przyspieszają przeszukiwanie, ale drzewa BST mogą się też degenerować, nie mają cech
samonaprawczych w sobie, jeśli mamy złośliwe dane, to doprowadzimy słownik do
postaci liniowej, że nic się tam nie będzie dało zrobić.
-Drzewa AVL i 2-3-4 (AVL próba by długości ścieżek były równe z dokładnością do 1, ale
wtedy czas balansowania jest bardzo duży i słownik trzymany w tej postaci jest duży, bo
długo zajmuje organizacja i to może nie być opłacalne, dlatego dojdziemy do drzew
czerwono czarnych, one nie są zbalansowane ale są na tyle bliskie zbalansowania, że
dobrze się szuka i czas nie jest długi.
-Drzewa zbalansowane (czerwono – czarne) w takim drzewie wystarczy by długości
ścieżek nie różniły się o więcej niż połowę, czyli maksymalnie dwa razy dłuższe ścieżki i
to nam zapewni logarytmiczny czas poszukiwania co będzie dla nas wystarczające.
-Będą też B drzewa i też będziemy budować słownik, binarne będą nieefektywne, to będą
drzewa 2-3-4 czyli mogą mieć więcej niż dwójkę dzieci, ale jeśli się to fajnie zorganizuje,
to będzie dobry czas i niezła implementacja, maksymalnie będą 4 dzieci czyli
maksymalnie cztery wskaźniki, pokazane w Sedgewicku czyli zaczniemy od słowników o
czasie logarytmicznym a potem przejdziemy do haszowania
- Tablice z haszowaniem – drzewa w dobrym wydaniu ma być czas logarytmiczny a tutaj
dostaniemy czas stały, czyli teoretycznie nie ważne ile będzie danych, zawsze dostaniemy
ten sam czas, ceną jest tutaj pamięć, jeśli mam jej bardzo dużo to i tak nawet jeśli zbiór
rośnie to czas NIE rośnie. Można to było spotkać w systemach sieciowych.
- Kompresja danych – pokaże nam kilka sposobów pakowania danych, czyli w jaki sposób
zrobić z dużego zbioru mały zbiór i pokaże mam tylko metody bezstratne, np. zip,. Rar
one odtworzą stan oryginalny, a przykład kompresji stratnej to JPG
- kodowanie arytmetyczne czyli w dowolnej liczbie można przetrzymać dowolnie długą
informację i do tego słownik, ale tu będzie trudne przechowywanie, bo ta liczba będzie
musiała być aż tak dokładna
- wyszukiwanie wzorca w tekście – są algorytmy siłowe czyli przyporządkowanie znaczek
po znaczku, ale są też fajniejsze i tu dużo dziwnych nazw
- algorytmy grafowe (Przeszukiwanie wszerz i wgłęb, drzewo rozpinające, znajdowanie
najlepszej drogi, znajdowanie najkrótszych ścieżek pomiędzy wszystkimi wierzchołkami),
to będzie na sieci, routing itp
Zaawansowane algorytmy
1
- NP-zupełność – duże problemy, takie których czas rozwiązania jest niesensowny, czyli
nie mają rozwiązania w czasie wielomianowym. Może nie zdążymy, a na egz będzie to co
zdążymy zrobić na wykładzie.
POWTÓRZENIE
Algorytm – recepta, która mówi w jaki sposób postępować by rozwiązać dany problem
Dla wielu zadań istnieje wiele rozwiązań danego zadania. Ogólnie mamy to co było w
zeszłym semestrze na pierwszym wykładzie.
Program – implementacja algorytmu w jakimś języku programowania
Struktura danych – organizacja danych niezbędna do rozwiązania problemu (metody
dostępu)
Ogólne spojrzenie
Wykorzystanie komputera:
- projektowanie programów (algorytmy, struktury danych)
- pisanie programów (kodowanie) tu myślimy o celach implementacyjnych
- weryfikacja programów (testowanie)
Cele algorytmiczne: poprawność i efektywność
Cele implementacji: zwięzłość, możliwość powtórnego wykorzystania
Co to znaczy, że algorytm działa dobrze? Np. to że ma działać szybko, ale można też
wziąć szybszy komputer, jeśli nie chcemy zmieniać programu, jednak to ma swoje
granice, istnieje jakiś najszybszy komputer, a jeśli mamy nadal ten sam komputer to
mamy sprawić, by wykonywał on mniej operacji. Drugim kryterium jest pamięć,
minimalizowanie zasobów. Ostatnim parametrem minimalizacji to kwestia, że jeśli
używamy wielu procesorów to możemy równolegle wykonywać ileś procesów, ileś
kawałków zadania, i wtedy możemy minimalizować ilość procesów prowadzonych
jednocześnie,. Zwykle jeśli algorytm używa mniej pamięci to jest wolniejszy itp. Czyli
taka odwrotna proporcjonalność.
Możliwość powtórnego wykorzystania to jedna z podstawowych cech, bo jednak chodzi o
to by wykorzystywać istniejące biblioteki a nie tworzyć je od nowa.
Zwięzłość to chodzi o to by nie było za wiele kodu, jeśli nie ma on sensu.
Algorytmy to rodzaj czarnej skrzynki przetwarzającej dane wejściowe na dane wyjściowe.
Przypomnijmy, że algorytm ma cechy: określoność, skończoność, poprawność, ogólność,
dokładność.
Algorytmem nazywamy skończoną sekwencję jednoznacznych instrukcji pozwalających
na rozwiązanie problemu, tj. na uzyskanie pożądanego wyjścia dla każdego legalnego
wejścia.
Ważne jest porządne wyspecyfikowanie wejścia i wyjścia, czyli powinniśmy jasno umieć
powiedzieć, co ma być na wejściu, oraz czy to co jest na wyjściu jest tym co mieliśmy
otrzymać.
Sortowanie przez wstawianie (złożoność kwadratowa, czyli nienajlepsza jeśli chodzi o
metody sortowania, a taka pożądana to było nlogn, a przy pewnych założeniach da się
dojść do liniowości).
Zaawansowane algorytmy
2
Gdy mówimy o wielkości zadania, to nie musi to oznaczać ilości danych na wejściu, bo
wystarczy wziąć zadanie, czy dana liczba na wejściu jest liczbą pierwszą np. sprawdzać
po kolei i dojść do pierwiastka z tej liczby. Czas wykonania jest wykładniczy. Rozmiarem
wejścia nie musi być ilość czy wielkość liczby, ale może być wielkość jej reprezentacji,
czyli wtedy istotne jest ile pamięci ona zajmuje, a nie jaka jest jej wartość. Jeśli
reprezentacja będzie dwukrotnie większa, to czas wcale nie będzie dwa razy dłuższy i to
jest sprawdzalne.
Tak więc liczymy ilość operacji i liczymy operacje dominujące, czyli te które trwają
najdłużej, a resztę pomijamy. Np. odczyt z dysku jeśli wykorzystywane są pamięci
masowe, a jeśli nie są one wykorzystywane to całe przypisanie itp.
Algorytm przez wstawianie – porównujemy liczby z kolejnymi czyli wsadzamy karty do
posortowanego już ciągu i przypominamy ten algorytm bo czasami musi on wykonać
dużo pracy czyli wtedy gdy musimy wsadzić liczbę na początek, czyli porównać ją ze
wszystkimi, lub mało pracy, gdy wsadzamy na koniec, dlatego można rozpatrywać różne
przypadki:
- najgorszy to przypadek takich danych gdy już gorzej być nie może czyli w sortowaniu
będą to liczby odwrotnie posortowane i to rozpatrywanie ma sens gdy chodzi np. o
rozstawianie karetek by zdążyły zawsze dojechać na miejsce itp. Czyli w sumie chodzi o
to gdzie czas ma sens.
- czasami najgorszy przypadek nas nie interesuje np. ustawianie świateł w mieście,
najwyżej czasami ludzie sobie poczekają, ale trudno, chodzi o to że interesuje nas
‘średni’ przypadek, a nie ten najgorszy, czyli ile zwykle się jedzie przez miasto itp.
Często są drastyczne różnice między tym średnim i najgorszym przypadkiem, bo
najlepszy nam nic nie daje. Przypadek najgorszy jest zwykle dobrze przebadany a
przypadek średni często nie jest nawet dobrze zdefiniowany.
Poprawność jest praktyczna i całkowita
Z punktu widzenia teoretycznego musimy mieć poprawność całkowitą, czyli musimy
umieć przeprowadzić dowód, że dla wszelkich poprawnych danych da się dowieść że
otrzymamy poprawny wynik. Ale często nie da się tego dowodu ładnie przeprowadzić w
budownictwie, zadaniach mechanicznych itp. Poprawność praktyczna jest wtedy gdy jeśli
mamy poprawne dane i algorytm zaczyna działać to skoro coś wypluwa, to wyprodukuje
wynik poprawny, ale nie umiemy dowieść, że dla wszelkich danych będzie dobrze.
Istnieją też niezmienniki.
Były również notacje asymptotyczne.
Cel: uproszczenie analizy czasu wykonania, zaniedbywanie szczegółów, które mogą
wynikać ze specyficznej implementacji czy sprzętu. Główna idea: jak zwiększa się czas
wykonania algorytmu wraz ze wzrostem rozmiaru wejścia (w granicy). Algorytm
asymptotycznie lepszy będzie bardziej efektywny dla prawie wszystkich rozmiarów wejść,
ewentualnie poza być może bardzo małych, ale ich będzie skończona ilość, poza tym one
nas zwykle nie interesują.
Wracamy do notacji O duże czyli ograniczenia z góry, wykorzystywane w analizie
najgorszego przypadku. Notacja z dołu to była omega mówiła o najlepszym możliwym
zachowaniu się algorytmu. To jest fajne gdy chcemy zbudować algorytm pomiędzy o
duże i omega duże. A gdy umiem opisać coś od góry i od dołu naraz to mamy Teta. Różni
się to stałymi.
Zaawansowane algorytmy
3
Drzewa poszukiwań binarnych
Najpierw sobie je omówimy, a potem będziemy je poprawiać, by się zachowywały
przyzwoicie, to jakieś dwa wykłady w sumie. Najpierw powtarzamy to co było w zeszłym
semestrze.
Wprowadzenie
Poszukujemy dynamicznego ADT, który efektywnie będzie obsługiwał następujące
operacje:
- wyszukiwania elementu (serach)
- znajdowanie minimum/maksimum – czasami się to przydawało np. przy strukturze
kopca
- znajdowanie poprzednika, następnika – przydaje się przy operacjach wstawiania i
usuwania
- wstawianie, usuwanie elementu
Wykorzystamy drzewo binarne. Wszystkie operacje powinny zajmować czas
Teta(lg_2(n)).- czyli okolice, pomiędzy.
Drzewo powinno być zawsze zbalansowany – inaczej czas będzie proporcjonalny do
wysokości drzewa (gorszy od O(lg_2(n))).
Struktura drzewa z korzeniem. Każdy węzeł x posiada pola left, right, parent , key.
Własności drzewa BST:
Niech x będzie dowolnym węzłem drzewa natomiast niech będzie należał do poddrzewa o
korzeniu w x wtedy:
- jeżeli należy do lewego poddrzewa to key(y)<=key(x)
- jeżeli należy do prawego poddrzewa to key(y)>key(x)
Czyli mamy strukturę gdzie jest dana i dwa wskaźniki na lewo i prawo w drzewie
binarnym, dlatego jest łatwe do implementacji. Duplikaty zawsze idą w jedną stronę.
Zasada prosta a łatwo się szuka po wartościach klucza w węźle. Definicja drzewa NIE
mówi o jednoznaczności, mogą być złożone z tych samych węzłów ale różnych ścieżek a
są niby tymi samymi drzewami. Są trzy porządki przechodzenia w drzewach:
In-order – z korzenia idę w lewo potem znów w lewo jak dojdę do końca to wracam
troszkę i idę na prawo itp. Czyli do przykładu ze slajdów byśmy mieli kolejność: 2 3 5 5 7
8 , a ad przykład drzewa obok byśmy mieli: 2 3 5 5 7 8 czyli drzewa są różne, złożone z
tych samych węzłów i przechodzenie In order otrzymaliśmy to samo i to posortowane. To
wynika z zasady budowy drzewa.
Pre-order Najpierw korzeń potem lewe poddrzewo i potem prawe, czyli byśmy mieli 5 3
2 5 7 8 a to drugie by było: 2 3 7 5 5 8 czyli nie mamy ani tego samego ani
posterowanego
post-order – najpierw dzieci potem rodzic czyli lewe potem prawe i na koniec korzeń,
pierwsze drzewo by było: 2 5 3 8 7 5 a drugie to 5 5 8 7 3 2 i znów mamy co innego i nie
ma posortowania.
Poszukiwanie w drzewie BST
Rekurencyjne lub iteracyjne UZUPELNIC KOD Cormen
Tree – Serach(x,k)
If x = null lub…
Sprawdzam czy szukany klucz jest mniejszy czy większy do danego węzła i jeśli jest
mniejszy to idę w lewo, a większy to w prawo no i trzeba kiedyś zakończyć albo gdy nie
Zaawansowane algorytmy
4
ma gdzie szukać, czyli jestem w liściu, albo znalazłam to czego szukałam. Jeśli szukamy
4 to w pewnym byśmy mieli 3 a w prawym 5, bo tyle węzełków musimy przejść i
stwierdzić ze nie są równe. Złożoność zależy od wysokości drzewa log(n) lub n – ilość
elementów.
Złożoność O(h).
Przechodzenie przez wszystkie węzły drzewa
Np. chcemy wypisać wszystkie elementy In order
Inorder-Tree-Walk(x)
If x różny od null then
Inorder-tree-walk(left[x})
Print key[x]
Inorder-tree-walk(right[x])
Czybyśmy chcieli pre-order to byśmy musieli wystarczy zmienić kolejność print i pójścia
w lewi, a jak zmienimy kolejność dwóch ostatnich instrukcji to dostaniemy porządek
post-order.
Czas wykonania T(0)=Teta(1)
T(n)=T(k)+T(n-k-1)+Teta(1)
złożoność Teta(n).
Odnajdowanie minimum i maksimum
Najmniejszy to ten w relacji ze wszystkimi
Tree-Minimum(x)
While left[x] różne od null
Do x:=left[x]
Return x
Tree-maksimum(x)
While right[x] różny od null
Do x:=right[x]
Return x.
Złożoność O(h) w najgorszym razie zajmuje tyle ile trwa najdłuższa ścieżka – liniowa
względem ilości elementów, a pry dobrze zbudowanym drzewie dostaniemy logarytm z
ilości węzłów.
Odnajdowanie następnika
Następnikiem x nazywamy najmniejszy element y wśród elementów większych od x.
Następnik może zostać odnaleziony bez porównywania kluczy. Jest to:
1. null jeśli x jest największym z węzłów (bo z definicji nie może mieć następnika)
2. minimum w prawym poddrzewie t jeśli ono istnieje
3. najmniejszy z przodków x dla których lewy potomek jest przodkiem x
Wstawianie elementów
Wstawianie jest bardzo zbliżone do odnajdowania elementu: i odnajdujemy właściwie
miejsce w drzewie w które chcemy wstawić nowy węzeł z. Dodawany węzeł z zawsze
staje się liściem. Zakładamy, że początkowo left(z) oraz right(z) mają wartość null.
Duplikaty wstawiamy tak samo, bo czemu by nie. Równe idą na lewo wg naszej zasady.
Są też inne strategie, ale ta jest najprostsza.
Usuwanie z drzewa BST
Usuwanie elementu jest bardziej skomplikowane niż wstawianie. Można rozważyć trzy
potomków usuwania węzła z:
1. z nie ma potomków: usuwamy z i zmieniamy u rodzica wskazanie na null
Zaawansowane algorytmy
5
2. z ma jednego potomka (usuwany węzeł zastępujemy dzieckiem, wystarczy tak
skleić, nie ważne czy to dziecko jest lewe czy prawe, czyli jakby zastępujemy ojca
wnukiem jak w carskiej armii ;) )
3. z ma 2 potomków (teraz jest już kłopot)
Rozwiązanie polega na zastąpieniu węzła jego następnikiem(najmniejszy wśród
większych od niego).
Założenie: jeśli węzeł ma dwóch potomków, jego następnik ma co najwyżej jednego
potomka
Dowód: jeśli węzeł ma 2 potomków to jego następnikiem jest minimum z prawego
poddrzewa. Minimum nie może posiadać lewego potomka (inaczej nie byłoby to
minimum).
Analiza złożoności
Usuwanie: dwa pierwsze przypadki wymagają O(1) operacji: tylko zamiana wskaźników
Przypadek trzeci wymaga wywołania tree-successor i dlatego wymaga czasu O(h)
Stąd wszystkie dynamiczne operacje na drzewie BST zajmują czas O(h) gdzie h jest
wysokością drzewa.
W najgorszym przypadku wysokość ta wynosi O(n).
Drzewa BST sa wygodne do efektywnego implementowania operacji:
Search, Successor, Predecessor, Minimum, Maximum, Insert, Delete w czasie O(h),
(gdzie h jest wysokoscia drzewa)
_ Jeśli drzewo jest zbalansowane (ma wtedy wysokość h = O(lg n)), operacje te są
najbardziej efektywne.
_ Operacje wstawiania i usuwania elementów mogą powodować, że drzewo przestaje być
zbalansowane. W najgorszym przypadku drzewo staje się lista liniowa (h = O(n))!
Rotacje
Będziemy przebudowywać drzewo, czyli zamieniamy w sumie kolejność węzłów by je
wywłaszczyć, manipulujemy trzema węzłami. Przykład na slajdach z 2 pliku. Taka rotacja
nam pomaga, gdy chcemy zmniejszyć głębokość, gdy się gdzieś robi za długa ścieżka, to
taka rotacja zmniejsza ścieżkę o jeden, (w drugim poddrzewie ją o 1 zwiększy, ale może
nam to nie przeszkodzi jeśli i tak w tym poddrzewie będzie znacznie mniejsza ścieżka) to
nie załatwia nam problemu, ale jest pewnym krokiem w dobrą stronę.
Zachowują własność drzewa BST.
Zajmują stały czas O(1) – stałą ilość operacji na wskaźnikach
Rotacje w lewo i w prawo są symetryczne.
Kawałek pseudokodu, chodzi o inteligentne podwieszanie wskaźników. Nie ma tu
zależności od ilości węzłów, to tylko manipulowanie trzema wskaźnikami – dwoma +
uwzględnienie rodzica. Był przykład slajd 29.
Wykorzystanie tej rotacji:
Drzewa zbalansowane AVL i 2-3-4
Drzewa są różne, ale my poznamy w sumie trzy rodzaje – te najciekawsze.
AVL – próbują byś idealne, próbują być zawsze dobrze wyważone. Wymaga to operacji
długotrwałych typu przebudowywanie drzewa przy każdej operacji wstawiania i usuwania,
a to powoduje obciążanie węzła – dodaje się licznik, który wskazuje na jego głębokość i
na tej podstawie decydujemy, czy coś robić czy nie.
Drzewa 2-3-4 to jakiś podzbiór drzew które nie mają struktury binarnej, odmiana B
drzewa, ilość potomstwa maksymalnie 4. Gdybyśmy chcieli mieć drzewo i nie wiadomo ile
potomków będzie to byśmy musieli każdemu kazać pamiętać rodzica i jednego z
rodzeństwo, tworzyć jakby listę dzieci, a węzeł pamięta tylko najstarsze dziecko. Czyli
operacje na takim drzewie byłoby bardziej skomplikowane niż na binarnym. Dlatego my
Zaawansowane algorytmy
6
się decydujemy na coś innego – na drzewa 2-3-4, czasami nazywane drzewami 2-4.
Można np. obciążać czterema adresami węzłów każdy nasz węzełek, tak jak teraz
obciążamy dwoma.
Są też drzewa czerwono – czarne, tam koszt jest obciążenie tylko jednego bitu –
pamiętamy kolor, kwestia stanu, chodzi o to by węzły o tych samych kolorach nie były
obok siebie. A to już nam pozwoli na utrzymanie niezłej struktury tego drzewa.
Sedgewick wyprowadza drzewa czerwono czarne z drzew 2-3-4 choć można podejść, że
to są inne struktury, kwestia podejścia.
Drzewa zbalansowane
Staramy się znaleźć takie metody operowania na drzewie, żeby pozostawało ono
zbalansowane.
_ Jeżeli operacja Insert lub Delete spowoduje utratę zbalansowania drzewa, będziemy
przywracać te własność w czasie co najwyżej O(lgn), tak aby nie zwiększać złożoności.
_ Potrzebujemy zapamiętywać dodatkowe informacje, aby to osiągnąć.
_ Najbardziej popularne struktury danych dla drzew zbalansowanych to:
– Drzewa AVL: równica wysokości dla poddrzew wynosi co najwyżej 1
– Drzewa 2-3-4 drzewa zbalansowane, ale nie binarne
– Drzewa czerwono-czarne: o wysokości co najwyżej 2(lg n + 1)
Drzewa AVL
Będziemy patrzeć na operacje, które są dynamiczne zarówno w tych drzewach jak i w 23-4, czyli na operacje wstawiania i usuwania. Oczywiście istotna będzie złożoność
obliczeniowa owych operacji. Na razie mamy zagwarantowaną złożoność liniową drzewem
BST, bo mówimy, że może być lepiej w BST, ale nie mam gwarancji że będzie.
Chcielibyśmy dostać złożoność obliczeniową logarytmiczną i dostaniemy, ale za cenę
pilnowania tego drzewa, zawsze jak coś z nim zrobi to muszę od razu poprawiać to
drzewko.
Definicja drzewa AVL
Drzewem AVL nazywamy drzewo BST takie, że dla każdego z węzłów różnica wysokości
jego lewego i prawego poddrzewa wynosi co najwyżej 1. Nie zagwarantuje równości bo
wystarczy wziąć jako drzewo węzeł z jednym dzieckiem, czyli równość nie zawsze musi
być. W implementacji – dodajemy do węzła info o jego głębokości i ewentualnie się
modyfikuje w zależności od tego.
Skrót AVL pochodzi od nazwisk twórców Adelson – Velskii oraz Landis.
Twierdzenie – wysokość drzewa AVL przechowującego n węzłów wynosi O(logn). Dowód
na wykładach na stronce.
Wstawiamy tak jak do drzewa BST – zawsze dodajemy nowy liść. Łatwo jest
modyfikować info o głębokości, bo skoro wstawiamy jak w BST to przechodzimy całą
ścieżkę wiec znamy głębokość poddrzewa, łatwo modyfikuję głębokość węzła – tylko
patrzę, czy idę w lewo czy w prawo, bo przecie modyfikuję głębokość ‘rodzica’. Jeśli się
zepsuje drzewo, w sensie przestanie ono być AVL to odnajdujemy pierwsze miejsce,
gdzie jest źle, pierwsze poddrzewo w którym się problem psuje i wtedy naprawiam
drzewo w tym miejscu i potem pójdę do góry i najwyżej będę też poprawiać wyżej.
Przebudowa drzewa
Niech (a,b,c) będzie pożądaną listą wierzchołków w porządku In order
Przeprowadzimy rotacje niezbędne do przemieszczenia b na górę poddrzewa.
Jeśli te trzy węzły są wszystkie na jedna stronę to robimy tak by b poszedł na górę, a
jeśli tak nie jest to musimy robić dwie rotacje – najpierw prawa c a potem lewa a – patrz
slajdy i przykład. Czas jest stały, czyli pożądany. Ale pamiętajmy, że problem się może
przenieść poziom wyżej, przenosimy go do korzenia. Przenosimy go wyżej tyle ile jest
Zaawansowane algorytmy
7
najdłuższa ścieżka. To nie popsuje nam czasu działania na tym drzewie. Zawsze po
jednej rotacji sprawdzam, czy się problem rozwiązał sprawdzając głębokości
poszczególnych węzłów.
Usuwanie z drzewa AVL
Usuwamy jak w BST i to znów może nam popsuć kwestię zbalansowania. Znów
naprawiamy poprzez rotację. Szukamy pierwszego węzła, który jest niezbilansowany.
Zwykle to oznacza zmianę korzenia drzewa. Dodawanie też mogło zmienić korzeń.
_ Niech z będzie pierwszym „niezbalansowanym” węzłem, na który natrafiamy idąc od w.
Niech y będzie dzieckiem z o większej wysokości, a x jego dzieckiem o większej
wysokości (patrz przykład na slajdzie 19).
_ Przeprowadzamy przebudowę drzewa w x , tak aby przywrócić zbalansowanie w z
(węzły a,b,c maja być w porządku inorder).
_ Ponieważ operacja taka może zachwiać balans w węźle powyżej - musimy sprawdzać
zbalansowanie drzewa powyżej (aż do korzenia)
Złożoność obliczeniowa operacji na drzewach AVL
Pojedyncza przebudowa zabiera czas O(1) – jeśli korzystamy ze struktury łączonego
drzewa binarnego (tylko 1 lub 2 rotacje)
Wyszukiwanie zajmuje O(logn) – wysokość drzewa wynosi O(logn)
Wstawianie zajmuje O(log n)
– Odnalezienie miejsca O(log n)
– Przebudowa drzewa O(log n) – ze względu na wysokość drzewa
Usuwanie - O(log n)
– Odnalezienie zabiera czas O(log n)
– Naprawa drzewa O(log n) – ze względu na wysokość drzewa
Przypomnijmy, że nie zostaliśmy przy drzewku BST dlatego, bo chcemy mieć
drzewko prawie zbalansowane, czyli by zapewnić sobie czas operacji logarytmiczny, a nie
tak jak było w zdegenerowanym BST tj kwadratowy. Przy rotacji w lewo wysokość lewego
poddrzewa się zmniejszy o 1, a prawego poddrzewa się zwiększy o 1 LUB NIE!
Drzewa 2-3-4
Określenie:
To drzewa zbalansowane, ale nie są oczywiście binarne. Drzewem poszukiwań o wielu
drogach (B-drzewem) nazywamy uporządkowane drzewo o następujących cechach:
- każdy węzeł wewnętrzny posiada co najmniej 2 potomków i przechowuje d-1
elementów- kluczy, gdzie d jest ilością potomków węzła.
Dla każdego węzła o potomkach v1 v2 …vd przechowującego klucze k1k2 … kd−1
• Klucze w poddrzewie v1 są mniejsze od k1
• Klucze w poddrzewie vi są pomiędzy ki−1 i ki (i = 2, …, d − 1)
• Klucze w poddrzewie vd są większe od kd−1
- Liście nie przechowują kluczy.
Też jest kłopot z duplikatami, trzeba przyjąć, gdzie one mają iść.
Implementacja:
Albo zrobimy wiele wskaźników, tj. tyle by wystarczyło, czy ograniczam z góry liczbę
dzieci, ale jeśli dopuszczę do miliona dzieci, to trzymanie w każdym węźle miliona
wskaźników jest bez sensu. Wtedy węzłem jest lista kluczy, struktura dynamiczna,
trzymam w kluczu wskaźnik na pierwszy element, on trzyma wskaźnik na drugi itp. Czyli
trzymanie listy to przecież trzymanie pierwszego wskaźnika. Ale co wtedy z dziećmi?
Robimy tak, by dziecko pamiętało, kto jest jego rodzicem, taka odwrócona sytuacja, czyli
mam strzałeczki w dwie strony. Rodzic pamięta, kto był pierwszym dzieckeim, czyli
pamięta najstarsze dziecko, a dziecko pamięta od siebie to o jeden młodsze.
Zaawansowane algorytmy
8
Przechodzenie inOrder w drzewach o wielu gronach
- możliwe jest rozszerzenie notacji odwiedzania węzłów inOrder z drzew binarnych na
drzewa o wielu drogach
- odwiedzamy element (k_i, o_i) w węźle v pomiędzy rekursywnymi odwiedzinami
poddrzew v o korzeniu w v_i oraz v_(i+1)
- odwiedzane węzły w tak zdefiniowanym porządku inOrder są uporządkowane rosnąco
Poszukiwanie podobne do BST, przykład na slajdach.
Przyda się jednak ograniczenie ilości dzieci, bo gdyby ta ilość byłą nieograniczona, to
moglibyśmy porównywać szukaną wartość z tymi, które są w węźle w nieskończoność.
Nie ważne, czy jest ograniczona piątką, czy milionem, chodzi o samo podejście, że musi
to ograniczenie być.
Dla każdego węzła wewnętrznego o potomkach v1 v2 …vd o kluczach k1 k2 …kd−1:
– k = ki (i = 1, …, d − 1):poszukiwanie zakończone sukcesem
– k < k1: poszukiwanie kontynuujemy w poddrzewie v1
– ki−1< k < ki (i = 2, …, d − 1): poszukiwanie kontynuujemy w poddrzewie vi
– k > kd−1: poszukiwania kontynuujemy w poddrzewie vd
B-drzewa są stosowane w bazach danych. Stosujemy je (a nie BST czy AVL) dlatego, bo
jak jest więcej dzieci to jest mniejsza wysokość i tu się wykorzystuje własności funkcji
logarytm, że ona jest taka ‘płaska’.
Drzewka 2-3-4 - własności
To drzewa poszukiwań o wielu drogach o następujących własnościach:
- własność ilości potomków – każdy węzeł wewnętrzny ma co najwyżej 4 potomków
- własność wysokości – wszystkie liście mają tę samą wysokość
W zależności od ilości potomków węzły wewnętrzne będziemy nazywać 2-węzłami, 3węzłami, 4-węzłami. Chcemy by te drzewa były dokładnie zbalansowane. Można troszkę
przeciążać te drzewka. Przy tych drzewach najlepiej jednak pamiętać wskaźniki na
wszystkie dzieci, skoro jest ich mało. W implementacji to drzewko najlpiej dokładnie
zbalansowane wcale nie jest takie hop.
Twierdzenie: Drzewo 2-3-4 przechowujące n elementów ma wysokość O(logn)
Dowód na slajdach.
Wyszukiwanie w drzewie 2-3-4 o n elementach zajmuje czas O(logn).
Jak dodajemy elementy np. same rosnące, to będziemy musieli wciąż to drzewko
przebudowywać, co czwarty element coś będziemy musieli wykonać.
Wstawianie
Nowy element (k,o) wstawiamy do węzła v – ostatniego wewnętrznego węzła, przez
który przechodziliśmy poszukując k. Wtedy nie psujemy własności wysokości drzewa,
możemy spowodować przepełnienie węzła (v mogłoby się stać piątym węzłem).
Przykład na slajdach.
Problem przepełnienia można rozwiązać przez podział węzła v:
– niech v1 … v5 będą potomkami v i k1 …k4 będą kluczami w v
– węzeł v zastępujemy 2 węzłami v' i v"
• v' jest 3-wezłem o kluczach k1 k2 i potomkach v1 v2 v3
• v" jest 2-wezłem o kluczach k4 i potomkach v4 v5
– klucz k3 jest wstawiany do rodzica u węzła v (to może tworzyć nowy korzeń)
_ Przepełnienie może teraz nastąpić w węźle u
Analiza wstawiania
Algorytm insertItem(k, o)
1. Odszukujemy klucz k w celu zlokalizowania węzła do wstawienia wartości v
2. Dodajemy nowy element (k, o) w węźle v
3. while overflow(v)
if isRoot(v)
Zaawansowane algorytmy
9
twórz nowy pusty korzeń nad v
v <- split(v)
Niech T będzie drzewem 2-3-4 o n elementach
– T ma wysokość O(log n)
– krok 1 zajmuje czas O(log n), ponieważ odwiedzamy O(log n) węzłów
– krok 2 zajmuje czas O(1)
– krok 3 zabiera O(log n) czasu, ponieważ każde rozdzielanie zabiera O(1) i możemy
mieć O(log n) takich operacji
_ Stad wstawianie do drzewa 2-3-4
zajmuje czas O(log n)
Jak będziemy dzielić i sytuacja problemowa nam się przeniesie do korzenia, to dzielimy
korzeń, czyli stworzymy nowy korzeń i z niego dwoje dzieci. Czyli mamy logarytmiczny
czas znalezienia problemu, potem stały czas zabawy wskaźnikami i znów logarytmiczny
czas naprawy.
Usuwanie
Rozważania na temat usuwania można sprowadzić tylko do przypadku usuwania wartości
z węzła posiadającego jedynie potomków – liście, wtedy się popsuje zbalansowanie.
W przeciwnym razie (węzły wewnętrzne) zastępujemy element kolejnym w porządku
Inorder (następnikiem, jego lewe dziecko musi być liściem).
Przykład na slajdach
Usunięcie elementu z węzła v może spowodować niedobór – v może stać się 1-węzłem (0
klucz, 1 potomek)
Dla obsługi tej sytuacji należy połączyć v z rodzicem u, rozpatrzmy dwa przypadki
Przypadek 1
Sąsiedni brat v jest 2-węzłem
- operacja łączenia: łączymy v z bratem w i przenosimy element z u do połączonego
węzła v
- po połączeniu, niedobór może nastąpić w węźle powyżej u
Przypadek 2
Sąsiedni brat w węzła v jest 3-węzłem lub 4węzłem
Przenosimy potomka w do v; element z u do v; element z w do u
Po przeniesieniu nie występują przepełnienia!! O tyle jeśli się da doprowadzić do drugiego
przypadku to z niego koniecznie korzystamy!
Przykład na slajdach.
Analiza usuwania
Niech T będzie drzewem 2-3-4 o n elementach. Wysokość T wynosi O(logn)
Operacja usuwania
- odwiedzamy O(logn) węzłów aby odszukać węzeł z którego usuwamy element
- obsługa niedoborów może prowadzić do wykonania serii O(logn) łączeń węzłów (przyp)
oraz jednego przesuwania (przyp 2)
- każde łączenie zajmuje O(1)
Stąd operacja usuwania w drzewie 2-3-4 zajmuje czas O(log n).
Gdybym miała drzewko korzeń a, z dziećmi b,c i usuwam c, to zmieniam całość tj
zmniejszam ilość poziomów i będę miała tylko korzeń w którym będzie a,b.
B-drzewa są wbrew pozorom jednak dość nieefektywne, dlatego szukamy fajniejszych
struktur i w ten sposób doszliśmy do…
O drzewach czerwono czarnych będzie innym razem (czas balansowania w AVL jest
logarytmiczny a to dość sporo, dlatego w RB będzie fajniej, bo będzie to struktura
binarna, tyle że jeszcze z tymi kolorkami).
W przeciętnym słowniku koszt operacji to O(n) – lista liniowa
Zaawansowane algorytmy
10
Lepszy słownik to O(ln n) – drzewo
Pamięć jest przy dużych obiektach ta sama, operacje są trochę bardziej skomplikowane,
jednak tanim kosztem można przejść między tymi wersjami. Ale da się jeszcze taniej –
by czas poszukiwania był w idealnym układzie stały niezależnie od wielkości zbioru.
Operacje dynamiczne też będziemy wykonywać w czasie niezależnym od rozmiaru zbioru,
co przy dużej bazie danych ma sens. Czyli zejdziemy na czas stały O(1), co przeczy
intuicji, ale jest możliwe.
Haszowanie Cormen rozdział 12
Tablice z adresowaniem bezpośrednim – pokażemy że to w ogóle możliwe,
adresowanie kluczem, czyli w tablicy indeksami będą wartości kluczy. Czyli odnajduję
miejsce w tablicy o danym kluczu i sprawdzam czy jest to miejsce puste czy nie, jeśli tak
to wstawiam tam element.
- Problem jest tutaj taki, że rozmiar tablicy jest tak duży jak rozmiar kluczy, czyli np.
mam miliard kluczy a zajmuję tylko 100 miejsc.
- Drugi problem – nie da się wsadzić tu duplikatów, stąd próbujemy pokombinować i
dochodzimy do kolejnego rozwiązania:
Tablice z haszowaniem (Adresowanie otwarte, haszowanie łańcuchowe) – bierzemy
proste funkcje, które się dobrze liczy, by była jak najprostsza i ona będzie odrzuca ileś
rzeczy tak że otrzymamy możliwości wstawiania do dużo mniejszej tablicy, będzie nam w
miarę równomiernie rozrzucać te klucze, nie jest różnowartościowa, wiec nie da się jej
odwrócić. A co jeśli dwa różne klucze zostaną przeprowadzone na to samo miejsce?
Pierwsze podejście do adresowanie otwarte – wsadzamy element w następne wolne
miejsce. Czyli jak chcę zobaczyć czy jakiś klucz jest w tablicy, to patrzę w które miejsce
mnie wrzuciła funkcja haszująca, jeśli nie mam tego klucza to szukam dalej i postępuję
według jakiegoś podejścia. Haszowanie łańcuchowe to możliwość wsadzania iluś
elementów do jednego miejsca, jeśli jest już miejsce zajęte, to buduję w nim listę
liniową, czyli mam tablicę list.
Funkcje haszujące (mieszające) – tu będzie właśnie problem by dobrać te funkcje, zwykle
są to funkcje w których się coś mnoży i bierze modulo rozmiar tablicy
Haszowanie uniwersalne
Idealne Haszowanie
Nie zakładamy uporządkowania kluczy.
Drzewa poszukiwań BST wymagają relacji porządku i dają czas O(lg n), a tablice z
haszowaniem pozwalają na dowolne klucze i dają średni czas O(1) – statystyczny
przypadek przy odpowiedniej pamięci, jednak jeśli tablica jest za mała, a kluczy jest dużo
to nie otrzymam tego czasu, wiec jednak muszę ileś pamięci mieć, niekoniecznie tak
dużo jak w BST, ale nadal…. W tych tablicach jednak przez to się niefajnie znajduje
najmniejszą lub największą wartość właśnie dlatego, bo nie mamy porządku, klucze są
rozrzucane niezależnie od ich wartości, czyli jak mamy jakiekolwiek zadanie związane z
porządkiem to Haszowanie jest złe.
Tablice z adresowaniem bezpośrednim
- Korzystamy z tablicy o rozmiarze zgodnym z możliwym zakresem kluczy
- Wykorzystujemy klucz k jako indeks tablicy A (k->A[k])
- Rozmiar tablicy z haszowaniem jest proporcjonalny do zakresu kluczy, nie do ilości
elementów
- Potrzeba bardzo dużo pamięci
Można np. trzymać 1 jeśli element jest ,a 0 jeśli go nie ma. Jak usuwamy, to ustawiamy
A[k] = 0, czyli operacje są proste i działają ;)To troszkę podobne do sortowania przez
zliczanie.
Oto co próbujemy z tym fantem zrobić:
Zaawansowane algorytmy
11
Zwykle jeśli mam milion kluczy to tablicę biorę dwa miliony i wstawiając elementy wciąż
sprawdzam, czy nie zapełniłam już połowy, jeśli tak to rozszerzam. Rozmiar tablicy z
haszowaniem jest proporcjonalny do ilości elementów , nie do ich zakresu.
Do tej pory mieliśmy funkcje identycznościową, a teraz obliczamy indeks jako wartość
pewnej funkcji haszowania h(k), czyli buduje funkcję, która działa z mojego zbioru
indeksów w pewien mniejszy zbiór (np. 0..n-1), dlatego nie jest ona różnowartościowa.
Zatem jeśli trafię z dwoma kluczami w to samo miejsce to albo przesuwam, albo buduję
listę.
Przykład to tablice rejestracyjne czy loginy i hasła użytkowników systemu.
Trzeba dobrać tak funkcje, by nie powielała informacji zawartej w kluczu, w sensie zła
jest funkcja haszująca taka, że jak ma adres IP to bierze tylko pierwszy oktet, a resztę
ucina, wtedy skoro jest tylko jedne oktet pierwszy dla Afryki, to całą Afrykę byśmy
wsadzili do jednego pola. Ale jeśli weźmiemy już modulo coś, to sytuacja się zmieni.
Formalnie:
Niech U będzie zbiorem wszystkich możliwych kluczy rozmiarze |U|, K – aktualny zbiór
kluczy o rozmiarze n, T tablica z haszowaniem o rozmiarze O(m), m <= |U|.
_ Niech h(k) będzie funkcją haszującą: h(k): U->[0..m–1] mapującą klucze z U na
indeksy tablicy T. wartość h(k) można obliczyć w czasie O(|k|) = O(1).
_ Elementy tablicy T[i] sa dostępne w czasie O(1). T[i] = null jest wejściem pustym.
_ Dla uproszczenia można przyjąć U = {0, …, N –1}.
Patrzmy jaki podzbiór kluczy nas interesuje i do niego dobierajmy funkcję haszująca. Na
sam koniec zawsze musimy pamiętać zrobić modulo rozmiar tablicy, by trafić w tablicę.
Krytycznie źle dobrana funkcja – wszystkie klucze trafiają w jedno miejsce tablicy. Wtedy
się czas zdegeneruje do liniowego.
Adresowanie bezpośrednie jako Haszowanie
Tablica z haszowaniem ma rozmiar m T[0..m-1]
Ilość wykorzystywanych kluczy n jest bliska m, lub m jest mała ( w porównaniu z
dostępnymi zasobami, pamięcią)
Klucz k staje się indeksem T, tj. funkcja mieszająca jest następująca: h(k) = k.
_ Nie ma kolizji, czas dostępu wynosi O(1), w najgorszym wypadku.
_ Nie ma potrzeby przechowywania klucza – tylko dane.
_ Problemy: Metoda ta wymaga dużo pamięci i jest niemożliwa do zrealizowania dla
dużych m
Tablica z haszowaniem
- Możemy używać funkcji mieszającej wiele do jednego h(k) do mapowania kluczy k na
indeksy T
- Zbiór wykorzystywanych kluczy K może być dużo mniejszy od przestrzeni kluczy U, tj.
m « |U|.
_ Rozwiązywanie kolizji kiedy h(k1) = h(k2) dwoma metodami:
I. Adresowanie otwarte
II. Łańcuchy
Współczynnik zapełnienia
Założenie o równomiernym haszowaniu: dla każdego klucza wszystkich m! permutacji
zbioru {0..m-1} jest jednakowo prawdopodobnych jako ciąg kolejnych próbkowań (ciąg
kontrolny)
Definicja:
Współczynnikiem zapełnienia a dla tablicy z haszowaniem T z m pozycjami określamy
stosunek n/m gdzie n jest ilością przechowywanych elementów.
Jest to średnia liczba elementów przechowywanych w jednej pozycji tablicy.
Zaawansowane algorytmy
12
Haszowanie przez adresowanie otwarte
Wszystkie klucze k , mapowane w tą samą pozycję T[h(k_i)] są wstawiane bezpośrednio
do tablicy w pierwsze wolne miejsce.
Poszczególne pozycje tablicy mogą zawierać elementy albo wartości null.
Funkcja haszująca powinna być zmodyfikowana (dwuargumentowa, gdzie drugi argument
jest numerem próby)
H(k,i): U x [0..m-1] ->[0..m-1]
Sekwencja kolejnych miejsc w tablicy dla elementu h(k,0), h(k,1),…Chodzi o to by to
rzadko liczyć, by nie była tu zależność od ilości elementów, by te łańcuszki były krótkie.
Przy dobrze dobranej funkcji i odpowiedniej wielkości tablicy przy którejś próbie będzie
dobrze. Czyli chodzi o to by kolejne próby teoretycznie zapełniły wszystkie miejsca w
tablicy, a nie o sytuację, że np. zapełniam tylko miejsca parzyste.
Operacje potrzebne dla adresowania otwartego
Insert:
testujemy kolejne miejsca w tablicy, aż do odnalezienia wolnego. Sekwencja kolejnych
miejsc do sprawdzenia zależy od wstawianego klucza. Jeśli nie odnajdujemy wolnego
miejsca po m próbach, oznacza to, że tablica jest pełna.
Search:
testujemy te sama sekwencje pozycji co przy wstawianiu, albo do momentu odnalezienia
poszukiwanego klucza (sukces), albo natrafienia na wolna pozycje (porażka).
Usuwanie:
Usuwanie jest kłopotem tym razem, bo jeśli pozbawimy ciągłości sekwencję próbkową to
będzie źle…Bo jeśli mamy tak po prostu klucze k1, k2, k3, k4, k5 i usuniemy po prostu
k3 to stracimy k4 i k5, a to dlatego, bo gdybyśmy wtedy chcieli znaleźć k4, to liczę
h(h4,0) i porównuję klucze, skoro nie jest null, to liczę następną próbę, czyli wchodzę w
drugie miejsce tablicy h(h4,1) znów zajęte, ale nie to o co nam chodzi, a przy kolejnym
próbkowaniu trafię na null czyli nie znajdę h4. Można więc przeliczyć wszystkie następne
hasze, ale to za czasochłonne, za to można ustawić znacznik, że jakiegoś klucza nie ma,
to tylko znacznik, który mówi, ze klucza nie ma, ale nie zostaje przerwana sekwencja
próbkowania. Przykład tego w bazach danych.
Złożoność : zależna od długość sekwencji próbkowania.
Strategie próbkowania
- adresowanie liniowe (to, że w kolejnej próbie biorę pod uwagę kolejne miejsce)
- adresowanie kwadratowe (czyli w kolejne próbie idę do kwadratu…, czyli się wciąż
oddalam coraz dalej, jak funkcja kwadratowa)
- Haszowanie dwukrotne
Adresowanie liniowe
Funkcja haszująca h(k,i) = (h’(k)+i)mod m
Gdzie h’(k) jest zwykłą funkcją haszująca niezależną od numeru próby.
Dla
klucza
k
sekwencja
próbkowania
jest
wtedy
T[h’(k)], T[h’(k)+1], … T[m–1 ], T[0], T[1] … T[h’(k)–1]
następująca:
Problem: tendencja do grupowania zajętych pozycji (czyli się łączą takie bloczki ze
sobą…). Długie spójne ciągi zajętych pozycji szybko się powiększają, co spowalnia
operacje poszukiwania.
Wykorzystywana przy kluczach gdzie jest niedużą dynamika.
Adresowanie kwadratowe
Funkcja haszująca
h(k, i) = (h’(k) + c1i + c2i^2) mod m
gdzie c1 i c2 stałe różne od 0, h’(k) zwykła funkcja haszująca (niezależna od numeru
Zaawansowane algorytmy
13
próby).
W przeciwieństwie do adresowania liniowego, kolejne rozpatrywane pozycje są oddalone
od początkowej o wielkość zależną od kwadratu numeru próby i.
Zwykle dodaje się tylko kwadrat numeru próby, wtedy nie powstaną nam te bloczki.
W przeciwieństwie do adresowana liniowego, kolejne rozpatrywane pozycje są oddalone
od początkowej o wielkość zależną od kwadratu numeru próby i.
Prowadzi to do mniej groźnego zjawiska grupowania - określanego jako grupowanie
wtórne: klucze o tej same pozycji początkowo dają takie same sekwencje.
Haszowanie podwójne
Funkcja haszująca
h(k, i) = (h1(k) + ih2(k)) mod m
gdzie h1(k) i h2(k) dwie funkcje haszujące.
Pierwsza sprawdzana pozycja to T[h1(k)]. Kolejne próby są oddalone od początkowej o
h2(k) mod m.
Wartość h2(k) powinna być względnie pierwsza z rozmiarem tablicy m, aby mieć
gwarancje że cała tablica zostanie przeszukana.
Przykłady funkcji:
h1(k) = k mod m
h2(k) = 1 + (k mod m’) gdzie m’ < m (np. m-1, m-2)
Generuje Teta(m^2) istotnie różnych sekwencji (dla liniowego i kwadratowego Teta(m) )
Przesuwamy się o wielokrotność drugiej funkcji haszującej a nie o numer próby.
Analiza adresowania otwartego
Twierdzenie: Jeśli współczynnik zapełnienia tablicy z haszowaniem wynosi a=n/m <1, to
oczekiwana liczba porównań kluczy w czasie wyszukiwania elementu (przy spełnieniu
założeniu o równomiernym haszowaniu)
- co najwyżej 1/(1-a) dla nieudanego poszukiwania
- co najwyżej 1/a ln 1/(1-a) dla udanego poszukiwania
Jeśli a jest stałe, to czas poszukiwania wynosi O(1).
BY oczyścić tablicę, to trzeba znów przeliczyć wszystkie wartości haszy.
Rozwiązywanie kolizji metodą łańcuchową
- Wszystkie klucze k, które trafiają w tę samą pozycję umieszcza się w liście liniowej…
- elementy tablicy przechowują wskaźniki do list Lj
- wstawianie: nowy klucz wstawiany jest na początek listy Lj (Czas O(1)) – tak jak
zawsze na programowaniu, że tworzę nowy węzełek i przepinam jeden wskaźnik, czyli
mamy tablicę kluczy
- poszukiwanie / usuwanie – przeszukujemy listę w poszukiwaniu klucza (czas
proporcjonalny do ilości elementów najdłuższej listy)
Nadal chodzi o to by te listy nie były za długie – czyli o dobry dobór funkcji haszujacej.
To przypomina sortowanie kubełkowe.
„Trzeba ją inteligentnie wykorzystać” 
Jak mam adresowanie otwarte intów i sizeof(int) = 4 to biorę 4* 2^32 to wyjdzie chyba
koło 4 giga, a przy long int który ma 8 bajtów to…
Od tego miejsca czyste przepisanie ze slajdów, brak omówienia na wykładzie…
Proste równomierne haszownie oznacza, że losowo wybrany element z jednakowym
prawdopodobieństwem trafia na każdą z m pozycji, niezależnie od tego gdzie trafiają inne
elementy.
Zaawansowane algorytmy
14
Twierdzenie: w tablicy haszowania wykorzystującej łańcuchową metodę rozwiązywania
kolizji, przy założeniu prostego, równomiernego haszowania, średni czas działania
procedury wyszukiwania (zarówno dla sukcesu, jak i pokraki) wynosi Teta(1+alfa), gdzie
alfa jest współczynnikiem zapełnienia tablicy.
Dobre funkcje haszujące
Wydajność haszowania zależy w olbrzymiej mierze od zbioru wykorzystywanych kluczy
oraz funkcji wykorzystywanej do haszowania.
_ Dobre funkcje haszujące to takie, które spełniają założenie prostego równomiernego
haszowania!
_ Zwykle jednak nie można (lub nie jest łatwo) stwierdzić, czy założenie takie jest
spełnione. Najczęściej nie znamy rozkładu prawdopodobieństwa występowania kluczy.
_ Szukając dobrych funkcji najczęściej poszukuje się funkcji działających dobrze „w
większości wypadków” -> heurystyczne
Heurystyczne funkcje haszujące
Funkcja powinna dobrze się sprawować dla większości zbiorów kluczy (takich, które
naprawdę występują w zadaniu), niekoniecznie dla specjalnie przygotowanych
„złośliwych” zbiorów kluczy.
_ Zbiór wykorzystywanych kluczy K zwykle nie jest losowy, ale posiada pewne cechy (np.
wspólne początkowe bity, wielokrotności pewnej liczby, itp.).
_ Celem jest znalezienie takiej funkcji haszującej, która tak dzieli cały zbiór
potencjalnych kluczy U , że dla zbioru kluczy aktualnych K wydaje się on losowy.
Przypadek najgorszy (worst-case) powinien być mało prawdopodobny.
Haszowanie modularne
Niech U = N = {0,1,2, …}, będzie zbiorem liczb naturalnych.
_ Mapujemy klucz k na pozycje m przez branie reszty z dzielenia k przez m:
h(k) = k mod m
_ aby taka metoda działała dobrze należy unikać takich m , które są potęgami 2
(m = 2^p) – ponieważ wtedy wybieramy ostatnie p bitów klucza, ignorując istotna część
informacji.
_ Heurystyka: wybieramy jako m liczbę pierwsza odległa od potęg 2.
Przykład
Weźmy |U| = n = 2000 i załóżmy, _e dopuszczamy maksymalnie 3 kolizje dla klucza.
_ Jaki powinien być rozmiar tablicy (m)?
_ Mamy floor(2000/3) = 666; liczba pierwsza bliska tej wartości, a jednocześnie daleka
od potęg 2 to np. 701.
_ Stad funkcja haszująca może być taka: h(k) = k mod 701
_ Wtedy klucze 0, 701, i 1402 są mapowane na 0.
Haszowanie przez mnożenie
_ Mapujemy klucz k na jedna z m pozycji przez pomnożenie go przez stałą a z zakresu
0 < a < 1, dalej wyznaczamy część ułamkowa ka, i mnożymy ja przez m:
H( k ) = floor (m(ka − floor(ka))) , 0 < a < 1
_ Metoda taka jest mniej wrażliwa na wybór m ponieważ_ „losowe” zachowanie wynika z
braku zależności pomiędzy kluczami a stała a.
_ Heurystyka: wybieramy m jako potęgę 2 i a jako liczbę bliska „złotemu podziałowi” :
a = ( sqrt5 −1)/ 2 = 0.6180339887...
Haszowanie uniwersalne
_ Idea: dobieramy funkcję haszującą losowo w sposób niezależny od kluczy.
Zaawansowane algorytmy
15
_ Funkcja wybierana jest z rodziny funkcji o pewnych szczególnych własnościach (takich,
które „średnio” zachowują się dobrze).
_ Gwarantuje to, że nie ma takiego zestawu kluczy, który zawsze prowadzi do
najgorszego przypadku.
_ Pytanie: jak określić taki zbiór funkcji?
_ Wybieramy ze skończonego zbioru uniwersalnych funkcji haszujacych.
Cel: chcemy aby zachodziło założenie o prostym równomiernym haszowaniu – tak aby
klucze były średnio rozproszone równomiernie.
Właściwości prostego, równomiernego haszowania:
– Dla dowolnych dwóch kluczy k1 i k2, i dowolnych dwóch pozycji y1 i y2,
prawdopodobieństwo tego, _e h(k1) = y1 i h(k2) = y2 wynosi dokładnie 1/m2.
– Dla dwóch kluczy k1 i k2, prawdopodobieństwo kolizji, tj. h(k1) = h(k2) wynosi
dokładnie 1/m.
_ Chcemy, aby rodzina funkcji haszujacych H była tak dobrana, że szansa kolizji jest taka
sama jak przy prostym, równomiernym haszowaniu.
Definicja: niech H będzie skończoną rodziną funkcji haszujacych, mapujacych zbiór
dopuszczalnych kluczy U na zbiór {0,1,…,m –1}.
H nazywamy uniwersalna jeżeli dla każdej pary równych kluczy k1 i k2 z U, ilość funkcji
haszujacych h z H, dla których h(k1) = h(k2) jest równa co najwyżej |H|/m.
_ Inaczej mówiąc, jeśli losowo wybierzemy funkcje z takiej rodziny to szansa na kolizje
pomiędzy różnymi kluczami k1 i k2 nie jest większa niż 1/m.
Twierdzenie: niech h będzie funkcja haszująca, wybrana losowo z uniwersalnej rodziny
funkcji haszujacych. Jeśli zastosujemy ją do haszowania n kluczy w tablice T o rozmiarze
m, to oczekiwana długość łańcucha, do którego dotłaczany jest klucz (przy założeniu, że
alfa = n/m jest współczynnikiem zapełnienia) wynosi:
– jeśli k nie ma w tablicy – co najwyżej alfa
– jeśli k jest już w tablicy – co najwyżej 1+alfa
Wniosek: Przy zastosowaniu uniwersalnego haszowania i rozwiązywania kolizji metodą
łańcuchową, dla tablicy o rozmiarze m, oczekiwany czas n operacji wstawiania, usuwania
i wyszukiwania wynosi Teta(n).
Konstrukcja rodziny uniwersalnej
Wybieramy liczbę pierwszą taką, że p > m i p jest większe od zakresu kluczy aktualnych
K . Niech Zp oznacza zbiór {0,…p –1}, i niech a i b będą dwoma liczbami z Zp.
_ rozważmy funkcje: ha,b(k) = (ak +b) mod p
_ Rodzina wszystkich, takich funkcji jest: Hp,m = {ha,b | a,bÎZp i a != 0 }
_ Aby wybrać losowa funkcje z tej rodziny – wybieramy losowo a i b ze zbioru Zp.
Lemat: dla dwóch różnych kluczy k1 i k2, oraz dwóch liczb x1 i x2 z Zp,
prawdopodobieństwo, że k1 trafi na pozycje x1 i k2 na pozycje x2 wynosi 1/p2.
Dowód na slajdach.
Ponieważ_ zakres kluczy może być bardzo duży, zawężamy zbiór funkcji haszujacych do
m wartosci: ha,b(k) = ((ak +b) mod p) mod m
Rodzina: Hp,m = {ha,b: = a, b Î Zp}jest poszukiwana rodzina uniwersalna funkcji
haszujacych.
Haszowanie uniwersalne daje czas O(1) w przypadku średnim i to dla dowolnego zbioru
aktualnych kluczy, nawet jeśli trafiamy na same „złośliwe” układy kluczy.
_ Szansa na złe zachowanie się metody jest bardzo mała.
_ Jednak dla dynamicznych zbiorów kluczy, nie możemy powiedzieć z wyprzedzeniem czy
dana funkcja będzie dobra, czy nie…
Zaawansowane algorytmy
16
Haszowanie idealne
Haszowanie uniwersalne zapewnia średni czas O(1) dla dowolnego zbioru kluczy.
_ Czy można zrobić to lepiej? W niektórych przypadkach TAK!
_ Idealne haszowanie zapewnia czas O(1) w najgorszym przypadku (worst-case) dla
statycznego zbioru kluczy (takiego, w którym klucz raz już zachowany, nie zmienia sie
nigdy).
_ Przykłady statycznych zbiorów kluczy: słowa kluczowe dla języka programowania,
nazwy plików na płycie CD.
Idea: korzystamy z dwupoziomowego schematu haszowania – za każdym razem jest to
haszowanie uniwersalne.
poziom 1: haszujemy łańcuchowo: n kluczy ze zbioru K jest umieszczanych na m
pozycjach w tablicy T z wykorzystaniem funkcji h(k) wybranej z rodziny uniwersalnej.
poziom 2: zamiast tworzyć listę liniowa dla kluczy wstawianych na pozycje j,
wykorzystujemy druga tablice z haszowaniem Sj skojarzona z funkcja hj(k). Wybieramy
hj(k) aby mieć pewność, że nie zachodzą kolizje, Sj ma rozmiar równy kwadratowi ilości
nj – kluczy umieszczonych na tej pozycji |Sj| = n_j^2. Przykład na slajdach.
Twierdzenie: jeśli przechowujemy n kluczy w tablicy o rozmiarze m = n^2 przy
wykorzystaniu funkcji h(k) losowo wybranej z rodziny uniwersalnej funkcji haszujacych,
wtedy prawdopodobieństwo kolizji wynosi < ½ .
Dla dużych n, przechowywanie tablicy o rozmiarze m = n^2 jest kosztowne.
_ Dla zredukowania potrzebnej pamięci można wykorzystać następujący schemat:
– poziom 1: T rozmiaru m = n
– poziom 2: Sj rozmiaru mj= n_j^2
_ Jeśli mamy pewność, że dla tablic drugiego poziomu nie ma kolizji, czas dostępu
(równie_ worst-case) jest stały.
_ Pytanie – jaki jest oczekiwany rozmiar potrzebnej pamięci?
Rozmiar tablicy pierwszego poziomu - O(n).
_ Oczekiwany rozmiar wszystkich tablic drugiego poziomu wynosi: tu szlaczek…
_ Wyrażenie w sumie określa łączną ilość kolizji.
_ Średnio jest to 1/m razy ilość par. Ponieważ_ m = n, daje to co najwyżej n/2.
_ Stad, oczekiwana rozmiar tablic drugiego poziomu wyniesie mniej niż 2n.
Haszowanie jest uogólnieniem abstrakcyjnego typu danych dla tablicy.
_ Pozwala na stały czas dostępu i liniowe składowanie dla dynamicznych zbiorów kluczy.
_ Kolizje rozwiązywane sa poprzez metodę łańcuchową albo otwarte adresowanie.
_ Haszowanie uniwersalne zapewnia gwarancje oczekiwanego czasu dostępu.
_ Idealne haszowanie zapewnia gwarancje czasu dostępu w przypadku statycznych
zbiorów kluczy.
_ Haszowanie nie jest dobrym rozwiązaniem przy poszukiwaniach związanych z
porządkiem (szukanie maksimum, następnika itp.) – nie ma porządku pomiędzy kluczami
w tablicy.
Kompresja danych
Co to jest kompresja danych?
Powinny zajmować mniej miejsca niż poprzednio.
Kodowanie to przekształcenie informacji, a kompresja to ściśnięcie informacji, zatem to
co innego.
Po co kompresować dane?
Zaawansowane algorytmy
17
Oszczędność przy składowaniu danych – nie zawsze ma miejsce, czasami jak spakujemy
coś, to plik ma nadal tą samą długość lub nawet większą, np. nie da się za bardzo
skompresować PDFa, a to dlatego, bo PDF, JPG itp. Mają już kompresję same w sobie. By
kompresować coś wykorzystujemy strukturę pewnej informacji, po skompresowaniu ta
struktura już nie istnieje, zatem program nie skompresuje tego.
Bardziej efektywne przesyłanie w sieciach komputerowych
Kompresja tekstu
Zadanie: mamy tekst z literami z pewnego alfabetu, chcemy zrobić tak by długie
łańcuchy stały się krótkimi łańcuchami. Chcemy znaleźć wersję skompresowaną tego
tekstu, ale by się dało odtworzyć potem tekst wejściowy.
Zatem szukamy odwracalnej funkcji kompresji.
Nie da się tak ładnie tego zrobić, bo wszystkie łańcuchy np. do 10znaków nie da się
przenieść na łańcuchy np. do 3 znaków, bo przecież w tych do 10znaków znajdują się już
te wszystkie do 3 znaków i masa innych. Zatem bez dodatkowych założeń nie da się
kompresować. Programy nasze jednak działają, bo wykorzystujemy pewne wartości
różnych zbiorów – np. częstość występowania znaków w tekstach.
Zwykle listery w tekście występują ze zróżnicowaną częstością, dla j. angielskiego e 12%,
t 10% itp. Znaki specjalne $# występują rzadko. Niektóre znaki występują sporadycznie,
są właściwie niewykorzystywane np. początkowe znaki kodu ASCII.
Teksty (pliki) podlegają pewnym regułom:
- słowa się powtarzają (występują jedynie słowa z pewnego słownika) – w sensie
przechodzimy z poziomu znaków na poziom słów, kodujemy tylko te łańcuchy znaków,
które coś znaczą, a nie jakieś trtrt
- nie każda kombinacja słów jest możliwa
- Zdania mają określoną strukturę (gramatyka)
- Programy korzystają z określonych słów (elementy języka programowania)
- wzorce są często powielane
- obrazy kodowane cyfrowo mają jednolite obszary (bez zmian koloru) – wtedy nie
kodujemy każdego piksela z osobna tylko cały obszar
Przykład
Przyjmijmy, że kod znaku w ASCII zajmuje 1 bajt. Przypuścimy że mamy tekst
składający się ze 100 znaków ‘a’ – tekst powinien zajmować 100 bajtów. Przechowując
tekst w postaci ‘100a’ możemy dostać dokładnie taką samą informację: rozmiar nowego
pliku wyniesie 4bajty. Oszczędność 4/100 -> 96%. Wiadomo że przy zwykłym tekście to
nie pójdzie, bo w normalnym tekście nie występują sekwencje tych samych znaków.
Kompresja bezstratna
W 100% odwracalna, poprzez dekompresję można odzyskać oryginalne dane. Poprzedni
przykład pokazywał taką kompresję. Musi być ta kompresja stosowana tam, gdzie istotna
jest integralność danych. Przykłady oprogramowania: winzip, GIF, bzip. Zwykle właśnie o
tą kompresję będzie nam chodziło.
Kompresja stratna
Stratna oznacza, że oryginalna informacja nie może być w całości odzyskana. Kompresja
ta zmniejsza rozmiar przez permanentne usuniecie pewnej części informacji. Po
dekompresji dostajemy jedynie część początkowej informacji (ale użytkownik może tego
wcale nie zauważyć, bo jeśli przekłamię ileś pikselów w obrazku, to nikt tego nie
zauważy, bo nie widzimy pikseli).
Kompresję stratną stosujemy dla plików audio, obrazów, wideo, formaty JPG i mpeg. Mp3
– stratna kompresja.
Zaawansowane algorytmy
18
Kody
- Metody reprezentacji informacji
- kod dla pojedynczego znaku często nazywany jest słowem kodowym
- dla kodów binarnych każdy znak reprezentowany jest przez unikalne binarne słowo
kodowe
- kody o stałej długości słowa: ASCII, Unicode, długość słowa kodowego dla każdego
znaku jest taka sama. – prowadzi to do dużego marnotrawstwa jak już mówiliśmy
Kody stałej długości
Przypuścimy że mamy n znaków
Jaka jest minimalna ilość bitów dla kodów o stałej długości? Sufit(log_2n).
Przykład {a,b,c,d,e} czyli 5 znaków, to sufit z tego logarytmu to 3 bity na znak. Możemy
zakodować np. tak: a=000, b=001, c=010, d=011, e=100.
Kody o zmiennej długości
Długość słowa kodowego może być różna dla różnych znaków
Znaki występujące częściej dostają krótsze kody
Znaki występujące sporadycznie dostają długie kody. Oczywiście
dostarczyć tablicę kodów.
Przykład na slajdach.
trzeba
będzie
By się dało rozkodować, to musi być kod prefiksowy, czyli żaden kod nie zawiera się w
początku innego kodu, przeanalizować przykład na slajdach. Po prostu jak mam
zakodowane słowo, czyli ciąg 0 i 1 to biorę pierwszy znak i patrzę, czy ten znak
odpowiada jakiejś literze, jeśli nie to biorę pierwsze dwa itp., jak znajdę że ten kod
odpowiada jakiejś literze to znaczy, że to jest ta litera i że tu się ten kod kończy i jadę
dalej.
Musimy mieć pewność, że żadne słowo kodowe nie występuje jako prefix innego.
Potrzebujemy kodów typu prefiksowego (prefix – free) – ostatni przykład taki był
Kody prefix-free pozwalają na jednoznaczne dekodowanie
Metoda Huffmana jest przykładem konstrukcji takiego kodu, będzie dokładniej omówiona
dalej.
Entropia
Entropia jest jednym z podstawowych pojęć teorii informacji
Za ojca teorii informacji uważa się Claude’a Shanonna który prawdopodobnie po raz
pierwszy użył tego terminu w 1945roku w swojej pracy zatytułowanej "A Mathematical
Theory of Cryptography". Natomiast w 1948 roku, w kolejnej pracy pt. "A
Mathematical Theory of Communication" przedstawił najważniejsze zagadnienia związane
z ta dziedzina nauki.
Entropia pokazuje, że istnieje ograniczenie dla bezstratnej kompresji
Ta granica jest nazywana entropia źródła H.
H określa średnią liczbę bitów niezbędną dla zakodowania znaku.
Niech n będzie rozmiarem alfabetu, p_i prawdopodobieństwem występowania
(częstością) i-tego znaku alfabetu. Wtedy entropia określona jest wzorem:
H = suma (od i=1 do n) –p_i log_2p_i
Przykład na slajdach
Algorytm kodowania Huffmana
Huffman wymyślił sprytną metodę konstrukcji optymalnego kody prefixowego (prefix
free) o zmiennej długości słów kodowych
Kodowanie opiera się o częstość występowania znaków
Optymalny kod jest przedstawiony w postaci drzewa binarnego
Zaawansowane algorytmy
19
– Każdy węzeł wewnętrzny ma 2 potomków
– Jeśli |C| jest rozmiarem alfabetu – to ma ono |C| liści i |C|-1 węzłów wewnętrznych
Budujemy drzewo od liści (bottom up). Zaczynamy od |C| liści. Przeprowadzamy |C|-1
operacji ‘łączenia’ .
Niech f[c] oznacza częstość znaku c w kodowanym tekście.
Wykorzystamy kolejkę priorytetową Q w której wyższy priorytet oznacza mniejsza
częstotliwość znaku:
– GET-MIN(Q) zwraca element o najniższej częstości i usuwa go z kolejki
Pseudokod na slajdach.
Czas wykonania O(nlgn)
Kodowanie Huffmana
–Jest adaptowane dla każdego tekstu
–Składa sie z
• Słownika, mapującego każdą literę tekstu na ciąg binarny
• Kod binarny (prefix-free)
_Prefix-free –Korzysta się tu z łańcuchów o zmiennej długości s1,s2,...,sm , takich że
żaden z łańcuchów s_i nie jest prefixem sj
Budowa kodów Huffmana
BARDZO WAŻNY PRZYKŁAD TEGO NA SLAJDACH! W nim to drzewo było tworzone tak, że
na dole najpierw wypisujemy wszystkie literki razem z ich częstościami. Na slajdach
zostały one wymienione w takiej kolejności, by się ładnie nam złożyło z nich drzewko.
Zawsze łączymy ze sobą dwie najmniejsze liczby i tak powstaje nam w górę drzewko. To
kwestia umowna czy na lewo idą 1 czy też 0. Na koniec z drzewka odczytujemy kod jaki
ma mieć dana liczba.
Znajdujemy częstości znaków
_Tworzymy węzły (wykorzystując częstości)
_powtarzaj: Stwórz nowy węzeł z dwóch najrzadziej występujących znaków (połącz
drzewa), a potem oznacz gałęzie 0 i 1
_Zbuduj kod z oznaczeń gałęzi
Poszukiwanie w kodach Hoffmana
Niech u będzie rozmiarem skompresowanego tekstu
Niech v będzie rozmiarem wzorca (zakodowanego na podstawie słownika)
KMP(będzie o tej metodzie potem z rozwinięciem tego skrótu) może odnaleźć wzorzec w
kodach Hoffmana w czasie O(u+v+m)
Zakodowanie wzorca zajmie O(v+m) kroków
_Budowanie prefixów zajmie czas O(v)
_Poszukiwanie na poziomie bitowym zajmie czas O(u+v)
_Problem: Algorytm rozważa bity, a można działać na poziomie bajtów
Kodowanie arytmetyczne – wprowadzenie
Pozwala na mieszanie bitów w sekwencji komunikatu. Pozwala też na redukcję bitów
potrzebnych do kodowania do poziomu l < 2 + suma (od i=1 do n) s_i, gdzie n to ilość
znaków, a s_i jest ilością wystąpień i-tego znaku.
Dla każdego znaku komunikatu przypisujemy podprzedział przedziału [0,1). Dla każdego
komunikatu przedział ten nazywa się przedziałem komunikatu. Przykład na slajdach.
Przy dekodowaniu trzeba przekazać jeszcze długość komunikatu byśmy wiedzieli ile razy
mamy dekodować.
Wynikowy przedział dla komunikatu nazywany jest przedziałem sekwencji.
Przedział komunikat jest unikalny
Zaawansowane algorytmy
20
Istotna własność: przedziały wynikowe dla różnych komunikatów o długości n są zawsze
rozłączne
Stad: podanie dowolnej liczby z przedziału komunikatu jednoznacznie identyfikuje ten
komunikat
Dekodowanie jest procesem podobnym do kodowania, odczytujemy znak i zawężamy
przedział
Przykład dekodowania na slajdach.
Możemy korzystać z krótszej ułamkowej reprezentacji binarnej dla wartości z przedziału
dla komunikatu. np. [0,.33) = .01 [.33,.66) = .1 [.66,1) = .11, trochę więcej na
slajdach…Ta kreska nad 01 na slajdach to odpowiednik (01) czyli 01 w okresie.
Ułamki binarne można uważać za reprezentacje przedziałów – slajdy.
Będziemy to nazywać przedziałem kodowym.
Lemat: Jeśli zbiór przedziałów kodowych jest parami rozłączny, odpowiadające im
kodowanie tworzy kod prefixowy.
Wybór przedziałów kodowania
Aby utworzyć kod prefixowy znajdujemy takie ułamki binarne, dla których przedziały
kodowe zawierają się w przedziałach komunikatów.
Przykład – slajdy.
Nie ma tu nic od strony 31 na slajdach, bo tego nie było na wykładzie.
Zaawansowane algorytmy
21

Podobne dokumenty