• Nie Znaleziono Wyników

Kolejka priorytetowa różni się od zwykłej kolejki lub stosu tym, że o kolejności wyjmowania z niej elementów nie decyduje kolejność ich wstawiania, ale tzw.

priorytety przechowywanych w niej elementów. Zakładamy, że każdy element ma określony priorytet, który można porównać z priorytetem dowolnego innego elementu. Z kolejki priorytetowej wyjmujemy element o najwyższym priorytecie spośród elementów aktualnie w niej obecnych. Jeśli na przykład elementy są liczbami, to możemy chcieć wydostać z kolejki największą z nich. Jeśli różne elementy mają ten sam priorytet, to zadowala nas dowolny z nich.

Przyjrzymy się trzem różnym implementacjom kolejki priorytetowej. Pierwsza z nich jest tablicą nieuporządkowaną. Wstawienie do kolejki polega na

zapamiętaniu elementu bezpośrednio za ostatnim elementem obecnym w kolejce, natomiast wyjęcie elementu wymaga wyszukania elementu o największym priorytecie. Liczba potrzebnych do tego operacji jest proporcjonalna do liczby elementów w kolejce (bo wszystkie trzeba zbadać).

#define NMAX 1000

typedef struct { ... } element;

typedef struct { int first;

element tab[NMAX];

} PrioQueue;

6.8

void InitPrioQueue ( PrioQueue *q ) {

q->first = 0;

} /*InitPrioQueue*/

void InsertPrioQueue ( PrioQueue *q, element el ) {

if ( q->first < NMAX ) q->tab[q->first++] = el;

else Error (); /* kolejka pełna */

} /*InsertPrioQueue*/

void RemoveMaxPrioQueue ( PrioQueue *q, element *el ) {

int i, j;

if ( q->first > 0 ) {

for ( j = 0, i = 1; i < q->first; i++ ) if ( WiekszyPrio ( q->tab[i], q->tab[j] ) )

j = i;

*el = q->tab[j];

q->tab[j] = q->tab[--q->first];

}

else Error (); /* kolejka pusta */

} /*RemoveMaxPrioQueue*/

W powyższej implementacji do porównywania priorytetów używa się funkcji WiekszyPrio— zwróćmy uwagę, że w ten sposób nie zakładamy niczego o naturze obiektów przechowywanych w kolejce, ani o sposobie określenia priorytetów (wystarczy nam jedynie pewność, że wszystkie elementy mają określone priorytety, które można uporządkować).

Jeśli w kolejce jest n > 0 elementów, to są one przechowywane w tablicy na pozycjach od 0 do n − 1, przy czym liczba n jest wartością pola first struktury opisującej kolejkę (jest to indeks pierwszego wolnego miejsca w tablicy, stąd taka nazwa). Zwracam uwagę na sposób, w jaki wartość pola first jest zmieniana.

Mamy instrukcje

q->tab[q->first++] = el; oraz q->tab[j] = q->tab[--q->first];

6.9

Element zapisujemy w tablicy przed zwiększeniem, a „wyjmujemy” z końca tablicy po zmniejszeniu indeksu („wyjęty” element trafia do „dziury” po elemencie o dotychczas największym priorytecie).

Druga implementacja tworzy tablicę uporządkowaną — kolejne elementy mają coraz większy priorytet. Definicje typów, funkcja WiekszyPrio i procedura InitPrioQueuesą takie same, zatem niżej są tylko procedury wstawiania i wyjmowania elementów:

void InsertPrioQueue ( PrioQueue *q, element el ) {

int i;

if ( q->first < NMAX ) {

for ( i = q->first-1; i >= 0 && WiekszyPrio(q->tab[i], el); i-- ) q->tab[i+1] = q->tab[i];

q->tab[i+1] = el;

q->first ++;

}

else Error (); /* kolejka pełna */

} /*InsertPrioQueue*/

void RemoveMaxPrioQueue ( PrioQueue *q, element *el ) {

if ( q->first > 0 )

*el = q->tab[--q->first];

else Error (); /* kolejka pusta */

} /*RemoveMaxPrioQueue*/

Łatwo jest sprawdzić, że w pierwszej implementacji koszt wstawiania elementu do kolejki jest stały, a koszt wyjmowania elementu jest proporcjonalny do liczby obecnych w niej elementów. W drugiej implementacji jest odwrotnie; aby wstawić element trzeba się napracować (ale niekoniecznie aż tyle, ile wyjmując element z kolejki zaimplementowanej w pierwszy sposób — złożoność optymistyczna jest nawet rzędu O(1)), a wyjąć element można w czasie stałym. Można zatem pytać, czy jest możliwa taka implementacja kolejki priorytetowej, w której obie operacje są wykonalne kosztem stałej liczby operacji.

Odpowiedź na to pytanie jest przecząca (co wkrótce udowodnimy). Można jednak zrealizować kolejkę priorytetową tak, aby koszt wstawiania i wyjmowania

6.10

elementu był proporcjonalny do logarytmu liczby obecnych w niej elementów. Do tego służy struktura danych zwana kopcem (ang. heap), którą zajmiemy się teraz.

Kopiec jest zbudowany tak: na dwóch elementach „ustawiamy” trzeci, którego priorytet jest większy. Mając dwa kopce, każdy z trzema elementami, możemy na nich „ustawić” siódmy element, którego priorytet jest większy od priorytetów elementów na wierzchołkach obu mniejszych kopców. To samo postępowanie możemy powtarzać rekurencyjnie. Otrzymany w ten sposób kopiec o wysokości h zawiera 2h− 1elementów.

