dynamiczne struktury danych

advertisement
Procedury służące do dynamicznego przydzielania i zwalniania pamięci,
zmienne typów wskaźnikowych są wykorzystywane do programowania
dynamicznych struktur danych, którym pamięć jest przydzielana i zwalniana w
trakcie wykonywania programu. Ze względu na organizację oraz sposoby
dołączania, wstawiania
i usuwania składników można wyróżnić między innymi następujące metody:

kolejki, w których można dołączyć tylko w jednym końcu, a usunąć
tylko
w drugim końcu,

listy, które charakteryzują się tym, że dla każdego składnika poza
pierwszym
i ostatnim, jest określony jeden składnik poprzedni i jeden składnik
następny lub tylko składnik poprzedni lub następny, przy czym w
dowolnym miejscu takiej struktury można dołączyć nowy składnik lub
usunąć składnik istniejący.

stosy, w których wstawianie, usuwanie i dostęp są możliwe tylko w
jednym końcu zwanym wierzchołkiem stosu
Dynamiczne struktury danych to nic innego, jak rozszerzenie możliwości oferowanych
przez C poprzez zastosowanie struktur, których definicje zawierają odwołania do samych
siebie. Zdefiniowanie takiej struktury możliwe jest poprzez zastosowanie wskaźników.
Oto przykład dynamicznego obiektu:
int *p;
wskaźnik na obiekt typu int - w momencie uruchomienia program istnie tylko wskaźnik, ale nie ma obiektu,
na który mógłby on pokazywać, obiekt ten można utworzyć, aby pracować z nim, a potem zniszczyć, aby
nie zajmował pamięci
Zmienna p jest wskaźnikiem mającym możliwość wskazywania na zmienne typu
całkowitego. Można używać go do wskazywania na zmienne istniejące już w programie,
za pomocą przypisania adresu zmiennej do wskaźnika:
int zmienna;
p = &zmienna;
ale jest to rzadko stosowane. Prawdziwa moc dawana przez stosowanie wskaźników leży
w tym, że zmienne można za ich pomocą tworzyć:
int *p;
wskaźnik na obiekt typu int - na razie nie zainicjalizowany żadną konkretną wartością
p = new int;
tworzenie nowego obiektu (new) typu int, i przypisanie adresu tegoż do zmiennej wskaźnikowej p. Teraz p
zawiera wskaźnik do liczby typu int i odwołanie *p jest jedynym sposobem dotarcia do tej zmiennej.
Powołanie do życia obiektu int wymaga przydzielenia mu pamięci w ilości wymaganej dla typu int (2 lub 4
bajty).
Utworzenie zmiennej typu int w momencie działania programu mimo pozornej
prostoty okazuje się przydatne wtedy gdy potrzebna jest taka zmienna, a nie chcemy
deklarować jej na początku działania programu. Oczywiście w praktyce działania na
wskaźnikach nie dotyczą prostych zmiennych int, ale całych struktur lub tablic.
Listy
Struktura zdefiniowana w sposób następujący:
struct LISTA {
struct LISTA *next;
}
nazywana jest ogólnie listą jednokierunkową. Do definicji pól dowolnego elementu
takiej struktury używana jest definicja pojedynczego jej elementu. To znaczy, że obiekt
LISTA zawiera pola, które definiowane są tak samo, jak LISTA, rekurencyjnie. Nie ma
jednak obawy o pozorną nieskończoność takiej struktury. Definicja pola next oznacza, że
jest to wskaźnik, a nie sama struktura. Zatem, w momencie definicji, wskaźnik ten
istnieje, ale nie istnieje żaden obiekt przez niego wskazywany. Dopiero w przyszłości
będzie on pokazywał na takie same elementy typu LISTA, wewnątrz jakiego sam się
znajduje. W praktyce do struktury tego typu wprowadza się jeszcze jakieś pola
przechowujące informacje, np. (przykład z prawdziwego, działającego programu):
struct TNode {
TNode *next;
int nrdl;
}
Właściwością tej struktury jest zdolność do przechowywania liczb całkowitych (w polu o
nazwie nrdl). Inna lista jednokierunkowa:
struct TListNode {
TListNode *nastepny;
void *dane;
}
posiada zdolność do przechowywania wskaźników na obiekty dowolnego typu. Można w
ten sposób przechowywać wskaźniki do liczb, napisów, tablic, funkcji i wszystkiego, co
posiada adres. Oczywiście obsługa tak przechowywanych adresów wymaga potem
umiejętnej oceny, czym jest akurat przechowywany wskaźnik, ale to już problemy innej
natury. Gdy do pojedynczego elementu typu na przykład LISTA dopiszemy następny,
wstawiając w polu next adres nowego elementu, otrzymamy "połączenie" pomiędzy
elementami - z pierwszego można trafić do drugiego za pomocą wskaźnika. Odwrotnie
już nie - drugi element nie zawiera żadnych informacji o tym, czy posiada poprzednika,
czy nie. Dlatego lista jest jednokierunkowa - stojąc na dowolnym elemencie można mieć
dostęp tylko do elementów następnych. Graficzna reprezentacja listy jest prosta i czytelna
gdy przedstawi się ją za pomocą strzałek i węzłów. Dla struktury typu LISTA wygląda to
tak:
Graficzna reprezentacja prostej, jednokierunkowej listy. Ostatni element to wskaźnik pusty - czyli next
mający wartość NULL.
Jak widać, tworzony jest łańcuch elementów, który nie ma z góry określonej swojej
długości. Długość może być zmienna, i jest to jedna z najważniejszych zalet struktur tego
typu. Na rysunku pokazano tylko cztery elementy, ale może ich być o wiele więcej. Co
więcej, ostatni wskaźnik next, na rysunku mając wartość NULL (czyli pusty - nigdzie nie
pokazujący) może pokazywać na pierwszy element, tworząc tak zwaną listę cykliczną.
Graficzną reprezentację listy cyklicznej można zobaczyć poniżej, tym razem dla
struktury zdefiniowanej jako TNode:
Lista cykliczna z numerami elementów w polu nrdl
Numeracja pól listy jest wprowadzona po to, aby można było wykryć pierwszy
element listy. Nie jest to konieczne, jeżeli nie zależy nam na kolejności, albo nie liczymy
elementów. Podczas przetwarzania listy cyklicznej można łatwo sprawdzić, w którym
jesteśmy miejscu. Lista taka - gdzie jednym z pól jest numer pozycji, nazywa się listą
numerowaną, lub listą indeksowaną.
Łatwo można sobie wyobrazić bardziej złożone struktury danych podobne tylko z
definicji do listy jednokierunkowej - np. listę dwukierunkową, trzykierunkową lub
więcej-kierunkową (N-kierunkową). Graficzne reprezentacje takich dziwnych struktur
bardzo się komplikują. Powstaje też problem, na co tak naprawdę mają wskazywać
poszczególne wskaźniki.
Lista dwukierunkowa powinna zawierać dwa wskaźniki obok innych pól
informacyjnych - wskazujące na poprzedni element oraz na następny. Taka lista jest
strukturą uniwersalną, i do niektórych zastosowań w programowaniu jest wprost idealna.
Przykład poniżej:
struct T2List {
T2List *n, *p;
.... /* dane */
}
Elementy takiej listy połączone są dwukierunkowo, co widać na poniższym rysunku:
Lista dwukierunkowa. Pole p wskazuje na element poprzedni, pole n na następny.
Wzdłuż całej listy dwukierunkowej można swobodnie poruszać się za pomocą
wskaźników w obie strony. Dlatego, posiadając dowolny element listy dwukierunkowej,
mamy dostęp do wszystkich pozostałych. Nie było to tak do końca możliwe w przypadku
list jednokierunkowych.
Ponieważ lista w ogólnym przypadku zaczyna się jakimś elementem i jakimś elementem
się kończy (z wyjątkiem przypadków cyklicznych), początek listy przyjęto nazywać
głową listy (head), zaś koniec - ogonem (tail). Dowolny element jest węzłem lub nodem
(node).
Lista dwukierunkowa to tak jakby dwie listy jednokierunkowe zrealizowane wewnątrz
jednej struktury. Charakterystyczne jest to, że wewnątrz jednego elementu wskaźniki
pokazują na element poprzedni oraz na element następny. Jeżeli określimy inaczej, na co
mają one wskazywać, np. oba na elementy następne, otrzymamy zamiast listy inną
strukturę - drzewo. Listy mogą być zależnie od sposobu obsługi traktowane inaczej.
Znane są np. kolejki, czyli lista jednokierunkowa obsługiwana tak specyficznie, że
pierwszy wstawiony element jest także pierwszym dającym się z listy odczytać (tzw.
kolejka FIFO - First in, first out - dokładne odwzorowanie normalnej kolejki przed
sklepem mięsnym w czasach kryzysu). Stos jest implementacją listy jednokierunkowej,
która pozwana na wstawianie elementu do struktury, a potem na odzyskiwanie ich w
odwrotnej kolejności - czyli pierwszy wstawiony jest ostatnim wyjętym, dokładnie tak,
jakby ktoś rzucał dane na kupę, lub stos. Jest to również wersja kolejki LIFO (last in first out - ostatni wchodzi, pierwszy wychodzi, pasuje to trochę do pewnego rodzaju
studentów).
Drzewa
Drzewa to równie ciekawe struktury danych jak listy, ponieważ obok innych
specyficznych cech, oferują niezwykle mały czas dostępu do swoich elementów
(zakładając, że są zoptymalizowane, lub wyważone). Struktura węzła najprostszego
drzewa jest identyczna jak w przypadku listy dwukierunkowej:
struct BTree {
BTree *lewy, *prawy;
/* .... dane ... */
}
Natomiast sens nadany wskaźnikom jest inny, i w efekcie wymusza to zupełnie inną
budowę całej struktury:
Drzewo binarne - reprezentacja struktury BTree
Pierwszy element drzewa to jakby praprzodek wszystkich pozostałych (analogicznie
do drzewa genealogicznego) lub korzeń (analogia do drzew), następnie - w drzewie
istnieją elementy mające swojego przodka i swoich potomków (normalne postacie w
drzewie genealogicznym) - zwane są one gałęziami. Elementy leżące najniżej w
hierarchii pionowej drzewa to liście lub synowie. Zatem widać, że każdy syn ma ojca, z
wyjątkiem praprzodka, który nie ma ojca. Co więcej, ojciec jest przodkiem syna, a syn
jest przodkiem swoich synów. Drzewa rosną w informatyce w dół, korzeń jest na górze :)
Oczywiście zależy to tylko od sposobu narysowania drzewa.
Wysokość drzewa określa się przez ilość jego poziomów. Drzewo na rysunku poniżej
ma 4 poziomy. Stopień drzewa to ilość gałęzi, jaka można wyrastać z korzenia. Drzewo,
które posiada tylko dwie gałęzie wyrastające z dowolnego elementu, nazywa się
drzewem binarnym.
Struktura drzewa binarnego o głębokości 4
Całkowita ilość elementów, jaka będzie w drzewie o podanym stopniu i wysokości
daje się łatwo policzyć, jako prosta zależność wykładnicza. Drzewa wyższych stopni
mają oczywiście więcej gałęzi.
Poza drzewami różniącymi się stopniem istnieją jeszcze inne ich odmiany. Drzewa
zlewowe to takie, w których odwrócono kierunek wskaźników (mają wiele korzeni, a
tylko jeden liść lub niewiele liści). Odwrotnym rodzajem są drzewa wylewowe
(rozpływowe). Drzewa balansowane (AVL - średniej długości, ważone) to przykłady
drzew, które "dbają" o swoją minimalną głębokość. Jeszcze innym rodzajem drzew są
sterty, przydatne bardzo w pewnych implementacjach sortowania. Istnieją także drzewa
czarno-czerwone (bez skojarzeń) oraz zupełnie oryginalne drzewa mutanty, jak np.
drzewa zwrotne, drzewa łączone z listami, itp.
Grafy
Można zbudować strukturę, która będzie miała zamiast określonej ilości wskaźników
(jak lista lub drzewo), po prostu listę wskaźników do następnych elementów. Można
także założyć, że nie wszystkie z tych wskaźników będą musiały na coś konkretnego
pokazywać. Otrzymamy wtedy najbardziej skomplikowaną strukturę z możliwych, w
informatyce nazywaną grafem. Ogólnie rzecz biorąc, graf definiuje się jako zbiór
elementów oraz zbiór połączeń pomiędzy nimi. Dlatego struktura, dla której możliwa jest
implementacja grafu, zawiera po prostu wskaźniki do dwóch list - na których
przechowywane są połączenia pomiędzy elementami oraz odpowiednio same elementy.
Grafy mogą posiadać rozmaite "kształty". Szczególnymi przypadkami grafów są np. listy
i drzewa. Zależnie od właściwości grafów, określa się je jako zorientowane,
niezorientowane, acykliczne, cykliczne, itp. Połączenia pomiędzy elementami nazywa się
krawędziami. Krawędź jest ukierunkowana, jeżeli możliwy jest ruch po niej tylko w
jedną stronę, natomiast gdy możliwy jest w obie strony - nie ma określonego kierunku
lub łączność jest bezkierunkowa. Zagadnieniami związanymi z tymi przedziwnymi i
skomplikowanymi strukturami danych zajmuje się odrębna dziedzina wiedzy
matematycznej - teoria grafów, pełna straszliwej i pięknej matematyki, niedostępna jak
dzika kobieta w czarnej skórze nabijanej ostrymi ćwiekami i wymagająca zaangażowania
intelektualnego graniczącego z przepaleniem niektórych obwodów :)
DYNAMICZNE STRUKTURY DANYCH
Dynamiczne struktury danych to proste i złożone struktury danych, którym
pamięć jest przydzielana i zwalniana w trakcie wykonywania programu.
STOS
wstawianie, usuwanie i dostęp do składników są możliwe tylko
w jednym końcu zwanym wierzchołkiem stosu
KOLEJKA dołączanie składników jest możliwe tylko w jednym końcu, a
usuwanie tylko w drugim końcu
LISTA
dla każdego składnika (poza pierwszym i ostatnim) jest
określony jeden składnik poprzedni i jeden składnik następny
lub tylko składnik poprzedni lub następny, w dowolnym
miejscu można dołączać lub usuwać składnik
DRZEWO dla każdego składnika (poza pierwszym) jest określony jeden
składnik poprzedni i dla każdego składnika (poza ostatnim) jest
określonych n (n2) składników następnych
GRAF
struktury definiowane przez dwa zbiory: zbiór wierzchołków i
zbiór krawędzi określający powiązania pomiędzy
poszczególnymi wierzchołkami.
Download