MAREK GAGOLEWSKI INSTYTUT BADAŃ SYSTEMOWYCH PAN WYDZIA L MATEMATYKI I NAUK INFORMACYJNYCH POLITECHNIKI WARSZAWSKIEJ Algorytmy i podstawy programowania. Dynamiczne struktury danych Materia ly dydaktyczne dla studentów matematyki na Wydziale Matematyki i Nauk Informacyjnych Politechniki Warszaiej Copyright 2010 201 Marek G agolei This work is licensed under a Creative Commons Attribution.0 Unported License.
SPIS TREŚCI 0 Spis treści.1. Wprowadzenie.................................. 1.2. Stos........................................ 1.2.1. Implementacja I: tablica......................... 1.2.2. Implementacja II: tablica rozszerzalna................. 2.2.. Implementacja III: lista jednokierunkowa................. Kolejka...................................... 8..1. Implementacja I: lista jednokierunkowa................ 8..2. Implementacja II: lista jednokierunkowa z ogonem......... 1.4. Kolejka priorytetowa............................... 14.4.1. Implementacja I: lista jednokierunkowa................ 1.4.2. Implementacja II: drzewo binarne.................... 1.. S lownik...................................... 20..1. Implementacja I: tablica......................... 20..2. Implementacja II: lista jednokierunkowa................ 22... Implementacja III: drzewo binarne................... 2.. Podsumowanie.................................. 2.. Ćwiczenia..................................... 28
.1. WPROWADZENIE 1.1. Wprowadzenie Wróćmy do zagadnienia implementacji podstawowych abstrakcyjnych typów danych wprowadzonych w rozdz., tj.: 1. stosu (rozdz..2), 2. kolejki (rozdz..),. kolejki priorytetowej (rozdz..4), 4. s lownika (rozdz..). Przypomnijmy, że definicje wszystkich abstrakcyjnych typów danych abstrahuja od sposobu ich implementacji. Do tej pory zastanawialiśmy sie, w jaki sposób zaimplementować je z użyciem tablicy. Informacja Tak jak ostatnio, bez straty ogólności umawiamy si e, że każdy prezentowany abstrakcyjny typ danych b edzie s luży l do przechowywania pojedynczych wartości ca lkowitych, tj. danych typu int..2. Stos Stos (ang. stack) jest abstrakcyjnym typem danych typu LIFO (ang. last-in-first-out), który udost epnia przechowywane weń elementy w kolejności odwrotnej niż ta, w której by ly one zamieszczane. Stos udost epnia trzy operacje: push (umieść) wstawia element na atek stosu, pop (zdejmij) usuwa i zwraca element z atku stosu, empty (czy pusty) sprawdza, czy na stosie nie znajduja sie żadne elementy. Chcia loby sie rzec tylko tyle i aż tyle. W jaki sposób zaimplementować taki ATD? Dysponujac wiedza, która posiadamy z poprzednich wyk ladów, najprawdopodobniej spróbowalibyśmy stworzyć stos oparty na zwyk lej tablicy..2.1. Implementacja I: tablica Do stworzenia najprostszej implementacji stosu potrzebować b edziemy trzech zmiennych (zadeklarowanych np. w funkcji main() 1 ): int n =...; // maksymalna l i c z b a elementów na s t o s i e int s = new int [n]; // t u p r z e c h o w u j e m y e l e m e n t y int t = 1; // i n d e k s a k t u a l n e g o o s t a t n i e g o e l e m e n t u Zacznijmy od prostego przyk ladu użycia stosu. 1 Lub umieszczonych w jednej strukturze struct Stos... ;
.2. STOS 2 1 int main () 2 int n = 100; 4 int s = new int [n]; int t = 1; push (, s, t, n); 8 push (, s, t, n); 9 push (, s, t, n); 10 11 while (! empty (s, t, n)) 12 cout << pop (s, t, n); 1 // w y p i s z e na e k r a n 14 1 return 0; 1 Rzecz jasna, najprostsza do zrealizowania operacja jest sprawdzenie, czy stos jest pusty: bool empty ( int s, int t, int n) return (t < 0); // t r u e, j e ś l i p u s t y Operacja zdejmowania elementu ze stosu może wygladać tak: int pop ( int s, int & t, int n) assert (t >= 0); // zak ladamy, ż e mamy co zdejmować return s[t ]; // zwróć s [ t ], a potem z m n i e j s z t o 1 Zauważmy, że zmienna t zosta la przekazana przez referencje. Chcemy, by jej zmiana by la widoczne na zewn atrz. Na koniec operacja dodawania elementu do stosu: void push ( int x, int s, int & t, int n) assert (t < n 1); // zak ladamy, ż e mamy g d z i e u m i e ś c i ć e l e m e n t s [++ t] = x; // n a j p i e r w z w i e k s z t o 1, a potem s [ t ] = x ; Widzimy, że wszystkie operacje potrzebuja zawsze sta lej liczby dostepów do tablicy, tzn. sa rzedu O(1). Jest to rozwiazanie optymalne (bardziej efektywne z obliczeniowego punktu widzenia istnieć nie może). Napotykamy w tym miejscu jednak istotny problem w definicji naszego ATD nie pojawia sie wcale za lożenie, że liczba przechowywanych elementów ma być z góry ograniczona. Z tego powodu powyższa implementacja stosu jest tylko cześciowo zgodna z definicja (formalista rzek lby, że to wcale nie jest implementacja stosu). Rzecz jasna, w przypadku niektórych programów takie ograniczenie wcale nie musi być niepoprawne. Co sie stanie, jeśli naprawde nie możemy z góry ustalić, ile elementów powinien móc przechowywać stos?.2.2. Implementacja II: tablica rozszerzalna Spróbujmy zatem operacji dodawania elementów do stosu na powiekszanie (w razie potrzeby) tablicy s. Bedziemy wiec musieli ja przekazywać przez referencje, gdyż może ona
.2. STOS (a w laściwie adres jej pierwszego elementu) ulec zmianie. Co wi ecej, także n może wówczas si e zmienić. void push ( int x, int & s, int & t, int & n) if (t == n 1) int starys = s; // z a p a m i e t a j, co b y l o do t e j p o r y s = new int [n +1]; // nowa t a b l i c a o j e d n o m i e j s c e w i e c e j ( na p r z y k l a d ) for ( int i =0; i<n; ++i) s[i] = starys [i]; // p r z e p i s z c a l y,, s t a r y s t o s ++n; // r o z m i a r j e s t j u ż w i e k s z y delete [] starys ; // s t a r e j u ż n i e p o t r z e b n e s [++ t] = x; Niestety, pesymistyczna z lożoność obliczeniowa operacji push wynosi teraz O(n) (dlaczego?). Co wiecej (powyższa implementacja dzieli te w lasność z poprzednia), jeśli stos jest jedynie cześciowo wype lniony, marnotrawimy wiele cennych komórek pamieci tylko po to, by by ly one w gotowości na zaś. Jeśli zaprogramujemy dodatkowo operacje pop tak, by zmniejsza la tablice s, to i ta operacja stanie sie bardziej kosztowna obliczeniowo. Taki stan rzeczy nie może nam sie podobać, spróbujemy wiec poszukać lepszego rozwiazania..2.. Implementacja III: lista jednokierunkowa Przedstawimy teraz implementacje stosu, która wykorzystuje tzw. liste jednokierunkowa. Istotna zaleta takie rozwiazania jest to, że wszystkie omawiane operacje sa realizowane w sta lym czasie (O(1)) oraz że liczba przechowywanych elementów jest dowolna. Lista jednokierunkowa to dynamiczna struktura danych, która sk lada sie z wez lów reprezentowanych przez typ z lożony o nastepuj acej definicji: struct wezel int elem ; // e l e m e n t przechowywany w w e ź l e wezel nast ; // w s k a ź n i k na n a s t e p n y e l e m e n t ; Zauważmy, że taka definicja ma w pewnym sensie charakter rekurencyjny. W definicji wez la pojawia sie odwo lanie do innego wez la (za pomoca aźnika). Wez ly w liście sa ze soba po l aczone, jeden za drugim. Do stworzenia listy wystarczy zatem aźnik na jej pierwszy element (tzw. g lowa): wezel ; oraz informacja, po którym w eźle lista si e kończy. Do tego celu stosuje si e najcz eściej wartownika adres (0). Schemat przyk ladowej listy jednokierunkowej przechowujacej elementy, oraz przedstawia rys..1. Zwróćmy szczególna uwage na to, w jaki sposób wez ly moga być rozlokowane w pamieci. Nim zaczniemy sie zastanawiać, w jaki sposób zrealizować stos za pomoca listy, zacznijmy od stworzenia pliku nag lówkowego (np. stos.h).
.2. STOS 4 0xffabc08 0xffabc00 (int) (wezel*) 0xff0001cd (int) (wezel*) 0xffabc08 0xff0001cd (int) (wezel*) 0 0xdf9044 (wezel*) 0xffabc00 Rys..1. Przyk ladowa lista jednokierunkowa przechowujaca elementy,,. Schemat graficzny (powyżej) i przyk ladowa organizacja pamieci (poniżej). 1 # pragma once 2 struct wezel 4 int elem ; // e l e m e n t przechowywany w w e ź l e wezel nast ; // w s k a ź n i k na n a s t e p n y e l e m e n t ; 8 9 void push ( int i, wezel & ); 10 int pop ( wezel & ); 11 bool empty ( wezel ); Zak ladamy tutaj, że operacje push i pop moga spowodować zmiane pierwszego elementu listy. Oto przyk ladowy plik main.cpp, zawierajacy funkcje main() analogiczna do tej, która zaprezentowaliśmy dla implementacji tablicowej stosu. 1 # include... 2 int main () 4 wezel = ; // p u s t a l i s t a ( na p o c z a t e k ) push (, );
.2. STOS 8 push (, ); 9 push (, ); 10 11 while (! empty ( )) 12 cout << pop ( ); 1 14 // 1 1 return 0; 1 Znowu najprostsza funkcja bedzie operacja sprawdzajaca, czy stos jest pusty. Wystarczy sprawdzić, czy weze l atkowy azuje bezpośrednio na wartownika. bool empty ( wezel ) return ( == ); // t r u e, j e ś l i l i s t a p u s t a Operacje push i pop można zrealizować jako, odpowiednio, wstawianie elementu na a- tek oraz usuwanie elementu z atku listy. Latwo sie domyślić, że bed a one potrzebowa ly sta lej liczby dostepów do wez lów listy, tzn. bed a rzedu O(1). Najpierw zajmijmy sie wstawianiem elementu na atek listy. Rys..2 ilustruje kolejne kroki potrzebne do utworzenia listy (,,,) z listy (,,). Zwróćmy uwage, że po dokonaniu tej operacji zmienia sie g lowa listy. Oto przyk ladowa implementacja operacji push stosujaca wstawianie elementu na atek listy jednokierunkowej. Zauważmy raz jeszcze, że pierwszym parametrem jest referencja na zmienna aźnikowa. Przekazujemy tutaj g low e listy, która bedzie zmieniona przez te funkcje. void push ( int i, wezel & ) wezel nowy = new wezel ; nowy >elem = i; nowy >nast = ; / w s k a z u j na to, na co w s k a z u j e, może być / = nowy ; / t e r a z l i s t a z a c z y n a s i e od nowego w e z l a / Rys.. przedstawia możliwy sposób usuwania elementów z atku listy jednokierunkowej. Implementacje operacji pop przedstawia poniższy kod. Zauważmy, że po jej dokonaniu możliwe jest osiagni ecie stanu glowa ==, co oznacza, że usuniety element by l jedynym. int pop ( wezel & ) assert (!= ); // na w s z e l k i wypadek... int i = >elem ; // z a p a m i e t a j przechowywany e l e m e n t wezel = ; // z a p a m i e t a j s t a r y = >nast ; // zmień
.2. STOS nowy nowy Rys..2. Wstawianie elementu na atek listy jednokierunkowej.
.2. STOS Rys... Usuniecie elementu z atku listy jednokierunkowej.
.. KOLEJKA 8 delete ; // s k a s u j s t a r y return i; Jak widzimy, powyższe operacje wykonywane na liście jednokierunkowej wcale nie sa (wbrew obiegowej opinii) skomplikowane. Ważne jest, byśmy już teraz dobrze zrozumieli zasade ich dzia lania. W nastepnym podrozdziale omówimy kolejny abstrakcyjny typ danych kolejke. Okaże sie, że jej efektywna implementacje można także oprzeć na (odpowiednio zmodyfikowanej) liście jednokierunkowej... Kolejka Kolejka (ang. queue) to ATD typu FIFO (ang. first-in-first-out). Podstawowa w lasnościa kolejki jest to, że pobieramy z niej elementy w takiej samej kolejności (a nie w odwrotnej jak w przypadku stosu), w jakiej by ly one umieszczane. Kolejka udostepnia nastepuj ace trzy operacje: enqueue (umieść) wstawia element na koniec kolejki, dequeue (wyjmij) usuwa i zwraca element z atku kolejki, empty (czy pusty). Zadanie Jak latwo sie domyślić, implementacje kolejki oparte na tablicy bed a cechować sie podobnymi wadami, jakimi cechowa ly sie analogiczne rozwiazania z rozdz..2.1 i.2.2. Ich wykonanie pozostawmy wobec tego zacnemu Czytelnikowi. Bedzie to dlań dobrym ćwiczeniem przed kolokwium...1. Implementacja I: lista jednokierunkowa Zastanówmy si e, w jaki sposób można zrealizować operacje kolejki z użyciem listy jednokierunkowej. Mamy tutaj dwie możliwości: 1. enqueue1 wstaw element na atek listy, dequeue1 usuń z końca, badź 2. enqueue2 wstaw element na koniec listy, dequeue2 usuń z atku. Niestety, choć (co już wiemy) enqueue1 / dequeue2 wymagaja O(1) operacji, to pozosta le dzia lania na liście cechuja sie pesymistyczna z lożonościa obliczeniowa rzedu O(n). Mimo to spróbujmy je zaimplementować. Rozważmy najpierw, w jaki sposób wstawić element na koniec listy (dzia lanie potrzebne do zrealizowania operacji enqueue2 ). Rys..4 i. ilustruja, jak utworzyć liste (,,, 1), majac dana liste (,, ). Zauważmy, że pierwszym krokiem, jaki należy wykonać, jest przejście na koniec listy. Tutaj korzystamy z dwóch dodatkowych aźników. Poniższa funkcja implementuje omawiana operacje. Zauważmy, że drugim parametrem jest referencja do aźnika (a dok ladniej do g lowy listy).
.. KOLEJKA 9 1 void enqueue2 ( int i, wezel & ) 2 wezel nowy = new wezel ; 4 nowy >elem = i; nowy >nast = ; // t o b e d z i e o s t a t n i e l e m e n t if ( == ) // c z y l i s t a p u s t a? 8 = nowy ; 9 else 10 wezel = ; 11 while ( >nast!= ) 12 = >nast ; // p r z e j d ź na o s t a t n i e l e m e n t 1 >nast = nowy ; // nowy o s t a t n i e l e m e n t 14 1 A oto równoważna wersja rekurencyjna. 1 void enqueue2_rek ( int i, wezel & ) // r e f e r e n c j a! 2 if (!= ) // t o n i e j e s t k o n i e c... 4 enqueue2_rek (i, >nast ); // w i e c i d ź d a l e j else // j e s t e ś na końcu = new wezel ; 8 >elem = i; 9 >nast = ; 10 11 Pora na operacje dequeue1, realizowana za pomoca usuwania elementu z końca listy. Rys.. ilustruje przyk ladowy przebieg takiego procesu. W wersji iteracyjnej tej funkcji musimy przejść na przedostatni element listy. Należy sie jednak wcześniej upewnić, czy przypadkiem nie kasujemy jedynego elementu (oddzielny przypadek). 1 int dequeue1 ( wezel & ) 2 assert (!= ); 4 int i; if ( >nast == ) // t y l k o 1 e l e m e n t 8 i = >elem ; 9 delete ; 10 = ; 11 12 else // w i e c e j n i z 1 e l e m e n t 1 14 wezel = ; 1 while ( >nast >nast!= ) 1 = >nast ; // i d ź na p r z e d o s t. e l. 1 i = >nast >elem ; 18 delete >nast ; 19 >nast = ; 20 21 return i; 22
.. KOLEJKA 10 Rys..4. Wstawianie elementu 1 na koniec listy jednokierunkowej cz. I.
.. KOLEJKA 11 nowy 1 nowy 1 Rys... Wstawianie elementu 1 na koniec listy jednokierunkowej cz. II. Omawiana procedure również można sformu lować znacznie prościej za pomoca rekurencji. 1 int dequeue1_ rek ( wezel & ) 2 assert (!= ); 4 if ( >nast == ) // j e s t e ś m y na końcu int i = >elem ; 8 delete ; 9 = ; 10 return i; 11 12 else // i d z i e m y d a l e j 1 return dequeue1_ rek ( >nast ); 14
.. KOLEJKA 12 Rys... Usuni ecie elementu z końca listy jednokierunkowej.
.. KOLEJKA 1..2. Implementacja II: lista jednokierunkowa z ogonem Okazuje sie jednak, że operacje kolejki da sie zaimplementować tak, by potrzebowa ly zawsze sta lej liczby (O(1)) dostepów do elementów. W poprzednim rozwiazaniu krytyczna (tzn. najbardziej czasoch lonna) czynnościa by lo przejście na koniec listy. Gdybyśmy zapamietali dodatkowo aźnik na ostatni element listy (ogon), wstawianie mog loby sie wykonywać bardzo szybko. Zadanie Ogon nie pozwala przyspieszyć operacji usuwania ostatniego elementu. Dlaczego? Przyjrzyjmy sie pe lnej implementacji naszej kolejki. Deklaracje funkcji (w pliku.h) moga wygladać tak: void enqueue ( int i, wezel &, wezel & ost ); int dequeue ( wezel &, wezel & ost ); bool empty ( wezel, wezel ost ); W dwóch pierwszych przypadkach dopuszczamy zarówno możliwość zmiany g lowy jak i ogona listy. Przyk ladowa funkcja g lówna: int main () wezel = ; // p u s t a l i s t a ( na p o c z a t e k ) wezel ost = ; // w s k a ż n i k na o s t a t n i e l e m e n t t e ż for ( int i =0; i <10; ++i) // wstaw c o ś w c e l a c h t e s t o w y c h enqueue (i,, ost ); while (! empty (, ost )) cout << dequeue (, ost ); return 0; Na koniec definicje funkcji. Tak może wygladać sprawdzenie, czy kolejka jest pusta: bool empty ( wezel, wezel ost ) return ( == ); // t r u e, j e ś l i l i s t a p u s t a
.4. KOLEJKA PRIORYTETOWA 14 Umieszczenie elementu w kolejce: void enqueue ( int i, wezel &, wezel & ost ) // w s t a w i a n i e na k o n i e c l i s t y wezel nowy = new wezel ; // s t w ó r z nowy w e z e l nowy >elem = i; nowy >nast = ; if ( == ) // gdy l i s t a p u s t a... = ost = nowy ; // z m i e n i a m y i o s t else // gdy l i s t a n i e p u s t a... ost >nast = nowy ; // po o s t a t n i m nowy ost = nowy ; // t e r a z o s t a t n i t o nowy // k o s z t o p e r a c j i : O( 1 ) Wyj ecie elementu z kolejki: int dequeue ( wezel &, wezel & ost ) assert (!= ); // na w s z e l k i wypadek... // u s u w a n i e z p o c z a t k u l i s t y int i = >elem ; // z a p a m i e t a j przechowywany e l e m e n t wezel = ; // z a p a m i e t a j s t a r y = >nast ; // zmień delete ; // s k a s u j s t a r y if ( == ) ost = ; return i; // k o s z t o p e r a c j i : O( 1 ) Dzieki prostej modyfikacji listy (jednej dodatkowej informacji) uda lo nam sie znaczaco przyspieszyć dzia lanie kolejki. W nastepnym podrozdziale wprowadzimy nowy ATD bardziej zaawansowana kolejke, w której bed a mog ly wystepować obiekty w pewnym sensie uprzywilejowane..4. Kolejka priorytetowa Kolejka priorytetowa (ang. priority queue) to abstrakcyjny typ danych typu HPF (ang. highest-priority-first). Może ona s lużyć do przechowywania elementów, na których zosta la określona pewna relacja porzadku liniowego <= 2. Elementy wydobywa sie z niej 2 W naszym przypadku, jako że umówiliśmy sie, iż omawiane ATD przechowuja dane typu int, najcze- ściej bedzie to po prostu zwyk ly porzadek.
.4. KOLEJKA PRIORYTETOWA 1 ynajac od tych, które maja najwiekszy priorytet (sa maksymalne wzgledem relacji <=). Jeśli w kolejce priorytetowej znajduja sie elementy o takich samych priorytetach, obowiazuje dla nich kolejność FIFO, tak jak w przypadku zwyk lej kolejki. Omawiany ATD udostepnia trzy operacje: insert (w lóż) wstawia element na odpowiednie miejsce kolejki priorytetowej. pull (pobierz) usuwa i zwraca element maksymalny, empty (czy pusty). Zadanie Podobnie jak wcześniej, implementacja tej struktury danych z użyciem tablicy wymaga O(n) dostepów do obiektów w przypadku operacji insert oraz O(1) dostepów przy wywo laniu pull. Pozostawmy jeszcze raz to pouczajace zagadnienie jako wyzwanie dla zdolnego i pracowitego Czytelnika..4.1. Implementacja I: lista jednokierunkowa Spróbujmy zaimplementować kolejke priorytetowa używajac listy jednokierunkowej, która przechowuje elementy posortowane wzgledem relacji <= (w kolejności odwrotnej, tzn. najwiekszy element jest pierwszy). Operacja pull powinna wiec po prostu zwracać i usuwać aktualna g low e listy (patrz wyżej, z lożoność O(1)). Oto wersja rekurencyjna operacji insert, która realizuje wstawianie elementu na odpowiednim miejscu listy (bez zaburzenia istniejacego porzadku). void insert ( int i, wezel & ) if ( == i > >elem ) // >, bo FIFO, g d y == // wstawiamy t u t a j wezel stary = ; // może b y ć = new wezel ; // s t w ó r z nowy w e z e l >elem = i; >nast = stary ; else // i d z i e m y d a l e j insert (i, >nast ); Niestety, powyższa operacja cechuje sie liniowa (O(n)) z lożonościa obliczeniowa. Nie pomoże tu oczywiście uwzglednienie np. dodatkowego aźnika na ostatni element listy. Dostrzegamy wobec tego konieczność zastosowania w tym miejscu innej dynamicznej struktury danych..4.2. Implementacja II: drzewo binarne Drzewo binarne badź binarne drzewo poszukiwań (ang. binary search tree, BST), to dynamiczna struktura danych, w której elementy sa uporzadkowane zgodnie z pewnym porzadkiem liniowym <=. Dane przechowuje sie tutaj w wez lach zdefiniowanych nastepuj aco:
.4. KOLEJKA PRIORYTETOWA 1 struct wezel int elem ; // e l e m e n t przechowywany w w e ź l e wezel lewy ; // l e w e podrzewo ( l e w y potomek ) wezel prawy ; // prawe podrzewo ( l e w y potomek ) ; Widzimy wi ec, że każdy w eze l ma co najwyżej dwóch potomków (równoważnie: rodzic ma co najwyżej dwoje dzieci). Drzewo binarne pozwala szybko (przeważnie, zob. dalej) wyszukiwać, wstawiać i usuwać elementy (średnio O(log 2 n) 4, pesymistycznie O(n)). Każde drzewo poszukiwań binarnych cechuje sie nastepuj acymi w lasnościami (aksjomaty BST): 1. Drzewo ma strukture acykliczna, tzn. wychodzac z dowolnego wez la za pomoca aźników nie da sie do niego wrócić. 2. Niech v b edzie dowolnym w ez lem. Wówczas: wszystkie elementy w lewym poddrzewie v sa nie wieksze (<=) niż element przechowywany w v, wszystkie elementy w prawym poddrzewie v sa wieksze (>) niż element przechowywany w v. Ciekawostka Czasem rozpatruje si e drzewa, w których zabronione jest przechowywanie duplikatów elementów. Poczatek (pierwszy weze l) drzewa określa element zwany korzeniem (ang. root), por. analogie do g lowy listy. Korzeń nie ma, rzecz jasna, żadnych przodków. Co wiecej, z korzenia można dojść do każdego innego wez la (warunek spójności). Wez ly, które nie maja potomków (wez ly terminalne, tzn. takie, których obydwaj potomkowie sa ustaleni na ), zwane sa inaczej liśćmi (ang. leaves). Warto być świadomym faktu, iż drzewa nie maja jednoznacznej postaci różne drzewa moga przechowywać te same wartości. Rys.. przedstawia przyk ladowe BST, które zawieraja elementy 1, 2,,,,, 8. Drugie z nich jest idealnie zrównoważone ma optymalna wysokość (liczbe poziomów). Liczba operacji koniecznych np. do znalezienia dowolnego elementu jest rzedu O(log 2 n). Z kolei trzecie drzewo jest zdegenerowane do listy. Wymaga O(n) operacji w przypadku pesymistycznym. Spróbujmy wiec zaimplementować kolejke priorytetowa oparta na drzewie binarnym. Znowu najprostsza jest operacja empty: Studenci MiNI na przedmiocie Algorytmy i struktury danych (III sem.) poznaja inne podobne struktury danych, min. drzewa AVL czy drzewa czerwono-czarne, które gwarantuja wykonanie wymienionych operacji zawsze w czasie O(log 2 n). Dokonuja one odpowiedniego równoważenia drzewa. Ciekawa alternatywa sa także różne struktury danych typu kopiec. 4 Logarytm jest funkcja, której wartości rosna dość wolno, np. log 2 1000 10, a log 2 10000 1 itd.
.4. KOLEJKA PRIORYTETOWA 1 korzen 8 1 2 korzen 2 1 8 h opt = log 2 n + 1 korzen 1 h max = n 2 8 Rys... Różne drzewa binarne przechowujace wartości 1, 2,,,,, 8.
.4. KOLEJKA PRIORYTETOWA 18 bool empty ( wezel ) return ( == ); // t r u e, j e ś l i drzewo p u s t e Operacja insert powinna umieszczać nowy element jako liść na swoim w laściwym miejscu (pamietamy, że drzewo jest zawsze uporzadkowane). Rys..8 ilustruje wstawianie elementu o wartości 4, który powinien stać sie lewym potomkiem wez la (4 ). Nie bedzie to jednak potomek bezpośredni, gdyż honorowe miejsce jest zajete przez weze l (nie bedziemy go wydziedziczać ). 4 zostanie prawym (4 > ) potomkiem bezpośrednim, bo nie ma wi ekszego dziecka. Oto przyk ladowa implementacja rekurencyjna: void insert ( int i, wezel & ) if ( == ) // gdy poddrzewo p u s t e... = new wezel ; // s t w ó r z l i ś ć >elem = i; >lewy = ; >prawy = ; else if ( i <= >elem ) insert (i, >lewy ); // wstaw do l e w e g o poddrzewa, b e d z i e FIFO, gdy == else insert (i, >prawy ); // wstaw do prawego poddrzewa Pesymistyczny koszt wykonania tej operacji operacji jest rz edu O(n) (dla drzewa zdegenerowanego), oczekujemy jednakże, że operacja wykonywać si e b edzie średnio w czasie O(log 2 n) (dla danych losowych).
.4. KOLEJKA PRIORYTETOWA 19 korzen 8 1 2 korzen 8 1 2 korzen 8 1 4 & 2 Rekurencja + referencja! Rys..8. Wstawianie wartości 4 do drzewa binarnego.
.. S LOWNIK 20 Trzecia operacja, tzn. pull, wyszukiwać bedzie najwiekszy element przechowywany w drzewie. Znajduje sie on zawsze w najbardziej wysunietym na prawo weźle (por. rys..9). Przyk ladowa implementacja korzystajaca z rekurencji: int pull ( wezel & ) assert (!= ); // na w s z e l k i wypadek... // u s u w a n i e s k r a j n i e prawego l i ś c i a if ( >prawy == ) // wtedy to, co w >= od w s z y s t k i e g o, co w ca lym p o d d r z e w i e int i = >elem ; wezel stary = ; = >lewy ; // może b y ć delete stary ; return i; else return pull ( >prawy ); // i d ź d a l e j Koszt wykonania tej operacji jest taki sam jak w poprzednim przypadku... S lownik S lownik (ang. dictionary) to abstrakcyjny typ danych, który udost epnia trzy operacje: search (znajdź) sprawdza, czy dany element znajduje si e w s lowniku, insert (dodaj) dodaje element do s lownika, o ile taki już si e w nim nie znajduje, remove (usuń) usuwa element ze s lownika. Dla celów pomocniczych rozważać bedziemy także nastepuj ace dzia lania na s lowniku: print (wypisz), removeall (usuń wszystko). Informacja Czasem zak lada sie, że na przechowywanych elementach zosta la określona relacja porzadku liniowego <=. Wtedy możemy tworzyć bardziej efektywne implementacje s lownika, np. z użyciem drzew binarnych (zob. dalej)...1. Implementacja I: tablica Dość latwo bedzie nam zaimplementować s lownik z użyciem zwyk lej tablicy. Niestety, wszystkie operacje w swej najprostszej implementacji bed a cechować sie liniowa (O(n)) pesymistyczna z lożonościa obliczeniowa. Jeśli jednak na elementach zosta la określona relacja <= (co jest bardzo czestym przypadkiem), operacje wyszukiwania można znaczaco przyspieszyć przyjmujac, że elementy sa
.. S LOWNIK 21 korzen 8 1 2 korzen & stary 8 1 2 Rekurencja + referencja! korzen & stary 1 2 Rys..9. Usuwanie elementu najwi ekszego w drzewie binarnym.
.. S LOWNIK 22 przechowywane zgodnie z powyższym porzadkiem. Wymagane jest tu jedynie odpowiednie zaimplementowanie operacji insert). W takiej sytuacji, w operacji search można skorzystać z algorytmu tzw. wyszukiwania binarnego (po lówkowego), którego pesymistyczna z lożoność wynosi zaledwie O(log n). Metoda ta bazuje na za lożeniu, że poszukiwany element x, jeśli oczywiście znajduje sie w tablicy, musi być ulokowany na pozycji (indeksie) ze zbioru l,l+1,...,p. Na atku ustalamy oczywiście l=0 i p=n 1. W każdej kolejnej iteracji znajdujemy środek owego obszaru poszukiwań, tzn. m=(p+l)/2 oraz sprawdzamy, jak ma sie t[m] do x. Jeśli okaże sie, że jeszcze nie trafiliśmy na w laściwe miejsce, to wtedy zaweżamy (o co najmniej po low e) nasz obszar modyfikujac odpowiednio wartość l badź p. bool search ( int x, int t, int n) if (n == 0) return false ; // s l o w n i k p u s t y // w y s z u k i w a n i e b i n a r n e ( po lówkowe ) int l = 0; // p o c z a t k o w y o b s z a r p o s z u k i w a ń int p = n 1; // c a l a t a b l i c a while (l <= p) int m = (p+l) /2; // ś r o d e k o b s z a r u p o s z u k i w a ń if (x == t[m]) return true ; // z n a l e z i o n y k o n i e c else if (x > t[m]) l = m +1; // zaw eżamy o b s z a r p o s z u k i w a ń else p = m 1; // jw. return false ; // n i e z n a l e z i o n y Pozosta le operacje pozostawiamy drogiemu Czytelnikowi do samodzielnej implementacji...2. Implementacja II: lista jednokierunkowa S lownik także nietrudno jest zaimplementować na liście jednokierunkowej. Chociaż takie rozwiazanie jest ma lo efektywne, wszystkie operacje cechuja sie bowiem z konieczności z lożonościa O(n), to jednak próba zmierzenia sie z nim może być dla nas dobrym ćwiczeniem. Przedstawmy wobec tego gotowy kod wszystkich pieciu interesujacych nas funkcji. Zauważmy, że poza operacja print korzystamy tutaj w każdym przypadku z bardzo dla nas wygodnej rekurencji. bool search ( int x, wezel w) if ( w == ) return false ; // k o n i e c n i e ma else if ( w >elem == x) return true ; // j e s t else return search (x, w >nast ); // może j e s t d a l e j? void insert ( int x, wezel & w) / Gdyby w s l o w n i k u n i e mog ly wystepować d u p l i k a t y elementów, t a o p e r a c j a c e c h o w a l a b y s i e z l o ż o n o ś c i a O( 1 ) można by b y l o w s t a w i ć nowy w e z e l na p o c z a t e k.
.. S LOWNIK 2 N i e s t e t y my musimy i ś ć być może na k o n i e c l i s t y... / if ( w == ) // wstaw t u w = new wezel ; w >nast = ; w >elem = x; else if ( w >elem == x) return ; // e l e m e n t j u ż j e s t n i c n i e r ó b else insert (x, w >nast ); // wstaw g d z i e ś d a l e j void remove ( int x, wezel & w) if (w == ) return ; // n i c n i e r ó b else if ( w >elem == x) // s k a s u j s i e wezel wstary = w; w = w >nast ; delete wstary ; // k o n i e c n i e i d z i e m y d a l e j else remove (x, w >nast ); // s z u k a j d a l e j void print ( wezel w) cout << " "; while (w!= ) cout << w >elem << " "; w = w >nast ; cout << "" << endl ; void removeall ( wezel & w) if (w == ) return ; // n i e ma c z e g o usuwać k o n i e c removeall (w >nast ); // usuń n a j p i e r w w s z y s t k i e n a s t e p n i k i delete w; // usuń s i e b i e samego w = ;... Implementacja III: drzewo binarne Jeśli na elementach s lownika zosta la określona relacja porzadku <=, możemy zaimplementować ten abstrakcyjny typ danych z użyciem drzew binarnych. Dzieki czemu wszystkie trzy podstawowe operacje bed a mia ly oczekiwana z lożoność obliczeniowa rzedu O(log n) (por. dyskusje w rozdz..4.2).
.. S LOWNIK 24 Stosunkowo najprostszymi, a zatem i niewymagajacymi szerszego komentarza, sa operacje search i removeall. Dla ilustracji, na rys..10 pokazujemy przebieg wyszukiwania elementu 2 w przyk ladowym drzewie z lożonym z elementów 1, 2,,,, 8. bool search ( int x, wezel w) if ( w == ) return false ; else if ( w >elem == x) return true ; else if ( w >elem < x) return search ( x, w >prawy ); else return search ( x, w >lewy ); void removeall ( wezel & w) if (w == ) return ; // n i e ma c z e g o usuwać removeall (w >lewy ); // usuń n a j p i e r w removeall (w >prawy ); // w s z y s t k i c h potomkow delete w; // potem usuń samego s i e b i e w = ; Aby nieco uatrakcyjnić sposób wyświetlania drzewa, w operacji print skorzystamy z dodatkowej, pomocniczej funkcji rekurencyjnej. void print_pomocnicza ( wezel w) // f u n k c j a p o m o c n i c z a // r e a l i z u j e,, z w y k l e w y p i s a n i e elementów if ( w == ) return ; print_ pomocnicza ( w >lewy ); cout << w >elem << " "; print_ pomocnicza ( w >prawy ); void print ( wezel w) cout << " "; print_pomocnicza (w); cout << "" << endl ; Operacja insert realizowana bedzie za pomoca wstawiania nowego elementu jako potomka odpowiednio wybranego liścia. void insert ( int x, wezel & w) if ( w == ) // wstaw t u w = new wezel ; w >lewy = ; w >prawy = ; w >elem = x; else if ( w >elem == x) return ; // e l e m e n t j u ż j e s t n i c n i e r ó b else if ( w >elem < x)
.. S LOWNIK 2 2 < korzen 8 1 2 2 < korzen 1 8 2 2 > 1 korzen 8 1 2 2 == 2 korzen 8 1 2 Rys..10. Wyszukiwanie elementu 2 w przyk ladowym drzewie binarnym.
.. PODSUMOWANIE 2 insert (x, w >prawy ); else insert (x, w >lewy ); Nieco bardziej skomplikowana bedzie ostatnia operacja. Usuwajac dany weze l musimy bowiem rozważyć trzy przypadki: 1. jeśli w eze l jest liściem po prostu go usuwamy, 2. jeśli jest w ez lem z tylko jednym dzieckiem zast epujemy go jego dzieckiem,. jeśli weze l ma dwoje dzieci należy go zastapić wybranym z dzieci, a nastepnie doczepić drugie dziecko (wraz ze wszystkimi potomkami) za najmniejszym/najwiek- szym potomkiem pierwszego. void remove ( int x, wezel & w) if (w == ) return ; // n i c n i e r ó b else if ( w >elem < x) remove (x, w >prawy ); else if ( w >elem > x) remove (x, w >lewy ); else // e l e m e n t z n a l e z i o n y wezel wstary = w; if (w >lewy!= ) // ma l e w e g o potomka w = w >lewy ; // z a s t a p w e z e l lewym d z i e c k i e m // A co z ewentualnym prawym potomkiem i j e g o d z i e ć m i? // Musimy go d o c z e p i c j a k o s k r a j n i e prawy l i ś ć wezel = w; while ( >prawy!= ) = >prawy ; >prawy = wstary >prawy ; else if (w >prawy!= ) // ma prawego potomka, // l e c z n i e ma l e w e g o w = w >prawy ; // po p r o s t u z a s t a p prawym else // j e s t l i s c i e m w = ; // po p r o s t u usuń delete wstary ;.. Podsumowanie Przewodnim celem niniejszej cz eści skryptu by lo przedstawienie czterech bardzo ważnych w algorytmice abstrakcyjnych typów danych. Omówiliśmy: 1. stos (LIFO), 2. kolejk e (FIFO),
.. PODSUMOWANIE 2. kolejke priorytetowa (HPF), 4. s lownik. Wiemy już, iż każdy ATD można zaimplementować na wiele sposobów, używajac np. tablic albo dynamicznych struktur danych (list, drzew itp.). W każdym przypadku interesowa la nas najbardziej wydajna implementacja. Tablica.1 zestawia z lożoność obliczeniowa najważniejszych operacji wykonywanych na tablicach, listach jednokierunkowych i drzewach binarnych. Każda z tych trzech struktur danych ma swoje mocne i s labe strony. Z dyskusji przeprowadzonej w poprzednich rozdzia lach wiemy już, jak ważny jest świadomy wybór odpowiedniego narzedzia, którym zamierzamy sie pos lużyć w praktyce. Tab..1. Z lożoność obliczeniowa wybranych operacji. Operacja Tablica Lista Drzewo BST Dostep do i-tego elementu O(1) O(n) O(n) Wyszukiwanie O(n) (a) O(n) O(n) (b) Wstawianie O(n) O(n) O(n) (b) na atek O(1) na koniec O(n) (c) Usuwanie O(n) O(n) O(n) (b) z atku O(1) z końca O(n) (d) (a) O(log n), jeśli tablica jest posortowana. (b) Oczekiwana z lożoność O(log n). (c) O(1), jeśli lista zawiera aźnik na ogon. (d) Nieomawiana tutaj lista dwukierunkowa pozwala na realizacj e tej operacji w czasie O(1). Oczywiście w algorytmice znanych jest o wiele wiecej ciekawych algorytmów i struktur danych, np. tablice mieszaj ace (ang. hash tables) implementujace s lownik. Takowe Czytelnik może poznać studiujac bogata literature przedmiotu badź s luchaj ac bardziej zaawansowanych wyk ladów.
.. ĆWICZENIA 28.. Ćwiczenia Zadanie.1. Zaimplementuj w postaci osobnych funkcji nastepuj ace operacje na liście jednokierunkowej przechowujacej wartości typu double: 1. Wypisanie wszystkich elementów listy na ekran. 2. Wyznaczenie sumy wartości wszystkich elementów.. Wyznaczenie sumy wartości co drugiego elementu. 4. Sprawdzenie, czy dana wartość rzeczywista y wystepuje w liście.. Wstawienie elementu na atek listy.. Wstawienie elementu na koniec listy.. Wstawienie elementu na i-ta pozycje listy. 8. Wstawienie elementu y z zachowaniem porzadku, tzn. jeśli pierwotna lista jest posortowana, to po wstawieniu porzadek nie jest zaburzony. 9. Usuwanie elementu z atku listy. Usuwany element jest zwracany przez funkcje. 10. Usuwanie elementu z końca listy. Usuwany element jest zwracany przez funkcje. 11. Usuwanie i-tego w kolejności elementu. 12. Usuwanie wszystkich elementów o zadanej wartości. Zwracana jest wartość logiczna w zależności od tego, czy choć jeden element znajdowa l sie na liście, czy nie. 1. Odwrócenie kolejności elementów listy. 14. Usuwanie z uporzadkowanej listy powtarzajacych sie elementów. 1. Usuwanie z listy (niekoniecznie uporzadkowanej) powtarzajacych sie elementów. 1. Przesuniecie co drugiego w kolejności elementu na koniec listy, z zachowaniem ich pierwotnej wzglednej kolejności. 1. Przesuniecie wszystkich elementów o wartości parzystej na koniec listy, z zachowaniem ich pierwotnej wzglednej kolejności. Zadanie.2. Dla danych dwóch list jednokierunkowych napisz funkcje, która je po l aczy, np. dla (1,2,,4) oraz (,2,) sprawi, że I lista bedzie postaci (1,2,,4,,2,), a II zostanie skasowana. Zadanie.. Dla danych dwóch posortowanych list jednokierunkowych napisz funkcje, która je po l aczy w taki sposób, że wartości bed a nadal posortowane, np. dla (1,2,4,) oraz (2,,) sprawi, że I lista bedzie postaci (1,2,2,,4,,), a II zostanie skasowana. Zadanie.4. Rozwiaż zadanie.1 zak ladajac tym razem, że operacje dokonywane sa na liście jednokierunkowej, dla której dodatkowo przechowujemy aźnik na ostatni element. Zadanie.. Rozwiaż zadanie.1 zak ladajac tym razem, że operacje dokonywane sa na liście dwukierunkowej. Lista dwukierunkowa sk lada sie z wez lów, w których znajduje sie nie tylko aźnik na nastepny, ale i także na poprzedni element. Co wiecej, prócz aźnika na pierwszy element, powinniśmy zapewnić bezpośredni dostep (np. w funkcji main()) do ostatniego, por. zad..4, tzn. dysponujemy jednocześnie g low a oraz ogonem listy. Zaleta listy dwukierunkowej jest możliwość bardzo szybkiego wstawiania i kasowania elementów znajdujacych sie zarówno na atku jak i na końcu tej dynamicznej struktury danych. Poniższy rysunek przedstawia schemat przyk ladowej listy dwukierunkowej przechowujacej elementy,,. kon
.. ĆWICZENIA 29 Zadanie.. Napisz samodzielnie pe lny program w jezyku C++, który implementuje z użyciem listy jednokierunkowej i testuje (w funkcji main()) stos (LIFO) zawierajacy dane typu double. Zadanie.. Napisz samodzielnie pe lny program w jezyku C++, który implementuje z użyciem listy jednokierunkowej z dodatkowym aźnikiem na ostatni element i testuje (w funkcji main()) zwyk l a kolejke (FIFO) zawierajac a dane typu char (napisy). Zadanie.8. Zaimplementuj operacje enqueue() i dequeue() zwyk lej kolejki (FIFO) typu int korzystajac tylko z dwóch gotowych stosów. Zadanie.9. Napisz samodzielnie pe lny program w jezyku C++, który implementuje i testuje (w funkcji main()) kolejke priorytetowa zawierajac a dane typu int. Zadanie.10. Napisz funkcje, która wykorzysta kolejke priorytetowa do posortowania danej tablicy o elementach typu int. Zadanie.11. Zaimplementuj w postaci osobnych funkcji nastepuj ace operacje na drzewie binarnym przechowujacym wartości typu double: 1. Wyszukiwanie danego elementu. 2. Zwrócenie elementu najmniejszego.. Zwrócenie elementu najwiekszego. 4. Wypisanie wszystkich elementów w kolejności od najmniejszego do najwiekszego.. Wypisanie wszystkich elementów znajdujacych sie wierzcho lkach bed acych liśćmi.. Sprawdzenie, czy dane drzewo jest zdegenerowane (bezpośrednio redukowalne do listy), tzn. czy każdy element posiada co najwyżej jednego potomka).. Wstawianie elementu o zadanej wartości. 8. Wstawianie elementu o zadanej wartości. Jeśli wstawiany element znajduje sie już w drzewie nie należy wstawiać jego duplikatu. 9. Usuwanie elementu najwiekszego. 10. Usuwanie elementu najmniejszego. 11. Usuwanie elementu o zadanej wartości. 12. Usuwanie co drugiego elementu. 1. Usuwanie wszystkich powtarzajacych sie wartości. 14. Odpowiednie przekszta lcenie drzewa (z zachowaniem w lasności drzewa binarnego) tak, by element najwiekszy znalaz l sie w korzeniu. 1. Wypisanie wszystkich elementów, których wszyscy potomkowie (pośredni i bezpośredni) maja dok ladnie dwóch potomków lub sa liśćmi. 1. Wypisanie wszystkich elementów, których wszyscy przodkowie (pośredni i bezpośredni) przechowuja wartości nieparzyste. Zadanie.12. Napisz funkcje, która jako parametr przyjmuje drzewo binarne i zwraca liste jednokierunkowa (aźnik na pierwszy element) zawierajac a wszystkie elementy przechowywane w drzewie uporzadkowane niemalejaco. Zadanie.1. Napisz funkcje, która jako parametr przyjmuje drzewo binarne i zwraca liste jednokierunkowa (aźnik na pierwszy element) zawierajac a wszystkie elementy przechowywane w drzewie uporzadkowane poziomami (tzn. pierwszy jest korzeń, nastepnie dzieci korzenia, potem dzieci-dzieci korzenia itd).