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

Podobne dokumenty