• Nie Znaleziono Wyników

Abstrakcyjne struktury danych

Dane przetwarzane przez programy są przechowywane w zmiennych określonego typu. Do tej pory mieliśmy do czynienia ze zmiennymi zbudowanymi w konkretny sposób, np. tablicami lub strukturami, ale takie spojrzenie nie bardzo uwzględnia specyfikę problemu, który ma być rozwiązywany: jeśli patrzymy na tablicę tylko jak na zbiór jednakowych „komórek” umieszczonych w pamięci obok siebie, to może nam umknąć cel, dla którego te komórki zostały zarezerwowane.

Pojęcie abstrakcyjnej struktury danych ma na celu określenie sposobu

wykorzystania zmiennych, w których te dane są przechowywane i w szczególności określenie dozwolonych operacji, które mogą być na tych danych wykonywane.

Słowo „abstrakcyjna” oznacza tu, że nie jest istotne, jaka jest dokładna

reprezentacja danych. Ważne jest tylko to, co z daną strukturą możemy robić, bo w szczególności tylko to jest istotne dla algorytmu, który tę strukturę

wykorzystuje. Oczywiście w programie trzeba określić wszystkie szczegóły — to się nazywa implementacją struktury.

Najczęściej używanymi abstrakcyjnymi strukturami danych są stosy, kolejki, kolejki priorytetowe i rozmaite słowniki.

Stosy

Stos jest swego rodzaju magazynem danych jednakowego rodzaju; dane (np.

struktury określonego typu) można na niego wstawiać i zdejmować. Cechą charakterystyczną stosu jest to, że jeśli wstawimy pewne dane na stos, to możemy je pozdejmować w kolejności odwrotnej do wstawiania (postępujemy zupełnie tak samo, jak ze stosem kartek, którego jednak nie wolno nam odwrócić i niczego nie możemy wyciągnąć ze środka). Stos jest określany skrótowcem LIFO, od angielskiego last in, first out.

Dla stosu mamy zatem cztery podstawowe operacje: inicjalizację,

wstawienie elementu, zdjęcie elementu oraz badanie, czy stos jest pusty. Każdy magazyn, a więc także stos, ma ograniczoną pojemność, w związku z czym próba wstawienia elementu, który się nie mieści, zakończy się zerwaniem obliczeń — może się przydać dodatkowa procedura badająca, czy stos jest pełny, ale zwykle rozpatrując algorytmy w pierwszym podejściu nie zwracamy na to uwagi (bo są ważniejsze rzeczy). Oczywiście, pisząc program musimy to uwzględnić. Zerwanie obliczeń musi być również skutkiem próby zdjęcia elementu z pustego stosu (i na to zwracamy uwagę od początku).

6.2

Powiedzmy zatem, że mamy definicję typu danych, które będą przechowywane w stosie:

typedef struct { ... } element;

Operacje na stosie takich elementów mogą być wykonywane przez podprogramy o następujących prototypach:

void InitStack ( void );

Po wykonaniu tej procedury stos istnieje i jest pusty.

void Push ( element el );

Procedura Push umieszcza na stosie kopię wartości parametru el.

void Pop ( element *el );

Procedura Pop zdejmuje element ze stosu i przypisuje jego wartość zmiennej wskazywanej przez parametr. Zwróćmy uwagę na różnicę w sposobie przekazywania parametrów procedur Push i Pop.

char StackEmpty ( void );

Wartością funkcji StackEmpty jest 1, jeśli stos jest pusty, albo 0, jeśli na stosie jest przynajmniej jeden element.

Użyjemy stosu, w którym mogą być przechowywane znaki (elementy typu char) do rozwiązania następującego problemu: w tablicy mamy pewien tekst, w którym występują nawiasy kilku rodzajów: ’(’, ’)’, ’[’, ’]’, ’{’, ’}’, ’<’, ’>’.

Nawiasy te występują w parach — w każdej parze jest nawias otwierający i nawias zamykający. Zadanie polega na zbadaniu, czy w danym tekście nawiasy są rozmieszczone w sposób poprawny: po każdym nawiasie otwierającym ma występować odpowiedni nawias zamykający, przy czym nawias zamykający dowolnego rodzaju nie może wystąpić, jeśli ostatni otwarty i niezamknięty nawias jest innego rodzaju.