Jeśli chcemy ustawić kopiec z innej niż pewna potęga dwójki zmniejszona o 1 liczby elementów, to przyjmiemy, że na każdym poziomie rekurencyjnego określenia niekompletny może być „drugi” kopiec, przy czym brakujące elementy znalazłyby się na samym dole, a pierwszy może być niekompletny tylko wtedy, gdy drugi kopiec jest kompletny i o 1 niższy. Przykład (w którym elementy są liczbami, określającymi ich priorytety) jest na rysunku.

20

PPPPPP

15

◗◗

12

◗◗

10

14

8

9

1 2 7 4 6

Pokazany wyżej kopiec jest szczególnym przypadkiem drzewa binarnego. Drzewo składa się z wierzchołków i krawędzi. Jeden z wierzchołków jest nazywany korzeniem i tradycyjnie na rysunkach jest umieszczany na górze (w naszym przykładzie w korzeniu jest liczba 20). Idąc od korzenia w dół wzdłuż krawędzi możemy dojść do każdego wierzchołka, przy czym taka droga do każdego wierzchołka jest tylko jedna. Jeśli z wierzchołka nie wychodzi żadna krawędź, to jest on nazywany liściem, a w przeciwnym razie wierzchołkiem wewnętrznym.

Wierzchołek wewnętrzny ma dwa poddrzewa, do korzeni których prowadzą wychodzące z niego krawędzie (ale jedno z poddrzew, w naszym przypadku zawsze prawe, może być puste). Opisywane tu drzewo jest pełne, tj. nie można zbudować drzewa binarnego o mniejszej wysokości i o tej samej liczbie wierzchołków.

Opisana struktura kopca jest łatwa do reprezentowania w tablicy indeksowanej od 0. Korzeń przechowujemy na miejscu pierwszym. Korzenie jego poddrzew na

6.11

miejscach 1 i 2. Ogólnie, jeśli na miejscu k-tym przechowujemy pewien wierzchołek wewnętrzny, to korzenie jego poddrzew są na miejscach 2k + 1 i 2k + 2. W ten sposób wszystkie liście mamy na końcu tablicy. Zawartość tablicy z kopcem pokazanym wyżej jest taka:

20 15 12 10 14 8 9 1 2 7 4 6

Okazuje się, że można do kopca z n elementami wstawić nowy element w czasie O(log n), a także usunąć w tym czasie element o największym priorytecie. Co więcej, okazuje się, że mając n elementów w tablicy (nieuporządkowanej) można skonstruować z nich kopiec w czasie O(n), a zatem szybciej niż w przypadku wstawiania do kopca każdego elementu osobno.

6.12

Zadania i problemy

1. Napisz zestaw procedur, które implementują dwa stosy w jednej tablicy.

Przepełnienie jednego lub drugiego stosu może się zdarzyć tylko wtedy, gdy suma liczb elementów, które mają być wstawione na oba stosy jest za duża.

2. Napisz zestaw procedur, które implementują kolejkę w tablicy. Pojemność kolejki ma być równa długości tablicy (a nie o jeden mniejsza, jak w implementacji podanej na wykładzie).

3. Napisz procedurę, która na szachownicy o wymiarach n × n umieszcza nhetmanów tak, aby żaden nie bił innego.

4. Dany jest ciąg liczb c1, . . . , cn. Zadanie polega na znalezieniu ciągu dodatnich liczb b1, . . . , bn, takich że dla każdego i jest i − bi≥ 0, dla k = i − bi+ 1, . . . , ijest ck≤ cioraz jeśli i > bi, to ci−bi> ci.

Zadanie to jest łatwe do rozwiązania kosztem O(n2)operacji, za pomocą dwóch zagnieżdżonych pętli for. Aby rozwiązać je kosztem O(n) operacji, można użyć stosu. Będą na nim przechowywane indeksy elementów ciągu (ci), z których składa się najdłuższy podciąg malejący, którego ostatnim elementem jest element właśnie badany.

{

for ( i = 1; i <= n; i++ ) b[i] = 1;

c[0] = ∞; /* strażnik */

InitStack ();

Push ( 0 );

for ( i = 1; i <= n; i++ ) { Pop ( &k );

while ( c[k] <= c[i] ) { b[i] += b[k];

Pop ( &k );

}

Push ( k ); Push ( i );

} }

Zbadaj, jak działa powyższa instrukcja (i w szczególności jak zmienia się zawartość stosu) dla ciągu

20 15 10 12 15 15 14 13 18 22 9 Uzasadnij, dlaczego koszt jest proporcjonalny do n.

6.13

5. Napisz procedurę, która sprawdza poprawność rozmieszczenia nawiasów w tekście (podobnie jak procedura przedstawiona na wykładzie), przy czym dopuszcza się możliwość wystąpienia nawiasów „)”, „]”, „}” i „>”, które nie stanowią pary do odpowiedniego nawiasu otwierającego obecnego w tekście poprzedzającym, ale każdy taki nawias musi zostać „zamknięty” odpowiednio przez „(”, „[”, „{”, „<”

w dalszym tekście (z uwzględnieniem pozostałych reguł poprawności takich jak na wykładzie). Na przykład napis „(>{[]][}<)” ma być uznany za poprawny.

7.1