Dwukierunkowa lista liniowa
Transkrypt
Dwukierunkowa lista liniowa
Podstawy Programowania semestr drugi Wykład czwarty 1. Dwukierunkowa lista liniowa Dwukierunkowa lista liniowa różni się w niewielkim stopniu od jednokierunkowej listy liniowej. Zasadnicza różnica w funkcjonalności polega na tym, że elementy listy dwukierunkowej możemy przeglądać zarówno od początku do końca, jak i od końca do początku. W przypadku listy jednokierunkowej, jak sama nazwa wskazuje, przeglądanie możliwe jest tylko od początku do końca. 2. Implementacja listy dwukierunkowej jako dynamicznej struktury danych Lista dwukierunkowa, podobnie jak jej poprzedniczka zostanie opisana na przykładzie programu, który wstawia do niej kolejne elementy, tak aby wartości, które one przechowują znajdowały się w porządku niemalejącym. Definicja typu pojedynczego elementu listy dwukierunkowej zawiera dwa pola wskaźnikowe (wiersze 5-9). Pole „next” przeznaczone jest na zapamiętanie adresu następnego elementu w liście, pole „prev” będzie służyło do przechowywania adresu poprzedniego elementu. Wartość tego pola dla pierwszego elementu na liście będzie wynosiła „NIL”, podobnie jak wartość pola „next” ostatniego elementu listy. Tak jak w przypadku jednokierunkowej listy potrzebujemy tylko jednej globalnej zmiennej wskaźnikowej, która będzie przechowywała adres pierwszego elementu listy 1 (wiersz 11). Pierwszy element listy jest tworzony przy pomocy procedury „create”. Może ona również służyć do wstawiania nowych elementów do listy, gdyż wywołuje procedurę „insert_node”, realizującą tę operację. Ta ostatnia różni się od swojej odpowiedniczki dla listy jednokierunkowej, tym że w jej kodzie zawarte są operacje związane z manipulacją zawartością pola „prev” elementu i że nie potrzebuje dodatkowej zmiennej lokalnej, która pamiętałaby wskaźnik na poprzedni element. Procedura ta ma dwa parametry. Przez pierwszy pobiera wskaźnik pierwszego elementu listy, natomiast poprzez drugi pobiera wartość, którą należy umieścić w elemencie. Posiada ona również dwie lokalne zmienne wskaźnikowe. W wierszach (18 21) tworzony jest i inicjalizowany nowy element listy, z uwzględnieniem pola „prev”, któremu należy nadać wartość początkową „NIL”. Jeśli warunek zawarty w wierszu 22, w instrukcji warunkowej jest prawdziwy, to oznacza to, że nowy element należy umieścić na początku listy. Wymaga to następujących operacji: zapamiętania wskaźnika na element listy, który dotychczas był pierwszym 1 program double_linked_list; elementem w polu „next” nowego elementu (wiersz 24). W 2 uses crt; polu „prev” pierwszego elementu listy należy zapamiętać adres nowego elementu (wiersz 25). Po wykonaniu tych operacji 3 type pozostaje jeszcze uczynić nowy element pierwszym elementem listy (wiersz 26) i zakończyć działanie procedury. Częściej się 4 wskaznik=^element; jednak zdarza, że musimy nowy element wstawić nie na początku listy, lecz w środku lub na końcu. Ponieważ 5 element = record założyliśmy, że elementy w liście powinny być posortowane 6 dana:integer; niemalejąco pod względem przechowywanych przez nie wartości, wystarczy znaleźć element przechowujący wartość 7 next:wskaznik; większą, od tej, którą chcemy umieścić w liście i wstawić nowy element przed tym elementem. Poszukiwania tego elementu 8 prev:wskaznik; przeprowadzamy przy pomocy pętli „repeat” ... „until” (wiersze 9 end; 30 -38). Może się zdarzyć, że na liście nie będzie elementu o większej wartości i nowy elementu powinien zostać dodany do 10 var końca listy. Jest to wykonywane w wierszach 33 34. Przebieg tej operacji jest podobny do tej samej operacji dla listy 11 first:wskaznik; jednokierunkowej, ale uwzględnia zapamiętanie w polu „prev” 12 i:integer; nowego elementu adresu bieżącego ostatniego elementu listy (34). Również w tym wypadku możemy zakończyć działanie 13 procedury. Jeśli jednak w wyniku wykonania pętli znajdziemy element, przed którym należy wstawić nowy element, to 14 procedure insert_node(var f:wskaznik; a:integer); wykonywany jest kod, którego działania można zilustrować 15 var rysunkiem: 16 p,nowy:wskaznik; 1. Przed wykonaniem wiersza 39 17 begin 18 new(nowy); 19 nowy^.dana:=a; 20 nowy^.prev:=nil; 21 nowy^.next:=nil; 22 if f^.dana > a then 23 1 dana NIL NIL 24 nowy^.next:=f; 25 f^.prev:=nowy; 26 f:=f^.prev; 27 exit; 28 end; 29 p:=f; p dana next prev dana next prev begin nowy 2. Po wykonaniu wiersza 39 dana NIL prev dana next prev nowy p dana next prev Jest to tylko pewna przyjęta konwencja. W przypadku listy dwukierunkowej nie ma znaczenia, na który element tej listy wskazuje ta zmienna. 1 Podstawy Programowania 30 31 repeat semestr drugi 3. Po wykonaniu wiersza 40 if p^.next=nil then 32 dana next prev begin 33 p^.next:=nowy; 34 nowy^.prev:=p; 35 exit; 36 end; 37 38 until p^.dana > a; 39 nowy^.prev:=p^.prev; 40 nowy^.next:=p; 41 p^.prev^.next:=nowy; 42 p^.prev:=nowy; p dana next prev dana next prev p:=p^.next; nowy 4. Po wykonaniu wiersza 41 dana next prev nowy p 43 end; dana next prev dana next prev 44 45 procedure create(var f:wskaznik; a:integer); 46 var 47 5. Po wykonaniu wiersza 42 nowy:wskaznik; 48 begin 49 50 begin 51 new(nowy); 52 nowy^.dana:=a; 53 nowy^.next:=nil; 54 nowy^.prev:=nil; 55 f:=nowy; 56 57 dana next prev if f=nil then dana next prev else insert_node(f,a); 59 end; 60 61 procedure show_both(f:wskaznik); 62 var 63 p:wskaznik; 66 begin 67 p:=f; write(f^.dana:3); 69 f:=f^.next; 70 end; 71 writeln; 72 while p<>nil do 73 74 2 while f<> nil do 68 dana next prev Procedura „delete_node” realizuje operację usuwania elementu z listy. Pisząc tego rodzaju procedurę należy uwzględnić trzy przypadki. W pierwszym elementem usuwanym będzie element znajdujący się na początku listy. Usunięcie go jest dokonywane w wierszach 96 -104. W wierszu 98 zapamiętujemy w zmiennej „tmp” adres elementu, który jest jego następnikiem, po czym w wierszu 99 zwalniamy pamięć przeznaczoną na pierwszy element. Pierwszym elementem listy zostaje element, którego adres pamiętamy w zmiennej „tmp”. Przepisujemy więc jego adres do zmiennej „p”, ustawiamy wartość jego pola „prev” na „NIL” i modyfikujemy wskaźnik na pierwszy element listy („f”), tak, aby wskazywał na niego2. Po wykonaniu tych czynności możemy zakończyć działanie procedury. Obsługa drugiego przypadku jest powiązana z obsługą trzeciego. W wierszach 105 108 przeszukujemy iteracyjnie listę szukając elementu, który przechowywałby zadaną wartość ten element będziemy chcieli usunąć. Może się okazać, że takiego elementu nie będzie na liście, 64 begin 65 p Po zakończeniu pętli „repeat” zmienna wskaźnikowa „p” wskazuje element listy, przed którym należy umieścić nowy element. W wierszach 39 i 40 przypisujemy odpowiednio polu „prev” i polu „next” nowego elementu adres elementu poprzedzającego element wskazywany przez „p” i adres elementu wskazywanego przez „p”. W wierszu 41 zapamiętujemy w polu „next” elementu poprzedzającego element wskazywany przez „p” adres nowego elementu. W wierszu 42, w polu „prev” elementu wskazywanego przez „p” również umieszczamy adres nowego elementu. Po tych operacjach otrzymujemy konfigurację przedstawioną na dole ilustracji. end 58 nowy begin write(p^.dana:3); Adres pierwszego elementu listy zawiera parametr „f” procedury, natomiast zmienna lokalna „p” jest tylko zmienną pomocniczą. 2 Podstawy Programowania 75 wówczas po prostu kończymy wykonanie procedury (wiersz 107). W przeciwnym przypadku po zakończeniu pętli wskaźnik „p” będzie wskazywał na element przeznaczony do usunięcia. Wykonywane w tym wypadku operacje można zilustrować rysunkiem: p:=p^.prev; 76 end; 77 writeln; semestr drugi 78 end; 1. Przed wykonaniem wiersza 109 79 p 80 procedure remove(var f:wskaznik); 81 var dana next prev 82 tmp:wskaznik; 83 begin 84 while f<>nil do 85 tmp:=f^.next; 87 dispose(f); 88 f:=tmp; 89 dana next prev 2. Po wykonaniu wiersza 109 begin 86 dana next prev p dana next prev end; dana next prev dana next prev 90 end; 91 procedure delete_node(var f:wskaznik; a:integer); 3. Po wykonaniu wiersza 110 p 92 var 93 tmp,p:wskaznik; dana next prev 94 begin 95 p:=f; dana next prev dana next prev 96 if p^.dana=a then 97 begin 98 tmp:=p^.next; 99 dispose(p); 100 p:=tmp; 101 p^.prev:=nil; 102 f:=p; 103 dana next prev dana next prev dana next prev exit; 104 end; 105 repeat 106 p:=p^.next; 107 if p=nil then exit; 108 4. Po wykonaniu wiersza 111 p Rysunek nie ilustruje przypadku, który jest uwzględniony w wierszu 109. Może się zdarzyć, że będziemy usuwać ostatni element z listy, wówczas odwołanie „p^.next^.prev” byłoby nieprawidłowe i mogło spowodować błędne działanie programu. Przed jego wykonaniem należy więc sprawdzić, czy istnieje następny element względem tego, który wskazuje zmienna „p”. Jeśli tak, to jego polu „prev” należy przypisać adres elementu poprzedzającego element wskazywany przez „p”, jeśli nie to nic nie musimy robić. W kolejnym wierszu, polu „next” elementu poprzedzającego element wskazywany przez „p” przypisujemy adres elementu następnego po elemencie wskazywanym przez „p” (jeśli taki element nie istnieje, to pole to będzie miało nadaną wartość „NIL”). Grube kreski przekreślające usuwany element na rysunku symbolizują zwalnianie pamięci przeznaczonej na ten element. Dane które się w niej znajdują nie ulegają od razu zniszczeniu, niemniej nie należ się już do nich odwoływać. Procedura usuwania wszystkich elementów z listy dwukierunkowej jest taka sama jak w programie używającym listy jednokierunkowej i nie będzie tutaj omawiana. Procedura „show_both” wyświetla zawartość elementów listy na dwa sposoby : najpierw począwszy od pierwszego elementu, a potem począwszy od ostatniego elementu. Pierwsza pętla „while” (wiersze 65 -70) realizuje pierwszy etap działania tej until p^.dana=a; 109 if p^.next<>nil then p^.next^.prev:=p^.prev; 110 p^.prev^.next:=p^.next; 111 dispose(p); 112 end; 113 114 115 begin 116 clrscr; 117 writeln(MemAvail); 118 for i:=1 to 5 do create(first,i); 119 show_both(first); 120 for i:=10 to 15 do insert_node(first,i); 3 Podstawy Programowania 121 show_both(first); 122 insert_node(first,7); 123 show_both(first); 124 insert_node(first,16); 125 show_both(first); 126 insert_node(first,0); 127 show_both(first); 128 delete_node(first,0); 129 show_both(first); 130 delete_node(first,4); 131 show_both(first); 132 delete_node(first,15); 133 show_both(first); 134 remove(first); 135 writeln(MemAvail); 136 readln; semestr drugi procedury. Po jej zakończeniu zmienna pomocnicza „p” zawiera adres ostatniego elementu na liście. Kolejna pętla „while” (wiersze 72 76) realizuje wypisanie zawartości elementów listy w porządku odwrotnym (od ostatniego do pierwszego elementu). W bloku głównym programu, podobnie jak w przypadku programu korzystającego z listy jednokierunkowej tworzona jest lista zawierająca elementy o wartościach od 1 do 5, następnie dodawane są do niej elementy o wartościach od 10 do 15 przy pomocy procedury „insert_node”, a następnie za pomocą tej samej procedury dodawane są pojedyncze elementy o wartościach odpowiednio 7, 16,0. Po każdej takiej operacji wstawienia wypisywana jest na ekran zawartość listy. W dalszej części programu usuwane są z listy elementy o wartościach 0,4,15, (tu również po każdej operacji następuje wypisanie zawartości listy na ekran), po czym cała lista jest usuwana. 137 end. 3. Uwagi końcowe Podobnie jak listę jednokierunkową listę dwukierunkową można zaimplementować w oparciu tablicę, jednak ta forma realizacji listy dwukierunkowej nie będzie tutaj omawiana 3. Zarówno stos, jak i kolejka mogą być oparte o listę dwukierunkową, lecz zazwyczaj te terminy oznaczają struktury oparte o listę jednokierunkową. Czas wykonania operacji wstawiania i usuwania elementu z listy (po tym jak ten element znajdziemy) jest stały, niezależny od ilości elementów w liście4, natomiast czas wyszukania pojedynczego elementu jest wprost proporcjonalny do liczby elementów listy5. Odwrotnie sytuacja wygląda w przypadku tablicy. Tablica charakteryzuje się stałym czasem dostępu do elementu, natomiast wstawianie (o ile jest wystarczająca ilość miejsca w tablicy) i usuwanie elementów z tablicy jest proporcjonalne do ich ilości. Tak ięc tablica jest strukturą danych o dostępie swobodnym (bezpośrednim), natomiast listy są strukturami o dostępie sekwencyjnym. W procedurach „insert_node” (wiersz 41) oraz „delete_node” (wiersz 110) występują dość złożone odwołania do elementów. Okazuje się jednak, że odwołania te mogą być jeszcze bardziej skomplikowane. Rozważmy następującą listę dwukierunkową: p 5 next NIL 4 next prev 1 next prev 9 next prev 3 next prev 2 NIL prev Wskaźnik „p” wskazuje na czwarty element tej listy. Wyrażenie p^.next^.next^.dana można przeczytać następująco: uzyskaj adres elementu następnego względem elementu wskazywanego przez „p”, następnie z tego elementu uzyskaj adres kolejnego po nim elementu i z tego elementu uzyskaj wartość zapisaną w polu „dana”. Tą wartością oczywiście będzie „2”. Podobne wyrażenia możemy budować wykorzystując wskaźnik „prev”, np.: p^.prev^.prev^.prev^.dana. To wyrażenie będzie miało wartość „5”. Można oczywiście w wyrażeniu użyć obu wskaźników równocześnie: p^.next^.next^.prev^.prev^.prev^.prev^.dana. Ostatnie wyrażenie ma wartość: „4”. Po uważnej analizie tego wyrażenia można dojść do wniosku, że można je uprościć, otrzymując taką samą wartość. Wniosek końcowy: należy zrozumieć w jaki sposób się buduje i jak działają tak rozbudowane wyrażenia, po czym nigdy ich nie stosować w programach :-) 3 4 5 Nie jest ona tak wygodna jak implementacja w postaci struktury dynamicznej Używając notacji asymptotycznej możemy napisać, że jest on równy O(1) Podobnie jak wcześniej możemy wyrazić to za pomocą notacji asymptotycznej: O(n), gdzie „n” to liczba elementów na liście. 4