Przykłady (znaki inne niż nawiasy są nieistotne): (){}[<({})>] — poprawny, ({)}— niepoprawny, <[] — niepoprawny, }(){ — niepoprawny.

6.3

Podprogram sprawdzający poprawność napisu o długości n, umieszczonego w tablicy t, może być taki:

char NawiasyPoprawne ( char t[], int n ) {

int i; char c;

InitStack ();

for ( i = 0; i < n; i++ ) /* tablicę t indeksujemy od 0 do n-1 */

switch ( t[i] ) {

case ’(’: Push ( ’)’ ); break;

case ’[’: Push ( ’]’ ); break;

case ’<’: Push ( ’>’ ); break;

case ’{’: Push ( ’}’ ); break;

case ’)’: case ’]’: case ’>’: case ’}’:

if ( StackEmpty () ) return 0;

Pop ( &c );

if ( c != t[i] ) return 0;

break;

default:

break;

}

return StackEmpty ();

} /*NawiasyPoprawne*/

Zauważmy, że są trzy możliwe rodzaje błędów rozmieszczenia nawiasów w napisie (podane przykłady błędnych napisów ilustrują wszystkie te rodzaje). Każdy z błędów jest wykrywany w innym miejscu algorytmu. Powrót z procedury z wartością 0 następuje po wykryciu błędu polegającego na osiągnięciu końca napisu z niezamkniętymi nawiasami, a także w razie odkrycia nawiasu zamykającego, który nie pasuje do nawiasu otwierającego.

„Prawdziwa” procedura będzie miała jeszcze jeden element. Jeśli utworzenie stosu powoduje trwałe efekty uboczne (np. rezerwację pamięci — nie było jeszcze o tym mowy, ale będzie), to ostatnią rzeczą, jaką musi wykonać podprogram jest

„posprzątanie po sobie”. Dla stosu może istnieć dodatkowa procedura, której wywołanie powoduje jego likwidację. Jeśli stos jest zaimplementowany w statycznej tablicy (jak poniżej), to niczego nie trzeba sprzątać.

6.4

#define MAXSTACK 1000 element tabst[MAXSTACK];

int sp;

void InitStack ( void ) {

sp = -1;

} /*InitStack*/

void Push ( element el ) {

if ( sp < MAXSTACK-1 ) tabst[++sp] = el;

else Error ();

} /*Push*/

void Pop ( element *el ) {

if ( sp >= 0 )

*el = tabst[sp--];

else Error ();

} /*Pop*/

char StackEmpty ( void ) {

return sp == -1;

} /*StackEmpty*/

Operatory zwiększania o 1 i zmniejszania o 1 są tu użyte w sposób, którego konsekwentne stosowanie daje bardzo krótkie i kompletnie niezrozumiałe programy w C. Wyrażenie tabst[++sp] oznacza, że wartość zmiennej sp należy zwiększyć o 1, a następnie użyć do indeksowania tablicy. Wyrażenie tabst[sp--]

oznacza, że wartość zmiennej sp ma zostać użyta jako indeks do tablicy, a nową, mniejszą o 1 wartość zmienna sp ma otrzymać potem.

W powyższej implementacji stosu kolejno wstawiane na stos elementy są umieszczane w tablicy tabst. Zmienna sp jest tzw. wskaźnikiem stosu. Jej wartość jest indeksem wskazującym wierzchołek stosu, czyli ostatnie zajęte miejsce w tablicy tabst. Procedura Error, którą należy oczywiście dopisać do programu, ma za zadanie zerwanie obliczeń w sytuacji błędnej (można jej dorobić parametr

6.5

— napis, który wyjaśnia przyczynę; procedura przed zatrzymaniem programu wyświetliłaby ten napis na ekranie). Do zakończenia działania programu może być użyta procedura exit, jej prototyp znajduje się w pliku nagłówkowym stdlib.h.

Kolejki

