TWÓJ PRZEWODNIK PO JĘZYKU JAVA Apress' 0 W JavaZaawansowane zastosowania Noel Kalicharan Helion Spis treści O autorach...
22 downloads
50 Views
77MB Size
TWÓJ PRZEW ODNIK PO JĘZYKU JAVA
Apress' 0
W
Java
Zaawansowane zastosowania Noel Kalicharan
Helion
Spis treści
O a u to ra c h ........................................................................................................................................... 9 Recenzenci techniczni ....................................................................................................................11 W prow adzenie ................................................................................................................................ 13 Rozdział 1.
Sortow anie, przeszukiwanie i scalanie ...................................................................................15 1.1. Sortowanie tablic: sortowanie przez w ybieranie.............................................................................. 15 1.2. Sortowanie tablic: sortowanie przez w staw ianie..............................................................................19 1.3. Wstawianie elementu w odpowiednim miejscu ............................................................................. 26 1.4. Sortowanie tablicy łańcuchów znaków .............................................................................................. 27 1.5. Sortowanie tablic równoległych ........................................................................................................... 29 1.6. Wyszukiwanie binarne ............................................................................................................................ 30 1.7. Przeszukiwanie tablicy łańcuchów znaków .......................................................................................32 1.8. Przykład: zliczanie wystąpień wyrazów.............................................................................................. 34 1.9. Scalanie posortowanych l i s t ................................................................................................................... 37 Ć w iczenia............................................................................................................................................................. 40
Rozdział 2.
W prow adzenie do obiektów ......................................................................................................43 2.1. O b iek ty ..........................................................................................................................................................44 2.2. Definiowanie klas i tworzenie obiektów ............................................................................................44 2.3. K onstruktory...............................................................................................................................................48 2.4. Hermetyzacja danych, metody akcesorów i m utatorów ............................................................... 51 2.5. Wyświetlanie danych obiektów .............................................................................................................55 2.6. Klasa P a r t ..................................................................................................................................................... 57 2.7. Jakie nazwy nadawać plikom źródłowym? ........................................................................................59 2.8. Stosowanie obiektów ................................................................................................................................ 60 2.9. W skaźnik null .............................................................................................................................................63 2.10. Przekazywanie obiektu jako argum en tu ..........................................................................................64 2.11. Tablice obiektów ......................................................................................................................................65 2.12. Przeszukiwanie tablicy obiektów ....................................................................................................... 67 2.13. Sortowanie tablicy obiektów ................................................................................................................70 2.14. Zastosowanie klasy do grupowania danych: licznik występowania słów ............................. 71 2.15. Zwracanie więcej niż jednej wartości: głosow anie........................................................................ 74 Ćwiczenia ............................................................................................................................................................ 80
Listy p o w ią z a n e .............................................................................................
..83
3.1. Definiowanie list pow iązanych.......................................................................
... 83
3.2. Proste operacje na listach pow iązanych......................................................
...85
3.3. Tworzenie listy powiązanej: dodawanie elementów na końcu listy ....
...88
3.4. Wstawianie elementów do list pow iązanych.............................................
...91
3.5. Tworzenie listy powiązanej: dodawanie elementu na początku listy .
... 93
3.6. Usuwanie elementów z list powiązanych....................................................
... 94
3.7. Tworzenie posortowanej listy pow iązanej..................................................
... 95
3.8. Klasa listy powiązanej .......................................................................................
... 99
3.9. Jak zorganizować pliki źródłowe Jav y?........................................................
.104
3.10. Rozszerzanie klasy L inkedL ist......................................................................
. 106
3.11. Przykład: palindromy .....................................................................................
. 107
3.12. Zapisywanie listy pow iązanej........................................................................
. 111
3.13. Tablice a listy pow iązane...............................................................................
. 111
3.14. Przechowywanie list powiązanych przy użyciu ta b lic ...........................
. 112
3.15. Scalanie dwóch posortowanych list pow iązanych................................. 3.16. Listy cykliczne i dwukierunkowe................................................................
. 113 . 116
Ć w iczenia......................................................................................................................
. 120
Stosy i kolejki .................................................................................................
123
4.1. Abstrakcyjne typy d an y ch ...............................................................................
. 123
4.2. Stosy ........................................................................................................................
. 124
4.3. Ogólny typ S ta c k .................................................................................................
. 130
4.4. Konwertowanie wyrażenia z zapisu wrostkowego na przyrostkowy ..
. 134
4.5. Kolejki ....................................................................................................................
. 142
Ćwiczenia ......................................................................................................................
. 151
R eku ren cja.......................................................................................................
153
5.1. Definicje rekurencyjne......................................................................................
. 153
5.2. Pisanie funkcji rekurencyjnych w języku Java ..........................................
. 154
5.3. Konwersja liczby dziesiątkowej na dwójkową przy użyciu rekurencji
. 156
5.4. Wyświetlanie listy powiązanej w odwrotnej k o le jn o ści.........................
. 159
5.5. Problem wież H a n o i..........................................................................................
. 160
5.6. Funkcja podnosząca liczbę do potęgi ...........................................................
.162
5.7. Sortowanie przez scalanie .................................................................................
. 163
5.8. Zliczanie organizmów .......................................................................................
. 166
5.9. Odnajdywanie drogi przez labirynt ..............................................................
. 170
Ćwiczenia ......................................................................................................................
. 174
Liczby losowe, gry i symulacje ................................................................
177
6.1. Liczby losowe .......................................................................................................
. 177
6.2. Liczby losowe i pseudolosowe ........................................................................
. 178
6.3. Komputerowe generowanie liczb losowych ..............................................
. 179
6.4. Zgadywanka ..........................................................................................................
. 180
6.5. Ćwiczenia z dodawania .....................................................................................
. 181
6.6. Gra Nim .................................................................................................................
. 182
6.7. Rozkłady nierów nom ierne..............................................................................
. 186
6.8. Symulowanie realnych problem ów ...............................................................
.189
6.9. Symulacja k o le jk i................................................................................................
. 190
6.10. Szacowanie wartości liczbowych przy użyciu liczb losow ych............
. 193
Ćwiczenia ......................................................................................................................
. 196
iEŚCI
Praca z plikam i ............................................................................................................
199
7.1. Operacje wejścia-wyjścia w Ja v ie ...................................................................................
199
7.2. Pliki tekstowe i binarne ...................................................................................................
200
7.3. Wewnętrzne i zewnętrzne nazwy p lik ó w ...................................................................
200
7.4. Przykład: porównywanie dwóch p lik ó w ....................................................................
201
7.5. Konstrukcja try. ..c a tc h ....................................................................................................
202
7.6. Operacje wejścia-wyjścia na plikach binarnych .......................................................
205
7.7. Pliki o dostępie swobodnym ..........................................................................................
209
7.8. Pliki indeksow ane..............................................................................................................
213
7.9. Aktualizacja pliku o dostępie swobodnym ................................................................
221
Ć w iczenia......................................................................................................................................
224
W prow adzenie do zagadnień drzew b in a rn y c h ..............................................
225
8.1. Drzewa ..................................................................................................................................
225
8.2. Drzewa b in arn e ...................................................................................................................
227
8.3. Przechodzenie drzew binarnych ...................................................................................
228
8.4. Sposoby reprezentacji drzew binarnych ....................................................................
231
8.5. Budowanie drzewa binarnego .......................................................................................
233
8.6. Binarne drzewa poszukiwań ..........................................................................................
237
8.7. Budowanie binarnego drzewa poszukiwań ...............................................................
240
8.8. Budowanie drzew binarnych ze wskaźnikami rodzica ..........................................
244
8.9. Przechodzenie drzewa poziomami ..............................................................................
249
8.10. Użyteczne funkcje operujące na drzewach b in arn y ch .........................................
254
8.11. Usuwanie wierzchołków z binarnego drzewa poszukiw ań................................
255
8.12. Tablice jako sposób reprezentacji drzew binarnych ............................................
257
Ćwiczenia ......................................................................................................................................
260
Zaaw ansow ane m etody s o rto w a n ia ....................................................................
263
9.1. Sortowanie przez kopcowanie .......................................................................................
263
9.2. Budowanie kopca przy użyciu metody siftU p ..........................................................
269
9.3. Analiza algorytmu sortowania przez kopcow anie...................................................
272
9.4. Kopce i kolejki priorytetow e..........................................................................................
273
9.5. Sortowanie listy elementów przy użyciu sortowania szybkiego.........................
274
9.6. Sortowanie Shella (z użyciem malejących odstępów) ............................................
284
Ćwiczenia ......................................................................................................................................
288
H a s z o w a n ie ..................................................................................................................
291
10.1. Podstawy haszowania ....................................................................................................
291
10.2. Rozwiązanie problemu wyszukiwania i wstawiania przy użyciu haszowania
292
10.3. Rozwiązywanie k o liz ji....................................................................................................
297
10.4. Przykład: licznik występowania słów ........................................................................
307
Ćwiczenia ......................................................................................................................................
310
Skorowidz
313
7
SPIS TREŚCI
8
O autorach D r Noel Kalicharan jest starszym wykładowcą informatyki na Uniwersytecie Indii Zachodnich (U W I) w St. Augustine na Trynidadzie. Przez wiele lat prowadził zajęcia z programowania przeznaczone dla osób na różnych poziomach umiejętności, zarówno dla dzieci, jak i dla dorosłych. Na UWI pracuje od 1976 roku, prowadząc m.in. kursy algorytmów i programowania. W 1988 roku opracował i prowadził 26-odcinkowy program telewizyjny poświęcony komputerom, zatytułowany Com pters: B it by B it (Komputery: bit po bicie). W programie przeznaczonym dla szerokiej rzeszy odbiorców uczył obsługi komputerów oraz programowania. Dr Kalicharan zawsze poszukuje innowacyjnych sposobów nauki logicznego myślenia, idących w parze z umiejętnościami programowania. Efektem jego prac było powstanie dwóch gier, BrainStorm ! oraz N ot Just L u ck, które przyniosły mu nagrody za inwencję i innowacje w latach odpowiednio 2000 i 2002. Jest autorem 17 książek komputerowych, w tym dwóch — Introduction to C om pu ter Studies oraz C by E xam ple — które zostały wydane przez Cambridge University Press i odniosły międzynarodowy sukces. Druga z tych książek, poświecona językowi C, zebrała doskonałe recenzje czytelników z wielu różnych krajów, takich jak Australia, Brazylia, Kanada, Francja, Indie oraz Szkocja. Wielu z nich uznało, że zawiera „najlepszy opis wskaźników”, jednego z zagadnień, których opanowanie przysparza najwięcej problemów. Ta książka, czyli Java. Z aaw an sow an e zastosow ania, ma nieco bardziej popularny charakter. W roku 2010 dr Kalicharan został uznany przez National Institute for Higher Education, Research, Science and Technology (NIHERST) za „ikonę nauk komputerowych Trynidadu i Tobago”. W 2011 roku za całość wkładu w rozwój edukacji odznaczono go N agrodą N arodow ą, M edalem za Zasługi w Służbie P ublicznej (złotym). W 2012 roku otrzymał dożywotnio od ministerstwa edukacji Trynidadu i Tobago N agrodę D oskon ałości w N auczaniu. W roku 2012 dr Kalicharan stworzył system o nazwie DigitalM ath (h ttp ://w w w .d ig ita lm a th .tt). Stanowi on doskonały sposób ręcznego wykonywania działań arytmetycznych. Z jego pom ocą, posługując się wyłącznie palcam i, m ożna szybko, dokładnie i pewnie wykonywać takie operacje jak dodawanie, odejm owanie, m nożenie i dzielenie. Urodził się w Lengua Village w Princes Town na Trynidadzie. Uczęszczał do szkoły podstawowej Lengua Presbyterian School, a następnie kontynuował edukację w Naparima College. Stopnie naukowe zdobywał na Uniwersytecie Indii Zachodnich na Jamajce, Uniwersytecie Kolumbii Zachodniej w Kanadzie oraz Uniwersytecie Indii Zachodnich na Trynidadzie.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
10
Recenzenci techniczni Jim Graham zdobył tytuł licencjata na Uniwersytecie Texas A&M z zakresu elektroniki w specjalizacji telekomunikacja; w 1998 roku, wraz ze swym rocznikiem (88), obronił pracę magisterską. Jego publikacja F ast P ack et Switching: An Overview o f Theory a n d P reform an ce ukazała się w ICA Communique z 1988 roku, wydawanym przez International Communications Association. Z jego doświadczeń zawodowych można wymienić pracę jako associate network engineer w Network Design Group w firmie Amoco Corporation (Chicago, IL), senior network engineer w Tybrin Corporation w Fort W alton Beach na Florydzie oraz jako intelligence systems analyst w 16th Special Operations W ing Intelligence i HQ US Air Force Special Operations Command Intelligence w Hurlburt Field na Florydzie. 18 grudnia 2001 roku otrzymał oficjalną pochwałę od 16th Special Operations W ing Intelligence. Manuel Jorda Elera jest programistą i badaczem samoukiem, którego cieszy poznawanie nowych technologii na drodze eksperymentów i integracji. Manuel wygrał 2010 Springy Award — Community Champion oraz Spring Champion 2013. W wolnym czasie, którego nie ma zbyt wiele, czyta Biblię i komponuje muzykę na swojej gitarze. Jest starszym członkiem Spring Community Forums, znanym jako dr_pompeii. Na jego blogu, http://m anueljordan.w ordpress.com /, można do niego napisać i poczytać publikowane przez niego doniesienia.
Massimo N ardone posiada tytuł magistra nauk komputerowych uzyskany na Uniwersytecie Salerno we Włoszech. Przez wiele lat pracował jako PCI QSA oraz jako starszy specjalista do spraw bezpieczeństwa IT oraz architekt rozwiązań „w chmurze”; aktualnie kieruje zespołem konsultantów firmy Hewlett Packard w Finlandii. Dysponując 19-letnimi doświadczeniami w zakresie rozwiązań SCADA, chmur obliczeniowych, infrastruktury komputerowej, rozwiązań mobilnych, bezpieczeństwa i technologii W W W , nabytymi podczas prac nad projektami krajowymi i międzynarodowymi, Massimo pracował już jako kierownik projektów, projektant, badacz, główny specjalista do spraw zabezpieczeń i specjalista do spraw oprogramowania. Pracował także jako wykładowca oraz kierownik do spraw ćwiczeń w Laboratorium Sieciowym Politechniki Helsińskiej (politechnika ta weszła następnie w skład Uniwersytetu Aalto), w ramach kursu Security o f Communication Protocols. Posiada także cztery międzynarodowe patenty (związane z zagadnieniami PKI, SIM, SML oraz Proxy).
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
12
Wprowadzenie W tej książce przyjąłem założenie, że czytelnicy dysponują roboczą znajomością podstawowych pojęć programistycznych, takich jak zmienne, stałe, instrukcje przypisania, instrukcje warunkowe ( i f . . . e l s e ) oraz pętle (whil e oraz for). Założyłem także, że czytelnicy nie mają problemów z pisaniem funkcji oraz operowaniem na tablicach. Jeśli tak nie jest, przed rozpoczęciem lektury należy sięgnąć po Java Program m ing: A Beginner’s Course (http://w w w .am azon.com /Java-Program m ing-Beginners-N oel-Kalicharan/dp/1438265182/) lub dowolną inną książkę wprowadzającą w zagadnienia programowania w języku Java. W książce nie skoncentrowałem się na nauce zaawansowanych zagadnień programowania w języku Java jako takich, lecz raczej na wykorzystaniu tego języka do nauki zaawansowanych zagadnień programistycznych, które powinien znać każdy programista. Głównymi zagadnieniami opisywanymi w tej książce są: podstawowe metody sortowania (sortowanie przez wybieranie oraz przez wstawianie) i wyszukiwania (wyszukiwanie sekwencyjne i binarne), a także scalanie, wskaźniki (które w języku Java są nazywane referencjam i), listy powiązane, stosy, kolejki, rekurencja, liczby losowe, pliki (tekstowe, binarne, pliki o dostępie swobodnym oraz pliki indeksowane), drzewa binarne, zaawansowane metody sortowania (sortowanie przez kopcowanie, sortowanie szybkie, sortowanie przez scalanie oraz sortowanie Shella) oraz haszowanie (bardzo szybka metoda wyszukiwania). W rozdziale 1. przypominam pewne podstawowe pojęcia, które należy znać. Jest on poświęcony sortowaniu list przy wykorzystaniu algorytmu sortowania przez wybieranie oraz przez wstawianie, przeszukiwaniu list przy użyciu wyszukiwania sekwencyjnego oraz binarnego i scalaniu dwóch posortowanych list. Java jest uznawana za język obiektowy. Dlatego też jednym i z kluczowych pojęć z nią związanych są klasy i obiekty. Oba tej pojęcia zostały szczegółowo opisane w rozdziale 2. Rozdział 3. jest poświęcony listom powiązanym, które same w sobie stanowią ważną strukturę danych, lecz oprócz tego są podstawą bardziej złożonych struktur, takich jak drzewa i grafy. W yjaśniam w nim, jak tworzyć listy, jak dodawać i usuwać elementy list, jak budować listy posortowane oraz jak je scalać. Rozdział 4. poświęciłem zagadnieniom kolejek i stosów, które można uznać za najbardziej przydatne rodzaje list liniowych. M ają one bardzo duże znaczenie dla informatyki. Pokażę w nim, jak można je implementować przy użyciu tablic i list powiązanych oraz jak z nich skorzystać w celu przekształcenia wyrażenia arytmetycznego na zapis przyrostkowy i wyliczenia jego wartości. W rozdziale 5. przedstawiam bardzo ważne pojęcie programistyczne, jakim jest rekurencja. Zrozumienie rekurencji i przyzwyczajenie się do jej stosowania bezsprzecznie wymaga czasu. Jednak kiedy uda się już ją opanować, umożliwi rozwiązanie bardzo wielu problemów, których rozpracowanie przy użyciu innych technik byłoby niezwykle trudne. Oprócz wielu innych interesujących problemów pokażę, jak zastosować rekurencję do rozwiązania problemu wież H an oi oraz znalezienia wyjścia z labiryntu. Wszyscy lubimy grać w różne gry. Ale czy wiemy, co leży u podstaw wszystkich takich programów? Są to liczby losowe. W rozdziale 6. pokazuję, jak można używać liczb losowych do zaimplementowania prostej gry oraz do symulacji zdarzeń ze świata realnego. W yjaśniam w nim m.in., jak napisać program do ćwiczenia arytmetyki czy nieomylnego grania w grę N im , jak symulować kolejki do kas w supermarkecie lub banku. Opisuję także nowatorskie zastosowanie liczb losowych, czyli użycie ich do szacowania wartości liczby n (pi).
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Niemal wszystko, co musimy przechowywać na komputerze, będzie magazynowane w plikach. Plików tekstowych używamy do przechowywania wszelkiego rodzaju dokumentów tworzonych przy użyciu edytorów tekstów. Z kolei z plików binarnych korzystamy, gdy przechowujemy zdjęcia cyfrowe, klipy muzyczne, filmy oraz różnego rodzaju rekordy. W rozdziale 7. pokazuję, jak można tworzyć pliki tekstowe i binarne, a także jak operować nimi. Wyjaśniam w nim także, jak należy pracować na dwóch najbardziej przydatnych rodzajach plików, czyli na plikach o dostępie swobodnym oraz plikach indeksowanych. Rozdział 8. to wprowadzenie do tematyki najbardziej wszechstronnej struktury danych — drzew binarnych. Łączą one elastyczność tablic oraz list powiązanych, eliminują jednocześnie ich wady. Przykładowo binarne drzewa poszukiwań pozwalają na wykonywanie operacji wyszukiwania ze złożonością wyszukiwania binarnego (które można stosować na posortowanych tablicach) oraz zapewniają łatwość dodawania i usuwania elementów, jaką dają listy powiązane. Metody sortowania przedstawione w rozdziale 1. (sortowanie przez wybieranie oraz przez wstawianie) są bardzo proste; chociaż robią, co do nich należy, to jednak są wolne, zwłaszcza gdy mają operować na wielkich zbiorach danych (np. na milionie liczb). Na komputerze, który wykonuje milion operacji porównania na sekundę, posortowanie miliona elementów zajęłoby aż 6 dni! W rozdziale 9. opisuję wybrane, szybsze metody sortowania, takie jak sortowanie przez kopcowanie, sortowanie szybkie oraz sortowanie Shella. Na takim samym komputerze jak opisany wcześniej pierwsze dwie z wymienionych metod pozwalają posortować milion elementów w czasie poniżej minuty, natomiast w przypadku algorytmu Shella sortowanie potrwałoby nieco ponad minutę. Rozdział 10. jest poświęcony haszowaniu, jednej z najszybszych metod wyszukiwania. Przedstawiam w nim podstawowe zagadnienia związane z haszowaniem i opisuję kilka różnych metod rozwiązywania kolizji, mających kluczowe znaczenie dla wydajności działania każdego algorytmu korzystającego z techniki haszowania. Moim celem jest zapewnienie możliwości poznania bardziej zaawansowanych technik programistycznych oraz zrozumienia ważnych struktur danych (list powiązanych, kolejek, stosów i drzew binarnych); a wszystko przy użyciu języka Java. Drogi Czytelniku, mam nadzieję, że zaostrzy to Twój apetyt i zachęci do dokładniejszego poznawania tych fascynujących i niezwykle ważnych dziedzin informatyki. Wiele ćwiczeń wymaga napisania programów. Celowo nie podaję ich rozwiązań. Z moich doświadczeń wynika, że w naszej obecnej, „fastfoodowej” kulturze studenci nie poświęcają dostatecznie dużo czasu na samodzielne poszukiwania rozwiązania problemu, jeśli mają dostęp do odpowiedzi. W każdym razie podstawową ideą ćwiczeń z programowania jest sam odzieln e pisanie programów. Programowanie jest procesem bazującym na powtarzaniu. Po skompilowaniu i uruchomieniu napisanego programu dowiadujemy się, czy działa prawidłowo. Jeśli nie, musimy podjąć próbę określenia, dlaczego program nie działa, poprawić go i spróbować ponownie. Jedynym sposobem dobrego nauczenia się programowania jest pisanie programów rozwiązujących nowe problemy. Podawanie odpowiedzi do ćwiczeń jest skrótem, który nie daje żadnych korzyści. Programy przedstawione w tej książce można skompilować i uruchomić przy użyciu Java Development Kit (JDK) w wersji 5.0 lub nowszej. Programy są niezależne i kompletne. Nie wymagają np. korzystania z udostępnianych przez kogoś klas obsługujących podstawowe operacje wejścia-wyjścia. Będą działać w takiej formie, w jakiej zostały dostarczone. Kody do przykładów prezentowanych w książce można pobrać z serwera FTP wydawnictwa Helion: ftp://ftp.helion .pl/przyklady/javazz.zip. Dziękuję za poświęcenie czasu na przeczytanie i przestudiowanie tej książki. Wierzę, że będzie się podobać i pozwoli zdobyć wiedzę oraz umiejętności pozwalające na kontynuację przygody z programowaniem w sposób bezbolesny, przyjemny i dający satysfakcję. — Noel Kalicharan
14
ROZDZIAŁ
1
Sortowanie, przeszukiwanie i scalanie W tym rozdziale wyjaśnione zostaną takie zagadnienia jak: • sortowanie listy elementów metodą sortowania przez wybieranie, • sortowanie listy elementów metodą sortowania przez wstawianie, • dodawanie nowych elementów do posortowanej listy tak, by pozostała posortowana, • sortowanie tablicy łańcuchów znaków, • sortowanie tablic powiązanych (równoległych), • przeszukiwanie posortowanej listy przy użyciu wyszukiwania binarnego, • przeszukiwanie tablicy łańcuchów znaków, • implementacja programu zliczającego w jednym przebiegu ilość wystąpień wyrazów we fragmencie tekstu, • sposoby scalania posortowanych list w jedną posortowaną listę.
1.1. Sortowanie tablic: sortowanie przez wybieranie Sortowaniem nazywamy proces, w wyniku którego zbiór wartości zostaje uporządkowany w kolejności rosnącej bądź malejącej. Czasami używamy sortowania, by wygenerować bardziej czytelne wyniki (np. by utworzyć spis alfabetyczny). Nauczyciel może przykładowo przygotowywać listę uczniów posortowaną alfabetycznie według nazwisk lub według średniej ocen. Gdybyśmy dysponowali dużym zbiorem liczb i chcieli wskazać w nim powtarzające się wartości, moglibyśmy to zrobić, wykorzystując sortowanie: po posortowaniu zbioru identyczne wartości będą umieszczone obok siebie. Kolejną zaletą sortowania jest to, że na posortowanych danych niektóre operacje mogą być wykonywane znacznie szybciej i bardziej wydajnie. Przykładowo posortowane dane można przeszukiwać przy użyciu wyszukiwania binarnego, które jest znacznie szybsze od wyszukiwania sekwencyjnego. Także scalanie dwóch posortowanych list może być wykonane znacznie szybciej, niż wtedy, gdy nie będą one posortowane. Istnieje wiele sposobów sortowania. W tym rozdziale opisane zostaną dwa „proste” algorytmy, czyli sortowanie przez wybieranie oraz sortowanie przez wstawianie. Informacje na temat bardziej wyszukanych sposobów sortowania można znaleźć w rozdziale 9. Zaczniemy od sortowania przez wybieranie.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Załóżmy, że dysponujemy następującą listą liczb, zapisaną w tablicy języka Java, w zmiennej o nazwie num. num 57
48
79
65
15
33
52
0
1
2
3
4
5
6
Sortowanie tablicy num w kolejności rosnącej, przy wykorzystaniu metody sortowania przez wybieranie będzie miało następujący przebieg. Pierwszy przebieg • Znajdujemy najmniejszą liczbę na liście, zaczynając od pozycji 0 do 6; najmniejszą liczbą jest liczba 15 umieszczona na pozycji 4. • Zamieniamy liczby umieszczone na pozycjach 0 i 4; w efekcie uzyskujemy listę w następującej postaci:
15
48
79
65
57
33
52
0
1
2
3
4
5
6
Drugi przebieg • Znajdujemy najmniejszą liczbę na pozycjach od 1 do 6 listy; najmniejszą z tych liczb jest liczba 33 umieszczona na pozycji 5. • Zamieniamy liczby na pozycjach 1 i 5, uzyskując listę w postaci: num 15
33
79
65
57
48
52
0
1
2
3
4
5
6
Trzeci przebieg Znajdujemy najmniejszą liczbę na pozycjach od 2 do 6 listy; najmniejszą z tych liczb jest liczba 48 umieszczona na pozycji 5. • Zamieniamy liczby na pozycjach 2 i 5, uzyskując listę w postaci: num 15
33
48
65
57
79
52
0
1
2
3
4
5
6
Czwarty przebieg • Znajdujemy najmniejszą liczbę na pozycjach od 3 do 6 listy; najmniejszą z tych liczb jest liczba 52 umieszczona na pozycji 6. • Zamieniamy liczby na pozycjach 3 i 6, uzyskując listę w postaci:
15
33
48
52
57
79
65
0
1
2
3
4
5
6
Piąty przebieg • Znajdujemy najmniejszą liczbę na pozycjach od 4 do 6 listy; najmniejszą z tych liczb jest liczba 57 umieszczona na pozycji 4. • Zamieniamy liczby na pozycjach 4 i 4, uzyskując listę w postaci: num 15
16
33
48
52
57
79
65
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Szósty przebieg • Znajdujemy najmniejszą liczbę na pozycjach od 5 do 6 listy; najmniejszą z tych liczb jest liczba 65 umieszczona na pozycji 6. • Zamieniamy liczby na pozycjach 5 i 6, uzyskując listę w postaci: num 15
33
48
52
57
65
79
0
1
2
3
4
5
6
W ten sposób tablica została w całości posortowana. W arto zwrócić uwagę, że gdy 6. w kolejności rosnącej liczba (65) znajdzie się w swym ostatecznym położeniu (na pozycji 5), największa liczba na liście (79) automatycznie trafi na właściwe dla niej miejsce (na pozycję 6). W tym przykładzie wykonanych zostało 6 przebiegów. Będziemy je liczyć, przypisując zmiennej h wartości od 0 do 5. Podczas każdego przebiegu określimy najmniejszą spośród liczb na pozycjach od h do 6. Jeśli przyjmiemy, że najmniejsza liczba została odnaleziona na pozycji s, to następnie zamieniamy ze sobą liczby na pozycjach h i s. Ogólnie rzecz biorąc, dla tablicy o wielkości n należy wykonać n-1 przebiegów. W powyższym przykładzie sortowana była tablica składająca się z 7 elementów, dlatego też wykonanych zostało 6 przebiegów. Poniżej przedstawiony został pseudokod opisujący algorytm sortowania tablicy num [0..n-1]. fo r h = 0 do n - 2 s = pozycja najm niejszej wartości z zakresu od num[h] do num[n-1] zamień num[h] z num[s] endfor Używając ogólnego parametru l i s t , algorytm ten możemy zaimplementować w następujący sposób. public s t a t i c void s e le c tio n S o rt(in t[] l i s t , int lo , int hi) { //sortowanie list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo ; h < h i; h++) { int s = g e tS m a lle s t(lis t, h, h i); sw ap (list, h, s ); } } Instrukcje umieszczone wewnątrz pętli for można by zastąpić jedną w postaci: sw ap (list, h, g e tS m a lle s t(lis t, h, h i ) ; ) ; Metody getSmal le s t oraz swap można z kolei zaimplementować tak: public s t a t i c int g etS m allest(in t l i s t [ ] , int lo , int hi) { //zwracamy położenie najmniejszego elementu z list[lo..hi] int small = lo ; fo r (in t h = lo + 1; h <= h i; h++) i f ( lis t [ h ] < lis t[ s m a ll]) small = h; return small; } public s t a t i c void swap(int l i s t [ ] , int i , in t j ) //zamieniamy elementy list[i] oraz list[j] int hold = l i s t [ i ] ; lis t[i] = l is t [ j] ; l i s t [ j ] = hold; }
{
17
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Aby sprawdzić, czy metoda selectio n S o rt działa prawidłowo, napiszemy program P1.1. Poniżej przedstawiona została wyłącznie jego metoda main. W celu dokończenia programu wystarczy skopiować i wkleić do niego przedstawione wcześniej metody sele ctio n S o rt, getSm allest oraz swap. Program P1.1 import ja v a .u t i l . * ; public c la ss S electSo rtT est { final s t a tic in t MaxNumbers = 10; public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); in t[] num = new in t[1 0 ]; System .out.printf("W pisz do %d lic z b i zakończ podawanie danych, wpisując 0\n", MaxNumbers); int n = 0; int v = in .n e x tIn t(); while (v != 0 && n < MaxNumbers) { num[n++] = v; v = in .n e x tIn t( ); } i f (v != 0) { System.out.printf("\nWpisano więcej niż %d liczb\ n", MaxNumbers); System .out.printf("U żytych zostanie tylko %d pierwszych liczb\ n", MaxNumbers); } i f (n == 0) { System .out.printf("\nN ie podano żadnych liczb \ n "); S y s te m .e x it(l); } //n liczb jest przechowywanych w tablicy, w komórkach od num[0] do num[n-1 ] selectionSort(num , 0, n -1 ); System.out.printf("\nPosortowane liczby to :\ n "); fo r (v = 0; v < n; v++) System .out.printf("% d " , num[v]); S y stem .o u t.p rin tf("\ n "); } //koniec main // tu należy umieścić metody selectionSort, getSmallest oraz swap } //koniec klasy SelectSortTest Program prosi o podanie do dziesięciu liczb (przy czym ich liczba jest określona przez stałą MaxNumbers), zapisuje je w tablicy num, następnie wywołuje metodę s e le c ti onSort, a w końcu wyświetla posortowaną listę. Poniżej przedstawione zostały przykładowe wyniki wykonania programu. Wpisz do 10 licz b i zakończ podawanie danych, wpisując 0 57 48 79 65 15 33 52 0 Posortowane liczby to : 15 33 48 52 57 65 79 Należy zwrócić uwagę, że jeśli użytkownik wpisze więcej niż dziesięć liczb, program to zauważy i posortuje tylko dziesięć pierwszych.
18
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
1.1.1. Analiza algorytmu sortowania przez wybieranie W celu znalezienia najmniejszego spośród k elementów wykonujemy k-1 porównań. W pierwszym przebiegu wykonujemy n -1 porównań, by znaleźć najmniejszy spośród n elementów. W drugim przebiegu wykonujemy n -2 porównań, aby znaleźć najmniejszy spośród n-1 elementów. I tak dalej, aż do ostatniego przebiegu, w którym wykonujemy jedno porównanie, by znaleźć najmniejszą z pary liczb. Ogólnie rzecz biorąc, w j-tym przebiegu wykonujemy n -j porównań w celu wyznaczenia najmniejszego spośród n -j+ 1 elementów. Dlatego też uzyskujemy całkowitą liczbę porównań. całkowita liczba porównań = 1 + 1 + ... + n -1 = A n (n -1) ~ An2 Mówimy, że algorytm sortowania przez wybieranie ma złożoność O (n2) („duże O n kwadrat”). W przypadku notacji „duże O ” stała lA nie ma znaczenia, gdyż stała ta przestanie mieć wpływ na wynik, gdy wartość n będzie bardzo duża. Podczas każdego przebiegu zamieniamy ze sobą dwa elementy, wykonując w tym celu trzy przypisania. Ponieważ w sumie wykonujemy n -1 przebiegów, zatem całkowita liczba operacji przypisania wyniesie 3(n -1). W przypadku korzystania z notacji „duże O ” mówimy, że liczba przypisań jest O(n). Gdy wartość n będzie bardzo duża, stałe 3 i 1 stracą znaczenie. Czy algorytm sortowania przez wybieranie będzie działał lepiej, gdy dane, na których będzie operował, zostaną posortowane? Nie. Jednym ze sposobów pozwalających się o tym przekonać jest przekazanie do niego posortowanej listy liczb i sprawdzenie, co się w takim przypadku stanie. Kiedy przeanalizujemy działanie algorytmu, zobaczymy, że uporządkowane dane nie mają wpływu na to działanie. Niezależnie od ich postaci, za każdym razem będzie wykonywał tę samą liczbę porównań. Dalej w tej książce będzie można przekonać się, że implementacja niektórych algorytmów sortowania (takich jak sortowanie przez scalanie lub sortowanie szybkie, opisanych odpowiednio w rozdziałach 5. i 9.) wymaga zastosowania dodatkowej tablicy. W arto zwrócić uwagę, że algorytm sortowania przez wybieranie jest wykonywany bezpośrednio na sortowanej tablicy i nie wymaga przydzielania żadnych dodatkowych obszarów pamięci. W ramach ćwiczenia można zmodyfikować kod programu w taki sposób, aby zliczał liczbę operacji porównania i przypisania, wykonywanych podczas procesu sortowania.
1.2. Sortowanie tablic: sortowanie przez wstawianie Załóżmy, że dysponujemy tą samą tablicą, co wcześniej: num 57
48
79
65
15
33
52
0
1
2
3
4
5
6
A teraz wyobraźmy sobie, że liczby te są kartami rozłożonymi na stole, które podnosimy w takiej kolejności, w jakiej są zapisane w tablicy. A zatem najpierw podnosimy 57, następnie 48, 79 itd., aż do ostatniej liczby 52. Kiedy jednak podnosimy każdą następną, nową kartę, dodajemy ją do kart trzymanych w ręce tak, żeby były posortowane. Kiedy podniesiemy 57, będziemy mieli w ręce tylko jedną kartę-liczbę. Uznajemy, że jedna liczba zawsze jest posortowana. Kiedy podniesiemy liczbę 48, umieścimy ją przed 57, a zatem liczby w naszej ręce będziemy trzymać w kolejności: 48 57 Kiedy podniesiemy liczbę 79, umieścimy ją za 57. Liczby w naszej ręce będą zatem miały kolejność: 48 57 79 Kiedy podniesiemy liczbę 65, umieścimy ją za 57; oznacza to, że liczby w ręce będą miały kolejność: 48 57 65 79
19
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jak widać, na tym etapie podnieśliśmy cztery liczby, a w ręce są one ułożone w kolejności rosnącej. Kiedy podniesiemy liczbę 15, umieścimy ją przed 48, a zatem liczby w naszej ręce będziemy trzymać w kolejności: 15 48 57 65 79 Kiedy podniesiemy liczbę 33, umieścimy ją za 15, a zatem liczby w naszej ręce będą miały kolejność: 15 33 48 57 65 79 I w końcu, po podniesieniu liczby 52 umieścimy ją za 48, a liczby w naszej ręce przyjmą kolejność: 15 33 48 52 57 65 79 Jak widać, liczby zostały posortowane w kolejności rosnącej. Przedstawiona powyżej metoda ilustruje sposób działania algorytmu sortowania przez wstawianie. Liczby umieszczone w tablicy będą przetwarzane po jednej, zaczynając do lewej. Odpowiada to podnoszeniu liczb ze stołu, po jednej w każdym przebiegu. Ponieważ pierwsza liczba jest zawsze posortowana, zatem przetwarzanie liczb w tablicy będziemy zaczynać od drugiej. Gdy sortowanie dotrze do liczby num[h], możemy założyć, że wszystkie liczby od num[0] do num[h-1] są już posortowane. Wstawiamy zatem num[h] pomiędzy liczby od num[0] do num[h-1] w taki sposób, by zakres num[0] do num[h] był posortowany. Następnie zajmiemy się przetwarzaniem liczby num[h+1]. Gdy to nastąpi, nasze założenie dotyczące tego, że liczby num[0] do num[h] są posortowane, będzie spełnione. Posortowanie tablicy num w kolejności rosnącej przy użyciu algorytmu sortowania przez wstawianie będzie wyglądało w następujący sposób. Przebieg pierwszy • Przetwarzamy num[1], czyli liczbę 48. Operacja ta sprowadza się do umieszczenia przetwarzanej liczby w taki sposób, że dwie pierwsze liczby zapisane w tablicy będą posortowane; a zatem num[0] oraz num[1] zawierają aktualnie liczby:
48
57
Pozostała część tablicy nie jest modyfikowana. Drugi przebieg • Przetwarzamy num[2], czyli liczbę 79. Operacja ta sprowadza się do umieszczenia przetwarzanej liczby w taki sposób, że trzy pierwsze liczby zapisane w tablicy będą posortowane; a zatem kom órki tablicy od num[0] do num[2] zawierają aktualnie liczby:
48
57
79
Pozostała część tablicy nie jest modyfikowana. Trzeci przebieg • Przetwarzamy num[3], czyli liczbę 65. Operacja ta sprowadza się do umieszczenia przetwarzanej liczby w taki sposób, że cztery pierwsze liczby zapisane w tablicy będą posortowane; a zatem kom órki tablicy od num[0] do num[3] zawierają aktualnie liczby:
48
57
65
79
Pozostała część tablicy nie jest modyfikowana.
20
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Czwarty przebieg • Przetwarzamy num[4], czyli liczbę 15. Operacja ta sprowadza się do umieszczenia przetwarzanej liczby w taki sposób, że pięć pierwszych liczb zapisanych w tablicy będzie posortowanych. W celu uproszczenia wyjaśnień można sobie wyobrazić, że liczba 15 jest pobierana z tablicy i zapisywana w zwyczajnej zmiennej (dajmy na to o nazwie key), przez co komórka num[4] zostaje opróżniona. Możemy to zilustrować w następujący sposób: key
S
num 48
57
65
79
0
1
2
3
4
33
52
5
6
Wstawienie liczby 15 w odpowiednim miejscu jest wykonywane w następujący sposób. • Porównujemy liczbę 15 z 79, jest mniejsza, zatem przesuwamy 79 do kom órki 4, co sprawia, że komórka 3 zostaje opróżniona. Aktualnie nasze dane wyglądają tak: key
num 48
57
65
0
1
2
3
79
33
52
4
5
6
• Porównujemy liczbę 15 z 65, jest mniejsza, zatem przesuwamy 65 do kom órki 3, co sprawia, że komórka 2 zostaje opróżniona. Teraz nasze dane wyglądają tak: key
num 48
57
0
1
2
65
79
33
52
3
4
5
6
• Porównujemy liczbę 15 z 57, jest mniejsza, zatem przesuwamy 57 do kom órki 2, co sprawia, że komórka 1 zostaje opróżniona. Nasze dane wyglądają aktualnie tak: key
num 48 0
1
57
65
79
33
52
2
3
4
5
6
• Porównujemy liczbę 15 z 48, jest mniejsza, zatem przesuwamy 48 do kom órki 1, co sprawia, że komórka 0 zostaje opróżniona i nasze dane wyglądają tak: key
num 0
48
57
65
79
33
52
1
2
3
4
5
6
• W tablicy nie ma już żadnych innych liczb, z którymi można by porównać 15, dlatego wstawiamy ją do kom órki 0, co sprawia, że dane wyglądają tak: key
num 15
48
57
65
79
33
52
0
1
2
3
4
5
6
• Logika umieszczenia liczby 15 (key) w odpowiednim miejscu polega na porównywaniu jej z liczbami zapisanymi po jej lewej stronie, zaczynając od liczby, która z nią sąsiaduje. Tak długo, jak dla pewnego k zmienna key jest mniejsza od num[k], przenosimy wartość num[k] do num[k+1] i przechodzimy do sprawdzenia wartości num[k-1], o ile tylko taka wartość istnieje w tablicy. W artość ta nie będzie istnieć, jeśli k wyniesie 0. W takim przypadku proces jest zatrzymywany, a wartość zmiennej key zostaje umieszczona w komórce 0.
21
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Piąty przebieg • Przetwarzamy num[5], czyli liczbę 33. Operacja ta polega na umieszczeniu przetwarzanej liczby w taki sposób, że sześć pierwszych liczb zapisanych w tablicy będzie posortowanych. Czynność ta jest wykonywana w następujący sposób. • Zapisujemy liczbę 33 w zmiennej key, przez co komórka 5 zostaje opróżniona. • Porównujemy 33 z 79; ponieważ liczba ta jest mniejsza, przenosimy 79 do kom órki 5, co sprawia, że komórka 4 zostaje opróżniona. • Porównujemy 33 z 65; ponieważ liczba ta jest mniejsza, przenosimy 65 do kom órki 4, co sprawia, że komórka 3 zostaje opróżniona. • Porównujemy 33 z 57; ponieważ liczba ta jest mniejsza, przenosimy 57 do kom órki 3, co sprawia, że komórka 2 zostaje opróżniona. • Porównujemy 33 z 48; ponieważ liczba ta jest mniejsza, przenosimy 48 do kom órki 2, co sprawia, że komórka 1 zostaje opróżniona. • Porównujemy 33 z 15; ponieważ liczba ta jest większa, wstawiamy 33 do kom órki 1; w ten sposób nasze dane przyjmują postać: key
num
I 33
15
33
48
57
65
79
52
0
1
2
3
4
5
6
• Logika umieszczenia liczby 33 (key) w odpowiednim miejscu polega na porównywaniu jej z liczbami zapisanymi po jej lewej stronie, zaczynając od liczby, która z nią sąsiaduje. Tak długo, jak dla pewnego k zmienna key jest mniejsza od num[k], przenosimy wartość num[k] do num[k+1] i przechodzimy do sprawdzenia wartości num[k-1], o ile tylko taka wartość istnieje w tablicy. Jeśli dla pewnego k wartość zmiennej key będzie większa lub równa num[k], to wartość zmiennej key zostaje zapisana w komórce k+1. W naszym przypadku 33 jest większe od wartości kom órki num[0], dlatego też zapisujemy tę liczbę w kom órce num[1]. Szósty przebieg • Przetwarzamy num[6], czyli liczbę 52. Operacja ta polega na umieszczeniu przetwarzanej liczby w taki sposób, że siedem pierwszych liczb zapisanych w tablicy (czyli wszystkie) będzie posortowanych. Czynność ta jest wykonywana w następujący sposób. • Zapisujemy liczbę 52 w zmiennej key, przez co komórka 6 zostaje opróżniona. • Porównujemy 52 z 79; ponieważ liczba ta jest mniejsza, przenosimy 79 do kom órki 6, co sprawia, że komórka 5 zostaje opróżniona. • Porównujemy 52 z 65; ponieważ liczba ta jest mniejsza, przenosimy 65 do kom órki 5, co sprawia, że komórka 4 zostaje opróżniona. • Porównujemy 52 z 57; ponieważ liczba ta jest mniejsza, przenosimy 57 do kom órki 4, co sprawia, że komórka 3 zostaje opróżniona. • Porównujemy 52 z 48; a ponieważ liczba ta jest większa, zapisujemy ją komórce 3, a nasze dane przyjmują postać: key
num
I 52
15
33
48
52
57
65
79
0
1
2
3
4
5
6
W ten sposób cała tablica została posortowana.
22
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Poniższy pseudokod opisuje sposób sortowania pierwszych n elementów tablicy num przy użyciu algorytmu sortowania przez wstawianie. fo r h = 1 to n - 1 do wstawiamy num[h] pomiędzy komórki od num[0] do num[h-1] w taki sposób, by komórki od num[0] do num[h] były posortowane endfor Na podstawie tego opisu możemy napisać funkcję in s e rti onSort operującą na parametrze l i s t . public s t a t i c void in se rtio n S o rt1 (in t l i s t [ ] , int lo , int hi) { //sortowanie komórek od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) { i nt key = l i s t [ h ] ; int k = h - 1; //rozpoczynamy porównywanie z wcześniejszymi elementami while (k >= lo && key < l i s t [ k ] ) { l is t[ k + 1] = l is t[k ] ; --k ; } l i s t [ k + 1] = key; } //k o n iec for } //koniec insertionSort1 Kluczowym elementem tego algorytmu sortowania jest instrukcja while. Stwierdza ona, że jeśli tylko znajdujemy się wewnątrz tablicy (k >= 0) i aktualnie przetwarzana liczba (key) jest mniejsza od liczby w tablicy (key < l i s t [ k ] ) , należy przenieść l ist[k ] na prawo (l ist[k +1] = l i s t [ k ] ) , a następnie przenieść się do elementu na lewo (--k ) i ponownie wykonać te same operacje. Pętla while kończy się, gdy k przyjmie wartość -1 bądź też, gdy dla jakiegoś k wartość zmiennej key będzie większa lub równa l i s t [ k ] . W każdym z tych przypadków wartość zmiennej key jest zapisywana w kom órce l i s t [ k + 1 ] . Jeśli k przyjmie wartość -1, będzie to oznaczać, że aktualnie przetwarzana liczba jest mniejsza od wszystkich umieszczonych przed nią i musi zostać zapisana w kom órce l i s t [ 0 ] . Jednak w przypadku gdy k ma wartość -1 komórka l i s t [ k + 1] jest komórką l i s t [ 0 ] , a zatem zmienna key zostanie zapisana we właściwym miejscu. Przedstawiona funkcja sortuje zawartość tablicy w kolejności rosnącej. Aby zmienić kolejność sortowania, w warunku pętli while należy zmienić operator < na >, co pokazano niżej: while (k >= 0 && key > l is t[k ]) Teraz wartość zmiennej key jest przesuwna w lewo, jeśli będzie większa. Program P1.2 służy do sprawdzenia, czy metoda i nsertionSort działa prawidłowo. Program P1.2 import ja v a .u t i l . * ; public c la ss InsertSort1T est { final s t a tic in t MaxNumbers = 10; public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); in t[] num = new int[MaxNumbers]; System .out.printf("W pisz do %d lic z b i zakończ podawanie danych, wpisując 0\n", MaxNumbers); int n = 0; int v = in .n e x tIn t(); while (v != 0 && n < MaxNumbers) { num[n++] = v; v = in .n e x t In t (); } i f (v != 0) { System.out.printf("\nWpisano więcej niż %d liczb\ n", MaxNumbers); System .out.printf("U żytych zostanie tylko %d pierwszych liczb\ n", MaxNumbers);
23
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
} i f (n == 0) { System .out.printf("\nN ie podano żadnych liczb \ n "); S y ste m .e x it(1); } //n liczb jest przechowywanych w tablicy, w komórkach od num[0] do num[n-1 ] insertionSort(num, n); System.out.printf("\nPosortowane liczby to :\ n "); fo r (v = 0; v < n; v++) System .out.printf("% d " , num[v]); S y stem .o u t.p rin tf("\ n "); } //koniec main public s t a t i c void in s e rtio n S o rt(in t l i s t [ ] , int n) { //sortowanie komórek od list[0] do list[n-1] w kolejności rosnącej fo r (in t h = 1; h < n; h++) { int key = l i s t [ h ] ; int k = h - 1; //rozpoczynamy porównywanie z wcześniejszymi elementami while (k >= 0 && key < l i s t [ k ] ) { l i s t [ k + 1] = l i s t [ k ] ; --k ; } l i s t [ k + 1] = key; } //koniec fo r } //koniec insertionSort } //koniec klasy InsertSortlTest Program prosi o podanie do dziesięciu liczb (ich maksymalna liczba jest określona przez stałą MaxNumbers), następnie zapisuje je w tablicy num, wywołuje metodę i nsertionSort i w końcu wyświetla posortowaną listę. Poniżej przedstawione zostały przykładowe wyniki wykonania programu. Wpisz do 10 licz b i zakończ podawanie danych, wpisując 0 57 48 79 65 15 33 52 0 Posortowane liczby to : 15 33 48 52 57 65 79 Warto zwrócić uwagę, że jeśli użytkownik wpisze więcej niż dziesięć liczb, program wykryje to i wykorzysta tylko dziesięć pierwszych. Z łatwością można by uogólnić metodę in s e r ti onSort w taki sposób, by operowała wyłącznie na fra g m e n cie tablicy. Aby to pokazać, napiszemy je j zmodyfikowaną wersję (nazwiemy ją in sertio n S o rt1), która będzie sortować kom órki od l i s t [ l o ] do l i s t [ h i ] , gdzie lo oraz hi będą przekazywane jako argumenty wywołania funkcji. Ponieważ element lo jest pierwszy, zatem zaczynamy przetwarzać elementy od lo+1 aż do elementu hi. Znalazło to odzwierciedlenie w postaci pętli for. Zatem w tym przypadku najmniejszym indeksem tablicy jest lo, a nie 0. Ta zmiana została z kolei odzwierciedlona w warunku pętli while, który aktualnie ma postać k >= lo. Reszta kodu metody nie została zmieniona. public s t a t i c void in se rtio n S o rt1 (in t l i s t [ ] , in t lo , int hi) { //sortowanie komórek od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) { int key = l i s t [ h ] ; int k = h - 1; //rozpoczynamy porównywanie z wcześniejszymi elementami while (k >= lo && key < l i s t [ k ] ) { l i s t [ k + 1] = l i s t [ k ] ; --k ;
24
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
} l i s t [ k + 1] = key; } //koniec fo r } //koniec insertionSortl Działanie metody i n serti onSort1 możemy przetestować, używając programu P1.2a. Program P1.2a import ja v a .u t i l . * ; public c la ss InsertSort1T est { final s t a tic in t MaxNumbers = 10; public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); in t[] num = new int[MaxNumbers]; System .out.printf("W pisz do %d lic z b i zakończ podawanie danych, wpisując 0\n", MaxNumbers); int n = 0; int v = in .n e x tIn t(); while (v != 0 && n < MaxNumbers) { num[n++] = v; v = in .n e x tIn t( ); } i f (v != 0) { System.out.printf("\nWpisano więcej niż %d liczb\ n", MaxNumbers); System .out.printf("U żytych zostanie tylko %d pierwszych liczb\ n", MaxNumbers); } i f (n == 0) { System .out.printf("\nN ie podano żadnych liczb \ n "); S y ste m .e x it(1); } //n liczb jest przechowywanych w tablicy, w komórkach od num[0] do num[n-1 ] insertionSort1(num, 0, n -1 ); System.out.printf("\nPosortowane liczby to :\ n "); fo r (v = 0; v < n; v++) System .out.printf("% d " , num[v]); S y stem .o u t.p rin tf("\ n "); } //koniec main // tu należy wstawić kod metody insertionSortl } //koniec klasy InsertSortlTest
1.2.1. Analiza algorytmu sortowania przez wstawianie W ramach przetwarzania j-ego elementu możemy zrobić co najmniej jedno porównanie (jeśli wartość num[j] będzie większa od num[j-1]) lub nie więcej niż j - 1 porównań (jeśli wartość num[j] jest najmniejsza spośród wszystkich liczb zapisanych we wcześniejszych komórkach tablicy). W przypadku danych losowych można oczekiwać, że liczba wykonanych porównań wyniesie średnio H (j-1). A zatem średnia liczba porównań wykonywanych podczas sortowania n elementów wynosi: n
1
£
- ( j -
j =-
2
1) = 1/2 {1 + 2 +... + n -1} = /n (n -1) « / n2
Mówimy, że algorytm sortowania przez wstawianie ma złożoność rzędu O(n2) („duże O n kwadrat”). Stała % przestaje mieć znaczenie, gdy wartość n stanie się bardzo duża. W raz z każdym porównaniem wykonywana jest także operacja przypisania. Oznacza to, że całkowita liczba przypisań wynosi także % n(n-1) ~ %n2.
25
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Należy podkreślić, że jest to średnia uzyskiwana w przypadku operowania na danych losowych. W odróżnieniu od algorytmu sortowania przez wybieranie, rzeczywista wydajność algorytmu sortowania przez wstawianie jest zależna od danych, na jakich on operuje. Jeśli tablica wejściowa będzie już posortowana, algorytm szybko to wykryje, wykonując jedynie n -1 porównań. W takim przypadku jego złożoność wyniesie O(n). Można by sądzić, że algorytm sortowania przez wstawianie będzie działał tym lepiej, im bardziej uporządkowane będą dane wejściowe. Jeśli jednak dane wejściowe będą posortowane w kolejności malejącej, algorytm będzie działał z najgorszą możliwą wydajnością, gdyż każdy nowy element będzie musiał zostać przeniesiony na sam początek listy. W takim przypadku liczba porównań wyniesie A n (n -1) ~ An2. A zatem liczba porównań wykonywanych przez algorytm sortowania przez wstawianie waha się od n -1 (w najlepszym przypadku), przez Wn2 (co stanowi wartość średnią), aż do lA n2 (w najgorszym przypadku). Liczba operacji przypisania zawsze jest taka sama jak liczba porównań. Algorytm sortowania przez wstawianie, podobnie jak sortowania przez wybieranie, nie wymaga przydzielania dodatkowych obszarów pamięci. W ramach ćwiczenia można zmodyfikować kod algorytmu w taki sposób, by zliczał ilość operacji sortowania i przypisania wykonywanych podczas sortowania.
1.3. Wstawianie elementu w odpowiednim miejscu Sortowanie przez wstawianie bazuje na pomyśle dodawania nowego elementu do już posortowanej listy w taki sposób, by pozostała posortowana. Zagadnienie to można potraktować jako odrębny problem (który nie ma nic wspólnego z sortowaniem przez wstawianie). Konkretnie rzecz biorąc, zakładamy, że dysponujemy posortowaną listą elementów od list[m ] do l is t [ n ] i chcemy dodać do niej nowy element (załóżmy, że jest on przekazywany jako parametr newItem), przy czym chcemy to zrobić w taki sposób, by elementy list[m ] do lis t[n + 1 ] były posortowane. Dodanie nowego elementu powiększa wielkość listy o 1. Zakładamy, że tablica zawiera dostatecznie dużo miejsca, by można było do niej dodać nowy element. Poniżej przedstawiona została funkcja insertInP lace, stanowiąca rozwiązanie postawionego problemu. public s t a t i c void in se rtIn P la ce (in t newItem, in t l i s t [ ] , in t m, int n) { //elementy list[m] do list[n] są posortowane //wstawiamy newItem w taki sposób, by elementy od list[m] do list[n+1] były posortowane int k = n; while (k >= m && newItem < l i s t [ k ] ) { l i s t [ k + 1] = l i s t [ k ] ; --k ; } l i s t [ k + 1] = newItem; } //koniec insertInPlace Wykorzystując metodę i nsertInPlace, możemy teraz zmodyfikować metodę in sertion So rt (nadając jej przy tym nową nazwę insertionSort2) w następujący sposób: public s t a t i c void in se rtio n S o rt2 (in t l i s t [ ] , in t lo , int hi) { //sortujemy elementy od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) i n s e r tIn P la c e (lis t[h ], l i s t , lo , h - 1); } //koniec insertionSort2 Działanie nowej metody insertionSort2 można przetestować przy użyciu programu P1.2b. Program P1.2b import ja v a .u t i l . * ; public c la ss InsertSort2T est { final s t a tic in t MaxNumbers = 10; public s t a t ic void m ain(String[] args) {
26
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Scanner in = new Scanner(System .in); in t[] num = new int[MaxNumbers]; System .out.printf("W pisz do %d lic z b i zakończ podawanie danych, wpisując 0\n", MaxNumbers); int n = 0; int v = in .n e x tIn t(); while (v != 0 && n < MaxNumbers) { num[n++] = v; v = in .n e x tIn t( ); } i f (v != 0) { System.out.printf("\nWpisano więcej niż %d liczb\ n", MaxNumbers); System .out.printf("U żytych zostanie tylko %d pierwszych liczb\ n", MaxNumbers); } i f (n == 0) { System .out.printf("\nN ie podano żadnych liczb \ n "); S y ste m .e x it(1); } //n liczb jest przechowywanych w tablicy, w komórkach od num[0] do num[n-1 ] insertionSort2(num, 0, n -1 ); System.out.printf("\nPosortowne liczby to :\ n "); fo r (v = 0; v < n; v++) System .out.printf("% d " , num[v]); S y stem .o u t.p rin tf("\ n "); } //koniec main public s t a tic void in se rtio n S o rt2 (in t l i s t [ ] , in t lo , int hi) { //sortujemy elementy od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) in s e r tIn P la c e (lis t[h ], l i s t , lo , h - 1); } //koniec insertionSort2 public s t a tic void in se rtIn P la ce (in t newItem, int l i s t [ ] , int m, int n) { //elementy list[m] do list[n] są posortowane //wstawiamy newItem tak, by elementy od list[m] do list[n+1] były posortowane int k = n; while (k >= m && newItem < l i s t [ k ] ) { l i s t [ k + 1] = l i s t [ k ] ; — k; } l i s t [ k + 1] = newItem; } //koniec insertlnPlace } //koniec klasy InsertSort2Test
1.4. Sortowanie tablicy łańcuchów znaków Przeanalizujemy teraz problem sortowania listy imion w kolejności alfabetycznej. W języku Java personalia, takie jak nazwiska i imiona, są zapisywane w zmiennych typu String, a zatem do przechowania listy będzie potrzebna tablica typu String. W większości przypadków możemy posługiwać się łańcuchami znaków tak, jakby był to typ podstawowy, jednak czasami warto pamiętać, że — precyzyjnie rzecz biorąc — jest to klasa. Będziemy wskazywali te różnice w przypadkach, gdy będzie to konieczne. Jedną z różnic, które będą miały dla nas duże znaczenie, jest to, że porównując łańcuchy znaków, nie możemy posługiwać się zwyczajnymi operatorami porównania (==, <, > itd.). Musimy w tym celu skorzystać z metod klasy S tring (bądź innych, które sami napiszemy). Do popularnych metod służących do porównywania łańcuchów znaków należą: equals, equalsIgnoreCase, compareTo oraz compareToIgnoreCase. Teraz napiszemy funkcję służącą do sortowania tablicy łańcuchów znaków przy użyciu algorytmu sortowania przez wstawianie. Nadamy jej nazwę insertionSort3.
27
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
public s t a t i c void in se rtio n S o rt3 (S trin g [] l i s t , in t lo , int hi) { //sortowanie elementów od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) { String key = l i s t [ h ] ; int k = h - 1; //zaczynamy porównywanie z poprzednimi danymi while (k >= lo && key.compareToIgnoreCase(list[k]) < 0) { l i s t [ k + 1] = l i s t [ k ] ; --k ; } l i s t [ k + 1] = key; } //koniec fo r } //koniec insertionSort3 Funkcja ta jest bardzo podobna do przedstawionych wcześniej, choć różni się od nich deklaracją parametru l i s t [ ] oraz zastosowaniem funkcji compareT oIgnoreCase, której używamy do porównywania łańcuchów. Gdyby wielkość liter miała znaczenie, powinniśmy użyć funkcji compareTo. Do sprawdzenia poprawności działania funkcji i nsertionSort3 służy program P1.3. Program P1.3 import ja v a .u t i l . * ; public c la ss SortS trin gs { final s t a tic in t MaxNames = 8; public s t a t i c void m ain(String[] args) { String name[] = {"Grahamski, Adam", "Papuzińska, K ornelia", "Karolewska, C elina", "Sumińska, A niela", "Rojewska, A lina", "Grahamska, Adrianna", "Rojewska, Anna", "Gierczyńska, Sonia" }; insertionSort3(nam e, 0, MaxNames - 1); System.out.printf("\nPosortowane łańcuchy:\n\n"); fo r (in t h = 0; h < MaxNames; h++) System .out.printf("% s\n", name[h]); } //koniec main // tu należy wstawić funkcję insertionSort3 } //koniec klasy SortStrings W ykonanie tego programu spowoduje wyświetlenie następujących wyników. Posortowane łańcuchy: Gierczyńska, Sonia Grahamska, Adrianna Grahamski, Adam Karolewska, Celina Papuzińska, Kornelia Rojewska, Alina Rojewska, Anna Sumińska, Aniela
28
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
1.5. Sortowanie tablic równoległych Często zdarza się, że dysponujemy powiązanymi informacjami, zapisanymi w różnych tablicach. Przykładowo oprócz tablicy name dysponujemy także tablicą typu int o nazwie id, a id[h] zawiera numer identyfikacyjny skojarzony z name[h], tak jak na rysunku 1.1. 0 1 2 3 4 5 6 7
imię i nazwisko Grahamski, Adam Papuzińska, Kornelia Karolewska, Celina Sumińska, Aniela Rojewska, Alina Grahamska, Adrianna Rojewska, Anna Gierczyńska, Sonia
id 3050 2795 4455 7824 6669 5000 5464 6050
Rysunek 1.1. D w ie tablice zaw ierające p o w iąz an e inform acje Przeanalizujmy teraz problem sortowania personaliów w kolejności alfabetycznej. Po zakończeniu operacji chcielibyśmy, aby z każdym łańcuchem znaków był skojarzony odpowiedni identyfikator. A zatem po zakończeniu sortowania w kom órce name[0] powinien znaleźć się łańcuch "Gierczyńska, Soni a", a w kom órce id[0] — wartość 6050. Aby uzyskać taki efekt, zawsze wtedy, gdy podczas procesu sortowania będą przesuwane personalia, musimy także przesuwać powiązany z nimi identyfikator. Ponieważ personalia i identyfikator przesuwane są „równolegle”, mówimy, że jest to „sortowanie równoległe” lub sortowanie „tablic równoległych”. W celu przedstawienia sposobu sortowania tablic równoległych zmodyfikujemy nieco funkcję insertionSort3. Dodamy do niej jedynie kod, który za każdym razem, gdy zostanie przesunięty łańcuch znaków, w taki sam sposób przesunie identyfikator. Nowa funkcja nosi nazwę p a ra lle lS o rt. public s t a t i c void p a ra lle lS o rt(S trin g [] l i s t , int id [ ], int lo , int hi) { //sortowanie elementów od list[lo] do list[hi] w kolejności alfabetycznej //z zapewnieniem, że każdemu łańcuchowi będzie odpowiadał prawidłowy identyfikator fo r (in t h = lo + 1; h <= h i; h++) { String key = l i s t [ h ] ; int m = id [h ]; //pobieramy numer id int k = h - 1; //zaczynamy porównywanie z poprzednimi danymi while (k >= lo && key.compareToIgnoreCase(list[k]) < 0) { l i s t [ k + 1] = l i s t [ k ] ; id[k + 1] = id [k ]; //przesuwamy identyfikator w górę wraz z łańcuchem --k ; } l i s t [ k + 1] = key; id[k + 1] = m; //zapisujemy numer id na tej samej pozycji, co nazwę } //koniec fo r } //koniec parallelSort Do przetestowania działania tej funkcji służy program P1.4. Program P1.4 import ja v a .u t i l . * ; public c la ss P a ra lle lS o rt { final s t a tic in t MaxNames = 8; public s t a tic void m ain(String[] args) { String name[] = {"Grahamski, Adam", "Papuzińska, K ornelia", "Karolewska, C elina", "Sumińska, A niela", "Rojewska, A lina", "Gramhamska, Adrianna", "Rojewska, Anna", "Gierczyńska, Sonia" };
29
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
int id[] = {3050,2795,4455,7824,6669,5000,5464,6050}; parallelSort(nam e, id, 0, MaxNames - 1); System.out.printf("\nPosortowane personalia i ich identyfikatory\n\n"); fo r (in t h = 0; h < MaxNames; h++) System .out.printf("% -20s %d\n", name[h], id [h ]); } //koniec main // tu należy wstawić funkcję parallelSort } //koniec klasy Parallel Poniżej przedstawione zostały wyniki wykonania tego programu. Posortowane personalia i ich identyfikatory Gierczyńska, Sonia Grahamska, Adrianna Grahamski, Adam Karolewska, Celina Papuzińska, Kornelia Rojewska, Alina Rojewska, Anna Sumińska, Aniela
6050 5000 3050 4455 2795 6669 5464 7824
Należy zauważyć, że jeśli dysponujemy kilkoma powiązanymi ze sobą zbiorami danych, które należy przetworzyć, to sortowanie każdego z nich w odrębnej tablicy nie będzie najlepszym rozwiązaniem. Lepsze byłoby zgrupowanie powiązanych informacji w formie klasy i operowanie na takiej grupie jak na pojedynczym elemencie. Takie rozwiązanie zostało przedstawione dalej w tej książce, w podrozdziale 2.14.
1.6. Wyszukiwanie binarne Wyszukiwanie binarne (ang. binary search) jest szybką metodą wyszukiwania konkretnego elementu z listy, przy założeniu, że lista ta jest posortow an a (w kolejności rosnącej lub malejącej). W celu zilustrowania tej metody wyszukiwania załóżmy, że dysponujemy listą trzynastu liczb, posortowanych w kolejności rosnącej i zapisanych w tablicy num [0..12]. num 17
24
31
39
44
56
66
72
78
83
89
96
0
1
2
3
4
5
6
7
8
9
10
11
Załóżmy, że chcemy odszukać liczbę 66. 1. Odnajdujemy środkowy element listy. W naszym przypadku jest to liczba 56 zapisana na pozycji 6. Porównujemy liczby 66 i 56. Ponieważ 66 jest większa, zatem wiemy, że jeśli w ogóle jest na liście, m usi zn ajd ow ać się za pozycją 6, gdyż liczby są posortowane rosnąco. W następnym kroku ograniczymy zatem poszukiwania do pozycji od 7 do 12. 2. Określamy środkowy element z zakresu od 7 do 12. W tym przypadku możemy wybrać element 9 lub 10. Algorytm, który za chwilę napiszemy, wybierze element 9, zawierający liczbę 78. 3. Porównujemy liczby 66 i 78. Ponieważ 66 jest mniejsza, zatem wiemy, że jeśli w ogóle jest na liście, m usi zn ajd ow ać się p rz ed pozycją 9, gdyż liczby są posortowane rosnąco. W następnym kroku ograniczymy zatem poszukiwania do pozycji od 7 do 8.
30
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
4. Określamy środkowy element z zakresu od 7 do 8. W tym przypadku możemy wybrać element 7 lub 8. Algorytm, który napiszemy, wybierze element 7, zawierający liczbę 66. 5. Porównujemy liczby 66 i 66. Ponieważ są równe, nasze wyszukiwanie kończy się sukcesem — udało się odnaleźć poszukiwaną liczbę na pozycji 7. Załóżmy teraz, że poszukiwalibyśmy wartości 70. Przebieg wyszukiwania byłby tak sam jak wcześniej, aż do momentu porównania liczby 70 i liczby 66 (zapisanej na pozycji 7). • Ponieważ wartość 70 jest większa, zatem wiemy, że jeśli w ogóle jest na liście, m usi zn ajd ow ać się za pozycją 7, gdyż liczby są posortowane rosnąco. W następnym kroku ograniczymy zatem poszukiwania do pozycji od 8 do 8, czyli do jednego elementu listy. • Porównujemy liczbę 70 z zawartością pozycji 8, czyli liczbą 72. Ponieważ 70 jest mniejsza, zatem wiemy, że jeśli w ogóle jest na liście, m usi zn ajd ow ać się p rzed pozycją 8. Ponieważ poszukiwana liczba nie może być jednocześnie za pozycją 7 i przed pozycją 8, zatem dochodzimy do wniosku, że w ogóle nie m a jej na liście. W każdym kolejnym kroku ograniczamy poszukiwania do pewnego fragmentu listy. Wykorzystamy zmienne lo oraz hi do przechowywania indeksów określających te pozycje. Innymi słowy, nasze poszukiwania będą ograniczały się do fragmentu listy od num[lo] do num[hi]. Początkowo chcemy przeszukiwać całą listę, dlatego przypisujemy zmiennej lo wartość 0, a zmiennej hi wartość 12. W jaki sposób określamy indeks środkowego elementu? Użyjemy w tym celu następującej instrukcji: mid = (lo + hi) / 2; Ponieważ zostanie w niej wykorzystane dzielenie całkowite, zatem ewentualna reszta z dzielenia będzie pominięta. Jeśli np. zmienna lo przyjmie wartość 0, a hi wartość 12, to w zmiennej mid zostanie zapisane 6; jeśli lo przyjmie wartość 7, a hi wartość 12, to w mid zostanie zapisane 9 i w końcu, jeśli lo przyjmie wartość 7, a hi wartość 8, to w mid zostanie zapisane 7. Jeśli tylko zmienna lo będzie mniejsza lub równa hi, będą one wyznaczały niepusty fragment listy, który można przeszukiwać. Jeśli wartości obu tych zmiennych będą równe, wyznaczą jeden element listy, który należy sprawdzić. Kiedy zmienna lo przyjmie wartość większą od hi, będzie to oznaczało, że przejrzeliśmy całą listę, a poszukiwana wartość nie została odnaleziona. Bazując na tym opisie, możemy teraz napisać funkcję bi narySearch. Aby nadać jej bardziej ogólny charakter, napiszemy ją w taki sposób, żeby kod wywołujący określał, który fragment tablicy należy przeszukać. A zatem do funkcji binarySearch będziemy musieli przekazać poszukiwaną wartość (key), tablicę ( l i s t ) , początkowy indeks przeszukiwanego zakresu tablicy (lo) oraz końcowy indeks tego zakresu (hi). Jeśli np. w tablicy numbędziemy poszukiwali wartości 66, możemy użyć następującego wywołania: binarySearch(66, num, 0, 12). Funkcja musi poinformować o wynikach wyszukiwania. Jeśli poszukiwana wartość została odnaleziona, funkcja zwróci jej położenie, w przeciwnym razie zwrócona zostanie wartość -1. public s t a t i c int binarySearch(int key, in t[] l i s t , in t lo , int hi) { //szukamy wartości podanej jako key, w zakresie od list[lo] do list[hi], //jeśli uda się ją znaleźć, zwracamy indeks, jeśli nie, zwracamy -1. while (lo <= hi) { int mid = (lo + hi) / 2; i f (key == lis t[m id ]) return mid; // znaleziono wartość i f (key < lis t[m id ]) hi = mid - 1; e lse lo = mid + 1; } return -1 ; //lo jest większe do hi; wartość nie została znaleziona } Jeśli przyjmiemy, że zmienna item zawiera poszukiwaną wartość, możemy napisać następujący fragment kodu: int ans = binarySearch(item , num, 0, 12); i f (ans == -1) System .ou t.prin tf("N ie znaleziono wartości %d\n", item ); e lse System .out.printf("W artość %d znaleziono na pozycji %d\n", item, ans);
31
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jeśli chcemy poszukać wartości item w zakresie określonym zmiennymi i oraz j , możemy to zrobić, używając następującej instrukcji: int ans = binarySearch(item , num, i , j ) ; Działanie funkcji binarySearch można przetestować przy użyciu programu P1.5. Program P1.5 public c la ss BinarySearchTest { public s t a tic void m ain(String[] args) { in t[] num = {17, 24, 31, 39, 44, 49, 56, 66, 72, 78, 83, 89, 96}; int n = binarySearch(66, num, 0, 12); System .out.printf("% d\n", n); // zwróci wynik 7; 66 zapisano na pozycji 7 n = binarySearch(66, num, 0, 6 ); System .out.printf("% d\n", n); // zwróci wynik -1; 66 nie ma w zakresie od 0 do 6 n = binarySearch(70, num, 0, 12); System .out.printf("% d\n", n); / / zwróci wynik -1; 70 nie ma na liście n = binarySearch(89, num, 5, 12); System .out.printf("% d\n", n); // zwróci wynik 11; 89 zapisano na pozycji 11 } //koniec main // tutaj należy wstawić kod funkcji binarySearch } //koniec klasy BinarySearchTest Po uruchomieniu program wyświetli następujące wyniki. 7 -1 -1 11
1.7. Przeszukiwanie tablicy łańcuchów znaków Posortowaną tablicę łańcuchów znaków (dajmy na to nazwisk i imion) można przeszukać, używając tej samej techniki, którą wcześniej zastosowaliśmy do wyszukiwania liczb. Podstawowe różnice sprowadzają się do deklaracji tablicy oraz sposobu porównywania, w którym zamiast operatorów == oraz < są używane funkcje klasy S tr i ng. Poniżej przedstawiona została zmodyfikowana wersja funkcji binarySearch operująca na łańcuchach znaków. public s t a t i c int binarySearch(String key, S trin g [] l i s t , in t lo , int hi) { //szukamy łańcucha określonego parametrem key w zakresie od list[lo] do list[hi] //jeśli uda się go znaleźć, zwracamy indeks, jeśli nie, zwracamy -1 while (lo <= hi) { int mid = (lo + hi) / 2; int cmp = key.com pareTo(list[m id]); i f (cmp == 0) return mid; // wyszukiwanie zakończone sukcesem i f (cmp < 0) hi = mid -1 ; // keyjest 'mniejszy' od list[mid] e lse lo = mid + 1; // keyjest 'większy' od list[mid] } return -1 ; //lo jest większe od hi; wartość nie została znaleziona } //koniec binarySearch
32
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Ponieważ musimy wiedzieć, czy jeden łańcuch znaków jest równy drugiemu, czy mniejszy od drugiego, najlepszym rozwiązaniem będzie zastosowanie metody compareTo. W arto zwrócić uwagę, że metoda compareTo jest wywoływana tylko raz. Zwrócona przez nią wartość (cmp) dostarcza wszystkich niezbędnych informacji. Gdybyśmy porównywali słowa lub personalia osób i chcieli, żeby wielkość liter nie była uwzględniana, powinniśmy zastosować metodę compareTolgnoreCase. Przedstawioną metodę można przetestować, używając programu P1.6. Program P1.6 import ja v a .u t i l . * ; public c la ss BinarySearchString { final s t a tic in t MaxNames = 8; public s t a t ic void m ain(String[] args) { String name[] = {"Grahamski, Adam", "Papuzińska, K ornelia", "Karolewska, C elina", "Sumińska, A niela", "Rojewska, Anna", "Gramhamska, Adrianna", "Rojewska, Anna", "Gierczyńska, Sonia" }; int n = binarySearch("Grahamski, Adam", name, 0, MaxNames - 1); System .out.printf("% d\n", n); //zwróci 0, indeks łańcucha Grahamski, Adam n = binarySearch("Rojewska, Anna", name, 0, MaxNames - 1); System .out.printf("% d\n", n); //zwróci 6, indeks łańcucha Rojewska, Anna n = binarySearch("Papuzińska, K ornelia", name, 0, MaxNames - 1); System .out.printf("% d\n", n); //zwróci 4, indeks łańcucha Papuzińska, Kornelia n = binarySearch("Papuzińska, K ornelia", name, 4, MaxNames - 1); System .out.printf("% d\n", n); //zwróci -1, ponieważ łańcucha nie ma w zakresie indeksów od 4 do 7 n = binarySearch("Wichura, Andrzej", name, 0, MaxNames - 1); System .out.printf("% d\n", n); //zwróci -1, ponieważ łańcucha Wichura, Andrzej nie ma na liście } //koniec main // tutaj należy wstawić kod funkcji binarySearch } //koniec klasy BinarySearchString Na początku programu tworzymy tablicę name zawierającą personalia posortowane w kolejności alfabetycznej. Następnie kilkakrotnie wywołujemy metodę binarySearch, przekazując do niej różne łańcuchy znaków i wyświetlając uzyskane wyniki. Można się zastanawiać, co by się stało w przypadku użycia następującego wywołania: n = binarySearch("Rojewska, Anna", name, 5, 10); Nakazuje ono metodzie bi narySearch wyszukanie łańcucha znaków "Rojewska, Anna" we fragmencie tablicy obejmującym pozycje od 5 do 10. Jednak pozycje od 8 do 10 nie istnieją. W takim przypadku wynik wyszukiwania będzie nieprzewidywalny. Program może ulec awarii lub zwrócić nieprawidłowe wyniki. Obowiązek przekazania metodzie binarySearch (oraz wszelkim innym metodom) prawidłowych argumentów leży po stronie kodu wywołującego.
33
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
1.8. Przykład: zliczanie wystąpień wyrazów Napiszemy teraz program, który będzie odczytywał fragment tekstu i zliczał ilość wystąpień poszczególnych słów. Program ten będzie wyświetlał alfabetyczną listę wszystkich odszukanych słów oraz liczbę wystąpień każdego z nich. Działanie programu można opisać w następujący sposób. while są dane wejściowe pobierz słowo wyszukaj słowo i f słowo je s t w ta b licy dodaj 1 do licz n ik a wystąpień danego słowa else dodaj słowo do ta b licy ustaw lic z n ik wystąpień tego słowa na 1 endi f endwhile wyświetl ta b lic ę To typowy przykład rozwiązania określanego jako „wyszukaj i wstaw”. Poszukujemy następnego słowa pomiędzy słowami, które już zostały zapamiętane. Jeśli uda się je odnaleźć, zwiększamy skojarzony z nim licznik. Jeśli natomiast słowa nie uda się odnaleźć, jest ono wstawiane do tablicy, a licznikowi jego wystąpień zostaje przypisana wartość 1. Najważniejszą decyzją, jaką należy podjąć podczas pisania tego programu, jest określenie sposobu przeszukiwania tablicy, a ten z kolei zależy od tego, gdzie i w jaki sposób będą w niej umieszczane nowe słowa. Do dyspozycji mamy dwie możliwości. 1. Nowe słowo jest dodawane do pierwszej wolnej kom órki tablicy. Oznacza to, że występowanie słów w tablicy musi być sprawdzane przy użyciu wyszukiwania sekwencyjnego, gdyż ich kolejność będzie losowa. Zaletą tego rozwiązania jest łatwość dodawania kolejnych słów do tablicy, jednak im więcej słów się w niej znajdzie, tym dłużej będzie trwało ich wyszukiwanie. 2. Nowe słowo będzie wstawiane do tablicy w taki sposób, że zawsze będzie ona posortowana alfabetycznie. Może to oznaczać konieczność przesuwania słów już zapisanych w tablicy, tak by nowe słowo znalazło się w odpowiednim miejscu. Ponieważ jednak tablica będzie posortowana, do odnajdywania słów można użyć algorytmu wyszukiwania binarnego. W przypadku zastosowania drugiego rozwiązania wyszukiwanie jest szybsze, a wstawianie nowych słów wolniejsze niż w przypadku pierwszego rozwiązania. Ogólnie rzecz biorąc, ponieważ operacja wyszukiwania będzie przeprowadzana częściej niż wstawiania, preferowane jest drugie rozwiązanie. Kolejną zaletą rozwiązania numer 2 jest to, że po zakończeniu zliczania wszystkich słów będą one zapisane w kolejności alfabetycznej, dzięki czemu nie będziemy musieli ich dodatkowo sortować. W przypadku zastosowania rozwiązania 1. wyświetlenie słów w kolejności alfabetycznej będzie wymagało dodatkowego posortowania. W naszym programie zastosujemy rozwiązanie numer 2. Pełny kod rozwiązania został przedstawiony w programie P1.7. Program P1.7 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss WordFrequency { final s t a tic in t MaxWords = 50; public s t a t ic void m ain(String[] args) throws IOException { S trin g [] wordList = new String[MaxWords]; in t[] frequency = new int[MaxWords]; FileReader in = new F ileR ead er("p assag e.txt");
34
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
PrintW riter out = new PrintWriter(new F ile W rite r ("o u tp u t.tx t")); fo r (in t h = 0; h < MaxWords; h++) { frequency[h] = 0; wordLi st[h ] = " " ; } int numWords = 0; String word = getWord(in).toLowerCase(); while (!w ord .equ als("")) { int loc = binarySearch(word, wordList, 0, numWords-1); i f (word.compareTo(wordList[loc]) == 0) ++frequ en cy[loc]; //znaleziono słowo e lse //to jest nowe słowo i f (numWords < MaxWords) { //jeśli tablica nie jest pełna addToList(word, wordList, frequency, lo c, numWords-1); ++numWords; } e lse out.printf("Słow o '%s' nie zostało dodane do tablicy\ n ", word); word = getWord(in).toLowerCase(); } p rin tR esu lts(ou t, wordList, frequency, numWords); i n .c lo s e () ; o u t.c lo s e (); } // koniec main public s t a t i c int binarySearch(String key, S trin g [] l i s t , in t lo , int hi) { //szukamy łańcucha określonego parametrem key w zakresie od list[lo] do list[hi] //jeśli uda się go znaleźć, zwracamy jego indeks, //wprzeciwnym przypadku zwracamy indeks miejsca, w którym łańcuch należy umieścić; //kod wywołujący musi sprawdzić to miejsce, by określić, czy łańcuch został znaleziony while (lo <= hi) { int mid = (lo + hi) / 2; int cmp = key.com pareTo(list[m id]); i f (cmp == 0) return mid; // wyszukiwanie zakończone sukcesem i f (cmp < 0) hi = mid - 1; // key jest 'mniejszy' od list[mid] e lse lo = mid + 1; // key jest 'większy' od list[mid] } return lo ; //łańcuch key musi zostać wstawiony do komórki o indeksie lo } //koniec binarySearch public s t a t i c void addToList(String item, S trin g [] l i s t , in t[] freq , int p, in t n) { //dodaje łańcuch w parametrze item na pozycji list[p]; przypisuje freq[p] wartość 1 //przesuwa elementy od list[n] do list[p] o jedną pozycję w prawo fo r (in t h = n; h >= p; h--) { l i s t [h + 1] = l is t [ h ] ; freq[h + 1] = freq[h] ; } lis t [ p ] = item; freq[p] = 1; } //koniec addToList public s t a t i c void prin tR esu lts(P rin tW riter out, S trin g [] l i s t , in t fr e q [] , in t n) { out.printf("\nSłow a Li cznik\n\n"); fo r (in t h = 0; h < n; h++) out.p rin tf("% -20s %2d\n", l i s t [ h ] , fre q [h ]); } //kon iec printResults public s t a t i c String getWord(FileReader in) throws IOException {
35
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
//zwraca nowe słowo odczytane z pliku final int MaxLen = 255; int c, n = 0; char[] word = new char[MaxLen]; / / przeskakujemy znaki, które nie są literami while (!C h a ra cte r.isL e tte r((c h a r) (c = in .r e a d ())) && (c != -1 )) ; //odczytujemy znaki i f (c == -1) return " " ; //nie znaleziono liter word[n++] = (char) c; while (C h a ra cte r.isL e tter(c = in .re a d ())) i f (n < MaxLen) word[n++] = (char) c; return new String(word, 0, n); } // koniec getWord } //koniec klasy WordFreąuency Załóżmy, że w pliku passage.txt znajduje się następujący tekst: S ta ra j s ię nie Można osiągnąć I s t n ie je tylko Nasz charakter
dążyć do sukcesu, lecz osiągać w artości. wszystko, co umysł może pojąć i wco je s t w stan ie uwierzyć. jeden sposób, by uniknąć krytyki: nic nie ro b ić, nic nie mówić, określa to , co robimy, kiedy nikt na nas nie patrzy.
być nikim.
Program P1.7 zapisuje wyniki w pliku output.txt. Poniżej przedstawiliśmy jego zawartość po przetworzeniu powyższego tekstu. Słowa by być charakter co do dążyć i is t n ie je jeden je s t kiedy krytyki lecz może można mówić na nas nasz nic nie nikim nikt określa osiągać osiągnąć patrzy pojąć robimy robić się
36
Licznik
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
sposób stanie s ta ra j sukcesu to tylko umysł uniknąć uwierzyć w wartości wszystko
1 1 1 1 1 1 1 1 1 2 1 1
Poniżej zamieszczono kilka komentarzy dotyczących programu P1.7. • W programie zakładamy, że słowa zaczynają się od litery i mogą się składać wyłącznie z liter. Gdybyśmy chcieli dodać do tego inne znaki (takie jak przecinek lub cudzysłów), trzeba będzie odpowiednio zmienić kod metody getWord. • Stała MaxWords określa maksymalną liczbę unikalnych słów, które program jest w stanie zapamiętać. Do zaprezentowanych celów testowych użyliśmy wartości 50. Jeśli liczba unikalnych słów w analizowanym tekście przekroczy wartość MaxWords (czyli dajmy na to 50), każde kolejne nowe słowo zostanie odczytane, lecz program go nie zapamięta i poinformuje o tym, wyświetlając stosowny komunikat. Jeśli jednak program odnajdzie słowo, które już jest zapisane w tablicy, jego licznik zostanie prawidłowo inkrementowany. • Metoda main przypisuje wszystkim licznikom początkową wartość 0, a we wszystkich komórkach tablicy słów zapisuje puste łańcuchy znaków. Następnie zaczyna przetwarzać kolejne słowa odczytane z tekstu, zgodnie z algorytmem opisanym na początku podrozdziału 1.8. • Metoda getWord odczytuje zawartość pliku wejściowego i zwraca kolejne znalezione słowo. • Wszystkie słowa zostają zapisane małymi literami, po to by słowa, takie jak Nie i nie, zostały uznane za identyczne. • Metoda binarySearch została napisana w taki sposób, że w przypadku odnalezienia słowa zostaje zwrócony jego indeks. Jeśli słowo nie zostało odnalezione, metoda zwraca pozycję, w której pow inno ono zostać zapisane. Do metody addToLi st przekazywane jest miejsce, w którym należy wstawić nowe słowo. Słowa położone na prawo od tej pozycji, włącznie z tym, które się na niej znajduje, są przesuwane o jedną komórkę, by zrobić miejsce dla nowego słowa.
1.9. Scalanie posortowanych list Scalanie to proces, w którym dwie posortowane listy zostają połączone w jedną posortowaną listę. Przykładowo dwie listy liczb, A oraz B, o następującej zawartości: A: 21 28 35 40 61 75 B: 16 25 47 54 mogą zostać scalone w jedną posortowaną listę C: C: 16 21 25 28 35 40 47 54 61 75 Lista C zawiera wszystkie liczby z list A i B. W jaki sposób można wykonać taką operację scalania? Jednym ze sposobów ułatwienia zrozumienia procesu scalania jest wyobrażenie sobie, że liczby tworzące konkretną listę są umieszczone na kartach, po jednej na karcie. Karty są umieszczone na stole w taki sposób, że liczby są widoczne, przy czym karta z najmniejszą liczbą znajduje się na samej górze. A zatem listy A i B możemy sobie wyobrazić w następujący sposób:
37
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
21 28 35 40 61 75
16 25 47 54
Patrzymy na dwie karty umieszczone na samej górze, czyli 21 oraz 16. Mniejszą z nich usuwamy i umieszczamy na liście C. W ten sposób pojawia się karta 25. Aktualnie dwoma górnymi kartami są 21 oraz 25. Mniejszą z nich jest 21; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16 i 21. W ten sposób pojawia się karta 28. Aktualnie dwoma górnymi kartami są 28 oraz 25. Mniejszą z nich jest 25; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21 oraz 25. W ten sposób pojawia się karta 47. Teraz dwoma górnymi kartami są 28 oraz 47. Mniejszą z nich jest 28; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21, 25 oraz 28. W ten sposób pojawia się karta 35. Aktualnie dwoma górnymi kartami są 35 oraz 47. Mniejszą z nich jest 35; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21, 25, 28 oraz 35. W ten sposób pojawia się karta 40. Aktualnie dwoma górnymi kartami są 40 oraz 47. Mniejszą z nich jest 40; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21, 25, 28, 35 i 40. W ten sposób pojawia się karta 61. Aktualnie dwoma górnymi kartami są 61 oraz 47. Mniejszą z nich jest 47; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21, 25, 28, 35 i 47. W ten sposób pojawia się karta 54. Aktualnie dwoma górnymi kartami są 61 oraz 54. Mniejszą z nich jest 54; usuwamy ją i dodajemy do listy C, która aktualnie zawiera karty: 16, 21, 25, 28, 35, 47 i 54. Na liście B nie ma już żadnych kart. Pozostałe karty z listy A— 61 oraz 75 — dodajemy do listy C, która po wykonaniu tej operacji przyjmie postać: 16 21 25 28 35 40 47 54 61 75 W ten sposób operacja scalania list została zakończona. W każdym kroku operacji scalania porównujemy najmniejszą liczbę pozostałą na liście Az najmniejszą liczbą pozostałą na liście B. Mniejsza z tej pary jest dodawana do listy C. Jeśli liczba zostanie pobrana z listy A, przechodzimy do następnego elementu listy A, jeśli natomiast liczba pochodzi z listy B, przechodzimy do następnego elementu listy B. Czynności te są powtarzane tak długo, aż zostaną użyte wszystkie liczby z listy Abądź z listy B. Jeśli zostały użyte wszystkie liczby z listy A, pozostałą zawartość listy B przenosimy na listę C. Jeśli zostały użyte wszystkie liczby z listy B, na listę C przenosimy pozostałą zawartość listy A. Tę logikę można opisać w następujący sposób. while (na każdej z l i s t A i B pozostaje przynajmniej jedna karta) i f (najm niejsza karta z A < najm niejsza karta z B) dodaj najm niejszą z A do C przejdź do następnej karty na A else dodaj najm niejszą z B do C przejdź do następnej karty na B endif endwhile i f ( l i s t a A zo stała w cało ści przetworzona) dodaj pozostałe liczby z B do C e lse dodaj pozostałe liczby z A do C
1.9.1. Implementacja scalania list Załóżmy, że tablica Azawiera mliczb zapisanych w komórkach od A[0] do A[m-1], natomiast tablica B zawiera n liczb zapisanych w komórkach od B[0] do B[n-1]. Zakładamy także, że liczby w obu tablicach są posortowane w kolejności rosnącej. Naszym celem jest scalenie liczb z tablic Ai B oraz zapisanie ich w tablicy Cw taki sposób, że komórki od C[0] do C[m+n-1] będą zawierać liczby z tablic Ai B posortowane w kolejności rosnącej.
38
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Jako indeksów tablic A, B i C użyjemy odpowiednio zmiennych całkowitych i, j oraz k. Zwrot „przejść do następnej pozycji w tablicy” będzie oznaczał powiększenie odpowiedniego indeksu o 1. Algorytm scalania możemy zaimplementować w formie następującej metody. public s t a t i c int m erge(int[] A, int m, in t[] B, in t n, in t[] C) { int i = 0; //i wskazuje na pierwszą (najmniejszą) liczbę w A int j = 0; //j wskazuje na pierwszą (najmniejszą) liczbę w B int k = -1 ; //k będzie inkrementowana przed zapisaniem liczby w C[k] while (i < m && j < n) { i f (A[i] < B [ j]) C[++k] = A [i++]; e lse C[++k] = B [j+ + ]; } i f (i == m) //kopiujemy liczby z zakresu od B[j] do B[n-1] do tablicy C fo r ( ; j < n; j+ + ) C[++k] = B [ j] ; e lse // j == n, kopiujemy liczby z zakresu od A[i] do A[m-1] do C fo r ( ; i < m; i++) C[++k] = A [i]; return m+ n; } //koniec merge Metoda merge pobiera argumenty A, m, B, n oraz C, wykonuje operację scalenia, a następnie zwraca liczbę elementów zapisanych w tablicy C, czyli m + n. Program P1.8 zawiera prostą metodę main służącą do testowania działania metody merge. Na samym początku tworzone są trzy tablice — A, B i C — następnie zostaje wywołana metoda merge, a na końcu jest wyświetlana zawartość tablicy C. Wykonanie tego programu spowoduje wyświetlenie następujących wyników. 16 21 25 28 35 40 47 54 61 75 Program P1.8 public c la ss MergeTest { public s t a tic void m ain(String[] args) { i nt[] A = {21, 28, 35, 40, 61, 75 }; //wielkość 6 i nt[] B = {16, 25, 47, 54 }; //wielkość 4 in t[] C = new in t[2 0 ]; //wystarczy, by zapisać wszystkie elementy int n = merge(A, 6, B, 4, C); fo r (in t h = 0; h < n; h++) System .out.printf("% d " , C[h]); S y stem .o u t.p rin tf("\ n "); } //koniec main // tu należy umieścić kod metody merge } //koniec klasy MergeTest W arto wiedzieć, że metodę merge można także zaimplementować w następujący sposób. public s t a t i c int m erge(int[] A, int m, in t[] B, in t n, in t[] C) { int i = 0; //i wskazuje na pierwszą (najmniejszą) liczbę w A int j = 0; //j wskazuje na pierwszą (najmniejszą) liczbę w B int k = -1 ; //k będzie inkrementowana przed zapisaniem liczby w C[k] while (i < m || j < n) { i f (i == m) C[++k] = B [j+ + ]; e lse i f ( j == n) C[++k] = A [i++]; e ls e i f (A[i] < B [ j] ) C[++k] = A [i++]; e lse C[++k] = B [j+ + ]; } return m + n; }
39
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Logika działania pętli while jest następująca. Pętla ta jest wykonywana tak długo, jak lista Alub lista B zawierają przynajmniej jeden element do przetworzenia. Jeśli przetwarzanie listy Azostało zakończone (i == m), kopiujemy element z B do C. Jeśli przetwarzanie listy B zostało zakończone (j == n), kopiujemy element z Ado C. W przeciwnym przypadku do listy Ckopiujemy mniejszy z elementów A[i] i B [ j] . Za każdym razem gdy skopiujemy element z którejś z tablic, inkrementujemy jej indeks. Choć poprzednia implementacja algorytmu scalania była prostsza, całkiem uzasadnione jest stwierdzenie, że ta wersja metody jest bardziej elegancka.
Ćwiczenia 1. Przeprowadzane jest badanie w celu określenia popularności dziesięciu artystów. Każda osoba oddaje swój głos, podając numer artysty (wartości od 1 do 10). Każdy głosujący może oddać głos tylko na jednego artystę. Głos jest rejestrowany jako liczba z zakresu od 1 do 10. Liczba głosujących nie jest znana przed badaniem, jednak podawanie głosów kończy się w momencie wpisania wartości 0. Każdy głos, który nie jest liczbą z zakresu od 1 do 10, jest uznawany za nieważny. Nazwiska i imiona artystów zostały zapisane w pliku votes.txt. Przyjmujemy, że pierwsze personalia reprezentują artystę numer 1, drugie — artystę numer 2 itd. W pliku po personaliach artystów zostały podane głosy. Napisz program, który odczyta dane z pliku i przetworzy wyniki głosowania. Wyniki mają byś wyświetlone w kolejności alfabetycznej według nazwisk artystów oraz według liczby otrzymanych głosów (w kolejności malejącej). Wyniki zapisz w pliku re sults.txt. 2. Napisz program, który będzie odczytywał nazwiska oraz numery telefonów i zapisywał je w dwóch tablicach. Następnie zażądaj podania nazwiska i wyświetl powiązany z nim numer telefonu. Do odnajdywania nazwisk użyj algorytmu wyszukiwania binarnego. 3. Napisz program, który będzie odczytywał słowa polskie oraz ich angielskie odpowiedniki i zapisywał je w dwóch tablicach. Poproś użytkownika o wpisanie kilku polskich słów. Dla każdego z nich wyświetl angielski odpowiednik. Wybierz odpowiedni znacznik końca tablicy. Szukaj wpisywanych słów przy użyciu algorytmu wyszukiwania binarnego. Zmodyfikuj program w taki sposób, by użytkownik mógł wpisywać słowa angielskie. 4. M ed iana zbioru n liczb (przy czym nie muszą to być liczby unikalne) jest wyznaczana poprzez ich posortowanie i wybranie liczby znajdującej się pośrodku listy. Jeśli n jest wartością nieparzystą, będzie to jedna, unikalna liczba. Jeśli n jest wartością parzystą, medianą będzie średnia dwóch liczb. Napisz program, który będzie wczytywał zbiór n dodatnich liczb całkowitych (załóż przy tym, że n < 100) i wyświetlał medianę. Wartość n nie jest określona, wiadomo natomiast, że wpisanie wartości 0 kończy podawanie zbioru danych wejściowych. 5. Dominanta zbioru n liczb jest liczbą, która występuje w tym zbiorze najczęściej. Przykładowo dominantą zbioru 7 3 8 5 7 3 1 3 4 8 9 jest liczba 3. Napisz program, który będzie wczytywał zbiór n dodatnich liczb całkowitych (załóż przy tym, że n < 100) i wyświetlał jego dominantę. Wartość n nie jest określona, wiadomo natomiast, że wpisanie wartości 0 kończy podawanie zbioru danych wejściowych. 6. Tablica chosen zawiera n unikalnych liczb całkowitych, zapisanych w losowej kolejności. Druga tablica, winners, zawiera m unikalnych liczb całkowitych zapisanych w kolejności rosnącej. Napisz program, który określi, ile liczb z tablicy chosen występuje także w tablicy winners. 7. Test wielokrotnego wyboru składa się z dwudziestu pytań. Dla każdego pytania podanych jest pięć odpowiedzi, oznaczonych jako A, B, C, D i E. Pierwszy wiersz pliku z danymi zawiera prawidłowe odpowiedzi na dwadzieścia pytań, zapisane jako dwadzieścia liter umieszczonych bezpośrednio jedna za drugą. Oto przykład: BECDCBAADEBACBAEDDBE Każdy kolejny wiersz zawiera odpowiedzi podane przez jednego zdającego. Wiersz rozpoczyna się identyfikatorem osoby (czyli liczbą całkowitą), po którym umieszczony jest jeden lub kilka znaków odstępu, a następnie dwadzieścia znaków reprezentujących odpowiedzi, zapisanych bezpośrednio jeden za drugim. Jeśli osoba zdająca test nie odpowiedziała na pytanie, jest to oznaczane literą X. Plik danych nosi nazwę exam .d ata; możesz założyć, że wszystkie umieszczone w nim dane są prawidłowe. Oto przykładowy wiersz danych: 4325 BECDCBAXDEBACCAEDXBE
40
ROZDZIAŁ 1. ■ SORTOWANIE, PRZESZUKIWANIE I SCALANIE
Test zdaje nie więcej niż sto osób. Wiersz, w którym identyfikator osoby zdającej ma wartość 0, oznacza koniec zbioru danych. Punkty za odpowiedzi są przyznawane w następujący sposób: za dobrą odpowiedź zdający otrzymuje 4 punkty, za złą 1 punkt, za brak odpowiedzi — 0 punktów. Napisz program, który będzie przetwarzał dane wejściowe z pliku i wyświetlał raport zawierający numer osoby zdającej oraz liczbę punktów uzyskanych z testu. Wyniki mają być posortowane na podstawie identyfikatorów, w kolejności rosnącej. Na samym końcu wyświetl średnią liczbę punktów wszystkich osób. 8. Ajest tablicą posortowaną w kolejności malejącej. B jest tablicą posortowaną w kolejności malejącej. Scal tablice A i B w tablicy C w taki sposób, by jej zawartość także była posortowana w kolejności malejącej. 9. Ajest tablicą posortowaną w kolejności malejącej. B jest tablicą posortowaną w kolejności malejącej. Scal tablice A i B w tablicy C w taki sposób, by jej zawartość była posortowana w kolejności rosnącej. 10. Ajest tablicą posortowaną w kolejności rosnącej. B jest tablicą posortowaną w kolejności malejącej. Scal tablice A i B w tablicy C, w taki sposób, by jej zawartość była posortowana w kolejności rosnącej. 11. Tablica A zawiera liczby całkowite, których wartości najpierw rosną, a następnie maleją; oto przykład: 17
24
31
39
44
49
36
29
20
18
13
0
1
2
3
4
5
6
7
8
9
75
Miejsce, w którym liczby zaczynają maleć, nie jest znane. Napisz wydajny kod, który skopiuje liczby z tablicy A do tablicy B w taki sposób, by zawartość B była posortowana rosnąco. Kod musi korzystać ze sposobu zapisu liczb w tablicy A. 12.
Dwa słowa są anagramami, jeśli jedno z nich można uzyskać, zmieniając kolejność liter drugiego; oto przykład: bandzior i zbrodnia. Napisz program, który będzie wczytywał dwa słowa i sprawdzał, czy są one anagramami. Napisz kolejny program, który będzie wczytywał listę słów i odnajdywał w niej zbiory słów będących anagramami.
41
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
42
ROZDZIAŁ
2
Wprowadzenie do obiektów
W tym rozdziale wyjaśnione zostaną takie zagadnienia jak: • pojęcia klasy, obiektu, pola oraz metody, • zmienne obiektowe oraz to, dlaczego nie zawierają one obiektu, lecz wskaźnik (referencję) do miejsca, w którym obiekt jest przechowywany, • różnice pomiędzy zmienną klasową (nazywaną także zmienną statyczną) oraz zmienną instancyjną (nazywaną także niestatyczną), • różnice pomiędzy metodą klasową (nazywaną także metodą statyczną) oraz metodą instancji (nazywaną także metodą niestatyczną), • znaczenie modyfikatorów dostępu publ ic, private oraz protected, • ukrywanie informacji, • odwołania do zmiennych klasowych i instancyjnych, • sposoby inicjalizacji zmiennych klasowych i instancyjnych, • konstruktory oraz sposoby ich implementacji, • przeciążanie metod, • hermetyzacja danych, • sposoby pisania metod akcesorów i mutatorów, • różne sposoby wyświetlania danych obiektów, • metoda to S trin g () oraz to, dlaczego ma w języku Java specjalne znaczenie, • to, co się stanie po przypisaniu jednej zmiennej obiektowej do drugiej, • porównywanie dwóch zmiennych obiektowych, • sposoby porównywania zawartości dwóch obiektów, • wykorzystanie obiektów do zwracania z funkcji więcej niż jednej wartości.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
2.1. Obiekty Java jest uznawana za język obiektowy. Projektanci stworzyli go w taki sposób, że obiekty znalazły się w centrum jego uwagi. Programy pisane w Javie tworzą obiekty i operują na nich, starając się odwzorować działania wykonywane w świecie rzeczywistym. Na nasze potrzeby przyjmiemy, że obiekt to pewna jednostka zawierająca stan oraz m etody służące do jego modyfikowania. Stan obiektu jest określany przez jego atrybuty. Za przykład obiektu możemy wziąć osobę. Osoba ma atrybuty, takie jak imię i nazwisko, wiek, płeć, wzrost, kolor włosów, kolor oczu itd. W programie każdy z takich atrybutów jest reprezentowany przez odpowiednią zmienną; np. zmienna typu S tring może reprezentować imię i nazwisko, zmienna typu int — wiek, zmienna typu char — płeć, zmienna typu doubl e — wzrost itd. Zmienne te są zazwyczaj określane jako pola (lub nazwy pól). A zatem stan obiektu jest definiowany przez wartości jego pól. Dodatkowo potrzebujemy także metod, które będą ustawiać i modyfikować wartości tych pól oraz zwracać je. Gdyby np. interesował nas wzrost osoby, potrzebowalibyśmy metody, która „zajrzy do wnętrza” obiektu i zwróci wartość pola reprezentującego wzrost. Innym, często stosowanym przykładem obiektu jest samochód. Dysponuje on takimi atrybutami jak producent, model, liczba miejsc, pojemność baku, aktualna ilość paliwa w baku, liczba przejechanych kilometrów, rodzaj wyposażenia muzycznego czy też szybkość. Z kolei obiekt reprezentujący książkę będzie miał takie atrybuty jak imię i nazwisko autora, tytuł, cena, liczba stron, rodzaj okładki (twarda, miękka, bindowanie) oraz czy jest dostępna w magazynie. Osoba, samochód oraz książka są przykładami konkretnych obiektów. W arto jednak także zauważyć, że obiekty mogą reprezentować pojęcia abstrakcyjne, takie jak dział firmy lub wydział na uniwersytecie. W poprzednim przykładzie nie mówiliśmy o kon kretnej osobie, a raczej o pewnej ogólnej kategorii „osób” takiej, że wszystkie należące do niej osoby dysponują tymi samymi, podanymi atrybutami. (Dokładnie to samo dotyczy samochodu i książki). W terminologii języka Java „osoba” jest klasą. Klasę możemy sobie wyobrazić jako ogólną kategorię (szablon), na podstawie której są tworzone poszczególne obiekty. Z kolei obiekty są instancjami klas; w naszym przykładzie obiekt Person reprezentowałby konkretną osobę. Aby posługiwać się dwoma takimi obiektami, musielibyśmy utworzyć je, bazując na definicji klasy Person. Każdy z tych obiektów posiadałby własny zestaw zmiennych — pól (nazywanych także zmiennymi instancyjnymi), a ich wartości w każdym z obiektów mogłyby być różne.
2.2. Definiowanie klas i tworzenie obiektów Najprostszy program napisany w Javie zawiera jedną klasę. W tej klasie piszemy jedną lub kilka metod (funkcji) służących do wykonywania operacji. Za przykład może posłużyć przedstawiony poniżej program P2.1. Program P2.1 //program prosi o podanie dwóch liczb i wyświetla ich sumę import ja v a .u t i l . * ; public c la ss Sum { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); System .ou t.printf("P odaj pierwszą lic z b ę : " ) ; int a = in .n e x tIn t(); System .ou t.printf("P odaj drugą lic z b ę : " ) ; int b = in .n e x tIn t(); System .out.printf("% d + %d = %d\n", a, b, a + b ); } //koniec main } //koniec klasy Sum Program składa się z jednej klasy (Sum) i jednej metody (mai n), zdefiniowanej wewnątrz tej klasy. Klasa pełni jedynie rolę szkieletu, w którym można umieścić logikę działania programu. Teraz zobaczymy, w jaki sposób można używać klasy do tworzenia obiektów.
44
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
W Javie każdy obiekt należy do jakiejś klasy i można go utworzyć, wyłącznie posługując się jej definicją. Załóżmy, że dysponujemy definicją klasy Book, której fragment został przedstawiony poniżej. publ ic c la ss Book { private s t a tic double Discount = 0 .2 5 ; private s t a tic int MinBooks = 5; private private private private private private
String author; String t i t l e ; double p rice; int pages; char binding; boolean inStock;
// zmienna // zmienna // zmienna // zmienna // zmienna / / zmienna
//zmienna klasowa //zmienna klasowa
instancyjna instancyjna instancyjna instancyjna instancyjna instancyjna
// tutaj znajdują się metody operujące na książce } //koniec klasy Book Oto, co zawiera nagłówek klasy (pierwszy wiersz kodu). • Opcjonalny modyfikator dostępu; w przedstawionym przykładzie użyty został modyfikator publi c, który będzie także stosowany w przeważającej większości klas wykorzystywanych dalej w tej książce. Zasadniczo oznacza to, że danej klasy mogą używać wszystkie inne klasy oraz że można ją rozszerzać, by tworzyć klasy pochodne. Przykładami innych modyfikatorów dostępu są a b stra ct oraz f in a l, które zostały przedstawione dalej. • Słowo kluczowe cl ass. • Identyfikator nadany klasie przez jej autora; w tym przypadku jest to Book. Wewnątrz pary nawiasów klamrowych jest umieszczone ciało klasy. Ogólnie rzecz biorąc, będzie ono zawierało deklaracje składników klasy. Oto one. • Zmienne statyczne (zmienne klasowe); cała klasa zawiera jedną kopię takiej zmiennej — będą z niej korzystały wszystkie obiekty danej klasy. Zmienne klasowe są deklarowane przy użyciu słowa kluczowego s ta tic . Jeśli zostanie pominięte, będzie to oznaczało, że dana zmienna jest zmienną instancyjną. • Zmienne niestatyczne (instancyjne); każdy obiekt posiada własną kopię takiej zmiennej. To właśnie one zawierają dane obiektu. • Metody statyczne (klasowe); są wczytywane tylko jeden raz — podczas wczytywania klasy — i można ich używać bez konieczności tworzenia obiektów danej klasy. Odwoływanie się w metodach statycznych do zmiennych instancyjnych (należących do obiektów) nie ma sensu, dlatego też w języku Java takie rozwiązania są zabronione. • M etody niestatyczne (instancyjne), które mogą być wywoływane w yłączn ie za pośrednictwem obiektów danej klasy. To właśnie metody instancyjne wykonują wszystkie operacje na danych (polach niestatycznych) obiektów. • W języku Java klasa String jest predefiniowana. Jeśli zmienna word jest klasy S tr i ng (a w zasadzie tak jest, jeśli zawiera obiekt klasy String) i jeśli napiszemy word.toLowerCase(), żądamy tym samym, by metoda instancyjna toLowerCase klasy String została wykonana na rzecz obiektu String, a konkretnie obiektu word. Metoda ta zamienia wszystkie wielkie litery na małe w łańcuchu, na rzecz którego została wywołana. • I podobnie, jeśli in jest obiektem klasy Scanner (utworzonym przy użyciu wyrażenia new Scanner...), to wyrażenie in .n e x tIn t() wywołuje metodę instancyjną nextInt na rzecz obiektu in; w tym przypadku metoda ta odczytuje następną liczbę całkowitą ze strumienia wejściowego skojarzonego ze zmienną in. Przedstawiona wcześniej klasa Book zawiera deklaracje dwóch zmiennych klasowych (Discount oraz MinBooks, zadeklarowanych z użyciem słowa kluczowego s t a tic ) oraz sześciu zmiennych instancyjnych; domyślnie są one zmiennymi instancyjnym i (gdyż słowo kluczowe s ta ti c zostało w nich pominięte).
45
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
2.2.1. Odwołania do zmiennych klasowych i instancyjnych W deklaracjach pól, oprócz słowa kluczowego s t a tic , mogą także występować opcjonalne modyfikatory dostępu, takie jak private, publ ic lub protected. W naszej przykładowej klasie Book wszystkie zmienne instancyjne zostały zadeklarowane z użyciem słowa kluczowego private. Oznacza ono, że konkretna zmienna jest „znana” wyłącznie wewnątrz danej klasy, a bezpośrednie operacje na niej mogą być wykonywane wyłącznie przez metody tej klasy. Innymi słowy, żadne metody spoza klasy nie mają bezpośredniego dostępu do zmiennych prywatnych (zadeklarowanych z użyciem słowa kluczowego private). Niemniej jednak, jak się wkrótce przekonamy, nic nie stoi na przeszkodzie, by udostępnić metody publiczne (zadeklarowane z użyciem słowa kluczowego public), z których inne klasy mogłyby korzystać w celu uzyskiwania dostępu do zmiennych prywatnych. W ten sposób możemy zapewnić, że dane klasy mogą być modyfikowane wyłącznie przez metody tej klasy. Zadeklarowanie zmiennej jako publicznej oznacza, że można się do niej odwołać bezpośrednio spoza klasy. Innymi słowy, inne klasy mogą robić, co im się będzie podobało. Gdyby np. zmienna Discount została zadeklarowana jako publiczna, dowolna inna klasa mogłaby się do niej odwołać i zmienić ją przy użyciu wyrażenia Book.Discount. Takie rozwiązania nie są zazwyczaj zalecane, gdyż sprawiają, że klasa traci kontrolę nad swoimi danymi. W większości przypadków pola klas będziemy deklarować jako prywatne. Takie postępowanie jest pierwszym krokiem na drodze do zastosowania idei ukryw ania inform acji, będącej jednym z kluczowych elementów filozofii programowania obiektowego. Zaleca ona, by użytkownicy obiektów nie mieli możliwości bezpośredniego operowania na ich danych; powinni to robić wyłącznie przy użyciu ich metod. Zadeklarowanie zmiennej jako chronionej (z wykorzystaniem słowa kluczowego protected) oznacza, że można się do niej odwoływać bezpośrednio wewnątrz danej klasy, wszystkich jej klas pochodnych oraz wszystkich klasy należących do tego samego pakietu. W tym wprowadzeniu nie będziemy używali zmiennych chronionych. Jeśli w deklaracji zmiennej nie zostanie wykorzystany żaden modyfikator dostępu, bezpośredni dostęp do niej będą miały wyłącznie klasy należące do tego samego pakietu. Metoda zadeklarowana w ew nątrz klasy może się odwoływać do dow olnej zmiennej (statycznej lub nie, publicznej lub prywatnej) za pomocą samej nazwy. (Jedynym wyjątkiem są metody statyczne, w których nie można odwoływać się do zmiennych instancyjnych). Jeśli zmienna statyczna jest dostępna poza klasą (czyli, jeśli nie jest prywatna), można się do niej odwołać, poprzedzając jej nazwę nazwą klasy np. tak: Book. Discount lub Book.MinBooks. Dostęp do zmiennej instancyjnej (o ile nie jest ona prywatna) można uzyskać, poza kodem klasy, wyłącznie za pośrednictwem obiektu, do którego ona należy; więcej informacji na ten temat można znaleźć w następnym punkcie rozdziału. Niemniej jednak, jak już napisano wcześniej, dobre praktyki programistyczne zalecają, by w większości przypadków zmienne były deklarowane jako prywatne; dlatego też bezpośrednie odwołania do zmiennych pojawiają się w kodzie bardzo rzadko.
2.2.2. Inicjalizacja zmiennych klasowych i instancyjnych W momencie wczytywania klasy Book rezerwowana jest pamięć dla zmiennych klasowych Discount oraz MinBooks, a następnie zostają im odpowiednio przypisane wartości 0.25 oraz 5. Zmienne te oraz ich wartości oznaczają, że w przypadku zakupu pięciu lub większej liczby egzemplarzy książki kupujący otrzymuje rabat wysokości 25%. Ponieważ wartości te dotyczą wszystkich książek, zapisywanie ich w danych każdej z książek byłoby marnowaniem miejsca, dlatego też zostały one zadeklarowane jako zmienne statyczne (w ich deklaracjach zostało użyte słowo kluczowe s ta tic ). Wszystkie obiekty książek będą miały dostęp do jednej kopii każdej z tych zmiennych. (Trzeba jednak zwrócić uwagę, że jeśli chcielibyśmy zmieniać te wartości dla poszczególnych książek, stałyby się one atrybutami konkretnej książki, więc musiałyby zostać zadeklarowane jako zmienne instancyjne). Podczas wczytywania klasy nie jest rezerwowana żadna pamięć dla zmiennych instancyjnych (niestatycznych). W tym momencie dysponujemy jedynie ich specyfikacją, jednak żadna z nich jeszcze nie istnieje. Pojawią się dopiero po utworzeniu obiektu danej klasy. Dane obiektu są określane przez zmienne
46
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
instancyjne. W czasie „tworzenia” obiektu przydzielana jest pamięć dla wszystkich zmiennych instancyjnych zdefiniowanych w klasie; każdy utworzony obiekt posiada w łasną k op ię wszystkich zmiennych instancyjnych. Obiekty tworzone są przy użyciu słowa kluczowego new; poniżej przedstawiono przykład jego zastosowania: Book b; b = new Book(); Pierwsza instrukcja deklaruje b jako zmienną typu Book. Na jej podstawie widzimy, że nazwa klasy jest traktowana jako typ (taki jak int lub char) i może być używana w deklaracjach zmiennych. Mówimy, że b jest zm ien ną obiektow ą typu Book. Taka deklaracja zmiennej nie pow od u je jednak utworzenia obiektu — tworzy jedynie zmienną, której wartością może być w skaźnik na obiekt podanego typu. W przypadku użycia takiej deklaracji wartość zmiennej obiektowej nie jest określona. Druga instrukcja znajduje wolny fragment pamięci, w którym może zostać umieszczony obiekt Book, tworzy go, a następnie zapisuje w zmiennej b jego adres. (Ten adres można sobie wyobrazić jako pierwszą komórkę pamięci zajętą przez obiekt; jeśli obiekt zajmuje kom órki od 2575 do 2599, jego adresem będzie 2575). Mówimy, że b zawiera referencję lub w skaźnik do obiektu. A zatem wartością zmiennej obiektowej jest adres obszaru p a m ięci zajmowanego przez obiekt, a nie sam obiekt, co widać na rysunku 2.1.
Rysunek 2.1. Instan cja o b iek t B ook Aby nieco skrócić kod, możemy w jednej instrukcji zadeklarować zmienną b i utworzyć obiekt. Oto przykład takiej instrukcji: Book b = new Book(); Częstym błędem jest wyobrażanie sobie, że zmienna b może zawierać obiekt Book. Jednak nie jest to możliwe — zmienna ta może zawierać jedynie referen cję do obiektu Book. (Analogicznie, powinniśmy wiedzieć już, że zmienna typu String nie zawiera samego łańcucha znaków, a jedynie adres miejsca w pamięci, gdzie jest on zapisany). Jednak w przypadkach, gdy rozróżnienie to nie ma większego znaczenia, będziemy mówić, że zmienna b zawiera obiekt Book. Po utworzeniu obiektu b możemy odwoływać się do jego pól w następujący sposób: b.author b.pages
b .t it le b.binding
b .p rice b.inStock
Takie odwołanie będzie można zastosować p o z a kodem klasy wyłącznie w przypadku, gdy pole będzie zadeklarowane jako publiczne. Dalej w tym rozdziale dowiemy się, jak — w pośredni sposób — można odwoływać się do pól prywatnych. Podczas tworzenia obiektu jego pola instancyjne są inicjalizowane w następujący sposób (chyba że jawnie zażądaliśmy użycia innych wartości): •
wszystkim polom liczbowym jest przypisywana wartość 0;
•
w polach znakowych zapisywana jest wartość '\0' (a konkretnie rzecz biorąc, znak Unicode '\u0000');
•
w polach logicznych zostaje zapisana wartość fa lse ;
•
we wszystkich polach obiektowych zapisywana jest wartość n u ll. (Jeśli zmienna obiektowa zawiera n u ll, oznacza to, że nie wskazuje na żaden obiekt).
47
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
• W naszym przypadku pola obiektu b zostaną zainicjalizowane w następujący sposób: • w polu b.author (typu S tr i ng) zostanie zapisana wartość n u ll; trzeba pamiętać, że String jest typem obiektowym; • w polu b . t i t l e (typu String) zostanie zapisana wartość n u ll; • w polu b.p ri ce (typu double) zostanie zapisana wartość 0.0; • w polu b.pages (typu int) zostanie zapisana wartość 0; • w polu b.binding (typu char) zostanie zapisany znak '\ 0'; • w polu b .i nStock (typu boolean) zostanie zapisana wartość false . W artości początkowe można także określać w deklaracjach zmiennych instancyjnych. Przeanalizujmy następujący kod: public c la ss Book { private s t a t ic double Discount = 0.25; private s t a t ic int MinBooks= 5; private String author="Brak autora"; private String title ; private double p rice; private int pages; private char binding = 'M'; // miękka okładka private boolean inStock = tru e; } Teraz, po utworzeniu obiektu w polach author, binding oraz i nStock zostaną zapisane podane wartości, natomiast w polach t i t l e , price oraz pages — wartości domyślne. Zmiennej jest przypisywana wartość domyślna wyłącznie w przypadku, gdy nie zostanie jej przypisana żadna jawnie określona wartość. Załóżmy, że utworzymy obiekt b, używając następującej instrukcji: Book b = new Book(); W takim przypadku pola tego obiektu zostaną zainicjalizowane w taki sposób: • w polu author zostanie zapisany łańcuch znaków "Brak autora", podany w deklaracji, • w polu t i t l e zostanie zapisana wartość null, domyślna dla typu S tri ng, • w polu price zostanie zapisana wartość 0.0, domyślna dla typu liczbowego, • w polu pages zostanie zapisana wartość 0, domyślna dla typu liczbowego, •
w polu bi ndi ng zostanie zapisana wartość 'M', podana w deklaracji,
• w polu inStock zostanie zapisana wartość true, podana w deklaracji.
2.3. Konstruktory Konstruktory zapewniają bardziej elastyczne sposoby inicjalizacji stanu tworzonych obiektów. W poniższej instrukcji fragment Book() jest nazywany konstruktorem: Book b = new Book(); Przypomina on nieco wywołanie metody, ale nie napisaliśmy takiej metody w deklaracji klasy. To prawda, jednak język Java udostępnia w takich przypadkach konstruktor domyślny — jest to konstruktor, który nie ma żadnych argumentów (dlatego też jest nazywany konstruktorem bezargumentowym). Konstruktor ten jest bardzo prosty — przypisuje jedynie zmiennym instancyjnym ich domyślne, początkowe wartości. Później możemy zapisać w polach obiektu bardziej interesujące dane, tak jak pokazano na poniższym przykładzie:
48
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
b.author = "Noel K alicharan"; b . t i t l e = "DigitalM ath"; b .p rice = 29.95; b.pages = 200; b.binding = 'M'; //miękka okładka b.inStock = tru e; //książka dostępna w magazynie Załóżmy teraz, że chcemy, by podczas tworzenia nowego obiektu książki język Java automatycznie zapisał w nim określonego autora i tytuł. Chcielibyśmy mieć możliwość tworzenia nowych obiektów Book w sposób przedstawiony na poniższym przykładzie: Book b = new Book("Noel Kalicharan", "D igitalM ath"); Możemy to zrobić, lecz najpierw musimy napisać odpowiedni konstruktor — definiujący dwa parametry typu String. Oto sposób, w jaki możemy to zrobić. public Book(String a, S trin g t) { author = a; title = t; } Poniżej przedstawiono kilka ważnych zagadnień, o których należy pamiętać. • Konstruktor ma taką sa m ą nazw ę jak klasa, w której jest umieszczony. Nasza klasa nosi nazwę Book, więc także konstruktor będzie miał nazwę Book. Ponieważ konstruktor ma być używany przez inne klasy, zatem w jego deklaracji użyliśmy słowa kluczowego public. • Konstruktor może nie mieć żadnych parametrów lub mieć ich kilka. W momencie wywoływania konstruktora należy w nim podać odpowiednią liczbę argumentów odpowiednich typów. W powyższym przykładzie, w deklaracji konstruktora zostały podane dwa parametry typu S tri ng, o nazwach a i t. A zatem w wywołaniu konstruktora trzeba podać dwa argumenty typu S tr i ng. • Ciało konstruktora zawiera kod, który zostanie wykonany w czasie jego wywołania. Przedstawiony wcześniej przykładowy konstruktor przypisuje zmiennej instancyjnej author wartość pierwszego argumentu, a zmiennej t i t l e — wartość drugiego argumentu. Ogólnie rzecz biorąc, w konstruktorze można umieszczać także inne instrukcje, nie tylko przypisania określające wartości zmiennych instancyjnych tworzonego obiektu. Przykładowo przed zapisaniem przekazanej wartości w polu można ją sprawdzić. Przykłady takich konstruktorów pokazano w następnym punkcie rozdziału. • Konstruktor nie ma typu wynikowego, nawet null. • Jeśli w deklaracji klasy zostały podane początkowe wartości zmiennych instancyjnych, zostaną one przypisane polom jeszcze p rz ed w yw ołaniem konstruktora. Załóżmy np., że klasa Book została zadeklarowana w następujący sposób. public c la ss Book { private s t a tic double Discount = 0.25; private s t a tic int MinBooks= 5; private String author="Brak autora"; private String title ; private double p rice; private int pages; private char binding = 'M'; // miękka okładka private boolean inStock = tru e; public Book(String a, S trin g t) { author = a; title = t; } } //koniec klasy Book
49
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Instrukcja Book b = new Book("Noel Kalicharan", "D igitalM ath"); zostanie wykonana w następujący sposób. 1. Najpierw zostanie znaleziony obszar pamięci dla obiektu Book, a jego adres zapisany w zmiennej b. 2. W polach nowego obiektu zostaną zapisane następujące wartości: w polu w polu w polu w polu w polu w polu
author łańcuch "Brak autora"; t i t l e wartość n u ll; price wartość 0 .0 ; pages wartość 0; binding wartość 'M'; inStock wartość tru e.
/ / określony w d ek laracji // domyślna wartość obiektów typu String // domyślna wartość typów numerycznych // domyślna wartość typów numerycznych // określona w deklaracji // określona w deklaracji
3. W końcu zostanie wywołany konstruktor, a w nim podane argumenty "Noel Kalicharan" oraz "DigitalM ath". Spowoduje to zapisanie w polu author łańcucha "Noel Kali charan", a w polu t i t l e — łańcucha "D igital Math"; przy czym wartości pozostałych pól nie ulegną zmianom. Po zakończeniu wywołania konstruktora pola nowego obiektu będą miały następujące wartości. author title price pages binding inStock
"Noel Kalicharan "DigitalMath" 0.0 0 'P ' true
2.3.1. Przeciążanie konstruktorów Java pozwala na tworzenie w jednej klasie kilku konstruktorów, pod warunkiem że każdy z nich będzie miał inną sygnaturę. Rozwiązanie, w którym kilka konstruktorów może mieć taką samą nazwę, nazywamy przeciążaniem konstruktorów. Załóżmy, że zależy nam na możliwości stosowania zarówno konstruktora bezargumentowego, jak i konstruktora posiadającego argumenty określające autora oraz tytuł. Poniżej pokazano, w jaki sposób takie konstruktory można dodać do deklaracji klasy. public c la ss Book { private s t a t ic double Discount = 0.25; private s t a t ic int MinBooks = 5; private private private private private private
String author ="Brak autora"; String t i t l e ; double p rice; int pages; char binding = 'M'; // miękka okładka boolean inStock = tru e;
public Book() { } public Book(String a, S trin g t) { author = a; title = t; } } //koniec klasy Book W arto zwrócić uwagę, że ciałem konstruktora bezargumentowego jest pusty blok kodu. W efekcie wykonania poniższej instrukcji zmiennym instancyjnym przypisywane są ich wartości początkowe (domyślne bądź określone) i zostaje wykonany konstruktor. W tym przypadku nie dzieje się nic więcej. Book b = new Book();
50
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Koniecznie trzeba zapamiętać, że w przypadku podania jakiegoś konstruktora w kodzie klasy dom yślny konstruktor bezargumentowy przestaje być dostępny. Jeśli chcemy, by nasza klasa dysponowała konstruktorem bezargumentowym, musimy go sami napisać, tak jak na powyższym przykładzie. Oczywiście, w jego ciele możemy umieścić dowolny kod, w tym także pusty blok kodu. W ramach ostatniego przykładu przedstawiono konstruktor, który pozwala na jawne określenie wartości wszystkich pól tworzonego obiektu. Oto jego kod. public Book(String a, S trin g t , double p, int g, char b, boolean s) { author = a; title = t; p rice = p; pages = g; binding = b; inStock = s; } Jeśli b jest zmienną typu Book, wywołanie tego konstruktora będzie mieć następującą postać: b = new Book("Noel K alicharan", "DigitalM ath", 29.95, 200, 'P ', tru e ); Spowoduje ono przypisanie polom utworzonego obiektu następujących wartości: author title p rice pages binding inStock
"Noel Kalicharan" "DigitalMath" 29.95 200 'P ' true
2.4. Hermetyzacja danych, metody akcesorów i mutatorów W dalszych rozważaniach będziemy używali terminu klasa użytkownika w odniesieniu do klasy, której metody muszą odwoływać się do pól i metod innych klas. Kiedy pole klasy zostanie zadeklarowane jako publiczne, dowolna inna klasa może odwoływać się do niego bezpośrednio, przy użyciu jego nazwy. Załóżmy, że dysponujemy następującą klasą: public c la ss Part { public s t a t ic int NumParts = 0; public String name; public double p rice;
// zmienna klasowa // zmienna instancyjna // zmienna instancyjna
} W powyższej klasie zadeklarowaliśmy jedną zmienną statyczną (klasową) oraz dwie zmienne instancyjne, przy czym wszystkie zostały zadeklarowane jako publiczne. D ow olna klasa użytkownika może odwołać się do zmiennej statycznej, używając przy tym wyrażenia w postaci Part.NumParts; może też używać instrukcji takich jak ta: Part.NumParts = 25; Jednak takie rozwiązania mogą być niepożądane. Załóżmy, że zmienna NumPartssłuży do zliczania ilości utworzonych obiektów klasy Part. Przy obecnej postaci klasy Part dowolny kod spoza niej może przypisać tej zmiennej dowolną wartość. Oznacza to, że jej twórca nie może zagwarantować, iż jej wartość faktycznie będzie odpowiadać liczbie utworzonych obiektów. Z kolei do zmiennych instancyjnych można się odwoływać wyłącznie za pośrednictwem obiektów. Jeśli klasa użytkownika utworzy obiekt p typu Part, będzie można użyć w niej wyrażenia p.pri ce (lub p.name), by odwołać się bezpośrednio do zmiennej instancyjnej i odczytać jej wartość, bądź — jeśli będzie trzeba — zmienić ją,
51
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
korzystając z prostej instrukcji przypisania. Nic nie jest w stanie powstrzymać klasy użytkownika przed przypisaniem takiej zmiennej dowolnej, nawet zupełnie bezsensownej wartości. Załóżmy np., że wszystkie ceny m ają wartości z zakresu od 0,0 do 99,99. Jednak klasa użytkownika może zawierać następujący kod, który narusza integralność danych w obiekcie Part: p .p rice = 199.99; Aby rozwiązać ten problem, musimy zadeklarować pole danych jako pole prywatne, czyli musimy ukryć dane. Następnie wystarczy udostępnić publiczne metody służące do odczytywania i ustawiania wartości poszczególnych pól. Prywatne dane oraz publiczne metody są kluczowym elementem hermetyzacji danych. Metody, które ustawiają bądź zmieniają wartości pól, są nazywane m utatoram i; z kolei metody pozwalające na odczyt wartości pól to akcesory. Przekonajmy się teraz, w jaki sposób można rozwiązać dwa przedstawione wcześniej problemy. Na początek zmienimy pola na prywatne: public c la ss private private private
Part { s t a tic int NumParts = 0; / / zmienna klasowa String name; / / zmienna instancyjna double p rice; // zmienna instancyjna
} Teraz, gdy pola zostały zadeklarowane jako prywatne, żadna inna klasa nie może się do nich odwoływać. Jeśli chcemy, by zmienna NumParts określała liczbę utworzonych obiektów klasy Part, będziemy musieli inkrementować jej wartość za każdym razem, gdy zostanie wywołany konstruktor tej klasy. Moglibyśmy w tym celu np. zmodyfikować konstruktor bezargumentowy: public P art() { name = "BRAK CZĘŚCI"; price = - 1 .0 ; // używamy wartości -1, gdyż 0 może być prawidłową ceną NumParts++; } Za każdym razem gdy klasa użytkownika wykona poniższą instrukcję, zostanie utworzony nowy obiekt Part, a wartość zmiennej NumParts zostanie powiększona o 1: Part p = new P a rt(); W ten sposób wartość zmiennej NumParts zawsze będzie odpowiadać liczbie utworzonych obiektów klasy Part. Co więcej, ponieważ jest to jedyn y sposób modyfikacji tej zmiennej, twórca klasy może zagwarantować, że jej wartość zawsze będzie odpowiadać liczbie utworzonych obiektów. Oczywiście, w dowolnej chwili może się pojawić konieczność odczytania przez klasę użytkownika wartości zmiennej NumParts. Ponieważ nie istnieje już możliwość bezpośredniego odwołania się do tej zmiennej, zatem musimy udostępnić publiczną m etodę akcesora (nadamy jej np. nazwę GetNumParts, rozpoczynając ją wielką literą, gdyż stanowi to szybki sposób rozróżnienia metody statycznej od instancyjnej), która zwróci jej wartość. Poniżej przedstawiony został kod tej metody. public s t a t i c int GetNumParts() { return NumParts; } Metoda została zadeklarowana jako statyczna (jej deklaracja zawiera słowo kluczowe s ta ti c), gdyż operuje wyłącznie na zmiennej statycznej, a do jej wywołania nie trzeba używać obiektu klasy Part. Metodę tę można wywołać, pisząc Part.GetNumParts(). Java pozwala także na stosowanie wywołań w postaci p.GetNumParts(), przy założeniu, że p jest obiektem klasy Part. Niemniej jednak taka postać wywołania sugeruje, że GetNumParts jest metodą instancyjną (czyli taką, którą trzeba wywoływać za pośrednictwem obiektu i która operuje na jego zmiennych instancyjnych), co może być nieco mylące. Trzeba zatem zadbać o to, by metody klasowe (statyczne) były wywoływane przy użyciu nazwy klasy, a nie za pośrednictwem obiektów. W ramach ćwiczenia można dodać do klasy Book pole zliczające ilość utworzonych obiektów tej klasy i zmienić jej konstruktora, tak by modyfikować wartość tego pola.
52
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
2.4.1. Poprawiony konstruktor Zamiast konstruktora bezargumentowego moglibyśmy napisać konstruktor o nieco bardziej realistycznej postaci, pozwalający na określenie nazwy oraz ceny części, dzięki czemu obiekty Part byłyby tworzone w następujący sposób: Part a f = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Konstruktor klasy Part mógłby mieć następującą postać: public P art(Strin g n, double p) { name = n; price = p; NumParts++; } Taki konstruktor będzie działać, choć wciąż pozwoli na przypisywanie cenie części nieprawidłowych wartości. Nic nie powstrzymuje użytkownika przed wykonaniem następującej instrukcji: Part a f = new P a r t ( " F ilt r powietrza", 199.99); To wywołanie posłusznie przypisze zmiennej price nieprawidłową wartość 199.99. Jednak w konstruktorze można wykonywać dowolne operacje, a nie ograniczać się jedynie do określania wartości zmiennych instancyjnych. Możemy zatem sprawdzać wartości i — jeśli trzeba — odrzucać je. W tym przykładzie przyjmiemy podejście, że w przypadku podania nieprawidłowej wartości obiekt zostanie utworzony, lecz w polu ceny zapiszemy wartość - 1 .0 , a dodatkowo wyświetlimy użytkownikowi stosowny komunikat. Poniżej przedstawiona została nowa wersja konstruktora. public P art(Strin g n, double p) { name = n; i f (p < 0.0 || p > 99.99) { System .ou t.p rin tf("C zęść: %s\n", name); System.out.printf("Nieprawidiowa cena: % 3.2f. Używamy wartości -1 .0 .\ n ", p); price = - 1 .0 ; } e lse p rice = p; NumParts++; } //koniec konstruktora Part Aby pozostać w zgodzie z dobrymi praktykami programistycznymi, dopuszczalny zakres cen (0.0 oraz 99.0) oraz cenę „pustą” (-1 .0 ) powinniśmy zadeklarować jako stałe. Możemy to zrobić tak: private private private
s t a t ic s t a t ic s t a t ic
final double final double final double
MinPrice = 0 .0 ; MaxPrice = 99.99; NullPrice = - 1 .0 ;
Tych stałych możemy teraz użyć w konstruktorze.
2.4.2. Akcesory Ponieważ może się zdarzyć, że klasa użytkownika będzie musiała odczytać nazwę lub cenę części, zatem musimy udostępnić odpowiednie akcesory zwracające wartości pól name oraz price. Akcesor jest prostą metodą, która zwraca wartość konkretnego pola. Zwyczajowo nazwy akcesorów rozpoczynają się do słowa get. Oto akcesory, które możemy umieścić w naszej przykładowej klasie Part. public S trin g getName() { // akcesor return name; } public double g etP rice() { // akcesor return p rice; }
53
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Należy zwrócić uwagę, że typ wynikowy akcesora jest taki sam jak typ pola. Przykładowo typem akcesora getName() jest String, gdyż pole name jest tego typu. Ponieważ akcesor zwraca wartość pola instancyjnego, sensowne będzie wywoływanie go wyłącznie w połączeniu z konkretnym obiektem (ponieważ wartości pól instancyjnych w poszczególnych obiektach mogą być unikalne). Jeśli p będzie obiektem typu Part, wywołanie p.getName() zwróci wartość pola name obiektu p, a wywołanie p .g e tP rice () zwróci wartość pola price obiektu p. W ramach ćwiczenia warto napisać akcesory dla pól klasy Book. Przedstawione akcesory są przykładami metod instancyjnych (niestatycznych, bo w ich deklaracjach nie pojawiło się słowo kluczowe s ta tic ). Wiemy, że każdy obiekt posiada swoje własne kopie zmiennych instancyjnych zadeklarowanych w klasie. Jeśli jednak chodzi o metody, są one jedynie dostępne w poszczególnych obiektach. Zawsze istnieje tylko jedna kopia metody, która jest kojarzona z konkretnym obiektem, w momencie gdy zostaje wywołana. Zakładając, że obiekt p klasy Part jest zapisany w komórce pamięci o adresie 725, można zobrazować tę sytuację w sposób przedstawiony na rysunku 2.2.
Rysunek 2.2. O biekt P art w raz ze sw oim i p o la m i i akcesoram i Możemy sobie wyobrazić, że pola name oraz p rice są zamknięte wewnątrz pudełka, a świat zewnętrzny może poznać ich wartości wyłącznie przy użyciu metod getName oraz getPrice.
2.4.3. Mutatory Jako twórcy klasy musimy zdecydować, czy pozwolimy użytkownikom zmieniać nazwę oraz cenę części po utworzeniu obiektu. Rozsądne jest założenie, że po utworzeniu obiektu użytkownicy nie będą już chcieli zmieniać jego nazwy. Jednak ceny się zmieniają, dlatego też powinniśmy udostępnić metodę (lub metody) pozwalającą na wprowadzanie takich zmian. W ramach przykładu napiszemy pu bliczn ą m etod ę m utatora (o nazwie setP ri ce), którą użytkownik klasy może wywołać w taki sposób: p .s e tP ric e (2 4 .9 5 ); To wywołanie ustawia cenę przechowywaną w obiekcie p klasy Part na 24.95. Podobnie do przedstawionego wcześniej konstruktora, także ta metoda nie pozwoli na zastosowanie nieprawidłowej ceny. Metoda sprawdzi przekazaną wartość i w razie konieczności wyświetli stosowny komunikat. Oto kod metody setPri ce, w którym wykorzystaliśmy stałe podane w punkcie 2.4.1. public void setPrice(double p) { i f (p < MinPrice || p > MaxPrice) { System .ou t.p rin tf("C zęść: %s\n", name); System.out.printf("NieprawidTowa cena: %3.2f.Używamy wartości %3.2f\n", p, N ullP rice); p rice = N ullPrice; } e lse p rice = p; } //koniec setPrice Po dodaniu tej metody obiekt p klasy Part możemy sobie wyobrazić tak, jak pokazano na rysunku 2.3.
54
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
setPriceO Rysunek 2.3. O biekt P art p o dodan iu m etody setPrice() Warto zwrócić uwagę na kierunek strzałki przy metodzie setP rice; w jej przypadku wartość jest przesyłana spoza obiektu do jego pola prywatnego. Także w tym przypadku należy podkreślić przewagę rozwiązania polegającego na zadeklarowaniu pola p rice jako prywatnego i udostępnieniu publicznych akcesora i mutatora nad rozwiązaniem, w którym pole to byłoby publiczne i klasy użytkowników mogły się do niego odwoływać w sposób bezpośredni. Moglibyśmy także udostępnić metody pozwalające na powiększanie i zmniejszanie ceny o określoną kwotę lub o określoną wartość procentową. W arto je napisać samodzielnie w ramach ćwiczenia. W ramach kolejnego ćwiczenia można także napisać mutatory dla pól price oraz inStock klasy Book.
2.5. Wyświetlanie danych obiektów Aby sprawdzić, czy dla naszych części zostały zapisane odpowiednie wartości, będziemy potrzebowali jakiegoś sposobu wyświetlania zawartości pól obiektów.
2.5.1. Zastosowanie metod instancyjnych (preferowane rozwiązanie) Jednym ze sposobów rozwiązania tego problemu jest napisanie metody instancyjnej (np. printP art), która wywołana na rzecz obiektu wyświetli dane zapisane w jego polach. W celu wyświetlenia zawartości obiektu p zastosujemy zatem następujące wywołanie: p .p r in tP a r t(); Poniżej przedstawiono kod tej metody. public void p rin tP art() { System.out.printf("\nNazwa c z ę ś c i: %s\n", name); System .out.printf("C ena: $%3.2f\n", p ric e ); } //koniec printPart Załóżmy, że obiekt części utworzyliśmy przy użyciu wywołania: Part a f = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); W takim przypadku wywołanie a f.p rin tP a r t() wyświetli następujące wyniki: Nazwa c z ę ś c i: F i l t r powietrza Cena: 80,75 zł Gdy metoda p rintP art jest wywoływana na rzecz obiektu af, używane w niej referencje do pól name oraz p rice stają się referencjami do pól obiektu af. Przedstawiono to na rysunku 2.4.
55
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 2.4. R eferen cje n am e ora z p rice odw ołu ją się do p ó l obiektu a f
2.5.2. Zastosowanie metody statycznej Gdybyśmy chcieli, metodę printP art moglibyśmy także napisać jako metodę statyczną. W takim przypadku obiekt p, którego dane chcemy wyświetlić, byłby przekazywany do niej w formie argum entu. Poniżej przedstawiono przykładowy kod tej metody. public s t a t i c void printP art (Part p) { System.out.printf("\nNazwa c z ę ś c i: %s\n", p.name); System .out.printf("C ena: %3.2f zł\n", p .p ric e ); } //koniec printPart Nazwy pól używane w tej metodzie należy poprzedzić nazwą zmiennej p. Bez niej w metodzie statycznej odwoływalibyśmy się do pól instancyjnych, a taka sytuacja jest w języku Java zabroniona. Przy założeniu, że c jest obiektem Part utworzonym w klasie użytkownika, posługując się tą metodą, jego pola moglibyśmy wyświetlić tak: P a rt.p r in tP a rt(c ); Takie rozwiązanie jest nieco mniej wygodne niż przedstawione wcześniej wywoływanie metody instancyjnej. Dla porównania, można np. użyć wywołania C h aracter.isD igital(ch ), by skorzystać z metody isDigi tal standardowej klasy Character języka Java.
2.5.3. Zastosowanie metody toString() Metoda to Strin g zwraca łańcuch znaków i ma szczególne znaczenie w języku Java. Jeśli zastosujemy zmienną obiektową w kontekście wymagającym użycia łańcucha znaków, Java spróbuje wywołać metodę to S tri ng klasy, do której należy dany obiekt. Załóżmy np., że umieściliśmy w kodzie następującą instrukcję, przy czym p jest zmienną klasy Part: S ystem .ou t.p rin tf("% s", p); Ponieważ trudno określić, co ma oznaczać wyświetlenie dowolnego obiektu, pisząc kod w języku Java, będziemy szukać wskazówek w konkretnej klasie. Można tu założyć, że klasa będzie wiedzieć, jak mają być wyświetlane jej obiekty. Jeśli zatem klasa udostępni metodę to S tri ng, Java ją wywoła. (Jeśli jednak metoda nie będzie dostępna, Java wyświetli łańcuch w postaci ogólnej, zawierający nazwę klasy i adres obiektu, zapisany w postaci szesnastkowej, np. Part@72e15c32). W naszym przykładzie moglibyśmy dodać do klasy Part metodę toString w postaci: public S trin g to S trin g () { return "\nNazwa c z ę ś c i: " + name + "\nCena: " + price + " zł\n"; }
56
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Jeśli a f będzie częścią o nazwie „Filtr powietrza”, poniższa instrukcja spowoduje wywołanie metody a f.to S tr in g (): S ystem .ou t.p rin tf("% s", a f ) ; W rezultacie wywołanie metody p rin tf przyjmie postać: S ystem .ou t.p rin tf("% s", a f.t o S t r in g ( ) ) ; Z kolei wywołanie a f.to S trin g () zwróci następujący łańcuch znaków: "\nNazwa c z ę śc i: F i l t r powietrza\nCena: 80,75 zł\n" W efekcie wywołanie metody p rin tf spowoduje wyświetlenie poniższego fragmentu tekstu: Nazwa c z ę ś c i: F i l t r powietrza Cena: 80,75 zł
2.6. Klasa Part Kiedy uwzględnimy wszystkie przedstawione wcześniej zmiany, kod klasy Part przyjmie następującą postać. public c la ss Part //state private s t a t ic private s t a t ic private s t a t ic private s t a t ic
{ final double MinPrice = 0 .0 ; final double MaxPrice = 99.99; final double NullPrice = - 1 .0 ; int NumParts = 0; / / zmienna klasowa
private String name; private double p rice;
/ / zmienna instancyjna // zmienna instancyjna
public P art(Strin g n, double p) { // konstruktor name = n; i f (p < MinPrice || p > MaxPrice) { System .ou t.p rin tf("C zęść: %s\n", name); System.out.printf("NieprawidTowa cena: % 3.2f. Używamy wartości %3.2f\n", p, N u llP rice); p rice = N ullPrice; } e lse p rice = p; NumParts++; } //koniec konstruktora Part public s t a t i c int GetNumParts() { return NumParts; }
// akcesor
public String getName() { // akcesor return name; } public double g etP rice() { // akcesor return p rice; } public void setPrice(double p) { // mutator i f (p < MinPrice || p > MaxPrice) { System .ou t.p rin tf("C zęść: %s\n", name); System.out.printf("NieprawidTowa cena: % 3.2f. Używamy wartości %3.2f\n", p, N u llP rice);
57
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
price = N ullPrice; } e lse price = p; } //koniec setPrice public void p rin tP art() { System.out.printf("\nNazwa c z ę ś c i: %s\n", name); System .out.printf("C ena: %3.2f zí\n", p ric e ); } //koniec printPart public String to S trin g () { return "\nNazwa c z ę ś c i: " + name + "\nCena: " + price + " zí\n"; } } // koniec klasy Part
2.6.1. Testowanie klasy Part Klasę po napisaniu należy przetestować, aby upewnić się, że działa prawidłowo. W przypadku klasy Part musimy się upewnić, że konstruktor działa prawidłowo, czyli — innymi słowy — że akcesory zwracają prawidłowe wartości, a mutator prawidłowo ustawia (nową) cenę. Musimy się także upewnić, że klasa w odpowiedni sposób reaguje na nieprawidłowe ceny. W programie P2.2 tworzymy trzy obiekty Part (przy czym w jednym z nich została podana nieprawidłowa cena) i wyświetlamy informacje o ich nazwach i cenach. Następnie wyświetlamy informację o liczbie utworzonych obiektów klasy Part, wywołując w tym celu metodę GetNumParts. Zanim wykonamy ten program, powinniśmy określić oczekiwane wyniki, dzięki czemu będziemy mogli przew idzieć, jakie wyniki powinien zwrócić program działający prawidłowo. Jeśli wyniki odpowiadają naszym oczekiwaniom, to świetnie; jeśli nie, znaczy to, że gdzieś wkradł się błąd, który należy rozwiązać. Program P2.2 public c la ss PartTest { / / program testujący działanie klasy Part public s t a t ic void m ain(String[] args) { Part a, b, c; // deklarujemy 3 zmienne klasy Part // a b c
tworzymy 3 obiekty Part = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); = new Part("Łącze kulowe", 2 9 .9 5 ); = new Part("Lampa przednia", 199.99); // nieprawidłowa cena
a .p rin tP a rt() ; // oczekiwane wyniki Filtr powietrza, 80,75 zł b .p rin tP a rt() ; // oczekiwane wyniki Łącze kulowe, 29,95 zł c .p r in tP a r t(); // oczekiwane wyniki Lampa przednia, -1.0 zł c .s e tP r ic e (3 6 .9 9 ); c .p rin tP a rt() ; // oczekiwane wyniki Lampa przednia, 36,99 zł // wyświetlamy sumaryczną liczbę części; oczekiwany wynik to 3 System .out.printf("\nLiczba c z ę ś c i: %d\n", Part.GetNumParts()); } //koniec main } //koniec klasy PartTest W ykonanie tego programu spowoduje wyświetlenie następujących wyników.
SS
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Część: Lampa przednia Nieprawidłowa cena: 199,99. Używamy wartości - 1 .0 . Nazwa c z ę ś c i: F i l t r powietrza Cena: 80,75 zł Nazwa c z ę ś c i: Łącze kulowe Cena: 29,95 zł Nazwa c z ę ś c i: Lampa przednia Cena: -1 ,0 0 zł Nazwa c z ę ś c i: Lampa przednia Cena: 36,99 zł Liczba c z ę ś c i: 3 To są oczekiwane wyniki, a zatem mamy pewność, że klasa działa tak, jak powinna. I w końcu ostatnia rzecz dotycząca klasy Part. Gdyby z dziwnych powodów klasa ta nie udostępniała metod printP art ani toString, klasa użytkownika mogłaby definiować własną metodę służącą do wyświetlania pól obiektów Part. Jednak taka metoda musiałaby używać akcesorów klasy Part, by uzyskać dostęp do danych, gdyż nie może odwoływać się do pól klasy Part w sposób bezpośredni. Poniżej pokazano przykładową postać takiej metody. public s t a t i c void p rin tP art(P art p) { // metoda zdefiniowana w klasie użytkownika System.out.printf("\nNazwa c z ę ś c i: %s\n", p.getName()); System .out.printf("C ena: $%3.2f zł\n", p .g e tP r ic e ()); } W innym miejscu klasy użytkownika możemy teraz użyć kodu: Part a f = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); p r in tP a r t(a f); a jego wykonanie spowoduje wyświetlenie następujących wyników: Nazwa c z ę ś c i: F i l t r powietrza Cena: 80,75 zł
2.7. Jakie nazwy nadawać plikom źródłowym? Jeśli nasz program składa się z jednej, publicznej klasy, język Java wymaga, by zapisać ją w pliku, którego nazwa odpowiada nazwie klasy i który ma rozszerzenie .java. A zatem klasa Pal indrome powinna zostać umieszczona w pliku P alindrom e.java. Kod źródłowy naszej przykładowej klasy Part powinien zostać umieszczony w pliku P art.java, a klasa PartTest powinna zostać umieszczona w pliku PartTest.java. Klasy te możemy teraz skompilować, wydając polecenia: javac P art.jav a javac P artT est.jav a Program testowy moglibyśmy następnie wywołać za pomocą polecenia: jav a PartTest
59
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Wiadomo już, że polecenie to spowoduje wykonanie metody mai n klasy PartTest. W arto zwrócić uwagę, że próba wykonania poniższego polecenia nie ma większego sensu: jav a Part Spowodowałoby ono jedynie wyświetlenie komunikatu informującego, że klasa Part nie zawiera metody main. Można by umieścić obie te klasy w jednym pliku; choć w takim przypadku tylko jedna z nich mogłaby być klasą publiczną. Przykładowo moglibyśmy pozostawić klasę PartTest w przedstawionej postaci, a z deklaracji klasy Part — public class Part — usunąć słowo kluczowe publi c. Po takiej modyfikacji moglibyśmy umieścić obie klasy w jednym pliku, który m usiałby mieć nazwę PartTest.java, gdyż klasą publiczną jest PartTest. Podczas kom pilacji takiego pliku P artTest.java Java wygenerowałaby dwa pliki: PartTest.class oraz Part.class. Program testowy moglibyśmy uruchomić przy użyciu polecenia: jav a PartTest
2.8. Stosowanie obiektów Wcześniej w tym rozdziale napisano, jak można definiować klasy i tworzyć obiekty tych klas, używając w tym celu konstruktorów. Pokazano także, jak można pobierać dane przechowywane w obiektach za pomocą akcesorów oraz jak te dane zmieniać przy użyciu mutatorów. Teraz przyjrzymy się niektórym problemom, jakie mogą pojawić się podczas korzystania z obiektów.
2.8.1. Przypisywanie jednej zmiennej obiektowej do drugiej Zmienna obiektowa (dajmy na to p) jest deklarowana przy użyciu nazwy klasy (np. Part) w następujący sposób: Part p; Jeszcze raz podkreślamy, że zmienna p nie może zawierać obiektu, a jedynie wskaźnik (referencję) do niego. W artością zmiennej p jest adres miejsca w pamięci — konkretnego miejsca, w którym został zapisany obiekt klasy Part. Załóżmy, że w programie został umieszczony następujący fragment kodu: Part a = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Part b = new Part("Łącze kulowe", 2 9 .9 5 ); Załóżmy teraz, że obiekt „Filtr powietrza” został zapisany pod adresem 3472, a obiekt„Łącze kulowe” pod adresem 5768. W takim przypadku wartością zmiennej a będzie 3472,a wartością zmiennej b —5768. Po utworzeniu obu tych obiektów stan programu można zilustrować rysunkiem 2.5.
name: Filtrpowietrza
name: Łącze kulowe
price: 80.75
price: 29.95 Ai
>k
3472
5768
a
b
Rysunek 2.5. Po utw orzeniu dw óch obiektów P art Załóżmy, że następnie przypisaliśmy a do c w następujący sposób: Part c = a; / / przypisujemy 3472 do c
60
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Powyższa instrukcja zapisuje 3472 w zmiennej c. W efekcie obie zmienne — c oraz a — wskazują teraz na ten sam obiekt „Filtr powietrza”. Możemy się do niego odwołać, używając którejkolwiek z tych zmiennych. Możemy np. zmienić cenę obiektu na 90.50: c .s e tP r ic e (9 0 .5 0 ); Tę sytuację ilustruje rysunek 2.6.
Rysunek 2.6. Po przypisaniu a do c Jeśli teraz pobierzemy cenę obiektu przy użyciu zmiennej a (jak pokazaliśmy na poniższym przykładzie), to zostanie zwrócona nowa cena obiektu „Filtr powietrza”: a .g e tP r ic e (); // zwróci 90.50 Załóżmy, że wykonamy teraz następującą instrukcję przypisania: c = b; // zapisuje w c adres 5768 W wyniku jej wykonania w zmiennej c został zapisany adres 5768, przez co wskazuje ona na obiekt „Łącze kulowe”. Jak widać, nie wskazuje już na obiekt „Filtr powietrza”. Teraz dostęp do obiektu „Łącze kulowe” możemy uzyskać za pośrednictwem którejkolwiek ze zmiennych b lub c. Jeśli dysponujemy adresem obiektu, mamy wszystkie informacje niezbędne do tego, by nim manipulować.
2.8.2. Utrata dostępu do obiektu Rozważmy następujący fragment kodu: Part a = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Part b = new Part("Łącze kulowe", 2 9 .9 5 ); Załóżmy, że wykonanie tych dwóch instrukcji prowadzi do sytuacji przedstawionej na rysunku 2.7.
name: Filtrpowietrza
name: tącze kulowe
price: 80.75
prke: 29.95 i
jk
3472
5768
o
b
Rysunek 2.7. Po utw orzeniu dw óch obiektów P art Teraz załóżmy, że wykonujemy instrukcję: a = b; Nową sytuację ilustruje rysunek 2.8.
61
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 2.8. Po przypisaniu b do a Aktualnie obie zmienne, a i b, mają tę samą wartość, czyli 5768. Obie wskazują na obiekt „Łącze kulowe”. Okazuje się, że zmiana wartości zmiennej a spowodowała utratę możliwości dostępu do obiektu „Filtr powietrza”. Kiedy żadna zmienna nie wskazuje na obiekt, staje się on niedostępny i nie można go używać. Pamięć zajmowana przez taki obiekt zostanie odzyskana przez system i zwrócona do puli dostępnej pamięci. Wszystko to dzieje się automatycznie, bez żadnej interwencji ze strony programu. Załóżmy jednak, że napisaliśmy następujący fragment kodu: c = a; a = b;
// c zawiera 3472, adres obiektu "Filtrpowietrza" // a i b zawierają 5768, adres obiektu "Łącze kulowe"
W tym przypadku dostęp do obiektu „Filtr powietrza” wciąż jest możliwy, gdyż zapewnia go zmienna c.
2.8.3. Porównywanie zmiennych obiektowych Rozważmy kolejny przykład, w którym poniższy fragment kodu tworzy dwa odrębne obiekty o identycznej zawartości i zapisuje ich wartości w zmiennych a i b. Part a = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Part b = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Wykonanie powyższych instrukcji prowadzi do sytuacji zilustrowanej na rysunku 2.9.
Rysunek2.9. Po utw orzeniu dw óch identycznych obiektów Ponieważ oba obiekty są identyczne, pewnym zaskoczeniem może być fakt, że poniższe porównanie zwróci wartość fa lse : a == b Jeśli jednak przypomnimy sobie, że zmienne a i b zawierają adresy, a nie obiekty, to stanie się oczywiste, iż powyższy warunek porównuje adres przechowywany w zmiennej a (2000) z adresem przechowywanym w zmiennej b (4000). Ponieważ adresy te są różne, więc porównanie musi zwracać wartość false. Porównanie dwóch zmiennych obiektowych zwróci wartość true wyłącznie w przypadku, gdy obie te zmienne będą zawierały ten sam adres (czyli gdy będą wskazywać na ten sam obiekt). Może się to zdarzyć np. wtedy, gdy jedna zmienna obiektowa została zapisana w drugiej.
62
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Oczywiście może się zdarzyć, że będziemy musieli wiedzieć, czy dwa obiekty są równe. Innymi słowy, jeśli zmienne a i b wskazują na różne obiekty, to czy zaw artości tych obiektów są identyczne? By uzyskać taką informację, będziemy musieli napisać własną metodę do porównywania poszczególnych pól obiektów. Kontynuując przykład klasy Part, napiszemy teraz metodę equals, która będzie zwracać wartość true tylko wtedy, kiedy oba obiekty są identyczne; w przeciwnym przypadku metoda zwróci wartość fa lse . Poniżej pokazano, w jaki sposób można użyć tej metody do porównania obiektów a i b: i f (a.eq u als(b )) . . . W naszym przypadku działanie tej metody sprowadza się do sprawdzenia, czy zawartości pól name oraz price obu obiektów są takie same. Ponieważ pole name jest obiektem typu S tri ng, do porównania nazw dwóch obiektów użyjemy metody equal s klasy String. public boolean equals(Part p) { return name.equals(p.name) && (price == p .p ric e ); } Zastosowane w powyższej metodzie zmienne name i pri ce (których nazwy nie zostały niczym poprzedzone) odwołują się do pól obiektu, na rzecz którego metoda została wywołana. Załóżmy, że w kodzie użyliśmy następującego wyrażenia: a.equ als(b) W takim przypadku zmienne te odwołują się do pól a.name oraz a.p ri ce. Z kolei p.name oraz p.pri ce odwołują się — oczywiście — do pól obiektu, który został przekazany jako argument wywołania metody equals (w naszym przykładzie jest to zmienna b). W efekcie instrukcja return metody z przedstawionego przykładu jest równoważna z następującą: return a.name.equals(b.name) && (a .p rice == b .p ric e ); A teraz wróćmy do wcześniejszego przykładu: Part a = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Part b = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Wyrażenie (a == b) zwróci fal se (gdyż zmienne a i b zawierają inne adresy), natomiast wyrażenie a.equal s(b) zwróci true (gdyż zawartość obu obiektów jest identyczna).
2.9. Wskaźnik null Zmienne obiektowe deklaruje się w sposób przedstawiony na poniższym przykładzie: Part p; Początkowo wartość takiej zmiennej nie jest zdefiniowana (podobnie jak zmiennych typów podstawowych). Najpopularniejszym sposobem nadania zmiennej p jakiejś wartości jest utworzenie nowego obiektu klasy Part przy użyciu operatora new i zapisanie adresu tego obiektu w zmiennej p: p = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); Język Java udostępnia także specjalną wartość wskaźnikową, oznaczaną przy użyciu słowa n u ll, którą można przypisać do dowolnej zmiennej obiektowej. Poniższa instrukcja pokazuje, jak można zapisać wartość null w zmiennej p typu Part: Part p = n u ll; Instrukcja ta stwierdza, że zmienna p ma zdefiniowaną wartość, lecz nie wskazuje na żaden obiekt. W przypadku gdy zmienna p ma wartość n u ll, próba odwołania się do obiektu wskazywanego przez p jest uznawana za błąd. Innymi słowy, jeśli p ma wartość n u ll, nie ma sensu mówić o polach p.name lub p.p rice, gdyż p nie wskazuje na żaden obiekt.
63
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jeśli dwie zmienne obiektowe, p i q, mają wartość n u ll, można je porównać przy użyciu operatora ==, a takie wyrażenie zawsze zwróci wartość true. Z drugiej strony, jeśli p wskazuje na jakiś obiekt, a q ma wartość null, to porównanie obu zmiennych zawsze zwróci wartość fa lse . Wskaźniki null są niezwykle przydatne, gdy trzeba zainicjaliować listę zmiennych obiektowych. Możemy ich także używać podczas tworzenia takich struktur danych jak listy powiązane lub drzewa binarne, kiedy niezbędna jest jakaś wartość pozwalająca określić koniec listy. Przykłady zastosowania wartości null można znaleźć w następnym rozdziale.
2.10. Przekazywanie obiektu jako argumentu Zmienna obiektowa przechowuje adres, jest to adres faktycznego obiektu. Kiedy używamy takiej zmiennej w wywołaniu metody, właśnie adres jest do niej przekazywany. Ponieważ argumenty w języku Java są przekazywane „przez w artość”, zatem w rzeczywistości do metody zostanie przekazana tymczasowa lokalizacja zawierająca wartość zmiennej. W punkcje 2.6.1 przedstawiliśmy statyczną metodę printP art klasy Part, służącą do wyświetlania informacji o przekazanej części: public s t a t i c void p rin tP art(P art p) { System.out.printf("\nNazwa c z ę ś c i: %s\n", p.name); System .out.printf("C ena: %3.2f zł\n", p .p ric e ); } //koniec printPart Załóżmy teraz, że klasa użytkownika zawiera następujący fragment kodu: Part a f = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); p r in tP a r t(a f); oraz pierwsza z tych instrukcji zapisuje w zmiennej af adres, np. 4000. W momencie wywoływania metody p rin tP art wartość 4000 jest kopiowana do tymczasowej lokalizacji w pam ięci, a ta lokalizacja zostaje przekazana do metody p rin tP art, gdzie będzie dostępna jako p, czyli będziemy mogli z niej korzystać, używając nazwy parametru formalnego. Ponieważ wartość p wynosi 4000, w rzeczywistości zmienna ta zapewnia dostęp do oryginalnego obiektu. W naszym przypadku metoda jedynie wyświetla wartości zmiennych instancyjnych. Jednak równie dobrze mogłaby je zmieniać. Przeanalizujmy następującą metodę klasy Part, która dodaje do ceny części wartość amount: public s t a t i c void changePrice(Part p, double amount) { p .p rice += amount; } Klasa użytkownika może dodać do ceny części wartość 10.50 przy użyciu następującego wywołania: P art.chang eP rice(af, 10.5 0 ); Jak już zaznaczono, parametr p zapewnia dostęp do oryginalnego obiektu. Dowolna zmiana wprowadzona w obiekcie, na który wskazuje p, będzie w rzeczywistości zmianą oryginalnego obiektu. Należy zwrócić uwagę na to, że metoda nie m oże z m ien ić w artości faktycznego argumentu af (ponieważ nie ma do niego dostępu), jednak m oże zm ien iać obiekt, na który a f wskazuje. W arto także zwrócić uwagę, że powyższy przykład przedstawiono wyłącznie w celach demonstracyjnych. W praktyce lepszym rozwiązaniem jest napisanie metody instancyjnej klasy Part służącej do zmiany ceny części.
2.11. Tablice obiektów W języku Java łańcuch znaków (dana typu S tr i ng) jest obiektem. A zatem tablica łańcuchów znaków jest tablicą obiektów. Jednak w Javie S trin g jest specjalnym rodzajem obiektu i pod pewnymi względami jest traktowany inaczej niż wszystkie inne obiekty. Przede wszystkim obiekty S trin g są niezm ienn e — nie można
64
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
zmieniać ich wartości. Oprócz tego warto sobie wyobrazić, że dysponują one jednym polem — zawierającym znaki łańcucha — natomiast inne obiekty zazwyczaj składają się z kilku pól. Tablice obiektów wyobrażamy sobie inaczej niż tablice łańcuchów znaków. Weźmy zdefiniowaną wcześniej klasę Part. Zawiera ona m.in. dwie zmienne instancyjne określone w następujący sposób: class Part { private String name; private double p rice;
// zmienna instancyjna // zmienna instancyjna
// metody i zmienne statyczne } //koniec klasy Part W arto przypomnieć sobie, co się dzieje, gdy w programie zadeklarujemy zmienną p typu Part: Part p; Przede wszystkim warto pamiętać, że zmienna p zawiera adres obiektu Part, a nie sam obiekt. Powyższa deklaracja jedynie rezerwuje w pamięci miejsce dla zmiennej p, lecz w żaden sposób nie określa jej wartości. Zmiennej p możemy teraz przypisać wartość null, tak jak na poniższym przykładzie: p = nul l ; Możemy także utworzyć obiekt Part i zapisać jego adres w zmiennej p; pozwala na to instrukcja: p = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); A teraz przeanalizujmy następującą deklarację: Part[] part = new P a rt[5 ]; Deklaruje ona tablicę o nazwie part, składającą się z pięciu elementów. Ponieważ są to zmienne obiektowe, język Java gwarantuje, że zostaną one zainicjalizowane wartością n u ll. Na razie nie został jeszcze utworzony żaden obiekt Part. Poniżej pokazano, w jaki sposób można utworzyć poszczególne obiekty Part i zapisać je w elementach tablicy. part[0] = new P a r t ( " F ilt r powietrza", 8 0 .7 5 ); part[1] = new Part("Łącze kulowe", 2 9 .9 5 ); part[2] = new Part("Lampaprzednia", 3 6 .9 9 ); part[3] = new Part("Św ieca", 5 5 .0 0 ); part[4] = new Part("Hamulec tarczowy", 2 4 .9 5 ); Tablicę part można zilustrować w sposób przedstawiony na rysunku 2.10. Każdy element tablicy part zawiera adres odpowiedniego obiektu. Trzeba pamiętać, że — ogólnie rzecz biorąc — każdy element tablicy może być traktowany tak samo jak zwyczajna zmienna tego samego typu, co typ tablicy. Przykładowo part [2] można traktować tak samo jak używaną wcześniej zmienną p. I analogicznie do wywołania p .se tP rice (4 0 .0 0 ) można napisać part[2] .s e tP r ic e (4 0 .0 0 ), co zmieni cenę „Lampy przedniej” na 40.00. A w jaki sposób można się odwoływać do pól obiektów Part? Jak zwykle, zależy to od lokalizacji kodu — czy będzie umieszczony wewnątrz klasy Part, czy poza nią. W pierwszym przypadku kod może się odwoływać do zmiennych instancyjnych, takich jak name lub pri ce, bezpośrednio, np. tak: part[2].name. Jeśli jednak kod będzie umieszczony poza klasą, odczyt lub ustawianie wartości pól będą wymagać skorzystania z odpowiednich akcesorów i mutatorów, takich jak np. part [2].getName().
65
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 2.10. T ablica obiektów P art Gdybyśmy musieli operować na setkach różnych części, lepszym rozwiązaniem byłoby zapisanie ich danych w pliku (np. pa rts.d at) i wczytanie do tablicy przy użyciu pętli fo r lub while. Załóżmy, że przedstawione wcześniej dane części zostały zapisane w pliku, w następującej postaci (nazwy części są zapisywane jako jedno słowo, tak by można było je odczytać przy użyciu jednego wywołania metody next klasy Scanner): Filtr_pow ietrza 80,75 Łącze_kulowe 29,95 Lampa_przednia 36,99 Świeca 55,00 Hamulec_tarczowy 24,95 W takim przypadku możemy przygotować tablicę part, używając następującego fragmentu kodu: Scanner in = new Scanner(new F ile R e a d e r("p a rts.d a t")); Part[] part = new P a rt[5 ]; fo r (in t h = 0; h < p art.len g th ; h++) part[h] = new P a r t(in .n e x t(), in.nextD ouble()); Ten kod jest znacznie lepszy i bardziej elastyczny. Aby wczytać tysiąc części, wystarczy zmienić deklarację tablicy part i dostarczyć odpowiedni plik z danymi. Nie trzeba wprowadzać żadnych zmian w powyższym kodzie. Oczywiście, nie m usim y wypełniać całej tablicy danymi części. Ich wczytywanie możemy zakończyć po odnalezieniu jakiegoś znacznika końca danych (np. angielskiego słowa End oznaczającego koniec). Gdybyśmy musieli wyświetlić dane o częściach, moglibyśmy to zrobić przy użyciu następującej pętli: fo r (in t h = 0; h < p art.len g th ; h++) p a rt[h ].p rin tP a rt(); Załóżmy, że chcemy zamienić miejscami dwie części w tablicy, np. part [2] oraz p a rt[4 ]. Możemy to zrobić w taki sam sposób, w jaki zamienialiśmy wartości dowolnych dwóch zmiennych tego samego typu: Part p = p a rt[2 ]; part[2] = p a rt[4 ]; part[4] = p; Warto przy tym zauważyć, że same obiekty pozostają w tych samych miejscach, w których zostały początkowo umieszczone. Powyższy fragment kodu zamienia jedynie adresy zapisane w komórkach part[2] i p a rt[4 ]. Odnosząc powyższą operację do schematu przedstawionego na rysunku 2.10, można ją sobie wyobrazić jako zamianę strzałek wychodzących z obu komórek tablicy.
66
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
2.11.1. Znajdowanie części o najniższej cenie Załóżmy, że chcemy znaleźć część, której cena będzie najniższa (w pewnym sensie chodzi zatem o znalezienie „najmniejszego” obiektu). Jeśli przyjmiemy, że kod ma być umieszczony poza klasą Part, metoda getLowestPrice zwracająca położenie części o najniższej cenie może mieć postać: public s t a t i c int getLow estPrice(Part[] p art, in t lo , int hi) { //zwraca indeks części o najniższej cenie //z zakresu od part[lo] do part[hi] włącznie int small = lo ; fo r (in t h = lo + 1; h <= h i; h++) i f (p a rt[h ].g e tP ric e () < p a rt[s m a ll].g e tP ric e ()) small = h; return small; } //koniec getLowestPrice Gdyby taki kod miał być umieszczony wewnątrz klasy Part, moglibyśmy zastosować tę samą metodę w niezmienionej postaci. Ponieważ dysponowalibyśmy już możliwością stosowania bezpośrednich odwołań do zmiennych instancyjnych, zatem moglibyśmy zmienić instrukcję i f w następujący sposób: i f (p a rt[h ].p ric e < p art[sm a ll].p ric e ) small = h; Informacje o części mającej najniższą cenę możemy wyświetlić, używając poniższego wywołania: System .out.printf("\nC zęść o n ajn iższej cen ie: %s\n", part[getLow estPrice(part, 0, p art.len gth -1)].getN am e()); W ramach ćwiczenia można napisać funkcję, która zwróci element o najwyższej cenie.
2.12. Przeszukiwanie tablicy obiektów W iemy już, jak można przeszukiwać tablice, których elementami są dane typów podstawowych lub łańcuchy znaków. W tym podrozdziale zastanowimy się, jak należy przeszukiwać tablice obiektów (a konkretnie rzecz biorąc, tablice referencji do obiektów), które mają więcej niż jedno pole. Załóżmy np., że dysponujemy klasą Person zdefiniowaną w taki sposób: public c la ss Person { String name; int age; char gender; // konstruktory, pola statyczne i inne metody } //koniec klasy Person Chcemy przeszukać tablicę person zawierającą obiekty klasy Person i odszukać w niej obiekt osoby o konkretnym imieniu. Klucz używany podczas przeszukiwania tablicy jednego z typów podstawowych lub tablicy łańcuchów znaków jest taki sam jak typ elementów tablicy. Jednak w przypadku przeszukiwania tablicy obiektów, które mają więcej niż jedno pole, typ klucza będzie odpowiadał typowi jedn ego z p ó l obiektu. Nasza metoda wyszukiwania musi porównywać parametr key z odpowiednim polem obiektów. W tym przykładzie będziemy go porównywać z person [h] .name. Poniżej przedstawiono kod metody przeszukującej tablicę typu Person. Zastosowano w niej metodę equalsIgnoreCase, żeby różne wielkości znaków w porównywanych łańcuchach nie miały znaczenia. // metoda poszukuje łańcucha key w pierwszych n elementach tablicy person; // jeśli uda się go znaleźć, zwracane jest jego położenie, // w przeciwnym razie metoda zwraca -1 public s t a t i c int sequentialSearch(String key, Person[] person, in t n) { fo r (in t h = 0; h < n; h++)
67
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
i f (key.equalsIgnoreCase(person[h].name)) return h; return -1 ; } Gdybyśmy chcieli odnaleźć osobę w określonym wieku (przechowywanym w polu age), musielibyśmy zmienić w deklaracji metody typu parametru key na int oraz użyć następującej instrukcji if: i f (key == person[h].age) return h; Należy zauważyć, że taka metoda zwróci pierwszą osobę w podanym wieku. Działanie powyższej metody przetestujemy przy użyciu programu P2.3. Program P2.3 import ja v a .u t i l . * ; public c la ss SearchTest { public s t a t ic void m ain(String[] args) { // tworzymy tablicę 7 osób Person[] person = new Person[7]; person[0] = new Person("G rzesiek", 25, 'M '); person[1] = new P erson("Iga", 21, 'K ') ; person[2] = new Person("Abel", 30, 'M '); person[3] = new Person("Olga", 36, 'K ') ; person[4] = new P erson("N atalia", 19, 'K ') ; person[5] = new Person("M aria", 27, 'K '); person[6] = new Person("Bartek", 32, 'M '); Scanner in = new Scanner(System .in); String s; System .out.printf("Podawaj kolejne imiona, a ja wyświetlę \n"); S y stem .o u t.p rin tf("ich wiek i płeć. Aby zakończyć, n a ciśn ij Enter\n\n"); while ( ! ( s = in .n e x tL in e ()).e q u a ls ("")) { int n = sequ entialSearch(s, person, person.length); i f (n >= 0) System .out.printf("% d %c\n\n", person[n].age, person[n].gkoniecer); e lse System .ou t.prin tf("N ie znaleziono osoby!\n\n"); } } //koniec main // metoda poszukuje łańcucha key w pierwszych n elementach tablicy person; // jeśli uda się go znaleźć, zwracane jest jego położenie, // w przeciwnym razie metoda zwraca -1 public s t a t i c int sequentialSearch(String key, Person[] person, in t n) { fo r (in t h = 0; h < n; h++) i f (key.equalsIgnoreCase(person[h].name)) return h; return -1 ; } //koniec sequentialSearch } //koniec klasy SearchTest class Person { String name; int age; char gkoniecer; Person(String n, int a, char g) { name = n; age = a; gkoniecer = g; } } //koniec klasy Person
68
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Metoda main przedstawionego programu tworzy tablicę person i zapisuje w niej obiekty kilku osób. Następnie prosi użytkownika o podawanie imion. Dla każdego z podanych imion wywoływana jest metoda sequential Search, która zwraca pewną wartość, dajmy na to n. Jeśli uda się odnaleźć osobę (n >= 0), program wyświetla je j wiek i płeć. W przeciwnym przypadku wyświetlany jest komunikat: Nie znaleziono osoby!. Oto przykładowe wyniki wykonania tego programu. Podawaj kolejne imiona, a ja wyświetlę ich wiek i pleć. Aby zakończyć, n a c iśn ij Enter Olga 36 K Franek Nie znaleziono osoby! n a ta lia 19 K GRZESIEK 25 M Należy zwrócić uwagę na sposób, w jaki została zadeklarowana klasa Person. Pominęliśmy w niej słowo kluczowe publi c, dzięki czemu możemy ją umieścić w tym samym pliku, w którym znajduje się SearchTest. Tym razem w deklaracjach pól (name, age i gender) nie podaliśmy żadnych modyfikatorów dostępu (ani publ ic, ani private). W takim przypadku inne klasy umieszczone w tym samym p liku mogą odwoływać się do pól w sposób bezpośredni; np. w metodzie main możemy użyć odwołań w postaci person[n].age oraz person[n].gender. Tablice obiektów można także przeszukiwać za pomocą wyszukiwania binarnego, przy czym w takim przypadku obiekty w tablicy muszą być posortowane według wartości pola używanego do wyszukiwania. Jeśli np. tylko obiekty w tablicy person będą posortowane na podstawie imienia, będziemy mogli przeszukiwać ją, by odnaleźć osobę o podanym imieniu. Oto metoda, która to robi: // metoda szuka łańcucha określonego parametrem key, w zakresie pierwszych // n elementów tablicy person //jeśli uda się go znaleźć, zwracamy indeks, jeśli nie, zwracamy -1 public s t a t i c int binarySearch(String key, Person[] person, int n) { int lo = 0, hi = n - 1; while (lo <= hi) { //jeśli są jeszcze elementy do sprawdzenia int mid = (lo + hi) / 2; int cmp = key.compareToIgnoreCase(person[mid].name); i f (cmp == 0) return mid; // wyszukiwanie zakończone sukcesem i f (cmp < 0) hi = mid - 1; // key jest 'mniejszy' od person[mid].name e lse lo = mid + 1; // key jest 'większy' od person[mid].name } return -1 ; // nie znaleziono podanego łańcucha } //koniec binarySearch W ramach ćwiczenia warto napisać program podobny do P2.3, który pozwoli sprawdzić działanie metody binarySearch.
69
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
2.13. Sortowanie tablicy obiektów W iemy już, jak należy sortować tablicę danych typów podstawowych lub tablicę łańcuchów znaków przy użyciu algorytmów sortowania przez wybieranie lub sortowania przez wstawianie. Poniższa metoda to sposób, w jaki można sortować tablice obiektów , a konkretnie obiektów klasy Person, w kolejności rosnącej, na podstawie zawartości pola name z wykorzystaniem sortowania przez wybieranie. public s t a t i c void selection So rt(P erso n [] l i s t , int lo , int hi) { // sortujemy elementy od list[lo] do list[hi], używając sortowania przez wybieranie fo r (in t h = lo ; h <= h i; h++) sw ap (list, h, g e tS m a lle s t(lis t, h, h i ) ) ; } //koniec selectionSort public s t a t i c int getSm allest(Person[] l i s t , int lo , int hi) { // metoda zwraca pozycję 'najmiejszego' imienia z zakresu od list[lo] do list[hi] int small = lo ; fo r (in t h = lo + 1; h <= h i; h++) i f (list[h].nam e.com pareToIgnoreCase(list[sm all].nam e) < 0) small = h; return small; } //koniec getSmallest public s t a t i c void swap(Person[] l i s t , int h, in t k) { // metoda zamienia elementy list[h] oraz list[k] Person hold = l i s t [ h ] ; l is t [ h ] = l i s t [ k ] ; l is t [ k ] = hold; } //koniec swap W metodzie getSm allest porównujemy pole name jednego elementu tablicy z polem name innego elementu. Tablicę person z programu P2.1 moglibyśmy posortować przy użyciu następującego wywołania: selectio n So rt(p erso n , 0, person.length - 1); Zawartość tej tablicy moglibyśmy następnie wyświetlić za pomocą poniższej pętli for: fo r (in t h = 0; h < person.length; h++) perso n [h ].p rin tP erso n (); gdzie printPerson jest metodą klasy Person zdefiniowaną w następujący sposób: void printPerson() { System .out.printf("% s %d %c\n", name, age, gender); } Podczas sortowania tablicy z programu P2.3 uzyskalibyśmy następujące wyniki. Abel 30 M Bartek 32 M Grzesiek 25 M Iga 21 K Maria 27 K N atalia 19 K Olga 36 K Tablicę obiektów Person możemy także posortować przy użyciu algorytmu sortowania przez wstawianie; poniższa metoda jest sposobem na to. public s t a t i c void in sertionSort(P erson [] l i s t , int lo , int hi) { //sortuje elementy od list[lo] do list[hi] na podstawie pola name //w kolejności rosnącej
70
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
fo r (in t h = lo + 1; h <= h i; h++) { Person hold = l i s t [ h ] ; int k = h - 1; //zaczynamy porównywanie z poprzednimi elementami while (k >= 0 && hold.name.compareToIgnoreCase(list[k].name) < 0) { l i s t [ k + 1] = l i s t [ k ] ; --k ; } l is t [ k + 1] = hold; } //koniec fo r } //koniec insertionSort Tablicę person z programu P2.3 moglibyśmy posortować z wykorzystaniem metody insertionSort, wywołując ją w następujący sposób: insertionSort(person, 0, person.length - 1); W pętli while porównujemy wartość pola name aktualnie przetwarzanego obiektu (znajdującego się w komórce określonej indeksem h) z polem name elementu tablicy.
2.14. Zastosowanie klasy do grupowania danych: licznik występowania słów W podrozdziale 1.8 napisany został program (P1.7) zliczający wystąpienia wyrazów w pewnym fragmencie tekstu. Zastosowano w nim tablicę typu String (o nazwie w ordlist), w której były przechowywane odczytane słowa, oraz tablicę typu int (o nazwie frequency) zawierającą liczniki wystąpień poszczególnych słów. Kod został napisany w taki sposób, że element frequency[i] zawierał wartość określającą liczbę wystąpień słowa zapisanego w elemencie w o rd lis t[i]. W tym podrozdziale zobaczymy, jak dzięki zastosowaniu klas i obiektów można ten sam problem rozwiązać w nieco inny sposób. Każde słowo występujące w tekście możemy sobie wyobrazić jako obiekt o dwóch atrybutach: pierwszym z nich są litery składające się na to słowo, a drugim — liczba wystąpień tego słowa. Zdefiniujemy zatem klasę WordInfo, której będziemy używać do tworzenia „obiektów słów”. class WordInfo { String word; int freq = 0; WordInfo(String w, in t f) { word = w; freq = f ; } void incrFreq() { freq++; } } //koniec klasy WordInfo W klasie WordInfo zdefiniowane zostały dwa pola — word oraz freq. Dysponuje ona także konstruktorem, który inicjalizuje obiekt WordInfo na podstawie podanego słowa i licznika. Załóżmy, że wo jest obiektem WordInfo utworzonym przy użyciu instrukcji: WordInfo wo = new WordInfo(aWord, 1 );
// aWord to zmienna typu String
Pole wo.word zawiera słowo, a pole wo.freq — liczbę jego wystąpień. Liczbę wystąpień słowa w tekście możemy inkrementować, używając wywołania wo.incrFreq(). Kolejnym krokiem będzie zdefiniowanie tablicy wordInfo, której każdy element będzie zawierał informacje o jednym słowie. WordInfo[] wordTable = new WordInfo[MaxWords + 1];
71
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Stała MaxWords określa maksymalną liczbę słów, które program jest w stanie przeanalizować. Do celów testowych zastosowaliśmy wartość 50. Jeśli liczba unikalnych słów w tekście przekroczy wartość MaxWords (np. 50), wszystkie kolejne odnalezione słowa zostaną odczytane, lecz program je zignoruje i wyświetli stosowny komunikat. Jednak w przypadku ponownego wystąpienia jednego z zarejestrowanych słów jego licznik będzie prawidłowo aktualizowany. Takie rozwiązanie zaimplementowaliśmy w programie P2.4. Program P2.4 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss WordFrequency { final s t a tic in t MaxWords = 50; public s t a t ic void m ain(String[] args) throws IOException { WordInfo[] wordTable = new WordInfo[MaxWords]; FileReader in = new F ileR ead er("p assag e.txt"); PrintW riter out = new PrintWriter(new F ile W rite r ("o u tp u t.tx t")); fo r (in t h = 0; h < MaxWords; h++) wordTable[h] = new WordInfo("", 0 ); int numWords = 0; String word = getWord(in).toLowerCase(); while (!w ord .equ als("")) { int loc = binarySearch(word, wordTable, 0, numWords-1); i f (word.compareTo(wordTable[loc].word) == 0) w ord T able[loc].in crFreq(); e lse //to jest nowe słowo i f (numWords < MaxWords) { //jeśli tablica nie jest pełna addToList(word, wordTable, lo c, numWords-1); ++numWords; } else out.printf("STowo '%s' nie zostało dodane do tab licy\ n ", word); word = getWord(in).toLowerCase(); } prin tR esu lts(o u t, wordTable, numWords); in .c lo s e (); o u t.c lo s e (); } //koniec main public s t a tic int binarySearch(String key, WordInfo[] l i s t , int lo , int hi) { //szukamy łańcucha określonego parametrem key, w zakresie od list[lo] do list[hi] //jeśli uda się go znaleźć, zwracamy jego indeks, //wprzeciwnym przypadku zwracamy indeks miejsca, w którym łańcuch należy umieścić; //kod wywołujący musi sprawdzić to miejsce, by określić, czy łańcuch został znaleziony while (lo <= hi) { int mid = (lo + hi) / 2; int cmp = key.compareToIgnoreCase(list[mid].word); i f (cmp == 0) return mid; // wyszukiwanie zakończone sukcesem i f (cmp < 0) hi = mid -1 ; // key jest 'mniejszy' od list[mid] e lse lo = mid + 1; // key jest 'większy' od list[mid] } return lo ; //łańcuch key musi zostać wstawiony do komórki o indeksie lo } //koniec binarySearch public s t a t i c void addToList(String item, WordInfo[] l i s t , int p, int n) { //dodaje łańcuch w parametrze item na pozycji list[p]; przypisuje freq[p] wartość 1 //przesuwa elementy od list[n] do list[p] o jedną pozycję w prawo
72
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
fo r (in t h = n; h >= p; h--) l is t [ h + 1] = l i s t [ h ] ; lis t [ p ] = new WordInfo(item, 1); } //koniec addToList public s t a t ic void prin tR esu lts(P rin tW riter out, WordInfo[] l i s t , int n) { out.printf("\nSiow a Licznik\n\n"); fo r (in t h = 0; h < n; h++) out.p rin tf("% -20s %2d\n", list[h ].w o rd , l i s t [ h ] . f r e q ) ; } //koniec printResults public s t a t ic String getWord(FileReader in) throws IOException { //zwraca nowe słowo odczytane z pliku final int MaxLen = 255; int c, n = 0; char[] word = new char[MaxLen]; / / przeskakujemy znaki, które nie są literami while (!C h a ra cte r.isL e tte r((c h a r) (c = in .r e a d ())) && (c != -1 )) ; //odczytujemy znaki i f (c == -1) return " " ; //nie znaleziono liter word[n++] = (char) c; while (C h a ra cte r.isL e tter(c = in .re a d ())) i f (n < MaxLen) word[n++] = (char) c; return new String(word, 0, n); } //koniec getWord } //koniec klasy WordFrequency class WordInfo { String word; int freq = 0; WordInfo(String w, int f) { word = w; freq = f ; } void incrFreq() { freq++; } } //koniec klasy WordInfo Załóżmy, że plik passage.txt zawiera następujący fragment tekstu: S ta ra j s ię nie Można osiągnąć I s t n ie je tylko Nasz charakter
dążyć do sukcesu, lecz osiągać w artości. wszystko, co umysł może pojąć i w co je s t w stan ie uwierzyć. jeden sposób, by uniknąć krytyki: nic nie ro b ić, nic nie mówić, być nikim. określa to , co robimy, kiedy nikt na nas nie patrzy.
W takim przypadku wywołanie programu P2.4 spowoduje zapisanie w pliku output.txt następujących wyników. Słowa by być charakter co do dążyć
Liczn 1 1 1 3 1 1
73
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
i is t n ie je jeden je s t kiedy krytyki l ecz może można mówi ć na nas nasz nic nie nikim nikt określa osi ągać osi ągnąć patrzy pojąć robimy robić si ę sposób stanie s ta ra j sukcesu to tylko umysł uniknąć uwierzyć w wartości wszystko
2.15. Zwracanie więcej niż jednej wartości: głosowanie Ten przykład wykorzystany zostanie do przedstawienia kilku zagadnień związanych ze stosowaniem klas i obiektów. Także tu użyjemy klasy do grupowania danych i pokażemy, w jaki sposób, korzystając z obiektów, funkcja może zwracać więcej niż jedną wartość. • Problem : w wyborach bierze udział kilku kandydatów. Każda głosująca osoba może oddać jeden głos na wybranego kandydata. Głos jest zapisywany jako liczba z zakresu od 1 do 7. Liczba głosujących osób nie jest z góry znana, jednak oddawanie głosów kończy się w momencie odczytania wartości 0. Każdy głos, który nie jest liczbą z zakresu od 1 do 7, jest uznawany za nieważny. • Imiona i nazwiska kandydatów są zapisane w pliku votes.txt. Pierwsza podana w nim osoba jest kandydatem numer 1, druga — kandydatem numer 2 itd. Za personaliami kandydatów zostały podane głosy. Naszym celem jest napisanie programu, który odczyta dane z pliku i przetworzy wyniki głosowania. Wszystkie wyniki m ają zostać zapisane w pliku results.txt.
74
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
• W wynikach mają się znaleźć: sumaryczna liczba oddanych głosów, liczba ważnych głosów oraz liczba nieważnych głosów. Następnie mają zostać wymienieni wszyscy kandydaci wraz z liczbą oddanych na nich głosów, a na samym końcu zwycięzca (lub zwycięzcy) wyborów. Zakładamy, że plik votes.txt ma następującą postać: Norbert Krawczyk Damian Derczyński Albert Twaróg Tomasz Graczyk Stefan Kalicki Darek Grochowski Jarosław Kot 3 1 6 5 4 3 5 3 5 3 2 8 1 6 7 7 3 5 6 9 3 4 7 1 2 4 5 5 1 4 0 Zatem program powinien zapisać w pliku results.txt następujące wyniki. Nieważny głos: 8 Nieważny głos: 9 Liczba głosujących: 30 Liczba prawidłowych głosów: 28 Liczba nieprawidłowych głosów: 2 Kandydat Norbert Krawczyk Damian Derczyński Albert Twaróg Tomasz Graczyk Stefan K alicki Darek Grochowski Jarosław Kot
Głosów 4 2 6 4 6 3 3
Zwycięzca (zwycięzcy): Albert Twaróg Stefan Kalicki Rozwiązanie postawionego problemu może zostać ogólnie opisane w następujący sposób: pobranie personaliów i ustawienie wyników na 0 przetworzenie głosów wyświetlenie wyników Program musi przechowywać personalia siedmiu kandydatów oraz liczbę głosów oddanych na każdego z nich. Moglibyśmy przechowywać personalia kandydatów w tablicy typu S tri ng, a liczniki oddanych głosów w tablicy typu int. Co jednak moglibyśmy zrobić, gdybyśmy musieli gromadzić znacznie więcej informacji o kandydatach? Każda z nich wymagałaby utworzenia kolejnej tablicy odpowiedniego typu. Aby zabezpieczyć się przed taką sytuacją i zapewnić większą elastyczność programu, utworzymy klasę Person i zastosujemy tablicę tego typu. A jak będzie wyglądać klasa Person? Na potrzeby rozwiązania naszego problemu wystarczy zdefiniować w niej dwa pola instancyjne, np. name oraz numVotes. Poniżej przedstawiono definicję tej klasy. class Person { String name; int numVotes;
75
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Person(String s, int n) { name = s; numVotes = n ; } } //koniec klasy Person W celu przechowania danych o siedmiu kandydatach zdefiniujemy stałą symboliczną MaxCandidates o wartości 7 i zadeklarujemy tablicę candidate typu Person: Person[] candidate = new Person[MaxCandidates+1]; Informacje o kandydacie h (gdzie h jest z zakresu od 1 do 7) będziemy przechowywali w elemencie candidate[h]. Pierwszy element tablicy, candi d ate[0], nie będzie używany. Dzięki temu będziemy mogli przetwarzać głosy w sposób bardziej naturalny, niż gdyby pierwszy kandydat był przechowywany w elemencie candidate[0]. Jeśli np. zostanie oddany głos na kandydata numer 4, wystarczy, że inkrementujemy licznik w elemencie candidate[4]. Gdybyśmy do przechowywania danych o pierwszym kandydacie użyli elementu candidate[0], to rejestrując głos na kandydata numer 4, musielibyśmy inkrementować licznik w elemencie candidate[3], a nie candidate[4]. Takie rozwiązanie byłoby mylące i niepokojące. Załóżmy, że zmienna in została zadeklarowana w następujący sposób: Scanner in = new Scanner(new F ile R e a d e r("v o te s .tx t")); Do odczytania personaliów kandydatów i ustawienia początkowych wartości liczników zastosujemy następującą pętlę for: fo r (in t h = 1; h <= MaxCandidates; h++) candidate[h] = new P erson (in .n extL in e(), 0 ); Po wykonaniu powyższego kodu zawartość tablicy candidate będzie można zilustrować rysunkiem 2.11. Należy zwrócić uwagę, że element candidate [0] nie jest używany.
Rysunek 2.11. T ablica can didate p o wczytaniu p erson aliów i przypisaniu licznikom p oczątkow ej w artości 0
76
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Naszym kolejnym zadaniem jest przetworzenie głosów. Zajmie się nim funkcja processVotes. Będzie ona odczytywać kolejne głosy i inkrementować licznik w obiekcie odpowiedniego kandydata. A zatem, jeśli głos będzie dotyczył kandydata numer 5, funkcja doda 1 do licznika tego kandydata. Kolejnym zadaniem funkcji processVotes jest zliczanie ilości głosów ważnych i nieważnych oraz przekazanie ich do metody main. W jaki sposób funkcja może zwrócić więcej niż jedną wartość? Cóż, może zwrócić jed n ą wartość — obiekt — jednak ten obiekt może zawierać wiele pól. W naszym przykładzie zadeklarujemy klasę (o nazwie VoteCount), zawierającą dwa pola i konstruktor. Poniżej przedstawiono jej kod. class VoteCount { int val id, s p o ilt; VoteCount(int v, int s) { valid = v; sp o ilt = s; } } //koniec klasy VoteCount Poniższe wywołanie utworzy nowy obiekt klasy VoteCount i przypisze polom v o tes.v alid oraz v o te s .s p o ilt wartość 0. VoteCount votes = new VoteCount(0, 0 ); Moglibyśmy także obyć się bez konstruktora i utworzyć obiekt VoteCount przy użyciu instrukcji: VoteCount votes = new VoteCount(); W takim przypadku pola obiektu także zostałyby zainicjow an e w artością 0, choć lepiej zrobić to jawnie, tak jak na poniższym przykładzie: v otes.v alid = v o te s .s p o ilt = 0; Kiedy odczytamy prawidłowy głos, wyrażenie ++votes. val id powiększa o 1 wartość pola v o tes. valid; natomiast po odczytaniu nieprawidłowego głosu wartość pola v otes.sp oiled jest inkrementowana przy użyciu wyrażenia ++votes.spoi led. Na samym końcu funkcja zwraca votes — obiekt zawierający dwie liczby. Dodatkowo musimy także napisać metodę printR esults, która wyświetli wyniki w opisanym wcześniej formacie. Najpierw wyświetlimy sumaryczną liczbę głosów oraz dodatkowo liczbę głosów prawidłowych i nieważnych. Następnie z wykorzystaniem pętli fo r wyświetlimy wyniki poszczególnych kandydatów. W końcu ta sama metoda określi zwycięzcę wyborów. Do wyboru zwycięzcy napiszemy funkcję getLargest, która znajdzie kandydata z największą wartością pola numVotes. Czynności te zostaną wykonane przy użyciu następującego fragmentu kodu: int win = g e tL a r g e s t(lis t, 1, MaxCandidates); int winningVote = list[win].num Votes; W powyższym kodzie l i s t jest tablicą typu Person. Po ustawieniu wartości zmiennej winni ngVote jeszcze raz przeglądamy tablicę, aby upewnić się, że nie ma w niej innego kandydata, który zdobył taką samą liczbę głosów. W ten sposób zagwarantujemy, że jeśli będzie remis, zostaną wyświetleni obaj zwycięzcy. Kompletny kod tego rozwiązania przedstawiono w programie P2.5. Program P2.5 import ja v a .u t i l . * ; import ja v a .i o .* ; public c la ss Voting { final s t a tic in t MaxCandidates = 7; public s t a t i c void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("v o te s .tx t")); PrintW riter out = new PrintWriter(new F ile W r ite r (" r e s u lts .tx t " ) );
77
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Person[] candidate = new Person[MaxCandidates+1]; //pętla pobiera personalia i ustawia wyniki na 0 fo r (in t h = 1; h <= MaxCandidates; h++) candidate[h] = new P erson (in .n extL in e(), 0 ); VoteCount count = processVotes(candidate, MaxCandidates, in, o u t); p rin tR esu lts(ou t, candidate, MaxCandidates, count); i n .c lo s e () ; o u t.c lo s e (); } //koniec main public s t a t i c VoteCount processVotes(Person[] l i s t , in t max, Scanner in, PrintW riter out) { VoteCount votes = new VoteCount(0, 0 ); //liczby głosów ważnych i nieważnych ustawiamy na 0 int v = in .n e x tIn t(); while (v != 0) { i f (v < 1 || v > max) { out.printf("Nieważny głos: %d\n", v ); + + v o te s .s p o ilt; } e lse { ++l ist[v].num Votes; ++v o tes.v alid ; } v = in .n e x tIn t( ); } //koniec while return votes; } //koniec processVotes public s t a t i c void prin tR esu lts(P rin tW riter out, Person[] l i s t , int max, VoteCount votes) { out.printf("\ nL iczba głosujących: %d\n", v o tes.v alid + v o te s .s p o ilt); o u t.p rin tf("L icz b a prawidłowych głosów: %d\n", v o te s .v a lid ); o u t.p rin tf("L icz b a nieprawidłowych głosów: %d\n", v o te s .s p o ilt); out.printf("\nKandydat Głosów\n\n"); fo r (in t h = 1; h <= MaxCandidates; h++) o ut.p rin tf("% -18s %3d\n", list[h ].n a m e , list[h].num V otes); out.printf("\nZwycięzca (zw ycięzcy):\n"); int win = g e tL a r g e s t(lis t, 1, MaxCandidates); int winningVote = list[win].num Votes; fo r (in t h = 1; h <= MaxCandidates; h++) i f (list[h].num Votes == winningVote) o u t.p r in tf(" } //koniec printResults
%s\n", lis t[h ].n a m e );
public s t a t i c int getLargest(Person[] l i s t , int lo , in t hi) { int big = lo ; fo r (in t h = lo + 1; h <= h i; h++) i f (list[h].num Votes > list[big].num V otes) big = h; return big; } //koniec getLargest } //koniec klasy Voting class Person { String name; int numVotes;
78
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
Person(String s, int n) { name = s; numVotes = n; } } //koniec klasy Person class VoteCount { int val id, s p o ilt; VoteCount(int v, int s) { valid = v; sp o ilt = s; } } //koniec klasy VoteCount Gdybyśmy chcieli wyświetlać wyniki w kolejności alfabetycznej, moglibyśmy to zrobić, wywołując metodę selectio n S o rt (przedstawioną w podrozdziale 2.13): selection S ort(can d id ate, 1, MaxCandidates); Ten sam efekt moglibyśmy uzyskać, korzystając także z metody insertio nSort (przedstawionej w podrozdziale 2.13): insertionSort(cand id ate, 1, MaxCandidates); Załóżmy jednak, że chcielibyśmy wyświetlić kandydatów w kolejności m alejącej, ale na podstawie liczby otrzymanych głosów, innymi słowy tak, by zwycięzca znalazł się na początku listy. Aby to zrobić, obiekty z tablicy candi date muszą zostać posortowane w kolejności malejącej, na podstawie pola numVotes. Zadanie to można wykonać przy użyciu podanego niżej wywołania; przy czym zastosowana w nim metoda sortByVote korzysta z algorytmu sortowania przez wstawianie (choć mógłby to być dow oln y inny algorytm sortowania), a w jej kodzie używany jest parametr formalny o nazwie l i s t . sortByVote(candidate, 1, MaxCandidates); Poniżej przedstawiono kod metody sortByVote. public s t a t i c void sortByVote(Person[] l i s t , int lo , int hi) { //sortowanie komórek od list[lo] do list[hi] w kolejności rosnącej fo r (in t h = lo + 1; h <= h i; h++) { Person hold = l i s t [ h ] ; int k = h - 1; //rozpoczynamy porównywanie z wcześniejszymi elementami while (k >= lo && hold.numVotes > list[k].num Votes) { l i s t [ k + 1] = l ist[k ] ; --k ; } l is t [ k + 1] = hold; } //koniec fo r } //koniec sortByVote Załóżmy, że do kodu programu P2.5 dodaliśmy metodę sortByVote oraz następujące wywołanie: sortByVote(candidate, 1, MaxCandidates); które umieściliśmy bezpośrednio przed wywołaniem: p rin tR esu lts(ou t, candidate, MaxCandidates, count); Jeśli w takim przypadku wykonamy program, przekazując do niego te same dane, wygeneruje on wyniki przedstawione poniżej; jak widać, kandydaci zostali wyświetleni w malejącej kolejności otrzymanych głosów.
79
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Nieważny głos: 8 Nieważny głos: 9 Liczba głosujących: 30 Liczba prawidłowych głosów: 28 Liczba nieprawidłowych głosów: 2 Kandydat Albert Twaróg Stefan K alicki Norbert Krawczyk Tomasz Graczyk Darek Grochowski Jarosław Kot Damian Derczyński
Głosów 6 6 4 4 3 3 2
Zwycięzca (zwycięzcy): Albert Twaróg Stefan Kalicki
Ćwiczenia 1. Co oznacza termin stan obiektu? Co określa stan obiektu? 2. Jaka jest różnica pomiędzy klasą a obiektem? 3. Jaka jest różnica pomiędzy zmienną klasową a zmienną instancyjną? 4. Jaka jest różnica pomiędzy metodą klasową a metodą instancyjną? 5. Jaka jest różnica pomiędzy zmienną publiczną a prywatną? 6. Wyjaśnij, co się stanie po wykonaniu polecenia String S = new S trin g ("C z e ść");? 7. Jakie wartości są zapisywane w zmiennych instancyjnych podczas inicjalizacji obiektu? 8. Co to jest konstruktor bezargum entowy? W jaki sposób można go udostępnić w klasie? 9. W swojej klasie zdefiniowałeś konstruktor. Co musisz zrobić, by użyć konstruktora bezargumentowego? 10.
Czym jest hermetyzacja danych?
11. „Zmienna obiektowa nie zawiera obiektu". Wyjaśnij to stwierdzenie. 12.
Wyjaśnij znaczenie metody to S trin g () w języku Java.
13. Napisz program, który odczyta z pliku personalia (imię i nazwisko) osób oraz ich numery telefonów i zapisze je w tablicy obiektów. 14. Napisz program, który wczyta z pliku słowa polskie oraz ich angielskie odpowiedniki i zapisze je w tablicy obiektów. Poproś użytkownika o wpisanie kilku polskich słów. Dlakażdego z nich wyświetlodpowiadające mu słowo angielskie. Wybierz odpowiedni znacznik końca danych wejściowych. Słowa wpisywane przez użytkownika wyszukuj przy użyciu wyszukiwania binarnego. 15. Data składa się z dnia, miesiąca oraz roku. Napisz klasę reprezentującą datę i pozwalającą na wykonywanie na datach prostych operacji. Przykładowo napisz funkcję, do której przekazywane są dwie daty — d1 i d2 — i która zwraca -1, jeśli data d1 jest wcześniejsza od d2, 0, jeśli obie daty są równe, oraz 1, gdy data d1 jest późniejsza od d2. Napisz także funkcję, która będzie zwracać liczbę dni pomiędzy datą d2 i d1. Jeśli d2 wypada przed d1, funkcja ma zwracać wynik mniejszy od 0. Dodatkowo napisz funkcję wyświetlającą datę w wybranym formacie.
80
ROZDZIAŁ 2. ■ WPROWADZENIE DO OBIEKTÓW
16. Zegar w formacie 24-godzinnym jest reprezentowany przez dwie liczby; np. 16 45 odpowiada godzinie 4:45 po południu. Korzystając z obiektu reprezentującego godzinę, napisz funkcję, która na podstawie dwóch obiektów, t1 i t2, zwraca liczbę minut pomiędzy t1 i t2. Jeśli np. zostaną przekazane czasy 16 45 oraz 23 25, funkcja powinna zwrócić wartość 400. 17. Rozważmy problem operacji na ułamkach reprezentowanych przy użyciu pary liczb całkowitych — pierwszej, określającej wartość licznika, oraz drugiej, określającej wartość mianownika. Przykładowo ułamek 5/9 będzie reprezentowany przez parę liczb 5 i 9. Napisz klasę służącą do wykonywania operacji na ułamkach. Napisz metody pozwalające na dodawanie, odejmowanie, mnożenie oraz dzielenie ułamków. Dodatkowo napisz metodę służącą do redukcji ułamków; będziesz musiał w tym celu wyznaczyć największy wspólny dzielnik dwóch liczb całkowitych. 18. Księgarnia musi przechowywać informacje o książkach. Dla każdej książki chcemy przechowywać informacje o autorze, tytule, cenie oraz liczbie dostępnych egzemplarzy. Personel księgarni w dowolnej chwili musi także wiedzieć, ile egzemplarzy obiektów książek zostało utworzonych. Napisz kod klasy Book, opierając się przy tym na następujących założeniach. • Napisz konstruktor bezargumentowy, który w polu autora zapisze łańcuch "Brak autora", w polu tytułu — łańcuch "Brak tytu łu ", a cenie oraz liczbie dostępnych egzemplarzy przypisze wartość 0. • Napisz konstruktor, do którego będzie można przekazać cztery argumenty — autora, tytuł, cenę i liczbę egzemplarzy — i który na ich podstawie utworzy obiekt Book. Cena nie może być mniejsza od 5 zł, a liczba egzemplarzy nie może być ujemna. Jeśli którykolwiek z tych warunków nie będzie spełniony, w polach ceny i liczby egzemplarzy należy zapisać 0. •
Klasa ma posiadać akcesory dla pól autora i ceny.
• Napisz metodę ustawiającą cenę książki; wartość ta będzie przekazywana jako argument wywołania. Jeśli podana cena będzie mniejsza od 5 zł, nie należy zmieniać ceny książki przechowywanej w obiekcie. • Napisz metodę zmniejszającą liczbę dostępnych egzemplarzy książki o podaną wartość. Jeśli wykonanie tej operacji miałoby sprawić, że liczba dostępnych egzemplarzy będzie mniejsza od zera, nie należy jej zmieniać. • Napisz metodę instancyjną wyświetlającą dane książki, przy czym wartość każdego pola ma być wyświetlona w odrębnym wierszu. • Napisz metodę to S trin g () zwracającą łańcuch znaków, który po wyświetleniu pokaże dane książki, przy czym każde pole będzie wyświetlone w odrębnym wierszu. •
Napisz metodę equals, która zwróci true, jeśli z aw a rto śćdwóch obiektów Book będzie taka sama; w przeciwnym razie metoda ma zwracać wartość false.
• Napisz klasę Test, która utworzy trzy obiekty Book zawierające dowolnie wybrane dane, wyświetli zawartość tych obiektów oraz liczbę utworzonych obiektów. 19. Test wielokrotnego wyboru składa się z dwudziestu pytań. Dla każdego pytania podanych jest pięć odpowiedzi, oznaczonych jako A, B, C, D i E. Pierwszy wiersz pliku z danymi zawiera prawidłowe odpowiedzi na dwadzieścia pytań, zapisane jako dwadzieścia liter umieszczonych bezpośredn io jedna za drugą. Oto przykład: BECDCBAADEBACBAEDDBE Każdy kolejny wiersz zawiera odpowiedzi podane przez jednego zdającego. Wiersz rozpoczyna się identyfikatorem osoby (czyli liczbą całkowitą), po którym umieszczony jest jeden lub kilka znaków odstępu, a następnie dwadzieścia znaków reprezentujących odpowiedzi, zapisanych bezpośredn io jeden za drugim. Jeśli osoba zdająca test nie odpowiedziała na pytanie, jest to oznaczane literą X. Plik danych nosi nazwę exam .data; możesz założyć, że wszystkie umieszczone w nim dane są prawidłowe. Oto przykładowy wiersz danych: 4325 BECDCBAXDEBACCAEDXBE Test zdaje nie więcej niż sto osób. Wiersz, w którym identyfikator osoby zdającej ma wartość 0, oznacza koniec zbioru danych. Punkty za odpowiedzi są przyznawane w następujący sposób: za dobrą odpowiedź zdający otrzymuje 4 punkty, za złą------ 1 punkt, za brak odpowiedzi — 0 punktów.
81
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Napisz program, który będzie przetwarzał dane wejściowe z pliku i wyświetlał raport zawierający numer osoby zdającej oraz liczbę punktów uzyskanych z testu. Wyniki mają być p osortow an e w kolejności rosn ącej na p o d staw ie identyfikatorów. (To samo ćwiczenie było w rozdziale 1., jednak teraz należy je rozwiązać, korzystając z obiektów). 20. Plik danych zawiera informacje rejestracyjne dotyczące sześciu kursów: CS20A, CS21A, CS29A, CS30A, CS35A oraz CS36A. Każdy wiersz danych zawiera siedmiocyfrowy identyfikator studenta, a następnie sześć wartości, z których każda jest liczbą 0 lub 1. Kolejność tych liczb ma znaczenie. Liczba 1 oznacza, że dany student jest zarejestrowany na kursie, a 0, że nie będzie w nim uczestniczył. A zatem ciąg liczb 1 0 1 0 1 1 oznacza, że student będzie uczestniczył w kursach CS20A, CS29A, CS35A oraz CS36A, lecz nie będzie brał udziału w kursach CS21A i CS30A. Można przyjąć, że liczba studentów nie przekracza stu, a dane rejestracyjne kończą się identyfikatorem studenta o wartości 0. Napisz program, który wczyta dane rejestracyjne i utworzy listy studentów zapisanych na poszczególne kursy. Każda lista ma się zaczynać na nowej stronie i zawierać numery identyfikacyjne studentów zapisanych na dany kurs. Nic nie stoi na przeszkodzie, by tej samej nazwy, equals, używać do porównywania obiektów String oraz obiektów Part. Jeśli metoda ta zostanie wywołana na rzecz obiektu Part, wykonana będzie metoda zdefiniowana w klasie Part. Jeśli metoda equal s zostanie wywołana na rzecz obiektu String, wykonana będzie metoda zdefiniowana w klasie String.
82
ROZDZIAŁ 3
Listy powiązane W tym rozdziale wyjaśnione zostaną takie zagadnienia jak: • notacja list powiązanych, • sposób pisania deklaracji do pracy z listami powiązanymi, • liczenie węzłów w liście powiązanej, • wyszukiwanie elementów w liście powiązanej, • znajdowanie ostatniego węzła listy powiązanej, • różnice pomiędzy dynamicznym a statycznym przydzielaniem pamięci, • tworzenie listy powiązanej poprzez dodawanie nowych elementów na jej końcu, • wstawianie węzła wewnątrz listy powiązanej, • tworzenie listy powiązanej poprzez dodawanie nowego elementu na jej początku, • usuwanie elementów z listy powiązanej, • tworzenie listy poprzez dodawanie do niej nowych elementów w taki sposób, by zawsze były posortowane, • sposoby organizowania plików źródłowych w języku Java, • zastosowanie list powiązanych do określenia, czy fraza jest palindromem, • sposoby zapisu list powiązanych, • różnice pomiędzy przechowywaniem elementów na liście oraz w tablicy, • reprezentacja list powiązanych przy użyciu tablic, • scalanie dwóch posortowanych list powiązanych, • listy cykliczne oraz listy podwójnie powiązane (dwukierunkowe).
3.1. Definiowanie list powiązanych Kiedy wartości są zapisane w tablicy jednowymiarowej (o zakresie np. od x[0] do x[n]), można je sobie wyobrazić, jakby były zorganizowane w formie „listy liniowej”. Wyobraźmy sobie, że każdy element na liście jest „węzłem”. Termin „lista liniowa” oznacza, że węzły listy są uporządkowane w kolejności liniowej, w sposób spełniający następujące założenia:
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
x[1] x[n] je ś l i je ś l i
je s t pierwszym węzłem lis t y je s t ostatnim węzłem lis t y 1 < k <= n, to x[k] je s t poprzedzane przez x [k - 1] 1 <= k < n, to x[k] poprzedza x[k + 1 ].
A zatem, jeśli dysponujemy węzłem, to „następny” węzeł, o ile — oczywiście — w ogóle będzie istniał, będzie kolejnym elementem tablicy. Kolejność węzłów odpowiada kolejności występowania elementów w tablicy i zaczyna się od jej pierwszego elementu. Rozpatrzymy teraz problem wstawiania nowego węzła pomiędzy dwa węzły już istniejące — x[k] oraz x[k + 1]. Można to zrobić wyłącznie wtedy, gdy węzeł x[k + 1] oraz wszystkie węzły położone za nim zostaną przesunięte, by zrobić miejsce dla nowego węzła. I podobnie, usunięcie węzła x[k] wiąże się z przesunięciem węzłów x[k + 1], x[k + 2] itd. Dostęp do konkretnego węzła jest bardzo łatwy — trzeba tylko podać odpowiedni indeks. W wielu sytuacjach do reprezentowania list liniowych używamy tablic. Jednak można je także ukazywać przy użyciu reprezentacji, w której każdy węzeł takiej listy jaw n ie odwołuje się do następnego. Listy reprezentowane w taki sposób nazywane są listami powiązanymi. Każdy węzeł listy powiązanej (pojedynczo) zawiera wskaźnik do następnego węzła. Każdy z takich węzłów można sobie wyobrazić w następujący sposób:
data
next
Element dane może w rzeczywistości być jednym polem lub kilkoma polami (zależnie od tego, jakie informacje mają być przechowywane na liście), natomiast element next „wskazuje na” następny węzeł listy. (Zamiast nazw data oraz next można użyć dowolnych innych). Ponieważ pole next ostatniego węzła nie wskazuje na nic, należy w nim zapisać specjalną wartość, nazywaną wskaźnikiem pustym. W języku Java taki wskaźnik to n u ll. Potrzebna jest także, oprócz poszczególnych elementów listy, zmienna obiektowa (nazwiemy ją top), która będzie „wskazywać na” pierwszy element tej listy. Dla listy pustej zmienna ta będzie zawierać wartość null. Listę powiązaną można zilustrować graficznie w sposób przedstawiony na rysunku 3.1.
Rysunek 3.1. Lista p o w iąz an a Elektryczny symbol uziemienia jest używany do oznaczenia wskaźnika pustego.
TL Przeglądanie listy powiązanej przypomina nieco poszukiwanie skarbów. Dysponujemy inform acją o położeniu jej pierwszego elementu. Znamy je dzięki zmiennej top. Kiedy dotrzemy do pierwszego elementu, znajdujemy w nim informację o położeniu drugiego (jest ona zapisana w polu next). Kiedy dotrzemy do drugiego elementu listy, odczytamy z niego położenie trzeciego (także w tym przypadku użyjemy pola next) itd. Kiedy dotrzemy do ostatniego elementu listy, wskaźnik pusty będzie informacją, że poszukiwania zostały zakończone (doszliśmy do końca listy). W jaki sposób można reprezentować listy powiązane w programach pisanych w języku Java? Ponieważ każdy węzeł składa się z przynajmniej dwóch pól, ich postać będziemy musieli zdefiniować przy użyciu klasy. Komponent data może się składać z jednego lub kilku pól (z których każde może być obiektem składającym się z kilku pól). Typ tych pól będzie zależał od rodzaju informacji, które chcemy przechowywać w węźle. Ale jakiego typu ma być pole next? W iemy, że jest to wskaźnik, ale na co? Otóż, jest to wskaźnik na obiekt bliźniaczo podobny do tego, który właśnie definiujemy! Mówimy, że element listy jest przykładem struktury, która odw ołu je się sa m a do siebie. W ramach przykładu załóżmy, że informacją zapisywaną w każdym węźle jest dodatnia liczba całkowita. W takim przypadku klasę, której będziemy używali do tworzenia węzłów listy, można zdefiniować w poniższy sposób (przy czym użyjemy w niej pola num, a nie data).
84
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
class Node { int num; Node n ex t; } Zmienną top możemy zatem zadeklarować tak: Node top; Jak już wcześniej wyjaśniono, deklaracja zmiennej top przydziela pamięć konieczną do jej przechowania, lecz nie przydziela pamięci dla żadnego węzła listy. W artością zmiennej top może być adres obiektu Node, lecz na razie żadnego takiego obiektu nie utworzyliśmy. Jak wiadomo, obiekt Node można utworzyć i zapisać w zmiennej top przy użyciu następującej instrukcji: top = new Node(); W efekcie powstanie lista w postaci:
top num
next
W arto sobie przypomnieć, że w momencie tworzenia obiektu język Java zainicjalizuje pola liczbowe, zapisując w nich wartość 0, natomiast w pola typów obiektowych zostanie zapisana wartość n u ll. Sposób tworzenia list powiązanych pokazany został dalej w tym rozdziale, wcześniej jednak zademonstrowano kilka podstawowych operacji, jakie można na nich wykonywać.
3.2. Proste operacje na listach powiązanych Do celów demonstracyjnych przyjmiemy, że dysponujemy listą powiązaną zawierającą liczby całkowite. Na razie nie będzie nas interesował problem, j a k taką listę należy utworzyć.
3.2.1. Zliczanie węzłów w liście Prawdopodobnie najprostszą operacją, jaką można wykonać na liście powiązanej, jest zliczenie tworzących ją węzłów. W celu przedstawienia tej operacji napisaliśmy funkcję, do której przekazywany jest wskaźnik na początek listy i która zwraca liczbę węzłów listy. Zanim pokażemy kod funkcji, zobaczmy, w jaki sposób można poruszać się pomiędzy jej elementami; zaczniemy od pierwszego z nich. Załóżmy, że zmienna top wskazuje początek listy. Przeanalizujemy teraz następujący fragment kodu: Node curr = top; while (curr != null) curr = cu rr.n ex t; Początkowo zmienna curr wskazuje na pierwszy element listy, jeśli w ogóle istnieje. Jeśli wartość tej zmiennej jest różna od null, zostanie wykonana instrukcja: curr = cu rr.n ex t; Zapisuje ona w zmiennej curr „dowolny węzeł, na który wskazuje aktualnie analizowany węzeł”; innymi słowy, zapisuje w niej następny węzeł listy. Przeanalizujmy listę o następującej postaci:
top
W
36
w
W
15
w
r
52
wF
23
85
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
• Początkowo zmienna curr wskazuje (węzeł zawierający) 36. Ponieważ wartość curr jest różna od null, zostaje wykonana instrukcja curr = curr.next, która zapisuje w curr dowolny węzeł wskazywany przez 36, w naszym przypadku będzie to (węzeł zawierający) 15. • Ponownie zostaje sprawdzony warunek w pętli whil e. Ponieważ wartość curr jest różna od n u ll, zostaje wykonana instrukcja curr = curr.next, która zapisuje w curr dowolny węzeł wskazywany przez 15, a w naszym przypadku będzie to 52. • Ponownie jest sprawdzany warunek w pętli whi le. Ponieważ wartość curr jest różna od n u ll, zostaje wykonana instrukcja curr = curr.next, która zapisuje w curr dowolny węzeł wskazywany przez 52, w naszym przypadku będzie to 23. • Ponownie jest sprawdzany warunek w pętli whi le. Ponieważ wartość curr jest różna od n u ll, zostaje wykonana instrukcja curr = curr .next, która zapisuje w curr dowolny węzeł wskazywany przez 23, a w naszym przypadku będzie to nul l . • Ponownie jest sprawdzany warunek w pętli whi le. Ponieważ wartość curr wynosi n u ll, zatem wykonywanie pętli zostaje zakończone. Należy zauważyć, że ciało pętli while zostaje wykonane za każdym razem, gdy zmienna curr jest różna od n u ll. Liczba przypadków, gdy wartość zmiennej curr jest różna od nu ll, odpowiada liczbie elementów listy. Innymi słowy, aby policzyć elementy listy, wystarczy policzyć, ile razy zostało wykonane ciało pętli while. W tym celu użyjemy licznika o wartości początkowej wynoszącej 0, który będzie inkrementowany wewnątrz pętli whi l e. Funkcję zliczającą ilość elementów listy możemy zatem zapisać tak: public s t a t i c int length(Node top) { int n = 0; Node curr = top; while (curr != null) { n++; curr = cu rr.n ex t; } return n; } W arto zauważyć, że jeśli lista będzie pusta, początkowa wartość zmiennej curr wyniesie n u ll, a pętla while w ogóle nie zostanie wykonana. W takim przypadku funkcja zwróci 0, czyli prawidłowy wynik. Właściwie zmienna curr w powyższej funkcji nie jest niezbędna. Funkcja będzie działać prawidłowo, jeśli pominiemy tę zmienną i zastąpimy ją parametrem top. W takim przypadku w momencie kończenia działania funkcji wartość top będzie równa n u ll. Można by się obawiać, że takie rozwiązanie doprowadzi do utraty dostępu do listy. Tak się jednak nie stanie. Trzeba pamiętać, że top w funkcji l ength jest jedynie kopią zawartości dowolnej zmiennej (dajmy na to zmiennej head), która w kodzie wywołującym wskazuje na początek listy. Zmiana zawartości parametru top nie ma żadnego wpływu na zawartość zmiennej head. Gdy wykonanie funkcji length zostanie zakończone, zmienna head wciąż będzie wskazywać na pierwszy element listy.
3.2.2. Przeszukiwanie listy powiązanej Kolejną powszechnie wykonywaną operacją jest przeszukiwanie listy w celu określenia, czy znajduje się na niej element o podanej wartości. Załóżmy np., że chcemy sprawdzić, czy na poniższej liście znajduje się liczba 52.
top
W
36
w
W
15
w r
52
w r
23
Operacja wyszukiwania powinna poinformować, że liczba 52 znajduje się na liście. Z drugiej strony, gdybyśmy spróbowali wyszukać liczbę 25, powinniśmy uzyskać informację, że nie ma jej na liście.
86
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
Załóżmy, że poszukiwana przez nas liczba została zapisana w zmiennej key. Przeszukiwanie będzie zatem polegać na porównywaniu zmiennej key z kolejnymi liczbami zapisanymi na liście; zaczynać się będzie od pierwszej z nich. Jeśli wartość key oraz liczba zapisana w analizowanym elemencie listy będą równe, będzie to oznaczało, że poszukiwana liczba została znaleziona. Jeśli jednak dotrzemy do końca listy, a poszukiwana wartość nie zostanie odnaleziona, możemy uznać, że nie ma jej na liście. Logika operacji wyszukiwania musi działać w taki sposób, by sprawdzanie listy kończyło się w momencie odnalezienia poszukiwanej wartości lub dotarcia do końca listy. Innymi słowy, poszukiwania są kontynuowane, jeśli nie dotarliśmy do końca listy i nie znaleźliśmy poszukiwanej wartości. Jeśli założymy, że zmienna curr wskazuje na sprawdzany element listy, taką logikę można wyrazić w poniższy sposób: while (curr != null && key != curr.num) curr = cu rr.n ex t; Język Java gwarantuje, że wyrażenia połączone operatorem &&będą przetwarzane od lewej do prawej, a przetwarzanie zostanie zakończone natychmiast wtedy, gdy okaże się, że cały warunek będzie prawdziwy. W naszym przypadku będzie to oznaczało, że przetwarzanie warunku zakończy się, kiedy któryś z jego operandów przyjmie wartość fa lse . W przeciwnym razie zostanie wykonany cały warunek. Możemy to wykorzystać, sprawdzając najpierw warunek curr != n u ll. Dzięki temu, jeśli zmienna curr faktycznie będzie równa n u ll, operator &&przyjmie wartość fa ls e , a drugie wyrażenie warunkowe, key != curr.num, w ogóle nie zostanie przetworzone. Gdybyśmy zapisali wyrażenia warunkowe w odwrotnej kolejności (jak pokazano na następnym przykładzie) i gdyby zmienna curr przyjęła wartość null, próba przetworzenia wyrażenia curr .num doprowadziłaby do awarii programu: while (key != curr.num && curr != null) curr = cu rr.n ex t; //błąd! Powyższy warunek żąda odczytania liczby wskazywanej przez zmienną curr, ale zmienna ta ma wartość null i na nic nie wskazuje. W takich przypadkach mówimy, że „próbujemy odwołać się do wskaźnika null ”, a to jest błąd. Zaimplementujemy teraz operację wyszukiwania w postaci funkcji, do której przekazywany jest wskaźnik na początek listy oraz poszukiwana wartość i która zwraca węzeł zawierający tę wartość, pod warunkiem że uda się go odnaleźć. Jeśli taki węzeł nie zostanie odnaleziony, funkcja zwróci wartość null. Wykorzystamy deklarację klasy Node przedstawioną w poprzednim punkcie rozdziału. Nasza funkcja będzie zatem zwracać wartość typu Node. Poniżej przedstawiono jej kod. public s t a t i c Node search(Node top, int key) { while (top != null && key != top.num) top = top.next; return top; } Jeśli wartości określonej parametrem key nie ma na liście, top przyjmie wartość n u ll, więc funkcja zwróci null. Jeśli jednak poszukiwana wartość będzie dostępna na liście, działanie pętli while zostanie zakończone, gdy parametr key będzie równy top.num; w tym momencie top będzie wskazywał na węzeł zawierający wartość key i to właśnie ta wartość top zostanie zwrócona jako wynik funkcji.
3.2.3. Określanie ostatniego węzła listy Czasami będziemy musieli znaleźć wskaźnik na ostatni element listy. W iadom o już, że jest to węzeł, w którym pole next ma wartość null. Poniżej przedstawiono funkcję, która zwraca wskaźnik na ostatni węzeł listy. Jeśli lista jest pusta, funkcja zwróci wartość n u ll. public s t a t i c Node getLast(Node top) { i f (top == null) return n u ll; while (top.next != null) top = top.next; return top; }
87
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Pętla while może zostać wykonana wyłącznie wtedy, gdy parametr top będzie różny od n u ll. W takim przypadku sprawdzenie wartości top.next nie ma sensu. Jeśli jednak pole top.next będzie różne od n u ll, zostanie wykonana wewnątrz pętli instrukcja, która zapisze w top wartość różną od null. Dzięki tem u możemy mieć pewność, że w chwili, gdy ponownie zostanie sprawdzony warunek pętli, będzie można określić jego wartość. Kiedy pole top.next przyjmie wartość null, będzie to oznaczało, że top wskazuje na ostatni element listy. Ta wartość top zostanie zatem zwrócona jako wynik funkcji.
3.3. Tworzenie listy powiązanej: dodawanie elementów na końcu listy Rozpatrzmy problem tworzenia listy powiązanej zawierającej dodatnie liczby całkowite w kolejności, w jakiej są one podawane. Załóżmy, że liczby są podawane w następującej kolejności (a podanie wartości 0 kończy sekwencję danych): 36 15 52 23 0 Chcemy utworzyć listę w następującej postaci:
top
W
36
wF
15
w
F
52
w
F
23
Pierwsze pytanie, na które musimy odpowiedzieć, dotyczy liczby węzłów na liście. Ta — oczywiście — zależy od liczby wpisanych danych. Jedną z wad stosowania tablic do przechowywania list liniowych jest konieczność określenia wielkości tablicy w momencie jej tworzenia. Jeśli po uruchomieniu programu okaże się, że podano więcej danych, niż można zapisać w tablicy, może to oznaczać, że trzeba będzie go przerwać. Gdy jednak trzeba do listy powiązanej dodać nowy element, rezerwowany jest odpowiedni obszar pamięci i ustawiane odpowiednie wskaźniki. Dzięki temu możemy przydzielić dokładnie tyle pamięci, ile trzeba do przechowania listy — ani więcej, ani mniej. To fakt, że lista używa nieco więcej pam ięci na przechowywanie dodatkowych wskaźników, jednak niedogodność tę z nawiązką rekom pensuje efektywność wykorzystania pam ięci oraz prostota operacji wstawiania i usuwania elementów listy. Przydzielanie pamięci „w razie konieczności” jest zazwyczaj nazywane dynamicznym przydzielaniem pamięci. (Pamięć przydzielana dla tablic jest nazywana pamięcią statyczną). W przedstawionym wcześniej sposobie tworzenia list początkowo dysponujemy listą pustą. W kodzie programu zostanie to odzwierciedlone poprzez zastosowanie instrukcji: top = n u ll; Po odczytaniu nowej liczby musimy wykonać poniższe operacje: • przydzielić pamięć dla węzła; • zapisać w węźle odczytaną liczbę; • zapisać nowy węzeł jako ostatni węzeł listy. Skorzystamy z klasy Node przedstawionej w podrozdziale 3.1 i dodamy do niej konstruktor pobierający jeden argument będący liczbą całkowitą. Konstruktor ten zapisze przekazaną liczbę w polu num obiektu, natomiast w polu next zapisze wartość null. public Node(int n) { num = n; next = nul l ; }
88
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
A teraz przeanalizujmy następującą instrukcję: Node p = new Node(36); Przede wszystkim rezerwuje ona pamięć dla nowego węzła. Przy założeniu, że zarówno liczba typu int, jak i wskaźnik zajmują 4 bajty pamięci, wielkość obiektu Node będzie wynosić 8 bajtów. A zatem powyższa instrukcja zarezerwuje 8 bajtów pamięci, zaczynając np. od adresu 4000. W polu num nowego obiektu zostanie zapisana wartość 36, a w polu next wartość null. Nasz obiekt wygląda teraz tak:
next 4000
36
Następnie w zmiennej p zostanie zapisana wartość 4000, co sprawi, że w efekcie zmienna ta będzie wskazywać na nowo utworzony obiekt. Ponieważ zazwyczaj sam adres — wartość 4000 — nie ma dla nas znaczenia, możemy ją usunąć i przedstawić sytuację tak, jak na poniższym schemacie.
Innymi słowy, zmienna p wskazuje na nowy obiekt, niezależnie od tego, jaki by nie był. Po odczytaniu pierwszej liczby musimy utworzyć dla niej węzeł, a następnie zapisać go w zmiennej top. W naszym przykładzie, gdy odczytamy liczbę 36, musimy utworzyć strukturę w postaci:
Możemy to zrobić przy użyciu następującej instrukcji i założeniu, że n zawiera odczytaną liczbę: i f (top == null) top = new Node(n); W komputerach nie ma strzałek, lecz ten sam efekt uzyskiwany jest po zastosowaniu wskaźników (zakładamy, że nowy węzeł listy został zapisany w komórce o adresie 4000):
top
4000 4000
36
Podczas dodawania każdej kolejnej liczby w polu next ostatniego (w danej chwili) węzła listy musimy zapisać wskaźnik nowego węzła. Oznacza to, że nowy węzeł stanie się jednocześnie ostatnim węzłem listy. Załóżmy, że wpisaliśmy liczbę 15. W efekcie uzyskamy dane w następującej postaci:
Ale w jaki sposób określimy ten ostatni węzeł listy? Jednym ze sposobów jest rozpoczęcie jej przeglądania od pierwszego elementu i pobieranie wskaźników zapisanych w polach next, aż do m om entu gdy dotrzemy do pola z wartością null. Gdybyśmy musieli wykonywać te operacje dla każdej nowej liczby dodawanej do listy, takie rozwiązanie byłoby dosyć czasochłonne. Znacznie lepszym pomysłem jest przechowywanie wskaźnika do ostatniego elementu listy (np. w zmiennej la s t). Taki wskaźnik należy aktualizować po dodaniu do listy kolejnego węzła. Kod służący do dodawania do listy nowego węzła należałoby zatem napisać w następujący sposób:
89
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
np = new Node(n); i f (top == null) top = np; e lse la s t.n e x t = np; la s t = np;
//tworzymy nowy węzeł //jeśli to pierwszy element, zapisujemy go w top //dla wszystkich pozostały węzłów zapisujemy //go w last.next //aktualizujemy last, zapisując w niej nowy węzeł
Załóżmy, że lista składa się z tylko jednego węzła, który jest także zapisany w zmiennej la s t. W naszym przykładzie zmienna ta będzie zawierać adres 4000. Dodatkowo załóżmy, że nowy węzeł, zawierający liczbę 15, jest zapisany w pamięci, począwszy od adresu 2000. Sytuację przedstawiono na poniższym rysunku.
top
4000
lost
36
4000
2000
4000
Powyższy kod ustawi wartość pola next w obiekcie zapisanym pod adresem 4000 na 2000, a oprócz tego zapisze adres 2000 w zmiennej la s t. W efekcie dane w naszym programie będą miały następującą postać:
top
4000 4000
lost
36
2000
2000 2000
Teraz zmienna top (4000) wskazuje na węzeł zawierający liczbę 36; pole next tego węzła zawiera adres 2000, a zatem wskazuje na węzeł zawierający wartość 15. Z kolei w tym węźle pole next ma wartość n u ll, co oznacza koniec listy. W zmiennej la s t zapisana jest wartość 2000, czyli adres ostatniego węzła listy. Program P3.1 wczytuje liczby i tworzy listę powiązaną w sposób, który przed chwilą został opisany. Aby upewnić się, że lista została prawidłowo utworzona, musimy wyświetlić jej zawartość. Do tego celu służy funkcja p rin tL ist, która odczytuje kolejno węzły listy, od pierwszego do ostatniego, i wyświetla zapisane w nich wartości. Program P3.1 import ja v a .u t i l . * ; public c la ss BuildList1 { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Node top, np, la s t = n u ll; top = n u ll; System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ wpisywanie 0\n") int n = in .n e x tIn t(); while (n != 0) { np = new Node(n); //tworzymy nowy węzeł zawierający n i f (top == null) top = np; //określamy zmienną top, jeśli to nowy węzeł e lse l a st.n e x t = np; //dla pozostałych węzłów ustawiamy last.next la s t = np; //zapisujemy nowy węzeł w zmiennej last n = in .n e x tIn t( ); } System .out.printf("\nO to liczb y zapisane na l iś c ie :\ n " ) ; p rin tL ist(to p ); } //koniec main public s t a t i c void printList(Node top) { while (top != null) { //dopóki są dalsze węzły System .out.printf("% d " , top.num); top = top.n ext; //przechodzimy do następnego węzła } S y stem .o u t.p rin tf("\ n "); } //koniec printList
90
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
} //koniec klasy BuildList1 class Node { int num; Node n ex t; public Node(int n) { num = n; next = n u ll; } } //koniec klasy Node Aby upewnić się, czy lista została prawidłowo utworzona, musimy wyświetlić jej zawartość. Metoda p rin tL ist odczytuje kolejne elementy, od pierwszego do ostatniego, i wyświetla liczbę zapisaną w danym węźle. Poniżej przedstawiono przykładowe wyniki wykonania programu P3.1. Podaj kilka licz b całkowitych i zakończ wpisywanie 0 9 1 8 2 7 3 6 4 5 0 Oto liczb y zapisane na l i ś c i e : 9 1 8 2 7 3 6 4 5
3.4. Wstawianie elementów do list powiązanych Lista, której poszczególne węzły zawierają jeden wskaźnik, jest nazywana listą jednokierunkową lub jednokrotnie powiązaną. Jedną z ważnych cech takich list jest to, że dostęp do nich uzyskujemy za pośrednictwem wskaźnika „początku listy” oraz pól ze wskaźnikami, dostępnymi w poszczególnych węzłach. (Nic jednak nie stoi na przeszkodzie, by inne, jawne wskaźniki odwoływały się do wybranych, innych miejsc listy; za przykład może posłużyć przedstawiony wcześniej wskaźnik la s t odwołujący się do ostatniego węzła na liście). Jedynym sposobem, by dostać się do czwartego węzła danych przedstawionych poniżej, będzie przejście przez węzły 1., 2. oraz 3. Ponieważ nie jesteśmy w stanie dostać się do fc-tego elementu listy, do przeszukiwania list nie będziemy mogli używać algorytmu wyszukiwania binarnego. Zaletą list są bardzo proste operacje wstawiania i usuwania elementów, i to niezależnie od ich położenia na liście. Załóżmy, że chcemy wstawić nowy węzeł i umieścić go pomiędzy 2. i 3. Operację tę można wyobrazić sobie w prosty sposób jako wstawienie nowego węzła za 2. węzłem listy. Załóżmy np., że zmienna prev wskazuje na 2. węzeł, a zmienna np na węzeł, który chcemy dodać do listy. Postać danych programu przedstawiono na rysunku 3.2. np.
.
.
prev W
15 1.
w W
23 2.
36 3.
wr
52 4.
Rysunek 3.2. W staw ianie nowego w ęzła do listy Aby wstawić taki węzeł do listy, w jego polu next musimy zapisać wskaźnik do 3. węzła listy, a w polu next 2. węzła musimy zapisać wskaźnik do węzła, który chcemy dodać. W arto zwrócić uwagę, że wszystkim, czego potrzebujemy do przeprowadzenia operacji, jest 2. węzeł listy — to jego pole next udostępni wskaźnik na 3. węzeł listy. np.next = prev.next; prev.next = np;
91
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Pierwsza z tych dwóch instrukcji stwierdza: „niech nowy węzeł wskazuje na to samo, na co aktualnie wskazuje 2. węzeł”. Z kolei druga z nich stwierdza: „2. węzeł listy ma wskazywać na nowy węzeł”. W efekcie wykonania tych dwóch instrukcji nowy węzeł zostanie wstawiony pomiędzy 2. i 3. węzłem listy. Ten nowy węzeł stanie się 3. węzłem listy, natomiast ten, który wcześniej był 3., po wykonaniu tej operacji stanie się 4. węzłem listy. W ten sposób struktura danych z rysunku 3.2 zmieni się w strukturę przedstawioną na rysunku 3.3.
Rysunek 3.3. Lista p o wstaw ieniu nowego w ęzła Czy ten kod będzie działał prawidłowo w sytuacji, gdy zmienna prev będzie wskazywać na ostatni węzeł listy, czyli w sytuacji, gdy będziemy chcieli wstawić nowy węzeł za jej końcem? Tak, także w tym przypadku kod będzie działał prawidłowo. Jeśli prev wskaże na ostatni węzeł listy, pole prev. next będzie zawierać n u ll; a zatem nowy węzeł stanie się ostatnim węzłem listy: np.next = prev.next; Podobnie jak wcześniej, także i teraz w polu prev.next zapisywany jest wskaźnik na nowy węzeł. Można to przedstawić, zmieniając schemat:
np
top
69
prev
___ V
15
23
W
36
w
52
Często zdarza się, że nowy węzeł musi być umieszczony na samym początku listy; innymi słowy chcemy, by nowy węzeł stał się 1. węzłem listy. Zakładamy, że zmienna np wskazuje na ten nowy węzeł, i chcemy zmienić listę w postaci:
w listę:
92
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
Można to zrobić, używając następującego fragmentu kodu: np.next = top; top = np; Pierwsza instrukcja sprawia, że nowy węzeł będzie wskazywał na węzeł, którego adres jest zapisany w zmiennej top, natomiast druga zapisuje w zmiennej top adres nowego węzła. W arto zwrócić uwagę, że powyższy kod będzie działał nawet wtedy, kiedy początkowo lista jest pusta (czyli wtedy, gdy zmienna top zawiera n u ll). W takim przypadku powyższy kod przekształca dane w postaci:
na listę:
3.5. Tworzenie listy powiązanej: dodawanie elementu na początku listy Ponownie rozpatrzymy problem tworzenia listy powiązanej zawierającej dodatnie liczby całkowite, jednak tym razem każdą nową liczbę będziemy wstawiali na początku, a nie na końcu listy. W efekcie uzyskamy liczby zapisane w kolejności odwrotnej do tej, w jakiej zostały wpisane. Załóżmy, że podane zostały następujące liczby (0 kończy wpisywanie danych). 36 15 52 23 0 Chcemy utworzyć listę:
Okazuje się, że program tworzący listę w odwrotnej kolejności jest prostszy od przedstawionego wcześniej. Jest niemal identyczny z programem P3.1. Jedyne zmiany są umieszczone wewnątrz pętli while. W momencie odczytania każdej liczby zapisujemy ją w nowym obiekcie, wraz z adresem 1. węzła listy, a następnie adres nowego węzła zapisujemy w zmiennej top, dzięki czemu staje się on początkiem listy. Takie zmiany zostały wprowadzone w programie P3.2, zaimplementowanym jako klasa BuildList2. Program P3.2 import ja v a .u t i l . * ; public c la ss BuildList2 { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Node to p , np, la s t = n u ll; top = n u ll; System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ wpisywanie 0\n"); int n = in .n e x tIn t(); while (n != 0) {
93
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
np = new Node(n); np.next = top; top = np; n = in .n e x t In t ();
//tworzymy nowy węzeł zawierający n //zapisujemy w nim adres pierwszego węzła //zapisujemy w top adres nowego węzła
} System .out.printf("\nO to liczb y zapisane na l iś c ie :\ n " ) ; p rin tL ist(to p ); } //koniec main public s t a t ic void printList(Node top) { while (top != null) { //dopóki jest węzeł System .out.printf("% d " , top.num); top = top .n ext; //przechodzimy do następnego węzła } S y stem .o u t.p rin tf("\ n "); } //koniec printList } //koniec klasy BuildList2 class Node { int num; Node next; public Node(int n) { num = n; next = n u ll; } } //koniec klasy Node A oto przykładowe wyniki wykonania tego programu. Podaj kilka licz b całkowitych i zakończ wpisywanie 0 9 1 8 2 7 3 6 4 5 0 Oto liczb y zapisane na l i ś c i e : 5 4 6 3 7 2 8 1 9 Program P3.1 dodaje kolejne liczby na końcu listy. To przykład dodawania elementów do kolejki. Kolejka jest listą liniow ą, do której elem enty są dodawane na jednym końcu, a usuwane na drugim (więcej inform acji na ten tem at można znaleźć w następnym podrozdziale). Program P3.2 dodaje elementy na początku listy. Jest przykładem dodawania elementów do stosu. Stos to lista liniowa, w której dodawanie i usuwanie elementów jest wykonywane w tym sam ym miejscu. W terminologii związanej ze stosami dodawanie elementów to odkładanie na stos, natomiast usuwanie to zdejmowanie ze stosu. Stosami i kolejkami zajmiemy się bardziej szczegółowo w rozdziale 4.
3.6. Usuwanie elementów z list powiązanych Usunięcie elementu z samego początku listy sprowadza się do wykonania jednej instrukcji: top = top.next; Nakazuje ona zapisać w zmiennej top adres, na który wskazywał 1. węzeł listy (czyli adres jej 2. węzła, jeśli taki w ogóle istnieje). A zatem, ponieważ po wykonaniu tej instrukcji zmienna top wskazuje na drugi element listy, pierwszy został z niej w zasadzie usunięty. W ykonanie tej instrukcji zmienia listę:
94
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
na listę w postaci:
top
Oczywiście, przed rozpoczęciem operacji powinniśmy sprawdzić, czy na liście je s t coś, co można usunąć; innymi słowy, czy zmienna top nie zawiera wartości n u ll. Jeśli lista składa się z tylko jednego elementu, jego usunięcie doprowadzi do powstania listy pustej, a zmienna top przyjmie wartość n u ll. Usunięcie z listy konkretnego węzła wymaga nieco więcej informacji. Załóżmy, że zmienna curr (skrót od angielskiego słowa current — bieżący) wskazuje na węzeł, który chcemy usunąć. Jego usunięcie wymaga jednak zmiany pola next wcześniejszego węzła listy. Oznacza to, że musimy znać wskaźnik do wcześniejszego węzła; zakładamy, że jest on zapisany w zmiennej prev (skrót od angielskiego słowa previous — wcześniejszy). W takim przypadku węzeł curr można usunąć przy użyciu instrukcji: prev.next = cu rr.n ex t; Zmienia ona listę w początkowej postaci:
top
na listę w postaci:
top
W praktyce węzeł wskazywany przez curr nie znajduje się już na liście — został z niej usunięty. Można się zastanawiać, co się dzieje z usuniętymi węzłami. W naszych rozważaniach usunięcie oznacza „logiczne usunięcie elementu z listy”. Znaczy to, że — z punktu przetwarzania listy — usunięte z niej węzły nie będą już na niej dostępne. Jednak węzły te wciąż będą istniały, zajmując miejsce w pamięci, pomimo faktu, że program nie dysponuje już wskaźnikami do nich. Jeśli dysponujemy dużą listą, której elementy są często usuwane, w pamięci pojawi się sporo takich „usuniętych” węzłów. Będą zajmowały miejsce, choć już nigdy nie będzie można ich przetworzyć. Język Java zawiera rozwiązanie tego problemu — jest nim automatyczne odzyskiwanie pamięci (ang. au tom atic g arbag e collection). Od czasu do czasu Java sprawdza, czy w pamięci znajdują się jakieś „nieosiągalne” węzły i usuwa je, odzyskując w ten sposób niepotrzebnie zajmowane obszary pamięci. Programiści nigdy nie muszą się przejmować tymi „usuniętymi” węzłami.
3.7. Tworzenie posortowanej listy powiązanej W ram ach trzeciego rozwiązania, które przeanalizujemy, załóżmy, że chcem y utworzyć listę zawierającą liczby całkowite, zapisane w kolejności rosnącej. Przyjm ijmy, że wpisane zostały następujące liczby (wartość 0 kończy wpisywanie danych wejściowych): 3 6 15 52 23 0
95
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Chcemy uzyskać listę w postaci:
Liczba po odczytaniu zostaje dodana do istniejącej listy (która początkowo jest pusta) w odpowiednim miejscu. Pierwsza liczba jest zwyczajnie dodawana do pustej listy. Każda kolejna liczba jest porównywana z liczbami zapisanymi na liście. Tak długo, jak długo nowa liczba jest większa od aktualnie analizowanego węzła listy, przechodzimy do następnego. Robimy to, aż do znalezienia węzła zawierającego liczbę większą lub równą nowej liczbie dodawanej do listy bądź do momentu dotarcia do końca listy. Aby ułatwić sobie wstawianie nowej liczby, zanim opuścimy bieżący węzeł i przejdziemy do następnego, zapisujemy adres bieżącego węzła, na wypadek gdyby okazało się, że nowa liczba musi być umieszczona za nim. Jednak tego możemy dowiedzieć się wyłącznie wtedy, kiedy porównamy nową liczbę z liczbą zapisaną w następnym węźle listy. Aby zilustrować te operacje, załóżmy, że dysponujemy listą powiązaną w następującej postaci oraz chcemy dodać do niej nową liczbę (dajmy na to 30) w taki sposób, by lista pozostała posortowana.
Załóżmy także, że liczby podane nad każdym z węzłów określają jego adres. A zatem wartością zmiennej top jest 400. Na samym początku porównujemy liczbę 30 z 15. Ponieważ pierwsza z nich jest większa, przechodzimy do następnego węzła, który zawiera liczbę 23; zapisujemy adres węzła z liczbą 15 (czyli 400). Następnie porównujemy liczbę 30 z 23. Ponieważ pierwsza z nich jest większa, przechodzimy do następnego węzła, który zawiera liczbę 36; zapisujemy adres węzła z liczbą 23 (czyli 200). W tym momencie adres węzła z liczbą 15 (czyli 400) nie jest już potrzebny. Następnie porównujemy liczbę 30 z 36. Ponieważ pierwsza z nich jest mniejsza, odnaleźliśmy liczbę, p rz ed którą należy umieścić nową liczbę (30). Jest to równoznaczne z dodaniem liczby 30 za liczbą 23. Ponieważ zapamiętaliśmy adres węzła z liczbą 23, możemy dodać do listy nowy węzeł. Do przetworzenia nowej liczby (n) zastosujemy następujący fragment kodu. prev = nul l ; curr = top; while (curr != null && n > curr.num) { prev = curr; curr = cu rr.n ex t; } Początkowo zmienna prev ma wartość null, a zmienna curr zawiera 400. Proces dodania do listy liczby 30 przebiega w następujący sposób.
96
•
Porównujemy 30 z wartością pola curr.num, czyli 15. Ponieważ30 jest większe, zapisujemy w prev wartość curr (400), a w curr wartość curr.next, czyli 200; wartość zmiennej curr jest różna od n u ll.
•
Porównujemy 30 z wartością pola curr.num, czyli 23. Ponieważ30 jest większe, zatem zapisujemy w prev wartość curr (200), a w curr wartość curr.next, czyli 800; wartość zmiennej curr jest różna od nul l .
•
Porównujemy 30 z wartością pola curr.num, czyli 36. Ponieważ30 jest mniejsze, zatem pętla while zostaje zakończona; w efekcie wartość zmiennej prev wynosi 200, a zmiennej curr 800.
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
A zatem po zakończeniu pętli while sytuacja wygląda tak, jak na poniższym schemacie.
top 400 15
200 w
23
prev
800 w
36
600
... -W
52
curr
Jeśli nowy węzeł jest zapisany w zmiennej np, możemy go dodać do listy przy użyciu poniższego kodu (nie możemy z niego skorzystać w przypadku dodawania elementu na początku listy — tym przypadkiem zajmiemy się dalej w tym rozdziale). np.next = curr; //moglibyśmy także użyć prev.next zamiast curr prev.next = np; Spowoduje to zmianę danych w postaci:
na następującą listę:
top
15
23
prev
w F
30
np
36
F
52
curr
W ramach ćwiczenia warto sprawdzić, czy ten kod będzie działał prawidłowo, gdy dodawana liczba będzie większa od wszystkich liczb na liście. Wskazówka: trzeba sprawdzić, czy działanie pętli while zostanie zakończone. Jeśli dodawana liczba jest m niejsza od wszystkich liczb, które już są zapisane na liście, musi zostać umieszczona na samym początku i stać się pierwszym węzłem listy. To oznacza, że wartość zmiennej top musi ulec zmianie, tak by wskazywała na nowy węzeł. Przedstawiona wcześniej pętla while będzie działać także w tym przypadku. Podczas pierwszego sprawdzenia jej warunek przyjmie wartość fa ls e (ponieważ wartość n będzie mniejsza od wartości pola curr.num). Kończąc operację, wystarczy sprawdzić, czy wartością zmiennej prev wciąż jest n u ll; jeśli tak, będzie to oznaczać, że nowy węzeł musi być dodany na początku listy. Gdyby lista była początkowo pusta, działanie pętli while zostanie natychmiast zakończone (ponieważ zmienna curr ma wartość null). W takim przypadku nowy węzeł musi być wstawiony na początku listy i stanie się jedynym jej elementem. Kod takiego rozwiązania przedstawiono w programie P3.3. Wstawienie nowego węzła w odpowiednim miejscu jest realizowane przez funkcję addInPlace, która zwraca wskaźnik zmodyfikowanej listy. Program P3.3 import ja v a .u t i l . * ; public c la ss BuildList3 { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Node to p , np, la s t = n u ll; top = n u ll; System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ wpisywanie 0\n");
97
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
int n = in .n e x tIn t(); while (n != 0) { top = addInPlace(top, n); n = in .n e x t In t (); } p rin tL ist(to p ); } //koniec main public s t a t ic Node addInPlace(Node top, in t n) { // ta funkcja wstawia n w odpowiednie miejsce uporządkowanej listy // (która początkowo może być pusta) wskazywanej przez top, funkcja // zwraca wskaźnik na nową listę Node np, curr, prev; np = new Node(n); prev = n u ll; curr = top; while (curr != null && n > curr.num) { prev = curr; curr = cu rr.n ex t; } np.next = curr; i f (prev == null) return np; //top wskazuje nowy węzeł prev.next = np; return top; //początek listy (top) nie zmienił się } //koniec addInPlace public s t a t i c void printList(Node top) { while (top != null) { //dopóki jest węzeł System .out.printf("% d " , top.num); top = top .n ext; //przechodzimy do następnego węzła } S y stem .o u t.p rin tf("\ n "); } //koniec printList } //koniec klasy BuildList3 class Node { int num; Node next; public Node(int n) { num = n; next = n u ll; } } //koniec klasy Node Po uruchomieniu program P3.3 tworzy posortowaną listę podanych liczb, a następnie wyświetla te liczby w kolejności, w jakiej są zapisane na liście. Oto przykładowe wyniki wykonania tego programu. Podaj kilka licz b całkowitych 9 1 8 2 7 3 6 4 50 1 2 3 4 5 6 7 8 9
98
i zakończ wpisywanie 0
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
3.8. Klasa listy powiązanej W tym rozdziale opisano już wiele podstawowych idei związanych z przetwarzaniem list powiązanych i przedstawiono implementacje operacji najczęściej wykonywanych na listach. Wykorzystano przy tym metody statyczne (p rin tL ist, addInPlace), przekazując do nich węzeł będący „początkiem listy”. Spróbujmy teraz nieco zmienić punkt widzenia. Naszym celem jest napisanie „klasy reprezentującej listę powiązaną”, która pozwoli tworzyć „obiekty listy”; tych z kolei będziemy mogli używać do pracy z listami powiązanymi. Na początek musimy odpowiedzieć sobie, co definiuje listę powiązaną. To dosyć łatwe. Listę definiuje zmienna (obiekt), a w zasadzie wskaźnik, określający pierwszy węzeł listy. A zatem nasza klasa będzie się zaczynać w następujący sposób. public c la ss LinkedList { Node head = n u ll;
} //koniec klasy LinkedList Pole head będzie zmienną określającą „początek listy”. Podczas wykonywania instrukcji, takiej jak przedstawiona poniżej, język Java przypisze polu head początkową wartość n u ll, jednak w klasie robimy to jawnie, aby zwrócić uwagę na jego początkową wartość. LinkedList LL = new L inkedL ist(); A w jaki sposób zdefiniujemy węzeł listy reprezentowany przez klasę Node? Cóż, to zależy od rodzaju elementów („danych”), które chcemy przechowywać na liście. Jeśli będziemy chcieli przechowywać liczby całkowite, możemy tę klasę zdefiniować w następujący sposób. class Node { int num; Node next; } Gdybyśmy chcieli przechować znaki, definicja powinna wyglądać tak: class Node { char ch; Node next; } Gdyby z kolei zależało nam na utworzeniu listy części, moglibyśmy użyć definicji: class Node { Part p art; Node next; } Jak widać, każda zmiana rodzaju informacji gromadzonych na liście powodowałaby konieczność zmiany definicji klasy Node. Jednocześnie musielibyśmy zmieniać także metody listy, gdyby zależały one od rodzaju gromadzonych na niej informacji. Przeanalizujmy np. metodę służącą do dodawania nowego węzła na początku listy liczb całkowitych. public void addHead(int n) { Node p = new Node(n); //zakładamy, że Node dysponuje odpowiednim konstruktorem p.next = head; head = p; }
99
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Można jej używać w następujący sposób (przy założeniu, że LL jest obiektem LinkedLi st): LL.addHead(25); Takie wywołanie spowoduje umieszczenie na początku listy LL węzła zawierającego liczbę 25. Gdybyśmy jednak chcieli utworzyć listę znaków, musielibyśmy zmienić pierwszy wiersz definicji metody addHead w następujący sposób: public void addHead(char c) A w przypadku listy części musiałby on przyjąć postać: public void addHead(Part p) Gdyby takich metod operujących na liście było więcej, konieczność zmieniania ich wszystkich, za każdym razem gdy zmieni się rodzaj danych przechowywanych na liście, byłaby dosyć uciążliwa. Dlatego też zastosujemy rozwiązanie pozwalające zminimalizować liczbę zmian, które trzeba będzie wprowadzać w kodzie klasy LinkedList. Zdefiniujmy klasę Node w następujący sposób. class Node { NodeData data; Node next; public Node(NodeData nd) { data = nd; next = n u ll; } } //koniec klasy Node Jak widać, zastosowaliśmy w niej niezdefiniowany jeszcze typ danych — NodeData. Klasa ta definiuje dwa pola, data oraz next. Bez jakiejkolwiek dodatkowej wiedzy na temat typu NodeData możemy napisać metodę addHead w następujący sposób. public void addHead(NodeData nd) { Node p = new Node(nd); p.next = head; head = p; } Klasa (dajmy na to T estL ist), która chciałaby używać klasy LinkedList, musiałaby udostępnić jej definicję typu NodeData. Załóżmy, że chcemy utworzyć listę liczb całkowitych. W takim przypadku moglibyśmy zdefiniować typ NodeData w następujący sposób (dalej w tym rozdziale wyjaśniono, dlaczego niezbędna jest w nim metoda toString). public c la ss NodeData { int num; public NodeData(int n) { num = n; } public String to S trin g () { return num + " "; //łańcuch ""jest potrzebny, by skonwertować num do postaci //łańcucha znaków; można także użyć "" (łańcuchapustego) } } //koniec klasy NodeData
100
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
Listę liczb zapisanych w odwrotnej kolejności moglibyśmy utworzyć przy użyciu następującego fragmentu kodu. LinkedList LL = new L inkedL ist(); System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ wpisywanie 0\n"); int n = in .n e x tIn t(); while (n != 0) { LL.addHead(new NodeData(n)); //konieczny jest argument typu NodeData n = in .n e x tIn t( ); } Należy zwrócić uwagę, że metoda addHead wymaga argumentu typu NodeData, musimy zatem utworzyć obiekt NodeData zawierający liczbę całkowitą n i to właśnie ten obiekt musimy przekazać w wywołaniu metody addHead. A w jaki sposób możemy wyświetlić elementy umieszczone na liście? Przypuszczalnie napisalibyśmy w tym celu metodę (np. p rin tL ist) i umieścili ją w klasie LinkedList. Jednak w jaki sposób możemy wyświetlać dane przechowywane w węzłach, skoro klasa LinkedList „nie wie”, co zawierają obiekty NodeData (a co więcej, mogą się one zmieniać w poszczególnych zastosowaniach listy)? Cała sztuczka polega na tym, by zadanie wyświetlenia danych wykonały same obiekty NodeData przy użyciu swojej metody toString. Oto sposób, w jaki możemy wyświetlić zawartość listy. public void p rin tL ist() { Node curr = head; while (curr != null) { S ystem .ou t.p rin tf("% s", c u rr.d a ta ); //wywołuje curr.data.toString() curr = cu rr.n ex t; } S y stem .o u t.p rin tf("\ n "); } //koniec printList Wiadomo już, że curr.data jest obiektem typu NodeData. Ponieważ użyliśmy go w kontekście wymagającym łańcucha znaków, dlatego Java poszuka w klasie NodeData metody to S tri ng. Ponieważ metoda ta jest dostępna, zostanie wywołana w celu wyświetlenia obiektu NodeData. Można by także zmienić nieco wywołanie p rin tf i jawnie odwołać się w nim do metody toString: System .out.printf("% s " , c u r r .d a ta .to S tr in g ()); Przy założeniu, że LL jest listą typu LinkedList, możemy wyświetlić jej zawartość, używając wywołania: L L .p rin tL is t(); Na razie nasza klasa LinkedList ma następującą postać. public c la ss LinkedList { Node head = n u ll; public void addHead(NodeData nd) { Node p = new Node(nd); p.next = head; head = p; } public void p rin tL ist() { Node curr = head; while (curr != null) { S ystem .ou t.p rin tf("% s", c u rr.d a ta ); //wywołuje curr.data.toString() curr = cu rr.n ex t; } S y stem .o u t.p rin tf("\ n "); } //koniec printList } //koniec klasy LinkedList
101
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Możemy teraz dodać do niej metodę, która sprawdzi, czy lista jest pusta. public boolean empty() { return head == n u ll; } Jeśli LL jest listą typu LinkedLi st, metody empty możemy użyć w następujący sposób: while (!LL.empty()) { . . . Załóżmy teraz, że chcemy dodać do klasy LinkedLi st metodę, która pozwoli na tworzenie „posortowanej” listy. Także w tym przypadku powstaje pytanie, jak zdefiniować „porządek sortowania” listy, skoro klasa LinkedList „nie wie”, jakie dane zawierają obiekty NodeData. I ponownie rozwiązanie polega na tym, by pozwolić klasie NodeData na określenie, kiedy jeden obiekt tego typu będzie większy od innego obiektu NodeData, mniejszy od niego lub mu równy. W tym celu zdefiniujemy w klasie NodeData odpowiednią metodę instancyjną (nazwiemy ją compareTo). Oto jej kod. public in t compareTo(NodeData nd) { i f (this.num == nd.num) return 0; i f (this.num < nd.num) return -1 ; return 1; } //koniec compareTo W kodzie metody compareTo po raz pierwszy użyliśmy słowa kluczowego th is. Jeśli a i b są dwoma obiektami NodeData, możemy użyć wywołania a.compareTo(b). W takiej metodzie słowo kluczowe th is odwołuje się do obiektu, na rzecz którego metoda została wywołana. A zatem w przypadku naszego wywołania this.num reprezentuje a.num. W arto zwrócić uwagę, że powyższa metoda działałaby prawidłowo także bez stosowania słowa kluczowego th is, gdyż num domyślnie zostałoby potraktowane jako odwołanie do pola a.num. Ponieważ nasza klasa NodeData definiuje tylko jedno pole będące liczbą całkowitą, metoda compareTo ogranicza się do porównania dwóch liczb całkowitych. Wyrażenie a.compareTo(b) zwraca 0, jeśli a.num jest równe b.num, wartość -1, jeśli a.num jest mniejsze od b.num, oraz 1, jeśli a.num jest większe od b.num. Teraz możemy zmienić metodę addInPlace, wykorzystując w niej metodę compareTo. public void addInPlace(NodeData nd) { Node np, curr, prev; np = new Node(nd); prev = nul l ; curr = head; while (curr != null && nd.compareTo(curr.data) > 0) { //nowa wartośćjest większa prev = curr; curr = cu rr.n ex t; } np.next = curr; i f (prev == null) head = np; e lse prev.next = np; } //koniec addInPlace Jeśli LL jest listą LinkedList, metodę addInPl ace możemy wywołać w taki sposób: LL.addInPlace(new NodeData(25)); Powyższa instrukcja tworzy nowy węzeł — obiekt Node — zawierający obiekt NodeData, w którym zostaje zapisana wartość 25; a następnie dodaje ten węzeł do listy w taki sposób, że jej zawartość będzie posortowana rosnąco. Program P3.4 wczytuje liczby całkowite, tworzy listę powiązaną, której zawartość jest posortowana rosnąco, a następnie ją wyświetla. Jak widać, w jego kodzie pominęliśmy słowa kluczowe public w definicjach klas NodeData, Node oraz LinkedList. Było to konieczne, by można było umieścić wszystkie te klasy w jednym
102
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
pliku źródłowym — LinkedListT est.java — gdyż publiczną klasą jest LinkedListTest. W iem y już, że w języku Java w danym pliku źródłowym można umieścić tylko jedną klasę publiczną. W ięcej inform acji na ten temat można znaleźć w podrozdziale 3.9. Program 3.4 import ja v a .u t i l . * ; public c la ss LinkedListTest { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); LinkedList LL = new L inkedL ist(); System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ wpisywanie 0\n"); int n = in .n e x tIn t(); while (n != 0) { LL.addInPlace(new NodeData(n)); n = in .n e x tIn t( ); } L L .p rin tL is t(); } //koniec main } //koniec klasy LinkedListTest class NodeData { int num; public NodeData(int n) { num = n; } public in t compareTo(NodeData nd) { i f (this.num == nd.num) return 0; i f (this.num < nd.num) return -1 ; return 1; } //koniec compareTo public String to S trin g () { return num + " "; //łańcuch ""jest potrzebny, by skonwertować num do postaci //łańcucha znaków; można także użyć "" (łańcuchapustego) } } //koniec klasy NodeData class Node { NodeData data; Node next; public Node(NodeData nd) { data = nd; next = n u ll; } } //koniec klasy Node class LinkedList { Node head = n u ll; public boolean empty() { return head == n u ll; } public void addHead(NodeData nd) {
103
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Node p = new Node(nd); p.next = head; head = p; } public void addInPlace(NodeData nd) { Node np, curr, prev; np = new Node(nd); prev = n u ll; curr = head; while (curr != null && nd.compareTo(curr.data) > 0) { //nowa wartośćjest większa prev = curr; curr = cu rr.n ex t; } np.next = curr; i f (prev == null) head = np; e lse prev.next = np; } //koniec addInPlace public void p rin tL ist() { Node curr = head; while (curr != null) { S ystem .ou t.p rin tf("% s", curr.d ata) ; //wywołuje curr.data.toString() curr = cu rr.n ex t; } S y stem .o u t.p rin tf("\ n "); } //koniec printList } //koniec klasy LinkedList Poniżej przedstawiono przykładowe wyniki wykonania programu P3.4. Podaj kilka licz b całkowitych 9 1 8 2 7 3 6 4 50 1 2 3 4 5 6 7 8 9
i zakończ wpisywanie 0
3.9. Jak zorganizować pliki źródłowe Javy? W poprzednim podrozdziale używaliśmy w sumie czterech klas — LinkedList, Node, NodeData oraz LinkedListTest — i wspomnieliśmy, że aby umieścić je w jednym pliku (by wspólnie utworzyły program P3.4), konieczne jest usunięcie słowa kluczowego public ze wszystkich klas, z wyjątkiem LinkedListTest. Poniżej podsumowano jeszcze raz niektóre z podanych wcześniej komentarzy i wyjaśniono, w jaki sposób można umieścić poszczególne klasy w odrębnych plikach. Klasę LinkedListTest możemy umieścić w pliku, który musiałby mieć nazwę LinkedListT est.java. Trzeba pamiętać, że klasa publiczna x musi być zapisana w pliku o nazwie x.java. Inne klasy można zapisać w tym samym pliku, pod warunkiem że ich deklaracje będą się zaczynały od publ i c xxx, a nie od publi c c la ss xxx. Aby można było używać tych klas w innych klasach, zorganizujemy je w nieco inny sposób. Klasę NodeData zadeklarujemy jako publiczną i umieścimy w odrębnym pliku. Będzie on nosił nazwę N odeD ata.java i na razie umieścimy w nim następujący kod. class NodeData { int num; public NodeData(int n) {
104
ROZDZIAŁ 3. M LISTY POWIĄZANE
num = n; j public in t compareTo(NodeData nd) j i f (this.num == nd.num) return O; i f (this.num < nd.num) return - i ; return i ; j //koniec compareTo public String to S trin g () j return num + " "; //łańcuch " "jest potrzebny, by skonwertować num do postaci //łańcucha znaków; można także użyć "" (łańcuchapustego) j j //koniec klasy NodeData Klasę LinkedList zadeklarujemy jako publiczną i umieścimy w osobnym pliku o nazwie L inkedList.java. Ponieważ klasa Node jest używana wyłącznie przez klasę LinkedList, w jej deklaracji pominiemy słowo kluczowe publi c i umieścimy ją w tym samym pliku. A zatem plik Lin kedL ist.java będzie aktualnie zawierał kod dwóch klas. class LinkedList j Node head = null ; public boolean empty() j return head == n u ll; j public void addHead(NodeData nd) j Node p = new Node(nd); p.next = head; head = p; j public void addInPlace(NodeData nd) j Node np, curr, prev; np = new Node(nd); prev = n u ll; curr = head; while (curr != null && nd.compareTo(curr.data) > O) j //nowa wartośćjest większa prev = curr; curr = cu rr.n ex t; j np.next = curr; i f (prev == null) head = np; e lse prev.next = np; j //koniec addInPlace public void p rin tL ist() j Node curr = head; while (curr != null) j S ystem .ou t.p rin tf("% s", curr.d ata) ; //wywołuje curr.data.toString() curr = cu rr.n ex t; j S y stem .o u t.p rin tf("\ n "); j //koniec printList
105
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
} //koniec klasy LinkedList class Node { NodeData data; Node next; public Node(NodeData nd) { data = nd; next = n u ll; } } //koniec klasy Node Należy zauważyć, że gdyby inne klasy musiały korzystać z klasy Node, najlepszym rozwiązaniem byłoby zadeklarowanie jej jako publicznej i umieszczenie w osobnym pliku.
3.10. Rozszerzanie klasy LinkedList Aby przygotować się do kolejnego przykładu, rozbudujemy klasę LinkedList, dodając do niej kilka kolejnych metod. Pierwszą z nich jest funkcja getHeadData, która zwraca pole data pierwszego węzła listy (jeśli taki w ogóle istnieje). public NodeData getHeadData() { i f (head == null) return n u ll; return head.data; } Kolejna metoda, del eteHead, usuwa pierwszy węzeł listy (jeśli taki istnieje). public void deleteHead() { i f (head != null) head = head.next; } Metoda addT a il dodaje nowy węzeł na końcu listy. Odnajduje ostatni węzeł listy (czyli ten, którego pole next ma wartość null ) i zmienia go tak, by wskazywał na nowy węzeł. public void addTail(NodeData nd) { Node p = new Node(nd); i f (head == null) head = p; e lse { Node curr = head; while (cu rr.next != null) curr = cu rr.n ex t; curr.next = p; } } //koniec addTail Metoda copyList tworzy kopię listy, na rzecz której została wywołana, i zwraca tę kopię. public LinkedList copyList() { LinkedList temp = new L inkedL ist(); Node curr = th is.h ead ; while (curr != null) { tem p.addTail(curr.data); curr = cu rr.n ex t; } return temp; } //koniec copyList
106
ROZDZIAŁ 3. M LISTY POWIĄZANE
Kolejna metoda, rev erseL ist, zmienia kolejność węzłów na liście. Operuje ona na oryginalnej liście, a nie na jej kopii. public void re v e rseL ist() { Node p1, p2, p3; i f (head == null || head.next == null) return; pi = head; p2 = p i.n ex t; p i.n ex t = n u ll; while (p2 != null) { p3 = p2.next; p2.next = p i; pi = p2; p2 = p3; } head = p i; } //koniec reverseList Funkcja equals porównuje dwie listy. Jeśli Li i L2 są dwoma obiektami LinkedList, wyrażenie Li.equals(L2) zwróci true wyłącznie w przypadku, gdy obie listy będą miały takie same elementy, zapisane w takiej samej kolejności; w przeciwnym razie wyrażenie zwróci wartość fa lse . public boolean equals(LinkedList LL) { Node t i = this.head ; Node t2 = LL.head; while ( t i != null && t2 != null) { i f (ti.d ata.com pareTo(t2.d ata) != 0) return fa ls e ; t i = t i.n e x t ; t2 = t2 .n e x t; } i f ( t i != null || t2 != null) return fa ls e ; //jeśli jedna z list się skończyła, //ale druga nie return tru e; } //k o n iec equals
3.11. Przykład: palindromy Przeanalizujmy problem określania, czy podany łańcuch znaków jest palindromem (czyli ma taką samą postać czytany od przodu oraz od tyłu). Poniżej przedstawiono kilka przykładów palindromów (należy w nich zignorować wielkość liter, odstępy oraz znaki przestankowe). Inni winni! a tu mam mamuta Zakopane na pokaz kule ma Mamel uk Gdyby wszystkie litery w słowie były tej samej wielkości (wielkie lub małe) i gdyby łańcuch znaków (dajmy na to słowo auto) nie zawierał żadnych odstępów ani znaków przestankowych, to postawiony problem m oglibyśm y rozw iązać w następujący sposób. porównać pierwszą i o sta tn ią l it e r ę je ś l i są różne, to łańcuch nie je s t palindromem je ś l i są takie same, porównać drugą i przedostatnią l i t e r ę łańcucha je ś l i są różne, to łańcuch nie je s t palindromem je ś l i są takie same, porównać trz e c ią i trz e c ią od końca l it e r ę łańcucha
107
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Te operacje należałoby powtarzać, aż do momentu odnalezienia pierwszej pary liter, które się od siebie różnią (co będzie oznaczać, że łańcuch nie jest palindromem), lub do momentu, gdy w łańcuchu nie będzie już żadnych par do porównania (co będzie oznaczało, że łańcuch jest palindromem). Ta metoda jest efektywna, lecz wymaga możliwości bezpośredniego dostępu do każdej litery łańcucha. Jest to możliwe, gdy słowo będzie zapisane w tablicy, a w odwołaniach do poszczególnych znaków zastosujemy indeksy. Jeśli jednak litery tworzące słowo zostaną zapisane na liście powiązanej, wykorzystanie takiego rozwiązania nie będzie możliwe, gdyż będziemy mogli odwoływać się do nich wyłącznie sekwencyjnie. Aby pokazać możliwości manipulowania listami powiązanymi, użyjemy ich do rozwiązania naszego problemu, a zrobimy to w następujący sposób. 1. Zapiszemy oryginalny łańcuch na liście powiązanej, umieszczając po jednym znaku w węźle. 2. Utworzymy kolejną listę zawierającą wyłącznie litery z oryginalnego łańcucha, skonwertowane do postaci małych liter; tę listę zapiszemy w zmiennej lis t 1 . 3. Odwrócimy kolejność znaków na liście l is t1 i tę nową listę zapiszemy w zmiennej l is t 2 . 4. Będziemy porównywać kolejne węzły list l i s t 1 i l i s t 2 , aż do momentu, gdy zapisane w nich litery będą różne (co będzie oznaczać, że łańcuch nie jest palindromem), lub gdy dotrzemy do końca list (w tym przypadku będziemy wiedzieć, że łańcuch jest palindromem). W ramach przykładu sprawdzimy łańcuch znaków Inni winni!. Zapiszemy go w liście o następującej postaci: head
W kroku 2. powyższa lista zostanie przekształcona do następującej postaci:
W kroku 3. zostanie utworzona kolejna lista, zawierająca te same znaki zapisane w odwrotnej kolejności:
Porównanie list l i s t 1 i l i s t 2 pokaże, że łańcuch Inni winni! jest palindromem. Naszym zadaniem jest napisanie programu, który poprosi użytkownika o podanie łańcucha znaków, a później sprawdzi, czy jest on palindromem. Następnie poprosi o podanie kolejnego łańcucha. Aby zakończyć działanie programu, wystarczy zamiast wpisywania łańcucha nacisnąć klawisz Enter. Oto przykładowe wyniki wykonania tego programu. Wpisz łańcuch. (Aby skończyć, Łańcuch został zamieniony na: To palindrom Wpisz łańcuch. (Aby skończyć, Łańcuch został zamieniony na: To palindrom Wpisz łańcuch. (Aby skończyć, Łańcuch został zamieniony na: To nie je s t palindrom Wpisz łańcuch. (Aby skończyć, Łańcuch został zamieniony na: To palindrom Wpisz łańcuch. (Aby skończyć,
108
n a ciśn ij 'E n t e r ') : a tu mamy mamuta a t u ma my ma mu t a n a ciśn ij 'E n t e r ') : Zakopane na pokaz z a k o p a n e n a p o k a z n a ciśn ij 'E n t e r ') : Mameluk kule ma ma me l u k k u l e ma n a c iśn ij 'E n t e r ') : inni winni! i n n i wi n n i n a c iśn ij 'E n te r '):
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
W poprzednich przykładach tworzyliśmy listy liczb całkowitych. W tym przypadku musimy jednak utworzyć listę znaków. Jeśli wszystko zrobiliśmy, tak jak trzeba, niezbędne zmiany będziemy musieli wprowadzić wyłącznie w klasie NodeData. Nie powinniśmy natomiast znaleźć się w sytuacji, która zmusi do wprowadzenia jakichkolw iek zmian w klasie LinkedList. I zapewne taka sytuacja się nie pojawi. A tak powinien wyglądać kod klasy NodeData. public c la ss NodeData { char ch; public NodeData(char c) { ch = c; } public char getData() {return ch;} public in t compareTo(NodeData nd) { i f (th is.c h == nd.ch) return 0; i f (th is.c h < nd.ch) return -1 ; return 1; } public String to S trin g () { return ch + " " ; } } //koniec klasy NodeData Do klasy NodeData dodaliśmy akcesor, getData, który zwraca informacje przechowywane w jedynym polu danych — ch. Pozostałe modyfikacje są w całości związane ze zmianą typu danej z int na char. Teraz napiszemy funkcję getPhrase, która pobierze tekst i zapisze tworzące go znaki na liście, po jednym w każdym węźle. Funkcja ta będzie zwracać utworzoną listę. Lista musi zawierać znaki zapisane w takiej samej kolejności, w jakiej użytkownik je wpisywał — czyli każdy nowy znak będzie zapisywany na jej końcu. Aby wykonać swoje zadanie, funkcja najpierw wczytuje cały wpisany łańcuch znaków do zmiennej typu S tr i ng, używając metody nextLine. Następnie, zaczynając do ostatniego znaku łańcucha i poruszając się w kierunku jego początku, dodaje kolejne znaki łańcucha do listy i umieszcza każdy z nich na jej początku . (Moglibyśmy także zaczynać od pierwszego znaku łańcucha i dodawać każdy z nich na końcu listy, jednak wymagałoby to większego nakładu pracy). A oto kod funkcji getPhrase. public s t a t i c LinkedList getPhrase(Scanner in) { LinkedList phrase = new L inkedL ist(); String s t r = in .n e x tL in e(); fo r (in t h = s tr.le n g th () - 1; h >= 0; h--) phrase.addHead(new N odeD ata(str.charA t(h))); return phrase; } //koniec getPhrase Kolejną funkcją, którą napiszemy, będzie l ettersLower. Będziemy do niej przekazywać listę znaków, a ona zwróci inną listę, zawierającą wyłącznie litery i to skonwertowane do postaci małych liter. Jej działanie jest proste, funkcja ta zamienia każdą napotkaną literę na małą i dodaje na końcu nowej listy, wywołując w tym celu metodę addTail. Oto kod funkcji l ettersLower. public s t a t i c LinkedList lettersLow er(LinkedList phrase) { LinkedList word = new L inkedL ist(); while (!phrase.em pty()) { char ch = phrase.getH eadData().getData(); i f (C h a ra cte r.isL e tter(ch )) word.addTail(new NodeData(Character.toLowerCase(ch))); phrase.deleteH ead(); } return word; } //koniec lettersLower
109
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Wyrażenie phrase.getHeadData() zwraca pole data (typu NodeData) pierwszego węzła listy. Z kolei akcesor getData zdefiniowany w klasie NodeData zwraca znak przechowywany w danym węźle. Teraz dysponujemy już wszystkimi metodami koniecznymi do napisania programu P3.5, który stanowi rozwiązanie naszego problemu palindromów. Zakładamy w nim, że klasy NodeData oraz LinkedList są publiczne, a ich kod został umieszczony w osobnych plikach. Program 3.5 import ja v a .u t i l . * ; public c la ss Palindrome j public s t a tic void m ain(String[] args) j Scanner in = new Scanner(System .in); System .out.printf("W pisz łańcuch. (Aby skończyć, n a c iśn ij 'E n t e r ') : " ) ; LinkedList aPhrase = getP hrase(in ); while (!aPhrase.em pty()) j LinkedList wl = lettersLow er(aPhrase); System .out.printf("Łańcuch został zamieniony na: " ) ; w l.p r in tL is t(); LinkedList wZ = w l.co p y L ist(); w Z .rev erseL ist(); i f (wl.equals(wZ)) System .ou t.prin tf("To palindrom\n"); e lse System .ou t.printf("To nie je s t palindrom\n"); System .out.printf("W pisz łańcuch. (Aby skończyć, n a c iśn ij 'E n te r '): " ) ; aPhrase = getP hrase(in ); j j //koniec main public s t a t i c LinkedList getPhrase(Scanner in) j LinkedList phrase = new L inkedL ist(); String s t r = in .n e x tL in e(); fo r (in t h = s tr.le n g th () - l ; h >= 0; h--) phrase.addHead(new N odeD ata(str.charA t(h))); return phrase; j //koniec getPhrase public s t a t i c LinkedList lettersLow er(LinkedList phrase) j LinkedList word = new L inkedL ist(); while (!phrase.em pty()) j char ch = phrase.getH eadData().getData(); i f (C h aracte r.isL e tter(ch )) word.addTail(new NodeData(Character.toLowerCase(ch)) ) ; phrase.deleteH ead(); j return word; j //koniec lettersLower j //koniec klasy Palindrome
■
U w a g a T o r o z w i ą z a n i e z o s t a ło p r z e d s t a w i o n e g ł ó w n i e w c e lu z a d e m o n s t r o w a n i a s p o s o b ó w w y k o n y w a n i a o p e r a c ji n a lis t a c h . P r o b l e m s p r a w d z a n i a p a l i n d r o m ó w m o ż n a r o z w i ą z a ć z n a c z n ie b a r d z ie j e f e k t y w n i e , w y k o r z y s t u j ą c t a b l i c e z n a k ó w , k t ó r e z a p e w n i a j ą b e z p o ś r e d n i d o s t ę p d o w s z y s t k ic h z n a k ó w ła ń c u c h a . N a w e t w z a p r e z e n t o w a n y m r o z w i ą z a n i u m o ż n a c z y ś c ić ła ń c u c h z n a k ó w p o d c z a s w p i s y w a n i a , a k c e p t u ją c s a m e li t e r y i o d r a z u k o n w e r t u j ą c w i e l k i e lit e r y n a m a łe . W r a m a c h ć w ic z e n ia m o ż n a n a p is a ć p r o g r a m r o z w i ą z u j ą c y p r o b le m s p r a w d z a n i a p a l i n d r o m ó w z w y k o r z y s t a n i e m t a b lic .
110
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
3.12. Zapisywanie listy powiązanej Kiedy tworzymy listę powiązaną, wskaźniki zapisywane w jej węzłach są określane w trakcie działania programu, w zależności od tego, gdzie został przydzielony obszar pamięci dla węzła. Podczas każdego wykonania programu wartości tych wskaźników będą inne. Co zatem moglibyśmy zrobić, gdybyśmy chcieli zapisać raz utworzoną listę, tak by można było skorzystać z niej w przyszłości? Ponieważ zapisywanie wartości wskaźników mija się z celem, musimy zapisać zawartość węzłów w taki sposób, by później można było odtworzyć listę. Najprostszym rozwiązaniem jest zapisanie poszczególnych elementów listy w pliku (rozdział 8.) w takiej samej kolejności, w jakiej występują na liście. Później taki plik można odczytać i odtwarzać listę, odczytując jej kolejne elementy. Może się także zdarzyć, że będziemy chcieli przekształcić listę do bardziej zwartej postaci i zapisać ją w formie tablicy. Jednym z powodów stosowania takiego rozwiązania może być chęć szybkiego przeszukania posortowanej listy. Ponieważ listy powiązane można przeszukiwać wyłącznie sekwencyjnie, możemy skopiować jej elementy do tablicy i użyć wyszukiwania binarnego. Załóżm y np., że dysponujem y listą powiązaną zawierającą nie więcej niż pięćdziesiąt elem entów, której początek określa zm ienna top. Jeśli przyjm iem y, że węzły tej listy zawierają pola num oraz next, je j zawartość m ożem y skopiować do tablicy saveLL za pom ocą poniższego fragm entu kodu. int saveLL[50], n = 0; while (top != null & n < 50) { saveLL[n++] = top.num; top = top.next; } Po jego wykonaniu wartość zmiennej n będzie określać liczbę skopiowanych liczb. Będą one zapisane w tablicy saveLL, w komórkach od saveLL[0] do saveLL[n-1].
3.13. Tablice a listy powiązane Tablice i listy powiązane są dwoma często wykorzystywanymi sposobami przechowywania list liniowych, a każdy z nich ma swoje zalety i wady. Jedną z podstawowych różnic pomiędzy nimi jest to, że tablice zapewniają bezpośrednio dostęp do poszczególnych elementów przy użyciu indeksu, natomiast w listach powiązanych dotarcie do elementu wymaga przejścia przez listę, które trzeba zacząć od jej początku. Jeśli lista elementów nie jest posortowana, zawsze trzeba ją będzie przeszukiwać sekwencyjnie, niezależnie od tego, czy jest zapisana w tablicy, czy w formie listy powiązanej. Jeśli natomiast lista elementów jest posortowana, tablicę będzie można przeszukiwać przy użyciu algorytmu wyszukiwania binarnego. Ponieważ ten rodzaj wyszukiwania wymaga możliwości bezpośredniego dostępu do elementów, nie można go stosować do wyszukiwania elementów na listach powiązanych. Jedynym sposobem przeszukiwania list powiązanych jest wyszukiwanie sekwencyjne. Wstawianie elementów na końcu listy zapisanej w tablicy jest bardzo proste (o ile tylko nie została ona w całości wypełniona), natomiast wstawianie elementów na początku wymaga przesunięcia wszystkich elementów. Wstawianie elementów pośrodku listy zapisanej w tablicy wymaga przesunięcia średnio połowy elementów, by zrobić miejsce dla nowego. Dodawanie elementów do listy powiązanej jest bardzo proste, gdyż wymaga jedynie ustawienia lub zmiany kilku odwołań. Także usuwanie elementów z listy powiązanej jest bardzo łatwe i to niezależnie do tego, gdzie jest położony usuwany element (na początku, pośrodku, czy też na końcu listy). Usuwanie elementów z tablicy jest łatwe wyłącznie w przypadku, gdy usuwany element znajduje się na końcu; usunięcie jakiegokolwiek innego elementu wymaga przesunięcia innych w celu wypełnienia powstałej „luki”.
111
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Zachowanie uporządkowania elementów tablicy (w przypadku dodawania do niej nowego elementu) może być niewygodne, gdyż każdy nowy element musi zostać umieszczony w odpowiednim miejscu, a to, jak już mieliśmy okazję się przekonać, będzie zazwyczaj wymagało przesunięcia innych elementów. Jednak określenie miejsca, w którym nowy element należy umieścić, jest szybkie, kiedy wykorzystamy wyszukiwanie binarne. W liście powiązanej określenie m iejsca, w którym należy umieścić nowy element, wymaga zastosowania wyszukiwania sekwencyjnego. Kiedy jednak zostanie ono ustalone, wstawienie nowego elementu w tym miejscu jest bardzo szybkie, gdyż sprowadza się do ustawienia lub zmiany kilku odwołań. W tabeli 3.1 podsumowaliśmy mocne i słabe strony sortowania listy elementów zapisanych w tablicy oraz w postaci listy powiązanej. Tabela 3.1. Sortow anie listy elem en tów zapisanych w tablicy o ra z w p ostaci listy pow iązan ej Tablica
Lista powiązana
Bezpośredni dostęp do wszystkich elementów.
Konieczność przechodzenia listy, by dotrzeć do elementu.
Konieczność zastosowania wyszukiwania sekwencyjnego, jeśli zawartość nie jest posortowana.
Konieczność zastosowania wyszukiwania sekwencyjnego, jeśli zawartość nie jest posortowana.
Możliwość użycia wyszukiwania binarnego, jeśli zawartość jest posortowana.
Konieczność użycia wyszukiwania sekwencyjnego nawet wtedy, kiedy zawartość jest posortowana.
Łatwość dodawania elementów na końcu listy.
Łatwość dodawania elementów, niezależnie od miejsca, w którym mają się znaleźć.
Konieczność przesuwania elementów, jeśli wstawiany element ma się znaleźć gdzieś indziej, a nie na końcu listy.
Łatwość wstawiania elementów, niezależnie od miejsca, w którym mają się znaleźć.
Usuwanie elementów (z wyjątkiem ostatniego) wymaga przesuwania innych elementów listy.
Usunięcie dowolnego elementu jest bardzo łatwe.
Dodanie nowego elementu do listy posortowanej wymaga przesuwania już istniejących elementów listy.
Dodawanie nowego elementu do posortowanej listy zawsze jest łatwe.
Można zastosować wyszukiwanie binarne w celu określenia miejsca, w którym należy dodać nowy element do posortowanej listy.
Do określenia miejsca, w którym należy dodać nowy element do posortowanej listy, trzeba używać wyszukiwania sekwencyjnego.
3.14. Przechowywanie list powiązanych przy użyciu tablic Wiemy już, jak można tworzyć listy powiązane, korzystając z dynamicznego przydzielania pamięci. Kiedy w takim przypadku chcemy dodać do listy nowy węzeł, żądamy przydzielenia dla niego odpowiedniego bloku pamięci. Jeśli chcemy usunąć jakiś węzeł, najpierw usuwamy go logicznie — zmieniając odpowiednie wskaźniki — a dopiero potem zostaje fizycznie zwolniona pamięć zajmowana przez ten węzeł. Jednak listy powiązane można także tworzyć i przechowywać z wykorzystaniem tablic. Załóżmy ponownie, że dysponujemy listą powiązaną w następującej postaci:
top _ _
36
112
w
15
wW
52
w
23
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
Taką listę możemy zapisać w taki sposób:
data
next
15
7
23
-1
36
1
52
3
W tym przypadku odwołania (wskaźniki) są jedynie indeksami tablicy. Ponieważ indeksy tablic są liczbami całkowitymi, zatem top jest zmienną typu int, a next jest tablicą typu int. W tym przykładzie dane przechowywane na liście są liczbami całkowitymi (dlatego data także jest tablicą typu in t), jednak mogą one być dowolnego innego typu, w tym także typu obiektowego. W artość zmiennej top wynosi 5, co oznacza, że pierwszy element listy jest zapisany w tablicy, w kom órce 0 indeksie 5. A zatem data[5] zawiera daną (w naszym przypadku jest to 36), natomiast next[5] (w naszym przypadku jest to 1) informuje, gdzie można znaleźć następny (czyli drugi) element listy. Drugi element listy możemy zatem znaleźć w komórce o indeksie 1; data[1] zawiera daną (15), a next[1] (7) informuje, gdzie można znaleźć następny (trzeci) element listy. Trzeci element listy możemy znaleźć w kom órce o indeksie 7; data[7] zawiera daną (52), a next[7] (3) informuje, gdzie można znaleźć następny (czwarty) element listy. Czwarty element listy możemy znaleźć w komórce o indeksie 3; data[3] zawiera daną (23), a next[3] (-1) informuje, gdzie można znaleźć następny element listy. W tym przypadku wartość -1 pełni rolę wskaźnika pustego (nul l ), co oznacza, że dotarliśmy do końca listy. Zamiast niej można użyć dowolnej innej wartości, której nie można pomylić z indeksem tablicy, jednak wartość -1 jest używana bardzo często. Wszystkie opisane w tym rozdziale operacje na listach powiązanych (takie jak dodawanie, usuwanie 1 odczytywanie zawartości) można w podobny sposób wykonywać na listach powiązanych zapisanych przy użyciu tablic. Główna różnica pomiędzy oboma przypadkami polega na tym, że wcześniej zmienna curr wskazywała na bieżący węzeł, a curr.next na następny; natomiast teraz, jeśli curr wskazuje na bieżący węzeł, to następny jest wskazywany przez n e x t[cu rr]. Jedną z wad przechowywania list powiązanych w tablicach jest to, że trzeba choćby z grubsza znać wielkość listy, by odpowiednio zadeklarować tablicę. Kolejną jest brak możliwości zwalniania i odzyskiwania pamięci, w której były przechowywane usunięte węzły listy. Z drugiej strony, pamięć tę można wykorzystać do zapisania nowych elementów listy.
3.15. Scalanie dwóch posortowanych list powiązanych W podrozdziale 1.9 przeanalizowany został problem scalania dwóch list posortowanych. Pokazano, w jaki sposób można go rozwiązać, gdy listy są przechowywane w tablicach. Teraz rozwiążemy ten sam problem, w sytuacji gdy listy są przechowywane w postaci list powiązanych. Chodzi o scalenie dwóch posortowanych list powiązanych w taki sposób, by utworzyły jedną posortowaną listę powiązaną. Załóżmy, że mamy dwie listy:
L-H2114»T^TM 3s |
6i 113
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Chcemy utworzyć jedną listę zawierającą wszystkie liczby, posortowane w kolejności rosnącej; a zatem powinna ona wyglądać tak, jak na poniższym rysunku.
Wynikową listę C będziemy konstruować, tworząc nowy węzeł dla każdej dodawanej liczby; listy A i B nie będą w żaden sposób modyfikowane. Zastosujemy ten sam algorytm, którego użyliśmy w podrozdziale 1.10. Oto on. while (na każdej z l i s t A i B pozostaje przynajmniej jedna liczb a) i f (najm niejsza licz b a zA < najm niejsza liczb a z B) dodaj najm niejszą z Ado C przejdź do następnej liczby na A else dodaj najm niejszą z B do C przejdź do następnej liczby na B endif endwhile // w tym momencie przynajmniej jedna z list została przetworzona w całości while (są ja k ie ś liczby na A) dodaj najm niejszą liczb ę z A do C przejdź do następnej liczby na A endwhile while (są ja k ie ś liczby na B) dodaj najm niejszą liczb ę z B do C przejdź do następnej liczby na A endwhile Ponieważ nasze listy zawierają liczby całkowite, zatem zastosujemy w nich typ NodeData z polem typu int. Zakładamy, że listy Ai B są typu LinkedList. Zaimplementujemy teraz w klasie LinkedList nową metodę instancyjną o nazwie merge, taką że wywołanie A.merge(B) zwróci listę LinkedList zawierającą scalone elementy list Ai B. Poniżej przedstawiony został kod tej metody. public LinkedList merge(LinkedList LL) { Node A = this.head ; Node B = LL.head; LinkedList C = new L inkedL ist(); while (A != null && B != null) { i f (A.data.compareTo(B.data) < 0) { C.addTail(A .data); A = A.next; } e lse { C .addTail(B.data); B = B.next; } } while (A != n u ll) { C.addTail(A .data);
114
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
A = A.next; } while (B != n u ll) { C.addTail(B.data); B = B.next; } return C; } //koniec merge Po zaimplementowaniu metoda addTail w takiej postaci musi przed dodaniem nowego węzła na końcu listy przejść wszystkie jej elementy. Takie postępowanie nie jest efektywne. Moglibyśmy przechowywać wskaźnik (dajmy na to t a i l ) do ostatniego węzła listy, aby ułatwić dodawanie nowych węzłów na jej końcu. Jednak na aktualnym etapie prac nad klasą LinkedLi s t takie rozwiązanie jedynie niepotrzebnie skomplikowałoby kod. Ponieważ dodawanie węzłów na początku listy jest prostą i wydajną operacją, lepiej byłoby dodawać nowe węzły właśnie na początku, a następnie, po zakończeniu scalania, odwrócić kolejność węzłów na liście. Zmodyfikujemy zatem metodę merge, zastępując wywołanie addTai l wywołaniem addHead, a oprócz tego bezpośrednio przed instrukcją return C dodamy jeszcze jedno wywołanie — C .re v e rse L is t();. Do przetestowania działania metody merge zastosujemy program P3.6. Prosimy w nim użytkownika o podanie danych do utworzenia dwóch list. Dane te można podawać w dowolnej kolejności. Tworzone listy zostaną posortowane, gdyż elementy będziemy dodawali w odpowiednich miejscach. W arto pamiętać, że program ten wymaga zastosowania klasy NodeData definiującej pole typu in t; należy ją zadeklarować jako klasę publiczną i zapisać w pliku N odeD ata.java. Dodatkowo funkcję merge należy dodać do kodu klasy LinkedList; także ona powinna zostać zadeklarowana jako publiczna i umieszczona w pliku LinkedList.java. Oczywiście, sam program P3.6 jest zapisany w pliku M ergeList.java. Program 3.6 import ja v a .u t i l . * ; public c la ss MergeLists { public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); LinkedList A = cre a te S o rte d L ist(in ); LinkedList B = cre a te S o rte d L ist(in ); System .out.printf("\nPo scaleniu lis t y :\ n " ) ; A.printLi s t ( ) ; S y stem .o u t.p rin tf("z lis t ą :\ n " ) ; B.printLi s t ( ) ; System.out.printf("otrzymamy lis t ę :\ n " ) ; A .m erg e(B ).p rin tL ist(); } //koniec main public s t a t ic LinkedList createSorted List(Scanner in) { LinkedList LL = new L inkedL ist(); System .out.printf("W pisz kilka lic z b całkowitych i zakończ, wpisując 0\n"); int n = in .n e x tIn t(); while (n != 0) { LL.addInPlace(new NodeData(n)); n = in .n e x t In t (); } return LL; } //koniec createSortedList } //koniec klasy MergeLists Poniżej przedstawiono przykładowe wyniki wykonania programu P3.6.
115
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Wpisz kilka licz b całkowitych i zakończ, wpisując 0 8 4 12 6 10 2 0 Wpisz kilka licz b całkowitych i zakończ, wpisując 0 5 7 15 1 3 0 Po scaleniu l i s t y : 2 4 6 8 10 12 z lis tą : 1 3 5 7 15 otrzymamy l i s t ę : 1 2 3 4 5 6 7 8 10 12 15
3.16. Listy cykliczne i dwukierunkowe Dotąd koncentrowaliśmy się głównie na listach jednokierunkowych (jednostronnych). Każdy węzeł takiej listy zawiera wskaźnik określający położenie następnego węzła. W ostatnim węźle listy jest zapisany wskaźnik n u ll, który informuje o jej końcu. Choć takie listy są zdecydowanie najbardziej popularne, istnieją jeszcze dwa inne, często stosowane rodzaje list; są to listy cykliczne oraz listy dwukierunkowe.
3.16.1. Listy cykliczne W listach cyklicznych ostatni element wskazuje na pierwszy, tak jak pokazano na poniższym rysunku.
W tym przypadku lista nie zawiera wskaźnika n u ll, który pozwoliłby określić, gdzie jest jej koniec; przeglądając taką listę, należy zachować ostrożność, by nie wpaść w nieskończoną pętlę. Załóżmy, że chcielibyśmy zastosować następujący fragment kodu. Node curr = top; while (curr != null) { //tu robimy coś z węzłem wskazywanym przez curr curr = cu rr.n ex t; } Wykonywanie tej listy będzie trwać bez końca, gdyż zmienna curr nigdy nie przyjmie wartości null. Aby uniknąć tego problemu, możemy zapisać wskaźnik początkowego węzła i wykrywać, kiedy ponownie do niego dotrzemy. Oto przykład takiego rozwiązania. Node curr = top; do { //tu robimy coś z węzłem wskazywanym przez curr curr = cu rr.n ex t; } while (curr != top); Uważni czytelnicy na pewno zauważyli, że ciało pętli d o ...w h ile jest wykonywane przynajmniej jeden raz, zatem przed rozpoczęciem wykonywania należy zagwarantować, że lista nie będzie pusta, w przeciwnym razie podczas wykonywania pętli spróbujemy użyć wskaźnika pustego do odwołania się do nieistniejącego obiektu. Listy tego typu są użyteczne do reprezentacji sytuacji, które mają charakter cykliczny. Przykładowo w grach karcianych lub planszowych, w których gracze kolejno wykonują swoje ruchy, listy cykliczne mogą
116
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
być używane do reprezentacji kolejności gry. Gdyby w takiej grze uczestniczyło czterech graczy, wykonywaliby oni ruchy w kolejności: 1., 2., 3., 4., 1., 2., 3., 4., 1., 2. itd. Po wykonaniu ruchu przez ostatniego gracza kolej ponownie przechodzi na pierwszego. W dziecięcych wyliczankach dzieci ustawiają się w kole, a następnie to z nich, które zostanie wskazane podczas wypowiadania ostatniego słowa wierszyka, zostaje wyeliminowane. Zabawa toczy się tak długo, aż zostaną wyeliminowane wszystkie dzieci z wyjątkiem jednego i to właśnie ono wygrywa. Napiszemy program z wykorzystaniem listy cyklicznej, by wyznaczyć zwycięzcę gry zdefiniowanej w następujący sposób. W yliczan ka: n dzieci (ponum erow anych o d 1 do n) ustaw ia się w kole. W ierszyk sk ład a ją cy się z m słów jest pow tarzan y w celu elim in ow an ia kolejnych dzieci, aż do m om en tu gdy zostan ie tylko jed n o z nich. Z aczynając od dziecka nu m er 1, dzieci są odliczan e o d 1 do m i m -te dziecko zostaje w yelim inow ane. K olejn e wyliczanie rozpoczyna się od 1. dziecka za tym, które zostało wyeliminowane, i ponow nie m -te dziecko zostaje wyeliminowane. Ten proces jest pow tarzany, aż do m om en tu gdy p ozostan ie tylko jed n o dziecko. W yliczanie je s t w ykonyw ane cyklicznie, a dzieci w yelim inow ane wcześniej nie są uwzględniane. N ależy n apisać program , który wczyta w artości liczb n i m (w iększe od 0), zasym uluje grę i p od a, które dziecko zostało zwycięzcą. Tak postawiony problem można rozwiązać, używając tablicy (np. o nazwie child). Jednak, aby można było zadeklarować taką tablicę, musielibyśmy znać maksymalną liczbę dzieci (np. max). Początkowo w komórkach od child [1] do child[n] moglibyśmy zapisać wartość 1, sygnalizując w ten sposób, że wszystkie n dzieci bierze udział w grze. Kiedy któreś dziecko (dajmy na to h) zostanie wyeliminowane, zapisywalibyśmy w komórce child [h] wartość 0 i rozpoczynali wyliczanie od początku. W raz z rozwojem gry kilka kom órek tablicy child zawierałoby wartość 0 i należałoby je pomijać podczas wyliczania. Innymi słowy, nawet gdy dziecko zostanie wyeliminowane, wciąż musielibyśmy analizować reprezentującą je komórkę tablicy i pomijać, gdyby zawierała 0. W raz z upływem czasu, kiedy coraz więcej dzieci byłoby wyeliminowanych, musielibyśmy analizować i ignorować coraz więcej takich komórek. Na tym polega główna wada rozwiązania tego problemu bazującego na zastosowaniu tablicy. Znacznie bardziej efektywne rozwiązanie można napisać, korzystając z cyklicznej listy powiązanej. Na początku tworzymy listę zawierającą n węzłów. W artością każdego z nich będzie num er dziecka. Gdyby n wynosiło 4, lista miałaby następującą postać (przy czym zakładamy, że zmienna curr wskazuje na jej początek).
curr
___ w
1
W
2
w
3
w
4
A
Załóżmy, że wartość m wynosi 5. Zaczynamy liczyć od 1, a gdy dotrzemy do dziecka numer 4, kolejny etap wyliczania przeniesie nas z powrotem do dziecka numer 1, które zostanie wyeliminowane. Tę sytuację można przedstawić w następujący sposób.
curr__
1
w
2
w
3
4
t Jak pokazano na rysunku, dziecko numer 1 już nie znajduje się na liście, więc po jakim ś czasie pamięć zajmowana przez jego węzeł zostanie zwolniona i odzyskana. Ponownie zaczynamy odliczać do 5, zaczynając tym razem od dziecka numer 2. Wyliczanie kończy się na dziecku numer 3, które zostanie wyeliminowane — wskaźnik w węźle dziecka numer 2 zostanie zmodyfikowany tak, by wskazywał na dziecko numer 4. W tym momencie lista wygląda tak:
117
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
curr
2
------------------------- ► 4
_____
i _________________________________ Ponownie rozpoczynamy wyliczanie, zaczynając tym razem od dziecka numer 4. Wyliczanie kończy się na dziecku numer 4, które w efekcie zostaje wyeliminowane. Zwycięzcą zostaje zatem dziecko numer 2. W arto zwrócić uwagę, że to rozwiązanie, w odróżnieniu od algorytmu korzystającego z tablicy, faktycznie eliminuje dzieci, usuwając ich węzły z listy. Tak wyeliminowane dzieci nie są ani sprawdzane, ani uwzględniane na dalszych etapach wyliczania, gdyż ich węzły zostały usunięte! Można zatem uznać, że to rozwiązanie bardziej odpowiada faktycznemu przebiegowi zabawy. Program P3.7 symuluje tę grę i wyznacza zwycięzcę przy użyciu cyklicznej listy powiązanej. Zaprezentowane rozwiązanie jest proste i wiernie odwzorowuje opisany wcześniej przebieg zabawy. Dlatego nie zastosowaliśmy w nim przedstawionej wcześniej klasy LinkedList. Zamiast niej skorzystaliśmy z klasy Node, definiującej dwa pola — pole typu int zawierające numer dziecka oraz pole ze wskaźnikiem na następny węzeł listy. Przedstawiony program najpierw pobiera liczbę dzieci oraz długość wyliczanki, następnie tworzy listę cykliczną, wywołując w tym celu metodę l inkCircular, po czym wywołuje metodę playGame, by zasymulować przebieg zabawy i wyznaczyć zwycięzcę. Program P3.7 import ja v a .u t i l . * ; public c la ss CountOut { public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); int m, n; do { System .ou t.printf("P odaj licz b ę dzieci i długość wyliczanki: " ) ; n = in .n e x tIn t( ); m = in .n e x t In t () ; } while (n < 1 || m < 1); Node la s t = lin k C ircu la r(n ); // tworzymy cykliczną listę dzieci Node winner = playGame(last, n-1, m); // eliminujemy n-1 dzieci System .out.printf("Zw yciężyło dziecko numer: %d\n", winner.num); } //koniec main public s t a t ic Node lin k C irc u la r(in t n) { //metoda łączy n dzieci, tworząc cykliczną listę powiązaną; //zwraca wskaźnik do ostatniego dziecka, które //wskazuje na pierwsze Node f i r s t , np; f i r s t = np = new Node(1); //pierwsze dziecko fo r (in t h = 2; h <= n; h++) { //łączymy z pozostałymi np.next = new Node(h); np = np.next; } np.next = f i r s t ; //ostatnie dziecko ma wskazywać na pierwsze return np; } //koniec linkCircular public s t a t ic Node playGame(Node l a s t , int x, in t m) { //metoda eliminuje x dzieci, używając wyliczanki o długości m;
118
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
//parametr last wskazuje na ostatnie dziecko, które z kolei //wskazuje na pierwsze Node prev = l a s t , curr = la s t.n e x t; //curr wskazuje na pierwsze dziecko //eliminujemy x dzieci fo r (in t h = l ; h <= x; h++) j //curr wskazuje na pierwsze dziecko, od którego należy //rozpocząć wyliczanie; //odliczamy m -l, by dotrzeć do m-tego dziecka fo r (in t c = l ; c < m; c++) j prev = curr; curr = cu rr.n ex t; j //usuwamy m-te dziecko prev.next = cu rr.n ex t; curr = prev.next; //zmieniamy curr, by wskazywała na następne //dziecko za wyeliminowanym j return curr; j //koniec playGame j //koniec klasy CountOut class Node j int num; Node next; public Node(int n) j num = n; next = n u ll; j j //koniec klasy Node Poniżej przedstawiono przykładowe wyniki wykonania tego programu. Podaj liczb ę dzieci i długość wyliczanki: 9 10 Zwyciężyło dziecko numer: 8
3.16.2. Listy dwukierunkowe (podwójnie powiązane) Zgodnie z tym, co sugeruje ich nazwa, każdy węzeł list tego rodzaju będzie zawierał dwa wskaźniki — jeden wskazujący na następny węzeł oraz drugi wskazujący na węzeł poprzedni. Choć takie rozwiązanie oznacza nieco większy nakład pracy związany z zaimplementowaniem i zarządzaniem listą, to jednak ma swoje zalety. Pierwszą i najbardziej oczywistą zaletą jest możliwość poruszania się po liście w obu kierunkach, zaczynając od każdego z jej końców. Jeśli trzeba, odwrócenie listy staje się bardzo prostą operacją. Kiedy dotrzemy do pewnego węzła (bieżącego) w liście powiązanej pojedynczo, nie mamy możliwości dotarcia do informacji o poprzednim węźle, chyba że zostały one wcześniej zapisane podczas jego analizy. W przypadku listy dwukierunkowej dysponujemy bezpośrednim wskaźnikiem do poprzedniego węzła, więc możemy poruszać się po liście w obu kierunkach. Jedną z możliwych wad list tego rodzaju jest zwiększone zużycie pamięci, związane z koniecznością przechowywania dodatkowych wskaźników. Kolejnym problemem, który wiąże się ze zwiększoną liczbą wskaźników, jest wzrost złożoności operacji dodawania i usuwania węzłów.
119
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Ćwiczenia 1. Dodaj do klasy LinkedList metodę instancyjną, która będzie zwracać wartość true, jeśli lista będzie posortowana w kolejności rosnącej, lub wartość fa ls e w przeciwnym przypadku. 2. Napisz metodę instancyjną, która odwróci kolejność węzłów i zwróci je w formie nowej listy. Metoda ma zwracać nową listę. 3. Napisz metodę instancyjną, która będzie sortować listę powiązaną liczb całkowitych w następujący sposób: a) odnajdzie największą wartość na liście; b) usunie ją z aktualnie zajmowanego miejsca i wstawi na początek listy; c) zaczynając do drugiego elementu tak zmodyfikowanej listy, ponownie wykona czynności z punktów a i b; d) zaczynając od trzeciego elementu tak zmodyfikowanej listy, ponownie wykona czynności z punktów a i b. Powyższe czynności mają być powtarzane aż do posortowania całej listy. 4. Napisz funkcję, która będzie pobierać trzy argumenty — wskaźnik na listę powiązaną liczb całkowitych oraz dwie liczby całkowite — n i j — i wstawi n za j-tym elementem listy. Jeśli wartość j wyniesie 0, wartość n zostanie wstawiona na samym początku listy. Jeśli z kolei wartość j będzie większa od liczby elementów listy, n zostanie wstawiona na jej końcu. 5. Znaki tworzące łańcuch są przechowywane na liście powiązanej, po jednym w każdym węźle. a) Napisz metodę, która na podstawie przekazanego wskaźnika na łańcuch oraz dwóch znaków, cl i c2, zastąpi wszystkie wystąpienia znaku cl znakiem c2. b) Napisz funkcję, która na podstawie przekazanego wskaźnika na łańcuch oraz znaku c usunie z łańcucha wszystkie wystąpienia podanego znaku. Funkcja ma zwracać wskaźnik na zmodyfikowany łańcuch znaków. c) Napisz funkcję, która utworzy nową listę, zawierającą litery znajdujące się na oryginalnej liście, zamienione na małe litery i posortowane rosnąco w kolejności alfabetycznej. Funkcja ma zwracać wskaźnik na nową listę. d) Napisz funkcję, która na podstawie przekazanych dwóch wskaźników na łańcuchy znaków zwróci wartość true, jeśli pierwszy łańcuch będzie fragmentem drugiego; w przeciwnym razie funkcja ma zwrócić fa lse . 6. Napisz funkcję, która na podstawie przekazanej liczby całkowitej n skonwertuje ją do postaci dwójkowej i zapisze każdy z jej bitów w odrębnym węźle listy powiązanej, przy czym najmniej znaczący bit liczby ma się znajdować na początku, najbardziejznaczący — na końcu listy. Przykładowo w przypadku liczby 13 bity mają być zapisane w kolejności 1 0 1 1, patrząc od początku listy. Funkcja ma zwracać wskaźnik na nową listę. 7. Napisz funkcję, która na podstawie przekazanego wskaźnika na listę bitów opisaną w punkcie 6. odczyta je j zawartośćjeden razi wyświetli dziesiątkowy odpowiednik liczby dwójkowej. 8. Dysponujemy dwoma wskaźnikami, b1 i b2. Każdy z nich wskazuje na liczbę dwójkową zapisaną na liście powiązanej w sposób opisany w punkcie 6. Należy zwrócić wskaźnik na nowo utworzoną listę powstałą z dwójkowych sum liczb reprezentowanych przez podane listy, przy czym jej najmniejznaczący bit ma się znajdować na początku listy, a najbardziejznaczący — na końcu. Napisz funkcje, które będą to robiły na dwa sposoby: i) wykorzystując funkcje stanowiące rozwiązania ćwiczeń 6. i 7.; ii) wykonując dodawanie „bit po bicie". 9. Ponownie rozwiąż ćwiczenia 6., 7. oraz 8., lecz tym razem zapisuj kolejne bity liczb w odwrotnej kolejności: na początku listy na się znaleźć najbardziejznaczący bit, a na jej końcu bit najmniejznaczący. 10. Dwa słowa są anagramami, jeśli jedno z nich można utworzyć, zmieniając kolejność liter drugiego; np. alergia i galeria. Dwa słowa są zapisane w formie list powiązanych, których każdy węzeł zawiera jedną literę.
120
ROZDZIAŁ 3. ■ LISTY POWIĄZANE
Napisz funkcję, która na podstawie przekazanych list w1 i w2, zawierających słowa zapisane małymi literami, zwróci 1, jeśli oba słowa są anagramami, lub 0 w przeciwnym przypadku. Algorytm sprawdzania powinien działać w następujący sposób: każdą literę w w1 należy spróbować odnaleźć w w2; jeśli to się uda, trzeba usunąć tę literę i powtórzyć sprawdzenie dla kolejnej litery w w1; jeśli poszukiwanej litery nie uda się znaleźć, funkcja powinna zwrócić 0. 11. Napisz ponownie program symulujący dziecięcą wyliczankę, lecz tym razem do przechowywania dzieci użyj tablicy. Logika działania programu powinna być taka sama jak w programie P3.7, z tą różnicą, że lista cykliczna oraz wykonywane na niej operacje mają być zaimplementowane z wykorzystaniem tablicy. 12. Poszczególne cyfry liczby całkowitej są przechowywane na liście w odwrotnej kolejności, a każdy węzeł listy zawiera jedną cyfrę. Napisz funkcję, która na podstawie przekazanych dwóch wskaźników na takie listy wykona dodawanie liczb „cyfra po cyfrze" i zwróci wskaźnik na listę zawierającą cyfry wyliczonej sumy, zapisane w odwrotnej kolejności. Uwaga: takiej funkcji można używać do dodawania dowolnie dużych liczb całkowitych.
121
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
122
ROZDZIAŁ 4
Stosy i kolejki W tym rozdziale wyjaśnimy takie zagadnienia jak: • zapis abstrakcyjnych typów danych, • to, czym jest stos, • implementacja stosu przy użyciu tablicy, • implementacja stosu z wykorzystaniem listy powiązanej, • tworzenie pliku nagłówkowego przeznaczonego do użycia przez inne programy, • implementacja stosu przeznaczonego dla danych ogólnego typu, • konwersja wyrażeń z zapisu wrostkowego na przyrostkowy, • sposoby przetwarzania wyrażeń arytmetycznych, • to, czym jest kolejka, • implementacja kolejki przy użyciu tablicy, • implementacja kolejki za pomocą listy powiązanej.
4.1. Abstrakcyjne typy danych Doskonale znamy sposób deklarowania zmiennych konkretnego typu (np. doubl e) oraz wykonywania na nich różnego rodzaju operacji (takich jak dodawanie, mnożenie bądź przypisywanie) i to bez konieczności posiadania dokładnej wiedzy na temat tego, j a k te zmienne są przechowywane w pamięci komputera. W tym przypadku projektanci kompilatora mogą zmienić sposób przechowywania zmiennych typu doubl e, a programista nie będzie musiał zmieniać żadnych programów używających zmiennych tego typu. Doubl e to właśnie przykład abstrakcyjnego typu danych. Abstrakcyjnym typem danych nazywamy typ, który pozwala użytkownikom manipulować na danych bez znajomości sposobu, w jaki są one reprezentowane na komputerze. Innymi słowy, z punktu widzenia użytkownika jedynym, co musi znać, są operacje, które można wykonywać na danych tego typu. Z kolei osoby implementujące taki typ mogą dowolnie go zmieniać i nie będzie to miało żadnego wpływu na jego użytkowników. W tym rozdziale pokazano, w jaki sposób można zaimplementować stosy i kolejki jako abstrakcyjne typy danych.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
4.2. Stosy Stos jest listą liniową, w której elementy są dodawane i usuwane na tym samym końcu. Doskonałym przykładem takiego stosu może być „stos talerzy” umieszczonych na stole, jeden na drugim. Kiedy potrzeba talerza, jest zdejmowany z wierzchołka stosu. Kiedy brudny talerz zostanie umyty, jest umieszczany na wierzchołku stosu. Warto zwrócić uwagę, że jeśli po dodaniu talerza trzeba będzie jakiś talerz zdjąć ze stosu, będzie to właśnie ten „nowy” — ostatni, który został umieszczony na stosie. A zatem stos działa według zasady: „ostatni na wejściu, pierwszy na wyjściu”. W celu zilustrowania idei działania stosu posłużymy się stosem liczb całkowitych. Naszym celem jest zdefiniowanie typu danych Stack, tak by użytkownik mógł deklarować zmienne tego typu i używać ich na różne sposoby. A konkretnie jak będzie można go zastosować? Jak już wspominano, będziemy potrzebowali możliwości dodania elementu do stosu; zazwyczaj operację tę określamy jako „umieszczenie na stosie” (ang. push). Oprócz tego będziemy także musieli usuwać elementy ze stosu; tę operację określa się zazwyczaj jako „zdejmowanie ze stosu” (ang. pop). Zanim spróbujemy coś zdjąć ze stosu, warto sprawdzić, czy w ogóle coś na nim jest; innymi słowy, czy nie jest pusty (ang. em pty). A zatem będzie potrzebna operacja, która sprawdza, czy stos nie jest pusty. Załóżmy teraz, że dysponujemy tymi trzema operacjami: um ieszczeniem na stosie, zdjęciem ze stosu oraz spraw dzeniem , czy stos nie je s t pusty. Zobaczymy teraz, w jaki sposób można ich użyć do wczytania grupy liczb i wyświetlenia ich w odwrotnej kolejności. Zakładamy, że wpisane zostały następujące liczby: 36 15 52 23 a chcemy wyświetlić poniższą sekwencję: 23 52 15 36 Problem ten możemy rozwiązać, umieszczając każdą wpisywaną liczbę na wierzchołku stosu S. Kiedy wszystkie liczby zostaną umieszczone na stosie, przyjmie on następującą postać: 23 52 15 36
(wierzchołek stosu)
(spód stosu)
Teraz wystarczy zdejmować kolejne liczby ze stosu i wyświetlać je. Potrzebujemy jeszcze jakiegoś sposobu, by określić, kiedy wszystkie liczby zostaną odczytane. Użyjemy do tego celu wartości 0, która będzie oznaczać koniec danych wejściowych. Logikę działania naszego programu można opisać w następujący sposób. utwórz pusty s to s , S read(num) while (num != 0) umieść num na s to s ie S read(num) endwhile while (S nie je s t pusty) zdejmij num ze stosu S //zapisz liczbę z wierzchołka S w zmiennej num print num endwhile A teraz pokażemy, jak można zaimplementować stos liczb całkowitych oraz operacje na nim.
4.2.1. Implementacja stosu przy użyciu tablicy Aby uprościć implementację podstawowych operacji na stosie, zajmiemy się tworzeniem stosu liczb całkowitych. Dalej w tym rozdziale pokazano, jak można zaimplementować stos danych ogólnego typu. W przypadku implementowania stosu (liczb całkowitych) przy użyciu tablicy zastosujemy tablicę liczb całkowitych (załóżmy, że nazwiemy ją ST) oraz zmienną całkowitą (dajmy na to top), która będzie przechowywać indeks określający wierzchołek stosu.
124
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Ponieważ używamy tablicy, zatem aby ją zadeklarować, będziemy musieli znać jej wielkość. Musimy dysponować jakim iś informacjam i na temat rozwiązywanego problemu, by można było określić rozsądną wielkość tablicy. W naszym kodzie zastosujemy stałą symboliczną MaxStack. Jeśli spróbujemy umieścić na stosie więcej elementów, niż wynosi wartość tej stałej, zostanie zgłoszony błąd przepełnienia stosu. Zaczniemy od zdefiniowania klasy Stack. publ ic c la ss Stack { final s t a tic in t MaxStack = 100; int top = -1 ; in t[] ST = new int[MaxStack]; / / pozostała część kodu klasy } //koniec klasy Stack Prawidłowe wartości zmiennej top mieszczą się w zakresie od 0 do MaxStack-1. W momencie inicjalizacji stosu zapisujemy w zmiennej top nieprawidłowy indeks -1. Teraz możemy już zadeklarować zmienną S reprezentującą stos: Stack S = new S tack ( ) ; Po wykonaniu powyższej instrukcji postać pamięci w naszym programie można zilustrować w następujący sposób:
Rysunek 4.1. Im plem en tacja stosu przy użyciu tablicy Tak można zilustrować pusty stos. Teraz potrzebujemy funkcji, która pozwoli sprawdzić, czy stos jest pusty. W tym celu dodamy do klasy Stack metodę instancyjną. public boolean empty() { return top == -1 ; } Jej działanie sprowadza się do sprawdzenia, czy wartość top jest różna od -1. Podstawowymi operacjami na stosie jest umieszczanie elementów na stosie (ang. push) oraz zdejmowanie ich z niego (ang. pop). Aby umieścić liczbę n na stosie, musimy zapisać ją w tablicy ST i zaktualizować zmienną top tak, by wskazywała na położenie tej liczby. Operację tę można ogólnie opisać w następujący sposób. dodaj 1 do top ustaw ST[top] na n Niemniej jednak musimy się także zabezpieczyć przed umieszczaniem nowych elementów na stosie, który jest już pełny. Stos jest pełny, gdy top ma wartość MaxStack - 1, czyli gdy wartość top odpowiada indeksowi ostatniego elementu tablicy. W takim przypadku wyświetlimy komunikat informujący, że stos jest pełny, i zakończymy działanie programu. Poniżej przedstawiono kod metody instancyjnej push klasy Stack. public void push(int n) { i f (top == MaxStack - 1) { System .out.printf("\nPrzepeTnienie stosu !\ n "); S y ste m .e x it(1); } ++top; ST[top] = n; } //koniec push
125
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Na rysunku 4.2 przedstawiono postać stosu S po umieszczeniu na nim czterech liczb: 36, 15, 52 oraz 23.
Rysunek 4.2. P ostać stosu p o um ieszczeniu na nim liczb 36, 15, 52 oraz 23 W końcu, aby zdjąć element ze stosu, zwracamy wartość z kom órki o indeksie określonym przez top i zmniejszamy wartość zmiennej top o 1. Sposób działania tej operacji można opisać w następujący sposób. ustaw hold na ST[top] zmniejsz top o 1 return hold Także w tym przypadku przed wykonaniem operacji musimy sprawdzić warunek, jednak tym razem chcemy się upewnić, czy stos nie jest pusty. A co należy zrobić, jeśli podczas próby usunięcia elementu ze stosu okaże się, że jest już pusty? Moglibyśmy zgłaszać błąd i kończyć działanie programu. Jednak lepszym pomysłem będzie zwracanie jakiejś specjalnej wartości, oznaczającej taką sytuację. W naszej funkcji pop zastosujemy właśnie to drugie rozwiązanie. Poniżej przedstawiono kod metody instancyjnej pop klasy Stack. public int pop() { i f (this.em p ty ())retu rn RogueValue; Ustala symboliczna i nt hold = ST[top]; --to p ; return hold; } //koniec pop W arto zwrócić uwagę, że choć zapewniliśmy sensowne działanie funkcji pop w przypadku próby zdjęcia elementu z pustego stosu, to jednak znacznie lepszym rozwiązanie będzie samodzielne sprawdzanie przez programistę, czy stos nie je s t pusty (przy użyciu funkcji empty), zanim spróbuje się wywołać funkcję pop. Dysponując klasą Stack, możemy napisać program P4.1, który odczyta liczby zakończone wartością 0, a następnie wyświetli je w odwrotnej kolejności. W arto zwrócić uwagę, że w deklaracji klasy Stack pominęliśmy słowo kluczowe public, aby można było umieścić ją w pliku StackTest.java. Program P4.1 import ja v a .u t i l . * ; public c la ss StackTest { public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); Stack S = new S ta c k (); System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ, wpisując 0\n"); i nt n = in .n e x tIn t(); while (n != 0) { S.push(n); n = in .n e x tIn t( ); } System .out.printf("\nLiczby w odwrotnej k o le jn o ści: \n"); while (!S.em pty()) System .out.printf("% d " , S .p o p ()); S y stem .o u t.p rin tf("\ n ");
126
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
} //koniec main } //koniec klasy StackTest class Stack { final s t a tic in t MaxStack = 100; final s t a tic in t RogueValue = -999999; int top = -1 ; in t[] ST = new int[MaxStack]; public boolean empty() { return top == -1 ; } public void push(int n) { i f (top == MaxStack - 1) { System .out.printf("\nPrzepeTnienie stosu !\ n "); S y ste m .e x it(1); } ++top; ST[top] = n; } //koniec push public in t pop() { i f (this.em p ty ())retu rn RogueValue; //stała symboliczna int hold = ST[top]; --to p ; return hold; } //koniec pop } //koniec klasy Stack Poniżej przedstawiono przykładowe wyniki wykonania programu P4.1. Podaj kilka licz b całkowitych i zakończ, wpisując 0 1 2 3 4 5 6 7 8 9 0 Liczby w odwrotnej k o lejn o ści: 9 8 7 6 5 4 3 2 1 Koniecznie należy zwrócić uwagę, że kod metody main, który wykonuje operacje na stosie, robi to, używając funkcji push, pop oraz empty i nie przyjmuje absolutnie żadnych założeń dotyczących tego, w ja k i sposób są przechowywane poszczególne elementy stosu. W łaśnie to jest cechą charakterystyczną abstrakcyjnych typów danych — można ich używać bez jakiejkolwiek wiedzy na temat tego, jak zostały zaimplementowane. W następnym punkcie rozdziału zaimplementujemy stos przy użyciu listy powiązanej, ale nawet mimo tej zmiany będziemy w stanie rozwiązać nasz problem przy użyciu dokładnie tego samego kodu metody main.
4.2.2. Implementacja stosu przy użyciu listy powiązanej Implementacja stosu w oparciu o tablicę jest niezwykle prosta i wydajna, co stanowi jej ogromną zaletę. Jednak jedną z jej podstawowych wad jest konieczność znajomości wielkości deklarowanej tablicy. Choć zapewne można poczynić tu jakieś rozsądne założenia, jednak i tak może się okazać, że zadeklarowana tablica będzie zbyt mała (co może doprowadzić do zatrzymania programu) lub zbyt duża (co będzie oznaczało niepotrzebne marnowanie pamięci komputera). Rozwiązaniem tego problemu może być zaimplementowanie stosu w oparciu o listę powiązaną. W tym przypadku pamięć dla kolejnego elementu stosu będzie przydzielana dopiero wtedy, gdy będzie to konieczne.
127
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Stos jest implementowany w postaci listy połączonej, w której nowe elementy są dodawane na początku. Kiedy należy zdjąć element ze stosu, usuwany jest element znajdujący się na początku listy. Także tę implementację stosu przedstawimy na przykładzie stosu liczb całkowitych. Najpierw musimy zdefiniować klasę Node, która posłuży do tworzenia węzłów listy. Zastosujemy następującą definicję. class Node { int data; Node next; public Node(int d) { data = d; next = n u ll; } } //koniec klasy Node Następnie zajmiemy się klasą Stack, której początek ma postać: class Stack { Node top = n u ll; public boolean empty() { return top == n u ll; } W klasie Stack zdefiniowaliśmy jedną zmienną instancyjną top, typu Node. Początkowo jest jej przypisywana wartość null, co sygnalizuje, że stos jest pusty. Funkcja empty ogranicza się do sprawdzenia, czy wartość zmiennej top wynosi n u ll. Pusty stos zilustrowany został na rysunku 4.3.
Rysunek 4.3. Pusty stos Metoda push dodaje element na początku stosu i można ją napisać w następujący sposób. public void push(int n) { Node p = new Node(n); p.next = top; top = p; } //koniec push Po umieszczeniu na stosie S liczb 36, 15, 52 oraz 23 (w dokładnie takiej kolejności) będzie on miał postać pokazaną na rysunku 4.4; S jest wskaźnikiem na zmienną top, która z kolei wskazuje na pierwszy element listy tworzącej stos.
Rysunek 4.4. W ygląd stosu p o um ieszczeniu na nim liczb 36, 15, 52, 23
128
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Aby zdjąć element ze stosu, najpierw sprawdzamy, czy nie jest pusty. Jeśli okaże się, że stos faktycznie jest pusty, zwracana jest specjalna stała symboliczna. W przeciwnym razie zwracamy wartość zapisaną w pierwszym węźle listy, który następnie jest z niej usuwany. Poniżej zamieszczono kod metody pop. public in t pop() { i f (th is.em pty()) return RogueValue; //stała symboliczna int hold = top.data; top = top.next; return hold; } //koniec pop Teraz zmodyfikujemy program P4.1, zapisując go jako program P4.2. Jego główna, publiczna klasa StackTest pozostanie bez zmian, natomiast w klasie Stack zastosujemy nowe definicje funkcji empty, push oraz pop. Ponownie chcemy zwrócić uwagę, że choć zmieniła się im plem en tacja stosu i zamiast tablicy jest on aktualnie tworzony przy użyciu listy powiązanej, to jednak kod, w którym go użyto (kod metody main), pozostał niezmieniony. Program 4.2 import ja v a .u t i l . * ; public c la ss StackTest { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Stack S = new S ta c k (); System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ, wpisując 0\n"); int n = in .n e x tIn t(); while (n != 0) { S.push(n); n = in .n e x tIn t( ); } System .out.printf("\nLiczby w odwrotnej k o le jn o ści: \n"); while (!S.em pty()) System .out.printf("% d " , S .p o p ()); S y stem .o u t.p rin tf("\ n "); } //koniec main } //koniec klasy StackTest class Node { int data; Node next; public Node(int d) { data = d; next = n u ll; } } //koniec klasy Node class Stack { final s t a tic in t RogueValue = -999999; Node top = n u ll; public boolean empty() { return top == n u ll; } public void push(int n) { Node p = new Node(n); p.next = top; top = p;
129
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
} //koniec push publi c in t pop() { i f (th is.em pty()) return RogueValue; //stała symboliczna int hold = top.data; top = top.next; return hold; } //koniec pop } //koniec klasy Stack Poniżej przedstawiono wyniki wykonania programu P4.2. Zgodnie z oczekiwaniami, są one takie same jak generowane przez program P4.1. Podaj kilka licz b całkowitych i zakończ, wpisując 0 1 2 3 4 5 6 7 8 9 0 Liczby w odwrotnej k o lejn o ści: 9 8 7 6 5 4 3 2 1
4.3. Ogólny typ Stack Aby uprościć prezentowane przykłady, posłużono się stosami liczb całkowitych. Poniżej wskazano jeszcze raz te miejsca, w których decyzja o zastosowaniu liczb całkowitych ma odzwierciedlenie w kodzie: • w deklaracji typu Node umieszczono pole typu int o nazwie num; • do metody push przekazywany jest argument typu int; • metoda pop zwraca wynik typu int. To oznacza, że gdybyśmy potrzebowali stosu, dajmy na to znaków, we wszystkich tych miejscach musielibyśmy zmienić in t na char. Analogiczne zmiany trzeba by wykonać podczas tworzenia stosów dowolnych innych typów. Dobrze by było, gdybyśmy mogli w jakiś sposób zminimalizować liczbę zmian, które trzeba wprowadzać podczas tworzenia stosu nowego typu. W tym podrozdziale pokazano, jak to zrobić. Na początek zmienimy definicję klasy Node. class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node Dane przechowywane w węźle składają się z obiektu ogólnego typu NodeData. Definiując tę klasę, użytkownik może zdecydować, jakiego rodzaju informacje chce przechowywać na stosie. Klasa Stack rozpoczyna się tak samo jak w poprzednich przykładach. class Stack { Node top = n u ll; public boolean empty() { return top == n u ll; }
130
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Jednak metoda push wymaga teraz argumentu typu NodeData, w związku z tym należy ją zapisać w następujący sposób. public void push(NodeData nd) { Node p = new Node(nd); p.next = top; top = p; } //koniec push Podobnie zmodyfikujemy metodę pop. Ponieważ jedynie klasa NodeData powinna znać typ informacji przechowywanych na stosie, pozwolimy jej zwracać specjalną wartość reprezentującą pusty stos. public NodeData pop() { i f (this.em p ty ())retu rn NodeData.getRogueValue(); NodeData hold = top.d ata; top = top.next; return hold; } //koniec pop Uważny czytelnik na pewno zaobserwował, że jedyną zmianą, jaką do tej pory wprowadziliśmy, była zamiana typu i nt na NodeData w klasie Node oraz w metodach push i pop. Gdybyśmy chcieli zaimplementować stos liczb całkowitych, klasa NodeData mogłaby przyjąć następującą postać (wygląda tak samo jak wcześniej, z tą różnicą, że dodaliśmy do niej metodę akcesora getData()). public c la ss NodeData { int num; public NodeData(int n) { num = n; } public i nt getData() {return num;} public s t a t ic NodeData getRogueValue() {return new NodeData(-999999);} public int compareTo(NodeData nd) { i f (this.num == nd.num) return 0; i f (this.num < nd.num) return -1 ; return 1; } public String to S trin g () { return num + " "; //''" jest potrzebny do skonwertowania num do postaci łańcucha //znaków; można także użyć "" (pustego łańcucha znaków) } } //koniec klasy NodeData Pomimo wszystkich zmian, które wprowadziliśmy w klasach Node, Stack oraz NodeData, klasa StackTest zastosowana w programach P4.1 oraz P4.2 wciąż będzie działać — trzeba jedynie zmienić wywołanie S.push(n) na S.push(new DataNode(n)) oraz S.pop() na S .p o p ().g etD ata() (obie te zmiany łatwo zauważyć w kodzie programu P4.3). W arto zwrócić uwagę, że w tym przypadku klasa NodeData nie musi udostępniać metod compareTo oraz to S trin g ; dlatego też zostały pominięte. Jak zwykle, z nagłówków wszystkich klas (z wyjątkiem StackTest) usunęliśmy słowo kluczowe public, dzięki czemu mogą one zostać umieszczone w jednym pliku.
131
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Program P4.3 import ja v a .u t i l . * ; public c la ss StackTest { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Stack S = new S ta c k (); System .ou t.printf("P odaj kilka lic z b całkowitych i zakończ, wpisując 0\n"); int n = in .n e x tIn t(); while (n != 0) { S.push(new NodeData(n)); n = in .n e x tIn t( ); } System .out.printf("\nLiczby w odwrotnej k o le jn o ści: \n"); while (!S.em pty()) System .out.printf("% d " , S .p o p ().g e tD a ta ()); S y stem .o u t.p rin tf("\ n "); } //koniec main } //koniec klasy StackTest
class NodeData j int num; public NodeData(int n) j num = n; } public in t getData() jretu rn num;} public s t a t ic NodeData getRogueValue() jretu rn new NodeData(-999999);} } //koniec klasy NodeData
class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node
class Stack { Node top = n u ll; public boolean empty() { return top == n u ll; } public void push(NodeData nd) { Node p = new Node(nd); p.next = top; top = p; } //koniec push public NodeData pop() {
132
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
i f (this.em p ty ())retu rn NodeData.getRogueValue(); NodeData hold = top.d ata; top = top.next; return hold; } //koniec pop } //koniec klasy Stack Gdybyśmy musieli skorzystać ze stosu znaków, wystarczyłoby zmienić definicję klasy NodeData w następujący sposób. public c la ss NodeData { char ch; public NodeData(char c) { ch = c; } public char getData() {return ch;} public s t a t ic NodeData getRogueValue() {return new N odeD ata('$');} public in t compareTo(NodeData nd) { i f (th is.c h == nd.ch) return 0; i f (th is.c h < nd.ch) return -1 ; return 1; } public String to S trin g () { return ch + " " ; } } //koniec klasy NodeData
4.3.1. Przykład: konwersja liczb dziesiątkowych na dwójkowe Przeanalizujmy problem konwersji dodatniej liczby całkowitej z zapisu dziesiętnego na dwójkowy. Jego rozwiązanie będzie polegało na cyklicznym dzieleniu liczby przez 2 i zapisywaniu uzyskiwanych reszt z dzielenia na stosie liczb całkowitych (zapiszemy go w zmiennej S). Algorytm tego rozwiązania można opisać w następujący sposób. z a in ic ja liz u j pusty stos S wczytaj lic z b ę , n while (n > 0) umieść n % 2 na s to s ie S n = n / 2 endwhile while (S nie je s t pusty) print pop(S) Ten algorytm został zaimplementowany w programie P4.4. Poniżej przedstawiliśmy wyłącznie klasę DecimalToBinary. Pozostałe klasy używane w programie — NodeData, Node oraz Stack — są takie same jak w programie P4.3. Program P4.4 import ja v a .u t i l . * ; public c la ss DecimalToBinary { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in);
133
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Stack S = new S ta c k (); System .out.printf("W pisz dodatnią licz b ę całkowitą: " ) ; int n = in .n e x tIn t(); while (n > 0) { S.push(new NodeData(n % 2 ) ) ; n = n / 2; } System .out.printf("\nO to ta sama liczb a zapisana w postaci dwójkowej: " ) ; while (!S.em pty()) System .out.printf("% d", S .p o p ().g e tD a ta ()); S y stem .o u t.p rin tf("\ n "); } //koniec main } //koniec klasy DecimalToBinary Poniżej przedstawiono przykładowe wyniki generowane przez ten program. Wpisz dodatnią licz b ę całkowitą: 99 Oto ta sama liczb a zapisana w postaci dwójkowej: 1100011
4.4. Konwertowanie wyrażenia z zapisu wrostkowego na przyrostkowy Jednym ze standardowych zastosowań stosu jest wyznaczanie wartości wyrażeń arytmetycznych. Problem związany z wyrażeniami arytmetycznymi polega na tym, że tradycyjna forma ich zapisu stosowana przez ludzi (tzw. zapis wrostkowy) nie nadaje się dla komputerów. Jeśli chcemy obliczyć wartość wyrażenia na komputerze, jednym ze sposobów jest skonwertowanie wyrażenia do zapisu przyrostkowego. Najpierw pokazano, jak wykonać taką konwersję, a następnie wyjaśniono, jak wykonuje się tak zapisane wyrażenia. Przeanalizujmy wyrażenie 7 + 3 * 4. Jaka będzie jego wartość? Bez znajomości kolejności wykonywania działań moglibyśmy wyliczać jego wartość, wykonując kolejne operacje arytmetyczne od lewej do prawej, czyli: (7 + 3 = 10) * 4 = 40. Jednak standardowe zasady kolejności wykonywania działań arytmetycznych określają, że m nożenie m a wyższy priorytet o d dodaw an ia. Oznacza to, że w takim wyrażeniu jak 7 + 3 * 4 najpierw jest wykonywane mnożenie (*), a dopiero potem dodawanie (+ ). Znając tę zasadę, możemy wyliczyć wartość wyrażenia: 7 + 12 = 19. Oczywiście, możemy wymusić wykonanie dodawania na początku, używając w tym celu nawiasów, np. (7 + 3) * 4. W tym przypadku zastosowanie nawiasów oznacza, że najpierw należy wykonać dodawanie. Powyższe wyrażenia są przykładami użycia zapisu wrostkowego — operatory arytmetyczne (+ oraz *) są umieszczane pomiędzy operandami. Jedną z wad tego zapisu jest konieczność stosowania nawiasów do wymuszenia odpowiedniej kolejności wykonywania działań, innej niż wynikająca z pierwszeństwa operatorów . Innym sposobem reprezentacji wyrażeń jest zastosowanie zapisu przyrostkow ego. W jego przypadku operator jest umieszczany za jego operandami, a określenie kolejności wykonywania działań nie wymaga stosowania nawiasów. Poniżej przedstawiono dwa przykłady tego samego wyrażenia zapisanego przy użyciu notacji wrostkowej oraz przyrostkowej. 7 + 3 * 4
można zapisać jako 7 3
4*
+
można zapisać jako 7 3
+4
*
oraz (7 + 3) * 4
W arto zauważyć, że w obu tych zapisach operandy występują w tej samej kolejności, choć zmienia się położenie operatora względem nich.
134
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
A dlaczego zapis przyrostkowy jest użyteczny? Jak już wspomniano, ludzie nie potrzebują stosować nawiasów, by określić kolejność wykonywania działań. Większe znaczenie ma jednak fakt, że zapis przyrostkowy zapewnia wygodny sposób przetwarzania wyrażeń. W przypadku zastosowania zapisu przyrostkowego algorytm obliczania wartości wyrażenia można opisać w następujący sposób. z a in ic ja liz u j początkowo pusty stos S while (nie dotarliśmy do końca wyrażenia) pobierz z wyrażenia następny element, x i f x je s t operandem, to umieść go na s to s ie i f x je s t operatorem, to zdejmij operandy z S, wykonaj operator, a następnie umieść wynik z powrotem na s to s ie S endwhile zdejmij wynik z S // to będzie wartość wyrażenia Przeanalizujmy wyrażenie (7 + 3) * 4, które można przedstawić przy użyciu zapisu przyrostkowego w następujący sposób: 7 3 + 4 *. Wyrażenie to jest wykonywane od strony lewej do prawej. 1. 2.
Kolejnym
elementem wyrażenia jest 7; umieszczamy 7
na stosieS,
S
zawiera 7.
Kolejnym elementem wyrażenia jest 3; umieszczamy 3 umieszczony z prawej strony).
na stosieS,
S
zawiera 7 3 (wie
3. Kolejnym elementem jest +; zdejmujemy ze stosu S 3 i 7; wykonujemy operator + na operandach 3 i 7, co daje wynik 10, który umieszczamy na stosie S, S zawiera 10. 4. 5.
Kolejnym
elementem wyrażenia jest 4; umieszczamy 4
na stosieS,
S
zawiera 10 4.
Kolejnym elementem jest *; zdejmujemy ze stosu S 4 i 10; wykonujemy operator * na operandach 4 i 10, co daje wynik 40, który umieszczamy na stosie S, S zawiera 40.
6. Dotarliśmy do końca wyrażenia; zdejmujemy element ze stosu S, uzyskując 40, czyli wartość wyrażenia. W arto zwrócić uwagę, że w momencie zdejmowania operandów ze stosu jako pierwszy zostaje zdjęty drugi operand, natomiast pierwszy operand zostaje zdjęty jako drugi. Nie ma to większego znaczenia w przypadku dodawania i mnożenia, natomiast będzie bardzo ważne podczas wykonywania odejmowania i dzielenia. W ramach ćwiczenia można skonwertować następujące wyrażenie do zapisu przyrostkowego, a następnie wykonać je zgodnie z powyższym algorytmem: (7 - 3) * (9 - 8 / 4). Oczywiście, podstawowym pytaniem jest, w jaki sposób komputer może przekształcić wyrażenie z zapisu wrostkowego na przyrostkowy? Zanim przedstawimy ten algorytm, trzeba zaznaczyć, że będzie on korzystał ze stosu operatorów . Oprócz tego zastosujemy w nim tablicę pierw szeństw a operatorów , która pozwoli określać priorytety operatorów względem siebie. Dla dowolnych dwóch operatorów tablica ta pozwoli na określenie, czy mają one taki sam priorytet (jak operatory + i -), a jeśli nie, to który z nich ma wyższy priorytet. W trakcie działania algorytm będzie stopniowo generował wyrażenie w zapisie przyrostkowym. Ten algorytm konwersji można opisać w następujący sposób. 1. Zainicjalizuj początkowo pusty stos operatorów S. 2. Pobierz następny element, x, z wyrażenia w zapisie wrostkowym; jeśli nie ma takiego elementu, przejdź do kroku 8. (x jest nie jest ani operandem, ani lewym bądź prawym nawiasem, ani operatorem). 3. Jeśli x jest operatorem, wyświetl x. 4. Jeśli x jest lewym nawiasem, umieść go na stosie S. 5. Jeśli x jest prawym nawiasem, zdejmij elementy ze stosu S i wyświetlaj je tak długo, aż na wierzchołku stosu S pojawi się lewy nawias; zdejmij lewy nawias ze stosu i zignoruj go. 6. Jeśli x jest operatorem, wykonaj kod o następującej logice: while (S nie je s t pusty) i (na wierzchołku stosu S nie znajduje s ię lewy nawias) i (na wierzchołku stosu znajduje s ię operator o p rio ry tecie większym lub równym priorytetowi operatora x) zdejmij element ze stosu S i wyświetl go umieść na s to s ie element x endwhile
135
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
7. Powtórz, zaczynając od kroku 2. 8. Zdejmij element ze stosu S i wyświetl go; powtarzaj te czynności, aż do momentu opróżnienia stosu. W ramach ćwiczenia warto skonwertować następujące wyrażenia, postępując zgodnie z powyższym algorytmem. 3 + 5 7 - 3 + 8 7 + 3 * 4 (7 + 3) * 4 (7 + 3 ) / (8 - 2 * 3) (7 - 8 / 2 / 2) * ((7 - 2) *
3 - 6)
Napiszmy teraz program, który będzie wczytywał uproszczone wyrażenie w zapisie wrostkowym i konwertował je do zapisu przyrostkowego. Założymy przy tym, że operandy są jednocyfrowymi liczbami całkowitymi, a dostępne operatory to +, -, * i /. Stosowanie nawiasów jest dopuszczalne. Obowiązują standardowe zasady pierwszeństwa operatorów: + i - mają ten sam priorytet, mniejszy od priorytetu operatorów * i / , przy czym ich priorytety są równe. Lewy nawias jest traktowany jako operator o bardzo małym priorytecie, mniejszym od priorytetu operatorów + i -. Zasady pierwszeństwa operatorów zaimplementujemy w postaci funkcji o nazwie precedence, która na podstawie przekazanego operatora zwróci liczbę całkowitą reprezentującą jego priorytet. Konkretna zwracana wartość nie ma większego znaczenia, o ile tylko zostanie zachowane pierwszeństwo operatorów względem siebie. W naszym programie funkcja ta będzie mieć następującą postać. public s t a t i c int precedence(char c) { //funkcja zwraca priorytet podanego operatora i f (c == ' ( ' ) return 0; i f (c == '+ ' || c == ' - ' ) return 3; i f (c == '* ' || c == ' / ' ) return 5; return -99; //błąd } //koniec precedence Funkcję precedence można także zapisać w alternatywnej postaci, używając instrukcji switch. public s t a t i c int precedence(char c) { switch (c) { case ' ( ' : return 0; case '+ ': case ' - ' : return 3; case ' * ' : case ' / ' : return 5; }//koniec switch } //koniec precedence Same wartości zwracane przez funkcję — 0, 3 i 5 — nie mają znaczenia. Mogą to być dowolne wartości, o ile tylko właściwie reprezentują względne pierwszeństwo operatorów. Będziemy także potrzebowali funkcji do wczytywania danych wejściowych i zwracania następnego, niepustego znaku. Jeśli trzeba, funkcja ta będzie pomijać wszystkie łańcuchy składające się z dowolnej liczby znaków odstępu. Znak końca wiersza będzie jednocześnie traktowany jako koniec wyrażenia. Poniżej zamieszczony został kod tej funkcji (nadano jej nazwę getToken). public s t a t i c char getToken() throws IOException { int n; while ((n = System .in.read ()) == ' ') ; //pomijamy znaki odstępu i f (n == '\ r ') return '\ 0 '; return (char) n; } //koniec getToken
136
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Stos operatorów będzie zwyczajnym stosem znaków, który zaimplementujemy, używając klasy NodeData przedstawionej w podrozdziale 4.3. Zamieszczono go w kodzie programu P4.5. Krok 6. opisanego wcześniej algorytmu wymaga porównania priorytetu operatora umieszczonego na wierzchołku stosu z priorytetem aktualnie przetwarzanego operatora. Zadanie to można by wykonać bardzo łatwo, gdybyśmy mieli możliwość „zerknięcia” na element umieszczony na wierzchołku stosu bez jego zdejmowania. W łaśnie w tym celu zaimplementujemy w klasie Stack metodę instancyjną peek. public NodeData peek() { i f (!th is.em p ty ()) return top.data; return n u ll; } //koniec peek Wszystkie te fragmenty zostały zebrane w programie P4.5, który stanowi implementację algorytmu konwersji wyrażeń z zapisu wrostkowego na przyrostkowy. Klasa Node ma taką samą postać jak w programie P4.3, natomiast klasa Stack różni się od przedstawionej w programie P4.3 jedynie dodaniem metody peek(). Program P4.5 import ja v a .i o .* ; public c la ss In fix T o P o stfix { public s t a tic void m ain(String[] args) throws IOException { char[] post = new char[255]; int n = readConvert(post); p rin tP o stfix (p o st, n); } //koniec main public s t a t i c int readConvert(char[] post) throws IOException { //funkcja wczytuje wyrażenie i wykonuje konwersję na zapis przyrostkowy; //zwraca długość wyrażenia w zapisie przyrostkowym Stack S = new S ta c k (); int h = 0; char c; System .out.printf("W pisz wyrażenie w zap isie wrostkowym i n a c iśn ij Enter\n"); char token = getToken(); while (token != '\ 0 ') { i f (C h aracter.isD igit(token )) post[h++] = token; e lse i f (token == ' ( ' ) S.push(new N od eD ata('(')); e lse i f (token == ' ) ' ) while ((c = S .p op ().g etD ata()) != ' ( ' ) post[h++] = c; e lse { while (!S.empty() && precedence(S.peek().getD ata()) >= precedence(token)) post[h++] = S .p o p ().g etD ata(); S.push(new NodeData(token)); } token = getToken(); } while (!S.em pty()) post[h++] = S .p o p ().g e tD ata(); return h; } //koniec readConvert public s t a t ic void p rin tP o stfix (ch a r[] post, int n) { System.out.printf("\nW yrażenie w zap isie przyrostkowym ma postać: \n"); fo r (in t h = 0; h < n; h++) System .out.printf("% c " , p o st[h ]); S y stem .o u t.p rin tf("\ n "); } //koniec printPostfix
137
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
public s t a t i c char getToken() throws IOException { int n; while ((n = System .in.read ()) == ' ') ; //pomijamy znaki odstępu i f (n == '\ r ') return '\ 0 '; return (char) n; } //koniec getToken public s t a t ic int precedence(char c) { //funkcja zwraca priorytet podanego operatora i f (c == ' ( ' ) return 0; i f (c == '+ ' || c == ' - ' ) return 3; i f (c == ' * ' || c == ' / ' ) return 5; return -99; //błąd } //koniec precedence } //koniec klasy InfixToPostfix class NodeData { char ch; public NodeData(char c) { ch = c; } public char getData() {return ch;} public s t a tic NodeData getRogueValue() {return new N odeD ata('$');} } //koniec klasy NodeData class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node class Stack { Node top = n u ll; public boolean empty() { return top == n u ll; } public void push(NodeData nd) { Node p = new Node(nd); p.next = top; top = p; } //koniec push public NodeData pop() { i f (this.em p ty ())retu rn NodeData.getRogueValue(); NodeData hold = top.d ata; top = top.next; return hold; } //koniec pop public NodeData peek() {
138
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
i f (!th is.em p ty ()) return top.data; return n u ll; } //koniec peek } //koniec klasy Stack Zadanie odczytu wyrażenia i jego skonwertowania na zapis przyrostkowy jest realizowane przez funkcję readConvert. Zapisuje ona skonwertowane wyrażenie w tablicy znaków post. Aby nie zaciemniać kodu sprawdzeniem ewentualnych błędów, założyliśmy, że tablica post jest dostatecznie duża, by można w niej zapisać skonwertowane wyrażenie. Funkcja readConvert zwraca liczbę elementów wyrażenia w zapisie przyrostkowym. Funkcja p rin tP o stfix służy do wyświetlenia wyrażenia w zapisie przyrostkowym. Poniżej przestawiono przykładowe wyniki wykonania programu P4.5. Wpisz wyrażenie w zap isie wrostkowym i n a ciśn ij Enter (7 - 8 / 2 / 2) * ((7 - 2) * 3 - 6) Wyrażenie w zap isie przyrostkowym ma postać: 7 8 2 / 2 / - 7 2 - 3 * 6 - * W arto zwrócić uwagę, że wyrażenie można wpisywać zarówno ze znakami odstępu pomiędzy operatoram i a operandami, jak również bez nich. Gdyby np. wyrażenie z przykładowego wykonania programu zostało zapisane w poniższej postaci, program i tak wygenerowałby prawidłowe wyrażenie w zapisie przyrostkowym: (7 - 8 /2 / 2 ) * ( ( 7-2) *3 - 6) Program zakłada, że wpisane wyrażenie jest poprawne. Jednak stosunkowo prosto można go zmodyfikować w taki sposób, by wykrywał niektóre rodzaje nieprawidłowych wyrażeń. Jeśli np. w wyrażeniu zapomniano 0 jakim ś prawym nawiasie, w momencie zakończenia jego wpisywania na stosie będzie się znajdował jeden lewy nawias. Podobnie, jeśli pominięty został lewy nawias, a po odczytaniu prawego nawiasu zaczniemy przeglądać stos w poszukiwaniu odpowiadającego mu lewego nawiasu, nie uda się nam go znaleźć. W arto w ramach ćwiczenia zmodyfikować program P4.5 w taki sposób, żeby wykrywał brakujące nawiasy w konwertowanym wyrażeniu. W arto go także rozbudować o możliwość podawania dowolnych, a nie jedynie jednocyfrowych liczb całkowitych. Dodatkową modyfikacją mogłoby być rozszerzenie go o obsługę innych operacji, takich jak %, sqrt (pierwiastek kwadratowy), sin (sinus), cos (cosinus), tan (tangens), log (logarytm) 1 exp (podnoszenie do potęgi).
4.4.1. Wyliczanie wartości wyrażeń arytmetycznych Program P4.5 umieszcza wyrażenie zapisane w notacji przyrostkowej w tablicy znaków o nazwie post. Teraz napiszemy funkcję, która na podstawie przekazanej tablicy post wyliczy i zwróci wartość wyrażenia. Funkcja ta będzie działać zgodnie z algorytmem opisanym na początku podrozdziału 4.4. Do wyliczenia wartości wyrażenia będziemy potrzebować stosu liczb całkow itych — użyjemy go do przechowywania operandów oraz wyników pośrednich. W arto przypomnieć sobie, że do przechowywania operatorów używaliśmy stosu znaków . Bez najmniejszych problemów będziemy w stanie posługiwać się oboma rodzajami stosów, jeśli zdefiniujemy klasę NodeData w następujący sposób. class NodeData { char ch; int num; public NodeData(char c) { ch = c; }
139
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
public NodeData(int n) { num = n; } public NodeData(char c, int n) { ch = c; num = n; } public char getCharData() {return ch;} public in t getIntD ata() {return num;} public s t a t ic NodeData getRogueValue() { return new NodeData('$', -999999); //użytkownik wybierze odpowiednią wartość } } //koniec klasy NodeData Pola typu char będziemy używali w stosie operatorów, natomiast pola typu int — w stosie operandów. W arto zwrócić uwagę na trzy konstruktory oraz trzy akcesory umożliwiające odpowiednio ustawianie i pobieranie wartości pól ch oraz num. Jeśli zastosujemy powyższą definicję klasy NodeData, program P4.5 będzie działał prawidłowo, po warunkiem że zastąpimy wszystkie wystąpienia metody getData metodą getCharData. Funkcja eval, która wyznacza wartość wyrażenia zapisanego w notacji przyrostkowej, została przedstawiona jako fragment kodu programu P4.6. Jej działanie sprawdzamy, wykonując następujące wywołanie, umieszczone jako ostatnia instrukcja metody main: System .out.printf("\nW artością tego wyrażenia j e s t : %d\n", ev al(p o st, n )); W kodzie programu P4.6 zostały pominięte klasy Node oraz Stack. Klasa Node ma taką samą postać jak w programie P4.3. Natomiast klasa Stack różni się od przedstawionej w programie P4.3 jedynie dodaniem metody peek(). Program P4.6 import ja v a .i o .* ; public c la ss EvalExpression { public s t a tic void m ain(String[] args) throws IOException { char[] post = new char[255]; int n = readConvert(post); p rin tP o stfix (p o st, n); System .out.printf("\nW artością tego wyrażenia j e s t : %d\n", ev al(p o st, n )); } //koniec main public s t a t i c int readConvert(char[] post) throws IOException { //funkcja wczytuje wyrażenie i wykonuje konwersję na zapis przyrostkowy; //zwraca długość wyrażenia w zapisie przyrostkowym Stack S = new S ta c k (); int h = 0; char c; System .out.printf("W pisz wyrażenie w zap isie wrostkowym i n a c iśn ij Enter\n"); char token = getToken(); while (token != '\ 0 ') { i f (C h aracter.isD igit(token )) post[h++] = token; e lse i f (token == ' ( ' ) S.push(new N o d eD ata('(')); e lse i f (token == ' ) ' ) while ((c = S.pop().getCharD ata()) != ' ( ' ) post[h++] = c;
140
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
e lse { while (!S.empty() && precedence(S.peek().getCharData()) >= precedence(token)) post[h++] = S.pop().getC harD ata(); S.push(new NodeData(token)); } token = getToken(); } while (!S.em pty()) post[h++] = S.pop().getC harD ata(); return h; } //koniec readConvert public s t a tic void p rin tP o stfix (ch a r[] post, int n) { System.out.printf("\nW yrażenie w zap isie przyrostkowym ma postać: \n"); fo r (in t h = 0; h < n; h++) System .out.printf("% c " , p o st[h ]); S y stem .o u t.p rin tf("\ n "); } //koniec printPostfix public s t a t i c char getToken() throws IOException { int n; while ((n = System .in.read ()) == ' ') ; //pomijamy odstępy i f (n == '\ r ') return '\ 0 '; return (char) n; } //koniec getToken public s t a t ic int precedence(char c) { //funkcja zwraca priorytet podanego operatora i f (c == ' ( ' ) return 0; i f (c == '+ ' || c == ' - ' ) return 3; i f (c == ' * ' || c == ' / ' ) return 5; return -99; //błąd } //koniec precedence public s t a tic int ev al(char[] post, int n) { //funkcja wylicza i zwraca wartość wyrażenia w zapisie przyrostkowym int a, b, c; Stack S = new S ta c k (); fo r (in t h = 0; h < n; h++) { i f (C h a ra cte r.isD ig it(p o st[h ])) S.push(new NodeData(post[h] - ' 0 ' ) ) ; e lse { b = S .p o p ().g e tIn tD a ta (); a = S .p o p ().g e tIn tD a ta (); i f (post[h] == ' + ') c = a + b; e lse i f (post[h] == ' - ' ) c = a - b; e lse i f (post[h] == ' * ' ) c = a * b; e lse c = a / b; S.push(new NodeData(c)) ; } //koniec if } //koniec fo r return S .p o p ().g e tIn tD a ta (); } //koniec eval } //koniec klasy EvalExpression class NodeData { char ch;
141
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
int num; public NodeData(char c) j ch = c; j public NodeData(int n) j num = n; j public NodeData(char c, int n) j ch = c; num = n; j public char getCharData() jretu rn c h ;j public in t getIntD ata() jretu rn num;j public s t a t ic NodeData getRogueValue() j return new NodeData('$', -999999); //użytkownik wybierze odpowiednią wartość j j //koniec klasy NodeData Oto przykładowe wyniki wykonania programu P4.6. Wpisz wyrażenie w zap isie wrostkowym i n a ciśn ij Enter (7 - 8 / 2 / 2) * ((7 - 2) * 3 - 6) Wyrażenie w zap isie przyrostkowym ma postać: 7 8 2 / 2 / - 7 2 - 3 * 6 - * Wartością tego wyrażenia j e s t : 45
4.5. Kolejki Kolejka jest listą liniową, w której elementy są dodawane na jednym końcu, a usuwane na drugim. Doskonale znanymi przykładami kolejek są te w bankach, supermarketach oraz do kas przed imprezami muzycznymi i sportowymi. Do kolejki dołączamy na końcu, a wychodzimy z niej na początku. Można przyjąć, że struktury danych reprezentujące kolejkę będą przydatne w tych wszystkich sytuacjach, które symulują kolejki występujące w realnym świecie. Kolejki stosuje się także w komputerach. Jeśli należy wykonać kilka zadań, będą one umieszczone w kolejce. Przykładowo kilka osób chce coś wydrukować na drukarce sieciowej. Ponieważ drukarka może wykonywać tylko jedno zadanie w danej chwili, wszystkie pozostałe zostaną umieszczone w kolejce. Istnieje kilka podstawowych operacji na kolejkach; są to: • dodawanie elementu do kolejki, • usuwanie elementu z kolejki, • sprawdzenie, czy kolejka jest pusta, • sprawdzenie elementu na początku kolejki. Podobnie jak w przypadku stosów, także i kolejki można implementować z wykorzystaniem tablic lub list powiązanych. W tym rozdziale do celów demonstracyjnych użyto kolejki liczb całkowitych.
142
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
4.5.1. Implementacja kolejki przy użyciu tablicy W implementacji kolejki (liczb całkowitych) za pomocą tablicy zastosujemy tablicę liczb całkowitych (nazwiemy ją QA), której będziemy używali do przechowywania liczb, oraz dwie liczby całkowite (head oraz t a i l ) , określające odpowiednio indeks elementu na początku listy i na jej końcu. Ponieważ używamy tablicy, by ją zadeklarować, musimy znać wielkość kolejki. Aby dobrać rozsądną wielkość tablicy, będziemy musieli dysponować jakim iś informacjam i na temat rozwiązywanego problemu. W naszym programie zastosujemy zmienną symboliczną MaxQ. Nasza kolejka będzie pełna, gdy spróbujemy dodać do niej element w przypadku, gdy zawiera ona już MaxQ-1 elementów. Zaczniemy od zdefiniowania klasy Queue. publ ic c la ss Queue { final s t a tic in t MaxQ = 100; int head = 0, ta il = 0; in t[] QA = new int[MaxQ]; Prawidłowymi wartościami pól head i ta il są liczby z zakresu od 0 do MaxQ-1. W momencie inicjalizacji kolejki w obu tych polach zapisywana jest wartość 0; nieco później przekonamy się, dlaczego wybór tej wartości jest dobrą decyzją. Pustą kolejkę możemy utworzyć w standardowy sposób: Queue Q = new Queue(); W efekcie wykonania powyższej instrukcji zostanie utworzona struktura danych przedstawiona na rysunku 4.5.
Rysunek 4.5. K olejka reprezen tow an a przy użyciu tablicy Reprezentuje ona pustą kolejkę. Do pracy z kolejkami będzie potrzebna funkcja pozwalająca sprawdzić, czy kolejka jest pusta. Można w tym celu zastosować funkcję w takiej postaci: public boolean empty() { return head == t a i l ; } Jak się niebawem przekonamy, zastosowane implementacje operacji dodawania i usuwania elementów kolejki sprawiają, że będzie ona pusta zawsze, gdy wartości pól head i ta il będą równe. Pola te nie zawsze będą mieć wartość równą 0 — mogą one przyjmować dowolne wartości z zakresu od 0 do MaxQ-1, co odpowiada zakresowi prawidłowych indeksów tablicy QA. Zastanówmy się, jak możemy dodać element do kolejki. W rzeczywistej kolejce osoba, która chce się do niej dołączyć, staje na końcu. My zrobimy to samo, inkrementując wartość zmiennej ta il i zapisując element w komórce tablicy określonej indeksem t a il. Aby np. dodać do kolejki liczbę 36, inkrementujemy pole tai l (przez co uzyska wartość 1) i zapisujemy wartość 36 w kom órce QA[1]; wartość pola head pozostaje niezmieniona i wynosi 0. Jeśli później zechcemy dodać do kolejki liczbę 15, zapiszemy ją w komórce QA[2], a wartość ta il wyniesie 2. Jeśli znowu zechcemy dodać do kolejki liczbę 52, zapiszemy ją w komórce QA[3], a wartość ta il wyniesie 3. Postać pamięci po wykonaniu tych operacji przedstawiono na rysunku 4.6.
143
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 4.6. Stan p o dodan iu do kolejki liczb 36, 15 oraz 52 Należy zwrócić uwagę, że pole head wskazuje na komórkę „bezpośrednio przed” pierwszym elementem, umieszczonym na początku kolejki; z kolei pole tai l wskazuje na ostatni element kolejki. Przeanalizujmy teraz operację usuwania elementu z kolejki. Usuwany jest zawsze element znajdujący się na początku. Aby go usunąć, na początku musimy inkrementować wartość head i zwrócić wartość umieszczoną w komórce, na którą to pole wskazuje. Jeśli np. usuniemy wartość 36, pole head przyjmie wartość 1 i będzie wskazywać komórkę umieszczoną „bezpośrednio przed” wartością 15, która teraz znajdzie się na początku kolejki. W arto zauważyć, że wartość 36 pozostanie zapisana w tablicy, jednak — praktycznie rzecz biorąc —już nie ma jej w kolejce. Załóżmy, że teraz dodamy do kolejki liczbę 23. Zostanie ona umieszczona w kom órce o indeksie 4; po wykonaniu operacji pole ta i l będzie mieć wartość 4, a pole head — wartość 1. Tę sytuację zilustrowano na rysunku 4.7.
Rysunek 4.7. Stan p o usunięciu z kolejki liczby 36 i dodan iu do niej liczby 23 Kolejka zawiera aktualnie trzy elementy: na początku znajduje się liczba 15, a na końcu liczba 23. Zastanówmy się teraz, co by się stało, gdybyśmy bezustannie dodawali do kolejki nowe elementy, bez usuwania z niej czegokolwiek. W takim przypadku wartość pola ta il powiększałaby się, aż do osiągnięcia wartości MaxQ-1, czyli ostatniego prawidłowego indeksu w tablicy QA. Co moglibyśmy zrobić, gdyby w takiej sytuacji pojawiła się konieczność dodania do listy kolejnego elementu? Oczywiście, m oglibyśm y stwierdzić, że kolejka jest pełna i zakończyć działanie programu. Jednak w rzeczywistości byłyby puste kom órki tablicy o indeksach 0 i 1. Lepszym rozwiązanie byłaby próba ich wykorzystania. W taki sposób dotarliśmy do idei list cyklicznych. W ich przypadku wyobrażamy sobie, że kom órki tablicy są rozmieszczone na obwodzie koła: za komórką o indeksie MaxQ-1 znajduje się komórka o indeksie 0. A zatem, jeśli pole tai l ma wartość MaxQ-1, jego inkrementacja sprawi, że przyjmie ono wartość 0. Załóżmy teraz, że nie usunęliśmy z kolejki żadnego elementu. W takim razie pole head wciąż ma wartość 0. Co się stanie, gdy podczas próby dodania do kolejki kolejnego elementu wartość pola ta il wynosząca MaxQ-1 zostanie inkrementowana i wyniesie 0? Oba pola, head i t a i l , będą zawierały tę samą wartość 0. W takim przypadku musimy uznać, że kolejka jest pełna. Zrobimy to, choć komórka o indeksie 0 jest pusta i można by w niej zapisać kolejny element kolejki. Zastosowaliśmy takie rozwiązanie, gdyż upraszcza kod i ułatwia określanie, kiedy kolejka jest pusta, a kiedy pełna. To także z tego powodu, inicjalizując kolejkę, obu polom przypisaliśmy wartość 0. Dzięki temu w przypadku ciągłego dodawania do kolejki nowych elementów bardzo łatwo będziemy mogli wykryć, kiedy zostanie w całości wypełniona. Zaznaczamy jeszcze raz: k olejka jest pełn a, kiedy zn ajdzie się na niej MaxQ-1 elem entów .
144
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Teraz możemy zająć się napisaniem instancyjnej metody enqueue, która będzie dodawać elementy do kolejki. public void enqueue(int n) { ta il = (t a il + 1) % MaxQ; //cykliczna inkrementacjapola tail i f ( ta il == head) { System .out.printf("\nK olejka je s t pełna!\n"); S y s te m .e x it(l); } QA[tail] = n; } //koniec enqueue Pierwszą operacją wykonywaną przez tę metodę jest inkrementacja pola t a i l. Jeśli w efekcie przyjmie ono tę samą wartość, co pole head, uznajemy, że kolejka jest pełna. W przeciwnym razie zapisujemy nowy element kolejki w komórce określonej przez t a il. W róćm y do przykładu z rysunku 4.7. Jeśli usuniemy z kolejki elementy 15 i 52, przyjmie ona postać przedstawioną na rysunku 4.8.
Rysunek 4.8. K olejka p o usunięciu elem en tów 15 i 52 Jak widać, teraz pole head ma wartość 3, ta il — wartość 4, a w kolejce znajduje się jedna wartość, 23, zapisana w kom órce o indeksie 4. Jeśli usuniemy tę ostatnią wartość, pola head oraz ta il będą miały tę samą wartość — 4 — co jednocześnie będzie oznaczać, że kolejka jest pusta. Oznaczałoby to, że kolejka jest pusta, gdy pole head przyjmie tę samą wartość, co pole t a i l. Ale chwileczkę! Czy przed chwilą nie stwierdzono, że kolejka jest p ełn a , kiedy pole head przyjmie tę samą wartość, co pole ta il? To prawda, a jednak jest pewna różnica pomiędzy tymi dwiema sytuacjami. W dowolnej chwili, jeśli head == t a i l , kolejka jest pusta. Jeśli jednak p o inkrem entacji wartości ta il w celu dodania nowego elementu pole przyjmie tę samą wartość, co pole head, będzie to oznaczało, że kolejka jest pełna. Teraz możemy napisać metodę dequeue, która usuwa elementy z kolejki. public in t dequeue() { i f (th is.em pty()) { System .out.printf("\nPróba usunięcia elementu z pustej k o le jk i!\ n "); S y ste m .e x it(2); } head = (head + 1) % MaxQ; //cykliczna inkrementacja pola head return QA[head]; } //koniec dequeue Jeśli kolejka jest pusta, funkcja wyświetla stosowny kom unikat i przerywa działanie programu. W przeciwnym przypadku inkrementujemy wartość pola head i zwracamy wartość kom órki wskazywanej przez to pole. Także w tym przypadku, jeśli podczas inkrem entacji pola head przyjmie ono wartość większą od MaxQ-1, przypisujemy mu 0. W celu przetestowania klasy Queue napiszemy program P4.7. Będzie on odczytywał liczbę całkowitą i wyświetlał tworzące ją cyfry w odwrotnej kolejności. Przykładowo po wpisaniu liczby 12345 program wyświetli 54321. Cyfry są zapisywane w kolejce, począwszy od prawej strony liczby. Następnie wszystkie elementy kolejki zostają kolejno pobrane i wyświetlone.
145
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Program P4.7 import ja v a .u t i l . * ; public c la ss QueueTest { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Queue Q = new Queue(); System .out.printf("W pisz dodatnią licz b ę całkowitą: " ) ; int n = in .n e x tIn t(); while (n > 0) { Q.enqueue(n % 10); n = n / 10; } System .out.printf("\nC yfry w odwrotnej k o lejn o ści: " ) ; while (!Q.empty()) System .out.printf("% d", Q.dequeue()); S y stem .o u t.p rin tf("\ n "); } //koniec main } //koniec klasy QueueTest class Queue { final s t a tic in t MaxQ = 100; int head = 0, ta il = 0; in t[] QA = new int[MaxQ]; public boolean empty() { return head == t a i l ; } public void enqueue(int n) { ta il = (t a il + 1) % MaxQ; //cykliczna inkrementacjapola tail i f ( ta il == head) { System .out.printf("\nK olejka je s t pełna!\n"); S y ste m .e x it(1); } QA[t a il] = n; } //koniec enqueue public in t dequeue() { i f (th is.em pty()) { System .out.printf("\nPróba usunięcia elementu z pustej k o le jk i!\ n "); S y ste m .e x it(2); } head = (head + 1) % MaxQ; //cykliczna inkrementacja pola head return QA[head]; } //koniec dequeue } //koniec klasy Queue Oto przykładowe wyniki wykonania tego programu. Wpisz dodatnią licz b ę całkowitą: 192837465 Cyfry w odwrotnej k o lejn o ści: 564738291
146
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
4.5.2. Implementacja kolejki przy użyciu listy powiązanej Podobnie jak stosy, także kolejki można implementować przy użyciu list powiązanych. To rozwiązanie ma tę zaletę, że nie trzeba zawczasu podejmować decyzji o tym, ile elementów kolejka będzie mogła zawierać. Do określania pierwszego i ostatniego elem entu kolejki użyjemy dwóch wskaźników; będą to odpowiednio head oraz t a i l . Na rysunku 4.9 przedstawiono strukturę danych kolejki, do której dodano cztery liczby (36, 15, 52 oraz 23).
Rysunek 4.9. R eprezen tacja k olejki przy użyciu listy pow iązan ej Kolejkę zaimplementujemy w taki sposób, by mogła operować na ogólnym typie danych, który nazwiemy NodeData. Każdy węzeł kolejki będzie obiektem klasy Node, zdefiniowanej w następujący sposób. class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node Definiując klasę NodeData, użytkownik może określić, jakiego rodzaju elementy będzie chciał przechowywać w kolejce. W naszym programie klasa Queue została zdefiniowana w następujący sposób. class Queue { Node head = n u ll, ta il = n u ll; public boolean empty() { return head == n u ll; } Pustą kolejkę możemy utworzyć, używając następującej instrukcji: Queue Q = new Queue(); Jej wykonanie spowoduje utworzenie struktury danych, której postać przedstawiono na rysunku 4.10.
Rysunek 4.10. Pusta k olejka (zaim plem entow an a za p o m o cą listy pow iązan ej)
147
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Aby dodać nowy element do kolejki, musimy go umieścić na końcu listy. To zadanie realizuje metoda enqueue. public void enqueue(NodeData nd) { Node p = new Node(nd); i f (th is.em pty()) { head = p ; ta il = p ; } e lse { t a il.n e x t = p; ta il = p ; } } //koniec enqueue Jeśli kolejka jest pusta, nowy element po dodaniu będzie jej jedyną zawartością, dlatego też oba pola, zarówno head, jak i t a i l , będą na niego wskazywać. Jeśli kolejka nie jest pusta, po dodaniu elementu zostanie zmodyfikowane pole t a i l , które będzie wskazywać na nowy, ostatni element kolejki. Podczas usuwania elem entu z kolejki najpierw sprawdzamy, czy nie jest ona pusta. Jeśli okaże się, że kolejka jest pusta, wyświetlamy stosowny kom unikat i kończym y działanie programu. W przeciwnym razie zwracany jest elem ent znajdujący się na początku kolejki, a węzeł, w którym był umieszczony, zostaje usunięty. Jeśli w konsekwencji usunięcia elementu pole head przyjmie wartość n u ll, będzie to oznaczało, że kolejka jest pusta. W takim przypadku także w polu tai l zapiszemy wartość null. Poniżej przedstawiono kod metody dequeue. public NodeData dequeue() { i f (th is.em pty()) { System .out.printf("\nPróba usunięcia elementu z pustej k o le jk i!\ n "); S y ste m .e x it(1); } NodeData hold = head.data; head = head.next; i f (head == null) ta il = n u ll; return hold; } //koniec dequeue Aby skorzystać z takiej klasy Queue, użytkownik musi jedynie określić, jak ma wyglądać typ NodeData. W ramach przykładu załóżmy, że interesuje nas kolejka liczb całkowitych. W takim przypadku klasę NodeData możemy zdefiniować w następujący sposób. class NodeData { int num; public NodeData(int n) { num = n; } public in t getIntD ata() {return num;} } //koniec klasy NodeData W poprzednim punkcie rozdziału przedstawiono program P4.7, który wczytywał liczbę całkowitą i wyświetlał wszystkie je j cyfry zapisane w odwrotnej kolejności. Zmodyfikujemy go teraz, wykorzystując nowe wersje klas Node, Queue oraz NodeData, i zapiszemy jako program P4.8.
148
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Program P4.8 import ja v a .u t i l . * ; public c la ss QueueTest { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); Queue Q = new Queue(); System .out.printf("W pisz dodatnią licz b ę całkowitą: " ) ; int n = in .n e x tIn t(); while (n > 0) { Q.enqueue(new NodeData(n % 1 0 )); n = n / 10; } System .out.printf("\nC yfry w odwrotnej k o lejn o ści: " ) ; while (!Q.empty()) System .out.printf("% d", Q .d equeu e().getIntD ata()); S y stem .o u t.p rin tf("\ n "); } //koniec main } //koniec klasy QueueTest class NodeData { int num; public NodeData(int n) { num = n; } public in t getIntD ata() {return num;} } //koniec klasy NodeData class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node class Queue { Node head = n u ll, ta il = n u ll; public boolean empty() { return head == n u ll; } public void enqueue(NodeData nd) { Node p = new Node(nd); i f (th is.em pty()) { head = p; ta il = p; } e lse { ta il .next = p; ta il = p; }
149
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
} //koniec enqueue public NodeData dequeue() { i f (th is.em pty()) { System .out.printf("\nPróba usunięcia elementu z pustej k o le jk i!\ n "); S y s te m .e x it(l); } NodeData hold = head.data; head = head.next; i f (head == null) ta il = n u ll; return hold; } //koniec dequeue } //koniec klasy Queue Poniżej przedstawiono przykładowe wyniki wykonania tego programu. Wpisz dodatnią licz b ę całkowitą: 192837465 Cyfry w odwrotnej k o lejn o ści: 564738291 Stosy i kolejki są bardzo istotne dla osób zajmujących się tworzeniem oprogramowania systemowego oraz kompilatorów. Mieliśmy okazję przekonać się, w jaki sposób można używać stosów do obliczania wartości wyrażeń arytmetycznych. Dodatkowo są one także wykorzystywane do implementacji mechanizmów „wywoływania” funkcji oraz „powrotu z” funkcji. Rozważmy sytuację, w której funkcja Awywołuje funkcję C, która z kolei wywołuje funkcję B, a ta wywołuje funkcję D. Kiedy wywołanie funkcji zostaje zakończone, w jaki sposób komputer może określić, do którego miejsca programu należy powrócić? Poniżej pokazano, jak można rozwiązać ten problem, wykorzystując stos. Załóżmy, że w naszym programie zaistniała sytuacja, w której liczba 100 oznacza adres pow rotny, czyli adres kolejnej instrukcji, którą należy wykonać po zakończeniu realizacji funkcji. function A
function B
C; 100:
function C
D;
function D
B;
200:
.
300:
Kiedy funkcja Awywoła funkcję C, adres 100 zostanie umieszczony na stosie S. Kiedy funkcja C wywoła funkcję B, na stosie S zostanie umieszczony adres 300. Gdy z kolei funkcja B wywoła funkcję D, na stosie zostanie umieszczony adres 200. W tym momencie stos wygląda w następujący sposób, a sterowanie znajduje się wewnątrz funkcji D: (spód stosu) 100
300
200
(wierzchołek stosu)
Kiedy funkcja Dzostanie zakończona i może wrócić do miejsca wywołania, ze stosu jest zdejmowany adres (w tym przypadku jest to adres 200), a sterowanie jest przenoszone w wyznaczone przez niego miejsce programu. Należy zwrócić uwagę, że adres ten wskazuje miejsce bezpośrednio za wywołaniem funkcji D. Następnie, kiedy funkcja B zostanie zakończona i może wrócić do miejsca wywołania, ze stosu jest zdejmowany kolejny adres (w tym przypadku jest to adres 300), a sterowanie jest przenoszone w wyznaczone przez niego miejsce programu. Należy zwrócić uwagę, że adres ten wskazuje m iejsce bezpośrednio za wywołaniem funkcji B. I w końcu, kiedy funkcja C zostanie zakończona i może wrócić do miejsca wywołania, ze stosu jest zdejmowany ostatni adres (w tym przypadku jest to adres 100), a sterowanie jest przenoszone w wyznaczone przez niego miejsce. Należy zwrócić uwagę, że adres ten wskazuje miejsce bezpośrednio za wywołaniem funkcji C.
150
ROZDZIAŁ 4. ■ STOSY I KOLEJKI
Oczywiście, opisywane tu struktury danych nazywane kolejkami są także używane w zastosowaniach symulujących kolejki, z którymi stykamy się w rzeczywistym świecie. Służą one także do implementacji kolejek w komputerach. W środowiskach umożliwiających jednoczesną realizację wielu procesów do kolejki można np. dodać kilka zadań oczekujących na dostęp do jednego zasobu, takiego jak procesor lub drukarka. Stosy i kolejki są także powszechnie wykorzystywane podczas wykonywania operacji na bardziej złożonych typach danych, takich jak drzewa i grafy. W ięcej informacji na temat drzew można znaleźć w rozdziale 8.
Ćwiczenia 1. Co rozumiemy pod pojęciem a b strak cyjneg o typ u dany ch? 2. Czym jest s tos? Jakie są podstawowe operacje, które można wykonywać na stosach? 3. Czym jest kolejka? Jakie są podstawowe operacje, które można wykonywać na kolejkach? 4. Zmodyfikuj program P4.5 w taki sposób, by wykrywał wyrażenia w zapisie wrostkowym, w których brakuje konkretnych nawiasów. 5. Program P4.5 wykonuje działania na operandach mających postać jednocyfrowych liczb całkowitych. Zmodyfikuj go w taki sposób, by obsługiwał dowolne liczby całkowite. 6. Zmodyfikuj program P4.5 w taki sposób, by obsługiwał wyrażenia z takimi operacjami jak procenty (%), pierwiastek kwadratowy, sinus, cosinus, tangens, logarytm oraz podnoszenie do potęgi. 7. Napisz deklaracje i funkcje konieczne do zaimplementowania stosu liczb typu double. 8. Napisz deklaracje i funkcje konieczne do zaimplementowania kolejki liczb typu double. 9. Tablica liczb całkowitych post jest używana do przechowywania wyrażeń arytmetycznych zapisanych w notacji wrostkowej i spełniających następujące założenia: • liczba dodatnia reprezentuje operandy; • liczba -1 reprezentuje operację dodawania; • liczba -2 reprezentuje operację odejmowania; • liczba -3 reprezentuje operację mnożenia; • liczba -4 reprezentuje operację dzielenia; • 0 oznacza koniec wyrażenia. Podaj zawartość tablicy post po przetworzeniu wyrażenia (2 + 3) * (8 / 4) - 6. Dodatkowo napisz funkcję eval, która na podstawie tablicy post obliczy wartość wyrażenia. 10. Wiersz danych wejściowych zawiera słowo zapisane wyłącznie małymi literami. Wyjaśnij, w jaki sposób można użyć stosu, by sprawdzić, czy podane słowo jest palindromem. 11. Pokaż, w jaki sposób można zaimplementować kolejkę, używając dwóch stosów. 12. Pokaż, w jaki sposób można zaimplementować stos, używając dwóch kolejek. 13. Kolejka priorytetowa jest kolejką, do której elementy są dodawane na podstawie posiadanego priorytetu. Zadania o wyższej wartości priorytetu trafiają bliżej początku kolejki niż te, które mają niższe priorytety. Zadanie jest dodawane do kolejki przed wszystkimi zadaniami mającymi mniejszy priorytet, lecz za wszystkimi zadaniami o wyższym priorytecie. Napisz klasę implementującą kolejkę priorytetową. Każdy element kolejki posiada numer zadania (liczbę całkowitą) oraz wartość priorytetu. Zaimplementuj co najmniej następujące operacje: (a) dodawanie zadania do kolejki w odpowiednim miejscu zależnym od priorytetu, (b) usuwanie z kolejki zadania znajdującego się na samym początku, (c) usuwanie z kolejki zadania o podanym numerze. Zadbaj o to, by metody działały niezależnie od stanu kolejki. 14. Stos S1 zawiera pewne liczby zapisane w dowolnej kolejności. Pokaż, w jaki sposób, używając drugiego stosu S2, można posortować zawartość S1 tak, by najmniejsza liczba znalazła się na jego wierzchołku, a największa — na spodzie.
151
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
152
ROZDZIAŁ
5
Rekurencja W tym rozdziale wyjaśnimy takie zagadnienia jak: • definicja rekurencyjna, • pisanie funkcji rekurencyjnych w języku Java, • konwersja liczby z zapisu dziesiątkowego na dwójkowy, • wyświetlanie listy powiązanej w odwrotnej kolejności, • problem wież Hanoi, • implementacja wydajnej funkcji do podnoszenia liczby do potęgi, • algorytm sortowania przez scalanie, • zastosowanie rekurencji do śledzenia podproblemów, które pozostają do rozwiązania, • zastosowanie rekurencji do implementacji mechanizmu cofania się na przykładzie odnajdywania drogi w labiryncie.
5.1. Definicje rekurencyjne Definicją rekurencyjną nazywamy definicję, która odwołuje się do siebie samej. Prawdopodobnie najczęściej podawanym przykładem takich definicji jest silnia. Silnia dodatniej liczby całkowitej n (zapisywana jako ni), jest zdefiniowana w następujący sposób: 0! = 1 ni = n(n - 1 ) ! , n > 0 Jak widać n! zostało zdefiniowane jako iloczyn liczby n i (n -1 )!, ale czym właściwie jest (n -1)!? Aby się tego dowiedzieć, ponownie musimy zastosować definicję silni! W tym przypadku będzie ona wyglądać następująco: (n - 1)! = 1, j e ś l i (n - 1) = 0 (n - 1)! = (n - 1)(n - 2 )! je ś l i
(n - 1) > 0
Azatem, ile będzie wynosić 3!? •
ponieważ 3> 0, zatem będzie to 3x2!;
•
ponieważ 2 > 0, zatem 2! jest równe 2x1!, czyli 3! jest równe 3x2x1!;
•
a ponieważ 1> 0, zatem 1! wynosi 1x0!, czyli 3! jest równe 3 x 2 x 1 x 0 !; a ponieważ 0! jest równe 1, zatem 3! wynosi 3 x 2 x 1 x 1 = 6.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Potocznie mówimy, że n ! jest iloczynem wszystkich liczb całkowitych z zakresu od 1 od n. Napiszmy teraz tę definicję w sposób bardziej zbliżony do zapisu programistycznego; będzie ona określać działanie funkcji o nazwie fact: fa c t(0 ) = 1 fact(n ) = n * fa ct(n - 1 ), n > 0 Rekurencyjna definicja funkcji składa się z dwóch części: • przypadku bazowego, określającego wartość funkcji, do której został przekazany argument o pewnej konkretnej wartości; jest on także określany jako kotwica, przypadek końcowy lub przypadek kończący i pozwala na zakończenie rekurencyjnie wykonywanych działań, • przypadku rekurencyjnego (lub ogólnego), w którym funkcja zostaje zdefiniowana w odniesieniu do siebie samej. Już niebawem napiszemy funkcję fa c t w języku Java, jednak zanim to zrobimy, podamy przykład rekurencji, który nie ma nic wspólnego z matematyką. Zastanówmy się, w jaki sposób można zdefiniować przodka. Najprościej rzecz ujmując, można by rzecz, że przodkiem są rodzice, dziadkowie, pradziadkowie itd. Jednak możemy to także wyrazić w bardziej precyzyjny sposób. a je s t przodkiem b , je ś l i (1) a je s t rodzicem b lub (2) a je s t przodkiem c i c je s t rodzicem b (1) jest przypadkiem bazowym, natomiast (2) — ogólnym, rekurencyjnym przypadkiem, w którym pojęcie bycia p rz o d kiem zostaje zdefiniowane w odniesieniu do siebie samego. Nieco bardziej żartobliwym przykładem rekurencji jest znaczenie akronimu LAME. Po rozwinięciu LAME oznacza LAME, Another MP3 Encoder. Kiedy ponownie rozwiniemy LAME, uzyskamy: LAME, Another M P3 Encoder, Another MP3 Encoder. Można zatem powiedzieć, że LAME jest akronimem rekurencyjnym. Nie jest on jednak w pełni zgodny z definicją rekurencji, gdyż nie ma żadnego przypadku bazowego.
5.2. Pisanie funkcji rekurencyjnych w języku Java Widzieliśmy już wiele przykładów funkcji, które wywołują inne funkcje. Nie widzieliśmy natomiast jeszcze funkcji, która wywołuje samą siebie, czyli funkcji rekurencyjnej. Zaczniemy od napisania funkcji fact. public s t a t i c int fa c t(in t n) { i f (n < 0) return 0 ; i f (n == 0) return 1; return n * fa ct(n - 1); } W ostatniej instrukcji funkcji umieściliśmy wywołanie do niej samej, do pisanej właśnie funkcji fact. Zatem funkcja wywołuje samą siebie. Przeanalizujmy następującą instrukcję: int n = f a c t ( 3 ) ; Zostanie ona wykonana w następujący sposób. 1. W artość 3 zostanie skopiowana do tymczasowego miejsca w pamięci, którego adres zostanie przekazany do funkcji fa c t, gdzie posłuży do określenia wartości zmiennej n. 2. Kiedy realizacja funkcji dotrze do jej ostatniej instrukcji, spróbuje ona wykonać wyrażenie 3 * fa c t(2 ). Jednak przed zwróceniem tej wartości konieczne będzie wywołanie funkcji fact z argumentem 2.
154
ROZDZIAŁ 5. ■ REKURENCJA
3. Jak zwykle, wartość 2 zostanie skopiowana do tymczasowego miejsca w pamięci, którego adres zostanie przekazany do funkcji fact, gdzie posłuży do określenia wartości zmiennej n. Gdyby fact była inną funkcją, nie byłoby żadnego problemu. Jednak jest to ta sam a fu n k cja , co się zatem stanie z pierwszą wartością n? Otóż musi ona zostać gdzieś zapisana, a następnie odtworzona po zakończeniu tego wywołania. 4. W artość n jest zapisywana na tzw. stosie wykonawczym (ang. runtim e stack). Za każdym razem gdy funkcja wywołuje samą siebie, przed jej wywołaniem i zastosowaniem nowych argumentów jej bieżące argumenty (oraz zmienne lokalne, jeśli takie są) zostają umieszczone na stosie. Następnie dla każdego wywołania tworzone są zmienne. A zatem, każde wywołanie funkcji dysponuje swoją własną kopią wszystkich argumentów i zmiennych lokalnych. 5. Gdy zmienna n przyjmie wartość 2, a realizacja funkcji dotrze do jej ostatniej instrukcji, spróbuje ona wykonać wyrażenie 2 * f a c t ( 1 ) . Jednak przed zwróceniem tej wartości konieczne będzie wywołanie funkcji fa c t z argumentem 1. 6. Kiedy realizacja aktualnie wykonywanej funkcji dotrze do ostatniego wiersza jej kodu, podjęta zostanie próba wyznaczenia wartości wyrażenia 1 * fa c t (0). Jednak przed określeniem tej wartości konieczne będzie wywołanie funkcji fa c t z argumentem 0. 7. W tym momencie stos wykonawczy zawiera argumenty 3, 2 oraz 1, przy czym na jego wierzchołku znajduje się wartość 1. Realizacja wywołania fa c t(0 ) dociera do drugiego wiersza kodu funkcji, gdzie kończy się zwróceniem wartości 1. 8. Teraz możliwe już jest wyliczenie wartości 1 * fa c t( 0 ) , dzięki czemu wywołanie fa c t(1 ) zwraca wartość 1. 9. Następnie możliwe już jest wyliczenie wartości 2 * fa c t(1 ), dzięki czemu wywołanie fa c t(2 ) zwraca wartość 2. 10. I w końcu możliwe już jest wyliczenie wartości 3 * f a c t( 2 ) , dzięki czemu wywołanie fa c t(3 ) zwraca wartość 6. Musimy podkreślić, że tę rekurencyjną wersję funkcji fa c t przedstawiliśmy tu wyłącznie w celach demonstracyjnych. Nie jest ona wydajnym sposobem wyliczania silni — pomyślmy o tych wszystkich wywołaniach funkcji, zapisywaniu i zdejmowaniu argumentów ze stosu, a wszystko tylko po to, by pomnożyć liczby z zakresu od 1 do n. Znacznie bardziej wydajny sposób wyliczania silni przedstawia następująca funkcja. public s t a t i c int fa c t( in t n) { int f = 1; while (n > 0) { f = f * n; --n ; } return f ; } Innym przykładem funkcji, którą można zdefiniować w sposób rekurencyjny, jest wyznaczanie największego wspólnego dzielnika dwóch nieujemnych liczb całkowitych mi n. hcf(m, n) wynosi (1) m, je ś l i n je s t równe 0 (2) hcf(n, m % n ), dla n > 0 W przypadku gdy m = 70, a n = 42 uzyskamy: h cf(70, 42)
= h cf(42, 70 % 42) =h cf(42, 28) = h cf(28, 42 % 28) = h cf(28, 14)= h cf(14, 28 % 14) = h cf(14, 0) = 14
W języku Java rekurencyjną funkcję hcf możemy napisać tak: public s t a t i c int h c f(in t m, int n) { i f (n == 0) return m; return hcf(n, m % n); }
155
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W ramach ciekawostki warto wiedzieć, że funkcję hcf można także napisać z użyciem pętli (a nie rekurencji), wykorzystując w niej algorytm Euklidesa. Oto kod tej funkcji. public s t a t i c int h c f(in t m, int n) { int r ; while (n > 0) { r = m % n; m = n; n = r; } return m; } Funkcja ta w jasny sposób robi dokładnie to samo, co poprzednia wykonywała w sposób niejawny. Jeszcze innym przykładem funkcji zdefiniowanej rekurencyjnie jest wyznaczenie ciągu Fibonacciego. Dwiema pierwszymi liczbami tego ciągu są 1 i 1. Każda kolejna liczba jest uzyskiwana poprzez dodanie dwóch poprzednich. A zatem początkowy fragment ciągu Fibonacciego ma postać: 1, 1, 2, 3, 5, 8, 13, 21 i tak d a l e j. .. Wykorzystując zapis rekurencyjny, «-tą liczbę ciągu Fibonacciego można wyliczyć w następujący sposób: F(0) = F(1) = 1 F(n) = F(n - 1) + F(n - 2 ), n > 1 Napisana w języku Java funkcja, która wylicza «-tą liczbę ciągu Fibonacciego, ma postać: public s t a t i c int f ib ( in t n) { i f (n == 0 || n == 1) return 1; return fib (n - 1) + fib (n - 2 ); } Także w tym przypadku podkreślamy, że choć przedstawiona funkcja jest elegancka, spójna i łatwa do zrozumienia, to jednak nie jest efektywna. Przeanalizujmy np. sposób obliczenia wartości F(5): F(5) = F(4) + F(3) = F(3) + F(2) + F(3) = F(2) + F(1) + F(2) + F(3) = F(1) + F(0) + F(1) + F(2) + F(3) = 1 + 1 + 1 + F(1) + F(0) + F(3) = 1 + 1 + 1+ 1 + 1 + F(2) + F(1) = 1 + 1 + 1 + 1 + 1 + f (1) + f (0) + F(1) = 1 + 1 + 1+ 1 + 1 + 1 + 1 + 1 = 8 W arto zwrócić uwagę na liczbę wykonanych wywołań funkcji oraz operacji dodawania; a wszystko to w celu wyliczenia wartości F (5), co można bardzo prosto zrobić przy użyciu czterech operacji dodawania. Sugerujemy, by we własnym zakresie spróbować napisania wydajnej funkcji wyliczającej «-tą liczbę ciągu Fibonacciego.
5.3. Konwersja liczby dziesiątkowej na dwójkową przy użyciu rekurencji W punkcie 4.3.1 przedstawiono sposób konwersji liczb dziesiątkowych na dwójkowe z wykorzystaniem stosu. Teraz wykonamy tę samą operację przy użyciu funkcji rekurencyjnej. Aby przekonać się, co mamy zrobić, wyobraźmy sobie, że n wynosi 13, czyli 1101 w zapisie dwójkowym. Pamiętamy już, że n % 2 daje ostat«i bit dwójkowego odpowiednika liczby n. Gdybyśmy w jakiś sposób byli w stanie wyświetlić wszystkie bity liczby dwójkowej z wyjątkiem ostatniego, moglibyśmy to zrobić, a następnie wyświetlić wartość n % 2. Jednak „wyświetlenie wszystkich bitów z wyjątkiem ostatniego”, to to samo, co wyświetlenie n/2.
156
ROZDZIAŁ 5. ■ REKURENCJA
Przykładowo 1101 to 110 z dodanym ostatnim bitem 1; 110 to dwójkowy odpowiednik liczby 6, czyli 13/2; natomiast 1 to wynik działania 13 % 2. A zatem dwójkowy odpowiednik dziesiątkowej liczby n można wyświetlić w następujący sposób: print dwójkową postać n / 2 print n % 2 Dokładnie w ten sam sposób wyświetlimy dwójkowy odpowiednik liczby 6. Będzie to dwójkowy odpowiednik wyrażenia 6/2, czyli 3 (11 dwójkowo), oraz wynik dzielenia 6 % 2, czyli 0; w efekcie uzyskujemy liczbę dwójkową 110. W ten sam sposób wyświetlimy dwójkowy odpowiednik liczby 3. Będzie to dwójkowy odpowiednik wyrażenia 3/2, czyli 1 (1 dwójkowo), oraz wynik dzielenia 3 % 2, czyli 1; w efekcie uzyskujemy liczbę dwójkową 11. Tej samej metody użyjemy, by wyświetlić dwójkowy odpowiednik liczby 1. Będzie to dwójkowy odpowiednik wyrażenia 1/2, czyli 0, oraz wynik dzielenia 1 % 2, czyli 1; ponieważ pierwsze wyrażenie zwraca 0, które ignorujemy, zatem w efekcie uzyskujemy liczbę dwójkową 1. Konwersję przerywamy, gdy okaże się, że musimy znaleźć dwójkowy odpowiednik 0. Dzięki temu dochodzimy do funkcji w następującej postaci: public s t a t i c void decToBin(int n) { i f (n > 0) { decToBin(n / 2 ); System .out.printf("% d", n % 2 ); } } Wywołanie decToBin(13) wyświetli wynik 1101. W arto zwrócić uwagę, o ile bardziej zwarty jest kod tej funkcji, w porównaniu z kodem zastosowanym w programie P4.4. Operacje na stosie, wykonywane tak często w programie P4.4, zostały tutaj zastąpione mechanizmami rekurencyjnymi obsługiwanymi przez sam język w momencie wywoływania funkcji. By dokładnie zobaczyć, jak działają, prześledźmy proces realizacji wywołania decToBin(13). 1. W momencie pierwszego wywołania n przyjmuje wartość 13. 2. W ramach realizacji wywołania decToBin(13) wykonywane jest wywołanie decToBin(6); wartość 13 zostaje umieszczona na stosie wykonawczym, a n przyjmuje wartość 6. 3. W ramach realizacji wywołania decToBin(6) wykonywane jest wywołanie decToBin(3); wartość 6 zostaje umieszczona na stosie, a n przyjmuje wartość 3. 4. W ramach realizacji wywołania decToBin(3) wykonywane jest wywołanie decToBin(1); wartość 3 zostaje umieszczona na stosie, a n przyjmuje wartość 1. 5. W ramach realizacji wywołania decToBin(1) wykonywane jest wywołanie decToBin(0); wartość 1 zostaje umieszczona na stosie, a n przyjmuje wartość 0. 6. Na tym etapie realizacji wywołania na stosie znajdują się wartości: 13, 6, 3 oraz 1. 7. Ponieważ n wynosi 0, zatem to wywołanie zostaje natychmiast zakończone; na razie nie zostały jeszcze wyświetlone żadne wyniki. 8. Po zakończeniu wywołania decToBin(0) argument umieszczony na wierzchołku stosu, czyli 1, zostaje z niego zdjęty i staje się ponownie wartością zmiennej n. 9. Sterowanie zostaje przekazane do wywołania metody p rin tf, która wyświetla wartość wyrażenia 1 % 2, czyli 1. 10. Teraz zostaje zakończone wywołanie decToBin(1), argument umieszczony na wierzchołku stosu, czyli 3, zostaje z niego zdjęty i staje się ponownie wartością zmiennej n. 11. Sterowanie zostaje przekazane do wywołania metody p rin tf, która wyświetla wartość wyrażenia 3 % 2, czyli 1. 12. Teraz zostaje zakończone wywołanie decToBin(3), argument umieszczony na wierzchołku stosu, czyli 6, zostaje z niego zdjęty i staje się ponownie wartością zmiennej n.
157
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
13. Sterowanie zostaje przekazane do wywołania metody p rin tf, która wyświetla wartość wyrażenia 6 % 2, czyli 0. 14. Teraz zostaje zakończone wywołanie decToBin(6), argument umieszczony na wierzchołku stosu, czyli 13, zostaje z niego zdjęty i staje się ponownie wartością zmiennej n. 15. Sterowanie zostaje przekazane do wywołania metody pri n tf, która wyświetla wartość wyrażenia 13 % 2, czyli 1. 16. Zostaje zakończone wywołanie decToBin(13), które wyświetliło wyniki 1101. Powyższy opis można by zilustrować w następujący sposób: decToBin(13)
decToBin(6) p rin t(13 % 2) decToBin(3) p rin t(6 % 2) p rin t(13 % 2) decToBin(1) p rin t(3 % 2) p rin t(6 % 2) p rin t(13 % 2) decToBin(0) = nic nie robimy p rin t(1 % 2) = 1 p rin t(3 % 2) = 1 p rin t(6 % 2) = 0 p rin t(13 % 2) = 1
Jedną z najważniejszych właściwości funkcji rekurencyjnych jest to, że w momencie wywoływania funkcji przez nią samą jej argumenty (oraz zmienne lokalne, jeśli takie są) zostają umieszczone na stosie. Realizacja funkcji rozpoczyna się do początku, przy czym używane są nowe wartości argumentów i zmiennych lokalnych. Kiedy wywołanie zostaje zakończone, argumenty (i zmienne lokalne) są pobierane ze stosu, a realizacja funkcji jest kontynuowana, począwszy od miejsca, w którym wcześniej zostało wykonane wywołanie rekurencyjne; przy czym teraz używane są wartości pobrane ze stosu. Załóżmy, że dysponujemy funkcją, której fragment kodu został przedstawiony poniżej, i przyjmijmy, iż zostało wykonane wywołanie w postaci t e s t ( 4 , 9): public s t a t i c void t e s t ( i n t m, in t n) { char ch; test(m + 1, n - 1); System .out.printf("% d %d", m, n ); } Funkcja zostaje wykonana z wykorzystaniem argumentów m = 4, n = 9 oraz zmiennej lokalnej ch. Oto co się stanie w momencie wykonania rekurencyjnego wywołania funkcji te st. 1. W artości m, n oraz ch zostaną umieszczone na stosie. 2. Rozpoczyna się wykonywanie funkcji te s t, przy czym parametr m = 5 i n = 8, a dodatkowo używana jest nowa, pusta kopia zmiennej lokalnej ch. 3. Kiedy zostanie zakończone to wywołanie funkcji te s t (niezależnie od tego, kiedy by to nie było, może nawet po kilku dalszych wywołaniach samej siebie i wygenerowaniu jakichś wyników), używane wartości zostają usunięte ze stosu, a program wznawia działanie od wywołania metody p rin tf (umieszczonego w następnej instrukcji za wywołaniem rekurencyjnym), używając przy tym zdjętych ze stosu wartości m, n i ch. W tym przykładzie zostałyby wyświetlone wyniki 4 9.
158
ROZDZIAŁ 5. ■ REKURENCJA
5.4. Wyświetlanie listy powiązanej w odwrotnej kolejności Przeanalizujmy problem wyświetlania listy powiązanej w odwrotnej kolejności.
Jednym z potencjalnych rozwiązań tego problemu jest odczytanie kolejno wszystkich węzłów listy i umieszczanie na stosie ich wartości. Po zakończeniu odczytywania listy wartość jej ostatniego węzła będzie się znajdować na wierzchołku stosu, natomiast wartość pierwszego węzła — na spodzie stosu. Następnie wystarczy pobierać kolejne elementy ze stosu i wyświetlać je. Zgodnie z tym, czego możemy się już spodziewać, okazuje się, że problem ten można także rozwiązać rekurencyjnie. Ideę tego rozwiązania można by opisać w następujący sposób. aby wyświetlić l i s t ę w odwrotnej ko lejn o ści, wyświetl w odwrotnej kolejności l i s t ę z wyjątkiem j e j pierwszego elementu wyświetl pierwszy element lis t y Jeśli założymy, że dysponujemy listą przedstawioną powyżej, oznacza to, że mamy wyświetlić w odwrotnej kolejności listę (15 52 23), a następnie wyświetlić 36. • Aby wyświetlić w odwrotnej kolejności listę (15 52 23), musimy wyświetlić w odwrotnej kolejności listę (52 23), a następnie wyświetlić 15. • Aby wyświetlić w odwrotnej kolejności listę (52 23), musimy wyświetlić w odwrotnej kolejności listę (23), a następnie wyświetlić 52. • Aby wyświetlić w odwrotnej kolejności listę (23), nie musimy wyświetlać niczego (po usunięciu 23 z listy zostaje ona opróżniona w całości i nie ma co wyświetlać), a następnie wyświetlić 23. Po zakończeniu powyższych operacji zostałyby wyświetlone następujące wyniki: 23 52 15 36. Operacje te można także przedstawić w innej formie: reverse(36 15 52 23)
reverse(15 52 23) 36 reverse(52 23) 15 36 reverse(23) 52 15 36 reverse() 23 52 15 36 23 52 15 36
Poniżej przedstawiono kod funkcji wyświetlającej listę w odwrotnej kolejności, przy czym założono, że jest do niej przekazywany wskaźnik na początek listy, a lista składa się z węzłów typu Node, zawierających pola num oraz next. public s t a t i c void reverse(Node top) { i f (top != null) { re v e rse(to p .n e x t); System .out.printf("% d " , top.num); } } Kluczowe znaczenie dla określenia rekurencyjnego rozwiązania problemu ma możliwość wyrażenia tego rozwiązania w oparciu o to samo rozwiązanie operujące na nieco mniejszym problemie. Jeśli problem za każdym razem jest coraz mniejszy, w pewnym momencie stanie się tak mały, że będziemy w stanie rozwiązać go bezpośrednio.
159
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Tę zasadę zademonstrowano w obu przedstawionych wcześniej problemach rekurencyjnych, czyli w konwersji liczby dziesiątkowej na dwójkową oraz w wyświetlaniu listy w odwrotnej kolejności. W pierwszym z nich konwersja wartości n została wyrażona jako konwersja wartości n/2, ta z kolei jako konwersja wartości n/4 itd., aż do momentu, w którym nie zostało już nic do skonwertowania. W drugim problemie wyświetlenie listy w odwrotnej kolejności zostało przedstawione jako wyświetlenie w odwrotnej kolejności nieco krótszej listy (w której pominięto pierwszy element). Lista stawała się zatem coraz krótsza, aż w pewnym momencie nie było już co wyświetlać.
5.5. Problem wież Hanoi Zagadka wież Hanoi jest klasycznym problemem, który można rozwiązać w sposób rekurencyjny. Legenda głosi, że podczas tworzenia świata mnisi ze świątyni Brahmy dostali trzy złote słupki. Na jednym z nich umieszczono sześćdziesiąt cztery złote krążki. Wszystkie miały inną średnicę, największy z nich był umieszczony na samym dole, a najmniejszy na samej górze; co więcej, żaden dysk nie był umieszczony nad mniejszym krążkiem. M nisi mieli za zadanie przenieść wszystkie sześćdziesiąt cztery krążki na inny słupek, zgodnie z następującymi regułami. • Można przenosić tylko jeden krążek; można przenosić tylko krążek umieszczony na wierzchołku słupka i musi on zostać umieszczony wyłącznie na wierzchołku innego słupka. • Można umieszczać wyłącznie mniejsze krążki na większych. Kiedy wszystkie krążki zostaną przeniesione, nastąpi koniec świata. Wieże Hanoi są przykładem problemu, który rekurencyjnie można rozwiązać całkiem łatwo, lecz którego inne rozwiązania są dosyć trudne. Oznaczmy słupki literami A, B i C i załóżmy, że krążki są początkowo umieszczone na słupku A, a słupkiem docelowym jest B. Słupek C pełni rolę pomocniczą i służy do tymczasowego przechowywania krążków. Załóżmy, że jest tylko jeden dysk. Można go przenieść bezpośrednio ze słupka A na B . Teraz załóżmy, że na słupku A znajduje się pięć krążków, co pokazano na rysunku 5.1.
Rysunek 5.1. Z ag ad ka wież H an oi z p ięciom a dyskam i Załóżmy, że wiemy, jak przenieść cztery najwyższe krążki ze słupka A na C , używając przy tym słupka B . Kiedy operacja ta zostanie wykonana, sytuacja będzie wyglądać tak, jak na rysunku 5.2.
A
B
C
Rysunek 5.2. Po przen iesien iu czterech dysków z A na C Teraz możemy już przenieść piąty dysk ze słupka A na B, co pokazano na rysunku 5.3.
160
ROZDZIAŁ 5. ■ REKURENCJA
A
B
C
Rysunek 5.3. P iąty dysk przen iesion y na słu pek B Pozostaje zatem przenieść cztery dyski ze słupka C na B, używając przy tym słupka A ; a to, zgodnie z wcześniejszym założeniem, wiemy, jak zrobić. Zadanie zostało wykonane, co pokazano na rysunku 5.4.
Rysunek 5.4. Po przen iesien iu czterech dysków z C na B A zatem udało się nam zredukować problem przeniesienia pięciu dysków do problemu przeniesienia czterech dysków z jednego słupka na inny. Ten z kolei można zredukować do problemu przeniesienia trzech krążków, ten można zredukować do przeniesienia dwóch krążków, który można następnie zredukować do przeniesienia jednego krążka, a to wiemy, jak zrobić. Rekurencyjne rozwiązanie problemu przeniesienia n krążków można zapisać w następujący sposób. 1. Przenieś n -1 krążków ze słupka A na C, korzystając z B. 2. Przenieś n-ty krążek ze słupka A na B. 3. Przenieś n -1 krążków ze słupka C na B, korzystając z A. Oczywiście, tego samego rozwiązania możemy użyć do przeniesienia n -1 dysków. Poniższa funkcja przenosi n dysków ze słupka startPin na słupek endPi n, używając przy tym słupka workPi n. public s t a t i c void hanoi(int n, char sta rtP in , char endPin, char workPin) { i f (n > 0) { hanoi(n - 1, sta rtP in , workPin, endPin); System .out.printf("Przenoszę dysk z %c na %c\n", sta rtP in , endPin); hanoi(n - 1, workPin, endPin, s ta rtP in ); } } W przypadku użycia następującego wywołania: hanoi(3, 'A ', 'B ', 'C ') ; //przenosimy trzy dyski z A na B, używając C funkcja wyświetli następujące wyniki: Przenoszę Przenoszę Przenoszę Przenoszę Przenoszę Przenoszę Przenoszę
dysk dysk dysk dysk dysk dysk dysk
zA zA zB zA zC zC zA
na na na na na na na
B C C B A B B
A ile kroków potrzeba do przeniesienia n dysków? • Jeśli n jest równe 1, konieczny jest jeden krok: ( 2 '-1 = 1). • Jeśli n jest równe 2, konieczne są trzy kroki: (22-1 = 3). • Jeśli n jest równe 3, koniecznych jest siedem kroków: (23-1 = 7).
161
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jak widać, n krążków można przenieść w 2n-1 krokach. Można udowodnić, że faktycznie tak jest. Jeśli zatem n wynosi 64, to liczba kroków wyniesie: 2- - 1 = 18 446 744 073 709 551 615 Przy założeniu, że wykonanie każdego kroku zajmuje mnichom jedną sekundę oraz nigdy nie popełniają błędów i nigdy nie odpoczywają, wykonanie zadania zajmie im niemal 600 miliardów lat. Możemy zatem spać spokojnie i nie obawiać się, że koniec świata nastąpi niebawem.
5.6. Funkcja podnosząca liczbę do potęgi Jeśli mamy daną liczbę x oraz liczbę całkowitą n, taką że n > 0, w jaki sposób można obliczyć wartość x podniesioną do potęgi n, czyli xn? Aby to obliczyć, możemy skorzystać z definicji, która stwierdza, że xn to wartość x pomnożona przez samą siebie n -1 razy. A zatem 34 to 3x3x 3x3. Poniżej przedstawiono metodę, która wylicza potęgę, korzystając z tej definicji. public s t a t i c double power(double x, in t n) { double pow = 1.0 ; fo r (in t h = 1; h <= n; h++) pow = pow * x; return pow; } Najpierw należy zauważyć, że gdy n = 0, funkcja power zwraca 1, co jest prawidłowym wynikiem. Funkcja wykonuje n mnożeń. Jednak istnieje inne rozwiązanie tego samego problemu, które pozwoliłoby napisać bardziej wydajną funkcję. Załóżmy, że chcemy obliczyć wartość x 16. Możemy to zrobić w następujący sposób. • Jeśli przyjmiemy, że x8 = x% możemy pomnożyć x8 razy x8, uzyskując wartość x16 i wykonując w tym celu tylko jedno mnożenie. • Jeśli przyjmiemy, że x4 = x% możemy pomnożyć x4 razy x4, uzyskując wartość x8 i wykonując w tym celu tylko jedno mnożenie. • Jeśli przyjmiemy, że x2 = x2, możemy pomnożyć x2 razy x2, uzyskując wartość x4 i wykonując w tym celu tylko jedno mnożenie. Wartość x znamy, zatem x2 możemy wyliczyć, wykonując jedną operację mnożenia. Kiedy znamy x2, jesteśmy w stanie obliczyć x4, wykonując kolejną operację mnożenia. Gdy znamy x4, jesteśm y w stanie wyliczyć x8, wykonując następną operację mnożenia. Kiedy znamy x8, jeszcze jedno mnożenie pozwoli wyznaczyć wartość x 16. Innymi słowy, jesteśm y w stanie wyznaczyć wartość x 16, wykonując tylko cztery operacje mnożenia. A co należałoby zrobić, gdy n wyniesie 15? Najpierw moglibyśmy obliczyć x 15/2, czyli x7 (oznaczmy to jako x7). Następnie powinniśmy pomnożyć x7xx7, aby uzyskać x 14. Następnie wystarczyłoby rozpoznać, że n jest liczbą nieparzystą i pomnożyć wyznaczoną wcześniej wartość przez x, uzyskując w ten sposób końcowy wynik. Podsumujmy to wszystko. xn = xn/2 * xn/2, je ś l i n je s t parzyste oraz x * x-'2 * x-'2, j e ś l i n je s t nieparzyste Powyższej zależności możemy użyć jako podstawy do napisania bardziej wydajnej funkcji obliczającej wartość xn. public s t a t i c double power(double x, in t n) { double y; i f (n == 0) return 1.0 ; y = power(x, n /2 ); y = y * y; i f (n % 2 == 0) return y; return x * y; } W ramach ćwiczenia warto prześledzić wykonanie tej funkcji dla n = 5 i n = 6.
162
ROZDZIAŁ 5. ■ REKURENCJA
5.7. Sortowanie przez scalanie Rozpatrzmy jeszcze raz problem sortowania «-elementowej listy w kolejności rosnącej. Zilustrowano go na przykładzie listy liczb całkowitych. W podrozdziale 1.9 pokazano, jak można scalić dwie posortowane listy, przechodząc każdą z nich tylko jeden raz. Teraz zobaczymy, jak wykorzystać rekurencję i scalanie w celu posortowania listy. Rozpatrzmy następujący algorytm. posortuj l i s t ę posortuj pierwszą połowę lis t y posortuj drugą połowę lis t y scal posortowane połówki l is t y w jedną posortowaną l i s t ę koniec sortowania Skoro możemy posortować połówki listy, a następnie je scalić, to uzyskamy jedną posortowaną listę. Ale w jaki sposób możemy posortować połówki listy? Używając tej samej metody! Aby np. „posortować pierwszą połowę listy”, wykonamy następujące operacje. posortuj (pierwszą połowę lis t y ) posortuj pierwszą połowę (pierwszej połowy lis t y ) // czyli ćwiartkę orygi«al«ej listy posortuj drugą połowę (pierwszej połowy lis t y ) // czyli ćwiartkę orygi«al«ej listy scal posortowane połówki l is t y w jedną posortowaną l i s t ę koniec sortowania I tak dalej. Każdy fragment listy, który mamy posortować, dzielimy na dwie połowy, sortujemy każdą z nich, a następnie scalamy uzyskane, posortowane listy. A kiedy zakończy się ten proces dzielenia i sortowania? Nastąpi to wtedy, gdy sortowany fragment listy będzie zawierał tylko jeden element — aby posortować listę składającą się z jednego elementu, nie trzeba nic robić. Nasz algorytm możemy zatem zmodyfikować w następujący sposób. posortuj l i s t ę i f l i s t a zawiera więcej niż jeden element posortuj pierwszą połowę lis t y posortuj drugą połowę lis t y scal posortowane połówki l is t y w jedną posortowaną l i s t ę end i f koniec sortowania Zakładamy, że lista jest zapisana w tablicy A, w jej elementach należących do zakresu od A[lo] do A [hi]. W takim razie powyższy algorytm możemy zaimplementować w formie następującej funkcji napisanej w języku Java. public s t a t i c void m ergeSort(int[] A, int lo , in t hi) { i f (lo < hi) { //lista zawiera co «ajm«iej 2 eleme«ty int mid = (lo + hi) / 2; //i«deks środkowego eleme«tu mergeSort(A, lo , mid); //sortowa«ie pierwszej potowy mergeSort(A, mid + 1, h i) ; //sortowa«ie drugiej potowy merge(A, lo , mid, h i); //scale«ie posortowa«ych połówek } } //ko«iec mergeSort Zakładamy przy tym, że dostępna jest funkcja merge, a wywołanie w postaci: merge(A, lo , mid, h i); pozwala scalić posortowane fragmenty listy A [lo..m id] oraz A [m id..h i], tak że cała lista A [lo ..h i] będzie posortowana. Już niebawem zobaczymy, jak można napisać funkcję merge.
163
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Zanim to nastąpi, zobaczymy, jak funkcja mergeSort sortuje poniższą listę, zapisaną w formie tablicy num.
0
1
2
3
4
S
6
Metodę tę będziemy wywoływali w następujący sposób: mergeSort(num, 0, 6 ); Wewnątrz metody wartość num będzie znana jako A, lo przyjmie wartość 0, a hi — wartość 6. Dysponując tymi informacjami, funkcja wyliczy wartość zmiennej min — będzie nią 3. Na tej podstawie zostaną wykonane dwa następujące wywołania: mergeSort(A, 0, 3 ); mergeSort(A, 4, 6 ); Przy założeniu, że pierwsze z nich posortuje fragment tablicy A [0 ..3 ], a drugie fragment A [4 ..6 ], w efekcie uzyskamy tablicę Aw postaci:
0
1
2
3
4
5
6
Wywołanie metody merge scali teraz oba te fragmenty w jedną posortowaną listę.
Każde z powyższych wywołań spowoduje dwa następne wywołania. Dla pierwszego z nich będą to następujące wywołania: mergeSort(A, 0, 1); mergeSort(A, 2, 3 ); a dla drugiego: mergeSort(A, 4, 5 ); mergeSort(A, 6, 6 ); Dalsze wywołania będą wykonywane tak długo, jak wartość lo jest mniejsza od hi. Jeśli parametry lo i hi przyjmą tę samą wartość, będzie to oznaczać, że lista składa się z jednego elementu; a w takim przypadku wywołanie funkcji zostaje zakończone bez żadnych dalszych operacji. Na poniższym listingu przedstawiono wszystkie wywołania wygenerowane przez początkowe wywołanie metody mergeSort(num, 0, 6); zapisane w kolejności, w jakiej były realizowane. mergeSort(A, 0, 6 ); mergeSort(A, 0, 3 ); mergeSort(A, 0, 1); mergeSort(A, 0, 0 ); mergeSort(A, 1, 1); mergeSort(A, 2, 3 ); mergeSort(A, 2, 2 ); mergeSort(A, 3, 3 ); mergeSort(A, 4, 6 ); mergeSort(A, 4, 5 ); mergeSort(A, 4, 4 ); mergeSort(A, 5, 5 ); mergeSort(A, 6, 6 );
164
ROZDZIAŁ 5. ■ REKURENCJA
W celu dokończenia procesu sortowania pozostaje napisanie metody merge. Możemy ją opisać w następujący sposób: public s t a t i c void m erge(int[] A, in t lo , int mid, int hi) { //A[lo..mid] oraz A[mid+1..hi] są posortowane; //funkcja scala oba fragmenty listy, tak by //tablica A[lo..hi] była posortowana W arto zwrócić uwagę na to, co ta metoda ma zrobić: musi scalić dwa sąsiadujące fragmenty tablicy A, tak by po operacji zajmowały ten sam obszar. Problem polega na tym, że nie m ożem y scalać list, zapisując je w tych sam ych m iejscach, na których operu je proces scalan ia, gdyż mogłoby to doprowadzić do nadpisania wartości, zanim zdążylibyśmy ich użyć. Oznacza to, że wyniki scalania będziemy musieli zapisywać do jakiejś innej (tymczasowej) tablicy, a dopiero potem skopiować jej elementy do wyznaczonego obszaru tablicy A. Tej tymczasowej tablicy nadamy nazwę T; musimy się przy tym upewnić, że jest dostatecznie duża, by pomieścić wszystkie scalane elementy. Liczba jej elementów wyniesie: hi-lo+1. Tablicę T zadeklarujemy w następujący sposób: in t[] T = new in t[h i - lo + 1]; A oto kod metody merge. public s t a t i c void m erge(int[] A, in t lo , int mid, int hi) { //A[lo..mid] oraz A[mid+1..hi] są posortowane; //funkcja scala oba fragmenty listy, tak by //tablica A[lo..hi] była posortowana in t[] T = new in t[h i - lo + 1]; int i = lo , j = mid + 1; int k = 0; while (i <= mid || j <= hi) { i f (i > mid) T[k++] = A [j+ + ]; e lse i f ( j > hi) T[k++] = A [i++]; e ls e i f (A[i] < A [j]) T[k++] = A [i++]; e lse T[k++] = A [j++]; } fo r ( j = 0; j < h i-lo + 1 ; j+ + ) A[lo + j ] = T [ j] ; } //koniec merge Użyliśmy zmiennej i jako indeksu operującego na pierwszej połowie tablicy oraz zmiennej j jako indeksu operującego na jej drugiej połowie. Indeksem w operacjach na tablicy T jest zmienna k. Powyższa metoda scala fragmenty A [lo. .mid] oraz A [m id+1..hi], zapisując je w tablicy T[0. . h i - l o ] . Pętla while działa w następujący sposób: dopóki nie zostały obsłużone wszystkie elementy obu scalanych fragmentów, będzie realizowany kod umieszczony wewnątrz pętli. Jeśli zostały przetworzone wszystkie elementy pierwszego scalanego fragmentu tablicy (czyli gdy i > mi d), kopiujem y do tablicy T pozostałe elementy drugiego fragmentu. Jeśli zostały przetworzone wszystkie elementy drugiego scalanego fragmentu tablicy (czyli gdy j > hi), kopiujemy do tablicy T pozostałe elementy pierwszego fragmentu. W pozostałych przypadkach kopiujemy do tablicy T mniejszy z pary elementów A[i] oraz A [j] . Na samym końcu kopiujemy zawartość tablicy T do fragmentu tablicy A, od A[lo] do A [hi]. Działanie metody mergeSort można przetestować przy użyciu programu P5.1. Program P5.1 public c la ss MergeSortTest { public s t a t ic void m ain(String[] args) { in t[] num = { 4 ,8 ,6 ,1 6 ,1 ,9 ,1 4 ,2 ,3 ,5 ,1 8 ,1 3 ,1 7 ,7 ,1 2 ,1 1 ,1 5 ,1 0 } ; int n = 18; mergeSort(num, 0, n -1); fo r (in t h = 0; h < n; h++) System .out.printf("% d " , num[h]); S y stem .o u t.p rin tf("\ n ");
165
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
} //koniec main public s t a t ic void m ergeSort(int[] A, int lo , in t hi) { i f (lo < hi) { //lista zawiera co najmniej 2 elementy int mid = (lo + hi) / 2; //indeks środkowego elementu mergeSort(A, lo , mid); //sortowanie pierwszej potowy mergeSort(A, mid + 1, h i) ; //sortowanie drugiej potowy merge(A, lo , mid, h i); //scalenie posortowanych połówek } } //koniec mergeSort public s t a t i c void m erge(int[] A, in t lo , int mid, int hi) { //A[lo..mid] oraz A[mid+1..hi] są posortowane; //funkcja scala oba fragmenty listy, tak by //tablica A[lo..hi] była posortowana in t[] T = new in t[h i - lo + 1]; int i = lo , j = mid + 1; int k = 0; while (i <= mid || j <= hi) { i f (i > mid) T[k++] = A [j++]; e lse i f ( j > hi) T[k++] = A [i++]; e lse i f (A[i] < A [j]) T[k++] = A [i++]; e lse T[k++] = A [j++]; } fo r ( j = 0; j < h i-lo + 1 ; j+ + ) A[lo + j ] = T [ j] ; } //koniec merge } //koniec klasy MergeSortTest W ykonanie tego programu spowoduje wyświetlenie następujących wyników: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 W arto także zauważyć, że sortowanie przez scalanie jest znacznie szybszą metodą sortowania niż przedstawione wcześniej sortowanie przez wybieranie bądź sortowanie przez wstawianie.
5.8. Zliczanie organizmów Załóżmy, że dysponujemy następującą strukturą danych: 0 0 1 1 1
1 0 1 0 1
01 11 01 10 00
1 0 0 0 0
10 0 0 0 1 11 10
Przyjmujemy, że 1 oznacza komórkę organizmu, a 0 brak komórki. Dwie kom órki są ciągłe, jeśli sąsiadują ze sobą w rzędzie lub kolumnie. Oto definicja organizmu. • Organizm składa się z przynajmniej jednej komórki. • Dwie ciągłe kom órki zawierające 1 należą do tego samego organizmu. W przedstawionej strukturze danych znajduje się pięć organizmów. Proszę je policzyć! Dysponując komórkami zapisanymi w formie siatki, chcemy napisać program, który policzy ilość występujących w niej organizmów.
166
ROZDZIAŁ 5. ■ REKURENCJA
Rzut okna na siatkę pozwala stwierdzić, że zaczynając od dowolnej kom órki (1), organizm może się rozrastać w każdym z czterech kierunków. Wychodząc z każdej z tych nowych komórek, organizm ponownie może się rozrastać w każdym z czterech kierunków, co w sumie daje szesnaście możliwości. Każda z tak wyznaczonych nowych komórek może stanowić punkt wyjścia do czterech kolejnych itd. W jaki sposób można prześledzić wszystkie te możliwości, wiedząc, które zostały już zbadane, a które jeszcze nie? Najprostszym rozwiązaniem jest utworzenie mechanizmu rekurencyjnego, który będzie to śledził za nas. Aby policzyć ilość organizmów, musimy dysponować sposobem pozwalającym określać, które komórki należą do organizmu. Najpierw musimy znaleźć komórkę zawierającą 1. Następnie musimy znaleźć wszystkie kom órki zawierające 1, które są ciągłe z komórką znalezioną wcześniej itd. Aby znaleźć ciągłe komórki zawierające 1, musimy sprawdzać w czterech kierunkach — na północ, wschód, południe oraz zachód (w dowolnej kolejności). Podczas tych poszukiwań może wystąpić jedna z czterech sytuacji: 1. znajdujemy się poza siatką i nie ma co sprawdzać; 2. w komórce odnajdziemy wartość 0, więc nie trzeba nic więcej sprawdzać; 3. w kom órce odnajdziemy wartość 1, która została sprawdzona już wcześniej, więc nie musimy nic więcej robić; 4. w komórce odnajdziemy wartość 1, która nie została jeszcze znaleziona; przesuwamy się zatem do tej kom órki i wychodząc z niej, kontynuujemy poszukiwania we wszystkich czterech kierunkach. Krok 3. oznacza, że kiedy odnajdziemy komórkę z wartością 1 po raz pierwszy, musimy ją w jakiś sposób oznaczyć, dzięki czemu, kiedy później ponownie do niej trafimy, będziemy wiedzieć, że odwiedziliśmy ją już wcześniej i nie przetworzymy jej ponownie. Najprostszą rzeczą, którą możemy zrobić, jest zapisanie w takiej komórce wartości 0; w ten sposób uzyskamy gwarancję, że po ponownym odwiedzeniu tej kom órki nic się nie stanie. Takie rozwiązanie sprawdzi się, jeśli zależy nam jedynie na policzen iu organizmów. Jeśli jednak zależy nam na określeniu, które kom órki tworzą dany organizm, będziemy musieli oznaczyć je w jakiś inny sposób. Przypuszczalnie będziemy potrzebowali jakiejś zmiennej, określającej liczbę odszukanych organizmów. Nazwijmy ją orgCount. Kiedy odnajdziemy pierwszą komórkę zawierającą 1, zmienimy jej wartość na orgCount + 1. A zatem komórki pierwszego organizmu będą oznaczone liczbą 2, komórki drugiego organizmu liczbą 3 itd. Takie rozwiązanie jest konieczne, ponieważ gdybyśmy zaczęli oznaczać organizmy od liczby 1, nie bylibyśmy w stanie odróżnić od siebie kom órek pierwszego organizmu od komórek, które należą do jakiegoś organizmu, lecz nie zostały jeszcze sprawdzone. To „dodawanie 1 do etykiety określającej organizm” jest niezbędne wyłącznie podczas przetw arzania siatki. Podczas wyświetlania wyników od wartości komórek będziemy odejmowali 1, dzięki czemu pierwszy organizm zostanie oznaczony liczbą 1, drugi liczbą 2 itd. Pisząc program, założymy, że siatka danych jest zapisana jako tablica Gi składa się z mwierszy oraz n kolumn. Maksymalną liczbę wierszy i kolumn określimy odpowiednio jako MaxRow oraz MaxCol. Dane wejściowe dla programu składają się z wartości mi n, a następnie zawartości poszczególnych komórek zapisanych wierszami. Dane przykładu przedstawionego na początku tego podrozdziału zostaną podane w następującej postaci: 5 7 0 1 0 0 0 1 1 1 0 1 0 1 1 1 0
1 1 1 0 0
1 0 0 0 0
1 0 0 1 1
0 0 1 1 0
Zakładamy, że dane te zostaną wczytane z pliku o nazwie orgs.in, natomiast wyniki zostaną zapisane w pliku orgs.out. Ogólną logikę działania programu można opisać w następujący sposób. przeglądamy siatk ę od lewej do prawej i z góry w dół kiedy znajdziemy 1, wiemy, że znaleźliśmy nowy organizm dodajemy 1 do orgCount wywołujemy funkcję findOrg, by oznaczyć wszystkie komórki tego organizmu
167
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Funkcja findOrg będzie stanowić implementację opisanego wcześniej poszukiwania kom órek organizmu w czterech możliwych kierunkach. Kiedy funkcja znajdzie wartość 1 w elemencie siatki o współrzędnych ( i , j) , dla każdej z komórek położonych na północ, wschód, południe oraz zachód od niej wywoła rekurencyjnie samą siebie. Wszystkie szczegóły tej funkcji przedstawiono w programie P5.2. Program P5.2 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss Organisms { s t a tic in t orgCount = 0; public s t a t ic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("o rg s.in ")); PrintW riter out = new PrintWriter(new F ile W rite r("o rg s.o u t")); int m = in .n e x tIn t(), n = in .n e x tIn t(); in t [ ] [ ] G = new in t[m ][n ]; fo r (in t i = 0; i < m; i++) fo r (in t j = 0; j < n; j++) G [ i] [ j] = in .n e x tIn t(); fo r (in t i = 0; i < m; i++) fo r (in t j = 0; j < n; j++) i f (G[i ] [ j ] == 1) { orgCount++; findOrg(G, i , j , m, n); } printOrg(out, G, m, n); in .c lo s e ( ) ; o u t.c lo s e (); } //koniec main public s t a t ic void fin d O rg (in t[][] G, int i , int j , in t m, int n) { i f (i < 0 || i >= m || j < 0 || j >= n) return; //poza siatką i f (G[i ] [ j ] == 0 || G [ i] [ j] > 1) return; //brak komórki lub komórka // już została uwzględniona; w przeciwnym razie G[i][j] = 1; G[i] [ j] = orgCount + 1; //aby komórka nie została ponownie uwzględniona findOrg(G, i - 1, j , m, n); findOrg(G, i , j + 1, m, n); findOrg(G, i + 1, j , m, n); findOrg(G, i , j - 1, m, n); } //koniec findOrg public s t a tic void printO rg(PrintW riter out, in t [ ] [ ] G, int m, int n) { out.printf("\ nL iczba organizmów = %d\n", orgCount); out.printf("\nOrganizmy są rozmieszczone w następujący sposób:\n\n"); fo r (in t i = 0; i < m; i++) { fo r (in t j = 0; j < n; j++) i f ( G [ i][j] > 1) out.printf("% 2d ", G [ i] [ j] - 1); //etykiety organizmów są o 1 większe od prawidłowej wartości e lse out.printf("% 2d " , G [ i ] [ j] ) ; o u t.p rin tf("\ n "); } } //koniec printOrg } //koniec klasy Organisms
168
ROZDZIAŁ 5. ■ REKURENCJA
Plik orgs.in ma następującą zawartość. 5 7 0 10 0 01 1 10 1 01 1 10
1 1 1 0 0
1 0 0 0 0
1 0 0 1 1
0 0 1 1 0
Po wykonaniu program zapisze w pliku orgs.out dane w poniższej postaci. Liczba organizmów = 5 Organizmy są rozmieszczone w następujący sposób: 0 0 3 3 3
1 0 3 0 3
0 2 0 5 0
2 2 2 0 0
2 0 0 0 0
2 0 0 4 4
0 0 4 4 0
Przeanalizujmy, w jaki sposób funkcja findOrg znajduje pierwszy organizm. W metodzie main, kiedy i = 0 oraz j = 1, G[0] [1] ma wartość 1, a zatem zostanie wykonane wywołanie findOrg(G, 0, 1, . . . ) , a tablica G będzie mieć następującą zawartość: 0 0 1 1 1
1 0 1 0 1
0 1 0 1 0
1 1 1 0 0
1 0 0 0 0
1 0 0 0 0 1 1 1 1 0
Ponieważ G[0][1] ma wartość 1, zatem wewnątrz metody fi ndOrg zapisujemy w tej kom órce wartość 2, a następnie wykonujemy cztery następujące, rekurencyjne wywołania funkcji findOrg: findOrg(G, findOrg(G, findOrg(G, findOrg(G,
-1 , 1, . . . ) ; 0, 2, . . . ) ; 1, 1, . . . ) ; 0, -1 , . . . ) ;
Ukończy się natychmiast, gdyż i < 0 //kończy się natychmiast, gdyż G[0][2] = 0 //kończy się natychmiast, gdyż G[1][1] = 0 //kończy się natychmiast, gdyżj < 0
Wszystkie te wywołania zostają natychmiast zakończone, zatem wyłącznie w komórce G[0][1] zostanie zapisana wartość 2. A teraz przeanalizujmy, w jaki sposób funkcja findOrg odszuka organizm num er 3. W metodzie main, gdy i = 2, a j = 0, okazuje się, że kom órka G [2][0] zawiera 1. Zostaje zatem wykonane wywołanie findOrg(G, 2, 0, . . . ) , a tablica G będzie mieć w tym m om encie następującą zawartość: 0 0 1 1 1
2 0 1 0 1
0 3 0 1 0
3 3 3 0 0
3 0 0 0 0
3 0 0 0 0 1 1 1 1 0
(Trzeba pamiętać, że na tym etapie działania programu etykiety organizmów zapisane w tablicy są o jeden większe od numeru danego organizmu). W tym przykładzie wykorzystamy zapis N (północ), E (wschód), S (południe) oraz W(zachód) zamiast indeksów określających, w jakim kierunku będziemy kontynuowali poszukiwania. W tym momencie działania programu zmienna orgCount ma wartość 3, a zatem w komórkach tablicy będzie zapisywana wartość 4.
169
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Poniżej przedstawiono rekurencyjne wywołania f i ndOrg, wykonane podczas realizacji początkowego wywołania findOrg(2, 0, . . . ) (dla poprawienia przejrzystości kodu pominięto w nim początkowy argument G): findOrg(2, 0, . . . ) //w G[2][0] zostaje zapisane 4 findOrg(N.. . ) //kończy się natychmiast, gdyż G[N] = 0 findOrg ( E .. .) //G[E] = 1, zmieniamy na 4, generuje 4 wywołania findO rg(N ...) //kończy się natychmiast, gdyż G[N] = 0 findOrg ( E .. .) //kończy się natychmiast, gdyż G[E] = 0 fin d O rg (S ...) //kończy się natychmiast, gdyż G[S] = 0 findOrg(W ...) //kończy się natychmiast, gdyż G[W] = 4 fin d O rg (S ...) //G[S] wynosi 1, zmieniamy na 4, generuje 4 wywołania findO rg(N ...) //kończy się natychmiast, gdyż G[N] = 4 findOrg ( E .. .) //kończy się natychmiast, gdyż G[E] = 0 fin d O rg (S ...) //G[S] = 1, zmieniamy na 4, generuje 4 wywołania findOrg(N.. . ) //kończy się natychmiast, gdyż G[N] = 4 fin d O rg (E ...) //G[E] = 1, zmieniamy na 4, generuje cztery wywołania findOrg (N ...) //kończy się natychmiast, gdyż G[N] = 0 findOrg ( E .. .) //kończy się natychmiast, gdyż G[E] = 0 find O rg(S.. . ) //kończy się natychmiast, gdyż G[S] jest poza siatką findOrg(W ...) //kończy się natychmiast, gdyż G[W] = 4 fin d O rg (S ...) //kończy się natychmiast, gdyż G[S] jest poza siatką findOrg(W ...) //kończy się natychmiast, gdyż G[W] jest poza siatką findOrg(W. . . ) //kończy się natychmiast, gdyż G[W] jest poza siatką findOrg(W ...) //kończy się natychmiast, gdyż G[W] jest poza siatką Kiedy wywołanie findOrg(2, 0, . . . ) zostanie zakończone, zawartość tablicy Gzostanie zmieniona i będzie mieć następującą postać: 0 0 4 4 4
20 03 40 01 40
3 3 3 0 0
3 0 0 0 0
3 0 0 1 1
0 0 1 1 0
Jak widać, trzeci organizm (oznaczony w siatce wartością 4) został zidentyfikowany. W arto zauważyć, że dla każdej kom órki tego organizmu zostały wygenerowane cztery wywołania metody findOrg.
5.9. Odnajdywanie drogi przez labirynt Przyjrzyjmy się następującemu diagramowi przedstawiającemu labirynt. ########## # # # # # # # ## # # # # # ###### # # # #S## # ## # ########## Problem: zaczynając od miejsca oznaczonego literą S i posuwając się po pustych miejscach, należy odnaleźć drogę wyjścia z labiryntu. Poniżej pokazano, jak należy to zrobić, przy czym droga przez labirynt została oznaczona znakami „x”.
170
ROZDZIAŁ 5. ■ REKURENCJA
########## # #xxx# # # #x#x## # #xxx#xxxx# #x######x# #x# #x##xx #xxxxx## # ########## Chcemy napisać program, który, dysponując reprezentacją labiryntu, określi, czy istnieje droga pozwalająca z niego wyjść. Jeśli taka droga istnieje, należy ją oznaczyć znakami „x”. Dla każdego miejsca w labiryncie istnieją dokładnie cztery kierunki, w których możemy się poruszać: północ (N), wschód (E), południe (S) oraz zachód (W ). Ruch nie będzie możliwy, jeśli w danym kierunku znajduje się ściana. Jeśli jednak sąsiednie miejsce w danym kierunku jest puste, można na nie przejść. Pisząc program, będziemy sprawdzali poszczególne kierunki w następującej kolejności: północ, wschód, południe i zachód. Do poszukiwania wyjścia z labiryntu posłużymy się poniższą strategią. próbujemy iś ć na północ i f na północy je s t ściana, to próbujemy iś ć na wschód e lse i f je s t puste m iejsce, przechodzimy na nie i oznaczamy je znakiem "x ". Powyższą strategię powtarzamy zawsze, gdy dojdziemy do pustego miejsca labiryntu. A zatem, jeśli próbujemy iść na wschód i znajdujemy tam puste miejsce, zaznaczamy je i w ychodząc z tego nowego położen ia, próbujemy iść we wszystkich czterech kierunkach. W końcu dotrzemy do wyjścia z labiryntu lub do ślepego zaułka. Załóżmy np., że dotarliśmy do miejsca oznaczonego na poniższym diagramie literką „C”. ########## #C# # # #B# # ## # #A # # #x###### # #x# #x## #xxxxx## # ########## Dotarliśmy w to miejsce z południa, a ze wszystkich pozostałych kierunków otaczają je ściany. W takim przypadku możemy wrócić do poprzedniej lokalizacji i z niej spróbować przejść w innym kierunku. W tym przykładzie wrócimy do miejsca położonego na południe od C (oznaczonego jako B). Wychodząc z miejsca B, możemy dostać się do C, kiedy idziemy na północ. Jednak tę możliwość już sprawdziliśmy i nie udało się z niej przejść nigdzie dalej, dlatego też sprawdzamy „następną” możliwość, czyli próbujemy pójść na wschód. To się nie udaje, bo na wschodzie jest ściana. Próbujemy zatem dalej — kolejną możliwością jest pójście na południe. To też się nie udaje, gdyż na południu już byliśmy. Ostatnia możliwość, pójście na zachód, też kończy się niepowodzeniem, gdyż na zachodzie także jest ściana. A zatem, będąc w B, musimy wrócić (można to także określić jako cofn ięcie się p o własnych śladach) do miejsca, w którym znajdowaliśmy się wcześniej (czyli do A). Po cofnięciu się do miejsca A „następną” możliwością ruchu jest przejście na wschód. Ponieważ znajdujemy tam puste miejsce, zatem przechodzimy na nie i oznaczamy je znakiem x; następnie próbujemy przejść gdzieś dalej, zaczynając od sprawdzenia pierwszego kierunku (północy). Kiedy cofamy się, wychodząc z miejsca, z którego nie udało się nam przejść nigdzie dalej, musimy usunąć jego zaznaczenie, czyli usunąć z niego znak x. To konieczne, gdyż to miejsce nie będzie znajdowało się na drodze do wyjścia. A w jaki sposób możemy się cofać po własnych śladach? Dzięki wykorzystaniu rozwiązania rekurencyjnego nie musimy w tym celu robić niczego szczególnego — mechanizm rekurencji sam o wszystko zadba, tak samo jak w poprzednim przykładzie z liczeniem organizmów. W poniższym pseudokodzie pokazano, jak będzie wyglądać funkcja do poszukiwania drogi wyjścia z labiryntu.
171
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
boolean findPath(P) { //funkcja znajduje ścieżkę, zaczynając z miejsca P i f P je s t poza labiryntem, je s t ścianą lub zostało już sprawdzone, to zwracamy fa ls e //jeśli tu dotarliśmy, oznacza to, że P jest pustym miejscem i możemy na nie przejść oznaczamy P znakiem x je ś l i P znajduje s ię na krawędzi labiryntu , to znaleźliśmy w yjście; zwracamy true //próbujemy wydłużyć ścieżkę na północ, jeśli się nam udało, to zwracamy true i f (findPath(N)) zwracamy tru e; //jeśli nie uda się pójść na północ, to próbujemy: wschód, południe i zachód i f (findPath(E)) zwracamy tru e; i f (findPath(S)) zwracamy tru e; i f (findPath(W)) zwracamy tru e; //jeśli nie uda się przejść w żadnym kierunku, usuwamy znacznik z miejsca P i wracamy oznaczamy P znakiem dostępu (spacją) zwracamy fa ls e ; //nie udało się znaleźć drogi wyjścia, zaczynając od P } //koniec findPath
5.9.1. Implementacja programu Najpierw musimy określić, w jaki sposób będzie reprezentowany labirynt. W naszym przykładzie będzie się on składał z ośmiu wierszy i dziesięciu kolumn. Jeśli każdą ścianę oznaczymy przy użyciu cyfry 1, a każde puste miejsce labiryntu przy użyciu cyfry 0, to nasz przykładowy labirynt można przedstawić w następujący sposób. 1 1 1 1 1 1 1 1
11 01 01 00 01 01 00 11
1 0 0 0 1 0 0 1
1 0 1 1 1 1 0 1
1 0 0 0 1 0 0 1
1 1 1 1 0 0 1 1 0 0 0 0 1 1 0 1 1 0 1 1 0 1 1 1
1 1 1 1 1 0 1 1
M iejsce, z którego wychodzimy, czyli S, znajduje się w szóstym wierszu i szóstej kolumnie. Pierwszy wiersz pliku z danymi będzie zawierał liczbę wierszy i kolumn labiryntu oraz położenie punktu, w którym zaczynamy poszukiwania. A zatem w naszym przypadku będzie miał postać: 8 10 6 6 W kolejnych wierszach pliku znajdą się informacje opisujące labirynt. Kiedy będziemy chcieli oznaczyć jakieś miejsce labiryntu znakiem x, zapiszemy w nim 2. Program będzie odczytywał dane wejściowe zapisane w pliku m aze.in oraz zapisywał wyniki w pliku m aze.out. Oto pełny kod programu P5.3. Program P5.3 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss Maze { s t a tic i n t[][]G ; //dostępne dla wszystkich metod s t a tic in t m, n, s r, sc; //dostępne dla wszystkich metod public s t a t ic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new FileR eader("m aze.in ")); PrintW riter out = new PrintWriter(new FileW riter("m aze.o u t")); getD ata(in); i f (fin d P ath (sr, sc)) printM aze(out);
172
ROZDZIAŁ 5. ■ REKURENCJA
e lse out.printf("\ nN ie znaleziono w y jścia!\ n"); in .c lo s e ( ) ; o u t.c lo s e (); } //ko«iec mai« public s t a t i c void getData(Scanner in) { m = in .n e x tIn t(); n = in .n e x tIn t(); G = new int[m +1][n+1]; sr = in .n e x tIn t(); sc = in .n e x tIn t(); fo r (in t r = 1; r <= m; r++) fo r (in t c = 1; c <= n; c++) G [r][c] = in .n e x tIn t(); } //koniec getData public s t a t i c boolean find Path(int r , int c) { i f (r < 1 || r > m || c < 1 || c > n) return fa ls e ; i f (G [r][c] == 1) return fa ls e ; //ścia«a i f (G [r][c] == 2) return f a ls e ; //miejsce już zostało sprawdzo«e // else G[r][c] = 0; G [r][c] = 2; //oz«aczamy ścieżkę i f (r == 1 || r == m || c == 1 || c == n) return tru e; //z«aleźliśmy ścieżkę — puste miejsce «a krawędzi labiry«tu i f (fin d P ath (r-1, c)) return tru e; i f (find P ath(r, c+1)) return tru e; i f (findPath(r+1, c)) return tru e; i f (find P ath(r, c -1 )) return tru e; G [r][c] = 0; //brak ścieżki, usuwamy oz«acze«ie return fa ls e ; } //ko«iec fi«dPath public s t a t i c void printM aze(PrintW riter out) { int r , c; fo r (r = 1; r <= m; r++) { fo r (c = 1; c <= n; c++) i f (r == sr && c == sc) o u t.p r in tf(" S " ); e lse i f (G [r][c] == 0) o u t.p rin tf(" " ) ; e lse i f (G [r][c] == 1) o u t.p r in t f(" # " ); e lse o u t.p r in tf(" x " ); o u t.p rin tf("\ n "); } } //ko«iec pri«tMaze } //koniec klasy Maze Załóżmy, że plik m aze.in ma następującą zawartość: 8 10 6 6 1 1 0 1 0 1 0 0 0 1 0 1 0 0 1 1
1 0 0 0 1 0 0 1
1 0 1 1 1 1 0 1
1 0
0 1
1 0 1 0 1 1 1 1
1 0 0 0 0 0 0 1
1 1 1 1 1 0
173
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W takim przypadku program P5.3 zapisze w pliku m aze.ou t poniższe wyniki. ########## # #xxx# # # #x#x## # #xxx#xxxx# #x######x# #x# #S##xx #xxxxx## # ##########
Ćwiczenia 1. Napisz iteracyjną funkcję wyliczającą wartość n-tego elementu ciągu Fibonacciego. 2. Wyświetl liczbę całkowitą, w której cyfry oznaczające tysiące zostaną oddzielone do reszty znakiem przecinka; np. liczbę 12058 należy wyświetlić jako 12,058. 3. Ajest tablicą zawierającą n liczb całkowitych. Napisz funkcję rekurencyjną, która będzie określać, ile razy podana liczba x występuje w tablicy A. 4.
Napisz funkcjęrekurencyjną stanowiącą implementację algorytmu sortowania p rzez wy b ieranie.
5.
Napisz funkcję rekurencyjną, która będzie zwracać największy element w tablicy liczb całkowitych.
6.
Napisz funkcję rekurencyjną, która będzie szukać podanej wartości w tablicy typu int.
7.
Napisz funkcję rekurencyjną, która będzie szukać podanej wartości w po s ortowane/tablicy typu int.
8. Jakie wyniki zostaną wyświetlone po wykonaniu wywołania W(0) poniższej funkcji: public s t a t ic void W(int n) { System .out.printf("% 3d", n); i f (n < 10) W(n + 3 ); System .out.printf("% 3d", n); } 9. Jakie wyniki zostaną wyświetlone po wykonaniu wywołania S ('C ') poniższej funkcji: public s t a t ic void S(char ch) { i f (ch < 'H') { S (++ch ); System .out.printf("% c ", ch); } } 10. Jakie wyniki zostałyby wyświetlone w punkcie 9., gdyby zamieniono kolejność instrukcji umieszczonych w instrukcji warunkowej if? 11. Jakie wyniki zostałyby wyświetlone w punkcie 9., gdyby wyrażenie ++ch zostało zmienione na ch++? 12. Napisz funkcję rekurencyjną o nazwie lenght, która na podstawie wskaźnika do listy powiązanej liczb całkowitych zwraca liczbę jej elementów. 13. Napisz funkcję rekurencyjną o nazwie sum, która na podstawie wskaźnika do listy powiązanej liczb całkowitych zwraca sumę wartości wszystkich jej węzłów. 14. Napisz funkcję rekurencyjną, która na podstawie wskaźnika do listy powiązanej liczb całkowitych zwróci wartość true, jeśli lista będzie posortowana rosnąco, lub fa ls e w przeciwnym przypadku.
174
ROZDZIAŁ 5. ■ REKURENCJA
15. Napisz metodę rekurencyjną, do której będzie przekazywana liczba całkowita i która będzie ją wyświetlała, oddzielając poszczególne cyfry znakiem spacji. Przykładowo po przekazaniu liczby 7583 metoda wyświetli: 7 5 8 3. 16. Co zostanie wyświetlone w efekcie wywołania fun(18, 3) następującej funkcji rekurencyjnej: public s t a t ic void fu n (in t m, int n) { i f (n > 0) { fun(m-1, n -1 ); System .out.printf("% d ", m); fun(m+1, n -1 ); } } 17. Co zostanie wyświetlone w efekcie wywołania t e s t( 7 , 2) następującej funkcji rekurencyjnej: public s t a t ic in t t e s t ( i n t n, int r) { i f (r == 0) return 1; i f (r == 1) return n; i f (r == n) return 1; return te s t(n -1 , r-1 ) + te s t(n -1 , r ) ; } 18. Rozpatrzmy punkt (m, n) w kartezjańskim układzie współrzędnych, taki że m i n są dodatnimi liczbami całkowitymi. Na północny wschód od punktu A znajduje się punkt B. Można się poruszać wyłącznie w górę lub w prawo (ruchy w dółoraz w lewo są zabronione). Napisz funkcję, która na podstawie przekazanych współrzędnych dwóch punktów, A i B, zwraca liczbę dostępnych ścieżek prowadzących z A do B. 19. Problem ośmiu królowych można rozwiązać w następujący sposób: umieszczamy na szachownicy osiem królowych w taki sposób, że żadne dwie królowe nie atakują siebie nawzajem. Dwie królowe mogą się zaatakować, kiedy znajdą się w tym samym wierszu, kolumnie lub na tej samej przekątnej. Jasne jest, że rozwiązanie tego problemu wymaga, by każda królowa znalazła się w innym wierszu i kolumnie. Tak postawiony problem można rozwiązać w następujący sposób. Umieszczamy pierwszą królową w pierwszym rzędzie i pierwszej kolumnie. Następnie umieszczany drugą królową tak, by nie atakowała pierwszej. Jeśli to nie jest możliwe, cofamy się, umieszczamy pierwszą królową w następnej kolumnie i próbujemy ponownie. Po umieszczeniu na szachownicy dwóch pierwszych królowych ustawiamy na niej trzecią, tak by nie atakowała dwóch pierwszych. Jeśli to nie jest możliwe, cofamy się, umieszczamy drugą królową w następnej kolumnie i próbujemy ponownie itd. W każdym kroku próbujemy umieścić następną królową w taki sposób, by nie atakowała tych, które już są umieszczone na szachownicy. Jeśli to się uda, próbujemy umieścić następną królową. Jeśli się nam nie uda, musimy się cofnąćdo poprzedniej królowej i przesunąć ją do następnej kolumny. Jeśli spróbowaliśmy już umieścić ją w każdej z kolumn, musimy się cofnąć do jeszcze wcześniejszej królowej i ją przesunąć do następnej kolumny. Idea działania tego algorytmu jest podobna do znajdowania drogi wyjścia z labiryntu. Napisz program, który rozwiąże problem ośmiu królowych. Do zaimplementowania mechanizmu cofania się zastosuj rekurencję. 20. Napisz program, który wczyta n ( < = 10) i wyświetli każdą możliwą kombinację n elementów. Przykładowo dla n = 3 program ma wyświetlić następujące wyniki: 1 1 1 1 2 2 3
2 2 3 3 3
175
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
176
ROZDZIAŁ
6
Liczby losowe, gry i symulacje W tym rozdziale wyjaśnimy takie zagadnienia jak: • liczby losowe, • różnice pomiędzy liczbami losowymi i pseudolosowymi, • sposoby generacji liczb losowych na komputerze, • implementacja programu grającego w zgadywankę, • implementacja programu do ćwiczenia arytmetyki, • implementacja programu do zagrania w grę N im , •
symulacja kolekcjonowania kapsli z butelek w celu ułożenia słowa,
• symulacja zgadywania w realnych sytuacjach, • metody szacowania wartości liczbowych przy użyciu liczb losowych.
6.1. Liczby losowe Gdybyśmy mieli 100 razy rzucić zwyczajną kostką do gry o 6 ściankach, zapisując za każdym razem uzyskaną liczbę, w efekcie zapisalibyśmy 100 losowych liczb całkow itych o rozkładzie rów nom iernym , z zakresu od 1 do 6. Gdybyśmy 144 razy rzucili monetą i wynik każdego rzutu zapisali jako 0 (reszka) lub 1 (orzeł), uzyskalibyśmy 144 liczby losowe o rozkładzie równomiernym z zakresu od 0 do 1. Gdybyśmy stanęli przy drodze i zapisywali dwie ostatnie cyfry numeru rejestracyjnego każdego mijającego nas pojazdu (przy założeniu, że dwa ostatnie znaki numeru są cyframi), wynikiem byłyby liczby losowe o rozkładzie równomiernym z zakresu od 0 do 99. A teraz zakręćmy 500 razy kołem ruletki (na którym jest umieszczonych 36 cyfr). Tych 500 liczb będzie losowymi liczbami całkowitymi o rozkładzie równomiernym z zakresu od 1 do 36. Term in losow y oznacza, że każde wystąpienie liczby jest całkowicie niezależne od pozostałych. Jeśli np. podczas jednego rzutu kostką wypadła 5, nie ma to żadnego wpływu na wynik kolejnego rzutu. Podobnie, jeśli podczas jednego obrotu kołem ruletki wypadnie 29, nie będzie to miało żadnego wpływu na to, które liczby pojawią się w przyszłości. Termin o rozkładzie rów nom iernym oznacza, że prawdopodobieństwo wystąpienia każdej z wartości jest identyczne. W przypadku rzutu kostką prawdopodobieństwo wyrzucenia liczby 1, 6 lub dowolnej innej jest takie samo. Kiedy wykonamy dużą liczbę rzutów, każda z liczb będzie się pojawiać mniej więcej równie często.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jeśli rzucimy monetą 144 razy, oczekujemy, że orzeł wypadnie 72 razy i tyle samo razy wypadnie reszka. W praktyce niemal nigdy nie udaje się uzyskać takich wartości, jeśli jednak moneta będzie „uczciwa”, wartości otrzymane będą na tyle zbliżone do oczekiwanych, by przejść pewne statystyczne testy. Przykładowo wynik, w którym zyskamy 75 orłów i 69 reszek, będzie na tyle bliski oczekiwanej wartości 72, że przejdzie niezbędne testy. Liczby losowe są powszechnie stosowane w symulowaniu gier losowych (takich jak wszelkiego typu gry wymagające rzucania kostką, monetą lub wyciągania kart), w grach edukacyjnych (takich jak formułowanie zadań arytmetycznych) oraz podczas symulowania na komputerach sytuacji z realnego świata. Gdybyśmy np. chcieli zagrać w grę o nazwie W ęże i drabiny (ang. Snakes an d L adders), rzut kostką można by zasymulować przez wylosowanie liczby z zakresu od 1 do 6. Załóżmy, że chcemy przygotować dla dziecka zadania z arytmetyki, używając przy tym wyłącznie liczb z zakresu od 1 do 9. Dla każdego z zadań komputer może wygenerować dwie liczby (np. 7 i 4) z podanego zakresu i poprosić dziecko o ich dodanie. Załóżmy jednak, że chcemy zasymulować ruch uliczny na skrzyżowaniu obsługiwanym przez sygnalizację świetlną. Chcemy, by czas zmiany świateł został tak dobrany, żeby okres oczekiwania na przejazd w obu kierunkach był możliwie jak najkrótszy. Aby przeprowadzić taką symulację na komputerze, będziemy potrzebowali danych o tym, jak szybko pojazdy odjeżdżają i opuszczają skrzyżowanie. Aby zapewnić, że symulacja będzie użyteczna, takie dane trzeba by uzyskać na podstawie obserwacji. Załóżmy, że udało się ustalić, iż w czasie każdych 30 sekund do skrzyżowania dojeżdża losowa liczba (od 5 do 15) pojazdów poruszających się w kierunku 1. W tym samym czasie 30 sekund do skrzyżowania dojeżdża od 8 do 20 pojazdów jadących w kierunku 2. Komputer może zasymulować tę sytuację w taki sposób: 1. wygenerować liczbę losową r1 z zakresu od 5 do 15; 2. wygenerować liczbę losową r2 z zakresu od 8 do 20. Liczby r1 i r2 są traktowane jako liczba pojazdów dojeżdżających do skrzyżowania w odpowiednich kierunkach, w czasie pierwszych 30 sekund. Ten proces jest powtarzany dla kolejnych 30-sekundowych okresów czasu.
6.2. Liczby losowe i pseudolosowe Liczba, która zostanie uzyskana podczas rzutu kostką, nie ma żadnego wpływu na rezultat następnego rzutu. Mówimy, że wyniki poszczególnych rzutów są niezależne, a uzyskiwane liczby są liczbami losowymi z zakresu od 1 do 6. Kiedy jednak do wygenerowania sekwencji liczb losowych z zadanego przedziału zostanie użyty komputer, zrobi to, posługując się pewnym algorytmem. Zazwyczaj kolejna liczba takiej sekwencji zostanie wygenerowana na podstawie wcześniejszej, w ściśle określony i ustalony sposób. Oznacza to, że poszczególne liczby w sekwencji nie są niezależne od siebie, jak jest np. w przypadku rzutów kostką. Niemniej jednak takie generowane wartości będą sprawdzane przy użyciu standardowego zestawu testów statystycznych, określających ich losowość, a zatem w zasadzie można je uznać za liczby losowe. Jednakże ze względu na fakt, że są one generowane w bardzo przewidywalny sposób, zazwyczaj nazywamy je liczbami pseudolosowymi. W wielu zastosowaniach, takich jak modelowanie różnych typów sytuacji, nie ma większego znaczenia, czy zostaną użyte liczby losowe, czy pseudolosowe. W rzeczywistości, w przeważającej większości zastosowań liczby pseudolosowe zapewniają satysfakcjonujące wyniki. Rozważmy jednak przykład firmy, która prowadzi cotygodniową loterię, w której można wygrać, podając 6-cyfrową liczbę. Czy w tym przypadku do określania zwycięskiego ciągu liczb w kolejnych cotygodniowych losowaniach powinno się używać generatora liczb pseudolosowych? Ponieważ taki generator podaje liczby w całkowicie przewidywalny sposób, zatem istniałaby możliwość przewidzenia zwycięskiej sekwencji w kolejnym tygodniu. Nie ma wątpliwości, że to nie byłoby pożądane (chyba że to m y zarządzamy takim generatorem!). W takim przypadku konieczne byłoby zastosowanie prawdziwie losowej metody generowania zwycięskiej sekwencji.
178
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
6.3. Komputerowe generowanie liczb losowych W dalszych rozważaniach nie będziemy rozróżniać liczb losowych oraz pseudolosowych, gdyż w przeważającej większości ich zastosowań takie rozróżnienie nie jest potrzebne. Niemal wszystkie języki programowania dysponują jakimiś generatorami liczb losowych, choć istnieją pewne nieznaczne różnice w sposobach ich działania. W języku Java liczby losowe można generować przy użyciu predefiniowanej, statycznej funkcji random dostępnej w klasie Math. Funkcja ta zwraca losowe ułamki (> 0.0 i < 0). Korzystamy z niej przy użyciu wywołania Math.random(). W praktyce funkcja ta niem al nigdy nie jest używana w przedstawionej, predefiniowanej postaci. Dzieje się tak dlatego, że zazwyczaj potrzebujemy liczby losowej należącej do pewnego określonego zakresu (np. od 1 do 6), a nie losowego ułamka. Bardzo łatwo można jednak napisać funkcję, w której użyjemy funkcji random do wygenerowania losowej liczby całkowitej z zakresu od m do n, gdzie m< n. Oto ona. public s t a t i c int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } Przykładowo wywołanie random (1, 6) zwróci losową liczbę całkowitą z zakresu od 1 do 6 włącznie. Jeśli m wynosi 1 i n wynosi 6, n-m+1 daje 6. Kiedy pomnożymy 6 przez ułamek z zakresu od 0,0 do 0,9 9 9 ..., uzyskamy liczbę z zakresu od 0,0 do 5 , 9 9 9 . Kiedy następnie rzutujemy tę wartość na liczbę całkowitą ( i n t ) , uzyskamy liczbę losową z zakresu od 0 do 5. Dodając do niej 1, uzyskamy liczbę z zakresu od 1 do 6. W kolejnym przykładzie załóżmy, że mwynosi 5, a n wynosi 20. W takim przypadku w zakresie od 5 do 20 istnieje 2 0 -5 + 1 = 16 liczb. Kiedy pomnożymy 16 przez ułamek z zakresu od 0,0 do 0 , 9 9 9 . , uzyskamy liczbę z zakresu od 0,0 do 1 5 ,9 9 9 . Rzutując ją na liczbę całkowitą (in t), uzyskamy liczbę losową z zakresu od 0 do 15, a dodając 5 — liczbę z zakresu od 5 do 20. Program P6.1 wygeneruje i wyświetli 20 liczb losowych z zakresu od 1 do 6. Każde wywołanie funkcji random generuje kolejną liczbę w sekwencji. W arto zwrócić uwagę, że sekwencja wygenerowana na innym komputerze bądź na tym samym komputerze, lecz z zainstalowanym innym kompilatorem, lub wygenerowana w innym czasie może być inna. Program P6.1 import ja v a .i o .* ; public c la ss RandomTest { public s t a t ic void m ain(String[] args) throws IOException { fo r (in t j = 1; j <= 20; j+ + ) System .out.printf("% 2d", random(1, 6 ) ) ; S y stem .o u t.p rin tf("\ n "); } //koniec main public s t a t ic int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy RandomTest Po uruchomieniu program P6.1 wyświetlił następujące wyniki. 3 5 1 1 3 4 5 3 2 3 3 4 3 2 5 1 5 2 4 6 Kolejne uruchomienie tego programu wygenerowało następującą sekwencję liczb. 4 6 2 4 4 4 3 5 6 6 2 6 5 3 4 4 1 6 1 6 Każde uruchomienie programu spowoduje wygenerowanie innej sekwencji liczb.
179
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
6.4. Zgadywanka Aby przedstawić proste zastosowanie liczb losowych, napiszemy program pozwalający grać w prostą grę polegającą na odgadywaniu liczb. Program „pomyśli” dowolną liczbę z zakresu od 1 do 100. Zadaniem gracza będzie odgadnięcie tej liczby w możliwie jak najm niejszej ilości prób. Poniżej przedstawiona zastała przykładowa rozgrywka. Wybrałem liczb ę z zakresu od 1 do 100. Spróbuj odgadnąć, co to za lic z b a . Podaj lic z b ę . . . 50 Za mała Podaj lic z b ę . . . 75 Za mał a Podaj lic z b ę . . . 85 Za duża Podaj lic z b ę . . . 80 Za duża Podaj lic z b ę . . . 77 G ratuluję, odgadłeś lic z b ę ! Jak widać, za każdym razem gdy użytkownik wpisze liczbę, program wyświetli informację, czy jest ona za duża, czy za mała i pozwoli podać następną. Program „pomyśli” o liczbie z zakresu od 1 do 100 przy użyciu wywołania random(1, 100). Gracz będzie odgadywał liczby tak długo, aż odgadnie lub zrezygnuje. Aby zrezygnować, wystarczy wpisać 0. Poniżej przedstawiono cały kod programu P6.2. Program P6.2 import ja v a .u t i l . * ; public c la ss GuessTheNumber { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); System.out.printf("\nWybrałem liczb ę z zakresu od 1 do 100.\n"); System .out.printf("Spróbuj odgadnąć, co to za liczba.\ n \n "); int answer = random(1, 100); System .ou t.printf("P odaj l i c z b ę ... " ) ; int guess = in .n e x tIn t(); while (guess != answer && guess != 0) { i f (guess < answer) System .ou t.prin tf("Z a mała\n"); e lse System .ou t.prin tf("Z a duża\n"); System .ou t.printf("P odaj l i c z b ę ... " ) ; guess = in .n e x tIn t(); } i f (guess == 0) System .out.printf("Przykro mi, moja licz b a to %d\n", answer); e lse S y stem .o u t.p rin tf("G ratu lu ję, odgadłeś lic z b ę !\ n "); } //koniec main public s t a t ic int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy GuessTheNumber
180
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
Uwaga program istyczna: dobrym pomysłem może być poinformowanie użytkownika o tym, że ma możliwość przerwania gry oraz jak może to zrobić. Przykładowo komunikat z prośbą o podanie liczby może mieć następującą postać: Podaj liczb ę (lub wpisz 0, by sk o ń czy ć)...
6.5. Ćwiczenia z dodawania Chcemy napisać program, który będzie zadawał użytkownikowi proste zadania arytmetyczne (program P6.3). Konkretnie rzecz biorąc, chodzi o napisanie programu, który będzie generował różne zadania wymagające dodawania. Zadania będą sprowadzały się do dodawania dwóch liczb. Ale skąd brać te liczby? Pozwolimy, by to komputer je „wymyślił”. Teraz już wiemy, że aby to zrobić, komputer musi wygenerować dwie liczby losowe. Musimy także podjąć decyzję dotyczącą zakresu liczb, które będą używane w zadaniach. Zakres ten będzie, do pewnego stopnia, określał poziom trudności zadań. W naszym programie użyjemy liczb dwucyfrowych, czyli z zakresu od 10 do 99. Można go jednak w prosty sposób zmienić i zastosować inny zakres. Program zacznie działanie od zadania użytkownikowi pytania, ile zadań chce rozwiązać. Użytkownik będzie musiał wpisać jakąś liczbę. Następnie program zapyta, ile razy użytkownik może podawać odpowiedź na każde zadanie. Także w tym przypadku użytkownik powinien wpisać jakąś liczbę. W końcu program zacznie generować i wyświetlać zadania. Poniżej przedstawiono przykładową postać sesji z programem. Liczby wpisywane przez użytkownika zostały podkreślone, wszystkie pozostałe teksty i liczby zostały wygenerowane przez program. Witamy w programie 'Zadania z dodawania' I le zadań chciałbyś rozwiązać? 3 I le ma być prób podania odpowiedzi do każdego z zadań? 2 Zadanie 1, próba 1 z 2 62 + 92 = 154 Prawidłowa odpowiedź, doskonale! Zadanie 2, próba 1 z 2 25 + 33 = 57 Błąd, spróbuj je szcze raz Zadanie 2, próba 2 z 2 25 + 33 = 58 Prawidłowa odpowiedź, doskonale! Zadanie 3, próba 1 z 2 86 + 35 = 122 Błąd, spróbuj je szcze raz Zadanie 3, próba 2 z 2 86 + 35 = 111 No cóż, prawidłową odpowiedzią j e s t : 121 Dziękujemy za ćwiczenia. Trzymaj s i ę . . . Poniżej przedstawiono pełny kod programu P6.3. Aby był możliwie krótki, zrezygnowano ze sprawdzania poprawności danych wpisywanych przez użytkownika. Jednak naprawdę warto postarać się, by wszystkie dane wejściowe wprowadzane przez użytkowników były sprawdzane, gdyż w ten sposób możemy zapewnić, że tworzone programy będą możliwie jak najbardziej niezawodne.
181
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Program 6.3 import ja v a .u t i l . * ; public c la ss Arithmetic { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); System.out.printf("\nWitamy w programie 'Zadania z dodawania'\n\n"); S y s te m .o u t.p rin tf("Ile zadań chciałbyś rozwiązać? " ) ; int numProblems = in .n e x tIn t(); S y s te m .o u t.p rin tf("Ile ma być prób podania odpowiedzi do każdego z zadań? " ) ; int maxTries = in .n e x tIn t(); giveProblems(in, numProblems, maxTries); System.out.printf("\nDziękujemy za ćwiczenia. Trzymaj s i ę ...\ n " ) ; } //koniec main public s t a t i c void giveProblems(Scanner in , int amount, int maxTries) { int numl, num2, answer, response, t r i ; / / ‘tri’, gdyż ‘try’jest słowem kluczowym fo r (in t h = 1; h <= amount; h++) { numl = random(10, 99); num2 = random(10, 99); answer = numl + num2; fo r ( t r i = 1; t r i <= maxTries; t r i ++) { System .out.printf("\nZadanie %d, próba %d z %d\n", h, t r i , maxTries); System .out.printf("% 5d + %2d = " , num1, num2); response = in .n e x tIn t(); i f (response == answer) { System.out.printf("Prawidłowa odpowiedź, doskonale!\n"); break; } i f ( t r i < maxTries) System .ou t.p rin tf("B łąd , spróbuj je szcze raz\n"); e lse System .out.printf("N o cóż, prawidłową odpowiedzią j e s t : %d\n", answer); } //koniec fo r tri } //koniec fo r h } //koniec giveProblems public s t a t i c int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy Arithmetic
6.6. Gra Nim Jedna z wersji gry N im jest rozgrywana przez dwóch graczy, oznaczonych przykładowo A i B. Początkowo na stole znajduje się znana liczba kamieni (powiedzmy startAmount). Gracze wykonują ruchy na przemian. Każdy z graczy w swoim ruchu może zdjąć ze stołu pewną liczbę kamieni, z zakresu od 1 do jakiejś ustalonej górnej granicy (powiedzmy maxPi ck). Gracz, który zdejmie ze stołu ostatni kamień — przegrywa. Poniżej przedstawiono przykładową rozgrywkę, przy czym startAmount wynosi 20, a maxPi ck 3. Gracz A zdejmuje 2 kamienie; na stole pozostaje 18 kamieni. Gracz B zdejmuje 1 kamień; na stole pozostaje 17 kamieni. Gracz A zdejmuje 3 kamienie; na stole pozostaje 14 kamieni. Gracz B zdejmuje 1 kamień; na stole pozostaje 13 kamieni.
182
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
Gracz A zdejmuje 2 kamienie; na stole pozostaje 11 kamieni. Gracz B zdejmuje 2 kamienie; na stole pozostaje 9 kamieni. Gracz A zdejmuje 1 kamień; na stole pozostaje 8 kamieni. Gracz B zdejmuje 3 kamienie; na stole pozostaje 5 kamieni. Gracz A zdejmuje 1 kamień; na stole pozostają 4 kamienie. Gracz B zdejmuje 3 kamienie; na stole pozostaje 1 kamień. Gracz A zostaje zmuszony do zdjęcia ostatniego kamienia, czyli przegrywa grę. Jaki jest najlepszy sposób prowadzenia rozgrywki w tej grze? Oczywiście, chodzi o to, by pozostawić przeciwnika z jednym kam ieniem na stole. Nazwijmy tę sytuację pozycją przegrywającą. Następnym pytaniem jest to, ile kamieni należy pozostawić na stole, by — niezależnie od tego, ile przeciwnik zdejmie (zgodnie z regułami gry) — po naszym ruchu pozostał tylko jeden kamień. W naszym przypadku odpowiedzią na to pytanie jest 5. Jeśli pozostawimy na stole 5 kamieni, to niezależnie od tego, czy przeciwnik zdejmie 1, 2 czy 3 kamienie, my zaw sze będziemy mogli pozostawić na stole jeden kamień. Jeśli przeciwnik zdejmie 1 kamień, my zdejmiemy 3; jeśli on zdejmie 2, my też zdejmiemy 2; jeśli on zdejmie 3, my zdejmiemy 1. Dlatego też 5 jest kolejną pozycją przegrywającą. Kolejnym pytaniem jest to, ile kamieni musimy pozostawić na stole, by — niezależnie od tego, ile kamieni zdejmie przeciwnik (zgodnie z regułami gry) — po naszym ruchu zostało na stole 5 kamieni? Odpowiedź brzmi: 9. W arto spróbować! Rozumując w ten sposób, dochodzimy do wniosku, że pozycjami przegrywającymi są kolejno 1, 5, 9, 13, 17 itd. Innymi słowy, jeśli będziemy w stanie pozostawić przeciwnikowi na stole tyle kamieni, możemy doprowadzić do jego przegranej. W naszym przykładzie ruch, w którym gracz B pozostawił graczowi A 17 kamieni, był tym momentem rozgrywki, od którego gracz B nie mógł przegrać, chyba że przestałby uważać. Ogólnie rzecz biorąc, pozycje przegrywające można wyznaczyć przez dodawanie 1 do kolejnych wielokrotności wartości maxPick+1. Jeśli maxPick wynosi 3, wielokrotnościami liczby 4 są: 4, 8, 12, 16 itd. A zatem, dodając do tych wartości 1, uzyskujemy pozycje przegrywające: 5, 9, 13, 17 itd. Napiszemy teraz program, w którym komputer będzie rozgrywać najlepsze możliwe partie gry w Nim. Jeśli będzie w stanie doprowadzić do sytuacji, w której gracz znajdzie się w pozycji przegrywającej, to tak właśnie zrobi. Jeśli natomiast to gracz sprawi, że program znajdzie się na pozycji przegrywającej, program zdejmie ze stołu losową liczbę kamieni. Liczymy tu na to, że gracz popełni błąd. Zakładamy, że zmienna remain określa liczbę kamieni pozostających na stole. W jaki sposób komputer może wyliczyć najlepszy ruch? Jeśli wartość remain jest mniejsza lub równa maxPick, komputer zdejmuje remain-1 kamieni, pozostawiając gracza z jednym kamieniem na stole. W pozostałych przypadkach program wykonuje następujące obliczenie: r = remain % (maxPick + 1) Jeśli r wynosi 0, to remain jest wielokrotnością maxPick+1; w takim przypadku program zdejmuje maxPick kamieni, pozostawiając gracza w pozycji przegrywającej. W naszym przypadku, jeśli wartość remai n wynosi 16 (co jest wielokrotnością 4), program zdejmuje 3 kamienie, pozostawiając graczowi 13 kamieni — czyli gracz znajdzie się w pozycji przegrywającej. Jeśli r wynosi 1, to program znalazł się w pozycji przegrywającej i może zdjąć dowolną, losową liczbę kamieni. W pozostałych przypadkach program zdejmuje r-1 kamieni, pozostawiając gracza w pozycji przegrywającej. W naszym przypadku, jeśli wartość remain wynosi 18, wartość r wyniesie 2. W tedy program zdejmuje 1 kamień, pozostawiając graczowi 17 kam ieni na stole — czyli gracz znajdzie się w pozycji przegrywającej. Strategia ta została zaimplementowana w funkcji bestPick, przedstawionej w programie P6.4. Program ten pozwala zagrać z komputerem w grę Nim.
183
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Program P6.4 import ja v a .u t i l . * ; public c la ss Nim { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); System .out.printf("\nPodaj liczb ę kamieni na s to le : " ) ; int remain = in .n e x tIn t(); System.out.printf("Maksymalna liczb a kamieni zdejmowanych w jednym ruchu to : " ) ; int maxPick = in .n e x tIn t(); playGame(in, remain, maxPick); } //koniec main public s t a t ic void playGame(Scanner in, in t remain, in t maxPick) { int userPick; System .out.printf("\nN a sto le pozostaje %d kamieni.\n", remain); while (true) { //pętla wykonywana aż do zakończenia gry do { System .out.printf("Tw oja k o le j, i le kamieni zdejmujesz: " ) ; userPick = in .n e x tIn t(); i f (userPick > remain) System .ou t.prin tf("N ie możesz zdjąć więcej niż %d kamieni.\n", Math.min(remain, maxPick)); e lse i f (userPick < 1 || userPick > maxPick) System.out.printf("Nieprawidłowa lic z b a : wpisz liczb ę od 1 do %d: ", maxPick); } while (userPick > remain || userPick < 1 || userPick > maxPick); remain = remain - userPick; System .out.printf("N a s to le pozostaje %d kamieni.\n", remain); i f (remain == 0) { S y ste m .o u t.p rin tf("P rz e g ra łe ś!!\ n "); return; } i f (remain == 1) { S ystem .ou t.p rin tf("W yg rałeś!!\ n "); return; } int compPick = bestPick(rem ain, maxPick); System .out.printf("Zdejm uję ze stołu %d kamieni.\n", compPick); remain = remain - compPick; System .out.printf("N a s to le pozostaje %d kamieni.\n", remain); i f (remain == 0) { S ystem .ou t.prin tf("W ygrałeś!!\n "); return; } i f (remain == 1) { System .out.printf("W ygrałem !!\n"); return; } } //koniec while (true) } //koniec playGame public s t a t i c int b e stP ick (in t remain, int maxPick) { i f (remain <= maxPick) return remain - 1; //doprowadź do sytuacji przegrywającej int r = remain % (maxPick + 1); i f (r == 0) return maxPick; //doprowadź do sytuacji przegrywającej i f (r == 1) return random(1, maxPick); //program w sytuacji przegrywającej return r - 1; //gracz w sytuacji przegrywającej } //koniec bestPick
184
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
public s t a t i c int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy Nim W arto zwrócić uwagę na pętlę do...whil e używaną do pobierania i weryfikacji poprawności danych wprowadzanych przez użytkownika. Jej ogólna postać wygląda następująco: do while (); Jak zwykle, może być pojedynczą instrukcją (zapisywaną w jednym wierszu) lub instrukcją złożoną (zapisywaną w nawiasach klamrowych). Słowa do, while oraz para nawiasów zakończona średnikiem są obowiązkowe. Programista podaje oraz . Pętla do.while jest wykonywana w następujący sposób: 1. wykonywana jest ; 2. określana jest wartość ; jeśli jest to true, zostaje powtórzony krok 1.; w przeciwnym przypadku wykonywanie pętli zostaje zakończone, a program przechodzi od następnej instrukcji za średnikiem (jeśli taka jest). Innymi słowy, jest wykonywana tak długo, jak długo przyjmuje wartość true. Koniecznie należy zauważyć, że ze względu na postać tej pętli < in stru k cja > zaw sze z ostan ie w ykon an a p rzy n ajm n iej jed en raz. To bardzo użyteczne zwłaszcza wtedy, gdy chcemy, by jakaś instrukcja została wykonana co najm niej jeden raz. W powyższym programie musimy zapytać użytkownika o jego ruch przynajmniej jeden raz; to właśnie dlatego zastosowaliśmy pętlę do.while. Poniżej przedstawione zostały przykładowe wyniki wykonania programu P6.4. Podaj liczb ę kamieni na s to le : 30 Maksymalna liczb a kamieni zdejmowanych w jednym ruchu to : 5 Na sto le pozostaje 30 kamieni. Twoja k o le j, i l e kamieni zdejmujesz: 2 Na sto le pozostaje 28 kamieni. Zdejmuję ze stołu 3 kamieni. Na sto le pozostaje 25 kamieni. Twoja k o le j, i l e kamieni zdejmujesz: 1 Na sto le pozostaje 24 kamieni. Zdejmuję ze stołu 5 kamieni. Na sto le pozostaje 19 kamieni. Twoja k o le j, i l e kamieni zdejmujesz: 4 Na sto le pozostaje 15 kamieni. Zdejmuję ze stołu 2 kamieni. Na sto le pozostaje 13 kamieni. Twoja k o le j, i l e kamieni zdejmujesz: 6 Nieprawidłowa lic z b a : wpisz licz b ę od 1 do 5 Twoja k o le j, i l e kamieni zdejmujesz: 2 Na sto le pozostaje 11 kamieni. Zdejmuję ze stołu 4 kamieni. Na sto le pozostaje 7 kamieni. Twoja k o le j, i l e kamieni zdejmujesz: 1 Na sto le pozostaje 6 kamieni. Zdejmuję ze stołu 5 kamieni. Na sto le pozostaje 1 kamieni. Wygrałem!! Zwracamy także uwagę, że warto wyświetlać jakieś informacje dotyczące gry.
185
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
6.7. Rozkłady nierównomierne Do tej pory wszystkie generowane liczby losowe były równomiernie rozłożone w całym zakresie. Przykładowo podczas generowania liczb z zakresu od 10 do 99 prawdopodobieństwo wylosowania każdej z tych liczb było takie samo. Analogicznie, wywołanie random(1, 6) może wylosować każdą z liczb od 1 do 6 z takim samym prawdopodobieństwem. A teraz załóżmy, że chcemy, by komputer „rzucał” zwyczajną kostką do gry o sześciu ściankach. Ponieważ komputer nie może zrobić tego fizycznie, musi zasymulować proces rzutu. A jaki jest cel rzucania kostką? Chodzi w nim o wybór losowej liczby z zakresu od 1 do 6. Jak już przekonaliśmy się, kom puter „wie”, jak to zrobić. Jeśli kostka jest uczciwa, prawdopodobieństwo wyrzucenia każdej ze ścianek będzie takie samo. Aby zasymulować rzut taką kostką, wystarczy wybrać losową liczbę o rozkładzie równomiernym z zakresu od 1 do 6. Możemy to zrobić za pomocą wywołania random(1, 6). Podobnie, kiedy rzucamy uczciwą monetą, prawdopodobieństwo wyrzucenia orła i reszki będzie takie samo. Aby zasymulować proces rzucania taką monetą na komputerze, wystarczy wybrać liczbę losową o rozkładzie równomiernym z zakresu od 1 do 2. Liczba 1 może reprezentować orła, a 2 — reszkę. Ogólnie rzecz biorąc, jeśli wszystkie możliwe wystąpienia zdarzenia (takie jak rzut uczciwą kostką) są tak samo prawdopodobne, możemy je symulować przy użyciu liczb losowych o rozkładzie równomiernym. Powstaje jednak pytanie, w jaki sposób zasymulować zdarzenia, jeśli jednak prawdopodobieństwo ich występowania nie będzie takie samo? W ramach przykładu zastanówmy się nad rzucaniem specjalnie przygotowaną monetą, w której orzeł wypada dwa razy częściej niż reszka. W takim przypadku mówimy, że prawdopodobieństwo wyrzucenia orła wynosi 2/3, a reszki 1/3. Aby zasymulować rzut taką monetą, generujemy liczbę losową o rozkładzie równomiernym z zakresu od 1 do 3. Jeśli wylosujemy 1 lub 2, uznajemy, że wypadł orzeł, jeśli wylosujemy 3, uznajemy, że wypadła reszka. A zatem, aby zasymulować zdarzenie o rozkładzie nierównomiernym, zmieniamy je na takie, w którym rozkład liczb losowych będzie równomierny. A teraz przeanalizujmy kolejny przykład. Załóżmy, że dla każdego dnia miesiąca (np. czerwca) spełnione są następujące warunki i tylko one są możliwe: prawdopodobieństwo słonecznej pogody = 4/9 prawdopodobieństwo opadu deszczu = 3/4 prawdopodobieństwo zachmurzenia = 2/9 W takim przypadku pogodę w czerwcu możemy zasymulować przy użyciu następującej funkcji: fo r każdego dnia czerwca r = random(1, 9) i f (r <= 4) "świeci słońce" e lse i f (r <= 7) "pada deszcz" e lse "pełne zachmurzenie" endfor W arto zwrócić uwagę, że słoneczna pogoda może być reprezentowana przez dow oln e cztery liczby losowe, dowolne inne trzy liczby mogą reprezentować opady deszczu, a pozostałe dwie liczby — zachmurzenie.
6.7.1. Zbieranie kapsli z butelek Wytwórca popularnych napojów urządza konkurs, w którym należy zebrać kapsle z literam i tworzącymi słowo MANGO. Wiadomo, że na każde 100 kapsli 40 jest z literą A, 25 z literą O, 15 z literą N, 15 z literą M i 5 z literą G . Naszym celem jest napisanie programu, który przeprowadzi 20 symulacji procesu zbierania kapsli, aż do momentu uzyskania możliwości ułożenia z nich słowa M ANGO. Dla każdej z tych symulacji chcemy mieć możliwość wyświetlenia liczby zebranych kapsli; dodatkowo chcemy poznać średnią liczbę kapsli zebranych we wszystkich symulacjach. Zbieranie kapsli jest zdarzeniem o rozkładzie nierównomiernym. Łatwiej trafić na kapsel z literą A niż na kapsel z literą G. Aby zasymulować takie zdarzenie, możemy wygenerować liczbę losową o rozkładzie równomiernym, należącą do zakresu od 1 do 100. Do określenia, jaka litera znajduje się na kapslu, użyjemy następującej funkcji:
186
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
c = random(1, 100) i f (c <= 40) mamy kapsel z l i t e r ą A e lse i f (c <=65)mamy kapselz l i t e r ą O e lse i f (c <=80)mamy kapselz l i t e r ą N e lse i f (c <=95) mamy kapsel z l i t e r ą M e lse mamy kapsel z l i t e r ą G W naszym przykładzie gdybyśmy chcieli, moglibyśmy przeskalować wszystkie wartości, zmniejszając je 5-krotnie; w takim przypadku funkcja miałaby następującą postać: c = random(1, 20) i f (c <= 8) mamy kapsel z l i t e r ą A e lse i f (c <=13)mamy kapselz l i t e r ą O e lse i f (c <=16)mamy kapselz l i t e r ą N e lse i f (c <=19) mamy kapsel z l i t e r ą M e lse mamy kapsel z l i t e r ą G Każda z dwóch wersji funkcji spełni swoje zadanie i pozwoli zasymulować problem. Poniżej przedstawiony został ogólny opis algorytmu rozwiązania naszego problemu. totalCaps = 0 fo r sim = od 1 do 20 capsThisSim = wykonaj jedną symulację print capsThisSim dodaj capsThisSim do totalCaps endfor print totalCaps / 20 A oto sposób wykonania jednej symulacji. numCaps = 0 while (nie można utworzyć słowa) { losujemy jeden kapsel i określamy l it e r ę dodajemy l it e r ę do kolekcji dodajemy 1 do wartości numCaps } return numCaps Do przechowywania statusu poszczególnych liter zastosujemy tablicę cap[5], zakładając przy tym, że cap[0] będzie zawierać liczbę posiadanych kapsli z literą A, cap[1] z literą O, cap[2] z literą N, cap[3] z literą M oraz cap[4] z literą G. W artość 0 zapisana w którejś z kom órek tablicy oznacza, że nie udało się zebrać kapsla z odpowiednią literą. Jeśli np. wylosujemy kapsel z literą N, inkrem entujem y wartość cap [2]; analogicznie postąpimy w przypadku pozostałych liter. A zatem kapsle z każdą literą uda nam się zebrać wtedy, gdy wartość każdego elementu tablicy cap będzie większa lub równa 1. W programie P6.5 przedstawiono kod pełnego rozwiązania naszego problemu. Program P6.5 public c la ss BottleCaps { s t a tic in t MaxSim = 20; s t a tic in t MaxLetters = 5; public s t a t ic void m ain(String[] args) { int sim, capsThisSim, totalCaps = 0; System .out.printf("\nSym ulacja zbierania kapsli\n\n"); fo r (sim = 1; sim <= MaxSim; sim++) { capsThisSim = doOneSimulation(); System .out.printf("% 6d %13d\n", sim, capsThisSim); totalCaps += capsThisSim; } System .out.printf("\nŚrednia liczb a zebranych kap sli: %d\n", totalCaps/MaxSim); } //koniec main
187
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
public s t a t i c int doOneSimulation() { boolean[] cap = new boolean[MaxLetters]; fo r (in t j = 0; j < MaxLetters; j+ + ) cap [j] = fa ls e ; int numCaps = 0; while (!mango(cap)) { int c = random(1, 20); i f (c <= 8) cap[0] = true; e lse i f (c <= 13) cap[1] = true; e lse i f (c <= 16) cap[2] = true; e lse i f (c <= 19) cap[3] = true; e lse cap[4] = tru e; numCaps++; } //koniec while return numCaps; } //koniec doOneSimulation public s t a t ic boolean mango(boolean[] cap) { fo r (in t j = 0; j < MaxLetters; j++) i f (cap [j] == fa lse ) return fa ls e ; return tru e; } //koniec mango public s t a t ic int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy BottleCaps Po uruchomieniu program generuje następujące, przykładowe wyniki. Symulacja zbierania kapsli 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
48 36 8 10 20 28 22 97 16 24 15 21 32 11 8 48 19 17 15 12
Średnia liczb a zebranych kap sli: 25 Jak widać, wyniki wahają się od tak niewielkiej liczby kapsli jak 8, aż do tak wysokiej jak 97. Czasami mamy szczęście, a czasami nie. Jednak za każdym razem gdy uruchom im y program, wygeneruje on inne wyniki.
188
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
6.8. Symulowanie realnych problemów Komputery, przeprowadzając symulacje, mogą odpowiadać na pewne pytania dotyczące realnych sytuacji. Proces symulacji pozwala rozważyć różne rozwiązania konkretnego problemu. Dzięki temu, z dużą dozą pewności możemy wybrać najlepsze rozwiązanie w danej sytuacji. Jednak, zanim będziemy w stanie przeprowadzić komputerową symulację, konieczne jest zebranie danych, dzięki którym stymulacja ta będzie możliwie jak najbardziej realistyczna. Gdybyśmy np. chcieli zasymulować obsługę klienta w banku, musielibyśmy znać (a przynajmniej oszacować): • ile czasu (określmy go jako t1) upływa, zanim do kolejki dołączy następny klient; • ile czasu (określmy go jako t2) zajmuje obsłużenie klienta. Oczywiście, czas t1 może być bardzo różny. Będzie np. zależał od pory dnia — w pewnych godzinach klienci przychodzą częściej niż w innych. Poza tym różni klienci m ają odmienne potrzeby, a zatem także czas t2 dla każdego klienta będzie inny. Jeśli jednak poświęcimy nieco czasu na obserwację systemu, poczynimy pewne założenia, takie jak: • czas t1 zmienia się losowo w zakresie od jednej do pięciu minut; • czas t2 zmienia się losowo w zakresie od trzech do dziesięciu minut. Bazując na tych założeniach, możemy przeprowadzić symulację długości kolejki, w przypadku gdy klienci będą obsługiwani w 2, 3, 4 i kolejnych stanowiskach. Zakładamy, że jest jedna kolejka, a osoba znajdująca się na jej początku podchodzi do pierwszego wolnego stanowiska obsługi. W rzeczywistości, w godzinach zwiększonego ruchu banki zazwyczaj udostępniają więcej stanowisk niż w godzinach, gdy liczba klientów jest mniejsza. W takim przypadku symulację można przeprowadzić w dwóch etapach; dobierając odpowiednie założenia dla każdego z nich. Istnieją także inne sytuacje, w których można zastosować podobne metody symulacji. Oto one. • Kasy w su perm arketach i sklepach. W tym przypadku zazwyczaj zależy nam na uzyskaniu kompromisu pomiędzy liczbą działających kas a średnią długością kolejki. Im mniej będzie uruchomionych kas, tym dłuższe będą kolejki. Z drugiej strony, większa liczba kas oznacza większą liczbę urządzeń oraz pracowników. Zależy nam zatem na określeniu najlepszego kompromisu pomiędzy kosztami operacyjnymi oraz jakością usług świadczonych klientom. • D ystrybutory na stacjach benzynowych. Ile dystrybutorów będzie w stanie optymalnie zaspokoić potrzeby użytkowników? • Sygnalizacja świetlna na skrzyżowaniach. Jaki będzie optymalny czas przełączania świateł, umożliwiający skrócenie do minimum średniej długości kolejek w każdym z kierunków? W tym przypadku konieczne byłoby zgromadzenie odpowiednich danych. Oto one. • Co ile czasu nadjeżdżają pojazdy z kierunku 1. i z kierunku 2.? Odpowiedź na to pytanie może mieć następującą, przykładową postać: z kierunku 1. w ciągu każdej minuty nadjeżdża między 5 a 15 pojazdów, z kierunku 2. w ciągu każdej minuty nadjeżdża między 10 a 30 pojazdów. • W jakim czasie pojazdy opuszczają skrzyżowanie w kierunku 1. i w kierunku 2.? A oto przykładowe odpowiedzi. W kierunku 1. w ciągu 30 sekund skrzyżowanie opuszcza 20 samochodów. W kierunku 2. w ciągu 30 sekund skrzyżowanie opuszcza 30 samochodów. W tej prostej symulacji zakładamy, że skręcanie nie jest dozwolone.
189
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
6.9. Symulacja kolejki Przeanalizujmy teraz przykład stanowiska obsługi w banku lub kasy w supermarkecie, do których klienci podchodzą i muszą czekać w kolejce na obsługę. Załóżmy, że kolejka jest tylko jedna, ale klienci są obsługiwani przy kilku stanowiskach. Jeśli wszystkie stanowiska są zajęte, klienci muszą czekać; osoba znajdująca się na początku kolejki podchodzi do pierwszego wolnego stanowiska. Aby zilustrować ten problem, załóżmy, że klienci są obsługiwani przy dwóch stanowiskach, oznaczonych odpowiednio jako C1 i C2. W celu przeprowadzenia symulacji musimy wiedzieć, jak często pojawiają się nowi klienci oraz ile czasu zajmuje obsłużenie każdego z nich. Przyjmijmy, że na podstawie obserwacji i doświadczenia jesteśmy w stanie sformułować następujące stwierdzenia. • Nowi klienci dołączają do kolejki w czasie wahającym się losowo w zakresie od jednej do pięciu minut. • Czas obsługi klienta waha się losowo w zakresie od trzech do dziesięciu minut. Aby symulacja była miarodajna, dane muszą być zbliżone do faktycznych. Generalnie należy stwierdzić, że symulacja jest tak dobra jak dane, na których się opiera. Załóżmy, że zaczynamy obsługiwać klientów o godzinie 9 rano. Przybycie pierwszych dziesięciu klientów możemy zasymulować, generując sekwencję liczb losowych z zakresu od 1 do 5 np.: 3 1 2 4 2 5 1 3 2 4 Oznacza to, że pierwszy klient przychodzi o godzinie 9:03, drugi o godzinie 9:04, trzeci o godzinie 9:06, czwarty o godzinie 9:10 itd. Z kolei czas obsługi każdego z tych klientów możemy zasymulować, generując liczby losowe z zakresu od 3 do 10 np.: 5 8 7 6 9 4 7 4 9 6 Oznacza to, że pierwszy klient spędzi przy stanowisku obsługi 5 minut, drugi 8 minut, trzeci 7 minut itd. W tabeli 6.1 pokazano, jak będzie wyglądała obsługa pierwszych dziesięciu klientów. Tabela 6.1. Śledzenie obsługi dziesięciu klientów Klient nr
Przybył o godzinie
Początek obsługi
Stanowisko
Czas obsługi
Zakończenie obsługi
Czas oczekiwania
1
9:03
9:03
C1
5
9:08
0
2
9:04
9:04
C2
8
9:12
0
3
9:06
9:08
C1
7
9:15
2
4
9:10
9:12
C2
6
9:18
2
5
9:12
9:15
C1
9
9:24
3
6
9:17
9:18
C2
4
9:22
1
7
9:18
9:22
C2
7
9:29
4
8
9:21
9:24
C1
4
9:28
3
9
9:23
9:28
C1
9
9:37
5
10
9:27
9:29
C2
6
9:35
2
190
•
Pierwszy klient przychodzi o godzinie 9:03 i podchodzi bezpośrednio nim pięć minut i wychodzi o godzinie 9:08, zwalniając stanowisko.
do stanowiska C1;spędza przy
•
Drugi klient przychodzi o godzinie 9:04 i podchodzi bezpośrednio do osiem minut i wychodzi o godzinie 9:12, zwalniając stanowisko.
stanowiska C2;spędza przy nim
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
• Trzeci klient przychodzi o godzinie 9:06. Okazuje się, że o tej godzinie oba stanowiska, C1 i C2, są zajęte, zatem klient musi czekać. Pierwszym stanowiskiem, które zostanie zwolnione, będzie stanowisko C1, a nastąpi to o godzinie 9:08. Zatem obsługa trzeciego klienta zaczyna się o godzinie 9:08. Klient spędza przy stanowisku C1 osiem minut i zwalnia je o godzinie 9:15. Ten klient musi czekać w kolejce przez dwie minuty. • Czwarty klient przychodzi o godzinie 9:10. Okazuje się, że o tej godzinie oba stanowiska, C1 i C2, są zajęte, zatem klient musi czekać. Stanowisko C2 zostaje zwolnione o godzinie 9:12. Zatem obsługa czwartego klienta zaczyna się o godzinie 9:12. Klient spędza przy stanowisku C2 sześć minut i zwalnia je o godzinie 9:18. Ten klient musi czekać w kolejce przez dwie minuty. I tak dalej. Trzeba przeanalizować pozostałych klientów z tabeli, by upewnić się, że wiadomo, w jaki sposób określane są te wartości. W arto także zwrócić uwagę, że po rozpoczęciu obsługi klientów oba stanowiska działają bez żadnych przerw. Gdy tylko aktualnie obsługiwany klient zwolni stanowisko, zaraz pochodzi do niego następny.
6.9.1. Implementacja symulacji A teraz zobaczymy, jak można napisać program, który wygeneruje dane przedstawione w tabeli 6.1. Jednocześnie należy zwrócić uwagę, że napisanie takiego samego programu symulującego większą liczbę stanowisk obsługi nie jest wcale trudniejsze niż przedstawionego tu programu symulującego dwa stanowiska. Dlatego też założymy, że dostępnych jest n (gdzie n < 10) stanowisk. Dla tego konkretnego przypadku przyjmiemy, że n wynosi 2. W programie wykorzystamy tablicę d ep art[10], której poszczególne komórki, d ep art[c], będą zawierały czas, w którym zostanie zwolnione stanowisko c. Komórka depart[0] nie będzie używana. Gdybyśmy musieli zwiększyć liczbę dostępnych stanowisk, wystarczy odpowiednio powiększyć tablicę depart. Załóżmy, że klient znajdujący się na początku kolejki pojawia się o godzinie określonej przez arriveT i me. Zostanie on skierowany do pierwszego wolnego stanowiska. Stanowisko c jest wolne, jeśli ostatni obsługiwany na nim klient opuścił je wcześniej, niż pojawił się następny klient do obsłużenia, czyli gdy wartość zmiennej arri veT ime jest większa lub równa wartości kom órki d ep art[c]. Jeśli żadne stanowisko nie jest wolne, klient musi czekać. Zostanie on później skierowany do pierwszego dostępnego stanowiska, czyli do stanowiska, któremu odpowiada najmniejsza wartość w tablicy depart; załóżmy, że będzie to komórka depart [m]. Klient zacznie być obsługiwany w momencie określonym przez większą z pary wartości: arriveTime oraz depart [m]. Działanie programu rozpoczyna się od prośby o podanie liczby dostępnych stanowisk obsługi oraz liczby klientów, jakie mają się pojawić w symulacji. Symulacja rozpoczyna się od czasu 0, a wszystkie inne czasy w programie są określane względem niego. Kompletny kod rozwiązania przedstawiono w programie P6.6. Program P6.6 import ja v a .u t i l . * ; public c la ss SimulateQueue { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); S y stem .o u t.p rin tf("\ n Ile je s t stanowisk obsługi? " ) ; int numCounters = in .n e x tIn t(); System .o u t.p rin tf("\ n Ilu będzie klientów? " ) ; int numCustomers = in .n e x tIn t(); doSimulation(numCounters, numCustomers); } //koniec main public s t a t ic void doSim ulation(int counters, in t customers) int m, arriveTime, sta rtS e rv e , serveTime, waitTime; in t[] depart = new int[cou nters + 1]; fo r (in t h = 1; h <= counters; h++) depart[h] = 0; System .out.printf("\n Czas Początek Czas Sy stem .o u t.p rin tf("K lien t przybycia obsługi Stanowisko obsługi
Koniec Czas\n"); obsługi oczekiwania\n\n")
191
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
arriveTime = 0; fo r (in t h = 1; h <= customers; h++) { arriveTime += random(1, 5 ); m = sm allest(depart, 1, counters); startServe = Math.max(arriveTime, depart[m]); serveTime = random(3, 10); depart[m] = startServe + serveTime; waitTime = startServe - arriveTime; System .out.printf("% 4d %8d %7d %8d %11d %8d %7d\n", h, arriveTime, startS erv e, m, serveTime, depart[m], waitTime); } //koniec fo r h } //koniec doSimulation public s t a t i c int sm a lle st(in t l i s t [ ] , int lo , int hi) { //funkcja zwraca indeks najmniejszej wartości z zakresu //tablicy list[lo... hi] int h, k = lo ; fo r (h = lo + 1; h <= h i; h++) i f (lis t [ h ] < l i s t [ k ] ) k = h; return k; } public s t a t i c int random(int m, int n) { //funkcja zwraca liczbę losową z zakresu od m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; } //koniec random } //koniec klasy SimulateQueue Poniżej przedstawione zostały przykładowe wyniki wykonania programu P6.6. Ile j est stanowi sk obsługi ? 2 Ilu będzie klientów? 10 Czas Początek Klien t przybycia obsługi 1 2 3 4 5 6 7 8 9 10
3 5 7 10 15 16 18 20 24 29
3 5 11 11 17 20 22 24 27 32
Stanowisko 1 2 1 2 1 2 1 2 2 1
Czas obsługi 8 6 6 9 5 4 10 3 7 6
Koniec Czas obsługi ocze 11 11 17 20 22 24 32 27 34 38
0 0 4 1 2 4 4 4 3 3
Jak widać, czas oczekiwania jest stosunkowo krótki. Jeśli jednak uruchomimy symulację dla 25 klientów, zauważymy, że czas ten zauważalnie wzrósł. A co by się stało, gdybyśmy uruchomili następne stanowisko? Dzięki symulacji łatwo można się o tym przekonać, a — co ważniejsze — nie trzeba w tym celu kupować kolejnego komputera ani zatrudniać dodatkowego pracownika. W naszym przypadku wszystko, co trzeba zrobić, sprowadza się do wpisania na początku programu odpowiednio liczb 3 i 25. W tedy przekonamy się, że czas oczekiwania klientów będzie bardzo krótki. Warto wypróbować różne zestawy danych — liczby stanowisk, klientów, częstotliwości pojawiania się kolejnych klientów oraz czasów ich obsługi — i sprawdzić, co się będzie działo.
192
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
6.10. Szacowanie wartości liczbowych przy użyciu liczb losowych Zobaczyliśmy już, w jaki sposób można używać liczb losowych do grania w gry oraz symulowania realnych sytuacji. Nieco mniej znanym i nieoczywistym zastosowaniem liczb losowych jest szacowanie wartości liczbowych, których wyliczenie jest trudne lub uciążliwe. Poniżej można zobaczyć, jak ich użyć do oszacowania pierwiastka kwadratowego liczby n (pi).
6.10.1. Szacowanie pierwiastka kwadratowego liczby 5 Zastosowanie liczb losowych do oszacowania pierwiastka kwadratowego liczby 5 opiera się na następujących założeniach. •
Jest to wartość z zakresu od 2 do 3.
•
x jest mniejsze od ^ [ 5 , jeśli x2 jest mniejsze od 5.
• Generowane są liczby losowe, z częścią ułamkową, z zakresu od 2 do 3. Program rejestruje ilość liczb, które są mniejsze od
1/5
.
• Niech maxCount będzie sumaryczną ilością wygenerowanych liczb losowych z zakresu od 2 do 3. Użytkownik poda wartość maxCount. • Niech amountLess będzie ilością tych liczb, które są mniejsze od ^ [ 5 . •
t.
.1 ..
.
Przybliżenie wyznaczanego
/“ .
.
/
i
.
.
«
amountLess
jest określone jako 2 + ---------—---- —.
Aby zrozumieć ideę działania tej metody, przyjrzyjmy się fragmentowi osi liczbowej z zakresu od 2 do 3 i załóżmy, że punkt r reprezentuje wartość pierwiastka kwadratowego liczby 5.
2
r
3
Jeśli wyobrazimy sobie, że fragment osi pomiędzy liczbami 2 i 3 jest w całości wypełniony kropkami, możemy oczekiwać, że liczba tych kropek na odcinku pomiędzy 2 i r będzie proporcjonalna do długości tego odcinka. Ogólnie rzecz ujm ując, liczba kropek przypadających na dowolny odcinek prostej będzie proporcjonalna do długości tego odcinka — im dłuższy odcinek, tym więcej kropek się na nim zmieści. A teraz wyobraźmy sobie, że każda liczba losowa z zakresu od 2 do 3 reprezentuje kropkę na osi liczbowej. Możemy oczekiwać, że im więcej liczb wygenerujemy, tym bliższe prawdy będzie nasze twierdzenie, że długość odcinka od 2 do r jest proporcjonalna do liczby kropek przypadających na ten odcinek; a zatem tym dokładniejsza będzie nasza szacunkowa wartość. Program P6.7 oblicza szacunkową wartość pierwiastka kwadratowego liczby 5, posługując się właśnie tą metodą. Trzeba przy tym pamiętać, że metoda Math.random generuje losowe ułamki. W przypadku zastosowania 1000 liczb losowych nasz program oszacował wartość pierwiastka kwadratowego liczby 5 na 2,234. Faktyczna wartość tego pierwiastka wynosi 2,236 z dokładnością do trzech miejsc po przecinku. Program P6.7 import ja v a .u t i l . * ; public c la ss Root5 { public s t a tic void m ain(String[] args) { Scanner in = new Scanner(System .in); System .o u t.p rin tf("\ n Ilu lic z b losowych użyć? " ) ; int maxCount = in .n e x tIn t(); int amountLess = 0;
193
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
fo r (in t j = 1; j <= maxCount; j+ + ) { double r = 2 + Math.random(); i f (r * r < 5) ++amountLess; } System .out.printf("\nPierw iastek kwadratowy z 5 wynosi około %5.3f\n", 2 + (double) amountLess / maxCount); } //koniec main } //koniec klasy Root5
6 .1 0 .2 . Szacowanie wartości n Przyjrzyjmy się rysunkowi 6.1, na którym przedstawiono okrąg wpisany w kwadrat.
Rysunek 6.1. O krąg wpisany w kw ad rat Jeśli zamkniemy oczy i będziemy zaznaczać na nim punkty, w końcu możemy otrzymać coś, co będzie przypominać rysunek 6.2 (przy czym zaznaczyliśmy na nim wyłącznie punkty, które znalazły się wewnątrz diagramu).
Rysunek 6.2. O krąg wpisany w kw ad rat p o zaznaczeniu na diagram ie losowych pu n któw Zwróćmy uwagę, że niektóre punkty znalazły się wewnątrz okręgu, a niektóre poza nim. Jeśli punkty będą zaznaczane „losowo”, można przyjąć, że liczba tych, które znajdą się wewnątrz koła, będzie proporcjonalna do jego powierzchni — im większe koło, tym więcej punktów znajdzie się wewnątrz niego. Na tej podstawie możemy podać następujące przybliżenie:
obszar koła obszar kwadratu
liczba punktów w kole liczba punktów w kwadracie
Należy zauważyć, że liczba punktów znajdujących się wewnątrz kwadratu zawiera także punkty, które znajdują się wewnątrz okręgu. Jeśli wyobrazimy sobie cały kwadrat wypełniony punktami, powyższe przybliżenie będzie całkiem dokładne. Ale w jaki sposób możemy użyć tego pomysłu do oszacowania wartości liczby n? Przyjrzyjmy się rysunkowi 6.3.
194
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
Rysunek 6.3. Ć w iartka k oła i kw adrat •
C jest ćwiartką koła o promieniu 1; S jest kwadratem o boku długości 1.
•
Pole C = — , natomiast pole S = 1. 4
TT
• Punkt (x,y) leżący wewnątrz C spełnia warunek x2 + y2 < 1, przy czym x > 0 i y > 0. • Punkt (x,y) leżący wewnątrz S spełnia warunek 0 < x < 1 i 0 < y < 1. Załóżmy, że generujemy dwa losowe ułamki, czyli liczby z zakresu od 0 do 1; nazwijmy je x i y. Ponieważ 0 < x < 1 i 0 < y < 1, oznacza to, że punkt (x, y) leży wewnątrz S. Ten sam punkt będzie także leżał wewnątrz C, jeśli x2+y2 < 1. Jeśli wygenerujemy n par losowych ułamków, to tak, jakbyśmy wygenerowali n punktów leżących wewnątrz S. Dla każdego z tych punktów możemy określić, czy leży on także wewnątrz C. Załóżmy, że m spośród tych n punktów leży wewnątrz C. Na podstawie wcześniejszych rozważań możemy przyjąć, że jest spełnione następujące przybliżenie:
T
Pole koła C wynosi — , natomiast pole S wynosi 1. A zatem spełnione jest następujące przybliżenie: t
_ m
4
n
A stąd otrzymujemy: t
4m = ---------
n Na tej podstawie możemy napisać program P6.8 szacujący wartość n. Program P6.8 import ja v a .u t i l . * ; publ ic c la ss Pi { public s t a t ic void m ain(String[] args) { Scanner in = new Scanner(System .in); int inC = 0; System .o u t.p rin tf("\ n Ilu lic z b należy użyć? " ) ; int inS = in .n e x tIn t(); fo r (in t j = 1; j <= inS; j+ + ) { double x = Math.random(); double y = Math.random(); i f (x * x + y * y <= 1) inC++; } System .out.printf("\nPrzybliżona wartość pi wynosi %5.3f\n", 4 .0 * inC /inS); } //koniec main } //koniec klasy Pi W artość n podana z dokładnością do trzech miejsc po przecinku wynosi 3,142. W przypadku zastosowania 1000 liczb losowych powyższy program wyznaczył przybliżenie liczby n o wartości 3,132. Po zastosowaniu 2000 liczb losowych przybliżenie to wyniosło 3,140.
195
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Ćwiczenia 1. Napisz program, który poprosi o podanie dwóch liczb, mi n, a następni wyświetli 25 liczb losowych z zakresu od mdo n 2. Wyjaśnij różnice pomiędzy liczbami losowymi i pseudolosowymi. 3. Zmodyfikuj program P6.3 w taki sposób, by generował zadania z odejmowaniem. 4. Zmodyfikuj program P6.3 w taki sposób, by generował zadania z mnożeniem. 5. W programie P6.3 zaimplementuj system punktacji. Przykładowo dajesz możliwość dwukrotnego podawania odpowiedzi na jedno pytanie; w razie udzielenia prawidłowej odpowiedzi za pierwszym razem możesz przyznawać 2 punkty, a za drugim razem — 1 punkt. 6. Zmodyfikuj program P6.3 w taki sposób, by wyświetlał użytkownikowi menu pozwalające określać, jakiego rodzaju zadania chce ćwiczyć (z dodawaniem, odejmowaniem lub mnożeniem). 7. Napisz program, który zasymuluje 1000 rzutów kostką do gry i wyświetli liczbę wyrzuconych jedynek, dwójek, trójek, czwórek, piątek i szóstek. Napisz ten program w dwóch wersjach: (a) z wykorzystaniem tablicy oraz (b) bez użycia tablicy. 8. Napisz program symulujący pogodę w okresie 60 dni, wykorzystując przy tym prawdopodobieństwa przedstawione w podrozdziale 6.7. 9. Podczas procesu produkcji żarówek prawdopodobieństwo wytworzenia wadliwej żarówki wynosi 0,01. Zasymuluj proces produkcji 5000 żarówek i podaj, ile z nich było wadliwych. 10. Kostka do gry została zmodyfikowana w taki sposób, że jedynka i piątka wypadają dwa razy częściej niż pozostałe liczby. Zasymuluj proces 1000 rzutów taką kostką i podaj, ile razy wyrzucono każdą z liczb. 11. Zmodyfikuj program P6.6 w taki sposób, by liczył średni czas oczekiwania klientów oraz łączny czas bezczynności poszczególnych stanowisk. 12. Z ero-jedenj est grą, którą może rozgrywać kilka osób przy użyciu zwyczajnej, sześciennej kostki do gry. Podczas swojego ruchu każdy z graczy może rzucić kostką dowolną liczbę razy. Wynik gracza w danym ruchu jest sumą wyrzuconych liczb, p o d warunkiem ż e g racz nie wyrzucił 1. Jeśli gracz wyrzucił 1, otrzymuje 0 punktów. Załóżmy, że gracz zdecydował się zastosować strategię polegającą na kończeniu kolejki po siedmiu rzutach. (Oczywiście, jeśli przy siódmym rzucie wyrzuci 1, będzie musiał zakończyć). Napisz program, który rozegra 10 kolejek tej gry, posługując się opisaną strategią. Wyświetl wyniki uzyskane podczas każdej kolejki. Dodatkowo wyświetl średni wynik wszystkich 10 kolejek. Uogólnij program w taki sposób, by prosił o podanie wartości numTurns (liczby kolejek) oraz maxThrowsPerTurn (maksymalnej liczby rzutów w ramach jednej kolejki) i wyświetlał podane wcześniej wartości. 13. Napisz program symulujący grę w W ęże i drabiny. Plansza składa się ze 100 kwadratów. Węże i drabiny są podawane w formie uporządkowanych par liczb, mi n Przykładowo para 17 64 oznacza, że na planszy istnieje drabina prowadząca z pola 17 do pola 64, a para 99 28, że na planszy istnieje wąż prowadzący z pola 99 na pole 28. Zasymuluj 20 rozgrywek, z których każda będzie składała się z nie więcej niż 100 ruchów. Wyświetl liczbę gier, które udało się zakończyć w 100 ruchach, oraz średnią liczbę ruchów w zakończonych grach. 14. Napisz program umożliwiający grę w zmodyfikowaną wersję gry Nim (podrozdział 6.6), w którym używane są dwie kupki pionków, a gracz w swoim ruchu może zdecydować, z której z nich zdejmie pionki. Jednak w tym przypadku gracz, który zdejmie ostatni pionek ze stołu, wygrywa. 15. Korzystając z danych o sygnalizacji świetlnej przedstawionych w podrozdziale 6.8, napisz program, który zasymuluje stan skrzyżowania w czasie 30 minut. Wyświetl liczbę samochodów oczekujących w kolejce w każdym z kierunków, podczas każdej zmiany świateł. 16. Napisz program szacujący wartość pierwiastka kwadratowego z liczby 59. 17. Napisz program, który wczyta dodatnią liczbę całkowitą n i oszacuje wartość pierwiastka kwadratowego z tej liczby.
196
ROZDZIAŁ 6. ■ LICZBY LOSOWE, GRY I SYMULACJE
18. Napisz program, który wczyta dodatnią liczbę całkowitą n i oszacuje wartość pierwiastka trzeciego stopnia z tej liczby. 19. Napisz program, który zasymuluje zbieranie kapsli z butelek aż do momentu, w którym będziemy w stanie ułożyć słowo APPLE. Na każde 100 kapsli wypada 40 kapsli z literami A i E, 10 kapsli z literą P oraz 10 z literą L. Przeprowadź 50 symulacji i wyświetl średnią liczbę kapsli uzyskaną w ramach wszystkich symulacji. 20. Loteria wymaga od graczy wylosowania siedmiu liczb z zakresu od 1 do 40. Napisz program, który będzie losowo generował i wyświetlał pięć zestawów po siedem liczb (przy czym każdy zestaw ma być wyświetlony w osobnym wierszu). Żadna z wylosowanych liczb nie może się powtarzać; oznacza to, że należy użyć dokładnie 35 z 40 dostępnych liczb. Jeśli wygenerowana liczba (powiedzmy p ) została już użyta wcześniej, należy użyć pierwszej liczby większej od p. (Zakładamy przy tym, że kolejną liczbą za 40 jest 1). Jeśli np. została wygenerowana liczba 15, lecz okazało się, że użyto jej już wcześniej, to należy spróbować użyć liczby 16; jeśli także ona została użyta, to należy użyć liczby 17 itd., aż do momentu odszukania liczby, która jeszcze nie została użyta. 21. Funkcja f x została zdefiniowana dla x z zakresu 0 x 1 i spełnia warunek, że dla wszystkich x z tego zakresu 0 f X 1 . Napisz program szacujący wartość całki fX w zakresie od 0 do 1. Podpowiedź: oszacuj pole pod krzywą, generując punkty (xy), takie że 0 x 1 i 0 y 1. 22. Hazardzista płaci 5 złotych za następującą grę. Rzuca dwiema zwyczajnymi kośćmi do gry. Jeśli suma wyrzuconych liczb jest parzysta, przegrywa postawione pieniądze. Jeśli suma jest nieparzysta, wyciąga kartę ze standardowej talii. Jeśli wyciągnie asa, 3, 5, 7 lub 9, zostaje mu wypłacona kwota stanowiąca odpowiednik wartości karty powiększona o 5 złotych (przy czym as jest liczony za 1). W przypadku wyciągnięcia jakiejkolwiek innej karty hazardzista przegrywa. Napisz program symulujący 20 rozgrywek takiej gry i wyświetl średnią kwotę wygrywaną przez hazardzistę w każdej grze.
197
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
198
ROZDZIAŁ
7
Praca z plikami W tym rozdziale wyjaśnimy takie zagadnienia jak: • różnice pomiędzy plikami tekstowymi i binarnymi, • różnice pomiędzy zewnętrznymi i wewnętrznymi nazwami plików, • implementacja programu porównującego dwa pliki tekstowe, • postać, działanie i zastosowania konstrukcji try...catch, • wykonywanie operacji wejścia-wyjścia z wykorzystaniem plików binarnych, • sposoby korzystania z plików binarnych zawierających rekordy, • pliki o dostępie swobodnym, • tworzenie plików o dostępie swobodnym i odczytywanie z nich rekordów, • pliki indeksowane, • sposoby aktualizacji plików o dostępie swobodnym przy użyciu indeksu.
7.1. Operacje wejścia-wyjścia w Javie Język Java udostępnia wyczerpujący zestaw klas służących do wykonywania operacji wejścia-wyjścia. W tej książce używaliśmy już pól System.in oraz System.out odpowiednio do wczytywania danych ze standardowego wejścia i wyświetlania ich na standardowym wyjściu. Przykładowo poniższej instrukcji używaliśmy do wczytywania danych z pliku input.txt: Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); Przy użyciu następującej instrukcji zapisywaliśmy dane wyjściowe w pliku output.txt: PrintW riter out = new PrintWriter(new F ile W rite r ("o u tp u t.tx t")); Do tej pory pracowaliśmy na p lik a ch tekstowych (czyli plikach, których zawartością były znaki). W tym rozdziale poznamy także sposoby korzystania z plików binarnych.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
7.2. Pliki tekstowe i binarne Plik tekstowy jest sekwencją znaków zorganizowanych w formie wierszy. Pod względem pojęciowym wyobrażamy sobie, że każdy z tych wierszy jest zakończony znakiem nowego wiersza. Jednak, zależnie od używanego środowiska, niektóre znaki mogą być przekształcane na inne. Jeśli np. zapiszemy w pliku znak nowego wiersza \n, zostanie on przekształcony na dwa znaki, powrotu karetki (ang. carriage return) oraz przesunięcia wiersza (ang. lin e-feed character). Dlatego też znaki zapisywane w pliku oraz te, które faktycznie zostaną zapisane na urządzeniu zewnętrznym, nie muszą sobie odpowiadać jeden do jednego. I analogicznie, taka relacja nie musi występować pomiędzy liczbą znaków zapisanych w pliku oraz liczbą znaków odczytanych przez program. Plik binarny jest prostą sekwencją bajtów, które są zapisywane i odczytywane bez wykonywania jakichkolwiek konwersji. W ich przypadku pomiędzy znakami zapisywanymi i odczytywanymi przez program oraz tymi, które są umieszczone w pliku zewnętrznym, istnieje odwzorowanie jeden do jednego. Oprócz możliwych przekształceń znaków, istnieją także inne różnice pomiędzy plikami tekstowymi i binarnymi. Przykładowo liczba całkowita typu short zajmuje dwa bajty (16 bitów) pamięci, liczba 3371 jest zapisywana jako 00001101 00101011. Gdybyśmy mieli zapisać tę liczbę w pliku tekstowym, zostałaby zapisana jako znak 3, następnie kolejny znak 3, za nim znak 7 i w końcu znak 1; w sumie zajęłaby w pliku 4 bajty. Z drugiej strony, ta sama liczba zapisana w pliku binarnym zajęłaby jedynie dwa bajty. Choć wciąż moglibyśmy wyobrażać ją sobie jako sekwencję dwóch „znaków”, jednak do przedstawienia ich wartości nie można by użyć jakichkolwiek prawidłowych znaków. Okazuje się, że w przypadku tej liczby dziesiątkowymi wartościami obu bajtów są odpowiednio: 13 i 43, które, wyrażone w formie znaków kodu ASCII, stałyby się znakami powrotu karetki oraz znakiem „+”. Różnice pomiędzy plikami tekstowymi oraz binarnymi można wyobrazić sobie także w inny sposób. Otóż, ogólnie rzecz biorąc, pliki tekstowe zawierają znaki, które są czytelne dla człowieka, natomiast pliki binarne — całkowicie dowolne sekwencje bitów. Pliki binarne mają ogromne znaczenie w przypadkach bezpośredniego zapisywania na urządzeniach zewnętrznych (takich jak dysk twardy) danych, w ich wewnętrznej postaci. Standardowe wejście i wyjście są uważane za pliki tekstowe. Plik dyskowy może zostać utworzony jako plik tekstowy lub plik binarny. W tym rozdziale pokazano, jak to zrobić.
7.3. Wewnętrzne i zewnętrzne nazwy plików Standardowym sposobem użytkowania komputerów jest posługiwanie się możliwościami, jakie dają ich systemy operacyjne. Zazwyczaj tworzymy i edytujemy pliki, używając procesorów tekstu lub edytorów plików tekstowych. Gdy tworzymy plik, nadajemy mu nazwę, której używamy zawsze wtedy, kiedy chcemy coś z tym plikiem zrobić. Jest to nazwa rozpoznawalna i stosowana w systemie operacyjnym. Takie nazwy to zewnętrzne nazwy plików. (W tym kontekście słowo „zewnętrzny” należy rozumieć jako „zewnętrzny względem programu napisanego w języku Java”). Może się zdarzyć, że pisząc program, będziemy chcieli określić np. sposób odczytu danych z pliku. Program może używać pewnej nazwy pliku, jednak z kilku powodów nie powinna ona być nazwą zewnętrzną. Poniżej przedstawiono kilka podstawowych przyczyn takiego stanu rzeczy. • Plik, z którego mają być odczytywane dane, nie został jeszcze utworzony. • Jeśli nazwa zewnętrzna została skojarzona z programem, program będzie w stanie odczytywać dane wyłącznie z pliku o podanej nazwie. Jeśli dane zostaną zapisane w innym pliku, konieczna będzie zmiana programu lub zmiana nazwy pliku z danymi. • Przenoszenie programu będzie trudniejsze, gdyż inne systemy operacyjne mogą stosować inne konwersje określania nazw plików. Zewnętrzna nazwa pliku prawidłowa w jednym systemie operacyjnym przez inny system może zostać uznana za niedozwoloną. • Z tych powodów w programach pisanych w języku Java używa się wewnętrznych nazw plików — przeważnie stosowaliśmy nazwy in do określenia danych wejściowych oraz nazwy out do określenia danych wyjściowych. Przykładowo korzystając z poniższej instrukcji, skojarzyliśmy nazwę wewnętrzną in z plikiem zewnętrznym o nazwie input.txt: Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t"));
200
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
To jest jedyna instrukcja programu, w której została podana nazwa pliku zewnętrznego. W e wszystkich pozostałych miejscach kodu używana jest nazwa wewnętrzna — in. Oczywiście, można sobie wyobrazić jeszcze bardziej elastyczne rozwiązanie, np. takie: Scanner in = new Scanner(new FileReader(fileN am e)); Program po uruchomieniu może prosić o podanie nazwy pliku, czyli fileName, przy użyciu poniższego fragmentu kodu: System .ou t.printf("P odaj nazwę pliku: " ) ; String fileName = kb .nextL ine(); //Scanner kb = new Scanner(System.in); W tym przykładzie pokazano także, w jaki sposób w jednym programie można odczytywać dane wprowadzane przez użytkownika z klawiatury (czyli standardowego wejścia) oraz z pliku. Przykładowo wywołanie k b .n e x tIn t() wczyta liczbę całkowitą z klawiatury, a wywołanie in .n e x tIn t() wczyta liczbę z pliku input.txt.
7.4. Przykład: porównywanie dwóch plików Rozważmy problem porównywania dwóch plików. Porównywanie jest realizowane wiersz po wierszu, aż do momentu znalezienia różnicy bądź dotarcia do końca plików. Rozwiązanie tego problemu przedstawiono w programie P7.1. Program P7.1 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CompareFiles { public s t a t i c void m ain(String[] args) throws IOException { Scanner kb = new Scanner(System .in); System .ou t.printf("P odaj nazwę pierwszego pliku: " ) ; String f i l e l = kb.nextL ine(); System .ou t.printf("P odaj nazwę drugiego pliku: " ) ; String f i le 2 = kb.nextL ine(); Scanner f l = new Scanner(new F ile R e a d e r (file l)); Scanner f2 = new Scanner(new F ile R e a d e r(file 2 )); String lin e l = " " , lin e2 = " " ; int numMatch = 0; while (f1.hasN extLine() && f2.hasN extLine()) { lin e l = f1 .n e x tL in e (); lin e2 = f2 .n e x tL in e (); i f (!lin e l.e q u a ls (lin e 2 )) break; ++numMatch; } i f (!fl.h asN ex tL in e() && !f2.hasN extLine()) System .out.printf("\nOba p lik i są identyczne.\n"); e lse i f (!fl.h asN e x tL in e ()) //pierwszy plik skończył się, a drugi nie System.out.printf("\n%d wierszy pliku %s, je s t fragmentem pliku %s.\n", numMatch, f i l e l , f i l e 2 ) ; e lse i f (!f2.hasN extL ine()) //drugiplik skończył się, apierwszy nie System.out.printf("\n%d wierszy pliku %s, je s t fragmentem pliku %s.\n", numMatch, f i l e 2 , f i l e l ) ; e lse { //znaleziono niezgodność
201
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
System .ou t.p rin tf("\ nP lik i różnią s ię w wierszu %d.\n", ++numMatch); System.out.printf("Porównywane wiersze to \n%s\n oraz \n%s\n", l in e l , lin e 2 ); } f 1 .c l o s e ( ) ; f 2 .c l o s e ( ) ; } //koniec main } //koniec klasy CompareFiles Powyższy program działa w następujący sposób. • Prosi o podanie nazw plików, których zawartość ma zostać porównana; jeśli którykolwiek z tych plików nie istnieje, zostanie zgłoszony wyjątek FileNotFoundExcepti on. • Tworzy dwa obiekty Scanner, f l i f2, po jednym dla każdego z porównywanych plików. • Używa metody hasNextLi ne, by sprawdzać, czy wiersz zawiera jeszcze jakieś wiersze do odczytania; jeśli metoda ta zwróci wartość true, w pliku znajduje się jeszcze co najm niej jeden wiersz, jeśli natomiast zwróci fal se, oznacza to, że cała zawartość pliku została już odczytana. • Zmienna numMatch przechowuje liczbę pasujących do siebie wierszy. Z każdego pliku program odczytuje po jedynym wierszu tekstu. Jeśli są takie same, wartość zmiennej numMatch jest powiększana o 1, a następnie program odczytuje kolejną parę wierszy. Pętla while zostaje zakończona w naturalny sposób, jeśli któryś z plików (lub oba) zostanie odczytany w całości; jeśli odczytane wiersze nie będą identyczne, działanie pętli jest przerywane instrukcją break. Jeśli zawartość pierwszego pliku będzie mieć następującą postać: jeden i jeden to dwa dwa i dwa to cztery trzy i trzy to sześć cztery i cztery to osiem pięć i pięć to d ziesięć sześć i sześć to dwanaście a drugi będzie zawierał poniższy tekst: jeden i jeden to dwa dwa i dwa to cztery trzy i trzy to sześć cztery i cztery to osiem to je s t piąty wiersz pliku sześć i sześć to dwanaście to program P7.1 wygeneruje następujące wyniki. P liki różnią s ię w wierszu 5. Porównywane wiersze to pięć i pięć to d ziesięć oraz to je s t piąty wiersz pliku
7.5. Konstrukcja try...catch Podczas prób odczytu danych z pliku mogą pojawić się błędy. Mogą one być związane z urządzeniem, z próbą odczytu danych poza końcem pliku albo z próbą odczytania danych z pliku, który nie istnieje. Podobnie, podczas prób zapisu danych w pliku może się okazać, że urządzenie jest zablokowane lub niedostępne bądź nie dysponujemy niezbędnymi uprawnieniami. W takich przypadkach język Java zgłasza w yjątek wejścia-wyjścia.
202
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
Zawsze wtedy, gdy istnieje szansa, że metoda doprowadzi do wystąpienia błędu wejścia-wyjścia, bądź to samodzielnie przeprowadzając jakąś operację wejścia-wyjścia, bądź też wywołując inną metodę, która to zrobi, język Java wymaga, by nasza metoda to zadeklarowała. Jednym ze sposobów, by to zrobić, jest dodanie do nagłówka metody fragmentu throws IOException, jak zrobiono w poniższym przykładzie: public s t a t i c void m ain(String[] args) throws IOException { Kolejnym sposobem obsługi błędów wejścia-wyjścia jest zastosowanie konstrukcji t r y ...c a t c h . Załóżmy, że program zawiera następującą instrukcję: Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); Jeśli po uruchomieniu program nie będzie mógł odszukać pliku o nazwie input.txt, zostanie zatrzymany z komunikatem błędu informującym o tym, że „nie udało się odnaleźć pliku”. Takiej sytuacji możemy uniknąć, używając kodu w następującej postaci: try { Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); } catch (IOException e) { System .out.printf("% s\n", e ); System .out.printf("Rozw iąż problem i spróbuj je szcze raz.\ n "); S y ste m .e x it(1); } Blok try zawiera słowo kluczowe try oraz instrukcję blokową (dowolną liczbę instrukcji zapisanych wewnątrz pary nawiasów klamrowych). Język Java spróbuje wykonać instrukcje umieszczone wewnątrz bloku. Blok catch zawiera słowo kluczowe catch, po którym w nawiasach zostanie określony „typ wyjątku”, a za nim kolejna instrukcja blokowa. W powyższym przykładzie przypuszczamy, że może zostać zgłoszony wyjątek wejścia-wyjścia, dlatego też wewnątrz nawiasów umieszczonych za catch podaliśmy IOExcept ion e. Jeśli faktycznie zostanie zgłoszony wyjątek, wykonane będą instrukcje umieszczone w bloku catch. Załóżmy, że podczas wykonywania powyższego fragmentu kodu plik input. txt istnieje. W takim przypadku instrukcja Scanner i n... zostanie prawidłowo wykonana, a program przejdzie do realizacji następnej instrukcji umieszczonej za blo kiem catch. Jeśli jednak okaże się, że plik n ie istnieje, zostanie zgłoszony wyjątek, który blok catch przechwyci i obsłuży. Kiedy to się stanie, zostaną wykonane wszelkie instrukcje umieszczone w bloku catch (jeśli takie w ogóle istnieją). Język Java pozwala na umieszczanie w bloku catch dowolnych instrukcji. W powyższym przykładzie zdecydowaliśmy się wyświetlać zawartość obiektu wyjątku e oraz komunikat, po czym realizacja programu zostanie zakończona. Jeśli uruchomimy taki program w sytuacji, gdy plik input.txt nie jest dostępny, wyświetli on następujące komunikaty: java.io.FileN otFoundException: in p u t.tx t Rozwiąż problem i spróbuj je szcze raz. Nie oznacza to wcale, że po wystąpieniu wyjątku działanie programu m usi się kończyć. Gdybyśmy pom inęli wywołanie metody e x it, program kontynuowałby działanie, zaczynając do pierwszej instrukcji umieszczonej za blokiem catch. W róćm y do poprzedniego przykładu i przeanalizujmy następujący fragment kodu: try { Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); n = in .n e x t In t (); } Próbujemy odczytać następną liczbę całkowitą z pliku. Przy tej okazji wiele rzeczy może pójść nie tak jak powinno: plik może nie istnieć, następny element w pliku może nie być prawidłową liczbą całkowitą bądź też w ogóle może nie być „następnego” elementu pliku. Zdarzenia te spowodują odpowiednio zgłoszenie wyjątków: „plik nie został znaleziony”, „niepasujące dane wejściowe” oraz „brak takiego elementu”. Ponieważ wszystkie te typy wyjątków są klasami pochodnymi klasy Exception, zatem możemy je przechwycić i obsłużyć za pomocą następującego fragmentu kodu.
203
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
catch (Exception e) { System .out.printf("% s\n", e ); System .out.printf("Rozw iąż problem i spróbuj je szcze raz.\ n "); S y s te m .e x it(l); } Jeśli plik będzie pusty, wykonanie powyższego fragmentu kodu spowoduje wyświetlenie następującego komunikatu: java.util.NoSuchElementExcepti on Rozwiąż problem i spróbuj je szcze raz. Jeśli natomiast plik będzie zawierał liczbę 5.7, komunikat będzie miał następującą postać: java.util.InputM ism atchExcepti on Rozwiąż problem i spróbuj je szcze raz. Jeśli trzeba, język Java pozwala także przechwytywać i obsługiwać konkretne typy wyjątków. W instrukcji try . ..c a tc h można umieścić dowolną liczbę bloków catch. W naszym przypadku moglibyśmy zapisać tę instrukcję w poniższy sposób. try { Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); n = in .n e x tIn t( ); } catch (FileNotFoundException e) { //obsługa wyjątku związanego z brakiem pliku } catch (InputMismatchException e) { //obsługa wyjątku związanego z odczytaniem nieprawidłowej liczby całkowitej } catch (NoSuchElementException e) { //obsługa wyjątku związanego z nieoczekiwanym przekroczeniem końca pliku } Czasami duże znaczenie ma kolejność, w jakiej zostaną zapisane poszczególne klauzule catch. Załóżmy, że wyjątek związany z brakiem pliku chcielibyśmy obsługiwać w inny sposób niż wszystkie pozostałe. Możemy spróbować zrobić to w poniższy sposób. try { Scanner in = new Scanner(new F ile R e a d e r("in p u t.tx t")); n = in .n e x t In t (); } catch (Exception e) { //obsługa wszystkich wyjątków (pewnie oprócz tego, który jest związany z brakiem pliku) } catch (FileNotFoundException e) { //obsługa wyjątku związanego z brakiem pliku } Jednak takiego kodu nawet nie uda się skompilować! Kiedy język Java dotrze do ostatniej klauzuli catch, wyświetli komunikat z informacją, że wyjątek FileNotFoundExcepti on już został przechwycony. Wynika to z faktu, że FileNotFoundException jest klasą pochodną klasy Excepti on. Aby rozwiązać ten problem, wystarczy przenieść klauzulę catch (FileNotFoundException e) p rz ed klauzulę catch (Exception e). Ogólnie rzecz ujmując, wyjątki klas pochodnych muszą być przechwytywane przed wyjątkami klas bazowych.
204
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
7.6. Operacje wejścia-wyjścia na plikach binarnych Jak już wspomniano, pliki binarne zawierają dane w postaci, która dokładnie odpowiada ich wewnętrznej reprezentacji w pamięci komputera. Jeśli np. zmienna fl oat zajmuje cztery bajty pamięci, zapisanie jej w pliku sprowadza się do zrobienia dokładnej kopii tych czterech bajtów. Z drugiej strony, zapis wartości tej samej zmiennej w pliku tekstowym spowoduje, że zostanie ona przekształcona do postaci znaków i to te znaki zostaną zapisane w pliku. Normalnie pliki binarne mogą być tworzone wyłącznie przez programy i tylko programy są w stanie odczytywać ich zawartość. Przykładowo wyświetlenie zawartości pliku binarnego pokaże jedynie „śmieci”, a czasami może nawet doprowadzić do wystąpienia błędu. W arto to porównać z plikami tekstowymi, które możemy utworzyć, wpisując jakiś tekst, i których zawartość możemy bez trudu wyświetlić i zrozumieć. Pomimo to, pliki binarne mają pewne zalety. • Dane mogą być zapisywane i odczytywane z plików binarnych znacznie szybciej niż z tekstowych, gdyż podczas tych operacji nie trzeba wykonywać żadnych konwersji; dane są zapisywane i odczytywane w niezmienionej postaci. • W plikach tekstowych można zapisywać i odczytywać wartości takich typów jak tablice oraz struktury. W przypadku plików tekstowych konieczne jest zapisywanie poszczególnych elementów. • Dane zapisywane w plikach binarnych zazwyczaj zajmują mniej miejsca niż te same dane zapisane w plikach tekstowych. Przykładowo liczba całkowita -25367 (składająca się z sześciu znaków) w pliku tekstowym zajmuje 6 bajtów, a w pliku binarnym tylko 2 bajty.
7.6.1. Klasy DataOutputStream oraz DatalnputStream Rozważmy problem odczytu liczb całkowitych z pliku o nazwie num .txt i zapisywania tych liczb w ich naturalnej postaci (binarnej) w pliku num .bin. Zakładamy przy tym, że liczby zapisane w pliku tekstowym są zakończone cyfrą 0, a cyfra ta nie ma być zapisywana w pliku binarnym. Zadanie to realizuje program P7.2. Program P7.2 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CreateBinaryFile { public s t a tic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new FileR eader("nu m .txt")); DataOutputStream out = new DataOutputStream(new FileOutputStream("num.bin")); int n = in .n e x tIn t(); while (n != 0) { o u t.w rite ln t(n ); n = in .n e x tIn t( ); } o u t.c lo s e (); in .c lo s e (); } //koniec main } //koniec klasy CreateBinaryFile Załóżmy, że plik num .txt zawiera następujące liczby: 25 18 47 96 73 89 82 13 39 0 Po uruchomieniu programu P7.2 liczby zapisane w pliku num .txt (z wyjątkiem zera) zostaną zapisane w swojej wewnętrznej postaci, w pliku num .bin. Nową instrukcją zastosowaną w programie P7.2 jest: DataOutputStream out = new DataOutputStream(new FileOutputStream("num.bin"));
205
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Wyjściowy strumień danych pozwala programowi zapisywać w pliku podstawowe typy danych języka Java. Wyjściowy strumień do pliku jest strumieniem służącym do zapisywania danych wyjściowych w pliku. Poniższe wywołanie konstruktora tworzy strumień wyjściowy, skojarzony z plikiem num .bin: new FileOutputStream("num.bin") Poniżej przedstawiono kilka wybranych metod klasy DataOutputStream. Wszystkie te metody operują na określonym strumieniu wyjściowym, a wszystkie wartości zostają zapisane od wysokiego bajta (czyli od bajta najbardziej znaczącego do najmniej znaczącego). void void void void void void void
w rite In t(in t v) writeDouble(double v) w riteChar(int v) w riteChars(String s) w rite F lo a t(flo a t v) writeLong(long v) w rite (in t b)
//zapisuje wartość typu int //zapisuje wartość typu double //zapisuje znak jako wartość dwubajtową //zapisuje łańcuch jako sekwencję znaków //zapisuje wartość typu float //zapisuje wartość typu long //zapisuje dolny bajt wartości d
Zastosowane w programie P7.2 wywołanie o u t.w riteIn t(n ) zapisuje wartość n w pliku num .bin. Jeśli spróbujemy obejrzeć jego zawartość, nie zobaczymy w niej nic sensownego. Przyjrzyjmy się teraz programowi P7.3, który odczytuje liczby z pliku num .bin i wyświetla je. Program P7.3 import ja v a .i o .* ; public c la ss ReadBinaryFile { public s t a t ic void m ain(String[] args) throws IOException { DataInputStream in = new DataInputStream(new FileInputStream ("num .bin")); int amt = 0; try { while (true) { int n = in .r e a d In t(); System .out.printf("% d " , n); ++amt; } } catch (IOException e) { } System.out.printf("\n\nOdczytano %d lic z b .\ n ", amt); } //koniec main } //koniec klasy ReadBinaryFile Jeśli przyjmiemy, że plik num .bin zawiera wyniki zapisane w pliku przez program P7.2, program P7.3 wygeneruje następujące dane. 25 l8 47 96 73 89 82 l3 39 Odczytano 9 lic z b . Nową instrukcją zastosowaną w programie P7.3 jest: DatalnputStream in = new DataInputStream(new FileInputStream ("num .bin")); W ejściowy strumień danych pozwala programom wczytywać wartości podstawowych typów danych z plików utworzonych jako wyjściowe strumienie danych. Plikowy strumień wejściowy jest strumieniem wejściowym służącym do wczytywania danych z plików. Poniższe wywołanie tworzy strumień wejściowy skojarzony z plikiem num .bin: new FileInputStream("num.bin")
206
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
Poniżej przedstawiono kilka wybranych metod klasy DataInputStream. int read In t() double readDouble() char readChar() void readFully(byte[] b) flo a t readFloat() long readLong() int skipBytes(int n)
//wczytuje 4 bajty i zwraca wartość typu int //wczytuje 8 bajtów i zwraca wartość typu double //odczytuje znak w formie wartości dwubajtowej //odczytuje bajty i zapisuje je w tablicy b, aż do momentu //jej wyczerpania //wczytuje 4 bajty i zwraca wartość typufloat //wczytuje 8 bajtów i zwraca wartość typu long //próbuje pominąć n bajtów strumienia; //zwraca liczbę bajtów, które faktycznie udało się pominąć
Ogólnie rzecz biorąc, przedstawione metody służą do odczytu danych zapisanych w pliku przez odpowiadające im metody strumienia typu DataOutputStream. W arto zwrócić uwagę na zastosowanie instrukcji t r y ...c a t c h , której używamy, by odczytywać dane z pliku wejściowego, aż do momentu dotarcia do jego końca. W iem y już, że w tym pliku nie zapisywaliśmy żadnego znacznika „końca danych”, dlatego nie możemy sprawdzać jego występowania. Pętla while będzie odczytywać dane w nieskończoność. Kiedy program dotrze do końca pliku, zostanie zgłoszony wyjątek EOFException. Jest to typ pochodny klasy IOException, dlatego klauzula catch go przechwyci. Blok catch jest pusty, po przechwyceniu wyjątku nic się nie stanie. Sterowanie zostanie przekazane do następnej instrukcji, która wyświetli liczbę odczytanych danych.
7.6.2. Binarne pliki z rekordami W poprzednim punkcie rozdziału utworzyliśmy plik binarny zawierający liczby całkowite i odczytaliśmy jego zawartość. Teraz dowiemy się, jak można korzystać z plików binarnych zawierających rekordy, przy czym każdy z tych rekordów może się składać z dwóch lub większej liczby pól. Załóżmy, że chcemy przechowywać informacje dotyczące części samochodowych. Na razie przyjmujemy, że każda z tych części składa się z dwóch pól: numeru części będącego liczbą typu int oraz ceny typu double. Załóżmy także, że dysponujemy plikiem tekstowym parts. txt, zawierającym dane części zapisane w następującym formacie: 4250 3000 6699 2270 0
12,95 17,50 49,99 19,25
Program P7.4 odczytuje te dane i zapisuje je w pliku binarnym o nazwie parts.bin. Program P7.4 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CreateBinaryFile1 { public s t a t i c void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("p a rts .tx t")); DataOutputStream out = new DataOutputStream(new FileO u tputStream ("parts.bin ")); int n = in .n e x tIn t(); while (n != 0) { o u t.w rite In t(n ); out.w riteD ouble(in.nextD ouble()); n = in .n e x tIn t( ); } in .c lo s e ( ) ; o u t.c lo s e (); } //koniec main } //koniec klasy CreateBinaryFile1
207
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Każdy rekord zapisany w pliku parts.bin składa się dokładnie z 12 bajtów (4 zajmuje liczba typu int, a następne 8 liczba typu doubl e). W przedstawionym przykładzie są cztery takie rekordy, zatem nasz plik binarny będzie miał dokładnie 48 bajtów długości. Wiemy, że pierwszy rekord zaczyna się od bajta o numerze 0, drugi od bajta o numerze 12, trzeci od bajta o numerze 24, a czwarty od bajta o numerze 36. Kolejny rekord rozpocznie się od bajta o numerze 48. W takim przypadku bardzo łatwo można obliczyć, gdzie rozpocznie się n-ty rekord; będzie to bajt o numerze (n -1)*12. Aby przygotować się do zagadnień opisywanych dalej w tym rozdziale, przepiszemy teraz program P7.4, wykorzystując w nim przedstawioną poniżej klasę Part. class Part { int partNum; double p rice; public P a rt(in t pn, double pr) { partNum = pn; p rice = pr; } public void p rin tP art() { System.out.printf("\nNumer c z ę ś c i: %s\n", partNum); System .out.printf("C ena: $%3.2f\n", p ric e ); } } //koniec klasy Part Program P7.5 odczytuje dane z pliku p arts.txt i na ich podstawie tworzy binarny plik parts.bin. Program P7.5 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CreateBinaryFile2 { s t a tic fin al in t EndOfData = 0; public s t a t ic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("p a rts .tx t")); DataOutputStream fp = new DataOutputStream(new FileO u tputStream ("parts.bin ")); Part part = getP artD ata(in); while (part != null) { w riteP artT oF ile(p art, fp ); part = getP artD ata(in); } i n .c lo s e () ; f p .c l o s e ( ) ; } //koniec main public s t a t ic Part getPartData(Scanner in) { int pnum = in .n e x tIn t(); i f (pnum == EndOfData) return n u ll; return new Part(pnum, in.nextD ouble()); } public s t a t i c void w ritePartToFile(Part p art, DataOutputStream f) throws IOException { f.w riteInt(part.partN um ); f.w riteD ou ble(part.pri c e );
208
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
p a r t.p r in tP a r t(); //wyświetlenie danych w standardowym strumieniu wyjściowym } //koniec writePartToFile } //koniec klasy CreateBinaryFile2 //tu należy umieścić kod klasy Part Program P7.5 po uruchomieniu wyświetli następujące wyniki. Numer c z ę ś c i: 4250 Cena: 12,95 zł Numer c z ę ś c i: 3000 Cena: 17,50 zł Numer c z ę ś c i: 6699 Cena: 49,99 zł Numer c z ę ś c i: 2270 Cena: 19,25 zł Po wygenerowaniu binarnego pliku części następny rekord Part możemy z niego odczytać, używając następującej funkcji: public s t a t i c Part readPartFromFile(DataInputStream in) throws IOException { return new P a rt(in .re a d In t(), in.readD ouble()); } //koniec readPartFromFile Zakładamy przy tym, że strumień wejściowy został utworzony za pomocą poniższej instrukcji: DataInputStream in = new DataInputStream(new F ileIn p u tS tream ("p arts.b in "));
7.7. Pliki o dostępie swobodnym W normalnym trybie działania rekordy są odczytywane z pliku w takiej kolejności, w jakiej były w nim zapisywane. Możemy sobie wyobrazić, że w momencie otwierania pliku na jego początku jest umieszczany umowny wskaźnik. Podczas odczytu danych z pliku wskaźnik jest przesuwany o ilość odczytanych bajtów. W dowolnym momencie wskaźnik ten określa miejsce pliku, w którym zostanie wykonana następna operacja odczytu (bądź zapisu). Zazwyczaj wskaźnik jest przesuwany niejawnie wskutek wykonywanych operacji odczytu i zapisu. Jednak język Java udostępnia także m ożliwości pozwalające na jaw ne przesunięcie go w dowolne m iejsce pliku. Jest to niezwykle przydatne wtedy, kiedy zależy nam na odczytywaniu danych w losowej kolejności, a nie w porządku sekwencyjnym. W ramach przykładu rozważmy utworzony wcześniej plik z rekordami części. Każdy rekord zajmuje 12 bajtów. Jeśli pierwszy rekord zaczyna się od zerowego bajta o numerze 0, początek n-tego przypada na bajt o numerze 12(n-1). Załóżmy, że chcemy odczytać dziesiąty rekord bez konieczności wcześniejszego odczytywania pierwszych dziewięciu. W takim przypadku możemy wyliczyć, że dziesiąty rekord rozpoczyna się od bajta numer 108. Jeśli umieścimy wskaźnik pliku na tym bajcie, będziemy mogli odczytać potrzebny rekord. W języku Java metody służące do operowania na plikach o dostępie swobodnym zostały zdefiniowane w klasie RandomAccessFile. Poniższa instrukcja stwierdza, że parts.bin będzie traktowany jak plik o dostępie swobodnym; rw określa tryb dostępu do pliku — „read/write” (zapis i odczyt) — co oznacza, że program będzie mógł odczytywać dane z pliku, j a k rów nież je w nim zapisywać. RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw "); Jeśli chcemy otworzyć plik tylko w trybie do odczytu, zamiast rw należy użyć r. Początkowo wskaźnik pliku przyjmuje wartość 0, co oznacza, że jest umieszczony na bajcie o numerze 0.
209
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Podczas odczytywania danych z pliku oraz ich zapisywania wartość tego wskaźnika jest modyfikowana. W naszym wcześniejszym przykładzie z plikiem części po odczytaniu (lub zapisaniu) pierwszego rekordu wartość wskaźnika wyniesie 12. Po odczytaniu (lub zapisaniu) piątego rekordu wskaźnik będzie miał wartość 60. Trzeba jednak zwrócić uwagę, że piąty rekord zaczyna się do bajta o numerze 48. W dowolnym momencie wywołanie fp .g e tF ile P o in te r() pozwala odczytać wartość wskaźnika pliku. Wskaźnik ten możemy przesunąć w dowolne miejsce pliku, wywołując metodę seek. Poniższa instrukcja przesuwa wskaźnik pliku na bajt o numerze n. fp .se e k (n ); //n jest liczbą całkowitą; może to być dowolna wartość typu long Przykładowo poniższy fragment kodu pozwala wczytać dziesiąty rekord z pliku do zmiennej typu Part. fp .se e k (1 0 8 ); //dziesiąty rekord zaczyna się od 108. bajta Part part = new P a rt(fp .r e a d In t(), fp.readD ouble()); Ogólnie rzecz biorąc, n-ty rekord można odczytać w następujący sposób: fp .seek ((n - 1) * 12); //n-ty rekord zaczyna się od bajta (n - 1) * 12 Part part = new P a rt(fp .r e a d In t(), fp.readD ouble()); Trzeba pamiętać, że zazwyczaj literał 12 należałoby zastąpić jakąś stałą symboliczną, taką jak PartRecordSize. Rozbudujemy teraz nasz wcześniejszy przykład z częściami, używając w nim rekordów w nieco bardziej realistycznej postaci. Załóżmy, że aktualnie każda część składa się z czterech pól: sześcioznakowego numeru części, jej nazwy, liczby sztuk danej części dostępnych w magazynie oraz ceny. Oto przykładowe dane. PKL070 BLJ375 PKL070 FLT015 DKP080 GSF555 FLT015 END
Szkła_ochronne 8 6,50 Złącze_kulowe 12 11,95 Szkła_ochronne 8 6,50 F iltr _ o le ju 23 7,95 Podkładki 16 9,99 Filtr_gazu 9 4,50 F iltr _ o le ju 23 7,95
Nazwa części jest zapisywana w postaci jednego słowa, dzięki czemu można ją odczytać przy użyciu metody next klasy Scanner. Trzeba zwrócić uwagę, że poszczególne nazwy części nie mają tej samej długości. Oprócz tego, koniecznie trzeba pamiętać, że aby mieć możliwość korzystania z dostępu swobodnego, wszystkie rekordy muszą mieć tę samą długość — bo tylko w ten sposób możemy określić położenie rekordu w pliku. A zatem, w jaki sposób można utworzyć plik o dostępie swobodnym zawierający rekordy części, jeśli ich nazwy mają różne długości? Sztuczka polega na zapisaniu nazw poszczególnych części w łańcuchu o tej samej długości. Przykładowo każdą nazwę zapiszemy w łańcuchu o długości 20 znaków. Jeśli nazwa będzie krótsza, puste miejsce wypełnimy znakami odstępu, tak by cały łańcuch miał dokładnie 20 znaków długości. Jeśli natomiast nazwa będzie dłuższa, skrócimy ją do 20 znaków. Jednak najlepszym rozwiązaniem będzie użycie na tyle długiego łańcucha, by zmieściła się w nim nawet najdłuższa nazwa. Jeśli użyjemy 20 znaków do zapisania nazwy części, jaka będzie sumaryczna wielkość jednego rekordu? W języku Java każdy znak jest zapisywany przy użyciu dwóch bajtów. A zatem num er części (6 znaków) zajmie 12 bajtów, natomiast jej nazwa — 40; liczba dostępnych egzemplarzy (wartość typu int) zajmie 4 bajty, a cena (wartość typu doubl e) zajmie 8 bajtów, czyli w sumie jeden rekord zajmie 64 bajty. Poniżej przedstawiono kod nowej klasy Part. class Part { String partNum, name; int amtInStock; double p rice; public P art(Strin g pn, String n, int a, double p) { partNum = pn; name = n;
210
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
amtInStock = a; p rice = p; } public void p rin tP art() { System.out.printf("Numer c z ę ś c i: %s\n", partNum); System.out.printf("Nazwa c z ę ś c i: %s\n", name); System .o u t.p rin tf("L iczb a dostępnych c z ę ś c i: %d\n", amtInStock); System .out.printf("C ena: $%3.2f\n", p ric e ); } } //koniec klasy Part Jeśli stała EndOfData ma wartość ENDi zakładamy, że plik części ma taką postać, jak pokazano wcześniej, możemy odczytywać jego zawartość przy użyciu następującej funkcji. public s t a t i c Part getPartData(Scanner in) { String pnum = in .n e x t(); i f (pnum.equals(EndOfData)) return n u ll; return new Part(pnum, in .n e x t(), in .n e x tIn t(), in.nextD ouble()); } Jeśli w pliku nie będzie już żadnych danych do odczytania, funkcja zwróci wartość null. W przeciwnym przypadku zwraca ona obiekt Part zawierający odczytane z pliku dane następnej części. Przy założeniu, że stała StringFixedLength zawiera długość łańcucha znaków używanego do zapisania nazwy części, możemy zapisać ją w pliku f , posługując się następującym fragmentem kodu. int n = M ath.m in(part.nam e.length(), StringFixedLength); fo r (in t h = 0; h < n; h++) f.w riteC har(part.nam e.charA t(h)); fo r (in t h = n; h < StringFixedLength; h++) f.w riteC h ar(' ' ) ; Jeśli przyjmiemy, że n zawiera mniejszą wartość z pary złożonej z długości nazwy oraz wartości stałej S tr i ngFixedLength, powyższy fragment zapisuje w pliku n znaków. Druga pętla fo r zapisuje w pliku znaki odstępu, dopełniając nazwę części do zadanej długości. Należy zwrócić uwagę, że jeśli stała StringFixedLength jest mniejsza od długości nazwy, druga pętla for nie zapisze w pliku żadnych dodatkowych znaków. Poniższy kod służy do odczytania z pliku nazwy części. char[] name = new char[StringFixedLength]; fo r (in t h = 0; h < StringFixedLength; h++) name[h] = f.read C har(); String hold = new String(name, 0, StringFixedLength); Powyższy fragment odczytuje z pliku dokładnie StringFixedLength znaków, zapisując je w tablicy. Następnie zawartość tej tablicy jest przekształcana na łańcuch znaków i zapisywana w zmiennej hold. Wywołanie hold.trim() usuwa puste znaki odstępu z końca łańcucha (jeśli takie istnieją). Metody hold.trim() użyjemy podczas tworzenia metody do odczytu rekordów klasy Part. Program P7.6 odczytuje dane z pliku tekstowego p arts.txt i tworzy plik o dostępie swobodnym o nazwie parts.bin . Program P7.6 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CreateRandomAccess { s t a tic fin al in t StringFixedLength = 20; s t a tic fin al in t PartNumSize = 6; s t a tic fin al in t PartRecordSize = 64; s t a tic fin al S trin g EndOfData = "END"; public s t a tic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("p a rts .tx t"));
211
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw "); Part part = getP artD ata(in); while (part != null) { w riteP artT oF ile(p art, fp ); part = getP artD ata(in); } } //koniec main public s t a t ic Part getPartData(Scanner in) { String pnum = in .n e x t(); i f (pnum.equals(EndOfData)) return n u ll; return new Part(pnum, in .n e x t(), in .n e x tIn t(), in.nextD ouble()); } //koniec getPartData public s t a t i c void w ritePartToFile(Part p art, RandomAccessFile f) throws IOException { System .out.printf("% s %-l5s %2d %5.2f %3d\n", part.partNum, part.name, part.am tInStock, p a rt.p ric e , f.g e tF ile P o in te r ( )); fo r (in t h = 0; h < PartNumSize; h++) f.w riteChar(part.partN um .charA t(h)); int n = M ath.m in(part.nam e.length(), StringFixedLength); fo r (in t h = 0; h < n; h++) f.w riteC har(part.nam e.charA t(h)); fo r (in t h = n; h < StringFixedLength; h++) f.w riteC h ar(' ' ) ; f.w rite In t(p art.am tIn S to ck ); f.w riteD ou ble(part.pri c e ); } //koniec writePartToFile } //koniec klasy CreateRandomAccess // tu należy dodać kod klasy Part Kiedy uruchomimy ten program, używając pliku parts.txt zawierającego przedstawione wcześniej, przykładowe dane, wygeneruje następujące wyniki. PKL070 BLJ375 PKL070 FLT0l5 DKP080 GSF555 FLT0l5
Szkła ochronne Złącze kulowe Szkła ochronne F i l t r ol eju Podkładki F i l t r gazu F i l t r ol eju
8 6,50 0 l2 l l,9 5 64 8 6,50 l28 23 7,95 l92 l6 9,99 256 9 4,50 320 23 7,95 384
Ostatnia wartość wyświetlana w każdym wierszu to wskaźnik pliku — numer bajta, od którego rozpoczyna się dany rekord. Dzięki zastosowaniu łańcucha formatującego w postaci %-l5s nazwa pliku jest wyświetlana w polu o długości 15 znaków i wyrównywana do lewej (znak - oznacza wyrównanie do lewej). Teraz napiszemy program P7.7, który pozwoli sprawdzić, czy plik binarny został utworzony prawidłowo. Program ten prosi użytkownika o podanie numeru rekordu i wyświetla odczytaną z niego nazwę części. Program P7.7 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss ReadRandomAccess { s t a tic fin al in t StringFixedLength = 20; s t a tic fin al in t PartNumSize = 6; s t a tic fin al in t PartRecordSize = 64; public s t a tic void m ain(String[] args) throws IOException { RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw ");
212
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
Scanner kb = new Scanner(System .in); System .out.printf("\nEnter a record number: " ) ; int n = k b .n e x tIn t(); while (n != 0) { fp.seek(PartRecordSize * (n - 1 ) ); re ad P artF rom F ile(fp ).p rin tP art(); System .out.printf("\nEnter a record number: " ) ; n = k b .n e x tIn t(); } } //koniec main public s t a t ic Part readPartFromFile(RandomAccessFile f) throws IOException { String pname = " " ; fo r (in t h = 0; h < PartNumSize; h++) pname += f.read C har(); char[] name = new char[StringFixedLength]; fo r (in t h = 0; h < StringFixedLength; h++) name[h] = f.read C har(); String hold = new String(name, 0, StringFixedLength); return new Part(pname, h o ld .trim (), f .r e a d I n t( ), f.readD ou ble()); } //koniec readPartFromFile } //koniec klasy ReadRandomAccess // tu należy wstawić kod klasy Part Poniżej przedstawione zostały przykładowe wyniki wykonania programu P7.7. Podaj numer rekordu: 3 Numer c z ę ś c i: PKL070 Nazwa c z ę ś c i: Szkła_ochronne Liczba dostępnych c z ę ś c i: 8 Cena: 6,50 zł Podaj numer rekordu: 1 Numer c z ę ś c i: PKL070 Nazwa c z ę ś c i: Szkła_ochronne Liczba dostępnych c z ę ś c i: 8 Cena: 6,50 zł Podaj numer rekordu: 4 Numer c z ę ś c i: FLT015 Nazwa c z ę ś c i: F iltr _ o le ju Liczba dostępnych c z ę ś c i: 23 Cena: 7,95 zł Podaj numer rekordu: 0
7.8. Pliki indeksowane W poprzednim podrozdziale pokazano, jak można pobierać rekordy części na podstawie ich numerów. Jednak to nie jest najbardziej naturalny sposób pobierania rekordów. Najprawdopodobniej będziemy chcieli pobierać rekordy na podstawie jakiegoś klucza; w naszym przypadku będzie to numer części. Znacznie bardziej naturalne jest zadanie pytania: „Ile części BLJ375 mamy w magazynie?” niż pytania „Ile części z rekordu numer 2 mamy w magazynie?”. A zatem nasz problem polega na tym, jak pobrać rekord na podstawie numeru części.
213
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jednym z możliwych rozwiązań jest zastosowanie indeksu. Analogicznie do indeksu w książce, który pozwala szybko znaleźć interesujące nas treści, indeks pliku pozwala szybko odnajdywać zapisane w nim rekordy. Indeks jest tworzony w trakcie wczytywania pliku. Później trzeba go aktualizować podczas dodawania nowych rekordów do pliku oraz usuwania rekordów już istniejących. W naszym przypadku jeden wpis w indeksie będzie się składał z numeru części oraz numeru rekordu. Do utworzenia indeksu posłużymy się następującą klasą. class Index { String partNum; int recNum; public Index(String p, int r) { partNum = p; recNum = r; } } //koniec klasy Index W programie użyjemy stałej MaxRecords, która określi maksymalną liczbę rekordów, jakie będzie można obsłużyć. Zadeklarujemy także następującą tablicę index: Index[] index = new Index[MaxRecords + 1]; W polu index[0] .recNum będziemy zapisywać wartość numRecords, czyli liczbę rekordów aktualnie zapisanych w pliku. W pisy indeksu będą zapisane w tablicy index w elem entach od index[1] do index[numRecords]. Zawartość indeksu będzie uporządkowana na podstawie numeru części. Chcemy utworzyć indeks dla następującej grupy części. O o
Neć
Q_
BLJ375 FLT015 DKP080 GSF555
Szkła ochronne Złącze kulowe F i l t r ol eju Podkładki F i l t r gazu
8 6,50 12 11,95 23 7,95 16 9,99 9 4,50
Zakładamy, że rekordy są zapisane w pliku, w przedstawionej kolejności. Po odczytaniu i zapisaniu w pliku pierwszego rekordu indeks będzie miał następującą postać. PKL070
1
Oznacza to, że rekord z częścią o numerze PKL070 jest zapisany jako pierwszy rekord w pliku danych. Po odczytaniu i zapisaniu drugiego rekordu (BLJ375) indeks przyjmie następującą postać. BLJ375 PKL070
2 1
Trzeba pamiętać, że zawartość indeksu jest posortowana na podstawie numeru części. Poniżej przedstawiono zawartość indeksu po wczytaniu i zapisaniu trzeciej części (FLT015). BLJ375 FLT015 PKL070
2 3 1
Po wczytaniu i zapisaniu czwartej części (DKP080) indeks przyjmie następującą postać. BLJ375 DKP080 FLT015 PKL070
214
2 4 3 1
ROZDZIAŁ 7. M PRACA Z PLIKAMI
I w końcu, po wczytaniu i zapisaniu piątej części (GSF5555) indeks przyjmie następującą postać. BLJ375 DKP080 FLTOl5 GSF555 PKLO7O
a 4 a 5 l
Sposób tworzenia indeksu przedstawiono w programie P7.S. Program P7.8 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss CreateIndex j s t a tic fin al in t StringFixedLength = 20; s t a tic fin al in t PartNumSize = 6; s t a tic fin al in t PartRecordSize = 64; s t a tic fin al in t MaxRecords = 100; s t a tic fin al S trin g EndOfData = "END"; public s t a t ic void m ain(String[] args) throws IOException j RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw "); Index[] index = new Index[MaxRecords + 1]; createM asterIndex(index, fp ); saveIndex(index); printIndex(index); f p .c lo s e ( ); j //koniec main public s t a t ic void createM asterIndex(Index[] index, RandomAccessFile f) throws IOException j Scanner in = new Scanner(new F ile R e a d e r("p a rts .tx t")); int numRecords = 0; Part part = getP artD ata(in); while (part != null) j int searchResult = search(part.partNum, index, numRecords); i f (searchResult > 0) System .out.printf("Powtórzona c z ę ś ć ... ignorujemy część %s.\n", part.partNum); e lse j //to nowy numer części; wstawiamy location -searchResult i f (numRecords == MaxRecords) j System .ou t.printf("Z byt wiele rekordów: dopuszczalna liczb a to %d.\n", MaxRecords); S y s te m .e x it(l); j //jest miejsce w tablicy index; przesuwamy wpisy, by wstawić nową część fo r (in t h = numRecords; h >= -searchR esu lt; h--) index[h + 1] = index[h]; index[-searchResult] = new Index(part.partNum, ++numRecords); w riteP artT oF ile(p art, f ) ; j part = getP artD ata(in); j //koniec while index[0] = new Index("NOPART", numRecords); in .c lo s e (); j //koniec createMasterIndex
215
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
public s t a t i c Part getPartData(Scanner in) j String pnum = in .n e x t(); i f (pnum.equals(EndOfData)) return n u ll; return new Part(pnum, in .n e x t(), in .n e x tIn t(), in.nextD ouble()); j //koniec getPartData public s t a t i c void w ritePartToFile(Part p art, RandomAccessFile f) throws IOException j fo r (in t h = 0; h < PartNumSize; h++) f.w riteChar(part.partN um .charA t(h)); int n = M ath.m in(part.nam e.length(), StringFixedlength); fo r (in t h = 0; h < n; h++) f.w riteC har(part.nam e.charA t(h)); fo r (in t h = n; h < StringFixedlength; h++) f.w riteC h ar(' ' ) ; f.w rite In t(p art.am tIn S to ck ); f.w riteD o u b le(p art.p rice); j //koniec writePartToFile public s t a tic void saveIndex(Index[] index) throws IOException j RandomAccessFile f = new RandomAccessFile("index.bin", "rw "); int numRecords = index[0].recNum; //wypełniamy nieużywane pozycje indeksu fikcyjnymi wpisami fo r (in t h = numRecords+1; h <= MaxRecords; h++) index[h] = new Index("NOPART", 0 ); f.w riteInt(M axRecords); fo r (in t h = 0; h <= MaxRecords; h++) j fo r (in t i = 0; i < PartNumSize; i++) f.w riteChar(index[h].partN um .charA t(i) ) ; f.w riteInt(index[h].recN um ); j f .c l o s e ( ) ; j //koniec saveIndex public s t a tic int search (String key, Index[] l i s t , int n) j //funkcja przegląda tablicę list[1..n] w poszukiwaniu klucza; jeśli go //znajdzie, zwraca jego lokalizację; w przeciwnym razie zwraca ujemną //wartość określającą miejsce, gdzie dany klucz powinien zostać wstawiony int lo = 1, hi = n; while (lo <= hi) j // dopóki są jakieś elementy do sprawdzenia int mid = (lo + hi) / 2; int cmp = key.compareToIgnoreCase(list[mid].partNum); i f (cmp == 0) return mid; // udało się znaleźć klucz i f (cmp < 0) hi = mid - 1; // klucz jest 'mniejszy' od list[mid].partNum e lse lo = mid + 1; // klucz jest 'większy' od list[mid].partNum j return - lo ; j //koniec search
// nie zaleziono klucza; należy go wstawić w miejscu lo
public s t a tic void printIndex(Index[] index) j System .out.printf("\nZawartość indeksu: \n\n"); int numRecords = index[0].recNum; fo r (in t h = 1; h <= numRecords; h++) System .out.printf("% s %2d\n", index[h].partNum, index[h].recNum); j //koniec printIndex j //koniec klasy CreateIndex class Part j String partNum, name;
216
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
int amtInStock; double p rice; public P art(Strin g pn, String n, int a, double p) { partNum = pn; name = n; amtInStock = a; p rice = p; } public void p rin tP art() { System.out.printf("Numer c z ę ś c i: %s\n", partNum); System.out.printf("Nazwa c z ę ś c i: %s\n", name); System .o u t.p rin tf("L iczb a dostępnych c z ę ś c i: %d\n", amtInStock); System .out.printf("C ena: %3.2f zi\n", p ric e ); } } //koniec klasy Part class Index { String partNum; int recNum; public Index(String p, int r) { partNum = p; recNum = r; } } //koniec klasy Index Po odczytaniu numeru części szukamy go w indeksie. Ponieważ jest on posortowany względem numeru części, możemy go przeszukiwać przy użyciu algorytmu wyszukiwania binarnego. Jeśli numer części już jest zapisany w indeksie, oznacza to, że część już wcześniej została zapisana; w takim przypadku aktualnie przetwarzany rekord zostaje zignorowany. Jeśli jednak jego numer nie istnieje w indeksie, oznacza to, że część jest nowa; w tym przypadku jej rekord zostaje zapisany w pliku parts.bin , o ile tylko liczba zapisanych rekordów nie przekracza wartości MaxRecords. Jeśli rekord zostaje zapisany, jednocześnie aktualizujemy liczbę odczytanych rekordów (przechowywaną w zmiennej numRecords), a oprócz tego tworzymy nowy wpis do indeksu zawierający num er części oraz numer rekordu, który wstawiany jest w odpowiednie miejsce tablicy index. Kiedy wszystkie rekordy zostaną przetworzone, zapisujemy indeks w kolejnym pliku — index.bin. Przed zapisaniem jego zawartości wszystkie niewykorzystane wpisy indeksu (czyli kom órki tablicy index o indeksach większych od numRecords) zostają wypełnione fikcyjnymi rekordami. Pierwszą wartością zapisywaną w pliku jest MaxRecords. Za nią zostają zapisane kom órki od index[0] do index[MaxRecords]. Trzeba przy tym pamiętać, że pole i ndex[0]. recNum zawiera wartość numRecords. Załóżmy, że plik parts.txt ma następującą zawartość. PKL070 BLJ375 PKL070 FLT015 DKP080 GSF555 FLT015 END
Szkła_ochronne 8 6,50 Złącze_kulowe 12 11,95 Szkła_ochronne 8 6,50 F iltr _ o le ju 23 7,95 Podkładki 16 9,99 Filtr_gazu 9 4,50 F iltr _ o le ju 23 7,95
217
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W takim przypadku uruchomienie programu P7.8 spowoduje wyświetlenie następujących informacji. Powtórzona c z ę ś ć ... ignorujemy część PKL070. Powtórzona c z ę ś ć ... ignorujemy część FLT015. Zawartość indeksu: BLJ375 DKP080 FLT015 GSF555 PKL070
2 4 3 5 1
Teraz napiszemy program, który pozwoli przetestować utworzony plik indeksu. Najpierw program wczyta plik indeksu, a następnie, w pętli, będzie prosił użytkownika o podanie numeru części, którą później spróbuje odszukać w indeksie. Jeśli to się uda, odnaleziony rekord indeksu będzie zawierał informację o położeniu rekordu części. Na jej podstawie program wczyta rekord części i wyświetli informacje o niej. Jeśli numeru części nie uda się odnaleźć w indeksie, będzie to oznaczało, że taka część nie istnieje. Poniżej przedstawiony został kod programu P7.9. Program P7.9 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss UseIndex j s t a tic fin al in t StringFixedLength = BO; s t a tic fin al in t PartNumSize = 6; s t a tic fin al in t PartRecordSize = 64; s t a tic in t MaxRecords; public s t a t ic void m ain(String[] args) throws IOException j RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw "); Index[] index = re trie v eIn d e x (); int numRecords = index[O].recNum; Scanner kb = new Scanner(System .in); System .out.printf("\nPodaj numer części (lub E, aby zakończyć): " ) ; String pnum = k b .n e x t(); while (!pnum.equalsIgnoreCase("E")) j int n = search(pnum, index, numRecords); i f (n > 0) j fp.seek(PartRecordSize * (index[n].recNum - 1 )); re ad P artF ro m F ile(fp ).p rin tP art(); j e lse System .ou t.prin tf("N ie udało s ię znaleźć c z ę ś c i.\ n "); System .out.printf("\nPodaj numer części (lub E, aby zakończyć): " ) ; pnum = k b .n e x t(); j //koniec while f p .c lo s e ( ); j //koniec main public s t a t i c Index[] retriev eInd ex() throws IOException j RandomAccessFile f = new RandomAccessFile("index.bin", "rw "); int MaxRecords = f.r e a d I n t(); Index[] index = new Index[MaxRecords + 1]; fo r (in t j = 0; j <= MaxRecords; j+ + ) j String pnum = " " ; fo r (in t i = 0; i < PartNumSize; i++) pnum += f.read C har(); index[j] = new Index(pnum, f.r e a d I n t ( ) ) ;
21S
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
} f .c l o s e ( ) ; return index; } //koniec retrieveIndex public s t a t ic Part readPartFromFile(RandomAccessFile f) throws IOException { String pname = " " ; fo r (in t h = 0; h < PartNumSize; h++) pname += f.read C har(); char[] name = new char[StringFixedLength]; fo r (in t h = 0; h < StringFixedLength; h++) name[h] = f.read C har(); String hold = new String(name, 0, StringFixedLength); return new Part(pname, h o ld .trim (), f .r e a d I n t () , f.readD ou ble()); } //koniec readPartFromFile public s t a t ic int search (String key, Index[] l i s t , int n) { //funkcja przegląda tablicę list[1..n] w poszukiwaniu klucza; jeśli go //znajdzie, zwraca jego lokalizację; w przeciwnym razie zwraca ujemną //wartość określającą miejsce, gdzie dany klucz powinien zostać wstawiony int lo = 1, hi = n; while (lo <= hi) { // dopóki są jakieś elementy do sprawdzenia int mid = (lo + hi) / 2; int cmp = key.compareToIgnoreCase(list[mid].partNum); i f (cmp == 0) return mid; // udało się znaleźć klucz i f (cmp < 0) hi = mid - 1; // klucz jest 'mniejszy' od list[mid].partNum e lse lo = mid + 1; // klucz jest 'większy' od list[mid].partNum } return - lo ; // nie zaleziono klucza; należy go wstawić w miejscu lo } //koniec search } //koniec klasy UseIndex // tu należy wstawić kod klas Index oraz Part Poniżej przedstawiono przykładowe wyniki wykonania programu P7.9. Podaj numer części (lub E, aby zakończyć): pkl 070 Numer c z ę ś c i: PKL070 Nazwa c z ę ś c i: Szkła_ochronne Liczba dostępnych c z ę ś c i: 8 Cena: 6,50 zł Podaj numer części (lub E, aby zakończyć): GsF555 Numer c z ę ś c i: GSF555 Nazwa c z ę ś c i: Filtr_gazu Liczba dostępnych c z ę ś c i: 9 Cena: 4,50 zł Podaj numer części (lub E, aby zakończyć): PKL060 Nie udało s ię znaleźć c z ę śc i. Podaj numer części (lub E, aby zakończyć): dkp080 Numer c z ę ś c i: DKP080 Nazwa c z ę ś c i: Podkładki Liczba dostępnych c z ę ś c i: 16 Cena: 9,99 zł Podaj numer części (lub E, aby zakończyć): e
219
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W arto zwrócić uwagę, że numery części można wpisywać w dowolny sposób — używając zarówno wielkich, jak i małych liter. Gdyby trzeba było, moglibyśmy skorzystać z indeksu, by wyświetlić części posortowane według numerów. Wystarczyłoby wyświetlić rekordy w takiej kolejności, w jakiej występują w indeksie. I tak w przypadku wykorzystania naszych przykładowych danych indeks ma następującą postać. BLJ375 DKP080 FLT015 GSF555 PKL070
2 4 3 5 1
Gdybyśmy wyświetlili rekord 2., następnie rekord 4., a za nim rekordy 3., 5. i 1., w efekcie ujrzelibyśmy wszystkie części w rosnącej kolejności ich numerów. Możemy to zrobić przy użyciu następującej metody. public s t a t i c void printFileInO rder(Index[] index, RandomAccessFile f) throws IOException { S ystem .ou t.p rin tf("\ nP lik posortowany według numeru c z ę ś c i: \n\n"); int numRecords = index[0].recNum; fo r (in t h = 1; h <= numRecords; h++) { f.seek(PartRecordSize * (index[h].recNum - 1 )); re a d P a rtF ro m F ile (f).p rin tP a rt(); S y stem .o u t.p rin tf("\ n "); } //koniec fo r } //koniec printFileInOrder Załóżmy teraz, że dodalibyśmy tę metodę do programu P7.9 i wykonali ją, używając poniższego wywołania: printFileInO rder(index, fp ); W efekcie program wygenerowałby następujące wyniki. Plik posortowany według numeru c z ę śc i: Numer c z ę ś c i: BLJ375 Nazwa c z ę ś c i: Złącze_kulowe Liczba dostępnych c z ę ś c i: 12 Cena: 11,95 zł Numer c z ę ś c i: DKP080 Nazwa c z ę ś c i: Podkładki Liczba dostępnych c z ę ś c i: 16 Cena: 9,99 zł Numer c z ę ś c i: FLT015 Nazwa c z ę ś c i: F iltr _ o le ju Liczba dostępnych c z ę ś c i: 23 Cena: 7,95 zł Numer c z ę ś c i: GSF555 Nazwa c z ę ś c i: Filtr_gazu Liczba dostępnych c z ę ś c i: 9 Cena: 4,50 zł Numer c z ę ś c i: PKL070 Nazwa c z ę ś c i: Szkła_ochronne Liczba dostępnych c z ę ś c i: 8 Cena: 6,50 zł
220
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
7.9. Aktualizacja pliku o dostępie swobodnym Informacje zapisywane w pliku zazwyczaj nie są statyczne. Od czasu do czasu trzeba je aktualizować. W przypadku naszego pliku części takie aktualizacje mogą być związane z nową liczbą części dostępnych w magazynie albo ze zmianą ich ceny. Może się także zdarzyć, że zechcemy dodać do magazynu nowe części, a to będzie oznaczało konieczność zapisania w pliku nowych rekordów; oprócz tego możemy zdecydować o usunięciu niektórych części z oferty, a to z kolei pociągnie za sobą konieczność usunięcia ich rekordów z pliku. Dodawanie nowych rekordów jest realizowane podobnie jak tworzenie nowego pliku. Usunięcie rekordów możemy wykonać w sposób logiczny — oznaczając je jako usunięte w pliku indeksu bądź faktycznie usunąć je z tego pliku. Później, podczas reorganizacji zawartości pliku będzie można usunąć te rekordy fizycznie (czyli całkowicie wyrzucić je z pliku). Ale w jaki sposób możemy zm ien ić informacje o istniejącym rekordzie? W tym celu musimy wykonać następujące operacje: 1. odszukać rekord w pliku, 2. wczytać go do pamięci, 3. zmienić wartości wybranych pól, 4. zapisać zaktualizowany rekord w tym sam ym m iejscu pliku, z którego go wcześniej odczytaliśmy. Operacje te wymagają, by plik został otworzony w trybie odczytu i zapisu. Przy założeniu, że plik już istnieje, oznacza to konieczność użycia trybu rw. Poniżej wyjaśniono, w jaki sposób można zaktualizować rekord części, której numer jest zapisany w zmiennej key. Na początek musimy przejrzeć indeks w poszukiwaniu wartości key. Jeśli jej nie znajdziemy, będzie to znaczyć, że część o podanym numerze nie istnieje. Załóżmy jednak, że udało się odnaleźć tę część w rekordzie o numerze k. W takim przypadku wartość pola i ndex[k].recNum zawiera numer rekordu części (załóżmy, że zapiszemy go w zmiennej n). Teraz możemy wczytać dane części w znany już sposób. fp.seek(PartRecordSize * (n - 1 ) ); Part part = readPartFrom File(fp); Rekord części znajduje się już w pamięci, jest zapisany w zmiennej part. Załóżmy, że chodzi o odjęcie wartości amtSold od liczby części dostępnych w magazynie. Możemy to zrobić w następujący sposób. i f (amtSold > part.amtInStock) System .out.printf("Dostępnych je s t %d c z ę ś c i: nie możesz sprzedać w ięcej!\ n "); e lse part.amtInStock -= amtSold; W podobny sposób moglibyśmy zaktualizować zawartość pozostałych pól (z wyjątkiem numeru części). Po wprowadzeniu wszystkich zmian zaktualizowany rekord części będzie się znajdował w pamięci. Musimy zatem zapisać go ponownie w pliku, dokładnie w tym sam miejscu, z którego go odczytaliśmy. Możemy to zrobić w następujący sposób. fp.seek(PartRecordSize * (n - 1 ) ); w riteP artT oF ile(p art, fp ); Koniecznie należy zwrócić uwagę, że ponownie musimy wywołać metodę seek, gdyż po wczytaniu rekordu wskaźnik pliku został przesunięty na początek następnego rekordu. Przed zapisaniem rekordu musimy zatem przesunąć ten wskaźnik na początek aktualizowanego rekordu. W efekcie stary rekord zostanie zastąpiony nowym. W programie P7.10 aktualizujemy pole atmInStoc rekordów w pliku części. Użytkownik zostaje poproszony o podanie numeru części oraz liczby sprzedanych egzemplarzy. Program przegląda indeks w poszukiwaniu numeru części przy użyciu wyszukiwania binarnego. Rekord po odnalezieniu jest wyczytywany z pliku, aktualizowany w pamięci i ponownie zapisywany w pliku. Czynności te są powtarzane, aż do momentu gdy użytkownik zamiast numeru części wpisze E. Program P7.10 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss UpdateFile { s t a tic fin al in t StringFixedLength = 20;
221
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
s t a tic fin al in t PartNumSize = 6; s t a tic fin al in t PartRecordSize = 64; s t a tic in t MaxRecords; public s t a t i c void m ain(String[] args) throws IOException j Scanner in = new Scanner(System .in); Index[] index = re trie v eIn d e x (); int numRecords = index[O].recNum; System .out.printf("\nPodaj numer części (lub E, aby zakończyć): " ) ; String pnum = in .n e x t(); while (!pnum.equalsIgnoreCase("E")) j updateRecord(pnum, index, numRecords); System .out.printf("\nPodaj numer części (lub E, aby zakończyć): " ) ; pnum = in .n e x t( ); j //koniec while j //koniec main public s t a t i c void updateRecord(String pnum, Index[] index, int max) throws IOException j Scanner in = new Scanner(System .in); RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw "); int n = search(pnum, index, max); i f (n < 0) System .ou t.prin tf("N ie udało s ię znaleźć c z ę ś c i.\ n "); e lse j fp.seek(PartRecordSize * (index[n].recNum - 1 )); Part part = readPartFrom File(fp); System .ou t.printf("P odaj licz b ę sprzedanych c z ę ś c i: " ) ; int amtSold = in .n e x tIn t(); i f (amtSold > part.amtInStock) System .out.printf("Dostępnych je s t %d c z ę ś c i: nie możesz sprzedać w ięcej!\n", part.am tInStock); e lse j part.amtInStock -= amtSold; System .o u t.p rin tf("L iczb a pozostałych c z ę ś c i: %d\n", part.am tInStock); fp.seek(PartRecordSize * (index[n].recNum - 1 )); w riteP artT oF ile(p art, fp ); System .out.printf("% s % -lls %2d %5.2f\n", part.partNum, part.name, part.am tInStock, p a r t.p r ic e ); j //koniec if j //koniec if f p .c lo s e ( ); j //koniec updateRecord public s t a t i c Index[] retriev eInd ex() throws IOException j RandomAccessFile f = new RandomAccessFile("index.bin", "rw "); int MaxRecords = f.r e a d I n t(); Index[] index = new Index[MaxRecords + 1]; fo r (in t j = 0; j <= MaxRecords; j+ + ) j String pnum = " " ; fo r (in t i = 0; i < PartNumSize; i++) pnum += f.read C har(); index[j] = new Index(pnum, f .r e a d I n t( ) ) ; j f.c lo s e ( ) ; return index; j //koniec retrieveIndex
222
ROZDZIAŁ 7. ■ PRACA Z PLIKAMI
public s t a t i c Part readPartFromFile(RandomAccessFile f) throws IOException { String pname = " " ; fo r (in t h = 0; h < PartNumSize; h++) pname += f.read C har(); char[] name = new char[StringFixedLength]; fo r (in t h = 0; h < StringFixedLength; h++) name[h] = f.read C har(); String hold = new String(name, 0, StringFixedLength); return new Part(pname, h o ld .trim (), f .r e a d I n t () , f.readD ou ble()); } //koniec readPartFromFile public s t a t i c void w ritePartToFile(Part p art, RandomAccessFile f) throws IOException { fo r (in t h = 0; h < PartNumSize; h++) f.w riteChar(part.partN um .charA t(h)); int n = M ath.m in(part.nam e.length(), StringFixedLength); fo r (in t h = 0; h < n; h++) f.w riteC har(part.nam e.charA t(h)); fo r (in t h = n; h < StringFixedLength; h++) f.w riteC h ar(' ' ) ; f.w rite In t(p art.am tIn S to ck ); f.w riteD ou ble(part.pri c e ); } //koniec writePartToFile public s t a t ic int search (String key, Index[] l i s t , int n) { //funkcja przegląda tablicę list[1..n] w poszukiwaniu klucza; jeśli go //znajdzie, zwraca jego lokalizację; w przeciwnym razie zwraca ujemną //wartość określającą miejsce, gdzie dany klucz powinien zostać wstawiony int lo = 1, hi = n; while (lo <= hi) { // dopóki są jakieś elementy do sprawdzenia int mid = (lo + hi) / 2; int cmp = key.compareToIgnoreCase(list[mid].partNum); i f (cmp == 0) return mid; // udało się znaleźć klucz i f (cmp < 0) hi = mid - 1; // klucz jest 'mniejszy' od list[mid].partNum e lse lo = mid + 1; // klucz jest 'większy' od list[mid].partNum } return - lo ; } //koniec search
// nie zaleziono klucza; należy go wstawić w miejscu lo
} //koniec klasy UpdateFile //tu należy wstawić kod klas Index i Part Poniżej przedstawiono przykładowe wyniki wykonania programu P7.10. Podaj numer części (lub E, aby zakończyć): b lj375 Podaj liczb ę sprzedanych c z ę ś c i: 2 Liczba pozostałych c z ę ś c i: 10 BLJ375 Złącze_kulowe 10 11,95 Podaj numer części (lub E, aby zakończyć): b lj375 Podaj liczb ę sprzedanych c z ę ś c i: 11 Dostępnych je s t 10 c z ę ś c i: nie możesz sprzedać w ięcej! Podaj numer części (lub E, aby zakończyć): dkp080 Podaj liczb ę sprzedanych c z ę ś c i: 4 Liczba pozostałych c z ę ś c i: 12 DKP080 Podkładki 12 9,99 Podaj numer części (lub E, aby zakończyć): gsf55 Nie udało s ię znaleźć c z ę śc i.
223
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Podaj numer części (lub E, aby zakończyć): gsf555 Podaj liczb ę sprzedanych c z ę ś c i: 1 Liczba pozostałych c z ę ś c i: 8 GSF555 Filtr_gazu 8 4,50 Podaj numer części (lub E, aby zakończyć): e
Ćwiczenia 1. Jaka jest różnica pomiędzy plikiem otworzonym w trybie "r" a w trybie "rw"? 2. Napisz program, który będzie sprawdzać, czy dwa pliki binarne są identyczne. Jeśli są różne, program ma wyświetlać numer pierwszego różniącego je bajta. 3. Napisz program, który wczyta plik (binarny) zawierający liczby całkowite, posortuje je, a następnie zapisze z powrotem do tego samego pliku. Przyjmij, że wszystkie liczby całkowite mogą być zapisane w tablicy. 4. Napisz jeszcze raz program z punktu 3., lecz tym razem przyjmij, że w danej chwili w pamięci (czyli w tablicy) może być zapisanych tylko 20 liczb. Podpowiedź: użyj przynajmniej dwóch plików pomocniczych do przechowywania wyników tymczasowych. 5. Napisz program, który wczyta zawartość dwóch plików z posortowanymi liczbami całkowitymi, a następnie scali te liczby i zapisze w trzecim, posortowanym pliku wynikowym. 6. Napisz program, który odczyta zawartość pliku tekstowego, a następnie zapisze w innym pliku tekstowym tylko te z odczytanych wierszy, których długość jest mniejsza od podanej. Upewnij się, że wiersze tekstu będą przerywane w sensownych miejscach; np. unikaj dzielenia łańcucha w połowie słowa lub pozostawiania na jego końcu samotnych znaków przestankowych. 7. Jaki jest cel tworzenia pliku indeksu? Poniżej przedstawiono kilka rekordów z pliku z danymi pracowników. Składają się z następujących pól: numeru pracownika (klucza), imienia i nazwiska, nazwy stanowiska, numeru telefonu, miesięcznego wynagrodzenia oraz podatku, który należy odliczyć. TF425, OM319, YS777, NR591, SN815, TF273, YS925,
J u lia Janczak, sekretarka, 623-3321, 2500, 650 Jan Małecki, programista, 676-1319, 3200, 800 Jerzy Kundelski, analityk systemowy, 671-2025, 4200, 1100 Leon Kaspszak, operator, 657-0266, 2800, 700 Cecylia K ielińska, asystentka, 652-5345, 2100, 500 Amelia Bytomska, kierownik do spraw danych, 632-5324, 3500, 850 Rafał Ubiński, starszy programista, 636-8679, 4800, 1300
Załóż, że rekordy są zapisane w pliku binarnym takiej samej kolejności, jaką pokazują powyższe dane przykładowe. a) W jaki sposób można pobrać rekord na podstawie jego numeru? b) W jaki sposób można pobrać rekord, dysponując tylko jego kluczem? c) Podczas wczytywania pliku utwórz indeks, którego klucze będą zapisywane w określonej kolejności. W jaki sposób można przeglądać taki indeks w poszukiwaniu konkretnej wartości? d) Podczas wczytywania pliku utwórz indeks, którego klucze będą posortowane. W jaki sposób można pobrać rekord, dysponując jego kluczem? Zastanów się, jakie zmiany trzeba by wprowadzać w indeksie podczas dodawania rekordów do pliku oraz usuwaniu ich z niego. 8. Do przedstawionego w tym rozdziale przykładu z plikiem części dodaj metody pozwalające: a) dodawać nowe rekordy, b) usuwać rekordy z pliku.
224
ROZDZIAŁ
8
Wprowadzenie do zagadnień drzew binarnych W tym rozdziale wyjaśnimy takie zagadnienia jak: • różnica pomiędzy drzewem a drzewem binarnym, • przechodzenie drzew binarnych metodami pre-order, in -order oraz post-order, •
sposoby reprezentacji drzew binarnych w programach komputerowych,
• sposoby konstruowania drzew binarnych na podstawie dostarczonych danych, • binarne drzewa poszukiwań oraz sposoby ich tworzenia, • implementacja programu zliczającego ilość wystąpień słów w tekście, • zastosowanie tablicy jako reprezentacji drzewa binarnego, • sposób implementacji funkcji rekurencyjnej uzyskującej informacje o drzewie binarnym, • sposób usuwania wierzchołków z binarnego drzewa poszukiwań.
8.1. Drzewa Drzewem nazywamy skończony zbiór wierzchołków, spełniający oba poniższe warunki. • Istnieje jeden, specjalnie wyznaczony wierzchołek nazywany korzeniem (ang. root) drzewa. • Pozostałe wierzchołki są podzielone na m (m > 0) rozłącznych zbiorów Ti, T 2, ..., Tm, przy czym każdy z nich jest drzewem. Drzewa Ti, T 2, ..., Tm są nazywane poddrzewami korzenia. Zastosowaliśmy tu definicję rekurencyjną, gdyż rekurencja stanowi jedną z kluczowych cech drzewa. Przykład drzewa przedstawiony został na rysunku 8.1. Zwyczajowo korzeń rysuje się na samej górze, a drzewo rośnie ku dołowi. Korzeniem tego drzewa jest wierzchołek A. Można wskazać trzy poddrzewa, których korzeniami są odpowiednio wierzchołki B, C i D. Drzewo, którego wierzchołkiem jest wierzchołek B, ma dwa poddrzewa, kolejne drzewo, z korzeniem w wierzchołku C, nie ma żadnego poddrzewa i w końcu ostatnie drzewo, którego korzeniem jest wierzchołek D, ma jedno poddrzewo. Każdy wierzchołek drzewa jest korzeniem poddrzewa. Stopniem (ang. degree) wierzchołka nazywamy liczbę jego poddrzew. Można go sobie wyobrazić jako liczbę krawędzi wychodzących z danego wierzchołka. Przykładowo wierzchołek Ama stopień 3., wierzchołek C — stopień 0, wierzchołek D— stopień 1., a wierzchołek G — stopień 3.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 8.1. D rzewo Określając relacje pomiędzy wierzchołkami w drzewie, używamy takich terminów jak rodzic (ang. parent), dziecko (ang. child) oraz bracia (ang. sibling). Przykładowo rodzic A ma troje dzieci, którymi są wierzchołki B, C i D; rodzic B ma dwoje dzieci, którymi są wierzchołki E i F; a rodzic Dma tylko jedno dziecko — wierzchołek G, który z kolei ma troje dzieci — wierzchołki H, I oraz J. Należy zauważyć, że wierzchołek może być dzieckiem jednego wierzchołka i rodzicem innego. Braćmi nazywamy wierzchołki stanowiące dzieci tego samego rodzica. W naszym przykładzie braćmi są zarówno wierzchołki B, C i D, jak również wierzchołki E i F oraz H, I i J. Dany wierzchołek drzewa może mieć kilkoro dzieci, lecz tylko jednego rodzica. Korzeń drzewa nie ma żadnego rodzica. Innymi słowy, do każdego wierzchołka, który nie jest korzeniem drzewa, prowadzi tylko jedna krawędź. W ierzchołek końcowy, nazywany także liściem (ang. leaf), to taki, którego stopień wynosi 0. W ierzchołkami gałęzi nazywamy wierzchołki, które nie są liśćmi. W przykładzie z rysunku 8.1 liśćm i są wierzchołki C, E, F, H, I oraz J , natomiast wierzchołkami gałęzi — A, B, Doraz G. M om entem (ang. m om en t) drzewa nazywamy liczbę jego wierzchołków. Drzewo z rysunku 8.1 ma moment wynoszący 10. W agą (ang. weight) drzewa nazywamy liczbę jego liści. Drzewo z rysunku 8.1 ma wagę 6. Poziom em (ang. level) lub głębokością (ang. depth) wierzchołka nazywamy liczbę krawędzi, jakie należy przejść, by dotrzeć do niego z korzenia drzewa. Korzeń zawsze ma poziom 0. W przykładzie przedstawionym na rysunku 8.1 wierzchołki B, C i Dznajdują się na poziomie 1., E i F na poziomie 2., a wierzchołki H, I oraz J na poziomie 3. Poziom wierzchołka jest miarą jego głębokości w drzewie. Wysokością (ang. height) drzewa nazywamy liczbę jego poziomów. Drzewo z rysunku 8.1 ma wysokość równą 4. W arto zauważyć, że wysokość drzewa jest o jeden większa od największego poziomu. Jeśli względna kolejność poddrzew T 1, T 2, ..., Tm ma znaczenie, takie drzewo nazywamy drzewem uporządkowanym (ang. ordered tree). Jeśli ta kolejność nie ma znaczenia, mówimy o drzewie zorientowanym. Las (ang. forest) to zbiór składający się z zera lub większej liczby rozłącznych drzew, takich jak przedstawione na rysunku 8.2.
Rysunek 8.2. Las sk ład a ją cy się z trzech rozłącznych drzew Choć drzewa ogólnie są dosyć interesujące, jednak zdecydowanie najważniejszym rodzajem drzew są drzewa binarne.
226
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
8.2. Drzewa binarne Drzewa binarne są klasycznym przykładem nieliniowej struktury danych — wystarczy porównać je z listą liniową, w której można wyróżnić pierwszy element, element następny oraz ostatni. Drzewa binarne są szczególnym przypadkiem bardziej ogólnej struktury danych, jaką są drzew a, jednak to właśnie one są najbardziej użyteczne i najczęściej stosowane. Drzewa binarne najłatwiej można zdefiniować przy użyciu następującej, rekurencyjnej definicji. D rzewo binarne: a) jest puste lub b) składa się z korzenia oraz dwóch poddrzew — lewego oraz prawego — przy czym każde z nich jest drzewem binarnym. Konsekwencją przyjęcia takiej definicji jest to, że wierzchołek zawsze ma dwa poddrzewa, z których każde może być puste. Inną konsekwencją jest to, że jeśli wierzchołek ma tylko jed n o niepuste poddrzewo, to istotne jest, czy zostało ono umieszczone po stronie lewej, czy prawej. Oto przykład.
Pierwsze z przedstawionych drzew ma puste prawe poddrzewo, natomiast w drugim puste jest lewe poddrzewo. Jednak jako drzew a są identyczne. Poniżej przedstawiono kilka przykładów drzew binarnych. Oto drzewo binarne z jednym wierzchołkiem — korzeniem.
Oto dwa drzewa binarne, z których każde składa się z dwóch wierzchołków.
Poniżej przedstawione zostały przykłady czterech drzew binarnych składających się z trzech wierzchołków.
227
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
A oto drzewa binarne, w których puste są odpowiednio: wszystkie lewe poddrzewa oraz wszystkie prawe poddrzewa.
Oto przykład drzewa, w którym wszystkie wierzchołki z wyjątkiem liści mają dokładnie dwa poddrzewa.
A oto ogólna postać drzewa binarnego.
8.3. Przechodzenie drzew binarnych W wielu przypadkach będziemy chcieli odwiedzać wierzchołki drzewa binarnego w jakiś systematyczny sposób. Te „odwiedziny” wyobrazimy sobie jako zwyczajne wyświetlenie informacji o wierzchołku. Dla drzewa o n wierzchołkach istnieje dokładnie n! sposobów na ich odwiedzenie, zakładamy przy tym, że każdy z nich zostanie odwiedzony dokładnie raz. Oto przykładowe drzewo składające się z trzech wierzchołków — A, B i C; można je odwiedzić w kolejności ABC, ACB, BCA, BAC, CAB oraz CBA. Nie wszystkie z tych sekwencji odwiedzin będą użyteczne. Zdefiniowano trzy przydatne metody odwiedzania wierzchołków: pre-order , in-order oraz post-order . Oto sposób przechodzenia drzewa metodą pre-order (nazywaną także przechodzeniem wzdłużnym). 1. Odwiedzamy korzeń drzewa. 2. Przechodzimy lewe poddrzewo metodą pre-order . 3. Przechodzimy prawe poddrzewo metodą pre-order . W arto zauważyć, że przechodzenie także zostało zdefiniowane w sposób rekurencyjny. W krokach 2. i 3. musimy ponownie zastosować definicję przechodzenia metodą pre-order , która nakazuje rozpoczęcie przechodzenia od korzenia itd.
228
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
Przejście poniższego drzewa metodą pre-order.
spowoduje odwiedzenie jego wierzchołków w kolejności. A B C. Z kolei przejście tego drzewa metodą pre-order.
spowoduje, że wierzchołki zostaną odwiedzone w następującej kolejności: C E F H B G A N J K. Poniżej przedstawiona została definicja przechodzenia drzewa metodą in -order (nazywana także przechodzeniem poprzecznym). 1. Przechodzimy lewe poddrzewo metodą in-order. 2. Odwiedzamy korzeń drzewa. 3. Przechodzimy prawe poddrzewo metodą in-order. W tym przypadku najpierw przechodzimy lewe poddrzewo, później odwiedzamy korzeń drzewa, a następnie przechodzimy prawe poddrzewo. Kiedy poniższe drzewo przechodzimy metodą in-order:
jego wierzchołki zostaną odwiedzone w kolejności: B A C. Z kolei w przypadku przechodzenia metodą in -order poniższego drzewa:
jego wierzchołki zostaną odwiedzone w następującej kolejności. F H E B C A G J N K. Przechodzenie metodą p ost-ord er (nazywaną także przechodzeniem wstecznym) zostało zdefiniowane w następujący sposób.
229
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
1. Przechodzimy lewe poddrzewo metodą post-order. 2. Przechodzimy prawe poddrzewo metodą post-order. Odwiedzamy korzeń drzewa. W tym przypadku lewe oraz prawe poddrzewo przechodzimy p rz ed odwiedzeniem wierzchołka. Podczas przechodzenia metodą p ost-ord er poniższego drzewa:
jego wierzchołki zostaną odwiedzone w kolejności: B C A. W przypadku tego drzewa:
zastosowanie metody post-ord er spowoduje, że jego wierzchołki zostaną odwiedzone w następującej kolejności: H F B E A J K N G C. W arto zwrócić uwagę, że nazwy metod realizujących operację przechodzenia są zależne od kolejności, w jakiej zostaną odwiedzone jego korzeń i poddrzewa. Jako następny przykład przeanalizujemy drzewo binarne, które może reprezentować następujące wyrażenie arytmetyczne: (54 + 37) / (72 - 5 * 13) A oto drzewo tego wyrażenia:
Liście tego drzewa zawierają operandy, natom iast wierzchołki gałęzi — operatory. W wierzchołku reprezentującym operator lewe poddrzewo reprezentuje pierwszy operand, a prawe poddrzewo — drugi operand. Podczas przejścia metodą p re-order wierzchołki zostaną odwiedzone w kolejności: / + 54 37 - 72 * 5 13. Kiedy wykonujemy przejście metodą in-order, wierzchołki zostaną odwiedzone w kolejności: 54 + 37 / 72 - 5 * 13. W przypadku przejścia metodą post-order wierzchołki zostaną odwiedzone w kolejności: 54 37 + 72 5 13 * - /. Przechodzenia metodą post-ord er możemy użyć wraz ze stosem, aby obliczyć wartość wyrażenia. Poniżej przedstawiono ogólny opis algorytmu takiego rozwiązania.
230
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
i n i c ja l i z a c ja początkowo pustego stosu, S while przechodzenie drzewa nie zostało zakończone pobieramy następny element, x i f x je s t operandem, umieszczamy go na s to s ie S i f x je s t operatorem zdejmujemy jego operandy ze stosu S wykonujemy d ziałanie określone przez operator umieszczamy wynik na s to s ie S endif endwhile zdejmujemy wynik ze stosu S; // stanowi on wartość całego wyrażenia Przeanalizujmy ten algorytm dla wierzchołków, które z pomocą metody post-ord er zostałyby odwiedzone w następującej kolejności: 54 37 + 72 5 13 8 - /. Zostaną one przetworzone w następujący sposób. 1.
Następnym elementem jest 54; umieszczamy 54 na stosie S; zawartość stosu S to 54.
2.
Następnym elementem jest 37; umieszczamy 37 na stosie S; zawartość stosu S to 54 37 (wierzchołek stosu jest z prawej strony).
3. Następnym elementem jest +; zdejmujemy ze stosu S elementy 37 i 54; wykonujemy działanie + na liczbach 54 i 37; uzyskujemy wynik 91, który umieszczamy na stosie S; zawartość stosu S to 91. 4.
Następnym elementem jest 72; umieszczamy 72 na stosie S; zawartość stosu S to 91 72.
5.
Następnymi elementami są 5 i 13; umieszczamy je na stosie S; zawartość stosu S to 91 72 5 13.
6. Następnym elementem jest *; zdejmujemy ze stosu S elementy 13 i 5, a następnie wykonujemy na nich działanie *; uzyskujemy wynik 65, który umieszczamy na stosie S; zawartość stosu S to 91 72 65. 7. Następnym elementem jest -; zdejmujemy ze stosu S elementy 65 i 72, a następnie wykonujemy działanie - na operandach 72 i 65; uzyskujemy wynik 7, który umieszczamy na stosie S; zawartość stosu S to 91 7. 8. Następnym elementem jest / ; zdejmujemy ze stosu S elementy 7 i 91, a następnie wykonujemy działanie / na operandach 91 i 7; uzyskujemy wynik 13, który umieszczamy na stosie S; zawartość stosu S to 13. 9. Całe drzewo zostało już odwiedzone; zdejmujemy element z wierzchołka stosu S, jest to liczba 13, a zatem wartością całego wyrażania jest 13. Trzeba zwrócić uwagę, że podczas zdejmowania operandów ze stosu pierwszy zdejmowany jest drugi operand, a pierwszy operand zostaje zdjęty jako drugi. Nie ma to większego znaczenia w przypadku operacji dodawania i mnożenia, jest jednak ważne dla operacji odejmowania i dzielenia.
8.4. Sposoby reprezentacji drzew binarnych W minimalnym przypadku każdy wierzchołek drzewa binarnego składa się z trzech pól: pola zawierającego wartość wierzchołka, wskaźnika na lewe poddrzewo oraz wskaźnika na prawe poddrzewo. W ramach przykładu załóżmy, że daną, która ma być przechowywana w wierzchołku, jest słowo. Możemy zatem zacząć od napisania klasy (dajmy na to TreeNode) definiującej trzy zmienne instancyjne oraz konstruktora tworzącego obiekt TreeNode. class TreeNode { NodeData data; TreeNode l e f t , rig h t; TreeNode(NodeData d) { data = d; l e f t = ri ght = n u ll; } }
231
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Aby zapewnić elastyczność, zdefiniowaliśmy klasę TreeNode, wykorzystując ogólny typ danych o nazwie NodeData. W każdym programie, w którym zechcemy zastosować klasę TreeNode, będziemy musieli podać definicję klasy NodeData. Gdyby np. informacją przechowywaną w wierzchołku drzewa miała być liczba całkowita, klasę NodeData moglibyśmy zdefiniować w następujący sposób. class NodeData { int num; public NodeData(int n) { num = n; } } //koniec klasy NodeData Podobnej definicji moglibyśmy użyć, gdyby w wierzchołkach drzewa miały być przechowywane znaki. Jednak nie jesteśmy ograniczeni do przechowywania w wierzchołkach drzewa pojedynczych informacji. Klasa NodeData może definiować dowolnie wiele pól. Dalej w tym rozdziale napiszemy program, który zlicza ilość wystąpień poszczególnych słów we fragmencie tekstu. Zastosujemy w nim drzewo, którego każdy wierzchołek będzie zawierał słowo oraz liczbę jego wystąpień. Klasa NodeData zastosowana w tym programie będzie miała następującą postać. class NodeData { String word; int freq ; public NodeData(String w) { word = w; freq = 0; } } //koniec klasy NodeData Oprócz wierzchołków drzewa, będziemy musieli także znać jego korzeń. Trzeba pamiętać, że dysponując dostępem do korzenia, czyli korzystając ze wskaźników na lewe i prawe poddrzewo, możemy dotrzeć do każdego z pozostałych wierzchołków drzewa. A zatem drzewo binarne może być określone przez sam korzeń. Napiszemy teraz klasę BinaryT ree, która pozwoli tworzyć drzewa binarne i posługiwać się nimi. Klasa ta będzie miała tylko jedną zmienną instancyjną, o nazwie root, a jej początkową postać przedstawiono poniżej. class BinaryTree { TreeNode ro ot; //jedyne pole zdefiniowane w tej klasie BinaryTree() { root = n u ll; } //metody klasy } //koniec klasy BinaryTree W zasadzie ten konstruktor nie jest potrzebny, gdyż język Java domyślnie zapisze w polu root wartość null w m om encie tworzenia nowego obiektu klasy BinaryT ree. Jednak został dodany, by podkreślić, że w pustym drzewie binarnym pole root ma wartość n u ll. Jeśli trzeba, klasę TreeNode można zadeklarować jako publiczną i umieścić w osobnym pliku, TreeNode.java. Jednak w przykładach prezentowanych w książce będzie ona umieszczana w tym samym pliku, w którym znajduje się klasa BinaryTree, gdyż TreeNode jest przez nią używana. Aby to zrobić, musimy usunąć z deklaracji klasy TreeNode słowo kluczowe publi c.
232
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
8.5. Budowanie drzewa binarnego Napiszemy teraz funkcję służącą do budowania drzewa binarnego. Załóżmy, że chcemy utworzyć drzewo składające się z jednego wierzchołka.
Dane takiego drzewa będą podawane w następujący sposób: A @ @. Każdy znak @ reprezentuje wskaźnik pusty — n u ll. Aby zbudować drzewo przedstawione na kolejnym przykładzie, zastosujemy dane w postaci: A B @ @ C @ @.
Za każdym wierzchołkiem podawane jest bezpośrednio jego lewe poddrzewo, a następnie prawe poddrzewo. Dla porównania, w celu zbudowania poniższego drzewa zastosujemy dane o postaci: A B @ C @ @ @.
Dwa znaki @ umieszczone bezpośrednio za C reprezentują jego poddrzewa (które są puste), natomiast ostatni znak @ reprezentuje prawe poddrzewo wierzchołka A (które także jest puste). W końcu, by zbudować kolejne przykładowe drzewo przedstawione poniżej, użyjemy danych w postaci: C E F @ H @ @ B @ @ G A @ @ N J @ @ K @ @.
Dysponując tak określonym form atem danych, poniższa funkcja zbuduje drzewo binarne i zwróci wskaźnik na jego korzeń. public s t a t i c TreeNode buildTree(Scanner in) { String s t r = in .n e x t(); i f (s tr.e q u a ls ("@ ")) return n u ll; TreeNode p = new TreeNode(new N odeData(str)); p .le f t = b u ild T ree(in ); p .rig h t = b u ild T ree(in ); return p; } //koniec buildTree
233
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Funkcja ta odczytuje dane ze strumienia wejściowego przekazanego jako parametr in typu Scanner. Korzysta ona z klasy NodeData zdefiniowanej w następujący sposób. class NodeData { String word; public NodeData(String w) { word = w; } } //koniec klasy NodeData Funkcja buildTree będzie wywoływana wewnątrz konstruktora. public BinaryTree(Scanner in) { root = b u ild T ree(in ); } Załóżmy, że klasa użytkownika dysponuje danymi opisującymi drzewo, zapisanymi w pliku btree.in. W takim przypadku może ona utworzyć drzewo binarne przy użyciu takiego fragmentu kodu. Scanner in = new Scanner(new F ile R e a d e r("b tre e .in ")); BinaryTree bt = new BinaryTree(in); Po zbudowaniu drzewa powinniśmy mieć możliwość sprawdzenia, czy faktycznie zostało prawidłowo utworzone. Jednym ze sposobów, by to zrobić, jest przejście drzewa. Załóżmy, że chcemy wyświetlić wierzchołki drzewa bt, przechodząc je metodą pre-order. Byłoby dobrze, gdybyśmy mogli w tym celu użyć wywołania. bt.p reO rd er(); W tym celu musimy zaimplementować w klasie Bi naryTree metodę instancyjną o nazwie preOrder. Kod tej metody przedstawiono w poniższym listingu. Zamieszczono w nim także kod metod inOrder oraz postOrder. Dodano także konstruktor bezargumentowy, gdyby użytkownik chciał utworzyć puste drzewo binarne. K lasa B in aryT ree class BinaryTree { TreeNode root; public BinaryTree() { root = n u ll; } public BinaryTree(Scanner in) { root = bu ild T ree(in ); } public s t a t ic TreeNode buildTree(Scanner in) { S tring s t r = in .n e x t(); i f (s tr .e q u a ls ("@ ")) return n u ll; TreeNode p = new TreeNode(new NodeData(str)); p .le f t = bu ild T ree(in ); p .rig h t = bu ild T ree(in ); return p; } //koniec buildTree public void preOrder() { preO rderTraversal(root); } public void preOrderTraversal(TreeNode node) { i f (node!= null) {
234
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
n o d e .d a ta .v is it(); preOrderTraversal(n o d e .le ft); preOrderTraversal(n o d e .rig h t); } } //koniec preOrderTraversal public void inOrder() { inO rderTraversal(root); } public void inOrderTraversal(TreeNode node) { i f (node!= null) { in O rd erTraversal(nod e.left); n o d e .d a ta .v is it(); inO rderTraversal(node.right); } } //koniec inOrderTraversal public void postOrder() { postO rderTraversal(root); } public void postOrderTraversal(TreeNode node) { i f (node!= null) { p ostO rderT raversal(node.left); postO rderTraversal(node.right); n o d e .d a ta .v is it(); } } //koniec postOrderTraversal } //koniec klasy BinaryTree Wszystkie metody służące do przechodzenia drzewa korzystają z tej samej instrukcji, n o d e .d a ta .v is it(). Ponieważ node.data jest obiektem klasy NodeData, zatem klasa ta powinna definiować metodę v is it. W tym przykładzie chodzi jedynie o wyświetlenie wartości wierzchołka, zatem metoda v is i t może mieć następującą postać: public void v is i t ( ) { System .out.printf("% s " , word); } Teraz napiszemy program P8.1, w którym zbudujemy drzewo binarne i wyświetlimy jego wierzchołki, przechodząc je z pomocą metod pre-order, in -order oraz post-order. Jak zwykle, klasę BinaryTree możemy zadeklarować jako publiczną i umieścić w osobnym pliku — B inaryTree.java. Także klasę TreeNode możemy zadeklarować jako publiczną i umieścić w pliku T reeN ode.java. Gdyby jednak ktoś wolał umieścić cały kod programu w jednym pliku, np. B inaryTreeT est.java, musiałby pominąć słowo kluczowe publi c w deklaracjach klas BinaryTree i TreeNode. Program P8.1 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss BinaryTreeTest { public s t a tic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("b tre e .in ")); BinaryTree bt = new BinaryTree(in); S y stem .o u t.p rin tf("\ n P rzejście metodą pre-order: " ) ;
235
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
bt.p reO rd er(); System .ou t.printf("\n\nP rzejście metodą in-ord er: " ) ; bt.in O rd er(); System .ou t.printf("\n\nP rzejście metodą post-order: " ) ; bt.p ostO rd er(); System .ou t.printf("\ n\ n"); in .c lo s e (); } //koniec main } //koniec klasy BinaryTreeTest class NodeData { String word; public NodeData(String w) { word = w; } public void v i s i t ( ) { System .out.printf("% s " , word); } } //koniec klasy NodeData //kod klasy TreeNode //kod klasy BinaryTree Jeśli plik btree.in zawiera treść: C E F @ H @ @ B @ @ G A @ @ N J @ @ K @ @, program P8.1 utworzy drzewo binarne przedstawione na poniższym diagramie.
Następnie program wyświetli następujące wyniki. P rz e jście metodą pre-order: C E F H B G A N J K P rz e jście metodą in-ord er: F H E B C A G J N K P rz e jście metodą post-order: H F B E A J K N G C Możliwości metody buil dTree nie ograniczają się do budowania drzew, których wierzchołki zawierają pojedyncze znaki — zamiast nich mogą być stosowane dowolne łańcuchy (pozbawione odstępów, gdyż do odczytywania danych używamy łańcucha formatującego %s). Gdyby np. plik btree.in zawierał treść: hut dan bum @ @ fan @ @ rum k it @ @ wir @ @
236
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
to program P8.1 zbudowałby drzewo w postaci.
i jednocześnie wyświetlił następujące informacje. P rz e jście metodą pre-order: hut dan bum fan rum k it wir P rz e jście metodą in-ord er: bum dan fan hut k it rum wir P rz e jście metodą post-order: bum fan dan k it wir rum hut W arto przy tym zauważyć, że przejście drzewa binarnego metodami in-order oraz pre-order definiuje je w jednoznaczny sposób. To samo można powiedzieć o przejściu drzewa metodami in-order oraz post-order . Nie dzieje się tak jednak w przypadku przechodzeniu drzewa metodami pre-order i post-order . Innymi słowy, mogą istnieć dwa różne drzewa binarne A i B, takie że przejście drzewa A metodami pre-order i post-order da identyczne wyniki z wynikami przejścia metodami pre-order i post-order drzewa B. W ramach ćwiczenia warto poszukać przykładów takich drzew.
8.6. Binarne drzewa poszukiwań Rozważmy przykład jednego z możliwych drzew binarnych zawierających trzyliterowe słowa, przedstawionego na rysunku 8.3.
Rysunek 8.3. Binarne drzewo poszukiwań zawierające trzyliterowe słowa Jest to specjalny rodzaj drzewa binarnego. M a ono tę właściwość, że dla dowolnego wierzchołka słowo umieszczone w lewym poddrzewie jest „mniejsze” od słowa w danym wierzchołku, a słowo w prawym poddrzewie jest od niego „większe”. (W tym przypadku terminy większy i mniejszy odnoszą się do porządku alfabetycznego). Takie drzewa są nazywane binarnymi drzewami poszukiwań (ang. binary search trees, w skrócie BTS). Ułatwiają one poszukiwania podanego klucza przy użyciu algorytmu przypominającego nieco wyszukiwanie binarne w tablicy. Załóżmy, że poszukujemy słowa rwa. Zaczynamy od korzenia: poszukiwane słowo rwa jest porównywane z oda. Ponieważ jest od niego większe (w porządku alfabetycznym), zatem wnioskujemy, że jeśli poszukiwane słowo jest w drzewie, musi się znajdować w jego prawym poddrzewie. Musi tak być, gdyż wszystkie wierzchołki lewego poddrzewa są mniejsze od słowa oda. Przechodzimy do prawego poddrzewa wierzchołka oda: porównujemy poszukiwane słowo rwa ze słowem ti k. Ponieważ rwa jest mniejsze, zatem przechodzimy do lewego poddrzewa wierzchołka tik . Następnie porównujemy słowa rwa i rwa, co sprawia, że poszukiwania kończą się sukcesem. A co by było, gdybyśmy poszukiwali słowa fal ?
237
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
1. fal jest mniejsze od oda, więc przechodzimy do lewego poddrzewa. 2. fal jest mniejsze od lo t, więc przechodzimy do lewego poddrzewa. 3. fal jest większe od era, więc musimy przejść do prawego poddrzewa. Jednak prawe poddrzewo wierzchołka era jest puste, zatem dochodzimy do wniosku, że słowa fal nie ma w drzewie. Warto zwrócić uwagę, że gdyby trzeba było je dodać, to znamy już miejsce, w którym należy to zrobić. Należałoby je dodać jako prawe poddrzewo wierzchołka era, co pokazano na rysunku 8.4. I oda I
I era |
| mak |
| twa |
| vim |
m Rysunek 8.4. B in arn e drzew o poszu kiw ań p o dodan iu słow a f a l Jak widać, binarne drzewo poszukiwań nie tylko ułatwia wyszukiwanie, lecz także pozwala na łatwe dodanie elementu, w przypadku gdy nie zostanie znaleziony. A zatem łączy szybkość wyszukiwania binarnego i łatwość dodawania nowych elementów listy powiązanej. Drzewo przedstawione na rysunku 8.3 jest optymalnym drzewem poszukiwań dla podanych siedmiu wyrazów. Określenie, że jest to najlepsze drzewo poszukiwań oznacza, że dla podanych siedmiu wyrazów nie można utworzyć innego, płytszego drzewa. Zapewnia ono taką samą liczbę porównań podczas poszukiwań klucza, jaką trzeba by wykonać, używając wyszukiwania binarnego w tablicy zawierającej te same słowa. Jednak nie jest to jedyne drzewo poszukiwań, jakie można utworzyć dla tych słów. Załóżmy, że słowa są przekazywane do programu po jednym, a każde z nich jest dodawane do drzewa w taki sposób, że powstaje drzewo przypominające binarne drzewo poszukiwań. Końcowe drzewo, jakie w ten sposób zbudujemy, będzie zależne od kolejności odczytywanych słów. Załóżmy np., że słowa zostaną przekazane w następującej kolejności: mak tik oda era rwa lo t vim Początkowo drzewo jest puste. Po odczytaniu słowa mak stanie się ono korzeniem drzewa. • Następnym odczytanym słowem będzie tik . Po porównaniu z mak okaże się, że jest ono większe, zatem zostanie dodane do drzewa jako prawe poddrzewo wierzchołka mak. • Następnym odczytanym słowem będzie oda. Po porównaniu z mak okaże się, że jest większe, zatem przechodzimy do prawego poddrzewa. Słowo oda jest mniejsze od tik , zatem zostanie dodane do drzewa jako prawe poddrzewo wierzchołka t i k. • Następnym odczytanym słowem będzie era. Po porównaniu z mak okaże się, że jest mniejsze, zatem zostanie dodane do drzewa jako lewe poddrzewo wierzchołka mak. Na razie utworzone drzewo ma postać przedstawioną na rysunku 8.5.
Rysunek 8.5. B in arn e drzew o poszu kiw ań p o dodan iu słów m ak, tik, oda i era • Następnym odczytanym słowem będzie rwa. Po porównaniu z mak okaże się, że jest ono większe, zatem przechodzimy do prawego poddrzewa. Słowo rwa jest mniejsze od tik , zatem przechodzimy do lewego poddrzewa; jest także większe od oda, zatem zostanie dodane do drzewa jako prawe poddrzewo wierzchołka oda.
238
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
Postępując w ten sam sposób, do drzewa dodamy słowo lo t, które stanie się prawym poddrzewem wierzchołka era, oraz słowo vim, które stanie się prawym poddrzewem wierzchołka tik . Końcową postać tak utworzonego drzewa przedstawiono na rysunku 8.6.
Rysunek 8.6. B in arn e drzew o poszu kiw ań p o dodan iu siedm iu słów W arto zwrócić uwagę, że tak utworzone drzewo znacznie się różni od optymalnego drzewa poszukiwań. Zmieniła się także liczba porównań, które należy wykonać w celu odszukania podanego słowa. Przykładowo odszukanie słowa rwa wymaga teraz wykonania czterech porównań, a wcześniej wymagało trzech; natomiast odszukanie słowa lo t wymaga aktualnie trzech porównań, a wcześniej wymagało tylko dwóch. Jednak jest także dobra wiadomość: wcześniej odnalezienie słowa era wymagało trzech porównań, natomiast teraz wymaga tylko dwóch. Można udowodnić, że jeśli słowa są pobierane w kolejności losowej, średni czas ich poszukiwania będzie 1,4 razy dłuższy od średniego czasu poszukiwania w drzewie optymalnym, czyli dla drzewa o n wierzchołkach wyniesie 1,4log2n. A jaki będzie najgorszy z możliwych przypadków? Gdyby słowa były odczytywane w kolejności alfabetycznej, zostałoby utworzone drzewo przedstawione na rysunku 8.7.
Rysunek 8.7. D rzewo zdegen erow ane Poszukiwanie klucza w takim drzewie sprowadza się do wyszukiwania sekwencyjnego w liście powiązanej. Takie drzewo nazywamy zdegenerowanym. Istnieją takie sekwencje słów, które doprowadzą do powstania bardzo niezrównoważonych drzew binarnych. W ramach ćwiczenia warto narysować drzewa utworzone ze słów przekazywanych w następującej kolejności: •
vim tik rwa oda mak lo t era,
•
era vim lo t tik mak rwa oda,
•
vimera lo t tik rwa mak oda,
•
lo t mak vim tik era rwa oda.
239
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
8.7. Budowanie binarnego drzewa poszukiwań Teraz napiszemy funkcję, która umożliwi wyszukiwanie i wstawianie elementów do binarnego drzewa poszukiwań. Korzystając z przedstawionych wcześniej definicji klas TreeNode oraz BinaryTree, napiszemy metodę instancyjną klasy BinaryTree o nazwie findOrInsert. Będzie ona przeglądać drzewo w poszukiwaniu elementu NodeData, przekazanego jako parametr d. Jeśli uda się go odnaleźć, funkcja zwróci wskaźnik na znaleziony wierzchołek. W przeciwnym razie funkcja wstawi element do drzewa w odpowiednim miejscu, a następnie zwróci wskaźnik na nowy wierzchołek. public TreeNode findOrInsert(NodeData d) { i f (root == null) return root = new TreeNode(d); TreeNode curr = ro ot; int cmp; while ((cmp = d.compareTo(curr.data)) != 0) { i f (cmp < 0) { //próbujemy z lewej i f ( c u r r .le f t == null) return c u r r .le ft = new TreeNode(d); curr = c u r r .l e f t ; } e lse { //próbujemy z prawej i f (c u rr.rig h t == null) return c u rr.rig h t = new TreeNode(d); curr = c u rr .rig h t; } } //d jest w drzewie; zwracamy wskaźnik na wierzchołek return curr; } //koniec findOrInsert W warunku pętli while zastosowane zostało wyrażenie d. compareTo (c u rr.d a ta ). Sugeruje to, że w klasie NodeData musimy także napisać metodę compareT o, służącą do porównywania dwóch obiektów tego typu. Poniżej przedstawiono jej kod. public in t compareTo(NodeData d) { return this.word.compareTo(d.word); } Sprowadza się on do wywołania metody compareT o klasy String, gdyż używana w tym programie klasa NodeData zawiera tylko jedno pole będące obiektem typu String. Jednak gdyby nawet klasa ta definiowała także inne pola, też moglibyśmy uznać, że obiekty NodeData mają być porównywane wyłącznie na podstawie wartości pola word bądź któregokolwiek innego pola.
8.7.1. Przykład. zliczanie wystąpień słów Przedstawione do tej pory idee zilustrowane zostaną na przykładzie programu, który będzie zliczał ilość wystąpień słów w tekście. Poszczególne słowa będą przechowywane w binarnym drzewie poszukiwań. Każde odczytane słowo będzie poszukiwane w drzewie. Jeśli nie uda się go znaleźć, zostanie dodane do drzewa, a jego licznik wystąpień zostanie ustawiony na 1. Jeśli natomiast słowo zostanie odnalezione, program powiększy jego licznik o 1. Po przetworzeniu wszystkich danych wejściowych program przejdzie drzewo metodą in-order, co sprawi, że wszystkie zapisane w nim słowa zostaną wyświetlone w kolejności alfabetycznej. Na początek musimy zdefiniować klasę NodeData. Będzie się składała z dwóch pól (słowa oraz licznika jego wystąpień), konstruktora, funkcji do inkrem entacji licznika wystąpień i funkcji compareTo oraz v is it. Oto kod tej klasy. class NodeData { String word; int freq ;
240
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
public NodeData(String w) { word = w; freq = 0; } public void incrFreq() { ++freq; } public in t compareTo(NodeData d) { return this.word.compareTo(d.word); } public void v i s i t ( ) { WordFrequencyBST.out.printf("%-15s %2d\n", word, fre q ); } } //koniec klasy NodeData W arto zwrócić uwagę na metodę inkrem entującą wartość licznika wystąpień. W metodzie v is i t wyświetlamy daną przechowywaną w konkretnym wierzchołku, używając obiektu WordFrequencyBST.out. Klasą WordFrequencyBST zajmiemy się już za chwilę, jednak na razie trzeba zaznaczyć, że dajemy jej możliwość określenia, gdzie mają być kierowanie dane wyjściowe programu, a pole out określa używany strumień wyjściowy. Gdyby było trzeba, można by wyświetlać wyniki na ekranie, korzystając ze standardowego strumienia wyjściowego, czyli System.out, oraz jego metody p rin tf. Istotę algorytmu, który zastosujemy w tym programie, można opisać w następujący sposób. tworzymy puste drzewo; korzeniowi przypisujemy wartość null while ( je s t następne słowo) pobieramy słowo wyszukujemy słowo w drzewie; j e ś l i to konieczne, wstawiamy i ustawiamy lic z n ik na 0 inkrementujemy lic z n ik // zarówno w przypadku starych słów, jak i nowego słowa endwhile wyświetlamy słowa oraz lic z n ik ich wystąpień W naszym programie zdefiniujemy słowo jako dowolny, nieprzerwany ciąg wielkich i małych liter, w tym wybranych polskich znaków diakrytycznych. Innym i słowy, każdy znak, który nie jest literą, zostanie potraktowany jako separator. Dotyczy to szczególnie odstępów i znaków przestankowych. Jeśli założymy, że in jest obiektem klasy Scanner, jego odpowiednie działanie możemy uzyskać, stosując następujące wywołanie: in .u seD elim iter("[^a-zA -Z ąćłóśż]+"); //
a oznacza
"nie"
Fragment wyrażenia regularnego umieszczony wewnątrz nawiasów kwadratowych oznacza: „dowolny znak, który nie jest ani dużą, ani małą literą”, natomiast znak + informuje, że takich znaków może być więcej lub tylko jeden. Domyślnymi separatorami fragmentów tekstu odczytywanych przez metodę next() klasy Scanner są odstępy. Jednak można to zmienić i wskazać dowolny inny znak, który ma być separatorem. Gdybyśmy np. chcieli, by rolę separatora pełnił dwukropek, moglibyśmy to zrobić w następujący sposób: in .u se D e lim ite r (":"); Kiedy w naszym programie będziemy używali takiego obiektu in, wywołując metodę in .n e x t(), będzie ona zwracać łańcuchy znaków zawierające wszystkie znaki, aż do wystąpienia dwukropka, lecz bez niego. Aby rolę separatorów pełnił dwukropek lub przecinek, dla obiektu in wystarczy użyć wywołania: in .u s e D e lim ite r (" [ :,] " ); //zbiór znaków tworzymy, używając nawiasów [ oraz] Para nawiasów kwadratowych oznacza zbiór znaków. Z kolei poniższe wywołanie sprawi, że separatorami będą znaki: dwukropek, przecinek, kropka oraz znak zapytania. in .u se D e lim ite r("[:,\ \ .\ \ ? ]");
241
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Kropka oraz znak zapytania to tzw. metaznaki (czyli znaki mające specjalne znaczenie), dlatego też należy je poprzedzić znakiem odwrotnego ukośnika i zapisać w postaci \. oraz \?. W iemy już, że znak odwrotnego ukośnika umieszczamy w łańcuchu znaków, zapisując go jako \\. Gdy chcem y określić, że separatorem ma być dowolny znak z wyjątkiem małych liter, możemy to zrobić w następujący sposób: in .u se D e lim ite r("[^ a -z ]"); // Aoznacza negację, "nie" Wyrażenie a-z oznacza zakres znaków, w tym przypadku litery od a do z. Jeśli za nawiasami kwadratowymi dodamy znak +, będzie on oznaczał sekwencję składającą się z „jednego lub większej ilości” znaków, które nie są małymi literami. A zatem, ponieważ chcemy, by separatorem była sekwencja dowolnych liter, które nie są ani małymi, ani wielkimi literami, musimy użyć wywołania: in .u seD elim iter("[^a-zA -Z ]+"); Niestety, wbudowane mechanizmy języka Java i wyrażeń regularnych nie uwzględniają w zakresach a-z oraz A-Z polskich znaków diakrytycznych. Pozostaje zatem dodać do powyższego wyrażenia wybrane spośród tych znaków, występujące w przetwarzanym fragmencie tekstu. W ten sposób uzyskamy ostateczną postać wywołania, dzięki której słowa zostaną wyznaczone i policzone prawidłowo. in .u seD elim iter("[^a-zA -Z ąćęłó śż]+"); Poniżej przedstawiono kod programu P8.2, który zlicza wystąpienia wyrazów zapisanych w pliku w ordF req.in i działa zgodnie z opisanym wcześniej algorytmem. Program 8.2 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss WordFrequencyBST { s t a tic Scanner in; s t a tic PrintW riter out; public s t a tic void m ain(String[] args) throws IOException { in = new Scanner(new FileReader("w ordFreq.in")); out = new PrintWriter(new FileW riter("w ord Freq.out")); BinaryTree bst = new BinaryTree(); in.useDel im iter("[^a-zA -Z ąćłó śż]+"); while (in.hasN ext()) { String word = in.n ext().toL ow erC ase(); TreeNode node = bst.findO rInsert(new NodeData(word)); nod e.d ata.in crFreq(); } out.printf("\nSłow a Liczba wystąpień\n\n"); b st.in O rd e r(); in .c lo s e ( ) ; o u t.c lo s e (); } //koniec main } //koniec klasy WordFrequencyBST
// Klasa NodeData class NodeData { String word; int freq ; public NodeData(String w) { word = w; freq = 0;
242
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
} public void incrFreq() { ++freq; } public in t compareTo(NodeData d) { return this.word.compareTo(d.word); } public void v i s i t ( ) { WordFrequencyBST.out.printf("%-15s %2d\n", word, fre q ); } } //koniec klasy NodeData // tu należy umieścić kod klasy TreeNode // tu należy umieścić kod klasy BinaryTree (wraz z dodaną metodą findOrInsert) Warto zwrócić uwagę, że in oraz out zostały zdefiniowane jako statyczne zmienne klasowe. Nie jest to konieczne w przypadku pola in, które mogłoby zostać zadeklarowane wewnątrz metody main, gdyż jest używane tylko w niej. Jednak metoda v is it klasy NodeData musi wiedzieć, gdzie mają być zapisywane wyniki programu, i dlatego powinna mieć dostęp do pola out. Zapewniamy jej ten dostęp, deklarując to pole jako zmienną klasową. Metoda findOrInsert wymaga przekazania argumentu typu NodeData, dlatego też musimy utworzyć taki obiekt na podstawie zmiennej word, zanim wywołamy tę metodę. Obiekt ten jest tworzony w następujący sposób. TreeNode node = bst.findO rInsert(new NodeData(word)); Przejście drzewa przy użyciu metody in -order spowoduje, że słowa zapisane w drzewie zostaną wyświetlone w kolejności alfabetycznej. Załóżmy, że plik w ordFreq.in zawiera następujący fragment tekstu1. Je ż e li marząc - nie ulegasz marzeniom; Je ż e li rozumując - rozumowania nie czynisz celem; Je ż e li umiesz przyjąć sukces i porażkę, jednakowo tra k tu ją c oba te złudzenia; Je ż e li ś cie rp isz wypaczenie twoich słów, z których krętacze czynią zasadzkę na naiwnych, albo zaakceptujesz ruinę tego, co było tr e ś c ią twego życia, kiedy pokornie zaczniesz odbudowę zużytymi już narzędziami; W takim przypadku program P8.2 zapisze w pliku w ordF req.out następujące wyniki. Słowa albo było celem co czynisz czynią i jednakowo je ż e li już kiedy krętacze których marzeniom
Liczba wystąpień 1 1 1 1 1 1 1 1 4 1 1 1 1 1
1 Jest to fragment wiersza Josepha Rudyarda Kiplinga pt. Jeżeli, tłumaczenie pochodzi ze strony WWW: http://silvarerum.eu/kipling i zostało skopiowane w maju 2014 r.
243
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
marząc na naiwnych narzędziami nie oba odbudowę pokornie porażkę przyj ąć rozumowania rozumując ruinę sukces słów te tego trak tu jąc tre ś c i ą twego twoich ulegasz umiesz wypaczenie z zaakceptujesz zaczniesz zasadzkę zużytymi złudzenia ścierp i sz życia
8.8. Budowanie drzew binarnych ze wskaźnikami rodzica W iemy już, jak można przechodzić drzewa binarne metodami in-order, p re-ord er i post-order, używając rekurencji (która domyślnie bazuje na stosie) lub też korzystając z jawnego stosu. Teraz poznamy trzecie rozwiązanie. Najpierw zbudujmy drzewo zawierające wskaźniki „rodzica”. W tym drzewie każdy wierzchołek będzie zawierał jedno dodatkowe pole — wskaźnik na rodzica. W wierzchołku stanowiącym korzeń drzewa pole parent będzie miało wartość n u ll. Przykładowo w drzewie przedstawionym na rysunku 8.8 pole rodzica wierzchołka Hwskazuje na wierzchołek F, pole rodzica wierzchołka Awskazuje na G, a wierzchołka Gwskazuje na C.
Rysunek 8.8. D rzewo bin arn e ze w skaźn ikam i rodzica
244
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
Do reprezentacji wierzchołków takiego drzewa zastosujemy klasę TreeNode w postaci: class TreeNode { NodeData data; TreeNode l e f t , rig h t, parent; public TreeNode(NodeData d) { data = d; l e f t = rig h t = parent = n u ll; } } //koniec klasy TreeNode Możemy teraz zmodyfikować metodę buildTree. public s t a t i c TreeNode buildTree(Scanner in) { String s t r = in .n e x t(); i f (s tr.e q u a ls ("@ ")) return n u ll; TreeNode p = new TreeNode(new N odeData(str)); p .le f t = bu ild T ree(in ); i f ( p .le f t != null) p .le ft.p a re n t = p; p .rig h t = b u ild T ree(in ); i f (p .rig h t != null) p .rig h t.p aren t = p; return p; } //koniec buildTree Po utworzeniu lewego poddrzewa wierzchołka p sprawdzamy, czy jest ono równe null. Jeśli jest, nie pozostaje nam już nic więcej do zrobienia. Jeśli jednak jest ono różne od n u ll, a jego korzeniem jest q, w polu q.parent zapisujemy p. W ten sam sposób postępujemy, tworząc prawe poddrzewo. Kiedy dysponujemy wskaźnikami rodzica, możemy przejść drzewo bez korzystania z rekurencji oraz umieszczania argumentów i zmiennych lokalnych na stosie, co jest nierozerwalnie z nią związane. Przykładowo przejście metodą in -order można by wykonać w następujący sposób. pobieramy pierwszy wierzchołek w porządku in-ord er; nazwiemy go "node" while (node je s t różne od null) odwiedzamy wierzchołek pobieramy następny wierzchołek w porządku in-order endwhile Kiedy dysponujemy korzeniem drzewa, różnym od null, jego pierwszy wierzchołek w porządku in-order możemy znaleźć, używając następującego fragmentu kodu. TreeNode node = ro o t; while (n o d e .le ft != null) node = n o d e .le ft; W ten sposób przechodzimy tak daleko na lewą stronę drzewa, jak tylko można. Kiedy nie da się przejść dalej, będzie to znaczyło, że dotarliśmy do pierwszego wierzchołka w porządku in-order. Po wykonaniu tego kodu zmienna node będzie wskazywać pierwszy wierzchołek drzewa w porządku in-order. Podstawowym problemem, jaki musimy rozwiązać, jest uzyskanie następnika (ang. successor) dowolnego wierzchołka drzewa w porządku in -order, czyli wierzchołka, który podczas przechodzenia drzewa metodą in -order zostanie odwiedzony jako następny. Ostatni wierzchołek odwiedzony w taki sposób nie będzie posiadał żadnego następnika. Rozwiązując ten problem, należy uwzględnić dwie sytuacje. 1. Jeśli wierzchołek dysponuje niepustym prawym poddrzewem, jego następnikiem w porządku in-order będzie pierwszy wierzchołek prawego poddrzewa odwiedzony podczas jego przechodzenia metodą in-order. Możemy go wskazać przy użyciu następującej metody zwracającej wskaźnik na wierzchołek: i f (node.right != null) { node = node.right; while (n o d e .le ft != null) node = n o d e .le ft; return node; }
245
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Następnik wierzchołka G w porządku in -order znajdujemy, przechodząc o jeden wierzchołek w prawo (do N), a następnie tak daleko w lewo, jak to tylko możliwe (czyli do J). A zatem następnikiem wierzchołka Gw porządku in -order będzie J. 2. Jeśli wierzchołek ma puste prawe poddrzewo, jego następnikiem w porządku in -order będzie jeden z jego przodków. Ale który? Otóż jest to najniższy przodek, dla którego dany wierzchołek znajduje się w lewym poddrzewie. Przykładowo który wierzchołek drzewa będzie następnikiem wierzchołka B? Aby to określić, sprawdzamy rodzica wierzchołka B, czyli wierzchołek E. Ponieważ B znajduje się w prawym poddrzewie E, zatem nie jest to wierzchołek E. Następnie sprawdzamy rodzica wierzchołka E, czyli C. Ponieważ E (a zatem także i B) znajduje się w lewym poddrzewie wierzchołka C, zatem wnioskujemy, że następnikiem wierzchołka B w porządku in -order jest wierzchołek C. Trzeba także zwrócić uwagę, że wierzchołek K, który jest ostatnim wierzchołkiem drzewa w porządku in-order, nie ma żadnego następnika. Jeśli będziemy cofać się z wierzchołka K, używając wskaźników rodzica, nie znajdziemy żadnego wierzchołka, dla którego K znajduje się w lewym poddrzewie. W takim przypadku nasza funkcja zwróci n u ll. Na bazie tych idei napiszemy teraz dwie metody instancyjne klasy BinaryTree, czyli inOrderTraversal oraz inOrderSuccessor. public void inOrderTraversal() { i f (root == null) return; //znajdujemy pierwszy wierzchołek w kolejności in-order TreeNode node = ro ot; while (n o d e .le ft != null) node = n o d e .le ft; while (node != null) { n o d e .d a ta .v is it(); //z klasy NodeData node = inOrderSuccessor(node); } } //koniec inOrderTraversal private s t a tic TreeNode inOrderSuccessor(TreeNode node) { i f (node.right != null) { node = node.right; while (n o d e .le ft != null) node = n o d e .le ft; return node; } //wierzchołek nie ma prawego poddrzewa; szukamy najniższego //przodka, dla którego dany wierzchołek jest w lewym poddrzewie; //zwracamy null, jeśli nie uda się go znaleźć (wierzchołek jest //ostatni w porządku in-order) TreeNode parent = node.parent; while (parent != null && p aren t.rig h t == node) { node = parent; parent = node.parent; } return parent; } //koniec inOrderSuccessor
246
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
W ramach ćwiczenia można napisać podobne metody służące do przechodzenia drzewa metodami pre-order oraz post-order. W następnym punkcie rozdziału napiszemy program, który pozwoli przetestować działanie metody inOrderTraversal.
8.8.1. Budowanie binarnego drzewa poszukiwań ze wskaźnikami rodzica Możemy nieco zmodyfikować funkcję findOrInsert klasy Bi naryTree, aby tworzyła drzewo ze wskaźnikami rodzica. Oto jej nowa wersja. public TreeNode findOrInsert(NodeData d) { //funkcja szuka d w drzewie; jeśli znajdzie, zwraca wskaźnik na //wierzchołek; w przeciwnym razie dodaje d i zwraca wskaźnik na nowy //wierzchołek; w polu parent d zostaje zapisany wskaźnik na jego rodzica TreeNode curr, node; int cmp; i f (root == n u ll) { node = new TreeNode(d); node.parent = n u ll; return root = node; } curr = ro o t; while ((cmp = d.compareTo(curr.data)) != 0) { i f (cmp < 0) { //próbujemy z lewej i f ( c u r r .le f t == null) { c u r r .le f t = new TreeNode(d); c u rr .le ft.p a re n t = curr; return c u r r .l e f t ; } curr = c u r r .l e f t ; } e lse { //próbujemy z prawej i f (c u rr.rig h t == null) { c u rr.rig h t = new TreeNode(d); cu rr.rig h t.p aren t = curr; return c u rr .rig h t; } curr = c u rr .rig h t; } //koniec else } //koniec while return curr; //d jest w drzewie; zwracamy wskaźnik na wierzchołek } //koniec findOrInsert Kiedy trzeba dodać do drzewa wierzchołek (np. N), a curr wskazuje istniejący wierzchołek, który będzie rodzicem nowego, po prostu go dodajemy i zapisujemy curr w polu parent wierzchołka N. Działanie funkcji findOrInsert oraz inOrderTraversal można sprawdzić za pomocą programu P8.3. Program P8.3 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss BinarySearchTreeTest { public s t a t i c void m ain(String[] args) throws IOException { Scanner in = new Scanner(new FileR eader("w ord s.in")); BinaryTree bst = new BinaryTree(); in.useD elim ite r("[^a-zA -Z ąćęłó śż]+");
247
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
while (in.hasN ext()) j String word = in .n ext().to lo w erC ase(); TreeNode node = bst.findO rInsert(new NodeData(word)); j System .ou t.printf("\n\nP rzejście drzewa metodą in-ord er: " ) ; b st.in O rd erT rav ersal(); S y stem .o u t.p rin tf("\ n "); i n .c lo s e () ; j //koniec main j //koniec klasy BinarySearchTreeTest class NodeData j String word; public NodeData(String w) j word = w; j public in t compareTo(NodeData d) j return this.word.compareTo(d.word); j public void v i s i t ( ) j System .out.printf("% s " , word); j j //koniec klasy NodeData class TreeNode j NodeData data; TreeNode l e f t , rig h t, parent; public TreeNode(NodeData d) j data = d; l e f t = rig h t = parent = n u ll; j j //koniec klasy TreeNode // klasa BinaryTree - przedstawiono tu jedynie metody używane w programie class BinaryTree j TreeNode root; public BinaryTree() j root = n u ll; j public void inOrderTraversal() j i f (root == null) return; //znajdujemy pierwszy wierzchołek w kolejności in-order TreeNode node = ro o t; while (n o d e .le ft != null) node = n o d e .le ft; while (node != null) j n o d e .d a ta .v is it(); //z klasy NodeData node = inOrderSuccessor(node); j j //koniec inOrderTraversal private s t a t ic TreeNode inOrderSuccessor(TreeNode node) j i f (node.right != null) j node = node.right; while (n o d e .le ft != null) node = n o d e .le ft; return node;
248
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
} //wierzchołek nie ma prawego poddrzewa; szukamy najniższego //przodka, dla którego dany wierzchołek jest w lewym poddrzewie; //zwracamy null, jeśli nie uda się go znaleźć (wierzchołek jest //ostatni w porządku in-order) TreeNode parent = node.parent; while (parent != null && p aren t.rig h t == node) { node = parent; parent = node.parent; } return parent; } //koniec inOrderSuccessor // tu należy wstawić kod metody findOrInsert przedstawionej wcześniej w tym punkcie rozdziału } //koniec klasy BinaryTree Program P8.3 odczytuje słowa z pliku w ords.in, buduje binarne drzewo poszukiwań, a następnie przechodzi je metodą in -order i wyświetla słowa w kolejności alfabetycznej. Załóżmy, że plik words.in zawiera następujące słowa. mak tik oda era rwa lo t vim W takim przypadku program P8.3 zbuduje poniższe binarne drzewo poszukiwań ze wskaźnikami rodzica.
I mak I
Po czym wyświetli następujące wyniki. P rz e jście drzewa metodą in-ord er: era lo t mak oda rwa tik vim
8.9. Przechodzenie drzewa poziomami Oprócz przedstawionych wcześniej metod przechodzenia drzew — in-order, p re-order oraz post-order — istnieje jeszcze jeden użyteczny sposób. przechodzenie drzewa poziomami (określane także metodą level-order). W tym przypadku przechodzimy drzewo poziom po poziomie, zaczynając od korzenia. Załóżmy np., że dysponujemy drzewem.
249
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W przypadku zastosowania metody level-order wierzchołki tego drzewa zostaną odwiedzone w kolejności: C E G B A N F J. Aby przejść drzewo w taki sposób, musimy użyć kolejki. Sam algorytm przechodzenia można opisać następująco. dodajemy korzeń do kolejki Q while (Q nie je s t pusta) pobieramy wierzchołek z początku kolejki Q i zapisujemy go w p odwiedzamy p je ś l i (le ft(p ) je s t różne od n u ll), dodajemy le ft(p ) do kolejki Q je ś l i (righ t(p ) je s t różne od n u ll), dodajemy righ t(p ) do kolejki Q endwhile Oto jak wyglądałoby zastosowanie tego algorytmu dla przedstawionego wcześniej drzewa. • Dodajemy wierzchołek C do kolejki Q. • Qnie jest pusta, więc usuwamy z niej wierzchołek C i odwiedzamy go; dodajemy do Qwierzchołki E i G; aktualna zawartość kolejki to E G. • Qnie jest pusta, więc usuwamy z niej wierzchołek E i odwiedzamy go; dodajemy do Qwierzchołek B; aktualna zawartość kolejki to G B. • Qnie jest pusta, więc usuwamy z niej wierzchołek Gi odwiedzamy go; dodajemy do Qwierzchołki Ai N; aktualna zawartość kolejki to B A N. • Qnie jest pusta, więc usuwamy z niej wierzchołek B i odwiedzamy go; dodajemy do Qwierzchołek F; aktualna zawartość kolejki to A N F. • Qnie jest pusta, więc usuwamy z niej wierzchołek Ai odwiedzamy go; nic nie dodajemy do kolejki, więc je j aktualną zawartością jest N F. • Qnie jest pusta, więc usuwamy z niej wierzchołek Ni odwiedzamy go; dodajemy do Qwierzchołek J; aktualna zawartość kolejki to F J. • Qnie jest pusta, więc usuwamy z niej wierzchołek F i odwiedzamy go; nic nie dodajemy do kolejki, jej aktualna zawartość to J. • Qnie jest pusta, więc usuwamy z niej wierzchołek J i odwiedzamy go; nic nie dodajemy do kolejki, zatem kolejka nie zawiera już żadnych wierzchołków. • Qjest pusta, więc proces przechodzenia został zakończony, a wierzchołki drzewa zostały odwiedzone w kolejności C E G B A N F J. Do wykonywania operacji na kolejce będziemy potrzebowali odpowiedniej klasy. Najpierw jednak zdefiniujemy klasę QueueData. class QueueData { TreeNode node; public QueueData(TreeNode n) { node = n; } } //koniec klasy QueueData Następnie definiujemy klasę QNode. class QNode { QueueData data; QNode next; public QNode(QueueData d) { data = d; next = n u ll;
250
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
} } //koniec klasy QNode I w końcu piszemy kasę Queue. class Queue { QNode head = n u ll, ta il = n u ll; public boolean empty() { return head == n u ll; } public void enqueue(QueueData nd) { QNode p = new QNode(nd); i f (th is.em pty()) { head = p; ta il = p; } e lse { ta il .next = p; ta il = p; } } //koniec enqueue public QueueData dequeue() { i f (th is.em pty()) { System .out.printf("\nPróba usunięcia elementu z pustej k o lejk i\ n "); S y s te m .e x it(l); } QueueData hold = head.data; head = head.next; i f (head == null) ta il = n u ll; return hold; } //koniec dequeue } //koniec klasy Queue Koniecznie należy zauważyć, że jeśli chcemy umieścić klasę QueueData w tym samym pliku, co klasę Queue lub program, który jej używa, to z jej deklaracji należy usunąć słowo kluczowe publi c. To samo dotyczy klasy QNode. Dysponując klasami Queue oraz QueueData, możemy już dodać do klasy BinaryTree metodę instancyjną l evelOrderTraversal. public void levelO rderTraversal() { Queue Q = new Queue(); Q.enqueue(new QueueData(root)); while (!Q.empty()) { QueueData temp = Q.dequeue(); te m p .n o d e .d ata.v isit(); i f (tem p.node.left != null) Q.enqueue(new QueueData(tem p.node.left)); i f (temp.node.right != null) Q.enqueue(new QueueData(temp.node.right)); } } //koniec levelOrderTraversal Łącząc ze sobą wszystkie te klasy i metody, możemy napisać program P8.4, który buduje drzewo na podstawie zawartości pliku btree.in, a następnie przechodzi je metodą level-order. Należy pamiętać, że aby umieścić cały ten program w jednym pliku, wyłącznie klasa zawierająca metodę main może być zadeklarowana jako publiczna. Na poniższym listingu w pozostałych klasach zostały przedstawione tylko te metody, które mają znaczenie dla rozwiązania problemu.
251
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Program 8.4 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss LevelOrderTest { public s t a tic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("b tre e .in ")); BinaryTree bt = new BinaryTree(in); System .ou t.printf("\n\nP rzejście drzewa metodą lev el-o rd er: " ) ; b t .l evelOrderTraversal ( ) ; S y stem .o u t.p rin tf("\ n "); in .c lo s e (); } // koniec main } //koniec klasy LevelOrderTest class NodeData { String word; public NodeData(String w) { word = w; } public void v i s i t ( ) { System .out.printf("% s " , word); } } //koniec klasy NodeData class TreeNode { NodeData data; TreeNode l e f t , rig h t, parent; public TreeNode(NodeData d) { data = d; l e f t = rig h t = parent = n u ll; } } //koniec klasy TreeNode //klasa BinaryTree - przedstawiono tu jedynie metody używane w programie class BinaryTree { TreeNode root; public BinaryTree() { root = n u ll; } public BinaryTree(Scanner in) { root = b u ild T ree(in ); } public s t a t ic TreeNode buildTree(Scanner in) { String s t r = in .n e x t(); i f (s tr.e q u a ls ("@ ")) return n u ll; TreeNode p = new TreeNode(new N odeData(str)); p .le f t = bu ild T ree(in ); p .rig h t = b u ild T ree(in );
252
ROZDZIAŁ 8. M WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
return p; j //koniec buildTree public void levelO rderTraversal() j Queue Q = new Queue(); Q.enqueue(new QueueData(root)); while (!Q.empty()) j QueueData temp = Q.dequeue(); tem p .n o d e.d ata.v isit(); i f (tem p.node.left != null) Q.enqueue(new QueueData(tem p.node.left)); i f (temp.node.right != null) Q.enqueue(new QueueData(temp.node.right)); j j //koniec levelOrderTraversal j //koniec klasy BinaryTree class QueueData j TreeNode node; public QueueData(TreeNode n) j node = n; j j //koniec klasy QueueData class QNode j QueueData data; QNode next; public QNode(QueueData d) j data = d; next = n u ll; j j //koniec klasy QNode class Queue j QNode head = n u ll, ta il = n u ll; public boolean empty() j return head == n u ll; j public void enqueue(QueueData nd) j QNode p = new QNode(nd); i f (th is.em pty()) j head = p; ta il = p; j e lse j t a il.n e x t = p; ta il = p; j j //koniec enqueue public QueueData dequeue() j i f (th is.em pty()) j System .out.printf("\nPr5ba usunięcia elementu z pustej k o lejk i\ n "); S y s te m .e x it(l); j
253
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
QueueData hold = head.data; head = head.next; i f (head == null) ta il = n u ll; return hold; } //koniec dequeue } //koniec klasy Queue Załóżmy, że plik btree.in ma następującą zawartość. C E @ B F @ @ @ G A @ @ NJ @ @ @ W takim przypadku program P8.4 zbuduje drzewo przedstawione na początku tego podrozdziału i wyświetli poniższe wyniki. P rz e jście drzewa metodą lev el-o rd er: C E G B A N F J
8.10. Użyteczne funkcje operujące na drzewach binarnych W tym podrozdziale przedstawiono kilka funkcji (zdefiniowanych w klasie BinaryTree), które zwracają informacje o drzewie binarnym. Pierwsza z nich określa liczbę wierzchołków drzewa. public in t numNodes() { return countNodes(root); } private int countNodes(TreeNode root) { i f (root == null) return 0; return 1 + countN odes(root.left) + countN odes(root.right); } Jeśli bt jest drzewem binarnym, wywołanie bt .numNodes() zwróci liczbę jego wierzchołków. Samo zadanie policzenia wierzchołków jest przekazywane do prywatnej funkcji countNodes. Kolejna funkcja zwraca liczbę liści w drzewie. public in t numLeaves() { return countLeaves(root); } private int countLeaves(TreeNode root) { i f (root == null) return 0; i f ( r o o t .le f t == null && ro o t.rig h t == null) return 1; return cou n tL eaves(roo t.left) + cou ntL eaves(root.right); } Następna określa wysokość drzewa. public in t height() { return numLevels(root); } private int numLevels(TreeNode root) { i f (root == null) return 0; return 1 + M ath.max(num Levels(root.left), num Levels(root.right)); }
254
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
Funkcja Math.max zwraca większy z dwóch przekazanych argumentów. W arto wypróbować te funkcje na jakim ś przykładowym drzewie, by przekonać się, czy zwracają wyniki zgodne z oczekiwaniami.
8.11. Usuwanie wierzchołków z binarnego drzewa poszukiwań Przeanalizujmy problem usuwania wierzchołka z binarnego drzewa poszukiwań w taki sposób, by po przeprowadzeniu tej operacji zachowało ono swoje właściwości i wciąż było binarnym drzewem poszukiwań. Musimy w tym celu rozważyć trzy przypadki. 1. W ierzchołek jest liściem. 2. (a) W ierzchołek nie ma lewego poddrzewa. (b) W ierzchołek nie ma prawego poddrzewa. 3. W ierzchołek ma niepuste lewe i prawe poddrzewo. Zilustrujemy wszystkie te przypadki na przykładzie drzewa przedstawionego na rysunku 8.9.
Rysunek 8.9. B in arn e drzew o poszukiw ań Przypadek pierwszy jest bardzo łatwy. Aby np. usunąć wierzchołek P, wystarczy zapisać null w prawym poddrzewie wierzchołka N. Także drugi przypadek jest łatwy. Aby usunąć wierzchołek A (który nie ma lewego poddrzewa), zastępujemy go wierzchołkiem C, czyli jego prawym poddrzewem. Aby usunąć wierzchołek F (który nie ma prawego poddrzewa), zastępujemy go wierzchołkiem A, czyli jego lewym poddrzewem. Przypadek trzeci jest nieco bardziej skomplikowany, ponieważ trzeba rozwiązać problem, co zrobić z jego oboma poddrzewami. W jaki sposób można np. usunąć wierzchołek L? Jednym z rozwiązań jest zastąpienie wierzchołka L przez jego następnik w porządku in-order, czyli N, który m usi mieć puste lewe poddrzewo. A dlaczego? Ponieważ definicja stwierdza, że następnikiem wierzchołka w porządku in -order jest pierwszy wierzchołek (w porządku in-order) znajdujący się w jego prawym poddrzewie. A ten pierwszy wierzchołek (w każdym drzewie) można znaleźć, przechodząc możliwie jak najdalej w lewo. Ponieważ Nnie ma lewego poddrzewa, zatem w jego polu lewego wskaźnika zapiszemy lewe poddrzewo wierzchołka L. Z kolei w polu lewego wskaźnika rodzica N(w naszym przypadku jest to R) zapiszemy P — prawe poddrzewo N. I w końcu, w polu prawego wskaźnika Nzapiszemy prawe poddrzewo L, przez co nasze drzewo binarne przyjmie postać przedstawioną na rysunku 8.10. Można to wyobrazić sobie w jeszcze inny sposób, a mianowicie tak, że zawartość wierzchołka Njest kopiowana do L. Jednocześnie w polu lewego wskaźnika rodzica N(czyli wierzchołka R) jest zapisywany wskaźnik na prawe poddrzewo N(czyli na wierzchołek P). W naszym algorytmie usuwamy wierzchołek jako korzeń poddrzewa. Usuwamy podany wierzchołek i zwracamy wskaźnik na korzeń zrekonstruowanego drzewa.
255
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
deleteNode(TreeNode T) { i f (T == null) return null i f (righ t(T ) == null) return le ft(T ) //przypadki 1 i 2b R = righ t(T) i f (le ft(T ) == null) return R //przypadek 2a i f (le ft(R ) == nuli) { le ft(R ) == le ft(T ) return R }
while (le ft(R ) P = R R = le ft(R ) }
!= null) { //pętla zostanie wykonana przynajmniej raz
//R wskazuje na następnik T w porządku in-order; //P jest jego rodzicem le ft(R ) = le ft(T ) le ft(P ) = right(R) right(R) = righ t(T) return R } //koniec deleteNode
Załóżmy, że wywołamy metodę deleteNode, przekazując do niej wskaźnik na wierzchołek L (rysunek 8.9). W efekcie metoda ta usunie L i zwróci wskaźnik na następujące drzewo.
Ponieważ L było prawym poddrzewem wierzchołka H, zatem teraz w polu prawego wskaźnika wierzchołka Hmożemy zapisać N— korzeń zwróconego drzewa.
256
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
8.12. Tablice jako sposób reprezentacji drzew binarnych Drzewo binarne nazywamy kompletnym, jeśli każdy jego wierzchołek niebędący liściem posiada dwa niepuste poddrzewa, a wszystkie liście znajdują się na tym samym poziomie. Przykłady kompletnych drzew binarnych przedstawiono na rysunku 8.11.
Rysunek 8.11. K om pletn e drzew a bin arn e Z lewej strony umieszczone zostało kompletne drzewo binarne o wysokości 1; drugi przykład przedstawia kompletne drzewo binarne o wysokości 2, natomiast z prawej strony widoczne jest drzewo o wysokości 3. Ogólnie rzecz biorąc, kompletne drzewo binarne o wysokości n składa się z 2n-1 wierzchołków. Przeanalizujmy trzecie drzewo. Ponumerujmy jego wierzchołki, tak jak pokazano na rysunku 8.12.
Rysunek 8.12. N um erow anie w ierzchołków drzew a p o ziom p o p o ziom ie Zaczynając od korzenia, któremu przypisujemy wartość 1, numerujemy wierzchołki kolejno poziomami, zaczynając od lewej i numerując wierzchołki do prawej. W arto zwrócić uwagę, że jeśli wierzchołek ma etykietę n, jego lewe poddrzewo będzie mieć etykietę 2n, a prawe etykietę 2n+1. Jeśli teraz zapiszemy wierzchołki w tablicy T [ 1 ..7 ] , takiej jak ta:
to: • T[1] będzie korzeniem drzewa, • lewym poddrzewem T[i] jest T[2i], o ile 2i < = 7 lub nul l w przeciwnym przypadku, • prawym poddrzewem T[i] jest T [2i+1], o ile 2i+1 < = 7 lub nul l w przeciwnym przypadku, • rodzicem wierzchołka T[i] jest T[i/2] (przy czym chodzi tu o dzielenie całkowite). Kiedy będziemy bazować na takich założeniach, tablica będzie reprezentacją kompletnego drzewa binarnego. Innymi słowy, dysponując taką tablicą, możemy łatwo zrekonstruować drzewo binarne, które ona reprezentuje. Tablica może reprezentować kompletne drzewo binarne, jeśli dla pewnego n liczba jej elementów wynosi 2n-1 . W przypadku innej liczby elementów tablica będzie reprezentować prawie kompletne drzewo binarne.
257
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
P raw ie kom pletn e drzew o bin arn e spełnia następujące warunki. • Wszystkie jego poziomy, być może z wyjątkiem ostatniego, są całkowicie wypełnione. • W ierzchołki (wyłącznie liście) tworzące najniższy poziom są przesunięte tak daleko na lewo, jak tylko to możliwe. Jeśli wierzchołki drzewa będą ponumerowane, tak jak na poprzednim przykładzie, wszystkie liście będą miały etykiety o wartościach z przedziału od n/2+1 do n. Ostatni wierzchołek niebędący liściem będzie miał etykietę o wartości n/2. Przykładowo przyjrzyjmy się drzewu składającemu się z 10 wierzchołków, przedstawionemu na rysunku 8.13.
Rysunek 8.13. D rzewo sk ła d a ją ce się z dziesięciu w ierzchołków pon u m erow an ych p o ziom a m i W arto zwrócić uwagę, że liście tego drzewa zostały ponumerowane liczbami od 6 do 10. Gdyby np. Hbyło prawym poddrzewem wierzchołka B, a nie lewym, to drzewo nie byłoby „prawie kompletne”, gdyż liście położone na najniższym poziomie „nie byłyby przesunięte tak daleko na lewo, jak tylko to możliwe”. Poniższa tablica składająca się z 10 elementów może reprezentować niemal kompletne drzewo binarne. r c
i
G
F
8
A
N
J
K
H
1
1
3
4
5
6
7
8
9
10
Ogólnie rzecz biorąc, jeśli drzewo jest reprezentowane przez tablicę T [1 ..n ], spełnione są następujące założenia. • Korzeniem drzewa jest T [1]. • Lewym poddrzewem T[i] jest T[2i], jeśli 2i < = n lub nul l w przeciwnym przypadku. • Prawym poddrzewem T[i] jest T [2i+1], jeśli 2i+1 < = n lub null w przeciwnym przypadku. • Rodzicem wierzchołka T[i] jest T[i/2] (przy czym chodzi tu o dzielenie całkowite). Patrząc na to w nieco inny sposób, można rzec, że istnieje dokładnie jedno prawie kompletne drzewo binarne o n wierzchołkach i jest ono reprezentowane przez tablicę o wielkości n. Prawie kompletne drzewo binarne nie ma żadnych „dziur”; nie ma w nim miejsca na dodawanie nowych wierzchołków pomiędzy już istniejącymi. Jedynym miejscem, gdzie można by dodać wierzchołek, jest sam koniec drzewa. Przykładowo drzewo przedstawione na rysunku 8.14 nie jest „prawie kompletne”, gdyż znajduje się w nim „dziura” — konkretnie stanowi ją puste prawe poddrzewo wierzchołka B. Ze względu na dziurę lewe poddrzewo wierzchołka A (na pozycji 6.) nie zn ajdu je się na pozycji 6*2 = 12, a prawe na pozycji 6*2+1 = 13. Ta relacja zachodzi wyłącznie wtedy, gdy drzewo jest prawie kompletne. Kiedy dysponujemy tablicą T [1 ..n ] reprezentującą prawie kompletne drzewo binarne o n wierzchołkach, możemy to drzewo przejść metodą in -order, posługując się poniższą funkcją wywoływaną w sposób inOrder(1, n).
258
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
Rysunek 8.14. Puste pra w e poddrzew o w ierzchołka B spraw ia, że to drzew o nie jest „praw ie kom p letn e” public s t a t i c void inOrder(int h, in t n) { i f (h <= n) { inOrder(h * 2, n); v is i t ( h ) ; //lub visit(T[h]), jeśli tak wolimy inOrder(h * 2 + 1, n); } } //koniec inOrder Możemy napisać podobne funkcje do przechodzenia drzewa w porządku p re-ord er oraz post-order. Dla porównania, pełnym drzewem binarnym nazywamy drzewo, w którym wszystkie wierzchołki z wyjątkiem liści mają dokładnie dwa niepuste poddrzewa. Przykład takiego drzewa przedstawiono na rysunku 8.15.
Rysunek 8.15. P ełne drzew o bin arn e W arto zauważyć, że kompletne drzewo binarne zawsze jest pełne, jednak — jak pokazano na rysunku 8.15 — pełne drzewo binarne niekoniecznie musi być kompletne. Prawie kompletne drzewo binarne może, lecz nie musi być pełne. Na rysunku 8.16 przedstawiono prawie kompletne, lecz niepełne drzewo binarne (wierzchołek G ma tylko jed n o niepuste poddrzewo).
Rysunek 8.16. P raw ie kom pletne, lecz n iepełne drzew o bin arn e Gdyby jednak usunąć z niego wierzchołek A, drzewo stałoby się prawie kompletne i pełne. W następnym rozdziale wyjaśniono, jak można sortować tablicę, traktując ją jak prawie kompletne drzewo binarne.
259
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Ćwiczenia 1. Drzewo binarne składa się z klucza całkowitego, wskaźników na lewe i prawe poddrzewo oraz wskaźnika rodzica. Napisz deklaracje potrzebne do zbudowania takiego drzewa oraz kod, który utworzy puste drzewo. 2. Każdy wierzchołek drzewa binarnego ma następujące pola: l e f t , righ t, key oraz parent. Napisz funkcję zwracającą następnik podanego wierzchołka x w porządku in-order. Podpowiedź:jeśliprawe poddrzewo wierzchołka x jest puste i x ma następnik y, to y jest najmniejszym przodkiemx, dlaktórego x znajduje się w lewym poddrzewie. Napisz funkcję zwracającą
następnik podanego wierzchołka x w porządku
p re-order.
Napisz funkcję zwracającą
następnik podanego wierzchołka x w porządku
post-order.
Korzystając z tych funkcji, napisz funkcje przechodzące wskazane drzewo binarne w porządkach in-order, p re-order oraz post-order. 3. Wykonaj ćwiczenie 2. jeszcze raz, zakładając, że drzewo binarne jest zapisane w tablicy. 4. Napisz funkcję, która — dysponując korzeniem binarnego drzewa poszukiwań — usuwa z niego najmniejszy wierzchołek, a następnie zwraca wskaźnik na korzeń zrekonstruowanego drzewa. 5. Napisz funkcję, która — dysponując korzeniem binarnego drzewa poszukiwań — usuwa z niego największy wierzchołek, a następnie zwraca wskaźnik na korzeń zrekonstruowanego drzewa. 6. Napisz funkcję, która — dysponując korzeniem binarnego drzewa poszukiwań — usuwa z niego korzeń i zwraca wskaźnik na korzeń zrekonstruowanego drzewa. Napisz funkcję, która zastępuje korzeń a) jego następnikiem w porządku in-order, b) jego poprzednikiem w porządku in-order. 7. Narysuj niezdegenerowane drzewo binarne składające się z pięciu wierzchołków i takie, że jego przejście w porządkach p re -orderoraz level-orderzwróó takie same wyniki. 8. Napisz funkcję, która — dysponując korzeniem drzewa binarnego — zwróci jego sz erokoś ć, czyli maksymalną liczbę wierzchołków na danym poziomie drzewa. 9. Binarne drzewo poszukiwań zawiera liczby całkowite. Dla każdej z poniższych sekwencji należy stwierdzić, czy może to być sekwencja wartości sprawdzanych podczas poszukiwania liczby 36. Jeśli nie może, wyjaśnij, dlaczego. 7 25 42 40 33 34 39 36 92 22 91 24 89 20 35 36 95 20 90 24 92 27 30 36 7 46 41 21 26 39 37 24 36 10. Narysuj binarne drzewo poszukiwań uzyskane po wczytaniu następujących kluczy przy założeniu, że będą one wstawiane w następującej kolejności: 56 30 61 39 47 35 75 13 21 64 26 73 18. Z tych kluczy da się utworzyć jedno prawie kompletne binarne drzewo poszukiwań. Narysuj je. Podaj klucze w takiej kolejności, która pozwoliłaby na utworzenie prawie kompletnego binarnego drzewa poszukiwań. Zakładając, że prawie kompletne drzewo jest zapisane w jednowymiarowej tablicy num[1..13], napisz funkcję rekurencyjną, która wyświetli liczby w tablicy w porządku post-order. 11. Do każdego pustego wskaźnika drzewa binarnego o n wierzchołkach dołączany jest fikcyjny wierzchołek „zewnętrzny". Jak wiele tych zewnętrznych wierzchołków zostanie dołączonych do drzewa? Jeśli I jest sumą poziomów wierzchołków oryginalnego drzewa, a E sumą poziomów wierzchołków zewnętrznych, udowodnij, że E-I = 2n. (I jest nazywane dł ugo ścią ście żki wewn ętrznej). Napisz funkcję rekurencyjną, która — dysponując korzeniem drzewa — zwraca wartość I. Napisz funkcję nierekurencyjną, która — dysponując korzeniem drzewa — zwraca wartość I.
260
ROZDZIAŁ 8. ■ WPROWADZENIE DO ZAGADNIEŃ DRZEW BINARNYCH
12. Narysuj drzewo binarne, którego przejście w porządkach in-orderorazpost-orderzwróci następujące sekwencje wierzchołków: in-order.
G DP K E NF A T L
Post-order.
G P DK F N T A L E
13. Narysuj drzewo binarne, którego przejście w porządkach pre-orderoraz in-orderzwróci następujące sekwencje wierzchołków: Pre-order.
N DGK P E T F AL
In-order.
G DP K E NF A T L
14. Narysuj dwa różne drzewa binarne, takie że przejście jednego i drugiego w porządkach pre-ordeń post-order zwróci identyczne wyniki. 15. Napisz funkcję rekurencyjną, która — dysponując korzeniem drzewa binarnego oraz kluczem — spróbuje odnaleźć ten klucz podczas przechodzenia drzewa w: a) porządku pre-order, b) porządku in-order, c) porządku post-order. Jeśli klucz uda się znaleźć, funkcja ma zwracać wskaźnik na wierzchołek, w przeciwnym razie ma zwrócić n u ll. 16. Zapisz poniższe liczby całkowite w tablicy b s t [ l . . l 5 ] , takiej że bst reprezentuje kompletne binarne drzewo poszukiwań: 34 23 45 46 37 78 90 2 40 20 87 53 12 15 91 17. Każdy wierzchołek binarnego drzewa poszukiwań zawiera trzy pola — le ft, right oraz data — o standardowym przeznaczeniu, przy czym pole data zawiera dodatnie liczby całkowite. Napisz wydajną funkcję, która — dysponując korzeniem drzewa oraz kluczem key — zwróci najmniejszą liczbę w drzewie większą od wartości key. Jeśli takiej liczby nie uda się znaleźć, funkcja powinna zwrócić wartość -1. 18. Napisz program, którego danymi wejściowymi będzie kod źródłowy programu napisanego w języku Java. Program ma wyświetlać wczytany kod, numerując jego kolejne wiersze, a następnie wyświetlić indeks wszystkich identyfikatorów, czyli listę, w której będzie prezentowany identyfikator oraz numery wierszy, w jakich został on użyty. Jeśli dany identyfikator pojawia się kilka razy w tym samym wierszu, w indeksie numer wiersza ma zostać wyświetlony taką samą ilość razy. Indeks nie możezawieraćsłów kluczowych języka Java, zawartości łańcuchów znaków ani słów wpisywanych w komentarzach.
261
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
262
ROZDZIAŁ 9
Zaawansowane metody sortowania W tym rozdziale wyjaśnimy takie zagadnienia jak: • sterta oraz algorytm sortowania przez kopcowanie przy użyciu metody si ftDown, • tworzenia kopca za pomocą metody siftUp, • analiza wydajności algorytmu sortowania przez kopcowanie, • użycie kopca w celu implementacji kolejki priorytetowej, • sortowanie listy elementów z wykorzystaniem algorytmu sortowania szybkiego, • odnajdywanie fc-tego najmniejszego elementu listy, • sortowanie listy elementów z zastosowaniem algorytmu sortowania Shella (z użyciem malejących odstępów). W rozdziale 1. przedstawiono dwie proste metody sortowania (sortowanie przez wybieranie oraz przez wstawianie), pozwalające na sortowanie listy elementów. W tym rozdziale dokładnie przeanalizujemy kilka innych, bardziej wydajnych metod sortowania — sortowanie przez kopcowanie (ang. heapsort), sortowanie szybkie (ang. quicksort) oraz sortowanie Shella (ang. shellsort).
9.1. Sortowanie przez kopcowanie Sortowanie przez kopcowanie (ang. heapsort) to algorytm sortowania, który interpretuje elementy w tablicy jako prawie kompletne drzewo binarne. Przeanalizujmy następującą tablicę, którą należy posortować w kolejności rosnącej. num 37
25
43
65
48
84
73
18
79
56
69
32
1
2
3
4
5
6
7
8
9
10
11
12
Taką tablicę możemy potraktować jako prawie kompletne drzewo binarne składające się z 12 wierzchołków, które zostało przedstawione na rysunku 9.1. Załóżmy, że teraz postawimy wymóg, by wartości w każdym z wierzchołków były większe lub równe wartościom w lewym i prawym poddrzewie; zakładamy przy tym, że nie są one puste. Krótko mówiąc, zobaczymy, w jaki sposób można przeorganizować wierzchołki tak, by każdy z nich spełniał tę właściwość. Zanim to zrobimy, nadamy takiej strukturze danych nazwę kopca.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 9.1. Zawartość tablicy przedstawiona w form ie drzewa binarnego Kopcem nazywamy prawie kompletne drzewo binarne, takie że wartość korzenia tego drzewa jest
większa lub równa wartościom jego lewego i prawego dziecka, a prawe i lewe poddrzewo także są kopcami. Natychmiastową konsekwencją takiej definicji jest to, że największa wartość w drzewie znajduje się w jego korzeniu. Taki kopiec jest nazywamy kopcem maksymalnym (ang. max-heap ). W podobny sposób możemy zdefiniować kopiec m inimalny — wystarczy zamienić relację większości na relację mniejszości. A zatem w kopcu minimalnym jego korzeń zawiera wartość najmniejszą . Spróbujmy teraz zmodyfikować drzewo binarne z rysunku 9.1, by stało się kopcem maksymalnym.
9.1.1. Konwersja drzewa binarnego w kopiec maksymalny Najpierw należy zauważyć, że wszystkie liście są kopcami, gdyż nie mają żadnych dzieci. Zaczynając od ostatniego wierzchołka, który nie jest liściem (w naszym przykładzie jest to wierzchołek numer 6), przekształcimy drzewo, którego jest on korzeniem, w kopiec maksymalny. Jeśli wartość wierzchołka jest większa od wartości jego dzieci, nie musimy nic robić. Tak właśnie jest w przypadku wierzchołka 6., gdyż 84 jest większe od 32. Następnie przechodzimy do wierzchołka o numerze 5. W jego przypadku wartość 48 jest mniejsza od wartości przynajmniej jednego dziecka (w właściwie, od obu dzieci, gdyż ich wartości wynoszą odpowiednio 56 i 69). Najpierw znajdujemy większe dziecko (69) i zamieniamy jego zawartość z zawartością wierzchołka 5. W efekcie liczba 69 zostaje zapisana w wierzchołku 5., a liczba 48 w wierzchołku 11. Następnie przechodzimy do wierzchołka 4. Większe z jego dzieci, 79, zostaje przeniesione do wierzchołka 4., a 65 do wierzchołka 9. Po zakończeniu tego etapu prac drzewo ma postać przedstawioną na rysunku 9.2.
Rysunek 9.2. Drzewo po przetworzeniu wierzchołków 6., 5. oraz wierzchołka numer 4
264
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Teraz przechodzimy do wierzchołka 3. W jego przypadku konieczne jest przeniesienie liczby 43. Większą z wartości dzieci tego wierzchołka jest 84, zatem zamieniamy wartości w w ierzchołkach 3. i 6. Aktualna wartość wierzchołka 6. (43) jest większa od wartości jego dziecka (32), więc nie musimy robić nic więcej. Trzeba jednak zwrócić uwagę, że gdyby wartością wierzchołka 6. była liczba (przykładowo) 28, musielibyśmy zamienić ją miejscami z liczbą 32. Po przejściu do wierzchołka 2. okazuje się, że konieczne jest zamienienie umieszczonej w nim liczby 28 z wartością większego z dzieci, czyli liczbą 79. Kiedy to zrobimy, liczba 25 umieszczona w wierzchołku 4. będzie mniejsza od liczby 65 w jego prawym dziecku, czy wierzchołku numer 9. Zatem także te dwie liczby należy zamienić miejscami. I w końcu, po przejściu do wierzchołka 1. zamieniamy jego zawartość, 37, z zawartością większego z jego dzieci, czyli z liczbą 84. Następnie jest ona dalej zamieniana z (nowym) większym dzieckiem zawierającym liczbę 73. W ten sposób drzewo staje się kopcem, a jego ostateczną postać przedstawiono na rysunku 9.3. 1 ^ —v
2 *— ( 79 )
i n )
—v5 69 )
00
K
)
( 25 )
<1° (56)
Z~Y ( 43 )
t 37
)
C48)C32)
Rysunek 9.3. O stateczna p o sta ć drzewa, które ju ż je s t kopcem
9.1.2. Proces sortowania W arto zwrócić uwagę, że po przekształceniu w kopiec korzeń drzewa zawiera największą z jego wartości, 84. Skoro wartości zapisane w tablicy tworzą kopiec, zatem teraz możemy je posortować w kolejności rosnącej. Oto sposób, w jaki należy to zrobić. • Musimy zapisać ostatni element, czyli 32, w jakim ś tymczasowym miejscu i przenieść liczbę 84 na ostatnie miejsce (do wierzchołka numer 12), zwalniając tym samym wierzchołek numer 1. Teraz musimy wyobrazić sobie, że liczba 32 znajduje się w wierzchołku 1. i przenieść ją tak, by elementy tablicy z zakresu od 1 do 11 tworzyły kopiec. Można to zrobić w następujący sposób. • 32 jest zamieniane z większym z jego dzieci, czyli liczbą 79, która zostaje przeniesiona do wierzchołka 1. Liczba 32 jest dalej zamieniana z jej (nowym) większym dzieckiem, którym jest liczba 69; co sprawia, że liczba 32 trafia do wierzchołka 2. W końcu liczba 32 zostaje zamieniona z liczbą 56, co sprawia, że drzewo przyjmuje postać przedstawioną na rysunku 9.4.
Rysunek 9.4. P ostać drzew a p o um ieszczeniu wartości 84 w odpow iednim m iejscu i zreorganizow aniu drzew a
265
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Na tym etapie sortowania druga największa liczba, 79, znajduje się w wierzchołku 1. Umieszczamy ją zatem w wierzchołku 11., a liczba 48 zostaje „przesiana w dół” z wierzchołka 1., tak by wierzchołki od 1. do 10. tworzyły kopiec. Teraz trzecią co do wielkości liczbą umieszczoną w drzewie jest 73, liczba ta znajduje się w jego korzeniu. W kolejnym etapie sortowania umieszczamy ją w wierzchołku 10. itd. Taki proces jest kontynuowany, aż do momentu posortowania całej tablicy. Po początkowym utworzeniu kopca proces jego sortowania można opisać, posługując się pseudokodem, w następujący sposób. fo r k = n downto 2 do item = num[k] //pobieram y aktualnie ostatni element num[k] = num[1] // przenosimy wierzchołek kopca do jego ostatniego wierzchołka siftDown(item, num, 1, k-1) / / odtwarzamy właściwości kopca w zakresie od 1 do k-1 endfor Przy czym metoda siftDown(item, num, 1, k-1) przyjmuje, że spełnione są następujące założenia: • element num[1] jest pusty, • elementy num[2] do num[k-1] tworzą kopiec. Zaczynamy od pozycji num er 1: wartość item jest wstawiana w taki sposób, że num[1] do num[k-1] tworzą kopiec. W opisanym powyżej procesie sortowania podczas każdej iteracji pętli wartość z aktualnie ostatniej pozycji (k) jest zapisywana w zmiennej item. W artość z wierzchołka 1. jest przenoszona na pozycję k, wierzchołek 1. zostanie opróżniony (i dostępny), a wszystkie wierzchołki z zakresu od 2 do k-1 spełniają warunek kopca. Wywołanie siftDown(item, num, 1, k-1) doda wartość w zmiennej item do tablicy w taki sposób, że elementy num[1] do num[k-1] będą tworzyć kopiec. Dzięki temu zapewnimy, że kolejna największa wartość w kopcu znajdzie się w wierzchołku 1. Bardzo użyteczną cechą metody s iftDown (kiedy już ją napiszemy) będzie to, że przy jej użyciu będziemy w stanie utworzyć początkowy kopiec z przekazanej tablicy. Przypomnijmy sobie proces tworzenia kopca opisany w punkcie 9.1.1. Dla każdego wierzchołka (dajmy na to h) „przesiewamy wartość w dół”, tak by utworzyć kopiec o korzeniu w wierzchołku h. Aby zastosować metodę siftDown w obecnej sytuacji, musimy uogólnić ją w następujący sposób: void siftDown(int key, int num[], in t ro o t, int la st) Metoda ta zakłada, że spełnione są następujące warunki: • element num[root] jest pusty, • indeks la s t wskazuje ostatni element tablicy num, • element num[root*2], jeśli istnieje (root*2 < la s t), jest korzeniem kopca, • element num[root*2+1], jeśli istnieje (root*2+1 < la s t), jest korzeniem kopca. Zaczynamy od elementu o indeksie root: wartość key jest wstawiana do tablicy num w taki sposób, że num[root] będzie korzeniem kopca. Dysponując tablicą wartości num[1] do num[n], możemy utworzyć kopiec, postępując w sposób opisany następującym pseudokodem. fo r h = n/2 downto 1 do // n/2 jest ostatnim wierzchołkiem, który nie jest liściem siftDown(num[h], num, h, n); Teraz zajmiemy się napisaniem metody siftDown. Przeanalizujmy kopiec przedstawiony na rysunku 9.5.
266
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Rysunek 9.5. Kopiec, z w yjątkiem w ierzchołków o num erach 1 i 2 Wszystkie wierzchołki, z wyjątkiem 1. i 2., spełniają warunki kopca, czyli są większe od swoich dzieci lub im równe. Załóżmy, że chcemy sprawić, by wierzchołek 2. stał się korzeniem kopca. Aktualnie umieszczona w nim liczba 25 jest mniejsza od jego dzieci (którymi są liczby 79 i 69). Chcemy zatem napisać metodę siftDown tak, by poniższe wywołanie doprowadziło do utworzenia kopca: siftDown(26, num, 2, 12) W powyższym wywołaniu 25 jest wartością parametru key, numjest tablicą, 2 jest indeksem korzenia, a 12 indeksem ostatniego przetwarzanego elementu tablicy. Po zakończeniu wywołania każdy z wierzchołków, od 2. do 12., będzie korzeniem kopca, a następujące wywołanie sprawi, że cała tablica będzie kopcem: siftDown(37, num, 1, 12) Zarys działania metody siftDown można opisać w następujący sposób. znajdujemy większe z dzieci wierzchołka num[root]; //załóżmy, że jest to wierzchołek m i f (key >= num[m]) gotowe; zapisujemy key w num[root] //wartość key jest mniejsza od większego z dzieci zapisujemy num[m] w num[root] // wybieramy większe z dzieci ustawiamy root na m Ten proces jest powtarzamy tak długo, jak długo wartość wierzchołka root jest większa od wartości jego dzieci bądź też wierzchołek ten nie będzie mieć dzieci. Poniżej przedstawiono kod metody siftDown. public s t a t i c void siftDown(int key, in t[] num, int ro o t, in t la s t) { int bigger = 2 * root; while (bigger <= la s t) { //dopóki jest co najmniej jedno dziecko i f (bigger < la s t) //istnieje także prawe dziecko; znajdujemy większe i f (num[bigger+1] > num[bigger]) bigger++; //'bigger' zawiera indeks większego dziecka i f (key >= num[bigger]) break; //wartość key jest mniejsza; wybieramy num[bigger] num[root] = num[bigger]; root = bigger; bigger = 2 * ro ot; } num[root] = key; } //koniec siftDown Teraz możemy już napisać kod metody heapSort; oto on. public s t a t i c void heapSort(int[] num, int n) { //sortujemy zakres tablicy od num[1] do num[n] //przekształcamy tablicę w kopiec
267
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
fo r (in t k = n / 2; k >= 1; k--) siftDown(num[k], num, k, n); fo r (in t k = n; k > 1; k-- ) { int item = num[k]; //pobieramy aktualnie ostatni element num[k] = num[1]; //przenosimy wierzchołek kopca do ostatniego elementu siftDown(item, num, 1, k -1 ); //odtwarzamy warunek kopca w zakresie od 1 do k-1 } } //koniec heapSort Działanie metody heapSort możemy sprawdzić przy użyciu programu P9.1. Program P9.1 import ja v a .i o .* ; public c la ss HeapSortTest { public s t a tic void m ain(String[] args) throws IOException { in t[] num = {0, 37, 25, 43, 65, 48, 84, 73, 18, 79, 56, 69, 32}; int n = 12; heapSort(num, n); fo r (in t h = 1; h <= n; h++) System .out.printf("% d ", num[h]); S y stem .o u t.p rin tf("\ n "); } public s t a t i c void heapSort(int[] num, int n) { //sortujemy zakres tablicy od num[1] do num[n] //przekształcamy tablicę w kopiec fo r (in t k = n / 2; k >= 1; k--) siftDown(num[k], num, k, n); fo r (in t k = n; k > 1; k-- ) { int item = num[k]; //pobieramy aktualnie ostatni element num[k] = num[1]; //przenosimy wierzchołek kopca do ostatniego elementu siftDown(item, num, 1, k -1 ); //odtwarzamy warunek kopca w zakresie od 1 do k-1 } } //koniec heapSort public s t a t i c void siftDown(int key, in t[] num, int ro o t, in t la s t) { int bigger = 2 * root; while (bigger <= la s t) { //dopóki jest co najmniej jedno dziecko i f (bigger < la s t) //istnieje także prawe dziecko; znajdujemy większe i f (num[bigger+1] > num[bigger]) bigger++; //'bigger' zawiera indeks większego dziecka i f (key >= num[bigger]) break; //wartość key jest mniejsza; wybieramy num[bigger] num[root] = num[bigger]; root = bigger; bigger = 2 * ro ot; } num[root] = key; } //koniec siftDown } //koniec klasy HeapSortTest Po uruchomieniu program P9.1 wygeneruje następujące wyniki (elementy num[1] do num[12] zostaną posortowane). 18 25 32 37 43 48 56 65 69 73 79 84
268
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Uwaga program istyczna : w przedstawionej postaci metoda heapSort sortuje tablicę, przy założeniu, że n elem entów zostało zapisanych w niej, zaczynając od indeksu 1 do n. Gdyby w artości miały być zapisywane w kom órkach o indeksach od 0 do n-1, konieczne byłoby wprowadzenie stosownych zmian, odpowiadających następującym obserwacjom . • Korzeniem drzewa jest element num[0]. • Lewym dzieckiem wierzchołka h jest wierzchołek 2h+1, jeśli 2h+1 < n. • Prawym dzieckiem wierzchołka h jest wierzchołek 2h+2, jeśli 2h+2 < n. • Rodzicem wierzchołka h jest wierzchołek (h -1)/2 (przy czym jest to dzielenie całkowite). • Ostatnim wierzchołkiem, który nie jest liściem, jest wierzchołek (n -2)/2 (przy czym jest to dzielenie całkowite). Wszystkie te obserwacje można zweryfikować, analizując drzewo (n = 12) przedstawione na rysunku 9.6.
Rysunek 9.6. Drzewo binarne zapisane w tablicy zaczyna się od elementu o indeksie 0 Zachęcamy do napisania metody heapSort w taki sposób, by sortowała tablicę w zakresie num [0..n-1]. W ramach podpowiedzi należy zwrócić uwagę, że jedyną zmianą, jaką trzeba w tym celu wprowadzić w kodzie metody siftDown, jest sposób obliczania wartości zmiennej bigger — zamiast 2 * root należy użyć wyrażenia 2 * root + 1.
9.2. Budowanie kopca przy użyciu metody siftUp Przeanalizujmy problem dodawania nowego wierzchołka do istniejącego kopca. Konkretnie rzecz biorąc, załóżmy, że elementy tablicy num[1] do num[n] zawierają kopiec. Chcemy dodać do niego nową liczbę newKey, w taki sposób, by num[1] do num[n+1] tworzyły kopiec zawierający wartość newKey. Zakładamy przy tym, że tablica zawierająca kopiec jest na tyle duża, by można w niej umieścić nowy klucz. Załóżmy np., że dysponujemy kopcem przedstawionym na rysunku 9.7 i chcemy dodać do niego liczbę 40. Po dodaniu kolejnej liczby tablica będzie zawierać 13 elementów. Przyjmujemy, że liczba 40 została początkowo umieszczona w komórce num[13] (jednak na razie jeszcze jej nie zapisujemy w komórce) i porównujemy ją z rodzicem, czyli liczbą 43 umieszczoną w kom órce num[6]. Ponieważ 40 jest mniejsze od 43, zatem warunek kopca jest spełniony, a my możemy umieścić nową liczbę w kom órce num[13], co zakończy proces dodawania. Załóżmy jednak, że chcemy dodać do kopca liczbę 80. Ponownie wyobrażamy sobie, że umieszczamy ją w komórce num[13] (choć jeszcze tego nie robimy) i porównujemy z rodzicem, czyli komórką num[6] zawierającą wartość 43. Ponieważ 80 jest większe od 43, zatem przenosimy 43 do num[13] i wyobrażamy sobie, że zapisujemy liczbę 80 w komórce num[6]. Następnie porównujemy 80 z wartością nowego rodzica — wartością kom órki num[3] — czyli z liczbą 73. Ponieważ 80 jest większe, zatem przenosimy 43 do kom órki num[6] i wyobrażamy sobie, że zapisujemy 80 w komórce num[3].
269
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
56 Rysunek 9.7. Kopiec, do którego dodamy nowy element W końcu porównujemy 80 z wartością nowego rodzica — wartością kom órki num[1] — czyli z liczbą 84. Ponieważ liczba 80 jest mniejsza, zatem zapisujemy ją w num[3] i przetwarzanie zostaje zakończone. Należy zwrócić uwagę, że gdybyśmy do kopca dodawali liczbę 90, wartość 84 zostałaby przeniesiona do komórki num[3], a 90 została zapisana w kom órce num[1]. W ten sposób nowy element stanie się największą wartością kopca. Na rysunku 9.8 przedstawiono kopiec po dodaniu do niego liczby 80.
Rysunek 9.8. Kopiec po dodaniu liczby 80 Poniższy kod pozwala dodać wartość newKey do kopca zapisanego w tablicy num, w zakresie num[1] do num[n]. child = n + 1; parent = child / 2; while (parent > 0) { i f (newKey <= num[parent]) break; num[child] = num[parent]; //przenosimy rodzica w dół child = parent; parent = child / 2; } num[child] = newKey; n = n + 1; Opisany powyżej proces jest zazwyczaj określany jako przesiewanie w górę (ang. sifting up). Możemy przepisać go w formie metody si ftUp. Zakładamy, że do metody będzie przekazywana tablica h eap [1..n ], taka że heap[1..n -1] zawiera kopiec, a heap[n] zawiera element do „przesiania w górę”. W efekcie metoda ma zapewnić, że tablica heap[1. .n] będzie zawierać kopiec. Innym i słowy, element heap[n] pełni rolę zmiennej newKey z powyższych rozważań. Kod metody siftU p przedstawimy jako fragment programu P9.2, który tworzy kopiec na podstawie liczb odczytywanych z pliku heap.in .
270
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Program P9.2 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss SiftUpTest { final s t a tic in t MaxHeapSize = 100; public s t a t ic void main (S trin g [] args) throws IOException { Scanner in = new Scanner(new FileR ead er("h eap .in ")); in t[] num = new int[MaxHeapSize + 1]; int n = 0, number; while (in .h asN extIn t()) { number = in .n e x tIn t(); i f (n < MaxHeapSize) { //sprawdzamy, czy tablica jest dostatecznie duża num[++n] = number; siftUp(num, n); } } fo r (in t h = 1; h <= n; h++) System .out.printf("% d ", num[h]); S y stem .o u t.p rin tf("\ n "); in .c lo s e (); } //fconiec main public s t a tic void s iftU p (in t[] heap, int n) { //heap[ 1] do heap[n-1] zawiera kopiec //przesiewamy wartość heap[n] w górę kopca, tak by heap[1..n] zawierała kopiec int siftIte m = heap[n]; int child = n; int parent = child / 2; while (parent > 0) { i f (s iftIte m <= heap[parent]) break; heap[child] = heap[parent]; //przenosimy rodzica w dół child = parent; parent = child / 2; } heap[child] = siftIte m ; } //koniec siftUp } //koniec klasy SiftUpTest Załóżmy, że plik heap.in zawiera następujące liczby: 37 25 43 65 48 84 73 18 79 56 69 32 Program P9.2 zbuduje kopiec (opisany poniżej) i wyświetli następujące wyniki. 84 79 73 48 69 37 65 18 25 43 56 32 Po wczytaniu liczb 37, 25 oraz 43 kopiec będzie miał postać przedstawioną na rysunku 9.9.
’© 2
Rysunek 9.9. K opiec p o przetw orzeniu liczb 37, 25 i 43
271
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Po wczytaniu kolejnych liczb, takich jak 65, 48, 84 i 73, kopiec przyjmie postać przedstawioną na rysunku 9.10.
Rysunek 9.10. Kopiec po przetworzeniu liczb 65, 48, 84 i 73 Po wczytaniu kolejnych liczb, 18, 79, 56, 69 i 32, kopiec przyjmie postać przedstawioną na rysunku 9.11.
18 Rysunek 9.11. Ostateczna postać kopca po przetworzeniu liczb 18, 79, 56, 69 i 32 W arto zwrócić uwagę, że kopiec z rysunku 9.11 jest inny niż przedstawiony na rysunku 9.3, choć oba zostały utworzone na podstawie tych samych liczb. Nie zmieniło się jednak to, że największa z liczb umieszczonych w kopcu, czyli 84, znajduje się w jego korzeniu. Jeśli wartości zostały już zapisane w tablicy num[1..n], możemy przekształcić je w kopiec, używając następującego wywołania: fo r (in t k = 2; k <= n; k++) siftUp(num, k);
9.3. Analiza algorytmu sortowania przez kopcowanie Która z metod, siftUp czy si ftDown, lepiej nadaje się do tworzenia kopca? Trzeba pamiętać, że w większości przypadków liczba przesunięć wierzchołka wyniesie log 2n. W metodzie siftDown przetwarzamy n/2 wierzchołków i w ram ach każdego etapu wykonujemy dwa porównania: jedno, by znaleźć większe dziecko, i drugie, by porównać je z wartością wierzchołka. Uproszczona analiza pokazuje, że w najgorszym przypadku będziemy musieli wykonać 2*n/2*log2n = nlog2n porównań. Jednak nieco bardziej dokładna analiza pozwala wykazać, że konieczne będzie wykonanie co najwyżej 4n porównań. W metodzie si ftUp przetwarzamy n -1 wierzchołków. W każdym z etapów wykonujemy jedno porównanie: wierzchołka z jego rodzicem. Uproszczona analiza pokazuje, że w najgorszym przypadku wykonamy (n-1)log2n porównań. Może się jednak zdarzyć, że wszystkie węzły będą musiały przebyć całą drogę, aż do korzenia drzewa. W takim przypadku mamy n/2 wierzchołków, które muszą przebyć drogę o długości log 2n, co daje łączną liczbę (n/2)log2n porównań. A powyższe rozważania dotyczą wyłącznie liści. Ostatecznie szczegółowa analiza pokazuje, że łączna liczba porównań wykonywanych przez metodę siftUp wynosi w przybliżeniu nlog 2n .
272
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Ta różnica w wydajności jest związana z faktem, że w metodzie si ftDown nie trzeba wykonywać żadnych operacji dla połowy wierzchołków (liści), natomiast metoda si ftUp właśnie dla tych wierzchołków musi wykonać większość operacji. Niezależnie od tego, której metody użyjemy do utworzenia początkowego kopca, algorytm sortowania przez kopcowanie posortuje tablicę o wielkości n, wykonując przy tym co najwyżej 2 nlog2n porównań oraz nlog 2n operacji przypisania. To bardzo szybki algorytm. Co więcej, jest to algorytm stabilny, w tym znaczeniu, że jego najgorsza wydajność zawsze wynosi 2 nlog2n, niezależnie od początkowej kolejności elementów w tablicy. Aby unaocznić, jak szybkie jest sortowanie przez kopcowanie (oraz wszystkie inne algorytmy sortowania o złożoności rzędu O(nlog 2n), takie jak sortowanie szybkie lub sortowanie przez scalanie), porównajmy go z algorytmem sortowania przez wybieranie, który podczas sortowania tablicy n-elementowej wykonuje około 1/2n2 porównań. W yniki tego porównania przedstawiono w tabeli 9.1. Tabela 9.1. P orów n an ie sortow an ia p rzez kopcow an ie z sortow aniem p rz ez w ybieranie n
wybieranie (porówn.)
kopcowanie (porówn.)
wybieranie (w sekundach)
kopcowanie (w sekundach)
100
5000
1329
0,005
0,001
1000
500 000
19 932
0,5
0,020
10 000
50 000 000
265 754
50
0,266
100 000
5 000 000 000
3 321 928
5000
3,322
1 000 000
500 000 000 000
39 863 137
500 000
39,863
W drugiej oraz trzeciej kolumnie pokazano liczbę porównań, które należy wykonać. Z kolei w ostatnich dwóch kolumnach przedstawiono czasy wykonania każdej z metod (wyrażone w sekundach), przy założeniu, że komputer jest w stanie wykonać milion porównań na sekundę. Aby np. posortować milion elementów, sortowanie przez wybieranie będzie potrzebować 500 tysięcy sekund (prawie 6 dni!), natomiast sortowanie przez kopcowanie poradzi sobie z tym w ciągu niespełna 40 sekund.
9.4. Kopce i kolejki priorytetowe Kolejka priorytetowa to kolejka, w której poszczególnym elementom są przypisywane pewne „priorytety” określające położenie danego elementu w kolejce. Element z najwyższym priorytetem jest umieszczany na początku kolejki. Poniżej przedstawiono kilka typowych operacji wykonywanych na kolejkach priorytetowych. • Usunięcie (udostępnienie) elementu o najwyższym priorytecie. • Dodanie elementu o podanym priorytecie. • Usunięcie (usunięcie bez udostępniania) elementu z kolejki. • Zmiana priorytetu elementu i aktualizacja jego położenia zgodnie z nowym priorytetem. Priorytet możemy sobie wyobrazić jako liczbę całkowitą — im wyższa liczba, tym wyższy priorytet. Od razu możemy zgadnąć, że jeśli zaimplementujemy taką kolejkę jako kopiec maksymalny, element 0 najwyższym priorytecie znajdzie się w jego korzeniu, dzięki czemu bardzo łatwo będzie można go usunąć. Reorganizacja kopca będzie wymagać „przesiania w dół” ostatniego elementu z korzenia kopca. Dodanie nowego elementu będzie wymagać umieszczenia go za aktualnie ostatnim elementem kopca 1 przesiania w górę, aż do mom entu określenia właściwego położenia. Aby usunąć z kolejki dowolny element, konieczna będzie znajomość jego położenia. Sam proces usunięcia będzie polegał na zamienieniu usuwanego elementu z aktualnie ostatnim elementem kopca, a następnie przesianiu go w górę, aż do określenia właściwego położenia. W efekcie kopiec zmniejszy się o jeden element.
273
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jeśli zmienimy priorytet elementu, może się okazać, że konieczne jest przesianie go w górę lub w dół, w celu umieszczenia w odpowiednim położeniu. Oczywiście, w zależności od zmiany, może się okazać, że element pozostanie w swoim oryginalnym położeniu. W wielu sytuacjach (np. w kolejkach zadań stosowanych w systemach wielozadaniowych) priorytet zadania może się zwiększać z upływem czasu, dzięki czemu w końcu zostanie ono wykonane. W takich sytuacjach po każdej zmianie priorytetu zadanie jest przesuwane coraz bliżej korzenia kopca; jak można się domyślić, wymaga to jedynie przesiania elementów w górę. W typowych rozwiązaniach informacje o elementach kolejki priorytetowej są przechowywane w innej strukturze danych, w której można je łatwo odnaleźć, np. w drzewie binarnym. Jedno pole wierzchołka będzie zawierać indeks elementu tablicy używanej do przechowywania kolejki priorytetowej. Kontynuując przykład priorytetowej kolejki zadań, załóżmy, że chcemy dodać do niej nowy element. M ożemy przeszukać drzewo na podstawie np. numeru zadania i dodać dany element do drzewa. W artość jego priorytetu posłuży do określenia miejsca w kolejce, w którym zadanie będzie umieszczone. Położenie to zostanie następnie zapisane w wierzchołku drzewa. Jeśli później priorytet zadania ulegnie zmianie, zmienione zostanie także położenie zadania w kolejce, a jego nowe położenie ponownie będzie zapisane w wierzchołku drzewa. W arto zwrócić uwagę, że zmiana położenia tego elementu może także powodować zmianę położenia innych elementów w kolejce (które będą przesuwane w górę lub w dół kopca), zatem także dla tych elementów konieczne będzie przeprowadzenie aktualizacji drzewa.
9.5. Sortowanie listy elementów przy użyciu sortowania szybkiego U podstaw algorytmu sortowania szybkiego (ang. quicksort) leży idea podziału listy na dwie części, względem pewnego, wybranego elementu, nazywanego elementem rozdzielającym (ang. pivot ). Załóżmy np., że naszym zadaniem jest posortowanie następującej tablicy liczb. num
53
12
98
63
18
32
80
46
72
21
1
2
3
i
5
6
7
8
9
10
Taką tablicę możemy podzielić względem pierwszej wartości, 53. Oznacza to umieszczenie wartości 53 w takim miejscu, że wszystkie wartości znajdujące się w tablicy na lewo od niej będą mniejsze, a znajdujące się na prawo będą od niej większe lub jej równe. Innymi słowy, algorytm podziału tablicy num opiszemy w następujący sposób. num
21
12
18
32
46
53
80
98
72
63
1
2
3
4
5
6
7
8
9
10
W artość 53 służy jako element rozdzielający . Zostaje umieszczona w komórce o numerze 6. Wszystkie wartości na lewo od 53 są od niej mniejsze, a wszystkie wartości na prawo są od niej większe. Miejsce, w którym jest umieszczony element rozdzielający, nosi nazwę punktu podziału (ang. division point ; oznaczymy je jako dp). Z definicji wartość 53 znajduje się już w swoim docelowym, posortowanym położeniu. Jeśli będziemy w stanie posortować fragmenty tablicy num[1..dp-1] oraz num[dp+1. .n ], to będziemy mogli posortować ją całą. Jednak do wykonania tego sortowania możemy zastosować ten sam proces, a to oznacza, że sortowanie można zaimplementować w formie rozwiązania rekurencyjnego. Zakładając, że dysponujemy funkcją p artitio n , która dzieli podany fragment tablicy i zwraca jego punkt podziału, funkcję quicksort możemy napisać w następujący sposób.
274
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
public s t a t i c void q u ic k so rt(in t[] A, int lo , in t hi) { //sortuje A[lo] do A[hi] w kolejności rosnącej i f (lo < hi) { int dp = p artition (A , lo , h i); quicksort(A, lo , dp-1); quicksort(A, dp+1, h i); } } //koniec quicksort Wywołanie quicksort(num, 1, n) posortuje tablicę num[1..n] w kolejności rosnącej. Teraz przyjrzymy się, jak można napisać funkcję p a rtitio n . Załóżmy, że dysponujemy następującą tablicą: num
53
12
98
63
18
32
80
46
72
21
1
2
3
4
5
6
7
8
9
10
Spróbujemy podzielić ją względem wartości num[1], czyli liczby 53 (elementu rozdzielającego), przechodząc przy tym tablicę tylko jeden raz. Kolejno odczytamy wartości poszczególnych elementów tablicy. Jeśli dany element będzie większy do elementu rozdzielającego, nie robimy nic. Jeśli będzie mniejszy, przeniesiemy go na lewą stronę tablicy. Początkowo zmiennej l astSmall przypisujemy wartość 1; w trakcie dalszego działania metody zmienna ta będzie przechowywać indeks ostatniego znanego elementu tablicy, który jest mniejszy od elementu rozdzielającego. Proces podziału tablicy num przebiega w następujący sposób. 1. Porównujemy 12 z 53, ponieważ jest mniejsze, dodajemy 1 do wartości lastSm all (czyli zmienna ta przyjmie wartość 2) i zamieniamy element num[2] z nim samym. 2. Porównujemy 98 z 53; ponieważ 98 jest większe, przechodzimy do następnego elementu. 3. Porównujemy 63 z 53; ponieważ 63 jest większe, przechodzimy do następnego elementu. 4. Porównujemy 18 z 53; ponieważ 18 jest mniejsze, dodajemy 1 do lastSmall (zmienna ta będzie mieć aktualnie wartość 3) i zamieniamy num[3] (czyli 98) z 18. Na tym etapie tablica ma postać: num
53
12
18
63
98
32
80
46
72
21
1
2
3
4
5
6
7
8
9
10
5. Porównujemy 32 z 53; ponieważ 32 jest mniejsze, dodajemy 1 do lastSmall (zmienna ta będzie mieć aktualnie wartość 4) i zamieniamy num[4] (czyli 63) z 32. 6. Porównujemy 80 z 53; ponieważ 80 jest większe, przechodzimy do następnego elementu. 7. Porównujemy 46 z 53; ponieważ 46 jest mniejsze, dodajemy 1 do lastSmall (zmienna ta będzie mieć aktualnie wartość 5) i zamieniamy num[5] (czyli 98) z 46. Na tym etapie tablica ma postać: num
53
12
18
32
46
63
80
98
72
21
1
2
3
4
5
6
7
8
9
10
8. Porównujemy 72 z 53; ponieważ 72 jest większe, przechodzimy do następnego elementu. 9. Porównujemy 21 z 53; ponieważ 21 jest mniejsze, dodajemy 1 do lastSmall (zmienna ta będzie mieć aktualnie wartość 6) i zamieniamy num[6] (czyli 63) z 21. 10. Dotarliśmy do końca tablicy; zamieniamy num[1] z num[lastSmall]; przenosimy tym samym element rozdzielający w jego docelowe, posortowane położenie (w naszym przykładzie będzie to komórka o numerze 6).
275
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
W efekcie uzyskujemy tablicę o następującej zawartości. num
21
12
18
32
46
53
80
98
72
63
1
2
3
4
5
6
7
8
9
10
Punkt podziału jest wskazywany przez wartość zmiennej l astSmall (czyli 6). Metodę działającą zgodnie z powyższym opisem zaimplementujemy jako funkcję p a r titio n l. Jej kod przedstawiono jako fragment programu P9.3, którego można użyć do przetestowania działania obu zaprezentowanych wcześniej metod, czyli quicksort oraz p a r t it i onl.
Program P9.3 import ja v a .i o .* ; public c la ss QuicksortTest { public s t a tic void m ain(String[] args) throws IOException { in t[] num = {0, 37, 25, 43, 65, 48, 84, 73, 18, 79, 56, 69, 32}; int n = 12; quicksort(num, 1, n); fo r (in t h = 1; h <= n; h++) System .out.printf("% d " , num[h]); S y stem .o u t.p rin tf("\ n "); } public s t a t ic void q u ic k so rt(in t[] A, int lo , in t hi) { //sortuje A[lo] do A[hi] w kolejności rosnącej i f (lo < hi) { int dp = partition 1(A , lo , h i); quicksort(A, lo , dp-1); quicksort(A, dp+1, h i); } } //koniec quicksort public s t a t ic int p a rtitio n 1 (in t[] A, int lo , in t hi) { //dzieli A[lo] do A[hi], używając A[lo] jako elementu rozdzielającego int pivot = A [lo]; int lastSm all = lo ; fo r (in t j = lo + 1; j <= h i; j+ + ) i f (A[j] < pivot) { ++l astSmal l ; swap(A, lastS m all, j ) ; } //koniec for swap(A, lo , lastS m all); return lastS m all; //zwracamy punkt podziału } //koniec partitionl public s t a tic void swap(int[] l i s t , int i , in t j ) //funkcja zamienia elementy list[i] oraz list[j] int hold = l i s t [ i ] ; lis t[i] = l is t [ j] ; l i s t [ j ] = hold; } } //koniec klasy QuicksortTest
276
{
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Po wykonaniu program P9.3 wyświetli następujące wyniki (pokazujące, że elementy tablicy od num[1] do num[12] zostały posortowane). 18 25 32 37 43 48 56 65 69 73 79 84 Sortowanie szybkie jest jednym z tych algorytmów sortowania, których wydajność może się wahać w granicach od bardzo dużej do bardzo małej. Zazwyczaj algorytm ten ma złożoność rzędu O(nlog 2n), a dla danych losowych liczba porównań waha się w granicach pomiędzy nlog2n a 3nlog2n. Jednak może ona być znacznie większa. Idea działania tego algorytmu polega na podzieleniu sortowanego fragmentu na dwie stosunkowo równe części. To, czy uda się to zrobić, czy nie, w znacznej mierze zależy od tego, jaka wartość zostanie wybrana na element rozdzielający. W przedstawionej funkcji jako element rozdzielający wybieramy pierwszy element sortowanego zakresu. Takie rozwiązanie sprawdzi się w większości przypadków, a zwłaszcza podczas sortowania danych losowych. Jeśli jednak pierwszy element okaże się najmniejszym elementem sortowanego zakresu, cała operacja podziału stanie się bezużyteczna, gdyż element rozdzielający znajdzie się na jego pierwszym miejscu. „Lewa” część zakresu będzie pusta, a „prawa” będzie tylko o jeden element mniejsza od aktualnie sortowanego zakresu. Podobnie stanie się, gdy elementem rozdzielającym okaże się największy element sortowanego zakresu. Choć nawet w takich przypadkach algorytm sortowania szybkiego spełni swoje zadanie, to jednak będzie działał znacząco wolniej. Jeśli np. tablica będzie już posortowana, sortowanie szybkie będzie działać równie wolno jak sortowanie przez wybieranie. Jednym z rozwiązań tego problemu jest wybranie jako elementu rozdzielającego losowego elementu tablicy, a nie pierwszego. Choć także w tym przypadku istnieje możliwość trafienia na element najmniejszy (lub największy), jednak stanie się to wyłącznie przez przypadek. Jeszcze innym rozwiązaniem jest użycie jako elementu rozdzielającego mediany pierwszego (A [lo]), ostatniego (A [hi]) oraz środkowego (A [(lo + h i)/2 ]) elementu zakresu. Sugerujemy, żeby spróbować przetestowania różnych sposobów wyboru elementu rozdzielającego. Przeprowadzone eksperymenty pokazały, że wybór losowego elementu tablicy jako elementu rozdzielającego był szybki i dawał dobre efekty nawet w przypadku sortowania już posortowanych danych. W rzeczywistości, w wielu przypadkach takie rozwiązanie będzie działać szybciej na danych posortowanych niż na danych losowych, co w przypadku algorytmu sortowania szybkiego jest niezwykłym wynikiem. Jedną z możliwych wad algorytmu sortowania szybkiego jest to, że zależnie od sortowanych danych narzuty związane z wykonywaniem wywołań rekurencyjnych mogą być wysokie. W punkcie 9.5.2 pokazano, jak można zminimalizować to zagrożenie. Z drugiej strony, wielką zaletą tego algorytmu jest niewielkie wykorzystanie dodatkowej pamięci. W arto to porównać z algorytmem mergesort (sortowaniem przez scalanie, także rekurencyjnym ), wymagającym znacznie więcej dodatkowego m iejsca (dokładnie tyle samo, ile wynosi wielkość sortowanej tablicy), którego używa do scalenia sortowanych fragmentów. Żadnej z tych wad nie ma natomiast algorytm sortowania przez kopcowanie. Nie jest to algorytm rekurencyjny i wymaga bardzo niewiele dodatkowej pamięci. Poza tym, zgodnie z inform acjam i podanymi w podrozdziale 9.3, sortowanie przez kopcowanie jest stabilne, pod tym względem, że jego wydajność w najgorszym razie wynosi 2 nlog 2n i to niezależnie od porządku elementów w sortowanej tablicy.
9.5.1. Inny sposób podziału Cel podziału sortowanego zakresu tablicy — czyli utworzenie dwóch części, takich że wszystkie elementy w lewej będą mniejsze od wszystkich elementów w prawej — można uzyskać na wiele sposobów. Pierwsza metoda, przedstawiona wcześniej, umieszczała element rozdzielający w jego docelowym położeniu. Dla odmiany przeanalizujemy teraz nieco inny sposób podziału. Choć także on przeprowadza podział względem elementu rozdzielającego, to jednak nie u m ieszcza go w docelowym położeniu. Jak się jednak przekonamy, nie będzie to żadnym problemem.
277
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Ponownie załóżmy, że dysponujemy tablicą num[1..n], gdzie n = 10. num
53
12
98
63
18
32
80
46
72
21
1
2
3
4
5
6
7
8
9
10
Jako element rozdzielający wybieramy element 53. Ogólna idea polega na tym, by przeglądać tablicę, zaczynając od prawej, i szukać w niej klucza o wartości mniejszej lub równej wartości elementu rozdzielającego. Następnie tablica jest przeglądana od lewej, w poszukiwaniu klucza, który jest większy lub równy wartości elementu rozdzielającego. W końcu obie te wartości są zamieniane miejscami. Proces ten w efekcie powoduje, że wartości mniejsze są umieszczane z lewej, a większe z prawej strony sortowanego fragmentu tablicy. Zastosujemy dwie zmienne, lo oraz hi, które będą oznaczały położenie z lewej oraz z prawej. Początkowo zmiennej lo przypisujemy wartość 0, a zmiennej hi wartość 11 (n+1). Następnie powtarzamy następujące czynności. 1. Odejmujemy 1 od hi (czyli zmienna ta przyjmuje wartość 10). 2. Porównujemy num[hi], czyli 21, z 53; ponieważ 21 jest mniejsze, zatrzymujemy przeszukiwanie tablicy od prawej na hi = 10. 3. Dodajemy 1 do lo (czyli zmienna ta przyjmuje wartość 1). 4. Porównujemy num[l o ], czyli 53, z 53; ponieważ 53 nie jest mniejsze, zatrzymujemy przeszukiwanie tablicy od lewej na lo = 1. 5. lo (1) jest mniejsze od hi (10), czyli zamieniamy num[lo] z num[hi]. 6. Odejmujemy 1 od hi (czyli zmienna ta przyjmuje wartość 9). 7. Porównujemy num[hi], czyli 72, z 53; ponieważ 72 jest większe, zmniejszamy wartość hi o 1 (przez co zmienna ta przyjmie wartość 8). Porównujemy num[hi], czyli 46, z 53, ponieważ 46 jest mniejsze, zatrzymujemy przeszukiwanie tablicy od prawej na hi = 8. 8. Dodajemy 1 do lo (czyli zmienna ta przyjmuje wartość 2). 9. Porównujemy num[lo], czyli 12, z 53; ponieważ 12 jest mniejsze, dodajemy 1 do lo (zmienna ta przyjmuje wartość 3). Porównujemy num[l o ], czyli 98, z 53; ponieważ 98 jest większe, zatrzymujemy przeszukiwanie tablicy od lewej na lo = 3. 10. lo (3) jest mniejsze od hi (8), czyli zamieniamy num[lo] z num[hi]. Na tym etapie działania zmienna lo = 3, zmienna hi = 8, a zawartość tablicy num ma postać: num
21
12
46
63
18
32
80
98
72
53
1
2
3
4
5
6
7
8
9
10
11. Odejmujemy 1 od hi (czyli zmienna ta przyjmuje wartość 7). 12. Porównujemy num[hi], czyli 80, z 53; ponieważ 80 jest większe, zmniejszamy wartość hi o 1 (przez co zmienna ta przyjmie wartość 6). Porównujemy num[hi], czyli 32, z 53, ponieważ 32 jest mniejsze, zatrzymujemy przeszukiwanie tablicy od prawej na hi = 6. 13. Dodajemy 1 do lo (czyli zmienna ta przyjmuje wartość 4). 14. Porównujemy num[l o ], czyli 63, z 53; ponieważ 63 jest większe, zatrzymujemy przeszukiwanie tablicy od lewej na lo = 4. 15. lo (4) jest mniejsze od hi (6), czyli zamieniamy num[lo] z num[hi], uzyskując tablicę o następującej zawartości: num
278
21
12
46
32
18
32
80
98
72
53
1
2
3
4
5
6
7
8
9
10
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
16. Odejmujemy 1 od hi (czyli zmienna ta przyjmuje wartość 5). 17. Porównujemy num[hi], czyli 18, z 53; ponieważ 18 jest mniejsze, zatrzymujemy przeszukiwanie tablicy od prawej na hi = 5. 18. Dodajemy 1 do lo (czyli zmienna ta przyjmuje wartość 5). 19. Porównujemy num[lo], czyli 18, z 53; ponieważ 18 jest mniejsze, dodajemy 1 do lo (zmienna ta przyjmuje wartość 6). Porównujemy num[l o ], czyli 63, z 53; ponieważ 63 jest większe, zatrzymujemy przeszukiwanie tablicy od lewej na lo = 6. 20. lo (6) nie jest m niejsze od hi (5), zatem algorytm kończy działanie. W artość zmiennej hi spełnia tę właściwość, że wartości num[1..hi] są mniejsze od wartości num[hi + 1 ..n ] . W naszym przypadku wartości num[1..5] są mniejsze od wartości num [6..10]. Trzeba zwrócić uwagę, że wartość 53 nie znajduje się w swoim docelowym, posortowanym położeniu. Jednak nie stanowi to większego problemu, gdyż w celu posortowania całej tablicy musimy jeszcze posortować jej fragmenty: num[1..hi] oraz num [hi+1..n]. Tak opisaną procedurę podziału możemy zaimplementować w formie następującej metody p artition 2. public s t a t i c int p a rtitio n 2 (in t[] A, int lo , in t hi) { //funkcja zwraca punkt podziału (dp), taki że A[lo..dp] <= A[dp+1..hi] int pivot = A [lo]; - - l o ; ++hi; while (lo < hi) { do - - h i ; while (A[hi] > p iv o t); do ++lo ; while (A[lo] < p iv o t); i f (lo < hi) swap(A, lo , h i); } return h i; } //koniec partition2 W przypadku zastosowania tej metody podziału tablicy na dwie części metodę quicksort2 możemy napisać w następujący sposób. public s t a t i c void q u ick so rt2(in t[] A, int lo , int hi) { //sortuje A[lo] do A[hi] w kolejności rosnącej i f (lo < hi) { int dp = partition 2(A , lo , h i); quicksort2(A, lo , dp); quicksort2(A, dp+1, h i); } } W metodzie p a r t it i on2 jako elem ent rozdzielający wybieramy pierwszy elem ent sortowanego zakresu tablicy. Jednak zgodnie z inform acjam i podanymi wcześniej, wybór dowolnego elem entu zapewniłby lepsze wyniki. Taki losowy element można wybrać w następujący sposób: swap(A, lo , random(lo, h i ) ) ; int pivot = A[l o ]; W tym przypadku funkcja random będzie mieć postać: public s t a t i c int random(int m, int n) { //zwraca losową liczbę całkowitą z zakresu do m do n włącznie return (in t) (Math.random() * (n - m + 1)) + m; }
279
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
9.5.2. Nierekurencyjna wersja algorytmu sortowania szybkiego W przedstawionych wcześniej wersjach metody quicksort po dokonaniu podziału sortowanego fragmentu tablicy ta sama metoda była wykonywana rekurencyjnie, w celu posortowania najpierw lewej, a następnie prawej części zakresu. Takie rozwiązanie spełnia się doskonale. Jednak może się zdarzyć, że dla dużych wartości n liczba wykonywanych wywołań rekurencyjnych stanie się tak duża, że wystąpi błąd „przepełniania buforu rekurencji” (ang. recursive stack overflow). Z przeprowadzonych eksperymentów wynika, że dzieje się tak dla n = 7000, jeśli dane były już wcześniej posortowane, a jako element rozdzielający wybrany został pierwszy element sortowanego zakresu. Jednak algorytm działał bardzo dobrze nawet dla n = 100 000, kiedy elementem rozdzielającym został wybrany losowy element tablicy. Jeszcze innym rozwiązaniem jest napisanie metody quicksort w taki sposób, by nie korzystać w niej z rekurencji. Wymaga ona umieszczenia na stosie tych elementów listy, które jeszcze nie zostały posortowane. Można wykazać, że jeśli podlista jest dzielona na dwie części, to posortowanie najpierw m niejszej z nich sprawia, że liczba elementów na stosie zostanie ograniczona niemal do log2n. Załóżmy np., że sortujemy tablicę A [1 ..9 9 ], a pierwszy punkt podziału wypadł w elemencie o numerze 40. Załóżmy dodatkowo, że używamy metody p artition 2, która nie umieszcza elementu rozdzielającego w jego docelowym, posortowanym położeniu. A zatem w celu dokończenia sortowania musimy posortować dwa fragmenty tablicy: A [1..40] oraz A [4 1 ..9 9 ]. Umieścimy zatem na stosie parę liczb (41, 99) i zajmiemy się posortowaniem fragmentu tablicy A [1..40] (czyli krótszej podlisty). Załóżmy, że punkt podziału fragmentu A [1..40] wypadł w elemencie numer 25. Umieszczamy zatem na stosie (1, 25) i przetwarzamy w pierwszej kolejności fragment A [2 6 ..4 0 ]. Na tym etapie prac na stosie znajdują się dwie podlisty — (41, 99) oraz (1, 25) — którymi jeszcze musimy się zająć. Próba posortowania fragmentu A[26. .40] spowoduje umieszczenie na stosie kolejnej podlisty itd. W naszej implementacji algorytmu na stosie będziemy także umieszczali krótszą podlistę, jednak natychmiast będzie ona zdejmowana z niego i przetwarzana. Wspominane wcześniej wyniki gwarantują, że na stosie nigdy nie będzie więcej niż log299 = 7 (po zaokrągleniu w górę) elementów. Nawet w razie sortowania n = 1 000 000 elementów mamy gwarancję, że liczba elementów umieszczonych na stosie nigdy nie przekroczy 20. Oczywiście, tym stosem musimy zarządzać samodzielnie. Każdy element stosu będzie zawierał dwie liczby całkowite (nazwiemy je l e f t i rig h t), określające, że do posortowania pozostaje jeszcze zakres tablicy pomiędzy elementami l e f t i righ t. Klasę NodeData możemy zdefiniować w następujący sposób. class NodeData { int l e f t , rig h t; public NodeData(int a, int b) { l e f t = a; righ t = b; } public s t a tic NodeData getRogueValue() {return new NodeData(-1, - 1 ) ;} } //koniec klasy NodeData W programie zastosujemy implementację stosu z podrozdziału 4.3. Teraz, bazując na przedstawionych wcześniej informacjach, możemy już napisać metodę quicksort3. Przedstawiono ją jako fragment programu P9.4. Program ten wczytuje liczby z pliku quick.in, sortuje je przy użyciu metody quicksort3, a następnie wyświetla posortowane liczby, po dziesięć w jednym wierszu. Program P9.4 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss Quicksort3Test { final s t a tic in t MaxNumbers = 100;
280
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
public s t a t i c void main (S trin g [] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("q u ick .in ")); in t[] num = new int[MaxNumbers+1]; int n = 0, number; while (in .h asN extIn t()) { number = in .n e x tIn t(); i f (n < MaxNumbers) num[++n] = number; //zapisujemy, jeśli w tablicy jest miejsce } quicksort3(num, 1, n); fo r (in t h = 1; h <= n; h++) { System .out.printf("% d " , num[h]); i f (h % 10 == 0) Sy stem .o u t.p rin tf("\ n "); //wyświetlamy po 10 liczb w wierszu } S y stem .o u t.p rin tf("\ n "); } //koniec main public s t a t i c void q u ick so rt3(in t[] A, int lo , int hi) { Stack S = new S ta c k (); S.push(new NodeData(lo, h i) ) ; int stackltems = 1, maxStackltems = 1; while (!S.em pty()) { --stack ltem s; NodeData d = S .p o p (); i f ( d .l e f t < d .rig h t) { //jeślipodlistajest > 1 elementu int dp = partition 2(A , d .l e f t , d .r ig h t); i f (dp - d .l e f t + 1 < d .rig h t - dp) { //porównujemy długości podlist S.push(new NodeData(dp+1, d .r ig h t)); S.push(new NodeData(d.left, dp)); } e lse { S.push(new NodeData(d.left, dp)); S.push(new NodeData(dp+1, d .r ig h t)); } stackltems += 2; //dwa elementy dodawane do stosu } //koniec if i f (stackltem s > maxStackltems) maxStackltems = stackltem s; } //koniec while System.out.printf("Maksymalna liczb a elementów na s to s ie : %d\n\n", maxStackltems); } //koniec quicksort3 public s t a t i c int p a rtitio n 2 (in t[] A, int lo , in t hi) { //funkcja zwraca punkt podziału (dp), taki że A[lo..dp] <= A[dp+1..hi] int pivot = A [lo]; - - l o ; ++hi; while (lo < hi) { do - - h i ; while (A[hi] > p iv o t); do ++lo; while (A[lo] < p iv o t); i f (lo < hi) swap(A,lo , h i); } return h i; } //koniec partition2 public s t a t i c void swap(int[] l i s t , int i , in t j ) //funkcja zamienia elementy list[i] oraz list[j]
{
281
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
int hold = l i s t [ i ] ; lis t[i] = l is t [ j] ; l i s t [ j ] = hold; } //koniec swap } //koniec klasy Quicksort3Test class NodeData { int l e f t , rig h t; public NodeData(int a, int b) { l e f t = a; righ t = b; } public s t a t ic NodeData getRogueValue() {return new NodeData(-1, - 1 ) ;} } //koniec klasy NodeData class Node { NodeData data; Node next; public Node(NodeData d) { data = d; next = n u ll; } } //koniec klasy Node class Stack { Node top = n u ll; public boolean empty() { return top == n u ll; } public void push(NodeData nd) { Node p = new Node(nd); p.next = top; top = p; } //koniec push public NodeData pop() { i f (this.em p ty ())retu rn NodeData.getRogueValue(); NodeData hold = top.d ata; top = top.next; return hold; } //koniec pop } //koniec klasy Stack W metodzie qui cksort3 po wykonaniu metody p artitio n 2 porównywane są długości obu podlist, po czym na stosie jest umieszczana najpierw dłuższa, a następnie krótsza z nich. W ten sposób krótsza lista zostanie zdjęta ze stosu jako pierwsza i przetworzona przed dłuższą. Oprócz tego, do metody quicksort3 dodano także instrukcje, które śledzą maksymalną liczbę elementów umieszczonych na stosie. Kiedy zastosowaliśmy program do posortowania 100 000 liczb, maksymalna liczba elementów umieszczonych na stosie wyniosła 13. To m niej niż wynosi teoretyczne maksimum, log2100000 = 17 (po zaokrągleniu w górę).
282
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
Załóżmy, że plik quick.in zawiera następujące liczby: 43 25 66 37 65 48 84 73 60 79 56 69 32 87 23 99 85 28 14 78 39 51 44 35 46 90 26 96 88 31 17 81 42 54 93 38 22 63 40 68 50 86 75 21 77 58 72 19 Po uruchomieniu program P9.4 wygeneruje poniższe wyniki. Maksymalna liczb a elementów na s to s ie : 5 14 32 48 68 85
17 35 50 69 86
19 21 37 38 51 54 72 73 87 88
22 39 56 75 90
23 40 58 77 93
25 42 60 78 96
26 43 63 79 99
28 44 65 81
31 46 66 84
W przedstawionej postaci, nawet jeśli podlista będzie zawierać jedynie dwa elementy, metoda wykona cały proces sortowania, włącznie z przeprowadzaniem podziału, sprawdzaniem długości podlist i umieszczaniem na stosie dwóch elementów. To całkiem sporo pracy jak na posortowanie dwóch elementów. Wydajność działania algorytmu sortowania szybkiego można dodatkowo poprawić przy użyciu jakiejś prostszej metody (takiej jak sortowanie przez wstawianie) do sortowania podlist krótszych od pewnej predefiniowanej długości (np. składających się z m niej niż 8 elementów). W arto wprowadzić taką modyfikację w metodzie quicksort i sprawdzić efekty dla różnych wartości predefiniowanej długości.
9.5.3. Znajdowanie k-tej najmniejszej liczby Przeanalizujemy teraz problem znajdowania k-tej najmniejszej liczby na liście zawierającej n liczb. Jednym ze sposobów rozwiązania tego problemu jest posortowanie listy i wybranie k-tej wartości. Jeśli liczby będą zapisane w tablicy A [1 ..n ], wystarczy w tym celu pobrać z tablicy element A[k]. Innym, bardziej wydajnym sposobem jest zastosowanie pomysłu podziału na części. Wykorzystamy w tym celu metodę p a rtitio n , która umieszcza element rozdzielający w docelowym, posortowanym miejscu. Załóżmy, że dysponujemy tablicą A [1..99] i przyjmijmy, że wywołanie metody part itio n zwróciło punkt podziału o wartości 40. Oznacza to, że element rozdzielający został umieszczony w kom órce A[40], wszystkie mniejsze liczby znajdują się po jego lewej stronie, a większe — po prawej. Innymi słowy, 40. najmniejsza wartość została umieszczona w elemencie tablicy A[40]. A zatem, jeśli k wynosi 40, od razu uzyskaliśmy rozwiązanie. A co będzie, jeśli wartość k wynosi 59? Wiemy, że 40 najmniejszych liczb zajmuje zakres A [1 ..4 0 ]. W takim razie 59. liczba musi się znajdować w zakresie A [4 1 ..9 9 ], więc możemy ograniczyć nasze poszukiwania do tego obszaru. Innymi słowy, jedno wywołanie metody p a rtitio n pozwoliło wyeliminować 40 liczb. Takie rozwiązanie przypomina nieco wyszukiw anie binarne. Załóżmy następnie, że wywołanie metody p a r titi on zwróciło wartość 65. Znamy zatem 65. najmniejszą liczbę, a 59. będzie się znajdować w zakresie A [4 1 ..6 5 ]. A zatem z dalszych poszukiwań możemy wykluczyć zakres A [66..99] . Taki proces możemy powtarzać, redukując za każdym razem wielkość zakresu tablicy, w którym znajduje się 59. najmniejsza liczba w tablicy. W końcu metoda part itio n zwróci wartość 59 i uzyskamy poszukiwaną odpowiedź. W poniższym kodzie przedstawiono jedną z możliwych postaci metody kthSmal l , wykorzystującą metodę p a r t i t i o n ! public s t a t i c int kthSm all(int[] A, int k, in t lo , int hi) { //zwraca k-tą najmniejszą liczbę z zakresu od A[lo] do A[hi] int kShift = lo + k - 1; //przesuwa k do podanego położenia, A[lo..hi] i f (kS h ift < lo || kShift > hi) return -9999; int dp = partition 1(A , lo , h i); while (dp != kShift) { i f (kS h ift < dp) hi = dp - 1; //k-ta najmniejsza liczba znajduje się w lewej części e lse lo = dp + 1; //k-ta najmniejsza liczba znajduje się w prawej części
283
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
dp = partition 1(A , lo , h i); } return A[dp]; } //koniec kthSmall Przykładowo wywołanie kthSmall(num, 59, 1, 99) zwróci 59. najmniejszą liczbę z zakresu num[1..99]. W arto jednak zauważyć, że wywołanie kthSmall(num, 10, 30, 75) zwróci 10. najmniejszą liczbę z zakresu num[30. .75]. W ramach ćwiczenia warto napisać rekurencyjną wersję metody kthSmall.
9.6. Sortowanie Shella (z użyciem malejących odstępów) W sortowaniu Shella (nazwanego tak od nazwiska jego twórcy — Donalda Shella) stosuje się sekwencję odstępów , które zarządzają procesem sortowania. Algorytm wykonuje kilka przebiegów przez sortowaną tablicę danych, przy czym ostatni z nich sprowadza się do zwyczajnego sortowania przez wstawianie. W e wcześniejszych przebiegach sortowane są elementy rozmieszczone w określonych odstępach (np. co pięć elementów), przy czym używana jest ta sama technika sortowania przez wstawianie. Aby np. posortować poniższą tablicę, zastosujemy trzy odstępy: 8, 3 i 1. num
67
90
28
84
29
58
25
32
16
64
13
71
82
10
51
57
1
2
3
4
5
6
7
3
9
10
11
12
13
14
15
16
Odstępy te są stopniowo zmniejszane (stąd określenie sortowanie z użyciem malejących odstępów), aż w końcu przybierają wartość 1. Zaczynamy do odstępu o wartości 8, a zatem sortujemy co ósmy element tablicy, czyli sortujemy elementy 1. i 9., 2. i 10., 3. i 11., 4. i 12., 5. i 13., 6. i 14., 7. i 15. oraz 8. i 16. Spowoduje to przekształcenie zawartości tablicy num do postaci: num
16
64
13
71
29
10
25
32
67
90
28
84
82
58
51
57
1
2
3
4
5
6
7
3
9
10
11
12
13
14
15
16
Następnie użyjemy odstępu o wartości 3, co oznacza, że sortujemy co trzeci jej element. Innymi słowy, sortujemy elementy (1., 4., 7., 10., 13. i 16.), (2., 5., 8., 11. i 14.) oraz (3., 6., 9., 12. i 15.). Uzyskujemy w ten sposób tablicę w postaci: num
16
28
10
25
29
13
57
32
51
71
58
67
82
64
84
90
1
2
3
4
5
6
7
3
9
10
11
12
13
14
15
16
Należy zwrócić uwagę, że po każdym takim kroku tablica jest w coraz większym stopniu posortowana. W końcu przeprowadzamy sortowanie z odstępem o wartości 1, czyli sortujemy całą listę, nadając jej przy tym ostateczną, posortowaną postać. num
10
13
16
25
28
29
32
51
57
58
64
67
71
82
84
90
1
2
3
4
5
6
7
3
9
10
11
12
13
14
15
16
Można by zapytać, dlaczego na samym początku nie wykonaliśmy sortowania z odstępem 1, sortując od razu całą zawartość tablicy? Cała idea tego algorytmu polega na tym, że gdy dochodzimy już do użycia odstępu 1, tablica jest w mniejszym lub większym stopniu posortowana, a jeśli używamy przy tym metody, która lepiej działa na częściowo posortowanych danych (takiej jak sortowanie przez wstawianie), proces sortowania może być wykonany bardzo szybko. W arto sobie przypomnieć, że sortowanie przez wstawianie
284
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
może się ograniczyć do wykonania jedynie n porównań dla n elementów (jeśli dane będą już posortowane) bądź wykonać ich aż 1/2n2 (jeśli dane będą posortowane w kolejności malejącej, a my chcemy je posortować w kolejności rosnącej). Gdy odstępy są duże, podlisty sortowanych liczb są niewielkie. W naszym przypadku przy odstępie 0 wartości 8 każda sortowana podlista składała się wyłącznie z dwóch elementów. Można oczekiwać, że małe podlisty będą sortowane szybko. Przy mniejszych odstępach liczba elementów w sortowanych podlistach rośnie. Kiedy jednak do tego dojdzie, zawartość tablicy będzie już częściowo posortowana i jeśli wykorzystamy algorytm sortowania, który potrafi wykorzystać takie uporządkowanie elementów, będziemy w stanie posortować je szybko. W naszym rozwiązaniu do sortowania z odstępem h, przy założeniu, że h jest większe od 1, zastosujemy nieco zmodyfikowaną wersję sortowania przez wstawianie. Kiedy w przypadku tego algorytmu dochodzimy do przetworzenia elem entu num[k], zakładamy, że elementy num [1..k-1] są posortowane i wstawiamy element num[k] w taki sposób, by elementy num[1..k] były posortowane. Załóżmy, że odstęp wynosi h i zastanówmy się, w jaki sposób możemy przetworzyć elem ent num[k], gdzie k jest dowolnym, prawidłowym indeksem tablicy. Pamiętajmy, że naszym celem jest posortowanie podlisty elementów oddalonych od siebie o h. A zatem, sortując num[k], musimy sprawdzić elementy num[k-h], num[k-2h], num[k-3h] itd., przy założeniu, że wszystkie te elementy leżą w obszarze tablicy. Kiedy zaczynamy przetwarzać element num[k] i wcześniejsze elementy, rozmieszczone w odstępach co h, są już posortowane, wystarczy wstawić element num[k] pomiędzy nie w taki sposób, by cała podlista kończąca się na num[k] była posortowana. Aby zilustrować ten proces, załóżmy, że h = 3, a k = 4. W tablicy przed num[4] znajduje się tylko jeden element — num[1]. A zatem, kiedy zaczynamy przetwarzać num[4], możemy założyć, że element num[1] jest posortowany. Wstawiamy zatem num[4] w takie miejsce, że podlista składająca się z elementów num[1] 1 num[4] będzie posortowana. Podobnie, przed elementem num[5] znajduje się tylko jeden element oddalony od niego o 3 i jest to num[2]. A zatem, kiedy zaczynamy przetwarzać num[5], możemy założyć, że element num[2] jest posortowany. Wstawiamy zatem num[4] w takie miejsce, że podlista składająca się z elementów num[2] i num[5] będzie posortowana. Podobnie postępujemy z elementami num[3] i num[6]. Kiedy zaczynamy przetwarzać element num[7], przed nim w tablicy będą się znajdowały dwa, już posortowane elementy — num[1] i num[4]. Wstawiamy zatem num[7] w takie miejsce, że podlista składająca się z elementów num[1], num[4] i num[7] będzie posortowana. Kiedy zaczynamy przetwarzać element num[8], przed nim w tablicy będą się znajdowały dwa, już posortowane elementy — num[2] i num[5]. Wstawiamy zatem num[8] w takie miejsce, że podlista składająca się z elementów num[2], num[5] i num[8] będzie posortowana. Następnie, kiedy zaczynamy przetwarzać element num[9], przed nim w tablicy będą się znajdowały dwa, już posortowane elementy — num[3] i num[6]. Wstawiamy zatem num[9] w takie miejsce, że podlista składająca się z elementów num[3], num[6] i num[9] będzie posortowana. Kiedy zaczynamy przetwarzać element num[10], przed nim w tablicy będą się znajdowały trzy, już posortowane elementy — num[1], num[4] oraz num[7]. Wstawiamy zatem num[10] w takie miejsce, że podlista składająca się z elementów num[1], num[4], num[7] i num[10] będzie posortowana. I tak dalej. Zaczynamy od h+1, przechodzimy tablicę, przetwarzając każdy jej element i porównując go z wcześniejszymi elementami w tablicy, rozmieszczonymi w odstępach będących wielokrotnością h. W naszym przykładzie, gdy h = 3, stwierdziliśmy, że musimy przetworzyć podlisty składające się z elementów (1., 4., 7., 10., 13., 16.), (2., 5., 8., 11., 14.) oraz (3., 6., 9., 12., 15.). To prawda; jednak nasz algorytm nie będzie sortował elementów (1., 4., 7., 10., 13., 16.), a następnie (2., 5., 8., 11., 14.), by zakończyć sortowaniem elementów (3., 6., 9., 12., 15.). Zamiast tego będzie je sortował równolegle, sortując poszczególne elementy w następującej kolejności: (1., 4.), (2., 5.), (3., 6.), (1., 4., 7.), (2., 5., 8.), (3., 6., 9.), (1., 4., 7., 10.), (2., 5., 8., 11.), (3., 6., 9., 12.), (1., 4., 7., 10., 13.), (2., 5., 8., 11., 14.), (3., 6., 9., 12., 15.), i w końcu (1., 4., 7., 10., 13., 16.). Być może wygląda to na dosyć złożone rozwiązanie, jednak pod względem implementacji jest ono łatwiejsze, gdyż wystarczy przetworzyć tablicę, zaczynając od h+1.
285
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Poniższa metoda wykona h-sortowanie tablicy A [1 ..n ]. public s t a t i c void h s o rt(in t[] A, in t n, int h) { fo r (in t k = h + 1; k <= n; k++) { int j = k - h; int key = A[k]; while (j > 0 && key < A [j]) { A[j + h] = A [j]; j = j - h; } A[j + h] = key; } } //koniec hsort Łatwo zauważyć, że gdy h przyjmie wartość 1, powyższa metoda staje się implementacją sortowania przez wstawianie. Uwaga program istyczna: kiedy chcemy posortować tablicę A [0 ..n -1 ], musimy w pętli while użyć warunku j >= 0, a oprócz tego zmienić instrukcję fo r w następujący sposób: fo r (in t k = h; k < n; k++) Teraz, kiedy dysponujemy sekwencją odstępów ht, ht-i,..., hi = 1, możemy wykonać sortowanie, wywołując metodę hsort dla każdego z odstępów, od największego do najmniejszego. Przedstawiony poniżej program P9.5 wczytuje liczby z pliku shell. in, sortuje je przy użyciu sortowania Shella (przy czym używa odstępów 8, 3 i 1), a następnie wyświetla posortowane liczby, umieszczając po 10 w wierszu. Program 9.5 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss Sh ellSo rtT est { final s t a tic in t MaxNumbers = 100; public s t a t ic void main (S trin g [] args) throws IOException { Scanner in = new Scanner(new F ile R e a d e r("s h e ll.in ")); in t[] num = new int[MaxNumbers+1]; int n = 0, number; while (in .h asN extIn t()) { number = in .n e x tIn t(); i f (n < MaxNumbers) num[++n] = number; //zapisujemy, jeśli jest miejsce w tablicy } //wykonujemy sortowanie Shella z przyrostami 8, 3 i 1 hsort(num, n, 8 ); hsort(num, n, 3 ); hsort(num, n, 1); fo r (in t h = 1; h <= n; h++) { System .out.printf("% d " , num[h]); i f (h % 10 == 0) Sy stem .o u t.p rin tf("\ n "); //wyświetlamy po 10 liczb w wierszu } S y stem .o u t.p rin tf("\ n "); } //koniec main public s t a t i c fo r (in t k int j = int key
286
void h s o rt(in t[] A, in t n, int h) { = h + 1; k <= n; k++) { k - h; = A[k];
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
while (j A[j + j = j } A[j + h] } //koniec for } //koniec hsort
> 0 && key < A [j]) { h] = A [j]; - h; = key;
} //koniec klasy ShellSortTest Załóżmy, że plik shell.in ma następującą zawartość: 43 25 46 90
6637 65 48 84 73 60 79 56 69 32 87 23 99 2696 88 31 17 81 42 54 93 38 22 63 40 68
85 28 14 78 39 51 44 35 50 86 75 21 77 58 72 19
W takim przypadku program P9.5 wyświetli następujące wyniki. 14 32 48 68 85
17 35 50 69 86
19 21 37 38 51 54 72 73 87 88
22 39 56 75 90
23 40 58 77 93
25 42 60 78 96
26 43 63 79 99
28 31 44 46 65 66 81 84
Mimochodem można zauważyć, że nasz kod byłby bardziej elastyczny, gdyby wielkości odstępów były zapisane w tablicy (dajmy na to incr), a do metody hsort były przekazywane kolejne elementy tej tablicy. Załóżmy np., że element in cr[0] zawiera liczbę odstępów (przykładowo m), a kolejne elementy od incr[1] do incr[m] zawierają wartości kolejnych odstępów, przy czym incr[m] = 1. W takim przypadku metodę hsort moglibyśmy wywoływać w taki sposób: fo r (in t i = 1; i <= in c r [0 ]; i++) hsort(num, n, i n c r [ i ] ) ; Jednym z pytań, na jakie należy znaleźć odpowiedź, jest to, w jaki sposób dobierać wielkość odstępu dla danego n. Zaproponowano wiele sposobów rozwiązania tego zagadnienia, a poniższy zapewnia rozsądne wyniki. niech h1 = 1 generuj h,., = 3h, + 1, dla s = 1, 2, 3, . . . zakończ, gdy h„ n; użyj h do h, jako odstępów w sortowaniu Innymi słowy, generujemy kolejne elementy sekwencji, aż do momentu, gdy wartość wygenerowanego elementu przekroczy n. Następnie odrzucamy dwa ostatnie elementy sekwencji, a pozostałych używamy jako odstępów w sortowaniu. Jeśli np. n = 100, generujemy następującą sekwencję: h1 = 1, h2 = 4, h3 = 13, h4 = 40, h5 = 121. Ponieważ h5 > 100, zatem do posortowania tablicy zawierającej 100 elementów używamy odstępów h1, h2 oraz h3. Złożoność algorytmu Shella waha się pomiędzy złożonością prostych metod sortowania (takich jak sortowanie przez wstawiania oraz przez wybieranie) wynoszącą O(n2) a złożonością O(nlog 2n) (charakterystyczną dla algorytmów sortowania przez kopcowanie, sortowania szybkiego oraz sortowania przez scalanie). Jej złożoność wynosi w przybliżeniu O (n13) dla n należącego do praktycznego zakresu wielkości i zmierza do O(nlog 2n) przy n dążącym do nieskończoności. W ramach ćwiczenia można napisać program sortujący listę przy użyciu sortowania Shella i zliczający ilość wykonanych operacji porównania i przypisania.
287
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Ćwiczenia 1.
N apisz pro g ra m u m o ż liw ia ją c y p o ró w n a n ie w y d a jn o ś c i m e to d s o rto w a n ia prze d s ta w io n yc h w ty m rozdziale pod w z g lę d e m „liczby p o ró w n a ń " oraz „liczby operacji przypisania". W przypadku a lg o ry tm u s o rto w a n ia szybkiego p o ró w n a j w y d a jn o ś ć u zys k iw a n ą , gdy e le m e n te m rozdzielającym jest w y b ie ra n y pierw szy e le m e n t zakresu oraz e le m e n t losow y. S p ra w d ź pro g ra m , sortując: a) 1 0 , 1 0 0 , 1 0 0 0 , 1 0 0 0 0 oraz 1 0 0 0 0 0 0 e le m e n tó w w lo so w ej kolejności oraz b) 1 0 , 1 0 0 , 1 0 0 0 , 1 0 0 0 0 oraz 1 0 0 0 0 0 0 e le m e n tó w ju ż pos o rto w an yc h .
2.
Do funkcji makeHeap przekazyw ana jest tablica A. Jeśli A[0] za w iera n, to A[1] do A[n] za w ie ra liczby w losow ej kolejności. N apisz funkcję makeHeap ta k ą , że A[1] do A[n] za w ie ra kopiec m aksym alny (z największym e le m e n te m w korzeniu). Funkcja m usi p rze tw a rz a ć e le m e n ty kopca w kolejności A[2], A[3], ..., A[n].
3.
Kopiec jest zapisany w je d n o w y m ia ro w e j tablicy liczb całkow itych num[1..n], przy czym w pierw szej kom órce zap isan a je s t największa w artość. Podaj w y d a jn y a lg o ry tm , który p o zw o li usunąć korzeń i p rze o rg a n izo w a ć e le m e n ty kopca w ta k i sposób, że b ęd zie z a jm o w a ł zakres ta b lic y od num[1] do num[n-1].
4.
Kopiec je s t za p is a n y w je d n o w y m ia ro w e j ta b lic y liczb całko w itych A [0...m ax] , przy czym największa w a rto ś ć zn a jd u je się w kom órce o n u m erze 1. Kom órka A[0] określa bieżącą liczbę e le m e n tó w kopca. N apisz funkcję, która doda do kopca n o w ą w a rto ś ć v. Funkcja p o w in n a dzia ła ć n a w e t w te d y , jeśli kopiec je s t p o c zątko w o pusty, a w przypadku gdy nie b ęd zie ju ż w nim m iejsca na d o d a n ie n o w e g o e le m e n tu , ma w y ś w ie tla ć s tosow ny kom u n ik a t.
5.
N apisz kod, który w czyta zb ió r dodatn ich liczb całko w itych (zakoń czony cyfrą 0) i na ich p o d s ta w ie u tw o rzy w ta b lic y H kopiec, w którego korzeniu b ęd zie um ieszczona najmniejsza liczba. Po odczytan iu każda liczba je s t d o d a w a n a do ta b lic y w ta k i sposób, że zo stają za c h o w a n e w ła ś c iw o ś c i kopca. W d o w o ln y m m o m en cie, jeśli w czytanych zo stało n liczb, to H[1..n] m usi z a w ie ra ć kopiec. M o żn a założyć, że tab lic a Hje s t na ty le du ża, iż pom ieści w szystkie liczby. Z a k ła d a m y , że zo stały p o d a n e n a stępujące liczby: 51 26 32 45 38 89 29 58 34 23 0. P okaż za w a rto ś ć H po w c zyta n iu i p rze tw o rze n iu każdej z nich.
6.
Do funkcji je s t p rze k a zy w a n a ta b lic a liczb całko w itych A oraz d w a indeksy m i n. Funkcja ma p rze o rg a n izo w a ć e le m e n ty od A[m] do A[n] i zw ró cić indeks d, ta k i że w szystkie e le m e n ty p o ło żo n e na le w o od d będą m niejsze lub ró w n e A[d], a w szystkie e le m e n ty na p ra w o od d b ę d ą w ię k s ze od A[d].
7.
N apisz fu n k c ję , do której p rze k a zy w a n a je s t ta b lic a liczb całko w itych num oraz liczba c a łko w ita n i która sortuje e le m e n ty ta b lic y od num[1] do num[n] przy użyciu s o rto w a n ia Shella. Funkcja m a zw ra c a ć liczbę p o ró w n a ń w y k o n a n y c h podczas s o rto w a n ia . Do w y b o ru o d s tę p ó w s o rto w a n ia m ożna w y b ra ć d o w o ln ą rozsądną m eto d ę .
8. W je d n e j ta b lic y liczb całko w itych A [1..n] w zakresie A [1..k] je s t um ieszczony kopiec m in im a ln y , n a to m ia s t zakres A[k+1..n] za w ie ra d o w o ln e w a rto ś c i. N apisz w y d a jn ą fu n kcję, która scali całą za w arto ś ć ta b lic y w ta k i sposób, że b ęd zie z a w ie ra ć je d e n kopiec m in im a ln y . Nie należy przy ty m u żyw a ć żad n ej d o d a tk o w e j tablicy. 9. W tablicy (np. A) jest zapisany kopiec m aksym alny liczb całkow itych. Elem ent A[0] z a w iera rozm iar kopca ( n), n a to m ia s t w a rto ś c i kopca są um ieszczone w ele m e n ta c h do A[1] do A[n], przy czym w e le m e n c ie A[1] jest um ieszczona największa w artość. i) N apisz funkcję deleteMax, która b ęd zie u su w ać z p rzekazan ej ta b lic y A n a jw ię k s zy e le m e n t, a n astęp n ie p rze o rg a n izu je ta b lic ę w ta k i sposób, że jej za w arto ś ć p o zo s ta n ie kopcem . ii) Przy za ło ż e n iu , że dysponujesz d w o m a ta b lic a m i A i B, ta k im i ja k opisana p o w y ż e j, napisz kod, który scali ich za w a rto ś ć i za p is ze ją w trze c ie j ta b lic y C w ta k i sposób, że b ę d z ie p o s o rto w a n a rosnąco. M e to d a p o w in n a p o ró w n y w a ć po je d n y m e le m e n c ie z ta b lic A i B. M o żn a przy ty m za ło ży ć, że d o s tę p n a je s t fu n k c ja deleteMax. 10.
288
N apisz funkcję rekurencyjną u m o ż liw ia ją c ą zn a le z ie n ie k-tej n ajm niejszej w artości w ta b lic y za w ie ra ją c e j n liczb, bez s o rto w a n ia jej zaw arto ści.
ROZDZIAŁ 9. ■ ZAAW ANSOW ANE METODY SORTOWANIA
11.
N apisz funkcję s o rto w a n ia przez w s ta w ia n ie , korzystającą z w y s zu k iw a n ia b in a rn e g o w celu określenia m iejsca, w którym e le m e n t A [j] ma zostać w s ta w io n y do p o s o rto w an ej podlisty A[1. . j - 1 ] .
12.
M ó w im y , że a lg o ry tm s o rto w a n ia je s t stabilny, jeśli proces s o rto w a n ia z a c h o w u je w z g lę d n ą kolejność kluczy. Które z opisanych m e to d s o rto w a n ia są stabilne?
13.
D y s p o n u je m y listą n liczb. N apisz w y d a jn y a lg o ry tm u m o ż liw ia ją c y zn a le z ie n ie : a ) w a rto ś c i n a jm n ie js ze j, b) w a rto ś c i n a jw ię k s z e j, c) ś re d n ie j, d) m e d ia n y o ra z e) d o m in a n ty (w a rto ś c i w y s tę p u ją c e j najczęściej). N apisz w y d a jn y a lg o ry tm zn ajd u jąc y te w artości.
1 4 . W ie m y , że każda liczba zn ajd u jąc a się na liście n unikalnych liczb je s t z zakresu od 1 0 0 do 9 9 9 . W ym yśl w y d a jn y a lg o ry tm s o rto w a n ia ta k ie j listy. Zm o d yfiku j p o d an y a lg o ry tm w ta k i sposób, by s o rto w a ł ta k że listę za w ie ra ją c ą p o w ta rz a ją c e się liczby. 1 5 . Zm o d yfiku j a lg o ry tm y s o rto w a n ia przez scalanie (p rze d s ta w io n e w rozd zia le 5.) oraz s o rto w a n ia szybkiego w ta k i sposób, że podlisty za w ie ra ją c e m niej niż określon ą, p re d e fin io w a n ą liczbę e le m e n tó w będą s o rto w a n e przy użyciu s o rto w a n ia przez w s ta w ia n ie . 16.
D yspo n u je m y listą n liczb oraz d o d a tk o w ą liczbą x . N a leży zn aleźć n ajm n iejszą liczbę za p is a n ą na liście m niejszą lub ró w n ą x . O d n ale zio n ą liczbę należy usunąć z listy, a na jej m iejsce w s ta w ić n o w ą liczbę y, z a c h o w u ją c przy ty m strukturę listy. W ym yśl ro zw ią za n ia te g o p ro b le m u , w yko rzystu jąc: a) n ie p o s o rto w a n ą ta b lic ę liczb, b) p o s o rto w a n ą ta b lic ę liczb, c) p o s o rto w a n ą listę p o w ią z a n ą , d) bin a rn e d rze w o poszukiw ań oraz e) kopiec.
17.
D yspo n u je m y (d łu g ą) listą słów . N apisz p ro g ra m p o z w a la ją c y określić, które z tych s łó w są a n a g ra m a m i. W y n ik i p re ze n to w a n e przez p ro g ra m p o w in n y z a w ie ra ć każdą podlistę odnalezio nych a n a g ra m ó w (d w a lub w ię c e j s łó w ), przy czym poszczególne podlisty m ają być od siebie o d d z ie lo n e je d n y m pustym w ie rs ze m . S łow a są a n a g ra m a m i, jeśli s kład ają się z tych sam ych liter, np. kaszel i szekla, kaprys i pryska.
18.
Każda w a rto ś ć ta b lic y A [1..n] je s t liczbą 1, 2 lub 3 . Podaj minimalną liczbę operacji zamiany, które trzeba w y k o n a ć w celu p o s o rto w an ia ta k ie j tab lic y . P rzykład o w o ta b lic ę
Która z tych m e to d je s t na jb a rd zie j w y d a jn a ?
m ożna p o s o rto w ać , w y k o n u ją c cztery za m ia n y , w następującej kolejności: (1 , 3 ), (4 , 7 ), (2 , 9) oraz (5 , 9). Innym ro zw ią z a n ie m są za m ia n y : (1 , 3 ), (2 , 9 ), (4 , 7) i (5 , 9). Tablicy nie m ożna po s o rto w ać , w y k o n u ją c m niej niż cztery o p eracje za m ia n y .
289
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
290
ROZDZIAŁ
10
Haszowanie W tym rozdziale opisane zostaną takie zagadnienia jak: • podstawowe idee związane z haszowaniem, • rozwiązywanie problemów wyszukiwania i wstawiania przy użyciu haszowania, • usuwanie elementów z tablicy mieszającej, • rozwiązywanie kolizji przy użyciu próbkowania liniowego, • rozwiązywanie kolizji za pomocą próbkowania kwadratowego, • rozwiązywanie kolizji z wykorzystaniem metody łańcuchowej, • rozwiązywanie kolizji z zastosowaniem próbkowania liniowego z podwójnym haszowaniem, • łączenie elementów w kolejności przy użyciu tablic.
10.1. Podstawy haszowania Wyszukiwanie elementów w dużych tablicach jest popularną operacją wykonywaną w bardzo wielu aplikacjach. W tym rozdziale zajmiemy się haszowaniem (ang. hashing), szybką techniką wykonywania takich poszukiwań nazywaną także mieszaniem . Podstawowa idea haszowania polega na zastosowaniu klucza elementu (przykładem mogą być numery rejestracyjne pojazdu) w celu określenia miejsca w tablicy, w którym dany element jest przechowywany. Klucz jest najpierw przekształcany na liczbę całkowitą (jeśli nie ma postaci liczbowej), a następnie wartość ta jest odwzorowywana na określony element tablicy. Oczywiście, jest całkowicie możliwe, by dwa klucze zostały odwzorowane na te same elementy tablicy. W takim przypadku mówimy o wystąpieniu kolizji (ang. collision) i musimy określić sposób jej rozwiązania. Wydajność (bądź jej brak) haszowania w dużej mierze zależy do zastosowanej metody rozwiązywania kolizji. Większą część tego rozdziału poświęcono na prezentację różnych rozwiązań tego problemu.
10.1.1. Problem wyszukiwania i wstawiania W klasycznym ujęciu problem wyszukiwania i wstawiania jest sformułowany w następujący sposób.
Dysponujemy listą elementów (przy czym lista początkowo może być pusta) i chcemy wyszukać w niej konkretny element. Jeśli element nie zostanie odnaleziony, należy go dodać do listy.
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Zawartość listy mogą tworzyć różne elementy, takie jak liczby (studentów, kont, pracowników, pojazdów itd.), imiona, słowa albo ogólnie pojęte łańcuchy znaków. Załóżmy np., że dysponujemy listą liczb całkowitych, niekoniecznie unikalnych, i chcemy się dowiedzieć, ile jest na tej liście unikalnych liczb. Zaczynamy od pustej listy. Każdą liczbę próbujemy odszukać na liście. Jeśli to się nie uda, dodajemy ją do listy i uznajemy za policzoną. Jeśli uda się odnaleźć poszukiwaną liczbę, nie musimy robić już nic więcej. W rozwiązaniu tego problemu najpoważniejszą decyzją jest określenie sposobu przeszukiwania listy, a to z kolei zależy od sposobu zapisu listy i dodawania do niej elementów. Poniżej podano kilka możliwości. 1. Lista jest zapisana w tablicy, a dodawane do niej liczby są umieszczane w jej pierwszej dostępnej komórce. Oznacza to, że dodawane elementy muszą być odnajdywane przy użyciu wyszukiwania sekwencyjnego. Metodę tę cechuje prostota i szybkość dodawania elementów do listy, jednak czas ich wyszukiwania wydłuża się wraz ze wzrostem liczby elementów tablicy. 2. Lista jest zapisana w tablicy, a nowe liczby są do niej dodawane w taki sposób, że zawsze jest posortowana. Może to wymagać przesuwania liczb już umieszczonych w tablicy, tak by dodawane liczby znalazły się w odpowiednim miejscu. 3. Jeśli lista jest posortowana, do sprawdzania, czy nowe liczby już są na niej zapisane, można użyć wyszukiwania binarnego. W tym przypadku wyszukiwanie jest szybsze, lecz dodawanie elementów zajmuje więcej czasu niż w poprzednim rozwiązaniu. Ponieważ — ogólnie rzecz biorąc — wyszukiwanie będzie wykonywane częściej niż wstawianie, zatem metoda ta może być preferowana. 4. Jej kolejną zaletą jest to, że po zakończeniu przetwarzania liczby całkowite będą uporządkowane (jeśli ma to jakieś znaczenie). W przypadku zastosowania metody num er 1 konieczne byłoby dodatkowe posortowanie listy. 5. Lista jest zapisana w formie nieposortowanej listy powiązanej, co oznacza, że musi być przeszukiwana sekwencyjnie. Ponieważ podczas dodawania kolejnych liczb konieczne jest sprawdzenie całej zawartości listy, zatem można je dodawać zarówno na początku, jak i na końcu, a obie te operacje są łatwe i szybkie. 6. Lista jest zapisana w formie posortowanej listy powiązanej. Nowe liczby muszą być dodawane w odpowiednim miejscu. Po określeniu tego miejsca sama operacja wstawienia nowego elementu listy jest łatwa. Podczas dodawania nowego elementu nie trzeba przeglądać całej zawartości listy, jeśli dodawany element nie został znaleziony. Jednak wciąż konieczne jest stosowanie wyszukiwania sekwencyjnego. 7. Lista została zapisana w formie binarnego drzewa poszukiwań. Przy założeniu, że drzewo nie będzie zbyt niezrównoważone, wyszukiwanie nowych liczb dodawanych do listy będzie stosunkowo szybkie. Dodawanie liczb jest łatwe, sprowadza się do zapisania kilku wskaźników. Jeśli konieczne będzie uzyskanie posortowanej listy liczb, można to zrobić, przechodząc drzewo metodą in-order. Jeszcze innym rozwiązaniem jest zastosowanie metody nazywanej haszowaniem (ewentualnie mieszaniem). Jak się wkrótce przekonamy, zapewnia ona niezwykle szybkie wyszukiwanie oraz dużą prostotę wstawiania nowych elementów do listy.
10.2. Rozwiązanie problemu wyszukiwania i wstawiania przy użyciu haszowania Haszowanie zostanie przedstawione na przykładzie rozwiązania problemu wyszukiwania i wstawiania operującego na liście liczb całkowitych. Lista będzie zapisana w tablicy num[1] do num[n]. W naszym przykładzie n wynosi 12. num
1
2
3
4
5
6
7
8
9
10
11
12
Początkowo lista jest pusta. Załóżmy, że pierwszą dodawaną do niej liczbą jest 52. Idea haszowania polega na przekształceniu liczby 52 (zazwyczaj nazywanej kluczem ) na prawidłowe położenie elementu w tablicy (oznaczymy go np. przez k). W naszym przykładzie to prawidłowe położenie będzie liczbą z zakresu od 1 do 12.
292
ROZDZIAŁ 10. ■ HASZOWANIE
Jeśli w elemencie num[k] nie ma liczby, zapisujemy w nim liczbę 52. Jeśli jednak element num[k] już jest zajęty przez inny klucz, mówimy, że wystąpiła kolizja, i musimy znaleźć inne miejsce, w którym zapiszemy dodawaną liczbę 52. Proces ten nosi nazwę rozwiązywania kolizji. Metoda używana do przekształcenia klucza na położenie w tablicy nosi nazwę funkcji mieszającej (ang. hash fu n ctio n ; oznaczymy ją literą H). Można w niej umieścić dowolne obliczenia, które zwrócą prawidłowe położenie elementu w tablicy (czyli jej indeks), jednak — jak się już niedługo przekonamy — niektóre funkcje dają lepsze rezultaty niż inne. Możemy np. użyć funkcji mieszającej w postaci H1(key) = key % 10 + 1. Innymi słowy, dodajemy 1 do ostatniej cyfry klucza. A zatem liczba 52 zostałaby przekształcona na wartość 3. W arto zwrócić uwagę, że funkcja H1 zwraca wyłącznie wartości z zakresu od 1 do 10. Gdyby tablica zawierała 100 elementów, nasza funkcja działałaby prawidłowo, lecz zapewne nie byłaby najlepszą funkcją, którą można by zastosować. Dodatkowo trzeba zauważyć, że w naszym przypadku funkcja H1 = key % 10 nie byłaby prawidłowym rozwiązaniem, gdyż np. dla klucza 50 zwróciłaby ona wartość 0, a w naszej tablicy nie jest to prawidłowe położenie elementu. Oczywiście, gdyby elementy tablicy zaczynały się od indeksu 0, wyrażenie key % 10 zwracałoby prawidłowe wartości indeksów, przy założeniu, że tablica miałaby co najmniej dziesięć elementów długości. Przykładem innej funkcji mieszającej może być: H2(key) = key % 12 + 1. Wyrażenie key % 12 zwraca wartości z zakresu od 0 do 11; powiększenie jej o 1 sprawia, że funkcja zwraca wartości z zakresu od 1 do 12. Ogólnie rzecz biorąc, wyrażenie key % n + 1 zwraca wartości z zakresu od 1 do n włącznie. Właśnie tej funkcji użyjemy w przykładzie. H2(52) = 52 % 12 + 1 = 5. Mówimy, że „klucz 52 został odwzorowany na pozycję 5”. Ponieważ element tablicy num[5] jest pusty, zatem zapisujemy w nim liczbę 52. Załóżmy, że później próbujemy odnaleźć liczbę 52. Na początek wywołujemy funkcję mieszającą i uzyskujemy wartość 5. Następnie porównujemy num[5] z 52, a ponieważ obie liczby pasują do siebie, zatem uznajemy, że udało się odnaleźć liczbę 52, wykonując tylko jedno porównanie. A teraz załóżmy, że do tablicy zostają zapisane poniższe klucze, dokładnie w podanej kolejności: 52 33
84 43 16
59 31
23 61
•
Liczba 52
zostaje zapisana w elemencie num[5].
•
Liczba 33
zostaje
odwzorowana na pozycję
10; element num[10] jest pusty, zatem zapisujemy 33 w num
•
Liczba 84
zostaje
odwzorowana na pozycję
1; element num[1]jest pusty, zatem zapisujemy 84 w num[1]
•
Liczba 43
zostaje
odwzorowana na pozycję
8; element num[8]jest pusty, zatem zapisujemy 43 w num[8]
Na tym etapie zawartość tablicy num na następującą postać: num
84 1
52 2
3
4
5
43 6
7
8
33 9
10
11
12
• Liczba 16 zostaje odwzorowana na pozycję 5; ponieważ element num[5] jest już zajęty i to nie przez klucz 16, zatem wystąpiła kolizja. W celu jej rozwiązania musimy znaleźć inne miejsce tablicy, w którym będziemy mogli zapisać liczbę 16. Jednym z najprostszych rozwiązań jest podjęcie próby użycia następnego elementu tablicy, o indeksie 6, a ponieważ num[6] jest pusty, klucz 16 zapisujemy w num[6]. • Liczba 59 zostaje odwzorowana na pozycję 12; element num[12] jest pusty, zatem zapisujemy 59 w num[12]. • Liczba 31 zostaje odwzorowana na pozycję 8; element num[8] jest zajęty i to nie przez 31, zatem wystąpiła kolizja. Sprawdzamy następny element tablicy, o indeksie 9, a ponieważ num[9] jest pusty, zapisujemy w nim 31. Na tym etapie zawartość tablicy num na następującą postać: num
84 1
2
3
4
52
16
5
6
7
43
31
33
8
9
10
59 11
12
293
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
• Liczba 23 zostaje odwzorowana na pozycję 12; ponieważ element num[12] jest już zajęty i to nie przez klucz 23, wystąpiła kolizja. Musimy sprawdzić następny element tablicy, ale co nim będzie w tym przypadku? Udajemy, że tablica jest „cykliczna”, czyli „za” elementem o indeksie 12 umieszczony jest element o indeksie 1. Jednak num[1] jest już zajęty i to nie przez 23. Próbujemy zatem kolejny element — num[2], a ponieważ jest pusty, klucz 23 zapisujemy w num[2]. • Liczba 61 zostaje odwzorowana na pozycję 2; element num[2] jest zajęty i to nie przez 61, zatem wystąpiła kolizja. Sprawdzamy następną pozycję tablicy, czyli element o indeksie 3, a ponieważ num[3] jest pusty, zapisujemy w nim 61. Na poniższym rysunku przedstawiono zawartość tablicy po zapisaniu w niej wszystkich liczb. num
84
23
61
1
2
3
4
52
16
5
6
7
43
31
33
8
9
10
59 11
12
Należy zauważyć, że jeśli liczba już jest w tablicy, metoda ją znajdzie. Załóżmy np., że szukamy liczby 23. •
Liczba 23 zostaje odwzorowana na pozycję 12.
•
Element num[12] jest zajęty, ale nie przez 23.
•
Sprawdzamy następny element tablicy o indeksie 1; num[1] jest zajęty,ale nie przez 23.
•
Sprawdzamy następny element tablicy o indeksie 2; num[2] jest zajęty przez 23 — czyli znaleźliśmy poszukiwaną liczbę.
Załóżmy, że szukamy liczby 33; liczba ta jest odwzorowywana na pozycję 10, num[10] zawiera 33 — tę liczbę udało się znaleźć natychmiast. W ramach ćwiczenia można sprawdzić, jak będzie wyglądała zawartość tablicy num po zapisaniu w niej przedstawionej wcześniej sekwencji liczb, gdyby była przy tym używana funkcja mieszająca H1(key) = key % 10 + 1. Tak opisany proces można przedstawić przy użyciu następującego algorytmu. //odnalezienie lub wstawienie liczby 'key' w tablicy num[1..n] loc = H(key) while (num[loc] nie je s t puste && num[loc] != key) loc = loc % n + 1 i f (num[loc] je s t puste) // klucza nie ma num[loc] = key dodaj 1 do licz n ik a unikalnych licz b endi f e lse wyświetl "Liczbę " , key, " znaleziono na pozycji ", loc W arto zwrócić uwagę na wyrażenie loc % n + 1, używane do przechodzenia do następnego elementu tablicy. Jeśli wartość loc jest mniejsza lub równa n, to loc % n przyjmuje wartość n, a wyrażenie będzie miało taką samą wartość, co loc + 1. Jeśli wartości loc i n będą równe, loc % n przyjmie wartość 1. W każdym przypadku w zmiennej loc zapisywany jest indeks następnego elementu tablicy. Łatwo zauważyć, że wykonywanie pętli while kończy się, w momencie gdy element num[loc] okazuje się pusty bądź zawiera poszukiwany klucz. A co dzieje się, jeśli żaden z tych warunków nie zostanie spełniony i wykonywanie pętli while nigdy się nie zakończy? Taka sytuacja wystąpi, gdy tablica będzie całkowicie wypełniona liczbami (nie będzie w niej żadnych pustych komórek) i nie będzie zawierać poszukiwanego klucza. W praktyce nigdy nie dopuszczamy do sytuacji, by tablica została w całości wypełniona. Zawsze dbamy o to, by były w niej jakieś „dodatkowe” puste komórki, dzięki którym pętla while w pewnym momencie zostanie zakoń czona. Ogólnie rzecz biorąc, technika haszowania działa lepiej, jeśli w tablicy będzie więcej pustych miejsc. W jaki sposób nasz algorytm może określić, czy dany element tablicy jest „pusty”? Otóż, w tablicy należy początkowo zapisać jakąś wartość, która będzie to oznaczała. Jeśli np. klucze są dodatnimi liczbami całkowitymi, pustą komórkę może reprezentować 0 lub -1. Teraz napiszemy program P10.1, który wczytuje liczby całkowite z pliku num bers.in i przy użyciu techniki haszowania określa ilość unikalnych liczb zapisanych w pliku.
294
ROZDZIAŁ 10. ■ HASZOWANIE
Program P10.1 import ja v a .u t i l . * ; import ja v a .i o .* ; public c la ss DistinctNumbers { final s t a tic in t MaxDistinctNumbers = 20; final s t a tic in t N = 23; final s t a tic in t Empty = 0; public s t a t ic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new FileReader("num bers.in")); in t[] num = new int[N + 1]; fo r (in t j = 1; j <= N; j+ + ) num[j] = Empty; int d is tin c t = 0; while (in .h asN extIn t()) { int key = in .n e x tIn t(); int loc = key % N + 1; while (num[loc] != Empty && num[loc] != key) loc = loc % N + 1; i f (num[loc] == Empty) { //klucza nie ma w tablicy i f (d is tin c t == MaxDistinctNumbers) { System .ou t.prin tf("\ nTablica je s t pełna: pomijam licz b ę %d.\n", key); S y ste m .e x it(1); } num[loc] = key; d is tin ct+ + ; } } //koniec while System.out.printf("\nW ta b lic y zapisano %d unikalnych lic z b .\ n ", d is t in c t ) ; in .c lo s e (); } //koniec main } //koniec klasy DistinctNumbers Załóżmy, że plik num bers.in zawiera liczby: 25 28 29 23 26 35 22 31 21 26 25 21 31 32 26 20 36 21 27 24 35 23 32 28 36 Po uruchomieniu program P10.1 wyświetli następujący komunikat. W ta b lic y zapisano 14 unikalnych lic z b . Poniżej zamieszczono kilka uwag dotyczących tego programu. • MaxDistinctNumbers (20) określa maksymalną liczbę unikalnych liczb, które program jest w stanie obsłużyć. • N(23) jest wielkością tablicy mieszającej; jest ona nieco większa od wartości MaxDisti nctNumbers, dzięki czemu w tablicy zawsze będą co najm niej trzy puste komórki. • Tablica mieszająca zajmuje elementy od num[1] do num[N]. Można się także zdecydować na zastosowanie elementu num[0], jednak w takim przypadku w funkcji mieszającej należałoby użyć wyrażenia key % N. • Jeśli wartości key nie ma w tablicy (znaleziono pustą komórkę), najpierw sprawdzamy, czy liczba wartości zapisanych w tablicy nie przekroczyła MaxDistinctNumbers. Jeśli przekroczyła, uznajemy, że tablica jest pełna i nie dodajemy liczby zapisanej w zmiennej key. W przeciwnym razie zapisujemy w tablicy wartość zmiennej key i modyfikujemy liczbę unikalnych wartości. • Jeśli uda się znaleźć wartość key w tablicy, przechodzimy do wczytania kolejnej liczby.
295
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
10.2.1. Funkcja mieszająca W poprzednim punkcie rozdziału można było zobaczyć, w jaki sposób klucz, będący liczbą całkowitą, może zostać „odwzorowany” na pozycję w tablicy. Okazuje się, że dla kluczy w takiej postaci operator reszty z dzielenia (%) często daje dobre wyniki. Co jednak można zrobić w sytuacjach, gdy klucze nie są wartościami liczbowymi, gdy są nimi np. słowa lub imiona? W takim przypadku pierwszym zadaniem jest skonwertowanie klucza do postaci liczbowej, a następnie zastosowanie operatora reszty z dzielenia. Załóżmy, że klucz jest słowem. Prawdopodobnie najprostszą rzeczą, jaką możemy zrobić, jest dodanie liczbowych wartości poszczególnych liter w każdym słowie. Zakładając, że słowo jest zapisane w zmiennej łańcuchowej word, możemy to zrobić tak: int wordNum = 0; fo r (in t h = 0; h < w ord.length(); h++) wordNum += word.charAt(h); loc = wordNum % n + 1; //loc jest przypisywana wartość od 1 do n Metoda będzie działać, choć ma ten mankament, że słowa składające się z tych samych liter trafią w to samo miejsce tablicy. Przykładowo każde ze słów, takich jak kora, karo i orka, trafiłoby do tej samej komórki. W przypadku haszowania musimy starać się, by uniknąć celowego odwzorowywania różnych kluczy na tę samą pozycję. Jednym ze sposobów rozwiązania tego problemu jest przypisanie poszczególnym literom różnych wag, zależnych od położenia danej litery w słowie. Takie wagi możemy przypisywać zupełnie dowolnie — głównym celem, który staramy się osiągnąć, jest uniknięcie sytuacji, w których klucze składające się z tych samych liter będą odwzorowywane na te same pozycje. Przykładowo pierwszej literze słowa możemy przypisać wagę 3, drugiej 5, trzeciej 7 itd. Pozwala na to poniższa funkcja. int wordNum = 0; int w = 3; fo r (in t h = 0; h < w ord.length(); h++) { wordNum += word.charAt(h) * w; w = w + 2; } loc = wordNum % n + 1; //loc jest przypisywana wartość od 1 do n Ta sama technika spełni swoje zadanie także wtedy, kiedy klucz będzie się składał z dowolnych znaków. Kiedy stosujemy haszowanie, zależy nam na tym, by klucze były rozmieszczone w całym obszarze tablicy. Jeśli np. klucze będą odwzorowywane na pozycje należące jedynie do pewnego fragmentu tablicy, może to skutkować niepotrzebnie wysoką liczbą kolizji. W łaśnie dlatego należy starać się używać całego klucza. Jeżeli np. klucze mają postać łańcuchów znaków, nie byłoby mądrze odwzorowywać wszystkich kluczy zaczynających się od tej samej litery na to samo miejsce tablicy. Inaczej mówiąc, należy unikać systematycznego odwzorowywania kluczy na tę samą pozycję. Ponieważ haszowanie z założenia ma być bardzo szybkie, wyznaczenie wartości funkcji mieszającej powinno być dosyć proste. Można by poważnie zmniejszyć zysk czasowy, jaki zapewnia haszowanie, gdyby wyliczanie indeksu zajmowało zbyt dużo czasu.
10.2.2. Usuwanie elementu z tablicy mieszającej Ponownie przyjrzyjmy się tablicy, w której zostały zapisane nasze wszystkie przykładowe liczby. num
84
23
61
1
2
3
4
52
16
5
6
7
43
31
33
8
9
10
59 11
12
Pamiętamy, że liczby 43 i 31 zostały odwzorowane na tę samą pozycję 8. Załóżmy teraz, że chcemy usunąć z tablicy liczbę 43. Pierwszą myślą, jaka może przyjść do głowy, będzie wyczyszczenie elementu tablicy, w którym się ona znajduje. Załóżmy, że to zrobiliśmy (czyli wyczyściliśmy element o indeksie 8), a następnie spróbujemy odszukać liczbę 31. Liczba ta zostanie odwzorowana na pozycję 8, jednak ponieważ element num[8] jest pusty,
296
ROZDZIAŁ 10. ■ HASZOWANIE
dochodzimy do wniosku, że liczby 31 nie ma w tablicy. Innymi słowy, okazuje się, że nie możemy ograniczyć się do prostego wyczyszczenia wskazanego elementu tablicy, gdyż może to uniemożliwić dotarcie do innych zapisanych w niej kluczy. Najprostszym rozwiązaniem jest zapisanie w takim elemencie jakiejś wartości oznaczającej, że dany element został usunięty, przy czym musiałaby to być wartość, której nie można pomylić z wartością oznaczającą pusty element ani z jakimkolwiek kluczem. Jeśli w naszym przykładzie klucze są dodatnimi liczbami całkowitymi, możemy użyć wartości 0 do oznaczenia pustych elementów tablicy ( Empty) oraz wartości -1 do oznaczenia elementów usuniętych (Deleted). Przeszukując tablicę, wciąż możemy sprawdzać, czy element tablicy nie zawiera klucza bądź nie jest pusty; jednak elementy oznaczone jako usunięte będziemy ignorować. Często powtarzającym się błędem jest przerywanie poszukiwań w momencie trafienia na element oznaczony jako usunięty. Takie postępowanie może prowadzić do podejmowania niewłaściwych wniosków. Jeśli poszukiwania wykażą, że analizowanego klucza nie ma w tablicy, możemy go umieścić w pustym elemencie usuniętym (jeśli taki udało się wcześniej odnaleźć). Załóżmy np., że z naszej przykładowej tablicy usunęliśmy wcześniej liczbę 43, zapisując w tym celu w num[8] wartość -1. Jeśli teraz spróbujemy odszukać w tablicy liczbę 55, sprawdzimy pozycje 8, 9, 10 i 11. Ponieważ element num[11] jest pusty, uznajemy, że liczby 55 nie ma w tablicy. Można, gdybyśmy chcieli, zapisać 55 w num[11]. Można jednak zmodyfikować algorytm w taki sposób, by pamiętał, że element num[8] jest pusty, bo jego zawartość została wcześniej usunięta. Takie rozwiązanie byłoby lepsze, gdyż pozwoliłoby odnaleźć liczbę 55 szybciej, niż gdyby była przechowywana w elemencie num[11]. Oprócz tego, stanowiłoby ono przykład lepszego wykorzystania zasobów, gdyż pozwalałoby zredukować liczbę elementów tablicy oznaczonych jako usunięte. A co by się stało, gdyby w tablicy znalazło się kilka elementów usuniętych, umieszczonych jeden za drugim? Optymalnym rozwiązaniem byłoby użycie pierwszego z nich, gdyż ograniczyłoby to liczbę poszukiwań, które wykonamy w przyszłości. Korzystając z tych wszystkich pomysłów, przepiszemy teraz naszą funkcję do wyszukiwania i wstawiania. //odnalezienie lub wstawienie liczby ’key’ w tablicy num[1..n] loc = H(key) deletedLoc = 0 while (num[loc] != Empty && num[loc] != key) i f (deletedLoc == 0 && num[loc] == Deleted) deletedLoc = loc loc = loc % n + 1 endwhile i f (num[loc] == Empty) { //nie znaleziono klucza i f (deletedLoc != 0) loc = deletedLoc num[loc] = key endif e lse wyświetl "Liczbę " , key, " znaleziono na pozycji ", loc Trzeba zwrócić uwagę, że szukamy, aż do znalezienia pustego elementu tablicy, w którym możemy umieścić klucz. Jeśli znajdziemy element usunięty, a zmienna deletedLoc ma wartość 0, oznacza to, że znaleźliśmy pierwszy usunięty element. Oczywiście, jeśli nigdy nie n atrafim y na usunięty element, a klucza nie będzie w tablicy, zostanie on zapisany w pustym elemencie.
10.3. Rozwiązywanie kolizji W programie P10.1 rozwiązywaliśmy kolizje, sprawdzając następny element tablicy. Jest to zapewne najprostszy z istniejących sposobów radzenia sobie z kolizjami. W takim przypadku mówimy, że rozwiązujemy kolizje przy użyciu próbkowania liniowego (ang. lin ear probing), metody, która bardziej szczegółowo opisana zostanie w następnym podrozdziale. Dalej w tym rozdziale przyjrzymy się bardziej wyrafinowanym sposobom rozwiązywania kolizji. Będą to m.in. próbkowanie kwadratowe, m etoda łańcuchow a oraz podwójne haszowanie.
297
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
10.3.1. Próbkowanie liniowe Próbkowanie linowe można scharakteryzować przy użyciu instrukcji loc = loc + 1. Przyjrzyjmy się ponownie zawartości tablicy num po zapisaniu w niej dziewięciu liczb. num
84
23
61
1
2
3
4
52
16
5
6
7
43
31
33
8
9
10
59 11
12
Jak widać, szansa, że podczas odwzorowywania kolejnego klucza trafimy na pusty element tablicy, maleje wraz umieszczaniem w niej kolejnych liczb. Załóżmy, że klucz został odwzorowany na pozycję 12. W efekcie, po uprzednim sprawdzeniu pozycji 12, 1, 2 i 3, zostanie on umieszczony w elemencie num[4]. Gdy to nastąpi, uzyskamy jeden, długi i nieprzerwany ciąg kluczy, zapisanych na pozycjach od 12 od 6. Każdy kolejny klucz odwzorowany na pozycję z tego zakresu zostanie zapisany w elemencie num[7], doprowadzając tym samym do powstania jeszcze dłuższego ciągu kluczy. Fenomen ten, określany jako grupowanie (ang. clustering), jest jedną z podstawowych wad próbkowania liniowego. Długi ciągi mają tendencję do jeszcze większego wydłużania się, gdyż prawdopodobieństwo, że nowy klucz trafi w zakres pozycji tworzących dłuższy ciąg, jest większe, niż że trafi w zakres pozycji tworzących krótszy ciąg. Stosunkowo łatwo można także doprowadzić do połączenia dwóch krótszych zakresów w jeden dłuższy, który następnie będzie miał tendencję do dalszego wydłużania się. Przykładowo dowolny klucz, który trafi do elementu num[7], utworzy długi ciąg od pozycji 5 do 10. Zdefiniowano dwa rodzaje grupowania. • Grupowanie pierwotne (ang. prim ary clustering), które zachodzi, gdy klucze odwzorowywane na różne pozycje w poszukiwaniu wolnego miejsca trafiają na tę samą sekwencję. Próbkowanie liniowe przejawia cechy grupowania pierwotnego, gdyż klucz, który zostanie odwzorowany na pozycję 5, prześledzi sekwencję pozycji: 5, 6, 7, 8, 9 itd., a klucz, który zostanie odwzorowany na pozycję 6, prześledzi sekwencję pozycji: 6, 7, 8, 9 itd. • Grupowanie wtórne (ang. secon dary clustering), które zachodzi, gdy klucze, odwzorowywane na tę samą pozycję, w poszukiwaniu wolnego miejsca tablicy mieszającej przechodzą tę samą sekwencję. Próbkowanie liniowe przejawia cechy grupowania wtórnego, gdyż klucze, które zostaną odwzorowane na pozycję 5, prześledzą sekwencję 5, 6, 7, 8, 9 itd. Metody rozwiązywania kolizji, których celem jest poprawa działania próbkowania liniowego, koncentrują się na próbach elim inacji obu tych rodzajów grupowania. Można by się zastanawiać, czy użycie wyrażenia loc = loc + k, gdzie k jest większe od 1 (np. wynosi 3), da lepsze efekty niż użycie loc = loc + 1. Jak się okazuje, nie zmieni to występowania grupowania, gdyż wciąż będą formowane grupy składające się z elementów zajmujących co k-tą pozycję. Co więcej, takie rozwiązanie może się nawet okazać gorsze od użycia k o wartości 1, gdyż istnieje możliwość, że niektóre pozycje w ogóle nie zostaną zajęte. Załóżmy, że tablica składa się z 12 elementów, k ma wartość 3, a odwzorowany klucz trafia na pozycję 5. Sekwencja pozycji, jaka zostanie sprawdzona, będzie zawierać pozycje: 5, 8, 11, 2, (11 + 3 - 12), 5 i dalej sekwencja zacznie się powtarzać. Dla porównania, w przypadku gdy k wynosi 1, wygenerowane zostaną wszystkie pozycje tablicy. Jednak opisana powyżej sytuacja nie stanowi poważnego problem u. Jeśli wielkość tablicy wynosi m, a mi k są liczbami względnie pierwszymi (nie mają wspólnych dzielników z wyjątkiem liczby 1), to i tak wygenerowane zostaną wszystkie pozycje. W arunek ten zostanie spełniony, jeśli jedna z tych liczb jest liczbą pierwszą, a druga nie stanowi jej wielokrotności, przykładem może tu być para liczb 5 i 12. W rzeczywistości jednak nie ma wymogu użycia liczby pierwszej. Liczby 21 i 50 (żadna z nich nie jest pierwszą) są względnie pierwszymi, ponieważ ich jedynym wspólnym dzielnikiem jest 1. Jeśli k przyjmie wartość 5, a mwartość 12, to podczas prób umieszczenia w tablicy klucza odwzorowanego na pozycję 5 odwiedzona zostanie sekwencja pozycji: 5, 10, 3, 8, 1, 6, 11, 4, 9, 2, 7, 12 — czyli wszystkie istniejące w tablicy. W przypadku odwzorowania klucza na dowolną inną pozycję także odwiedzone zostaną wszystkie pozycje.
298
ROZDZIAŁ 10. ■ HASZOWANIE
Jednak możliwość odwiedzenia wszystkich pozycji w tablicy jest zagadnieniem czysto akademickim, gdyż gdybyśmy musieli odwiedzać wiele pozycji w celu odnalezienia pustej, poszukiwania trwałyby zbyt długo i trzeba by poszukać innej metody. Pomimo tego, co napisaliśmy wcześniej, okazuje się, że użycie metody loc = loc + k, gdzie k zmienia się wraz z kluczem, jest jednym z najlepszych sposobów implementacji haszowania. Jak to zrobić, pokazano w punkcie 10.3.4. A zatem, jak szybka jest metoda próbkowania liniowego? Interesuje nas średnia długość poszukiwań, czyli liczba pozycji, jakie musimy sprawdzić, by odszukać lub wstawić dany klucz. W poprzednim przykładzie długość poszukiwań dla klucza 33 wyniosła 1, dla klucza 61 wyniosła 2, a dla klucza 23 — 3. Długość poszukiwań jest funkcją współczynnika wypełnienia tablicy (oznaczanego literą f), gdzie: .
liczba wartości iv tablicy liczba elementów tablicy
,,
. .
.
...
/ = ---------------------------------------= współczynnik wypełnienia tablicy
W przypadku udanych poszukiwań średnia liczba porównań wynosi “ ^1 + nieudanych wynosi ona 2
j , a dla poszukiwań
^ f j j . Należy zwrócić uwagę, że długość poszukiwań zależy nie od wielkości
tablicy, lecz od współczynnika jej wypełnienia. W tabeli 10.1 pokazano, jak zmienia się długość poszukiwań wraz ze wzrostem współczynnika wypełnienia tablicy. Tabela 10.1. Długość poszukiwań zwiększa się wraz ze wzrostem stopnia wypełnienia tablicy f
Długość udanych poszukiwań
Długość nieudanych poszukiwań
0,25
1,2
1,4
0,50
1,5
2,5
0,75
2,5
8,5
0,90
5,5
50,5
Gdy tablica będzie w 90% wypełniona, średnia długość udanych poszukiwań ma rozsądną wartość 5,5. Niemniej jednak sprawdzenie, czy nowego klucza nie ma w tablicy, może potrwać całkiem długo (wymaga aż 50,5 prób). Zatem w przypadku zastosowania próbkowania liniowego warto zadbać o to, by tablica nigdy nie była wypełniona więcej niż w 75%. Dzięki temu możemy zagwarantować wysoką wydajność, stosując prosty algorytm.
10.3.2. Próbkowanie kwadratowe W tej metodzie próbkowania zakładamy, że jeśli na pozycji loc wystąpiła kolizja sprawdzanego klucza, przechodzimy na pozycję oddaloną o ai+bi2, gdzie a oraz b są pewnymi stałymi, a i przyjmuje wartość 1 dla pierwszej kolizji, wartość 2 dla drugiej kolizji, wartość 3 dla trzeciej itd. Jeśli np. przyjmiemy, że a = 1 i b = 1, to z początkowej pozycji loc przesuwamy się o i+ i2. Załóżmy, że funkcja mieszająca początkowo wskazała pozycję 7., lecz podczas próby umieszczenia w niej klucza wystąpiła kolizja. W takim razie obliczamy wartość wyrażenia i+i2 dla i = 1, czyli przesuwamy się o 2, uzyskując nową pozycję 7+2 = 9. Jeśli ponownie wystąpi kolizja, obliczamy wartość wyrażenia i+i2 dla i = 2, co daje 6. W efekcie przesuwamy się o następne 6 pozycji, czyli 9+6 = 15. Jeśli jeszcze raz wystąpi kolizja, obliczamy wartość wyrażenia i+ i2 dla i = 3, co daje 12; zatem przesuwamy się o 12 pozycji i sprawdzamy pozycję 15+12 = 27. I tak dalej. Po każdej kolejnej kolizji powiększamy wartość i o 1 i ponownie obliczamy, o ile pozycji mamy się przesunąć tym razem. Ten proces jest kontynuowany, aż do odnalezienia pustego miejsca w tablicy.
299
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Jeśli w którymkolwiek momencie obliczenia wskażą pozycję znajdującą się poza tablicą, należy potraktować ją jako tablicę cykliczną i wznowić poszukiwania od jej początku. Jeśli np. tablica składa się z 25 elementów, a obliczenia wskazały pozycję 27, nowe położenie wyznaczymy, odejmując 27 -2 5 , czyli od pozycji 2. Jeśli dla kolejnego klucza w początkowym miejscu wyznaczonym przez funkcję mieszającą wystąpiła kolizja, przypisujemy i wartość oraz sprawdzamy kolejne wyznaczane pozycje w sposób opisany przed chwilą. Warto zwrócić uwagę, że dla każdego kolejnego klucza sekwencja „przyrostów” będzie taka sama: 2, 6, 12, 20, 30... Oczywiście, możemy uzyskać inne sekwencje, zmieniając wartości stałych a i b. Powyższy proces możemy zaimplementować w formie następującego algorytmu. //odnalezienie lub wstawienie liczby ’key’ w tablicy num[1..n] loc = H(key) i = 0 while (num[loc] != Empty && num[loc] != key) i = i + 1 loc = loc + a * i + b * i * i while (loc > n) loc = loc - n //while zamiast if; patrz uwaga poniżej endwhile i f (num[loc] == Empty) num[loc] = key e lse wyświetl "Liczbę " , key, " znaleziono na pozycji ", loc
■
U w a g a W ty m a lg o r y tm ie z a s to s o w a liś m y p ę tlę
while
z a m i a s t in s t r u k c ji
if,
n a w y p a d e k g d y b y n o w a p o z y c ja
w s k a z a n a p r z e z o b lic z e n ia w y p a d ł a d a l e j o d k o ń c a t a b l ic y , n iż w y n o s i je j d łu g o ś ć . J e ś li n p .
nw ynosi
2 5 , w y lic z o n y
p r z y r o s t m a w a r t o ś ć 4 2 , a m y z n a j d u j e m y s ię n a p o z y c ji 2 0 , t o w e d ł u g w z o r u p o w i n n i ś m y s p r a w d z i ć p o z y c ję 6 2 . G d y b y ś m y u ż y li in s tr u k c ji
if,
n o w ą p o z y c j ą b y ło b y 6 2 - 2 5 =
t a b l i c y . D z ię k i z a s t o s o w a n i u p ę t li
while
3 7 , c z y li w c i ą ż m ie j s c e z n a j d u j ą c e s ię p o z a z a k r e s e m
d z i a ł a n i e z o s t a n i e w y k o n a n e p o w t ó r n i e i u z y s k a m y p o z y c ję 3 7 - 2 5 =
12.
A czy zamiast pętli while moglibyśmy użyć wyrażenia loc % n? W naszym przykładzie uzyskalibyśmy prawidłową lokalizację, gdyby jednak nowa lokalizacja była wielokrotnością n, to wyrażenie loc % n zwróciłoby wartość 0. Byłaby to niewłaściwa pozycja, gdyby tablica była indeksowana od 1. W próbkowaniu kwadratowym dla kluczy odwzorowywanych na różne pozycje sekwencje odwiedzane po wystąpieniu kolizji także będą różne. Dlatego też problem grupowania pierwotnego w tym przypadku nie występuje. Jednak w przypadku kluczy odwzorowanych na to samo położenie sekwencje odwiedzane po wystąpieniu kolizji wciąż będą takie same; dlatego problem grupowania wtórnego wciąż występuje. Oto kilka dodatkowych informacji, na które warto zwrócić uwagę. • Jeśli n jest potęgą liczby 2, czyli n = 2m dla pewnego m, przedstawiona metoda pozwoli odwiedzić wyłącznie niewielką część wszystkich elementów tablicy, co sprawia, że nie jest szczególnie wydajna. • Jeśli n jest liczbą pierwszą, metoda ta pozwala odwiedzić połowę elementów tablicy; dla większości praktycznych zastosowań jest to wartość wystarczająca.
10.3.3. Metoda łańcuchowa W tej metodzie wszystkie elementy trafiające na tę samą pozycję są umieszczane na liście powiązanej. Jedną z dużych zalet tej metody jest to, że elementy trafiające na pozycje położone „blisko” siebie nie będą sobie wzajemnie przeszkadzały, gdyż nie będą rywalizować o puste pozycje w tablicy, jak to się dzieje w przypadku próbkowania liniowego. Jednym ze sposobów im plem entacji takiego rozwiązania jest zapisywanie w elementach tablicy mieszającej wskaźników na początek listy. Jeśli np. hash[1..n] jest tablicą mieszającą, hash[k] wskazuje na początek listy elementów odwzorowanych na pozycję k. Nowe elementy mogą być dodawane na początku tej listy, na jej końcu bądź w miejscu zapewniającym, że zawartość listy będzie posortowana. W celu zilustrowania tej metody załóżmy, że elementami są liczby całkowite. Każda lista powiązana będzie się składać z wartości całkowitej oraz wskaźnika na następny element listy. Do tworzenia węzłów tej listy użyjemy poniższej klasy.
300
ROZDZIAŁ 10. ■ HASZOWANIE
class Node { int num; Node n ex t; public Node(int n) { num = n; next = n u ll; } } //koniec klasy Node Możemy zdefiniować tablicę hash w następującej postaci: Node[] hash = new Node[n+1];
/ / zakładamy, że n ma wartość
A także zainicjalizować jej zawartość przy użyciu poniższej pętli for: fo r (in t h = 1; h <= n; h++) hash[h] = n u ll; Załóżmy teraz, że odczytany klucz i nKey zostaje odwzorowany na pozycję k. Musimy zatem odszukać klucz inKey w liście, wskazywanej przez zawartość hash[k]. Jeśli nie uda się go znaleźć, musimy dodać go do listy. W naszym program ie będziemy dodawać klucze w taki sposób, że zawartość listy będzie posortowana rosnąco. Teraz napiszemy program P10.2, który będzie zliczał unikalne liczby całkowite zapisane w pliku numbers.in. Będzie on korzystał z techniki haszow an ia z wykorzystaniem m etody łańcuchow ej. Po przetworzeniu danych wejściowych program wyświetli listę liczb odwzorowanych na poszczególne pozycje tablicy. Program P10.2 import ja v a .u t i l . * ; import ja v a .i o .* ; public c la ss HashChain { final s t a tic in t N = 13; public s t a t ic void m ain(String[] args) throws IOException { Scanner in = new Scanner(new FileReader("num bers.in")); Node[] hash = new Node[N+1]; fo r (in t h = 1; h <= N; h++) hash[h] = n u ll; int d is tin c t = 0; while (in .h asN extIn t()) { int key = in .n e x tIn t(); i f (!search(k ey , hash, N)) d is tin ct+ + ; } System .out.printf("\nZnaleziono %d unikalnych liczb\n\n", d is tin c t ) ; for (in t h = 1; h <= N; h++) i f (hash[h] != null) { System .out.printf("hash[% d]: ", h); p rin tL ist(h a s h [h ]); } i n .c lo s e () ; } //koniec main public s t a t ic boolean search (in t inKey, Node[] hash, int n) { //funkcja zwraca true, jeśli klucz inKey został znaleziony, oraz false //w przeciwnym przypadku; nowy klucz jest dodawany do listy tak, //by jej zawartość była posortowana in t k = inKey % n + 1; Node curr = hash[k]; Node prev = n u ll;
301
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
while (curr != null && inKey > curr.num) { prev = curr; curr = cu rr.n ex t; } i f (curr != null && inKey == curr.num) return tru e; //klucz znaleziony //nie udało się znaleźć klucza; dodajemy go do listy z zachowaniem porządku Node np = new Node(inKey); np.next = curr; i f (prev == null) hash[k] = np; e lse prev.next = np; return fa ls e ; } //koniec search public s t a t i c void printList(Node top) { while (top != null) { System .out.printf("% 2d " , top.num); top = top.next; } S y stem .o u t.p rin tf("\ n "); } //koniec printList } //koniec klasy HashChain class Node { int num; Node next; public Node(int n) { num = n; next = n u ll; } } //koniec klasy Node Załóżmy, żew pliku num bers.in zostały zapisane poniższe liczby: 24 57 35 37 31 98 85 47 60 32 48 82 16 96 87 46 53 92 71 56 73 85 47 46 22 40 95 32 54 67 31 44 74 40 58 42 88 29 78 87 45 13 73 29 84 48 85 29 66 73 87 17 10 83 95 25 44 93 32 39 W takim przypadku program P10.2 wygeneruje następujące wyniki. Znaleziono 43 unikalnych licz b hash[1]: hash[2]: hash[3]: hash[4]: hash[5]: hash[6]: hash[7]: hash[8]: hash[9]: hash[10]: h a s h [n ]: hash[12]: hash[13]:
302
13 39 78 40 53 66 92 54 67 93 16 29 42 17 56 82 95 31 44 57 83 96 32 45 58 71 84 46 85 98 47 60 73 22 35 48 74 87 10 88 24 37 25
ROZDZIAŁ 10. ■ HASZOWANIE
Jeśli na listach powiązanych zostało zapisanych mkluczy, a w tablicy znajduje się n pozycji, to średnia długość list wynosi — , a ponieważ listy musimy przeszukiwać liniowo, średnia długość udanego wyszukiwania będzie wynosić — . Długość wyszukiwania można zmniejszać poprzez zwiększanie liczby pozycji dostępnych w tablicy. 2n Innym sposobem implementacji haszowania z użyciem metody łańcuchowej jest zastosowanie jednej tablicy i użycie jej indeksów jako odwołań. W tym przypadku możemy zastosować następujące deklaracje: class Node { int num; //klucz int next; //indeks tablicy do następnego elementu listy } Node[] hash = new Node[MaxItems+1]; Pierwsza część tablicy, dajmy na to hash[1. .n ], pełni rolę tablicy mieszającej, natomiast jej pozostałe elementy spełniają rolę tablicy nadmiarowej (ang. overflow table ); pokazano to na rysunku 10.1.
Rysunek 10 .1. Implementacja metody łańcuchowej wykorzystująca tablicę W naszym przypadku hash[1. .5] jest tablicą mieszającą, a hash[6..15] — tablicą nadmiarową. Załóżmy, że klucz zapisany w zmiennej key został odwzorowany na pozycję k w tablicy mieszającej. • Jeśli pole hash[k].num jest puste (przykładowo ma wartość 0), zapisujemy w nim wartość key, a polu hash[k] .next przypisujemy wartość -1, informując tym samym, że zawiera wskaźnik pusty. • Jeśli pole hash[k] .num jest różne od 0, musimy przeszukać listę, zaczynając od k odpowiadającego danej wartości klucza (key). Jeśli nie uda się go znaleźć, zapisujemy go w następnej dostępnej pozycji (dajm y na to f) w tablicy nadmiarowej i dodajemy do listy, zaczynając od hash[k]. Oto jeden ze sposobów, w jaki można to zrobić. hash [f].n ext = hash[k].next; hash[k].next = f ; • Innym sposobem dołączenia nowego klucza jest dodanie go na końcu listy. Jeśli Ljest pozycją ostatniego węzła listy, można to zrobić przy użyciu dwóch poniższych instrukcji. hash[L].next = f ; h a s h [f].next = -1 ;
//teraz to jest ostatni węzeł
303
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Kiedy konieczne jest zapewnienie możliwości usuwania kluczy, musimy podjąć decyzję, co robić z usuwanymi pozycjami. Jednym z rozwiązań jest przechowywanie w tablicy nadmiarowej listy wszystkich dostępnych pozycji. Kiedy trzeba będzie dodać nowy klucz, wystarczy pobrać z listy pozycję, w której zostanie zapisany. Z kolei w przypadku usuwania klucza zajmowana przez niego pozycja jest zwracana do listy. Początkowo wszystkie elementy tablicy nadmiarowej możemy połączyć w sposób przedstawiony na rysunku 10.2, a położenie pierwszego wolnego elementu zapisać w zmiennej free (free = 6). Element 6. wskazuje na element 7., ten wskazuje na element 8. itd., aż do elementu 15., który znajduje się na samym końcu listy.
Rysunek 10 .2. Lista powiązana tworząca „listę dostępnych elementów”, umieszczona w tablicy nadmiarowej Załóżmy, że klucz 37 został odwzorowany na pozycję 2. Ta komórka tablicy jest pusta, zatem zapisujemy 37 w hash[2] .num. Jeśli teraz inna liczba (np. 24) także zostanie odwzorowana na pozycję 2, musi zostać zapisana w tablicy nadmiarowej. Najpierw musimy pobrać jej pozycję z „listy dostępnych elementów”. Do tego służy następujący fragment kodu: f = fre e ; free = h ash [fre e ].n e x t; return f ; W tym przypadku zwraca on wartość 6, a w zmiennej free zapisywana jest wartość 7. Liczba 24 zostaje zapisana w pozycji 24, natomiast wartością pola hash[2].next zostaje 6. Kiedy to nastąpi, zmienna free przyjmuje wartość 7, a zawartość tablic wygląda tak, jak na rysunku 10.3. A teraz zastanówmy się, w jaki sposób można usuwać klucze. Musimy rozpatrzyć dwa przypadki. • Jeśli usuwany element znajduje się w tablicy mieszającej (w pozycji dajmy na to k), możemy go usunąć, wykonując następujący algorytm. i f (hash[k].next == -1) zapisujemy w hash[k] wartość Empty // tylko elementy listy e lse //kopiujemy element z tablicy nadmiarowej do tablicy mieszającej h = hash[k].next; hash[k] = hash[h]; //kopiujemy informacje zpozycji h na pozycję k zwracamy h do l is t y dostępnych elementów //patrz informacje w tekście endif
304
ROZDZIAŁ 10. ■ HASZOWANIE
Rysunek 10 .3. Stan tablic po dodaniu do tablicy nadmiarowej liczby 24 • Jego pozycję (dajmy na to h) możemy zwrócić do listy dostępnych elementów. hash[h].next = fre e ; free = h; • Jeśli usuwany element znajduje się w tablicy nadmiarowej (na pozycji dajmy na to curr) i prev jest położeniem elementu wskazującego na usuwany, samą operację usunięcia możemy wykonać, używając poniższego fragmentu kodu. hash[prev].next = hash [cu rr].n ext; zwracamy curr do l is t y dostępnych elementów A teraz zastanówmy się, w jaki sposób można przetwarzać kolejne klucze? Załóżmy, że zmienna free ma wartość 9, a liczba 52 została odwzorowana na pozycję 2. Przeszukujemy listę, zaczynając od pozycji 2, i sprawdzamy, czy nie jest w niej zapisana wartość 52. Jeśli jej nie znajdziemy, 52 zostanie zapisana w następnej dostępnej pozycji — 9. Pozycja 6 zawiera ostatni elem ent listy, zatem w hash[6]. next zapisujemy 9, a w hash[9] .next zapisujemy -1. Ogólnie rzecz biorąc, klucz key możemy odszukać, a jeśli nie zostanie znaleziony, dodać go na końcu listy, używając algorytmu opisanego następującym pseudokodem. k = H(key) //H jest funkcją mieszającą i f (hash[k].num == Empty) hash[k] .num = key hash[k].next = -1 else curr = k prev = -1 while (curr != -1 && hash[curr].num != key) prev = curr curr = hash[curr].next endwhile i f (curr != -1) key je s t na l i ś c i e w pozycji curr e lse //liczby key nie ma na liście hash[free].num = key //zakładamy, że lista dostępnych elementów nie jest pusta h ash [free].n ext = -1
305
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
hash[prev].next = free free = h ash [free].n ext endif //koniec else endif //koniec else
10.3.4. Próbkowanie liniowe z podwójnym haszowaniem1 W punkcie 10.3.1 przekonaliśmy się, że zastosowanie rozwiązania loc = loc + k, gdzie k jest większe od 1, nie zapewnia lepszej wydajności niż ta, którą uzyskujemy, gdy k jest równe 1. Jeśli jednak k będzie się zmieniać wraz z kluczem, możemy uzyskać znakomite wyniki, bo (w odróżnieniu do próbkowania liniowego i kwadratowego) kiedy różne klucze zostaną odwzorowane na tę samą pozycję, dla każdego z nich sekwencja pozycji odwiedzanych w przypadku wystąpienia kolizji będzie inna. Najbardziej naturalnym sposobem zapewnienia możliwości zmiany k jest zastosowanie drugiej funkcji mieszającej. Pierwsza z nich będzie używana do generowania początkowej pozycji w tablicy mieszającej. Kiedy okaże się, że wystąpiła kolizja, druga funkcja wyznaczy używany przyrost k. Jeśli elementy tablicy, w których należy umieścić klucz, należą do zakresu od 1 do n, możemy użyć rozwiązania opisanego następującym pseudokodem. skonwertuj klucz do postaci numerycznej, num ( je ś l i nie je s t on w artością numeryczną) loc = num % n + 1 //ta instrukcja wyznacza początkowe położenie klucza k = num % (n - 2) + 1 //ta instrukcja wyznacza przyrost dla danego klucza Jak wspomniano wcześniej, mądrze jest wybrać n (rozmiar tablicy), które będzie liczbą pierwszą. W tej metodzie możemy uzyskać jeszcze lepsze wyniki, jeśli n - 2 także będzie liczbą pierwszą (takie dwie liczby pierwsze są nazywane liczbami bliźniaczymi; przykładami takich liczb są 103 i 101, 1021 i 1019). Oprócz tego, że wartość k nie jest stała, metoda ta niczym się nie różni od p ró b k o w an ia liniowego. Opisana zostanie przy użyciu dwóch funkcji mieszających H1 i H2. Funkcja H1 służy do określenia początkowej pozycji w tablicy mieszającej i zwraca wartości z zakresu od 1 do n włącznie. Z kolei funkcja H2 generuje przyrosty będące wartościami z zakresu od 1 do n - 1, przy czym n - 1 i n powinny być liczbami względnie pierwszymi. Spełnienie tego warunku jest pożądane, gdyż w przypadku występowania kolizji pozwoli sprawdzić możliwie dużo pozycji. Zgodnie z informacjami podanymi wcześniej, jeśli n jest liczbą pierwszą, wszystkie liczby z zakresu od 1 do n - 1 oraz n będą liczbami względnie pierwszymi. W poprzednim przykładzie druga funkcja mieszająca generuje wartości z zakresu od 1 do n - 2. Poniżej opisano jej algorytm. //odnalezienie lub wstawienie liczby ’key’przy użyciu próbkowania liniowego //z podwójnym haszowaniem loc = H1(key) k = H2(key) while (hash[loc] != Empty && hash[loc] != key) loc = loc + k i f (loc > n) loc = loc - n endwhile i f (hash[loc] == Empty) hash[loc] = key e lse print key, " znaleziono na pozycji " , loc e lse wyświetl "Liczbę " , key, " znaleziono na pozycji ", loc Podobnie jak wcześniej, także i w tym algorytmie, aby zagwarantować, że działanie pętli while kiedyś się zakończy, nie dopuszczamy do całkowitego wypełnienia tablicy. Jeśli chodzi o obsłużenie, dajmy na to MaxItems elementów, zadeklarowany rozmiar tablicy powinien być większy od MaxI tems. Ogólnie rzecz biorąc, im więcej będzie wolnych pozycji w tablicy, tym lepsze wyniki będzie dawało haszowanie. Kiedy jednak zastosujemy podwójne haszowanie, do zapewniania dobrej wydajności nie potrzebujemy tak wielu pustych pozycji w tablicy, jak podczas użycia zwyczajnego próbkowania liniowego. Wynika to z faktu, że podwójne haszowanie eliminuje zarówno grupowanie pierwotne, jak i wtórne.
1 Technika ta jest czasami także nazywana adresowaniem otwartym z podwójnym haszowaniem.
306
ROZDZIAŁ 10. ■ HASZOWANIE
Grupowanie pierwotne jest eliminowane, gdyż klucze odwzorowywane na tę samą pozycję generują różne sekwencje sprawdzanych pozycji. Z kolei grupowanie wtórne jest eliminowane, ponieważ różne klucze, które zostaną odwzorowane na tę samą pozycję, będą generowały odmienne sekwencje. Ogólnie rzecz biorąc, będzie się tak działo dlatego, że różne klucze będą generowały różne przyrosty (w algorytmie oznaczone jako k). Byłoby naprawdę rzadkim zbiegiem okoliczności, gdyby dwa różne klucze zostały odwzorowane na tę samą pozycję zarówno przez funkcję H1, jak i H2. W praktyce wydajność każdego zastosowania technik haszowania można poprawić, gromadząc informacje o tym, jak często są używane poszczególne klucze. Jeśli takimi informacjam i dysponujemy jeszcze przed implementacją rozwiązania, możemy wypełnić tablicę mieszającą, umieszczając w niej najpierw te klucze, które są używane najczęściej, a dopiero potem wykorzystywane rzadziej. Takie rozwiązanie pozwoli obniżyć średni czas dostępu do wszystkich kluczy. Jeśli jednak, zabierając się do pracy, nie dysponujemy takimi informacjami, do każdego klucza możemy dołączyć licznik i inkrementować jego wartość, za każdym razem gdy dany klucz zostanie użyty. Po pewnym określonym czasie (np. po miesiącu) możemy ponownie wypełnić tablicę mieszającą, umieszczając w niej najpierw najczęściej używane klucze, a dopiero potem mniej popularne. Po takiej modyfikacji zerujemy wartości liczników i przez następny miesiąc zbieramy nowe statystyki. W ten sposób możemy zapewnić, że aplikacja cały czas będzie odpowiednio „dostrojona”, gdyż w następnym miesiącu inne klucze mogą być częściej używane.
10.4. Przykład: licznik występowania słów Ponownie rozważmy problem napisania programu, który zlicza wystąpienia poszczególnych słów w pewnym fragmencie tekstu. Jego wynikiem ma być wyświetlenie alfabetycznej listy słów wraz z ilością wystąpień każdego z nich. W tym przykładzie słowa będą zapisywane w tablicy mieszającej, przy użyciu próbkowania liniowego z podwójnym haszowaniem. Każdy element tablicy będzie zawierał trzy pola: word, freq oraz next. Do tworzenia obiektów zapisywanych w tablicy mieszającej zastosujemy poniższą klasę. class WordInfo { String word = int freq = 0; int next = -1 ; } //koniec klasy WordInfo Tablica jest deklarowana i inicjalizowana przy użyciu następujących instrukcji. WordInfo[] wordTable = new WordInfo[N+1]; fo r (in t h = 1; h <= N; h++) wordTable[h] = new WordInfo(); Po odczytaniu każdego kolejnego słowa przeszukujemy tablicę. Jeśli nie uda się znaleźć słowa, dodajemy je do tablicy, a jego licznik jest ustawiany na 1. W przeciwnym razie, jeśli słowo zostanie odnalezione, jedynie inkrementujemy jego licznik. Oprócz tego, w momencie dodawania słowa do tablicy ustawiamy odwołania w taki sposób, by zawartość powiązanej listy słów była posortowana w porządku alfabetycznym. Zmienna f i r s t wskazuje pierwsze słowo na liście. Załóżmy np., że w tablicy mieszającej zostało już zapisanych pięć słów. Są one ze sobą połączone przy wykorzystaniu pola next, co pokazano na rysunku 10.4; zmienna f i r s t ma wartość 6. A zatem chłopak jest pierwszym słowem, które wskazuje na dla (1), to z kolei wskazuje na dziewczyna (7), które wskazuje na osoba (4), a to słowo wskazuje na ten (3), które nie wskazuje na nic (-1). Słowa są umieszczone na liście w kolejności alfabetycznej: chłopak dla dziewczyna osoba ten. W arto zwrócić uwagę, że odwołania pomiędzy poszczególnymi słowami będą działać niezależnie od tego, w którym miejscu tablicy umieści je funkcja mieszająca. Algorytm haszowania najpierw umieszcza słowo w tablicy. Następnie, niezależnie od tego, gdzie zostało ono umieszczone, jego pozycja zostaje dołączona do listy w taki sposób, by zachować jej uporządkowanie. Załóżmy np., że nowe słowo, kotek, zostało umieszczone w pozycji 2; w takim przypadku w polu next obiektu ze słowem kotek zostanie zapisana liczba 4 (dzięki czemu będzie on wskazywał na słowo osoba), natomiast w polu next obiektu ze słowem dziewczyna zostanie zapisana liczba 2 (dzięki czemu będzie on wskazywał na słowo kotek).
307
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Rysunek 10 .4. Słowa połączone w listę, posortowaną w kolejności alfabetycznej (first = 6) Alfabetyczną listę słów możemy wyświetlić, odwiedzając kolejne elementy listy powiązanej. Kod programu P10.3 przedstawia wszystkie szczegóły tego rozwiązania.
Program P10.3 import ja v a .i o .* ; import ja v a .u t i l . * ; public c la ss WordFrequencyHash { s t a tic Scanner in; s t a tic PrintW riter out; final s t a tic in t N = 13; //wielkość tablicy final s t a tic in t MaxWords = 10; final s t a tic S trin g Empty = " " ; public s t a t ic void m ain(String[] args) throws IOException { in = new Scanner(new FileReader("w ordFreq.in")); out = new PrintWriter(new FileW riter("w ord Freq.out")); WordInfo[] wordTable = new WordInfo[N+1]; fo r (in t h = 1; h <= N; h++) wordTable[h] = new WordInfo(); int f i r s t = -1 ; //wskazuje pierwsze słowo w porządku alfabetycznym int numWords = 0; in.useD elim iter("[^a-zA -ZąćęTńóśźż]+"); while (in.hasN ext()) { String word = in.n ext().toL ow erC ase(); int loc = search(wordTable, word); i f (lo c > 0) w ordTable[loc].freq++; e lse //to nowe słowo i f (numWords < MaxWords) { //jeśli tablica nie jest pełna f i r s t = addToTable(wordTable, word, -lo c , f i r s t ) ; ++numWords; } e lse out.printf("STowo '%s' nie zostało dodane do tab licy\ n ", word); } printResults(wordTable, f i r s t ) ; in .c lo s e (); o u t.c lo s e (); } //koniec main public s t a t i c int search(WordInfo[] ta b le , S trin g key) { //funkcja szuka klucza ’key’ w tablicy; jeśli go znajdzie, zwraca jego
308
ROZDZIAŁ 10. ■ HASZOWANIE
//pozycję; w przeciwnym razie, jeśli słowo musi być wstawione w pozycji //loc, funkcja zwraca -loc. int wordNum = convertToNumber(key); int loc = wordNum % N + 1; int k = wordNum % (N - 2) + 1; while (!table[loc].w ord.equals(Em pty) && !tab le[lo c].w o rd .eq u als(k ey )) { loc = loc + k; i f (loc > N) loc = loc - N; } i f (table[loc].w ord.equals(Em pty)) return -lo c ; return lo c; } //koniec search public int int fo r
s t a t i c int convertToNumber(String key) { wordNum = 0; w = 3; (in t h = 0; h < k ey .len g th (); h++) { wordNum += key.charAt(h) * w; w = w + 2;
} return wordNum; } //koniec convertToNumber public s t a t i c int addToTable(WordInfo[] ta b le , String key, int lo c, int head) { //funkcja zapisuje key w table[loc] i dołącza go w kolejności alfabetycznej table[loc].w ord = key; ta b le [lo c ].fr e q = 1; int curr = head; int prev = -1 ; while (curr != -1 && key.compareTo(table[curr].word) > 0) { prev = curr; curr = ta b le [c u r r].n e x t; } ta b le [lo c ].n e x t = curr; i f (prev == -1) return lo c; //nowy pierwszy element tab le[p rev ].n ext = lo c; return head; //pierwszy element nie został zmieniony } //koniec addToTable public s t a t i c void printResults(W ordInfo[] ta b le , int head) { out.printf("\nSTowo Liczba wystąpień\n\n"); while (head != -1) { o ut.p rin tf("% -15s %2d\n", table[head].word, ta b le [h e a d ].fre q ); head = tab le[h ead ].n ext; } } //koniec printResults } //koniec klasy WordFrequencyHash class WordInfo { String word = " " ; int freq = 0; int next = -1 ; } //koniec klasy WordInfo
309
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
Załóżmy, że w pliku w ordFreq.in został zapisany następujący fragment tekstu: Je ż e li marząc - nie ulegasz marzeniom; Je ż e li rozumując - rozumowania nie czynisz celem; Je ż e li umiesz przyjąć sukces i porażkę, jednakowo tra k tu ją c oba te złudzenia; Jeśli w takim przypadku wielkość tablicy mieszającej wyniesie 13, a stała MaxWords będzie mieć wartość 10, wykonanie programu P10.3 spowoduje zapisanie w pliku w ordF req.out następujących wyników. Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo Słowo
p rzy jąć' nie zostało dodane do ta b licy sukces' nie zostało dodane do ta b licy i ' nie zostało dodane do ta b licy porażkę' nie zostało dodane do ta b licy jednakowo' nie zostało dodane do ta b licy tra k tu ją c ' nie zostało dodane do ta b licy oba' nie zostało dodane do ta b licy t e ' nie zostało dodane do ta b licy złudzenia' nie zostało dodane do ta b licy ś cie rp is z ' nie zostało dodane do ta b licy wypaczenie' nie zostało dodane do ta b licy twoich' nie zostało dodane do ta b licy słów' nie zostało dodane do ta b licy
Słowo
Liczba wystąpień
celem czynisz je ż e li marzeniom marząc nie rozumowania rozumując ulegasz umiesz
1 1 1 1 1 1 1 1
Ćwiczenia 1.
Liczby c a łk o w ite są w s ta w ia n e do ta b lic y m ieszającej H [1..11] przy użyciu g łó w n e j funkcji m ieszającej h1(k) = 1 + k mod 11. Pokaż stan ta b lic y po w s ta w ie n iu kluczy: 10, 22, 31, 4, 15, 28, 17, 88 o raz 58 ; przy założeniu, że u żyw a n e jest: a) p ró b k o w a n ie lin io w e, b) p ró b k o w a n ie k w a d ra to w e korzystające z funkcji próbkującej i + 12 oraz c) p o d w ó jn e h a s zo w a n ie korzystające z funkcji h2(k) = 1 + k mod 9 .
2.
Liczby c a łk o w ite są w s ta w ia n e do ta b lic y m ieszającej l i s t [ 1 ] do l is t [ n ] przy użyciu p ró b k o w a n ia lin io w e g o z p o d w ó jn y m h a s zo w a n ie m . Za łó ż, że funkcja h1 g e n e ru je p o c z ą tk o w e pozycje w ta b lic y m ieszającej, n a to m ia s t funkcja h2 g e n e ru je przyrosty. Każda d ostępna pozycja w ta b lic y za w ie ra w a rto ś ć Empty, a pozycja, której w a rto ś ć zo stała usunięta, za w ie ra w a rto ś ć Del eted . N apisz funkcję do po s zu k iw a n ia p o d a n e j w artości key. Jeśli uda się ją zn aleźć, funkcja ma zw ra c a ć pozycję za w iera ją cą tę w artość. W przeciw nym razie, jeśli w artość key nie zostanie odn alezio n a, funkcja ma w s ta w ia ć ją do ta b lic y w pierw szej n a p o tk a n e j pozycji oznaczonej jako usunięta lub ja k o pusta i zw ra c a ć tę pozycję. M o żn a założyć, że tablica l i s t z a w ie ra m iejsce p o z w a la ją c e na d o d a n ie n o w e j liczby.
3. W ro zw ią za n iu korzystającym z h a s zo w a n ia klucze skład ają się z ła ń c u c h ó w zn a k ó w . N apisz funkcję m ieszającą, która na p o d s ta w ie prze k a za n e g o klucza oraz liczby c a łk o w ite j max zw ró ci pozycję w ta b lic y m ieszającej, z zakresu od 1 do max (w łą c zn ie ). Funkcja m usi korzystać z całej w a rto ś c i klucza i nie p o w in n a c e lo w o zw ra c a ć te j sam ej w artości dla kluczy składających się z tych sam ych liter.
310
ROZDZIAŁ 10. ■ HASZOWANIE
4. Tablica mieszająca o wielkości n zawiera obiekty składające się z dwóch pól — danej będącej liczbą całkowitą oraz odwołania, które także jest liczbą całkowitą; pola noszą odpowiednio nazwy data oraz next. Pole next jest używane do połączenia elementów zapisywanych w tablicy mieszającej w listę powiązaną, której zawartość będzie posortowana w kolejności rosnącej. Koniec listy jest oznaczany przy użyciu wartości -1 . Zmienna top (której początkowa wartość wynosi -1) wskazuje położenie elementu danych zawierającego najmniejszą liczbę na liście. Liczby są umieszczane w tablicy mieszającej przy użyciu funkcji mieszającej h1 i próbkowania liniowego. Pole data w dostępnych pozycjach tablicy ma wartość Empty, a z tablicy nigdy nie są usuwane żadne elementy. Napisz program, który będzie odnajdywał w tablicy podaną wartość key. Jeśli zostanie znaleziona, program nie musi robić nic więcej. Jeśli jednak nie uda się jej znaleźć, wartość key ma być wstawiona do tablicy w taki sposób, by zostało zachowane uporządkowanie listy. Można przyjąć, że w tablicy jest miejsce na dodanie nowej liczby. 5. W pewnym rozwiązaniu klucze odwzorowywane na te same pozycje są przechowywane na liście powiązanej. Każdy element tablicy mieszającej zawiera wskaźnik do pierwszego elementu tej listy, a nowe klucze są umieszczane na końcu. Każdy element listy składa się z dwóch pól, kej oraz count, będących liczbami całkowitymi, oraz pola next wskazującego następny element listy. Pamięć do przechowywania kolejnych elementów listy jest przydzielana, jeśli trzeba. Załóż, że tablica mieszająca ma rozmiar n, a wywołanie H(key) zwraca wartość z zakresu 1 do n(włącznie) określającą pozycję w tej tablicy. Napisz kod, który zainicjalizuje tablicę mieszającą. Napisz funkcję, która na podstawie przekazanego klucza nkey spróbuje go odszukać w tablicy, a jeśli się to nie uda, doda go w odpowiednim miejscu i przypisze polu count wartość 0. Jeśli klucz uda się znaleźć, do wartości jego pola count należy dodać 1. Jeśli wartość pola count przekroczy 10, węzeł należy usunąć z dotychczasowego miejsca, umieścić na samym początku listy i ponownie przypisać polu count wartość 0. 6. Napisz program, który pozwoli wczytać i przechowywać słownik synonimów. Dane wejściowe programu składają się z wierszy tekstu. Każdy z nich zawiera liczbę unikalnych słów (która w każdym wierszu może być różna); wszystkie są synonimami. Można przyjąć, że słowa składają się wyłącznie z liter i są oddzielone znakami odstępu. Słowa mogą być zapisywane dowolną kombinacją wielkich i małych liter. Wszystkie słowa mają być umieszczone w tablicy mieszającej przy użyciu techniki otwartego adresowania z podwójnym haszowaniem. Słowo może występować w kilku różnych wierszach, jednak każde unikalne słowo może być zapisane w tablicy tylko jeden raz. Jeśli słowo występuje w kilku wierszach, wszystkie słowa z każdego z tych wierszy są synonimami. Ta część danych jest zakończona wierszem zawierającym słowo EndOfSynonyms. Struktura danych powinna być zorganizowana w taki sposób, by na podstawie przekazanego słowa można było szybko znaleźć listę jego synonimów. Druga część danych wejściowych dla programu składa się z kilku poleceń, z których każde jest zapisane w osobnym wierszu. Prawidłowe polecenia są oznaczone literami P, A, D oraz E. P słow o — wyświetla wszystkie synonimy słowa sło w o uporządkowane w kolejności alfabetycznej. Asłow ol słow o2 — dodaje słow ol do listy synonimów słowa2. Dsłow o — powoduje usunięcie słow a ze słownika. E — oznacza koniec danych. 7. Napisz program, który pozwoli porównać próbkowanie kwadratowe, próbkowanie linowe z podwójnym haszowaniem oraz metodę łańcuchową. Danymi wejściowymi programu ma być fragment tekstu zapisanego w języku polskim, a program ma zapisać wszystkie unikalne, występujące w nim słowa w tablicy mieszającej. Dla każdego słowa oraz każdej użytej metody należy zarejestrować liczbę prób wykonanych podczas zapisywania słowa w tablicy mieszającej. Program powinien prawidłowo obsłużyć do 100 unikalnych słów. W przypadku próbkowania kwadratowego oraz podwójnego mieszania należy użyć tablicy mieszającej o 103 elementach. W przypadku metody łańcuchowej należy zastosować dwa rozmiary tablic: 23 oraz 53. Dla każdej z porównywanych metod należy użyć tej samej prostej funkcji mieszającej. Program ma wyświetlać alfabetyczną listę słów wraz z liczbą prób dla każdej z porównywanych metod. Dane wynikowe należy przedstawić w postaci, która ułatwi porównanie wydajności poszczególnych metod.
311
JAVA. ZAAW ANSOW ANE ZASTOSOWANIA
312
Skorowidz
A abstrakcyjne typy danych, 123 adres obiektu, 61-63 pierwszego węzła, 94 adresowanie otwarte, 306 akcesor, 52, 53 aktualizowanie pliku, 221 wskaźnika, 89 algorytm Euklidesa, 156 haszowania, 307 scalania, 39 sortowanie przez wstawianie, 20, 25 sortowanie przez wybieranie, 19 atrybut, 44 automatyczne odzyskiwanie pamięci, 95
B binarne drzewo poszukiwań, 237-240, 247, 255 blok catch, 203 try, 203 błąd przepełniania buforu rekurencji, 280 brat, sibling, 226 BTS, binary search trees, 237 budowanie drzewa binarnego, 233, 240, 244, 247 kopca, 269
C ciało klasy, 45 konstruktora, 49 ciąg Fibonacciego, 156
D definicje rekurencyjne, 153 definiowanie klas, 44 list powiązanych, 83 długość ścieżki wewnętrznej, 260 dodawanie dwóch liczb, 181 elementów do kolejki, 94 elementów do listy, 88, 93 nowego elementu, 26 dostęp do obiektu, 61 do zmiennej, 46 droga w labiryncie, 170 drzewo, 225, 226 binarne, 225, 231, 244 kompletne, 257 pełne, 259 prawie kompletne, 257, 259 ze wskaźnikami rodzica, 244 uporządkowane, 226 zdegenerowane, 239 zorientowane, 226 dynamiczne przydzielanie pamięci, 88 dziecko, child, 226
SKOROWIDZ
E element rozdzielający, pivot, 274
F funkcja, Patrz metoda funkcja mieszająca, hash function, 293, 296, 305 funkcje rekurencyjne, 154
G generowanie liczb losowych, 179 głębokość wierzchołka, 226 gra Nim, 182 Węże i drabiny, 178 zgadywanka, 180 grupowanie, clustering, 298 danych, 71 pierwotne, 298, 307 wtórne, 298
H haszowanie, 291, 307 hermetyzacja danych, 51
I implementacja kolejki listą powiązaną, 147 tablicą, 143 metody łańcuchowej, 303 scalania list, 38 stosu listą powiązaną, 127 tablicą, 124 indeks, 214 inicjalizowanie pola, 47, 48 zmiennych, 46 instancja klasy, 44 instrukcja return, 63
J język obiektowy, 44
314
K kapsel, 186 klasa, 44 Arithmetic, 182 BinarySearchString, 33 BinarySearchTest, 32 BinarySearchTreeTest, 247 BinaryTree, 232, 234, 248, 252 BinaryTreeTest, 235 Book, 45 BottleCaps, 187 BuildListl, 90 BuildList2, 93 BuildList3, 97 CompareFiles, 201 CreateBinaryFile, 205-208 CreateIndex, 215 CreateRandomAccess, 211 DataInputStream, 205, 207 DataOutputStream, 205 DecimalToBinary, 133 DistinctNumbers, 295 EvalExpression, 140 Exception, 203, 204 GuessTheNumber, 180 HashChain, 301 HeapSortTest, 268 Index, 214, 217 InfixToPostfix, 137 InsertSort1Test, 23, 25 InsertSort2Test, 26 LevelOrderTest, 252 LinkedList, 99-106 Maze, 172 MergeLists, 115 MergeSortTest, 165 MergeTest, 39 Nim, 184 Node, 85, 99, 106, 130, 138 NodeData, 100, 109, 131, 148, 232, 240, 248 Organisms, 168 Palindrome, 110 ParallelSort, 29 Part, 57, 65, 208, 216 PartTest, 58 Person, 67, 75, 78 Pi, 195 QNode, 250, 253 Queue, 143, 146, 251 QueueData, 250, 253 QueueTest, 146, 149
SKOROWIDZ
Quicksort3Test, 280 QuicksortTest, 276 RandomAccessFile, 209 RandomTest, 179 ReadBinaryFile, 206 ReadRandomAccess, 212 Root5, 193 Scanner, 45, 241 SearchTest, 68 SelectSortTest, 18 ShellSortTest, 286 SiftUpTest, 271 SimulateQueue, 191 SortStrings, 28 Stack, 127-132, 138 StackTest, 126, 129, 132 String, 27, 45 Sum, 44 TreeNode, 231, 248 Uselndex, 218 VoteCount, 79 Voting, 77 WordFrequency, 34, 72 WordFrequencyBST, 242 WordFrequencyHash, 308 WordInfo, 71, 73 klasy użytkownika, 51 klucz, 292 kolejka, 94, 142 kolejka priorytetowa, 273 kolizja, 291, 293, 297 konstrukcja try.. .catch, 202 konstruktor, 49 bezargumentowy, 48, 50 domyślny, 48 poprawiony, 53 konwersja drzewa binarnego, 264 liczb, 133, 156 wyrażenia, 134 kopiec, 264, 273 maksymalny, 264 minimalny, 264 korzeń, root, 225
L las, 226 liczba porównań, 25 wierzchołków drzewa, 254 n, 194
liczby bliźniacze, 306 losowe, 177 pseudolosowe, 178 licznik, 71, 86 licznik występowania słów, 307 lista, 37 dwukierunkowa, 119 cykliczna, 116, 144 jednokierunkowa, 91 jednokrotnie powiązana, 91 liniowa, 142 powiązana, 83, 111 liść, leaf, 226
M metaznaki, 242 metoda addHead, 100 addInPlace, 102 addTai, 106 addToTable, 309 binarySearch, 31-33, 37 buildTree, 233-236, 245 compareTo, 27, 33, 102, 240 compareToIgnoreCase, 27 copyList, 106 countLeaves, 254 countNodes, 254 createMasterlndex, 215 decToBin, 157 deleteNode, 256 dequeue, 145, 148, 150 enqueue, 145, 150 equals, 27, 107 equalsIgnoreCase, 27, 67 fact, 155 findOrg, 168 findOrlnsert, 243 findPath, 172 getPhrase, 109 getSmallest, 17 getToken, 136 getWord, 37 hanoi, 161 heapSort, 267 height, 254 hsort, 286 insertInPlace, 26 insertionSort, 23-28 kthSmall, 283 levelOrderTraversal, 253 315
SKOROWIDZ
metoda łańcuchowa, 300 main, 37 merge, 39, 163 mergeSort, 164 parallelSort, 29 partition, 283 partition2, 279 peek, 137 pop, 129, 131 power, 162 precedence, 136 printFilelnOrder, 220 printList, 91 processVotes, 77 push, 128, 131 quicksort, 275 quicksort2, 279 quicksort3, 282 random, 179 readPartFromFile, 219 reverseList, 107 search, 301, 308 seek, 210 selectionSort, 18 siftDown, 266 siftUp, 269, 272 smallest, 192 swap, 17 toString, 56, 101 updateRecord, 222 visit, 241 writePartToFile, 216, 223 metody instancyjne, 45, 55 klasowe, 45 klasy BinaryTree, 254 klasy DatalnputStream, 207 niestatyczne, 45 statyczne, 45, 56 mieszanie, Patrz haszowanie modyfikator dostępu, 45 moment drzewa, 226 mutator, 52, 54
N nagłówek klasy, 45 następnik, successor, 245 nazwy plików, 59 wewnętrzne, 200 zewnętrzne, 200
316
notacja przyrostkowa, 134, 139 wrostkowa, 134
O obiekt, 44, 60 obsługa klientów, 190 odczyt z pliku, 199 odkładanie na stos, 94 odnajdywanie drogi, 170 odwołania do zmiennych, 46 odwracanie listy powiązanej, 159 odzyskiwanie pamięci, 95 operacje na listach, 85 wejścia-wyjścia, 199, 205 ostatni węzeł listy, 87
P palindrom, 107 pamięć statyczna, 88 pętla do...while, 185 for, 17 while, 23, 40 pierwszeństwo operatorów, 134 plik btree.in, 236 LinkedList.java, 105 parts.txt, 217 pliki binarne, 200, 205 binarne z rekordami, 207 indeksowane, 213 o dostępie swobodnym, 209, 221 tekstowe, 200 źródłowe, 104 poddrzewo, 225 podwójne haszowanie, 306 podział tablicy, 277 pole, 44 head, 99, 148 next, 84 tail, 148 porównywanie list, 108 łańcuchów znaków, 27 plików, 201 zmiennych obiektowych, 62
SKOROWIDZ
problem ośmiu królowych, 175 wyszukiwania i wstawiania, 291, 292 próbkowanie kwadratowe, 299 liniowe, linear probing, 297 liniowe z podwójnym haszowaniem, 306 przechodzenie drzewa, 228, 231, 235, 244 drzewa poziomami, 249 metodą in-order, 229 level-order, 251 post-order., 230 pre-order, 228, 259 poprzeczne, 229 wsteczne, 229 wzdłużne, 228 przechowywanie list, 111, 112 przeciążanie konstruktorów, 50 przekazywanie obiektu jako argumentu, 64 przepełnienie stosu, 125 przesiewanie w górę, 270 przeszukiwanie listy powiązanej, 86 tablicy, 32, 34, 67 przetwarzanie list powiązanych, 83 przypisywanie zmiennych obiektowych, 60 publiczna metoda akcesora, 52 punkt podziału, division point, 274
R referencja, 47 rekurencja, 153 rekurencyjna definicja funkcji, 154 reprezentacja drzewa binarnego, 231 rodzic, parent, 226 rozkład nierównomierny, 186 równomierny, 177 rozszerzanie klasy, 106 rozwiązywanie kolizji, 293 metoda łańcuchowa, 300 podwójne haszowanie, 306 próbkowanie kwadratowe, 299 próbkowanie liniowe, 298
S scalanie list, 37, 113, 163 silnia, 153
słowo kluczowe class, 45 new, 45 null, 63 private, 46 protected, 46 public, 46 static, 45, 46 this, 102 sortowanie, 15 listy, 95, 112 przez kopcowanie, heapsort, 263, 272 scalanie, 163 wstawianie, 19, 70 wybieranie, 15, 19, 273 Shella, shellsort, 284 szybkie, quicksort, 274, 277, 280 tablicy, 15, 19 łańcuchów znaków, 27 obiektów, 70 równoległej, 29 z użyciem malejących odstępów, 284 stała MaxWords, 37 StringFixedLength, 211 stan obiektu, 44 stopień, degree, 225 stos, 124 operatorów, 135 pusty, 128 wykonawczy, 155 stosowanie obiektów, 60, 111 strumień wyjściowy, 206, 241 symulowanie kolejki, 190 realnych problemów, 189 zbierania kapsli, 188 szacowanie pierwiastka kwadratowego, 193 wartości n, 194
T tablica, 15, 111 candidate, 76 łańcuchów znaków, 27, 32, 70 mieszająca, 295, 296 nadmiarowa, overflow table, 303 typu String, 27 tablice obiektów, 64, 69, 70 równoległe, 29 317
SKOROWIDZ
tablicowa reprezentacja drzewa, 257 testowanie klasy Part, 58 tryb rw, 221 tworzenie klasy listy powiązanej, 99 listy posortowanej, 95 listy powiązanej, 88, 93 obiektów, 44 węzła, 94 typ LinkedList, 114 Stack, 130
U umieszczanie na stosie, 124 usuwanie elementu z listy, 94 elementu z tablicy, 296 kluczy, 304 wierzchołków, 255
W waga drzewa, 226 wartość domyślna, 48 END, 211 key, 221, 295 wyrażenia, 139 węzły listy, 83 wierzchołek końcowy, 226 wieże Hanoi, 160 wskaźnik, 47 do węzła, 84, 95 na korzeń, 233 na rodzica, 247 null, 63, 84 współczynnik wypełnienia tablicy, 299 wstawianie elementu, 26, 91 węzła do listy, 91
318
wyjątek, 203, 204 wyliczanie potęgi liczby, 162 wartości wyrażeń, 139 wyrażenie w zapisie przyrostkowym, 142 w zapisie wrostkowym, 142 wysokość drzewa, 226 wyszukiwanie binarne, 30 i wstawianie, 291, 292 łańcucha, 68 wyświetlanie listy powiązanej, 159 zawartości pól, 55
Z zapis do pliku, 36, 199, 206 listy powiązanej, 111 przyrostkowy, 134, 141 wrostkowy, 134, 140 zdejmowanie ze stosu, 94, 124 zliczanie, 166 węzłów, 85 wystąpień wyrazów, 34, 240 złożoność algorytmu, 25, 287 zmienna curr, 85, 95 obiektowa, 47 prev, 95 top, 84, 93, 125-128 zmienne instancyjne, 44, 51 klasowe, 45, 51 niestatyczne, 45 obiektowe, 60, 62 statyczne, 45 znajdowanie najmniejszej liczby, 283 zwracanie wartości, 74