Podstawowe struktury danych Listy Lista to skończony ciąg elementów: q=[x1, x2, ... , xn ]. Skrajne elementy x1 i xn nazywamy końcami listy, a wielkość |q| = n długością (rozmiarem) listy. Szczególnym przypadkiem jest lista pusta: q = [ ]. Podstawowe abstrakcyjne operacje na listach q =[x1, x2, ... , xn ] i r =[y1, y2, ... , ym ] dla 1 ≤ i ≤ j ≤ n to: • dostęp do elementu listy - q[i] = xi ; • podlista - q[i..j] = [xi , xi+1 , ... , xj ] ; • złożenie - q&r = [x1 , ... , xn , y1, ... ,ym ] ; Na podstawie operacji podstawowych można zdefiniować inne operacje, np. wstawianie elementu x za element x i, na liście q: q[1..i] & [x] & q[i+1 .. |q| ]. W operacjach na listach ograniczamy się zwykle do zmian ich końców: a) front(q) = q[1] - pobierz lewy koniec listy b) push(q,x) = [x]&q - wstaw element na lewy koniec c) pop(q) =q[2..|q| ] - usuń bieżący lewy koniec d) rear(q) = q[|q|] - pobierz prawy koniec listy e) inject(q,x) =q&[x] -wstaw element na prawy koniec f) eject(q) =q[1..|q|-1 ] - usuń bieżący prawy koniec W zależności od możliwości wykonania różnych operacji wyróżniemy: • kolejkę podwójną - wszystkie sześć operacji • stos - tylko operacje front, push, pop • kolejkę - tylko operacje front, pop , inject Dwie podstawowe implementacje (reprezentacje) listy q =[x1, x2, ... , xn ] to: • tablicowa - q[i] = xi , gdzie 1≤ i ≤ n, • dowiązaniowa W implementacjach pojedynczej liniowej i podwójnej liniowej dowiązanie prowadzące do listy wskazuje na pierwszy element na liście. W implementacji pojedynczej cyklicznej i podwójnej cyklicznej dowiązanie prowadzące do listy wskazuje na element ostatni. Aby dowiązana struktura nigdy nie była pusta dodaje się na początku listy element pusty zwany GŁOWĄ lub WARTOWNIKIEM listy. Operacje na listach o stałej złożoności czasowej: a) w implementacji pojedynczej liniowej: operacje stosu, wstawianie jednego elementu za drugi, usuwanie następnego elementu, b) w implementacji pojedynczej cyklicznej: te co w a) oraz złożenie i operacje rear i inject, c) w implementacji podwójnej cyklicznej: te co w b) oraz eject, wstawianie jednego elementu przed drugim, wstawianie danego elementu, odwracanie listy. LISTY JEDNOKIERUNKOWE Lista jednokierunkowa jest oszczędną pamięciowo strukturą danych, pozwalającą grupować dowolną ograniczoną tylko ilością dostępnej pamięci - liczbę elementów: liczb, znaków, rekordów. Do budowy listy używane są dwa typy rekordów: - informacyjny - wskaźniki, dowiązania do początku listy (głowa) i końca listy (ogon) - robocze - pole wartości i wskaźnik do następnego elementu listy Dzięki rekordowi informacyjnemu mamy ciągły dostęp do niektórych operacji, np. dołączanie elementu na koniec listy. Głowa, ogon i następny to wskaźniki, wartość to dowolna wielkość (znanego typu). Wskaźniki NULL oznaczają adresy pamięci pod którymi nie ma żadnej zmiennej. Przykład listy jednokierunkowej: Pola głowa i ogon pozwalają na przeglądanie elementów listy i dołączanie nowych elementów. Przykład (pseudokod) przeglądania elementów listy: _________________________________________________ adres_tmp=info.głowa; while (adres_tmp <> NULL ) { if(adres_tmp.wartość == x) { Wypisz "Znalazłem szukany element" opuść procedurę } else adres_tmp=adres_tmp.następny } Wypisz "Nie znalazłem elementu" _________________________________________ Dokładanie nowych elementów (dwa podejścia): 1)potraktowanie listy jak worek nie-uporządkowanych elementów i umieszczanie nowych elementów na początku 1) dokładanie elementów we właściwym ustalonym przez użytkownika porządku (całość listy musi być widziana jako posortowana) Możliwe są trzy przypadki: a) wstawiamy element na początek listy b) wstawiamy element na koniec listy c) wstawiamy element gdzieś w środku W każdym z przypadków musimy zapamietywać dwa wskaźniki - przed który element wstawić i po którym mamy to zrobić. Podobnie postępujemy przy fuzji (łączeniu) list tak by wypadkowa lista pozostała uporządkowana. Podsumowując wady i zalety list jednokierunkowych: Wady nienaturalny dostęp do elementów niełatwe sortowanie Zalety małe zużycie pamięci elastyczność Lista w której elementy są już na samym początku wstawiane w określonym porządku, służy obok gromadzenia danych, także do ich porządkowania. W sytuacji, gdy jest tylko jedno kryterium sortowania struktura działa bardzo dobrze "sama" dbając o sortowanie. Dla kilku kryteriów sortowania należy wprowadzić obok listy danych, także kilka list z wskaźnikami do danych - list tych powinno byś tyle ile kryteriów sortowania. Sortowanie w takich wypadku polega na porządkowaniu wskaźników bez ruszania listy danych. Nieposortowaną listę DANE można uporządkować według trzech kryteriów: - imienia i nazwiska (Adam Fuks, Jan Kowalski, Michał Zaremba) - kodów ( 30, 34, 37) - kwot ( 1200, 2000, 3000 ) Tablicowa implemantacja list jest niezwykle prosta jeśli umówimy się, że i-temu indeksowi tablicy odpowiada i-ty element listy. Wymagana jest dodatkowa informacja wskazująca jak wiele elementów liczy lista (jak duża musi być tablica). Wadą jest marnotrawstwo pamięci bo najczęściej przydzielamy na tablicę większy obszar pamięci niż to zwykle potrzeba. Operacje na listach są w implementacji tablicowej proste: 1) front(q) , x=q[1] - pobierz lewy koniec listy 2) push(q,x) - przesuń wszystkie elementy tablicy o jeden w prawo i q[1]=x - wstaw element na lewy koniec 3) pop(q), przesuń wszystkie elementy tablicy poza pierwszym o jeden w lewo - usuń bieżący lewy koniec 4) rear(q), x=q[n] - pobierz prawy koniec listy 5) inject(q,x), q[n+1]=x -wstaw element na prawy koniec 6) eject(q), n=n-1 - usuń bieżący prawy koniec Dodatkowo: A) B) usunięcie k-tego elementu to - przesunąć w lewo elementy tablicy q[k+1]...q[n], n=n-1 wstawienie elementu na pozycję k to - przesunąć w prawo elementy tablicy q[k]...q[n], n=n+1 LISTY DWUKIERUNKOWE Listy jednokierunkowe są wygodne i zajmują mało pamięci. Operacje na nich zajmują dużo czasu. W liście dwukierunkowej komórka robocza zawiera wskaźniki do elementów: poprzedniego i następnego . • pierwsza komórka na liście nie posiada swojego • poprzednika (pole poprzedni zawiera NULL wskaźnik pokazujący pusty element pamięci) ostatnia komórka na liście nie posiada swojego następnika (pole następny zawiera NULL wskaźnik pokazujący pusty element pamięci) Lista dwukierunkowa jest kosztowna jeśli chodzi o pamięć, ale wygodna gdy chodzi o szybkość. Usunięcie elementu listy dwukierunkowej: Lista cykliczna jest zamknięta w pierścień , wskaźnik „ostatniego” elementu wskazuje na „pierwszy” element. Elementy „pierwszy” i „ostatni” są umowne. STOSY Stos jest struktura danych, do której dostęp jest możliwy tylko od strony tzw. wierzchołka, czyli pierwszego wolnego miejsca znajdującego się na nim. Funkcje odkładania elementu X na stos ( push(X) ) i pobieranie go ze stosu ( pop(X) ) można opisać symbolicznie (wraz z kodem błędu s wprowadzonym przez użytkownika): Tablicowa implementacja stosu wygląda analogicznie jak dla listy, ale z dostępnymi jedynie operacjami front, push, pop. Grafy Wprowadzenie do teorii grafów. Przykład Ważony graf skierowany. Kółka – wierzchołki grafu. Linie łączące wierzchołki - krawędzie grafu. Wszystkie krawędzie posiadają przypisany kierunek– graf skierowany (digraf). W digrafie mogą istnieć dwie krawędzie między dwoma wierzchołkami, każda biegnąca w innym kierunku. Jeżeli krawędzie posiadają związane ze sobą wartości, są one nazywane wagami (nieujemne). Graf jest zwany grafem ważonym. Droga w grafie ważonym to sekwencja wierzchołków, taka że istnieje krawędź z każdego wierzchołka do jego następnika – [ν1, ν4, ν3] jest drogą, [ν3, ν4, ν1] nie jest drogą. Droga jest nazywana prostą, jeżeli nie przechodzi dwa razy przez ten sam wierzchołek. Droga prosta nigdy nie zawiera pod-drogi, która byłaby cykliczna. Długością drogi w grafie skierowanym jest suma wag krawędzi należących do drogi. Droga z wierzchołka do niego samego – cykl. Graf zawierający cykl jest grafem cyklicznym, gdy nie zawiera cyklu jest grafem acyklicznym. Najczęstsze zadanie dla grafów to znalezienie najkrótszych dróg z każdego wierzchołka do wszystkich innych wierzchołków. Najkrótsza droga musi być drogą prostą. Grafy i drzewa Załóżmy, że planista przestrzenny chce połączyć określone miasta drogami w taki sposób, aby było możliwe dojechanie z dowolnego z tych miast do dowolnego innego. Dążymy do zbudowania najkrótszej sieci dróg. Do rozwiązania tego problemu niezbędne jest poznanie zagadnień z zakresu teorii grafów. Przypomnijmy, że graf jest nieskierowany, gdy jego krawędzie nie posiadają kierunku. Mówimy wówczas po prostu, że krawędź jest między dwoma wierzchołkami. Droga w grafie nieskierowanym jest sekwencją wierzchołków, taką że każdy wierzchołek i jego następnik łączy krawędź. Krawędzie nie mają kierunku, więc droga z wierzchołka u do wierzchołka ν istnieje wtedy i tylko wtedy, gdy istnieje droga z ν do u. Graf nieskierowany jest nazywany spójnym, kiedy między każdą parą wierzchołków istnieje droga. Grafy z rysunku poniżej są spójne. W grafie nieskierowanym droga wiodąca z wierzchołka do niego samego, zawierająca co najmniej 3 wierzchołki, wśród których wszystkie wierzchołki pośrednie są różne, jest nazywana cyklem prostym. Graf nieskierowany nie zawierający żadnych cykli prostych jest określany mianem acyklicznego (grafy (a), (b) są cykliczne). Drzewo jest acyklicznym spójnym grafem nieskierowanym (grafy (c), (d) są drzewami). Funkcjonuje też pojęcie drzewo korzeniowe – to drzewo posiadające jeden wierzchołek, określony jako korzeń (jest to inna klasa drzew niż te rozpatrywane tutaj). Szerokie zastosowanie ma problem usuwania krawędzi ze spójnego, ważonego grafu nieskierowanego G w celu utworzenia takiego pod-grafu, że wszystkie wierzchołki pozostają połączone, a suma ich wag jest najmniejsza. Podgraf o minimalnej wadze musi być drzewem, ponieważ gdyby tak nie było, zawierałby cykl prosty, więc usunięcie krawędzi tego cyklu prowadziłoby do grafu o mniejszej wadze. Drzewo rozpinające grafu G to spójny pod-graf, który zawiera wszystkie wierzchołki należące do G i jest drzewem ( (c) i (d) są drzewami rozpinającymi). Spójny pod-graf o minimalnej wadze musi być drzewem rozpinającym, ale nie każde drzewo rozpinające ma minimalną wagę. Algorytm rozwiązujący przedstawiony wcześniej problem musi tworzyć drzewo rozpinające o minimalnej wadze. Takie drzewo nosi nazwę minimalnego drzewa rozpinającego ((d) jest takim drzewem). Znalezienie minimalnego drzewa rozpinającego metodą siłową wymaga czasu gorszego niż wykładniczy. Chcemy rozwiązać to bardziej wydajnie wykorzystując podejście zachłanne. __________________________________________________ Definicja Graf nieskierowany G składa się ze skończonego zbioru V wierzchołków oraz zbioru E par wierzchołków ze zbioru V czyli krawędzi grafu. Graf G oznaczamy: G = ( V, E ) ________________________________________________ Dla grafu (a): V = {ν1,ν2,ν3,ν4,ν5} E = {(ν1,ν2),(ν1,ν3),(ν2,ν3),(ν2,ν4),(ν3,ν4),(ν3,ν5),(ν4,ν5)} Drzewo rozpinające T dla grafu G zawiera te same wierzchołki V, co graf G, jednak zbiór krawędzi drzewa T jest podzbiorem F zbioru E. Drzewo rozpinające możemy oznaczyć jako T = ( V, F ). Problem polega na znalezieniu podzbioru F zbioru E, takiego aby T = ( V, F ) było minimalnym drzewem rozpinającym grafu G. Wysokopoziomowy algorytm zachłanny realizujący to zadanie mógłby wyglądać: F=∅; //Inicjalizacja zbioru krawędzi while (realizacja nie została rozwiązana) { wybierz krawedz zgodnie z warunkiem optymalnym lokalnie; // procedura wyboru if(dodanie krawędzi do F nie powoduje powstania cyklu) // spr. wykonalnosci dodaj ją; } if(T=(V,F) jest drzewem rozpinajacym) realizacja jest rozwiazana; Oczywiście „warunek optymalny lokalnie” może być inny w różnych problemach i w rożnych algorytmach rozwiązania. Dwa najbardziej znane algorytmy realizujące to zadanie to algorytm Prima i algorytm Kruskala. Drzewa wyszukiwania binarnego ( i ich optymalizacja) Opracowujemy algorytm określania optymalnego sposobu zorganizowania zbioru elementów w postaci drzewa wyszukiwania binarnego. Dla każdego wierzchołka w drzewie binarnym poddrzewo, którego korzeniem jest lewy (prawy) potomek tego wierzchołka, nosi nazwę lewego (prawego) pod-drzewa wierzchołka. Lewe (prawe) pod-drzewo korzenia drzewa nazywamy lewym (prawym) pod-drzewem drzewa. Drzewo wyszukiwania binarnego. Drzewo wyszukiwania binarnego jest binarnym drzewem elementów (kluczy) pochodzących ze zbioru uporządkowanego. Najprostsze drzewo wyszukiwania binarnego spełnia warunki: • Każdy wierzchołek zawiera jeden klucz. • Każdy klucz w lewym poddrzewie danego wierzchołka jest mniejszy lub równy kluczowi tego wierzchołka. • Klucze znajdujące się w prawym pod-drzewie danego wierzchołka są większe lub równe kluczowi tego wierzchołka. Przykład. Dwa drzewa o tych samych kluczach. W lewym drzewie prawe pod-drzewo wierzchołka Rudolf zawiera klucze (imiona) Tomasz, Urszula, Waldemar wszystkie większe od Rudolf zgodnie z porządkiem alfabetycznym. Zakładamy, że klucze są unikatowe. Głębokość wierzchołka w drzewie jest liczbą krawędzi w unikatowej drodze, wiodącej od korzenia do tego wierzchołka, inaczej zwana poziomem wierzchołka w drzewie. Głębokość drzewa to maksymalna głębokość wszystkich wierzchołków (w przykładzie - drzewo po lewej głębokość 3, po prawej głębokość 2) Drzewo nazywane jest zrównoważonym, jeżeli głębokość dwóch pod-drzew każdego wierzchołka nigdy nie różni się o więcej niż 1 (w przykładzie – lewe drzewo nie jest zrównoważone, prawe jest zrównoważone). Zwykle drzewo wyszukiwania binarnego zawiera pozycje, które są pobierane zgodnie z wartościami kluczy. Celem jest takie zorganizowanie kluczy w drzewie wyszukiwania binarnego, aby średni czas zlokalizowania klucza był minimalny. Drzewo zorganizowane w ten sposób jest nazywane optymalnym. Jeżeli wszystkie klucze charakteryzuje to samo prawdopodobieństwo zostania kluczem wyszukiwania, to drzewo z przykładu (prawe) jest optymalne. Weźmy przypadek, w którym wiadomo, że klucz wyszukiwania występuje w drzewie. Aby zminimalizować średni czas wyszukiwania musimy określić złożoność czasową operacji lokalizowania klucza. ________________________________________________________ Algorytm wyszukiwania klucza w drzewie wyszukiwania binarnego Wykorzystujemy strukturę danych: struct nodetype { keytype key; nodetype* left; nodetype* right; }; typedef nodetype* node_pointer; Zmienna typu node_pointer jest wskaźnikiem do rekordu typu nodetype. Problem: określić wierzchołek zawierający klucz w drzewie wyszukiwania binarnego, zakładając że taki występuje w drzewie. Dane: wskaźnik tree do drzewa wyszukiwania binarnego oraz klucz keyin. Wynik: wskaźnik p do wierzchołka zawierającego klucz. void search(node_pointer tree, keytype keyin, node_pointer p) { bool found; } p = tree; found = false; while (!found) if (p->key == keyin) found = true; else if (keyin < p->key) p = p->left; else p = p->right; Liczbę porównań wykonywanych przez procedurę search w celu zlokalizowania klucza możemy nazwać czasem wyszukiwania. Chcemy znaleźć drzewo, dla którego średni czas wyszukiwania jest najmniejszy. Zakładając, że w każdym przebiegu pętli while wykonywane jest tylko jedno porównanie możemy napisać : czas wyszukiwania = głębokość(key) + 1 Przykładowo (lewe poddrzewo): czas wyszukiwania = głębokość(Urszula) + 1 = 2+1 = 3 Niech Key1, Key2, …, Keyn będą n uporządkowanymi kluczami oraz pi będzie prawdopodobieństwem tego, że Keyi jest kluczem wyszukiwania. Jeżeli ci oznacza liczbę porównań koniecznych do znalezienia klucza Keyi w danym drzewie, to: n średni czas wyszukiwania = Σci pi i=1 Jest to wartość która trzeba zminimalizować. Przykład. Mamy 5 różnych drzew dla n = 3. Wartości kluczy nie są istotne. Jeżeli: p1 = 0.7 , p2 = 0.2 oraz p3 = 0.1 to średnie czasy wyszukiwania dla drzew wynoszą : 1) 3*(0.7) + 2*(0.2) + 1*(0.1) = 2.6 2) 2*(0.7) + 3*(0.2) + 1*(0.1) = 2.1 3) 2*(0.7) + 1*(0.2) + 2*(0.1) = 1.8 4) 1*(0.7) + 3*(0.2) + 2*(0.1) = 1.5 5) 1*(0.7) + 2*(0.2) + 3*(0.1) = 1.4 Piąte drzewo jest optymalne. Oczywiście znalezienie optymalnego drzewa wyszukiwania binarnego poprzez rozpatrzenie wszystkich drzew wiąże się z ilością drzew co najmniej wykładniczą w stosunku do n. W drzewie o głębokości n-1 wierzchołek na każdym z n-1 poziomów (oprócz korzenia) może się znajdować na prawo lub lewo. Zatem liczba różnych drzew o głębokości n-1 wynosi 2n-1 Załóżmy, że klucze od Keyi do Keyj są ułożone w drzewie, które minimalizuje wielkość: j Σ cm pm m=i gdzie cm jest liczbą porównań wymaganych do zlokalizowania klucza Keym w drzewie. Drzewo to nazywamy optymalnym. Wartość optymalną oznaczymy jako A[i][j] oraz A[i][i]=pi (jeden klucz wymaga jednego porównania). Korzystając z przykładu można pokazać, że w problemie tym zachowana jest zasada optymalności. Możemy sobie wyobrazić n różnych drzew optymalnych: drzewo 1 w którym Key1 jest w korzeniu, drzewo 2 w którym Key2 jest w korzeniu, … , drzewo n w którym Keyn jest w korzeniu. Dla 1 ≤ k ≤ n pod-drzewa drzewa k muszą być optymalne, więc czasy wyszukiwania w tych pod-drzewach można opisać: Dla każdego m ≠ k wymagana jest o jeden większa liczba porównań w celu zlokalizowania klucza Keym w drzewie k niż w celu zlokalizowania tego klucza w poddrzewie w którym się znajduje. Dodatkowe porównanie jest związane z korzeniem i daje 1 * pm do średniego czasu wyszukiwania. Średni czas wyszukiwania dla drzewa k wynosi lub inaczej n A[1][k-1] + A[k+1][n] + Σ pm m=1 Jedno z k drzew musi być optymalne więc średni czas wyszukiwania optymalnego drzewa określa zależność: n A[1][n] = minimum(A[1][k-1] + A[k+1][n]) + Σ pm m=1 gdzie A[1][0] i A[n+1][n] są z definicji równe 0. Uogólniamy definicje na klucze od Keyi do Keyj , gdzie i < j i otrzymujemy: j A[i][j] = minimum(A[i][k-1] + A[k+1][j]) + Σ pm i < j i≤k≤j m=i A[i][i] = pi A[i][i-1] oraz A[j+1][j] są z definicji równe 0. Wyliczenia prowadzimy podobnie łańcuchowego mnożenia macierzy. Algorytm znajdowania przeszukiwania binarnego. jak w algorytmie optymalnego drzewa Problem: określenie optymalnego drzewa wyszukiwania binarnego dla zbioru kluczy, z których każdy posiada przypisane prawdopodobieństwo zostania kluczem wyszukiwania. Dane: n-liczba kluczy oraz tablica liczb rzeczywistych p indeksowana od 1 do n, gdzie p[i] jest prawdopodobieństwem wyszukiwania i-tego klucza Wyniki: zmienna minavg, której wartością jest średni czas wyszukiwania optymalnego drzewa wyszukiwania binarnego oraz tablica R, z której można skonstrułować drzewo optymalne.R[i][j] jest indeksem klucza znajdującego się w korzeniu drzewa optymalnego, zawierającego klucze od i-tego do j-tego. void optsearch(int n, const float p[], float minavg, index R[][]) { index i, j, k, diagonal; float A[1..n+1][0..n]; for (i=1; i <= n; i++) { A[i][i-1] = 0; A[i][i] = p[i]; R[i][i] = i; R[i][i-1] = 0; } A[n+1][n] = 0; for(diagonal = 1; diagonal <= n-1; diagonal++) for(i = 1; i <= n - diagonal; i++) //Przekatna 1 { //tuz nad glowna przek j = i + diagonal; j A[i][j]=minimum(A[i][k-1]+A[k+1][j] + i ≤ k ≤ j Σ pm ; m=i R[i][j]= wartość k, która dała minimum; } minavg = A[1][n]; } Złożoność czasową można określić podobnie jak dla mnożenia łańcuchowego macierzy: T(n) = n(n-1)(n+1)/6 ∈ Θ( n3 ) Algorytm budowania binarnego. optymalnego drzewa przeszukiwania Problem: zbudować optymalne drzewo wyszukiwania binarnego. Dane: n – liczba kluczy, tablica Key zawierająca n uporządkowanych kluczy oraz tablica R, utworzona w poprzednim algorytmie. R[i][j] jest indeksem klucza w korzeniu drzewa optymalnego, zawierającego klucze od i-tego do j-tego Wynik: wskaźnik tree do optymalnego drzewa wyszukiwania binarnego, zawierającego n kluczy. node_pointer tree(index i,j) { index k; node_pointer p; } k = R[i][j]; if(k == 0) return NULL; else { p = new nodetype; p->key = Key[k]; p->left = tree(i,k-1); p->right = tree(k+1,j); return p; } Przykład. Załóżmy, że mamy następujące wartości w tablicy Key: Damian Izabela Rudolf Waldemar Key[1] Key[2] Key[3] Key[4] oraz p1 = 3/8 p2 = 3/8 p3 = 1/8 p4 = 1/8 Tablice A i R będą wówczas wyglądać: 0 1 2 3 4 1 | 0 3/8 9/8 11/8 7/4 | 2| 0 3/8 5/8 1 | 3| 0 1/8 3/8 | 4| 0 1/8 | 5| 0 A 0 1 |0 | 2| | 3| | 4| | 5| 1 1 2 1 3 2 4 2 0 2 2 2 0 3 3 0 4 0 R