Kolejka jest to struktura danych określana skrótem FIFO, od angielskiego first in, first out— to też jest magazyn danych jednakowego rodzaju, ale wyjmuje się z niej element, który został w kolejce umieszczony najdawniej. Operacje na kolejce są realizowane przez następujące podprogramy:

void InitQueue ( void );

Powyższa procedura tworzy pustą kolejkę.

void Enqueue ( element el );

Procedura Enqueue wstawia do kolejki element będący wartością parametru el.

void Dequeue ( element *el );

Procedura Dequeue usuwa z kolejki pierwszy element i przypisuje go zmiennej wskazywanej przez parametr el. Próba usunięcia elementu z pustej kolejki kończy się fatalnie.

char QueueEmpty ( void );

Wartość funkcji QueueEmpty informuje o tym czy kolejka jest pusta.

Kolejki najczęściej są używane jako bufory, tj. „przechowalnie” danych, które są

„produkowane” przez pewien algorytm, a następnie dalej przetwarzane przez inny, przy czym kolejność przetwarzania ma być identyczna jak kolejność

„produkowania”. Często opłaca się „hurtowe” przetwarzanie danych wtedy, gdy nazbiera się ich dostatecznie dużo, bo np. daje się to zrobić sprawniej niż przetwarzanie danych pojedynczo. Inne ważne zastosowania kolejek występują w przeszukiwaniu grafów. O tym będzie mowa później.

Implementacja kolejki w statycznej tablicy może być następująca:

6.6

#define MAXQUEUE 1000

element tabqu [ MAXQUEUE+1 ];

int fr, en;

void InitQueue ( void ) {

fr = en = 0;

} /*InitQueue*/

void Enqueue ( element el ) {

tabqu[en++] = el;

if ( en > MAXQUEUE ) en = 0;

if ( en == fr ) Error ();

} /*Enqueue*/

void Dequeue ( element *el ) {

if ( fr != en ) {

*el = tabqu[fr++];

if ( fr > MAXQUEUE ) fr = 0;

}

else Error ();

} /*Dequeue*/

char QueueEmpty ( void ) {

return fr == en;

} /*QueueEmpty*/

Elementy wstawione do kolejki są przechowywane w tablicy tabqu. Zmienna en wskazuje na koniec kolejki, tj. jest indeksem miejsca, w którym zostanie

zapamiętany następny wstawiony element. Zmienna fr wskazuje początek kolejki, tj. pierwszy element, który zostanie z niej wyjęty. Jeśli obie zmienne mają tę samą wartość, to kolejka jest pusta, tj. wszystkie wstawione elementy zostały z niej wyjęte. Zauważmy, że ten sposób reprezentowania informacji o tym, czy kolejka jest pusta, wymaga, aby tablica miała o jedno miejsce więcej niż pojemność kolejki (bo w przeciwnym razie sytuacja, gdy w kolejce mamy MAXQUEUEelementów byłaby nieodróżnialna od sytuacji, gdy kolejka jest pusta).

Tablica używana w implementacji kolejki działa jako bufor cykliczny. Kolejne

6.7

elementy są zapamiętywane na kolejnych miejscach. Po dojściu do końca tablicy wskaźniki początku i końca kolejki (zmienne fr i en) otrzymują wartość 0. W ten sposób w tablicy mamy dwie części — zajętą i wolną, przy czym w danej chwili jedna z tych części może zajmować początek i koniec tablicy.

Zauważmy, że tak stos, jak i kolejka, mogą być zrealizowane tak, że każda operacja jest wykonywana przy użyciu co najwyżej stałej liczby operacji (czyli „w czasie stałym”). Kolejki priorytetowe, opisane niżej, nie mogą być zrealizowane w ten sposób. Liczba czynności wykonywanych podczas wstawiania lub wyjmowania elementu z takiej kolejki zależy od liczby n pozostałych elementów w niej przechowywanych. Jest to istotne dla złożoności algorytmów korzystających z abstrakcyjnych struktur danych. Operację wstawienia lub usunięcia elementu można liczyć jako jedną operację tylko wtedy, gdy jej koszt jest niezależny od rozmiaru zadania (w szczególności od liczby elementów obecnych w danej chwili w stosie, kolejce lub kolejce priorytetowej).