Moze zainteresuje cie ta ksiazka , jest w niej opisane wiele problemow , miedzy innymi kompresja danych http://i14.tinypic.com/43rmpeb.jpg Stron: 333 Język: Polski Rozmiar: 4,8 MB Format: .pdf Pozdrawiam Atryu Besire
Spis treści
Moje dopiski
K.
Przedmowa...
Rozdział1 Zanim wystartujemy
1.1. Jak to wcześniej bywało, czyli wyjątki z historii maszyn algorytmicznych
1.2. Jak to się niedawno odbyło, czyli o tym kto " wymyślił " metodologię programowania
1.3. Proces koncepcji programów
1.4. Poziomy abstrakcji opisu i wybór języka
1.5. Poprawność algorytmów
Rozdział 2 Rekurencja
9
17
19
21
22
23
25
29
2.1. Definicja rekurencji
29
2.2. Ilustracja pojęcia rekurencji
31
2.3. Jak wykonują się programy rekurencyjne?
33
2.4. Niebezpieczeństwa rekurencji
34
2.4.1. Ciąg Fibonacciego
35
2.4.2. Stack overflow!
36
2.5. Pułapek ciąg dalszy
37
2.5.1. Stąd do wieczności
38
2.5.2. Definicja poprawna, ale
38
2.6. Typy programów rekurencyjnych.................................................................................... 40
rekurcncyjnych
2.7. Myślenie rekurencyjne
42
2.7.1. Spirala
42
2.7.2. Kwadraty „parzyste "
44
2.8. Uwagi praktyczne na temat technik rekurencyjnych
45
2.9. Zadania
47
2.10. Rozwiązania i wskazówki do zadań
49
Rozdział 3 Analiza sprawności algorytmów
3.1. Dobre samopoczucie użytkownika programu
53
54
6
3.2. Przykład 1: Jeszcze raz funkcja silnia
3.3. Przykład 2; Zerowanie fragmentu tablicy
3.4. Przykład 3: Wpadamy w pułapkę
3.5. Przykład 4: Różne typy złożoności obliczeniowej...
3.6. Nowe zadanie: uprościć obliczenia!
3.7. Analiza programów rekurencyjnych
3.7.1. Terminologia
3.7.2. Ilustracja metody na przykładzie
3.7.3. Rozkład „logarytmiczny "
3.7.3
3.7.4. Zamiana dziedziny równania rekurencyjnego
3.7.5. Funkcja Ackermanna, czyli coś dla smakoszy
3.8. Zadania
3.9. Rozwiązania i wskazówki do zadań
Rozdział 4 Algorytmy sortowania
2
4.1. Sortowanie przez wstawianie, algorytm klasy 0(N )
2
4.2. Sortowanie bąbelkowe, algorytm klasy O(N2)
4.3. Quicksort, algorytm klasy 0(N log^N)
2
4.4. Uwagi praktyczne
...........................................57
...........................................61
...........................................64
.........................................65
.........................................68
.........................................68
.........................................69
.........................................71
.........................................72
.........................................72
.........................................74
.........................................75
.........................................76
.........................................78
.........................................81
.........................................82
.........................................84
.........................................87
.........................................90
Rozdział5 Struktury danych
...........................................93
5.1. Listy jednokierunkowe
...........................................94
5.1.1. Realizacja struktur danych listy jednokierunkowej ...........................................96
...........................................98
5.1.2. Tworzenie listy jednokierunkowej
5.1.3. Listy jednokierunkowe- teoria i rzeczywistość.... .........................................108
.........................................122
5.2. Tablicowa implementacja list
.........................................122
5.2.1. Klasyczna reprezentacja tablicowa
.........................................124
5.2.2. Metoda tablic równoległych
.........................................127
5.2.3. Listy innych typów
.........................................128
5.3. Stos
5.3.1. Zasada działania stosu
.........................................128
5.4. Kolejki FIFO
.........................................133
5.5. Sterty i kolejki priorytetowe
.........................................136
5.6. Drzewa i ich reprezentacje
.........................................143
5.6.1. Drzewa binarne i wyrażenia arytmetyczne
.........................................147
5.7. Uniwersalna struktura słownikowa
.........................................152
5.8. Zbiory
.........................................159
5.9. Zadania
.........................................161
5.10. Rozwiązania zadań
.........................................162
Rozdział 6 Derekursywacja
6.1. Jak pracuje kompilator?
6.2. Odrobina formalizmu... nie zaszkodzi!
6.3. Kilka przykładów derekursywacji algorytmów
6.4. Derekursywacja z wykorzystaniem stosu
6.4.1. Eliminacja zmiennych lokalnych
6.5. Metoda funkcji przeciwnych
6.6. Klasyczne schematy derekursywacji
.........................................165
.........................................166
.........................................169
.........................................170
.........................................174
.........................................175
.........................................177
.........................................180
Spis treści
7
6.6.1. Schemat typu while
181
6.6.2. Schemat typu if... else........................................................................................182
if..
else
6.6.3. Schemat z podwójnym wywołaniem rekurencyjnym
185
6.7. Podsumowanie
187
Rozdział 7 Algorytmy przeszukiwania
7.1. Przeszukiwanie liniowe
7.2. Przeszukiwanie binarne
7.3. Transformacja kluczowa.
7.3.1. W poszukiwaniu funkcji H
7.3.2. Najbardziej znane funkcje H
7.3.3. Obsługa konfliktów dostępu
7.3.4. Zastosowania transformacji kluczowej
7.3.5. Podsumowanie metod transformacji kluczowej
Rozdział 8 Przeszukiwanie tekstów
8.1. Algorytm typu brute-force
8.2. Nowe algorytmy poszukiwań
8.2.1. Algorytm K-M-P
8.2.2. Algorytm Boyera i Moore'a
8.2.3. Algorytm Rabina i Karpa
Rozdział 9 Zaawansowane techniki programowania
189
189
190
191
193
194
197
204
204
207
207
210
211
216
218
223
9.1. Programowanie typu " dziel-i-rządź " ................................................................................ 224
„ d z i e l
i
rządź "
.
9.1.1. Odszukiwanie minimum i maksimum w tablicy liczb
225
y
9.1.2. Mnożenie macierz)' o rozmiarze NxN................................................................ 229
N*N
9.1.3. Mnożenie liczb całkowitych
232
9.1.4. Inne znane algorytmy " dziel-i-rządź " .................................................................. 233
„dziel-i-rządź " ..
9.2. Algorytmy , " żarłoczne " , czyli przekąsić coś nadszedł już czas
234
9.2.1.Problem plecakowy, czyli niełatwe niełatwe turysty-piechura.......................... 235
Problem plecakowy, czyli jest życie jest życie turysty-piechura
9.3. Programowanie dynamiczne
238
9.4. Uwagi bibliograficzne
243
Rozdział 10 Elementy algorytmiki gratów
245
10.1. Definicje i pojęcia podstawowe
246
10.2. Sposoby reprezentacji grafów
248
10.3. Podstawowe operacje na grafach
249
10.4. Algorytm Roy-Warshalla
251
10.5. Algorytm Floyda
254
10.6. Przeszukiwanie grafów
257
10.6.1. Strategia „w głąb "
257
10.6.2. Strategia „wszerz "
259
10.7. Problem właściwego doboru........................................................................................ 261
doboru
10.8. Podsumowanie
266
Rozdziału
Algorytmy numeryczne
11.1. Poszukiwanie miejsc zerowych funkcji
11.2. Iteracyjne obliczanie wartości funkcji
11.3. Interpolacja funkcji metodą Lagrange'a
11.4. Różniczkowanie funkcji
267
268
269
270
272
8
B
Spis treści
11.5. Całkowanie funkcji metodą Simpsona
11.6. Rozwiązywanie układów równań liniowych metodą Gaussa
11.7. Uwagi końcowe
Rozdział 12 W stronę sztucznej inteligencji
281
12.1. Reprezentacja problemów
12.2. Gry dwuosobowe i drzewa gier
282
283
12.3. Algorytm mini-max
286
Rozdział13 Kodowanie i kompresja danych.....
293
13.1. Kodowanie danych i arytmetyka dużych liczb
13.2. Kompresja danych metodą Huffmana
294
302
Rozdział 14 Zadania różne
14.1. Teksty zadań
14.2. Rozwiązania
274
276
279
309
.
309
312
Dodatek A Poznaj C++ w pięć minut
317
Literatura
337
Spis ilustracji
Spis tablic
339
343
Skorowidz
345
Przedmowa
Algorytmika stanowi gałąź wiedzy, która w ciągu ostatnich kilkudziesięciu lat do
starczyła wielu efektywnych narzędzi wspomagających rozwiązywanie różnorod
nych problemów przy pomocy komputera. Książka ta prezentuje w miarę szeroki.
ale i zarazem pogłębiony wachlarz tematów z tej dziedziny. Ma ona również
ukazać Czytelnikowi odpowiednią perspektywę możliwych zastosowań
komputerów i pozwolić mu - j e ś l i można użyć takiej metafory' - nie wyważać
drzwi tam, gdzie ktoś dawno już je otworzył.
Dla kogo jest ta książka?
Niniejszy podręcznik szczególnie polecam osobom zainteresowanym programowaniem. a nie mającym do tego solidnych podstaw teoretycznych. Ponieważ
grupuje on dość obszerną klasę zagadnień z dziedziny informatyki, będzie również
użyteczny jako repetytorium dla tych, którzy zajmują się programowaniem zawodowo. Jest to książka dla osób, które zetknęły się już z programowaniem
i rozumieją podstawowe pojęcia, takie jak zmienna, program, algorytm, kompilacja... - tego typu terminy będą bowiem stanowiły podstawę języka używanego w tej książce.
Co odróżnia tę książkę od innych podręczników?
Przede wszystkim - nie jest to publikacja skierowana jedynie dla informatyków.
Liczba osób wykorzystujących komputer do czegoś więcej niż do gier i pisania
listów jest wbrew pozorom dość duża. Zaliczyć do tego grona można niewątpliwie
studentów kierunków informatycznych, ale nie tylko; w programach większości
10
Przedmowa
studiów technicznych znajdują się elementy informatyki, mające na celu przygoto
wanie do sprawnego rozwiązywania problemów przy pomocy komputera. Nie
wolno pomijać także stale rosnącej grupy ludzi zajmujących się programowaniem
traktowanym jako hobby. Uwzględniając tak dużą różnorodność potencjalnych
odbiorców tej publikacji duży nacisk został położony na prostotę i klarowność
wykładu oraz unikanie niedomówień - oczywiście w takim stopniu, w jakim to
było możliwe ze względu na ograniczoną objętość i przyjęty układ książki.
Dlaczego C++?
Niewątpliwe kilka słów wyjaśnienia należy poświęcić problemowi języka pro
gramowania, w którym są prezentowane algorytmy w książce. Wybór padł na no
woczesny i modny język C++ którego precyzja zapisu i modulamość przemawiają
za użyciem go do programowania nowoczesnych aplikacji. Warto jednak przy oka
zji podkreślić, że sam język prezentacji algorytmu nie ma istotnego znaczenia dla
jego działania -jest to tylko narzędzie i stanowi wyłącznie zewnętrzną powłokę,
która ulega zmianom w zależności od aktualnie panujących mód. Ponieważ C++
zdobywa sobie olbrzymia popularność, został wybrany dla potrzeb tej książki. Dla
kogoś, kto niedawno poznał C++, może to być doskonała okazją do prze
studiowania potencjalnych zastosowań tego języka. Dla programujących do
tychczas tylko w Pascalu został przygotowany mini-kurs języka C++, który
powinien umożliwić im błyskawiczne opanowanie podstawowych różnic między
C++ i Pascalem.
Oczywiście niemożliwe jest szczegółowe nauczenie tak obszernego pojęciowo
języka, jakim jest C++, dysponując objętością zaledwie krótkiego dodatku - bo
tyle zostało przeznaczone na ten cel. Zamiarem było jedynie przełamanie bariery
składniowej, tak aby były zrozumiałe prezentowane listingi. Czytelnik pragnący
poszerzyć zasady programowania w C++ może sięgnąć na przykład po [PohI89],
| WF92] lub [Wró94] gdzie zagadnienia te zostały poruszone szczegółowo.
Ambitnym i odważnym programistom można polecić dokładne przestudiowanie
[STR92] - dzieła napisanego przez samego twórcę języka i stanowiącego osta
teczną referencję na temat C++.
Jak należy czytać tę książkę?
Czytelnik, który zetknął się wcześniej z tematyką podejmowaną w tej książce,
może ją czytać w dość dowolnej kolejności.
Przedmowa
11
Początkującym zalecane jest trzymanie się porządku narzuconego przez układ
rozdziałów. Książka zawiera szczegółowy skorowidz i spis ilustracji - powinny
one ułatwić odszukiwanie potrzebnych informacji.
Wiele rozdziałów zawiera przy końcu zestaw zadań związanych tematycznie
z aktualnie opisywanymi zagadnieniami. W dużej części zadania te są rozwiązane.
ewentualnie podane są szczegółowe wskazówki do nich.
Oprócz zadań tematycznych ostatni rozdział zawiera zestaw różnorodnych zadań.
które nie zmieściły się w toku wykładu. Przed rozwiązaniem zadań w nim zamiesz
czonych zaleca się dokładne przestudiowanie całego materiału, który obejmują po
przedzające go rozdziały.
Ostrożność nie zawadzi...
Niemożliwe jest zaprezentowanie wszystkiego, co najważniejsze w dziedzinie
algorytmiki, w objętości jednej książki. Jest to niewykonalne z uwagi na roz
piętość dziedziny, z jaką mamy do czynienia. Może się więc okazać, że to, co
zostało pomyślane jako logicznie skonstruowana całość, jednych rozczaruje.
innych zaś przytłoczy ogromem poruszanych zagadnień. Pragnieniem autora
było stworzenie w miarę reprezentacyjnego przeglądu zagadnień algorytmicz
nych przydatnego dla tych Czytelników, którzy programowanie mają zamiar
potraktować w sposób profesjonalny.
Co zostało opisane w tej książce?
Opis poniższy jest w pewnym sensie powtórzeniem spisu treści, jednak zawiera on
coś, czego żaden spis treści nie potrafi zaoferować - minimalny komentarz
dotyczący zawartości.
Rozdział 1 Zanim wystartujemy
Rozbudowany wstęp pozwalający wziąć „głęboki oddech " przed przystąpieniem
do klawiatury...
Rozdział 2 Rekurencja
Rozdział ten jest poświęcony jednemu z najważniejszych mechanizmów używa
nych w procesie programowania - rekurencji. Uświadamia zarówno oczywiste
zalety, j a k i nie zawsze widoczne wady tej techniki programowania.
12
Przedmowa
Rozdział 3 Analiza sprawności algorytmów
Przegląd najpopularniejszych i najprostszych metod służących do obliczania spraw
ności obliczeniowej algorytmów i porównywania ich ze sobą w celu wybrania
„najefektywniejszego " .
Rozdział 4 Algorytmy sortowania
Prezentuje najpopularniejsze i najbardziej znane procedury sortujące.
Rozdział 5 Struktury danych
Omawia popularne struktury danych (listy, kolejki, drzewa binarne etc.) i ich
implementację programową. Szczególną uwagę poświęcono ukazaniu możli
wych zastosowań nowo poznanych struktur danych.
Rozdział 6 Derekursywacja i optymalizacja algorytmów
Prezentuje sposoby przekształcania programów rekurencyjnych na ich wersje
iteracyjne. Rozdział ten ma charakter bardzo „techniczny " i jest przeznaczony
dla programistów zainteresowanych problematyką optymalizacji programów.
Rozdział 7 Algorytmy przeszukiwania
•
Rozdział ten stosuje kilka poznanych już wcześniej metod do zagadnienia wy
szukiwania elementów w słowniku, a następnie szczegółowo omawia metodę
transformacji kluczowej (ang. hashing).
Rozdział 8 Przeszukiwanie tekstów
Ze względu na wagę tematu algorytmy przeszukiwania tekstów zostały zgrupowane
w osobnym rozdziale. Szczegółowo omówiono metody brute-force, K-M-P,
Boyera i Moore'a, Rabina i Karpa.
Rozdział 9 Zaawansowane techniki programowania
Wieloletnie poszukiwania w dziedzinie algorytmiki zaowocowały wynalezie
niem pewnej grupy metod o charakterze generalnym: programowanie dyna
miczne. dziel-i-rządź, algorytmy żarłoczne (ang. greedy). Te meta-algorytmy
rozszerzają znacznie zakres możliwych zastosowań komputerów do rozwiązy
wania problemów.
Przedmowa
13
Rozdział 10 Elementy algorytmiki grafów
Opis jednej z najciekawszych struktur danych występujących w informatyce. Grafy
ułatwiają (a czasami po prostu umożliwiają) rozwiązanie wielu problemów, które
traktowane przy pomocy innych struktur danych wydają się nie do rozwiązania.
Rozdział 11 Algorytmy numeryczne
Kilka ciekawych problemów natury obliczeniowej, ukazujących zastosowanie
komputerów w matematyce, do wykonywania obliczeń przybliżonych.
Rozdział 12 Czy komputery mogą myśleć?
Wstęp do bardzo rozległej dziedziny tzw. sztucznej inteligencji. Przykład im
plementacji programowej popularnego w teorii gier algorytmu Mini-Max.
Rozdział 13 Kodowanie i kompresja danych
Omówienie popularnych metod kodowania i kompresji danych: systemu krypto
graficznego z kluczem publicznym 1 metody Huffmanna Rozdział zawiera ponadto
dokładne omówienie sposobu wykonywania operacji arytmetycznych na bardzo
dużych liczbach całkowitych.
Rozdział 14 Zadania różne
Zestaw różnorodnych zadań, które nie zmieściły się w głównej treści książki.
Wersje programów na dyskietce
Programy znajdujące się na dołączonej do książki dyskietce są zazwyczaj pełniejsze
i bardziej rozbudowane. Jeśli w trakcie wykładu jest prezentowana jakaś funkcja
bez podania explicite sposobu jej użycia, to na pewno dyskietkowa wersja za
wiera reprezentacyjny przykład j e j zastosowania (przykładowa funkcja
main i komplet funkcji nagłówkowych). Warto zatem podczas lektury porów
nywać wersje dyskietkowe z tymi, które zostały omówione na kartach książki!
Pliki na dyskietce są w formacie MS-DOS. Programy zostały przetestowane zarów
no systemie DOS (kompilator Borland C++), jak i w systemie UNIX
U
(kompilator GNIJ C++).
Na dyskietce znajdują się zatem pełne wersje programów, które z założenia
powinny dać się od razu uruchomić na dowolnym kompilatorze C++ (UNIX lub
14
Przedmowa
DOS/Windows). Jedyny wyjątek stanowią programy „graficzne " napisane dla
popularnej serii kompilatorów firmy Borland; wszelkie inicjacje trybów graficznych
itp. są tam wykonane według standardu tej firmy.
W tekście znajduje się jednak tabelka wyjaśniająca działanie użytych instrukcji gra
ficznych. tak więc nawet osoby, które nigdy nie pracowały z kompilatorami
Borlanda, poradzą sobie bez problemu z analizą programów przykładowych.
Konwencje typograficzne i oznaczenia
Poniżej znajduje się kilka typowych oznaczeń i konwencji, które można napotkać
na kartkach książki.
W szczególności regułą jest, że wszystkie listingi i teksty ukazujące się na
ekranie zostały odróżnione od zasadniczej treści książki czcionką C o u r i e r :
prog.cpp
Tekst programu znajduje s i ę na d y s k i e t c e w p l i k u prog.cpp
(w tej wersji nie bardzo) K.
Inna konwencja dotyczy odnośników bibliograficznych:
Patrz [Odn93] - odnośnik do pozycji bibliograficznej [Odn93] ze spisu na końcu
książki.
Uwagi na marginesie
Książka ta powstała w trakcie mojego kilkuletniego pobytu we Francji, gdzie
miałem niepowtarzalną okazję korzystania z interesujących zasobów bibliogra
ficznych kilku bibliotek technicznych. Większość tytułów, których lektura zain
spirowała mnie do napisania tej książki, jest ciągle w Polsce dość trudno (jeśli
w ogóle) dostępna i będzie dla mnie dużą radością, jeśli znajdą się osoby, którym
niniejszy podręcznik oszczędzi w jakiś sposób czasu i pieniędzy.
Wstępny wydruk (jeszcze w „pieluchach " ) książki został przejrzany i opatrzony
wieloma cennymi uwagami przez Zbyszka Chamskiego. Ostateczna wersja
książki została poprawiona pod względem poprawności językowej przez moją
siostrę, Ilonę. Chciałbym gorąco podziękować im obojgu za wykonaną pracę,
licząc jednocześnie, że efekt końcowy ich zbytnio nie zawiódł...
P.W.
Lannion
Wrzesień 1995
Przedmowa
15
Uwagi do wydania 2
W bieżącej edycji książki, wraz z całym tekstem zostały gruntownie przejrzane
i poprawione programy przykładowe, jak również, rysunki znajdujące sic w tekście.
które w pierwszym wydaniu zawierały kilka niekonsekwencji. Została zwiększona
czytelność listingów (wyróżnienie słów kluczowych), oraz dołożono trzy nowe
rozdziały (77 - 13) Uzupełnieniu uległy ponadto rozdziały: 10 (gdzie omówione
zostało dodatkowo m.in. przeszukiwanie grafów) i 5 (omówiono implementację
zbiorów). Licząc, że wniesione poprawki odbiją się pozytywnie na jakości
publikacji, życzę przyjemnej i pożytecznej lektury.
P.W.
Czerwiec 1997
Rozdział 1
Zanim wystartujemy
Zanim na dobre rozpoczniemy operowanie takimi pojęciami jak wspomniany we
wstępie „algorytm " , warto przedyskutować dokładnie, co przez nie rozumiemy.
1
ALGORYTM:
• skończony ciąg/sekwencja reguł, które aplikuje się na skończonej liczbie
danych, pozwalający rozwiązywać zbliżone do siebie klasy problemów,
• zespół reguł charakterystycznych dla pewnych obliczeń lub czynno
ści informatycznych
Cóż. definicje powyższe wydają się klarowne i jasne, jednak obejmują na tyle
rozległe obszary działalności ludzkiej, że daleko im do precyzji. Pomijając
chwilowo znaczenie, samo pochodzenie terminu algorytm nie zawsze było do
końca jasne. Dopiero specjaliści zajmujący się historią matematyki odnaleźli
najbardziej prawdopodobne źródło słów: termin ten pochodzi od nazwiska per
skiego pisarza-matematyka Abu Ja'far Mohammed ibn Musa al-Khowarizmi
(IX wieku n.e.). Jego zasługą jest dostarczenie klarownych reguł wyjaśniają
cych krok po kroku zasady operacji arytmetycznych wykonywanych na licz
bach dziesiętnych.
Słowo algorytm często jest łączone z imieniem greckiego matematyka Euklidesa
(365-300 p.n.e.) i jego słynnym przepisem na obliczanie największego wspól
nego dzielnika dwóch liczb a i b (NWD):
dane wejściowe: a i b;
1
Definicja pochodzi ze słownika « Le Nouveau Petit Robert » (Dictionnaircs le Robert
- Paris 1994) - (tłumaczenie własne).
Jego nazwisko pisane było po łacinie jako Algorismus.
18
Rozdział. Zanim wystartujemy
dopóki a & gt; 0 wykonuj;
podstaw za c reszto z dzielenia a przez b;
podstaw za b liczbę a;
podstaw za a liczbę c;
podstaw za res liczbę b;
rezultat: res.
Oczywiście Euklides nie proponował swojego algorytmu dokładnie w ten sposób
(w miejsce funkcji i reszty z dzielenia stosowane były sukcesywne odejmowania),
ale jego pomysł można zapisać w powyższy sposób bez szkody dla wyniku,
który w każdym przypadku będzie taki sam. Nie jest to oczywiście jedyny algo
rytm, z którym mieliśmy w swoim życiu do czynienia. Każdy z nas z pewnością
umie zaparzyć kawę:
• włączyć gaz;
•
zagotować niezbędną ilość wody;
•
wsypać zmieloną kawę do szklanki;
•
zalać kawę wrzącą wodą;
•
osłodzić do smaku:
•
poczekać, aż odpowiednio naciągnie...
Powyższy przepis działa, ale zawiera kilka słabych punktów: co to znaczy „odpo
wiednia ilość wody " ? Co dokładnie oznacza stwierdzenie „osłodzić do smaku " ?
Przepis przygotowania kawy ma cechy algorytmu (rozumianego w sensie zacyto
wanych wyżej definicji słownikowych), ale brak mu precyzji niezbędnej do wpi
sania go do jakiejś maszyny, tak aby w każdej sytuacji umiała ona sobie poradzić
z poleceniem „przygotuj mi małą kawę " . (Np. jak w praktyce określić warunek,
że kawa " odpowiednio naciągnęła " ?).
Jakie w związku z tym cechy powinny być przypisane algorytmowi rozumianemu
w kontekście informatycznym? Dyskusję na ten temat można by prowadzić
dość długo, ale przyjmując pewne uproszczenia można zadowolić się następu
jącymi wymogami:
Każdy algorytm:
•
posiada dane wejściowe (w ilości większej lub równej zero) pochodzą
ce z dobrze zdefiniowanego zbioru (np. algorytm Euklidesa operuje na
dwóch liczbach całkowitych);
•
produkuje pewien wynik (niekoniecznie numeryczny);
•
jest precyzyjnie zdefiniowany (każdy krok algorytmu musi być jedno
znacznie określony);
1.1. Jak to wcześniej bywało, czyli...
19
• jest skończony (wynik algorytmu musi zostać „kiedyś " dostarczony mając algorytm A i dane wejściowe D powinno być możliwe precyzyj
ne określenie czasu wykonania 1(A)).
Ponadto niecierpliwość każe nam szukać algorytmów efektywnych, t/n. wyko
nujących swoje zadanie w jak najkrótszym czasie i wykorzystujących jak naj
mniejszą ilość pamięci (do tej tematyki powrócimy jeszcze w rozdziale 3).
Zanim jednak pośpieszymy do klawiatury, aby wpisywać do pamięci komputera
programy spełniające powyższe założenia, popatrzmy na algorytmikę z per
spektywy historycznej.
1.1. Jak to wcześniej bywało, czyli wyjątki
z historii maszyn algorytmicznych
Cytowane na samym początku tego rozdziału imiona matematyków kojarzonych
z algorytmiką rozdzielone są ponad tysiącem lat i mogą łatwo zasugerować, że
ta gałąź wiedzy przeżywała w ciągu wieków istnienia ludzkości burzliwy i błysko
tliwy rozwój. Oczywiście nijak się to ma do rzeczywistego postępu tej dziedziny.
który był i ciągle jest ściśle związany z rewolucją techniczną dokonującą się na
przestrzeni zaledwie ostatnich dwustu lat. Popatrzmy zresztą na kilka charakte
rystycznych dat z tego okresu:
-1801Francuz Joseph Marie Jacquard wynajduje krosno tkackie, w którym wzorzec
tkaniny był " programowany " na swego rodzaju kartach perforowanych. Proces
tkania był kontrolowany przez algorytm zakodowany w postaci sekwencji
otworów wybitych w karcie.
-1833Anglik Charles Babbage częściowo buduje maszynę do wyliczania niektórych
formuł matematycznych. Autor koncepcji tzw. maszyny analitycznej, zbliżonej
do swego poprzedniego dzieła, ale wyposażonej w możliwość przeprogramo¬
wywania, jak w przypadku maszyny Jacquarda.
- 1890Pierwsze w zasadzie publiczne i na dużą skalę użycie maszyny bazującej na kartach
perforowanych. Chodzi o maszynę do opracowywania danych statystycznych.
dzieło Amerykanina Hermana Holleritha użyte przy dokonywaniu spisu ludności.
20
Rozdział 1. Zanim wystartujemy
(Na marginesie warto dodać, że przedsiębiorstwo H o I e r i t h a przekształciło się
w I911 roku w International business Machines Corp., bardziej znane jako IBM).
- lata 30-te Rozwój badań nad teorią algorytmów (plejada znanych matematyków: Turing,
Godel, Markow.
- lata 40-te Budowa pierwszych komputerów ogólnego przeznaczenia (głównie dla potrzeb
obliczeniowych wynikłych w tym " wojennym " okresie: badania nad „łamaniem "
kodów, początek „kariery " bomby atomowej).
Pierwszym urządzeniem, które można określić jako „komputer " był, automatyczny
kalkulator M A R K 1 skonstruowany w 1944 roku (jeszcze na przekaźnikach.
czyli jako urządzenie elektromechaniczne). Jego twórcą był Amerykanin Howard
Aiken z uniwersytetu Harvard. Aiken bazował na idei Babbage'a, która musiała
czekać 100 lat na swoją praktyczną realizację! W dwa lata później powstaje
pierwszy „elektroniczny " komputer ENIAC (Jego wynalazcy: J. P. Eckert i J.
W. Mauchly z uniwersytetu Pensylwania).
Powszechnie jednak za „naprawdę " pierwszy komputer w pełnym tego słowa
znaczeniu uważa się EDVAC zbudowany na uniwersytecie w Princeton. Jego
wyjątkowość polegała na umieszczeniu programu wykonywanego przez kom
puter całkowicie w pamięci komputera. Autorem tej przełomowej idei był ma
tematyk Johannes von Neumann (Amerykanin węgierskiego pochodzenia).
- okres powojenny Prace nad komputerami prowadzone są w wielu krajach równolegle. W grę zaczyna
wchodzić wejście na obiecujący nowo powstały rynek komputerów (kończy się
bowiem era budowania unikalnych „uniwersyteckich " prototypów). Na rynku
pojawiają się kalkulatory I B M 604 i B U L L Gamma3?, a następnie duże kompu
tery naukowe np. UNIVAC 1 i IBM 650. Zaczynającej się zarysowywać domina
cji niektórych producentów usiłują przeciwdziałać badania prowadzone w wielu
krajach (mniej lub bardziej systematycznie i z różnorakim poparciem polityków)
ale... to j u ż jest lemat na osobną książkę!
- TERAZ Burzliwy rozwój elektroniki powoduje masową, do dziś trwającą komputeryzację
wszelkich dziedzin życia. Komputery stają się czymś powszechnym i niezbędnym,
wykonując tak różnorodne zadania, jak tylko każe im to wyobraźnia ludzka.
1.2. Jak to się niedawno odbyło, czyli...
21
1.2. Jak to się niedawno odbyło, czyli o tym kto
„wymyślił " metodologię programowania
Zamieszczony w poprzednim paragrafie „kalendarz " został doprowadzony do
momentu, w którym programiści zaczęli mieć do dyspozycji komputery z praw
dziwego zdarzenia. Olbrzymi nacisk, j a k i był kładziony na rozwój sprzętu.
w istocie doprowadził do znakomitych rezultatów - efekt jest widoczny dzisiaj
w każdym praktycznie biurze i w coraz większej ilości domów prywatnych.
W latach 60-tych zaczęto konstruować pierwsze naprawdę duże systemy infor
matyczne - w sensie ilości kodu, głównie asemblerowego, wyprodukowanego na
poczet danej aplikacji. Ponieważ jednak programowanie było ciągle traktowane
jako działalność polegająca głównie na intuicji i wyczuciu, zdarzały się całkiem
poważne wpadki w konstrukcji oprogramowania: albo były twoi/one szybko
systemy o małej wiarygodności albo też nakład pieniędzy włożonych w rozwój
produktu znacznie przewyższał szacowane wydatki i stawiał pod znakiem zapytania
sens podjętego przedsięwzięcia. Brak było zarówno metod, jak i narzędzi umoż
liwiających sprawdzanie poprawności programowania, powszechną metodą
programowania było testowanie programu aż do momentu jego całkowitego
„odpluskwienia " 1 . Zwróćmy jeszcze uwagę, że oba wspomniane czynniki: wiary
godność systemów i poziom nakładów są niezmiernie ważne w praktyce; infor
matyczny system bankowy musi albo działać stuprocentowo dobrze, albo nie
powinien być w ogóle oddany do użytku! Z drugiej strony poziom nakładów
przeznaczonych na rozwój oprogramowania nic powinien odbić się niekorzystnie
na kondycji finansowej przedsiębiorstwa.
W pewnym momencie sytuacja stała się tak krytyczna, że zaczęto nawet mówić
o kryzysie w rozwoju oprogramowania! W roku 1968 została nawet zwołana kon
ferencja NATO (Garmisch, Niemcy) poświęcona na przedyskutowanie zaistniałej
sytuacji. W rok później została utworzona w ramach IFIP (InternationaI Federation
for Information Processing) specjalna grupa robocza pracująca nad tzw. meto
dologią programowania.
Z historycznego punktu widzenia dyskusja na temat udowadniania poprawności
algorytmów zaczęła się jednak od artykułu Johna McCarthy-ego " A basis for a
mathematical theory of computation " gdzie padło zdanie: „w miejsce sprawdzania
programów komputerowych metodą prób i błędów aż do momentu ich całkowitego
odpluskwienia, powinniśmy udowadniać, że posiadają one pożądane własności " .
Nazwiska ludzi, którzy zajmowali się teoretycznymi pracami na metodologii
Żargonowe określenie procesu usuwania błędów z programu.
22
Rozdział 1. Zanim wystartujemy
programowania nic znikły bynajmniej z horyzontu; Dijkstra, Hoare, Floyd. Wirth...
(Będą oni jeszcze nie raz cytowani w lej książce!).
Krótka prezentacja, której dokonaliśmy w poprzednich dwóch paragrafach.
ukazuje dość zaskakującą młodość algorytmiki jako dziedziny wiedzy. Warto
również zauważyć, że nie jest to nauka, która powstała samorodnie. O ile obec
nie warto ja odróżniać jako odrębną gałąź wiedzy, to nie sposób nie docenić
wielowiekowej pracy matematyków, którzy dostarczyli algorytm ice zarówno
narzędzi opisu zagadnień, jak i wielu użytecznych teoretycznych rezultatów.
(Powyższa uwaga tyczy się również wielu innych dziedzin wiedzy).
Teraz, gdy już zdefiniowaliśmy sobie głównego bohatera tej książki (bohatera
zbiorowego: chodzi bowiem o algorytmy!), przejrzymy kilka sposobów używanych
do jego opisu.
1.3. Proces koncepcji programów
W paragrafie poprzednim wyszczególniliśmy kilka cech charakterystycznych,
które powinien posiadać algorytm rozumiany jako pojęcie informatyczne. Szcze
gólny nacisk położony został na precyzję zapisu. Wymóg ten jest wynikiem ogra
niczeń narzuconych przez współcześnie istniejące komputery i kompilatory- nie
są one bowiem w stanie rozumieć poleceń nieprecyzyjnie sformułowanych, zbu
dowanych niezgodnie z „wbudowanymi " w nie wymogami syntaktycznymi.
Rysunek 1 - 1 obrazuje w sposób uproszczony etapy procesu programowania
komputerów. Olbrzymia żarówka symboli/uje etap. który jest od czasu do czasu
pomijany przez programistów (dodajmy, że typowo z opłakanymi skutkami...) REFLEKSJĘ.
Rys. 1 - /.
Etapy konstrukcji
programu.
Następnie jest tworzony tzw. tekst źródłowy nowego programu, mający postać pliku
tekstowego, wprowadzanego do komputera przy pomocy zwykłego edytora teksto
wego. Większość istniejących obecnie kompilatorów posiada taki edytor już
wbudowany, więc użytkownik w praktyce nie opuszcza tzw. środowiska zintegro
wanego. grupującego programy niezbędne w procesie programowania. Ponadto
niektóre środowiska zintegrowane zawierają zaawansowane edytor)' graficzne
umożliwiające przygotowanie zewnętrznego interfejsu użytkownika praktycznie bez
1.3. Proces koncepcji programów
23
pisania jednej linii kodu. Pomijając już jednak tego typu szczegóły, generalnie
efektem pracy programisty jest plik lub zespól plików opisujących w formie
symbolicznej sposób zachowania się programu wynikowego. Opis ten jest
kodowany w tzw. języku programowania, który stanowi na ogół podzbiór języka1.
Kompilator dokonuje mniej lub bardziej zaawansowanej analizy poprawności
i, jeśli wszystko jest w porządku, produkuje tzw. kod wykonywalny, zapisany
w postaci zrozumiałej przez komputer. Plik zawierający kod wykonywalny może
być następnie wykonywany pod kontrolą systemu operacyjnym komputera (który
notabene także jest zbiorem programów).
Gdzie w tym procesie umiejscowione jest to, co stanowi tematykę książki, którą
trzymasz. Czytelniku, w ręku? Otóż z całego skomplikowanego procesu tworzenia
oprogramowania zajmiemy się tym, co do tej pory nic jest (jeszcze?) zauto
matyzowane: koncepcją algorytmów, ich jakością i technikami programowania
aktualnie używanymi w informatyce. Będziemy anonsować pewne problemy dające
się rozwiązywać przy pomocy komputera, a następnie omówimy sobie, jak to zadanie
wykonać w sposób efektywny. Tworzenie zewnętrznej otoczki programów, czyli tzw.
interfejsu użytkownika jest w chwili obecnej procesem praktycznie do końca zauto
matyzowanym, co wyklucza konieczność poruszania tego tematu w książce.
1.4. Poziomy abstrakcji opisu i wybór języka
Jednym z delikatniejszych problemów związanych z opisem algorytmów jest spo
sób ich prezentacji „zewnętrznej " . Można w tym celu przyjąć dwie skrajne pozycje:
•
zbliżyć się do maszyny (język asemblera: nieczytelny dla nieprzygoto
wanego odbiorcy);
•
zbliżyć się do człowieka (opis słowny: maksymalny poziom abstrakcji
zakładający poziom inteligencji odbiorcy niemożliwy aktualnie do „wbu
dowania " w maszynę " ).
Wybór języka asemblera do prezentacji algorytmów wymagałby w zasadzie
związania się z określonym typem maszyny, co zlikwidowałoby jakąkolwiek
ogólność rozważań i uczyniłoby opis trudnym do analizy. Z drugiej zaś strony
opis słowny wprowadza ryzyko niejednoznaczności, która może być kosztowna:
program, po przetłumaczeniu go na postać zrozumiałą przez komputer, może nie
zadziałać!
1
W praktyce jest to język angielski.
2
Niemowlę radzi sobie bez problemu z problemami, nad którymi biedzą się specjaliści
od tzw. sztucznej inteligencji usiłujący je rozwiązywać przy pomocy komputerów!
(Chodzi o efektywność uczenia się, rozpoznawanie form etc).
24
Rozdział 1. Zanim wystartujemy
A b y zaradzić zaanonsowanym wyżej p r o b l e m o m , przyjęło się z w y c z a j o w o
prezentowanie algorytmów w dwojaki sposób:
•
przy pomocy istniejącego języka programowania;
•
używając pseudojęzyka programowania (mieszanki języka naturalnego
i form składniowych pochodzących z kilku reprezentatywnych języków
programowania).
W niniejszym podręczniku można napotkać obie te formy i wybór którejś z nich
zostanie podyktowany kontekstem omawianych zagadnień. Przykładowo, jeśli
dany algorytm jest możliwy do czytelnej prezentacji przy pomocy języka progra
mowania, w y b ó r będzie oczywisty! Od czasu do czasu jednak napotkamy na
sytuacje, w których prezentacja kodu w pełnej postaci, gotowej do wprowadzenia
do komputera, byłaby zbędna (np. zbliżony materiał był j u ż przedstawiony
wcześniej) lub nieczytelna (liczba linii kodu przekracza objętość jednej strony).
W każdym jednak przypadku ewentualne przejście z jednej f o r m y w drugą nie
powinno stanowić dla Czytelnika większego problemu.
Już we wstępie zostało zdradzone, iż językiem prezentacji programów będzie
C++. Pora zatem dokładniej wyjaśnić powody, które obstawały za tym wyborem.
C + + jest językiem programowania określanym jako strukturalny, co z założenia
ułatwia pisanie w nim w sposób czytelny i z r o z u m i a ł y . Z w i ą z e k tego j ę z y k a
z klasycznym C u m o ż l i w i a oczywiście tworzenie absolutnie nieczytelnych
listingów, będziemy tego jednak starannie unikać. W istocie, częstokroć będą
omijane pewne możliwe mechanizmy optymalizacyjne, aby nie zatracić prostoty
zapisu. Najważniejszym jednak powodem użycia C + + jest fakt, iż ułatwia on
programowanie na wielu poziomach abstrakcji. Istnienie klas i wszelkie obiektowe
cechy lego języka powodują, iż bardzo łatwe jest ukrywanie szczegółów imple
mentacyjnych, rozszerzanie j u ż zdefiniowanych modułów (bez ich kosztownego
„przepisywania " ), a są to właściwości, którymi nie można pogardzić.
Być może cenne będzie podkreślenie „ u s ł u g o w e j " r o l i , jaką w procesie progra
mowania pełni język do tego celu wybrany. Wiele osób pasjonuje się wykazy
waniem wyższości jednego języka nad drugim, co jest sporem tak samo j a ł o w y m ,
jak wykazywanie „wyższości świąt Wielkiej Nocy nad świętami Bożego Naro
dzenia " (choć zapewne mniej śmiesznym...). Język programowania jest w koń
cu t y l k o narzędziem, ulegającym zresztą znacznej (r)e wol uc ji na przestrzeni
ostatnich lat Pracując nad pewnymi partiami tej książki musiałem zwalczać od
czasu do czasu silną pokusę prezentowania niektórych a l g o r y t m ó w w takich
językach j a k LISP czy PROLOG.
Uprościłoby to znacznie wszelkie rozważania o listach i rekurencji - niestety
ograniczyłoby również potencjalny krąg odbiorców książki do ludzi profesjonalnie
związanych wyłącznie z informatyką.
1.4. Poziomy abstrakcji opisu i wybór języka
_____25
Zdając sobie sprawę, że C++ może być pewnej grupie Czytelników nieznany, został
w dodatku A przygotowany mini kurs tego języka. Polega on na równoległej
prezentacji struktur składniowych w C++ i Pascalu, tak aby poprzez porównywanie
fragmentów kodu nauczyć się czytania listingów prezentowanych w tej książce.
Kilkustronicowy dodatek nie zastąpi oczywiście podręcznika poświęconego
tylko i wyłącznie C++, umożliwi jednak lekturę książki osobom pragnącym z niej
skorzystać bez konieczności poznawania nowego języka.
1.5. Poprawność algorytmów
Wpisanie programu do komputera, skompilowanie go i uruchomienie jeszcze nie
gwarantują, że kiedyś nie nastąpi jego „załamanie " (cokolwiek by to miało znaczyć
w praktyce). O ile jednak w przypadku „niewinnych " domowych aplikacji nie
ma to specjalnego znaczenia (w tym sensie, że tylko my ucierpimy.,.), to w
momencie zamierzonej komercjalizacji programu sprawa znacznie się komplikuje.
W grę zaczyna wchodzić nie tylko kompromitacja programisty, ale i jego
odpowiedzialność za ewentualne szkody poniesione przez użytkowników
programów.
Od błędów w swoich produktach nie ustrzegają się nawet wielkie koncerny pro
gramistyczne - w miesiąc po kampanii reklamowej produktu A' pojawiają się po
cichu „darmowe " (dla legalnych użytkowników) uaktualnione wersje, które nie
mają wcześniej niezauważonych błędów... Mamy tu do czynienia z pośpiechem
mającym na celu wyprzedzenie konkurencji, co usprawiedliwia wypuszczanie
przez dyrekcje firm niedopracowanych produktów - ze szkodą dla użytkowników.
którzy nie mają żadnych możliwości obrony przed tego typu praktykami. 7 drugiej
jednak strony uniknięcie błędów w programach wcale nie jest problemem banalnym
i stanowi temat poważnych badań naukowych !
Zajmijmy się jednak czymś bliższym rzeczywistości typowego programisty: pisze
on program i chce uzyskać odpowiedź na pytanie: „Czy będzie on działał po
prawnie w każdej sytuacji, dla każdej możliwej konfiguracji danych wejścio
wych? " . Odpowiedź jest tym trudniejsza, im bardziej skomplikowane są pro
cedury, które zamierzamy badać. Nawet w przypadku pozornie krótkich w za
pisie programów ilość sytuacji, które mogą zaistnieć w praktyce wyklucza ręczne
przetestowanie programu. Pozostaje więc stosowanie dowodów natury matema
tycznej, zazwyczaj dość skomplikowanych... Jedną z możliwych ścieżek, którymi
można dojść do stwierdzenia formalnej poprawności algorytmu, jest stosowanie
Formalne badanie poprawności systemów algorytmicznych jest możliwe przy użyciu
specjalnych języków stworzonych do tego celu.
26_
Rozdział 1. Zanim wystartujemy
metody niezmienników (zwanej niekiedy metodą Floyda). Mając dany algorytm,
możemy łatwo wyróżnić w nim pewne kluczowe punkty, w których dzieją się in
teresujące dla danego algorytmu rzeczy. Ich znalezienie nie jest zazwyczaj trudne:
ważne są momenty inicjalizacji zmiennych, którymi będzie operować procedura,
testy zakończenia algorytmu, „pętla główna " ... W każdym z tych punktów możli
we jest określenie pewnych zawsze prawdziwych warunków - tzw. niezmien
ników. Można sobie zatem wyobrazić, że dowód formalnej poprawności algoryt
mu może być uproszczony do stwierdzenia zachowania prawdziwości niezmien
ników dla dowolnych danych wejściowych.
Dwa typowe sposoby stosowane w praktyce to:
•
sprawdzanie stanu punktów kontrolnych przy pomocy debuggera
(odczytujemy wartości pewnych „ w a ż n y c h " zmiennych i sprawdzamy,
czy zachowują się „ p o p r a w n i e " dla pewnych „reprezentacyjnych " da
nych wejściowych " ).
•
formalne udowodnienie (np. przez indukcję matematyczną) zachowania
niezmienników dla dowolnych danych wejściowych.
Zasadnicza wadą powyższych zabiegów jest to, że są one nużące i potrafią łatwo
zabić całą przyjemność związaną z efektywnym rozwiązywaniem problemów przy
pomocy komputera. Tym niemniej Czytelnik powinien być świadom istnienia
również i tej strony programowania. Jedną z prostszych (i bardzo kompletnych)
książek, którą można polecić Czytelnikowi zainteresowanemu formalną teorią
programowania, metodami generowania algorytmów i sprawdzania ich własno
ści, jest [Gri84] - entuzjastyczny wstęp do niej napisał sam Dijkstra , co jest chyba
najlepszą rekomendacją dla tego typu pracy. Inny tytuł o podobnym charakterze,
[Kal90], można polecić miłośnikom formalnych dowodów i myślenia matematycznego. M e t o d y matematycznego dowodzenia poprawności a l g o r y t m ó w są
prezentowane w tych książkach w pewnym sensie niejawnie; zasadniczym celem
jest dostarczenie narzędzi, które u m o ż l i w i ą quasi-automatyczne generowanie
algorytmów.
Każdy program „wyprodukowany " przy pomocy tych metod jest automatycznie
poprawny - pod warunkiem, że nie został „ p o drodze " popełniony jakiś błąd. „ W y
generowanie " algorytmu jest możliwa dopiero po j e g o poprawnym zapisaniu
wg schematu;
Stwierdzenia: „ważne zmienne " , „poprawne " zachowanie programu, „reprezenta
tywne " dane wejściowe etc. należą do gatunku bardzo nieprecyzyjnych i są ściśle
związane z konkretnym programem, którego analizą się zajmujemy.
3
Jeśli już jesteśmy przy nim, to warto polecić przynajmniej pobieżną lekturę [DF89], któ
ra stanowi dość dobry wstęp do metodologii programowania.
1,5
1.5. Poprawność algorytmów
27
{warunki wstępne 4 } poszukiwany-program {warunki końcowe}
Możliwe jest przy pewnej dozie doświadczenia wyprodukowanie ciągu instruk
cji, które powodują przejście z „warunków wstępnych " do „warunków końco
wych " - wówczas formalny dowód poprawności algorytmu jest zbędny. Można
też podejść do problemu z innej strony; mając dany zespól warunków wstęp
nych i pewien program: czy jego wykonanie zapewnia „ustawienie " pożąda
nych warunków końcowych?
Czytelnik może nieco się obruszyć na ogólnikowość powyższego wywodu, ale
jest ona wymuszona przez „rozmiar " lematu, który wymaga w zasadzie osobnej
książki! Pozostaje zatem tylko ponowić zaproszenie do lektury niektórych zacy
towanych wyżej pozycji bibliograficznych - niestety w momencie pisania tej
książki niedostępnych w polskich wersjach językowych.
4
Wartości zmiennych, pewne warunki logiczne je wiążące etc.
Rozdział 2
Rekurencja
Tematem niniejszego rozdziału jest jeden z najważniejszych mechanizmów
używanych w informatyce - rekurencja. zwana również rekursją1. Mimo iż
użycie rekurencji nie jest obowiązkowe " , jej zalety są oczywiste dla każdego,
kto choć raz spróbował tego stylu programowania. Wbrew pozorom nie jest to
wcale mechanizm prosty i wiele jego aspektów wymaga dogłębnej analizy.
Niniejszy rozdział ma kluczowe znaczenie dla pozostałej części książki - o ile
j e j lektura może być dość swobodna i nieograniczona naturalną kolejnością
rozdziałów, o tyle bez dobrego zrozumienia samej istoty rekurencji nie będzie
możliwe swobodne „czytanie " wielu zaprezentowanych dalej algorytmów i metod
programowania.
2.1. Definicja rekurencji
Pojęcie rekurencji poznamy na przykładzie. Wyobraźmy sobie małe dziecko
w wieku lat - przykładowo - pięciu. Dostaje ono od rodziców zadanie zebrania
do pudełka wszystkich drewnianych klocków, które „nierozmyślnie " zostały
rozsypane na podłodze. Klocki są bardzo prymitywne, są to zwyczajne drewniane
sześcianiki, które doskonale nadają się do budowania nieskomplikowanych
budowli. Polecenie jest bardzo proste: „Zbierz to wszystko razem i poukładaj
tak jak było w pudełku " . Problem wyrażony w ten sposób jest dla dziecka
1
Subtelna różnica między tymi pojęciami w zasadzie już się zatraciła w literaturze,
dlatego też nie będziemy się niepotrzebnie rozdrabniać w szczegóły terminologiczne.
Programy zapisane w formie rekurencyjnej mogą być przekształcone - z mniejszym
lub większym wysiłkiem - na postać klasyczną, zwaną dalej iteracyjną (patrz
rozdział 6).
30
Rozdział 2. Rekurencja
potwornie skomplikowany: klocków jest cała masa i niespecjalnie wiadomo jak
się do tego całościowo zabrać. Mimo ograniczonych umiejętności na pewno niw
przerasta go następująca czynność: wziąć jeden klocek z podłogi i włożyć do
pudełka. Małe dziecko zamiast przejmować się złożonością problemu, której
być może sobie nawet nie uświadamia, bierze się do pracy i rodzice z przyjem
nością obserwują jak strefa porządku na podłodze powiększa się z minuty na
minutę.
Zastanówmy się chwilkę nad metodą przyjęta przez dziecko: ono wic, że pro-.
blem postawiony przez rodziców to wcale nie jest „zebrać wszystkie klocki " !
(bo to de Facto jest niewykonalne za jednym zamachem), ale: „wziąć jeden klocek
przełożyć go do pudelka, a następnie zebrać do pudelka pozostałe " . W jaki sposób
można zrealizować to drugie? Proste, zupełnie tak jak poprzednio: „bierzemy
jeden klocek... " itd. - postępując tak do momentu wyczerpania się klocków.
Spójrzmy na rysunek 2 - 1 , który przedstawia w sposób symboliczny tok rozumowania przyjęty przy rozwiązywaniu problemu „sprzątania rozsypanych
klocków " .
Rys. 2 - I.
„Sprzątanie
kloc-
ków " , czyli rekurencja
w praktyce.
Jest mało prawdopodobne, aby dziecko uświadamiało sobie, że postępuje w sposób
rekurencyjny, choć tak jest w istocie! Jeśli uważniej przyjrzymy się opisanemu
powyżej problemowi, to zauważymy, że jego rozwiązanie charakteryzuje się następującymi cechami, typowymi dla algorytmów rekurencyjnych:
•
zakończenie algorytmu jest jasno określone ( „ w momencie gdy na
podłodze nie będzie więcej klocków, możesz uznać, że zadanie zostało
wykonane " ).
•
„duży " problem został rozłożony na problem elementarny (który umiemy rozwiązać) i na problem o mniejszym stopniu skomplikowania niż
ten. z którym mieliśmy do czynienia na początku.
Zauważmy, że w sposób dość śmiały użyte zostało określenie „algorytm " . Czy
jest sens mówić o opisanym powyżej problemie w kategorii algorytmu? Czy w
ogóle możemy przypisywać pięcioletniemu dziecku wiedzę, z której ono nic
zdaje sobie sprawy?
Przykład, na podstawie którego zostało wyjaśnione pojęcie algorytmu rekurencyjnego, jest niewątpliwie kontrowersyjny. Prawdopodobnie dowolny specjalista
2.2. Ilustracja pojęcia rekurencji
31
od psychologii zachowań dziecka chwyciłby się za głowę z rozpaczy czytając
powyższy wywód... Dlaczego jednak zdecydowałem się na użycie takiego właśnie
a nie innego - może bardziej informatycznego - przykładu? Otóż zasadniczym
celem była chęć udowodnienia, iż myślenie w sposób rekurencyjny jest jak naj
bardziej zgodne z naturą człowieka i duża klasa problemów rozwiązywanych
przez umysł ludzki jest traktowana podświadomie w sposób rekurencyjny.
Pójdźmy dalej za tym śmiałym stwierdzeniem; jeśli tylko zdecydujemy się na
intuicyjne podejście do algorytmów rekurencyjnych, to nie będą one stanowiły
dla nas tajemnic, choć być może na początku nie w pełni uświadomimy sobie
mechanizmy w nich wykorzystywane.
Powyższe wyjaśnienie pojęcia rekurencji powinno być znacznie czytelniejsze
niż typowe podejście zatrzymujące się na niewiele mówiącym stwierdzeniu, że
„program rekurencyjny jest to program, który wywołuje sam siebie " ...
2.2. Ilustracja pojęcia rekurencji
Program, którego analizą będziemy się zajmowali w tym podrozdziale, jest
bardzo zbliżony do problemu klocków, z którym spotkaliśmy się przed
chwilą. Schemat rekurencyjny zastosowany w nim jest identyczny, jedynie za
gadnienie jest nieco bliższe rzeczywistości informatycznej.
Mamy do rozwiązania następujący problem:
• dysponujemy tablicą n liczb całkowitych
-1];
tab[n-I];
lab[n]=tab[0],
tab[1]...
t
• czy w tablicy rab występuje liczba x (podana jako parametr)?
Jak postąpiłoby dziecko z przykładu, który posłużył nam za definicję pojęcia
rekurencji, zakładając oczywiście, że dysponuje już ono pewną elementarną
wiedzą informatyczną? Jest wysoce prawdopodobne, że rozumowałoby ono
w sposób następujący:
•
Wziąć pierwszy niezbadany element tablicy n-elementowej;
•
jeśli aktualnie analizowany element tablicy jest równy A, to:
wypisz „Sukces " i zakończ;
w przeciwnym wypadku
Zbadaj pozostałą część tablicy n-1-elementowej.
32
Rozdział 2. Rekurencja
Wyżej podaliśmy warunki pozytywnego zakończenie programu. W przypadku,
gdy przebadaliśmy całą tablicę i element x nie został znaleziony, należy oczywiście
zakończyć program w jakiś umówiony s p o s ó b - np. komunikatem o niepo
wodzeniu.
Proszę spojrzeć na przykładową realizację, jedną z kilku możliwych;
rekl.cpp
const
n=10;
int tab[n] = { 1 , 2 , 3 , 2 , - 7 , 4 4 , 5 , l , 0 , - 3 } ;
void szukaj( i n t t a b [ n ] , i n t l e f t , i n t r i g h t , i n t x )
//Left, r i g h t = lewa i prawa g r a n i c a obszaru poszukiwań
// tab
=
tablica
// x
= wartość do odnalezienia
{
if (left & gt; right)
cout & lt; & lt; " Element " *' & lt; & lt; x & lt; & lt; " nie z o s t a ł odnaleziony\n " ;
else
if ( t a b [ l e f t ] == x)
cout & lt; & lt; " Znalazłem szukany element " & lt; & lt; x «endl;
else
szukaj(tab,left+1,right,x);
} & gt;
Warunkiem zakończenia programu jest albo znalezienie szukanego elementu x,
albo leż wyjście poza obszar poszukiwań. Mimo swojej prostoty program powyż
szy dobrze ilustruje podstawowe, wspomniane j u ż wcześniej cechy typowego
programu rekurencyjnego. Przypatrzmy się zresztą uważniej:
•
Zakończenie programu jest jasno określone:
- element znaleziony;
- przekroczenie zakresu tablicy.
•
Duży problem zostaje „rozbity " na problemy elementarne, które umie
my rozwiązać (patrz wyżej), i na analogiczny problem, tylko o mniej
szym stopniu skomplikowania:
- z tablicy o rozmiarze n „schodzimy " do tablicy o rozmiarze n-l.
Podstawowymi błędami popełnianymi przy konstruowaniu programów rekurencyjnych są:
•
złe określenie warunku zakończenia programu;
•
niewłaściwa (nieefektywna) dekompozycja problemu.
W dalszej części rozdziału postaramy się wspólnie dojść do pewnych „zasad bezpiee/eństwa " niezbędnych przy pisaniu programów rekurencyjnych. Zanim to jed
nak nastąpi, konieczne będzie dokładne wyjaśnienie schematu ich wykonywania.
2.3. Jak wykonują się programy rekurencyjne?
33
2.3. Jak wykonują się programy rekurencyjne?
Dociekliwy Czytelnik będzie miał prawo zapytać w tym miejscu: ..OK. zoba
czyłem na przykładzie, że TO działa, ale mam też chyba prawo poznać bardziej
od podszewki JAK to działa! " . Pozostaje zatem przyporządkować się temu
słusznemu żądaniu.
Odpowiedzią na nie jest właśnie niniejszy podrozdział. Przykład w nim użyty
będzie być może banalny, tym niemniej nadaje się doskonale do zilustrowania
sposobu wykonywania programu rekurencyjnego.
Już w szkole średniej (lub może nawet podstawowej?!) na lekcjach matematyki
dość często używa się tzw. silni z n. czyli iloczynu wszystkich liczb naturalnych
od / do n włącznie. Ten użyteczny symboli zdefiniowany jest w sposób na
stępujący:
0! = 1,
n! = n * ( n - 1 ) ! gdzie n & gt; 1
Pomińmy jego znaczenie matematyczne, nieistotne w tym miejscu. Nic nie stoi
jednak na przeszkodzie, aby napisać prosty program, który zajmuje się oblicza
niem silni w sposób rekurencyjny:
rekl.cpp
2.
unsigned long int silnia(int x)
{(
if (x==0)
return 1;
else
return x*silnia & lt; x-l);
})
Prześledźmy na przykładzie, jak się wykonuje program, który obliczy 3! Rysunek
2 - 2 przedstawia kolejne etapy wywoływania procedury rekurencyjnej i badanie
warunku na przypadek elementarny.
Konwencje użyte podczas tworzenia są następujące:
• pionowe strzałki w dół oznaczają „zagłębianie się " programu z poziomu
n na n-1 itd. w celu dotarcia do przypadku elementarnego 0!;
• pozioma strzałka oznacza obliczanie wyników cząstkowych;
•
ukośna strzałka prezentuje proces przekazywania wyniku cząstkowego
z poziomu niższego na wyższy.
Oznaczany przez n!
34
Rozdział 2. Rekurencja
Rys. 2 - 2.
Drzewo wywołań
funkcji silnia(3)
x=0?
3*2!
nie!
x=0?
2*1!
nie!
0*1!
x =0? nie!
x=0?
nie!
1
Czymże są jednak owe tajemnicze poziomy, przekazywanie parametrów, etc.?
Chwilowo te pojęcia mają prawo brzmieć z lekka egzotycznie. Aby zmienić to
wrażenie, opiszemy słownie sposób obliczenia silnia(2):
Funkcja silnia otrzymuje liczbę 2 jako parametr wywołania i analizuje: „czy
2 równa się 0? " Odpowiedź brzmi „Nie " , zatem funkcja „przyjmuje " , że jej
wynikiem jest 2* silnia(1).
Niestety, wartość silnia(1) jest nieznana... Funkcja wywołuje zatem kolejny
swój egzemplarz, który zajmie się obliczeniem wartości silnia(1), wstrzymu
jąc jednocześnie skalkulowanie wyrażenia 2*silnia(1). Po tym wywołaniu rekurencyjnym funkcja silnia czeka na wynik cząstkowy, który zostanie
„nadesłany " przez jej wywołany niedawno nowy „egzemplarz " .
W praktyce przekazywanie parametrów odbywa się za pośrednictwem stosu,
programista jednak ma prawo zupełnie się tym nie przejmować. Fakt, iż parametr
zostanie zwrócony za pośrednictwem stosu, niewiele się bowiem różni od prze
dyktowania wyniku przez telefon. Końcowy efekt, wyrażony przez stwierdzenie
„Wynik jest gotowy! " jest bowiem dokładnie taki sam w każdym przypadku.
niezależnie od realizacji.
Gdzież się jednak znajdują wspomniane poziomy rekurencji? Spójrzmy raz jeszcze
na rysunek 2-2. Aktualna wartość parametru x badanego przez funkcję silnia jest
zaznaczona z lewej strony reprezentującego ją „pudełka " . Ponieważ dany egzem
plarz funkcji silnia czasami wywołuje kolejny swój egzemplarz (dla obliczenia wy
niku cząstkowego) wypadałoby jakoś je różnicować. Najprostszą metodą jest doko
nywanie tego poprzez wartość x która jest dla nas punktem odniesienia używanym
r,
przy określaniu aktualnej „głębokości " rekurencji.
2.4. Niebezpieczeństwa rekurencji
Z użyciem rekurencji czasami związane są pewne niedogodności. Dwa klasycz
ne niebezpieczeństwa prezentują poniższe przykłady.
2.4. Niebezpieczeństwa rekurencji
35
2.4.1.Ciąg Fibonacciego
Naszym pierwszym zadaniem jest napisanie programu, który liczyłby elementy
l/w.
tzw. ciągu Fibonacciego. Ten dziwoląg matematyczny, używany do wielu różnych
i czasami zaskakujących celów, jest definiowany następująco:
fib(0) = 1
ftb(1) = 1
fib(n) = f ( n - l ) + fib(n) gdzien & gt; = 2
Zaprezentowany niżej program jest niemal dokładnym przetłumaczeniem
powyższego wzoru i nic powinien stanowić dla nikogo niespodzianki:
rek3.cpp
unsigned long int fib(int x)
{{
if (x & lt; 2)
return 1;
else
return fib(x-1)+fib(x-2);
}
Spróbujmy prześledzić dokładnie wywołania rekurencyjne. Nieskomplikowana
analiza prowadzi do następującego drzewa:
Rys. 2 - 3.
Obliczanie fib(4)
Każde „zacieniowane " wyrażenie stanowi problem elementarny; problem o rozmia
rze n & gt; = 2 zostaje „rozbity " na dwa problemy o niniejszym stopniu skomplikowania:
n-1 i n-2.
Skąd się jednak wziął pesymistyczny tytuł tego podrozdziału? Przypatrzmy się
dokładniej rysunkowi 2 - 3 . Już w pierwszej chwili można dostrzec, że znaczna
część obliczeń jest wykonywana więcej niż jeden raz (np. cała gałąź zaczynają
ca się od fib(2) jest wręcz zdublowana!). Funkcja fib nie ma żadnej możliwości.
aby to „zauważyć " 1 , w końcu jest to tylko program, który wykonuje to, co mu
1
Jeśli można sobie pozwolić na tego typu personifikację...
36
Rozdział2. Rekurencja
każemy. W rozdziale 9 zostanie omówiona ciekawa technika programowania
(tzw. programowanie dynamiczne) pozwalająca poradzić sobie z powyższą wadą.
2.4.2.Stack overflow!
Tytuł niniejszego podrozdziału oznacza po polsku " przepełnienie stosu " . Jak
wykazuje praktyka programowania, pisanie programów podlega regułom raczej
świata magii i nieokreśloności niż naszym zachciankom. Ile razy zdarzało się
nam „zawiesić " komputer (przez co rozumiemy powszechnie stan, w którym
program nie reaguje na nic i trzeba mu zasalutować trzema klawiszami " ) na
szym programem? Zdarza się to nawet najbardziej uważnym programistom
i sianowi raczej nieodłączny element pracy programistycznej.,.
Istnieje kilka typowych przyczyn „zawieszania " programów:
• zachwianie równowagi systemu operacyjnego przez „nielegalne'' użycie
jego zasobów;
• „nieskończone " pętle;
• brak pamięci;
• nieprawidłowe lub niejasne określenie warunków zakończenia progra
mu;
• błąd programowania (np. zbyt wolno wykonujący się algorytm).
Programy rekurencyjne są zazwyczaj dość pamięciożerne: z każdym wywołaniem
rekurencyjnym wiąże się konieczność zachowania pewnych informacji3 niezbęd
nych do odtworzenia stanu sprzed wywołania, a to zawsze kosztuje trochę cennych
bajtów pamięci. Spotyka się programy rekurencyjne, dla których określenie
maksymalnego poziomu zagłębienia rekurencji podczas ich wykonywania jest
dość łatwe. Analizując program obliczający 3! widzimy od razu, że wywoła sam
siebie tylko 3 razy; w przypadku funkcji fib szybka „diagnoza " nie przynosi już
tak kompletnej informacji.
Przybliżone szacunki nie zawsze należą do najprostszych. Dowodzi tego chyba
najlepiej funkcja funkcja MacCarthy'ego. zaprezentowana poniżej:
rek4.cpp
unsigned long i n t MacCarthy(int x)
{
i f (x & gt; 100)
2 Ctrl-ALT-Del w systemie DOS, instrukcja kill w systemie Unix...
1
W szczegóły wnikać nie będziemy, gdyż tematyka ta nie ma dla nas większego zna
czenia w tym miejscu.
2.4. Niebezpieczeństwa rekurencji
return
37
(x-10);
else
return MacCarthy(MacCarthy(x+ 1 1 ) ) ;
Już na pierwszy nawet rzut oka widać, że funkcja jest jakaś „dziwna " . Kto potrafi
powiedzieć w przybliżeniu, jak się przedstawia jej ilość wywołań w zależności od
parametru x podanego w wywołaniu? Chyba niewielu byłoby w stanie od razu po
wiedzieć, że zależność ta ma postać przedstawioną na wykresie z rysunku 2-4...
Nie było to wcale takie oczywiste, prawda?
Ćwicz. 2-1
Proszę dokładnie zbadać funkcję MacCarthy'ego w większym przedziale
liczbowym, niż ten na rysunku. Jakich niebezpieczeństw można się doszukać?
Rys. 2 - 4.
Ilość wywołań
funkcji Mac
Carthy 'ego
w zależności od
parametru
wywołania,
200
150
100
50
0
20
40
60
80
100
2.5. Pułapek ciąg dalszy
Jakby nie dość było negatywnych stron programów rekurencyjnych, należy jeszcze
dorzucić te, które nie Wynikają z samej natury rekurencji, lecz raczej z błędów
programisty. Być może warto w tym miejscu podkreślić, iż omawianie
„ciemnych stron " rekurencji nie ma na celu zniechęcenia Czytelnika do jej sto
sowania! Chodzi raczej o wskazanie typowych pułapek i sposobów ich omija
nia - a te ostatecznie istnieją zawsze (pod warunkiem, że wiemy CO omijać).
Zapraszam zatem do lektury następnych paragrafów...
38
Rozdział 2. Rekurencja
2.5.1.Stąd do wieczności
W wielu funkcjach rekurencyjnych, pozornie dobrze skonstruowanych, może
z łatwością ukryć się błąd polegający na sprowokowaniu nieskończonej ilości
wywołań rekurencyjnych. Taki właśnie zwodniczy przykład jest przedstawiony
poniżej:
std.cpp
int StadDoWiecznosci(int n)
{
if (n == 1)
return 1;
else
if ((n % 2) == 0)
// czy n jest parzyste?
return StadDoWiecznosci(n-2)*n;
else
return StadDoWiecznosci(n-1)*n;
}
Gdzie jest umiejscowiony problem? Patrząc na ten program trudno dopatrzyć się
szczególnych niebezpieczeństw. W istocie, definicja rekurencyjna wydaje się
poprawna: mamy przypadek elementarny kończący łańcuch wywołań, problem o
rozmiarze n jest upraszczany do problemu o rozmiarze n-1 lub n-2. Pułapka tkwi
właśnie w tej naiwnej wierze, że proces upraszczania doprowadzi do przypadku
elementarnego (czyli do n=l)! Po dokładniejszej analizie można wszakże
zauważyć, że dla n & gt; 2 wszystkie wywołania rekurencyjne kończą się parzystą
wartością n. Implikuje to, iż w końcu dojdziemy do przypadku n=2, który zostanie
zredukowany do n=0. który zostanie zredukowany do w=-2, który... Można tak
kontynuować w nieskończoność, nigdzie „po drodze " nie ma żadnego przypadku
elementarnego!
Wniosek nasuwa się sam: należy zwracać baczną uwagę na to, czy dla wartości
parametrów wejściowych należących do dziedziny wartości, które mogą być
użyte, rekurencja się kiedyś kończy.
2.5.2.Definicja poprawna, ale...
Rozpatrywany poprzednio przykład służył do zilustrowania problemów związanych
ze zbieżnością procesu rekurencyjnego. Wydaje się, że dysponując poprawną
de linieją rekurencyjna, dostarczoną przez matematyka, możemy już być spokojni o
to, że analogiczny program rekurencyjny także będzie poprawny (tzn. nie zapętli się,
będzie dostarczać oczekiwane wyniki etc.). Niestety jest to wiara dość naiwna
i niczym nie uzasadniona. Matematyk bowiem jest w stanie zrobić wszystko
związane ze „swoją " dziedziną: określić dziedziny wartości funkcji, udowodnić, że
ona się zakończy, wreszcie podać złożoność obliczeniową -jednej jednak rzeczy
2.5. Pułapek ciąg dalszy
39
nie będzie mógł sprawdzić: jak rzeczywisty kompilator wykona tę funkcję! Mimo,
że większość kompilatorów działa podobnie, to zdarzają się pomiędzy nimi drobne
różnice, które powodują, że identyczne programy będą dawać różne wyniki. Nasz
kolejny przykład będzie dotyczył właśnie takiego przypadku.
Proszę spojrzeć na następującą funkcję:
i n t N(int n , i n t p)
{
if (n==0)
r e t u r n 1;
else
r e t u r n N(n-1,N(n-p,p))
}}
Można przeprowadzić dowód matematyczny 1, że powyższa definicja jest poprawna
w tym sensie, iż dla dowolnych wartości n & gt; 0 i p & gt; 0 jej wynik jest określony
i wynosi l. Dowód ten opiera się na założeniu, że wartość argumentu wywołania
funkcji jest obliczana tylko wtedy, gdy jest naprawdę niezbędna (co wydaje się
dość logiczne). Jak się to zaś ma do typowego kompilatora C++?
Otóż regułą w jego przypadku jest to, iż wszystkie parametry funkcji rckurencyjnej są ewaluowane jako pierwsze, a następnie dokonywane jest wywołanie
samej funkcji. (Taki sposób pracy jest zwany wywołaniem przez wartość.
Problem może zaistnieć wówczas, gdy w wywołaniu funkcji spróbujemy umieścić ją
samą; zobaczmy j a k to się odbędzie w przypadku naszej funkcji, np. dla N(1,0) (patrz
rysunek 2-5).
Rys. 2-5.
Nieskończony ciąg
wywołań
rekurencyjnych.
Patrz [Kro89].
40
Rozdział
2.
Rekurencja
Zapętlenie jest spowodowane próbą obliczenia parametru p, tymczasem to drugie
wywołanie jest w ogóle niepotrzebne do zakończenia funkcji! Istnieje w niej
bowiem warunek obejmujący przypadek elementarny: jeśli n=0, to zwróć 1.
Niestety, kompilator o tym nie wie i usiłuje obliczyć ten drugi parametr, powo
dując zapetlenie programu...
Przykład omówiony w niniejszym paragrafie należy traktować jako swoista
ciekawostkę, niemniej warto go zapamiętać ze względów czysto edukacyjnych.
2.6.Typy programów rekurencyjnych
Na podstawie lektury poprzednich paragrafów Czytelnik mógłby wyciągnąć kilka
ogólnych wniosków na temat programów używających technik rekurencyjnych:
typowo zachłanne w dysponowaniu pamięcią komputera, niekiedy „zawieszają "
system operacyjny... Na szczęście jest to błędne wrażenie! Programy rekurencyjne mają jedną olbrzymią zaletę: są łatwe do zrozumienia i zazwyczaj zajmują
mało miejsca jeśli rozpatrujemy liczbę linii kodu użytego na ich realizację. Z tym
ostatnim jest ściśle związana łatwość odnajdywania ewentualnych błędów.
Wróćmy jednak do tematu.
Zauważyliśmy wspólnie, że program rekurencyjny może być pamięciochłonny i wy
konywać się dość wolno. Pytanie brzmi: czy istnieją jakieś techniki programowania
pozwalające usunąć (lub co najmniej zredukować) powyższe wady z programu
rekurencyjnego? Odpowiedź jest na szczęście pozytywna! Otóż pewna klasa
problemów natury " rekurencyjnej " da się zrealizować na dwa sposoby, dające
dokładnie taki sam efekt końcowy, ale różniące się nieco realizacją praktyczną.
Podzielmy metody rekurencyjne, tytułem uproszczenia, na dwa podstawowe typy:
• rekurencja „naturalna " ;
• rekurencja „z parametrem dodatkowym " 1.
Typ pierwszy mieliśmy okazję zobaczyć podczas analizy dotychczasowych
przykładów, teraz zapoznamy się z drugim.
Rozważmy raz jeszcze przykład funkcji obliczającej silnię. Do tej pory
znaliśmy ją w postaci:
rekS.cpp
unsigned long int silnia1 (unsigned long int x)
{
1
Pozostaniemy na moment przy tej nieprecyzyjnej nazwie; ten typ rekurencji powróci
nam jeszcze w rozdziale 6 - w innym jednakże kontekście.
2.6. Typy programów rekurencyjnych
if
else
}
41
(x==0)
return 1;
return
x * s i l n i a 1(x-1); ;
lfx-l)
Nie jest to bynajmniej jedyna możliwa realizacja funkcji obliczającej silnię.
Spójrzmy dla przykładu na następującą wersję:
unsigned long int silnia2(unsigned long int x,
unsigned long int tmp=l)
{
if (x==0)
return tmp;
else
return silnia2(x-l,x*tmp);
}
W pierwszym momencie działanie tej funkcji nie jest być może oczywiste, ale
wystarczy wziąć kartkę i ołówek, aby przekonać się na kilku przykładach, że
wykonuje ona swoje zadanie. Osobom nie znającym dobrze C++ należy się
niewątpliwie wyjaśnienie konstrukcji funkcji silnia!. Otóż dowolna funkcja
w C++ może posiadać parametry domyślne. Dzięki temu funkcja o nagłówku:
FunDom(int a,int k=l)
może być wywołana na dwa sposoby:
• określając wartość drugiego parametru, np FumDom(12,5): w tym
przypadku k przyjmuje wartość 5;
• nie określając wartości drugiego parametru, np. FunDom(12)\ k przyj
muje wtedy wartość domyślną równą tej podanej w nagłówku, czyli 1.
Ta użyteczna cecha języka C++ wykorzystana została w drugiej wersji funkcji do
obliczania silni. Jednak jakie istotne względy przemawiają za używaniem tej
osobliwej z pozoru metody programowania? Argumentem nie jest tu wzrost
czytelności programu, bowiem już na pierwszy rzut oka silnia2 jest o wiele
bardziej zagmatwana niż silnia I !
Istotna zaleta rekurencji „z parametrem dodatkowym " jest ukryta w sposobie
wykonywania programu. Wyobraźmy sobie, że program rekurencyjny „bez
parametru dodatkowego " wywołał sam siebie 10-krotnie, aby obliczyć dany
wynik. Oznacza to, że wynik cząstkowy z dziesiątego, najgłębszego poziomu
rekurencji będzie musiał być przekazany przez kolejne dziesięć poziomów do
góry, do swojego pierwszego egzemplarza.
Jednocześnie z każdym „zamrożonym " poziomem, który czeka na nadejście
wyniku cząstkowego, wiąże się pewna ilość pamięci, która służy do odtworzenia
42
Rozdział 2. Rekurencja 2_
m.in. wartości zmiennych tego poziomu (tzw. kontekst). Co więcej, odtwarzanie
kontekstu już samo w sobie zajmuje cenny czas procesora, który mógłby być
wykorzystany np. na inne obliczenia...
Czytelnik domyśla się już zapewne, że program rekurencyjny „z parametrem dodat
kowym " robi to wszystko nieco wydajniej. Ponieważ parametr dodatkowy służy
do przekazywania elementów wyniku końcowego, dysponując nim nie ma po
trzeby przekazywania wyniku obliczeń do góry, „piętro po piętrze " . Po prostu
w momencie, w którym program stwierdzi, że obliczenia zostały zakończone,
procedura wywołująca zostanie o tym poinformowana wprost z ostatniego ak
tywnego poziomu rekurencji. Co za tym wszystkim idzie, nie ma absolutnie żad
nej potrzeby zachowywania kontekstu poszczególnych poziomów pośrednich,
liczy się tylko ostatni aktywny poziom, który dostarczy wynik i basta!
2.7. Myślenie rekurencyjne
Pomimo oczywistych przykładów na to, że rekurencja jest dla człowieka czymś
jak najbardziej naturalnym, niektórzy mają pewne trudności z używaniem jej
podczas programowania. Nieumiejętność „wyczucia " istoty tej techniki progra
mowania może wynikać z braku dobrych i poglądowych przykładów na jej wykor/Ysianie. Idąc za tym stwierdzeniem, postanowiłem wybrać kilka prostych
programów rekurencyjnych. generujących znane motywy graficzne - ich dobre
zrozumienie będzie wystarczającym testem na oszacowanie swoich zdolności
myślenia rekurencyjnego (ale nawet wówczas wykonanie zadań zamieszczo
nych pod koniec rozdziału będzie jak najbardziej wskazane...).
2.7.1.Spirala
Zastanówmy się. jak można narysować rekurencyjnie jednym „pociągnięciem "
kreski rysunek 2 - 6 .
Parametrami programu są:
• odstęp pomiędzy liniami równoległymi: alpha:
• długość boku rysowanego w pierwszej kolejności: Ig.
Algorytm iteracyjny byłby również nieskomplikowany (zwykłą pętla), ale za
łóżmy, że zapomnimy chwilowo o jego istnieniu i wykonamy to samo rekuren
cyjnie. Istota rekurencji polega głównie na znalezieniu właściwej dekompozycji
problemu. Tutaj jest ona przedstawiona na rysunku i w związku z tym ewentu
alne przetłumaczenie jej na program w C++ powinno być znacznie ułatwione.
2.7. Myślenie rekurencyjne
43
Rekurencyjność naszego zadania jest oczywista, bowiem program wyniko
wy zajmuje się powtarzaniem głównie tych samych czynności (rysuje linie
poziome i pionowe, jednakże o różnej długości). Naszym zadaniem będzie
odszukanie schematu rekurencyjnego i warunków zakończenia procesu
wywołań rekurencyjnych.
Rys. 2 - 6.
Spirala narysowa
na rekurencyjnie.
alpha
Jak rozwiązać to zadanie? Wpierw przybliżmy się nieco do „rzeczywistości
ekranowej " i wybierzmy jako punkt startowy pewną parę (x,y). Idea rozwiązania
polega na narysowaniu 4 odcinków „zewnętrznych " spirali i dotarciu do punktu
(x',y'). W tym nowym punkcie startowym możemy już wywołać rekurencyjnie
procedurę rysowania, obarczoną oczywiście pewnymi warunkami gwarantującymi
jej poprawne zakończenie.
Elementarny przypadek rozwiązania prezentuje rysunek 2 - 7 .
Rys. 2 - 7.
Spirala narysowa
na rekurencyjnie szkic rozwiązania.
Jedna z kilku możliwych wersji programu, który realizuje to, co zostało wyżej
opisane, jest przedstawiona poniżej.
W celu ułatwienia lektury programu zamieszczone zostały również objaśnienia
instrukcji graficznych.
spirala.cpp
const double alpha=10;
void spirala(double Ig,double x,double y)
44
Rozdział2. Rekurencja
1
if
(lg & gt; 0)
{
lineto(x+lg,y);
lineto(x+lg,y-lg);
lineto(x+alpha,y + I g ) ;
lineio(x+alpha,y+alpha);
spirala(lg-2*alpha,x+alpha,y+alpha);
}
void main
// tu zainicjuj tryb graficzny
moveto(90,50);
spirala{getmaxx()/2,getx(),gety());
g e t c h ( ) ; // poczekaj na n a c i ś n i ę c i e klawisza
/ / T u zamknij t r y b g r a f i c z n y
}
Tabela 2 - L
Objaśnienia
instrukcji
graficznych.
Uwaga! Działa tylko na
starych kompilatorach
Borlanda tj. TurboC i Turbo
C++
Jeśli koniecznie chcecie tak
pisać -- & gt; Google (WinBGI)
( " odkurzona " biblioteka
graficzna BGI, składnia taka
sama)
FUNKCJA
ZASTOSOWANIE
lineto (x,y)
kreśli odcinek prostej od pozycji bieżącej do punktu
(x, y)
moveto(x,y)
przesuwa kursor graficzny do punktu (x, y)
gctmaxx()
zwraca maksymalną współrzędną poziomą (zależy od
rozdzielczości trybu graficznego)
getmaxy()
zwraca maksymalną współrzędną, pionową (j. w.)
getx()
zwraca aktualną współrzędną poziomą
gety()
zwraca aktualną współrzędną pionową
K.
2.7.2.Kwadraty „parzyste "
Zadanie jest podobne do poprzedniego: jak jednym pociągnięciem kreski naryso
wać figurę przedstawioną na rysunku 2 - 8 ?
Rys. 2-8.
Kwadraty
" parzyste " (n=2)
2.7, Myślenie rekurencyjne
45
Przypadkiem elementarnym będzie tutaj narysowanie jednej pary kwadratów
(wewnętrzny obrócony w stosunku do zewnętrznego).
To zadanie jest nawet prostsze niż poprzednie, sztuka polega jedynie na wyborze
właściwego miejsca wywołania rekurencyjnego:
kwadraty.cpp
I
v o i d kwadraty(t i n t n,double lg, double x, double y)
{
// n = parzysta i l o ś ć kwadratów
// x,y = punkt s t a r t o w y
i f (n & gt; 0)
{
lineto{x+lg,y) ;
lineto(x-t-lg,y+lg);
lincto(x,y+lg);
lineto(x,y+lg/2) ;
lineto(x+lg/2, y+lg) ;
lineto ( x + l g f y + I g / 2 ) ;
l i n e t o ( x + ]g/2,y) ;
lineto(x+lg/4,y+lg/4) ;
kwadraty(n-l,lg/2,x+lg/4,y-lg/4);
lineto(x,y+lg/2) ;
lineto(x,y);
)}
})
void main{)
{
// inicjuj tryb graficzny
moveto(90,50) ;
kwadraty (5, gtmaxx() /2, getx(), g e t y ( ));
;
getch();
// zamknij t r y b g r a f i c z n y
2.8. Uwagi praktyczne na temat
technik rekurencyjnych
Szczegółowy wgląd w techniki rekurencyjne uświadomił nam, że niosą one ze sobą
zarówno plusy, jak i minusy. Zasadniczą zaletą jest czytelność i naturalność
zapisu algorytmów w formie rekursywnej - szczególnie gdy zarówno problem,
jak i struktury danych z nim związane są wyrażone w postaci rekurencyjnej.
Procedury rekurencyjne są zazwyczaj klarowne i krótkie, dzięki czemu dość
łatwo jest wykryć w nich ewentualne błędy. Dużą wadą wielu algorytmów
46
__
Rozdział 2 , Rekurencja
rekurencyjnych jest pamięciożerność: wielokrotne wywołania rekurencyjne
mogą łatwo zablokować całą dostępną pamięć! Problemem jest tu jednak nie
Takt zajętości pamięci, ale typowa niemożność łatwego jej oszacowania przez
konkretny algorytm rekurencyjny. Można do tego wykorzystać metody służące
do analizy efektywności algorytmów (patrz rozdział 3), jednakże jest to dość
nużące obliczeniowo, a czasami nawet po prostu niemożliwe.
W podrozdziale Typy programów rekurencyjnych poznaliśmy metodę na
ominięcie kłopotów z pamięcią poprzez stosowanie rekurencji „z parametrem
dodatkowym " . Nie wszystkie jednak problemy dadzą się rozwiązać w ten sposób,
ponadto programy używające tej metody tracą odrobinę na czytelności. No cóż,
nic ma róży bez kolców...
Kiedy nie należy używać rekurencji? Ostateczna decyzja należy zawsze do pro
gramisty. tym niemniej istnieją sytuacje, gdy ów dylemat jest dość łatwy do
rozstrzygnięcia. Nie powinniśmy używać rozwiązań rekurencyjnych, gdy:
•
w miejsce algorytmu rekurencyjnego można podać czytelny i/lub szybki
program iteracyjny;
•
algorytm rekurencyjny jest niestabilny (np. dla pewnych wartości
parametrów wejściowych może się zapętlić lub dawać „dziwne " wyniki).
Ostatnią uwagę podaję już raczej, by dopełnić formalności. Otóż w literaturze
można czasem napotkać rozważania na temat niekorzystnych cech tzw. nkurencji skrośnej: podprogram A wywołuje podprogram B, który wywołuje z kolei
podprogram A. Nie podałem celowo przykładu takiego „dziwoląga " , gdyż
nadmiar złych przykładów może być szkodliwy. Praktyczny wniosek, który
możemy wysnuć analizując " osobliwe " programy rekurencyjne. pełne niepraw
dopodobnych konstrukcji, jest jeden: U N I K A J M Y ICH, jeśli tylko nie jesteśmy
całkowicie pewni poprawności programu, a intuicja nam podpowiada, że w danej
procedurze jest coś nieobliczalnego.
Korzystając z katalogów algorytmów, formalizując programowanie etc. można
bardzo łatwo zapomnieć, że wiele pięknych i eleganckich metod powstało
samo z siebie - jako przebłysk geniuszu, intuicji, sztuki... A może i my mogli
byśmy dołożyć nasze „co nieco " do tej kolekcji? Proponuję ocenić własne siły
poprzez rozwiązywanie zadań, które odpowiedzą w sposób najbardziej obiektyw
ny, czy rozumiemy rekurencję jako metodę programowania.
2.9. Zadania
47
2.9.Zadania
Wybór reprezentatywnego dla rekurencji zestawu zadań wcale nie był łatwy dla
autora tej książki - dziedzina ta jest bardzo rozległa i w zasadzie wszystko
w niej jest w jakiś sposób interesujące... Ostatecznie, co zwykłem podkreślać,
zadecydowały względy praktyczne i prostota.
Zad. 2-1
Załóżmy, że chcemy odwrócić w sposób rekurencyjny tablicę liczb całkowi
tych. Proszę zaproponować algorytm z użyciem rekurencji „naturalnej " , który
wykona to zadanie.
Zad. 2-2
Powróćmy do problemu poszukiwania pewnej zadanej liczby x w tablicy, tym
razem jednak posortowanej od wartości minimalnych do maksymalnych. Metoda
poszukiwania, bardzo znana i efektywna, (tzw. przeszukiwanie binarne) polega na
następującej obserwacji:
podzielmy tablicę o rozmiarze n na połowę:
• t[0], t[l]... t[n/2-l], t[n/2], t[n/2+1]... t[n-l]
]
• jeśli x=t[n/2J,to element x został znaleziony1;
• jeśli A & lt; t[n/2], to element x być może znajduje się w „lewej polowie "
tablicy; analizuj ją:
].
• jeśli x & gt; t[n/2|. to element x być może znajduje się w „prawej połowic "
tablicy; analizuj ją.
Wyrażenie być może daje nam furtkę bezpieczeństwa w przypadku niepowodze
nia poszukiwania. Zadanie polega na napisaniu dwóch wersji funkcji realizującej
powyższy algorytm, jednej używającej rekurencji naturalnej i drugiej - dla od
miany - nierekurencyjnej.
Rysunek 2 - 9 prezentuje działanie algorytmu dla następujących danych:
•
12-elementowa tablica zawiera liczby: 1, 2, 6, 18, 20, 23, 29, 32, 34, 39,
40,41:
• szukamy liczby 18.
1
W C++ dzielenie całkowite „obcina " wynik do liczby całkowitej (odpowiednik div w
Pascalu).
48
Rozdział 2. Rekurencja
W celu dokładniejszego przeanalizowania algorytmu posłużymy się kilkoma
zmiennymi pomocniczymi;
•
left indeks tablicy ograniczający obserwowany obszar tablicy od lewej
strony;
•
right indeks tablicy ograniczający obserwowany obszar tablicy od prawej
strony;
Rys. 2 - 9.
0
1
1
Przeszukiwanie
binarne nu przy
kładzie.
3
2
2
6
5
4
18
20
7
6
23
29
8
32
9
34
39
10
40
11
41
teft=0, right=11. mid=(left+right)/2=5 tab[mid]=23
18 & lt; 23
1
2
6
18
20
23
18 & gt; 23
18=23
29
32
34
39
40
41
left=0, right=mid=4, mid=(left+right)/2=2 tab[mid]=6
18 & lt; 6
18=6
18 & gt; 6
1
2
6
18 20
23
29
32
J4
39
40
41
left=mid=2 right=4, mid=(left+right)/2=3, tab[mid]=l8
I8 & lt; I8
18=18
18 & gt; IX
mid indeks elementu środkowego obserwowanego aktualnie obszaru
tablicy.
Na rysunku 2-9 przedstawione jest działanie algorytmu oraz wartości zmiennych
left, right i mid podczas każdego ważniejszego etapu. Poszukiwanie zakończyło
się pomyślnie już po trzech etapach2. Warto zauważyć, że to samo zadanie, roz
wiązywane za pomocą przeglądania od lewej do prawej elementów tablicy, zosta
łoby ukończone dopiero po 4 etapach. Być może otrzymany zysk nie oszałamia,
proszę sobie jednak wyobrazić, co by było, gdyby tablica miała rozmiar kilkanaście
razy większy niż ten użyty w przykładzie?! Proszę napisać funkcję, która reali
zuje poszukiwanie binarne w sposób rekurencyjny.
2
Za " etap " będziemy tu uważali moment testowania, czy dana liczba jest tą. której po
szukujemy.
2.9. Zadania
49
Zad. 2-3
Napisać funkcję, która otrzymując liczbę całkowitą dodatnią wypisze jej repre
zentację dwójkową. Należy wykorzystać znany algorytm dzielenia przez pod
stawę systemu. Przykładowo, zamieńmy liczbę 13 na jej postać binarną:
13 :
6 :;
3 :
1 :
2
2
2
2
= 6 + 1,
=3+0.
=1 + 1 .
= 0 + 1.
0 = & gt; koniec algorytmu.
Problem polega na tym, że otrzymaliśmy prawidłowy wynik, ale „od tyłu " ! Al
gorytm dał nam 1011 natomiast prawidłową postacią jest 1101. Dopiero w tym
miejscu zaczyna się właściwe zadanie:
Pyt. 1 Jak wykorzystać rekurencję do odwrócenia kolejności wypisywania cyfr?
Pyt. 2 Czy istnieje łatwe rozwiązanie tego zadania, wykorzystujące rekurencję
z „parametrem dodatkowym " ?
Zad. 2-4
Spróbuj napisać funkcję, która wymalowuje rekurencyjnie „dywanik " przed
stawiony na rysunku 2-10:
Rys. 2-10.
Trójkąty narysowane
rekurencyjnie.
2.10.Rozwiązania i wskazówki do zadań
Zad. 2-1
Idea rozwiązania jest następująca:
• zamieńmy miejscami elementy skrajne tablicy {przypadek elementarny);
• odwróćmy pozostałą część tablicy (wywołanie rekurencyjne).
50
Rozdział 2. Rekurencja
Odpowiadający temu rozumowaniu program przedstawia się następująco:
revjab.cpA
// zamiana zmiennych:
void swap{ints & a, int & b)
{
int temp=a;
a=b;
b = temp;
}}
void odwroc(int *tab,
{
if(left & lt; right)
int l e f t , i n t right)
{
swap (tab [left I] , tab [right] ) ; //zamieniamy
// elementy skrajne
odwroc(tab,left+1,rioht-1}; // odwracamy resztę
)
void main()
{
]=
int tabl[81«{l,2,3,4,5,6,7,8};
odwroc{tabl,0,7);
for(int i=0;i & lt; 8;i++)
cout & lt; & lt; tabl[i];
// przykładowe wywołanie
// sprawdzamy efekt...
Zad. 2-2
Poniżej przedstawiona jest wyłącznie wersja rekurencyjna programu. Jestem
przekonany, że Czytelnik odkryje bez trudu analogiczne rozwiązanie iteracyjne:1
int szukaj_rec (int * tab, i n t x, int left, int right)
{
{
if(left & gt; right)
return - 1 ; // element nie znaleziony
else
{
int mid=(left+right)/2;
if(tab[midl==x) return raid; // element znaleziony!
else
if (x & lt; tab [mid])
return szukaj_rec(tab,x,left,mid-l);
else
)
return szukaj rec(tab,x,mid+1,right};
1
Lub zajrzy do rozdziału 7...
binary_s.cpp
; 2.10. Rozwiązania i wskazówki do zadań
51
Zad. 2-3
Program nie należy do zbyt skomplikowanych, choć wcale nie jest trywialny.
Zastanówmy się, jak zmusić algorytm do przedstawienia wyniku w postaci
normalnej, tzn. od lewej do prawej. W tym celu przeanalizujmy raz jeszcze
działanie algorytmu bazującego na dzieleniu przez podstawę systemu liczbo
wego (tutaj 2). Liczba v jest dzielona przez dwa, co daje nam liczbę [x div 2]
plus reszta. Owa reszta to oczywiście [x mod 2] i jest to jednocześnie ostatnia
cyfra reprezentacji binarnej, którą chcemy otrzymać.
Czy jest jakiś sposób, aby odwrócić kolejność wyprowadzania cyfr dwójko
wych, korzystając ciągle z tego prostego algorytmu? Otóż tak, pod warunkiem,
że spojrzymy nań nieco inaczej. Popatrzmy, jak symbolicznie można rozpisać
tworzenie reprezentacji dwójkowej pewnej liczby x, używając już właściwych
dla C++ operatorów:
[x] 2 =[x%2][x/2],
2
Zapis ten sugeruje już, jak można rekurencyjnie przedstawić ten algorytm:
przypadek elementarny wywołanierekurencyjne
wywianie rekurencyjne
[x]=
[x%2]
[x/2]2
Jeśli w powyższym algorytmie każemy komputerowi wpierw wypisać liczbę
]
[x/2J dwójkowo, a dopiero potem [x%2] (które to wyrażenie przybiera dwie
wartości: 0 lub 1), to wynik pojawi się na ekranie w postaci normalnej, a nie
odwrócony jak poprzednio.
Warto zapamiętać tę sztuczkę, może być ona pomocna w wielu innych programach
post_2.cpp
void post_dw(unsigned long int n)
{
if(n!=0)
{
d
post_ćw(n/2); // n modulo 2
cout & lt; & lt; n % 2; // reszta z dzielenia przez 2
Co zaś się tyczy pytania drugiego, to z mojej strony mogę dać na nie odpowiedź:
być może. Rozwiązałem ten problem z użyciem rekurencji „z parametrem dodatko
wym " , ale nie udało mi się znaleźć rozwiązania na tyle eleganckiego, aby było
warte prezentacji jako odpowiedź. Być może któryś z Czytelników znajdzie więcej
czasu i dokona lego wyczynu? Gorąco zachęcam do prób - być może do niczego nie
doprowadzą, ale na pewno więcej nauczą niż lektura gotowych rozwiązań.
52
Rozdział 2- Rekurencja
Zad. 2-4
Oto jedno z możliwych rozwiązań:
1
trójkąty.cpp
void trójkąty (double n,double lg, double x, double y)
{i
// n = ilość podziałów
if (n & gt; 0)
{(
double a=lg/n;
double h=a*sqrt(3)/2.0;
lineto(x-a/2.0,y-h);
trójkąty(n-l,lg-a,x-a/2.0,y-h);
lineto(x+a/2.0,y-hj;
for(double i=l;i & lt; rn;i++)
{
lineto(x+(i-l)*a/2.0,y-[i+l)*h);
lineto(x+(i+l)*a/2.0,y-[it-i]*h);
}}
lineto ( x,y);
void main()
{
// inicjuj tryb graficzny
moveto (getmaxx ( ) / 2 , getmaxy () -10) ;
t r ó j k ą t y (6, getmaxx () / 2 , g e t x ( ) , g e t y ( ) ) ;
getch() ;
// zamknij t r y b g r a f i c z n y
}
}
Znów tryb graficzny,
inicjalizacja:
initgraph();
zamknięcie:
cosegraph();
Używane w tej książce funkcje
graficzne działają
tylko ze starym Turbo C lub
Turbo C++ Borlanda.
Jak pisałem przy tabelce parę stron
wcześniej,
jak koniecznie chcecie używać tych
funkcji to ściągnijcie sobie bibliotekę
WinBGI
-- & gt; Google
K.
Rozdział 3
Analiza sprawności algorytmów
Podstawowe kryteria pozwalające na wybór właściwego algorytmu zależą
głównie od kontekstu, w jakim zamierzamy go używać. Jeśli chodzi nam o spo
radyczne używanie programu do celów „domowych " czy też po prostu prezentacji
wykładowej, współczynnikiem najczęściej decydującym bywa prostota algorytmu.
Nieco inna sytuacja powstaje w momencie zamierzonej komercjalizacji pro
gramu, ewentualnie udostępnienia go szerszej grupie osób. Ktoś z „zewnątrz " ,
dostający do ręki dyskietkę z programem w postaci wynikowej (tzn. jako plik
binarny), jest w nikłym stopniu - jeśli w ogóle!
zainteresowany estetyką
„wewnętrzną " programu, klarownością i pięknem użytych algorytmów etc.
Użytkownik ten - zwany czasem końcowym - będzie się koncentrował na tym,
co jest dla niego bezpośrednio dostępne: rozbudowanych systemach menu, pomocy
kontekstowej, jakości prezentacji wyników w postaci graficznej... Taki punkt
widzenia jest często spotykany i programista, który zapomni go uwzględnić,
ryzykuje wyeliminowanie się z rynku programów komercyjnych.
Konflikt interesów, z którym mamy tu do czynienia, jest zresztą typowy dla
wszelkich relacji typu produeent-klient. pierwszy jest głęboko zainteresowany,
c
aby stworzyć swój produkt najtaniej i sprzedać go jak najdrożej, natomiast
drugi chciałby za niewielką sumę dostać coś najwyższej jakości...
Upraszczając dla potrzeb naszej dyskusji wyżej zaanonsowaną problematykę,
możemy wyróżnić dwa podstawowe kryteria oceny programu. Są to:
• sposób komunikacji z operatorem:
• szybkość wykonywania podstawowych funkcji programu.
W rozdziale tym zajmiemy się wyłącznie aspektem sprawnościowym wykony
wania programów, problem komunikacji - j a k o zbyt obszerny - zostawiając
może na inną okazję.
54
Rozdział 3. Analiza sprawności algorytmów
Tematyką tego rozdziału jest tzw. złożoność obliczeniowa algorytmów, czyli
próba odpowiedzi na pytanie: który z dwóch programów wykonujących to samo
zadanie (ale odmiennymi metodami) jest efektywniejszy? Wbrew pozorom w wielu
przypadkach odpowiedź wcale nie jest taka prosta i wymaga użycia dość złożonego
aparatu matematycznego. Nie będzie jednak wymagane od Czytelnika posiadanie
jakichś szczególnych kwalifikacji matematycznych - prezentowane metody bę
dą w dużym stopniu uproszczone i nastawione raczej na zastosowania praktycz
ne niż teoretyczne studia.
Istotna uwaga należy się osobom, które byłyby głębiej zainteresowane stroną
matematyczną prezentowanych zagadnień, dowodami użytych metod etc.
(Głównym kryterium doboru zaprezentowanych narzędzi matematycznych była
ich prostota. Nie każdy programista jest matematykiem i zamienianie tej książki
w podręcznik analizy matematycznej nie było bynajmniej celem autora.
Tych Czytelników, którym brakuje nieco formalizmu matematycznego, można
odesłać do dokładniejszej lektury up. [BB87], [Gri84], [Kro89] czy też klasycznych
tytułów; [Knu73], [Knu69], [Knu75].
Pomocne będą także zwykłe podręczniki matematyczne, ale należy zdawać
sobie sprawę z tego, iż częstokroć zawierają one nadmiar informacji i wyłuskanie
tego, co jest nam naprawdę niezbędne, jest znacznie trudniejsze niż w przypadku
tytułów z założenia przeznaczonych dla programistów.
3 . 1 . Dobre samopoczucie użytkownika programu
Zanurzając się w problematykę analizy sprawnościowej programów, możemy
wyróżnić min. dwa ważne czynniki wpływające na dobre samopoczucie użyt
kownika programu:
• czas wykonania (znowu się „zawiesił " , czy też coś liczy?!);
c/as
• zajętość pamięci (mam już dość komunikatów typu: Insufficient memory
save your work1 ).
*
Z uwagi na znaczne potanienie pamięci RAM w ostatnich latach to drugie
kryterium straciło już praktycznie na znaczeniu " . Co innego jest z pierwszym!
1
Ang. Brak pamięci - zachowaj swoje dane: dość częsty komunikat w pewnym przere
klamowanym edytorze tekstów dla systemu MS-Windows.
2
Stwierdzenie to jest fałszywe w odniesieniu do niektórych dziedzin techniki, niektó
re algorytmy używane w syntezie obrazu pochłaniają tyle pamięci, że w praktyce są ciągle
nieużywalne w komputerach osobistych. Ponadto należy sobie zdać sprawę, że obsługa
skomplikowanych struktur danych jest na ogól dość czasochłonna -jedno kryterium
oddziałuje zatem na drugie!
3.1. Dobre samopoczucie użytkownika programu
55
Wcale nie jest aż tak dobrze z szybkością współczesnych komputerów3, aby
przestać się tym zupełnie przejmować. Bo cóż z tego, że komputer xxxDX jest
12 razy szybszy od yyySX. jeśli dla algorytmu A i problemu P oznacza to przy
spieszenie czasu zakończenia obliczeń z... 12 lat do „zaledwie " jednego roku?!
Abstrahuję tu od tego, że nikt by algorytmu A do tego zadania nie użył. Dla tego
samego problemu znaleziono inny algorytm, który zrobił to samo w przeciągu
kilku godzin.
Jednym ze szczególnie istotnych problemów w dziedzinie analizy algorytmów
jest dobór właściwej miary złożoności obliczeniowej. Musi być to miara na tyle
reprezentatywna, aby użytkownicy np. małego komputera osobistego i potężnej
stacji roboczej - obaj używający tego samego algorytmu - mogli się ze sobą
porozumieć co do jego sprawności obliczeniowej. Jeśli ktoś stwierdzi, że jego
program jest szybki, bo wykonał się w 1 minutę, to nie dostaniemy w ten sposób
żadnej reprezentatywnej informacji. Musi on jeszcze odpowiedzieć na na
stępujące pytania:
• Jakiego komputera użył?
• Jaka jest częstotliwość pracy zegara taktującego procesor?
• Czy program był jedynym wykonującym się wówczas w pamięci? Jeśli
nie, to jaki miał priorytet?
• Jakiego kompilatora użyto podczas pisania tego programu.
• Jeśli to był kompilator XYZ, to czy zostały włączone opcje optymalizacji
kodu?
Od razu jednak widać, że daleko w ten sposób nie zajdziemy. Potrzebna jest
nam miara uniwersalna, nie mająca nic wspólnego ze szczegółami natury,
nazwijmy to, „sprzętowej " .
Parametrem decydującym najczęściej o czasie wykonania określonego algorytmu
jest rozmiar danych4 z którymi ma on do czynienia. Pojęcie rozmiaru danych
ma wielorakie znaczenie: dla funkcji sortującej tablicę będzie to po prostu
rozmiar tablicy, natomiast dla programu obliczającego wartość funkcji silnia
będzie to wielkość danej wejściowej.
Podobnie, funkcja wyszukująca dane w liście (patrz rozdział 5) będzie bardzo
„uczulona " na jej długość... Wszystkie te przypadki określa się właśnie jako rozmiar
danych wejściowych. Ponieważ odczytanie właściwego znaczenia tego terminu
Oczywiście mam na myśli komputery osobiste.
W toku dalszego wykładu okaże się, że nie jest to bynajmniej jedyny współczynnik de
cydujący o czasie wykonania programu.
56
Rozdział 3. Analiza sprawności algorytmów
jest intuicyjnie bardzo proste, dalej będziemy używać właśnie tego nieprecyzyjnego
określenia w miejsce rozwlekłych wyjaśnień cytowanych powyżej.
Powróćmy jeszcze do przykładu przytoczonego na samym początku tego roz
działu. Nieprzygotowany Czytelnik widząc stwierdzenia: „czas wykonania pro
gramu równy 12 lat " ma prawo się nieco obruszyć - czy to jest w ogóle możliwe?! W istocie, w miarę rozwoju techniki mamy do czynienia z coraz szybszymi
komputerami i być może kiedyś 12 lat mogło być nawet prawdą, ale obecnie?
Niestety, trzeba podkreślić, że podany czas wcale nie jest tak przerażająco długi...
Proszę spojrzeć na tabelę 3 - 1 . Zawiera ona krótkie zestawienie czasów wyko
nania algorytmów przy następujących założeniach:
•
niech elementarny czas wykonania wynosi jedną mikrosekundę;
•
niech pewien algorytm A ma złożoność obliczeniową 0 równą n! Wów
x
czas dla danej wejściowej o rozmiarze ,v i klasy algorytmu n! czas wy
konania programu jest proporcjonalny do x!).
5
Przy powyższych założeniach można otrzymać zacytowane w tabelce wyniki dość szokujące, zwłaszcza jeśli spojrzymy na ostatnie jej pozycje.
Teraz każdy sceptyk powinien przyznać należyte miejsce dziedzinie wiedzy po
zwalającej uniknąć nużącego, kilkusetwiecznego oczekiwania na efekt zadzia
łania programu...
Tabela 3-1.
Czasy wykonania
programów dla
algorytmów różnej
klasy.
20
10
30
40
50
60
n
0.00001 s 0.00002s 0.000 03 s
00 0 2
.00s
0.00004 s
0,000 05 s
0,000 06
n2
0,0001 s 0.000 4s 0,000 09 s
0.0016s
0,002 .1 s
0,003 6s
n3
0,001 s
0.008 s
0.027 s
0.064 s
0,125 s
0.2l6s
2n
0.001 s
1.0 s
17.9 min
12.7 dni
35.7 lat
366 w
3n
0.59 s
58 min
6.5 lat
3855 w
200 • 106 w
1,3*1013w
n!
3,6 s
768 w
8.4*10l6w
2.6*I032 vv
9,6*1048 w
2,6*1066 w
Aby dobrze
zrozumieć mechanizmy obliczeniowe używane przy analizie złożoności algo
rytmów, zgłębimy wspólnie kilka charakterystycznych przykładów obliczenio
wych. Nowe pojęcia związane z obliczaniem złożoności obliczeniowej algoryt-
5
Oznaczenia: s-sekunda, w-wiek.
3.1. Dobre samopoczucie użytkownika programu
57_
mów zostaną wprowadzone na reprezentatywnych przykładach, co wydaje się
lepszym rozwiązaniem niż zacytowanie suchych definicji.
3.2. Przykład 1: Jeszcze raz funkcja silnia...
Do zdumiewających zalet funkcji silnia należy niewątpliwie mnogość zagad
nień, które można za jej pomocą zilustrować... Z rozdziału poprzedniego pamię
tamy jeszcze zapewne rekurencyjną definicję:
0! = 1,
n! = n * ( n - 1 ) ! gdzie n & gt; = 1
Odpowiadająca tej formule funkcja w C++ miała następującą postać:
i n t s i l n i a ( i n t a)
{
if (n==0)
r e t u r n 1;
else
return n* s i l n i a ( n - 1 ) ;
}
Przyjmijmy dla uproszczenia założenie, bardzo zresztą charakterystyczne w tego
typu zadaniach, że najbardziej czasochłonną operacją jest tutaj instrukcja
porównania if Przy takim założeniu czas. w jakim wykona się program, możemy
zapisać również w postaci rekurencyjnej:
T(0) = tc
T(n) = tc + T(n-1)dla n & gt; =1.
Powyższe wzory należy odczytać w sposób następujący: dla danej wejściowej
równej zero czas wykonania funkcji, oznaczany jako T(O), równa się czasowi
wykonania jednej instrukcji porównania, oznaczonej symbolicznie przez tc,.
Analogiczny czas dla danych wejściowych & gt; 1 jest równy, zgodnie z formułą
rekurencyjną, T(n)-tc+ T(n-1).
Niestety, tego typu zapis jest nam do niczego nieprzydatny - trudno np. powie
dzieć od razu, ile czasu zajmie obliczenie silnia(100)... Widać już, że do proble
mu należy podejść nieco inaczej. Zastanówmy się. jak z tego układu wyliczyć
T(n), tak aby otrzymać jakąś funkcję nierekurencyjną pokazującą, jak czas wy-
58
Rozdział 3. Analiza sprawności algorytmów
konania programu zależy od danej wejściowej n? W tym celu spróbujmy rozpisać
równania:
T(n) = tc + T(n-1),
T(n-1) = tc + T(n-2),
T(n-2) = tc + T(n-3),
:
:
T(1) = tc + T(0),
T(0) = tc.
Jeśli dodamy je teraz stronami, to powinniśmy otrzymać:
T(n) + T(n-1)+...+T(0) = (n+1)tc + T(n-1)+...+t(0).
co powinno dać, po zredukowaniu składników identycznych po obu stronach
równości, następującą zależność:
T(n) = (n+1)tc
Jest to funkcja, która w satysfakcjonującej, nieskomplikowanej formie poka
zuje, w jaki sposób rozmiar danej wejściowej wpływa na ilość instrukcji porów
nań wykonanych przez program - czyli dc facto na czas wykonania algorytmu.
Znając bowiem parametr tc i wartość n możemy powiedzieć dokładnie w ciągu
ilu sekund (minut, godzin, lat...) wykona się algorytm na określonym komputerze.
Tego typu rezultat dokładnych obliczeń zwykło się nazywać złożonością
praktyczną algorytmu. Funkcja ta jest zazwyczaj oznaczana tak jak wyżej,
przez T.
W praktyce rzadko interesuje nas aż tak dokładny wynik. Niewiele bowiem się
zmieni, jeśli zamiast T(n) = (n+1)tc.otrzymamy T(n)=(n+3)tc !
t
L)o
D czego zmierzam? Otóż w dalszych rozważaniach będziemy głównie szukać od
powiedzi na pytanie:
Jaki typ funkcji matematycznej, występującej w zależności określającej złożo
ność praktyczną programu, odgrywa w niej najważniejszą rolę, wpływając
najsilniej na czas wykonywania programu?
3.2. Przykład 1: Jeszcze raz funkcja silnia...
59
Tę poszukiwaną funkcję będziemy zwać złożonością teoretyczną1 i z nią najczęściej
można się spotkać przy opisach „katalogowych " określonych algorytmów. Funkcja
ta jest najczęściej oznaczana przez O. Zastanówmy się, w jaki sposób możemy ją
otrzymać.
Istnieją dwa klasyczne podejścia, prowadzące z reguły do tego samego rezultatu:
albo będziemy opierać się na pewnych twierdzeniach matematycznych i je apli
kować w określonych sytuacjach, albo też dojdziemy do prawidłowego wyniku
metodą intuicyjną.
Wydaje mi się, że to drugie podejście jest zarówno szybkie, jak i znacznie przy
stępniejsze, dlatego skoncentrujemy się najpierw na nim. Popatrzmy w tym
celu na tablicę 3 - 2 zawierającą kilka przykładów „wyłuskiwania " złożoności
teoretycznej z równań określających złożoność praktyczną.
Wyniki zawarte w tej tabelce możemy wyjaśnić w następujący sposób: w rów
naniu pierwszym pozwolimy sobie pominąć stałą l i wynik nie ulegnie zna
czącej zmianie. W równaniu drugim o wiele ważniejsza jest funkcja kwadratowa
niż liniowa zależność od w; podobnie jest w równaniu trzecim, w którym dominuje
n
funkcja 2 " .
Tabela 3 - 2.
Złożoność teoretyczna
algorytmów - przykłady,
T(n)
O
3n+l
0(n)
n2-n+l
0(n2)
2n+n2+4
0(2 n )
Pojęcie funkcji O jest jednak kluczowe, zatem dla ciekawskich warto przytoczyć
formalną definicję matematyczną. W tym celu przypomnijmy następujące
oznaczenia znane z podręczników analizy matematycznej:
• N, R i są zbiorami liczb odpowiednio naturalnych i rzeczywistych (wraz z
zerem);
•
Plus (+) przy nazwie zbioru oznacza wykluczenie z niego zera (np.
N + jest zbiorem liczb naturalnych dodatnich);
• R+ będziemy oznaczać zbiór liczb rzeczywistych dodatnich lub zero;
•
Znak graficzny
•
Znak graficzny
1
oznacza przyporządkowanie;
należy czytać jako: dla każdego;
Lub klasą algorytmu - określenie zresztą znacznie częściej używane.
•
Znak graficzny
należy czytać jako: istnieje;
• Małe litery pisane kursywą na ogół oznaczają nazwy funkcji (np. g);
• Dwukropek zapisany po pewnym symbolu .S należy odczytywać: S,
taki, że... .
Bazując na powyższych oznaczeniach, klasę O dowolnej funkcji
możemy zdefiniować jako:
Jak wynika z powyższej definicji, klasa O (wedle definicji jest to zbiór funkcji)
ma charakter wielkości asymptotycznej, pozwalającej wyrazić w postaci aryt
metycznej wielkości z góry nie znane w postaci analitycznej. Samo istnienie tej
notacji pozwala na znaczne uproszczenie wielu dociekań matematycznych,
w których dokładna znajomość rozważanych wielkości nie jest konieczna.
Dysponując tak formalną definicją można łatwo udowodnić pewne „oczywiste "
wyniki, np.: T(n) = 5n3+3n2+2
O(n3)
wówczas
dowody wielu podobnych zadań.
(dobieramy doświadczalnie c=1 i n0=0
W sposób zbliżony można przeprowadzić
Funkcja O jest wielkością, której można używać w równaniach matematycznych.
Oto kilka własności, które mogą posłużyć do znacznego uproszczenia wyrażeń je
zawierających:
c
*O(f(n))
=
0(f(n)) + 0(f(n)) =
0(0(f(n)))
=
0(f(n))
0(f(n))
0(f(n))
0(f(n))0(g(n))
0(f(n)g(n))
0(f(n)g(n))
f(n)0((n))
=
=
Do ciekawszych należy pierwsza z powyższych własności, która „niweluje "
wpływ wszelkich współczynników o wartościach stałych.
Przypomnijmy elementarny wzór podający zależność pomiędzy logarytmami
o różnych podstawach:
logb h
log
x-
ln
x
ln b
3.2. Przykład 1: Jeszcze raz funkcja silnia...
61
W obliczeniach wykonywanych przez programistów zdecydowanie króluje
podstawa 2, bowiem jest wygodnie zakładać, że np. rozmiar tablicy jest wielo
krotnością liczby 2 etc.
Następnie na podstawie takich założeń częstokroć wyliczana jest złożoność
praktyczna i z niej dedukowana jego klasa, czyli funkcja O. Ktoś o bardzo rady
kalnym podejściu do wszelkich „sztucznych " założeń, mających ułatwić wyliczenie
pewnych normalnie skomplikowanych zagadnień, mógłby zakwestionować
przyjmowanie podstawy 2 za punkt odniesienia, zapytując się przykładowo
" a dlaczego nie 2.5 lub 3 " ? Pozornie takie postawienie sprawy wydaje się słuszne,
ale na szczęście tylko pozornie! Na podstawie bowiem zacytowanego wyżej wzoru
możemy z łatwością zauważyć, że logarytmy o odmiennych podstawach różnią
się pomiędzy sobą tylko pewnym współczynnikiem stałym, który zostanie
„pochłonięty " przez O na podstawie własności
cO(f(n)) = 0(f(n))
Z tego właśnie względu w literaturze mówi się, że „algorytm
" A
O(log2N) " .
Popatrzmy jeszcze na inny aspekt stosowania O-notacji. Załóżmy, że pewien
algorytm A został wykonany w dwóch wersjach W1 i W2. charakteryzujących
się złożonością praktyczną odpowiednio 100 log2N i 10N. Na podstawie
uprzednio poznanych własności możemy szybko określić, że W1 O(logN), W2
czyli Wl jest lepszy od W2. Niestety, ktoś szczególnie złośliwy mógłby
się uprzeć, że jednak algorytm W2 jest lepszy, bowiem dla np. N=2 mamy
100log22 & gt; 10*2... Wobec takiego stwierdzenia nie należy wpadać w panikę, tylko
wziąć do ręki odpowiednio duże N, dla którego algorytm W1 okaże się jednak
lepszy od W2\ Nie należy bowiem zapominać, że O-notacja ma charakter
!
asymptotyczny i jest prawdziwa dla ,.odpowiednio dużych wartości N " .
3.3. Przykład 2: Zerowanie fragmentu tablicy
Rozwiążemy teraz następujący problem: jak wyzerować fragment tablicy (tzn.
macierzy) poniżej przekątnej (wraz z nią)? Ideę przedstawia rysunek 3 - 1 .
62
Rozdział 3. Analiza sprawności algorytmów
Rys. 3 - 1.
Zerowanie
tablicy.
1
1
1
1
1
1
0
1
1
1
1
1
1
1
1
1
1
1
0
0
1
1
1
1
1
1
1
1
1
1
0
0
0
1
1
1
1
1
1
1
1
1
0
0
0
0
1
1
1
1
1
1
1
1
0
0
0
0
0
1
1
1
1
1
1
1
0
0
0
0
0
0
•
Funkcja wykonująca to zadanie jest bardzo prosta:
int tab[n] [r];
void zerowanie()
{
int i, j;
i=O;
while (i & lt; n)
{
{
j=0;
while (1 & lt; =i)
{
tab[i][j]=0;
i-i+1;
}
}}
}}
oznaczenia:
ta czas wykonania instrukcji przypisania
tc czas wykonania instrukcji porównania.
Do dalszych rozważań niezbędne będzie zrozumienie funkcjonowania pętli
typu while:
while (i & lt; =n)
{
instrukcje;
i = i+1;
i
} = i+1;
}
Jej działanie polega na wykonaniu n razy instrukcji zawartych pomiędzy nawiasami
klamrowymi, warunek natomiast jest sprawdzany n+1 razy1 .
1
Warto zauważyć, że istniejące w C++ pętle łatwo dają się sprowadzić do odmiany
pętli zacytowanej powyżej.
3.4. Przykład 3: Wpadamy w pułapkę
63
Korzystając z powyższej uwagi oraz informacji zawartych w liniach komentarza
możemy napisać:
T{n) =
Po usunięciu sumy z wewnętrznego nawiasu otrzymamy:
T(n) =
(*)
/=l
Przypomnijmy jeszcze użyteczny wzór na sumę szeregu liczb naturalnych
od i do N:
1 + 2 + 3+...+N =
N(N + 1)
2
Po jego zastosowaniu w równaniu (*) otrzymamy:
N(N+1)
T(n) = t e +t a + 2N(ta+tt) +
(tc+2ta)
2
Ostateczne uproszczenie wyrażenia powinno nam dać:
T(n) = ta(1+3N + N2) + tc 1 + 2,5tc +
N2
2
n 2)
co sugeruje od razu, że analizowany program jest klasy 0(tr).
Ufff!
Nie było to przyjemne, prawda? A problem wcale nic należał do specjalnie zło
żonych. Nie zrażajmy się jednak trudnym początkiem, wkrótce okaże się, że
można było zrobić to samo znacznie prościej! Do tego potrzebna nam będzie
odrobina wiedzy teoretycznej, dotyczącej rozwiązywania równań rekurencyj
nych. Poznamy ją szczegółowo po " przerobieniu " kolejnego przykładu zawie
rającego pewną pułapkę, której istnienie trzeba niestety co najmniej raz sobie
uświadomić.
64
Rozdział3. Analiza sprawności algorytmów
3.4. Przykład 3: Wpadamy w pułapkę
Zadania z dwóch poprzednich przykładów charakteryzowała istotna cecha: czas
wykonania programu nie zależał od wartości, jakie przybierała dana, lecz tylko
od jej rozmiaru. Niestety nie zawsze tak jest! Takiemu właśnie przypadkowi po
święcone jest kolejne zadanie obliczeniowe. Jest to fragment większego progra
mu. którego rola nie jest dla nas istotna w tym miejscu. Załóżmy, że otrzymuje
my len „wyrwany z kontekstu " fragment kodu i musimy się zająć jego analizą:
const N-10;
int t[N];
funkcja_ad_hoc()
{
i n t k,i;
int
suma=0;
w h i l e (i & lt; N)
{
{
}
//
//
//
}
tc
//
while
(j & lt; =t[i])
{
{
suma=suma+2;
ta
tc
ta
}
i=i+1;
Uprośćmy nieco problem zakładając, że;
•
najbardziej czasochłonne są instrukcje porównania, wszelkie inne
ignorujemy zaś jako nie mające większego wpływu na czas wykonania
programu.
•
zamiast pisać explicite tc wprowadzimy pojęcie czasu jednostkowego
wykonania instrukcji, oznaczając go przez 1
Niestety jedno zasadnicze utrudnienie pozostanie aktualne: nie znamy zawartości
tablicy, a zatem nie wiemy, ile razy wykona się wewnętrzna pętla while! Popa
trzmy, jak możemy sobie poradzimy w tym przypadku:
A
T(N)
=TC+
(
i=1
t[i]
ttcc
+
tc
i=1
)
(*)
A
T(n) =tc+ Ntc+
T(n)
=tc+ Ntc
i=1
tc[ i' ]t'c, .
,
+Nt[i]tc,.
c
(**)
3.4. Przykład 3: Wpadamy w pułapkę
65
T(n)=tc(1+N+Nt[i])
T(n)=max(N,Nt[i]).
Początek jest klasyczny: „zewnętrzna " suma od i do A' z równania (*) zostaje
zamieniona na N-krotny iloczyn swojego argumentu. Podobny „trik " zostaje wy
konany w równaniu (**), po czym możemy już spokojnie zająć się grupo
waniem i upraszczaniem... Czas wykonania programu jest proporcjonalny do
większej z liczb: N i Nt[i] i tylko tyle możemy na razie stwierdzić. Niestety,
kończąc w tym miejscu rozważania wpadlibyśmy w pułapkę. Naszym proble
mem jest bowiem nieznajomość zawartości tablicy, a ta jest potrzebna do
otrzymania ostatecznego wyniku! Nie możemy przecież zastosować funkcji
matematycznej do wartości nieokreślonej.
Nasze obliczenia doprowadziły zatem do momentu, w którym zauważamy brak
pełnej informacji o rozważanym problemie. Gdybyśmy przykładowo wiedzieli,
że w tym fragmencie programu, w którym „pracuje " nasza funkcja, można z dużym
prawdopodobieństwem powiedzieć, iż tablica wypełniona jest głównie zerami,
to nie byłoby w ogóle problemu! Nie mamy jednak żadnej pewności, czy rze
czywiście zajdzie taka sytuacja. Jedynym rozwiązaniem wydaje się zwrócenie
do jakiegoś matematyka, aby ten, po przyjęciu dużej ilości założeń, przeprowa
dził analizę statystyczną zadania i doprowadził do ostatecznego wyniku w sa
tysfakcjonującej nas postaci.
3.5. Przykład 4: Różne typy złożoności
obliczeniowej
Postawmy ponownie przed sobą następujące zadanie: należy sprawdzić, czy pewna
liczba x znajduje się w tablicy o rozmiarze w. Zostało już ono rozwiązane w roz
dziale 2, spróbujmy teraz napisać iteracyjną wersję tej samej procedury. Nie jest
to czynność szczególnie skomplikowana i sprowadza się do ułożenia następują
cego programu:
szukaj.cpp
c o n s t n=10;
i n t tab[n]={1,2,
3,2,-7,44,5,1,0,-3};
int szukaj(int tab[n],int x)
{// funkcja zwraca indeks poszukiwanego elementu x
i n t pos=0;
w h i l e ((pos & lt; n) & & ( t a b [ p o s ] ! = x ) )
pos++;
if(pos & lt; n)
66
Rozdział 3. Analiza sprawności algorytmów
return pos;
else
return -1;
}1
//element został znaleziony
// porażka poszukiwań
Idea tego algorytmu polega na sprawdzeniu, czy w badanym fragmencie tablicy
lewy skrajny element jest poszukiwaną wartością x. Wywołując procedurę
w następujący sposób: szukaj(tab,x) powodujemy przebadanie całej .tablicy
o rozmiarze n. Co można powiedzieć o złożoności obliczeniowej tego algorytmu.
przyjmując jako kryterium ilość porównali wykonanych w pętli while1? Na tak
sformułowane pytanie można się niestety tylko obruszyć i mruknąć ,.To zależy,
gdzie znajduje się x " ! Istotnie, mamy do czynienia z co najmniej dwoma skrajnymi
przypadkami:
•
znajdujemy się w komórce tab[0], czyli T(n)=1 i trafiamy na tzw. najIcpszy przypadek;
•
w poszukiwaniu x przeglądamy całą tablicę, czyli
na tzw. najgorszy przypadek.
T(n)=n
i trafiliśmy
Jeśli na jedno precyzyjne pytanie: " Jaka jest złożoność obliczeniowa algorytmu
liniowego przeszukiwania tablicy n-elementowej? " , otrzymujemy dwie odpo
wiedzi, obarczone klauzulami J e ś l i " , „w przypadku, gdy... " , to jedno jest pewne:
odpowiedzi na pytanie ciągle nie mamy!
Błąd tkwił oczywiście w pytaniu, które powinno uwzględniać konfigurację danych,
która w przypadki) przeszukiwania tablicy ma kluczowe znaczenie. Proponowane
odpowiedzi mogą być zatem następujące: rozważany algorytm ma w najlepszym
przypadku złożoność praktyczną równą T(n)=J, a w najgorszym przypadku
T(n)=n. Ponieważ jednak życie toczy się raczej równomiernie i nie balansuje
pomiędzy skrajnościami (co jest dość prowokacyjnym stwierdzeniem, ale
przyjmijmy chwilowo, że jest to prawda...), warto byłoby poznać również od
powiedź na pytanie: jaka jest średnia wartość T(n) tego algorytmu? Należy ono
do gatunku nieprecyzyjnych, jest zatem stworzone dla statystyka... Nie pozostaje
nam nic innego, jak przeprowadzić analizę statystyczną omawianego algorytmu.
Oznaczmy przez p; prawdopodobieństwo, że x znajduje się w tablicy tab i przy
puśćmy, że jeśli istotnie x znajduje się w tablicy, to wszystkie miejsca są jednako
wo prawdopodobne.
Oznaczmy również przez Dn,l (gdzie 0 & lt; = i & lt; n) zbiór danych, dla których x znaj
duje się na i-tym miejscu tablicy i Dnn zbiór danych, gdzie x jest nieobecne.
Wedle przyjętych wyżej oznaczeń możemy napisać, że:
3.5. Przykład 4: Różne typy złożoności obliczeniowej
67
Koszt algorytmu oznaczmy klasycznie przez T, tak więc:
=
T(D,nn)-ii oraz T(Dnn) = n.
i
Otrzymujemy zatem wyrażenie;
Przykładowo, wiedząc, że x na pewno znajduje się w tablicy (p=1). możemy
od razu napisać:
Tśrednie = (1-1)n+
(n+1)
(n+1)
2
2
Zdefiniowaliśmy zatem trzy podstawowe typy złożoności obliczeniowej (dla
przypadków: najgorszego, najkorzystniejszego i średniego), warto teraz zastanowić
się nad użytecznością praktyczną tych pojęć. Z matematycznego punktu widze
nia te trzy określenia definiują w pełni zachowanie się algorytmu, ale czy aby
na pewno robią to dobrze?
W katalogowych opisach algorytmów najczęściej mamy do czynienia z rozwa
żaniami na temat przypadku najgorszego - tak aby wyznaczyć sobie pewną
górną „granicę " , której algorytm na pewno nie przekroczy (jest to informacja naj
bardziej użyteczna dla programisty).
Przypadek najkorzystniejszy ma podobny charakter, dotyczy jednak „progu
dolnego " czasu wykonywania programu.
Widzimy, że pojęcia złożoności obliczeniowej programu w przypadkach
najlepszym i najgorszym mają sens nie tylko matematyczny, lecz dają progra
miście pewne granice, w których może on go umieścić. Czy podobnie możemy
rozpatrywać przypadek średni?
Jak łatwo zauważyć, wyliczenie przypadku średniego (inaczej to określając: typo
wego) nie jest łatwe i wymaga założenia szeregu hipotez dotyczących możliwych
konfiguracji danych. Między innymi musimy umówić się co do definicji
zbioru danych, z którym program ma do czynienia - niestety zazwyczaj nic jest to
ani możliwe, ani nie ma żadnego sensu! Programista dostający informację o średniej
złożoności obliczeniowej programu powinien być zatem świadomy tych ograniczeń
i nie brać tego parametru za informację wzorcową.
68
_ _ _ _
Rozdział 3. Analiza sprawności algorytmów
3.6. Nowe zadanie: uprościć obliczenia!
Nie sposób pominąć faktu, że wszystkie nasze dotychczasowe zadania były
dość skomplikowane rachunkowo, a tego leniwi ludzie (czytaj: programiści) nie
lubią. Jak zatem postępować, aby wykonać tylko te obliczenia, które są naprawdę
niezbędne do otrzymania wyniku? Otóż warto zapamiętać następujące
" sztuczki " , które znacznie ułatwią nam to zadanie, pozwalając niejednokrotnie
określić natychmiastowo poszukiwany wynik:
•
W analizie programu zwracamy uwagę tylko na najbardziej „czaso
chłonne " operacje (np. poprzednio były to instrukcje porównań).
•
Wybieramy jeden wiersz programu znajdujący się w najgłębiej położonej
instrukcji iteracyjnej (pętle w pętlach- a te jeszcze w innych pętlach...),
a następnie obliczamy, ile razy się on wykona. Z tego wyniku dedukujemy złożoność teoretyczną.
Pierwszy sposób był już wcześniej stosowany. Aby wyjaśnić nieco szerzej drugą
metodę, proponuję przestudiować poniższy fragmentu programu:
while
(i & lt; N)
{
w h i l e (j & lt; =N)
{
suma=suma+2;
j=j+1;
}
}
Wybieramy instrukcję suma=suma+2 i obliczamy w prosty sposób, iż wykona
N(N + 1)
się ona
razy. Wnioskujemy, że ten fragment programu ma złożo2
ność teoretyczną równą O(n2).
3.7. Analiza programów rekurencyjnych
Większość programów rekurencyjnych nie da się niestety rozważyć przy użyciu
metody poznanej w przykładzie znajdującym się w §3.2. Istotnie, zastosowana
tani metoda rozwiązywania równania rekurencyjnego, polegająca na rozpisaniu
jego składników i dodaniu układu równań stronami, nie zawsze się sprawdza. U
nas doprowadziła ona do sukcesu, tzn. do uproszczenia obliczeń - niestety, za
zwyczaj równania potraktowane w ten sposób jeszcze bardziej się komplikują...
3.7. Analiza programów rekurencyjnych
69
W tym paragrafie przedstawiona zostanie metoda mająca charakter o wiele ogól
niejszy. Ma ona swoje uzasadnienie matematyczne, którego z powodu jego skom
plikowania nie będę przedstawiał. Osoby szczególnie zainteresowane stroną mate
matyczną powinny dotrzeć bez kłopotu do odpowiedniej literatury (patrz uwagi za
mieszczone we wstępie rozdziału).
3.7.1.Terminologia
Lektura kilku następnych paragrafów wymaga od nas poznania terminologii.
którą będziemy się dość często posługiwać. Pomimo „groźnego " wyglądu, zro
zumienie poniższych definicji nie powinno Czytelnikowi sprawić szczególnych
kłopotów.
Szereg rekurencyjny liniowy SRL jest to szereg o następującej postaci:
r
Xn+r,n & gt; =0
aiXn+r-i +u(n,m)
i=1
oznaczenia:
u(n.m) nierekurencyjną reszta równania, będąca wielomianem stopnia m i
zmiennej n. Przykładowo:
• jeśli u(n,m)=3n+1 to mamy wielomian stopnia pierwszego;
• jeśli u(n,m)=2, to jest to wielomian stopnia zerowego.
uwagi:
• współczynniki a, są dowolnymi liczbami rzeczywistymi:
• r jest liczbą całkowitą.
Skomplikowaną postacią tego wzoru nie należy się przejmować, jest to po pro
stu sformalizowany zapis ogólnego równania rekurencyjnego, podany raczej
gwoli formalności niż w jakimś praktycznym celu.
Równanie charakterystyczne RC jest to wielomian sztucznie stworzony na
podstawie równania rekurencyjnego, powstały wg wzoru:
r
R(x) = Xr
r
a,xr-i
i
_
i=1
Równanie to można rozwiązać, otrzymując rozkład postaci:
p
(x-
R(x) =
i=1
J=l
m
i)
70
Rozdział 3. Analiza sprawności algorytmów
2
Przykład: SRL=xn-3xn-1+2 xn -2=0 daje R(x)=x -3x+2=(x-l)(x-2).
Otrzymane powyżej współczynniki At posłużą do skonstruowania tzw.
rozwiązania ogólnego R0 liniowego równania rekurencyjnego:
p
Pi
RO=
i
i=1
r-|
Dodatkowo będziemy potrzebować tzw. rozwiązania szczególnego RS linio
wego równania rekurencyjnego. Jego postać zależy od formy, jaką przybiera
reszta u(n.m). Oto możliwe przypadki:
• Jeśli u(n,m)=0, to:
RS=0.
• Jeśli u(n,m) jest wielomianem stopnia m i zmiennej n oraz 1 (jeden) nie
jest rozwiązaniem RC. wówczas:
RS=Q(n,m)
gdzie: Q(n,m) jest pewnym wielomianem stopnia m i zmiennej n, o współ
czynnikach nieznanych (do odnalezienia).
• Jeśli u(n,m) jest wielomianem stopnia m zmiennej n oraz 1 jest roz
wiązaniem RC wtedy:
RS= npQ(n,m),
gdzie: p jest stopniem pierwiastka.
Przykładowo, jeśli jedynka jest pierwiastkiem pojedynczym RC to p=1, jeśli pier
wiastkiem podwójnym, to p=2 itd.
• Jeśli u(n,m) = an i a nie jest rozwiązaniem RC wtedy:
RS=can.
• Jeśli u(n.m)= an i a jest pierwiastkiem stopnia p RC wtedy:
RS=cannn.
• Jeśli u(n,m)=anW(n,m) i a nie jest rozwiązaniem RC (tradycyjnie już
W(n,m) jest pewnym wielomianem stopnia m i zmiennej n), będziemy
wówczas mieli: RS= anS(n,m),
gdzie: S(n,m) jest pewnym wielomianem stopnia m i zmiennej n.
Uwaga: występujące po prawej stronie wzorów wielomiany i stałe mają cha
rakter zmiennych, które należy odnaleźć!
Rozwiązaniem równania rekurencyjnego jest suma obu równań: ogólnego i szczególnego.
3.7. Analiza programów rekurencyjnych
71
Cały ten bagaż wzorów był naprawdę niezbędny! Dla zilustrowania metody
rozwiążemy proste zadanie.
3 7 2.Ilustracja metody na przykładzie
Spójrzmy jeszcze raz na Przykład z §3.2. Otrzymaliśmy wtedy następujące
równanie;
T(0) = 1,
T(n)=1 + T(n-1).
Spróbujmy je rozwiązać nowo poznaną metodą.
ETAP 1 Poszukiwanie równania charakterystycznego:
Z postaci ogólnej SRL=T(n)-T(n-1) wynika, że RC=x-I.
ETAP 2 Pierwiastek równania charakterystycznego:
Jest to oczywiście r=1.
ETAP 3 Równanie ogólne;
|
RO=Arn, gdzie A jest jakąś stałą do odnalezienia. Ponieważ r = 1 , to RO=A
(1 podniesione do dowolnej potęgi da nam oczywiście 1). Stałą A wyliczymy
dalej.
ETAP 4 Poszukiwanie równania szczególnego:
Wiemy, że u(n,m)=1 (jeden jest wielomianem stopnia zero!). Ponadto 1 jest pier
wiastkiem pierwszego stopnia równania charakterystycznego. Tak więc:
J
S=mpc=nc.
Pozostaje nam jeszcze do odnalezienia stała c. Wiemy, że RS musi spełniać
pierwotne równanie rekurencyjne, zatem po podstawieniu go jako T(n)
otrzymamy;
nc=1+(n-1)c,
n . c = 1 + (n-1)c,
1 + .c c
n . c = 1 + n (n --1)c
c
=1
ETAP 5 Poszukiwanie ostatecznego rozwiązania:
Wiemy, że ostatecznym rozwiązaniem równania jest suma R() i RS:
T(n)=RO+RS= A+n*c=A+n. Stałą A możemy z łatwością wyliczyć poprzez
podstawienie przypadku elementarnego:
72
Rozdział 3. Analiza sprawności algorytmów
T(0) 1, ,
T(0)== 1
1
= A + 0,
A
=
1.
Po tych karkołomnych wyliczeniach otrzymujemy: T(n)-n+1
Jest to jest to identyczne z poprzednim rozwiązaniem .
Metoda równań charakterystycznych jest jak widać bardzo elastyczna. Pozwala
ona na szybkie określenie złożoności algorytmicznej nawet dość rozbudowanych
programów. Są oczywiście zadania wymagające interwencji matematyka, ale
zdarzają się one rzadko i dotyczą zazwyczaj programów rekurencyjnych o nikłym
znaczeniu praktycznym.
3.7.3.Rozkład „logarytmiczny "
Z rozdziału poprzedniego pamiętamy zapewne zadanie poświęcone przeszukiwa
niu binarnemu. Jedną z możliwych wersji funkcji " wykonującej to zadanie jest:
i n t binary_search(int * t a b , i n t x , i n t l e f t , i n t right)
{
if(left==right)
if (t[left]==x)
return left;
else
return -1 ;
else
}
int mid= (left+right)/2;
if(tab[mid]==x)
return mid;
else
if(x & lt; tab[mid])
return
else
return
}
// element znaleziony
// element nie odnaleziony
// element znaleziony!
szukaj_rec(tab,x,left,mid);
szukaj_ec(tab,x,mid,right);
Jaka jest złożoność obliczeniowa tej funkcji? Analiza ilości instrukcji porównań
prowadzi nas do następujących równości:
1
Jeśli dwie metody prowadzą do takiego samego, prawidłowego wyniku, to istnieje duże
prawdopodobieństwo, iż obie są dobre...
2
Innej niż poprzednio zaproponowana.
3.7. Analiza programów rekurencyjnych
73
T(1) = 1 + 1 =2,
Widać już, że powyższy układ nijak się ma do podanej poprzednio metody.
W określeniu równania charakterystycznego przeszkadza nam owo dzielenie n
przez 2. Otóż można z tej pułapki wybrnąć, np. przez podstawienie n=2ps ale ciąg
dalszy obliczeń będzie dość złożony. Na całe szczęście matematycy zrobili w tym
miejscu programistom miły prezent: bez żadnych skomplikowanych obliczeń
można określić złożoność tego typu zadań, korzystając z kilku gotowych reguł.
„Prezent " ten jest tym bardziej cenny, że zadania o rozkładzie podobnym do powyż
szego występują bardzo często w praktyce programowania. Przed ostatecznym jego
rozwiązaniem musimy zatem poznać jeszcze kilka wzorów matematycznych, ale
obiecuję, że na tym już będzie koniec, jeśli chodzi o matematykę „wyższą " *...
Załóżmy, że ogólna postać otrzymanego układu równań rekurencyjnych przed
stawia się następująco:
T(1) = 1,
T(n) = aT
+ d(n).
(Przy założeniu, że n & gt; =2 oraz a i b są pewnymi stałymi).
W zależności od wartości a, b i d(n) otrzymamy różne rozwiązania zgrupowane
tablicy 3 - 3 .
Tabela 3 - i.
Czasy wykonania
programów dla
algorytmów różnej
klasy.
logba
)
T(n)
O(n
T(n)
O(n logbd(b) )
T(n)
logbd(b)
O(n
gdy d(n)=na to T(n)
logbn) gdy d(n) = na to T(n)
O(na)=O(d(n))
O(nalogbn)
Wzory
te są wynikiem dość skomplikowanych wyliczeń bazujących na następujących
założeniach:
• w jest potęgą b co pozwala wykonać podstawienie n=bk sprowadzające
równanie nieliniowe do równania (bk)=aT(bk-1)+d(bk). Podstawiając
ponadto t k -T(b k ) otrzymujemy równanie liniowe tk=atk+1+d(bk) z wa
runkiem początkowym t0=1. Dyskuja wyników tego równania prowadzi
do wniosków końcowych, przedstawonych w tabeli 3-3;
74
Rozdział 3. Analiza sprawności algorytmów
• funkcja d(n) musi spełniać następującą własność: d(xy)=d(x)d(y) (np.
d(n)=n2 spełnia tę własność, a d(n)=n-1 już nie).
Pomimo tych ograniczeń okazuje się, iż bardzo duża klasa równań może być
dzięki powyższym wzorom z łatwością rozwiązana. Spróbujmy dla przykładu
skończyć zadanie dotyczące przeszukiwania binarnego. Jak pamiętamy, otrzyma
liśmy wówczas następujące równania:
T(1)=2.
()
T(n) = 2 + T
Patrząc na zestaw podanych powyżej wzorów widzimy, że nie jest on zgodny ze
„wzorcem " podanym wcześniej. Nic nie stoi jednak na przeszkodzie, aby za
pomocą prostego podstawienia doprowadzić do postaci, która będzie nas satys
fakcjonowała:
U(1) = T(1)-l =1,
U(n) = T(n)-l
U(n) = U
T(n) -1 =1 + T
+ 1.
Identyfikujemy wartości stałych: a=1, b=2 i d(n)-1, co pozwala nam zauważyć,
iż zachodzi przypadek trzeci: a=d(b). Poszukiwany wynik ma zatem postać:
U(n)
O(n log21 log2n) = O(n0 log2n) = O(log2n).
3,7.4-Zamiana dziedziny równania rekurencyjnego
Pewna grupa równań charakteryzuje się zdecydowanie nieprzyjemnym wyglądem
i nijak nie odpowiada podanym uprzednio wzorom i metodom. Czasem jednak
zwykła zmiana dziedziny powoduje, iż rozwiązanie pojawia się niemal natych
miastowo. Przeanalizujmy następujący przykład:
an = 3 n-1 dla n & gt; =1,
a0 =1.
2
Równanie nie jest zgodne z żadnym poznanym wcześniej schematem. Pod
stawmy jednak b n =log 2 a n i zlogarytmujmy obie strony równania:
log2an = log2(32n-1)
3.7. Analiza programów rekurencyjnych
75
otrzymując w efekcie;
6=2bn-1+3log23
b
0
=0
równanie
liniowe
Zadanie w tej postaci nadaje się j u ż do rozwiązania! Po dokonaniu niewiel
kich obliczeń możemy otrzymać: h n =(2 n -1)log 2 3 co ostatecznie daje
an=2
(2n-1)log23
=3
n
2 -1
3.7.5.Funkcja Ackermanna, czyli coś dla smakoszy
Gdyby małe dzieci znały się odrobinę na informatyce, to rodzice na pewno by je
straszyli nie kominiarzem, ale funkcją Ackermanna. Jest to wspaniały przykład
ukazujący, jak pozornie niegroźna „z wyglądu " funkcja rekurencyjna może być
kosztowna w użyciu. Spójrzmy na listing:
i n t A(int n, i n t p)
{
if (n==0)
r e t u r n 1;
i f ((p==)} & & (n & gt; =l))
if (n==1)
r e t u r n 2;
else
r e t u r n n+2;
if
((p & gt; =l) & i(n & gt; =l))
return A(A(n-1,p),p-1);
a.cpp
}
Pytanie dotyczące tego programu brzmi; jaki jest powód komunikatu Stack
overflow! (ang. przepełnienie stosu) podczas próby wykonania go? Komunikat
ten sugeruje jednoznacznie, iż podczas wykonania programu nastąpiła znacz
na ilość wywołań funkcji Ackermanna. Jak znaczna, okaże się już za chwile...
Pobieżna analiza funkcji A prowadzi do następującego spostrzeżenia:
& gt; =1, A(n,1) = A(A(n-l,1),0) = A(n-1,1)+2,
co daje natychmiast
& gt; 1, A(n,l) = 2n.
76
Rozdział 3. Analiza sprawności algorytmów
Analogicznie dla 2 otrzymamy:
& gt; 1, A(n,2) = A(A(n -1,2),1) = 2A(n-1,2).
co z kolei pozwala nam napisać, że:
& gt; =1, A(n.2) = 2n.
Z samej definicji funkcji Ackermanna możemy wywnioskować, że:
1A(n,3) = A(A(n-1,3),2) = 2 A(n-1,3)
oraz A(0,3) = 1.
Na bazie tych równań możliwe jest rekurencyjne udowodnienie, że:
& gt; 1, A(n,3) = 2
Nieco gorsza sytuacja występuje w przypadku A(n,4), gdzie trudno jest podać
„wzór ogólny " . Proponuję spojrzeć na kilka przykładów liczbowych:
2.
A(2,4) =
22=4.
8
A(3,4) = 2 =
65536.
A(4,4) = 2
Wyrażenie w formie liczbowej A(4,4) jest - co może będzie zbyt dyplomatycz
nym stwierdzeniem - niezbyt oczywiste, nieprawdaż? W przypadku funkcji
Ackermanna trudno jest nawet nazwać j e j klasę — stwierdzenie, że zachowuje
się ona wykładniczo, może zabrzmieć jak kpina!
3.8.Zadania
Zad. 3-1
Proszę rozważyć problem prawdziwości lub fałszu poniższych równań:
2
a) T(n )
O(n3);
3.8. Zadania
77
T(n2)
O(n3)
T(2n+1)
O(2n)
T((2n+1)!)
T(n)
O(n)
O(n!)
{T(n)}2
O(n2)
f) Twój własny przykład?
Zad. 3-2
Jednym z analizowanych już wcześniej przykładów był tzw. ciąg Fibonacciego.
Funkcja obliczająca elementy tego ciągu jest nieskomplikowana:
int fib(int n)
{
{
if
else
if
else
}}
(n==0)
r e t u r n 1;
(n==1)
return 1;
return f i b ( n - 1 ) + f i b ( n - 2 ) ;
Proszę określić, jakiej klasy jest to funkcja.
Zad. 3-3
Proszę przeanalizować jeden ze swoich programów, taki. w którym jest dużo
wszelkiego rodzaju zagnieżdżonych pętli i tego rodzaju skomplikowanych kon
strukcji. Czy nie dałoby się go zoptymalizować w jakiś sposób?
Prz4ykładowo często się zdarza, że w pętlach są inicjowane pewne zmienne i to
za każdym przebiegiem pętli, choć w praktyce wystarczyłoby je zainicjować
tylko raz. W takim przypadku „sporną " instrukcję przypisania „wyrzuca się "
przed pętlę, przyspieszając jej działanie. Podobnie odpowiednio układając ko
lejność pewnych obliczeń, można wykorzystywać częściowe wyniki, będące rezultatem pewnego bloku instrukcji, w dalszych blokach - pod warunkiem
oczywiście, że nie zostały „zamazane " przez pozostałe fragmenty programu.
Zadanie polega na obliczeniu złożoności praktycznej naszego programu przed
i po optymalizacji i przekonaniu się „na własne oczy " o osiągniętym (ewentualnie)
przyspieszeniu.
78
Rozdział 3. Analiza sprawności algorytmów
Zad. 3-4
Proszę rozwiązać następujące równanie rekurencyjne:
un=un-1-un*un-1(dla n & gt; =1),
uo = 1
3.9. Rozwiązania i wskazówki do zadań
Zad. 3-1
Równanie rekurencyjne ma postać:
T(0) = 1,
T(1) = 2,
T(n) = 2 + T(n-l) + T(n-2).
Mimo dość skomplikowanej postaci w zadaniu tym nie kryje się żadna pułapka
i rozwiązuje " się " ono całkiem przyjemnie. Spójrzmy na szkic rozwiązania:
FTAP 1 Poszukiwanie równania charakterystycznego:
Z postaci ogólnej SRL: T(n)- T(n-1)-T(n-2) wynika, że RC=x2-x-l.
ETAP 2 Pierwiastki równania charakterystycznego:
Po prostych wyliczeniach otrzymujemy dwa pierwiastki tego równania kwa1- 5
1+ 5 i
ri =
r2 =
2
2
FTAP 3 Równanie ogólne:
Z teorii wyłożonej
wcześniej wynika, że równanie ogólne ma posiać
RO = Ar1n + Br2n zostawmy je chwilowo w tej formie.
FTAP 4 Poszukiwanie równania szczególnego:
Wiemy, że u(n,m)=2 i jest to wielomian stopnia zero. Z teorii wynika, że musimy
odnaleźć również jakiś wielomian stopnia zerowego, czyli mówiąc po ludzku:
pewną stałą c. Równanie szczególne jest rozwiązaniem równania rekurencyjnego. zatem możemy je podstawić w miejsce T(n), T(n-I) i T(n-2) (Tutaj n nie
gra żadnej roli!). Wynikiem tego podstawienia będzie oczywiście c=2+c+c = & gt;
ETAP 5 Poszukiwanie ostatecznego rozwiązania:
Poszukiwanym rozwiązaniem jest suma RO i RS:
n
n
T(n) = RO + RS = ( Ar1 + Br2 ) + ( - 2 ) = Ar1 + Br2 - 2.
n
n
Pozostają nam do odnalezienia tajemnicze stałe A i B. Do tego celu posłu
żymy się warunkami początkowymi (tzn. przypadkami elementarnymi, aby
pozostać w zgodzie z terminologią z rozdziału 2) układu równań rekuren
cyjnych (T(0)=1 i T(1)=2). Po wykonaniu podstawienia otrzymamy:
1 = A + B-2,
2 = Ar1 + Br2 - 2.
Jest to prosty układ dwóch równań z dwoma niewiadomymi (A i B). Jego wy
liczenie powinno nam dać poszukiwany wynik. Skończenie tego zadania pozo
stawiam Czytelnikowi.
Zad. 3-4
Oto szkic rozwiązania:
Załóżmy, że un !=0, CO pozwoli nam podzielić równania przez unun-1:
11 1
un-1 un
Podstawmy wówczas vn = 1 -1
un
1
un
1.
co da nam bardzo proste równanie, z którym już
mieliśmy prawo wcześniej się spotkać:
VN=VN-1
+ 1,
vn - 1 .
Jego rozwiązaniem jest oczywiście vn=n+1 (patrz §3.2.) Po powrocie do
pierwotnej dziedziny otrzymamy dość zaskakujący w y n i k : u n =
1
n+1
Rozdział 4
Algorytmy sortowania
Tematem tego rozdziału będzie opis kilku bardziej znanych metod sortowania
danych. O użyteczności tych zagadnień nie trzeba chyba przekonywać; każdy
programista prędzej czy później z tym zagadnieniem musi mieć do czynienia.
Opisy metod sortowania będą dotyczyły wyłącznie tzw. sortowania wewnętrznego,
używającego wyłącznie pamięci głównej komputera 1 . Po sporych wahaniach zde
cydowałem się jednak nie podejmować problematyki tzw. sortowania zewnętrz
nego . Sortowanie zewnętrzne dotyczy sytuacji, z którą większość Czytelników
być może nigdy się nie zetknie w praktyce programowania: ilość danych do
sortowania jest tak olbrzymia, że niemożliwa do umieszczenia w pamięci w celu
posortowania ich przy pomocy jednej z wielu metod sortowania " wewnętrznego " .
Dlaczego przyjmuję tak optymistyczną hipotezę? W chwili obecnej jest zauwa
żalne systematyczne tanienie nośników pamięci R A M i dysków twardych. Pro
ces ten jest nieodwracalny i jedyne, o co się można spierać, to stopień jego za
awansowania.
Mój pierwszy prywatny komputer osobisty typu I B M PC XT miał 1MB R A M
i dysk twardy 20MB. Od tego momentu minęło zaledwie kilka lat: dzisiaj większość
programów nie chciałaby zwyczajnie wystartować z tak małą ilością pamięci, a ob
jętość 20MB jest wystarczająca na instalację pojedynczego programu... Coraz gło
śniej zaczyna być o bazach danych całkowicie rezydujących w pamięci (dla zwięk
szenia sprawności), rzecz niewyobrażalna kilka lat temu w praktyce. W konsekwen
cji takiego status quo zdecydowałem się nie poświęcać sonowaniu zewnętrznemu
specjalnej uwagi. Osoby zainteresowane odnajdą szczegółowe informacje na przy
kład w [ A H U 8 7 ] , [FGS90], [Knu75], [Sed92] - mam nadzieję, że większość
Czytelników odniesie się do tego posunięcia ze zrozumieniem.
1
Ang. internal sorting.
2
Ang. external sorting.
82
Rozdział 4. Algorytmy sortowania
Potrzeba sortowania danych jest związana z typowo ludzką chęcią gromadzenia
i/lub porządkowania. Darujmy sobie jednak pasjonującą dyskusję na temat so
cjalnych aspektów sortowania i skoncentrujmy się raczej na zagadnieniach czysto
algorytmicznych...
Istotnym problemem w dziedzinie sortowania danych jest ogromna różno
rodność algorytmów wykonujących to zadanie. Początkujący programista często
nie jest w stanie samodzielnie dokonać wyboru algorytmu sortowania najod
powiedniejszego do konkretnego zadania. Jedno z możliwych podejść do te
matu polegałoby zatem na krótkim opisaniu każdego algorytmu, wskazaniu je
go wad i zalet oraz podaniu swoistego rankingu jakości. Wydaje mi się jednak,
że lego typu prezentacja nie spełniłaby dobrze swojego zadania informacyjnego, a
jedynie sporo zamieszała w głowie Czytelnika. Skąd to przekonanie? Z po
bieżnych obserwacji wynika, że programiści raczej używają dobrze sprawdzo
nych „klasycznych " rozwiązań, takich jak np. sortowanie przez wstawianie,
sortowanie szybkie, sortowanie bąbelkowe, niż równie dobrych (jeśli nie lep
szych) rozwiązań, które służą głównie jako tematy artykułów czy też przyczyn
ki do badań porównawczych z dziedziny efektywności algorytmów.
Aby nie powiększać entropii wszechświata, skoncentrujemy się na szczegóło
wym opisie tylko kilku dobrze znanych, wręcz „wzorcowych " metod. Będą to
algorytmy charakteryzujące się różnym stopniem trudności (rozpatrywanej
w kontekście wysiłku poświęconego na pełne zrozumienie idei) i mające
odmienne „parametry czasowe " . Wybór tych właśnie, a nie innych metod jest
dość arbitralny i pozostaje mi tylko żywić nadzieję, że zaspokoi on potrzeby jak
największej grupy Czytelników.
4.1. Sortowanie przez wstawianie,
2
algorytm klasy 0(N )
Metoda sortowania przez wstawianie jest używana bezwiednie przez większość
graczy podczas układania otrzymanych w rozdaniu kart. Rysunek 4 - 1 przed
stawia sytuację widzianą z punktu widzenia gracza będącego w trakcie tasowa
nia kart, które otrzymał on w dość „podłym " rozdaniu:
Rys. 4 - /.
Sortowanie przez
wstawianie (I).
Karty posortowane
Karty do posortowania
2)
4.1. Sortowanie przez wstawianie, algorytm klasy 0(N2)
83
Idea tego algorytmu opiera się na następującym niezmienniku: w danym
momencie trzymamy w ręku karty posortowane3 oraz karty pozostałe do po
sortowania. W celu kontynuowania procesu sortowania bierzemy pierwszą z brzegu
kartę ze sterty nieposortowanej i wstawiamy ją na właściwe miejsce w pakiecie
już wcześniej posortowanym.
Popatrzmy na dwa kolejne etapy sortowania. Rysunek 4 - 2 obrazuje sytuację
już po wstawieniu karty '10' na właściwe miejsce, kolejną kartą do wstawienia
będzie '6'.
Rys. 4 - 2.
Sortowanie przez
wstawianie (2).
Karty posortowane
Karty do posortowania
Tuż po poprawnym ułożeniu szóstki otrzymujemy „rozdanie " z rysunku 4 - 3
Rys. 4 - 3.
Sortowanie przez
wstawianie (3).
Karty posortowane
Karty do posortowania
Widać już, że algorytm jest nużąco jednostajny i... raczej dość wolny.
Ciekawa odmiana tego algorytmu realizuje wstawianie poprzez przesuwanie
zawartości tablicy w prawo o jedno miejsce w celu wytworzenia odpowiedniej
luki, w której następnie umieszcza ów element. Skąd mamy wiedzieć, czy kon
tynuować przesuwanie zawartości tablicy podczas poszukiwania luki? Podjęcie
decyzji umożliwi nam sprawdzanie warunku sortowania (sortowanie w kierunku
wartości malejących, rosnących czy też wg innych kryteriów). Popatrzmy na
tekst programu:
3
Na samym początku algorytmu możemy mieć puste ręce, ale dla zasady twierdzimy
wówczas, że trzymamy w nich zerową ilość kart.
84
Rozdział 4. Algorytmy sortowania
insert.cpp
void InsertSort(int *tab)
{
for(int i=1; i & lt; n;i++)
{{
int j=i;
// 0..i-l są posortowane
int temp=tab[j];
while ((j & gt; 0) & & (tab[j-l] & gt; temp))
{
tab[j]=tab[j-l];
j--;
}
}
tab [j ]=temp;
}
Algorytm sortowania przez wstawianie charakteryzuje się dość wysokim kosztem:
jest on bowiem klasy 0(N2), co eliminuje go w praktyce z sortowania dużych
tablic. Niemniej jeśli nic zależy nam na szybkości sortowania, a potrzebujemy
algorytmu na tyle krótkiego, by się w nim na pewno nic pomylić - to wówczas
jest on idealny w swojej niepodważalnej prostocie.
Uwaga: Dla prostoty przykładów będziemy analizować Jedynie sortowanie
tablic liczb całkowitych. W rzeczywistości sortowaniu podlegała naj
częściej tablice lub listy rekordów; kryterium sortowania odnosi się
wówczas do jednego z pól rekordu. (Patrz również §5.1.3).
4.2. Sortowanie bąbelkowe, algorytm klasy
2
0(N )
Podobnie jak sortowanie przez wstawianie, algorytm sortowania bąbelko
wego charakteryzuje się olbrzymią prostotą zapisu. Intrygująca jego nazwa
wzięła się z analogii pęcherzyków powietrza ulatujących w górę tuby wypełnio
nej wodą - o ile postawioną pionowo tablicę potraktować jako pojemnik z wo
dą, a liczby jako pęcherzyki powietrza. Najszybciej ulatują do góry „bąbelki "
najlżejsze - liczby o najmniejszej wartości (przyjmując oczywiście sortowanie
w kierunku wartości niemalejących). Oto pełny tekst programu:
bubble.cpp
void bubble(int *tab)
{
for(int i=l;i & lt; n;i++)
4.2, Sortowanie bąbelkowe, algorytm klasy 0(N2)
for ( i n t j = n - l ; j & gt; i ; j — )
if (tab[j] & lt; tab[j-l])
{ // zamiana tab[j-1]
i n t trr,p=tab [ j - 1 ] ;
tab[j-l]=cab|j);
tab[j]=tmp;
}
85
z tab[j]
})
Przeanalizujmy dokładnie sortowanie bąbelkowe pewnej 7-clementowej tablicy Na
rysunku 4 - 4 element „zacieniowany " jest tym, który w pojedynczym przebiegu
głównej pętli programu „uleciał " do góry jako najlżejszy. Tablica jest przemiatana sukcesywnie od dołu do góry (pętla imiennej i). Analizowane są zawsze
dwa sąsiadujące ze sobą elementy (pętla zmiennej j): jeśli nie są one uporząd
kowane (u góry jest element „cięższy " ), to następuje ich zamiana. W trakcie
pierwszego przebiegu na pierwszą pozycję tablicy (indeks 0) ulatuje element
„najlżejszy " , w trakcie drugiego przebiegu drugi najlżejszy wędruje na drugą
pozycję tablicy (indeks 1) i tak dalej, aż do ostatecznego posortowania tablicy.
Strefa pracy algorytmu zmniejsza się zatem o 1 w kolejnym przejściu dużej pętli
- analizowanie za każdym razem całej tablicy byłoby oczywistym marno
trawstwem!
Rys. 4 - 4.
Sortowanie
„bąbelkowe " .
Nawet dość pobieżna analiza prowadzi do kilku negatywnych uwag na temat
samego algorytmu:
•
dość często zdarzają się „puste przebiegi " (nie jest dokonywana żadna
wymiana, bowiem elementy są już posortowane);
•
algorytm jest bardzo wrażliwy na konfiguracje danych. Oto przykład
dwóch niewiele różniących się tablic, z których pierwszą wymaga jednej
86
Rozdział 4. Algorytmy sortowania
zamiany sąsiadujących ze sobą elementów, a druga będzie wymagać ich
aż sześciu:
wersja 1:
wersja 2:
4 2 6 18 20 39 40
4 6 18 20 39 40 2.
Istnieje kilka możliwości poprawy jakości tego algorytmu - nie prowadzą one co
prawda do zmiany jego klasy (w dalszym ciągu mamy do czynienia z O(N2)),
ale mimo to dość znacznie go przyśpieszają. Ulepszenia te polegają odpo
wiednio na:
• zapamiętywaniu indeksu ostatniej zamiany (walka z „pustymi prze
biegami " );
• przełączaniu kierunków przeglądania tablicy (walka z niekorzystnymi
konfiguracjami danych).
Tak poprawiony algorytm sortowania bąbelkowego nazwiemy sobie po polsku
sortowaniem przez wytrząsanie (ang. shaker-sort). Jego pełny tekst jest zamiesz
czony poniżej, lecz tym razem już bez tak dokładnej analizy, jak poprzednio:
shaker.epp
void ShakerSort(int *tab)
{
{
int ieft=l,right=n-1,k=n-1;
do
{
for(int j=right; j & gt; left; j—)
if(tab[j-l] & gt; tab[j])
{{
zamiana(tab[j-1], tab[i]) ;
k=j;
}
left=k+1;
f o r ( j - l e f t ; j & lt; r i g h t ; j++)
if(tab[j-l] & gt; tabl[j])
{
zamiana ( t a b [ j - 1 ] , t a b [ j ] ) ;
k= j ;
}
right=k-1;
}while (left & lt; right);
}}
!
87
4.3. Quicksort, algorytm klasy 0(Nlog 2 N)
Jest to słynny a l g o r y t m , zwany również po polsku sortowaniem szybkim.
Należy on do tych rozwiązań, w których poprzez odpowiednią dekompozycję
osiągnięty został znaczny zysk szybkości sortowania. Procedura sortowania
dzieli się na dwie zasadnicze części: część służącą do właściwego sortowania,
która nie robi w zasadzie nic robi... oprócz wywoływania samej siebie, oraz
procedury rozdzielania elementów tablicy względem wartości pewnej komórki
tablicy służącej za oś (ang. pivot) podziału. Proces sortowania jest dokonywany
przez tę właśnie procedurę, natomiast rekurencja zapewnia „sklejenie " w y n i
ków cząstkowych i w konsekwencji posortowanie całej tablicy.
Jak dokładnie działa procedura podziału? Otóż w pierwszym momencie odczytuje
się wartość elementu osiowego P, którym zazwyczaj jest po prostu pierwszy
element analizowanego fragmentu tablicy. Tenże fragment tablicy jest następ
nie dzielony 2 wg klucza symbolicznie przedstawionego na rysunku 4 - 5 .
Kolejnym etapem jest zaaplikowanie procedury Quicksort na „ l e w y m " i „ p r a w y m "
fragmencie tablicy, czego efektem będzie j e j posortowanie. To wszystko!
Rys. 4 - S.
Podział tablicy
element & lt; 'P' element osiowy 'P' element & gt; =
'P'
w metodzie Quicksort.
Na rysunku 4 - 6 są przedstawione symbolicznie dwa główne etapy sortowania
metodą Quicksort (P oznacza tradycyjnie komórkę tablicy służącą za „ o ś " ) .
Rys. 4 - 6.
Zasada działania pro
cedury Quicksort.
QuickSort
'P'
Quicksort
& lt; 'P'
1
Quicksort
& gt; ='P'
'P' & gt; = 'P'
Patrz C.A.R. Hoare - „Quicksort " w Computer Journal, 5, 1(1962).
2 Elementy tablicy są fizycznie przemieszczane, jeśli zachodzi potrzeba.
Rozdział 4. Algorytmy sortowania
88
Jest chyba dość oczywiste, że wywołania rekurencyjne zatrzymają się w mo
mencie, gdy rozmiar fragmentu tablicy wynosi 1 - nic ma już bowiem czego
sortować.
Przedstawiona powyżej metoda sortowania charakteryzuje się olbrzymią prostotą,
wyrażoną najdoskonalej przez zwięzły zapis samej procedury:
void Quicksort(int *tab,int left, int right)
{
if (left & lt; right)
}
int m;
// Podziel tablicę względem elementu osiowego:
// P=pierwszy element, czyli tab[left];
// Pod koniec podziału element 'p' zostanie
// przeniesiony do komórki numer 'm';
Quicksort(tab,left,m-1);
Quicksort(cab,m+l,right) ;
}}
Jak najprościej zrealizować fragment procedury sprytnie ukryty za komenta
rzem? Jego działanie jest przecież najistotniejszą częścią algorytmu, a my jak
dotąd traktowaliśmy go dość ogólnikowo. Takie postępowanie wynikało z dość
prozaicznej przyczyny: Quicksort-ów jest mnóstwo i różnią się one właśnie
realizacją procedury podziału tablicy względem wartości „osi'1.
Oszczędzając Czytelnikowi dylematów dotyczących wyboru „właściwej " wer
sji zaprezentujemy poniżej - zgodnie z prawdziwymi zasadami współczesnej
demokracji - tę najwłaściwszą...
Kryteriami wyboru były: piękno, szybkość i prostota - tych cech można nie
wątpliwie doszukać się w rozwiązaniu przedstawionym w [Bcn92].
Pomysł opiera się na zachowaniu dość prostego niezmiennika w aktualnie
„rozdzielanym " fragmencie tablicy (patrz rysunek 4 - 7).
Rys. 4 - 7.
Budowa niezmiennika
dla algorytmu
Quicksort.
P
left
& lt; F
P
fragment niezbadany
& gt; =P
m
i
Oznaczenia:
left lewy skrajny indeks aktualnego fragmentu tablicy;
right prawy skrajny indeks aktualnego fragmentu tablicy;
right
U. Quicksort, algorytm klasy Q(Nlog2N)
log2N)
89
•
P wartość „osiowa " (zazwyczaj będzie to tab[left]):
•
i indeks przemiatający tablice od left do right;
•
m poszukiwany indeks komórki tablicy, w której umieścimy element
osiowy.
Przemiatanie tablicy służy do poukładania jej elementów w taki sposób, aby
po lewej stronie m znajdowały się wartości mniejsze od elementu osiowego, po
prawej zaś - większe lub równe. W tym celu podczas przemieszczania indeksu
sprawdzamy prawdziwość niezmiennika tab[i] & gt; P. Jeśli jest on fałszywy, to poprze
inkrementację i wymianę wartości tab[m] i tab[i] przywracamy „porządek " . Gdy
zakończymy ostatecznie przeglądanie tablicy w pogoni za komórkami, które nie
chciały się podporządkować niezmiennikowi, zamiana tab[left] i tab[m] do
prowadzi do oczekiwanej sytuacji, przedstawionej wcześniej na rysunku 4 - 7 .
Nic już teraz nie stoi na przeszkodzie, aby zaproponować ostateczną wersję pro
cedury Quicksort. Omówione wcześniej etapy działania algorytmu zostały połą
czone w jedną procedurę:
qsort. cpp
void qsort(int " tab, int left,
int right)
{{
if (left & lt; right)
{{
int m=left;
for(int i=left-l;i & lt; right;i++)
if
(tab[i] & lt; tab[left])
zamiana(tab[++m],tab[i]);
zamiana(tab[left],tab[m]);
qsort (tab, l e f t , n - l ) ;
qsort(tab,m+1,right);
}
)
})
W celu dobrego zrozumienia działania algorytmu spróbujmy posortować nim
„ręcznie " małą tablicę, np. zawierającą następujące liczby:
2 9 , 4 0 , 2 , 1,6, 1 8 . 2 0 , 3 2 . 2 3 . 3 4 , 3 9 , 4 1 .
Rysunek 4 - 8 przedstawia efekt działania
Quicksort, które faktycznie coś robią.
tych
egzemplarzy
procedury
Widać wyraźnie, że przechodząc od skrajnie lewej gałęzi drzewa do skraj
nie prawej i odwiedzając w pierwszej kolejności „ l e w e " jego odnogi, przecha
dzamy się w istocie po posortowanej tablicy! W naszym programie taki spacer
realizują wywołania rekurencyjne procedury qsort. Algorytm Quicksort stano
wi dobry przykład techniki programowania zwanej „dziel i rządź " , która zosta
nie dokładniej omówiona w rozdziale 9.
90
Rozdział 4. Algorytmy sortowania
29 40
Rys. 4 - 8.
Sortowanie
meto
dą Quicksort
na przykładzie.
2
1
6
2
1
6
18 20 32 23 34 39 41
18 20 23
6
18 20 23
40
32
34
39 41
32 34 39
Tutaj zapowiem jedynie, że chodzi o taką dekompozycję problemu, aby uzy
skać zysk czasowy wykonywania programu (jak i przy okazji uproszczenie rozwią
zywanego zadania). Algorytm Quicksort spełnia te oba założenia wręcz wzorcowo!
4.4. Uwagi praktyczne
Kryteria wyboru algorytmu sortowania mogą być zebrane w kilka łatwych do
zapamiętania zasad:
• do sortowania małych ilości elementów nie używaj superszybkich algo
rytmów, takich jak np. Quicksort, gdyż zysk będzie znikomy;
• część znanych z literatury i prasy fachowej algorytmów sortowania nie
jest nigdy - lub jest bardzo rzadko - stosowana praktycznie. Powód jest
dość prosty: trzymając się dobrze znanych metod mamy większą pew
ność, iż nie popełnimy jakiegoś nadprogramowego błędu.
Podczas programowania warto również uważnie czytać, czy w bibliotekach stan
dardowych używanego kompilatora nie ma już zaimplementowanej funkcji
sortującej. Przykładowo w kompilatorze gcc istnieje gotowa funkcja o nazwie...
qsort o następującym nagłówku:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *))
Tablica do posortowania może być dowolna (typ void), ale musimy dokładnie po
dać jej rozmiar: ilość elementów nmemb o rozmiarze size. Funkcja qsort wymaga
ponadto jako parametru wskaźnika do funkcji porównawczej. Przy omawianiu list
jednokierunkowych dokładnie omówiono pojęcie wskaźników do funkcji. Tutaj w
celu ilustracji podam tylko gotowy kod do sortowania tablicy liczb całkowitych
(przeróbka na sortowanie tablic innego typu niż int wymaga jedynie modyfikacji
funkcji porównawczej comp i sposobu wywołania funkcji qsort):
4.4. Uwagi praktyczne
91
int comp{const void *x, const void *y)
{
quick-gcc.cc
int xx=*(int*)x;
int
yy=*(int*)y;
// jawna konwersja z typu
//
'void*'
do : int*'
i f ( x x & lt; yy)
i f ( x x == yy)
return 0;
else
return 1;
// & lt; 0
return - 1 ;
// & lt; 0
// = 0
// & gt; 0
})
c o n s t n=12;
int tab[n]-{40,29,2,1,6,18,20,32,34,39,23,41);
void main()
{
qsort- ( t a b , n , s i z e o f ( i n t ) , comp) ;
for ( i - 0 ; i & lt; n ; i++)
cout & lt; & lt; t a b [ i ] & lt; & lt; " " ; ;
c o u t & lt; & lt; e n d l ;
}
Funkcja porównawcza comp zmienia się w zależności od typu danych sortowanej
tablicy. Przykładowo, dla tablicy wskaźników ciągów znaków użylibyśmy jej
następująco:
int comp(const void* a, const void* b)
{
{
return(strcmp((char*)a,(chmr*)b));
}
void main()
}
char s[5][4]=( " aaa " , " ccc " , " ddd " , " zzz " , " fff " };
qsort((void*)s, 5, sizeof(s[0]), comp);
for(int i=0; i & lt; 5; i++)
cout « s[i] « endl;
}
Wadą stosowania gotowej funkcji bibliotecznej jest brak dostępu do kodu źró
dłowego: dostajemy „kota w worku " i musimy się do niego przyzwyczaić...
Pisanie własnej procedury sortującej ma tę zaletę, że możemy ją zoptymalizo
wać pod kątem naszej własnej aplikacji. Już wbudowanie funkcji comp wprost do
procedury sortującej powinno nieco poprawić jej parametry czasowe... nie zmie
niając jednak klasy algorytmu!
Rozdział 5
Struktury danych
Nikogo nie trzeba chyba przekonywać o wadze tematu, który zostanie poruszony
w tym rozdziale. Od wyboru właściwej w danym momencie struktury danych
może zależeć wszystko: szybkość działania programu, możność jego łatwej
modyfikacji, czytelność zapisu algorytmów i... dobre samopoczucie programisty.
Każdy, kto poznał jakikolwiek język programowania, został niejako zmuszony
do opanowania zasad posługiwania się tzw. typami podstawowymi. Przykładowo
w C++ mamy do dyspozycji typy: im, long, float, char, typy wskaźnikowe etc.
Mogą one posłużyć j a k o elementy bazowe rekordów, tablic, unii, które już
zasługują na miano struktur danych - na tyle jednak prymitywnych, iż nic będą
one stanowić przedmiotu naszych głębszych rozważań. Prawdziwa przygoda
rozpoczyna się dopiero, gdy dostajemy do ręki tzw. listy, drzewa binarne,
grafy... Wraz z nimi rozszerzają się znacznie możliwości rozwiązania progra
mowego wielu ciekawych zagadnień; zwiększa się wachlarz potencjalnych
zastosowań informatyki. Listy ułatwiają tworzenie elastycznych baz danych.
drzewa binarne mogą posłużyć do analizy symbolicznej wyrażeń arytmetycznych,
grafy 1 ułatwiają rozwiązanie wielu zagadnień z dziedziny tzw. sztucznej inteli
gencji - możliwości jest doprawdy bardzo dużo. W kolejnych podrozdziałach
zostaną przedstawione najważniejsze struktury danych i sposoby posługiwania
się nimi. Jednocześnie przykłady ilustrujące ich użycie zostały tak wybrane, aby za
sugerować niejako ewentualną dziedzinę zastosowań. Zapraszam zatem do lektury.
Materiał dotyczący grafów został, ze względu na jego znaczenie i rozmiar, wyodrębniony w
rozdziale 10.
94
Rozdział 5. Struktury danych
Lista jednokierunkowa jest oszczędną pamięciowo strukturą danych, pozwalającą
grupować dowolną — ograniczoną tylko ilością dostępnej pamięci — liczbę
elementów: liczb, znaków, rekordów... Jest to duża zaleta w porównaniu z ta
blicami, których rozmiar co prawda może być określany dynamicznie, ale
przydział dużego, „liniowego " obszaru pamięci podczas wykonywania programu
nie zawsze musi się zakończyć sukcesem. Nietrudno sobie bowiem wyobrazić,
że o wiele bardziej prawdopodobne jest bezproblemowe przydzielenie 50.000
razy pamięci na rekordy 4 bajtowe niż zarezerwowanie miejsca na tablicę
zajmującą 200 KB! (W rzeczywistości lista, która pozwala zapamiętać 200 KB
informacji zajmuje w pamięci oprócz owych „gołych " 200 KB pewną dodatkową
pamięć. Z każdym rekordem jest związane dodatkowe pole na wskaźnik do
kolejnego rekordu listy - patrz rysunek 5 - 1.
Rys, 5 - 1
Typy rekordów
używanych pod
czas programowa
nia list.
INFO
ELEMENT
Do budowy listy jednokierunkowej używane są dwa typy „komórek " pamięci.
Pierwszy jest zwykłym rekordem natury informacyjnej, zawierającym dwa
wskaźniki: do początku i do końca listy. Drugi typ komórek jest również rekordem,
jednakże ma on już charakter roboczy. Zawiera bowiem pole wartości i wskaź
nik na następny element listy. W typowych opisach struktur listowych nie
wzmiankuje się zazwyczaj rekordu informacyjnego (nie jest on elementem
struktury danych) -jest to oczywisty błąd. Kosztem kilku bajtów pamięci " uzy
skujemy bowiem ciągły dostęp do bardzo istotnych operacji i ułatwiamy
ogromnie operację podstawową: dołączenie nowego elementu na koniec listy
(jeśli nie wstawiamy na koniec listy, to zawsze możemy przyłączyć nowy element
na początek listy, ale tracimy wówczas informację o kolejności przybywania
danych!).
Pola: głowa, ogon i następny są wskaźnikami3, natomiast wartość może być
czymkolwiek— liczbą, znakiem, rekordem etc. W przykładach znajdujących się
2
W IBM PC zmienna wskaźnikowa zajmuje 4 lub 8 bajtów w zależności od użytego modelu
pamięci.
3 Fakt „wskazywania " na coś jest symbolizowany dalej przez „strzałki " .
5.1. Listy jednokierunkowe
95
w tej książce dla uproszczenia operuje się głównie wartościami typu int, co nie
umniejsza bynajmniej ogólności wywodu, Ewentualne przeróbki tak uprosz
czonych algorytmów należą już raczej do „kosmetyki " niż do zmian o charakterze
zasadniczym.
Idea jest zatem następująca: jeżeli lista jest pusta, to struktura informacyjna
zawiera dwa wskaźniki NULL. Na rysunkach znajdujących się w tej książce,
wartość NULL będzie od czasu do czasu zaznaczana jako 0000h - adres pamię
ci równy zero. Warto jednak pamiętać, że w ogólnym przypadku NULL nie jest
bynajmniej równa zeru -jest to pewien adres, na który na pewno żadna zmienna
nie wskazuje (taka jest ogólna idea wskaźnika NULL, niestety wielu programistów
o tym nie pamięta). Pierwszy element listy jest złożony z jego własnej wartości
(informacji do przechowania) oraz ze wskaźnika na drugi element listy. Drugi
zawiera własne pole informacyjne i, oczywiście, wskaźnik na trzeci element
listy itd. Miejsce zakończenia listy zaznaczamy poprzez wartość specjalną
NULL. Spójrzmy na rysunek 5 - 2 przedstawiający listę złożoną z trzech ele
mentów: 2, -12, 3.
Rys. 5 - 2.
Przykład listy
jednokierunkowej
NULL
ELEMENT
(1)
ELEMENT
ELEMENT
INFO
Rysunek 5 - 3 jest dokładnym odbiciem swojego poprzednika - z tą tylko
różnicą, że w miejsce strzałek symbolizujących „wskazywanie " są użyte kon
kretne wartości liczbowe adresów komórek pamięci. Heksadecymalna liczba
umieszczona nad rekordem jest adresem w pamięci komputera, pod którym zo
stało mu przydzielone miejsce przez standardową procedurę new.
Wróćmy jeszcze do analizy rekordów składających się na listę. Pole głowa
struktury informacyjnej wskazuje na komórkę zawierającą 2 pierwszy element
listy), czyli - wyrażając się czytelniej - zawiera adres, pod którym w pamięci
komputera jest zapamiętany rekord.
Ze względów historycznych warto może przypomnieć, że w klasycznym języku C
trzeba było w celu przydzielania pamięci używać funkcji bibliotecznych calloc i malloc.
W C++ instrukcja new robi dokładnie to samo, lecz o wiele czytelniej, i stanowi już
element języka.
Rozdział 5. Struktury danych
96
Rys. 5 - i.
Przykłlad listy jednokierunkowej (2).
FC00h
FC14h
FC00h
2
FFEEh
FC14h
FFEEh
-12
FFEEh
3
0000h
dane
Pole ogon struktury informacyjnej wskazuje na komórkę zawierającą 3 (ostatni
element listy). Pola te służą do przeglądania elementów listy i do dołączania
nowych. Oto jak może wyglądać procedura przeglądająca elementy listy, np.
w poszukiwaniu wartości x (komórka informacyjna nazywa się info):
a d r e s tmp=info.głowa
dopóki (adres_tmp!=NULL) wykonuj
{
jeśli(adres_tmp.wartość==x) to
{
Wypisz
„Znalazłem poszukiwany
opuść procedure
{
element "
}
w przeciwnym przypadku
adres_tmp-adres_tmp.następny
}
Wypisz
„Wie
znalazłem poszukiwanego
elementu "
W dalszej części rozdziału będziemy przeplatać opis algorytmów w pseudojęzyku programowania (takim jak wyżej) z gotowym kodem C++; kryterium
wyboru będzie czytelność procedur (patrz uwagi zawarte w rozdziale 1).
Oczywiście, nawet jeśli prezentacja algorytmu zostanie dokonana w pseudo-kodzie,
to wersja dyskietkowa będzie zawierała w pełni kompilowalne wersje w C++.
5.1.1 .Realizacja struktur danych listy jednokierunkowej
Poniższa implementacja struktur potrzebnych do programowej obsługi listy
jednokierunkowej jest dokładnym odzwierciedleniem rysunku 5 - 2 i nie należy
się tu spodziewać szczególnych niespodzianek. Osoby, które nie znają jeszcze
zbyt dobrze składni języka C++, powinny dobrze zapamiętać sposób deklaracji
typów danych „rekurencyjnych " (tzn. zawierających wskaźniki do elementów
swojego typu). Różni się on bowiem odrobinę od sposobu używanego na przy
kład w Pascalu (patrz również dodatek A).
lista.h
typedef struct rob
{
i
int wartość;
struct rob nastepny; // wskaźnik do następnego elementu
}ELEMENT;
class LISTA
{
f
5.1. Listy jednokierunkowe
97
public:
int pusta()
{
/ / czy l i s t a j e s t p u s t a ?
return (inf.glowa==NULL);
}}
// sumuje dwie l i s t y :
LISTA f r i e n d & operator +(LISTA & ,LISTA & );
();
void wypisz{);
/ / wypisuje e l e m e n t y l i s t y
int szukaj(int x);
// szuka elementu y
void d o r z u c 1 ( i n t x ) ;
// dorzuca x bez s o r t o w a n i a
void d o r z u c 2 ( i n t x ) ;
// dorzuca x z sortowaniem
void z e r u j ()
// zerowanie l i s t y
{ inf.glowa=inf.ogon=NULL; }
LISTA & operator
--();
// usuwa o s t a t n i element l i s t y
LISTA()
// k o n s t r u k t o r
{(inf.glowa=inf.ogon=NULL;)
}
{
}INFO;
~LISTA()
// d e s t r u k t o r
{while ( ! p u s t a ( ) ) ( * t h i s ) - - ; }
private:
typedef s t r u c t
// s t r u k t u r a informacyjna
{
ELEMENT * g l o w a ;
ELEMENT *ogon;
}INFO;
INFO inf;
};
};
// r e k o r d informacyjny
// koniec d e k l a r a c j i k l a s y LISTA
Pole wartość w naszym przykładzie jest typu int, ale w praktyce może to być
bardzo złożony rekord informacyjny (np. zawierający imię, nazwisko, wiek..).
Klasa LISTA nie jest zbyt rozbudowana, jednak zawiera kilka rozwiązań, które
wymagają dość szczegółowego komentarza. Kwestią otwartą pozostaje wybór
ewentualnego utajnienia typów danych; programista musi sam podjąć odpo
wiednie decyzję mając na uwadze takie aspekty, jak: sens ujawnia
nia/ukrywania atrybutów, parametry „sprawnościowe " metod etc. Propozycje
przedstawione w tym rozdziale w żadnym razie nie pretendują do miana
rozwiązań wzorcowych - takie bowiem nic istnieją wobec nieskończonej w zasa
dzie ilości nowych sytuacji i problemów, z którymi może się w praktyce spotkać
programista. Staraniem autora było raczej pokazanie istniejącej różnorodności,
a nie przekonywanie do jednych rozwiązań przy jednoczesnym pomijaniu innych.
98
Rozdział 5. Struktury danych
W następnych paragrafach zostaną przedstawione wszystkie melody, które były
wyżej wzmiankowane jedynie poprzez swoje nagłówki.
5.1.2.Tworzenie listy jednokierunkowej
Najwyższa już pora na przedstawienie sposobu dołączania elementów do listy.
Posłuży nam do tego celu kilka funkcji, o mniejszym lub większym stopniu
skomplikowania. Na początek zdefiniowaliśmy miniaturową funkcję usługową
pusto, która pomimo swej prostoty ma szansę być dość często używana w praktyce.
Z uwagi na małe rozmiary funkcja ta została zdefiniowana wprost w ciele kla
sy. Potrzeba sprawdzania czy jakieś elementy już są zapamiętane na liście, wy
stąpi przykładowo w funkcji dorzuci1 która dołącza nowy element do listy.
Podczas dokładania nowego elementu możliwe są dwa podejścia: albo będziemy
traktować listę jako zwykły ,.worek " do gromadzenie danych nieuporządkowa
nych (będzie to wówczas naukowy sposób na zwiększanie bałaganu), albo też
przyjmiemy założenie, że nowe elementy dokładane będą w liście we właściwym,
ustalonym przez nas porządku - na przykład sortowane od razu w kierunku
wartości niemalejących.
Pierwszy przypadek jest trywialny - odpowiadająca mu procedura dorzuci jest
przedstawiona poniżej:
lista.cpp
#include " l i s t a . h "
void LISTA::dorzucl(int x)
{{
// dorzucanie elementu bez sortowania
ELEMENT *q=new ELEMENT;
// tworzenie nowej komórki
q- & gt; wartosc=x;
q- & gt; nastepny=NULL;
if (inf .qlowa—NULL)
// lista pusta?
inf.glowa-inf.ogon=q;
else
{
(inf.ogon)- & gt; nastepny=q;
inf.ogon=q;
}
}
Działanie funkcji dorzuci jest następujące: w przypadku listy pustej oba pola
struktury informacyjnej są inicjowane wskaźnikiem na nowo powstały element.
W przeciwnym wypadku nowy element zostaje „podpięty " do końca, stając się
tym samym ogonem listy.
Oczywiście, możliwe jest dokładanie nowego rekordu przez pierwwzy element listy
(wskazywanej zawsze przez pewien łatwo dostępny wskaźnik, powiedzmy ptr)9
5.1, Listy jednokierunkowe
99
stawałby się on wówczas automatycznie głową listy i musiałby zostać zapa
miętany przez program, aby nie stracić dostępu do danych:
ELEMENT
// a)
// b)
// c)
*
q=new ELEMENT;
q- & gt; wartosc=x;
q- & gt; nastepny=ptr;
W tym i dalszych przykładach przyjmowane jest założenie, że przydział
pamięci ZAWSZE kończy się sukcesem. W rzeczywistych programach
jest to przypuszczenie dość niebezpieczne i warto sprawdzać, czy istotnie
po użyciu instrukcji ELEMENT *q=new ELEMENT wartości q nie zostało
przypisane NULL! Z uwagi na chęć zapewnienia klarowności prezento
wanych algorytmów, tego typu kontrola zostanie w książce pominięta,
podczas realizacji „prawdziwego " programu takie niedopatrzenie może
się okazać dość przykre w skutkach.
Kod ten może być zilustrowany schematem z rysunku 5-4.
a) lista pierwotna
Rys, 5 - 4.
Dołączanie ele
mentu na jej po
czątek.
NULL
b) nowa komórka
q
c) zmodyfikowana lista
q
NULL
NULL
Sposób podany powyżej jest poprawny, ale pamiętajmy, że dokładając nowe
elementy zawsze na początek listy tracimy istotną czasami informację na temat
kolejności nadchodzenia elementów!
•
wiele bardziej złożona jest funkcja dołączająca nowy element w takie miej
sce, aby całość listy była widziana jako posortowana (tutaj: w kierunku
Wartości niemalejących). Ideę przedstawia rysunek 5 - 5 , gdzie możemy zo
baczyć sposób dołączania liczby 1 do już istniejącej listy złożonej z elementów -1. 3 i 12.
Rys. 5 - 5.
Dołączanie ele
mentu listy z sor
towaniem.
...
...
...
3
1
12
...
Tu przerwać!
Nowy element (narysowany pogrubioną kreską) może zostać wstawiony na
początek (a), koniec (b) listy, jak i również gdzieś w jej środku (c), W każdym
100
Rozdział 5. Struktury danych
z tych przypadków w istniejącej liście trzeba znaleźć miejsce wsławienia, tzn,
zapamiętać dwa wskaźniki: element, przed który mamy wstawić nową komórkę
i element, za którym mamy to zrobić. Do zapamiętania tych istotnych informa
cji posłużą nam zmienne przed i po.
Następnie, gdy dowiemy się „gdzie jesteśmy " , możemy dokonać wstawienia
nowego elementu do listy. Sposób, w jaki lego dokonamy, zależy oczywiście od
miejsca wstawienia i od tego, czy lista przypadkiem nic jest jeszcze pusta. Krótko
powiedziane, niestety realizacja jest dość złożona. Pewne skomplikowanie
funkcji dorzuci wynika z połączenia w niej poszukiwania miejsca wsławienia
z samym dołączeniem elementu. Równie dobrze można by te dwie czynności
rozbić na osobne funkcje nie zostało to jednak uczynione w obecnej wersji.
Istnieją 3 przypadki „współrzędnych " nowego elementu w liście, symbolicznie
przedstawione na rysunku 5 - 6 (zakładamy, że lista już coś zawiera).
Rys. 5 - 6.
Wstawianie nowe
go elementu do
listy - analiza
przypadków.
przed = NULL
a)
b)
po = NULL
c)
przed, po
NULL
W zależności od ich wystąpienia zmieni się sposób dołączenia elementu do listy.
Oto polny tekst funkcji dorzuc2, która swoje działanie opiera właśnie na idei
przedstawionej na rysunku 5 - 6 :
void LISTA::dorzuc2{int x)
i
// dołączamy rekord na właściwe miejsce
// (ver.2 - z " sortowaniem " ;
// tworzymy nowy element listy:
ELEMENT *q=new ELEMENT;
q- & gt; wartosc=x;
// wypełniamy jego zawartość
// Poszukiwanie właściwej pozycji na
// wstawienie elementu
if (pusta ())
{
inf.glowa-inf.ogon=q;
q- & gt; nastepny=NULL;
}
else //szukamy miejsca na wstawienie
5.1 Listy jednokierunkowe
101
*
ELEMENT przed=NULL,*po=inf.głowa;
enum{ SZUKAJ,ZAKOŃCZ} stan=SZUKAJ;
//zmienna wyliczeniowa
while f(stan==SZUKAJ) & & (po!=NULL))
if(po- & gt; wartosc & gt; =x)
stan=ZAKONCZ; //znaleźliśmy właściwe miejsce!
else
// przemieszczamy się w poszukiwaniach
{ // właściwego miejsca
przed=po;
// wskaźniki " przed " i " po "
po=po- & gt; nastepny;// zapamiętają " miejsce "
// wstawiania
}
// analiza rezultatu poszukiwań:
if(przed==NULL) //wstawiamy na początek listy
{
inf.glowa=q;
q- & gt; nastepny=po;
}
else
if(po-=NULL) // wstawiamy na koniec listy
{
{{
i f ogon- & gt; nastepny=q;
n.
q- & gt; nastepny=NULL;
inf.ogon=q;
}
else
// wstawiamy gdzieś " w środku "
{
przed- & gt; nastepny=q;
q- & gt; nastepny=po;
}}
}
}
Kolejne ważne, choć skrajnie nieskomplikowane metody są niemalże identyczne
koncepcyjnie. W celu znalezienia w liście pewnego elementu x należy przejrzeć
ją za pomocą zwykłej pętli while:
i n t LISTA::szukaj{int x)
{
ELEMENT * q = i n f . g ł o w a ;
w h i l e (q != NULL)
{
i f (q- & gt; wartosc==x)
q=q- & gt; nastepny;
return
(1);
}
return(0);
}
Identyczną strukturę posiada metoda wypisz służąca do wypisywania za
wartości listy:
void LISTA::wypisz()
Rozdział 5. Struktury danych
ELEMENT *q=inf.głowa;
if (pusta())
cout & lt; & lt; " (lista pusta) " ;
else
while (q != NULL)
{
cout & lt; & lt; q- & gt; wartosc & lt; & lt; "
q=q- & gt; nastepny;
" ;
}
cout & lt; & lt; " \n " ;
}
j
Do usuwania ostatniego elementu listy zatrudniliśmy predefiniowany operator
dekrementacji.
Funkcja, która się za nim „ukrywa " , jest relatywnie prosta: jeśli na liście jest
tylko jeden element, to modyfikacji ulegnie zarówno pole głowa jak i pole ogon
struktury informacyjnej. Oba te pola, po uprzednim usunięciu jedynego ele
mentu listy, zostaną zainicjowane wartością NULL.
Nieco trudniejszy jest przypadek, gdy lista zawiera więcej niż jeden element.
Należy wówczas odszukać przedostatni jej element, aby móc odpowiednio
zmodyfikować wskaźnik ogon struktury informacyjnej. Znajomość przedostat
niego elementu listy umożliwi nam łatwe usunięcie ostatniego elementu listy.
Poniżej jest zamieszczony pełny tekst funkcji wykonującej to zadanie.
LISTA & L I S T A : : o p e r a t o r --()
{ // parametrem domyślnym j e s t sam o b i e k t
if(inf.glowa==inf.ogon)
// jeden element
{
// (lub lista pusta)
delete inf.głowa;
inf.glowa=inf.ogon=NULL;
}else
{
ELEMENT *temp=inf.głowa;
whilo {(temp- & gt; nastepny) !- inf.ogon)
// szukamy przedostatniego elementu listy...
temp=temp- & gt; nastepny;
inf.ogon=temp;
delete temp- & gt; nastepny; // ... i usuwamy go
temp- & gt; nastepny=NULL;
}
return (*this); // zwracamy zmodyfikowany obiekt
})
Obiekt jest zwracany poprzez swój adres, czyli może posłużyć jako argument
dowolnej dozwolonej na nim operacji. Przykładowo możemy utworzyć wyrażenie
--)-(l2—)—.wypisz(). Mimo groźnego wyglądu działanie tej instrukcji jest trywialne:
pierwsza „dekrementacja " zwraca prawdziwy, fizycznie istniejący obiekt, który
jest poddawany od razu drugiej dekrementacji. Rezultat tej ostatniej -jako peł-
5.1. Listy jednokierunkowe
103
poprawny obiekt- może aktywować dowolną metodę swojej klasy, czyli przy
kładowo sprawdzić swoją zawartość przy pomocy funkcji wypisz.
Przy okazji omawiania operatora dekrementacji spójrzmy jeszcze na inne jego
zastosowanie. W definicji klasy został zawarły jej destruktor. Przypomnijmy,
że destruktor jest specjalną funkcją wywoływaną automatycznie podczas nisz
czenia obiektu. To niszczenie może być bezpośrednie, np. za pomocą operatora
delete:
LISTA
• * *
*p=new LISTA;
d e l e t e p;
//
tworzymy
nowy o b i e k t . . .
// . . . i niszczymy go!
lub też pośrednie, w momencie gdy obiekt przestaje być dostępny Przykladem
tej drugiej sytuacji niech będzie następujący fragment programu:
if (warunek)
{{
L
LISTA p; // tworzymy o b i e k t l o k a l n y
.. . .
.
// widoczny t y l k o w t e j i n s t r u k c j i if
}}
Obiekt p zadeklarowany w ciele instrukcji if jest dla niej całkowicie lokalny.
Żaden inny fragment programu nie ma prawa dostępu do niego. Z takim tym
czasowym obiektem wiąże się czasem dość sporo pamięci zarezerwowanej tylko
dla niego. Otóż, gdyby nie było destruktora, programista nie miałby wcale
pewności, czy ta pamięć została w całości zwrócona systemowi operacyjnemu.
Celowo podkreślam, że w całości, bowiem automatyczne zwalnianie pamięci
jest możliwe tylko w przypadku tych zmiennych, które są z założenia lokowane
na stosie. Dotyczy to np. zwykłych pól obiektu, ale nie jest możliwe w przypadku
struktur dynamicznych, które są nierzadko „rozsiane " po dość sporym obszarze pa
mięci komputera, lak jest w przypadku list. drzew, tablic dynamicznych etc. W takim przypadku programista musi sam napisać jawny destruktor, który znając do
skonale sposób, w jaki pamięć została przydzielona obiektowi, będzie ją umiał pra
widłowo zwrócić.
Tak też się dzieje w naszym przykładzie. Destruktor ma zaskakująco prostą
budowę:
~LISTA() { w h i l e ( ! p u s t a ()) ( * t h i s ) - - ; }
Jest to zwykła pętla while, która tak długo usuwa elementy z listy, aż stanie się
ona pusta. Mimo tego, iż nie jest to optymalny sposób na zwolnienie pamięci,
został jednak zastosowany w celu ukazania możliwych zastosowań wskaźnika
this, który -jak wiemy - wskazuje na „własny " obiekt. Linia (*this)— oznacza
5
De facto to my go znamy i dzielimy się tą cenną wiedzą z destruktorem... Rozsiane
tu i ówdzie personifikacje są nie do uniknięcia w tego typu opisach!
104
Rozdział 5. Struktury danych
dla danego obiektu wykonanie na sobie operacji „dekrementacji " . Obiekt
ulegający z pewnych powodów destrukcji (typowe przypadki zostały wzmiankowa
ne wcześniej) wywoła swój destruktor, który zaaplikuje na sobie tyle razy funkcję
dekrementacji, aby całkowicie zwolnić pamięć wcześniej przydzieloną liście.
Kolejna porcja kodu do omówienia dotyczy redefinicji operatora + (plus). Na
szym celem jest zbudowanie takiej funkcji, która umożliwi dodawanie list w
jak najbardziej dosłownym znaczeniu tego słowa. Chcemy, aby w wyniku na
stępujących instrukcji:
LISTA x,y,z;
// tworzymy 3 puste listy.
x.dorzuc2(3); x.dorzuc2(2); x.dorzuc2(1);
y.dorzuc2(6); y.dorzuc2(5); y.dorzuc2(4) ;
z=x+y;
... lista wynikowa z zawierała wszystkie elementy list x i y, tzn.: 1, 2, 3, 4, 5 i 6
(posortowane!). Najprostszą metodą jest przekopiowanie wszystkich elementów z
list x i y do listy r, aktywując na rzecz tej ostatniej metodę dorzuci. Zapewni to
utworzenie listy już posortowanej:
LISTA & o p e r a t o r +(LISTA & x,LISTA & y)
{
LISTA *temp=new LISTA;
ELEMENT *ql =(x.inf).głowa; // wskaźniki robocze
ELEMENT *q2=fy.inf).głowa;
while {ql !- NULL) // przekopiowanie Listy x do temp
{{
tenp- & gt; dorzuc2(ql- & gt; wartosc);
ql=ql- & gt; iidstepny;
}
w h i l e (q2 != NULL) // p r z e k o p i o w a n i e l i s t y y do temp
{{
temp " & gt; dorzuc2(q2- & gt; wartosc);
q2=q2- & gt; nastepny;
}}
return (*temp);
}
Czy jest to najlepsza metoda? Chyba nie, z uwagi chociażby na niepotrzebne du
blowanie danych. Ideałem byłoby posiadanie metody, która wykorzystując fakt, iż
listy są już posortowane , dokona ich zespolenia ze sobą (tzw.fuzji) używając wy
łącznie istniejących komórek pamięci, bez tworzenia nowych. Inaczej mówiąc,
będziemy zmuszeni do manipulowania wyłącznie wskaźnikami i to jest jedyne
narzędzie, jakie dostaniemy do ręki!
* Zakładamy tym samym użycie podczas tworzenia listy metody dorzuci.
2
105
Na rysunku 5 - 7 możemy przykładowo prześledzić jak powinna być wykony
wana fuzja pewnych dwóch list x=(l,3,9) i y=(2,3,14), tak aby w jednej z nich
znalazły się wszystkie elementy x i y
oczywiście posortowane (w naszym
przykładzie w kierunku wartości niemalejących).
Najmniejszym z dwóch pierwszych elementów list jest 1 i on tez będzie stanowił
zaczątek „nowej " listy. „Następnikiem " tego elementu będzie fuzja dwóch list; x' =
(3,9) iy=(2.3,14). Jak dokonać fuzji list x' i y'? Dokładnie tak samo: bierzemy
element 2, który jest najmniejszy z dwóch pierwszych elementów list .Y' i y.„
Można tak rekurencyjnie kontynuować aż do momentu, gdy natrafimy na przy
padki elementarne: jeśli jedna z list jest pusta, to fuzją ich obu będzie oczywi
ście ta druga lista. Na tej zasadzie jest skonstruowana procedura fuzja(ob,.ob2), która wywołana z dwoma parametrami obi i ob2 zwróci w liście
obl sumę elementów list ob1 i ob2. Lista ob2 jest w wyniku tej operacji zero
wana, choć jej całkowite usunięcie pozostaje ciągle w gestii programisty (taki jest
nasz wybór - równie dobrze można by to zrobić od razu).
Rys. 5 - 7.
Fuzja list
na przykładzie.
Nasze zadanie wykonamy w dość nietypowy dla C++ sposób, który ma na celu
ukazanie zakresu możliwych zastosowań tzw. funkcji zaprzyjaźnionych L klasą.
Przypomnijmy, iż są to funkcje (lub procedury), które nic będąc metodami danej
klasy, mają dostęp do zastrzeżonych pól private i protected) obiektu, którego
adres został im przekazany jako jeden z parametrów wywołania. Ponieważ nie
są to metody, nie mogą być wywoływane w ramach notacji z kropką, a ponadto
obiekt, na który mają działać, musi im zostać przekazany w sposób jawny - na
przykład poprzez swój adres.
Fuzję list wykonamy w dwóch etapach. Wpierw przygotujemy prostą funkcję,
która otrzymując dwie posortowane listy a i b. zwróci jako wynik listę, która
106
Rozdział 5. Struktury danych 5.1
będzie ich fuzją. Rekurencyjny zapis tego procesu jest bardzo prosty i
stylem do rozwiązywania problemów listowych w takich językach jak Lisp
Prolog:
ELEMENT *sortuj(ELEMENT *a,ELEMENT *b)
{{
if
(a==NULL)
if
(b==NULL)
return b;
return a;
i f (a- & gt; wartosc & lt; =b- & gt; wartosc)
{i
a- & gt; nastepny=sortuj(a- & gt; nastepny,b);
return a;
}else
{{
b- & gt; nastepny=sortuj(b- & gt; nastepny,a);
return b;
}}
}
}
1
Dysponując już funkcją sortuj możemy zastosować ją proccdurze fuzja, która będąc
" zaprzyjaźnioną " z klasą LISTA, może dowolnie manipulować prywatnymi
komponentami list x i y, które zostały jej przekazane w wywołaniu.
void fuzja(LISTA fix,LISTA & y)
{
// listy a i b muszą być posortowana
ELEMENT *a=x.inf.głowa,*b=y.inf.głowa;
ELEMENT *wynik=sortuj(a,b);
x.inf.glowa=wynik;
if(x.inf.ogon- & gt; wattosc & lt; =y.inf,ogon- & gt; wartosc)
x.inf.ogon=y.inf.ogon;
else
x.inf.ogon=x.inf.ogon;
y.zeruj();
1
}
Celowo znacznie rozbudowana funkcja main ilustruje sposób korzystania z opi
sanych wyżej funkcji. Do obu list są dołączane elementy tablic, następnie ma
miejsce testowanie niektórych metod oraz sortowanie dwóch list poprzez ich
fuzję.
void main()
{
LISTA 11,12;
const n=6;
int
tabl[n]={2,5,-11,4,14,12};
// każdy element tablicy zostanie wstawiony do listy
cout & lt; & lt; " \nLl = " ;
for (int i-0; i & lt; n; 11.dorzuc2(tabl[i++]));
ll.wypisz();
// wypisz 11
int tab2[n]={9,6,77,1,7,4);
5.1 Listy jednokierunkowe
107
c o u t & lt; & lt; " L2 = " ;
for (i=0; i & lt; n; 12.dorzuc2(tab2[i++]));
12.wypisz();
// wypisz 11
c o u t & lt; & lt; " Efekt poszukiwań l i c z b y 14 w l i ś c i e 1 1 : "
& lt; & lt; 1 1 . s z u k a j ( 1 4 ) & lt; & lt; e n d l ;
c o u t & lt; & lt; " Efekt poszukiwań l i c z b y 0 w l i ś c i e 1 1 : "
& lt; & lt; 11.szukaj(0) & lt; & lt; cndl;
c o u t & lt; & lt; " Oto l i s t a będąca sumą dwóch poprzednich\nL3= " ;
LISTA 13=11+12;
13.wypisz();
cout & lt; & lt; " Listy Ll i L2 p o z o s t a ł y bez zmian: \nLl = " ;
11.wypisz();
c o u t & lt; & lt; " L2 = " ;
12.wypisz{);
c o u t & lt; & lt; " L i s t a LI bez dwóch o s t a t n i c h elementów:\nLl= " ;
(11--)--.wypisz();
c o u t & lt; & lt; " Efekt f u z j i L1 z L 2 : \ n " ;
fuzja(ll,12);
c o u t & lt; & lt; " L1 = " ;
l1.wypisz();
c o u t & lt; & lt; " L2 = " ;
l2.wypisz O ;
ll.dorzuc2(80);ll.dorzuc2(8);
cout & lt; & lt; " dorzucamy do LI l i c z b y 80 i 8\nLl = " ;
11.wypisz();
}
Oto wyniki uruchomienia programu:
T1 = - U
.
2
4
5
12
14
L2 - 1 4
A
6
7
9
77
Efekt poszukiwań liczby 14 w liście 11: 1
Efekt poszukiwań liczby 0 w liście 11: 0
Oto lista będąca sumą dwóch poprzednich
L3 = -11
1
2
4
4
5
6
7
9
Listy LI i L2 pozostały bez zmian;
LI = -11
?
4
5
12
14
L2 = 1
4
6
7
9
77
Lista L1 bez dwóch ostatnich elementów:
L1 = -11
2
4
5
Efekt f u z j i LI z L2:
L1 = -11
1
2
4
4
5
6
1
9
L2 - ( l i s t a p u s t a )
dorzucamy do Li l i c z b y 80 i 8
R
L 1 = -11
1
2
4
4
5
6
7
8
12
14
77
77
9
77
80
108
Rozdział 5. Struktury danych
5.1.3.Listy jednokierunkowe - teoria i rzeczywistość
Oprócz pięknie brzmiących rozważań teoretycznych istnieje jeszcze twarda rze
czywistość, w której... mają wykonywać się nasze pieczołowicie przygotowane
7
programy .
Spójrzmy obiektywnie na listy jednokierunkowe pod kątem ich wad i zalet
(patrz tabela 5-1).
wady
Tabela 5- I.
Wady i zalety list
jednokierunkowych,
zalety
nienaturalny dostęp do elementów
male zuzycie pamięci
niełatwe sortowanie
elastyczność
Przeanalizujmy szczególnie uważnie zagadnienie sortowania danych będących ele
mentami listy. Wyobrażamy sobie zapewne, że posortowanie w pamięci struktury
danych, która nie jest w niej rozłożona liniowo (tak jak ma to miejsce w przypadku
tablicy),jest dość złożone.
I
Iista, do której nowe elementy są wstawiane j u ż na samym początku konse
kwentnie w określonym porządku, służy, oprócz swojej podstawowej roli gro
madzenia danych, także do ich porządkowania. Jest to piękna właściwość:
„sama " struktura danych dba o sortowanie! W sytuacji gdy istnieje tylko jedno
kryterium sortowania (np. w kierunku wartości niemalejących pewnego pola A'),
to możemy mówić o ideale. Cóż jednak mamy począć, gdy elementami listy są
rekordy o bardziej skomplikowanej strukturze. np.:
struct
{{
char
char
int
int
imię [ 2 0 ] ;
nazwiako[30];
wiek;
kod_pracownika;
}
}
Raz możemy zechcieć dysponować taką listą uporządkowaną alfabetycznie, wg
nazwisk, innym razem będzie nas interesował wiek pracownika... Czy należy
w takim przypadku dysponować dwiema wersjami tych list - co „pożera " cenną
pamięć komputera - czy też może zdecydujemy się na sortowanie listy w pamięci?
Jednak uwaga: to drugie rozwiązanie zajmie z kolei cenny czas procesora!
7
Wbrew wszelkim przesłankom nie jest to definicja systemu operacyjnego...
5.1. Listy jednokierunkowe
109
Poruszony powyżej problem był na tyle charakterystyczny dla wielu rzeczywi
stych programów, że zostało do jego rozwiązania wymyślone pewne „sprytne''
rozwiązanie, które postaram się dość szczegółowo omówić.
Pomysł polega na uproszczeniu i na skomplikowaniu zarazem lego, co poznali
śmy wcześniej. Uproszczenie polega na tym, że rekordy zapamiętywane w liście
nie są w żaden sposób wstępnie sortowane. Inaczej mówiąc, do zapamiętywania
możemy użyć odpowiednika jakże prostej funkcji dorzuci ze strony 98. Słowo
„odpowiednik " pasuje tutaj najlepiej, bowiem niezbędne okaże się wprowadze
nie kilku kosmetycznych zabiegów związanych z ogólną zmianą koncepcji.
Obok listy danych będziemy ponadto dysponować kilkoma listami wskaźników
do nich. List tych będzie tyle, ile sobie zażyczymy kryteriów sortowania.
Jak nietrudno się domyślić, jeśli nie zamierzamy sortować listy danych (a jed
nocześnie chcemy mieć dostęp do danych posortowanych!), to podczas wsta
wiania nowego adresu do którejś z list wskaźników musimy dokonać jej sorto
wania. Zadanie jest zbliżone do tego. które wykonywała funkcja dorzuci, z tą
tylko różnicą, że dostęp do danych nie odbywa się w sposób bezpośredni.
Podczas sortowania list wskaźników dane nie są w ogóle „ruszane " - prze
mieszczaniu w listach będą ulegały wyłącznie same wskaźniki! Na tym etapie
ma prawo to wszystko brzmieć dość enigmatycznie, pora zatem na jakiś kon
kretny przykład. Popatrzmy w tym celu na rysunek 5 - 8 .
Rys. 5 - 8.
sortowanie listy
bez przemieszcza
nia jej elementów
adr1
adr2
adr3
lista DANE
Zawiera on listę o nazwie DANE, zbudowaną z kilku rekordów, które stanowią
zaczątek miniaturowej bazy danych o pracownikach pewnego przedsiębiorstwa.
Przyjmijmy dla uproszczenia, że jedyne istotne informacje, które chcemy zapa
miętać, to: imię, nazwisko, pewien kod i oczywiście zarobek. Na rysunku są
zaznaczone symbolicznie adresy rekordów: adrl, adr2 i adr3 przydzielone
przez funkcję dorzuci.
110
Rozdział 5. Struktury danych
Rysunek S - 9 zawiera już kilka nowości w porównaniu z tym, co mieliśmy
okazję do tej pory poznać.
Tablica TAB_PTR zawiera rekordy informacyjne (tzn. wskaźniki głowa i ogon)
do list złożonych z adresów rekordów z listy DANE - w naszym przypadku za
kładamy 3 listy wskaźników i będą one oczywiście zawierać adresy adrl, odr2
i adr3 (chwilowo na liście znajdują się trzy elementy; w miarę dokładania no
wych elementów do listy DANE będą ulegały odpowiedniemu wzrostowi listy
wskaźników).
Rys. 5 - 9.
Sortowanie listy
II
bez przemieszcza
nia jej elementów
(2).
1
2
lista TA B_PTR
Rozmiar tablicy TABPTR jest równy liczbie kryteriów sortowania: patrząc od
góry możemy zauważyć, że listy są posortowane kolejno wg nazwiska, ko
du i zarobków.
Podsumujmy informacje, które można odczytać z rysunków 5 - 8 i 5 - 9:
• nieposortowana baza danych, która jest zapamiętana w liście o nazwie
DANE, zawiera w danym momencie 3 rekordy;
• tablica wskaźników TAB_PTR zawiera 3 rekordy informacyjne (poznane
już poprzednio), których pola głowa i ogon umożliwiają dostęp do trzech
list wskaźników. Każda z tych list jest posortowana wg innego kryterium
sortowania.
Przykładowo lista wskazywana przez TAB_PTR[0] jest posortowana alfabe
tycznie wg nazwisk pracowników (Fuks, Kowalski i Zaremba), analogicznie
TAB_PTR[1] klasyfikuje pracowników wg pewnego kodu używanego w tej
fabryce (Zaremba, Fuks i Kowalski), podobnie TAB_PTR[2] grupuje pracow
ników wg ich zarobków.
Poniżej jest przedstawiona nowa wersja klasy LISTA, uwzględniająca już pro
pozycje przedstawione na rysunku 5 - 8 . Aby umożliwić sensowną prezentację
w postaci programu przykładowego, pewnemu uproszczeniu uległa struktura
danych zawierająca informacje o pracowniku: ograniczymy się tylko do nazwiska
5.1 Listy jednokierunkowe
111
i zarobków. (Rozbudowa tych struktur danych nie wniosłaby koncepcyjnie nic
nowego, natomiast zagmatwałaby i tak dość pokaźny objętościowo listing).
Struktury danych prezentują się w nowej wersji następująco:
typedef struct rob
{
char nazwisko[100];
long zarobek;
struct rob *nastepny;
}ELEMENT;
// w s k a ź n i k do
// następnego elementu
typedef
{
struct rob_ptr// struktura robocza l i s t y
// wskaźników
ELEMENT
*adres;
struct rob_ptr *następny;
}LPTR;
Olbrzymich zmian jak na razie nie ma i uważny Czytelnik mógłby się słusznie
zapytać, dlaczego nie zostały wykorzystane mechanizmy dziedziczenia, aby mak
symalnie wykorzystać już napisany kod? Powód jest prosty: poprzednia wersja
klasy LISTA służyła w zasadzie do ukazania mechanizmów i algorytmów ba
zowych związanych z listami jednokierunkowymi; j e j zastosowanie praktyczne
było w związku z tym raczej nikłe.
Obecnie prezentowana wersja struktury listy jednokierunkowej charakteryzuje się
bardzo dużą elastycznością użytkowania i to właśnie ona winna służyć jako
klasa bazowa w ewentualnej hierarchii dziedziczenia (o ile Czytelnik w istocie
będzie w ogóle potrzebował mechanizmów dziedziczenia).
Oto nowa wersja klasy LISTA:
const n=a;
const n2=2;
class LISTA
{
{
public:
LISTA() ;
~LISTA();
v o i d dorzuć(ELEMENT * ) ;
void wypisz(char};
lista2.h
//
ilość
kryteriów sortowania
//konstruktor
//destruktor
// dolacz nowy element q
// wypisz zawartość
listy
// usuń element, który jest zgodny z wzorcowa komórką
/ / podana. j a k o p a r a m e t r :
i n t usun(ELEMENT*,int(*decyzja)(ELEMENT*,ELEMENT*));
private:
typedef s t r u c t
// s t r u k t u r a informacyjna
{
// d a n y c h
ELEMENT * g l o w a ;
ELEMENT * o g o n ;
}INFO;
112
Rozdział 5. Struktury danych
I F
N O
info_dane;
typedef struct rob_ptr_inf
// l i s t y wskaźników
//rekord informacyjny l i s t y danych
// struktura informacyjna
{
LPTR * g l o w a ;
LPTR
}LPTR_INFO;
LPTR_INFO i n f _ p t r [ n 2 ] ;
// k i l k a prywatnych
funkcji
*ogon;
|
/ / t a b l i c a k r y t e r i ó w (na
/ / r y s u n k u j e s t t o TAB_PTR)
{omówione w t e k ś c i e ) :
LPTR_INFO
*odszukaj_wsk(LPTR_INFO*,ELEMENT*,
i n t ( * ) (ELEMENT*,ELEMENT*) ) ;
ELEMENT *usun_wsk(LPTR_INFC*,ELEMENT*,
int(*)(ELEMENT*,ELEMENT*));
i n t usun_dane(ELEMENT*);
void
dorzuc2(
int,ELEMENT*,
i n t ( * d e c y z j a ) (ELEMENT*, ELEMENT* ) ) ;
void wypiszl(LPTR_INFO*);
1
};
Tajemnicze metody prywatne, podane wyżej bez żadnego opisu, zostaną szcze
gółowo omówione w następnych paragrafach...
Analizując procedury i funkcje do obsługi list można zauważyć, że operacje odszu
kiwania i pewnego elementu wg podanego wzorca (np. „odszukaj pracownika, który
zarabia 1200zl " ) i wyszukiwania miejsca na wstawienie nowego elementu różniły
się nieznacznie. Od tego spostrzeżenia do gotowej realizacji programowej jest już
tylko jeden krok. Aby go pokonać, musimy dobrze zrozumieć zasady operowania
wskaźnikami do funkcji8 w C++, bowiem ich użycie pozwoli na eleganckie rozwią
zanie kilku problemów. Zdając sobie sprawę, że wskaźniki do funkcji są relatywnie
rzadko stosowane, niezbędne wydało mi się przypomnienie sposobu ich stosowania
w C++. Jest to ukłon głównie w stronę programistów pascalowych, bowiem w ich
ulubionym języku ten mechanizm w ogóle nie istnieje.
Przedstawiony poniżej przykład ilustruje sposób użycia wskaźników do funkcji
w C++.
wsk_fun.cpp
int do_2(int a)
{
return a*a;
}
int do_4(int a)
{
return a*a*a*a;
}
8 s Miłośnicy i znawcy języka LISP mogą opuścić ten paragraf...
5.1 Listy jednokierunkowe
113
int wzor{int x,int(*fun)(int;)
return
fun(X;;
{
void main()
{
cout & lt; & lt; " Wzór 1: " & lt; & lt; wzór (10, do_2) & lt; & lt; endl;
cout & lt; & lt; " wzór 2: " & lt; & lt; wzór(10,do_4) & lt; & lt; endl;
}
Funkcja wzór zwraca - w zależności od tego, czy zostanie wywołana jako
Wzor(10, do_2), czy też wzor(10.do4) - odpowiednio 100 lub 10000. Mamy tu
do czynienia z podobnym fenomenem, jak w przypadku tablic; nazwa funkcji
jest jednocześnie wskaźnikiem do niej. Bezpośrednią konsekwencją jest dość
naturalny sposób użycia, pozwalający na uniknięcie typowych dla C++ operato
rów * (gwiazdka) i & (operator adresowy).
Inny przykład: procedura f, która otrzymuje j a k o parametr liczbę x (typu int)
i wskaźnik do funkcji o nazwie g (zwracającej typ double i operującej trzema pa
rametrami: int. double, i char*\ może zostać zadeklarowana w następujący sposób:
void f(int x, double (*g) (int, double, char *))
{
k=g(12,5.345, " 1984 " ) ;
cout & lt; & lt; k & lt; & lt; endl;
}
}
Zakres stosowania wskaźników do funkcji jest dość szeroki i przyczynia się do
uogólnienia wielu procedur i funkcji.
Powróćmy teraz do odsuniętych chwilowo na bok list i zajmijmy się proble
mem wstawiania nowego elementu do listy uprzednio posortowanej. Chcemy
znaleźć dwa adresy: przed i po (patrz rysunek 5 - 6). które umożliwią nam takie
zmodyfikowanie wskaźników, aby cala lisia była widziana jako posortowana.
W tym celu zmuszeni jesteśmy do użycia pętli while poznanej na stronie 101:
w h i l e ( ( S t a n = = S Z U K A J ) & & (po!=NULL & gt; ))
if
(po- & gt; zarobek & gt; =x)
stan=ZAKONCZ;
else
{
przed=po;
po=po- & gt; nastepny;
}
Gdybyśmy zaś chcieli usunąć pewien element listy, który spełnia przykładowo
warunek, że pole zarobek wynosi 1200 zł, to również będą nam potrzebne
wskaźniki przed i po. Odnajdziemy je w sposób następujący:
w h i l e ; (stan==SZUKAJ) & . (po!=NULL))
&
(
if
(po- & gt; zarobek==1200)
114
Rozdział 5. Struktury danych
stan=ZAKONCZ;
else {
przcd=po;
e
po=po- & gt; nastepny;
}
Różnica pomiędzy tymi dwiema pętlami white tkwi wyłącznie w warunku in
strukcji if- else. Idea naszego rozwiązania jest zatem następująca: napiszmy
uniwersalną funkcję, która posłuży do odszukiwania wskaźników przed i po w celu
ich późniejszego użycia do dokładania elementów do listy, jak również do ich
usuwania. Funkcja ta powinna nam zwrócić oba wskaźniki - posłużymy się do
tego celu strukturą LPTR_INFO (patrz strona 112), umawiając się, że pole
głowa będzie odpowiadało wskaźnikowi przed, a pole ogon - wskaźnikowi po.
Łatwo jest zauważyć, że operacje poszukiwania, wstawiania etc. rozpoczynamy
od listy wskaźników, z której zdobędziemy adres rekordu danych (adres ten
jest/zostanie zapamiętany w polu adres struktury LPTR, która stanowi element
składowy listy wskaźników - patrz rysunek 5 - 9). Dopiero po zmodyfikowaniu
wszystkich list wskaźników (a może ich być tyle, ile przyjmiemy kryteriów
sortowania) należy zmodyfikować listę danych. Pracy jest -jak widać - mnóstwo,
ale jest to cena za wygodę późniejszego użytkowania takiej listy! Pociesze
niem niech będzie takt, że po jednokrotnym napisaniu odpowiedniego zesta
wu funkcji bazowych będziemy mogli z nich później wielokrotnie korzystać
bez konieczności przypominania sobie, jak one to robią... Przejdźmy już do
opisu realizacji funkcji odszukaj_wsk, która zajmie się poszukiwaniem wskaź
ników przed i pa, zwracając je w strukturze LPTR_INFO:
LISTA::LPTR_INFO* L I S T A : : o d s z u k a j _ w s k
(LISTA::LFTR_lNFO * i n f ,
ELEMENT*q,
i n t ( * d e c y z j a ) ( E L E M E N T *ql,ELEMENT
{
LPTR_INFO
if
*res=now
*q2))
LPTR_INFO;
res- & gt; glowa=res- & gt; ogon=NULL;
( inf- & gt; glowa==NUT,T.)
return(res); // lista pusta!
else
{
LPTR *przed,*pos;
przed=NULL;
pos=int- & gt; glowa;
enum (SZUKAJ,ZAKOŃCZ) stan=SZUKAJ;
while ((stan==SZUKAJ) & & (pos!=NULL))
if (decyzja(pos- & gt; adres,q))
stan=ZAKONCZ;
// znaleźliśmy miejsce w Którym element
else
// istnieje (albo ma być wstawiony)
// przemieszczamy sie w poszukiwaniach
przed=pos;
pos=pos- & gt; nastepny;
5.1. Listy jednokierunkowe
115
res- & gt; glowa=przed;
res- & gt; oqon=pos;
return
(res);
}
}
•
wskaźnik inf do struktury informacyjnej listy wskaźników; adres
początku znajduje się w polu głowa, a adres końca w polu ogon',
•
wskaźnik ą do pewnego fizycznie istniejącego rekordu danych. Jest to
albo nowy rekord, który chcemy dołączyć do listy, albo po prostu
pewien szablon poszukiwań;
•
wskaźnik decyzja do funkcji porównawczej, która zostanie włożona do
instrukcji if w pętli while.
Przykładowo, jeśli chcemy odszukać i usunąć pierwszy rekord, który w polu nazwi
sko zawiera „Kowalski " , to należy stworzyć tymczasowy rekord, który będzie miał
odpowiednie pole wypełnione tym nazwiskiem (pozostałe nie będą miały wpływu
na poszukiwanie):
ELEMENT *f=new ELEMENT;
s t r c p y ( f - & gt; nazwisko, " Kowalski " );
Podobna uwaga należy się pozostałym kryteriom poszukiwań - wg zarobków, imie
nia, etc. Jeśli poszukiwanie zakończy się sukcesem, to w polu ogon zostanie zwró
cony adres fizycznie istniejącego rekordu, który odpowiadał wzorcowi naszych po
szukiwań. W przypadku gdyby element taki nie istniał, powinny zostać zwróco
ne wartości N U L L . Znajomość wskaźników przed i po umożliwi nam zwolnie
nie komórek pamięci zajmowanych dotychczas przez rekord danych, jak rów
nież odpowiednie zmodyfikowanie całej listy, tak aby wszystko było na swoim
miejscu.
Innym przykładem zastosowań funkcji niech będzie dołączanie nowego elementu
do listy. Trzeba wówczas stworzyć nowy rekord, prawidłowo wypełnić jego
pola i dołączyć na koniec listy danych. Następnie należy adres tego elementu
wstawić do list wskaźników posortowanych wg zarobków, nazwisk, czy też do
wolnych innych kryteriów. W każdej z tych list miejsce wstawienia będzie inne,
czyli za każdym razem różne mogą być wartości wskaźników przed i po, które
zwróci funkcja odszukaj__wsk.
Zastosowanie funkcji odszukaj_wsk jest, jak widać, bardzo wszechstronne. Ta
ka elastyczność możliwa była do osiągnięcia tylko i wyłącznie poprzez użycie
wskaźników do funkcji - we właściwym miejscu i o właściwej porze...
Oto „garść " funkcji decyzyjnych, które mogą zostać użyte jako parametr:
116
Rozdział 5. Struktury danych
int alfabetycznie( ELEMENT* q1, ELEMENT* q2 )
{
//Czy rekordy q1 i q2 są uporządkowane alfabetycznie
return (strcmp(q1- & gt; nazwisko, q2- & gt; nazwisko) & gt; = 0);
}
int wg_zarobkow( ELEMENT* q1, ELEMENT* q2 )
{
//Czy rekordy q1 i q2 są uporządkowane wg zarobków?
return (q1- & gt; zarobek & gt; = q2- & gt; zarobek);
}
int equal( ELEMENT* q1, ELEMENT* q2 )
{
//Czy rekordy q1 i q2 mają identyczne nazwiska?
return (strcmp( q1- & gt; nazwisko, q2- & gt; nazwisko ) == 0);
}
int equal2( ELEMENT* q1, ELEMENT* q2 )
{
//Czy rekordy q1 i q2 mają identyczne zarobki?
return ( q1- & gt; zarobek == q2- & gt; zarobek );
}
Dwie pierwsze funkcje z powyższej listy służą do porządkowania listy, pozo
stałe ułatwiają proces wyszukiwania elementów. Oczywiście, w rzeczywistej
aplikacji bazy danych o pracownikach analogiczne funkcje byłyby nieco bardziej
skomplikowane - wszystko zależy od tego, jakie kryteria poszukiwa
nia/porządkowania zamierzamy zaprogramować oraz jak skomplikowane
struktury danych wchodzą w grę.
Po tak rozbudowanych wyjaśnieniach działanie funkcji odszukaj_wsk nie po
winno stanowić już dla nikogo tajemnicy.
Na stronie 97 mieliśmy okazję zapoznać się z funkcfą pusta informującą, czy li
sta danych coś zawiera. Nic nie stoi na przeszkodzie, aby do kompletu dołożyć
jej kolejną wersję, badającą w analogiczny sposób listę wskaźników:
i n l i n e i n t pusta (LPTR_INFO *inf)
{
r e t u r n (inf- & gt; glowa==NULL) ;
}
Ponieważ użyliśmy dwukrotnie tej samej nazwy funkcji, nastąpiło w tym mo
mencie jej przeciążenie: podczas wykonywania programu właściwa jej wersja
zostanie wybrana w zależności od typu parametru, z którym zostanie wywołana
(wskaźnik do struktury INFO lub wskaźnik do struktury LPTR_INFO).
5.1. Listy jednokierunkowe
117
Mając już komplet funkcji pusta, zestaw funkcji decyzyjnych i uniwersalną
funkcję odszukaj_wsk, możemy pokusić się o napisanie brakującej procedury do
rzuci, która będzie służyła do dołączania nowego rekordu do listy danych z
jednoczesnym sortowaniem list wskaźników. Załóżmy, że będą tylko dwa kry
teria sortowania danych, co implikuje, iż tablica zawierająca „wskaźniki do list
wskaźników* " będzie miała tylko dwie pozycje (patrz rysunek 5 - 9).
Adres tej tablicy, jak również wskaźniki do listy danych i do nowo utworzonego
elementu zostaną obowiązkowo przekazane jako parametry:
void LISTA::dorzuć(ELEMENT *q)
{
// rekord dołączamy bez sortowania
if
{info_dane.glowa==NULL)
// lista pusta
info_danc.glowa-info_dane.ogon=q;
else
// coś jest w liście
{{
(info_dane.ogon) - & gt; nastepny=q;
info_dane.ogon=q;
}}
// dołączamy wskaźnik do rekordu do listy
// posortowanej alfabetycznie:
dorzuc2(0,q,alfabetycznie);
// dołączamy wskaźnik do rekordu do listy
// p o s o r t o w a n e j wg z a r o b k ó w :
dorzuc2(1,ą,wg zarobków);
}
Funkcja jest bardzo prosta, głównie z uwagi na tajemniczą procedurę o nazwie
dorzuci. Oczywiście nie jest to jej poprzedniczka ze strony 101. choć różni się od
tamtej doprawdy niewiele:
void LISTA::dorzuc2(
i n t nr,
ELEMENT *q,
int(*decyzja(ELEMENT
ELEMENT *q2)
*ql,
}{
LPTR *wsk=new LPTR;
wsk- & gt; adres=q; // wpisujemy adres rekordu q
//Poszukiwanie właściwej pozycji na wstawienie elementu;
if (inf_ptr [nr] .glcwa==NULL) // lista pusta
{
inf_ptr[nr],glowa=inf_ptr[nr].ogon=wsk;
Wsk & gt; nastepny=NULL;
}else //szukamy miejsca na wstawienie
{
LPTR *przed,*po;
LPTR_INFO
*gdzie;
gdzie=odszukaj_wsk( & inf_ptr[nr],q,decyzja);
prrzed=gdzie- & gt; glowa;
po-qdzie- & gt; ogon;
118
Rozdział 5. Struktury danych
(
if{przed==NUlL & gt;
// wstawiamy na początek l i s t y
)
{
inf_ptr[nr].glowa=wsk;
wsk- & gt; nastepny=po;
if
}
}}
(po (po=NULL)
== NULL)
// wstawiamy na koniec Listy
{{
int_ptr[nr].oqon- & gt; nastepny=wsk;
wsk- & gt; nastepny-NULL;
i n f _ p t r [ n r ] .ogon=wsk;
}
else
// wstawiamy g d z i e ś " w środku "
{
przed- & gt; nastepny-wsk;
wsk- & gt; nastepny=po;
}
j
W celu zrozumienia dokonanych modyfikacji właściwe byłoby porównanie obu
wersji funkcji dorzuc2, aby wykryć różnice, które między nimi istnieją,
„Filozoficznie " nie ma ich wiele - w miejsce sortowania danych sortujemy po
prostu wskaźniki do nich.
Funkcja zajmująca się usuwaniem rekordów wymaga przesłania m.in. fizycz
nego adresu elementu do usunięcia. Mając tę informację należy „wyczyścić "
zarówno listę danych, jak i listy wskaźników:
int LISTA::usun(ELEMENT *q,
int(*decyzja){ELEMENT *ql,
ELEMENT *q2) )
{{
// usuwa całkowicie informacje z obu l i s t :
//wskaźników i danych
ELEMENT *ptr_dane;
for (int
i=0;
i & lt; n2;
|
i++)
ptr_dane=usun_wsk ( & inf_ptr [ i ] , q, decyzja) ;
if (ptr_dane==NULL)
return(0);
else
r e t u r n usun_dane(ptr_dane);
}
Funkcja usun_wsk zajmuje się usuwaniem wskaźników danego elementu z list
wskaźników - jakakolwiek byłaby ich liczba. Czytelnik może zauważyć z ła
twością, że raz jeszcze mamy tu do czynienia z bardzo podobnym do poprzed
nich schematem algorytmu.
Można nawet odważyć się na stwierdzenie, że listing jest zamieszczany wy
łącznie gwoli formalności! Elementarna kontrola błędów jest zapewniana przez
5,1.
Listy
jednokierunkowe
119
wartość zwracaną przez funkcję: w normalnej sytuacji winien to być różny od
NULL adres fizycznego rekordu przeznaczonego do usunięcia.
ELEMENT* L I S T A : : u s u n wsk(
LPTR_INFO * i n f ,
ELEMENT *q,
int(*decyzja){ELEMENT
ELEMENT *q2)
*ql,
if
(inf- & gt; glowa==NULL}
// l i s t a p u s t a , c z y l i n i e ma co usuwać!
r e t u r n NULL;
else
//szukamy e l e m e n t u d o u s u n i ę c i a
{
LPTR
*przed,*pos;
LPTR_INFO *gdzie=odszukaj_wsk{inf,q,decyzja);
przed=gdzie- & gt; glowa;
pos=gdzie & gt; ogon;
if (pos=NULL)
return NULL;
// element nie odnaleziony
if(pos==inf- & gt; glowa) // usuwamy z początku listy
inf- & gt; glowa=pos- & gt; nastepny;
else
if (pos- & gt; nastepny==NULL) //usuwamy z końca listy
{
inf- & gt; ogon=przed;
p r z e d - & gt; n a s t e p n y =NULL;
}else
/ / usuwamy g d z i e ś " z e ś r o d k a "
przed- & gt; nastepny=pos- & gt; nastepny;
ELEMENT * r e t = p o s - & gt; a d r e s ;
d e l e t e pos;
return ret;
}}
Funkcja usuń dane jest zbudowana wg podobnego schematu co funkcja
usun_wsk. Ponieważ przyjmowane jest założenie, że element, który chcemy
usunąć, istnieje, programista musi zapewnić dokładną kontrolę poprawności
wykonywanych operacji. Tak się dzieje w naszym przypadku - ewentualna
nieprawidłowość zostanie wykryta już podczas próby usunięcia wskaźnika i
wówczas usuniecie rekordu po prostu nie nastąpi.
int
LISTA::usun_dane(ELEMENT *q)
{
{
// założenie: q istnieje!
ELEMENT *przed,*pos;
przed=NULL;
pos=info
dane.glowa;
while((pos!=q) & & (pos!=NULL))//szukany
{
przed-poa;
pos=pos- & gt; nastepny;
elementu " przed "
Rozdział 5. Struktury danych
if (pos!=q)
return(O);
// element nie odnaleziony?!
if (pos==info dane.głowa)
// usuwamy z początku listy
{
{{
info_dane.glowa~pos- & gt; nastepny;
delete pos;
else
if(pos- & gt; nastepny=*NULL) // usuwany z końca listy
{
info_dane.ogon=przed;
przed- & gt; nastepny=NULL;
delete pos;
}else
// usuwamy gdzieś " ze środka "
}
przed- & gt; nastepny=pos- & gt; nastepny;
delete pos;
return(1);
}
|
}
Pomimo wszelkich prób uczynienia powyższych funkcji bezpiecznymi, kontrola
w nich zastosowana jest ciągle bardzo uproszczona. Czytelnik, który będzie zaj
mował się implementacją dużego programu w C++, powinien bardzo dokładnie
kontrolować poprawność operacji na wskaźnikach. Programy stają się wówczas
co prawda mniej czytelne, ale jest to cena za mały, lecz jakże istotny szczegół:
ich poprawne działanie,..
Poniżej znajduje się rozbudowany przykład użycia nowej wersji listy jednokie
runkowej. Jest to dość spory fragment kodu. ale zdecydowałem się na jego
zamieszczenie (biorąc pod uwagę względne skomplikowanie omówionego
materiału kłoś nieprzyzwyczajony do sprawnego operowania wskaźnikami miał
prawo się nieco zgubić; szczegółowy przykład zastosowania może mieć zatem duże
znaczenie dla ogólnego zrozumienia całości).
Dwie proste funkcje wypisz I i wypisz zajmują się eleganckim wypisaniem na
ekranie zawartości bazy danych w kolejności narzuconej przez odpowiednią
lisię wskaźników:
void LISTA::wypiszl(LPTR_INFO *inf)
{
// wypisujemy zawartość posortowanej l i s t y
// wskaźników ( o c z y w i ś c i e n i c i n t e r e s u j e nas
// wypisanie wskaźników (są to a d r e s y ) , l e c z
// i n f o r m a c j i na k t ó r e one wskazują
LPTR *q=inf- & gt; glowa;
while (q ! = NULL)
{
cout & lt; & lt; setw{9) & lt; & lt; q- & gt; adres- & gt; nazwisko & lt; & lt; " z a r a b i a "
& lt; & lt; setw{4) & lt; & lt; q & gt; adres- & gt; zarobek & lt; & lt; " z
\n " ;
q=q- & gt; nastepny;
5.1 Listy jednokierunkowe
121
Uruchomienie programu powinno dać następujące wyniki:
*** Basa danych p o s o r t o w a n a a l f a b e t y c z n e
***
Bec zarabia 1300zł
Becki
z a r a b i a 1000zł
Czerniak zarabia 3000zl
Fikus zarabia 3200zł
Tertek zarabia 2000zł
* * * Baza danych p o s o r t o w a n a wg zarobków ***
Becki
fikus
Bec
Pertek
zarabia
zarabia
2arabia
zarabia
lOOOzł
1200zł
1300zł
2000zł
122
Rozdział5. Struktury danyn
Czerniak z a r a b i a 3000zł
Wynik u s u n i ę c i a rekordu pracownika z a r a b i a j ą c e g o
*** 3aza danych posortowana a l f a b e t y c z n e ***
Bec z a r a b i a 1300zł
Becki z a r a b i a l 0 0 0 z ł
Czerniak z a r a b i a 3000zł
Fikus z a r a b i a 1200zł
*** Baza danych posortowana wg zarobków **
Becki z a r a b i a l000zł
0
Fikus z a r a b i a 120Czł
Bec z a r a b i a 1300zł
Czerniak z a r a b i a 3000zł
2000zł=l
5.2.Tablicowa implementacja list
Programowanie w C + + zmusza niejako programistę do dobrego poznania operacji na dynamicznych sturukturach danych, sprawnego żonglowania wskaźni
kami etc. To jest uwaga natury ogólnej, natomiast trzeba zauważyć również, iż
nie wszyscy wskaźniki lubią. Przyczyn tej niechęci należy upatrywać głównie
w próbach programowania na przykład struktur listowych bez pełnego zrozu
mienia tego. co się chce zrobić. Efekty najczęściej są opłakane, a winę w takich
przypadkach rzecz jasna ponosi „chłopiec do bicia " , czyli sam język progra
mowania. Tymczasem, podobnie zresztą jak i w życiu, to samo można zrobić
wieloma sposobami - o czym niejednokrotnie zapominamy.
Tak też jest i z listami. Okazuje się. że istnieje kilka sposobów tablicowej im
plementacji list, niektóre z nich charakteryzują się nawet dość istotnymi zale
tami, niemożliwymi do uzyskania w realizacji „klasycznej " (czyli tej, którą
mieliśmy okazję poznać wcześniej). Olbrzymią wadą tablicowych wersji
struktur listowych jest marnotrawstwo pamięci przydzielamy przecież na stałe
pewien obszar pamięci, powiedzmy dla 1000 elementów - bo tyle w „porywach "
będziemy potrzebowali miejsca. Gdyby natomiast nasz program używał listy o
długości 200 elementów, to i tak obszar realnie zajmowany wynosiłby 1009,
Jest to jednak cena nie do uniknięcia, płacimy ją za prostotę realizacji.
|
5.2.1.Klasyczna reprezentacja tablicowa
Jedną z najprostszych metod zamiany tablicy na listę jest umówienie się co do
sposobu interpretacji jej zawartości. Jeśli powiemy sobie głośno (i nie zapomnimy
zbyt szybko o tym), że i-temu indeksowi tablicy będzie odpowiadać i-ty element
listy, to problem mamy prawie z głowy. To „prawie " wynika z tego, że trzeba
się umówić, ile maksymalnie elementów zechcemy zapamiętać na liście. Oprócz
5.2. Tablicowa implementacja list
123
tego konieczne będzie wybranie jakiejś zmiennej do zapamiętywania aktualnej
ilości elementów wstawionych wcześniej do listy.
Ideę ilustruje rysunek 5 - 10, gdzie możemy zobaczyć tablicową implementację
listy 3-elementowcj złożonej z elementów: 4, 6, I, -5 i 12:
Rys. 5 - 10.
Tablicowa
implementacja
listy
dane
5
4
4
6
1
12
-5 12
miejsce wolne
Programowa realizacja jest bardzo prosta - deklaracja klasy nie zawiera żad
nych niespodzianek:
lista tab.cpp
const int MaxTab=201;
class ListaTab
// 200 możliwych elementów
{int tab[MaxTab];
// tab[0] zarezerwowane!
public:
ListaTab()
{
tab[0]=0;.
// k o n s t r u k t o r klasy
// usuń element z pozycji k
// wstaw element x na koniec l i s t y
void UsunElement ( i n t k ) ;
// wstaw element x na pozycję k:
void WstawElement ( i n t x);
void WstawElement ( i n t x, i n t k) ;
void WypiszListe ( ) ;
};
};
Omówmy błyskawicznie wszystkie funkcje usługowe klasy. Przypuśćmy, że
chcemy dysponować możliwością usunięcia A:-tego elementu naszej „listy " . Po
zbadaniu sensu takiej operacji (element musi istnieć!) wystarczy przesunąć zawar
tość tablicy o jeden w lewo od A-tej pozycji. Podczas przesuwania element nr k
jest bezpowrotnie „zamazywany " przez swojego sąsiada:
void ListaTab::UsunElement(int k)
{
ł
// usuwamy k-ty element listy, k & gt; =l
i f ( ( k & gt; = l ) & & ( k & lt; = t a b [ 0 ] ) )
{{
for(int i=k;i & lt; tab[0];i++)
tab[i]=tab[i+1];
tab[0]--;
}
Wariantów przedstawionej wyżej funkcji może być dość sporo. Mam nadzieję, że
Czytelnik w miarę swoich specyficznych potrzeb będzie mógł je sobie stworzyć.
124
Rozdział 5. Struktury danych
Co jednak z dołączaniem elementów do listy? Poniżej są omówione dwie wersje
odpowiedniej funkcji: pierwsza wstawia na koniec listy, druga na k-tą jej pozycję.
Oczywiście w przypadku tej drugiej funkcji niezbędne jest dokonanie od
powiedniego przesunięcia zawartości tablicy, podobnie jak w metodzie
UsunElement:
void ListaTac::WstawElement;int x)
{
// wstawiamy element x na koniec l i s t y
if(tab[0] & lt; MaxTab-l)
}
tab[++tab[O]]=x;
void ListaTab::WstawElement (int x,int k)
{//wstawiamy element x na k-tą pozycję listy:
iff(k & gt; =l) & & (k & lt; =tab[0]+l) & & (tab[0] & lt; MaxTab-l & gt; 1
{
for(int i=tab[0];i & gt; =k;i—)
tab[i+1]=tab[i];// robimy miejsce
tab[k]=x;
tab[0]++;
}
}
Zasady posługiwania się taką pseudolistą są już po stworzeniu wszystkich metod
identyczne z „prawdziwą " listą jednokierunkową, dlatego leż darujemy sobie cyto
wanie funkcji main.
Możliwe jest oczywiście takie zdefiniowanie klasy ListaTab, aby dołączanie
elementów następowało już w porządku malejącym, rosnącym, czy też wedle
jakiegoś innego klucza - Czytelnik może odpowiednio rozbudować funkcje
i metody w ramach nieskomplikowanego ćwiczenia.
5.2.2.Metoda tablic równoległych
W poprzednio poznanej implementacji list przy pomocy zwykłej tablicy przypi
saliśmy na sztywno i-temu elementowi tablicy i-ty element listy. W prostych
zastosowaniach może to wystarczyć w zupełności, jednak rozwiązanie takie
o wiele bardziej jest zbliżone ideowo do tablicy niż do listy. „Prawdziwa " lista
powinna umożliwiać dość dowolne układanie elementów i sortowanie ich przy
użyciu tylko i wyłącznie wskaźników. Chcieliśmy jednak od wskaźników,
przydziałów pamięci, procedur new i delete uciec jak najdalej! Czyżby ich użycie
było nieuchronne?
Odpowiedź na szczęście brzmi NIE! Wszystko można w końcu zasymulować,
więc czemu nie wskaźniki?! Popularna metoda polega na zadeklarowaniu tablicy
rekordów składających się z pola informacyjnego info i pola typu całkowitego
następny, które służy do odszukiwania elementu „następnego " na liście. Dobrze
znane i klasyczne wręcz rozwiązanie. Idea jest przedstawiona na rysunku
5.2. Tablicowa implementacja list
125
5 - 1 1 , gdzie można zobaczyć przykładową implementację listy służącej do
przechowywania znaków, zawierającej w danym momencie pięć liter układają
cych się w słowo „ K O T E K " .
Rys. 5-11
Metoda " tablic
równoległych " (I).
hu następny,
char info:
rekord bazowy
Przykładowa tablica rekordów z danymi
Pierwszy element tablicy (tzn. ten z pozycji 0) pełni rolę wskaźnika początku
listy, -lest to zatem zmienna typu głowa. Jeśli oznaczymy tablice jako t, to
t[OJ.następny zawiera indeks pierwszego rzeczywistego elementu listy. W naszym
przykładzie jest to 3, zatem w t[3].info znajduje sic pierwszy element l i s t y jest nim znak ' K ' . Aby dowiedzieć się następnie, co następuje po ' K ' , musimy
odczytać l[3].następny. Jest to 2 i tam też jest umieszczona kolejna litera słowa
„ K O T E K " - etc. Koniec listy jest zaznaczany umownie poprzez wartość -/
w polu następny.
Rozwiązanie to można uznać za eleganckie i elastyczne. Dopisanie funkcji, które
obsługują taką strukturę danych, nic jest trudne. Występuje tu pełna analogia
pomiędzy już wcześniej przedstawionymi funkcjami (patrz Listy jednokierunkowe)*
dlatego też zadanie ewentualnego opracowania ich pozostawiam Czytelnikowi.
Należy przy okazji zwrócić uwagę na jedną niedogodność: mamy tu do czynienia
z bardzo ścisłym połączeniem samej ..gołej " informacji z komórkami, które sy
mulują wskaźniki. O ile w przypadku list był to zabieg niezbędny, to przy wy
korzystaniu tablic możemy bez wahania oddzielić te dwie rzeczy. Inaczej rzecz
formułując, dobrze by było dysponować osobną tablicą na dane i osobną na
wskaźniki. Dlaczego jednak nic pójść dalej i nie używać kilku tablic na wskaź
niki?! Zbliżylibyśmy się wówczas do wersji zaprezentowanej na rysunku 5 - 8.
otrzymując jednak o wiele prostsze w realizacji zadanie.
Na rysunku 5 - 1 2 jest przedstawiona mini-baza danych zgrupowana w wyod
rębnionej tablicy danych.
126
Rozdział 5. Struktury danych
Rys. 5 -12.
Metoda „tablic
równoległych " (2).
L1
DANE
L2
L3
0
głowa
1
2
Kowalski
37
2000
3
1
3
3
Zaremba
30
3000
1
4
1
4
Fuks
34
1200
2
3
4
5
6
7
Obok tablicy danych możemy zauważyć trzy osobne tablice „wskaźników " ,
które umożliwiają dostęp do danych widzianych jako listy posortowane wedle
przeróżnych kryteriów. Tablica dane zawiera rekordy z danymi, przy czym efek
tywne informacje zaczynają się począwszy od komórki dane[2] w górę. Dlaczego
tak dziwnie? Otóż zabieg ten zapewnia nam odpowiedniość „l do 1 " tablicy
danych i tablic „wskaźników " (LI, L2 i L3, które są w rzeczywistości zwykłymi
tablicami liczb całkowitych).
W tych tablicach bowiem komórki nr 0 i nr 1 są zarezerwowane odpowiednio
na: wskaźnik początku listy i znacznik końca. Należy to rozumieć w ten sposób,
że Ll[0] zawierający liczbę 4 informuje nas, iż dane[4] są pierwszym rekordem
na liście. A jaki jest rekord następny? Oczywiście Ll{4]=2 co oznacza, że dru
gim rekordem na liście danych jest dane[2]. Postępując tak dalej odtwarzamy
całą listę: dane [4], dane[2], dane[3] - łatwo zauważyć, że jest to lista posorto
wana alfabetycznie wg nazwisk. Skąd jednak wiemy, że dane[3J jest ostatnim re
kordem na liście? Otóż IA[3] zawiera 1. co stanowi wg naszej umowy znacznik
końca listy.
|
Analogicznie postępując możemy „odkryć " , że L2 jest listą pusortowaną
wg kodów 2-cyfrowych, a L3 - wg zarobków. Tablicowa reprezentacja list, w
której nastąpiło oddzielenie danych od wskaźników, pozwala na zapamiętanie
w tym samym obszarze pamięci kilku list jednocześnie - o ile oczywiście ich
elementy składowe w jakiś sposób się pokrywają. W aplikacjach, w których
występuje taka sytuacja, jest to cenna właściwość przyczyniająca się do
zmniejszenia zużycia pamięci. Ponadto wspomniana na samym początku tego
paragrafu wada tablic, tzn. zajmowanie przez nie stałego obszaru, może być
w łatwy sposób ominięta poprzez sprytne ukrycie dynamicznego zarządza
nia tablicą w definicji klasy (patrz np. klasy z grupy TArray w systemie Bor
land C++). Samodzielne zdefiniowanie takiej klasy jest jednak czasochłonne zwłaszcza jeśli zamierzamy zadbać ojej uniwersalność.
5.2. Tablicowa implementacja list
127
5.2.3-Listy innych typów
Listy jednokierunkowe są bardzo wygodne w stosowaniu i zajmują stosunkowo
mało pamięci. Tym niemniej operacje na nich niekiedy zajmują dużo czasu. Zauwa
żyło ten fakt sporo ludzi i tym sposobem zostały wymyślone inne typy list, np.:
lista dwukierunkowa - komórka robocza zawiera wskaźniki do elementów:
poprzedniego i następnego:
Rys. 5 -13. Lista
dwukierunkowa.
• pierwsza komórka znajdująca się w liście nie posiada swojego po
przednika; zaznaczamy to wpisując wartość NULL do pola poprzedni;
• ostatnia komórka znajdująca się w liście nie posiada swojego na
stępnika; zaznaczamy to wpisując wartość NULL do pola następny.
Lista dwukierunkowa jest dość „kosztowna " , jeśli chodzi o zajętość
pamięci, tym niemniej czasami ważniejsza jest szybkość działania od
ewentualnych strat pamięci.
Struktura wewnętrzna listy dwukierunkowej jest oczywista:
typedef s t r u c t rob
{
int wartość;
struct
rob *następny;
struct
rob *poprzedni;
}ELEMENT;
Załóżmy teraz, że podczas przeglądania elementów listy zapamiętaliśmy
wskaźnik pozycji bieżącej p. (Przykładowo szukaliśmy elementu spełniającego
pewien warunek i na wskaźniku p nasze poszukiwania zakończyły się sukcesem).
Jak usunąć element p z listy? Jak pamiętamy z paragrafów poprzednich, do pra
widłowego wykonania tej operacji niezbędna była znajomość wskaźników przed
i po, wskazujących odpowiednio na komórki poprzednią i następną. W przypadku
listy dwukierunkowej w komórce wskazywanej przez p te dwie informacje już
się znajdują i wystarczy tylko po nie sięgnąć:
void usun2kier(ELEMENT *p)
{
if(p- & gt; poprzedni!=NULL)
// nie jest to element pierwszy
p- & gt; poprzedni- & gt; nastepny=p- & gt; nastepny;
if(p- & gt; nastepny!=NULL)
// nic jest to element ostatni
p- & gt; nastepny- & gt; poprzedni=p- & gt; pcprzedni;
}
128
Rozdział 5. Struktury danych
W zależności od konkretnych potrzeb można element p fizycznie usunąć z pamięci
przez instrukcję delete lub też go w niej pozostawić do ewentualnych innych
celów. Rysunek 5 - 14 jest odbiciem procedury usim2kier (potrzebne modyfikacji
wskaźników są zaznaczone linią pogrubioną):
Rys. 5-14.
Usuwanie danych
z listy dwukie
runkowej
lista cykliczna - patrz rysunek 5 - 1 5 - j e s t zamknięta w pierścień:. wskaźni\
ostatniego elementu wskazuje „pierwszy " element.
•
Pewien element określany jest jako " pierwszy' " raczej umownie i służy
wyłącznie do wejścia w „magiczny krąg " wskaźników listy cyklicznej...
Rys, 5-/5.
Lista cykliczna.
Kaźda z przedstawionych powyżej list ma swoje wady i zalety. Celem tej prezentacji było ukazanie istniejących rozwiązań, zadaniem zaś Czytelnika będzie
wybranie jednego z nich podczas realizacji swojego programu.
5.3. Stos
Stos jest kluczową strukturą danych w informatyce. To zdanie brzmi bardzo
groźnie, lecz chciałbym zapewnić, że nie kryje się za nim nic strasznego. Krótko
mówiąc jest to struktura danych, która ułatwia rozwiązanie wielu problemów
natury algorytmicznej i w tę właśnie stronę wspólnie będziemy zdążać. Zanim doj
dziemy do zastosowań stosu, spróbujmy go jednak zaimplementować w C++!
5.3.1.Zasada działania stosu
Stos jest strukturą danych, do której dostęp jest możliwy tylko od strony tzw. wierz
chołka, czyli pierwszego wolnego miejsca znajdującego się na nim. Z tego też
względu jego zasada działania jest bardzo często określana przy pomocy
5.3. Stos
129
angielskiego skrótu LIFO: Last-In-First-Out. co w wolnym tłumaczeniu oznacza
„ostatni będą pierwszymi " . Do odkładania danych na wierzchołek stosu służy zwy
czajowo funkcja o nazwie push(X), gdzie X jest daną pewnego typu. Może to być
dowolna zmienna prosta lub złożona: liczba, znak. rekord...
Podobnie, aby pobrać element ze stosu, używa się funkcji o nazwie pop(X), która
załadowuje zmienną X daną zdjętą z wierzchołka stosu. Obie te podstawowe
funkcje oprócz swojego głównego zadania, które zostało wzmiankowane wyżej,
zwracają jeszcze kod błędu . Jest to stała typu całkowitego, która informuje pro
gramistę, czy czasem nie nastąpiła sytuacja anormalna, np. próba zdjęcia czegoś ze
stosu w momencie, gdy był on już pusty, lub też próba odłożenia na nim kolejnej
danej, w sytuacji gdy brakowało w nim miejsca (brak pamięci). Programowe reali
zacje stosu różnią się między sobą drobnymi szczegółami (ostateczne słowo w koń
cu ma programista!), ale ogólna koncepcja jest zbliżona do opisanej wyżej.
Zasada działania stosu może zostać zatem podsumowana dwiema regułami:
• po wykonaniu operacji push(X) element X sam staje się nowym
wierzchołkiem stosu „przykrywając " poprzedni wierzchołek (jeśli
oczywiście coś na stosie już było);
• jedynym bezpośrednio dostępnym elementem stosu jest jego wierzchołek.
Dla dokładniejszego zobrazowania zasady działania stosu proszę prześledzić
kilka operacji dokonanych na nim i efekt ich działania patrz rysunek 5 - 16.
W tej wersji niekoniecznie K.
Rys. 5 - 16.
Stos i podstawowe
operacje na nim.
Courier
- Kod programu
pochylona kursywa - komentarz
s=pop(c); s = push('A'); s = push('L'); s = pop(c); s=push('B'); s=push('C');
2
1
Rysunek przedstawia stos służący do zapamiętywania znaków. Stałe symbo
liczne StosPusty, OK i StosPelny są zdefiniowane przez programistę w module
zawierającym deklarację stosu. Wyrażają się one w wartościach typu int (co
akurat nic ma specjalnego znaczenia dla samego stosu...). Nasz stos ma pojemność
dwóch elementów, co jest oczywiście absurdalne, ale zostało przyjęte na użytek
naszego przykładu, aby zilustrować efekt przepełnienia.
1 Nie jest to bynajmniej obowiązkowe!
_130
Rozdział 5. Struktury danych
Symboliczny stos znajdujący się pod każdą z sześciu grup instrukcji ukazuje
zawsze stan po wykonaniu „swojej " grupy instrukcji. Jak można łatwo zauważyć,
operacje na stosie przebiegały pomyślnie do momentu osiągnięcia jego całkowitej
pojemności; wówczas stos zasygnalizował sytuację błędną.
Jakie są typowe realizacje stosu? Najpopularniejszym sposobem jest użycie
tablicy i zarezerwowanie jednej zmiennej w celu zapamiętania liczby danych
aktualnie znajdujących się na stosie. Jest to dokładnie taki sam pomysł, jak ten
zaprezentowany na rysunku 5 - 10, z jednym zastrzeżeniem: mimo iż wiemy,
jak stos jest zbudowany „od środka " , nie zezwalamy nikomu na bezpośredni
dostęp do niego. Wszelkie operacje odkładania i zdejmowania danych ze stosu
muszą się odbywać za pośrednictwem metod push i pop. Jeśli zdecydujemy się
na zamknięcie danych i funkcji służących do ich obsługi w postaci klasy " , to
wówczas automatycznie uzyskamy „bezpieczeństwo " użytkowania - zapewni je
sama koncepcja programowania zorientowanego obiektowo. Taki właśnie spo
sób postępowania obierzemy.
Możliwych sposobów realizacji stosu jest mnóstwo; wynika to z faktu, iż ta
struktura danych nadaje się doskonale do ilustracji wielu zagadnień algoryt
micznych. Dla naszych potrzeb ograniczymy się do bardzo prostej realizacji
tablicowej, która powinna być uważana raczej za punkt wyjścia niż za gotową
implementację.
W związku z założonym powyżej celowym uproszczeniem, definicja klasy
STOS jest bardzo krótka:
stos.h
const
i n t DLUGOSC_MAX=300;
c o n s t i n t ST0S_PELNY=3;
c o n s t i n t STOS_PUSTY=2;
c o n s t i n t OK=1;
template & lt; class TypPodst & gt; class STOS
{I
TypPodst t [ DLUGOSC__MAX+l ] ;
stos=t[0]...t[DLUGOSC_MAX]
int szczyt;
J
// szczyt = pierwsza WOLNA komórka
public:
STOS
()
{
szczyt=0};
// k o n s t r u k t o r
void c l e a r () { szczyt=0;} // zerowanie s t o s u
i n t push(TypPodst x);
i n t pop (TypPodst & w);
i n t StanStosu{);
}; // k o n i e c d e f i n i c j i k l a s y STOS
Nasz stos będzie mógł potencjalnie służyć do przechowywania danych wszela
kiego rodzaju, z tego też powodu celowe wydało się zadeklarowanie go w postaci
tzw. klasy szablonowej, co zostało zaznaczone przez słowo kluczowe template.
" Czyli dokonamy tzw. hermetyzacji.
2
5.3. Stos
131
Idea klasy szablonowej polega na stworzeniu wzorcowego kodu, w którym typ
pewnych danych (zmiennych, wartości zwracanych przez funkcje...) nie zostaje
precyzyjnie określony, ale jest zastąpiony pewną stałą symboliczną. W naszym
przypadku jest to stała TypPodst.
Zaletą tego typu postępowania jest dość duża uniwersalność tworzonej klasy.
gdyż dopiero w funkcji main określamy, że np. TypPodst powinien zostać za
mieniony na np. float, char* lub jakiś złożony typ strukturalny. Wadą klasy
szablonowej jest jednak dość dziwna składnia, której musimy się trzymać chcąc
zdefiniować jej metody. O ile jeszcze definicje znajdują się w ciele klasy (tzn.
pomiędzy jej nawiasami klamrowymi), to składnia przypomina normalny kod
C++. W momencie jednak gdy chcemy definicje metody umieścić poza klasą,
to otrzymujemy tego rodzaju dziwolągi3:
template & lt; class TypPodst & gt; int STOS & lt; TypPodst & gt; : :
push(TypPodst x)
{
// element x zostanie położony na stos
if ( szczyt & lt; =DLUGOSC_MAX)
{
{
t[szczyt++]=x;
return (OK);
} else
return (STOS_PELNY) ;
}}
Metoda push, bowiem to jej kod mamy przed oczami, jest bardzo prosta, co jest
zresztą cechą wszelkich realizacji tablicowych. Nowy element x (jaki by nie był
jego typ) jest zapisywany na szczyt stosu, który jest wskazywany w prywatnej
dla klasy zmiennej szczyt. Następnie wartość szczytu stosu jest inkrementowana
- to wszystko pod warunkiem, że stos nie jest już zapełniony!
Metoda pop wykonuje odwrotne zadanie, zdejmowany ze stosu element jest zapa
miętywany w zmiennej w (przekazanej w wywołaniu przez referencję); zmien
na szczyt jest oczywiście dekrementowana pod warunkiem że stos nie był pu
sty (z próżnego to nawet i programista nie... naleje?):
template & lt; class TypPodst & gt; int STOS & lt; TypPodst & gt; ::
pop(TypPodst & w)
Oczywiście, zawsze można się pocieszać, że ewentualnie mogłoby to zostać jeszcze
bardziej skomplikowane... Ale żarty na bok, powyższy problemy wynikają z prostego
faktu: C++ należy do grupy języków których kompilatory muszą znać precyzyjnie typ
danych, które wchodzą w grę podczas programowania, stąd tez każdy zabieg, który
służy uczynienia go pozornie nieczułym na typy danych, musi być nieco sztuczny. Warto
wspomnieć przy okazji, że istnieją języki z zasady pozbawione pojęcia typu danych, np.
Smaltalk-80 (jest to język obiektowy o zupełnie innej filozofii niż C++, który wydaje
się przy nim swego rodzaju asemblerem obiektowym...).
132
Rozdział 5.
Struktury danych
{
// " w " z o s t a n i e " załadowane " w a r t o ś c i ą z d j ę t ą z e s t o s u
i f (szczyt & gt; 0)
{
w=t [--szczyt ];
r e t u r n (OK);
}else
r e t u r n (STOS_PUSTY);
}
Od czasu do czasu może zajść potrzeba zbadania stanu stosu bez wykonywania
na nim żadnych operacji. Użyteczna może być wówczas następująca funkcja:
t e m p l a t e & lt; c l a s s TypPodst & gt; i n t STOS & lt; TypPodst & gt; ::
StanStosu()
{
// zwraca informacje o s t a n i e s t o s u
switch(szczyt)
{
case 0
: r e t u r n (;STOS__PUSTY) ;
+1
c a s e DLUGOSC_MAXl : r e t u r n (STOS_PELNY) ;
default
: r e t u r n (OK)
}
}
{
{
}
}
Jakie są inne możliwe sposoby zdefiniowania stosu? Kie powinno dla nikogo
stanowić niespodzianki, że logicznym następstwem użycia tablic są struktury
dynamiczne, np. listy. Bezpośrednie wbudowanie listy do stosu, zamiast na
przykład tablicy t tak jak wyżej, byłoby jednakże nieefektywne - warto poświęcić
odrobinę wolnego czasu i stworzyć osobną klasę od samego początku.
Chwilę uwagi należy jeszcze poświęcić wykorzystaniu stosu. Zasadniczą kwestią
jest składnia użycia klasy szablonowej w funkcji main. Deklaracja stosu .v, który
ma posłużyć do przechowywania zmiennych typu np. char*, dokonuje się poprzez:
STOS & lt; char* & gt; s- podobnie dzieje się w przypadku każdego innego typu danych:
stos.cpp
#include " s t o s . h "
#include & lt; iotream.h & gt;
c h a r * t a b 1 3 ] = " ala " , " ma " , " k o t a " } ;
f l o a t tab2[3]={3.14, 2.12,100};
void main()
{
// deklarujemy jeden stos do przechowywania tekstów:
STOS & lt; char* & gt; sl;
/ / deklarujemy j e d e n s t o s d o przechowywania l i c z b :
ST0S & lt; float & gt; s 2 ;
cout & lt; & lt; " Odkładam na 1 stos: " ;
for{int i-0; i & lt; 3;iłl)
{i
5,3. Stos
133
cout & lt; & lt; t a b l [ i ] & lt; & lt; " "
s1.push(tab1[i]) ;
}
cout & lt; & lt; " NnOdkładan na 2 stos: " ;
for (i=0; i & lt; 3;i++)
{
cout & lt; & lt; tab2[i] & lt; & lt; "
" ;
s2.push(tab2li]);
}
for(i=0; i & lt; 3;i++)
{
char *z;
float f;
s1.pop(z);
s2.pop(f);
cout & lt; & lt; " \nZdejmuje parami dane ze stosów: ( "
& lt; & lt; z & lt; & lt; " , " & lt; & lt; t & lt; & lt; " ) \n " ;
}
}
}}
Oto wyniki naszego programu:
Odkładam
Odkładam
Zdejmuje
Zdejmuję
Zdejmuje
na 1 stos: ala ma kota
na 2 stos: 3.14 2.12 100
parami dane ze stosów: (kota,100)
parami dane ze stosów: (ma,2.12)
parami dane ze stosów: (ala,3.11)
5.4. Kolejki FIFO
Kolejki typu FIFO (ang. First In First Out, co w wolnym tłumaczeniu oznacza:
kto by I pierwszy, ten i pierwszym pozostanie!), będą kolejnym omawianym typem
danych. Podobnie jak i stos, jest to struktura danych o dostępie ograniczonym.
Zakłada ona dwie podstawowe operacje:
• wstaw - wprowadź dane (klienta) na ogon kolejki;
• obsłuż - usuń dane (klienta) z czoła kolejki.
W porównaniu ze stosem kolejki są rzadziej stosowane w praktyce programo
wania. Pewne zagadnienia natury algorytmicznej dają się jednak relatywnie
łatwo rozwiązywać właśnie przy użyciu tej struktury danych i to jest głównie
powód niniejszej prezentacji.
1
Zasada obsługi ogonka ludzi przed kasą sklepową.
134
Rozdział 5. Struktury danych
Jak to zwykle bywa, możliwych implementacji kolejek jest co najmniej kilka.
Realizacja efektywna „czasowo " za pomocą list jednokierunkowych jest zbli
żona do tej wzmiankowanej przy okazji omawiania stosu. Nie będzie ona sta*
nowiła przedmiotu naszej dyskusji, ograniczymy się jedynie do prostej imple
mentacji tablicowej (zbliżonej zresztą ideowo do tablicowej realizacji stosu).
Tablicowa implementacja kolejki FIFO jest wyjaśniona na rysunku 5 - 17.
Zawartość kolejki stanowią elementy pomiędzy głową i ogonem - te dwie zmienne
będą oczywiście zmiennymi prywatnymi klasy FIFO. Dojście nowego elementu do
kolejki wiąże się z inkrementacją zmiennej ogon i dopisaniem elementu u dołu
„szarej strefy " . Oczywiście w pewnym momencie może się okazać, że ogon osią
gnął koniec tablicy - wówczas pojęcie dołu odwróci się i to dosłownie!
]
W takim przypadku cała szara strefa zawinie się wokół elementu zerowego tablicy.
Obsługa „klienta " będącego aktualnie na początku kolejki wiąże się z zapamięta
niem elementu czołowego i z inkrementacją zmiennej głowa. Trzeba się umówić
ponadto jak interpretować stwierdzenie, że kolejka jest pusta?
Rys. 5-17.
Tablicowa realiza
cja kolejki FIFO.
MaxElt
Zamiast komplikować sobie życie specjalnymi testami zawartości tablicy, moż
na po prostu założyć, że gdy gIowa=ogony to kolejka jest pusta. Tym samym
trzeba zarezerwować jeden dodatkowy element tablicy, który nigdy nie będzie
wykorzystany z uwagi na sposób działania metody wstaw. Po tych rozbudowa
nych wyjaśnieniach programowa realizacja kolejki nic powinna już stanowić
żadnej niespodzianki dla Czytelnika:
kolejka.h
template & lt; c l a s s TypPodst & gt; class FIFO
TypPodst * t ;
int głowa,ogon,MaxElt;
5.4, Kolejki FIFO
135
public:
FIFO(int n)
{
{
// konstruktor kolejki o rozmiarze n
HaxElt-n;
glowa=ogon=0 ;
r=new TypPodst[MaxElt+l]; // przydział pamięci
}
v o i d wstaw(TypPodst x)
{
// wstawia nowy element x do k o l e j k i
t[ogon++]=x;
if (ogon & gt; MaxElt)
}
ogon=0;
mt obsluz (TypPodst & w)
{
{
// o b s ł u g u j e 1-go k l i e n t a z k o l e j k i
if (qlowa==ogon) // k o l e j k a p u s t a
return -1;
/ / informacja o b ł ę d z i e o p e r a c j i
w=t[glowa++];
if(glowa & gt; MaxElt)
glowa=0;
return 1;
// sukces o p e r a c j i
}}
int pusta()
/ / czy k o l e j k a j e s t p u s t a ?
{
};
if (glowa==ogon)
return 1;
else
return 0;
}
// kolejka pusta
// coś jest w kolejce
Podobnie jak w przypadku stosu, zdefiniowaliśmy nowy typ danych w postaci
klasy szablonowej, umożliwia to łatwe definiowanie rozmaitych kolejek obsłu
gujących różnorodne typy danych. Definicja klasy FIFO nie jest kompletna:
brakuje w niej na przykład jawnego destruktora, ponadto kontrola operacji mo
głaby być nieco bardziej rozbudowana... Te " dodatki " są jednak pozostawione
Czytelnikowi jako proste ćwiczenie programistyczne.
Popatrzmy, jak wygląda korzystanie w praktyce z nowej struktury danych
kolejka.cpp
#include & lt; iostream.h & gt;
#include " kolejka.h "
s t a t i c char *tab[]=
{ " Kowalska " , " Fronczak " , " Becki " , " Pigwa " );
v o i d main ()
{
FIFO & lt; char* & gt; k o l e j k a ( 5 ) ;
// k o l e j k a 5-osobowa
136
Rozdział 5. Struktury danych
for(int i=0; i & lt; 4;i++)
kolejka.wstaw(tab[i]);
for(i=0; i & lt; 5;i++)
{
char *s;
int res=kolejka.obsluz,(s);
if (res==l)
cout & lt; & lt; " Obsłużony został k l i e n t : " & lt; & lt; s & lt; & lt; endl;
else
cout & lt; & lt; " Kolejka pusta!\n " ;
|
}
}
}}
Zasada obsługi kolejki (w krajach cywilizowanych) polega na uwzględnianiu w
pierwszej kolejności osób, które zjawiły się na samym początku. Tak też jest w na
szym przykładzie, o czym najdobitniej świadczą rezultaty wykonania programu:
Obsłużony klient:Kowalska
Obsłużony klient:Fronczak
Obsłużony klient:Becki
Obsłużony klient:Pigwa
Kolejka pusta!
5.5. Sterty i kolejki priorytetowe
W paragrafach poprzednich mieliśmy okazję zapoznać się m.in. z dwiema
strukturami danych stanowiącymi swoje skrajności „ideowe " :
• kolejką - usuwało się z niej w pierwszej kolejności „najstarszy " element;
• stosem - usuwało się z niego w pierwszej kolejności „najmłodszy "
element.
Były to struktury danych służące z zasady do zapamiętywania danych nieuporząd
kowanych, co zdecydowanie upraszczało wszelkie operacje! Kolejna zaś
struktura danych, którą będziemy się zajmować - kolejki priorytetowe - działa
wg zupełnie odmiennej filozofii, choć zachowuje ciągle zaletę operowania nie
uporządkowanym zbiorem danych. (Stwierdzenie o nieuporządkowaniu jest
prawdą w sensie globalnym - lokalnie fragmenty sterty są w pewien szczególny
sposób uporządkowane, o czym przekonamy się już za moment).Dwie podstawowe
operacje wykonywane na kolejkach priorytetowych polegają na:
• zwykłym wstawianiu nowego elementu;
• usuwaniu największego elementu1.
Jeśli w kolejce priorytetowej będą składowane rekordy o pewnej strukturze, to jednym z pól
rekordu będzie jego priorytet wyrażony w postaci liczby całkowitej dodatniej lub
5.5. Sterty i kolejki priorytetowe
137
Jednym z najłatwiejszych sposobów realizacji kolejek priorytetowych jest użycie
struktury danych zwanej stertą2. Sterta jest swego rodzaju drzewem binarnym,
które ze względu na szczególne własności warto omówić osobno. (Kwestia ter
minologiczna: zarówno sterta, jak i kolejki priorytetowe są strukturami danych.
jednakże tylko kolejka priorytetowa ma charakter czysto abstrakcyjny).
Uporządkowanie elementów wchodzących w skład sterty można zaobserwować
na rysunku 5 - 1 8 przedstawiającym 12-elemcntową stertę. Jest to również
przykład tzw. kompletnego drzewa binarnego. Stosując pewne uproszczenie de
finicyjne można także powiedzieć, iż jest to „drzewo bez dziur " ... Jeśli spojrzeć
na numery przypisane węzłom drzewa, to widać, że ich kolejność definiuje pewien
charakterystyczny porządek wypełniania go: pod istniejące węzły „dowieszamy "
maksymalnie po dwa nowe aż do ulokowania wszystkich 12-elementów. Można
to oczywiście wyrazić nieco bardziej formalnie, ale zapewniam, że zdecydowanie
mniej zrozumiale.
Rys. 5 -18.
Sterta i jej tablico¬
wa implementacja.
o
1
2
Y
3
T
5
4
s
N
N
R
R
6
E
E
8
7
L
A
9
10
D
0
11
F
12
c
Liniowy porządek wypełniania drzewa automatycznie sugeruje sposób jego
składowania w tablicy3:
• „wierzchołek " (czyli de facto korzeń, bo drzewo jest odwrócone)=1;
• „lewy " potomek i-tego węzła jest „schowany " pod indeksem 2*i;
ujemnej. W naszych przykładach dla prostoty ograniczymy się tylko do przypadku skła
dowania liczb całkowitych.
2
Ang. heap - inna spotykana polska nazwa to stóg.
3
Zerowa komórka tablicy nie jest używana do składowania danych.
138
Rozdział 5. Struktury danych
• „prawy " potomek i-tego węzła jest „schowany " pod indeksem 2*i+1.
Uwaga: dany węzeł może mieć od 0 do 2 potomków.
|
Powyżej zdefiniowaliśmy sposób składowania danych, nic jednak nie powiedzieliśmy o zależnościach istniejących pomiędzy nimi. Otóż cechą charakterystyczną sterty jest to, iż wartość każdego węzła jest większa4 od wartości węzłów
jego dwóch potomków -jeśli oczywiście istnieją. Sposób organizacji drzewa
(jak również w konsekwencji tablicy) ułatwia operacje wstawiania i usuwa
nia elementów. Możemy bowiem nowy element bez problemu „dopisać " na
koniec tablicy (co oczywiście zburzy nam lad wcześniej tam panujący), następnie za pomocą dość prostych modyfikacji tablicy przywrócić z powrotem ta
blicy (drzewu) własności sterty. Popatrzmy na przykładzie, w jaki sposób jest
konstruowana sterta ze zbioru elementów: 37, 41, 26, 14, 19, 99, 23, 17, 12, 20.
25 i 42 - dołączanych sukcesywnie do drzewa. Cały proces jest pokazany na i
rysunku 5 - 19.
Na rysunku tym widzimy gdzie wędruje nowy - zaznaczony wytłuszczoną
czcionką - element. Poprzez porównanie z etapem poprzednim łatwo zauwa
żamy modyfikacje struktury drzewa. Załóżmy, że dokładamy na koniec drzewa
4
Spotyka się również implementacje, w których jest to wartość nie większa.
5.5. Sterty i kolejki priorytetowe
139
liczbę 99 (patrz etap 5). Drzewo ma już 5-elementów, zatem nowy powędruje
na miejsce nr 6 w tablicy - „pod " 26. W tym momencie jednak zostaje zła
mana zasada konstrukcji sterty: potomek węzła jest większy co do wartości niż
sam węzeł, do którego jest on „przywieszony " ! Co możemy zrobić, aby przy
wrócić porządek? W tym miejscu wystarczy zwyczajnie wymienić 26 i 99
miejscami, aby wszystko się lokalnie „uspokoiło " . Zauważmy, że taka lokalna
zamiana przywraca porządek jedynie na aktualnie analizowanym poziomie burząc go być może na następnym! Zatem aby w całej stercie zapanował porzą
dek, należy proces zamieniania kontynuować' w górę aż do osiągnięcia
„korzenia " . (W naszym przykładzie konieczna będzie jeszcze zamiana liczb 99 i
41). Programową realizację opisanej powyżej czynności wykona procedura o na
zwie DoGory, Opisaną sytuację ilustruje rysunek 5 - 20.
Teraz, gdy już wiemy CO to jest sterta i JAK się ją tworzy, pora wyjaśnić
wreszcie dlaczego sterta umożliwia łatwe tworzenie kolejek priorytetowych.
W §5.5. wymieniliśmy istotną cechę wyróżniającą kolejki priorytetowe od
innych podobnych struktur danych: pierwszym obsługiwanym „klientem " jest
Rys. 5 - 20.
Poprawne wsta
wianie nowego
elementu do sterty.
ten, który ma największą wartość (lub też w przypadku rekordów największą
wartość pewnego wybranego pola). Jeśli trzymać się ciągle analogii kolejki do
kasy sklepowej, to można by powiedzieć, że wszyscy ustawiają się elegancko na
koniec „ogonka " , ale to kasjerka patrzy klientom w oczy i wybiera do obsługi
tych najbardziej uprzywilejowanych (ewentualnie najprzystojniejszych...).
W przypadku list i zwykłych tablic problemem byłoby znalezienie właśnie tego
największego elementu - należałoby w tym celu dokonać przeszukiwania, które
zajmuje czas proporcjonalny do N (wielkości tablicy lub listy). A jak to wygląda
w naszym przypadku? Spójrzmy raz jeszcze na tablicę z rysunku 5 - 1 8 dla
upewnienia się: TAK, my w ogóle nie musimy szukać największego elementu,
bowiem z założenia znajduje się on w komórce tablicy o indeksie 1!
Po euforii powinna jednak przyjść chwila zastanowienia: a co z wstawianiem?
Elementy są co prawda zawsze dokładane na koniec, ale potem zawsze trzeba
wywołać procedurę DoGory, która przywróci stercie zachwiany (ewentualnie)
5
Jeśli zachodzi oczywiście potrzeba.
140
Rozdział 5. Struktury danych
porządek. Czy czasem owa procedura nie jest na tyle kosztowna, że ewentualny
zysk z użycia sterty nie jest już tak oczywisty? Na szczęście okazuje się, że nie.
Wszelkie algorytmy operujące na stercie wykonują się wprost proporcjonalnie do
długości drogi odwiedzanej podczas przechodzenia przez drzewo binarne repie
zentujące stertę. Co można powiedzieć o tej długości wiedząc, że drzewo binarne
jest kompletne? Na przykład to, iż dowolny wierzchołek jest odległy od wierz
chołka (korzenia) o co najwyżej log2N węzłów! Z tego właśnie powodu algo
rytmy „stertowe " wykonują się na ogół w czasie „logarytmicznym " . Jest to dobry
wynik decydujący często o użyciu tej, a nie innej struktury danych.
Po tak długim wstępie warto wreszcie zaprezentować kilka linii kodu w
C++, które lepiej przemówią niż przewlekłe wyjaśnienia. Definicja klasy
Sterta' jest następująca:
sterta.h
class sterta
{
private:
i n t *t;
int L;
public:
Sterta(int
// ilość elementów
nMax)
// prosty konstruktor
i
t-now int;nMax+l] ;
L=0;
}
void wstaw(int x ) ;
int
obsluz();
v o i d DoGory();
void NaDol();
void p i s z ( ) ;
}; // koniec definicji klasy Sterta
Konstruktor klasy tworzy tablicę, w której będą zapamiętywane elementy -1[0]
jest nieużywane, stąd deklaracja tablicy o rozmiarze nMax+L a nie nMax (jest
to szczegół implementacyjny ukryty przed użytkownikiem).
Na początek zajmijmy się wstawieniem nowego elementu do sterty;
void Sterta::wstaw{int x)
{
t[++L]=x;
DoGory();
}
Procedura DoGory była już wcześniej wzmiankowana: zajmuje się ona przywró
ceniem porządku w stercie po dołączeniu na koniec tablicy t nowego elementu.
6
Aby nie rozwlekać kodu nadmiernym generalizowaniem, podany zostanie przykład dla
sterty liczb całkowitych.
5.5. Sterty i kolejki priorytetowe
141
Treść procedury DoGory nie powinna stanowić niespodzianki. Jedyną różnicą
pomiędzy wskazaną na rysunku 5 - 2 0 zamianą elementów jest... jej brak!
W praktyce szybsze okazuje się przesunięcie elementów w drzewie, tak aby
zrobić miejsce na „unoszony " do góry ostatni element tablicy:
void Sterta::DoGory()
{
int temp=t[L];
i n t n=L;
while ( (n!-l) & fi (t[r/2] & lt; -temp) )
{
t[n]=t[n/2];
n=n/2;
}
L[n]=temp;
}
Jest to być może zbędna „sztuczka " w porównaniu z oryginalnym algoryt
mem polegającym na systematycznym zamienianiu elementów ze sobą (w
miarę potrzeby) podczas przechodzenia przez węzły drzewa, jednak pozwala ona
nieco przyspieszyć procedurę .
Nawiązując do kolejek priorytetowych wspomnieliśmy, że są one łatwo implementowalne za pomocą sterty. Wstawianie „klienta " do kolejki priorytetowej
(czyli sterty) na sam jej koniec zostało zrealizowane powyżej. Jak pamiętamy
pierwszym obsługiwanym „klientem " w kolejce priorytetowej był ten, który miał
największa wartość - t[1]. Ponieważ po usunięciu tego elementu w tablicy robi
się „dziura " , ostatni element tablicy wstawiamy na miejsce korzenia, dekre
mentujemy L i wywołujemy procedurę NaDol, która skoryguje w odpowiedni
sposób stertę, której porządek mógł zostać zaburzony:
int sterta;:obsluz()
{
int x=t[1];
t[l]=t[L--];
NaDol();
return x;
// brak kontroli błędów!!!
}
(Czytelnik powinien samodzielnie rozbudować powyższą metodę, wzbogacając
ją o elementarną kontrolę błędów).
Jak powinna działać procedura NaDol? Zmiana wartości w korzeniu mogła
zaburzyć spokój zarówno w lewym, jak i prawym jego potomku. Nowy korzeń
należy przy pomocy zamiany z większym z jego potomków przesiać w dół drzewa
Która oczywiście pozostanie w dalszym ciągu „logarytmiczna " - cudów bowiem
w informatyce nie ma!
Rozdział 5. Struktury danych
142
aż do momentu znalezienia właściwego dlań miejsca. Popatrzmy na efekt za
działania procedury NaDol wykonanej na pewnej stercie (patrz rysunek 5 - 21).
Rys. 5-21.
Ilustracja proce
dury NaDol-
Element 12 został zaznaczony wytłuszczoną czcionką. Za pomocą pogrubionej
kreski zaprezentowano drogę, po której zstępował element 12 w stronę swojego...
miejsca ostatecznego spoczynku!
Oto jak można sposób zrealizować procedurę NaDol,
void S t e r t a ; : N a D o l ( )
{
i n t i=1;
while(1)
{
int p=2*i;
// lewy potomek węzła " i " : p, prawy: p+1
if(p & gt; L)
break;
if(p+l & lt; =L)
//prawy potomek niekoniecznie musi istnieć!
if(t[p] & lt; t[p+i])
p++;
// przesuwamy się do następnego
if(t[i] & gt; =t[p])
break;
// zamiana
int temp t[p]
t[p]=t[i]
t[i]=temp
i=p;
}
Sposób korzystania ze sterty jest zbliżony do poprzednio opisanych struktur danych
i nie powinien sprawić Czytelnikowi żadnych problemów. Nieco bardziej interesu
jące jest ukazanie efektownego zastosowania sterty do... sortowania danych.
5.5. Sterty i kolejki priorytetowe
143
Wystarczy bowiem dowolną tablicę do posortowania wpierw zapamiętać w stercie
używając metody wstaw, a następnie zapisać ją „od tyłu " w miarę obsługiwania
przy pomocy metody obsłuz:
tfinclude " sterta.h "
int
tab[14]={l2,3,-12,9,34,23,l,81,45,17,9,23,ll,4};
void main()
{
Sterta s(14);
for (int i=0;i & lt; 14;i++)
s.wstaw{tab[i]) ;
for (i=14;i & gt; 0;i--)
tab [ i - l ] = s . o b s l u z ( ) ;
cout & lt; & lt; " tablica posortowana;\n " ;
for (i=0;i & lt; 14;i++)
cout & lt; & lt; " " & lt; & lt; t a b [ i ] ;
}
Jest to oczywiście jedno z możliwych zastosowań sterty - prosta i efektowna
metoda sortowania danych, średnio zaledwie dwa razy wolniejsza od algorytmu
Quicksort (patrz opis algorytmu Quicksort).
Powyższa procedura może być jeszcze bardziej przyspieszona poprzez włączenie ko
du metod wstaw i obsłuż wprost do funkcji sortującej, tak aby uniknąć zbędnych i
kosztownych wywołań proceduralnych. W tym przypadku zachodzi jednak potrzeba
zaglądania do prywatnych informacji klasy - tablicy t (patrz plik sterta.h), zatem
procedura sortująca musiałaby być funkcją zaprzyjaźnioną. Łamiemy jednak w tym
momencie koncepcję programowania obiektowego (separacja prywatnego
„wnętrza*' klasy od jej „zewnętrznego " interfejsu)!
Jest to cena do zapłacenia za efektywność - funkcje zaprzyjaźnione zostały
wprowadzone do C++ zapewne również z uwagi na użycie tego języka do pro
gramowania aplikacji wyjściowych, a nie tylko do prezentacji algorytmów (jak
to jest w przypadku Pascala, który zawiera celowe mechanizmy zabezpieczające
przed używaniem dziwnych sztuczek... bez których programy działałyby zbyt
wolno na rzeczywistych komputerach).
5.6. Drzewa i ich reprezentacje
Dyskusją na temat tzw. drzew można by z łatwością wypełnić kilka rozdziałów.
Temat jest bardzo rozległy i różnorodność aspektów związanych z drzewami
znacznie utrudnia decyzję na temat tego, co wybrać, a co pominąć. W ostatecznym
rozrachunku zwyciężyły względy praktyczne: zostaną szczegółowo omówione
te zagadnienia, które Czytelnik będzie mógł z dużym prawdopodobieństwem
wykorzystać w codziennej praktyce programowania. Bardziej szczegółowe
144
Rozdział 5. Struktury danych
studia dotyczące drzew można znaleźć w zasadzie w większości książek po
święconych ogólnie strukturom danych. Ponieważ jednak te ostatnie nie są celem
samym w sobie (o czym bardzo często autorzy książek o algorylmice zapominają ..), to wierzę, że bardziej praktyczne podejście do tematu zostanie przez
większość Czytelników zaakceptowane.
Nasze rozważania zaczniemy od najpopularniejszych i najczęściej używanych
drzew binarnych, których użyteczność do rozwiązywania przeróżnych zagadnień
algorytmicznych jest niezaprzeczalna.
Co to są zatem drzewa binarne? Są to struktury bardzo podobne do list jedno
kierunkowych, ale wzbogacone o jeszcze jeden wymiar (lub kierunek jak kto
woli...).
Podstawowa komórka służąca do konstrukcji drzewa binarnego ma postać:
s t r u c t wezel
{
i n t i n f o ; // lub dowolny inny typ danych
s t r u c t wezel *lewy,*prawy;
}
Jak łatwo jest zauważyć, w miejsce jednego wskaźnika następny (jak w liście
jednokierunkowej) mamy do czynienia z dwoma wskaźnikami o nazwach lewy
i prawy, będącymi wskaźnikami do lewej i prawej gałęzi drzewa binarnego. Aby
dobrze zrozumieć sposób działania i użyteczność drzew binarnych, popatrzmy
na rysunek 5 -22.
s t r u c t węzeł
Rys. 5 - 22.
Drzewa binarne
i wyrażenia
arytmetyczne.
{
char
operator;
float val;
węzeł * l e w y , * p r a w y ;
}
Konwencja:
o p e r a t o r = ' 0 ' = & gt;
znaczenie ma pole val
o p e r a t o r = ' + ' , ' - ' , ' * ' etc. = & gt;
pole val j e s t bez znaczena;
{(2+3)+(7*9)}*l2.5
Pokazuje on jeden z możliwych przykładów zastosowania drzew binarnych,
a mianowicie reprezentowanie wyrażeń arytmetycznych. Do tego przykładu
jeszcze powrócimy w dalszych paragrafach, na razie wystarczy ogólny opis
5.6. Drzewa i ich reprezentacje
145
sposobu korzystania z takiej reprezentacji. Otóż, dowolne wyrażenie arytme
tyczne może być zapisane w kilku odmiennych postaciach związanych z poło
żeniem operatorów: przed swoimi argumentami, po nich oraz klasycznie pomię
dzy nimi (jeśli oczywiście mamy do czynienia tylko z wyrażeniami dwuargumentowymi, co pozwolimy sobie tutaj dla uproszczenia przykładów założyć).
Struktura danych z tego rysunku jest zwykłym drzewem binarnym, posiadającym
dwa pola przeznaczone do przechowywania danych (operator i vol) oraz trady
cyjne wskaźniki do lewego i prawego odgałęzienia naszego odwróconego „do góry
nogami " drzewa. Umówimy się ponadto, że w przypadku, gdy pole operator
zostanie zainicjowane jakąś bezsensowną wartością (tutaj '0'; nic jest to żaden
znany operator), to wówczas pole vol ma jakąś wartość, którą możemy uznać za
sensowną. Taka dualna reprezentacja może posłużyć do łatwego rozróżnienia
przy użyciu tylko jednego typu rekordów, dwóch typów węzłów; wartości (listek
drzewa) i operatora arytmetycznego, wiążącego w ogólnym przypadku trzy typy
węzłów:
Tabela 5 - 2 .
Typy węzłów w drzewie
lewy potomek
|
prawy potomek
wyrażenie
wyrażenie
wyrażenie
wartość
wartość
opisującym wyrażenie
arytmetyczne.
wyrażenie
Jeśli napiszemy odpowiednie funkcje obsługujące powyższą strukturę danych
wedle przyjętych przez nas reguł postępowania, to możemy przy pomocy takiej
prostej reprezentacji wyrazić dowolnie skomplikowane wyrażenia arytmetyczne,
wykonywać na nich operacje, różniczkować je etc. Wszystko zależy wyłącz
nie od tego, co zamierzamy uzyskać - możliwych zastosowań jest dość sporo,
a ponadto, jak się okaże już wkrótce, jeśli do roboty zaprzęgniemy rekurencję,
to algorytmy obsługi drzew binarnych (i nie tylko), stają się bardzo proste i
zrozumiałe na pierwszy rzut oka.
Czy reprezentacja przy pomocy rekurencyjnych struktur danych jest optymalna?
Na to pytanie można odpowiedzieć sensownie jedynie mając przed oczami
ostateczne zastosowanie implementowanego drzewa: jeśli nie dbamy zbytnio
o ząjętość pamięci, a zależy nam na łatwości implementacji, to reprezentacja
tablicowa może okazać się nawet lepsza od tej klasycznej, zaprezentowanej
powyżej.
Jak zapamiętać drzewo w tablicy? Nie jest to bynajmniej dla nas problem
nowy, w §5.5 została podana dość prosta metoda na zapamiętanie w tablicy innej
„drzewiastej " struktury danych - sterty. Podobnie w §5.2.2 poznaliśmy tzw. me
todę tablic równoległych do reprezentacji list z wieloma kryteriami sortowania.
Rozdział 5. Struktury danych
146
Jak widać, inteligentne użycie tablic może nam podsunąć możliwości z trudem
uzyskiwane w przypadku optymalnych, listowych struktur danych.
Popatrzmy dla przykładu na implementację tablicową drzew, w których nie są
zapamiętywane informacje dotyczące potomków danego węzła (tzn. nie interesuje
nas zstępowanie w stronę liści), ale informacje o rodzicach danego potomka.
Terminologia używająca określeń: ojciec, syn, potomek lewy, potomek prawy
etc. jest ogólnie spotykana w książkach poświęconych strukturom drzewiastym
- również anglojęzycznych. W tym miejscu warto być może przytoczyć anegdotę
dotyczącą właśnie tego typu określeń, które mogą osoby nieprzyzwyczajenie
prowadzić do konfuzji. W 1993 roku uczestniczyłem w kursie języka angielskiego
przeznaczonym dla Francuzów i prowadzonym przez przybyłą do Francji Amerykankę o dość ekstrawaganckim sposobie bycia. W trakcie kursu należało przygoto
wać małe expose na dowolny w zasadzie, ale techniczny temat. Jeden z francu
skich studentów omówił pewien algorytm dotyczący rozproszonych baz danych.
w którym dość sporo miejsca zajmowało wyjaśnienie drzewiastej struktury
danych, służącej do reprezentacji pewnych istotnych dla algorytmu danych.
Terminologia, której używał do opisu drzewa, była identyczna z zaprezentowana
powyżej: ojciec, syn, potomek itp. Anglosasi są ogólnie dość uczuleni na punkcie
jawnego rozróżniania form osobowych (on, ona) od bezosobowych, obejmujących
w zasadzie wszystko oprócz osób (określane w sposób ogólny zaimkiem it).
Student, o którym jest mowa, omawiał coś o charakterze bez wątpienia bezoso
bowym - strukturę danych, ale od czasu do czasu używał określeń „zarezerwo
wanych " normalnie dla istot ludzkich - ojciec, syn... Amerykanka słuchała jego
przemowy przez dobrych kilka minut, otwierając coraz szerzej oczy, aż w końcu
nie wytrzymała, wyskoczyła na środek klasy i przerwała Francuzowi: " What father?
What child? Please show me where is the zizi1 here! " - pokazując jednocześnie na
narysowane na tablicy drzewo...
Ale wróćmy do lematu i pokażmy wreszcie obiecaną implementację drzew przy
pomocy tablic, tak aby uzyskać informację o węzłach „ojcach " . Rysunek 5 - 23
przedstawia drzewo służące do zapamiętywania liter (czyli pole val jest typu char).
Rys. 5 - 23.
Tablicowa repre
zentacja drzewa.
0
1
2
3
4
5
6
7
8
9
syn ojciec
C
0
A
0
0
B
Z
0
A
1
C
1
D
1
M
2
K
3
L
3
W ten sposób francuskie dzieci określają nieodłączny atrybut każdego mężczyzny.
5,6. Drzewa i ich reprezentacje
147
Numery znajdujące się przy węzłach mają charakter wyłącznie ilustracyjny - ich
wybór jest raczej dowolny i nie podlega żadnym szczególnym regułom... chyba że
sobie sami je wymyślimy na użytek konkretnej aplikacji. W ramach kolejnej
konwencji umówmy się, że jeśli ojciec[x] jest równy x t to mamy do czynienia
z pierwszym elementem drzewa.
Teraz, gdy już wiemy, jak reprezentować drzewa wykorzystując dostępne w C++
(oraz w każdym nowoczesnym języku programowania) mechanizmy, spróbuj
my popatrzeć na możliwe sposoby przechadzania się po gałęziach drzew...
5.6.1.Drzewa binarne i wyrażenia arytmetyczne
Nasze rozważania o drzewach będziemy prowadzić poprzez prezentację dość
rozbudowanego przykładu, na podstawie którego zobrazowane zostaną fenomeny,
z którymi programista może się zetknąć, oraz mechanizmy, z których będzie on
musiał sprawnie korzystać w celu efektywnego wykorzystania nowo poznanej
struktury danych.
Problematyka będzie dotyczyła kwestii zaanonsowanej już na rysunku 5 - 22.
Zobaczyliśmy tam, że drzewo doskonale się nadaje do reprezentacji informa
tycznej wyrażeń arytmetycznych, bardzo naturalnie zapamiętując nie tylko in
formacje zawarte w wyrażeniu (tzn. operandy i operatory), ale i ich logiczną
strukturę, która daje się poglądowo przedstawić właśnie w postaci drzewa.
Przypomnijmy jeszcze raz typ komórki, który może służyć - zgodnie z ideą
przedstawioną na rysunku 5 - 22 - do zapamiętywania zarówno operatorów
(ograniczymy się tu do; +, -, * i do dzielenia wyrażonego przy pomocy : lub /),
jak i operandów (liczb rzeczywistych).
wyrażen.cpp
struct wyrażenie
{
double
val;
char
op;
wyrażenie *lewy,*prawy;
};
};
Inicjacja takiej komórki determinuje późniejszą interpretację jej zawartości. Je
śli w polu l o p ' zapamiętamy wartość '0' to będziemy uważali, że komórka nie
jest operatorem i wartość zapamiętana w polu val ma sens. W odwrotnym zaś
przypadku będziemy zajmowali się wyłącznie polem 'op' bez zwracania uwagi
na to, co znajduje się w val Popatrzmy na rysunek 5 - 24, który ukazuje kilka
pierwszych etapów tworzenia drzewa binarnego wyrażenia arytmetycznego.
Do tworzenia drzewa użyjemy dobrze nam znanego z poprzednich dyskusji
stosu (patrz §5.3). Tym razem będzie on służył do zapamiętywania wskaźni
ków do rekordów typu struct wyrażenie, co implikuje jego deklarację przez
STOS & lt; wyrażenie * & gt; s (Jak widać warto było raz się pomęczyć i stworzyć stos
w postaci klasy szablonowej).
148
Rozdział 5. Struktury danych
Rys. 5 - 24.
Tworzenie drzewa
binarnego wyra
żenia arytmetycz
nego.
„nadchodzące " elementy:
I
2
+
7
etc.
Etapy tworzenia drzewa binarnego (zawartość stosu):
Typowe wyrażenie arytmetyczne, zapisane w powszechnie używanej postaci
(zwanej po polsku wrostkową), da się również przedstawić w tzw. Odwrotnej
Notacji Polskiej (ONP. postfiksowej). Zamiast pisać a op b używamy formy:
a b op . Mówiąc krótko: operator występuje po swoich argumentach. Operacja
arytmetyczna jest łatwa do odtworzenia w postaci klasycznej, jeśli wiemy, ile
operandów wymaga dany operator.
Analiza wyrażenia beznawiasowego odbywa się w następujący sposób:
• Czytamy argumenty znak po znaku, odkładając je na stos.
• W momencie pojawienia się jakiegoś operatora ze stosu zdejmowana
jest odpowiednia dlań liczba argumentów - wynik operacji kładziony
jest na stos jako kolejny argument.
Na rysunku 5-24 możemy zaobserwować opisany wyżej proces w bardziej poglą
dowej formie niż powyższy suchy opis. Pierwsze dwa argumenty. J i 2, jako nie
będące operatorami, są odkładane na stos (w programie odpowiadać to będzie stwo
rzeniu dwóch komórek pamięci, których pola wskaźnikowe lewy i prawy są zaini
cjowane wartościami NULL). Trzecim elementem, który przybywa z „zewnątrz " ,
jest operator i. Tworzona jest nowa komórka pamięci, jednocześnie sam fakt nadej
ścia operatora prowokuje zdjęcie ze stosu dwóch argumentów, którymi są komórki
zawierające liczby 1 i 2. Te komórki są „doczepiane " do pól wskaźnikowych ko
mórki zawierającej operator +. Kolejnym nadchodzącym elementem jest znowu
liczba (7) -jest ona odkładana na stos i proces może być kontynuowany dalej...
W opisany wyżej sposób pracują kompilatory w momencie obliczania wyrażeń
za pośrednictwem stosu. Jedyną różnicą jest to, że nie są odkładane na stos
kolejne poddrzewa. ale już obliczone fragmenty dowolnie w zasadzie skom
plikowanych wyrażeń arytmetycznych. Czytelnik zgodzi się chyba ze
5.6. Drzewa i ich reprezentacje
149
stwierdzeniem, że z punktu widzenia komputera ONP jest istotnie bardzo wy
godna w użyciu " .
Przypatrzmy się już konkretnym instrukcjom w C++, które zajmują się inicjacją
drzewa binarnego.
W powyższym listingu tablica t zawiera poprawną sekwencję danych, tzn. taką.
która istotnie stworzy drzewo binarne mające sens. Warto odrobinę poeksperymentować z zawartością tablicy, aby zobaczyć, jak algorytm ..zareaguje "
na błędny ciąg danych. Można się spodziewać, że w przypadku np. braku
drugiego operanda lub operatora rezultaty otrzymane będą również błędne jest to prawda, ale najlepiej jest przekonać się o tym „na własnej skórze " .
2
Notabene wbrew pozorom ONP jest dość wszechstronnie stosowana: patrz kalkulatory
firmy Hewlett Packard, język Forth, język opisu stron drukarek laserowych
Postscript... W pewnych „kręgach " jest to zatem dość znana notacja.
150
Rozdział 5. Struktury danych
Jak jednak obejrzeć zawartość drzewa, które tak pieczołowicie stworzyliśmy
Wbrew pozorom zadanie jest raczej trywialne i sprowadza się do wykorzysta
nia własności „topograficznych " drzewa binarnego. Sposób interpretacji formy
wyrażenia (czy jest ona infiksowa prefiksowa czy też postfiksowa) zależy bo
wiem tylko i wyłącznie od sposobu przechodzenia przez gałęzie drzewa!
Popatrzmy na realizację funkcji służącej do wypisywania drzewa w postaci
klasycznej, tzn. wrostkowej. Jej działanie można wyrazić w postaci prostego algo
rytmu rekurencyjnego;
)
wypisz(vvj
{
jeśli wyrażenie w jest liczbą to wypisz ją;
jeśli wyrażenie w jest operatorem op to wypisz w kolejności:
(wypisz(w- & gt; left) op wypisz(w- & gt; right))
}
Realizacja programowa jest oczywiście dosłownym tłumaczeniem powyższego
zapisu:
void pisz_infix(struct wyrażenie *w]
(
// funkcja wypisuje w postaci wrostkowej
if{w- & gt; op=='0') // wartość liczbowa...
cout & lt; & lt; w- & gt; val;
else
cout
pisz
cout
pisz
cout
& lt; & lt; " { " }
infix(w- & gt; lewy);
& lt; & lt; w- & gt; op;
infix(w- & gt; prawy);
& lt; & lt; " ) " ;
}
}
W analogiczny sposób możemy zrealizować algorytm wypisujący wyrażenie
w formie beznawiasowej, czyli ONP:
v o i d p i s z p r e f i x ( s t r u c t wyrażenie *w)
{
// funkcja wypisuje w postaci prefiksowej
if (w- & gt; op=='0') // wartość liczbowa...
cout & lt; & lt; w- & gt; val & lt; & lt; " " ;
else
{
cout & lt; & lt; w- & gt; op & lt; & lt; " " ;
pisz_prefix(w- & gt; lewy);
pisz_prefix(w- & gt; prawy);
}
}
{
5.6. Drzewa i ich reprezentacje
151
Jak łatwo zauważyć, w zależności od sposobu przechadzania się po drzewie
możemy w różny sposób przedstawić jego zawartość bez wykonywania jakiej
kolwiek zmiany w strukturze samego drzewa!
Reprezentacja wyrażeń arytmetycznych byłaby z pewnością niekompletna.
gdybyśmy jej nie uzupełnili funkcjami do obliczania ich wartości. Zanim jed
nak cokolwiek zechcemy obliczać, musimy dysponować funkcją, która spraw
dzi, czy wyrażenie znajdujące się w drzewie jest prawidłowo skonstruowane.
tzn. czy przykładowo nie zawiera nieznanego nam operatora arytmetycznego.
Zauważmy, że o poprawności drzewa decyduje j u ż sam sposób jego kon
struowania z użyciem stosu. Pomimo lego ułatwienia dysponowanie dodatkową
funkcją sprawdzającą poprawność drzewa jest jednak mało kosztowne - dosłow
nie kilka linijek kodu - a użyteczność takiej dodatkowej funkcji jest oczywista.
// to
są
znane o p e r a t o r y
return (poprawne(w- & gt; lewy)*poprawne(w- & gt; prawy));
default :
return ( 0 ) ; / / b ł ą d ! ! ! - & gt; o p e r a t o r n i e z n a n y
}
}
Nie będę nikogo zachęcał do zrealizowania powyższych funkcji w formie iteracyjnej -jest to oczywiście wykonalne, ale rezultat nie należy do specjalnie czy
telnych i eleganckich.
Przejdźmy wreszcie do prezentacji funkcji, która zajmie się obliczeniem wartości
wyrażenia arytmetycznego. Jego schemat jest bardzo zbliżony do tego zasto
sowanego w funkcji poprawne:
double oblicz(struct wyrażenie *w)
{
if(poprawne(w)) // wyrażenie poprawne?
if(w- & gt; op=='0')
return (w & gt; val); // pojedyncza wartość
else
switch (w- & gt; op)
{
case '+':
152
Rozdział 5. Struktury danych
5.7. Uniwersalna struktura słownikowa
Nasze rozważania poświęcone strukturom drzewiastym zakończymy prezentu
jąc szczegółową implementację tzw. Uniwersalnej Struktury Słownikowej
(określanej dalej jako USS). Jest to dość złożony przykład wykorzystania możli
wości, jakie oferują drzewa, i nawet jeśli Czytelnik nie będzie miał w praktyce
okazji skorzystać Z USS, to zawarte w tym paragrafie informacje i techniki będą
mogły zostać wykorzystane przy rozwiązywaniu innych problemów, w których
w grę wchodzą zbliżone kwestie.
Z uwagi na czytelność wyjaśnień wszelkie przykłady dotyczące USS będą
tymczasowo obywały się bez poruszania zagadnienia polskich znaków dia
krytycznych: ą ę, ć etc. Temat ten poruszę dopiero pod koniec tego paragra
fu. gdzie zaproponuję prosty sposób rozwiązania tego problemu - w istocie
będą to niewielkie, wręcz kosmetyczne modyfikacje zaprezentowanych już za
moment algorytmów.
5.7. Uniwersalna struktura słownikowa
153
Najwyższa już pora wyjaśnić właściwy temat naszych rozważań. Otóż wiele
programów z różnych dziedzin, ale operujących tekstem wprowadzanym przez
użytkownika, może posiadać funkcje sprawdzania poprawności ortograficznej
wprowadzanych pieczołowicie informacji (patrz np. arkusze kalkulacyjne, edytory
tekstu). Całkiem prawdopodobne jest, iż wielu Czytelników chciałoby móc
zrealizować w swoich programach taki „mały weryfikator " , jednak z uwagi
na znaczne skomplikowanie problemu nawet się do niego nie przymierzają.
W istocie z problemem weryfikacji ortograficznej są ściśle związane następujące
pytania, na które odpowiedź wcale nie jest jednoznaczna i prosta:
• jakich struktur danych używać do reprezentacji słownika?
• jak zapamiętać słownik na dysku?
• jak wczytać słownik „bazowy " do pamięci?
• jak uaktualniać zawartość słownika?
Konia z rzędem temu, kto bez wahania ma gotowe odpowiedzi na te pytania!
Oczywiście na wszystkie naraz, bowiem nierozwiązanie na przykład problemu
zapisu na dysk czyni resztę całkowicie bezużyteczną.
Ze wszelkiego rodzaju słownikami wiąże się również problem ich niebagatelnej
objętości. O ile jeszcze możemy się łatwo pogodzić z zajętością miejsca na
dysku, to w przypadku pamięci komputera decyzja już nie jest taka prosta średniej wielkości słownik ortograficzny może z łatwością „zatkać " całą do
stępną pamięć i nie pozostawić miejsca na właściwy program. No, chyba, że ma
on wypisywać komunikat: „Out of memory " '... Sprawy komplikują się niepo
miernie, jeśli w grę wchodzi tak bogaty język, jakim jest np. nasz ojczysty
- z jego mnogimi formami deklinacyjnymi, wyjątkami od wyjątków etc. Zapa
miętanie tego wszystkiego bez odpowiedniej kompresji danych może okazać się
po prostu niewykonalne.
Istnieją liczne metody kompresji danych, większość z nich ma jednak charakter
archiwizacyjny - służący do przechowywania, a nie do dynamicznego ope
rowania danymi. Marzeniem byłoby posiadanie struktur}' danych, która przez
swoją naturę automatycznie zapewnia kompresję danych już w pamięci kom
putera, nie ograniczając dostępu do zapamiętanych informacji.
Prawdopodobnie wszyscy Czytelnicy domyślili się natychmiast, że USS należy
do tego typu struktur danych.
Idea USS opiera się na następującej obserwacji; wiele słów posiada te same
rdzenie (przedrostki), różniąc się jedynie końcówkami (przyrostkami). Przykładowo
1
Ang. Brak pamięci.
154
Rozdział 5. Struktury danych
weźmy pod uwagę następującą grupę słów: KROKUS, KROSNO, KRAWIEC,
KROKODYL, KRAJ. Gdyby można było zapamiętać je w pamięci w formie
drzewa przedstawionego na rysunku 5 - 25, to problem kompresji mielibyśmy
z głowy. Z 31 znaków do zapamiętania zrobiło nam się raptem 27, co może nie
oszałamia, ale pozwala przypuszczać, że w przypadku rozbudowanych słowników
zysk byłby jeszcze większy. Zakładamy oczywiście, że w słowniku będą zapa
miętywane w dużej części serie słów zaczynających się od tych samych literczyli przykładowo pełne odmiany rzeczowników etc.
K
Rys. 5-25.
Kompresja danych zaletą
Uniwersalnej Struktury
Słownikowej.
Pora już na przedstawienie owej tajemniczej USS w szczegółach. Jej realizacja
jest nieco przewrotna, bowiem zbędne staje się zapamiętywanie słów i ich frag
mentów, a pomimo tego cel i tak zostaje osiągnięty!
Program zaprezentuję w szczegółowo skomentowanych fragmentach. Oto pierw
szy z nich zawierający programową realizacją USS:
|
uss.cpp
const int n=29; // liczba rozpoznawanych liter
typedef struct słownik
{
struct słownik
}USS,*USS_PTR;
*t[n];
Mamy oto typową dla C++ deklarację typu rekurencyjnego, którego jedynym
elementem jest tablica wskaźników do tegoż właśnie typu. (Tak, zdaję sobie
sprawę, iż brzmi to okropnie). Literze 'a' (lub 'A') odpowiada komórka t[0],
analogicznie literom 'z' (lub 'Z') komórka t[25]. Dodatkowe komórki pamięci
będą służyły do znaków specjalnych, które nie należą do podstawowych liter
alfabetu, ale dość często wchodzą w skład słów (np. myślnik, polskie znaki
diakrytyczne...).
5.7. Uniwersalna struktura słownikowa
155
Dla oszczędności miejsca słowa będą zapamiętywane już w postaci przetransformowanej na duże litery. Słowo odpowiada jest tu bardzo charakterystyczne.
bowiem słowa nie są w USS zapamiętywane bezpośrednio.
Zapętlenie wskaźnika t[n-1] do swojej własnej tablicy oznacza
znacznik końca słowa.
Dokładną zasadę działania USS wyjaśnimy na przykładzie zamieszczonym na
rysunku 5 - 26.
Rys. S - 26.
Reprezentacja
słów w USS.
A
K
B
rekord bazowy
N
A
B
K N
A
B
K
N
(•)
A
B
K
N
A
B
K
N
(*• & gt;
A
B K N
A
B
A
B
K
N
(***)
K N
A
B
K N
A
B
K
N
Założeniem przyjętym podczas analizy niech będzie ograniczenie liczby liter
alfabetu do 4: A, B, K, N. USS zawiera tablicę t o rozmiarze 5: ostatnia komórka
służy jako znacznik końca słowa. Jeśli wskaźnik w t[4] wskazuje na t, to oznacza że
w tym miejscu pewne słowo zawiera swój znacznik końca. Które dokładnie?
Spójrzmy jeszcze raz na rysunek 5 - 26. Komórka nazwana pierwotną umożliwia
dostęp do wszystkich słów naszego 4-literowego alfabetu. Wskaźnik znajdujący
się w l[l] (czyli t['B']) zawiera adres komórki oznaczonej jako (*). Znajdujący
się w niej wskaźnik t[0] (czyli t ['A']) wskazuje na (**). Tu uwaga! W komórce
(**) t[4J jest „zapętlony " , czyli znajduje się tu znacznik końca słowa, na które
go litery składały się odwiedzane ostatnio indeksy: wpierw 'B' potem 'A', na
koniec znacznik końca słowa - co daje razem słowo BA " .
2
Przyjmijmy dla potrzeb tej książki, ze ono coś istotnie oznacza....
Rozdział 5. Struktury danych
Proces przechadzania się po drzewie nie jest bynajmniej zakończony:
komórki (**) odchodzi strzałka do (***), w której także następuje „zapetlenie'
Jakie słowo teraz przeczytaliśmy? Oczywiście BAK! Rozumując podobnie mo
żerny „przeczytać " jeszcze słowa BANK i ABBA.
Idea USS, dość trudna do wyrażenia bez poparcia rysunkiem, jest zaskakuje
co prosta w realizacji końcowej, w postaci programu wynikowego. Oczywiście
nie tworzą one jeszcze kompletnego modułu obsługi słownika, ale ta reszto.
której brakuje (obsługa dysku, „ładne " procedurki wyświetlania etc), to juz
tylko zwykła „wykończeniówka " ,
Omówmy po kolei procedury tworzące zasadniczy szkielet modułu obsługi USS.
Funkcje do indeksu i z_jndeksu pełnią role translacyjne. Z indeksów liczbowych
tablicy / (elementu składowego rekordu USS) możemy odtworzyć odpowiadające
poszczególnym pozycjom litery i vice versa. To właśnie zwiększając wartość
stałej n oraz nieco modyfikując te dwie funkcje możemy do modułu obsługującego US
int do_indeksu(char c)
{
// znak ASCII - & gt; indeks
if ( c & lt; ='2' & & c & gt; -'A' || c & lt; ='z' & & c & gt; ='a' }
return toupper(c)-'A';
else
{
if (c==' ')
if (c=='-')
return 26;
return 27;
}
}
}
}
char z_indeksu(int n)
{
// indeks - & gt; znak ASCII
if (n & gt; =0 & & n & lt; = ( ' Z ' - ' A ' ) )
return t o u p p e r ( ( c h a r ) n + ' A ' ) ;
else
{
{
}
if
if
}
(n==26) r e t u r n ' ' ;
( = = 2 7 ) return ' - ' ;
Funkcja zapisz otrzymuje wskaźnik do pierwszej komórki słownika. Zanim
zostanie stworzona nowa komórka pamięci funkcja ta sprawdzi, czy aby jest to
na pewno niezbędne. Przykładowo niech w drzewie USS istnieje już słowo
ALFABET, a my chcemy doń dopisać imię sympatycznego kosmity ze znanego
amerykańskiego serialu: ALF. Otóż wszystkie poziomy odpowiadające literom
'A', 'L' i 'F już istnieją- w konsekwencji żadne nowe komórki pamięci nie
5.7. Uniwersalna struktura słownikowa
157
zostaną stworzone. Jedynie na poziomie litery 'F' zostanie utworzona komórka,
w której do t[n-I] zostanie wpisany wskaźnik „do siebie " . Przypomnijmy, że to
ostatnie służy jako znacznik końca słowa.
void zapisz(char *slowo, USS P R p)
T
{
USS_PTR q; // zmienna pomocnicza
i n t pos;
f o r ( i n t i=1;i & lt; =strlen(słowo);i++)
}
}
{
pos=ćo_indeksu(słowo[i-1]);
if (p- & gt; t[pos] !=NULL)
p=p- & gt; t[pos];
else
{
q=new USS;
p- & gt; t[pos]=q;
for (int k=0; k & lt; n; q- & gt; r[k+ + ]=NULL) ;
p=q;
}
}
p - & gt; t [ n - l ] = p ; / / p ę t l a jako koniec słowa
}
Funkcja pisz słownik służy do wypisania zawartości słownika - być może nie
w najczytelniejszej formie, ale można się dość łatwo zorientować, jakie słowa zostały zapamiętane w USS.
void pisz_slownik(USS_PTR p)
{
f o r ( i n t i=0;i & lt; 26;i++)
if (p- & gt; t[i] != N
KULL)
{
if((p- & gt; t[i])- & gt; t[n-l]==p- & gt; l[i])// koniec słowa = & gt; CR
cout & lt; & lt; z_indeksu(i) & lt; & lt; endl & lt; & lt; " " ;
else
cout & lt; & lt; z_indeksu(i);
cout & lt; & lt; "
" ; // to dla ładnego wyglądu...
pisz_slownik(p- & gt; t[i]); //wypisz rekurency]nie resztę
}
}
Funkcja szukaj realizuje dość oczywisty algorytm szukania pewnego słowa
w drzewie: jeśli przejdziemy wszelkie gałęzie (poziomy) odpowiadające literom
poszukiwanego słowa i trafimy na znacznik końca tekstu, to wynik jest chyba
oczywisty!
void szukaj(char 'słowo, USS_PTR p)
i
// szukaj słowa w słowniku
int test=l;
int i=0;
while ((test==l) & & i & lt; strlen(słowo))
158
Rozdział 5. Struktury danych
{
(
i f (p- & gt; t[do_indeksu(slowo[i])]==NULL) t e s t = 0 ;
// brak o d g a ł ę z i e n i a , słowa n i e ma!
e l s e p = p - & gt; t [ d o _ i n d e k s u ( s l o w o [ i + + ] ) ] ; / / szukamy d a l e j
}
}
if ( i = = s t r l e n ( s ł o w o ) & & p - & gt; t [ n - 1 ] = = p & & t e s t )
cout & lt; & lt; " Słowo znalezione!\n " ;
else
cout & lt; & lt; " Słowo nie zostało znalezione w słowniku\n " ; ;
}
Oto przykładowa funkcja main:
void main()
{
int i;
char tresc[100];
USS_PTR p=new USS; // tworzymy nowy słownik
for (i=0; i & lt; n; p- & gt; t[i++]=NULL);
f o r ( i = l ; i & lt; =7; i++) // wczytamy 7 słów
{
c o u t & lt; & lt; " Podaj słowo k t ó r e mam u m i e ś c i ć w s ł o w n i k u : " ;
cin & gt; & gt; tresc;
zapisz(tresc,p);
1
}
piss_slownik(p);
// wypisujemy słownik
for(i=l ;i & lt; =4;i++) // szukamy 4 słów
{
}
}
c o u t & lt; & lt; " Podaj słowo k t ó r e mam poszukać w s ł o w n i k u : " ;
c i n & gt; & gt; tresc;
szukaj(tresc,p);
|
|
Przypuśćmy, że podczas sesji z programem wpisaliśmy następujące słowa: alf,
alfabet, alfabetycznie, anagram, anonim, ASTRonoM/a, Ankara (duże i male
ia
litery zostały celowo pomieszane ze sobą). Po wczytaniu tej serii program po
winien wypisać zawartość słownika w dość dziwnej co prawda, ale w miarę
czytelnej formie, która ukazuje rzeczywistą konstrukcję drzewa USS dla tego
przykładu:
A-L-F
-A-B-E-T
-Y-C-Z-N-I-E
-N-A-G-R-A-M
-K-A-R-A
-O-N-T-M
I
-S-T-R-O-N-O-M-IA
5.8. Zbiory
159
5.8. Zbiory
Implementacja programowa zbiorów matematycznych napotyka na szereg ograniczeń związanych z używanym językiem programowania. Miłośnicy Pascala znają
zapewne definicje zbliżone do:
type Litery = 'A' . . 'Z' ;
ZbiorLiter = set of Litery;
var
Alfabet:ZbiorLiter;
c: char;
begin
Alfabet: = [ 'A'..'Z' ];
read (c) ;
if c in Alfabet then (itd. )
end.
Oczywiście, to co dla programisty pascalowego jest zbiorem, wcale nim nie jest
dla matematyka, z uwagi na wymóg jednakowego typu zapamiętywanych ele
mentów.
Niemniej, dla podstawowych zastosowań, konwencje istniejące w Pascalu
nadają się znakomicie, gdyż możliwe jest np. wykonywanie operacji typu: dodawanie elementu do zbioru, mnożenie (iloczyn) zbiorów, odejmowanie zbio
rów, testowanie przynależności do zbiorów...
W tej książce do opisu algorytmów i prezentacji struktur danych używamy
języka C++, który na ogół spełnia swoje zadanie dość dobrze. Niestety, nie posiada on „wbudowanej " obsługi zbiorów i w związku z tym należy ją dołożyć
w sposób jawny, używając przy okazji różnorodnych technik, zależnych od
aktualnie realizowanych zadań.
Weźmy dla przykładu implementacją zbioru znaków, która nie wymaga użycia
struktur listowych i dynamicznego przydzielania pamięci. Załóżmy, że w komputerze występuje „tylko " 256 znaków (między innymi znaki alfabetu duże
i małe, cyfry oraz tzw. znaki kontrolne niedrukowalne). Do „zasymulowania "
zbioru wystarczy wówczas najzwyklejsza tablica typu unsigned char, tak jak
w przykładzie poniżej:
set.cpp
class Zbior
{
unsigned char zbior[256]; // cala tablica ASCII
public:
Zbiór () // konstruktor, „zeruje " zbiór
{
for(int i=0;i & lt; 256;i++)
zbiór[i] =0;
}
160
Rozdział 5. Struktury danych
Zbior & operator +(unsigned char c)
{
// dodaj 'c' do zbioru
zbiór[c]-l;
return *this; // zwraca zmodyfikowany obiekt
Zbior & operator -(unsigned char c)
{
// usuwa 'c' ze zbioru
zbior(c]=0;
return
*this;
// zwraca zmodyfikowany obiekt
}
i n t należy(unsigned char c)
{
// czy 'c' należy do zbioru?
return zbior[c]—1;
}
Zbior & dodaj(Zbior s2)
{ // dodaj zawartość zbioru
's2'
do obiektu
for(int i-0; i & lt; 256;i++)
if(s2.należy(i)) // jeśli element obecny w s2
zbior [i]=1; // dodaj go do zbioru
return *this; // zwraca zmodyfikowany obiekt
}
int ile()// zwraca liczbę elementów w zbiorze
{
int n;
for (int i=0; i & lt; 256;i++)
if(zbiór[i]==l) // element obecny
n++;
return n;
{
void pisz()
// wypisuje zawartość zbioru
{
cout & lt; & lt; " { " ;
for(int i=0; i & lt; 256;i++)
if (zbior [i ] ==1) // element, obecny
cout & lt; & lt; (char)i & lt; & lt; " " ;
if;i==0)
cout & lt; & lt; " Zbiór pusty! " ;
cout & lt; & lt; " }\n " ;;
}
}; // koniec definicji klasy Zbiór
Pomimo dużej prostoty, powyższa implementacja umożliwia już manipulacje
typowe dla zbiorów:
void main()
{
Zbiór s l , s2;
sl-sl+'A'; sl-sl+'A 1 ; s l - s l + ' B ' ; sl=sl+'C';
s2=s2+'B'; s2=s2+'B'; s2=s2+'E'; s2=s2+'F;
cout & lt; & lt; " Zbiór Sl = " ;
sl.pisz ();
5.8. Zbiory
161
sl=sl-'C;
cout & lt; & lt; " Zbior S1 - 'C' = " ;
sl.pisz();
cout & lt; & lt; " Zbior S2 = " ; s2.pisz();
sl.dodaj(s2);
cout & lt; & lt; " Zbior Sl + S2 = " ;
sl.pisz();
}
Uruchomienie programu powinno spowodować wyświetlenie na ekranie nastę
pujących komunikatów:
Zbiór
Zbiór
Zbiór
Zbiór
Sl
Sl
S2
Sl
=
=
+
{A B C}
'C' = {A B}
{B E F}
S2 = {A B E F)
Czytelnik z łatwością uzupełni samodzielnie operacje, dostępne w powyższej
implementacji klasy Zbiór o przecinanie (iloczyn) i odejmowanie zbiorów.
Możliwe jest stworzenie dowolnej w zasadzie implementacji zbiorów, tj.
akceptujących zmienną liczbę danych (wymaga dynamicznego przydziału
pamięci, np. przy pomocy list) jak również akceptujących złożone elementy skła
dowe, np. struktury. Wydaje się jednak, że zaprojektowanie klasy Zbiór z użyciem
klas szablonowych (patrz §5.2.1) i list, byłoby „nadużyciem siły " , w przypadku.
jeśli jedynymi potrzebnymi nam elementami zbiorów miałyby zostać jedynie...
znaki alfabetu!
5.9.Zadania
Zad. 5-1
Zastanów się, jak można w prosty sposób zmodyfikować model Uniwersalnej
Struktury Słownikowej (patrz strona 154), aby możliwe było jej użycie jako slownika 2-języcznego, np. polsko-angielskiego. Oszacuj wzrost kosztu słownika
(chodzi o ilość zużytej pamięci) dla następujących danych: 6.000 rekordów USS
w pamięci zawierających 25.000 zapamiętanych słów.
Zad. 5-2
Zestaw dość podobnych zadań. Napisz funkcje, które usuwają:
a) pierwszy element listy;
b) ostatni element listy;
162
Rozdział 5. Struktury danych
c) pewien element listy, który odpowiada kryteriom poszukiwań podanym
jako parametr funkcji (aby uczynić funkcję uniwersalną wykorzystaj meto
dę przekazania wskaźnika funkcji jako parametru).
Zad. 5-3
Napisz funkcję, która:
a) zwraca liczbę elementów listy;
b) wraca k-ty element listy;
c) usuwa k-ty element listy.
5.10.Rozwiązania zadań
Zad. 5-1
Modyfikacja struktury USS:
typedef
s t r u c t slownik
{
struct slownik *c[n];
char
*tlumaczenie;
} USS,*USS_PTR;
Tłumaczenie jest „dopisywane " (alokowane) w funkcji zapisz podczas zazna
czania końca słowa - w ten sposób nie stracimy związku słowo-tłumaczeme.
Koszt:
• bez drugiego języka:
Koszt - (n=29)*4 bajty („duży " model pamięci)-696000 bajtów - ok.
679kB.
• z drugim językiem:
Założenie: średnia długość słowa angielskiego wynosi 9 bajtów + ogra
nicznik, czyli 10 bajtów.
Koszt = przypadek poprzedni plus 25,000 * 10 plus pewna ilość nie zuży
tych wskaźników na tłumaczenie - przyjmijmy zaokrąglenie na 1000.
Ostatecznie mamy: 25.000* 10+1000*4=254.000 bajtów, czyli ok. 248 kB.
5.10. Rozwiązania zadań
163
W danym przypadku koszt wzrósł o ok. 36 % pierwotnej zajętości pamięci.
Zad. 5-3
Oto propozycja rozwiązania zadania 5-3a:
i n t cpt{ELEMENT *q, i n t res=0)
{
if (glowa==NULL)
return res;
else
cpt(q- & gt; nastepny,res+1);
}}
Przykładowe wywołanie: i n t i l o s c = c p t ( i n f - & gt; g ł o w a ) .
Podczas rozwiązywania zadań 5-2 i 5-3 proszę dokładnie przemyśleć efektywny
sposób informacji o sytuacjach błędnych (np. próba usunięcia k-tego elementu.
podczas gdy on nie istnieje e t c ) .
Rozdział 6
Derekursywacja
Podjęcie tematu przekształcania algorytmów rekurencyjnych na ich postać iteracyjną - oczywiście równoważną funkcjonalnie! - jest logiczną konsekwencją
omawiania rekurencji. Pomimo iż temat ten był kiedyś podejmowany wyłącznie
na użytek języków nie umożliwiających programowania rekurencyjnego
(FORTRAN, COBOL), nawet obecnie znajomość tych zagadnień może mieć
pewne znaczenie praktyczne.
Sam fakt poruszenia tematu derekursywacji w książce poświęconej algorytmom
i technikom programowania jest trochę ryzykowny - nie są to zagadnienia o cha
rakterze czysto algorytmicznym. Tym niemniej w praktyce warto coś na temat
wiedzieć, gdyż trudno derekursywacji odmówić znaczenia praktycznego. Skąd
jednak wziął się sam pomysł takiego zabiegu? Programy wyrażone w formie
rekurencyjnej są z natury rzeczy bardzo czytelne i raczej krótkie w zapisie. Nie
trzeba być wybitnym specjalistą od programowania, aby się domyślić, iż wersje
iteracyjne będą zarówno mniej czytelne, jak i po prostu dłuższe. Po cóż więc
w ogóle podejmować się tego - zdawałoby się bezsensownego - zadania?
Rzeczywiście, postawienie sprawy w ten sposób jest zniechęcające. Poznawszy
kilka istotnych zalet stosowania technik rekurencyjnych chcemy się teraz od tego
całkowicie odwrócić plecami! Na szczęście nie jest aż tak źle, bowiem nikt tu
nie ma zamiaru proponować rezygnacji z rekurencji. Nasze zadanie będzie
wchodziło w zakres zwykłej optymalizacji kodu w celu usprawnienia jego wy
konywania w rzeczywistym systemie operacyjnym, w prawdziwym komputerze.
Piętą Achillesową większości funkcji rekurencyjnych jest intensywne wykorzy
stywanie stosu, który służy do odtwarzania „zamrożonych " egzemplarzy tej
samej funkcji. Z każdym takim nieczynnym chwilowo egzemplarzem trzeba
zachować pełny zestaw jego parametrów wywołania, zmiennych lokalnych czy
wreszcie adres powrotu. To tyle, jeśli chodzi o samą zajętość pamięci. Nie
zapominajmy jednak, iż zarządzanie przez kompilator tym całym bałaganem
166
Rozdział 6. Derekursywacja
kosztuje cenny czas procesora, który dodaje się do ogólnego czasu wykonania
programu!
Pomysł jest zatem następujący: podczas tworzenia oprogramowania wykorzy
stajmy całą siłę i elegancję algorytmów rekurencyjnych. natomiast w momencie
pisania wersji końcowej (tej, która ma być używana w praktyce), dokonajmy
1
transformacji na analogiczną postać iteracyjną . Z uwagi na to, że nie zawsze
jest to proces oczywisty, warto poznać kilka standardowych sposobów używa
nych do tego celu.
Zaletą zabiegu transformacji jest pełna równoważność funkcjonalna. Implikuje to
między innymi fakt. iż będąc pewnym poprawności działania danego programu rekurencyjnego, nie musimy już udowadniać poprawności jego wersji iteracyjnej,
Wyrażając to innymi słowy: dobry algorytm rekurencyjny nie ulegnie zepsuciu po
swojej poprawnej transformacji.
6.1. Jak pracuje kompilator?
Języki strukturalne, pełne konstrukcji o wysokim poziomie abstrakcji, nie mogłyby
spełniać w ogóle swojej roli. gdyby nie istniały kompilatory. Kompilatory są to
również programy, które przetłumaczą nasze dzieła na postać zrozumiałą przez
(mikro)procesor.
Dodajmy jeszcze, że efekt tego tłumaczenia marnie przypomina to, co z takim
trudem napisaliśmy i uruchomiliśmy. Wyklęta ongiś instrukcja goto (a w każdym
razie jej odpowiedniki) występuje w kodzie wynikowym częściej. Popatrzmy
dla przykładu na tłumaczenie maszynowe " zwykłej instrukcji warunkowej:
if(warunek)
Instrl;
else
Instr2;
Jest to prosta instrukcja strukturalna, ale jej wykonanie musi być sekwencyjne:
if not warunek goto etl
ASM(Instrl)
goto koniec
etl;
1
Pod warunkiem, że jest to konieczne z uwagi na parametry czasowe naszej aplikacji
lub jej ograniczone zasoby pamięci. W każdym innym przypadku podejmowanie się
derekursywacji ma sens raczej wątpliwy.
Przedstawione oczywiście symbolicznie za pomocą pseudokodu asemblerowego.
6.1. Jak pracuje kompilator?
167
ASM(Instr2)
koniec:
(instr)
ASM(imtr) znaczą ciąg instrukcji asemblerowych odpowiadających instrukcji
instr, a if, if_not i goto są elementarnymi instrukcjami procesora (słowami klu
czowymi języka asemblera).
Każdą dowolną instrukcję strukturalną można przetłumaczyć na jej postać
sekwencyjną (rzeczywiste kompilatory tym właśnie między innymi się zajmują).
Także w przypadku wywołań proceduralnych czynność ta. wbrew pozorom, nie
jest skomplikowana. Przyjmując pewne uproszczenia, ciąg instrukcji:
Instr1;
P(x);
Instr2;
odpowiada, już po przetłumaczeniu przez kompilator, następującej sekwencji:
ASM(instrl)
tmp=x
adr_powr=etl
goto et2
etl:
ASM(Instr2);
et2:
ASM(P(tmp)),
...
goto adr_powr
Czy w podany wyżej sposób da się również potraktować wywołania reku
rencyjne (w procedurze P wywołujemy jeszcze raz P)? Oczywiście nie powie
lamy tyle razy fragmentu kodu odpowiadającego tekstowi P, aby obsłużyć
wszystkie jej egzemplarze - byłoby to absurdalne i niewykonalne w praktyce.
Jedyne, co nam pozostaje, to zasymulować wywołanie rekurencyjne poprzez
zwykłe wielokrotne użycie tego bloku instrukcji, który odpowiada procedurze P
- z jednym wszakże zastrzeżeniem: wywołanie rekurencyjne nie może zacierać
informacji, które są niezbędne do prawidłowego kontynuowania wykonywania
programu.
Niestety, sposób podany poprzednio nie spełnia tego warunku. Spójrzmy na
przykład na następujący program rekurencyjny :
P(int x)
{
Instrl;
P(F(x));
Instr2;
3
Funkcja F oznacza grupę przekształceń dokonywanych na parametrach funkcji.
168
Rozdział 6. Derekursywacja
Jak odróżnić powrót z procedury P który powoduje definitywne jej zakończenie
od tego, który przekazuje kontrolę do Instr2? Okazuje się, że jedyny łatwy do
zautomatyzowania sposób polega na użyciu tzw. stosu wywołań rckurencyjnych (patrz również §5.3).
Zarządzanie powrotami z wywołań rekurencyjnych wymaga uprzedniego zapa
miętywania dwóch informacji: tzw. otoczenia (np. wartości zmiennych lokal
nych) i adresu powrotu, dobrze nam znanego z poprzedniego przykładu. Pod
czas wywołania rekurencyjnego następuje zapamiętanie na stosie tych informacji
i kontrola jest oddawana procedurze. Jeśli wewnątrz niej nastąpi jeszcze raz
wywołanie rekurencyjne, to na stos zostaną odłożone kolejne wartości otoczenia
i adresu powrotu - różniące się od poprzednich. Podczas powrotu z procedury
rekurencyjnej możliwe jest odtworzenie stanu zmiennych otoczenia sprzed
wywołania poprzez zwykłe zdjęcie ich ze stosu.
Kompilator „wie " , gdzie ma nastąpić powrót, bowiem adres (argument instruk
cji goto4) także został zapamiętany na stosie. Testując stan stosu możliwe jest
określenie momentu zakończenia procedury: jeśli stos jest pusty, to wszystkie
wywołania rekurencyjne już „się " wykonały.
Oto jak możliwe byłoby zrealizowanie w formie sekwencyjnej poprzedniego
przykładu:
start:
*
ASM(Instrl)
push(Otoczenie, et1)
x=F(x)
goto start
procedura i wywołania
rekurencyjne
et1:
ASM(Instr2)
if not(StosPusty)
{
!
pop(Otoczenie, Addr
Odtwórz(Otoczenie)
goto Addr
}
)
;powroty z wywołań
;rekurencyjnych
}
}
To tyle tytułem wstępu. W dalszej części rozdziału przystąpimy już do kilku
prób tłumaczenia algorytmów rekurencyjnych na iteracyjne.
Warto przypomnieć, że instrukcja goto istnieje również w C++,
.2, Odrobina formalizmu... nie zaszkodzi!
169
6.2.Odrobina formalizmu... nie zaszkodzi!
Mimo iż podręcznik ten bazuje na przykładach, od czasu do czasu warto przy
wdziać „garnitur naukowy " i zachowywać się dostojnie - a nic tak nie przekonuje o wadze tematu jak Definicje i Twierdzenia. Oto i one:
Def. 1 Procedura iteracyjna I jest równoważna procedurze rekurencyjnej Z', je
śli wykonuje dokładnie to samo zadanie co P, dając identyczne rezultaty.
Przykładowo dwie poniższe procedury symetria I i symetria2 mogą być uważane za równoważne. Obie zajmują się dość błahym zadaniem rysowania
„szlaczka " typu & lt; & lt; & lt; & lt; - & gt; & gt; & gt; & gt; - o regulowanej przez parametr* szerokości.
void s y m e t r i a l ( i n t x)
(
}
void symetria2(int x)
{
for(int i=i; i & lt; =x;i++)
cout & lt; & lt; " & lt; " ; " & lt; " ;
& lt; & lt;
cout & lt; & lt; " - " ;
for(i=1; i & lt; =x;i++)
cout & lt; & lt; " & gt; " ;
if (x==0)
cout & lt; & lt; cout
" - " ;
& lt; & lt; " - " ;
else
{
cout & lt; & lt; " & lt; " ,
symetrial(x-l) ;
cout
cout & lt; & lt; " & gt; " ; & lt; & lt;
}
cout
" & gt; " ;
}
}
Def. 2
Wywołanie rekurencyjne procedury P jest zwane terminalnym (ang.
end-recursion), jeśli nie następuje po nim już żadna instrukcja tej proce-
dury.
Przykład:
void RecTerm(int n)
{
if (x==0)
cout & lt; & lt; " . " ;
{
else
{
cout & lt; & lt; Ą " ;
RecTerm(n-l) ;
}
}
170
Rozdział 6. Derekursywacji
Uwaga: Wywołanie rekurencyjne procedury P zawarte w jakiejkolwiek pętli, np:
v o i d P ( i n t n)
{
{
}
Instr 1 ;
P(n-l);
!
}
vie jest uważane za terminalne, bowiem w zależności od warunku V, wywołanie P(n-I) może, ale nie musi być wykonywane jako ostatnie.
Twierdzenie 1 Następujące procedury P1 i P2 są sobie wzajemnie równoważ
ne, pod warunkiem że PI zawiera tylko jedno rekurencyjne
wywołanie terminalne.
void
{
1
if
(cond(x))
Instrl(x);
else
{
Instr2 (x);
Pl(F(x))
}
P1(x)
v o i d P2(x)
{
while(!Cond(x))
{
Instr2(x);
x=F(x);
}
Instrl(x);
}}
}}
6.3. Kilka przykładów derekursywacji algorytmów
Wypróbujmy teraz świeżo nabytą wiedzę na „nieśmiertelnym " przykładzie tzw,
wież Hanoi. Jest to łamigłówka o dość legendarnym rodowodzie — w co wnikać
nie będziemy podczas naszych wywodów, koncentrując się raczej na problemie
logicznym i sposobie rozwiązania go.
Zadanie jest następujące: mamy do dyspozycji n krążków o malejących średni
cach, każdy z nich posiada wydrążoną dziurkę, która umożliwia nadzianie go na
jeden z 3 wbitych w ziemię drążków. Na rysunku 6 - 1 jest przedstawiona sytuacja
początkowa (z lewej strony) i końcowa (z prawej) dla 4 krążków.
Rys. 6 -1.
Wieże Hanoi
-prezentacja
problemu.
6.3. Kilka przykładów derekursywacji algorytmów
171
Musimy przełożyć krążki z drążka oznaczonego a na drążek b, posiłkując się
drążkiem pomocniczym c - tak jednak postępując, aby w żadnym przypadku
krążek o mniejszej średnicy nie został przykryty przez inny krążek o większej
średnicy. Przyjmuje się, że krążek o numerze 1 ma najmniejszą średnicę, a
ten o numerze n - największą. Ponadto, dla potrzeb programu wynikowego
oznaczymy krążki a. b i c jako 0, 1 i 2,
Analiza rekureneyjna zadania prowadzi nas do następujących spostrzeżeń:
• jeśli mamy do czynienia z jednym krążkiem, to zadanie sprowadza się
do przemieszczenia go z a na b (przypadek elementarny);
• jeśli mamy do czynienia z n & gt; 2 krążkami, to przy założeniu, że umiemy
przemieścić n-I krążków z jednego drążka na drugi, zadanie sprowadza
się do wykonania przemieszczeń symbolicznie przedstawionych na ry
sunku 6 - 2 .
Rys. 6 - 2.
Wieże Hanoi
-sposób rozwią
zywania.
ETAP 2
ETAP I
a)
b)
c)
b)
c)
ETAP 4
ETAP3
»)
«)
b)
c)
a)
b)
o
Etap pierwszy przedstawia sytuację wyjściową. Załóżmy teraz, że przenieśliśmy
jakimś „tajemniczym " sposobem n-1 krążków z drążka o na drążek c. Na drążku
a pozostał nam największy krążek, ten o numerze n. W tym momencie dotarli
śmy do sympatycznie prostego przypadku elementarnego i już bez żadnej do
datkowej magii możemy krążek o numerze n przenieść z drążka a na drążek b.
Znajdziemy się w ten sposób w sytuacji oznaczonej na rysunku jako etap 3. Jak
doprowadzić do rozwiązania łamigłówki dysponując taką konfiguracją danych?
Pouczeni doświadczeniem etapu pierwszego, postąpimy dokładnie w taki sam
sposób: weźmiemy n-l krążków z drążka c i przemieścimy je tajemniczym sposo
bem na drążek b..,
172
Rozdział 6. Derekursywacja
Wzmiankowany powyżej „tajemniczy sposób " nie powinien stanowić niespo
dzianki dla osób, które mają za sobą lekturę rozdziału 2. Chodzi oczywiście o
sprowokowanie serii wywołań rekurencyjnych, które będą pamiętały o naszych
intencjach i postępując wg założonych reguł, rozwiążą łamigłówkę.
Zauważmy, że przy przyjętych oznaczeniach mamy a + b + c=0+ l+ 2 +3, czyli
c=3-a-b. Procedura, która rozwiązuje problem wież Hanoi, jest teraz niesły
chanie prosta:
hanoi.cpp
void hanoi(int n, int a, int b)
{
if
(n==l)
cout & lt; & lt; " Przesuń dysk nr " & lt; & lt; n & lt; & lt; " z " & lt; & lt; a
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
else
{
hanoi(n-1,a,3-a b);
cout & lt; & lt; " Przesuń dysk nr " & lt; & lt; n & lt; & lt; " z " & lt; & lt; a
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
hanoi(n-1,3-a-b,b);
Niestety, algorytm ten jest dość kosztowny, bowiem czas jego wykonania
wynosi aż (2n-1)*te gdzie te jest czasem pojedynczego przemieszczenia krążka
z jednego drążka na inny . Wynik ten nie jest trudny do uzyskania, ale dla czy
telności wykładu zostanie pominięty.
.
j
O ile jednak nie możemy specjalnie w ten czas ingerować (sam problem jest dość
czasochłonny „z natury " ), to możemy nieco ułatwić generację kodu kompilatorowi,
eliminując drugie wywołanie rekurencyjne, które spełnia warunek narzucony przez
Twierdzenie I (patrz str. 170). Przekształcenie procedury hanoi wg podanej tam re
guły jest natychmiastowe:
void hanoi2(int n, int a, int b)
{
while
(n!-l}
'
{
hanoi2(n-l,a,3-a-b);
cout & lt; & lt; " Przesuń dysk nr ' & lt; n & lt; & lt; " z " & lt; & lt; a
* & lt;
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
n=n-l;
a=3-a-b;
}}
cout & lt; & lt; " Przesuń dysk nr 1 z " & lt; & lt; a & lt; & lt; " na "
})
1
& lt; & lt; b & lt; & lt; endl;
Wynik ten nie jest trudny do uzyskania, ale dla czytelności wykładu zostanie pominięty.
|
6.3. Kilka przykładów derekursywacji algorytmów
173
Pokaźna grupa procedur rekurencyjnych dość łatwo poddaje się transformacji
opisanej w Twierdzeniu 1. Ponadto wiele procedur daje się sprowadzić, poprzez
niewielkie modyfikacje kodu, do „transformowalnej " postaci. Taki właśnie
przykład będziemy teraz analizowali.
Podczas omawiania rekurencji mieliśmy okazję poznać programową realizacje
funkcji obliczającej silnię:
i n t s i l n i a ( i n t x)
{
cout & lt; & lt; " x " & lt; & lt; x & lt; & lt; endl;
if (x==0)
return 1;
else
}
return
x*silnia(x-l);
Czy uda nam się zamienić ją na wersję iteracyjną? Pierwszy problem skupia się
na tym, że mamy do czynienia ze skrótem polegającym na wprowadzeniu wy
wołania rekurencyjnego do równania zwracającego wynik funkcji. Nic jednak
nie stoi na przeszkodzie, aby ową sporną linię rozpisać, co da nam następującą
wersję (oczywiście całkowicie równoważną);
i n t s i l n i a ( i n t x)
{
if (X==0)
return 1;
else
{
int tmp=silnia (x-1) ;
}
return x*tmp;
}
Niestety, niewiele nam to pomogło, gdyż wywołanie rekurencyjne nie jest
terminalne, a zatem nie jest możliwe zastosowanie Twierdzenia I. Ta przeszkoda
może być jednak łatwo pokonana, jeśli dokonamy kolejnej transformacji:
int silnia(int x,
int res1)
{
if (x==0)
return res;
else
silnia(x-1,x*res);
}
Nie sposób tu ukryć, że powróciliśmy do tak zachwalanego, podczas oma
wiania rekurencji, typu rekurencji „z parametrem dodatkowym " (laką wówczas
przyjęliśmy nazwę). Czyżby zatem rekurencja „terminalna " i rekurencja „z para
metrem dodatkowym " były dokładnie tymi samymi fenomenami?! Jeśli tak,
174
Rozdział 5.
Derekursywacja
to dlaczego nie wspomnieliśmy o tym wcześniej, wprowadzając na dodatek
nowe nazewnictwo?
Odpowiedź zabrzmi dość przewrotnie: te dwa typy rekurencji są i nie są zarazem
takie same. Wprowadzając nowy termin, ową rekurencję z parametrem dodatko
wym, miałem na uwadze pewną klasę zagadnień natury numerycznej lub quasinumerycznej. Wyrażając to jeszcze dokładniej: grupę programów, które zwracają
„namacalny " wynik, np. liczbę, tablicę, ciąg znaków etc. Ten wynik jest dostar
czany poprzez parametr dodatkowy i stąd pochodzi nazwa. Natomiast programem
terminalnym może być procedura hanoi która nic .,dotykalnego " - oprócz przepi
su na rozwiązanie łamigłówki - nie dostarcza! Poprzestając na tym wyjaśnieniu
przekształćmy wreszcie funkcję silnia na jej postać rekurencyjną. Niespodzianek
nie powinno być Żadnych-tłumaczenie jest niemal automatyczne:
int silnia_it(int x, int res=1)
{
while(x!=0)
{
res=x*res;
x--;
}
}
return res;
|
}
6.4. Derekursywacja z wykorzystaniem stosu
W tym paragrafie zapoznamy się z nową metodą derekursywacji. która niestety
jest dość kontrowersyjna. Zmuszeni bowiem będziemy do swoistego zaprze
czenia wielkim regułom programowania strukturalnego i na dodatek propono
wane rozwiązania nie będą miały nic wspólnego z " estetycznymi " wymogami
programowania. Powodem tego jest operowanie pojęciami o bardzo niskim poziomie abstrakcji, bardzo zbliżonymi do zwykłego języka asemblera. Zasada
jest prosta: wiedząc, jak kompilator traktuje wywołania rekurencyjne, będziemy
usiłowali robić to samo, lecz próbując po drodze nieco upraszczać jego zadanie.
Mamy bowiem do dyspozycji coś, czego brakuje współczesnym kompilatorom:
naszą inteligencję. Kompilator jest zwykłym programem postępującym automatycznie: plik tekstowy zawierający program w języku wysokiego poziomu
jest zamieniany na maszynową reprezentację, która możliwa jest do wykonania
przez procesor komputera. Kompilator rozpatruje programy pod kątem ich
składni i nie jest raczej w stanie analizować ich sensu i celu. My natomiast całą
tę wiedzę posiadamy i stąd właśnie wziął się pomysł metody derekursywacji
z wykorzystaniem stosu.
5.4. Derekursywacja z wykorzystaniem stosu
175
Metoda ta jest podzielona na dwa etapy:
1. zamianę zmiennych lokalnych na globalne;
2. transformację programu rekurencyjnego pozbawionego zmiennych lo
kalnych na postać iteracyjną.
W kolejnych paragrafach szczegółowo omówimy te dwa posunięcia.
6.4.1.Eliminacja zmiennych lokalnych
Zanim w ogóle zaczniemy coś eliminować, warto upewnić się. czy zdajemy sobie
sprawę, co będzie przedmiotem naszych zabiegów. Zmienne lokalne pełnią w języ
ku strukturalnym rolę szczególną: umożliwiają czytelne formułowanie algorytmów
i pozbawiają tak dobrze znanego programującym w dawnym BASICu strachu
przed modyfikacją jakiejś ważnej „gdzie indziej " zmiennej. Mając to na uwa
dze, dziwną wydawać by się mogła propozycja powrotu do tych prehistorycz
nych czasów, w których nie było procedur, zmiennych lokalnych, przesłaniania
nazw etc. Na szczęście nikt czegoś takiego nie ma zamiaru proponować! Oma
wiana metoda nie jest bowiem w żadnym razie metodą programowania, lecz
zwykłą techniką optymalizacyjną - a jest to istotna różnica. Wróćmy zatem do
zmiennych lokalnych i zdefiniujmy sobie, co to takiego.
zmienną lokalną
pewnej procedury P będziemy zwali taką zmienną, która
może być modyfikowana tylko przez tę procedurę.
zmienną globalną -
z punktu widzenia procedury P będzie taka zmienna, któ
ra może być zmodyfikowana na zewnątrz tej procedury.
W C++ każda zmienna zadeklarowana wewnątrz bloku ograniczonego nawia
sami klamrowymi { i } jest uważana za lokalną dla tego bloku! Tak więc w poniż
szej procedurze mamy do czynienia z dwiema różnymi zmiennymi lokalnymi
varjoc i jedną zmienną globalną var_glob:
i n t var_glob;
void P()
{
int
var_loc;
while(jakiś_warunek)
{
{
int var_loc;
...
}
}
Wiedząc już dokładnie z czym mamy do czynienia, możemy zobaczyć, w jaki
sposób przekształcić rekurencyjną procedurę zawierającą zmienne lokalne
176
Rozdział 6. Derekursywacja
zm_loc i pewne parametry wywołania param_wywol w analogicznie działąjąą
procedurę, ale używającą tylko zmiennych globalnych. (Tym samym procedura
P nie będzie już miała w ogóle parametrów wywołania).
Rozważmy dość ogólną formę wywołania procedury rekurencyjnej P
void
{
P(param_wywol)
...
F(param_wywol));
...
Pierwszy etap transformacji polega na usunięciu funkcji F z wywołania P:
v o i d P(param_wywol)
{
...
param wywol=F(param_wywol);
P(param_wywol);
...
}
Jest to najzwyczajniejsze przepisanie kodu w nieco innej postaci. Chcemy
uczynić zm_loc i param_wywol zmiennymi globalnymi, tymczasem ulegają one
podczas wywołania rekurencyjnego modyfikacji poprzez kolejny egzemplarz
procedury P\ Jak sobie z tym poradzimy? Musimy bowiem w jakiś sposób
zachować wartości zm_loc i param_wywoł, aby pomimo ewentualnych zmian
ich zawartości podczas wykonania procedury P sytuacja przed i po była taka
sama. Pomoże nam w tym oczywiście stos:
void P()
{
...
push{param_wywol)
push (zm__loc)
param_wywol-r(param_wywol);
P(param_wywol);
pop(zm_loc);
pop(param wywol);
}
' Zarówno zmloc jak i param _wywol reprezentują listy zmiennych - to dla skrócenia
zapisu.
6,5. Metoda funkcji przeciwnych
177
Dokonaliśmy zatem tego, co było naszym celem: pozbawiliśmy procedurę P
wszelkich parametrów lokalnych, a pomimo to jej funkcjonowanie - j a k rów
nież funkcjonowanie całego programu - nie uległo zmianie. Musimy jednak
pamiętać o rym, by prawidłowo zainicjować globalne już zmienne zm_loc i
param_wywol właściwymi wartościami7, tak aby zachować pełną równoważność
funkcjonalną naszego programu - przed i po przeróbce. Analizując jeszcze naszą
metodę warto wspomnieć o nasuwającej się od razu optymalizacji. Na stosie
musimy zachowywać tylko te wartości zmiennych lokalnych, które są potrzeb
ne. W szczególności absolutnie nie ma potrzeby chować na stos tych zmien
nych lokalnych, które nie są już używane po wywołaniu rekurencyjnym.
Dla ilustracji opisanego powyżej procesu przeanalizujmy raz jeszcze nasz kla
syczny przykład wież Hanoi (patrz str. 170). Proste przekształcenia algorytmu
prowadzą do następującej wersji:
void hanoi3()
{
while (n!=1)
{
push(n); push(a); push(b);
n=n-l; b=3-a-b;
hanoi3();
pop(b); pop(a); pop(n);
cout & lt; & lt; " Przesuń dysk nr " & lt; & lt; n & lt; & lt; " z " & lt; & lt; a
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
n=n-l;
a-3-a-b;
}
cout & lt; & lt; " Przesuń dysk nr 1 z " & lt; & lt; a & lt; & lt; " na "
& lt; & lt; b & lt; & lt; endl;
}
}
6.5. Metoda funkcji przeciwnych
Użycie stosu - wywołań typu push i pop -jest kosztowne zarówno ze względu
na czas potrzebny na obsługę tej struktury danych, jak i na pamięć niezbędną na
rezerwację dostatecznie dużego stosu. Jak dużego? Problemem jest to. że nie
wiemy tego a priori, co zmusza nas do założenia najgorszego przypadku. Z tego
też powodu wszelkie ewentualne metody pozwalające nie korzystać ze stosu
powinny być przez nas powitane jak najprzychylniej. Taka metoda zostanie
przedstawiona już za moment.
Przed pierwszym wywołaniem procedury P.
178
Rozdział 6. Derekursywacja
Dużą wadą nowej techniki będzie niemożność łatwego j e j sformalizowania.
Z praktycznego punktu widzenia sprawa polega na tym, iż nie jest możliwe
podanie prostego przepisu, który mógłby być w miarę automatycznie8 zastoso
wany. Będziemy musieli zatrudnić naszą wyobraźnię i intuicję - a czasami nawet
pogodzić się z niemożnością znalezienia rozwiązania. Przejdźmy jednak do
szczegółów.
Przypomnijmy raz jeszcze ogólną posiać procedury rekurencyjnej:
void P1(param_wywol)
{
param_wywol=F(param_wywol);
P1(param_wywol) ;
}i
Wiemy, że wywalanie P (param_wywol) modyfikuje (lub może modyfikować)
zm_loc i param_wywol Poprzednio, aby się od tego uchronić, wykorzystaliśmy
zachowawcze własności stosu.
Pomysł polega na tym, aby uzupełnić procedurę PI o pewne instrukcje, które
wiedząc, jak wywołanie PI (param_wywol) modyfikuje zm_loc i param_wywol
wykonałyby czynność odwrotną, lak aby przywrócić ich wartości sprzed wy
wołania! Inaczej mówiąc, chodzi nam o doprowadzenie programu do postaci:
voidP2
{{
param_wywol-F (param_wywol ) ;
(1)
P2;
FUNKCJA_ODWROTNA(zm_loc,param_wywol);
(2)
}
Działanie owej tajemniczej funkcji odwrotnej musi być takie. aby wartości
zm_loc i param_wywol były dokładnie takie same w punktach programu oznaczo
nych (I) i (2). Jak to zrobić? Ba! oto jest dopiero pytanie! Odpowiedź na nic będzie
inna w przypadku każdego programu i nie pozostaje nam nic innego, jak tylko po
kazać jakiś konkretny przykład.
Poniższa procedura P1 liczy elementy wymyślonego ad hoc ciągu mate
matycznego:
odwrotna.cpp
void P I ( i n t
a, i n t & b)
{{
if (a==O)
Co nie znaczy, że bezmyślnie!
65. Metoda funkcji przeciwnych
179
b=l;
else
{
P1(a-l,b);
// tu funkcja odwtotna?
b=b+a;
}
}
Sens matematyczny tej procedury jesl dla nas nieistotny. Jedyne, co nas w tym
momencie interesuje, to takie jej przekształcenie, aby uzyskać procedurę void
P2. która korzystając tylko ze zmiennych globalnych a i b będzie działała w sposób
identyczny.
Pierwszym etapem naszej analizy jest odpowiedź na pytanie: „Które zmienne
są modyfikowane przez rekurencyjne wywołanie P1? " . Zmienna b ma charakter
globalny, gdyż nie jest przez P1 modyfikowana. Służy ona wyłącznie do
przekazywania wyniku z wywoływanej procedury, tak więc funkcja odwrotna
-jaka by nie była jej postać - nie będzie się musiała zajmować zachowaniem
wartości b. Jedyną zmienną, która jest modyfikowana, jest a. Dekrementowana
wartość zmiennej a jest przekazywana procedurze PI, natomiast po ukończeniu
pracy tejże chcemy korzystać z niezmienionej wartości a.
W poznanej poprzednio metodzie eliminacji zmiennych lokalnych należałoby
po prostu zachować oryginalną wartość a na stosie W naszym przypadku
wystarczy (wiedząc, że jedyną modyfikacją, jakiej może na zmiennej a dokonać
procedura PL jest dekrementacja) po prostu przywrócić oryginalną wartość a
inkrementując ją! I to jest tą naszą tajemniczą „funkcją odwrotna " ... Popatrzmy
na zmodyfikowaną treść procedury:
int a,b; // zmienne globalne
void P2()
{
if(a==0J
b=1;
else
{
a=a-l;
P2() ;
}
a=a+l;
b=b+a;
}
Sprawdźmy teraz w programie głównym, czy istotnie nasz program działa pra
widłowo':
' Nie zastąpi to oczywiście formalnego dowodu równoważności PI i P2, ale zadanie jest
na tyle proste, iż dowód ten wydaje się w tym miejscu zbędny.
180
Rozdział 6. Derekursywacja 6.6
void main()
{
for (int i=0; i & lt; 17;i++) {Pl(i,b);
cout & lt; & lt; endl;
for (i=0;i & lt; 17;i++)
{a=i;P2();cout & lt; & lt; b & lt; & lt; " " ;}
}
cout & lt; & lt; b & lt; & lt;
" ; }
Oto co ukaże się na ekranie:
2 4 7 11 16 22 29 37 46 56 67 79 92 106 121 137
2 A 7 11 16 22 29 37 45 56 67 7 9 92 106 1 2 1 137
1
Wszelkie znaki na ekranie i papierze wskazują, iż procedury PI i P2 są równo
ważne...
6.6. Klasyczne schematy derekursywacji
Poznane wcześniej metody eliminacji zmiennych lokalnych z procedur, jak również
ich „deparametryzacja " służyły jednemu istotnemu celowi: jak największemu zbliżeniu sposobu wykonywania procedur rekurencyjnych do typowego programu iteracyjnego. W istocie, czym jest program określany jako „iteracyjny " ? Termin ten
dotyczy zasadniczo systematycznego powtarzania pewnych fragmentów kodu, np,
przy pomocy instrukcji for, while, do... while,. Wywołanie rekurencyjne ma wiele
wspólnego z iteracyjnym sposobem wykonywania programów pod względem ide
owym (chodzi o systematyczne powtarzanie pewnych czynności), bardzo niewiele
jednak ma z nim wspólnego praktycznie. Iteracje są zwykłymi instrukcjami goto11
przeplatanymi badaniem warunków. Wywołania rekurencyjne natomiast znajdują
się co najmniej o poziom11 wyżej. Poprzez usunięcie zmiennych lokalnych i para
metrów funkcji przybliżyliśmy je bardzo do schematu iteracyjnego.
Procedury rekurencyjne posiadają obowiązkowo pewne testy służące do sprowa
dzania procesu wywołań rekurencyjnych do tzw. przypadków elementarnych ,
Przykładowo, obliczając rekurencyjnie silnię z n ciągle, badamy czy w jest rów
ne zeru. Jeśli odpowiedź brzmi tak, procedura zwraca wartość 1 - w przypadku
zaś przeciwnym następuje kolejne wywołanie rekurencyjne. Są to dwie różne
rzeczy - d w a różne fragmenty kodu wykonywane w zależności od spełnienia
lub nie pewnych warunków. Iteracje natomiast, generalnie rzecz ujmując.
10
W rozmaitych wariacjach zależnych od zestawu instrukcji procesora.
11
Abstrakcji, skomplikowania...
12
Jest to wymuszone naturalną chęcią zakończenia kiedyś szeregu wywołań rekurencyjnych!
6.6. Klasyczne schematy derekursywacji
181
wykonują systematycznie pewne stałe fragmenty kodu i to je odróżnia od pro
cedur rekurencyjnych.
Narzucającym się natychmiast rozwiązaniem jest włożenie do części wykonawczej
instrukcji iteracyjnej instrukcji warunkowych sprawiających, iż kod wykony
wany w iteracji numer i będzie - być może - odmienny od kodu iteracji i+1.
Jest to droga, którą pójdziemy w celu odnalezienia sposobu derekursywacji
pewnych schematów, często spotykanych podczas programowania z wykorzy
staniem technik rekurencyjnych.
Uwaga: Wszystkie rozpatrywane dalej schematy dotyczą procedur już
bezparametrowych i pozbawionych zmiennych lokalnych.
6.6.1.Schemat typu while
Kolejnym schematem, z którym będziemy mieli do czynienia, jest:
v o i d P()
{
while(warunek(x))
{
A(x);
P();
B(x);
}
c(x)
;
}
W celu wynalezienia równoważnej formy iteracyjnej zapiszmy procedurę P
w nieco innej postaci z użyciem instrukcji goto. Posunięcie to doprowadzi do
wyeliminowania instrukcji while (w dość sztuczny sposób, to trzeba przyznać).
Wprowadźmy ponadto kolejną globalną zmienną N - używaną już zresztą
wcześniej:
void P()
{
N=0;
start:
if (warunek (x) )
{
A(x);
N++; P; N--;
B(x);
goto s t a r t ;
}
else
c(x);
182
Rozdział 6. Derekursywacja
Jest to forma niewątpliwie równoważna, choć pozornie niewiele z niej narazie
wynika. Przeanalizujmy jednak dokładniej działanie tego programu, starając
odtworzyć sekwencyjny sposób wywoływania grup instrukcji oznaczonych symbolicznie jako A(x), B(x) i C(x).
Widać od razu, iż każdorazowe spełnienie warunku instrukcji if... else spowoduje na
pewno wykonanie A(x) i N++ . Niespełnienie zaś warunku spowoduje jedno
krotne wykonanie C(x). Tyle możemy zaobserwować odnośnie kodu obsługi
wanego przed wywołaniem rekurencyjiiym P.
Co się jednak dzieje podczas wywołań i powrotów rekurencyjnych? Otóż wy.
konywana jest instrukcja B(x). oczywiście wraz z N--. Jeśli teraz zdecydujemy sę
i
na zasymulowanie operacji wywoływania i powrotu rekurencyjnego poprzez odpowiednio - N++ i N--. możliwe jest zaproponowanie następującej równo
ważnej formy procedury P:
int N=0;
void P()
{
do
{
while(warunek (x) )
{
A(x);
N++;
}
C(x);
if(N==0)
goto koniec;
N--;
B(x);
}while{N!=0);
koniec:
}
Czytelnik, którego nie przekonał ten wywód, może znaleźć bardziej ścisły mate
matycznie dowód prawidłowości powyższej transformacji w [Kro89]. Na użytek
tego podręcznika zdecydowałem się jednak na zamieszczenie mniej formalnego
wyjaśnieni;! urn bardziej, że zagłębianie się w dywagacje na temat derekursywacji ma bardzo mało wspólnego z algorytmiką, a bardzo wiele z „dziw
nymi " sztuczkami łatwo prowadzącymi do przyjęcia złego stylu programowania.
6.6.2.Schemat typu if else
ff...
Weźmy pod uwagę schemat rekurencyjny przedstawiony na listingu poniżej:
void P()
{
if(warunek(x))
5.6. Klasyczne schematy derekursywacji
183
P();
B(X) ;
}
else
C(X);
}
Zakładając N-krotne wywołanie procedury P, jej działanie można poglądowo
przedstawić jako sekwencję instrukcji:
Jest to taka forma zapisu algorytmu, która od razu sugeruje możliwe zapisanie
w formie iteracyjnej... pod warunkiem wszakże znajomości N. Niestety, ilość
wywołań procedury P nie jest nigdy znana a priori - wszystko zależy od glo
balnego „parametru " , z którym zostanie ona wywołana!
Nie popadajmy jednak w przedwczesną depresję i spójrzmy na następującą
wersję procedury P'i n t N=0;
v o i d F()
{
if
warunek(x)
{
A(x) ;
N++;P() ; N - - ;
B(x);
}
else
C(X) ;
}
Załóżmy, że wykonanie tego programu zostało przerwane w pewnym losowo
wybranym momencie i za pomocą debuggera odczytaliśmy wartość N. Biorąc
pod uwagę, iż - j a k to wynika z treści programu - zmienna globalna N jest
inkrementowana podczas każdego wywołania rekurencyjnego P i dekremento
wana po powrocie z niego, możemy wykorzystać tę zmienną do odczytywania
aktualnego poziomu rekurencji procedury P13
Idea kolejnej transformacji procedury P jest teraz następująca: wywołanie re
kurencyjne proceduiy P będziemy symulować przy pomocy skoku do jej początku.
Podczas kolejnego wykonania procedury P możemy w bardzo łatwy sposób
13
Patrz §2.3.
184
_
Rozdział 6. Derekursywacja
przetestować, czy wszystkie „zaległe " jej wywołania zostały już ukończone
powie nam o tym wartość N, do której zawsze mamy dostęp wewnątrz P14.
Powyższe uwagi prowadzą natychmiast do kolejnej wersji programu:
int
N=0;
Zapis z użyciem instrukcji goto jest oczywiście w pełni dopuszczalny, jednakże
jedynie wówczas, gdy przemawiają za tym szczególne względy. Nasz prosty
przykład ich nie dostarcza; program ten bowiem może być z łatwością za
mieniony na postać strukturalną.
Poniżej zamieszczone są obie wersje procedury P: oryginalna i tak długo po
szukiwana jej iteracyjna wersja:
void P()
{
if(warunek(x))
{
A(x);
P();
B(x);
}}
else
C(x);
1
14
int N=C;
void P()
{
w h i l e ( w a r u n e k (x))
{
A(x);
N++;
C(X);
while(N--!=0)
B(x);
}
Jeśli N wynosi 0, to wszystkie zaległe wywołania zostały już ukończone.
6.6. Klasyczne schematy derekursywacji
185
Sprawdźmy teraz, czy w istocie podane wyżej przekształcenie działa. W tym celu
powróćmy do programu przykładowego ze strony 179 (zapisanego teraz w nieco
zwięźlejszej postaci).
odwrot2.cpp
6.6.3.Schemat z podwójnym wywołaniem rekurencyjnym
Ostatni omawiany schemat rekurencyjny należy do rzadko spotykanych w praktyce. Ponadto dowód na poprawność transformacji jest dość złożony, dlatego po
niżej przeanalizujemy jedynie gotowy rezultat i omówimy przykład zastosowania
transformacji.
Oto dwie równoważne formy algorytmów:
186
Rozdział
D(x);}
6
Derekursywacja
while((N!=1) & & (N%2))
{
N=N/2;
C(x);
}
if(N==l)
goto koniec;
N=N+1;
B(x);
}while ( p !=1);
}
Dla zilustrowania metody rozważmy jeszcze raz problem wież Hanoi, przed
stawiony na stronie 170.
Mając do dyspozycji metodę funkcji przeciwnych (patrz §6.5) łatwo dojdziemy
do następującej wersji zaproponowanej tam procedury:
void hanoi()
hanoi_it.cpp
{
if (n!=l)
{
n--; b=3-a-b;
hanoi();
n++; b - 3 - a - b ;
cout & lt; & lt; " przesuń dysk nr " & lt; & lt; n & lt; & lt; " z " & lt; & lt; a
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
n--; a=3-a-b;
hanoi ();
n+t; a-3-a-b;
}
else
cout & lt; & lt; " Przesuń dysk nr " & lt; & lt; n & lt; & lt; " z " & lt; & lt; a
& lt; & lt; " na " & lt; & lt; b & lt; & lt; endl;
)
Zauważmy, że instrukcje n++ i n— anulują się wzajemnie, mogą być zatem po
prostu usunięte.
Jeśli poddamy procedure hanoi przeróbce na wersje iteracyjną. powinniśmy
otrzymać:
void hanoi_iter()
{
int
do
M=l;
6.7. Podsumowanie
187
Uruchomienie programu przekonuje, iż obie procedury wykonują dokładnie to
samo zadanie i dają identyczne rezultaty.
6.7. Podsumowanie
Omówione w tym rozdziale techniki derekursywacji algorytmów nie wyczerpują
zestawu dostępnych metod służących do tego celu, jednak prezentują wachlarz
dostatecznie szeroki, aby móc obsłużyć większość najczęściej spotykanych procedur rekurencyjnych. Prezentowane metody mogą ponadto posłużyć jako wzorzec
przy rozwiązywaniu zadań podobnych, ale nie całkowicie zgodnych z omówio
nymi schematami.
Rozdział 7
Algorytmy przeszukiwania
Pojęcie „przeszukiwania " pojawiało się w tej książce już kilka razy w cha
rakterze przykładów i zadań. Tym niemniej jest ono na tyle ważne, iż wymaga
ujęcia w klamry osobnego rozdziału. Aby unikać powtórzeń, tematy już omówione
będą zawierały raczej odnośniki do innych części książki niż pełne omówienia.
Szczegółowej dyskusji zostanie poddana metoda transformacji kluczowej. Z uwagi
na pewną „odmienność " tematu przeszukiwanie tekstów zostało zgrupowane
w rozdziale kolejnym.
7.1. Przeszukiwanie liniowe
Temat przeszukiwania liniowego pojawił się już jako ilustracja pojęcia rekurencji.
Iteracyjna wersja zaproponowanego tam programu jest oczywista - do jej
„wymyślenia " nie jest nawet potrzebna znajomość rozdziału 6. Poniżej przed
stawiony jest przykład przeszukiwania tablicy liczb całkowitych. Oczywiście
metoda ta działa również w nieco bardziej złożonych przypadkach modyfikacji
wymaga jedynie funkcja porównująca x z aktualnie analizowanym elementem.
Jeśli elementami tablicy są rekordy o dość skomplikowanej strukturze, to warto
użyć jednej funkcji szukaj, która otrzymuje jako parametr wskaźnik do funkcji
porównawczej .
int szukaj(int tab [n],int x)
{
for(int i=0;(i & lt; n) & & (tab[i]!=x);i++);
return i;
}
Wskaźniki do funkcji zostały omówione szczegółowo w §5.1
łinear.cpp
190
Rozdział 7. Algorytmy przeszukiwania
Odnalezienie liczby x w tablicy tab jest sygnalizowane poprzez wartość funk
cji; jeśli jest to liczba z przedziału 0... n-1, wówczas jest po prostu indek
sem komórki, w której znajduje się x. W przypadku zwrotu liczby n jesteśmy
informowani. iż element x nie został znaleziony. Zasada obliczania wyrażeń logicz
nych w C++ gwarantuje nam, że podczas analizy wyrażenia (i & lt; n) & &
(łab[i]!=x) w momencie stwierdzenia fałszu pierwszego czynnika iloczynu lo
gicznego reszta wyrażenia - jako nie mająca znaczenia - nie będzie już
sprawdzana. W konsekwencji nie będzie badana wartość spoza zakresu dozwolo
nych indeksów tablicy, co jest tym cenniejsze, iż kompilator C++ w żaden spo
sób o tego typu przeoczeniu by nas nie poinformował.
W tym miejscu wypada jeszcze uściślić, że ten typ przeszukiwania, polegający na
zwykłym sprawdzaniu elementu po elemencie, jest metodą bardzo wolno dzia
łającą. Winna być ona stosowana jedynie wówczas, gdy nie posiadamy żadnej
informacji na leniał struktury przeszukiwanych danych, ewentualnie sposobu
ich składowania w pamięci. Jest oczywiste, iż dowolny algorytm przeszukiwania
liniowego jest klasy O(n)!
7.2. Przeszukiwanie binarne
Jak już zostało zauważone w paragrafie poprzednim, ewentualna informacja na
temat sposobu składowania danych może być niesłychanie użyteczna podczas
przeszukiwania. W istocie często mamy do czynienia z uporządkowanymi już
w pamięci komputera zbiorami danych: np. rekordami posortowanymi alfabe
tycznie, według niemalejących wartości pewnego pola rekordu etc. Zakładamy
zatem, że tablica jest posortowana, ale jest to dość częsty przypadek w prak
tyce, bowiem człowiek lubi mieć do czynienia z informacją uporządkowana.
W takim przypadku można skorzystać z naszej „meta-wiedzy " w celu usprawnie
nia przeszukiwania danych. Łatwo możemy bowiem wyeliminować z poszu
kiwań te obszary tablicy, w których element .v na pewno nie może się znaleźć.
Dokładnie omówiony przykład poszukiwania binarnego znalazł się juz w rozdziale
2 - patrz zad. 2-2 i jego rozwiązanie. W tym miejscu możemy dla odmiany po
dać iteracyjną wersję algorytmu:
binary-i.cpp
int szukaj (int tab[],int x)
{
enum {TAK,NIE} Znalnzlem=NIE;
i n t left=0, right:=n-l,mid;
while(left & lt; =right & & Znalazłem!=TAK)
{
mid=(Ieft+right)/2;
if(tab[mid]==x)
11. Przeszukiwanie binarne
else
191
Znalazlem=TAK;
i f (tab[mid] & lt; x)
left-mid+1;
else
right=mid-l;
}
}
if (Zna lazlem==TAK)
return mid;
else return - 1 ;
Nazwy i znaczenie zmiennych są dokładnie takie same. jak we wspomnianym
zadaniu, dlatego warto tam zerknąć choć raz dla porównania. Pewnej dyskusji
wymaga problem wyboru elementu „środkowego " (mid). W naszych przykła
dach jest to dosłownie środek aktualnie rozpatrywanego obszaru poszukiwań.
W rzeczywistości jednak może nim być oczywiście dowolny indeks pomiędzy
left i right! Nietrudno jednak zauważyć, że ..przepoławianie " tablicy zapewnia
nam eliminację największego możliwego obszaru poszukiwań. Ich niepowo
dzenie jest sygnalizowane przez zwrot wartości -1. W przypadku sukcesu zwra
cany jest „tradycyjnie " indeks elementu w tablicy.
Przeszukiwanie binarne jest algorytmem klasy O(log 2 N) (patrz Rozkład
„logarytmiczny " ). Dla dokładnego uświadomienia sobie jego zalet weźmy pod
uwagę konkretny przykład numeryczny:
Przeszukiwanie liniowe pozwala w czasie proporcjonalnym do rozmiaru
tablicy (listy) odpowiedzieć na pytanie, czy element x się w niej znajduje.
Zatem dla tablicy o rozmiarze 20,000 należałoby w najgorszym przypadku
wykonać 20,000 porównań, aby odpowiedzieć na postawione pytanie.
Analogiczny wynik dla przeszukiwania binarnego wynosi log 2 20000 (ok.
14 porównań).
Nic tak dobrze nie przemawia do wyobraźni, jak dobrze dobrany przykład
liczbowy, a powyższy na pewno do takich należy...
7.3.Transformacja kluczowa
Zanim powiemy choćby słowo na temat transformacji kluczowej, musimy spre
cyzować dokładnie dziedzinę zastosowań tej metody. Otóż jest ona używana,
192
Rozdział 7. Algorytmy przeszukiwania
gdy maksymalna ilość elementów należących do pewnej dziedziny2 R jest z góry
znana ( E m a x ) , natomiast wszystkich możliwych (różnych) elementów tej dzie
dziny mogłoby być potencjalnie bardzo dużo (C) Tak dużo, że o ile przydział
pamięci na tablicę o rozmiarze E m a x jest w praktyce możliwy, o tyle przydział
tablicy dla wszystkich potencjalnych C elementów dziedziny R byłby fizycznie
niewykonalny3.
Ponieważ poprzednie zdanie brzmi makabrycznie, być może warto jest podać
ilustrujący je przykład;
• chcemy zapamiętać R m a x = 250 słów o rozmiarze 10 (tablica o rozmiarze 250*11=2750 bajtów jest w pełni akceptowalna4;
• wszystkich możliwych słów jest C = 2610 (nie licząc w ogóle polskich
znaków!). Praktycznie niemożliwe jest przydzielenie pamięci na tablicę,
która mogłaby je wszystkie pomieścić...
Idea transformacji kluczowej polega na próbie odnalezienia takiej funkcji H,
która otrzymując w parametrze pewien zbiór danych, podałaby nam indeks
w tablicy T, gdzie owe dane znajdowałyby się... gdyby je tam wcześniej zapa
miętano!
Inaczej rzecz ujmując; transformacja kluczowa polega na odwzorowaniu:
dane -- & gt; adres komórki w pamięci.
Zakładając taką organizację danych, położenie nowego rekordu w pamięci teo
retycznie nie powinno zależeć od położenia rekordów już wcześniej zapamiętanych.
Jak zapewne pamiętamy z rozdziału 5, nie był to przypadek list posortowanych,
drzew binarnych, steny...
2
„Dziedziny " w sensie matematycznym.
3
4
A nawet jeśli tak, to zdrowy rozsądek zatrzymałby nas przed jego realizacją!
Jeden dodatkowy bajt na znak '\0' kończący ciąg tekstowy w C++.
7.3. Transformacja kluczowa
193
Naturalną konsekwencją nowego sposobu zapamiętywania danych jest maksy
malne uproszczenie procesu poszukiwania. Poszukując bowiem elementu x cha
rakteryzowanego przez pewien klucz 1 v możemy się posłużyć znaną nam funk
cją H. Obliczenie H(v) powinno zwrócić nam pewien adres pamięci, pod któ
rym należy sprawdzić istnienie x, i do tego w sumie sprowadza się cały proces
poszukiwania!
Mamy tu zatem do czynienia z zupełnym porzuceniem jakiegokolwiek procesu
przeszukiwania zbioru danych: znając funkcję H, automatycznie możemy okre
ślić położenie dowolnego elementu w pamięci - wiemy również od razu, gdzie
prowadzić ewentualne poszukiwania.
Czytelnik ma prawo w tym miejscu zadać sobie pytanie: to po co w takim razie
męczyć się z listami, drzewami czy też innymi strukturami danych, jeśli można
używać transformacji kluczowej? Jest to bardzo dobre pytanie, niestety odpo
wiedź na nie jest możliwa w jednym zdaniu. Wstępnie możemy tu podać dwa
istotne powody ograniczające użycie lej metody:
•
ograniczenia pamięci (trzeba
Emax elementów,
z góry
zarezerwować
•
tablicę
T na
trudności w odnalezieniu dobrej funkcji H.
O ile pierwszy powód jest oczywisty, to sprecyzowanie, czym jest dobra funk
cja H, wymaga osobnego paragrafu.
7.3.1.W poszukiwaniu funkcji H
Funkcja H na podstawie klucza v dostarcza indeks w tablicy T, służącej do
przechowywania danych. Potencjalnych funkcji, które na podstawie wartości
danego klucza v zwrócą pewien adres adr, jest jak się zapewne domyślamy mnóstwo. Parametry, które mają główny wpływ na stopień skomplikowania
funkcji H, to: długość tablicy, w której zamierzamy składować rekordy danych,
oraz bez wątpienia wartość klucza v. Przed zamierzonym przystąpieniem do
klawiatury, aby wklepać naprędce wymyśloną funkcję H, warto się dobrze za
stanowić, które atrybuty rekordu danych zostaną wybrane do reprezentowania
klucza. Logicznym wymogiem jest posiadanie przez tą funkcję dwóch własności:
Pojęcie „klucza " pochodzi z teorii baz danych i jest dość powszechnie używane w
informatyce; „kluczem " określa się zbiór atrybutów, które jednoznacznie identyfikują
rekord (nie ma dwóch różnych rekordów posiadających taką samą wartość atrybutów
kluczowych).
194
Rozdział 7. Algorytmy przeszukiwania
• powinna być łatwo obliczalna, tak aby ciężaru przeszukiwania zbioru
danych
nie
przenosić
na
czasochłonne wyliczanie
H(v);
• różnym wartościom klucza v powinny odpowiadać odmienne indeksy w
tablicy T, tak aby nie powodować kolizji dostępu (np. elementy powin
ny być rozkładane w tablicy T równomiernie.
Pierwszy punkt nie wymaga komentarza, do drugiego zaś jeszcze powrócimy,
gdyż porusza on bardzo ważny problem. W następnym paragrafie poznamy ty
powe metody konstruowania funkcji H. W rzeczywistych aplikacjach stosuje
się przeróżne kombinacje cytowanych tam funkcji i w zasadzie nie można tu
podać reguł postępowania! Transformacja kluczowa jest bardzo silnie związana
z aplikacją końcową i często etap uruchamiania może znacznie się wydłużać.
7.3.2.Najbardziej znane funkcje H
Najwyższa już pora zaprezentować kilka typowych funkcji matematycznych
używanych do konstruowania funkcji stosowanych w transformacji kluczowej. Są
to metody w miarę proste, jednak samodzielnie niewystarczające - w praktyce
stosuje się raczej ich kombinacje niż każdą z nich osobno. Czytelnik, który z po
jęciem transformacji kluczowej spotyka się po raz pierwszy, ma prawo być nieco
zbulwersowany poniższymi propozycjami (modulo, mnożenie etc). Brakuje tu
bowiem pewnej " naukowej " metody: nic nie jest do końca zdeterminowane, pro
gramista może w zasadzie wybrać, co mu się żywnie podoba, a algorytmy poszu
kiwania/wsławiania danych będą i tak działały. W dalszych przykładach będzie
my zakładać, że „klucze " są ciągami znaków, które można łączyć ze sobą i dość
dowolnie interpretować jako liczby całkowite. Każdy znak alfabetu będziemy dla
uproszczenia obliczeń w naszych przykładach kodować przy pomocy 5 bitów
(patrz tablica 7 -1) - wybór kodu nie jest niczym zdeterminowany.
Tabela 7 - A
Kodowanie
liter przy pomo
cy 5 bitów.
A-00001
B-00010
c=00011 D-00100
E-00101
F-00110
H=01000
I-01001
J-01010
K=01011
L-01100
M-01101 N-01110
O-01111
P-10000
Q-10001
R-10010
S-10011
T- 10100 U-10101
V = 10110
W-10111
X-11000
Y=11001
Z-11010
G-00111
Wspomniany wyżej " brak metody " jest na szczęście pozorny. Wiele podręczni
ków algorytmiki błędnie prezentuje transformację kluczową, koncentrując się
na tym JAK, a nie omawiając szczegółowo PO CO chcemy w ogóle wykony
wać operacje arytmetyczne na zakodowanych kluczach. Tymczasem sprawa
jest względnie prosta:
•
kodowanie jest wykonywane w celu zamiany wartości klucza (nieko
niecznie numerycznej!) na liczbę; sam kod jest nieistotny, ważne jest
7.3. Transformacja kluczowa
195
tylko, aby jako wynik otrzymać pewną liczbę, którą można później sto
sować w obliczeniach;
•
Naszym celem jest możliwie jak najbardziej losowe „rozsianie " rekordów po tablicy wielkości M funkcja H ma nam dostarczyć w zależności od argumentu v adresy od 0 do M-I. Cały problem polega na tym, że
nie jest możliwe uzyskanie losowego rozrzutu elementów, dysponując
danymi wejściowymi, które z założenia nie są losowe. Musimy zatem
uczynić coś, aby ową „losowość " w jakiś sposób dobrze zasymulować.
Badanie praktyczne dokonywane na dużych zestawach danych wejściowych
wykazały, że istnieje grupa prostych funkcji arytmetycznych (modulo, mnożenie,
dzielenie), które dość dobrze się do tego celu nadają. Omówimy je kolejno
w kilku paragrafach.
Przykład:
Zalety:
• funkcja H łatwa do obliczenia; suma modulo 2, w przeciwieństwie do
iloczynu i sumy logicznej, nie powiększa (jak to czyni suma logiczna)
lub pomniejsza (jak iloczyn) swoich argumentów.
• Używanie operatorów & i | powoduje akumulację danych odpowiednio na
początku i na końcu tablicy T, czyli jej potencjalna pojemność nie jest
efektywnie wykorzystywana.
Wady:
• permutacje tych samych liter dają w efekcie identyczny wynik - można
jednak temu zaradzić poprzez systematyczne przesuwanie cykliczne re
prezentacji bitowej: pierwszy znak o jeden bit w prawo, drugi znak o
dwa bity w prawo etc.
196
Rozdział 7. Algorytmy przeszukiwania
dzielenie modulo
Rmax:
H(v) = v%Rmax
Przykład:
D l a
R max = 37H(„KOT " )=(0101l 01110 10100)2 % ( 3 7 ) 0 = (11732)10=3.
Zalety:
•
funkcja H łatwa do obliczenia.
Wady:
• otrzymana wartość zależy
dość paradoksalnie - bardziej od Rmax niż od
klucza!
Przykładowo gdy Rmax jest parzyste, na pewno wszystkie otrzymane indeksy
danych o kluczach parzystych będą również parzyste, ponadto dla pew
nych dzielników wiele danych otrzyma ten sam indeks... Można temu czę
ściowo zaradzić poprzez wybór Rmax jako liczby pierwszej, ale tu znowu
będziemy mieli do czynienia z akumulacją elementów w pewnym obsza
rze tablicy - a wcześniej wyraźnie zażyczyliśmy sobie, aby funkcja H roz
dzielała indeksy „sprawiedliwie " po całej tablicy!
• w przypadku dużych liczb binarnych nie mieszczących się w reprezen
tacji wewnętrznej komputera, obliczenie modulo już nie jest możliwe
przez zwykłe dzielenie arytmetyczne.
Co się tyczy ostatniej wady, to prostym rozwiązaniem dla ciągów tekstowych
w C++ („wewnętrznie " są to przecież zwykłe ciągi bajtów!) jest następująca
funkcja, bazująca na interpretacji tekstu jako szeregu cyfr 8-bitowych:
i n t H ( c h a r *s, i n t Rmax)
{
f o r ( i n t tmp=0; *s!=NULL; s++-)
t m p = ( 6 4 * t m p + ( * s ) )% Rmax;
r e t u r n tmp;
}
Powyższą formułę należy odczytywać następująco: klucz v jest mnożony przez
pewną liczbę o z przedziału otwartego (0,1). Z wyniku bierzemy część ułam
kową, mnożymy przez E
i ze wszystkiego liczymy część całkowitą.
7.3.3.Obsługa konfliktów dostępu
Kilka prostych eksperymentów przeprowadzonych z funkcjami zaprezen
towanymi w poprzednim paragrafie prowadzi do szybkiego rozczarowania.
Spostrzegamy, iż nie spełniają one założonych własności, co może łatwo skło
nić do zwątpienia w sens całej prezentacji. Cóż, prawda należy do złożonych. Z
jednej strony widzimy już, że idealne funkcje H nie istnieją, z drugiej zaś
strony dziwnym byłoby zaczynać dyskusję o transformacji kluczowej i dopro
wadzić ją do stwierdzenia, że... jej realizacja nie jest możliwa praktycznie!
Oczywiście nie jest aż tak źle. Istnieje kilka metod, które pozwalają poradzić
sobie w zadowalający sposób z zauważonymi niedoskonałościami, i one wła
śnie będą stanowić przedmiot naszych dalszych rozważań.
Powrót do źródeł
Co robić w przypadku stwierdzenia kolizji dwóch odmiennych rekordów, którym
funkcja H przydzieliła ten sam indeks w tablicy T? Okazuje się, że można sobie
poradzić poprzez pewną zmianę w samej filozofii transformacji kluczowej.
Otóż, jeśli umówimy się, że w tablicy T zamiast rekordów będziemy zapamiętywać głowy list do elementów charakteryzujących się tym samym kluczem,
wówczas problem mamy... z głowy! Istotnie, jeśli wstawiając element x do tablicy
- Programowo można otrzymać tę wartość przy pomocy instrukcji (int)(fmod(11732*
0.61803398887,1)*30); ponadto należy na początku programu dopisać
#include & lt; math.h & gt;
3
Daje się to nawet uzasadnić teoretycznie (patrz np. dobrze znany w statystyce tzw. paradoks
urodzin).
198
Rozdział 7. Algorytmy przeszukiwania
pod indeks m, stwierdzimy, że już wcześniej ktoś się tam „zameldował " , wystarczy doczepić * na koniec listy, której głowa jest zapamiętana w T[m],
Analogicznie działa poszukiwanie: szukamy elementu v i H(x) zwraca nam pewien
indeks m. W przypadku, gdy T[m] zawiera NULL, możemy być pewni, że szu
kanego elementu nie odnaleźliśmy - w odwrotnej sytuacji, aby się ostatecznie
upewnić, wystarczy przeszukać listę T[m]. (Warto przy okazji zauważyć, że listy
będą na ogół bardzo krótkie).
Opisany powyżej sposób jest zilustrowany na rysunku 7 - 1 .
Obrazuje on sytuację powstałą po sukcesywnym wstawianiu do tablicy T rekordów
A, B C, D, E, F i G, którym funkcja H przydzieliła adresy (indeksy): 1. i, 2, 5,
3, I i 0. Indeksy tablicy, pod którymi nie ukrywają się żadne rekordy danych, są
zainicjowane wartością NULL - patrz np. komórki 4 i 6. Na pozycji 1 mamy do
czynienia z konfliktem dostępu: rekordy A i F otrzymały ten sam adres!
Odpowiednia funkcja wstaw (którą musimy przewidująco napisać!) wykrywa tę
sytuację i wstawia element F na koniec listy T[I].
Rys. 7 - 1.
Użycie list
do obsługi kon
fliktów dostępu.
Obrazuje on sytuację powstałą po sukcesywnym wstawianiu do tablicy T re
kordów A, B, C, D, E, F i G, którym funkcja H przydzieliła adresy (indeksy):
i, 2, 5, 3, 1 i 0. Indeksy tablicy, pod którymi nic ukrywają się żadne rekordy
danych, są zainicjowane wartością N U L L - patrz np. komórki 4 i 6. Na pozycji
mamy do czynienia z konfliktem dostępu: rekordy A i F otrzymały ten sam adres!
Odpowiednia funkcja wstaw (którą musimy przewidująco napisać!) wykrywa tę
sytuację i wstawia element F na koniec listy T[1].
Podobna sytuacja dotyczy rekordów B i E. Proces poszukiwania elementów jest
zbliżony do ich wstawiania - Czytelnik nie powinien mieć trudności z dokład
nym odtworzeniem sposobu przeszukiwania tablicy T w celu odpowiedzi na
pytanie, czy został w niej zapamiętany dany rekord, np. E.
7.3. Transformacja kluczowa
199
Co jest niepokojące w zaproponowanej powyżej metodzie? Zaprezentowana
wcześniej idea transformacji kluczowej zawiera zachęcającą obietnicę porzucenia
wszelkich list. drzew i innych skomplikowanych w obsłudze struktur danych na
rzecz zwykłego odwzorowania :
dane -- & gt; adres komórki w pamięci
Podczas dokładniejszej analizy napotkaliśmy jednak mały problem i... powró
ciliśmy do „starych, dobrych list " . Z tych właśnie przyczyn rozwiązanie to
można ze spokojnym sumieniem uznać za nieco sztuczne4 - równie dobrze
można było trzymać się list i innych dynamicznych struktur danych, bez wpro
wadzania do nich dodatkowo elementów transformacji kluczowej! Czy możemy
w tej sytuacji mieć nadzieję na rozwiązanie problemów dotyczących kolizji do
stępu? Zainteresowanych odpowiedzią na to pytanie zachęcam do lektury na
stępnych paragrafów.
Jeszcze raz tablice!
Metoda transformacji kluczowej została z założenia przypisana aplikacjom, które
pozwalając przewidzieć maksymalną ilość rekordów do zapamiętania, umożli
wiają zarezerwowanie pamięci na statyczną tablicę stwarzającą łatwy, indek
sowany dostęp do nich. Jeśli możemy zarezerwować tablicę na wszystkie ele
menty, które chcemy zapamiętać, może by jej część przeznaczyć na obsługę
konfliktów dostępu?
Idea polegałaby na podziale tablicy T na dwie części: strefę podstawową i strefę
przepełnienia. Do tej drugiej elementy trafiałyby w momencie stwierdzenia
braku miejsca w części podstawowej. Strefa przepełnienia wypełniana byłaby
liniowo wraz z napływem nowych elementów „kolizyjnych " . W celu ilustracji
nowego pomysłu spróbujmy wykorzystać dane z rysunku 7 - 1 . zakładając
rozmiar stref: podstawowej i przepełnienia na odpowiednio: 6 i 4.
Efekt wypełnienia tablicy jest przedstawiony na rysunku 7 - 2 .
Rys. 7 - 2.
Podział tablicy do
obsługi konfliktów
dostępu.
strefa
4
podstawowa
Choć parametry „czasowe " tej metody są bardzo korzystne.
200
Rozdział 7, Algorytmy przeszukiwania
Rekordy E i F zostały zapamiętane w momencie stwierdzenia przepełnienia na
kolejnych pozycjach 6 i 7. Sugeruje to, że gdzieś „w tle " musi istnieć zmienna
zapamiętująca ostatnią wolną pozycję strefy przepełnienia.
Również w jakiś sposób należy się umówić, co do oznaczania pozycji wolnych
w strefie podstawowej - to już leży w gestii programisty i zależy silnie od
struktury rekordów, które będą zapamiętywane.
Rozwiązanie uwzględniające podział tablic nie należy do skomplikowanych, co
jest jego niewątpliwą zaletą. Stworzenie funkcji wstaw i szukaj jest kwestią kilku
minut i zostaje powierzone Czytelnikowi jako proste ćwiczenie.
Dla ścisłości należy jednak wskazać pewien słaby punkt. Otóż nie jest zbyt
oczywiste, co należy zrobić w przypadku zapełnienia strefy... przepełnienia!
(Wypisanie „ładnego " komunikatu o błędzie nie likwiduje problemu). Użycie lej
metody powinno być poprzedzone szczególnie starannym obliczeniem rozmiarów
tablic, tak aby nie załamać aplikacji w najbardziej niekorzystnym momencie
- na przykład przed zapisem danych na dysk.
Próbkowanie liniowe
W opisanej poprzednio metodzie w sposób nieco sztuczny rozwiązaliśmy
problem konfliktów dostępu w tablicy T. Podzieliliśmy ją mianowicie na dwie
części służące do zapamiętywania rekordów, ale w różnych sytuacjach. O ile jed
nak dobór ogólnego rozmiaru tablicy Rmax jest w wielu aplikacjach łatwy do
przewidzenia, to dobranie właściwego rozmiaru strefy przepełnienia jest w praktyce
bardzo trudne. Ważną rolę grają tu bowiem zarówno dane, jak i funkcja H i w zasa
dzie należałoby je analizować jednocześnie, aby w przybliżony sposób oszaco
wać właściwe rozmiary obu części tablic. Problem oczywiście znika samoczynnie,
gdy dysponujemy bardzo dużą ilością wolnej pamięci, jednak przewidywanie
a priori takiego przypadku mogłoby być dość niebezpieczne.
Jak zauważyliśmy wcześniej, konflikty dostępu są w metodzie transformacji
kluczowej nieuchronne. Powód jest prosty: nie istnieje idealna funkcja H, która
rozmieściłaby równomiernie wszystkie Rmax elementów po całej tablicy T. Jeśli
taka jest rzeczywistość, to może zamiast walczyć z nią-jak to usiłowały czynić
poprzednie metody - spróbować się do niej dopasować?
Idea jest następująca: w momencie zapisu nowego rekordu do tablicy, w przy
padku stwierdzenia konfliktu możemy spróbować zapisać element na pierwsze
kolejne wolne miejsce. Algorytm funkcji wstaw byłby wówczas następujący
(zakładamy próbę zapisu do tablicy T rekordu x charakteryzowanego kluczem v):
s
Oczywiście może to być również dowolna zmienna prosta!
7.3. Transformacja kluczowa
201
Załóżmy teraz, że poszukujemy elementu charakteryzującego się kluczem k.
W takim przypadku funkcja szukaj mogłaby wyglądać następująco:
Różnica pomiędzy poszukiwaniem i wstawianiem jest w przypadku transformacji
kluczowej doprawdy nieznaczna. Algorytmy są celowo zapisane w pseudokodzie,
bowiem sensowny przykład korzystający z tej metody musiałby zawierać do
kładne deklaracje typu danych, tablicy, funkcji H, wartości specjalnej
WOLNE - analiza tego byłaby bardzo nużąca. Instrukcja pos = (pos+i) %
Rmax zapewnia nam powrót do początku tablicy w momencie dotarcia do jej
końca podczas (kolejnych) iteracji pętli while.
Dla ilustracji spójrzmy, jak poradzi sobie nowa metoda przy próbie sukcesyw
nego wstawienia do tablicy T rekordów A, B, C, D. E, F, G i H którym funkcja
H przydzieliła adresy (indeksy): 1, 3, 2, 5, 3. 1, 7 i 7. Ustalmy ponadto roz
miar tablicy T na 8 - wyłącznie w ramach przykładu, bowiem w praktyce taka
wartość nie miałaby zbytniego sensu. Efekt jest przedstawiony na rysunku 7 - 3 :
Rys. 7 - 3.
Obsługa konflik
tów dostępu przez
próbkowanie li
niowe.
Dość ciekawymi jawią się teoretyczne wyliczenia średniej ilości prób potrzebnej
do odnalezienia danej x. W przypadku poszukiwania zakończonego sukcesem
średnia liczba prób wynosi około:
202
Rozdział 7. Algorytmy przeszukiwania
gdzie a jest współczynnikiem zapełnienia tablicy T. Analogiczny wynik dla p
o
szukiwania zakończonego niepowodzeniem wynosi około:
Przykładowo dla tablicy zapełnionej w dwóch trzecich swojej pojemności
liczby te wyniosą odpowiednio: 2 i 5.
W praktyce należy unikać szczelnego zapełniania tablicy T, gdyż zacytowane
powyżej liczby stają się bardzo duże (or nie powinno przybierać wartości bli
skich 1). Powyższe wzory zostały wyprowadzone przy założeniu funkcji H, któ
ra rozsiewa równomiernie elementy po dużej tablicy T. Te zastrzeżenia są tu
bardzo istotne, bowiem podane wyżej rezultaty mają charakter statystyczny.
Podwójne kluczowanie
Stosowanie próbkowania liniowego prowadzi do niekorzystnego liniowego
zapełniania tablicy T. co kłóci się z wymogiem narzuconym wcześniej funkcji H
(patrz §7.3.1). Intuicyjnie rozwiązanie tego problemu nie wydaje się trudne:
trzeba uczynić coś, aby nieco bardziej losowo „porozrzucać1' elementy. Prób
kowanie liniowe nie było z tego względu dobrym pomysłem, gdyż napotkawszy
pewien zapełniony obszar tablicy T, proponowało wstawienie nowego elementu
tuż za nim -jeszcze go powiększając! Czytelnik mógłby zadać pytanie: a dla
czego jest to aż takie groźne? Oczywiście względy estetyczne nie grają tu żadnej
roli: zauważmy, że liniowo zapełniony obszar przeszkadza w szybkim znalezieniu
wolnego miejsca na wstawienie nowego elementu! Fenomen ten utrudnia rów
nież sprawne poszukiwanie danych.
Rozpatrzmy prosty przykład przedstawiony na rysunku 7 - 4 .
Rys. 7 - 4.
Utrudnione
poszukiwanie
danych przy
próbkowaniu li
niowym.
start
koniec
T
Na rysunku tym zacieniowane komórki tablicy oznaczają miejsca już zajęte.
Funkcja H(k) dostarczyła pewien indeks, od którego zaczyna się przeszukiwana
strefa tablicy (poszukujemy oczywiście pewnego elementu charakteryzują
cego się kluczem k) - powiedzmy, że zaczynamy poszukiwanie od indeksu
7.3. Transformacja kluczowa
203
oznaczonego symbolicznie jako START. Proces poszukiwania zakończy się
sukcesem w przypadku „trafienia " w poszukiwany rekord - aby to stwierdzić,
czynimy dość kosztowne6 porównanie T[pos].v!=k (patrz algorytm procedury
szukaj ze strony 201).
Co więcej, wykonujemy je za każdym razem podczas przesuwania się po liniowo
wypełnionej strefie! Informację o ewentualnej porażce poszukiwań dostajemy
dopiero po jej całkowitym sprawdzeniu i natrafieniu na pierwsze wolne miej
sce. W naszym rysunkowym przykładzie dopiero po siedmiu porównaniach algo
rytm natrafi na pustą komórkę (oznaczoną etykietą KONIEC), która poinformuje
go o daremności podjętego uprzednio wysiłku... Gdyby zaś tablica była zapeł
niona w mniej liniowy sposób, statystycznie o wiele szybciej natrafilibyśmy na
WOLNE miejsce, co automatycznie zakończyłoby proces poszukiwania zakoń
czonego porażką.
Na szczęście istnieje łatwy sposób uniknięcia liniowego grupowania elemen
tów: tzw. podwójne kluczowanie. Podczas napotkania kolizji następuje próba
„rozrzucenia " elementów przy pomocy drugiej, pomocniczej funkcji H
Procedura wstaw pozostaje niemal niezmieniona:
int pos =H1(x.v);
int krok=H2(x.v);
while (T[pos] != WOLNE)
pos = (pos+krok) & Rmax;
T[pos]=x;
Procedura poszukiwania jest bardzo podobna i Czytelnik z pewnością będzie
w stanieją napisać samodzielnie, wzorując się na przykładzie poprzednim.
Przedyskutujmy teraz problem doboru funkcji H2. Nie trudno się domyślić, i? ma
ona duży wpływ na jakość procesu wstawiania (i oczywiście poszukiwania!).
Przede wszystkim funkcja H2 powinna być różna od H2!W przeciwnym wypadku
doprowadzilibyśmy tylko do bardziej skomplikowanego tworzenia stref „ciągłych "
- a właśnie od tego chcemy uciec... Kolejny wymóg jest oczywisty: musi być to
funkcja prosta, która nie spowolni nam procesu poszukiwania/wstawiania.
Przykładem takiej prostej i jednocześnie skutecznej w praktyce funkcji może być
H2(k)=8-(k%8): zakres skoku jest dość szeroki, a prostota niezaprzeczalna!
Metoda podwójnego kluczowania jest interesująca z uwagi na widoczny zysk
w szybkości poszukiwania danych. Popatrzmy na teoretyczne rezultaty wyli
czeń średniej ilości prób przy poszukiwaniu zakończonym sukcesem i porażką.
W przypadku poszukiwania zakończonego sukcesem średnia liczba prób
wynosi około:
Koszt operacji porównania zależy od stopnia złożoności klucza, tzn. od ilości i typów
pól rekordu, które go tworzą.
204
Rozdział 7. Algorytmy przeszukiwania
(gdzie a jest, tak jak poprzednio, współczynnikiem zapełnienia tablicy T.
Analogiczny wynik dla poszukiwania zakończonego niepowodzeniem wynosi
około:
7.3.4.Zastosowania transformacji kluczowej
Dotychczas obracaliśmy się wyłącznie w kręgu elementarnych przykładów; tablice
o małych rozmiarach, proste klucze znakowe lub liczbowe.., Rzeczywiste aplikacje
mogą. być oczywiście znacznie bardziej skomplikowane i dopiero wówczas
Czytelnik będzie mógł w pełni docenić wartość posiadanej wiedzy. Zastosowania
transformacji kluczowej mogą być dość nieoczekiwane: dane wcale nie muszą
znajdować się w pamięci głównej; w przypadku programu bazy danych moż
na w dość łatwy sposób użyć H-kodu do sprawnego odszukiwania danych.
Konstruując duży kompilator/linker, możliwe jest wykorzystanie metod transformacji kluczowej do odszukiwania skompilowanych modułów w dużych pli
kach bibliotecznych.
7.3.5.Podsumowanie metod transformacji kluczowej
Transformacja kluczowa poddaje się dobrze badaniom porównawczym otrzymywane wyniki są wiarygodne i intuicyjnie zgodne z rzeczywistością.
Niestety sposób ich wyprowadzenia jest skomplikowany i ze względów czysto
humanitarnych zostanie tu opuszczony. Tym niemniej ogólne wnioski o charakterze praktycznym są warte zacytowania:
•
przy słabym wypełnieniu 7 tablicy T wszystkie metody są w przybliżeniu
tak samo efektywne;
•
metoda próbkowania liniowego doskonale sprawdza się przy dużych,
słabo wykorzystanych tablicach T (czyli wówczas, gdy dysponujemy
dużą ilością wolnej pamięci). Za j e j stosowaniem przemawia również
niewątpliwa prostota.
Na koniec warto podkreślić coś, o czym w ferworze prezentacji rozmaitych
metod i dyskusji mogliśmy łatwo zapomnieć: transformacja kluczowa jest
narzędziem wprost idealnym... ale tylko w przypadku obsługi danych, których
7
Tzn. do ok. 30-40 % całkowitej objętości tablicy.
7 3. Transformacja kluczowa
205
liczba jest z dużym prawdopodobieństwem przewidywalna. Nic możemy sobie
bowiem pozwolić na „załamanie się " aplikacji z powodu naszych zbyt nieostroż
nych oszacowań rozmiarów tablic!
Przykładowo wiedząc, że będziemy mieli do czynienia ze zbiorem rekordów
w liczbie ustalonej na przykład na 700, deklarujemy tablicę T o rozmiarze
1000, co zagwarantuje nam szybkie poszukiwanie i wstawianie danych nawet przy
zapisie wszystkich 700 rekordów. Wypełnienie tablicy w 70-80 % okazuje się tą
magiczną granicą, za którą sens stosowania transformacji kluczowej staje się
coraz mniej widoczny - dlatego po prostu nie warto zbytnio się do niej zbliżać.
Niemniej metoda jest ciekawa i warta stosowania - oczywiście uwzględniwszy
kontekst praktyczny aplikacji końcowej.
Rozdział 8
Przeszukiwanie tekstów
Zanim na dobre zanurzymy się w lekturę nowego rozdziału, należy wyjaśnić
pewne nieporozumienie, które może towarzyszyć jego tytułowi. Otóż za tekst
będziemy uważali ciąg znaków w sensie informatycznym. Nie zawsze będzie to miało
cokolwiek wspólnego z ludzką „pisaniną " ! Tekstem będzie na przykład również
ciąg bitów 1 , który tylko przez umowność może być podzielony na równej wielkości
porcje, którym przyporządkowano pewien kod liczbowy " .
Okazuje się wszelako, że przyjęcie konwencji dotyczących interpretacji informacji
ułatwia wiele operacji na niej. Dlatego też pozostańmy przy ogólnikowym stwier
dzeniu „tekst " wiedząc, że za określeniem tym może się kryć dość sporo znaczeń.
1.1. Algorytm typu brute-force
Zadaniem, które będziemy usiłowali wspólnie rozwiązać, jest poszukiwanie
wzorca 3 w o długości M znaków w tekście t o długości N. Z łatwością możemy
zaproponować dość oczywisty algorytm rozwiązujący to zadanie bazując na
„pomysłach " symbolicznie przedstawionych na rysunku 8 - 1 .
Zarezerwujmy indeksy j i i do poruszania się odpowiednio we wzorcu i tekście
podczas operacji porównywania znak po znaku zgodności wzorca z tekstem. Załóżmy, że w trakcie poszukiwań obszary objęte szarym kolorem na rysunku
okazały się zgodne. Po stwierdzeniu tego faktu przesuwamy się zarówno we
wzorcu, jak i w tekście o jedną pozycję do przodu ( i + + ; j + + ) .
1
Reprezentujący np. pamięć ekranu.
2
Np. ASCII lub dowolny inny.
3
Ang. pattern matching.
208
Rozdział 8. Przeszukiwanie tekstów
Rys. 8 - 1.
Algorytm typu
brute-force prze
szukiwania tekstu.
wzorzec w
Badany tekst t
Cóż się jednak powinno stać z indeksami i oraz j podczas stwierdzenia nie
zgodności znaków? W takiej sytuacji cale poszukiwanie kończy się porażką, co
musza nas do anulowania „szarej strefy " zgodności. Czynimy to poprzez cof
niecie się w tekście o to, co było zgodne, czyli o j-1 znaków, wyzerowując przy
okazji j. Omówmy jeszcze moment stwierdzenia całkowitej zgodności wzorca
z tekstem. Kiedy to nastąpi? Otóż nie jest trudno zauważyć, że podczas stwier
dzenia zgodności ostatniego znaku j powinno zrównać się z M. Możemy wówczas
łatwo odtworzyć pozycję, od której wzorzec startuje w badanym tekście: będzie
to oczywiście i-M.
Tłumacząc powyższe na C++ możemy łatwo dojść do następującej procedury:
txt-1.cpp
i n t szukaj (char *w,char *t)
{
i n t i=0,j=0,M,N;
M=strlen(w);
// długość wzorca
N=strlen(t);
// długość tekstu
while(j & lt; M & & i & lt; N)
{
if(t[i]!-w[j])
{
/ / *
}
i++; j++;
//
* *
}
if (j==M)
return i-M;
else
return -1;
}
Sposób korzystania z funkcji szukaj jest przedstawiony na przykładzie nastę
pującej funkcji main:
void main()
char *b= " abrakadabra " ,*a= " rak " ;
cout & lt; & lt; szukaj(a,b) & lt; & lt; endl;
}
// zwraca 2
.1. Algorytm typu brute-force
209
Jako wynik funkcji zwracana jest pozycja w tekście, od której zaczyna się wzorzec,
lub -I w przypadku, gdy poszukiwany tekst nie został odnaleziony jest to
znana nam już doskonale konwencja. Przypatrzmy się dokładniej przykładowi
poszukiwania wzorca 10100 w pewnym tekście binarnym (patrz rysunek 8 - 2).
Rys. 8 - 2.
Fałszywe starty "
podczas
poszukiwania.
Rysunek jest nieco uproszczony: w istocie poziome przesuwanie się wzorca
oznacza instrukcje zaznaczone na listingu jako (*), natomiast cala szara strefa
o długości k oznacza k-krotne wykonanie (**).
Na podstawie zobrazowanego przykładu możemy spróbować wymyślić taki
najgorszy tekst i wzorzec, dla których proces poszukiwania będzie trwał moż
liwie najdłużej. Są to oczywiście zarówno tekst, jak i wzorzec złożone z samych
„zer " i zakończone Jedynką " .
Spróbujmy obliczyć klasę tego algorytmu dla opisanego przed chwilą ekstre
malnego najgorszego przypadku Obliczenie nie należy do skomplikowanych
czynności: zakładając, że „restart " algorytmu będzie konieczny (N-1)-(M-2)=NM+1 razy, i wiedząc, że podczas każdego cyklu jest konieczne wykonanie M
porównań, otrzymujemy natychmiast M(N-M+1), czyli około5 M*N.
4
Zera i jedynki symbolizują tu dwa. różne od siebie, znaki.
Typowo M będzie znacznie mniejsze niż N.
210
Rozdział 8. Przeszukiwanie tekstów
Zaprezentowany w tym paragrafie algorytm wykorzystuje komputer jako
bezmyślne, ale sprawne liczydło6. Jego złożoność obliczeniowa eliminuje
go w praktyce z przeszukiwania tekstów binarnych, w których może wystąj
wiele niekorzystnych konfiguracji danych. Jedyną zaletą algorytmu jest jego pro
stota, co i tak nie czyni go na tyle atrakcyjnym, by dać się zamęczyć jego powol
nym działaniem.
8.2. Nowe algorytmy poszukiwań
Algorytm, o którym będzie mowa w tym rozdziale, posiada ciekawą historię, którą
w formie anegdoty warto przytoczyć. Otóż w 1970 roku S. A. Cook udowodnił
teoretyczny rezultat dotyczący pewnej abstrakcyjnej maszyny. Wynikało z niego,
że istniał algorytm poszukiwania wzorca w tekście, który działał w czasie pro
porcjonalnym do M+N w najgorszym przypadku. Rezultat pracy Cooka wcale
nie był przewidziany do praktycznych celów, niemniej D. E. Knuth i V. R. Pratt
otrzymali na jego podstawie algorytm, który był łatwo implementowalny prak
tycznie - ukazując przy okazji, iż pomiędzy praktycznymi realizacjami a rozwa
żaniami teoretycznymi wcale nie istnieje aż tak ogromna przepaść, jakby się to
mogło wydawać. W tym samym czasie J. H. Morris „odkrył " dokładnie ten sarn
algorytm jako rozwiązanie problemu, który napotkał podczas praktycznej imple
mentacji edytora tekstu. Algorytm K-M-P - bo tak będziemy go dalej zwali jest jednym z przykładów dość częstych w nauce „odkryć równoległych " : z ja
kichś niewiadomych powodów nagle kilku pracujących osobno ludzi dochodzi
do tego samego dobrego rezultatu. Prawda, że jest w tym coś niesamowitego i aż
się prosi o jakieś metafizyczne hipotezy?
Knuth, Morris i Pratt opublikowali swój algorytm dopiero w 1976 roku. W między
czasie pojawił się kolejny „cudowny " algorytm, tym razem autorstwa R. S. Boyera
i J. S. Moore'a, który okazał się w pewnych zastosowaniach znacznie szybszy od
algorytmu K-M-P. Został on również równolegle wynaleziony (odkryty?) przez R.
W. Gospera. Oba te algorytmy są jednak dość trudne do zrozumienia bez po
głębionej analizy, co utrudniło ich rozpropagowanie.
W roku 1980 R. M. Karp i M. O. Rabin doszli do wniosku, że przeszukiwanie
tekstów nie jest aż tak dalekie od standardowych metod przeszukiwania i wy
naleźli algorytm, który działając ciągle w czasie proporcjonalnym do M + N , jest
ideowo zbliżony do poznanego już przez nas algorytmu typu brute-force. Na
6
Termin brute-force jeden z moich znajomych ślicznie przetłumaczył jako „metodę mastodonta " .
8.2. Nowe algorytmy poszukiwań
211
dodatek jest to algorytm łatwo dający się generalizować na poszukiwanie w tabli
each 2-wymiarowych, co czyni go potencjalnie użytecznym w obróbce obrazów.
W następnych trzech sekcjach szczegółowo omówimy sobie wspomniane w tym
„przeglądzie historycznym " algorytmy.
8.2.1 .Algorytm K-M-P
Wadą algorytmu brute-force jest jego czułość na konfigurację danych: " fałszywe
restarty " są tu bardzo kosztowne; w analizie tekstu cofamy się o całą długość
wzorca, zapominając po drodze wszystko, co przetestowaliśmy do tej pory. Na
rzuca się tu niejako chęć skorzystania z informacji, które już w pewien sposób
posiadamy - przecież w następnym etapie będą wykonywane częściowo te same
porównania co poprzednio!
W pewnych szczególnych przypadkach, przy znajomości struktury analizowanego tekstu możliwe jest ulepszenie algorytmu. Przykładowo jeśli wiemy na
pewno, iż w poszukiwanym wzorcu jego pierwszy znak nic pojawia się już
w nim w ogóle , to w razie restartu nie musimy cofać wskaźnika i o j-1 pozycji,
jak to było poprzednio (patrz str. 208). W tym przypadku możemy po prostu
zinkrementować i wiedząc, że ewentualne powtórzenie poszukiwań na pewno
nic by j u ż nie dało. Owszem, można się łatwo zgodzić z twierdzeniem, iż tak
wyspecjalizowane teksty zdarzają się relatywnie rzadko, jednak powyższy
przykład ukazuje, iż ewentualne manipulacje algorytmami poszukiwań są ciągle
możliwe - wystarczy się tylko rozejrzeć. Idea algorytmu K-M-P, polega na wstępnym zbadaniu wzorca, w celu obliczenia ilości pozycji, o które należy cofnąć
wskaźnik i w przypadku stwierdzenia niezgodności badanego tekstu ze wzor
cem. Oczywiście można również rozumować w kategoriach przesuwania wzorca
do przodu - rezultat będzie ten sam. To właśnie tę drugą konwencję będziemy sto
sować dalej. Wiemy już, że powinniśmy przesuwać się po badanym tekście nieco inteligentniej niż w poprzednim algorytmie. W przypadku zauważenia
niezgodności na pewnej pozycji.j wzorca 2 należy zmodyfikować ten indeks wykorzystując informację zawartą w już zbadanej „szarej strefie zgodności " .
Brzmi to wszystko (zapewne) niesłychanie tajemniczo, pora więc jak najszybciej
wyjaśnić tę sprawę, aby uniknąć możliwych nieporozumień. Popatrzmy w tym celu
na rysunek 8 - 3 .
Moment niezgodności został zaznaczony poprzez narysowanie przerywanej
pionowej kreski. Otóż wyobraźmy sobie, że przesuwamy teraz wzorzec bardzo
wolno w prawo, patrząc jednocześnie na już zbadany tekst - tak aby obserwować
1
Przykład: „ABBBBBBB " - znak 'A' wystąpił tylko jeden raz.
2
Lub i w przypadku badanego tekstu.
212
Rozdział 8. Przeszukiwanie t e k *
Rys, 8 - 3.
Wyszukiwanie
optymalnego prze
sunięcia w algo
rytmie
K-M-P,
ewentualne pokrycie się lej części wzorca, która znajduje się po lewej stronie
przerywanej kreski, z tekstem, który umieszczony jest powyżej wzorca. W pewnym
momencie może okazać się, że następuje pokrycie obu tych części. Zatrzymu
jemy wówczas przesuwanie i kontynuujemy testowanie (znak po znaku) zgod
ności obu części znajdujących się za kreską pionową.
Od czego zależy ewentualne pokrycie się oglądanych fragmentów tekstu i wzorca?
Otóż, dość paradoksalnie badany tekst „nie ma tu nic do powiedzenia " - jeśli
można to tak określić. Informacja o tym, jaki on był, jest ukryta w stwierdzeniu
" j-1 znaków było zgodnych " - w tym sensie można zupełnie o badanym tekście
zapomnieć i analizując wyłącznie sam wzorzec, odkryć poszukiwane optymalne
przesunięcie. Na tym właśnie spostrzeżeniu opiera się idea algorytmu K-M-P.
Okazuje się, że badając samą strukturę wzorca można obliczyć, jak powinniśmy
zmodyfikować indeksy w razie stwierdzenia niezgodności tekstu ze wzorcem
na j-tej pozycji.
Zanim zagłębimy się w wyjaśnienia na temat obliczania owych przesunięć, po
patrzmy na efekt ich działania na kilku kolejnych przykładach.
Rys. 8 - 4.
" Przesuwanie
się " wzorca w
algorytmie
K-M-P (1).
Na rysunku 8 - 4 możemy dostrzec, iż na siódmej pozycji wzorca3 (którym jest
dość abstrakcyjny ciąg 12341234) została stwierdzona niezgodność. Jeśli zo
stawimy indeks i „w spokoju " , to modyfikując wyłącznie j możemy bez pro
blemu kontynuować przeszukiwanie. Jakie jest optymalne przesunięcie wzorca?
„Ślizgając " go wolno w prawo (patrz rysunek 8 - 3 ) doprowadzamy w pewnym
momencie do nałożenia się ciągów 123 przed kreską - cala strefa niezgodności
została „wyprowadzona " na prawo i ewentualne dalsze testowanie może być
kontynuowane!
Licząc indeksy tablicy tradycyjnie od zera.
8.2. Nowe algorytmy poszukiwań
213
Analogiczny przykład znajduje się na rysunku 8 - 5 .
Rys. 8 - 5.
„Przesuwanie
się " wzorca w
algorytmie
K-M-P (2).
Tym razem niezgodność wystąpiła na pozycjij=3. Dokonując - podobnie jak po
przednio - „przesuwania " wzorca w prawo, zauważamy, iż jedyne możliwe
nałożenie się znaków wystąpi po przesunięciu o dwie pozycje w prawo - czyli
dla j=1. Dodalkowo okazuje się, że znaki za kreską też się pokryły, ale o tym
algorytm „dowie się " dopiero podczas kolejnego testu zgodności na pozycji i.
Dla potrzeb algorytmu K-M-P konieczne okazuje się wprowadzenie tablicy przesunięć
int shift[M], Sposób jej zastosowania będzie następujący: jeśli na pozycji j wystąpiła
niezgodność znaków, to kolejną wartością/ będzie shift[j]. Nie wnikając chwilowo w
sposób inicjalizacji tej tablicy (odmiennej oczywiście dla każdego wzorca), możemy
natychmiast podać algorytm K-M-P, który w konstrukcji jest niemal dokładną ko
pią algorytmu typu brute-force:
kmp.cpp
int kmp(char -w,char *t)
{
int i,j,N=strlen(t);
for & lt; i=0,j=0;i & lt; N & & j & lt; M;i++,j++)
while((j & gt; -C) & & (t[i]!=w[j]))
if
j=shift[j];
(j==M)
return i-M;
else
return - 1 ;
}
Szczególnym przypadkiem jest wystąpienie niezgodności na pozycji zerowej:
z założenia niemożliwe jest tu przesuwanie wzorca w celu uzyskania nałożenia
się znaków. Z tego powodu chcemy, aby indeks j pozostał niezmieniony przy
jednoczesnej progresji indeksu i. Jest to możliwe do uzyskania, jeśli umówimy
się, że shift[0] zostanie zainicjowany wartością-1. Wówczas podczas kolejnej
iteracji pętli for nastąpi inkrementacja i i j, co wyzeruje nam j.
Pozostaje do omówienia sposób konstrukcji tablicy shift [M]. Jej obliczenie
powinno nastąpić przed wywołaniem funkcji kmp, co sugeruje, iż w przypadku
wielokrotnego poszukiwania tego samego wzorca nie musimy już powtarzać
214
Rozdział 8. Przeszukiwanie t e k *
inicjacji tej tablicy. Funkcja inicjująca tablicę jest przewrotna -jest ona praktycz
nie identyczna z kmp z tą tylko różnicą, iż algorytm sprawdza zgodność wzorca..,
z nim samym!
j
int
shift[M];
int init_shifts(char *w)
{
int i , j ;
shift [0]=-1;
f o r ( i = 0 , j = - l ; i & lt; M - l ; i + + , j++, s h i f t [ i ] = j )
while((j & gt; =0) & & (w[i] !=w[j]))
j=shift[j];
}
Sens tego algorytmu jest następujący: tuż po inkrementacji i i j wiemy, że
pierwsze i znaków wzorca jest zgodne ze znakami na pozycjach: p[i-j-I]... p[i-1]
(ostatnie j pozycji w pierwszych i znakach wzorca). Ponieważ jest to największe
j spełniające powyższy warunek, zatem, aby nie ominąć potencjalnego miejsca
wykrycia wzorca w tekście, należy ustawić shift[i] na j
Rys. 8 - 6.
Optymalne
przesunięciu wzor
ca " ananas " .
Popatrzmy, jaki będzie efekt zadziałania funkcji init_shifts na słowie
„ananas " (patrz rysunek 8 - 6). Zacieniowane litery oznaczają miejsca, w któ
rych wystąpiła niezgodność wzorca z tekstem. W każdym przypadku graficznie
przedstawiono efekt przesunięcia wzorca - widać wyraźnie, które strefy pokry
wają się przed strefą zacieniowaną (porównaj rysunek 8 - 5). Przypomnijmy jesz
cze, że tablica shift zawiera nową wartość dla indeksu j, który przemieszcza się
po wzorcu.
Algorytm K-M-P można zoptymalizować, jeśli znamy z góry wzorce, których
będziemy poszukiwać. Przykładowo jeśli bardzo często zdarza nam się szukać
w tekstach słowa „ananas " , to w funkcji kmp można „wbudować " tablicę prze
sunięć:
i n t kmp ananas(char *t)
8.2. Nowe algorytmy poszukiwań
215
W celu właściwego odtworzenia etykiet należy oczywiście co najmniej raz wy
konać funkcję init_shifts lub obliczyć samemu odpowiednie wartości. W każ
dym razie gra jest warta świeczki: powyższa funkcja charakteryzuje się bardzo
zwięzłym kodem wynikowym asemblerowym, jest zatem bardzo szybka. Posia
dacze kompilatorów, które umożliwiają, generację kodu wynikowego jako tzw,
„assembly output " 4 mogą z łatwością sprawdzić różnice pomiędzy wersjami
kmp i kmp_ananas! Dla przykładu mogę podać, że w przypadku wspomnianego
kompilatora GNU „klasyczna " wersja procedury kmp (wraz z init_shifts) miała
objętość około 170 linii kodu asemblerowego, natomiast kmp ananas zmieściła
się w ok. 100 liniach... (Patrz pliki z rozszerzeniem s na dyskietce).
Algorytm K-M-P działa w czasie proporcjonalnym do M+N w najgorszym
przypadku. Największy zauważalny zysk związany z jego użyciem dotyczy
przypadku tekstów o wysokim stopniu samopowtarzalności - dość rzadko wy
stępujących w praktyce. Dla typowych tekstów zysk związany z wyborem metody
K-M-P będzie zatem słabo zauważalny.
Użycie tego algorytmu jest jednak niezbędne w tych aplikacjach, w których na
stępuje liniowe przeglądanie tekstu - bez buforowania. Jak łatwo zauważyć,
wskaźnik i w funkcji kmp nigdy nie jest dekrementowany, co oznacza, że plik
można przeglądać od początku do końca bez cofania się w nim. W niektórych
systemach może to mieć istotne znaczenie praktyczne przykładowo mamy za
miar analizować bardzo długi plik tekstowy i charakter wykonywanych operacji
nie po/wala na cofnięcie się w tej czynności (i w odczytywanym na bieżąco pliku).
1
W przypadku kompilatorów popularnej serii Turbo C++/Borland C++ należy skompilować
program „ręcznie " poprzez polecenie tcc -S -/xxx plik.cpp, gdzie xxx oznacza katalog
z plikami typu H; identyczna opcja istnieje w kompilatorze GNU C++, należy
„wystukać " : C++ -S plikcpp.
216
Rozdział 8. Przeszukiwanie tekstów
8.2.2.Algorytm Boyera i Moore'a
Kolejny algorytm, który będziemy omawiali Jest ideowo znacznie prostszy do zro
zumienia niż algorytm K-M-P, W przeciwieństwie do metody K-M-P porów.
nywaniu ulega ostatni znak wzorca. To niekonwencjonalne podejście niesie ze
sobą kilka istotnych zalet:
• jeśli podczas porównywania okaże się, że rozpatrywany aktualnie znak nie
wchodzi w ogóle w skład wzorca, wówczas możemy skoczyć w analizie
tekstu o całą długość wzorca do przodu! Ciężar algorytmu przesunął się
więc z analizy ewentualnych zgodności na badanie niezgodności - a te
ostatnie są statystycznie znacznie częściej spotykane;
•
„skoki " wzorca są zazwyczaj znacznie większe od 1 - porównaj z metodą
K-M-P!
Zanim przejdziemy do szczegółowej prezentacji kodu, omówimy sobie na przykła
dzie jego działanie. Spójrzmy w tym celu na rysunek 8 - 7 , gdzie przedstawione jest
poszukiwanie ciągu znaków " lek " w tekście " Z pamiętnika młodej lekarki?5 .
Pierwsze pięć porównań trafia na litery: p, i, n, a i /, które we wzorcu nie wy
stępują! Za każdym razem możemy zatem przeskoczyć w tekście o trzy znaki
do przodu (długość wzorca). Porównanie szóste trafia jednak na literę e która
w słowie „lek " występuje. Algorytm wówczas przesuwa wzorzec o tyle pozycji
do przodu, aby litery e nałożyły się na siebie, i porównywanie jest kontynuowane
Rys. 8- 7.
Przeszukiwanie
tekstu metoda
Boyera i Moore'a.
Następnie okazuje się, że litera j nie występuje we wzorcu - mamy zatem prawo
przesunąć się o kolejne 3 znaki do przodu. W tym momencie trafiamy j u ż na po
szukiwane słowo, co następuje po jednokrotnym przesunięciu wzorca, tak aby
pokryły się litery k.
Tytuł znakomitego cyklu autorstwa Ewy Szumańskiej.
I 8,2, Nowe algorytmy poszukiwań
217
Algorytm jest jak widać klarowny, prosty i szybki. Jego realizacja także nie jest
zbyt skomplikowana. Podobnie jak w przypadku metody poprzedniej, także i tu
musimy wykonać pewną prekompilację w celu stworzenia tablicy przesunioc
Tym razem jednak tablica ta będzie miała tyle pozycji, ile jest znaków w alfa
becie - wszystkie znaki, które mogą wystąpić w tekście plus spacja. Będziemy
również potrzebowali prostej funkcji indeks, która zwraca w przypadku spacji
liczbę zero — w pozostałych przypadkach numer litery w alfabecie. Poniższy
przykład uwzględnia jedynie kilka polskich liter - Czytelnik uzupełni go z ła
twością o brakujące znaki. Numer litery jest oczywiście zupełnie arbitralny i za
leży od programisty. Ważne jest tylko, aby nie pominąć w tablicy żadnej litery.
która może wystąpić w tekście. Jedna z możliwych wersji funkcji indeks jest
przedstawiona poniżej:
//znaki ASCII+polskie litery+spacja
Funkcja indeks ma jedynie charakter usługowy. Służy ona m.in. do właściwej inicjalizacji tablicy przesunięć. Mając za sobą analizę przykładu z rysunku 8-7. Czy
telnik nie powinien być zbytnio zdziwiony sposobem inicjalizacji:
int init_shifts(char *w)
{
i n t i, M=strlen(w);
for(i=0;i & lt; K;i++)
shift[i]=M;
for(i=0,-i & lt; M;i++)
shift[indeks(w[i])l=M-i-l;
}
Przejdźmy wreszcie do prezentacji samego listingu algorytmu:
int bm(char *w, char *t)
{
init_shifts(w);
i n t i, j,N=strlen(t),M=strlen(w);
218
Rozdział 8. Przeszukiwania tekstów
for(i=M-l, j=M-1; j & gt; 0; i - - , j--)
while(t[i]!=w[j])
{
int x=shift[indeks(t[i])];
if(M-j & gt; x)
i+=M-j;
else
if
}
|
|
i+=x;
(i & gt; =N)
return - 1 ;
j=M-l;
return i;
}
!
|
Algorytm Boyera i Moore'a. podobnie jak i K-M-P, jest klasy M+N - jednak jest
on o tyle od niego lepszy, iż w przypadku krótkich wzorców i drugiego alfabetu
kończy się po około M/N porównaniach. W celu obliczenia optymalnych przesu
nięć6 autorzy algorytmu proponują skombinowanie powyższego algorytmu z tym
zaproponowanym przez Knutha, Morrisa i Pratta. Celowość tego zabiegu wydaje
się jednak wątpliwa, gdyż optymalizując sam algorytm, można W bardzo łatwj
sposób uczynić zbyt czasochłonnym sam proces prekompilacji wzorca.
8.2.3.Algorytm Rabina i Karpa
Ostatni algorytm do przeszukiwania tekstów, który będziemy analizowali.
wymaga znajomości rozdziału 7 i terminologii, która została w nim przedsta
wiona. Algorytm Rabina i Karpa polega bowiem na dość przewrotnej idei:
•
wzorzec w (do odszukania) jest kluczem (patrz terminologia transfor
macji kluczowej w rozdziale 7) o długości M znaków, charakteryzują
cym się pewną wartością wybranej przez nas funkcji H. Możemy zatem
obliczyć jednokrotnie Hw=H(w) i korzystać z tego wyliczenia w sposób
ciągły.
•
tekst wejściowy t (do przeszukania) może być w taki sposób odczyty
wany, aby na bieżąco znać M ostatnich znaków 7 . Z tych M znaków wy
liczamy na bieżąco Ht=H(i).
Zakładając jednoznaczność wybranej funkcji H, sprawdzenie zgodności
wzorca z aktualnie badanym fragmentem tekstu sprowadza się do odpowiedzi na
pytanie: czy Hw jest równe Ht? Spostrzegawczy Czytelnik ma jednak prawo po
kręcić w tym miejscu z powątpiewaniem głową: przecież to nie ma prawa
działać szybko! Istotnie, pomysł wyliczenia dodatkowo funkcji H dla każdego
' Rozważ np. wielokrotne występowanie takich samych liter we wzorcu.
7
Na samym początku będzie to oczywiście M pierwszych znaków tekstu.
8.2. Nowe algorytmy poszukiwań
słowa wejściowego o długości M wydaje się tak samo kosztowny
bardziej! - j a k zwykle sprawdzanie tekstu znak po znaku (patrz
brute-force). Tym bardziej że jak do tej pory nie powiedzieliśmy ani
temat funkcji H... Z poprzedniego rozdziału pamiętamy zapewne, iż
wcale nie był taki oczywisty.
219
- jak nie
algorytm
słowa na
jej wybór
Omawiany algorytm jednak istnieje i na dodatek działa szybko! Zatem, aby to
wszystko, co poprzednio zostało napisane, logicznie się ze sobą łączyło, po
trzebny nam będzie zapewne jakiś trik... Sztuka polega na właściwym wyborze
funkcji H. Robin i Karp wybrali taką funkcję, która dzięki swym szczególnym
właściwościom umożliwia dynamiczne wykorzystywanie wyników obliczeń
dokonanych krok wcześniej, co znacząco potrafi uprościć obliczenia wykony
wane w kroku bieżącym.
Załóżmy, że ciąg M znaków będziemy interpretować jako pewna, liczbę całko
witą. Przyjmując za b - j a k o podstawę systemu - ilość wszystkich możliwych zna
ków, otrzymamy:
Przesuńmy się teraz w tekście o jedną pozycję do przodu i zobaczmy jak zmieni
się wartość x:
Jeśli dobrze przyjrzymy się x i x' to okaże się, że x' jest w dużej części
zbudowana z elementów tworzących x - pomnożonych przez b z uwagi na
przesunięcie. Nietrudno jest wówczas wywnioskować, że;
Jako funkcji H użyjemy dobrze nam znanej z poprzedniego rozdziału H(x)=x %p,
gdzie p jest dużą liczbą pierwszą. Załóżmy, że dla danej pozycji / wartość H(x)
jest nam znana. Po przesunięciu się w tekście o jedną pozycję w prawo pojawia
się konieczność wyliczenia dla tego „nowego " słowa wartości funkcji H(x')
Czy istotnie zachodzi potrzeba powtarzania całego wyliczenia? Być może ist
nieje pewne ułatwienie bazujące na zależności jaka istnieje pomiędzy x i x'?
Na pomoc przychodzi nam tu własność funkcji modulo użytej w wyrażeniu aryt
metycznym. Można oczywiście obliczyć modulo z wyniku końcowego, lecz to
bywa czasami niewygodne z uwagi na na przykład wielkość liczby, z którą
mamy do czynienia - a poza tym, gdzie tu byłby zysk szybkości?! Identyczny
wynik otrzymuje się jednak aplikując funkcję modulo po każdej operacji cząst-
220
Rozdział 8. Przeszukiwanie tekstów
kowej i przenosząc otrzymaną wartość do następnego wyrażenia cząstkowego
Dla przykładu weźmy obliczenie:
(5*100 + 6 * 1 0 + 8 ) % 7 = 5 6 8 % 7 = l .
Wynik ten jest oczywiście prawdziwy, co można łatwo sprawdzić z kalkulatorem. Identyczny rezultat da nam jednak następująca sekwencja obliczeń:
... co też jest łatwe do weryfikacji.
Implementacja algorytmu jest prosta, lecz zawiera kilka instrukcji wartych
omówienia. Popatrzmy na listing:
|
i n t rk(char w[],char t [ ] ]
{
unsigned long i,bM_l=l,Hw=0,Ht=0,M,N;
M=strlen(w),N=strlen(t);
for(i=0;i & lt; M;i++)
rk.cpp
{
Hw=(Hw*b*indeks(w[i]))%p; //inicjacja funkcji H dla wzorca
Ht=(Ht*b+indeks(t[i] ) )%p; //inicjacja funkcji H dla tekstu
}
for(i=l;i & lt; M;i++) bM_l=(b*bM_l)%p;
for(i=0;Hw!=Ht;i++)
// przesuwanie się w tekście
{
Ht=(Ht+b*p-indeks(t[i])*bM_l)%p; // (*)
Ht=(Ht*b+indeksit[i+M]))%p;
if (i & gt; N-M)
return - 1 ;
// porażka poszukiwań
}
return
i;
}
W pierwszym etapie następuje wyliczenie początkowych wartości Ht i Hw. Po
nieważ ciągi znaków trzeba interpretować jako liczby, konieczne będzie zasto
sowanie znanej już nam doskonale funkcji indeks (patrz str. 217). Wartość Hw jest
niezmienna i nie wymaga uaktualniania. Nie dotyczy to jednak aktualnie badane
go fragmentu tekstu - tutaj wartość Ht, ulega zmianie podczas każdej inkrementacji zmiennej i. Do obliczenia H(x') możemy wykorzystać omówioną wcześniej
własność funkcji modulo - co jest dokonywane w trzeciej pętli for. Dodatkowego
wyjaśnienia wymaga być może linia oznaczona (*). Otóż dodawanie wartości
b*p do Ht pozwala nam uniknąć przypadkowego „wskoczenia " w liczby ujem
ne. Gdyby istotnie tak się stało, przeniesiona do następnego wyrażenia arytme
tycznego wartość modulo byłaby nieprawidłowa i sfałszowałaby końcowy wynik!
|
|
B.2. Nowe algorytmy poszukiwań
221
Kolejne uwagi należą się parametrom p i b. Zaleca się, aby p było dużą liczbą
pierwszą , jednakże nie można tu przesadzać z uwagi na możliwe przekroczenie
zakresu „pojemności " użytych zmiennych. W przypadku wyboru dużego p
zmniejszamy prawdopodobieństwo wystąpienia „kolizji " spowodowanej niejednoznacznością funkcji H. Ta możliwość - mimo iż mało prawdopodobna ciągle istnieje i ostrożny programista powinien wykonać dodatkowy test zgodności w i t[i] t[i+M-I] po zwróceniu przez funkcję rk pewnego indeksu i.
Co zaś się tyczy wyboru podstawy systemu (oznaczonej w programie jako b), to
warto jest wybrać liczbę nawet nieco za duża, zawsze jednak będącą potęgą
liczby 2. Możliwe jest wówczas zaimplementowanie operacji mnożenia przez b
jako przesunięcia bitowego - wykonywanego znacznie szybciej przez komputer
niż zwykle mnożenie. Przykładowo dla b=64 możemy zapisać mnożenie b*p
jako p & lt; & lt; 6.
Gwoli formalności jeszcze można dodać, że gdy nie występuje kolizja (typowy
przypadek!), algorytm Robina i Karpa wykonuje się w czasie proporcjonalnym
do M-N.
1
W naszym przypadku jest to liczba 33554393.
Rozdział 9
Zaawansowane techniki
programowania
Rozdziały poprzednie (szczególnie 2 i 5) dostarczyły nam interesujących narzędzi
programistycznych. Zapoznaliśmy się z wieloma ciekawymi strukturami danych
przede wszystkim nauczyliśmy się posługiwać technikami rekurencyjnymi,
.stanowiącymi bazę nowoczesnego programowania. Zasadnicza rola rekurencji w procesie koncepcji programów nie była specjalnie eksponowana, koncen
trowaliśmy się bowiem na próbach dokładnego zapoznania się z tym mechani
zmem od strony „technicznej " .
W rozdziale niniejszym akcent położony na stosowanie rekurencji będzie o wiele
silniejszy, gdyż większość prezentowanych w nim metod swoje istnienie
zawdzięcza właśnie tej technice programowania.
Tematyka tego rozdziału jest nieco przewrotna i łatwo może nieuważnego
odbiorcę sprowadzić na manowce. Będziemy się bowiem zajmowali tzw. technikami (lub też inaczej: metodami) programowania, mającymi charakter niesłychanie
ogólny i sugerującymi możliwość programowego rozwiązania niemal wszystkiego.
co nam może tylko przyjść do głowy. Podawane algorytmy (a raczej ich wzorce)
zostaną bowiem ilustrowane bardzo różnorodnymi zadaniami i generalnie rzecz
biorąc będą dostarczać urzekająco efektownych rezultatów. Co więcej, będzie się
wręcz wydawać, że dostajemy do ręki uniwersalne recepty, które automatycznie
spowodują zniknięcie wszelkich nierozwiązywalnych wcześniej zadań... Czytelnik
domyśla się już zapewne, że bynajmniej nie będzie to prawdą. Złudzenie, któremu uleglibyśmy (gdyby nie niniejsze ostrzeżenie), wyniknie z dobrze dobranych
przykładów, które wręcz wzorcowo będą pasować do aktualnie omawianej metody.
W ogólnym jednak przypadku rzeczywistość będzie o wiele bardziej skomplikowana i próby stosowania tych technik programowania jako uniwersalnych
„przepisów kucharskich " nie powiodą się. Czy ma to oznaczać, że owe metody są
błędne? Oczywiście nie, tylko wszelkie usiłowania „bezmyślnego " ich zastosowania na pewno spalą na panewce, o ile nie dokonamy adaptacji metody do na
potkanego problemu algorytmicznego.
224
Rozdział 9. Zaawansowane techniki programowania
Należy zdawać sobie bowiem sprawę z lego, iż każde nowe zadanie powinno
być dla nas nowym wyzwaniem!
Programista dysponując pewną bazą wiedzy (nabyta teoria i praktyka) będzie
z niej czynił odpowiedni pożytek wiedząc jednak, że uniwersalne przepisy
(w zasadzie) nie istnieją. Po algorytmice bowiem, jak i innych gałęziach wie
dzy nic należy spodziewać się cudów (chciałoby się dodać: niestety...).
9.1. Programowanie typu „dziel-i-rządź "
Programowanie typu „dziel-i-rządź " polega na wykorzystaniu podstawowej
cechy rekurencji: dekompozycji problemu na pewną skończoną ilość podproblemów tego samego typu. a następnie połączeniu w pewien sposób otrzyma
nych częściowych rozwiązań w celu odnalezienia rozwiązania globalnego. Jeśli
oznaczymy problem do rozwiązania przez Pb, a rozmiar danych przez M to za
bieg wyżej opisany da się przedstawić za pomocą zapisu:
Pb(N) - & gt; Pb(N1) + Pb(N2) + ... + Pb(Nk) + KOMB(N).
Problem „rzędu " N został podzielony na k pod-problemów.
Uwaga: funkcja KOMB(N) nie jest rekurencyjna,
Zasadniczo znak + nie jest użyty powyżej w charakterze arytmetycznym, ale jeśli
będziemy rozumować przy pomocy czasów wykonania programu (patrz ozna
czenia z rozdziału 3), to wówczas — & gt; możemy zamienić na znak równości i
otrzymana równość będzie spełniona.
Powyższa uwaga ma fundamentalne znaczenie dla omawianej techniki progra
mowania, bowiem podział problemu nie jest na ogól wykonywany dla estetycznych
celów (choć nie jest to oczywiście zabronione), ale ma za zadanie zwiększenie
efektywności programu. Inaczej rzecz ujmując: chodzi nam o przyspieszenie
algorytmu.
Technika „dziel-i-rządź " pozwala w wielu przypadkach na zmianę klasy algo
rytmu (np. z O(n) do O(1og2X) etc.). Z drugiej jednak strony istnieje grupa zadań,
dla których zastosowanie metody „dziel-i-rządź " nic spowoduje pożądanego przy
spieszenia - z rozdziału 3 wiemy, jak porównywać ze sobą algorytmy, i przed
Termin ten, rozpropagowany w literaturze anglojęzycznej, niezbyt odpowiada idei
Machiavcllicgo wyrażonej przez jego zdanie „Divide ut Regnes " (które ma niewątpliwą
konotację destruktywną), ale wydaje się, że mało kto już na to zwraca uwagę...
.1. Programowanie typu „dzlel-l-rządź "
225
zastosowaniem omawianej melody warto wziąć do reki kartkę i ołówek, aby prze
konać się, czy w ogóle warto zasiadać do klawiatury!
Oto formalny zapis metody zaprezentowany przy pomocy pseudo-języka progra
mowania:
dziel_i_rządź (N)
{
j e ś l i N w y s t a r c z a j ą c o mały
zwróć Przypadek_Elementamy (N) ;
w przeciwnym wypadku
{
„Podziel " Pb(N) na mniejsze egzemplarze:
Pb(N1), Pb(N2) . . . Pb(Nk);
dla i - 1 . . . k
o b l i c z wynik cząstkowy Wi=dziel_i_rzadź(N) ;
z w r ó ć K0MB (w1, w2, . . , wk) ;
}
}
Określenie właściwego znaczenia sformułowań „wystarczająco mały " „przy
padek elementarny " będzie ściśle związane z rozważanym problemem i trudno
tu podać dalej posuniętą generalizację. Ewentualne niejasności powinny się
wyjaśnić podczas analizy przykładów znajdujących się w następnych paragrafach.
9.1.1 .Odszukiwanie minimum i maksimum w tablicy liczb
Z metodą „dziel-i-rządź " mieliśmy już w tej książce do czynienia w sposób nie
jawny i odnalezienie algorytmów, które mogą się do niej zakwalifikować, zostaje
pozostawione Czytelnikowi jako test na " spostrzegawczość i orientację " (kilka
odnośników zostanie jednak pod koniec podanych).
Jako pierwszy przykład przestudiujemy dość prosty problem odnajdywania
elementów: największego i najmniejszego w tablicy. Problem raczej banalny, ale
o pewnym znaczeniu praktycznym. Przykładowo wyobraźmy sobie, że chcemy
wykreślić na ekranie przebieg funkcji y=f(x). W tym celu w pewnej tablicy za
pamiętujemy obliczane ,V wartości tej funkcji dla przedziału, powiedzmy, xd... xg.
Mając zestaw punktów musimy przeprowadzić jego „rzut " na ekran komputera,
tak aby zmieścić na nim tyle punktów, by otrzymany wykres nie przesunął się
w niewidoczne na ekranie obszary. Z osią OX nie ma problemów: możemy się
umówić, że x d odpowiada współrzędnej 0, a x E - odpowiednio maksymalnej
rozdzielczości poziomej. Aby jednak przeprowadzić skalowanie na osi OY
konieczna jest znajomość ekstremalnych wartości funkcji f(x). Dopiero wówczas
możemy być pewni, że wykres istotnie zmieści się w strefie widocznej ekranu!
226
Rozdział 9. Zaawansowane techniki programowania
Ćwicz. 9-1
Proszę wyprowadzić wzory tłumaczące wartości (x, y,) na współrzędne ekra
nowe (Xekr, Yekr). znając rozdzielczość ekranu graficznego X max i Ymin oraz inaksyinaluc odchylenia wartości funkcji f(x), które oznaczymy jako fmax, fmin.
Powróćmy teraz do właściwego zadania. Pierwszy pomysł na zrealizowanie
algorytmu poszukiwania minimum i maksimum pewnej tablicy2 polega na jej
liniowym przeszukaniu:
min_max.cpp
c o n s t i n t n=12;
i n t tab[n] = ( 2 3 , 1 2 , 1 , - 5 , 3 4 , - 7 , 4 5 , 2 , 8 8 , - 3 , - 9 , 1 ) ;
v o i d min_maxl ( i n t t [ ] , i n t m i n i n t & max)
{
/ / u ż y j t y l k o gdy n & gt; - l !
min=max=t[0];
for(int i=l;i & lt; n;i++)
{
if(max & lt; t[i])
// (*)
max=t[i];
if(min & gt; t[i])
/ / (**)
min=[i];
}
}
Załóżmy, że tablica ma rozmiar n, tzn. obejmuje elementy od t[0] do t[n-I],
Obliczmy złożoność obliczeniową praktyczną tego algorytmu, przyjmując za
element czasochłonny instrukcje porównań. Wynik jest natychmiastowy:
T(n)=2(n-I)t a zatem program jest klasy O(n). Algorytm jest nieskomplikowany
i... nieefektywny. Po bliższym przyjrzeniu się procedurze można bowiem za
uważyć, że porównanie (**) jest zbędne w przypadku, gdy (*) jako pierwsze
zakończyło się sukcesem. Dołożenie else tuż po (*) spowoduje, że w najgor
szym razie wykonamy 2(n-1) porównań, a w najlepszym - tylko n-l. Nie zmieni to
oczywiście klasy algorytmu, ale ewentualnie go przyspieszy - w zależności
oczywiście od konfiguracji danych wejściowych.
Zrealizujmy teraz analogiczną procedurę, wykorzystującą rekurencyjne
uproszczenie algorytmu zgodnie z zasadą „dziel-i-rządź " . Idea jest następująca:
Przypadek ogólny:
• jeśli tablica ma rozmiar równy 1, to zarówno min, jak i max są równe
jedynemu elementowi, który się w niej znajduje;
• jeśli tablica ma dwa elementy, poprzez ich porównanie możemy z ła
twością określić wartości min i max.
2
W przykładzie będzie to tablica liczb całkowitych, co nie umniejsza ogólności algorytmu.
9.I. Programowanie typu „dziel-i-rządź "
227
Przypadek ogólny;
- jeśli tablica ma rozmiar & gt; 2, to:
-
podziel ją na dwie części;
-
wylicz wartości (min1, max1) i (min2, max2) dla obu tych części;
- zwróć jako wynik min=min(minl, min2) i max=max(maxl, max2).
Odpowiadająca temu opisowi procedura rekurencyjna ma następującą postać:
v o i d min _ m a x 2 ( i n t l e f t ,
int right,
{
if
(Ieft==right)
min=max=t[left];
else
if (left==right-l)
if (t[left] & lt; t[right])
//
int
t[],int &
min,
i n t & max)
jeden element
/ / dwa e l e m e n t y
{
min=t[left];
max=t[right];
}else
{
min=t[right];
max=t[left];
}
else
{
i n t temp_ min1, temp_maxl, temp_min2, temp_max2,mid;
mid=(left+right)/2;
min_max2 ( l e f t , m i d , t , t e m p _ m i n l , t e m p _ m a x l ) ;
min_max2(mid+1,right,t,temp min2,temp_max2);
if (temp_minl & lt; temp_min2) // (1)
min=temp_minl;
else
min=temp_min2;
if(temp_maxl & gt; temp_max2)
max= t e m p _ m a x l ;
else
max=temp_max2;
}
// (2)
}
Porównując powyższe ze schematem ogólnym metody, można zauważyć, że:
• „wystarczająco mały " oznacza rozmiar tablicy 1 lub 2;
• mamy dwa przypadki elementarne;
• dzielimy tablicę na 2 równe egzemplarze NI i N2;
• wynikami cząstkowymi są
(temp_min2, temp_max2);
pary:
(temp min I, temp_max1) oraz
228
Rozdział 9. Zaawansowane techniki programowania
•
funkcja KOMB polega na najzwyklejszym porównywaniu wyników
cząstkowych - jej rolę pełnią instrukcje oznaczone w komentarzach
przez(l)i(2).
W dalszych przykładach nic będziemy już tak dokładnie „rozbierać " procedur,
ufamy bowiem, że Czytelnik w miarę potrzeby uczyni to bez trudu samodzielnie.
Obliczenie złożoności obliczeniowej procedury min_max2 także nie nastręcza
trudności. Dekompozycja problemu jest następująca:
Jest to znany nam już rozkład logarytmiczny, po rozwiązaniu otrzymamy wynik
Obliczenie złożoności praktycznej T(n) tego programu nie należy do trud
nych. Jeśli odpowiednio rozpiszemy równanie:
i założymy, że istnieje pewne k takie że n=2k, to otrzymalibyśmy takie samo
równanie, jak w przypadku procedury min_maxl. Nie powinno to budzić
zdziwienia, biorąc pod uwagę, że w drugiej wersji wykonujemy dokładnie taką
samą pracę, jak w poprzedniej - postępując jednak w odmienny sposób.
Czy powyższy wynik nie jest czasem nieco niepokojący? Wygląda bowiem na
to, że nowa metoda nie gwarantuje poprawy efektywności algorytmu!
Trafiliśmy już na zapowiedziany we wstępie przypadek problemu, dla którego za
stosowanie metody „dziel-i-rządź " nic zmienia w istotny sposób parametrów
„czasowych " programu. Cóż, można się przynajmniej łudzić, że ich nie pogar
sza! Niestety, w naszym przypadku nie jest to prawdą. Jeśli sięgniemy pamięcią
do rozdziału poświęconego rekurencji i jej „ciemnym stronom " powinno być dla
nas jasne, że z uwagi na wprowadzenie dodatkowego obciążenia pamięci
(stos wywołań rekurencyjnych) i niepomijalnej ilości dodatkowych wywołań rekurencyjnych zakładana równoważność „czasowa " obu procedur nie jest prawdą.
Przedstawiony powyżej przykład nie jest prawdopodobnie najlepszą reklamą
omawianej metody - miał on jednak na celu ukazanie potencjalnych zagrożeń
związanych z naiwną wiarą w „cudowne metody " . Są oczywiście przypadki,
w których „dziel-i-rządź " czyni wręcz cuda (już zaraz kilka z nich zresztą za
prezentuję...), ale o ich zaistnieniu można się przekonać jedynie wyliczając zło-
9,1. Programowanie typu „dziel-i-rządż
229
żoność obliczeniową obu metod. Jeśli w istocie otrzymamy znaczący zysk
szybkości - na przykład zmianę klasy programu na lepszą - to jest mało praw
dopodobne, aby pewne niekorzystne cechy rekurencji grały istotną rolę W
przypadku jednak otrzymania wyniku dowodzącego równoważność czasową
metod, trzeba również wziąć pod uwagę wskaźniki, które nie mając nic wspól
nego z teorią, odgrywają niebagatelną rolę w praktyce (w tzw. rzeczywistym
świecie). Pewne uwagi trzeba bowiem wypowiedzieć co najmniej raz. aby póź
niej nie denerwować się, że komputer nie chce robić tego. co my mu każemy
(lub robi to gorzej niż chcielibyśmy).
9.1.2.Mnożenie macierzy o rozmiarze NxN
W wielu zagadnieniach natury numerycznej często zachodzi potrzeba mnożenia
ze sobą macierzy, co z definicji jest dość czasochłonną operacją. Sposób wyli
czania iloczynu dwóch macierzy może być symbolicznie przedstawiony w spo
sób zaprezentowany na rysunku 9 - 1 .
Jeśli macierz C (przypomnijmy, że z punktu widzenia programisty macierz jest
tablicą dwuwymiarową) będziemy uważać za wynik mnożenia A * B, to do
wolny element C[i,j] można otrzymać stosując wzór:
(Mnożymy odpowiadające sobie elementy linii i i kolumny j, kumulując jedno
cześnie sumy cząstkowe).
Rys. 9 - /.
Mnożenie
macierzy.
It
j
A
c
i
230
Rozdział 9. Zaawansowane techniki programowania
Koszt wyliczenia jednego elementu macierzy C, mając na uwadze ilość wyko
nywanych operacji mnożenia (przyjmijmy rozsądnie, że to one są tu „najko
2
sztowniejsze " ), jest równy oczywiście N. Ponieważ wszystkich elementów jest N ,
3
3
to koszt całkowity wyniesie N , czyli program należy do O(N) .
Algorytm jest bardzo kosztowny, ale wydawało się to przez długi czas tak nie
uniknione, że praktycznie wszyscy się z tym pogodzili. W roku 1968 Volker
Strassen sprawił jednakże wszystkim sporą niespodziankę, wynajdując algorytm
bazujący na idei „dziel-i-rządź " , który był lepszy niż wydawałoby się
3
„nienaruszalne " O(N) .
Oznaczmy elementy macierzy A. B i C w sposób następujący:
Nie jest trudno wykazać, że prawdziwe są następujące równości:
C11 = A11B11+A12B21
C12 = A11B12+A12B22
C21 = A21B11+A22B21
C22 = A21B12+A22B22
Podejście polegające na podziale każdej z matryc A i B na 4 równe części
(zakładając oczywiście, że N jest potęgą liczby 2...) i wykonanie mnożenia matryc
mniejszego rzędu wydaje się bezpośrednim zastosowaniem techniki „dziel-i-rządź " .
Ponieważ jednak podział nie oszczędza nam pracy (w dalszym ciągu jesteśmy zmu
szeni do zrobienia dokładnie tego samego, co algorytm iteracyjny), to na pewno nie
otrzymamy tu efektywniejszego algorytmu. Stwierdzenie to nie jest poparte obli
czeniami. ale zapewniam, że jest prawdziwe.
Spójrzmy teraz jak V. Strassen zoptymalizował mnożenie macierzy. Zasadnicza
idea jego metody polega na wprowadzeniu dodatkowych „zmiennych " , będą: P, Q, R, S, T, U, V, służących do zapamiętania wycych matrycami rzędu
3
ników następujących o bliczeń :
Obok równań są zaznaczone operacje arytmetyczne wymagane do wyliczenia danej
równości.
9.1. Programowanie typu „dziel-i-rządź "
231
P = (A11+A22)(B11+B22) *, +, +
Q =
R =
(A21+A22)B11
A11(B12-B22)
S =
A22(B21-B11)
T =
U=
+, *
*, *,
-
B22(A11+A12)
(A21-A11)(B11+B12)
*,+
*, + , -
Gdy mamy te cząstkowe w y n i k i , otrzymanie matrycy C może być dokonane
poprzez następujące podstawienia:
C11 = P + S -T+ V
C12 =
R
+
+, -.+
T
C 22 = P + R - Q+ U
+
+, - . +
Algorytm tej postaci wymaga 7 operacji * i dodatkowych 18 operacji + l u b - .
Zauważmy, że algorytm Strassena przenosi w inteligentny sposób ciężar obliczeń
Z zawsze kosztownej operacji mnożenia na znacznie szybsze dodawanie lub
odejmowanie.
Rozkład rekurencyjny w algorytmie V. Strassena jest następujący:
(a jest pewną stałą).
Bliższe badania praktyczne tego algorytmu wykazały, że realny zysk powyższej
metody daje się zauważyć w przypadku mnożenia matryc dla A' rzędu kilkadziesiąt,
ale w przypadku naprawdę dużych A'(np. powyżej 100) efektywność algorytmu
ponownie zbliża się do swojego iteracyjnego „konkurenta " . Fenomen ten zależy
m.in. od sposobu zarządzania pamięcią w danym środowisku sprzętowym. Jeśli
mamy do czynienia z komputerem osobistym o dużym „prywatnym " zasobie
pamięci, to działanie algorytmu będzie zbliżone do przewidywań teoretycznych.
Jednak w przypadku systemów rozproszonych, w których program „widząc "
Zwłaszcza jeśli elementami macierzy są liczby „rzeczywiste " (typ double w C++).
232
Rozdział 9. Zaawansowane techniki programowania
pozornie całą żądaną pamięć, faktycznie operuje jej „stronami " . które dosyta
mu w miarę potrzeb system operacyjny, sprawa może wyglądać j u ż trochę
gorzej. Drugim istotnym powodem spadku efektywności algorytmu dla dużych
(praktycznie występujących) wartości N, jest kumulacja wielokrotnych wywołań
rekurencyjnych i wzrastającej „zauważalności " roli operacji dodawania i odej
mowania. Z wymienionych wyżej względów algorytm V. Strassena należy
raczej traktować jako ciekawy wynik teoretyczny, o niepodważalnych walorach
edukacyjnych!
9.1.3.Mnożenie liczb całkowitych
Kolejny przykład jest również natury obliczeniowej: zajmiemy się mnożeniem
liczb całkowitych.
Mnożenie dwóch liczb całkowitych X i K, których reprezentacja wewnętrzna
ma rozmiar N-bitów. jest operacją klasy O(N 2 ). Zakładamy, że mnożenie jest
wykonywane klasycznie, tak jak nas tego nauczono w szkole podstawowej
(sumujemy „w słupku " N wyników iloczynów cząstkowych, każdy z nich jest
klasy O(n)).
Metoda „dziel-i-rządź v w przypadku mnożenia liczb całkowitych może być za
stosowana po dokonaniu następującej obserwacji:
A i B oraz C i D oznaczają odpowiednio „połówki' 1 reprezentacji binarnych
liczb X i Y. Iloczyn X*Y może być zapisany jako:
Jeśli założymy, że N jest potęgą liczby 2 (co jest generalnie prawdą we współ
czesnych komputerach), to możemy wyrazić złożoność obliczeniową programu
przez:
W równaniach tych zaznaczamy wpływ czterech kosztownych operacji mnoże
nia plus pewien proporcjonalny do N koszt związany z dodawaniami i przesu-
8.1. Programowanie typu „dziel-i-rządź "
233
nięciami bitowymi . Aby wyliczyć klasę tego algorytmu można sięgnąć do
wzorów podanych w rozdziale 3 albo nie wysilać się zbytnio i dojść do wnio
sku, że... mamy do czynienia z 0(N2).
Skąd ta pewność? Wynika ona z obserwacji wynikłej podczas analizy procedury
min_max: dokonaliśmy podziału problemu, ale w niczym nie zmniejszyliśmy
ilości wykonywanej pracy. Cudów żalem nie będzie ! Zanim jednak rozczaru
jemy się na dobre do metody „dziel-i-rządź " , popatrzmy na następujące
„przepisanie " operacji X*Y w nieco inny sposób niż poprzednio:
Mimo nieco bardziej skomplikowanej postaci (patrz algorytm Strassena!) zmniej
szyliśmy ilość operacji mnożenia z 4 na 3 (AC i BD występują podwójnie, zatem
za drugim użyciem można skorzystać z poprzedniego wyniku). Formuła rekuren
cyjna towarzysząca temu rozkładowi jest identyczna jak w przypadku poprzed
nim, wystarczy tylko zamienić 4 na 3. Wiedząc to, otrzymujemy natychmiast, że
algorytm jest klasy
Zachęca się Czytelnika do zbadania na różnych przykładach i przy użyciu róż
norodnych założeń co do kosztów operacji elementarnych ( + , -, przesunięcie
bitowe), kiedy istotnie ten algorytm może dać „zauważalne " rezultaty w porów
naniu z metodą klasyczną.
9.1.4.Inne znane algorytmy „dziel-i-rządź "
Nie eksponując specjalnie tego, już w rozdziałach poprzednich mogliśmy zapo
znać się z kilkoma ciekawymi algorytmami, które można zaklasyfikować do
metody „dziel-i-rządź " .
Programem, który zdecydowanie " króluje " wśród nich, jest niewątpliwie słynny
QuickSort (patrz opis). Oferuje on znaczący wzrost szybkości sortowania i, co
najważniejsze, jest przy tym niesłychanie prosty zarówno w zapisie, jak i ideowo.
Omówiony przy okazji rozpatrywania technik derekursywacji problem wież Hanoi
(patrz rozdział 6) jest również dobrym przykładem inteligentnej dekompozycji
5
Przypomnijmy, że mnożenie liczby przez potęgę podstawy systemu (21,22, 23...) jest
równoważne przesunięciu jej reprezentacji wewnętrznej o „wykładnik potęgi " miejsc
w lewo(1, 2, 3...).
234
Rozdział 9. Zaawansowane techniki programowania
problemu. Mimo iż wersje iteracyjne i rekurencyjne są tej samej klasy, to prostota
zapisu rekurencyjnego jest najlepszym argumentem za jego zastosowaniem.
Procedura przeszukiwaniu binarnego również może być zaklasyfikowana do
metody „dziel-i-rządź " , choć „filozoficznie " różni się nieco od schematu ze
strony 225. Jest ona dobrym przykładem na to. jak dobry algorytm może
przyspieszyć rozwiązanie postawionego problemu, dla którego znana jest prosta,
ale nieefektywna metoda (przeszukiwanie liniowe).
9.2.Algorytmy „żarłoczne " , czyli przekąsić coś
nadszedł już czas...
Nazwa nowej metody jest bardzo intrygująca, ale w literaturze przedmiotu
przyjęło się nazywać pewną klasę metod jako „żarłoczne " (ang. greedy, franc.
glouton). Algorytmy te służą do odnajdywania rozwiązań, które mają zastoso
wanie w odszukiwaniu przepisu na rozwiązanie danego problemu. Przepis ten
jest obarczony pewnymi założeniami (ograniczeniami), które mogą na przykład
żądać podania rozwiązania optymalnego wg pewnych kryteriów. Chcąc skonstruować ów przepis, mamy do czynienia z szeregiem opcji tworzących zbiór
danych wejściowych. Cechą szczególną algorytmu „żarłocznego " jest to, że
w każdym etapie poszukiwania rozwiązania wybiera on opcję lokalnie opty
malną. W zależności od tego doboru, rozwiązanie globalne może być również
optymalne, ale nie jest to gwarantowane. Omawiana metoda najlepiej odpowiada
pewnej klasie zadań natury optymalizacyjnej: podać najkrótszą drogę w grafie,
określić optymalną kolejność wykonywania pewnych zadań przez komputer etc.
Metoda algorytmów „żarłocznych " odpowiada ludzkiej naturze, gdyż bardzo
często otrzymując jakieś zadanie zadowalamy się jego szybkim i w miarę poprawnym rozwiązaniem, choć niekoniecznie optymalnym.
Schemat generalny algorytmu jest następujący:
Żarłok(W)
{
ROZW=O; // zbiór pusty
dopóki (nie Znaleziono(ROZW) i W!=O}
wykonuj:
{
X=Wybór(W);
jeśli Odpowiada(X) to ROZW=ROZW U {X} ; //U - suma zbiorów
}
jeśli Znaleziono(ROZW) zwróć ROZW;
9.2. Algorytmy żarłoczne, czyli...
235
w przeciwnym wypadku zwróć „nie ma rozwiązania "
}
W opisie metody zostały użyte następujące oznaczenia:
W
— zbiór danych wejściowych;
ROZW
— zbiór, na podstawie którego będzie konstruowane rozwiąza
nie;
element zbioru;
X
Wyhór(A)
- funkcja dokonująca „optymalnego " wyboru elementu ze
zbioru A (usuwając go z niego);
Odpowiada(X)
- czy wybierając X można tak skompletować rozwiązanie
cząstkowe, aby odnaleźć co najmniej jedno rozwiązanie
globalne?
Znaleziono(R)
— czy R jest rozwiązaniem zadania?
Powyższy zapis wyjaśnia nazwę metody: na każdym etapie dobieramy najlep
szy kąsek, nie troszcząc się specjalnie o przyszłość... Popatrzmy na kilka przy
kładów zastosowania nowej metody.
9.2.1.Problem plecakowy, czyli niełatwe jest życie turysty-piechura
Wczujmy się teraz w rolę turysty wybierającego się na dłuższą pieszą wyciecz
kę po górach. Aby urealnić przykład, niech naszym zadaniem będzie dotarcie
na szczyt pewnej góry w Pirenejach, gdzie znajduje się „punkt zbiorczy " , który
nasi wspólni znajomi wybrali na zorganizowanie „przyjęcia " na łonie natury.
Do punktu docelowego zmierza w sumie pięć osób - każda z nich zobowiązała
się dostarczyć imponującą ilość wiktuałów, tak aby umówioną imprezę uczynić
iście królewską ucztą. Nie będziemy wnikać w zbędne szczegóły usiłując od
gadnąć, co niosą ze sobą pozostałe cztery osoby, zajmiemy się jedynie naszym
prywatnym problemem, który napotkaliśmy przygotowując wyprawę. Załóżmy,
że zostaliśmy obarczeni zadaniem dostarczenia kilku gatunków dobrych serów
i niespecjalnie wiemy, jak upakować je w wolnej przestrzeni plecaka.
Nasz plecak posiada gwarantowaną przez producenta pojemność 60 litrów, z czego
zostało nam M=20 litrów na część kulinarną. Reszla już jest wypełniona nie
zbędnymi do przeżycia w górach elementami, pozostał nam jedynie dylemat
optymalnego wypełnienia reszty plecaka. Chcemy wziąć w sumie trzy gatunki
sera (s1, s2 i s3). W domowej lodówce owe sery znajdują się w ilościach w1.
w2 i w3 litrów. Każdy z serów jest doskonały, niemniej możemy im przypisać
orientacyjne ceny cl, c2 i c3. które pozwalają ustawić je w swoistym rankingu
jakości. Naszym celem jest wzięcie z każdego gatunku sera takiej jego ilości
(0 & lt; =xl, x2, x3 & lt; =1), aby w sumie nie przekroczyć maksymalnej pojemności
236
Rozdział 9. Zaawansowane techniki programowania
Kilka przykładów zamieszczonych w tabelce 9 - 1 ilustruje różnorodność potencjal
nych rozwiązań w przypadku nietrywialnej konfiguracji danych wejściowych
(taka wystąpiłaby, gdyby suma w, była mniejsza od M- Czytelnik z łatwością
odgadnie właściwe rozwiązanie w przypadku takiej sytuacji... ). Chwilowo spo
śród trzech wymyślonych ad hoc rozwiązań optymalnym jest drugie, nic nam
jednak nie gwarantuje, że nie istnieją lepsze konfiguracje parametrów xi.
Trzeba w tym miejscu być może podkreślić, że podstawowa idea, na której zo
stała zbudowana procedura żarłok, nie gwarantuje optymalności.
Tabela 9-1.
Przykładowe rozwią
zania problemu pleca
kowego.
Wręcz przeciwnie, w typowym przypadku otrzymane rozwiązanie1 będzie tylko
prawie optymalne!
Przy takim postawieniu sprawy można dość szybko zniechęcić się do omawianej
metody... o ile nie przypomnimy sobie uwagi zawartej na samym wstępie tego
rozdziału; problemy, które będziemy chcieli rozwiązywać, mogą wymusić
adaptację omawianych meta-algorytmów, każda próba bezmyślnego ich stosowania
spali (prawdopodobnie) na panewce.
Jeśli oczywiście istnieje!
9.2. Algorytmy żarłoczne, czyli...
237
Aby to dokładniej zilustrować, przeanalizujmy kilka możliwych strategii roz
wiązania problemu plecakowego przy użyciu algorytmu „żarłocznego " . Pierwsze,
pozornie optymalne rozwiązanie polega na próbach wypełniania plecaka przy
pomocy najdroższego sera (s1): jeśli jego całkowita objętość mieści się w wolnej
przestrzeni, to bierzemy go w całości, w przypadku przeciwnym ucinamy taki
jego kawałek, aby nie przekroczyć objętości M i zużyć możliwie największy
kawałek tego sera. Następnie zajmujemy się w sposób analogiczny kolejnym
w rankingu cen serem itd.
Cóż, wystarczy przetestować „ręcznie " kilka konfiguracji otrzymanych przy
pomocy tej metody, aby się przekonać, że nie daje ona najlepszych rezultatów.
Najlepszym przykładem może tu być analiza tabelki 9 - 1 , zwłaszcza pozycji 1 i 2.
Przyczyna nieoptymalności rozwiązania jest relatywnie prosta: efekt końcowy
(funkcja, którą chcemy zmaksymalizować) zależy nie tylko od aktualnej wartości
wkładanych serów, ale i od ich objętości. Może zatem należy patrzeć w pierwszej
kolejności nie na parametr c, ale na w?
Kilka prób dokonanych „z ołówkiem w ręku " prowadzi nas jednak do niezbyt
zachęcających rezultatów także i w tym przypadku i znowu możemy zwątpić
w sens metody...
Jeśli obie analizowane „skrajności " nie prowadzą do optymalnego rozwiązania.
to jedyne co nam pozostaje, to zmienić strategię postępowania w taki sposób,
aby obiektywnie uwzględniała oba parametry (w, c)jednocześnie. Okazuje się,
że jeśli wstępnie poustawiamy dane wejściowe w taki sposób, aby dla dowol
nego i zachować stosunek:
to algorytm „żarłoczny " prowadzi do rozwiązania optymalnego. Aby nie
nasycać tego podręcznika zbędną porcją matematyki, dowód powyższego twier
dzenia sobie darujemy, gdyż nie jest on istotny.
Popatrzmy na program w C++, który rozwiązuje nasz dylemat plecakowy:
greedy.cpp
const n-3;
v o i d g r e e d y ( d o u b l e M , d o u b l e W [ n ] , d o u b l e C [ n ] , d o u b l e X[n])
{
double Z=M; // pozostaje do wypełnienia
for(int i=0;i & lt; n;i++)
{
if(W[i] & gt; 2)
x[i]=l;
Z=Z-W[i];
break;
238
Rozdział 9. Zaawansowane techniki programowania
if(i & lt; n)
X[i]=Z/W[i];
}
void main()
{
d o u b l e W [ n ] = { 1 0 , 1 2 , 1 6 } , C [ n ] = { 6 0 , 7 0 , 8 0 } , X[n] = { 0 , 0 , 0 } ;
greedy(20,W,C,X);
double p=0;
for(int i=0; i & lt; n;p+=X[i] *C[i],i++)
c o u t & lt; & lt; i & lt; & lt; " \ t " & lt; & lt; W[i] & lt; & lt; " \ t " & lt; & lt; C [ i ] & lt; & lt; " \ t "
& lt; & lt; X[i] & lt; & lt; endl;
cout & lt; & lt; " Total: " & lt; & lt; p & lt; & lt; endl;
Okazuje
się,
że
rozwiązaniem
optymalnym
jest
wektor
x
=
ności danych, w jakiej są zamieszczone na listingu, gdzie nastąpiła już wstępna
„obróbka " wg zacytowanego wcześniej wzoru.
Wniosek z analizy problemu plecakowego powinien być dla Czytelnika następują
cy: przed przystąpieniem do kodowania programu w naszym ulubionym języku
programowania (niekoniecznie w C++), warto poświęcić kilka minut na refleksję,
co może znakomicie zwiększyć jakość otrzymanego rozwiązania końcowego.
9.3. Programowanie dynamiczne
Zalety programowania rekurencyjnego uwidaczniają się w prostocie i natural
ności formułowania rozwiązań. Niestety rekurencja ma swoje drugie oblicze,
o którym łatwo zapomnieć rozważając ją w kategoriach czysto matematycznych.
Chodzi oczywiście o to. jak naprawdę formula rekurencyjna zostanie wykonana
przez komputer, ile będzie „kosztowało " zrealizowanie wywołań rekurencyjnych. powrotów z nich, kombinowanie rezultatów cząstkowych etc.
Może się zatem okazać, że formalnie szybki algorytm rekurencyjny (rozumując
w kategoriach klasy O) będzie znacznie wolniejszy niż to wynika z obliczeń
teoretycznych.
Sposobów na zaradzenie temu zjawisku jest kilka (patrz np. rozdział 6). między
innymi jest wśród nich... pisanie tylko procedur iteracyjnych!
Wprowadzanie rewolucji w programowaniu w postaci powszechnego zakazu
stosowania rekurencji nie jest bynajmniej celem tej książki. Postawmy zatem
problem inaczej: czy jest możliwe wykorzystanie korzyści, płynących z rekurencyjnego formułowania rozwiązań, bez używania rekurencji?
3 . Programowanie dynamiczne
_ _ _
239
Wbrew pozorom nie jest to paradoks - technika programowania dynamicznego
bazuje właśnie na tym - zdawałoby się niemożliwym do zrealizowania - po
stulacie. Nadaje się ona szczególnie dobrze do rozwiązywania problemów o cha
rakterze numerycznym:
• obliczanie najkrótszej drogi w grafach (które poznamy szczegółowo w roz
dziale 10);
• wyliczenie pewnej skomplikowanej wartości podanej przy pomocy równania
rekurencyjnego...
Konstrukcja programu wykorzystującego zasadę programowania dynamicznego
może być sformułowana w trzech etapach:
koncepcja;
• dla danego problemu P stwórz rekurencyjny model jego rozwiązania
(wraz z jednoznacznym określeniem przypadków elementarnych);
• stwórz tablicę, w której będzie można zapamiętywać rozwiązania
przypadków elementarnych i rozwiązania pod-problemów, które zo
staną obliczone na ich podstawie;
inicjacja:
• wpisz do tablicy wartości numeryczne, odpowiadające przypad
kom elementarnym;
progresja:
• na podstawie wartości numerycznych wpisanych do tablicy uży
wając formuły rekurencyjnej, oblicz rozwiązanie problemu wyższego
rzędu i wpisz je do tablicy:
• postępuj w ten sposób do osiągnięcia pożądanej wartości.
Być może powyższy zapis brzmi enigmatycznie, ale jak to wyniknie z dalszych
przykładów, metoda jest naprawdę nieskomplikowana. Zanim jednak przej
dziemy do ilustracji tej techniki programowania, porównajmy ją z wcześniej
poznaną metodą „dziel-i-rządź " .
dziel-i-rządź "
• problem rzędu N rozłóż na pod-problemy mniejszego „kalibru " i roz
wiąż je;
• połącz rozwiązania pod-problemów w celu otrzymania rozwiąza
nia globalnego.
240
Rozdział 9. Zaawansowane techniki programowania
„programowanie dynamiczne "
• mając dane rozwiązanie problemu elementarnego, wylicz na jego
podstawie
• problem wyższego rzędu i kontynuuj obliczenia, aż do otrzy
mania rozwiązania rzędu N.
Nowa technika ma pewien posmak optymalności: raz znalezione rozwiązanie
pewnego pod problemu zostaje zarejestrowane w tablicy i w miarę potrzeb jest
później wykorzystywane. Nie był to bynajmniej przypadek metody „dziel-irządź " , która pozwalała na wielokrotne wyliczanie łych samych wartości.
Nowo poznaną metodę zilustrujemy dwoma przykładami o różnym stopniu skom
plikowania, zaczynając od... doskonale nam znanego problemu obliczania elemen
tów ciągu Fibonaceiego (patrz §2-4.1). Przypomnimy (po raz kolejny) definicję
tego ciągu:
fib(n) = f(n-1) + fib(n) gdzie n & gt; = 2
Rozwiązanie rekurencyjne testowaliśmy już kilkakrotnie, spróbujmy teraz
zaadoptować rekurencyjną procedurę obliczania tego ciągu do podanych powy
żej zasad konstrukcji programu wykorzystujących programowanie dynamiczne:
koncepcja - wzór rekurencyjny już mamy, pozostaje tylko zadeklarować
tablicę fib[n] do składowania obliczanych wartości;
inicjacja - początkowymi wartościami w tablicy fib będą oczywiście
warunki początkowe: fib[0]= I i fib[1]=1;
progresja algorytmu - ten punkt zależy ściśle od wzoru rekurencyjnego, który
implementujemy przy pomocy tablicy. W naszym przypadku wartością fib[i]
w tablicy (dla i & lt; = 2) jest suma dwóch poprzednio obliczonych wartości:
fib[i-1] i fib[i-2]. Obie te wartości zostały zapamiętane w tablicy, zupełnie
jak w programie rekurencyjnym. który zapamiętuje je... na stosie wywołań
rekurencyjnych.
Zauważmy jednak, że tej analogii nie można posunąć zbyt daleko, bowiem
nasze postępowanie ma charakter sekwencji instrukcji elementarnych bez
dodatkowych wywołań proceduralnych, tak jak to czyni każdy program re
kurencyjny.
Powyższe uwagi są zilustrowane na rysunku 9 - 2 .
9.3. Programowanie dynamiczne
241
Rys. 9 - 2.
Obliczanie warto
ści ciągu liczb
Fibonacciego.
Zupełnie już dla formalności podam procedurę, która realizuje omówione
uprzednio obliczenia:
void fib_dyn(int x, int f[])
{
f[0]=l;
f[1]=1;
for (int i = 2 ; i & lt; x ; i + + )
f[i]=f[i-l]+f[i-2];
}
Nieco bardziej skomplikowana sytuacja występuje w przypadku równań rekurencyjnych posiadających więcej niż jedną zmienna. Popatrzmy na następujący
wzór:
Mamy tu do czynienia z dwiema zmiennymi, i oraz j, interesuje nas obliczenie
wartości parametru P. Powyższy wzór jest dość nieprzyjemny już na pierwszy
rzut oka - można również udowodnić, że jest bardzo kosztowny, jeśli chodzi o
czas obliczeń. Mamy zatem doskonały przykład dowodzący, że jeśli nic musimy
stosować rekurencji, to najlepiej byłoby tego w ogóle nie czynić... pod warunkiem
posiadania alternatywnych dróg rozwiązania.
Technika programowania dynamicznego taką drogę podpowiada. Sposób obli
czenia wzoru rekurencyjnego jest trywialny, jeśli wpadniemy na pomysł użycia
tablicy dwuwymiarowej, której „współrzędne " pozioma i pionowa będą odpo
wiadać zmiennym i oraz j. Popatrzmy na rysunek 9 - 3 przedstawiający ogólną
ideę programu obliczającego wartości P(i,j).
Z uwagi na specyfikę problemu wygodnie będzie zainicjować tablicę już na
samym wstępie warunkami początkowymi (zera i jedynki w odpowiednich
242
Rozdział 9. Zaawansowane techniki programowania
miejscach), chociaż w zoptymalizowanej wersji można by tę część wbudować
w pętlę główną programu. Do obliczenia wartości P(i, j) potrzebna jest znajo
mość dwóch sąsiednich komórek: dolnej - P(i,j-1) oraz tej znajdującej się z le
wej strony - P(i-I, j). Uwaga la prowadzi nas do spostrzeżenia, że naturalnym
sposobem obliczania wartości P(i,j) będzie posuwanie się „zygzakiem " zazna
czonym na rysunku 9 - 3 .
Rys. 9 - j .
„Dwuwymiarowy "
wzór rekurencyjny.
Gdy mamy te wszystkie informacje, realizacja programowa jest natychmiastowa:
c o n s t n=5;
v o i d dynam(double P[n] [ r ] )
{
int
i,j;
for(i=1;i & lt; n;i++)
//
inicjacja
{
P[i] [0]=0;
P[0][i]-1;
}
for(j=1;j & lt; n;j++)
// p r o g r e s j a
for(i=l;i & lt; n;i++)
P[i][j]=(P[i-1][j]+P[i][j-l])/2,0;
}
Nietrudno jest zauważyć, że program powyższy jest dokładnym odbiciem wzoru
rekurencyjnego-jedyny nasz wysiłek polega w zasadzie na tym. żeby znaleźć
prawidłowy sposób wypełniania tablicy. Celowo podkreślam, że prawidłowy.
bowiem w przypadku rekurencji dwu- i więcej wymiarowych (Jeśli możemy
sobie na takie określenie pozwolić...) możemy bardzo łatwo popełnić błąd po
legający na próbie wykorzystania wartości z tablicy, które w danym etapie nie są
jeszcze obliczone. Tego typu potknięcia są czasami bardzo trudne do wykrycia,
więc warto przy tym szczególnie uważać.
9.4. Uwagi bibliograficzne
243
9.4. Uwagi bibliograficzne
W tym rozdziale mieliśmy okazję poznać kilka prostych technik programowania.
których efektywne użycie może znacznie zwiększyć sprawność programisty
w rozwiązywaniu problemów przy pomocy komputera. Oczywiście, nie są to
wszystkie meta-algorytmy, które można napotkać w literaturze problemu wybór padł na te techniki, które nie są zbyt nudne do pojęcia i nie wymagają
pogłębionych studiów informatycznych.
Czytelnika zainteresowanego głębszymi studiami w dziedzinie technik progra
mowania szczególnie zachęcam do sięgnięcia po [HS78] - książkę napisaną bardzo
prostym językiem (cóż z tego, że angielskim. .) i zawierającą bardzo szczegóło
we omówienie wielu różnorodnych strategii i technik programowania. Osoby
zainteresowane wykorzystaniem struktur drzewiastych w rozwiązywaniu pro
blemów algorytmicznych mogą połączyć lekturę ostatniej pozycji z [ N i l 8 2 ] .
W przypadku braku dostępu do oryginalnego tytułu Nilssona dużo cennych in
formacji znajduje się również w [BC89]. Ostatnia praca, którą można polecić, to
[CP84], ale może być dość trudna do zdobycia - j e s t to skrypt, w związku z tym
należy go szukać nie na francuskim rynku wydawniczym, ale w tamtejszych
bibliotekach uczelnianych.
Rozdział 10
Elementy algorytmiki grafów
Grafy są niczym innym jak strukturą danych i poświęcenie im osobnego
rozdziału może wzbudzić pewne zdziwienie u Czytelnika. Zabieg ten wydaje się
jednak konieczny z uwagi na szczególne znaczenie grafów w algorytmice. Nie
jest przesadą stwierdzenie, iż bez tej struktury danych niemożliwe byłoby
rozwiązanie wielu problemów algorytmicznych.
Ciraty posiadają dość złożoną podbudowę teoretyczną (w zasadzie można nawet
wyodrębnić osobny dział matematyki tylko im poświęcony), ale w naszej
prezentacji postaramy się uniknąć zbytniego formalizowania.
Odrobiona teorii zostanie przedstawiona jedynie w celu ścisłego umiejscowienia
omawianego problemu, ale z założenia będzie to niezbędne minimum.
Czytelnikom zainteresowanym głębiej teorią grafów można w zasadzie polecić
dowolny podręcznik algorytmiki, gdyż ta struktura danych zajmuje poczesne
miejsce w literaturze przedmiotu. Interesujące podejście, będące mieszaniną
matematyki i informatyki, prezentuje [Hel86], ale nie jestem jednak w stanie
potwierdzić, czy tytuł ten jest już dostępny na rynku wydawniczym w formie
książkowej, czy też pozostał na zawsze uproszczonym skryptem uczelnianym.
Celem tego rozdziału jest zaprezentowanie minimalnej wiedzy (temat jest bowiem
ogromny) dotyczącej grafów i sposobów ich reprezentacji w programach. Poznamy
niezbędne słownictwo związane z tą strukturą danych, jak również przedstawimy
kilka typowych algorytmów, które ich dotyczą.
Patrząc z perspektywy historycznej, grafy „narodziły się " w roku 1736 dzięki
niemieckiemu matematykowi L. Eulerowi. Przy ich pomocy rozwiązał on
problem, który stawiali sobie mieszkańcy Koenigsberg, a mianowicie jak
przemierzyć wszystkie siedem mostów znajdujących się w tym mieście, tak aby
nie przechodzić dwukrotnie przez ten sam.
246
Rozdział 10. Elementy algorytmiki grafów
Ta historyczna anegdota stanowi jednocześnie doskonały przykład na to, do
czego graty mogą się w praktyce przydać: wszelkie zadania algorytmiczne,
w których w grę wchodzą problemy odnajdywania (optymalnych) dróg, mogą być
przez grafy doskonale modelowane. Oczywiście nie tylko one!
Programista, który dobrze pozna i zrozumie możliwości związane z użyciem
grafów, praktycznie podwaja swoje kompetencje związane z umiejętnością
sprawnego modelowania problemów do rozwiązania Dość paradoksalną stroną
wielu zagadnień programistycznych jest to, że potrafią one rozwiązywać się
niemalże „same " pod warunkiem dobrego zmodelowania całości.
Następny paragraf będzie poświęcony podstawowemu słownictwu związanemu
z grafami, po czym przejdziemy do sposobów reprezentowania ich w progra
mach komputerowych.
10.1.Definicje i pojęcia podstawowe
Niezbyt duże grafy doskonale dają się przedstawiać w postaci wiele mówiących
rysunków, takich jak np. 10 - 1.
Rys. 10 - 1.
Przykład grafu.
Grafem G nazywamy parę (X, T), gdzie X oznacza zbiór tzw. węzłów (albo wierz
chołków), a T zbiór (x,y) € X2 jest zespołem krawędzi.
Graf jest skierowany, jeśli krawędziom został przypisany jakiś kierunek (na
rysunkach symbolizowany przez strzałkę).
Jeśli weźmiemy pod uwagę dwa węzły grafu. x i y, połączone krawędzią, to
węzeł x jest węzłem początkowym, a węzeł y węzłem końcowym.
10.1 Definicje i pojęcia podstawowe
247
Graf z rysunku 10 - 1 posiada 6 węzłów; A, B, C, D, E i F, niektóre z nich są
połączone pomiędzy sobą krawędziami: (A,B), (B,C), (B,D), (D,F), (D,E) i (E,F).
Węzeł C ma charakter specjalny, bowiem wychodzi z niego krawędź, która...
wraca z powrotem do swojego węzła początkowego! W niektórych zagadnieniach algorytmicznych i takie dziwne „krawędzie " są potrzebne, bowiem można
przy ich pomocy modelować więcej sytuacji niż tylko z użyciem samych wę
złów i krawędzi.
Numery węzłów (lub też symboliczne etykiety literowe) służą w zasadzie tylko do
rozróżniania węzłów, bez przypisywania im jednakże jakiejś określonej kolejności.
Programista może jednak w razie potrzeby narzucić numerom węzłów dodat
kowe znaczenie (np. w teorii gier będzie to rekord opisujący stan gry).
Z definicji pomiędzy dwoma węzłami może istnieć tylko jedna krawędź, ale
możliwe jest bardzo łatwe przejście z grafu, który nie jest zgodny z naszą definicją
(patrz 1 0 - 2 (a)), do grafu „standardowego " poprzez zwykle dołożenie sztucz
nych wierzchołków (rysunek 10 - 2 (b)).
Rys. 10- 2.
„Normalizowanie "
grafu(1).
Pojęcie grafu skierowanego ma charakter najogólniejszy, gdyż graf nieskierowany (patrz rysunek 10-3 (a)) może być bardzo łatwo przetransformowany na
skierowany (rysunek 10-3 (b)).
Rys. 10- 3.
„Normalizowanie "
grafu(2).
Dla pewnych zastosowań celowe jest przypisanie krawędziom grafu wartości
(najczęściej liczbowych, ale mogą to również być etykiety innego typu). Zmienia
nam się wówczas definicja grafu, gdyż zamiast dwójki (X, T) mamy (X, T, V). Trze
ci parametr V oznacza właśnie zbiór wartości odpowiadających danym krawędziom.
W teorii grafów można napotkać jeszcze sporo innych definicji i pojęć, ale my
chwilowo poprzestaniemy na tych zaprezentowanych powyżej. Nowe pojęcia, jeśli
okażą się niezbędne, będą systematycznie wprowadzane w trakcie wykładu.
Obecnie przejdziemy do opisu kilku typowych metod reprezentowania gra
fów w pamięci komputera.
248
Rozdział 10. Elementy algorytmiki grafów
10.2.Sposoby reprezentacji grafów
Poznane uprzednio struktury danych, takie jak tablice, listy i drzewa dobrze
nadają się do reprezentowania grafów. Dwie reprezentacje można uznać jednak
za dominujące: przy pomocy tablicy dwuwymiarowej i tzw. słownika węzłów,
Graf może być reprezentowany przy użyciu tablicy dwuwymiarowej, jeśli
umówimy się, że wiersze będą oznaczały węzły początkowe krawędzi gra
fu, a kolumny ich węzły końcowe Przy takiej umowie graf z rys. 10-1 może być przedstawiony w postaci tablicy z rysunku 10 - 4.
Rys. !0 - 4.
Tablicowa repre
zentacja grafu.
A
C
D
B
1
1
c
1
A
D
E
B
E
F
1
1
1
1
1
F
Jedynka na pozycji (s, y) oznacza, że pomiędzy węzłami v i y istnieje krawędź
skierowana w stronę v. W każdym innym przypadku tablica będzie zawierała na
przykład zero.
Zauważmy, że reprezentacja tablicowa ma jedną istotną zaletę: jest bardzo prosta
do implementacji programowej w dowolnym w zasadzie języku programowania,
a ponadto korzystanie z niej nie jest trudne. Wada. jedynie grafy o ustalonej z góry
liczbie węzłów mogą być łatwo reprezentowane w postaci tablic.
Aby przedstawić graf o liczbie węzłów, która może ulegać zmianie w trakcie
wykonywania się programu, należy użyć np. reprezentacji przy pomocy słowni
ka węzłów.
Słownik węzłów może dotyczyć dwóch typów węzłów: następników (węzłów
odchodzących) lub poprzedników (węzłów dochodzących) od danego węzła.
Idea jest przedstawiona na rysunku 10 - 5.
Słownik jest zwykłą tablicą wskaźników do list węzłów, odpowiednio odcho
dzących (a) lub dochodzących (b) do danego węzła przy pomocy krawędzi.
Niektóre algorytmy dotyczące grafów potrzebują właśnie tego typu informacji,
stąd celowość dysponowania taką reprezentacją. Biorąc pod uwagę, że słownik
węzłów jest łatwo implementowalny w postaci listy list, znika nam automa
tycznie problem napotkany przy reprezentacji tablicowej - ilość węzłów grafu
może być w zasadzie nieograniczona.
10.2. Sposoby reprezentacji grafów
Rys. 10-5.
Reprezentacja
grafu przy pomocy
słownika węzłów.
249
a)
b)
A
B
B
C,D
A NULL
B
A
C C,E
C B.C
D E,F
D
B
F
E
C,D
F NULL
F
D,E
E
Warto w tym miejscu „odświeżyć " oznaczenia matematyczne poznane w rozdziale 3.
250
Rozdział 10. Elementy algorytmiki grafów
{
z=0;
while(l) // pętla nieskończona
{
if (z==n)
break;
if ((gl[x][z]==l) & & (g2[z][y]==l))
{
g3[x][y]=l;
break;
}
z++;
}
}
}
• potęga grafu
Potęga Gp jest zdefiniowana w sposób rekurencyjny:
D oznacza tzw. graf diagonalny, czyli taki, w którym istnieją wyłącznie
" krawędzie " typu (x,x). Z potęgą grafu jest związane dość ciekawe twierdze
nie: (x,y) należy do Gp wtedy i tylko wtedy, jeśli w G istnieje droga o długości p
która prowadzi od węzła x do węzła y
Graf jest dość ciekawym wytworem z punktu widzenia matematyki, gdyż zupełnie
naturalnie pozwala on przez samą swoją konstrukcję wyrazić relacje binarne
zdefiniowane na zbiorze swoich wierzchołków X.
Elementarnym przykładem niech będzie pojęcie symetrii, jeśli istnienie krawędzi (x,
y) implikuje istnienie krawędzi (y, x) to możemy powiedzieć o grafie, że jest on sy
metryczny. W podobny sposób można zdefiniować całkiem sporo innych relacji bi
narnych. z których większość... nie ma żadnego praktycznego zastosowania. Wyjąt
kiem jest relacja przechodniości. która oznacza, że każda droga grafu G o dłu
gości większej lub równej 1 jest „podtrzymywana " przez jakąś krawędź.
Dlaczego relacja przechodniości jest taka ważna? Otóż przechodniość sama w
sobie dość paradoksalnie nic nic oznacza. Jest ona po prostu dość wygodnym
środkiem do zdefiniowania tzw. domknięcia przechodniego grafu, oznaczanego
typowo przezG =(X, T), gdzie:
G ={(x, y)} istnieje droga od x do y w grafie G}
10.3. Podstawowe operacje na grafach
251
Jeśli umiemy dokonać domknięcia przechodniego grafu, to umiemy odpowie
dzieć na ważne pytanie, czy możliwe jest przejście po krawędziach grafu od
jednego wierzchołka do drugiego. Zauważmy, że domknięcie przechodnie nie
daje przepisu na przejście od danego wierzchołka do wierzchołka y; dowiadujemy się tylko, że jest to możliwe.
Jednym z możliwych sposobów na obliczenie domknięcia przechodniego gra
fu jest wyliczenie go w sposób przedstawiony poniżej:
(n oznacza ilość wierzchołków grafu, czyli nieco formalniej n=|X|).
Zaletą powyższego algorytmu jest prostota zapisu, w a d ą - czego nietrudno się
domyślić - złożoność realizacji i duży koszt otrzymywanych algorytmów.
Czytelnik dysponujący dużą ilością wolnego czasu może bez zbytniego wysiłku
wymyślić co najmniej jeden algorytm, który realizuje domknięcie przechodnie
wg powyższego przepisu. Warto może jednak z góry uprzedzić, że nie będzie to
miało specjalnego sensu, gdyż istnieje inny algorytm, który przewyższa jakością
wszelkie wariacje algorytmów otrzymanych na podstawie „potęgowania " gra
fów. Jest to słynny algorytm Roy-Warshalla, który zostanie omówiony w para
grafie następnym.
10.4.Algorytm Roy-Warshalla
Algorytm omawiany w tym paragrafie charakteryzuje się kilkoma cechami, które
powodują, że w zasadzie jest on bezkonkurencyjny, jeśli chodzi o obliczanie
domknięcia przechodniego grafu. Przede wszystkim nie używa on żadnych gra
fów dodatkowych (czego nie da się uniknąć w przypadku algorytmów opartych
na potęgowaniu), a ponadto pozwala dość łatwo odtworzyć drogę, którą należy
pójść, aby przejść po krawędziach od jednego wierzchołka do drugiego.
Zapis powyższy oznacza, że dla danego wierzchołka k do zbioru krawędzi Y
dorzucamy krawędzie łączące poprzedniki i następniki tego wierzchołka.
252
Rozdział 10. Elementy algorytmiki grafów
Algorytm zapisuje się bardzo prosto w C + + :
warshall.cpp
void war3hall(int g[n][n])
{
f o r ( i n t x=0;x & lt; n;x++)
f o r ( i n t y=0;y & lt; n;y++)
for(int z=0;z & lt; n;z++)
if(g[y][z]==0)
g[y][z]=g[y] [x]*g[x] [ z ] ;
W celu dokładnego zrozumienia tego programu prześledźmy jego wykonanie na
przykładzie prostego grafu 5-węzłowego przedstawionego na rysunku 10-6.
Rys. 10- 6.
Przykładowe wy
konanie
algorytmu
Roy-Warshalla
Efekt wykonania
algorytmu Roy-Warshalla
(Zamiast tradycyjnych " jedynek " na rysunku zostały użyte znaki X.
Z tablicy na rysunku 1 0 - 6 możemy odczytać m i n . następujące informacje:
•
nie jest możliwe dojście do węzłów o numerach 0 i 4\
•
- węzła o numerze l możemy dojść do 2, 3 i... 1 (natrafiliśmy na tzw.
obwód zamknięty),
Nawet na tak prostym przykładzie możemy już co najmniej „poczuć " ogromne
możliwości, jakie oferuje nam algorytm Roy-Warshalla.
Jest on niesłychanie prosty zarówno ideowo, jak i w zapisie, co klasyfikuje go
do grona algorytmów, które prywatnie określam jako „eleganckie " (J. Bentley
używa do tego celu wyrażenia „perła " programistyczna).
4. Algorytm Roy-Warshalla
253
Algorytm Roy-Warshalla może być w dość prosty sposób zmodyfikowany, tak
aby dostarczyć informacji nie tylko o istnieniu drogi wiodącej od wierzchołka x
do wierzchołka y, ale oprócz tego podać przepis którędy należy pójść...
W celu zidentyfikowania drogi (oczywiście jeśli w ogóle ona istnieje!) przyporządkujemy macierzy reprezentującej graf tzw. macierz kierowania ruchem
(ang. routing) R. Jest ona zdefiniowana w sposób następujący:
• R[x,y]=0, jeśli nic ma drogi, która wiedzie od x do y;
• R[x, y]=z, gdzie z oznacza „następny " wierzchołek na drodze od x do y.
Konstrukcja matrycy umożliwia w naturalny sposób odtworzenie drogi wiodącej od
danego wierzchołka do innego:
route.cpp
void p i s z ( i n t x, i n t y, int R[n][n])
{
i n t k;
if(R[x][y]==0)
cout & lt; & lt; " Drogi nie ma\n " ;
else
{
c o u t & lt; & lt; x & lt; & lt; e n d l ;
k=x;
while(k!=y)
{
k=R[k][y];
c o u t & lt; & lt; k & lt; & lt; e n d l ;
}
}
c o u t & lt; & lt; e n d l ;
}
Wiemy już, jak wypisać drogę na podstawie macierzy R, najwyższa zatem pora
na przedstawienie algorytmu, który ją prawidłowo dla danego grafu wylicza.
Przedstawiona poniżej procedura route „zakłada " , że matryca R przekazana
jej w parametrze została zainicjowana uprzednio w następujący sposób;
• R[x,y]=0, jeśli nic istnieje krawędź (x,y);
• R[x,y]=y, w przeciwnym przypadku.
Zapis procedury jest ekstremalnie prosty:
void r o u t e ( i n t R [ n ] [ n ] )
{
f o r ( i n t x=0;x & lt; n;x++)
f o r ( i n t y=0;y & lt; n;y++)
if(R[y][x]!=0) // wiemy jak dojść z 'y' do 'x'
for(int z=0;z & lt; n;z++)
254
Rozdział 10. Elementy algorytmiki grafów 10.
if(R[y][z]==0 & & R[x][z]!=0)
R[y][z]=R[y][x];
}
A l g o r y t m jest oczywistą wariacją algorytmu poznanego uprzednio i wyg
podobnie jak on niewinnie... jednak matematyczny dowód na to, że działa po
prawnie, bynajmniej nie jest prosty.
Popatrzmy na efekt wykonania procedury route dla grafu przedstawionego na
rysunku 1 0 - 7 .
Droga od 0 do 2:0 3 1 2
Droga od 1 do 0: Drogi nie ma
Droga od 1 do 5: 1 2 4 5
Droga od 2 do 0: Drogi nie ma
Droga od 4 do 2: 4 5 2
Droga od 5 do 3: Drogi nie ma
Rys. 10- 7.
Poszukiwanie
dragi w grafie.
Kolejnym problemem, który o m ó w i m y , jest znajdowanie w grafie drogi opty
malnej pod względem kosztów.
10.5.Algorytm Floyda
Nietrudno jest domyślić się, że nasze nowe zadanie będzie wymagało użycia
grafów, które charakteryzują się przypisaniem wartości liczbowych swoim
krawędziom.
A l g o r y t m Floyda zaprezentujemy przy użyciu następujących założeń:
•
•
Dysponujemy matrycą W, w której są zapamiętane wartości przypisane
krawędziom grafu:
W[i,i]=0;
wartość optymalnej drogi będzie zapamiętywana w matrycy D
•
Ideę algorytmu w zrozumiały sposób prezentuje następujący przykład:
Załóżmy, że szukamy optymalnej drogi od i do j. W tym celu „przechadzamy "
się po grafie, próbując ewentualnie znaleźć inny, pośredni wierzchołek A, którego
„wbudowanie " w drogę umożliwiłoby otrzymanie lepszego wyniku niż już ob
liczone D[i. j]. Znajdujemy pewne k i zadajemy pytanie: czy przejście przez
wierzchołek k poprawi nam wynik, czy nie? Popatrzmy na rysunek 10 - 8. który
przedstawia odpowiedź na to pytanie w nieco bardziej poglądowej formie niż
goły wzór matematyczny (przedstawiony obok).
RYS.
10 - 8.
Algorytm Floyda
Jest oczywiste, że w przypadku większej ilości takich „optymalnych " wierz
chołków pośrednich należy wybrać najlepszy z nich!
Przedstawiony poniżej program jest najprostszą formą algorytmu Floyda, która
wyłącznie oblicza wartość optymalnej drogi, ale jej nie zapamiętuje.
floyd.cpp
void
{
floyd(int
g[n][n])
f o r ( i n t k=0;k & lt; n;k++)
for(int i=0;i & lt; n;i++)
for(int j=0;j & lt; n;i++)
g[i][j]=min( g [ i ] [ j ] ,
g[i][k]+g[k][j]);
}
Popatrzmy na rysunek 10 - 9, który przedstawia przykład wyboru optymalnej
drugi przez algorytm Floyda.
Załóżmy, że interesuje nas optymalna droga od wierzchołka nr 0 do wierzchołka
numer 4. Z uwagi na dość prostą topografię grafu, widać, że mamy do wyboru dwie
drogi: 0-1-4 i nieco dłuższą: 0-1-2-4.
Elementarne obliczenia wykazują, że druga trasa jest efektywniejsza (koszt: 45)
od pierwszej (koszt: 50).
256
Rozdział 10, Elementy algorytmiki gaó
rf w
Rys. 10- 9.
Algorytm Floyda
(2).
Brak możliwości odtworzenia optymalnej drogi jest dość istotną wadą,
gdyż o ile w przypadku małych grafów (takich jak ten z rysunku 10 - 9) może
my ją ewentualnie odczytać sami, to przy naprawdę dużych grafach jest to
praktycznie niewykonalne.
Potrzebna nam jest zatem jakaś prosta modyfikacja algorytmu Floyda, która nie
zmieniając jego zasadniczej idei, umożliwi zapamiętanie drogi.
Jak się okazuje, rozwiązanie nie jest trudne. Do oryginalnego algorytmu (patrz
listing wyżej) należy wprowadzić następującą poprawkę:
if(
{
g[i][k]+g[k][j] & lt; g[i][j])
g[i][j]=g[i][k]+g[k][j];
R[i][j]=k;
...
Optymalna droga będzie zapamiętywana w matrycy kierowania ruchem R,
Czytelnikowi nie powinno sprawie zbytniego kłopotu napisanie procedury, któ
ra odtwarza znajdującą się w niej drogę. Załóżmy, że początkowo matryca R
jest wyzerowana. Aby odtworzyć optymalną drogę od wierzchołka i do wierz
chołka j, patrzymy na wartość P[i][j]- Jeśli jest ona równa zero, to mamy do
czynienia z przypadkiem elementarnym, tzn. z krawędzią, którą należy przejść.
Jeśli nie, to droga wiedzie od i do P[i][j] i następnie od P[i][j] do j. Z uwagi
na to. że powyższe dwie " pod-drogi'' mogą nie być elementarne, łatwo zauwa
żyć rekurencyjny charakter procedury.
Liczę na to, że Czytelnik nie będzie miał kłopotów z j e j stworzeniem... w ra
mach pożytecznego ćwiczenia!
|
.5. Algorytm Floyda
257
6.Przeszukiwanie grafów
Dużo interesujących zadań algorytmicznych, w których użyto grafu do mode
lowania pewnej sytuacji, wymaga systematycznego przeszukiwania grafu,
„ślepego " lub kierującego się pewnymi zasadami praktycznymi (tzw. heurystykami). W szczególności temat ten jest przydamy we wszelkich zagadnieniach
związanymi z tzw. teorią gier. ale do tej kwestii jeszcze powrócimy w rozdziale 12.
Teraz skupimy się na dwóch najprostszych technikach przechadzania się po
grafach: strategii „w głąb " (ang. depth first) i strategii „wszerz " (ang. breadth
first). Analizując przykłady, będziemy się koncentrować na samym procesie prze
szukiwania, bez zastanawiania się znad jego celowością. Pamiętajmy zatem, że w
ostateczności przeszukiwanie grafu ma „czemuś " służyć: odnaleźć optymalną
strategię gry, rozwiązać łamigłówkę lub konkretny problem techniczny przed
stawiony przy pomocy grafów...
Uwaga: nasze przykłady będą używały wyłącznie reprezentacji tablicowej
grafów. Zabieg ten pozwala na uproszczenie prezentowanych przykła
dów, ale należy pamiętać, że nie jest to jedyna możliwa reprezentacja! W
przypadku algorytmów przeszukiwania dla bardzo dużych grafów użycie
tablic jest niemożliwe. Jedynym wyjściem w takiej sytuacji jest użycie re
prezentacji popartej na słowniku węzłów. Wiąże się z tym modyfikacja
wspomnianych algorytmów, zatem dla ułatwienia zostaną również podane
w pseudo-kodzie, tak aby możliwe było ich przepisanie na użytek kon
kretnej struktury danych.
10.6.1.Strategia „w głąb "
Nazwa tytułowej techniki eksploracji grafów jest związana z topologicznym
kształtem ścieżek, po których się przechadzamy podczas badania grafu. Algorytm
przeszukiwania „w głąb " bada daną drogę, aż do j e j całkowitego wyczerpania
(w przeciwieństwie do algorytmu „wszerz " , który najpierw bada wszystkie
poziomy grafu o jednakowej głębokości) 1 . Jego cechą przewodnią jest zatem
maksymalna eksploatacja raz obranej drogi, przed ewentualnym wybraniem
następnej.
Rysunek 10 - 10. przedstawia niewielki graf, który posłuży nam za ilustrację
problemu.
1
W naszej dotychczasowej dyskusji na temat gratów nie używaliśmy, co prawda, za
cytowanej powyżej terminologii, ale jej znaczenie powinno się szybko wyjaśnić podczas
analizy konkretnych przykładów.
258
Rozdział 10. Elementy algorytmiki gaó
rfw
Rys. 10- 10.
Lista
Przeszukiwanie
wierzchołków przyległych:
grafu, " w głąb " .
Lista wierzchołków przyległych do danego wierzchołka jest dla ułatwienia w
y
pisana obok grafu " .
Algorytm przeszukiwania „w głąb " zapisuje się dość prosto w C++:
int i , j , G[n][n], V[n];
// G - g r a f nxn
// V - p r z e c h o w u j e i n f o r m a c j e , czy dany w i e r z c h o ł e k
//
b y ł j u ż b a d a n y (1) l u b n i e (0)
void s z u k a j ( i n t G [ n ] [ n ] , i n t V[n])
{
int
depthf.cpp
i;
for ( i = 0 ; i & lt; n ; i + + )
V[i]=0; // wierzchołek nie był jeszcze badany
for(i=0;i & lt; n;i++)
if(V[i]==0)
zwiedzaj (G,V,i);
}
v o i d z w i e d z a j ( i n t G[n] [ n ] , i n t V [ n ] , i n t i )
{
V[i]=l;
/ / zaznaczamy w i e r z c h o ł e k j a k o " z b a d a n y "
for ( i n t k=0;k & lt; n;k++)
if(G[i][k]!=0) // istnieje przejście
if(V[k]==0)
zwiedzaj(G,V,k);
}
Jak łatwo zauważyć, składa on się z dwóch procedur: szukaj, która inicjuje sam
proces przeszukiwania i zwiedzaj, która tak ukierunkowuje proces przeszukiwania,
aby postępował on naprawdę " w głąb " . Procedura zwiedzaj przeszukuje listę
wierzchołków przylegających do wierzchołka 'i' zatem jej właściwa treść (w
pseudokodzie) przedstawia się w ten sposób:
2
Kolejność elementów w tej liście jest związana z użyciem reprezentacji tablicowej, w której
indeks tablicy (czyli numer węzła) z góry narzuca pewien porządek wśród węzłów.
.6. Przeszukiwanie grafów
zwiedzaj(i)
{
zaznacz ' i ' jako " zbadany " ;
d l a każdego wierzchołka 'k'
259
przyległego do
'i'
jeśli 'k' nie był już zbadany
zwiedzaj(k)
}
Uruchomienie programu poinformuje nas, że kolejność przeszukiwanych
wierzchołków jest następująca: 0. I, 2. 6, 3, 4 i 5.
Lista wierzchołków przyległych do danego wierzchołka jest dla ułatwienia
wypisana obok grafu. Zastanówmy się, czy jest to rzeczywiście przeszukiwanie
„w głąb " . Zgodnie z pętlą for zawadą w procedurze szukaj, pierwszym przeszuki
wanym wierzchołkiem będzie 0 i on też zostanie jako pierwszy zaznaczony jako
„zbadany " (1). Przylegają do niego trzy wierzchołki 1, 3, i 4 i dla nich zostanie
kolejno wywołana procedura zwiedzaj (tym razem rekurencyjnie). Wierzchołek
2 zostaje zaznaczony jako „zbadany " , a następnie badana jest lista wierzchołków przyległych do niego (0, 2 i 4). Ponieważ wierzchołek 0 został już wcze
śniej przebadany, to następnym będzie 2. dla którego ponownie zostanie wywołana procedura zwiedzaj, (Oczywiście, zanim to nastąpi, zostanie on zazna
czony jako „zbadany " ). Wierzchołkami przyległymi do 2 są 1 i 6, ale ponieważ
1 został już zbadany, procedura zwiedzaj zostanie wywołana tylko dla 6 itd.
Postępując dalej tą drogą, można odtworzyć sposób pracy algorytmu prze
szukiwania „w głąb " dla całego grafu.
10.6.2.Strategia „wszerz "
Do analizy przeszukiwania „wszerz " użyjemy takiego samego grafu jak w przy
kładzie poprzednim. Rysunek został jednak uzupełniony o elementy ułatwiające
zrozumienie nowej idei przeszukiwania.
Rys. 10-11.
Przeszuk iwanie
grafu „wszerz " .
Załóżmy, że rozpoczynamy od wierzchołka 0. Na liście wierzchołków przyległych
znajdują się kolejno: 1, 3 i 4 i te właśnie wierzchołki zostaną jako pierwsze
260
Rozdział 10. Elementy algorytmiki grafów
przebadane podczas przeszukiwania. Dopiero polem algorytm weźmie p d
o
uwagę listy wierzchołków przyległych, wierzchołków j u ż przebadanych: (0, 2,
(0, 4, 6) i (0, 1, 3, 5). W konsekwencji, kolejność przeszukiwania grafu z rysunku
1 0 - 1 1 , będzie taka: 0, 1, 3, 4, 2, 6 i 5.
Jak jednak zapamiętać, podczas przeszukiwania danego wierzchołka i, że mamy
jeszcze ewentualne inne wierzchołki czekające na przebadanie? Okazuje się,że
najlepiej jest do tego wykorzystać zwykłą kolejkę3, która „sprawiedliwie " ob
służy wszystkie wierzchołki, zgodnie z kolejnością ich wchodzenia do kolejki
(poczekalni).
Zawartość kolejki dla naszego przykładu przedstawiać się będzie zatem w ten
sposób:
Rys. W- 12.
Zawartość kolejki
podczas przeszu
kiwania grafu
„wszerz " .
Algorytm przeszukiwania „wszerz " , zapisany w C++, przedstawia się następująco:
breadthf.cpp
void s z u k a j ( i n t G [ n ] [ n ] , i n t V [ n ] , i n t i )
// rozpoczynamy od wierzchołka ' i '
// G - graf nxn
// V
przechowuje informację; czy dany wierzchołek
//
był już badany (1) lub nie (0)
{
FIFO & lt; int & gt;
kolejka(n);
Kolejka.wstaw(i);
int
s;
while(!kolejka.pusta())
{
kolejka.obsluz(s);// bierzemy z kolejki pewien
V[s]=l;
// wierzchołek ' s '
/ / zaznaczamy ' s ' j a k o " z b a d a n y "
f o r ( i n t k=0;k & lt; n;k++)
if(G[s][k]!=0)
if(V[k]==0)
3
Patrz 5.4
// istnieje przejście
// ' k ' n i e był j e s z c z e badany
10.6. Przeszukiwanie grafów
261
Sens tego algorytmu może być wyjaśniony znacznie czytelniej w pseudo-kodzie
szukaj (i)
{
wstaw ' i ' d o k o l e j k i ;
dopóki k o l e j k a n i e j e s t p u s t a wykonuj:
{
wyjmij z kolejki pewien wierzchołek 's';
zaznacz 's' jako " zbadany " ;
dla każdego wierzchołka 'k' przyległego do 's'
j e ś l i ' k ' n i e b y ł już zbadany
{
zaznacz 'k' jako " zbadany " ;
wstaw 'k' do k o l e j k i ;
}
}
}
10.7.Problem właściwego doboru
Kończąc rozważania dotyczące grafów, pragnę zaprezentować bardzo ciekawe
i złożone zagadnienie: problem doboru (lub inaczej minimalizowania konfliktów)
Będzie to kolejny dowód na to, że dobry model ułatwia odnalezienie właściwego
rozwiązania.
Ponieważ sformułowanie zagadnienia w postaci czysto matematycznej jest bardzo
nieczytelne, prześledźmy jego ideę na przykładzie wziętym z życia. Wyobraźmy so
bie następującą sytuację: mamy N studentów i N tematów prac magisterskich. Do
każdej pracy magisterskiej jest przypisany jeden promotor (profesor danej uczelni).
zatem z obu stron mamy do czynienia z czynnikiem ludzkim. Każdy student ma
pewną opinię (preferencję) na temat danej pracy i z pewnością woli jedne tematy od
innych. Również nie każdy profesor lubi jednakowo wszystkich studentów i z pew
nością wolałby pracować ze znanym mu studentem X niż z niezbyt mu kojarząc} m
się studentem Y, który systematycznie opuszczał jego wykłady...
Oczywiście,problem doboru nie ogranicza się wyłącznie do kręgów akademickich
i może być odnaleziony w przeróżnych postaciach w rozmaitych dziedzinach życia
262
Rozdział 10. Elementy algorytmiki grafów
Dlaczego jest on rozwiązywany przy pomocy grafów? Cóż, chyba najlepiej zilu
struje to rysunek 10 - 13.
Rys. 10 - 13.
Problem doboru.
Rysunek przedstawia jedno z możliwych rozwiązań problemu doboru dla N=5 stu
dentów i prac. Zadanie jest przedstawione w postaci specjalnego grafu, w którym
węzły są pogrupowane według kategorii i ustawione obok siebie. Warto sobie jed
nak zdawać sprawę, że laka forma wizualizacji jest przydatna wyłącznie dla czło
wieka, gdyż komputer nic widzi różnicy pomiędzy ustawieniem „ładnym " i „brzyd
kim " , (Struktura graficzna konkretnego doboru jest po prostu grafem, w którym
mamy do czynienia z pewną liczbą par węzłów). Jeśli węzły i oraz j są ze sobą połą
czone, to oznacza to, źe zostały one dobrane (nieważne czy dobrze, czy źle). Ozna
cza to, że niedopuszczalne jest wykorzystanie węzła więcej niż jeden raz.
Analizując problem doboru, stajemy nieuchronnie wobec problemu wyrażania
preferencji. Każdy student musi mieć opinię o danej pracy i jej promotorze,
każdy promotor musi jasno określić swoje preferencje dotyczące określonych
osób. Okazuje się, że naturalną metodą są tzw. listy rankingowe: opinią stu
denta X na temat pracy Y będzie jej pozycja na jego liście rankingowej prac
magisterskich, podobne listy będą musieli stworzyć profesorowie o studentach.
Omówiona sytuacja jest przedstawiona na rysunku 10 - 14.
Rys. 10- 14.
Listy rankingowe
W problemie
doboru.
117. Problem właściwego doboru
?63
Nietrudno zauważyć, że o ile samo dobranie A' dwójek {student, praca} jest
trywialne, to jednoczesne sprostanie bardzo zróżnicowanym wymaganiom tylu
osób nie jest bynajmniej takie proste. Weźmy pod uwagę następującą propozy
cję: D-0, E-1, A-2, B-3, C-4 Jest to niewątpliwie jakieś rozwiązanie problemu
doboru (bowiem żaden węzeł nie jest wykorzystany więcej niż raz), ale czy na
pewno dobre? Student D dostał temat 0, który na jego liście zajmował dalekie,
trzecie miejsce. Zgodnie ze swoimi wymaganiami wolałby on zapewne dostać
lemat 3. Temat 3 przypadł jednak studentowi B. Promotor zajmujący się tematem 3.
na swojej liście preferencyjnej umieścił bardzo wysoko studenta D, a tymcza
sem „dostał " studenta A! Mamy więc dość zabawną sytuację:
D-0
D woli bardziej 3 od 0
B-3
3 woli bardziej D od B
Rozwiązanie zaproponowane powyżej jest zwane niestabilnym, gdyż rodzi po
tencjalne konflikty personalne... Ideałem byłoby znalezienie takiego algorytmu.
który proponowałby możliwie najbardziej stabilny wybór, uwzględniający w naj
większym możliwym stopniu dostarczone listy rankingowe. Pamiętając o tzw.
czynniku ludzkim, powinno być jasne, dlaczego zadanie nie jest łatwe do rozwią
zania: listy rankingowe będą miały po prostu bardzo nierównomierne rozkłady
Pewne tematy będą lubiane przez przeważającą większość, inne znajdą się na
szarym końcu. O ile samo dobranie N dwójek wydaje się niekłopotliwe z pro
gramistycznego punktu widzenia, to sprawdzenie stabilności wydaje się dość zło
żone. Kłopot sprawia tu mnogość potencjalnych rozwiązań, z których każde
należałoby sprawdzić pod kątem jego stabilności. Zatem algorytm typu bruteforce, który najpierw losuje potencjalne rozwiązanie (jest ich przecież skoń
czona liczba), a potem sprawdza jego stabilność, byłby bardzo nieefektywny.
Zagadnienie dobom było wszechstronnie studiowane i wydaje się, że zostało
znalezione rozwiązanie, które charakteryzuje się pewną „inteligencją " w po
równaniu z bezmyślnym algorytmem typu brute-force. Jego idea polega na
systematycznym powtarzaniu schematu cząstkowego doboru:
•
student i proponuje temat j, który znajduje się najwyżej na jego liście ran
kingowej:
jeśli promotor j nie wybrał jeszcze studenta, to
„związek " (i,j) jest tymczasowo akceptowany.
jeśli promotor j zaakceptował już tymczasowo studenta k, to
związek (k, j) może zostać złamany na rzecz studenta j pod warunkiem, ze
promotor lubi bardziej j niż wcześniej wybranego k. W konsekwencji
student k znów staje się wolny i w jednym z następnych etapów będzie
musiał zaproponować lemat ze swojej listy rankingowej, następny po
uprzednio odrzuconym.
264
Rozdział 10. Elementy algorytmiki grafów
l Używając danych z rysunku 10-14, algorytm mógłby potoczyć się według eta
pów z tabeli 10- 1.
Tabela 10-1
Problem doboru na
przykładzie.
Pora już na omówienie kodu C++, który zajmie się rozwiązaniem problemu
właściwego doboru. Jego względna prostota opiera się na wykorzystaniu jedynie
tablic liczb całkowitych, dzięki czemu wszelkie manipulacje danymi ulegają mak
symalnemu uproszczeniu1:
breadthf.cpp
int nastepny[5]={-1,-1,-1,-1,-1);
// zapamiętuje ostatni wybór, na samym początku
// n a s t e p n y [ - l + 1] =0, p ó ź n i e j posuwamy s i ę o 1
// pozycje dalej podczas danego etapu wyboru
i n t d o b ó r [ 5 = { - 1 , - l , - 1 , - 1 , - 1 } ; // rozwiązanie zadania
int wybiera[5][5]= { // preferencje studentów
{0,4,3,2,1},
{1,0,4,2,3},
{0,3,1,2,4},
{3,4,0,1,2},
{4,3,2,1,0}};
/*
/*
/*
/*
/*
A
B
C
D
E
*/
*/
*/
*/
*/
// preferencje promotorów:
// lubi[i][0| = nr A na liście 'i'
// lubi[i][1] = nr B na liście 'i' itd.
i n t l u b i [ 5 ] [ 5 ] = { / * A B C D E */
{2,1,3,4,C},
{0,1,2,4,3},
{4,3,2,0,1},
{2,3,4,0,1}};
Wszelkie dane liczbowe są zgodne z rysunkiem 10 - 14.
1D.7. Problem właściwego doboru
265
Algorytm doboru można zamknąć w rozbudowanej funkcji main:
v o i d main()
{
int student, wybierający, promotor, odrzucony;
for (student=0; student & lt; 5;student++)
{
wybierajacy=student;
while(wybierający!=-1)
{
następny[wybierajacy]++;
promotor=wybiera[wybierający] [ n a s t ę p n y [ w y b i e r a j ą c y ] ];
i f ( d o b ó r [ p r o m o t o r ] = = - 1 ) //promotor (i jego temat) j e s t wolny
{
dobór[promotor]=wybierajacy;
wybierajacy=-l;
}
else
{
i f ( l u b i [ p r o m o t o r ] [ w y b i e r a j ą c y ] & lt; l u b i [ p r o m o t o r ] [ d o b ó r [promotor])
{
odrzucony=dobor[promotor];
dobór[promotorl=wybierajacy;
wybierajacy=odrzucony;
}
}
)
for (int i=0;i & lt; 5;i++)
cout & lt; & lt; " (Promotor " & lt; & lt; i & lt; & lt; " , student "
& lt; & lt; (char)(dobor[i]+'A') & lt; & lt; " )\n " ;
}
Spróbujmy przeanalizować pracę programu, ukazując poszczególne wybory
dokonywane przez studentów i informując o łamanych związkach:
•
Wybierającym staje się A i próbuje on temat (promotora) 0;
•
Temat (promotor) 0 był wolny i zostaje on przyznany studentowi A;
•
Wybierającym staje się B i próbuje on temat (promotora) 1;
•
Temat (promotor) 1 był wolny i zostaje on przyznany studentowi B;
•
Wybierającym staje się C i próbuje on temat (promotora) 0;
•
Promotor 0 porzuca swój aktualny wybór A na rzecz C;
•
Wybierającym staje się porzucony A i próbuje on temat (promotora) 4;
•
Temat (promotor) 4 był wolny i zostaje on przyznany studentowi A;
•
Wybierającym staje się D i próbuje on temat (promotora) 3;
•
Temat (promotor) 3 był wolny i zostaje on przyznany studentowi D;
•
Wybierającym staje się E i próbuje on temat (promotora) 4;
266
Rozdział 10. Elementy algorytmiki grafów
•
Promotor 4 porzuca swój aktualny wybór A na rzecz E;
•
Wybierającym staje się A i próbuje on temat (promotora) 3;
próbuje on temat (promotora) 2;
•
Temat (promotor) 2 był wolny i zostaje on przyznany studentowi A.
Ostateczne w y n i k i :
(Promotor 0,
(Promotor 1.
(Promotor 2,
(Promotor 3,
student C)
student B)
student A)
student D)
(Promotor 4, student E)
Omówiony algorytm doboru nie jest idealny, gdyż jak łatwo się przekonać te
stując go praktycznie, liniowy charakter pętli for, która czyni aktywnymi
uczestnikami wyłącznie studentów (oni bowiem proponują, a promotorzy czekają
biernie na nadchodzące oferty), nie wpływa na sprawiedliwość ostatecznego
wyniku. Skomplikowane wersje powyższego algorytmu zmieniają uczestników
aktywnych w danym etapie na uczestników biernych i odwrotnie. Powodem pre
zentacji obecnej wersji była j e j prostota i chęć pokazania ciekawej techniki
rozwiązywania pozornie złożonych zagadnień.
10.8.Podsumowanie
Na tym zakończymy naszą krótką przygodę z grafami. Jak już wspomniałem na
początku, poznaliśmy wyłącznie elementy teorii grafów. Liczę jednak na to, że
zaprezentowany do tej pory materiał - pomimo że znacznie „ocenzurowany "
wobec bogactwa istniejących t e m a t ó w - przyda się znacznej ilości Czytelników,
zachęcając ich być może do sięgnięcia po zacytowaną na początku rozdziału
literaturę.
Rozdział 11
Algorytmy numeryczne
Przez dziesiątki lat pierwszym i głównym zastosowaniem komputerów było
szybkie dokonywanie obliczeń (do dziś dla wielu ludzi słowa " komputer " i „kalkulator " są synonimami...). Dziedzina tych zastosowań pozostaje ciągle aktualna,
lecz należy zdawać sobie sprawę, że wielokrotne wymyślanie tych samych
rozwiązań ma znikomy sens praktyczny. W minionych latach powstała cala
gama gotowych programów potrafiących rozwiązywać typowe problemy mate
matyczne (np. obliczanie układów równań, interpolacja i aproksymacja, całkowanie
i różniczkowanie, przekształcenia symboliczne...)1 i osobie szukającej wyrafi
nowanych możliwości można polecić zakup takiego narzędzia.
Celem tego rozdziału będzie ukazanie kilku przydatnych metod z dziedziny
algorytmów numerycznych, takich, które potencjalnie mogą znaleźć zastosowanie jako część większych projektów programistycznych. Nie będziemy się
zbytnio koncentrować na matematycznych uzasadnieniach prezentowanych
programów, ale postaramy się pokazać jak algorytm numeryczny daje się
przetłumaczyć na gotowy do użycia kod C++, Algorytmy cytowane w tym
rozdziale zostały opracowane głównie na podstawie prac: dostępnego w Polsce
skryptu [Kla87] oraz klasycznego dzieła [Knu69], ale Czytelnik nie powinien
mieć trudności z dotarciem do innych podręczników poruszających tematykę
algorytmów numerycznych, gdyż powstało ich dość sporo w ostatnich latach.
Wszystkie prezentowane w tym rozdziale programy są kompletne pod każdym
względem i w zasadzie użytkownik potrzebujący konkretnej procedury może
z nich korzystać jak ze swego rodzaju „książki kucharskiej " .
Np. Eureka , Mathcad.
268
Rozdział 1 1 . Algorytmy numeryczne
11.1 .Poszukiwanie miejsc zerowych funkcji
Jednym z częstych problemów, z jakimi mają do czynienia matematycy, jest poszu
kiwanie miejsc zerowych funkcji. Metod numerycznych, które umożliwiają rozwią
zanie takiego zadania przy pomocy komputera jest dość sporo, my ograniczymy się
do jednej z prostszych - do tzw. metody Newtona. W skrócie polega ona na syste
matycznym przybliżaniu się do miejsca zerowego przy pomocy stycznych do
krzywej, tak jak to pokazuje rysunek 1 1 - 1 .
Rys. II-I.
Algorytm Newtona
odszukiwania
miejsc zerowych.
- & gt; x
Z punktu widzenia programisty, algorytm Newtona sprowadza się do iteracyjnego powtarzania następującego algorytmu (i oznacza etap iteracji):
•
stop. jeśli f ( z i ) & lt; E.
Symbol E - oznacza pewną stalą (np. 0,00001) gwarantującą zatrzymanie algo
rytmu. Oczywiście, na samym początku inicjujemy Zo pewną wartością począt
kową, musimy ponadto znać jawnie równania f i f' (funkcji i jej pierwszej po
chodnej) " .
" Musimy je wpisać do kodu programu w C++ „na sztywno " .
11.2. Iteracyjne obliczanie wartości funkcji
269
Zaproponujemy rekurencyjną wersję algorytmu, który przyjmuje jako parametry
m.in. wskaźniki do funkcji reprezentujących f i f',
Popatrzmy dla przykładu, na obliczanie przy pomocy metody Newtona zera funkcji
3x -2. Procedura zero jest dokładnym tłumaczeniem podanego na samym
początku wzoru:
newton.cpp
const double epsilon=0.0001;
2-
double f (double x) // funkcja f = 3x 2
{
return 3*x*x-2;
}
2
{
double fp(double x) // pochodna f' = (3x -2)' = 6x
return 6*x;
}
double zero(double x0,
double(*f)(double),
double(*fp)(double)
{
)
if (f ( x 0 ) & lt; e p s i l o n )
return x0;
else
return z e r o ( x 0 - f ( x C ) / f p ( x 0 ) , f , f p ) ;
}
void main()
{
cout & lt; & lt; z e r o ( 1 , f, fp) & lt; & lt; endl; // wynik 0,816497
}
Użycie wskaźników do funkcji pozwala uczynić procedurę zero bardziej
uniwersalną, ale oczywiście nic stoi na przeszkodzie, aby używać tych funkcji
w sposób bezpośredni.
11.2.Iteracyjne obliczanie wartości funkcji
Jak efektywnie obliczać wartość wielomianów, dowiemy się szczegółowo w rozdziale 13, przy okazji omawiania tzw. schematu Homera. Obecnie zajmiemy się
dość rzadko używanym w praktyce, ale czasami użytecznym algorytmem iteracyjnego obliczania wartości funkcji.
Załóżmy, że dysponujemy jawnym wzorem pewnej funkcji występującym w tzw.
postaci uwikłanej:
270
Rozdziału. Algorytmy numeryczne
F(x, y)=0.
(funkcję w klasycznej postaci y=f(x) można łatwo sprowadzić do postaci
uwikłanej). Oznaczmy pochodną cząstkową, liczoną względem zmiennej y
przez F'y(x, y)!=0 Przyjmując pewne uproszczenia, można za pomocą metody
Newtona (patrz §11.1) obliczyć jej wartość dla pewnego x w sposób iteracyjny:
wartf. cpp
double wart(double x, double yn)
{
double ynl=2*yn-x*yn*yn;
/ / f a b s ( x ) - | x | w a r t o ś ć bezwzględna d l a danych double
if ( fabs(yn-ynl) & lt; epsilon)
r e t u r n ynl;
else
return
wart(x,ynl);
}
11.3.Interpolacja funkcji metodą Lagrange'a
W poprzednich paragrafach tego rozdziału, bardzo często korzystaliśmy jawnie
z wzorów funkcji i jej pochodnej. Cóż jednak począć, gdy dysponujemy frag
mentem wykresu funkcji (tzn. znamy jej wartości dla skończonego zbioru
argumentów) lub też wyliczanie na podstawie wzorów byłoby zbyt czasochłonne,
11.3. Interpolacja funkcji metodą Lagrange'a
271
z uwagi na ich skomplikowaną postać? Na pomoc, w obu przypadkach, przy
chodzą tzw. metody interpolacji funkcji, tzn. przybliżania jej przy pomocy prostej
funkcji (np. wielomianu określonego stopnia), tak aby funkcja interpolacyjna
przechodziła dokładnie przez znane nam punkty wykresu funkcji jak na ry
sunku 11-2.
Rys. 11- 2.
Interpolacja funkcji f(x) przy pomo
cy wielomianu
F(x).
W zobrazowanym na nim przykładzie dysponujemy 7 parami (x0,y0)... (x6,y6)
i na tej podstawie udało nam się obliczyć wielomian F(x), dzięki któremu obli
czanie wartości f(X) staje się o wiele prostsze (choć czasami wyniki mogą być
dalekie od prawdy).
Wielomian interpolacyjny konstruuje się przy pomocy kłopotliwego oblicze
niowo wyznacznika Vandermonde'a, który pozwala na wyliczenie współczyn
ników poszukiwanego wielomianu. Jeśli jednak zależy nam tylko na wartości
funkcji w pewnym punkcie z. to istnieje prostsza i efektywniejsza metoda
Lagrange'a:
Pomimo dość makabrycznej postaci, wzór powyższy tłumaczy się bezpośrednio
na kod C++, przy pomocy dwóch zagłębionych pętli for:
interpol.cpp
const int n=3; //stopień wielomianu Interpolujacego
// wartości funkcji (y[i]=f(x[i]))
double
x[n+1]={3.0,
double y[n+1] = { l . 7 3 2 ,
5.0,
2.236,
6.0,
2.449,
7.0};
2.646};
double interpol(double z, double x[n], double y[n])
272
Rozdział 11. Algorytmy numeryczne
//zwraca wartość funkcji w punkcie
{
d o u b l e wnz=C,om=l,w;
f o r ( i n t i=0;i & lt; =n;i++)
'z'
(czyli
F(z))
{
om=om*(z-x[i]);
w=1.0;
for(int
j=0;j & lt; =n;j++)
if (i!=j) w=w*(x[i]-x[j]);
wnz=wnz+y[i]/(w* ( z - x [ i ] ) ) ;
}
r e t u r n wnz=wnz*om;
}
void main()
{
double z=4.5;
cout & lt; & lt; " Wartość funkcji
" wynosi " & lt; & lt; interpol(z,x,y) & lt; & lt; endl;
}
11.4.Różniczkowanie funkcji
W poprzednich paragrafach tego rozdziału bardzo często korzystaliśmy z wzorów
funkcji i jej pochodnej wpisanych wprost w kod C++. Czasami jednak, obliczenie
pochodnej może być kłopotliwe i pracochłonne, przydają się wówczas metody,
które radzą sobie z tym problemem bez potrzeby korzystania z jawnego wzoru
funkcji.
Jedną z popularniejszych metod różniczkowania numerycznego jest tzw. wzór
Stirlinga Jego wyprowadzenie leży poza zakresem tej publikacji, dlatego zde
cydowałem się zademonstrować jedynie rezultaty praktyczne, nie wnikając
w uzasadnienie matematyczne.
Wzór Stirlinga pozwala w prosty sposób obliczyć pochodne f' i f " w punkcie
x0, dla pewnej funkcji f(x), której wartości znamy w postaci tabelarycznej:
... (x0-2h, f(x0-2h)), (x0-h. f(x0-h)), (x 0 , f(x 0 )), (x 0 +h, f(x0+h)), (x0+2h, ,0f(x0+2h))...
Parametr h jest pewnym stałym krokiem w dziedzinie wartości x.
Metoda Stirlinga wykorzystuje tzw. tablice różnic centralnych, której konstrukcję
przedstawia tabela 1 1 - 1 .
11.4. Różniczkowanie funkcji
273
tabela 11-1.
tablica różnic
centralnych
Przyjmując upraszczające założenie, że zawsze będziemy obliczali pochodne
dla punktu centralnego x=x0, wzory Stirlinga przyjmują następującą postać:
Punktów „kontrolnych " funkcji może być oczywiście znacznie więcej niż 5;.
w naszym przykładzie skoncentrujemy się na bardzo prostym przykładzie z pięcio
ma wartościami funkcji, co prowadzi do tablicy różnic centralnych niskiego rzędu.
Wzorcowy program w C++, wyliczający pochodne dla pewnej funkcji f(x); może
wyglądać następująco:
interpol.cpp
const i n t n=5; //rząd obliczanych różnic centralnych wynosi n-1
double t [n][n+l]=
{
{0.8,
{0.5,
4.80}
5.85},
/ / p a r y ( x [ i ] , y [ i ] ) d l a y=5x 2 +2x
// zwróć uwagę na s p o s ó b i n i c j a c j i
Rozdziału. Algorytmy numeryczne
{1,
{1.1,
{1.2,
7.00},
8.25},
9.60}
/ / t a b l i c y : w p i s a n e s ą dwie p i e i w s z e
// kolumny, a nie w i e r s z e !
};
s t r u c t POCHODNE{double f 1 , f 2 ; } ;
POCHODNE s t i r l i n g ( d o u b l e t [ n ] [ n + l ] )
/ / f u n k c j a zwraca w a r t o ś c i f ' ( z ) i f " ( z ) g d z i e z
/ / j e s t elementem c e n t r a l n y m : t u t a j t [ 2 ] [ 0 ] , t a b l i c a
/ / ' t ' musi być u p r z e d n i o c e n t r a l n i e z a i n i c j o w a n a ,
// Poprawność j e j k o n s t r u k c j i nie jest s p r a w d z o n a !
{
POCHODNE r e s ;
d o u b l e h = ( t [ 4 ] [ 0 ] - t [ 0 J [ 0 ] ) / ( d o u b l e ) ( n - l ) ; / / krok ' x '
f o r ( i n t j=2;j & lt; =n;j++)
for(int i=0;i & lt; =n-j;i++)
t[i][j]=t[i+l][j-1]j-t[i][j-l];
res.f1=((t[1][2]+t[2][2])/2.0-(t[0][4]+t[l][4])/12.0)/h;
res.f2=(t[l][3]-t[0][5]/l2.0)/(h*h);
return res;
}
void
{
main
POCHODNE r e s = s t i r l i n g ( t ) ;
cout & lt; & lt; " f ' = " & lt; & lt; r e s . f 1 & lt; & lt; " , f ' ' = " & lt; & lt; r e s . f 2 & lt; & lt; e n d l ;
|
i
}
Jeśli już omawiamy różniczkowanie numeryczne, to warto podkreślić związaną
z nim dość niską dokładność. Im mniejsza wartość parametru h, tym większy
wpływ na wynik mają błędy zaokrągleń, z kolei zwiększenie h jest niezgodne
z ideą metody Stirlinga (która ma przecież przybliżać prawdziwe różniczkowanie!).
Metoda Stirlinga nie jest odpowiednia dla różniczkowania na krańcach prze
działów zmienności argumentu funkcji. Zainteresowanych tematem zapraszam
zatem do studiowania właściwej literatury przedmiotu, wiedząc, że temat jest
bogatszy niż się to wydaje.
11.5.Całkowanie funkcji metodą Simpsona
Całkowanie niektórych funkcji może być niekiedy skomplikowane, z uwagi na
trudność obliczenia symbolicznego całki danej funkcji. Czasami trzeba wykonać
dość sporo niełatwych przekształceń (np. podstawienia, rozkład na szeregi...), aby
otrzymać pożądany rezultat.
Na pomoc przychodzą tu jednak metody interpolacji (czyli przedstawiania
skomplikowanej funkcji w prostszej obliczeniowo, przybliżonej postaci. Ideę
całkowania numerycznego przedstawia rysunek 11-3.
.5. Całkowanie funkcji metodą Simpsona
275
Rys. 11-1.
Przybliżone
całkowanie
funkcji.
Na danym etapie i, trzy kolejne punkty funkcji podcałkowej są przybliżane pa
rabolą, co zapewnia dość dobrą dokładność całkowania (dla niektórych krzy
wych wyniki mogą być wręcz identyczne z tymi otrzymanymi z całkowania „na
kartce papieru " ). Dla rozpatrywanego fragmentu całka cząstkowa wyniesie:
Wzór powyższy, zwany wzorem Simpsona, wystarczy zastosować dla każdego
przedziału całkowanego obszaru, złożonego z 3 kolejnych punktów krzywej f(x).
Jedynym wymogiem jest takie dobranie odstępów h, aby były one jednakowe.
Zakładając zatem granice całkowania od a do b, przy podziale na 2n odcinków
będziemy mieli h=(b-a)/2n. Całka globalna będzie, oczywiście sumą całek
cząstkowych, obliczonych jak niżej:
slmpson.cpp
const int n=4; // ilość punktów= 2n+1
// funkcja przykładowa x2-3x+l w przedziale [-5,3]
double f[2*n+l]={41, 29, 19, 11, 5, 1, -1, -1, 1};
double simpson(double f [ 2 * n + 1 ] , double a, double b)
//funkcja zwraca c a ł k ę f u n k c j i f(x) w p r z e d z i a l e [ a , b ] ,
// k t ó r e j w a r t o ś c i sa. podane t a b e l a r y c z n i e w 2n+l punktach
{
double s = 0 , h = ( b - a ) / ( 2 . 0 * n ) ;
for(int i=0;i & lt; 2*n;i+=2) // skok co dwa punkty!
s+=h*(t[i]+4*f[i+l]+f[i+2])/3.0;
return s;
}
Oczywiście, całkowanie metodą Simpsona można również zastosować do scałkowania funkcji znanej w postaci analitycznej, a nie tylko tabelarycznej:
double fun(double x)
{
// funkcja f(x) jak w przykładzie powyżej
return X*X-3*X+1;
}
276
Rozdziału. Algorytmy numeryczne
d o u b l e simpson_f ( d o u b l e ( * f ) (double) ,//wskaźnik do f(x)
double a, double b, i n t N)
// funkcja zwraca c a ł k ę znanej w p o s t a c i wzoru
// f u n k c j i f(xł w p r z e d z i a l e [ a , b ] ,
// N - i l o ś ć podziałów
{
double s = 0 , h = ( b - a ) / ( d o u b l e ) N ;
for ( i n t i = l ; i & lt; = N ; i + + )
s+=h* (f ( a + ( i - l ) * h ) + 4 * f ( a - h / 2 . 0 + i * h ) + f ( a + i * h ) ) / 6 . 0 ;
return s;
}
!
v o i d main()
{
cout & lt; & lt; " Wartość c a ł k i = " & lt; & lt; s i m p s o n ( f , - 5 , 3 ) & lt; & lt; e n d l ;
cout & lt; & lt; " Wartość c a ł k i = " & lt; & lt; simpson_f ( fun, -5, 3, 8) & lt; & lt; e n d l ;
}
11.6.Rozwiązywanie układów równań liniowych
metodą Gaussa
Potrzeba rozwiązywania układów równań liniowych zachodzi w wielu dziedzi
nach, szczególnie technicznych. Biorąc pod uwagę, że w samym rozwiązywaniu
układów równań nie ma nic odkrywczego (uczono nas już tego w szkole
podstawowej!), cenne wydaje się dysponowanie procedurą komputerową,
która wykona za nas tę żmudną pracę.
Aby komputer mógł rozwiązać dany układ równań, musimy go uprzednio zapisać
w postaci rozszerzonej, tzn. nie eliminując współczynników równych zero i pisząc
zmienne w określonej kolejności. To wszystko ma na celu prawidłowe skonstru
owanie macierzy układu.
Układ równań:
5x+z=9
x-z+y=6
2x-y+z=0
musi zatem zostać przedstawiony jako:
5x+0y+1z=9
1x+1y-1z=6
2x-1y+1z=0
Wymnożenie tych macierzy powinno spowodować powrót do klasycznej,
czytelnej postaci.
Zaletą reprezentacji macierzowej jest możliwość zapisania wszystkich współ
czynników liczbowych w jednej tablicy Nx(N+I) i operowania nimi podczas
rozwiązywania układu. Operacje na tej macierzy będą odbiciem przekształceń
dokonywanych na równaniach (np. w celu eliminacji zmiennych, dodawania
równań stronami...).
Z uwagi na łatwość implementacji programowej, bardzo szeroko rozpowszech
nioną metodą rozwiązywania układów równań liniowych jest tzw. eliminacja
Gaussa. Przebiega ona zasadniczo w dwóch etapach: sprowadzania macierzy
układu do tzw. macierzy trójkątnej, wypełnionej zerami poniżej przekątnej, oraz
redukcji wstecznej, mającej na celu wyliczanie wartości poszukiwanych zmien
nych. W pierwszym etapie eliminujemy zmienną x Z wszystkich oprócz pierw
szego wiersza (poprzez klasyczne dodawanie wiersza bieżącego, pomnożonego
przez współczynnik, który spowoduje eliminację). W etapie drugim postępujemy
identycznie ze zmienną v i wierszem 2, w celu ostatecznego otrzymania macie
rzy trójkątnej. Popatrzmy na przykładzie:
•
eliminacja x z wierszy 2 i 3 (efekt dodawania wierszy jest pokazany
w etapie następnym);
278
Rozdział 11. Algorytmy numeryczne
Mając macierz w takiej postaci, można już pokusić się o wyliczenie zmiennych
(redukcja wsteczna, idziemy od ostatniego do pierwszego wiersza układu):
z=-0.6/0.6=-1
y=I.2z+4.2=3
x= (9-z)/5 =2
Metoda nie jest zatem skomplikowana, choć jej zapis w C++ może się wydać po
czątkowo nieczytelny. Jedyną „niebezpieczną " operacją metody eliminacji Gaussa
jest... eliminacja zmiennych, która czasami może prowadzić do dzielenia przez zero
(jeśli na etapie i eliminowana zmienna w danym równaniu nie występuje). Biorąc
jednak pod uwagę, że zamiana wierszy miejscami nie wpływa na rozwiązanie ukła
du, niebezpieczeństwo dzielenia przez zero może być łatwo oddalone poprzez taki
właśnie wybieg. Oczywiście, zamiana wierszy może okazać się niemożliwa ze
względu na niespełnienie warunku, jakim jest znalezienie poniżej wiersza i takiego
wiersza, który ma konfliktową zmienną różną od zera. W takim przypadku układ
równań nie ma rozwiązania, co też jest pewną informacją dla użytkownika!
Oto pełna treść programu wykonującego eliminację Gaussa, wraz z danymi przy
kładowymi:
gauss.cpp
const int N=3;
double x[N]; // wyniki
double a[N][N+l]=
{
{5 , 0, 1,
{1 , 1,
-1,
{2, - 1 , 1,
};
9},
6},
0}
i n t gauss(doublo a[N][N+l], double x[N])
{
i n t max;
double tmp;
for(int i=0;i & lt; N;i++) // eliminacja
{
max=i;
for(int j-i+1;j & lt; N;j++)
i f ( f a b s ( a [ j ] [ i ] ) & gt; fabs(a[max] [ i ] ) )
max=j;
/ / f a b s ( x ) = | x | , w a r t o ś ć bezwzględna dla danych double
for ( i n t k=i;k & lt; N+1;k++)
// zamiana wierszy
{
tmp=a[i][k];
a[i][k]=a[max][k];
a[max][k]=tmp;
11.6. Rozwiazywanie układów równań liniowych metodą Gaussa
279
}
if (a[i][i]==0)
return 0;
// Układ sprzeczny!
for(j=i+1;j & lt; N;j++)
for(k=N;k & gt; =i;k--)
//mnożenie wiersza j przez współczynnik
a[j][k]=a[j][k] - a [ i ] [ k ] * a [ j ] [ i ] / a [ i ] [ i ] ;
" zerujący " :
}
// redukcja wsteczna
for{int j=N-l;j & gt; =0;j--)
{
tmp=0;
for(int k=j+l;k & lt; =N;k++)
tmp=tmp+a[j][k]*x[k] ;
x[j]=(a[j][N]-tmp)/a[j][j];
}
r e t u r n 1;
// w s z y s t k o w p o r z ą d k u !
}
void main()
{
if(!gauss(a,x))
cout & lt; & lt; " Układ jest sprzeczny !\n " ;
else
{
cout & lt; & lt; " Rozwiązanie: \n " ;
for(int i=0;i & lt; N;i++)
cout & lt; & lt; " x[ " & lt; & lt; i & lt; & lt; " ]= " & lt; & lt; x[i] & lt; & lt; endl;
}
}
11.7.Uwagi końcowe
W tym krótkim rozdziale nie mogłem poruszyć wielu zagadnień z dziedziny
obliczeń numerycznych, jednak przedstawione zestawienie zawiera z pewnością
wybór najczęściej używanych w praktyce programów. Uwagi zawarte na jego
wstępie pozostają aktualne, warto jednak wspomnieć, że implementowanie
algorytmów numerycznych z użyciem C++jest czasami robione nieco „na si
łę " , gdyż język ten nie wspomaga w bezpośredni sposób modelowania zagad
nień natury czysto obliczeniowej. Matematykom i fizykom potrzebującym
sprawnych narzędzi obliczeniowych, można polecić w jego miejsce którąś z no
woczesnych implementacji Fortranu. Język ten, co prawda nie nadaje się do
„zwykłego " programowania (tak jak C++ i Pascal), ale wraz z nim są dostar
czane zazwyczaj, bardzo bogate biblioteki procedur obliczeniowych
(odwracanie macierzy, całkowanie, interpolacja...) - te wszystkie procedury,
które programista C++ musi typowo pisać od zera...
Rozdział 12
Czy komputery mogą myśleć?
Zamieszczenie w podręczniku algorytmiki o charakterze ogólnym, rozdziału
poświęconego dziedzinie zwanej dość myląco ..sztuczną inteligencją " 1, wiąże
się z całym szeregiem niebezpieczeństw. Przede wszystkim jest to dziedzina tak
ogromnie rozległa, iż trudno się pokusić o stworzenie jakiegoś dobrego „stresz
czenia " opisującego zagadnienie bez zbytnich uproszczeń. Jest to wręcz nie
możliwe. Po drugie, zagadnienia związane ze sztuczną inteligencją są na ogól
dość trudne i trzeba naprawdę wiele wysiłku, aby uczynić z nich temat frapujący.
Udało się to bez wątpienia Nilssonowi w [Nil82], lecz miał on na to zadanie
kilkaset stron!
Mój dylemat polegał więc na wyborze kilku interesujących przykładów i na
opisaniu ich na tyle prostym językiem, aby nie informatyk nie miał zbytnich
kłopotów z ich zrozumieniem. Wybór padł na elementy teorii gier. Temat ten wią
że się z odwiecznym marzeniem człowieka, aby znaleźć optymalną strategię danej
gry pozwalającą na pewne jej wygranie.
Pytanie zawarte w tytule rozdziału jest bardzo bliskie powszechnym odczuciom
komputerowych laików. Fakt. że jakaś maszyna potrafi grać. rysować, animować
jest dla nich jednoznaczny z myśleniem. „Oczywiście, jest to błędne przekonanie " .
powie informatyk, który wie, że w istocie komputery są wyłącznie skompliko
wanymi automatami, z możliwościami zależnymi od programów, w które je
wyposażymy. W tych właśnie programach tkwi możliwość symulowania inteli
gentnego zachowania komputera, zbliżającego jego sposób postępowania do
ludzkiego. W chwili obecnej potrafimy jedynie kazać komputerowi naśladować
zachowania inteligentne, gdyż stopień skomplikowania lud/kiego mózgu "
Do tego worka wrzuca się dość sporo bardzo różnych dziedzin: teorię gier, planowanie.
systemy ekspertowe, etc.
2 Na dodatek, zasada działania ludzkiego mózgu wcale nie jest tak do końca rozumiana
przez współczesną naukę.
282
Rozdział 12. Czy komputery mogą myśleć?
przewyższa najbardziej nawet złożony komputer. Pamiętajmy jednak, że wcale
nie jest powiedziane, iż za kilka lat nie powstanie technologia, która pozwoli
skonstruować ideowy odpowiednik ludzkiego mózgu i nauczyć go rozwiązy
wania problemów niedostępnych nawet dla człowieka!
Proszę traktować ten rozdział jedynie, jako zachęcający wstęp do dalszego
studiowania bardzo rozleglej dziedziny sztucznej inteligencji. Bardzo polecam
lekturę [Nil82], na rynku polskim jest również dostępne ciekawe opracowanie
[BC89] dotyczące metod przeszukiwania, odgrywających tak istotną rolę w dzie
dzinie sztucznej inteligencji.
12.1.Reprezentacja problemów
Najważniejszym bodajże zagadnieniem przy pisaniu programów „inteligent
nych " jest właściwe modelowanie rozwiązywanego zagadnienia. Przykładowo,
pisząc program do gry w szachy, musimy sobie zadać następujące pytania:
•
Jaki język najlepiej się nadaje do naszego zadania?
•
Jakich struktur danych należy użyć do reprezentowania szachowni
cy i pionków?
•
Jakich struktur danych należy użyć do reprezentowania toku myślenia
gracza?
Są to pytania niebagatelne i czasami od odpowiedzi na nie zależy możliwość
rozwiązania danego zadania!
Przykład:
Dysponujemy szachownicą 3x3, na której chcemy zamienić miejscami
" k o n i k i " białe z czarnymi (patrz rysunek 12 - 1). Możliwe ruchy konika znaj
dującego się na pozycji o numerze S są przedstawione przy pomocy strzałek.
Rys. / 2 - /.
Problem konika
szachowego (I).
Reprezentacja zadania w tej postaci, w jakiej jest przedstawiona na rysunku,
wcale nie ułatwia nam rozwiązania: nie widać klarownie jakie mchy są dozwolone,
12.1. Reprezentacja problemów
283
jaki cel należy osiągnąć. Popatrzmy jednak na inne przedstawienie tej samej
sytuacji (patrz rysunek 12 - 2).
Jeśli założymy, że dany konik może poruszać się tylko o dwa pola (w przód i w tył
po wyznaczonej ścieżce 0-5-6-1-8-3-2-7-0), to zauważymy, że modelujemy w ten
sposób bardzo łatwo ruchy dozwolone i umiemy napisać funkcję, która stworzy
listę takich ruchów dla danego konika. Z rysunku została usunięta również pozycja
„martwa " (4), całkowicie niedostępna i w związku z tym w ogóle nam niepo
trzebna.
Rys. 12 - 2
Problem konika
szachowego (2).
Ćwicz. 12-1
Proszę się zastanowić, jak rozwiązać postawione zadanie, dozwalając być może
jednoczesne ruchy kilku pionów?
Ważną reprezentacją zagadnień sztucznej inteligencji są tzw. grafy stanów
ilustrujące w węzłach stany problemu (np. planszę z zestawem pionów), a poprzez
krawędzie możliwości zmiany jednego stanu na inny (np. poprzez wykonanie
ruchu). W przypadku gry w szachy należałoby zatem zapamiętywać w węźle
aktualne stany szachownicy, co czyniłoby reprezentację dość kosztowną, zwa
żywszy na liczbę możliwych sytuacji i co za tym idzie - rozmiar grafu!
12.2.Gry dwuosobowe i drzewa gier
Zaletą typowych gier dwuosobowych jest względna łatwość ich programowej
implementacji. Decydując tym następujące cechy:
•
w danym etapie mamy komplet wiedzy o sytuacji, w jakiej znajduje się
gra (stan planszy);
284
Rozdział 12. Czy komputery mogą myśleć?
role
graczy
symetryczne;
reguły gry są znane z wyprzedzeniem.
W przypadku gier dwuosobowych, bardzo wygodną strukturą danych uła
twiających reprezentację stanu i przebiegu gry jest drzewo. Ruchy kolejnych
graczy są przedstawiane przy pomocy węzłów drzewa, w którym poszczególne
„piętra " (poziomy zagłębienia) odpowiadają wszystkim możliwym do wykona
nia ruchom danego gracza. Przykład drzewa gry jest podany na rysunku 12-3.
Rys. 12-3.
Przykład drzewa
pewnej wyimagi
nowanej gry (2).
Poszczególne węzły są dość skomplikowanymi strukturami danych, pozwalającymi
zapisać kompletny stan pola gry (w naszym przykładzie jest to kratownica 4x4,
omawiana gra jest całkowicie fikcyjna). Gracz A, jako zaczynający grę ma naj
większą swobodę ruchów. Jeśli wybierze on ruch I, to gracz B powinien się do
stosować do jego wyboru według dwóch kryteriów:
•
wybór musi być najkorzystniejszy dla B (kryterium zdrowego rozsądku);
•
wybór musi być zgodny z regularni gry ( k r y t e r i u m poprawności).
Drzewo gry jest tym prostsze, im mniej skomplikowana jest gra pod względem
możliwości ruchów. Tak więc nietrudno sobie wyobrazić, że drzewo gry „kółko
i krzyżyk " jest o wiele prostsze od drzewa gry w warcaby lub szachy.
Drzewa gry w pewnym momencie się kończą (nawet jeśli są bardzo duże): każda
sensowna gra prowadzi przecież prędzej czy później do wygranej, przegranej
jednej ze stron lub remisu! Powstaje zatem praktyczne pytanie: czy da się tak
poprowadzić przebieg partii, aby zaproponować jednemu z graczy strategie
wygrywającą? Aby komputer mógł „rozumować " w kategoriach strategii
wygrywającej lub przegrywającej, musi być wyposażony w algorytm skutecznie
symulujący w nim zdolność inteligentnego podejmowania decyzji. W praktyce
oznacza to wbudowanie w program dwóch typów funkcji:
12.2.
Gry
dwuosobowe
i
drzewa
gier
289
•
ewaluacja: bieżący stan gry jest szacowany pod kątem przewagi jednej
ze stron i na tej podstawie jest generowana liczba rzeczywista. Porównanie
dwóch stanów gry sprowadzi się zatem do porównania dwóch liczb!
•
decyzja: na podstawie ewaluacji bieżącego stanu gry i ewentualnie k i l
ku stanów następnych (znanych na podstawie wygenerowanego w cało
ści lub częściowo drzewa gry) podejmowana jest decyzja, który ruch
wybrać na danym etapie gry.
Pierwsza funkcja jest ideowo dość prosta do stworzenia, pozwala ona bowiem
ocenić „siłę rażenia'' jednej ze stron. O ile jednak sama idea nie jest skompli
kowana, to matematyczne uzasadnienie wyboru lej, a nie innej funkcji bywa
czasami bardzo trudne. Programy często wykorzystują pewne intuicyjne obser
wacje trudno przekładalne na język matematyki, a jednak w praktyce skuteczne!
Funkcja decyzja próbuje ująć w postaci programu komputerowego coś, co
nazywamy po prostu strategią gry. Funkcja decyzja staje się trywialna, jeśli
możemy szybko wygenerować cale drzewo gry i oszacować jego „ścieżki " , czyli
drogi, po których może się potoczyć dana partia. Niestety, dla większości gier
powszechnie uznawanych za godne uwagi rozrywki intelektualne (np. szachy,
REVERSI, GO...) jest to jeszcze niewykonalne. Komputery są ciągle zbyt wolne
do pewnych zastosowań, mimo że zdarza nam się o tym zapominać, podczas
oglądania oszałamiających animacji, czy też fascynujących i intrygujących zło
żonością gier komputerowych. To co pozostaje, to wygenerowanie fragmentu
drzewa gry (do jakiejś sensownej głębokości) i na tej podstawie podjęcie
„odpowiedniej " decyzji.
Okazuje się, że dziedzina sztucznej inteligencji odniosła duże sukcesy w poszuki
waniu takich algorytmów, zwanych często zwyczajnie strategiami przeszukiwania.
Najbardziej znanymi z nich są: A*, mini-max, algorytm cięć a-B. SSS*.
Szczególowe przedstawienie każdego z tych algorytmów byłoby dość: trudne w tej
książce, gdzie została przyjęta zasada prezentacji gotowych programów w C++ ilu
strujących omawiane zagadnienia. Niestety, listingi zajęłyby zbyt dużo miejsca, aby
to miało w ogóle sens. zdecydowałem się zatem na szersze omówienie tylko i wy
łącznie algorytmu mini-max na prostym do kodowania przykładzie gry w „kółko
i krzyżyk " . Nawet tak prosta gra wymaga jednak dość sporo linii kodu, i aby nie
wypełniać niepotrzebnie stron książki listingami, zdecydowałem się na szersze
omówienie kluczowych punktów programu gry w „kółko i krzyżyk " . Czytelnik dys
ponujący dużą ilością wolnego czasu powinien być w stanie napisać na tej podsta
wie program dowolnej innej gry dwuosobowej, w naszej prezentacji chodzi wy
łącznie o zrozumienie stosowanych mechanizmów. (Na dołączonej dyskietce znaj
duje się pełna wersja gry, patrz plik tictac.cpp)
Pełniejsze omówienie pominiętych (ale bardzo ważnych w praktyce) algoryt
mów, Czytelnik znajdzie np. w [BC89]. Książka ta nie prezentuje, co prawda
algorytmów w jakimś konkretnym języku programowania, ale dla wprawnego
programisty nie powinno to stanowić żadnej przeszkody.
286
Rozdział 12. Czy komputery mogą myśleć?
12.3.Algorytm mini-max
Wychodzimy z pozycji startowej (stan gry) i szukamy najlepszego możliwego
ruchu. Mamy dwa typy węzłów; „max " i „min " . Algorytm „przypuszcza " iż prze
ciwnik skonfrontowany z wieloma wyborami, wykonałby najlepszy ruch dla niego
(czyli najgorszy dla nas). Naszym celem będzie zatem wykonanie ruchu, który mak
symalizuje dla nas wartość pozycji, po której przeciwnik wykonał swój najlepszy
ruch (taki, który minimalizuje wartość dla niego). Analizujemy w ten sposób pewną
ilość poziomów' i analizujemy tylko te ostatnie. Wartości z tych ostatnich pozio
mów są „wnoszone " do góry, wedle reguł mmi-maxa.
Prosty przykład prezentowany jest na rysunku 12 - 4, i ilustruje sposób wybra
nia pierwszego najlepszego ruchu. Wartości liczbowe reprezentują siłę rażenia
danej pozycji.
Idea mini-max polega na systematycznej propagacji wartości danych pozycji,
poczynajcie od samego dołu. aż do wierzchołka. Jeśli bieżący wierzchołek ojca
reprezentuje mchy gracza A, to z wierzchołkiem tym wiąże się maksimum z warto
ści jego wierzchołków potomnych.
W przypadku, gdy węzeł reprezentuje przeciwnika (gracza B ) , to bierze się
minimum tych wartości. Dlaczego tak, a nie na przykład odwrotnie? Jest to
związane z istotnym założeniem zdrowego rozsądku obu graczy: B będzie się
starał maksymalizować swoje szanse zwycięstwa, czyli inaczej mówiąc: zmi
nimalizować szanse A na zwycięstwo. Jeśli analiza całego drzewa nie jest
praktycznie możliwa, algorytmy poprzestają na pewnej arbitralnej głębokości na naszym przykładzie jest to h=2.
Ich ilość jest wybieralna i determinuje głębokość zagłębienia procedury mini-max, W przy
padku niektórych gier, zbyt głębokie przeszukiwanie nie ma, oczywiście zbytniego sensu:
są one zbył „płytkie " !
12.3. Algorytm mini-max
287
Załóżmy również, że wartości liczbowe węzłów z ostatniego poziomu, zostały
nam dostarczone przez pewną znaną funkcję ewaluacja. W analizowanym
przykładzie został wybrany węzeł z wartością 1=max(-1, 2, 1). Pamiętajmy, że
ten wybór zależy od głębokości analizy drzewa gry i przy innej wartości h
pierwszy nich mógłby być zupełnie inny!
Istnieje poprawiona wersja algorytmu mini-max, która pozwala znacznie skró
cić czas analizy, eliminując zbędne porównania wartości pochodzących z poddrzcw i tak nie mających szansy na wyniesienie podczas propagacji wartości wg
reguły mini-max. Jest ona powszechnie znana jako algorytm cięć a-B. Przykła
dowo, wartość -1 wyniesiona do góry na rysunku 1 2 - 4 (szacujemy węzły ter
minalne od lewej do prawej) sugeruje, iż nie ma sensu analizować tych części
drzewa, które wyniosłyby wartość mniejszą niż - I . Jest to oczywiste wykorzy
stanie matematycznych własności funkcji min i max...
Przedstawmy wreszcie tajemniczą procedurę mini-max. W celu ułatwienia jej
implementacji programowej zostanie ona zaprezentowana w pseudo-kodzie4.
Algorytm przeszukiwania drzewa gry, z wykorzystaniem reguły mini-max, ma na
stępującą postać :
MiniMax(wezeł
w)
{
jeśli w jest typu MAX to v=-OO;//(OO =infinity)(dk)
jeśli w jest typu MIN to v=+OO;
jeśli w jest węzłem terminalnym to
zwróć ewaluacja(w);
p1 , p2, ... pk = generuj(w) ; // potomkowie węzła
dla j=l...k wykonuj
w.
{
jeśli w jest typu MAX to
v=max (v, mimimax (pk)) ;
w przeciwnym wypadku
v=min (v, (v, nimimax (p k ));
}
zwróć v;
}
Dotychczas unikaliśmy dyskusji na temat funkcji ewaluacja. Powód jest dość
prozaiczny: funkcja ta jest silnie związana z rozpatrywaną i nie ma sensu jej
omawiać poza jej kontekstem.
Warto sobie zdać sprawę, że konkretna implementacja procedury mmi-max może być
zmieniona nie do poznania przez grę i sposób jej reprezentacji.
5
Ta wersja jest nastawiona na zwycięstwo gracza MAX.
Rozdział 12. Czy komputery mogą myśleć?
288
Po czym poznajemy siłę naszej pozycji w danym etapie gry w „kółko i krzy
żyk " ? Można wymyślać dość sporo dziwnych kryteriów, mnie jednak przeko
nało jedno. które notabene dość często pojawia się w literaturze. Wykorzystuje
my pojęcie ilości linii otwartych dla danego gracza, tzn. takich, które nie są bloko
wane przez przeciwnika i w związku z tym rokują nadzieję na skonstruowanie peł
nej linii dającej nam zwycięstwo. Omawianą zasadę ilustruje rysunek 12-5.
Wartość tej liczby jest pomniejszana o ilość linii otwartych dla przeciwnika.
Rys. 12 - 5.
Pojęcie linii
otwartych
w grze
w „kółko
i krzyżyk " ,
Rysunek sugeruje przy okazji strukturę danych, która może być wykorzysty
wana do zapamiętania stanu gry. Jest to zwykła tablica int i[9], której indek
sy odpowiadają pozycjom planszy z rysunku 12 - 5. Oprócz wartości typu int
możliwe jest pewne wzbogacenie stosowanej semantyki poprzez zastosowanie
typu wyliczeniowego 6 :
enum KTO{nic, komputer, człowiek};
Wartościami danego pola planszy byłyby wówczas zmienne nie typu int, ale typu
KTO, choć znawcy języków C/C++ wiedzą, że wewnętrznie jest to również int...
Funkcja ewaluacja otrzymuje w parametrze planszę i informację o tym dla ko
go wyliczenie ma zostać przeprowadzone.
Problem wartości typu plus można rozwiązać wybierając liczby, które są
znacznie większe od tych. zwracanych przez funkcję ewaluacja:
const plus_niesk = 1000;
const minus_niesk = -1000;
Podczas gry następuje zmiana gracza, w związku z tym przydatna będzie funkcja
mówiąca nam o tym. kto ma zagrać:
KTO Następny_Gracz(KTO gracz)
Stałym komputer i człowiek odpowiadają na planszy znaki „kółko " i „krzyżyk " ,
12.3. Algorytm
mini-max
{
if
289
(gracz==komputer)
r e t u r n cz1owiek;
else
return komputer;
}
Przydadzą się również funkcje pomocnicze;
void WyswietlPlansze(plansza);
void ZerujPlansze(plansza);
int KoniecGry(plansza);
Ostatnia funkcja dokonuje sprawdzenia, czy ktoś nie postawił linii złożonej
z trzech jednakowych znaków, co - j a k pamiętamy - gwarantuje zwycięstwo
w tej grze, lub czy nie doszło do remisu.
Sama gra jest zwykłą pętlą, która prowokuje wykonanie ruchów. Załóżmy, że
pętla ta została zamknięta w funkcji Graj:
void Graj(plansza, gracz)
{
gracz tmp=gracz;
whilo(!SprawdzCzyKoniecGry(plansza,gracz_tmp));
WyswietlPlansze(plansza);
ruch=WybierzRuch(gracz_tmp, plansza);
WykonajRuch(gracz_tmp, plansza, ruch);
gracz_tmp-Nastepny_Gracz(gracz_tmp);
}
}
Powyższy schemat jest identyczny dla większości gier dwuosobowych. Na samym
początku musimy określić, kto zaczyna (komputer, człowiek?), np. poprzez losowy
wybór. Losowanie to powinno się dokonać raz w funkcji main, która po wyze
rowaniu planszy powinna wywołać procedurę Graj. Warunkiem progresji pętli
jest stan, w którym nikt jeszcze nie wygrał lub nie zremisował gry. Procedura
Wykonuj Ruch ściśle zależy od zastosowanych struktur danych. W naszym przy
padku może to być po prostu:
plansza[ruch]=gracz_tmp;
Nieco trudniejsze jest wykonanie ruchu w tak skomplikowanej grze jak szachy,
czy też Reversi, mamy bowiem do uwzględnienia efekty uboczne, takie jak bicie
pionów, roszady...
Skąd mamy jednak wiedzieć, jaki ruch powinien zostać wykonany? Odpowiedzi
dostarczyć nam powinna funkcja WybierzRuch która używa poznanej wcze
śniej funkcji mini-max:
20
Rozdział 12. Czy komputery mogą myśleć?
i n t WybierzRuch(gracz, p l a n s z a )
{// wybór ruchu zależy od t e g o , kto g r a
if
(gracz==czlowiek)
cout & lt; & lt; " Twój wybór(0..8): " ;
cin & gt; & gt; ruch;
}while(!Zajęte(plansza, ruch));
else
{
cout & lt; & lt; " Ruch k o m p t e r a : \ n " ;
ruch=MiniMax(plansza,gracz);
r e t u r n ruch;
}
Rys. 12 - 6.
generowanie listy
możliwych ruchów
gracza na podsta
wie danego węzła
Treść procedury MiniMax jest dokładnym tłumaczeniem algorytmu ze strony
287, oczywiście z uwzględnieniem struktur danych właściwych dla danej gry.
Pozorną trudność może sprawić generowanie węzłów-potomków danego węzła.
Rysunek 1 2 - 6 ukazuje wynik funkcji generuj dla pewnego węzła w (zakła
damy, Że ruch należał do gracza stawiającego „krzyżyki " ). Nasuwają się tutaj na
myśl jakieś listy, drzewa, zbiory... Popatrzmy jednak, jak sprytnie można zako
dować listę potomków danego węzła, z użyciem tylko jednej pomocniczej planszy
(patrz rysunek 12 - 7). Wystarczy się umówić, że wpisanie wartości innej, niż -1
oznacza jeden wygenerowany węzeł: pozwala to nam upakować w jednej
planszy całą listę możliwych ruchów!
Rys. 12 - 7.
Kodowanie listy
węzłów potomnych
przy użyciu tylko
jednego węzła.
12.3. Algorytm mini-max
291
Na tym zakończymy omawianie zagadnień technicznych związanych z progra
mowaniem gier dwuosobowych. Czytelnikowi głębiej zainteresowanemu tą tematyką, polecam jednak pogłębienie swojej wiedzy literaturą specjalistyczną
przed przystąpieniem do kodowania np. gry w szachy... Algorytm mini-max w
swojej podstawowej formie jest dość wolny i w praktyce bywa często zastę
powany procedurą cięć a-B Z kolei, nie każdy algorytm przeszukiwania dobrze
nadaje się do programowania określonych gier, z uwagi na skomplikowaną ob
sługę struktur danych. Dobre algorytmy odszukiwania właściwej strategii gry
są, niestety, bardzo złożone. Programiści zaczynają coraz częściej wykorzysty
wać szybkość współczesnych komputerów, co pozwala uprościć sam proces
programowania poprzez stosowanie najprostszych algorytmów przeszukiwania
typu brute-force. Tak postąpili programiści, którzy konstruowali tegoroczną
(1996) maszynę mającą pokonać w grze w szachy samego mistrza Kasparowa'.
We wspomnianym pojedynku górą znowu okazał się człowiek, ale kto wie, co
nam przyniesie przyszłość?
Komputer generował w zadanym czasie, jak największą ilość możliwych strategii, ob
liczał ich silę (funkcja ewalucja!) i wybierał tę lokalnie najlepszą.
Rozdział 13
Kodowanie i kompresja danych
W chwili obecnej coraz więcej komputerów jest podłączanych do globalnych sieci
komputerowych. Ze względu na relatywnie niskie koszty, w Polsce prym wiedzie
Internet, z którego dobrodziejstw korzysta coraz więcej osób, również nie związa
nych z informatyką. Możliwość „przechadzania się " po sieci, przy pomocy łatwych
w użyciu graficznych przeglądarek (np. Netscape, Mosaic) czy to w poszukiwaniu
jakichś istotnych danych, czy też zwyczajnie dla rozrywki, fascynuje wiele osób,
stając się nieraz czymś w rodzaju nałogu...
Prostota oprogramowania, które służy do korzystania z zasobów sieciowych,
skutecznie odseparowuje zwykłego użytkownika komputera od problemów
z którymi musi sobie radzić oprogramowanie komunikacyjne. Dawniej, gdy
głównym problem stanowiła niska przepustowość łącz, kluczowym zagadnieniem
była kompresja przesyłanych danych, czyli takie ich zakodowanie 1 , które - nie
umniejszając ilości przesyłanej informacji - zmniejszy ilość bitów krążących
„po kablach " . Obecnie punkt ciężkości przesunął się na bezpieczeństwo da
nych, tzn. ich ochronę przed niepowołanym dostępem „z zewnątrz " .
Czy kompresja danych jest w ogóle możliwa? Dla laika proces kompresji danych
wydaje się magiczny, jednak po powierzchownym nawet wejrzeniu okazuje się, że
nie ma w nim niczego tajemniczego. Weźmy dla przykładu 50-znakową wiado
mość: „SPOTKANIE JUTRO O PIĘTNASTEJ NA ŁAWCE POD RATUSZEM " .
Przyjmując najprostsze kodowanie 8-bitowym kodem ASCII (w którym na każ
dy z 256 znaków tego kodu przypada pewien 5-bitowy ciąg zerojedynkowy),
długość powyższej wiadomości możemy oszacować na 50x8=400 bitów 2 . Czy
1
Kodowanie = przedstawienie informacji w postaci dogodnej do przesyłania, np. w postaci
ciągów „zer " i ,jedynek " , czyli po prostu dwóch sygnałów elektrycznych dających się
łatwo odróżnić od siebie, np. poprzez pomiar ich amplitudy lub częstotliwości.
?
Dla uproszczenia, nie uwzględniamy tutaj żadnych dodatkowych bitów związanych
z kontrolą poprawności transmisji danych, ani ze szczegółami technicznymi konkretnego
protokołu telekomunikacyjnego - inaczej mówiąc, znajdujemy się na poziomie aplikacji.
294
Rozdział 13. Kodowanie i kompresja danych
jednak w przypadku zwykłych tekstów, zawierających komunikaty w języku
polskim, musimy koniecznie używać kosztownego kodowania 8-bitowego? Ję
zyk polski nie zawiera przecież aż 28=256 znaków! Załóżmy, że dla typowych
tekstów ograniczymy się do następującego alfabetu:
'A'...'Z'
' '(spacja) , ; . Ą, Ć, Ę, Ł, Ń, Ó, Ź, Ż
=
=
=
RAZEM:
26 znaków
5znaków
8 znaków
39 znaków.
Do zakodowania 39 znaków w zupełności wystarczy 6 bitów ( A = 0 0 0000,
B=00 0001, C= ...), czyli komunikat „kurczy " nam się z 400 do 300 bitów 3 !
Łatwo zauważyć, że znajomość przesyłanego alfabetu pozwala, przy umiejętnym
doborze kodu, znacznie zmniejszyć długość przesyłanego komunikatu, bez utrat)
informacji w nim zawartej. Istnieje mnogość kodów, bardziej skomplikowanych
niż prymitywne kody „tabelkowe " typu ASCII, nie jest jednakże moim zamiarem
zamienienie tego rozdziału w mini-podręcznik teorii kodowania i informacji.
I3cz wnikania w szczegóły, warto być może wspomnieć, że istnieją dwie pod
stawowe grupy kodów: równomierne (o stałej długości słowa kodowego) i nie
równomierne (o zmiennej długości słowa kodowego). W obu przypadkach można
do zakodowanej informacji dołączyć pewne dodatkowe bity kontrolne, ułatwiające
odtworzenie informacji, nawet w przypadku częściowego uszkodzenia przesy
łanego komunikatu (uzyskujemy wówczas tzw. kody nadmiarowe). Nie chciałbym
jednak zbyt szeroko omawiać tych zagadnień, gdyż są one związane bardziej
z transmisją sygnałów (fizyczna transmisja danych; sens przesyłanej informacji
nie jest istotny), niż z informatyką w czystej postaci (aplikacje użytkownika;
sens przesyłanej informacji ma kluczowe znaczenie).
W dalszej części rozdziału omówimy szczegółowo popularny system kodowania
z tzw. kluczem publicznym oraz kod Huffmana, który jest znakomitym i nie
skomplikowanym przykładem uniwersalnego algorytmu kompresji danych.
13.1.Kodowanie danych i arytmetyka dużych liczb
Kodowanie danych (lub jak kto woli: szyfrowanie wiadomości) ma miejsce
wszędzie tam, gdzie z pewnych względów chcemy utajnić zawartość przesyłanej
informacji, tak aby j e j treść nie dostała się w niepowołane ręce i nie mogła być
wykorzystana w niemiłych nam celach. Może ono dotyczyć prywatnej koresponZyskujemy 25 % pierwotnej długości tekstu!
13.1. Kodowanie danych i arytmetyka dużych liczb
295
dencji, jednak w praktyce najczęstsze zastosowanie znajduje we wszelkiego ro
dzaju transakcjach gospodarczych ze względu na dobro kontrahentów.
Kodowanie pasjonowało ludzi od wieków i czyniono wielkie starania, aby wymy
ślać takie algorytmy kodujące, które byłyby trudne do złamania w rozsądnym
czasie. Proces kodowania i dekodowania można przedstawić w postaci prostego
schematu, przedstawionego na rysunku 1 3 - 1 .
Rys. U - I.
Algorytmiczny
system
kodujący.
Pewna wiadomość Wjest szyfrowana przez nadawcę A przy pomocy procedury
szyfrującej koduj, która przyjmuje dwa parametry: tekst do zaszyfrowania i pewien
dodatkowy parametr K. zwany kluczem. Klucz K pełni rolę elementu kompli
kującego powszechnie znany algorytm kodowania i ma na celu utrudnienie
osobom niepowołanym odczytanie wiadomości. Przykładem najprostszego
kodu i klucza jest przypisanie literze alfabetu numeru (załóżmy, że nasz alfa
bet składa się z 39 znaków). Jest to zwykle kodowanie tabelkowe, bardzo łatwe
zresztą do złamania przez językoznawców uzbrojonych w komputerowe „liczydło' "
i swoją wiedzę. Jak skomplikować ten powszechnie znany algorytm kodowa
nia? Można na przykład dodać do przesyłanej liczby kodowej pewną wartość
K, co spowoduje, że niemożliwe stanie się odczytanie wiadomości poprzez
zwykłe porównywanie pozycji tabelki kodującej. Odbiorca £. zanim rozpocznie
dekodowanie powinien odjąć od otrzymanych liczb liczbę K, tak aby otrzymać
kanoniczny kod tabelkowy1. Uważny Czytelnik dostrzeże zasadniczą niedogod
ność takiego systemu kodującego przyglądając się rysunkowi 1 3 - 1 : nadawca i
odbiorca muszą znać wartość klucza K! Przesyłanie konwencjonalnymi metodami
klucza, np. poprzez kuriera, jest bardzo niepraktyczne i na dodatek naraża na nie
bezpieczeństwo zarówno poufność danych, jak i... samego kuriera!
Jak rozwiązać problem transmisji klucza w świecie, gdzie ważne jest, aby wia
domość dotarła w ułamku sekundy do odbiorcy, bez obarczania go dodatkową
troską o wiarygodność otrzymanego klucza K? Ponieważ nie znaleziono sen
sownego rozwiązania tego problemu, z wielką ulgą powitano wynalezienie w
1976 r., metody kodowania z kluczem publicznym, która eliminowała całkowi
cie dystrybucję klucza. Wynalazcami metody byli W. Diffie i M. Hellman. jedZarówno przykład kodu, jak i klucza są najprostszymi z możliwych i żadna armia na
świecie nie zakodowałaby przy ich pomocy nawet jadłospisu dziennego, aby nie
ośmieszyć się przed przeciwnikiem!
296
Rozdział 13. Kodowanie i kompresja danych
nak jej praktyczna realizacja została opracowana przez R. Rivetsa, A. Shamira i
L. Adlemana, stając się znaną jako tzw. kryptosystcm RSA. Metoda RSA gwa
rantuje bard/o duży stopień bezpieczeństwa przesyłanej informacji. Ponieważ
została ona uznana przez matematyków za niemożliwą do złamania, stała się
momentalnie obiektem zainteresowania komputerowych maniaków na całym
świecie, którzy za punkt honoru przyjęli jej złamanie...
Zanim przeanalizujemy system RSA na konkretnym przykładzie liczbowym,
spróbujmy zrozumieć samą ideę kryptografii z kluczem publicznym.
Rys. U - 2.
System kodujący
z kluczem
publicznym.
System kryptograficzny z kluczem publicznym jest przedstawiony na rysunku
13 - 2. Składa się on z trzech procedur: prywatnych: rozkoduj A i rozkodujB i publicznej: kodujAB. Nadawca A, chcąc wysłać do odbiorcy B wiado
mość W, w pierwszym momencie czyni rzecz dość dziwną: zamiast
„zwyczajnie " zakodować ją i wysłać poprzez kanał transmisyjny do odbiorcy,
dodatkowo używa funkcji rozkodujA na niezaszyfrowanej wiadomości! Czyn
ność ta, na pierwszy rzut oka dość absurdalna, ma swoje uzasadnienie prak
tyczne: na wiadomości W jest odciskany niepowtarzalny podpis cyfrowy
nadawcy A, co w wielu systemach (np. bankowych) ma znaczenie wręcz strate
giczne! Następnie, podpisana wiadomość (W1) jest szyfrowana przez powszech
nie znaną procedurę szyfrującą kodujAB i dopiero w tym momencie wysyłana do B.
Odbiorca B otrzymuje zakodowaną sekwencję kodową i używa swojej prywatnej
funkcji rozkodujB, która jest tak skonstruowana, że na wyjściu odtworzy podpisaną
wiadomość W1 Podobnie specjalna musi być funkcja kodujAB, która z cyfrowo
podpisanej wiadomości W1 powinna odtworzyć oryginalny komunikat W.
Wymogi bezpieczeństwa zakładają praktyczną niemożność odtworzenia
tajnych procedur rozkodowujących, na podstawie jawnych procedur
kodujących.
Idea jest zatem urzekająca, pod warunkiem wszakże, dysponowania trzema tajemni
czymi procedurami, które na dodatek są powiązane ze sobą dość ostrymi wymaga
niami! Dopiero po roku od pojawienia się idei systemu z kluczem publicznym po
wstała pierwsza (i jak do tej pory najlepsza) realizacja praktyczna: system krypto
graficzny RSA. System ten zakłada, że odbiorca B wybiera losowo trzy bardzo duże
13.1. Kodowanie danych i arytmetyka dużych liczb
297
liczby pierwsze S, NI i N2 (typowo 100 cyfrowe) i udostępnia publicznie tylko ich
iloczyn5 N=N1xN2 oraz pewną liczbę P, spełniającą warunek:
PxS mod (NI-I) x(N2-I)=1.
zostało udowodnione, że dla każdego ciągu kodowego M (tekst zostaje zamie
niony na odpowiadający mu ciąg liczbowy o pewnej skończonej długości) speł
niona jest równość: MPS mod N = M.
Kodowanie sprowadzi się zatem do obliczenia równości:
{ciąg kodowy}=koduj(M)= MP mod N,
natomiast dekodowanie jest równoważne obliczeniu:
M=dekodnj({ciąg kodowy})= {ciąg kodowy}S mod N.
Pomimo pozornej trudności wykonania operacji na bardzo dużych liczbach,
okazuje się, że własności funkcji modulo powodują, iż zarówno ciąg kodowy,
jak i jego zaszyfrowana postać należą do lego samego zakresu liczb. Złamanie
systemu RSA byłoby możliwe, jeśli umielibyśmy na podstawie znanych wartości
N i P odtworzyć utajnione S, potrzebne do rozkodowania wiadomości! Nie zna
leziono do tej pory algorytmu, który potrafiłby wykonać to zadanie w rozsąd
nym czasie.
Wszelkie algorytmy kryptograficzne napotykają na problem wykonywania
obliczeń na bardzo dużych liczbach całkowitych. Okazuje się, że obliczenia te
mogą zostać znacznie uproszczone, pod warunkiem traktowania tych liczb jako
współczynników wielomianów. Weźmy dla przykładu liczbę:
12 9876 0002 6000 0000 0054
W systemie o podstawie x=10 powyższa liczba może zostać przedstawiona jako:
x 2 1 + 2 x 2 0 + ( 9 x l 9 + 8x l 8 + 7 x 1 7 + 6 x l 6 ) + (2x 1 2 ) + ( 6 x 1 1 ) + ( 5 x l + 4 ) .
Jeśli x=10 wydaje nam się za male, to identyczną liczbę otrzymamy podsta
wiając, np. x=10000:
(12x 4 ) + ( 9 8 7 6 x 3 ) + ( 2 x 2 ) + (6x) + 54.
;
Ponieważ nie są aktualnie znane szybkie metody rozkładu na czynniki pierwsze, doko
nanie takiego rozkładu przez osobę postronną jest wysoce nieprawdopodobne.
298
Rozdział 13. Kodowanie i kompresja danych
W konsekwencji, jeśli będziemy interpretować duże liczby jako wielomiany, to
wszelkie operacje na tych liczbach mogą zostać zastąpione algorytmami działają
cymi na wielomianach.
Aby dodawać i mnożyć duże liczby całkowite, musimy zatem nauczyć się
dodawać i mnożyć... wielomiany!
Reprezentacja wielomianu w C++ jest najprostsza przy użyciu tablic, służących
do zapamiętywania współczynników. Wielomian stopnia n i zmiennej x jest
ogólnie definiowany następująco:
W(x) = anxn + an-1xn-1 +...+a 1 x + a0.
Obliczanie wartości W(b) dla pewnego b, wydaje się dość kosztowne z uwagi na
konieczność wielokrotnego mnożenia i dodawania;
i n t oblicz_wielomianl(int b, i n t w[], i n t rozm)
{
i n t res=0,pot=l;
f o r ( i n t j=rozm-l;j & gt; =0;j--)
{
res+=pot*w[j];
// sumy cząstkowe
pot*=b;
// następna potęga b
}
return res;
}
(W przypadku wielomianów o współczynnikach niecałkowitych, należy wszędzie
zamienić typ int na double).
Istnieje jednak tzw. schemat Hornera6 pozwalający na znacznie prostsze
obliczenie W(b):
Realizacja schematu Hornera może być następująca:
int oblicz_wielomian2(int a, int w[],int rozm)
{
int res=w[0];
f o r ( i n t j=l;j & lt; rozm;res=res*a+w[j++]);
return r e s ;
}
const
n=5;
void main()
6
horner.cpp
// stopień wielomianu
Wynalazcą tej procedury był tak naprawdę Isaac Newton, ale historia przypisała ją
Hornerowi.
13.1. Kodowanie danych i arytmetyka dużych liczb
299
{
int w[n]={1,4,-2,0,7}; // współczynniki wielomianu
cout & lt; & lt; oblicz_wielomianl(2,w,n) & lt; & lt; endl;
cout & lt; & lt; oblicz_wielomian2(2,w,n) & lt; & lt; endl;
}
Przy użyciu reprezentacji tablicowej, nieskomplikowane staje się również dodawanie i mnożenie wielomianów:
wielom.cpp
void d o d a j _ w i e l ( i n t x [ ] , i n t y [ ] , i n t z [ ] ,
i n t rozm)
{
f o r ( i n t i=0;i & lt; rozm;i++)
z[i]=x[i]+y[i];
// wielomian z=x+y
}
v o i d m n o z _ w i e l ( i n t x [ ] , i n t y [ ] , i n t z [ ] , i n t rozm)
{
int i, j ;
f o r ( i = 0 ; i & lt; 2 * r o z m - l ; i++)
z[i]=0;
/ / zerowanie r e z u l t a t u
// wielomian z=x*y
f o r ( i=0;i & lt; rozm;i++)
for (j=0;j & lt; rozm; j++)
z[i+j]=z[i+j] + x [ i ] * y [ j ] ;
}
Zacytowane powyżej algorytmy są bezpośrednim tłumaczeniem praktycznych
sposobów znanych nam ze szkoły podstawowej lub średniej. Co jest ich pod
stawową wadą? Otóż to, co wydawało nam się zaletą: reprezentacja tablicowa
(a więc prosty dostęp do współczynników)! Jest ona mało ekonomiczna, jeśli
chodzi o zużycie pamięci, o czym najlepiej niech świadczy próba pomnożenia
następujących wielomianów:
(2x1600+3x900)x(3x85+l).
Owszem, można zarezerwować tablice o rozmiarach 1600, 85 i 1600+85 (na
wynik), ale biorąc pod uwagę, że będą się one składały głównie z zer, nie jest to
najrozsądniejszym pomysłem...
Na pomoc przychodzi tutaj reprezentacja wielomianu przy pomocy listy jednokie
runkowej: wybierzemy najprostsze rozwiązanie, w którym nowe składniki
wielomianu są dokładane na początek listy (użytkownik musi jednak pamię
tać o wstawianiu nowych składników w określonej kolejności: od potęg najwyż
szych do najniższych lub odwrotnie). Nie będą zapamiętywane składniki zerowe:
typedef s t r u c t wsp
{
i n t c;
int j ;
wielomż.cpp
300
Rozdział 13. Kodowanie i kompresja danych
s t r u c t wsp * n a s t e p n y ;
(WSPOLCZYNNIKI, *WSPOLCZYNNIKI_PTR;
WSPOLCZYNNIKI_PTR wstaw(WSPOLCZYNNIKI_PTR p , i n t c ,
/ / d o d a j e nowy w ę z e ł ( w s p ó ł c z y n n i k ) d o w i e l o m i a n u
{
if(c!=0)
// tylko elementy
c*xi
dla c!=0
{
WSPOLCZYNNIKI_PTR
i n t j)
q=new WSPOLCZYNNIKI;
q- & gt; c=c;
q- & gt; j=j;
q- & gt; nastepny=p;
return q;
}
else
return p; // lista nie została zmieniona
}
Funkcje obsługujące taką reprezentację komplikują się nieco, ale algorytmy zy
skują znacznie na efektywności i są oszczędne w kwestii zajmowania pamięci.
Popatrzmy na funkcję, która doda do siebie dwa wielomiany:
WSPOLCZYNNIKI_PTR
dodaj(WSPOLCZYNNIKI_PTR
x,
WSPOLCZYNNIKI_PTR
y)
// zwraca wielomian x+y
{
W S P O L C Z Y N N I K I _ P T R res=NULL;
while( (x!=NULL) & & (y!=NULL))
if (x- & gt; j==y- & gt; j)
{
res=wstaw(res,x- & gt; c+y- & gt; c,x- & gt; j);
x=x- & gt; nastepny;
y=y- & gt; nastepny;
}
else
if(x- & gt; j & lt; Y- & gt; j)
{
res=wstaw(res,x- & gt; c,x- & gt; j);
x=x- & gt; nastepny;
}
else
if(y- & gt; j & lt; x- & gt; j)
{
res=wstaw(res,y- & gt; c,y- & gt; j);
y=y- & gt; nastepny;
//
//
//
//
}
W tym momencie x lub y może jeszcze zawierać
elementy, które nie zostały obsłużone w p ę t l i
while z uwagi na jej warunek; wstawiamy zatem
resztę czynników (jeśli i s t n i e j ą ) :
13.1. Kodowanie danych i arytmetyka dużych liczb
while
301
(x!=NULL)
{
res=wstaw(res,x- & gt; c,x- & gt; j);
x=x- & gt; nastepny;
}
while
(y!=NULL)
{
res=wstaw(res,y- & gt; c,y- & gt; j);
y=y- & gt; nastepny;
}
return res;
}
Algorytm funkcji dodaj został pozostawiony w możliwie najprostszej i łatwej
do analizy postaci. (Czytelnik dysponujący wolnym czasem może się pokusić
o wprowadzenie w nim szeregu drobnych ulepszeń). Popatrzmy jeszcze na sposób
korzystania z powyższych funkcji:
void main()
{
WSPOLCZYNNIKI_PTR p w l , p w 2 , p w 3 , p w t e m p ;
pwl=pw2=pw3=pwtemp=NULL;
// w i e l o m i a n
pw2*5x 1 7 0 0 +6x 7 0 0 +10x 5 0 +
5;
pwl=wstaw(pwl,5,1700);
pwl=wstaw(pwl,6,700);
pwl=wstaw(pwl,10,50);
p w l = w s t a w ( p w l , 5, 0) ;
// wielomian pw2=6-x l800 -6x 700 +5x 50 +15;
pw2=wstaw(pw2,6,1800);
pw2=wstaw(pw2,-6, 700);
pw2=wstaw(pw2,5, 50);
pw2=wstaw(pw2,15,0);
// dodajemy pw1 i pw2:
pw3=dodaj(pwl,pw2);
//
}
wielomian
pw3=6x1800+5x1700+15x50+20;
Omawiając system kodowania danych RSA, napotkaliśmy na niedogodność
związaną z operacjami na bardzo dużych liczbach całkowitych. Aby otrzymać
ciąg kodowy powstały na podstawie pewnego tekstu M, musimy obliczyć dość
makabryczne wyrażenie:
{ciąg kodowy}= M P mod N.
7
Pamiętajmy, że po zamianie każdej litery tego tekstu na pewną liczbę (np. w kodzie
ASCII), całość możemy traktować jako jedną, bardzo dużą liczbę M.
302
Rozdział 13. Kodowanie i kompresja danych
Podnoszenie do potęgi może być zrealizowane poprzez zwykle mnożenie, ale
co zrobić z obliczaniem funkcji modulo Jak sobie, na przykład, poradzić z wy
liczeniem:
12 9876 0002 6000 0000 0054 mod N?
Jeśli wszakże przedstawimy powyższą liczbę jako wielomian o podstawie x=10
000, to otrzymamy znacznie prostsze wyrażenie:
12(x4* mod N) + 9876(x3 mod N) i 2(x2 mod N) + 6(x mod N) + 54.
Wartości w nawiasach są stałymi, które można wyliczyć tylko raz i „na sztywno "
wpisać do programu kodującego!
13.2.Kompresja danych metodą Huffmana
Kod, który zdecydujemy się używać, może się znacznie różnić od znanego kodu
ASCII Jak pamiętamy, kod ASCII jest tabelą 8-bitowych znaków tekstu (nie
wszystkie są, co prawda używane w języku polskim, ale nie ma to tutaj większego
znaczenia). Jego podstawową cechą jest równa długość każdego słowa kodowego
odpowiadającego danemu znakowi: 8 bitów. Czy jest to obowiązkowe? Otóż nic,
popatrzmy na przykład kodowania znaków pewnego alfabetu 5 znakowego
(tabela 13-1).
Tabela 13 - I.
Przykład
kodowania
znaków pewnego alfa
betu 5-znakowego.
Gdzieś, w dalekiej dżungli, żyje lud. który potrafi za pomocą kombinacji tych 5
znaków wyrazić wszystko: wypowiedzenie wojny, rozejm, prośbę o żywność,
prognozę pogody... Teksty zapisywane są na liściach pewnej odpornej na działanie
pogody rośliny. W celu szybkiej komunikacji, został wymyślony system szybkiego
przesyłania wiadomości przy pomocy sygnałów trąb niosących dźwięk na bardzo
długie dystanse.
13.2. Kompresja danych metodą Huffmana
303
Pomyłki są, jak to wyraźnie widać, niemożliwe, gdyż żaden znak kodowy nie
jest przedrostkiem (prefiksem) innego znaku kodowego. Dotarliśmy do istotnej cechy kodu: ma on być jednoznaczny, tzn. nie może być wątpliwości czy
dana sekwencja należy do znaku X, czy też może do znaku Y.
Konstrukcja kodu o powyższej własności jest dość łatwa, w przypadku reprezentacji
alfabetu w postaci tzw. drzewa kodowego. Dla naszego przykładu wygląda ono
tak, jak na rysunku 13 - 3.
Rys. 13 - 3.
Przykład drzewa
kodowego (1).
Przechadzając się po drzewie (poczynając od jego korzenia aż do liści), odwie
dzamy gałęzie oznaczone etykietami 0 („lewe " ) lub 1 („prawe " ). Po dotarciu do
danego listka, ścieżka, po której szliśmy jest jego binarnym słowem kodowym.
Zasadniczym problemem drzew kodowych jest ich... nadmiar. Dla danego al
fabetu można skonstruować cały las drzew kodowych, o czym świadczy przy
kład rysunku 13 - 4.
Powstaje naturalne pytanie: które drzewo jest najlepsze? Oczywiście, kryterium
jakości drzewa kodowego jest związane z naszym celem głównym: kompresją.
Kod, który zapewni nam największy stopień kompresji, będzie uznany za naj
lepszy. Zwróćmy uwagę, że długość słowa kodowego nie jest stała (w naszym
przykładzie wynosiła 2 lub 3 znaki). Jeśli w jakiś magiczny sposób sprawimy, że
znaki występujące w kodowanym tekście najczęściej będą miały najkrótsze
słowa kodowe, a znaki występujące sporadycznie - odpowiednio - najdłuższe,
to uzyskana reprezentacja bitowa będzie miała najmniejszą długość w porów
naniu z innymi kodami binarnymi.
Na tym spostrzeżeniu bazuje kod Huffmana, który służy do uzyskania optymal
nego drzewa kodowego. Jak nietrudno się domyślić, potrzebuje on danych na temat
częstotliwości występowania znaków w tekście. Mogą to być wyniki uzyskane od
językoznawców, którzy policzyli prawdopodobieństwo występowania określonych
znaków w danym języku, lub po prostu, nasze własne wyliczenia bazujące na
wstępnej analizie tekstu, który ma zostać zakodowany. Sposób postępowania
zależy od tego. co zamierzamy kodować (i ewentualnie przesyłać): teksty języka
mówionego, dla którego prawdopodobieństwa występowania liter są znane, czy
też losowe w swojej treści pliki „binarne " (np. obrazy, programy komputero
we...).
Dla zainteresowanych podaję tabelkę zawierającą dane na temat języka polskiego
(przytaczam za [CR90]).
Algorytm Huffmana korzysta w bezpośredni sposób z tabelek takich jak 13 - 2.
Wyszukuje on i grupuje rzadziej występujące znaki, tak aby w konsekwencji
przypisać im najdłuższe słowa binarne, natomiast znakom występującym częściej odpowiednio najkrótsze. Może on operować prawdopodobieństwami lub czę
stotliwościami występowania znaków. Poniżej podam klasyczny algorytm kon
strukcji kodu Huffmana, który następnie przeanalizujemy na konkretnym przykład/ie obliczeniowym.
13.2. Kompresja danych metodą Huffmana
305
Tabela 13 - 2.
Prawdopodo
bieństwa wy
stępowania liter
w języku
polskim.
FAZA REDUKCJI (kierunek: w dół)
1. Uporządkować znaki kodowanego alfabetu wg malejącego prawdopodo
bieństwa;
2. Zredukować alfabet poprzez połączenie dwóch najmniej prawdopodob
nych znaków w jeden znak zastępczy, o prawdopodobieństwie równym
sumie prawdopodobieństw łączonych znaków;
3. Jeśli zredukowany alfabet zawiera 2 znaki (zastępcze), skok do punktu 4,
w przeciwnym przypadku powrót do 2;
FAZA KONSTRUKCJI KODU (kierunek: w górę)
4. Przyporządkować dwóm znakom zredukowanego alfabetu słów kodo
wych 0 i 1;
5. Dopisać na najmłodszych pozycjach słów kodowych odpowiadających
dwóm najmniej prawdopodobnym znakom zredukowanego alfabetu 0 i 1;
6. Jeśli powróciliśmy do alfabetu pierwotnego, koniec algorytmu, w prze
ciwnym wypadku skok do S.
Zdaję sobie sprawę, że algorytm może wydawać się niezbyt zrozumiały, ale
wszystkie jego ciemne strony powinien rozjaśnić konkretny przykład oblicze
niowy, który zaraz wspólnie przeanalizujemy.
Załóżmy, że dysponujemy alfabetem składającym się z sześciu znaków: X1, x2, x3,
x4 ,x5 i x6 Otrzymujemy do zakodowania tekst długości 60 znaków, których
częstotliwości występowania są następujące: 20, 17, 10, 9, 3 i 1. Aby zakodować
sześć różnych znaków, potrzeba minimum 3 bity (6 & lt; 2 3 zatem zakodowany
306
Rozdział 13. Kodowanie i kompresja dartych
tekst zająłby 3x60-180 bitów. Popatrzmy teraz, jaki będzie efekt zastosowania
algorytmu Huffmana w celu otrzymania optymalnego kodu binarnego.
Postępując według reguł zacytowanych w powyższym algorytmie, otrzymamy
następujące redukcje (patrz rysunek 13 - 5).
Rys. 13 - 5.
Konstrukcja kodu
Huffmana - faza
redukcji
Rysunek przedstawia 6 etapów redukcji kodowanego alfabetu (Proszę nie suge
rować się postacią rysunku, to jeszcze nie jest drzewo binarne!). Znaki x5 i x6
występują najrzadziej, zatem redukujemy je do zastępczego znaku, który ozna
czymy jako x56. Podobnie czynimy w każdym kolejnym etapie, aż dochodzimy
do momentu, w którym zostają nam tylko dwa znaki alfabetu (zastępcze). Faza
redukcji została zakończona i możemy przejść do fazy konstrukcji kodu. Po
patrzmy w tym celu na rysunek 13 - 6.
Rysunek przedstawia 6 etapów redukcji kodowanego alfabetu (Proszę nie suge
rować się postacią rysunku, to jeszcze nie jest drzewo binarne!).
Znaki x5 i x6 występują najrzadziej, zatem redukujemy je do zastępczego znaku,
który oznaczymy jako x56,. Podobnie czynimy w każdym kolejnym etapie, aż
dochodzimy do momentu, w którym zostają nam tylko dwa znaki alfabetu
(zastępcze). Faza redukcji została zakończona i możemy przejść do fazy kon
strukcji kodu. Popatrzmy w tym celu na rysunek 13 - 6.
2. Kompresja danych metodą
Huffmana
307
Bity 0 i 1, które są dokładane na danym etapie do zredukowanych znaków alfabetu,
są wytłuszczone. Mam nadzieję, że czytelnik nie będzie miał zbytnich kłopotów
z odtworzeniem sposobu konstruowania kodu. tym bardziej, że nasz przykład
bazuje na krótkim alfabecie.
Przy klasycznej metodzie kodowania binarnego, komunikat o długości 60
(napisany z użyciem 6-znakowcgo alfabetu) zakodowalibyśmy przy pomocy
60x3= 180 bitów. Przy użyciu kodu Huffmana, ten sam komunikat zająłby
odpowiednio: 20x2+17x2+ 10x2+9x3+3x4+1x4=137 znaków (zysk wynosi
ok. 23 %).
Wiemy już jak konstruować kod. warto zastanowić się nad implementacją pro
gramową w C++. Nie chciałem prezentować gotowego programu kodującego,
gdyż zająłby on zbyt dużo miejsca. Dobrą metodą byłoby skopiowanie struktury
graficznej przedstawionej na dwóch ostatnich rysunkach. Jest to przecież tablica
2-wymiarowa, o rozmiarze maksymalnym 6x5. W jej komórkach trzeba by było
zapamiętywać dość złożone informacje: kod znaku, częstotliwość jego wystę
powania, kod bitowy. Z zapamiętaniem tego ostatniego nie byłoby problemu,
możliwości w C++ jest dość sporo: tablica 0/1, liczba całkowita, której repre
zentacja binarna byłaby tworzonym kodem... Podczas kodowania nie należy
również zapominać, aby wraz z kodowanym komunikatem posłać jego... kod
Huffmana! (Inaczej odbiorca miałby pewne problemy z odczytaniem wiadomości).
Problemów technicznych jest zatem dość sporo. Oczywiście, zaproponowany
powyżej sposób wcale nie jest obowiązkowy. Bardzo interesujące podejście
(wraz z gotowym kodem C++) prezentowane jest w [Sed92]. Autor używa tam
kolejki priorytetowej do wyszukiwania znaków o najmniejszych prawdopodo
bieństwach, ale to podejście z kolei komplikuje nieco proces tworzenia kodu bi
narnego na podstawie zredukowanego alfabetu. Zaletą algorytmów bazujących
na „stertopodobnych " strukturach danych jest jednak niewątpliwie ich efektyw
ność: operacje na stercie są bowiem klasy log N. co ma wymierne znaczenie
praktyczne! Popatrzmy zatem, jak można wyrazić algorytm tworzenia drzewa
kodowego Huffmana, właśnie przy użyciu tych struktur danych:
Huffman(s, f)
// s - kodowany binarny ciąg znaków
//f - tablica częstotliwości występowania znaków w alfabecie
{
wstaw wszystkie znaki ciągu s do sterty H
stosownie do ich częstotliwości;
dopóki H nie jest pusta wykonuj
{
jeśli H zawiera tylko jeden znak X wówczas
X staje się korzeniem drzewa Huffmana T;
w
przeciwnym
{
wypadku
308
Rozdział 13. Kodowanie i kompresja danych
}
• weź dwa znaki X i Y z najmniejszymi częstotliwościami
f1 i f2 i usuń je ze s t e r t y H;
• zastąp X i Y znakiem zastępczym Z, którego częstotliwość występowania wynosi f=f1 + f2;
• wstaw znak Z do kolejki H;
• wstaw X i Y do drzewa T jako potomków Z;
}
zwróć drzewo T;
}
Algorytm ten jest oczywiście równoważny podanemu wcześniej, zmieniliśmy
tylko formę zapisu.
Zachęcam Czytelnika do głębszych studiów teorii kodowania i informacji, gdyż
są to bardzo ciekawe zagadnienia o dużym znaczeniu praktycznym. Z braku
miejsca nie mogłem podjąć wielu interesujących wątków, poza tym pewne za
gadnienia trudno przełożyć na łatwy do zrozumienia kod C++, Proszę zatem
potraktować len rozdział jako wstęp, za którym kryje się bardzo rozległa i cie
kawa dziedzina wiedzy!
Uwaga:
Na dyskietce dołączonej do książki, w katalogu HUFFMAN znajdują się pro
gramy HUF.C i UNHUF.C autorstwa Shaun Case. Są to programy typu public
domain, ściągnięte przez ftp z sieci Internet. Autor prezentuje gotowe procedu
ry kodujące i dekodujące pliki binarne. Pliki są dostarczone w nietkniętej po
staci i mogą wymagać dostosowania do konkretnej wersji kompilatora C++
(Oryginalnie są napisane w jezyku C dla kompilatora Borland C++ 2.0). Oczy
wiście, nie mogę ręczyć, że działają one poprawnie, ale taka już jest idea opro
gramowania public domain...
Rozdział 14
Zadania różne
W tym rozdziale została zamieszczona grupa zadań, które nie zmieściły się
w rozdziałach poprzednich. Są to proste wprawki programistyczne o dość
atrakcyjnej tematyce - ich rozwiązanie może stanowić test na sprawność w sta
wianiu czoła codziennym zadaniom programistycznym. Niektóre zadania nie po
siadają rozwiązań, sądzę jednak, że ich prostota powinna usprawiedliwić ten za
bieg.
14.1.Teksty zadań
Zad. 14-1
Tzw. sito Erastotenesa jest jedną ze starszych metod na otrzymywanie liczb
pierwszych (tzn. tych, które dzielą się tylko przez siebie i przez 1). Algorytm
polega na następującym „odsiewie'' liczb:
• wypisać ciąg liczb naturalnych:
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15... N
• usunąć z nich wielokrotności liczby 2:
l,*,l*,5,*,7,*,9,*,ll,*,13,*,15,...
• usunąć z nich wielokrotności liczby 3:
1,*,*,*,5,*,7,*,*,*,11,*,13,*,*...N
• usunąć z nich wielokrotności liczby 5, 7...
N
310
Rozdział 14. Zadania różne
Algorytm ten można nieco uprościć, wiedząc że jeśli liczba n nie jest pierwsza.
wówczas jest ona podzielna przez pewną liczbę pierwszą, taką że jest ona mniejsza
lub równa całkowitej części
Proszę napisać program, który:
•
sprawdza metodą brute-force), czy dana liczba jest liczbą pierwszą;
•
wykorzystując metodę sita Erastotenesa, liczy wszystkie liczby pierwsze
mniejsze od 100;
•
wykorzystując metodę uproszczoną, liczy wszystkie liczby pierwsze
mniejsze od 100.
Zad. 14-2
Napisać funkcję, która otrzymując na wejściu datę zakodowaną w postaci licz
by całkowitej (np. 220744) wypisze słownie jej znaczenie (tutaj; „22 lipca
1944 " ).
Zad. 14-3
W operacjach macierzowych często są używane tablice z dużą ilością zer. Repre
zentowanie ich w postaci dwuwymiarowej wydaje się marnotrawstwem pamięci.
Spróbuj zaproponować strukturę danych, która będzie zawierała tylko informację
o ..współrzędnych " elementów niezerowych. Zakładamy, że wszystkie pozo
stałe liczby, nie zaprezentowane w niej, są zerowe. Zaproponuj funkcje obsłu
gujące taką strukturę danych: wypisujące macierz w formie „odkodowanej " ,
dodające i mnożące dwie macierze etc.
Spróbuj określić w przybliżeniu, do jakiego stopnia zapełnienia tablicy zerami
taka struktura danych jest opłacalna, jeśli chodzi o zużycie pamięci.
Zad. 14-4
Zaproponuj dwie wersje rekurencyjnego algorytmu obliczania funkcji x n (rekurencja „naturalna " i rekurencja ..z parametrem dodatkowym " ).
Zad. 14-5
Spróbuj stworzyć nierekurencyjną funkcję, która na podstawie dwóch list posor
towanych zwróci jako wynik listę posortowaną, zawierającą wszystkie ich ele
menty. Wymóg: nie wolno tworzyć nowych komórek pamięci, jedyne, co jest
dozwolone, to manipulacja wskaźnikami.
14.1. Teksty zadań
311
Zad. 14-6
Napisz funkcję, która na podstawie ceny podanej w postaci liczby całko
w i t e j typu. np. long wydrukuje ją w postaci słownej. Przykład: wywołanie
cena_slownie(12304) powinno dać w rezultacie tekst: „dwanaście tysięcy trzysta cztery zł " .
Zad. 1 4 - 7
Napisz program, który realizuje permutację cykliczną danej tablicy wejściowej
o zadaną liczbę pozycji. Spróbuj przeprowadzić dokładną analizę problemu.
wybierając technikę programowania: rozwiązanie iteracyjne, rekurencyjne - j e śli tak, to jakiego typu?
Zad. 14-7
Napisać program liczący w najprostszy sposób ilość wystąpień danego słowa
w tekście wejściowym.
Zad. 14-9
Napisać program obliczający w najprostszy możliwy sposób dane „statystyczne " tekstu wejściowego: ilość wystąpień każdej litery, słowa etc.
Zad. 14-10
Napisać program sprawdzający, czy zdanie wejściowe jest palindromem (tzn.
czy da się czytać tak samo z lewej do prawej, jak i z prawej do lewej strony).
Zad. 14-11
Napisać program sprawdzający, czy zdanie wejściowe zawiera „ukryte słowo " .
np. tekst „Bronek alergicznie nie znosił makaronu z kaszą " ukrywa słowo
„bramka " .
Zad. 14-12
Napisać funkcję obliczającą wyrażenia postaci: „ 2 + 2 + 1 " . „ 1 + 2 * 3 " etc., podane
w postaci wskaźnika typu char*. (Funkcją biblioteczną C++, która zamienia
ciąg znaków na liczbę zmiennopozycyjną jest atof, ale jej działanie zatrzymuje
się na pierwszym znaku tekstu, który nie jest cyfrą).
Rozdział 14. Zadania różne
14.2.Rozwiązania
Zad. 14-1
Do rozwiązania zadania (a) będziemy potrzebowali funkcji zwracającej nam
pierwszą całkowitą liczbę r spełniającą warunek x*x & lt; n:
erastot.cpp
inline int sqrt_int(int n)
{ return (int)sqrt((double)n)/l;}
(Wykorzystujemy fakt, że dzielenie całkowite w C++ obcina część ułamkową).
Odpowiedź na pytanie, „czy n jest liczbą pierwszą? " , sprowadza się do spraw
dzenia, czy istotnie dzieli się ona tylko przez / i przez siebie samą:
int
pierwsza ( i n t n)
{
// czy n j e s t liczba pierwsza?
i n t limes=sqrt i n t ( n ) ;
for ( i n t i = 2 ; n ! = ( n / i ) * i & & i & lt; =limes; i++) ;
if (i & gt; limes)
r e t u r n 1; // t a k , l i c z b a pierwsza
else
return 0; // nie, " zwyczajna " liczba
}
Nieco bardziej skomplikowana jest realizacja „sita Erastotenesa " (b).
Problemem jest konieczność deklaracji dużych tablic, ale jest to jedyna wada
tej prostej metody:
void sito(int n)
{
// wypisuje wszystkie liczby pierwsze & lt; n
int *tp=new int[n+1];
for (int i=l;i & lt; =n;i++) // zaznaczenie wszystkich
tp[i]=i; // liczb naturalnych od 1 do n
int cpt=1;
while(cpt & lt; n)
{
//szukamy pierwszego niezerowego elementu tablicy tp:
for(cpt++;(tp[cpt]==0) & & (cpt!=100); cpt++);
// zerujemy wielokrotności tego elementu (cpt w tp
int k=2;
while(cpt*k & lt; =n)
{
tp[cpt*k]=0;
k++;
}
14.2. Rozwiązania
313
}
for(i=l;i & lt; =n;i++)
i f ( t p [ i ] ! = 0 ) c o u t & lt; & lt; " Liczba p i e r w s z a : "
& lt; & lt; tp[i] & lt; & lt; endl;
delete tp; // usuniecie tablicy pamięci
}
Metoda będąca przedmiotem pytania (c) jest skomplikowana w zapisie, ale sama
jej idea odpowiada dokładnie tej, zapowiedzianej w tekście zadania. Na samym
początku możemy zauważyć, że ilość potencjalnie odnalezionych liczb pierw
szych & lt; n na pewno jest mniejsza od sqrt_int(n). Ułatwi nam to optymalny
przydział pamięci. Aby sprawdzić, czy pewna liczba n jest liczbą pierwszą,
wystarczy zatem podzielić ją przez uprzednio wyliczone wartości:
int
{
x
liczby_pierwsze(int n)
// zwraca tablicę liczb pierwszych & lt; n
// ilość liczb pierwszych & lt; n na pewno nie przekracza
// sqrt_int(n), stad optymalny przydział pamięci:
int limes_i,*lp=new int[sqrt_int(n)];
lp[0]=l;lp[l]=2; //inicjacja tablicy liczb pierwszych
for ( i n t i = 2 ; i & lt; n ; i + + )
lp[i]=0;
i n t np=2;
/ / n a s t ę p n a l i c z b a pierwsza
// i n d e k s w t a b l i c y
for (i=3;i & lt; n;i++)
{
(tzn. jej
limes_i=sqrt_int(i);
for(int j=1; (i!=lp[j]*(i/lp[j])) & &
(lp[j] & lt; =limes_i);j++);
if (lp[j] & gt; limes_i)
lp[np++]=i;
}
return l p ;
}
Zad. 14-3
Struktura danych obsługująca „tablicę zer " może mieć postać następującej listy:
struct zero_lst
{
int x,y;
i n t val;
// lub inny dowolny
s t r u c t z e r o _ l s t *nastepny;
};
t y p danych
Zakładając, że wskaźnik zastepny zajmuje dwa bajty pamięci (podobnie jak
i zmienne typu int), dowolny element listy zajmuje p=2+2+2+2=8 bajtów
pamięci. Z drugiej zaś strony, w przypadku klasycznej tablicy, pojedynczy
314
Rozdział14. Zadania różne
element kosztuje nas tylko 2 bajty (jest to zmienna typu int), ale za to z góry
musimy przydzielić pamięć na całą tablicę.
Oznaczmy rozmiar tablicy przez N. Wówczas całkowita zajęta przez nią pamięć
2
wynosi 2N
bajtów. „Magiczną " granicę k, przy której sens stosowania listy
2
jest wątpliwy, można z łatwością, obliczyć przy pomocy równości: k*p=2N ,
Przykładowo dla p=8, N=10), dwudziesty szósty niezerowy element już prze
biera miarkę. Praktycznie rzecz ujmując, typowa ilość niezerowych elementów
powinna być znacznie mniejsza od
zję, co do właściwej interpretacji wyrażenia „znacznie " ...
Zad. 14-4
Dwie wersje programów rekurencyjnych służących do obliczania xn znajdują się
poniżej:
pot.cpp
int pot1(int x, i n t n)
{
if (n==0)
return 1;
else
return (pot 1 (x, n-1)*x) ;
}
int pot2(int x, int n, int temp=l)
{
if (n==0)
r e t u r n temp;
else
return (pot2(x,n-1,temp*x));
}
void
{
cout
cout
cout
main()
& lt; & lt; " Dwa do p o t e g i t r z e c i e j : \ n " ;
& lt; & lt; " Metoda l\t " & lt; & lt; potl(2,3) & lt; & lt; " \n " ;
& lt; & lt; " Metoda 2\t " & lt; & lt; pot2(2,3) & lt; & lt; " \n " ;
}
Zad. 14-10
Zadanie należy do elementarnych, nie powinno zatem nikomu sprawić trudności dojście
do następującego rozwiązania:
palindro.cpp
void palindrom(char *s)
{
14.2. Rozwiązania
315
int dl=strlen(s),cpt=0;
// słowo j e s t (na r a z i e ) palindromem :
enum {TRUE,FALSE} test=TRUE;
w h i l e ( ( c p t & lt; = d l / 2 ) & & ( t e s t = T R U E ) )
if(s[cpt]==s[dl-cpt-l])
cpt++;
else
test=FALSE;
cout & lt; & lt; s;
if(test==TRUE)
c o u t & lt; & lt; " j e s t palindromem \ n " ;
else
cout & lt; & lt; " j e s t zwykłym s ł o w e m . . . \ n " ;
}
Zad, 14-12
Problem obliczania wartości wyrażeń zapisanych w postaci słownej, występuje
dość często w praktyce programistycznej- Zadanie jest ogólnie dość skompli
kowane i warto dobrzeje przemyśleć. Niech zatem rozwiązanie, które przed
stawiam poniżej posłuży raczej za przyczynek do rozważań, niż gotowy wzo
rzec. Tym bardziej, że dla pewnych konfiguracji danych wyrażenie jest obli
czane źle!
double t r a n s l ( c h a r * s )
// zamienia ciągi znaków typu 1+1 na 2, l+l+2*5 na 12 e t c .
// uwaga funkcja nie analizuje operacji dzielenia i
// badania przypadku dzielenia przez zero!
{
i n t n=strlen(s) ;
c h a r *sl=new c h a r [n+1] ; //kopia robocza tekstu wejściowego
strcpy(sl,s);
wyraz.cpp
// szukamy znaków + i *
f o r ( i n t i = 0 ; i & lt; n;i++)
if (s[i]==' + ' || s [ i ] = = ' * ' )
{
s l [ i ] = ' \ 0 ' // " ucinamy " kopie robocza, tekstu z prawej strony
if (s[i]=='+')
return transl(sl)+transl(s+i+1);
else
return transl(s1)*transl(s+i+1);
}
// przypadek e l e m e n t a r n y :
delete sl;
return arof(s); //
atof= " a s c i i to f l o a t "
}
316
Rozdział 14. Zadania różne
void main()
{
cout & lt; & lt; " 1+1= " & lt; & lt; transl( " l+l " ) & lt; & lt; endl;
cout & lt; & lt; " 2*2*3= " & lt; & lt; transl( " 2*2*3 " ) & lt; & lt; endl;
cout & lt; & lt; " 2+2*3= " & lt; & lt; transl( " 2+2*3 " ) & lt; & lt; endl;
cout & lt; & lt; " 2+2+3= " & lt; & lt; transl( " 2+2+3 " ) & lt; & lt; endl;
cout & lt; & lt; " 2+2*0= " & lt; & lt; transl( " 2+2*0 " ) & lt; & lt; endl;
cout & lt; & lt; " 2*3+4*5= " & lt; & lt; trans1( " 2*3+4*5 " ) & lt; & lt; endl;
//
//
//
//
//
//
2
12
8
7
2
46
OK
OK
OK
OK
OK
źle!
}
Proszę się zastanowić, dlaczego funkcja transl źle obliczyła ostatnie wyrażenie?
(Wskazówka: proszę odtworzyć „kierunek " analizy wyrażenia.)
Dla zaawansowanych programistów C++: proszę przeanalizować zarządzanie
pamięcią w funkcji transl. Czy użycie new i delete jest na pewno optymalne
w tym przypadku?
Dodatek A
Poznaj C++ w pięć minut!
Dodatek ten stanowi w swoim założeniu pomost dla programistów pascalo
wych, którzy chcą szybko i bezboleśnie poznać podstawowe elementy języka C++,
tak aby lektura książki nie napotykała na barierę niezrozumienia na poziomie
użytej składni. Materiał ten celowo został umieszczony w dodatku, bowiem nie
wchodzi on w zasadniczy nurt książki.
Rozdział ten nie zastąpi z pewnością monograficznego podręcznika poświęconego
językowi C++. nic to jest jednak jego celem. Poniższy szybki kurs języka C++
obejmuje tylko te elementy, które są konieczne do zrozumienia prezentowanych
listingów Jest to niezbędne minimum, zorientowane wyłącznie na składnię.
Elementy języka C++ na przykładach
Kolejne sekcje zawierają serię przykładów, na podstawie których osoba znająca
język Pascal może na zasadzie analogii poznać podstawowe zasady zapisu algo
rytmów w C++.
Pierwszy program
Spójrzmy na poniższy program, z doskonale znanego gatunku hello world:
program prl;
{komentarz}
begin
writeln('Witaj!')
end.}
•
#include
& lt; iostream.h & gt;
void main() //komentarz
{
cout & lt; & lt; Witaj ! \ n " ;
}
blok w C++jest ograniczany przez nawiasy klamrowe { };
318
Dodatek A
•
słowo void oznacza procedurę, czyli funkcję nie zwracającą wartości
w sensie arytmetycznym;
•
działanie programu rozpoczyna się od funkcji o nazwie main,
W C++ komentarz //,.działa " , aż do końca linii. Chcąc coś napisać w komentarzu
pomiędzy instrukcjami, użyj raczej /* komentarz */ niż //komentarz.
Linia #include & lt; iostream.h & gt; oznacza dołączenie pliku iostream.h do pliku z progra
mem. Plik ten jest obowiązkowy, jeśli zamierzamy używać standardowych strumieni
tout, tin, cen\ które odpowiadają standardowemu wyjściu (np. ekran), wejściu (np.
klawiatura) oraz miejscu, do którego należy wysyłać komunikaty o biedach. To ostat
nie zazwyczaj odpowiada ekranowi.
Uwaga: W dalszych przykładach dyrektywa ta będzie omijana.
Pliki z rozszerzeniem h (lub hxx) zawierają zazwyczaj deklarację często używanych
stałych i typów. Oczywiście, rozszerzenie pliku nic ma dla kompilatora naj
mniejszego znaczenia, warto się jednak trzymać jakiejś określonej konwencji.
Tekst w C++ można wypisać, wysyłając ciąg znaków ograniczony przez cudzysłów
( " tekst " ) do standardowego wyjścia. Sekwencja \x oznacza znak specjalny, np.
\n jest to skok do nowej linii podczas wypisywania tekstu na ekranie. \t - znak
tabulacji etc.
Operacje arytmetyczne
Niewielkie różnice dotyczą pewnych operatorów, które w Pascalu nazywają się
nieco inaczej niż w C++. To, co może uderzyć nas przy pierwszym spojrzeniu
na język C++, to nasycenie programów skrótami w zapisie operacji arytme
tycznych. czyniące listingi dość często pozornie mało czytelnymi. Mam tu
przede wszystkim na myśli operatory ++, -- oraz całą rodzinę wyrażeń typu:
zmienna OPERATOR = wyrażenie
Należy podkreślić, iż stosowanie tych form nie jest obowiązkowe, tym niemniej
wskazane - kod wynikowy programu będzie dzięki temu nieco efektywniejszy.
const pi=3.14;
const float pi=3.14;
program pr2;
// lub double
var a, b, c: integer; { globalne} int a,b,c;
begin
void main()
a:=l;
b:=1;
1
Jeszcze przed właściwą kompilacją.
{
a=1;
Poznaj C++ W pięć minut!
a:=a+l; {inkrementacja}
end.
319
b=l;
a++; //inkrementacja
b-=2; // ŚREDNIK!
}
• miejsce deklarowania zmiennych w C++ jest dowolne. Można to uczynić przed, za i w ciele niektórych instrukcji;
• przy deklaracji stałej, opuszczenie typu w deklaracji oznacza, że bę
dzie to domyślnie int.
• przypisanie wartości zmiennej odbywa się za pomocą =, a nie:=;
• znanym z Pascala div i mod, odpowiadają w C++ odpowiednio / i %.
Zwróćmy uwagę na często używane w C ++ operatory inkrementa¬
cji/dekrementacji (++/--). Zastosowane w wyrażeniu mają one priorytet " , jeśli
są użyte przedrostkowo, natomiast w przypadku użycia przyrostkowego prio
rytet ma wyrażenie.
Przykład:
a=2;
b=5;
n=a+b+ + ; // n=7 (priorytet ma dodawanie)
b=5;
k=a+ ++b; // k-8 (priorytet ma inkrementacja)
• zmienna o wyrażenie jest równoważne klasycznemu zapisowi: zmienna=zmienna o wyrażenie, gdzie o oznacza pewien operator dwuargumentowy.
Operacje logiczne
Podobnie jak arytmetyczne, operacje logiczne także mają swoje osobliwości.
Na szczęście nie jest ich aż tak wiele. Programiści pascalowi powinni zwrócić
szczególną uwagę na różnicę pomiędzy = w Pascalu, a == w C++. Niestety.
kompilator nie wykaże błędu, jeśli w C++ spróbujemy skompilować instrukcję
if(a=1)a=a-3!
Program pr3;
var a:boolean;
begin
a:=true;
2
int a;
void main {)
{
a=-5;
Tzn. są uwzględniane w pierwszej kolejności.
320
Dodatek A
if a=true then
writeln('true')
else
writeln('false')
end.
if
(a==0)
cout & lt; & lt; " true \n " ;
else
cout & lt; & lt; " false \n " ;
}
W C++ typ boolean nie istnieje: „symuluje " się go na ogół za pomocą, int, przy
czym zero oznacza false, a wszystkie inne wartości - true.
Zwróć uwagę na rolę średnika w C++, który oznacza koniec danej instrukcji. Z tego
powodu nawet instrukcja znajdująca się przed else musi być nim zakończona!
Niektóre operatory logiczne używane w porównaniach są odmienne w obu ję
zykach (patrz tabela A-1).
Tabela i - 1.
Porównanie operatorów
Pascalu i C++.
Zmienne dynamiczne
C++ stanowi ciekawy melanż mechanizmów o wysokim poziomie abstrakcji
(jest to przecież tzw. język strukturalny) z możliwościami zbliżającymi go do
języka asemblera. Umiejętne wykorzystanie zarówno jednych, jak i drugich
umożliwia łatwe programowanie efektywnych aplikacji. Zmienne dynamiczne,
adres) I wskaźniki są kluczem do dobrego poznania C++i trzeba je dobrze
opanować. Poniższy przykład ukazuje sposób tworzenia zmiennych dynamicz
nych i operowania nimi.
program pr4;
type example=^real;
var p:example;
begin
new (p)
p^:=3.1 1;
dispose(p)
void main()
{
float *p;
// albo: double *p
p=new float;
*p=3.14;
delete p;
end.
}
•
W C + + operacje wskaźnikowe (na adresach) nie są ograniczone do
zmiennych dynamicznych.
Poznaj C++ w pięć minut!
321
Typy złożone
W języku C++ występuje komplet typów prostych i złożonych, dobrze znanych
z języków strukturalnych. Należą do nich między innymi tablice i rekordy.
W porównaniu z Pascalem. C++ oferuje tu pozornie mniejsze możliwości
Podstawowe ograniczenie tablic dotyczy zakresu indeksów: zawsze zaczynają
się one od zera. Nie jest możliwe również deklarowanie rekordów „z warianta
mi " . Te niedogodności są. oczywiście do obejścia, ale nie w sposób bezpośred
ni.
Tablice
Indeksy w tablicach deklarowanych w C++ startują zawsze od zera. Tak więc
deklaracja tablicy t o rozmiarze 4 oznacza w istocie 4 zmienne: t[0], t[1].
t[2] i t[3]. Aby uzyskać zgodność indeksów w programach napisanych w
Pascalu i w C++, konieczne jest zastosowanie właściwej translacji tychże!
end.
• Język C++ nie zapewnia kontroli przekroczenia granic tablic podczas
dostępu do nich przy pomocy indeksowania, ufając niejako programiście.
Radą na to jest zastosowanie mechanizmów obiektowych, ale w wersji
pierwotnej trzeba po prostu uważać, aby nie znaleźć się ,,w malinach " ;
• Nazwa tablicy w C++ jest jednocześnie wskaźnikiem do niej. Przykła
dowo, t wskazuje na pierwszy element tablicy, a (t+3) na czwarty. No
tacja *(t+I) jest równoważna t[l];
•
Deklaracja int*x jest równoważna int x[].
Rekordy
Prosty przykład pokazuje elementarne operacje na rekordach:
Tutaj mogłoby to oznaczać „złe adresy " ...
3 2 2
Dodatek A
end;
x;
cell
var
begin
x.c:= ' a ' ;
x.a:=l
end.
x:cell;
void main ()
{
'a' ;
x.a=l;
x.c=
}
• rekordy w C++ są zwane strukturami, dostęp do nich jest podobny jak
w przypadku Pascala;
• nie można wprosi zadeklarować rekordu z wariantami;
• jest możliwe, podobnie jak w Pascalu, „włożenie " tablicy do rekor
du i odwrotnie;
• pole nazwa pola rekordu dynamicznego, wskazywanego przez zmienną
x nie jest dostępne poprzez x.nazwa_pola, lecz przez x- & gt; nazwa_pola.
Instrukcja switch
Instrukcja switch w C++ różni się w kilku zdradzieckich szczegółach od swojej
odpowiedniczki w Pascalu - proszę zatem uważnie przeanalizować podany
przykład!
Najważniejsza do zapamiętania informacja, jest związana ze słowem kluczowym
break (ang. przerwij). Ominiecie go, spowodowałoby wykonanie instrukcji
znajdujących się dalej, aż do napotkania jakiegoś innego break lub końca in
strukcji switch.
program
var w:integer;
begin {
w:=2;
case w of
1:
2;
pr7;
int w;
void main()
w=2;
switch(w)
{
case l:cout & lt; & lt; " l\n " ;break;
writeln('l');
writeln('2');
case 2:cout & lt; & lt; " 2\n " ;break;
default:
cout & lt; & lt; " ?\n " ; break;
}
otherwise:
writeln('?');
end
end.
}
• W C++ break pełni rolę separatora przypadków.
Iteracje
Instrukcje iteracyjne są podobne w obu językach:
program
pr8;
var i,j:integer;
begin
int
i,j;
void main()
Poznaj C++ w piec minut!
323
j:=1;
for i:-l to 5 do
begin
writeln(i*j);
j:=j+i
end;
i:=l;
j:=10;
while j & gt; i do
begin
i:=i+l;
writeln(i)
end
end.
• endl oznacza znak powrotu do nowej linii:
• niewymieniona tu instrukcja do{... }while(v) jest wykonywana w C++
dopóty, dopóki wyrażenie v jest różne od zera.
• elementy instrukcji for (el: e2: e3) oznaczają odpowiednio:
el: inicjację pętli
e2: warunek wykonania pętli
e3: modyfikator zmiennej sterującej (może nim być funkcja, grupa in
strukcji oddzielonych przecinkiem - wtedy są one wykonywane od le
wej do prawej).
Przykład:
for (int; i=6;
i & lt; 100;
Insert(tab[i++]),
Pisz(i));
(Pisz i Insert są funkcjami, tab zaś pewną tablicą.)
Podprogramy
W języku C++, podobnie zresztą jak i w klasycznym C, wszystkie podprogramy
są nazywane funkcjami. Odpowiednikiem znanej z Pascala procedury jest spe
cjalna funkcja „zwracająca " typ o nazwie void.
Procedury
program pr9;
procedure procl(a,b:integer;
var m:inteqer
)
zmienna
4
lokalna:
Porównaj np. z repeal... until.
void procl(int a,
i n t b,
int & m.
)
//zmienna lokalna
324
Dodatek A
C++ nie umożliwia tworzenia procedur i funkcji lokalnych;
zdefiniowane funkcje i procedury są ogólnie dostępne w całym progra
mie;
odpowiednikiem deklaracji typu var w nagłówku funkcji, jest w C++
zasadniczo tzw. referencja ( & ) . np. Fun(var i:integer;...) jest równo
ważne funkcjonalnie formie Fun(int & i... );
nie jest możliwe przekazanie przez referencję tablicy. W C++ tablice są
z założenia przekazywane przez adres. Przykładowo, zapis Fun(int
tab[3]) oznacza chęć użycia jako parametru wejściowego tablicy ele
mentów typu int. Podczas wywołania funkcji Fun. tablica tab jest prze
kazywana poprzez swój adres i jej zawartość może być fizycznie zmo
dyfikowana.
Funkcje
Zasadnicze różnice pomiędzy funkcjami w C++ i w Pascalu dotyczą sposobu
zwracania wartości:
Poznaj C++ w pięć minut!
325
w C++ instrukcja return(v) powoduje natychmiastowy powrót z funkcji
z wartością v. Przykładowo, po instrukcji if(v) retuni(val) nie trzeba
używać else - w przypadku prawdziwości warunku v ewentualna dalsza
część procedury już nie zostanie wykonana;
dobrym zwyczajem programisty jest używanie tzw. nagłówków funkcji.
czyli informowanie kompilatora o swoich intencjach, co do typów
parametrów wejściowych. Nagłówek funkcji jest tym wszystkim, co
zostaje z funkcji po usunięciu z niej jej definicji i nazw parametrów
wejściowych. Przykładowo, jeśli gdzieś w programie jest zdefiniowana
funkcja:
void f(int k. char* s[3]){...}
to tuż za dyrektywami tfinclude możemy dopisać linię:
void(int, char*[]); //tu średnik!
(Zwróć uwagę na tradycyjny średnik!). Celowo piszę możemy, bowiem
użycie nagłówków jest wymuszone tylko i wyłącznie zdrowym rozsądkiem
programisty. Pozwala ono już na etapie wstępnych kompilacji uniknąć
wielu błędów związanych z wywołaniem funkcji ze złymi parametrami.
Notabene niektóre kompilatory z założenia nie tolerują ich braku.
Struktury rekurencyjne
Przykład następny pokazuje sposób deklarowania rekurencyjnych struktur danych.
Odpowiednikiem nil w C++jest NULL.
Ale, oczywiście nie jest to zabronione.
326
Dodatek A
•
Adres dowolnej zmiennej w C++ może być z niej ..pobrany " poprzez
poprzedzenie j e j nazwy operatorem & .
•
W C++ nie ma pozaskładniowych ograniczeń, co do operacji na adre
sach. zmiennych wskaźnikowych, dynamicznych przydziałach pamięci
etc.
•
Wartość zmiennej, na którą wskazuje pewna zmienna wskaźnikowa wsk,
może być z niej ..wyłuskana " poprzez poprzedzenie jej nazwy operatorem
(gwiazdka).
Przykład:
i n t k=12;
i n t *wsk= & k;
c o u t & lt; & lt; *wsk & lt; & lt; e n d l ; // program wypisze 12
Programowanie obiektowe w C++
Cala silą i piękno języka Cl i zawiera się nie w cechach odziedziczonych od
swojego przodka 6 , lecz w nowych możliwościach udostępnionych przez wpro
wadzenie elementów obiektowych. W zasadzie po raz pierwszy w historii in
formatyki mamy do czynienia z przypadkiem, aż tak dużego zainteresowania
jakimś językiem programowania, jak to się stało z C++. Niegdysiejsza moda
staje się już powoli wymogiem chwili: jest to narzędzie tak efektywne, iż nie
skorzystanie z niego naraża programistę na „stanie w miejscu " w momencie, gdy
świat coraz szybciej podąża do przodu!
Dla formalności tylko, przypomnę jeszcze „ostrzeżenie " zawarte we wstępie:
cały ten rozdział służy wyłącznie nauczeniu programisty pascalowego czytania
i rozumienia listingów napisanych w C + + . Ograniczona objętość książki, w
konfrontacji z rozpiętością tematyki, nie pozwala na omówienie wszystkiego.
Tym niemniej, cytowane tu przykłady zostały wybrane ze względu na ich dużą
reprezentatywność. Osoby głębiej zainteresowane programowaniem obiekto
wym w C++ mogą skorzystać, np. z [Poh89|, |WF921 lub [Wró941 w celu po
szerzenia swojej wiedzy.
Terminologia
Typowe pojęcia związane z programowaniem obiektowym poglądowo zgrupo
wano na rysunku A - l .
6
Którym jest oczywiście język C!
Poznaj C++ w pięć minut!
327
Rys. A - /.
Terminologia
w programowaniu
obiektowym.
Zmienna tego nowego typu danych zwana jest obiektem;
Melody są to zwykłe funkcje lub procedury operujące polami, stano
wiące jednak własność klasy .
Istnieją dwie metody specjalne:
konstruktor, który tworzy i inicjalizuje obiekt (np. przydziela niezbędną
pamięć, inicjuje w żądany sposób pewne pola e t c ) . W deklaracji klasy
można bardzo łatwo rozpoznać konstruktora po nazwie - jest ona iden
tyczna z nazwą klasy, ponadto konstruktor ani nie zwraca żadnej wartości,
ani nawet nie jest typu void;
destruktor, który niszczy obiekt (zwalnia zajętą przezeń pamięć). Podobniejak i konstruktor, posiada on specjalną nazwę: identyczną z nazwą
klasy, ale poprzedzoną znakiem tyldy (~);
Każda metoda ma dostęp do pól obiektu, na rzecz którego została ona
aktywowana poprzez ich nazwy. Inny sposób dostępu jest związany ze
wskaźnikiem o nazwie this (słowo kluczowe C++): wskazuje on na
własny obiekt. Tak więc, dostęp do atrybutu x może się odbyć albo
poprzez. x, albo przez this- & gt; x. Typowo jednak wskaźnik this służy w sytu
acjach, w których metoda, po uprzednim zmodyfikowaniu obiektu, chce
go zwrócić jako wynik (np.: return *this;),
Obiekty na przykładzie
Klasa, jako specjalny typ danych, przypomina w swojej konstrukcji rekord, który
został „wyposażony " w możliwość wywoływania funkcji. Definicja klasy może
7
Tzn. mogą z nich korzystać obiekty danej klasy - inne, „zewnętrzne " funkcje progra
mu już nie!
328
Dodatek A
być podzielona na kilka sekcji charakteryzujących się różnym stopniem dostęp
ności dla pozostałych części programu. Najbardziej typowe jest używanie
dwóch rodzajów sekcji: prywatnej i publicznej. W części prywatnej, na ogól
umieszcza się informacje dotyczące organizacji danych (np. deklaracje typów
i zmiennych), a w części publicznej, wymienia dozwolone operacje, które można
na nich wykonywać. Operacje te mają, oczywiście postać funkcji, czyli - uży
wając j u ż właściwej terminologii - metod przypisanych klasie.
Spójrzmy na sposób deklaracji klasy, która w sposób dość uproszczony obsługuje
tzw. liczby zespolone:
Konstrukcja klasy Complex informuje o naszych intencjach:
•
wiemy, że liczby zespolone są wewnętrznie widziane j a k o część
rzeczywista i część urojona. Ponieważ sposób budowy klasy jest jej prywatną sprawą, informację o tym umieszczamy w sekcji prywatnej, która
redukuje się w naszym przypadku do deklaracji zmiennych Re i Im:
•
z punktu widzenia obserwatora zewnętrznego (czyli po prostu użytkownika
klasy), liczba zespolona jest to obiekt, na którym można wykonywać
Poznaj C++ w pięć minut!
329
operację dodawania8 (mnożenia, dzielenia etc.) oraz wypisywać ją
9
w pewnej określonej postaci ;
•
w celu dodawania liczb zespolonych przedefiniujemy znaczenie stan
dardowego operatora 1, podobnie uczynimy w przypadku wypisywania
- tym razem z operatorem «.
Konstruktor klasy oraz dwie proste metody Czesc_Rzecz i Czesc_Uroj są zdefi
niowane już „wewnątrz " deklaracji klasy ograniczonej nawiasami klamrowymi { }.
Decyzja o miejscu definicji jest najczęściej podyktowana długością kodu: jeśli
metoda ma pokaźną objętość 10, to zwykle przemieszcza się ją „na ze
wnątrz " , w „środku " pozostawiając tylko nagłówek.
Deklaracja przykładowego obiektu 20+10*j ma w programie postać:
przypadek I : (niejawne tworzenie obiektu poprzez jego deklarację):
Complex Nazwa(Obiektu(20, 10);
przypadek 2 : (jawne tworzenie obiektu poprzez new):
Complex
*NazwaObiektu Ptr = new Complex(20,10);
Wywoływanie metod odbywa się za pomocą standardowej notacji „z kropką " :
NazwaObiektu. Nazwa Metody (parametry); //przypadek I
lub
NazwaObiekfu l'tr- & gt; Nazwa Metody (parametry); //przypadek 2
Wiedząc już jak to wszystko powinno działać, popatrzmy, jak zrealizować brakują
ce metody.
Funkcja wypisz jest tak trywialna, iż równie dobrze mogłaby być zdefiniowana
wprost w ciele klasy. Ponieważ jest to metoda klasy Complex, musimy o tym
poinformować kompilator poprzez poprzedzenie j e j nazwy, nazwą klasy zakoń
czoną operatorem :: (wymóg składniowy). Jako metoda klasy, procedura ta ma
dostęp do prywatnych pól obiektu, na rzecz którego została aktywowana.
Gdyby jednym z parametrów tej metody był inny obiekt klasy Complex (np.
Przykład ogranicza się tylko do dodawania - pozostałe operacje arytmetyczne Czytelnik
może z łatwością dopisać samodzielnie.
9
10
Reprezentacja za pomocą modułu i lazy zostaje pozostawiona do realizacji Czytelnikowi
jako proste ćwiczenie programistyczne.
Powszechnie zalecaną regułą jest nieprzekraczanie jednej strony przy konstrukcji
procedury - tak aby całość mogła zostać objęta wzrokiem bez konieczności gorącz
kowego przerzucania kartek.
.
330
Dodatek A
Complex .r), to dostęp do jego pól odbywałby się za pomocą notacji z kropką.
Przykład: x.Re.
complcx.cpp
void Complex::wypi3Zf)
i
cout « Re « " 4j* " « im « endl;
)
Język C++ umożliwia łatwe przedeftniowanie znaczenia operatorów' standar
dowych, tak aby operacje na obiektach uczynić możliwie najprostszymi. Ponieważ
liczby zespolone nieco inaczej dodaje się niż te „zwykłe " , celowe będzie
ukrycie sposobu dodawania w funkcji, a w świecie zewnętrznym pozostawie
nie do tego celu operatora +. Najwygodniejszym sposobem przedefiniowania
operatora dwuargumeiitowego jest użycie do tego celu \zw. funkcji zaprzyjaź
nionej: jest to specjalna funkcja, która nie będąc metodą " pewnej określonej
klasy, może operować obiektami należącymi do niej. Dotyczy to również do
stępu do pól prywatnych!
Nasza funkcja zaprzyjaźniona ma następujące działanie: dwa obiekty x i y są prze
kazywane jako parametry. Odczytując wartości ich pól Re i Im, możliwe jest
skonstruowanie nowego obiektu klasy Complex wg prostego wzoru:
(a+j*b)~(c+j*d) = (a+c)~(b+d)*j. Jest to matematyka elementarna, zawarta
w programie nauczania szkoły średniej. Po utworzeniu, nowy obiekt jest zwracany
przez referencję - czyli jako w pełni adresowalny obiekt, który może być przy
pisany innemu obiektowi, na którego rzecz może być aktywowana jakaś metoda
klasy Complex etc. Prawidłowe będą zatem instrukcje:
Complex x ( l , 2 } , y ( 2 , 3 ) , c ;
c«x+y;
// deklaracje obiektów
// c-łl+2) IJM2+3)
Popatrzmy na listing funkcji +:
Complex* operator +(Complex x,Complex y)
f
double tmp_Re-x.Czt!5C_Rzec2 () + y . C z e s c Rzecz U;
d o u b l e t m p _ l m = x . C z e s c _ J T r n j O + y. C z e s c _ U r o j {) ,Complex *NowyObiekc-new C o m p l e x ( t N p _ R e , t t n p I m ) ;
return (*NowyObiekt);
}
Warto zwrócić uwagę na fakt, iż obiekt NowyOhiekt jest tworzony w sposób
jawny przy pomocy new. Tego typu postępowanie zapewnia nam, że zwrócona
referencja będzie się odnosiła do obiektu trwałego (zwykła instrukcja Complex
NowyObieki użyta wewnątrz bloku stworzyłaby obiekt tymczasowy, któiy znik
nąłby po wykonaniu instrukcji zawartych we wspomnianym bloku).
W konsekwencji nie mogą być wywoływane za pomocą notacji „z kropką " !
Poznaj C++ w pięć minut!
331
Podobnie jak w przypadku operatora +, celowe mogłoby być przedefilowanie
operatora «, który wysyła sformatowane dane do strumienia wyjściowego.
W C++ służy do tego celu klasa o nazwie ostream. Bez wnikania w szczegóły12,
proponuje zapamiętać zastosowaną poniżej sztuczkę:
ostream* o p e r a t o r «
(ostream
fistr,Complex
xj
(
Str « X.CzeSC_RzPC7 () & lt; & lt; " -*.j*» £ & lt; x.Czesc_Uroj () ;
return 3tr;
)
Spójrzmy wreszcie na program przykładowy, który tworzy obiekty i mani
puluje nimi:
frinclude " complex.h "
void main()
i
Complex cl (1,2),c2(3,4);
cout « " cl- " ;
cl.wypisz();
COUt « " cZ= " ;
c2.wypisz();
cuuL « M c l - c 2 = " & lt; & lt; ( c l + c 2 ) « e n d l ;
Complex *c ptrr=new Complex ( 1 , 7) ;
c o u t & lt; & lt; " d ) c p e r wskazuje na o b i e k t " ;
c_ptr- & gt; wypisz() ;
cout & lt; & lt; " b) c _ p t r wskazuje na u b i e k t " & lt; & lt; * c ptr & lt; & lt; e n d l ;
ł
Dla formalności prezentuję rezultaty wykonania programu:
cl-l+j*2
c2=3+jM
cl+c?=4+j*6
c _ p t r wskazuje na o b i e k t l + j * 7
c _ p t r wskazuje r.a ohipkr l + j * 7
Składowe statyczne klas
Każdy nowo utworzony obiekt posiada pewne unikalne cechy (wartości swoich
atrybutów). Od czasu do czasu zachodzi jednak potrzeba dysponowania czymś
w rodzaju zmiennej globalnej w obrębie danej klasy: służą do tego tzw. pola
statyczne.
Poprzedzenie w definicji klasy C, atrybutu A słowem static, spowoduje utworzenie
właśnie tego typu zmiennej. Inicjacja takiego pola może nastąpić nawet przed
12
Nie miejsce tu bowiem na omawianie dość złożonej hierarchii bibliotek klas dostarczanych
z dobrymi kompilatorami C++. Początkującego fana C++ taki opis mógłby dość skutecznie
zanudzić...
332
Dodatek A
utworzeniem jakiegokolwiek obiektu klasy C! W tym celu piszemy po prostu
C::x-jakaś[_wartość,
Zbliżone ideowo jest pojęcie metody statycznej', może być ona wywołana jeszcze
przed utworzeniem jakiegokolwiek obiektu. Oczywistym ograniczeniem metod
statycznych jest brak dostępu do pól niestatycznych danej klasy\ ponadto
wskaźnik this nie ma żadnego sensu. W przypadku metody statycznej, jeśli
chcemy jej umożliwić dostęp do pól niestatycznych pewnego obiektu, trzeba go
jej przekazać jako... parametr!
Metody stałe klas
Metoda danej klasy może zostać przez programistę określona mianem stałej (np.
void fitnO cons/:). Nazwa ta jest dość nieszczęśliwie wybrana, chodzi w istocie
o metodę, która deklaruje się, że nigdy nie zmodyfikuje pól obiektu, na rzecz
którego została zaktywowana.
Dziedziczenie własności
Załóżmy, że dysponujemy starannie opracowanymi klasami A i B. Dostaliśmy je
w postaci skompilowanych bibliotek, tzn. oprócz kodu wykonywalnego mamy
tylko do dyspozycji szczegółowo skomentowane pliki nagłówkowe, które
informują nas o sposobach użycia metod i o dostępnych atrybutach.
Niestety, twórca klas A i B dokonał kilku wyborów, które nas niespecjalnie
satysfakcjonują, i zaczęło nam się wydawać, że my zrobilibyśmy to nieco lepiej...
C/y musimy wobec tego napisać własne kkisy A i fi, a dostępne biblioteki wy
rzucić na śmietnik? Powinno być oczywiste dla każdego, że nie zadawałbym
tego pytania, gdyby odpowiedź nie brzmiała: NIE. Język C++ pozwala na bardzo
łatwą ,-reutyiizację'* kodu już napisanego (a nawet skompilowanego), przy jed
noczesnym umożliwieniu wprowadzenia „niezbędnych " zmian. Weźmy dla
przykładu deklaracje dwóch klas A i B, zamieszczone na listingu poniżej:
dziedzic, h
class Cl
{
protected:
int x;
public:
Cl(int n) //konstruktor
{
x = n;
& gt;
void pisz()
t
cout & lt; & lt; " **Stara wer.sia " ;
Poznaj C++ w pięć minut!
^
333
cout « " m e t o d y ł p i s z : x » w
& lt; & lt; x & lt; & lt; e n d l ;
ł
c l a s s C2
{
private:
i n t y;
public:
C2(int nj //konst r u k t o r
1
y - n;
int ret_y()
I
return y;
ł
& gt; ;
Słowo kluczowe protected (ang. chroniony) oznacza, że mimo piywatnego dla
użytkownika klasy charakteru informacji znajdujących się w tej sekcji, zostaną
one przekazane ewentualnej klasie pochodnej (zaraz zobaczymy, co to oznacza...).
Oznacza to. że klasa dziedzicząca będzie ich mogła używać zwyczajnie poprzez
nazwę, ale już użytkownik nie będzie miał do nich dostępu poprzez rtp. notację
„z kropką " . Jeszcze większymi ograniczeniami charakteryzują się pola prywatne:
klasa dziedzicząca traci możliwość używania ich w swoich metodach przy
pomocy nazwy. Ten brak dostępu można, oczywiście sprytnie ominąć, definiując
wyspecjalizowane metody służące do kontrolowanego dostępu do pól klasy.
Dołożenie tego typu ochrony danych znakomicie izoluje tzw. interfejs użytkownika
od bezpośredniego dostępu do danych.... ale to już jest temat na osobny rozdział'
Przeanalizujmy wreszcie konkretny przykład programu. Nowa klasa C dziedziczy
własności po klasach A i B oraz dokłada nieco swoich własnych elementów:
dziedzic.cpp
tłinclude " dziedzic.h "
class C3:public C1,C2
{
int z; // pole prywatne
public:
C3(intnJ : Cl(n+1),C2(n-1)
{
z-2*n;
)
pisz wszystko{)
cout & lt; & lt; " Wszystkie p o l a : \ n " ;
cout & lt; & lt; " \ t x= " & lt; & lt; x & lt; xendl;
// nowy
// konstruktor
334
Dodatek A
cout « " C y= " « ret__y() «endl;
\
cout « " \t z- " «z«enal;
)
v o i d main i)
i
C3 o b ( 1 0 ) ;
ofc & gt; .pisz_wszystl & lt; o
I
// wynik:
// Wszystkie pola:
//X-11
// y-9
// z=20
Konstruktor klasy C3, oprócz tego, że inicjalizuje własną zmienną z, wywołuje
jeszcze konstruktory klas Cl i C2 7. takimi parametrami, jakie mu aktualnie odpo
wiadają. Kolejność wywoływania konstruktorów jest logiczna: najpierw konstruk
tory klas bazowych (w kolejności narzuconej przez ich pozycję na liście znajdującej
sie po dwukropku), a na sam koniec konstruktor klasy C3. W naszym przypadku pa
rametry /r / i /;-/ zostały wzięte „z kapelusza " .
Kod zaprezentowany na powyższych listingach jest poglądowo wyjaśniony na
rysunku A - 2.
Rys, A- 2.
Dziedziczenie
własności.
Poznaj C++ w pięć minut!
335
W C++ kilka różnych pod względem zawartości funkcji może nosić taką samą
nazwę 13 - „ta właściwa'* funkcja jest rozróżniana poprzez typy swoich para
metrów wejściowych. Przykładowo, jeśli w pliku / programem są zdefiniowane
dwie procedury: void pfełtar* sj i void pCmt k), to wówczas wywołanie pili).
niechybnie będzie dotyczyć tej drugiej wersji.
Mechanizm „przeciążania " może być zastosowany bardzo skutecznie w powią
zaniu z mechanizmami dziedziczenia. Załóżmy, że nie podoba nam się funkcja
pisz, dostępna w klasie C3 dzięki temu, że w procesie dziedziczenia „przeszła "
ona z klasy Ci do ('J. Z drugiej zaś strony, podoba nam się nazwa pisz w tym
sensie, że chcielibyśmy jej używać na obiektach klasy C', ale do innego celu.
Uzupełniamy wówczas klasę C3 o następującą definicję ;
void C3::pisz(}
I
cout & lt; & lt; " nowa wersja metooy " p i s z ' \ n " ;
//'
//
//
//
wynik użycia o b . C l : : p i s z ( ' ; w main:
** s t a r a wersja metody ' p i s z ' ; r.-ll " wynik użycia o b . p i s z (5; w main:
** nowe wersja metody l p i s z ' : z=20 " *
Teraz instrukcja ob.pLszf) wywoła nową metodę pisz (z klasy ('J), gdybyśmy
zaś koniecznie chcieli użyć starej wersji, to należy jawnie lego zażądać poprzez
oh.Cl::pisz().
Nasz przykład zakłada kilka celowych niedomówień. Wynika to z tego, że pro
blematyka dziedziczenia własności w C—1- zawiera wiele niuansów, które
mogłyby być nużące dla nieprzygotowanego odbiorcy. Tym niemniej, zapre
zentowana powyżej wiedza jesl już wystarczająca do tworzenia dość skompli
kowanych aplikacji zorientowanych obiektowo. Inne mechanizmy, lakie jak np.
bardzo ważne funkcje wirtualne i tzw. klasy abstrakcyjne, trzeba już pozostawić
wyspecjalizowanym publikacjom - zachęcam Czytelnika do lektury.
1*
1
M
*
Taka cecha jesl zwana przeciążaniem.
Ponadto należy dodać linię void pis-(); do sekcji publicznej klas\ ( ' l
Literatura
338
L iteratura
Spis ilustracji
340
Spis ilustracji
Spis ilustracji
341
Spis tablic
Skorowidz
346
Skorowidz
Skorowidz
347
348
Skorowidz