Professional Documents
Culture Documents
Algorytmy Od Podstaw HELION Simon Harris James Ross
Algorytmy Od Podstaw HELION Simon Harris James Ross
OD PODSTAW
/
r vU i
J -
CIII S
W p r o w a d z e n i e d o p r o b l e m a t y k i a l g o r y t m ó w i struktur d a n y c h
Badanie z ł o ż o n o ś c i a l g o r y t m ó w
Analiza i i m p l e m e n t a c j a a l g o r y t m ó w
Zasady testowania kodu
A
wrox I HHK .KAMMJK Helion
http://helion.pl
Tytuł oryginału: Beginning Algorithms
ISBN: 83-246-0372-7
Ali rights reserved. No part of this book may be reproduced or transmitted in any
form or by any means, electronic or mechanical, including photocopying, recording
or by any information storage retrieval system, without permission from the Publisher.
Wydawnictwo HELION
ul. Kościuszki lc, 44-100 GLIWICE
tel. 032 231 22 19, 032 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://hel ion.pl/user/op inie ? algpo
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
Printed in Poland.
Spis treści
O autorach 9
Podziękowania H
Wprowadzenie 13
Rozdziali. Zaczynamy 23
Czym są algorytmy? 23
Co to jest złożoność algorytmu? 26
Porównywanie złożoności i notacja „dużego O" 27
Złożoność stała — 0(1) 29
Złożoność liniowa — 0(N) 29
Złożoność kwadratowa — 0(N 2 ) 30
Złożoność logarytmiczna — 0(log N) i 0(N log N) 31
Złożoność rzędu silni — 0(N!) 32
Testowanie modułów 32
Czym jest testowanie modułów? 33
Dlaczego testowanie modułów jest ważne? 35
Biblioteka JUnit i jej wykorzystywanie 35
Programowanie sterowane testami 38
Podsumowanie 39
Rozdział 3. Listy 71
Czym są listy? 71
Testowanie list 74
Implementowanie list 86
Lista tablicowa 87
Lista wiązana 95
Podsumowanie 104
Ćwiczenia 104
Rozdział 4. Kolejki 105
Czym są kolejki? 105
Operacje kolejkowe 106
Interfejs kolejki 107
Podsumowanie 454
Linie 481
Trójkąty 481
Podsumowanie 51°
Ćwiczenia 510
Skorowidz 585
8 Algorytmy. Od podstaw
O autorach
Simon Harris rozpoczął swą przygodę z programowaniem jeszcze w szkole podstawowej,
kiedy to zajmował się tworzeniem animowanych sprite'ów dla komputera Commodore 64.
Z biegiem czasu jego działalność programistyczna nabrała wymiaru profesjonalnego: zgłębił
samodzielnie tajniki asemblerów dla komputera IBM/370 i procesorów serii Intel 80x86, by
ostatecznie przesiąść się na języki wysokiego poziomu — C, C++ i oczywiście Javę. Jest
przekonany, że fundamentem programowania jest algorytmika, a dobrego oprogramowania
nie sposób tworzyć bez znajomości algorytmów i docenienia ich roli w tym procesie. Prze-
konanie to stara się upowszechnić, prowadząc ożywione dyskusje i demonstrując dobre
techniki programistyczne wszystkim, którzy tylko chcą poświęcić mu swą uwagę. Jest za-
łożycielem i właścicielem firmy RedHill Consulting.
James Ross w ciągu ponad 15 lat działalności zawodowej zajmował się projektami o zróż-
nicowanej skali — od produktów „na półkę", poprzez duże systemy korporacyjne, do badań
i eksperymentów z zakresu kompilatorów i języków programowania. W ostatnich latach
dał się poznać jako fanatyczny niemal rzecznik wysokiej jakości kodu oraz specjalista od
„zwinnych" {agile) metod tworzenia oprogramowania, szczególnie w warunkach wytwarzania
sterowanego testami (test-driven development). Jest konsultantem w firmie ThoughtWorks,
zajmującej czołową pozycję na rynku oprogramowania wytwarzanego tymi metodami; obec-
nie jest menedżerem dużego projektu, tworzonego w technologii J2EE na potrzeby przemysłu
ubezpieczeniowego w Melbourne w Australii. Mieszka w Melbourne z żoną i rodziną.
10 Algorytmy. Od podstaw
Podziękowania
Od Simona Harrisa: Przede wszystkim ogromne dzięki dla Jona Eavesa, który dał nam
okazję do napisania niniejszej książki, i dla Jamesa, którego umiejętności i profesjonalizm
nigdy nie przestały mnie fascynować. Gdyby nie Wy, ta książka z pewnością nie mogłaby
powstać.
Wielkie podziękowania dla wszystkich, którzy zechcieli przejrzeć szkic książki i podzielić
się ze mną swymi uwagami — to między innymi Andrew Harris, Andy Trigg, Peter Barry,
Michael Melia i Darel Deboer (przepraszam tych, których tu pominąłem). Mam nadzieję,
że finalny produkt będzie należytą nagrodą za Wasz wysiłek.
Winien jestem wdzięczność mojemu bratu Timowi za cierpliwe wysłuchiwanie moich tyrad we
dnie i w nocy oraz Kern Rusnak i jej rodzinie za przepyszne wafelki i niezliczone filiżanki
herbaty. Nie mogę nie wspomnieć tu o moich uczniach Aikido, systematycznie trenujących
w czasie mych licznych nieobecności.
Dziękuję wszystkim wspaniałym kolegom z ThoughtWorks, dzięki którym moje życie zawo-
dowe jest tak przyjemne. Szczególne podziękowania należą się Andy'emu Triggowi, z którym
wspaniale współpracuje mi się od czasu, gdy napisaliśmy wspólnie pierwszy moduł testowy,
12 Algorytmy. Od podstaw
Nie jest natomiast wymagana jakaś szczególna wiedza z zakresu omawianych w książce
poszczególnych algorytmów czy struktur danych.
Czytelnicy dobrze znający język Java z pewnością zwrócą uwagę na pewne podobieństwo
klas prezentowanych w niniejszej książce do klas znajdujących się w pakiecie java.utill.
Podobieństwo to nie jest jednak wyrazem zależności niniejszej książki od konkretnej im-
plementacji bibliotek Javy, a jedynie prostą konsekwencją faktu, że projektanci tego języka
należycie rozumieli znaczenie specyficznych implementacji poszczególnych algorytmów,
jak i funkcjonowania oraz stosowania tychże implementacji.
Jak już wspominaliśmy, nie jest celem niniejszej książki nauczanie programowania od pod-
staw, w szczególności programowania w języku Java. Nie wyjaśniamy więc sposobu wyko-
rzystywania standardowych bibliotek Javy; mimo iż nieustannie odwołujemy się do klas
pakietu java.lang (i w niektórych przypadkach do klas pakietu java.io), generalnie stro-
nimy od wykorzystywania innych gotowych pakietów na rzecz „ręcznego" tworzenia
omawianych klas — co powinno być i pouczające, i na swój sposób satysfakcjonujące dla
Czytelnika samodzielnie implementującego rozmaite algorytmy.
Podobnie ma się rzecz z testowaniem programów: mimo iż w treści każdego rozdziału po-
święcono należytą uwagę testowaniu modułów, to jednak niniejszy podręcznik nie preten-
duje do roli przewodnika (czy tym bardziej studium) po tej tematyce. Prezentując gotowy kod
wielu modułów testowych, chcieliśmy raczej zilustrować podstawowe zasady tej metodyki
testowania.
Wprowadzenie 15
Zrób to prosto
Jakże często zdarza się słyszeć „To jest zbyt skomplikowane, by dało się zrozumieć" albo
„Nasz kod jest zbyt skomplikowany, by można było go efektywnie testować". Jednym z klu-
czowych elementów inżynierii programowania jest radzenie sobie ze złożonością.
System, który spełnia swoje zadanie, lecz jest zbyt trudny do zrozumienia lub testowania,
działa najprawdopodobniej tylko przez przypadek: nawet jeśli jesteś przekonany, że staran-
nie zaimplementowałeś jakieś konkretne rozwiązanie, to i tak poprawność tej implementacji
jest raczej kwestią probabilistyki niż czystego determinizmu.
Problem, który wydaje się nadmiernie złożony, kwalifikuje się zazwyczaj do podzielenia na
podproblemy dające się łatwiej ogarnąć i łatwiej rozwiązywać. Poprzez refaktoryzację i abs-
trahowanie bazujące na powszechnie stosowanym kodzie, powszechnie znanych rozwiąza-
niach itp. dochodzimy ostatecznie do dużego systemu, który tak naprawdę jest złożoną
kombinacją prostych rzeczy.
Zgodnie z naczelną zasadą zachowania prostoty — KISS, od ang. Keep It Simple, Stupid]
— wszystkie prezentowane w tej książce przykłady są tak proste, jak tylko to możliwe (ale
ani odrobinę prostsze!). Ponieważ niniejsza książka pomyślana jest jako praktyczny prze-
wodnik po algorytmice, forma tych przykładów zbliżona jest jak najbardziej do tej spoty-
kanej w rzeczywistych aplikacjach; w niektórych jednak przypadkach metody są nieco
dłuższe, niż mogłyby być faktycznie — wszak chcemy Czytelników czegoś nauczyć, a nie
tylko wytrenować w pisaniu jak najbardziej zwięzłego kodu.
1
W wersji polskiej BUZI — Bez Udziwnień Zapisuj, Idioto — p r z y p . tłum.
Wprowadzenie 17
Dobrze zaprojektowany kod łatwo poddaje się profilowaniu i optymalizacji — znacznie le-
piej niż „sprytny" kod podobny do talerza spaghetti (ruszysz coś w jednym miejscu i zaraz
coś rusza się gdzie indziej). Przekonaliśmy się wielokrotnie, iż dobrze zaprojektowany kod
można uczynić kodem efektywnym przy użyciu niewielu tylko zabiegów optymalizujących.
Interfejsy
Każdy algorytm i każda struktura danych może być rozpatrywana dwojako: w aspekcie
własnej implementacji oraz w aspekcie funkcjonalności widocznej dla otoczenia zewnętrz-
nego. Dwa algorytmy, nieodróżnialne z punktu widzenia otoczenia mogą być zaimplemen-
towane w całkowicie różny sposób. Programiści często stają przez wyborem kilku możliwych
implementacji danego algorytmu, w obliczu ograniczeń narzuconych m.in. na wykorzysta-
nie pamięci czy żądaną szybkość aplikacji. W wielu wypadkach konsekwencje — pamię-
ciowe, wydajnościowe itp. — określonego wyboru nie są znane a priori i manifestują się
dopiero na etapie testowania lub wykonywania aplikacji.
Testowanie na bieżąco
Współczesne standardy programowania wymagają, by aplikacje tworzone były w sposób
modularny, a funkcjonalność realizowana przez każdy z modułów testowana była pod ką-
tem tego, czy faktycznie spełnia założenia wyrażone za pośrednictwem interfejsu. Rodzi to
kolejny wymóg praktyczny — ten mianowicie, iż po określeniu interfejsu, lecz jeszcze
przed przystąpieniem do implementowania modułu, należy „przetłumaczyć" jego założenia
funkcjonalne (odzwierciedlane przez interfejs) na zestaw przypadków testowych (test cases)
weryfikujących spełnienie tychże założeń.
Fakt, że zestaw przypadków testowych dla danego modułu konstruowany jest na podstawie
interfejsu tegoż modułu, a nie jego implementacji, ma bardzo istotne następstwa praktycz-
ne, przekładające się w prosty sposób na wygodę programistów: otóż niezależnie od zmian
w implementacji modułu — w szczególności zmiennych decyzji co do wyboru konkretnej
implementacji — kod wykonujący testowanie tego modułu pozostaje niezmieniony2.
Zdarza się, że tworzone w ten sposób testy okazują się nazbyt ogólne i — jak twierdzą te-
sterzy-puryści — próbują „załatwić" zbyt wiele spraw w ramach jednej metody. Generalnie
podzielamy tego rodzaju zastrzeżenia, niekiedy jednak, kierując się względami prostoty i zro-
zumiałości kodu, pozwalamy sobie łączyć kilka scenariuszy testowych w jednej metodzie.
Kodowanie asertywne
W obliczu rygoryzmu, jakiemu podlega funkcjonalne testowanie poszczególnych modułów,
można by odnieść wrażenie że gruntownie przetestowany kod jest kodem wolnym od błę-
dów. Przeświadczenie takie jest jednak z gruntu błędne, z podstawowej przyczyny: otóż te-
stowanie nie oznacza bynajmniej weryfikowania poprawności programów, lecz jedynie we-
ryfikację ich zachowania przy określonych założeniach — te natomiast niekoniecznie
muszą znajdować swe odzwierciedlenie w rzeczywistości. Nawet najbardziej wszechstron-
ne testowanie niewiele będzie mieć wspólnego z weryfikacją poprawności programu, jeśli
niewłaściwy będzie przedmiot tego testowania.
2
" Autorzy mają tu na myśli kod wykonujący testowanie powiązań między modułami. Konkretne
szczegóły implementacyjne poszczególnych modułów same z siebie są przedmiotem odrębnych
testów i wymagają specyficznego dla siebie kodu testującego — przyp. tłum.
Wprowadzenie 19
Wyobraźmy sobie bazę danych finansowych i jedno z pól w jej rekordach; programiści
tworzący tę bazę są pewni co do tego, iż pole to „nie może" zawierać kwoty ujemnej, bo
jest to „wykluczone z powodu..." (i tu następuje wyliczenie argumentów na rzecz tego, ze
nieujemna wartość w polu jest wręcz zagwarantowana). Pewni swego programiści nie
umieszczają oczywiście w kodzie stosownej asercji badającej wartość wspomnianego pola.
Po upływie kilku miesięcy w polu tym pojawia się — mniejsza o to, z jakiej przyczyny —
wartość minus 0,01 PLN. Z powodu braku stosownej asercji fakt ten nie zostaje oczywiście
wykryty i zaczyna się nieszczęście: z biegiem czasu — dni, miesięcy, a może lat — rozma-
ite konsekwencje tego faktu być może niszczą skrytobójczo cenne dane i gdy pewnego dnia
zjawisko przybiera rozmiary kataklizmu, pierwotnej jego przyczyny dociec już zgoła nie-
podobna. A przecież wystarczyłby jeden (swoją drogą jakże cenny) dodatkowy wiersz kodu,
by aplikacja zareagowała na „niemożliwe" zdarzenie w sposób przewidywalny, bez jakie-
gokolwiek uszczerbku dla przetwarzanych danych.
Przy całej swej użyteczności asercje są także mechanizmem wysoce atrakcyjnym. Są bar-
dzo proste, a ich udział w ogólnym czasie wykonania i zajętości pamięci jest znikomy. Nie
trzeba się obawiać, że spowodują one pogorszenie efektywności programu: czas ich wyko-
nania nie daje się porównać z czasem realizacji (na przykład) zdalnego wywołania procedury
czy kwerendy skierowanej do bazy danych. Z tego względu, mimo iż często po zakończe-
niu testowania programiści dezaktywują asercje lub wręcz usuwają je z kodu, my zalecamy
pozostawienie asercji także w kodzie „produkcyjnym", przeznaczonym dla użytkownika
końcowego.
Dla Czytelników hołdujących podejściu „zrób to sam" także mamy dobrą nowinę. Wszystko,
co będzie Wam potrzebne, to
• Kopia Java Development Kit (JDK) w wersji 1.4 lub nowszej—zawiera komplet
mechanizmów niezbędnych do kompilowania i uruchamiania kodu prezentowanego
w niniejszej książce.
20 Algorytmy. Od podstaw
Konwencje typograficzne
Aby uczynić układ książki bardziej przejrzystym, a samą książkę maksymalnie wygodną dla
Czytelników, wyróżniliśmy wizualnie określone jej fragmenty zgodnie z poniższymi kon-
wencjami.
J a k to działa?
Po każdej sekcji „Spróbuj sam" każdy skonstruowany w jej ramach blok kodu jest komen-
towany i objaśniany w sekcji „Jak to działa". Jako że zasadnicza tematyka niniejszej książki
— algorytmy — lepiej nadaje się do omawiania w postaci przykładów, a nie konkretnych
ćwiczeń, sekcje „Spróbuj sam" i „Jak to działa" występują zawsze naprzemiennie. Odbywa
się to z pożytkiem dla Czytelnika, który na bieżąco poznaje zastosowanie każdego z pozna-
nych algorytmów.
Wprowadzenie 21
W tekście zasadniczym:
• nowo wprowadzane ważne pojęcia wyróżnione SĄ kursywą, tak samo wyróżnione
są nazwy plików oraz adresy URL.
• kombinacje klawiszy zapisywane są w postaci: Ctrl+A.
m cytowany kod prezentowany jest czcionką o stałej szerokości.
• fragmenty kodu źródłowego prezentowane są w dwojaki sposób:
Nowy i istotny kod jest w przykładach wyróżniany w ten sposób.
Wyróżnienie nie jest stosowane do kodu. który w tym przykładzie jest mniej
istotny lub pojawił się już wcześniej.
22 Algorytmy. Od podstaw
1
Zaczynamy
Naszą wycieczkę po krainie algorytmów rozpoczniemy od omówienia pewnych zagadnień
podstawowych oraz zdefiniowania kilku ważnych pojęć. Niecierpliwych Czytelników spie-
szymy zapewnić, że jest to niezbędne i bez przestudiowania niniejszego rozdziału pożytek
z lektury rozdziałów następnych może okazać się tylko połowiczny, bowiem czytany tekst
będzie wydawać się niezrozumiały, a prezentowane przykłady kodu — na wskroś enigma-
tyczne. W niniejszym rozdziale wyjaśniamy mianowicie:
Czym są algorytmy?
Być może słyszałeś wielokrotnie o istotnej roli algorytmów w obliczeniach komputero-
wych, zastanawiając się jednocześnie, co dokładnie oznacza pojęcie „algorytm", do czego
algorytmy mogą się przydać i czy warto się w ogóle nimi zajmować.
Z algorytmami spotykamy się nie tylko w obliczeniach komputerowych, lecz także w życiu
codziennym. Algorytm, mówiąc prosto, to zbiór dobrze zdefiniowanych kroków prowadzą-
cych do wykonania pewnego zadania. Zawsze, gdy zajmujemy się gotowaniem obiadów,
pieczeniem ciast czy w ogóle realizacją wszelkich przepisów, postępujemy — mniej lub
bardziej świadomie — według pewnego algorytmu.
24 Algorytmy. Od podstaw
Postępowanie według jakiegoś algorytmu wiąże się ze zmianą stanu pewnego systemu,
obiektu itp. — od stanu początkowego, poprzez ciąg stanów pośrednich, aż do stanu koń-
cowego. Banalnym tego przykładem może być mnożenie liczb naturalnych. Chociaż wszy-
scy znamy tabliczkę mnożenia jeszcze z podstawówki, to mnożenie takie może być utoż-
samiane z serią dodawań — mnożenie „5 x 2" jest równoważne dodaniu do siebie pięciu
dwójek (2 + 2 + 2 + 2 + 2) lub dwóch piątek (5 + 5). Ogólnie, mnożenie A razy B jest rów-
noważne zsumowaniu A liczb naturalnych, z których każda równa jest B. Można to zapisać
w postaci następującej sekwencji kroków:
1. Nadaj trzeciej zmiennej — C — wartość początkową zero.
2. Jeśli A równe jest zero, zadanie jest wykonane, a wynik mnożenia znajduje się w C.
Jeśli A jest różne od zera, przejdź do kroku 3.
3. Dodaj wartość B do zmiennej C.
4. Zmniejsz o 1 wartość zmiennej A.
5. Przejdź do kroku 2.
Zwróćmy uwagę na ważny fakt, że w przeciwieństwie do przepisu na np. tort czekoladowy,
powyższa sekwencja zawiera zapętlenie: w kroku 5. następuje skok wstecz, do punktu 2.
Większość algorytmów wykorzystuje zapętlenie w celu powtarzania pewnych obliczeń:
dwa najważniejsze rodzaje zapętlenia — rekurencję i iterację — omówimy szczegółowo
w następnym rozdziale.
Algorytmy zapisuje się zazwyczaj w formie mniej potocznej niż zaprezentowana powyżej
sekwencja pięciu kroków: ów formalny zapis, zwany popularnie pseudokodem, jest (a przy-
najmniej być powinien) z jednej strony bardziej konkretny, a z drugiej łat\yy do zrozumie-
nia także dla nieprogramistów. Poniższy pseudokod jest zapisem funkcji o nazwie Mnóż,
która otrzymuje dwie liczby całkowite — A i B — i zwraca iloczyn A x B, nie wykonując
mnożenia, a jedynie serię dodawań:
Rzecz jasna przedstawiony algorytm realizacji mnożenia za pomocą serii dodawań jest bardzo
prosty — nieporównanie prostszy od algorytmów wykorzystywanych do rozwiązywania
rzeczywistych problemów. Bardziej złożone algorytmy są z natury trudniejsze do zrozu-
mienia, a przez to bardziej podatne na błędy w programowaniu, wskutek czego jednym
z najważniejszych obszarów informatyki jest weryfikowanie lub dowodzenie poprawności
rozmaitych algorytmów i ich implementacji.
Wybór odpowiedniego algorytmu nie zawsze jest prostą sprawą. Większość problemów
daje się rozwiązywać za pomocą wielu różnych algorytmów. Niektóre z tych rozwiązań są
proste, inne bardziej złożone, a ich efektywność jest na ogół zróżnicowana; najprostsze
Rozdział 1. • Zaczynamy 25
Wyobraźmy sobie jednak przechodnia, który (z jakiejś przyczyny) wiedzy tej nie posiada
1 chciałby ją w jakiś sposób zdobyć. Może on w tym celu obserwować zaparkowane samo-
chody: gdy, przy obserwacji od strony chodnika, przód samochodu znajduje się na prawo
od jego tyłu, mamy prawdopodobnie do czynienia z ruchem prawostronnym. Ta prosta za-
sada, pomocna w większości przypadków, może jednak okazać się złudna i to z kilku po-
wodów. Po pierwsze, samochody nie zawsze parkują po tej stronie ulicy, po której się poru-
szają po drugie, w pobliżu miejsca, w którym chcemy przejść przez jezdnię, może obowiązywać
zakaz parkowania, a po trzecie wreszcie, samochody mogą niekiedy poruszać się po oby-
dwu stronach jezdni — na przykład na każdej ulicy jednokierunkowej czy też na większości
ulic hinduskiego miasta Bangalore.
Jak więc widać, zasadniczą wadą wszelkich heurystyk jest niemożność określenia, jak sku-
teczne okażą się one przy rozwiązywaniu danego problemu. Postępowanie heurystyczne
zawsze wnosi do algorytmu mniejszy lub większy czynnik niepewności, a otrzymane roz-
wiązanie może okazać się akceptowalne albo całkowicie bezużyteczne.
Ostatecznie jednak każdy problem rozwiązywany jest przy użyciu jakiegoś algorytmu. Im
ów algorytm jest prostszy, bardziej precyzyjny i łatwiejszy do zrozumienia, tym łatwiejsze
będzie nie tylko upewnienie się co do jego poprawności, lecz także przewidzenie tego, jak
efektywnie odbywać się będzie rozwiązywanie przedmiotowego problemu.
1
Wydanie polskie: D.E. Knuth Sztuka programowania, Wydawnictwa Naukowo-Techniczne,
Warszawa 2002 — przyp. tłum.
26 Algorytmy. Od podstaw
Generalnie więc złożoność algorytmu można traktować jako miarę ilości zasobów wyma-
ganych przezeń do rozwiązania problemu o określonym rozmiarze. Mimo iż pojęcie „zaso-
bów" można by rozumieć całościowo — w kategoriach czasu obliczeń, zużywanej pamięci
(operacyjnej i masowej) — i rozumienie takie okazuje się często użyteczne, złożoność al-
gorytmu rozumie się najczęściej w kategoriach jego złożoności czasowej, czyli zużytego
czasu procesora. Czas ten bowiem przekłada się w pierwszym rzędzie na liczbę operacji
wykonywanych przez algorytm w procesie rozwiązywania problemu.
Interesujące jest przy tym to, iż wspomniana liczba operacji wcale nie musi być znana do-
kładnie; istotne jest natomiast, jak zmienia się ona wskutek zmian rozmiaru rozwiązywanego
problemu. Czy przy zwiększeniu tego rozmiaru o rząd wielkości zwiększy się ona liniowo
(o ten sam czynnik co wspomniany rozmiar) czy może kwadratowo, a może wykładniczo?
Dochodzimy w tym momencie do interesującego spostrzeżenia: znając złożoność algorytmu,
możemy określić jego wydajność, lecz nie odwrotnie.
W niniejszej książce nieodłącznym elementem opisu algorytmów jest analiza ich złożoności.
Mimo iż generalnie analiza ta jest zagadnieniem skomplikowanym, staraliśmy się przed-
stawić j ą tak, by nawet Czytelnik bez przygotowania matematycznego nie miał trudności
z jej zrozumieniem: niezbędnym przewidywaniom teoretycznym towarzyszy empiryczna
analiza wyników otrzymanych na podstawie przypadków testowych. W większości przy-
padków koncentrujemy się na tzw. złożoności przeciętnej (oczekiwanej), często jednak
zajmujemy się także złożonością optymistyczną (tzw. najlepszy przypadek — best-case)
i złożonością pesymistyczną (tzw. najgorszy przypadek —- worst-case) wykazywaną przez
Rozdział 1. • Zaczynamy 27
• 0(1) — złożoność „rzędu 1": liczba operacji wykonywanych przez algorytm jest
w przybliżeniu niezależna od rozmiaru problemu.
• 0(N) — złożoność „rzędu N", zwana także złożonością liniową: liczba
wykonywanych przez algorytm operacji jest w przybliżeniu proporcjonalna
do rozmiaru problemu.
• 0(N2) — złożoność „rzędu N2": liczba operacji rośnie proporcjonalnie do kwadratu
rozmiaru problemu.
• 0(log N) — złożoność „rzędu logarytmu z N" (logarytmiczna) — liczba operacji
rośnie proporcjonalnie do logarytmu z rozmiaru problemu.
• 0(N log N) — złożoność „rzędu N log A'7': liczba operacji jest proporcjonalna
do iloczynu rozmiaru problemu przez jego logarytm.
• 0(N\) — złożoność „rzędu N silnia": liczba operacji wzrasta proporcjonalnie
do silni rozmiaru problemu.
Istnieją oczywiście algorytmy, których złożoność wyrazić można jeszcze innymi formułami,
jednak te przedstawione powyżej okazują się wystarczające do opisu złożoności algoryt-
mów omawianych w niniejszej książce.
Na rysunku 1.1 widoczny jest wykres porównawczy różnych rzędów złożoności algorytmu.
Oś pozioma reprezentuje rozmiar problemu — na przykład liczbę rekordów do przeszukania
— natomiast wzdłuż osi pionowej mierzona jest „trudność obliczeniowa", czyli wykorzystanie
zasobów komputera. Wykorzystania tego nie należy (zgodnie z wcześniejszymi uwagami)
utożsamiać z konkretnym czasem obliczeń — wykres ma jedynie charakter porównawczy.
2
Pewną uwagę odnośnie adekwatności takiego stwierdzenia oraz informację o innych symbolach
wyrażających rząd wielkości danej formuły znaleźć mogą Czytelnicy na stronach 17-18 (ramka)
książki R. Stephensa Algorytmy i struktury danych z przykładami w Delphi, Helion 2000
— przyp. tłum.
Rozmiar problemu
Oczywiście nie można zupełnie ignorować znaczenia stałych czynników w formule złożo-
ności algorytmów, tak jak nie można ignorować rzeczywistego czasu wykonania programu:
różnica między złożonością 2 x N a 100 x N może oznaczać różnicę między półgodzinnym
a całodobowym oczekiwaniem na wyniki — mimo iż obydwie te wielkości są równe 0(N).
To prawda, z drugiej jednak strony łatwiej jest programistom skrócić o połowę czas wyko-
nywania algorytmu o złożoności 0(N), niż dla algorytmu o złożoności 0(N2) znaleźć rów-
noważny algorytm o złożoności 0{N).
Rozdział 1. • Zaczynamy 29
Mimo iż niezależność liczby operacji od rozmiaru problemu może wydawać się zbyt piękną
by okazać się prawdziwą to jednak wiele prostych funkcji wymaga obliczeń o takiej wła-
śnie złożoności. Banalnym przykładem takiej funkcji może być odczytanie elementu tablicy
na podstawie wartości indeksu: operacja ta wykonywana jest natychmiastowo, niezależnie
od rozmiaru wspomnianej tablicy 3 .
Dla bardziej złożonych problemów znalezienie algorytmu o złożoności 0(1) jest już sprawą
znacznie trudniejszą, aczkolwiek możliwą: w rozdziałach 3. i 11. omawiamy algorytmy i struktu-
ry danych charakteryzujące się taką właśnie stałą złożonością.
Warto przy okazji zwrócić uwagę na istotny fakt, że algorytm o stałej złożoności wcale nie
musi być algorytmem szybkim: jeżeli wymaga on (na danym komputerze) całego miesiąca
obliczeń bez względu na rozmiar rozwiązywanego problemu, to w dalszym ciągu ma zło-
żoność 0(1), mimo iż czas oczekiwania na wyniki może być absolutnie nieakceptowalny.
Złożoność liniowa—0(N)
Algorytm o złożoności liniowej wykonuje się w czasie proporcjonalnym do rozmiaru pro-
blemu. Na wykresie przedstawionym na rysunku 1.1 krzywa oznaczona 0(N), jakkolwiek
systematycznie pnie się w górę, to jednak jej nachylenie pozostaje niezmienne.
Prostym przykładem procesu o złożoności liniowej może być obsługa klientów czekających
w kolejce do kasy w supermarkecie. Przy założeniu, że średni czas obsługi jednego klienta
jest stały — czyli niezależny od długości kolejki — i wynosi (powiedzmy) 2 minuty, ob-
sługa 10 klientów wymaga 10 x 2 = 20 minut, zaś 20 klientów — 20 x 2 = 40 minut. Jest
więc proporcjonalny do długości kolejki — j e ś l i długość ta wynosi N (klientów), czas wy-
magany na jej obsłużenie będzie czasem 0(N).
Interesujące jest przy tym to, że skrócenie jednostkowego czasu obsługi — na przykład
dzięki zmianie kasjerki na bardziej operatywną czy też uruchomienie drugiej kasy — mimo
iż spowoduje skrócenie bezwzględnej wartości czasu obsługi kolejki, nie zmieni jednak
rzędu złożoności tego czasu (0{N)) — stałe czynniki nie mają bowiem (jak pamiętamy)
wpływu na ów rząd.
3
Pomijamy tu efekty związane z pamięcią wirtualną: tablica o mniejszym rozmiarze z większym
prawdopodobieństwem znajdować się będzie w całości w pamięci operacyjnej, natomiast
pobieranie elementów bardzo dużej tablicy w sposób losowy może powodować konieczność
częstego sprowadzania stron z pliku wymiany, a więc odwołania do elementów realizowane
będą znacznie dłużej. Efekt ten wynika jednak ze specyfiki implementacji pamięci wirtualnej,
nie zaś z samej organizacji tablicowej — p r z y p . tłum.
30 Algorytmy. Od podstaw
Złożoność kwadratowa—0(N 2 )
Wyobraźmy sobie grupę nieznających się nawzajem osób, z których każda chce osobiście
zapoznać się z pozostałymi. Jeżeli będzie to grupa sześcioosobowa, wymagać to będzie 5
+ 4 + 3 + 2 + 1 = 15 uścisków dłoni, jak przedstawiono to na rysunku 1.2.
Rysunek 1.2.
Każdy uczestnik
grupy chce
przywitać się
osobiście
z każdym
z pozostałych
-N N2
Ogólnie, w przypadku N-osobowej grupy liczba powitań wynosić będzie ; ponie-
waż z punktu widzenia notacji „dużego O" możemy zaniedbać stały czynnik, formuła po-
wyższa upraszcza się do wartości N -N. Co więcej, wraz ze wzrostem N odejmowanie N od
N2 daje efekt coraz mniej zauważalny — możemy sobie darować to odejmowanie, otrzy-
mując ostatecznie złożoność 0(N2).
4
Ponieważ złożoność 0(N2) charakterystyczna jest dla algorytmów typu „każdy z każdym",
często nazywana bywa złożonością kombinatoryczną—przyp. tłum.
Rozdział 1. • Z a c z y n a m y 31
5
Dla trzech dodatnich liczb a, b i N (a > \ , b> \) prawdziwa jest tożsamość log N = - ,
log, a
a więc zmiana podstawy logarytmu z b na a równoważna jest pomnożeniu wartości tego logarytmu
przez stały czynnik . Ponieważ stałe czynniki są nieistotne z punktu widzenia notacji
log,, a
„dużego O", konkretna podstawa logarytmu jest nieistotna dla rzędu złożoności algorytmu.
Dla ustalenia uwagi można więc wybrać dowolną wygodną w obliczeniach podstawę i często
przyjmuje się w tej roli właśnie liczbę 2 — p r z y p . tłum.
32 Algorytmy. Od podstaw
Ponowne spojrzenie na rysunek 1.1 pozwala stwierdzić, że algorytmy o złożoności 0(N log N)
są lepsze od algorytmów o złożoności kwadratowej, lecz daleko im jeszcze do złożoności
liniowej. Algorytmami tego typu zajmiemy się w rozdziałach 6. i 7.
Wartości funkcji silnia dla kilku początkowych liczb naturalnych — i przy okazji porówna-
nie jej z funkcją kwadratową—przedstawione są w tabeli 1.2.
Tabela 1.2. Porównanie funkcji silnia z funkcją kwadratową dla niewielkich liczb naturalnych
N N2 N!
1 1 l
2 4 2
3 9 6
4 16 24
5 25 120
6 36 720
7 49 5 040
8 64 40 320
9 81 362 880
Jak widać, dla N < 3 funkcja kwadratowa góruje nad funkcją silnia, począwszy od N = 4 ta
ostatnia zaczyna jednak rosnąć lawinowo, stając się znacznie gorszą od i tak „niedobrej"
złożoności 0(N2). Pozostaje tylko żywić nadzieję, że algorytmów o tej złożoności uda się
uniknąć.
Testowanie modułów
Zostawmy na chwilę same algorytmy i przyjrzyjmy się podstawom techniki zwanej testo-
waniem modułów (unit testing). W ostatnich latach technika ta zyskała wielką popularność
w kręgu programistów, którzy przywiązują należytą wagę do jakości tworzonych przez sie-
bie aplikacji. Uważają oni za konieczne powiązanie z każdym wyprodukowanym przez siebie
Rozdział 1. • Zaczynamy 33
„kawałkiem" kodu innego kawałka testowego weryfikującego, iż dany program, klasa czy
metoda istotnie realizują to, czego się od nich oczekuje. Również i my podzielamy ten
punkt widzenia, dlatego każdej prezentowanej przez nas implementacji konkretnego algo-
rytmu towarzyszy zarówno wyjaśnienie zasad jej działania, jak i testy sprawdzające, czy
istotnie działa ona zgodnie z tymi zasadami. Uważamy, że podejście takie powinno stać się
nawykiem każdego programisty.
W ramach każdej klasy testowej wyróżnić można trzy następujące, fundamentalne operacje:
przedstawia się tak jak na rysunku 1.3. Dzięki temu instrukcje pakietu Javy na początku
każdego z plików pozostają te same, choć same „testowe" pliki odseparowane zostają od
kodu „produkcyjnego". Daje to pewność, że ów produkcyjny kod pozostaje niezależny od
kodu testowego, a uaktywnienia testu dokonuje się za pomocą metod o zasięgu pakietu
(package scoped methods).
Rysunek 1.3.
Umiejscowienie - mam
plików źródłowych | - com
związanych z klasą
testową w kontekście
umiejscowienia plików - algorithms
źródłowych klasy
I - Widget
zasadniczej
-- test
| - com
| - wrox
- algorithms
| - WidgetTest
T
36 Algorytmy. Od podstaw
Nie jest w tej chwili specjalnie istotne, do testowania jakiej klasy służy przedstawiony kod
(powrócimy do niego w rozdziale poświęconym kolejkom). Ważne jest natomiast to, że ów
kod ma postać konkretnej klasy wywodzącej się z klasy bazowej zdefiniowanej w bibliote-
ce JUnit: widzimy definicję klasy RandomListQueueTest odwołującą się do klasy bazowej
TestCase i wprowadzającą kilka nowych statycznych pól, po czym deklarowana jest instan-
cja klasy testowanej przechowującą przykładową kolejkę w czasie testu.
Po zdefiniowaniu klasy testowej klasy pochodnej (na bazie klasy TestCase) należy przede-
finiować jej oryginalną metodę SetUpO w celu utworzenia konkretnego obiektu przystoso-
wanego do specyficznego testu. Treść przedefiniowanej metody rozpoczyna się od wywo-
łania oryginalnej (odziedziczonej po superklasie) metody setUpO, po czym tworzona jest
instancja klasy:
protected void setUpO throws Exception {
super. setUpO;
Zwróć uwagę na pisownię nazwy metody setUp ( ): nazwa ta rozpoczyna się małą
literą, a wewnątrz nazwy występuje wielka litera U. Wjęzyku Java wielkość liter
w nazwach identyfikatorów ma znaczenie, tak więc na przykład setUp, SetUp i Setup
oznaczają trzy różne identyfikatory. Jest to o tyle istotne, że fakt przedefiniowania
metody dziedziczonej po superklasie nie jest sygnalizowany jawnie w żaden sposób6,
a jedynie wynika z identyczności identyfikatorów. Drobna pomyłka literowa w kodzie
źródłowym może więc drastycznie zmienić zachowanie klasy.
Metoda setUpO klasy testowej wywoływana jest automatycznie przed wywołaniem każdej
metody klasy testowej — gwarantuje to biblioteka JUnit. Analogicznie po zakończeniu wy-
konywania wspomnianej metody wywoływana jest automatycznie metoda tearDownO, któ-
rej przedefiniowanie daje okazję do wykonania niezbędnych czynności końcowych, na
przykład wyzerowania wskaźnika na instancję klasy, jak w poniższym przykładzie:
protected void tearDownO throws Exception {
super.tearDown();
_queue = nul 1;
}
Nawiasem mówiąc, w związku z istnieniem w Javie mechanizmu automatycznego odśmie-
cania (garbage collectiori) zerowanie wskaźnika na niepotrzebną instancję klasy nie jest
konieczne, lecz w dużych zestawach testowych może znacząco przyczynić się do efektyw-
ności wykorzystania pamięci, warto więc przyjąć tę praktykę jako dobry zwyczaj programi-
styczny.
Przedmiotem testu wykonywanego przez widoczną poniżej metodę jest zachowanie się pu-
stej kolejki w sytuacji, gdy próbuje się pobrać z niej element czołowy (zgodnie założeniami
autora jest to niedopuszczalne). Sytuacja ta jest o tyle interesująca, że weryfikuje fakt zała-
mania się wykonywania kodu w sposób przewidziany a priori na okoliczność próby wyko-
nania niedozwolonej operacji.
6
Na przykład poprzez s ł o w o k l u c z o w e override, jak w Object Pascalu — p r z y p . tłum.
Rozdział 1. • Zaczynamy 37
try {
_queue.dequeue():
failO; // nieoczekiwane zachowanie
} catch (EmptyQueueException e) {
// oczekiwane zachowanie
assertEquals(3. _queue.size());
assertFalse(_queue.isEmpty());
_queue.clear();
assertEquals(0. _queue.size()):
assertTrue(_queue.i sEmpty());
}
Nazwa metody rozpoczyna się od przedrostka test, co umożliwia bibliotece JUnit jej zi-
dentyfikowanie jako metody testowej. Testowanie zachowania kolejki polega na dodaniu
do niej trzech elementów i zweryfikowaniu oczekiwanej wartości metod sizeO i i sEmpty O.
Kolejka zostaje następnie opróżniona i oczekiwane (nowe) wartości wymienionych metod
weryfikowane są ponownie.
Gdy odnośny test modułu zostanie już stworzony, pozostaje go tylko uruchomić. Nie da się
tego jednak zrobić w zwykły sposób — test ten nie posiada bowiem metody mainO. Za-
miast tego biblioteka JUnit dostarcza kilku „uruchamiaczy" (test runners), od prostego in-
terfejsu w postaci konsoli tekstowej do wyszukanych interfejsów graficznych. Większość
opartych na Javie środowisk projektowych — j a k Eclipse czy Intel liJ IDEA — posiada
bezpośrednie wsparcie dla uruchamiania testów opartych na JUnit; z poziomu wiersza po-
leceń można uruchomić prezentowany test, wydając następujące polecenie:
java junit.textui.TestRunner com.wrox.algori thms.queues.RandomLi stQueueTest
(należy oczywiście zadbać o to, by plik junit. jar znajdował się na ścieżce klas classpath).
Uruchomienie graficznej wersji testu jest równie nieskomplikowane:
java junit.swingui.TestRunner com.wrox.algorithms.queues.RandontistQueueTest
Bibliotekę JUnit można też wykorzystywać z poziomu wielu innych narzędzi, jak Ant lub
Maven. Konsekwentne uruchamianie dobrego zestawu testowego po każdej istotnej zmianie
testowanego systemu czyni życie programistów łatwiejszym, a sam system — bardziej so-
lidnym i niezawodnym. Warto więc zainteresować się biblioteką JUnit na poważnie.
itp. niezmieniające jego zewnętrznego zachowania. W ten oto sposób sukcesywnemu po-
wstawaniu nowego i modyfikowaniu istniejącego kodu towarzyszy powstawanie mechani-
zmów ułatwiających wykrywanie błędów nieuchronnie popełnianych w procesie progra-
mowania.
Jeżeli, czytając niniejszą książkę, poznasz i docenisz zalety testowania modułów, z pewno-
ścią zainteresują Cię inne książki poświęcone tej tematyce. Kilka z nich, godnych naszym
zdaniem polecenia, wymieniamy w dodatku A.
Podsumowanie
Czytając niniejszy rozdział, mogłeś dowiedzieć się, że:
• algorytmy są wszechobecne w naszym życiu, nie tylko w programowaniu,
• algorytmy stanowią podstawę większości systemów komputerowych,
• dla danego problemu może istnieć kilka algorytmów różniących się od siebie
złożonością
• rząd złożoności algorytmów, będący podstawą ich klasyfikacji, wygodnie jest
wyrażać za pomocą notacji „dużego O",
• testowanie modułów ma duże znaczenie dla jakości tworzonego kodu,
• biblioteka JUnit jest powszechnie wykorzystywanym środowiskiem testowym
dla aplikacji tworzonych w języku Java.
40 Algorytmy. Od podstaw
2
Iteracja i rekurencja
Iteracja i rekurencja to dwie fundamentalne koncepcje, bez których tworzenie programów
byłoby znacznie utrudnione, jeżeli w ogóle możliwe. Sortowanie nazw, obliczanie sumarycz-
nej kwoty transakcji kart kredytowych, drukowanie listy towarów na fakturze — to wszystko
wymaga wykonania określonych czynności na każdym elemencie określonego zbioru.
Iteracja to po prostu wielokrotne powtarzanie tej samej czynności. Liczba powtórzeń uwa-
runkowana jest różnymi czynnikami, specyficznymi dla konkretnego przypadku: przykła-
dowo obliczenie bilansu gry na giełdzie w ostatnim miesiącu wymaga powtórzenia okre-
ślonej sekwencji dla każdej z firm, w akcje której zainwestowaliśmy nasze pieniądze —
liczba tych firm wyznacza więc wspomnianą liczbę powtórzeń. Z kolei najprostszym (choć
zdecydowanie mało efektywnym) sposobem otrzymania tysiąca początkowych liczb pierw-
szych jest badanie podzielności kolejnych liczb nieparzystych tak długo, aż otrzymamy ty-
sięczną liczbą pierwszą— liczba powtórzeń nie jest w tym przypadku znana a priori.
Jak pokazuje praktyka programistyczna, większość algorytmów daje się zaliczyć do jednej
z dwóch kategorii: pierwszą z nich tworzą algorytmy o charakterze iteracyjnym, drugą —
zdecydowanie mniej liczną-— algorytmy rekurencyjne. W niniejszym rozdziale zakładamy,
że Czytelnik umie konstruować pętle i wywołania metod, operacje te bowiem stanowią
podstawę implementowania iteracji i rekurencji w rozwiązywaniu problemów.
Wykonywanie obliczeń
Jednym z najprostszych bodaj obliczeń o charakterze iteracyjnym jest obliczanie wartości
potęgi o całkowitej podstawie i całkowitym wykładniku drogą kolejnych mnożeń. Przykła-
dowo 32 = 3 x 3 = 9, a 106 = 10 x 10 x 10 x 10 x 10 x 10 = 1 000 000. Skonstruujemy w tym
celu klasę PowerCalculator posiadającą tylko jedną metodę o nazwie calculate i dwóch
całkowitych parametrach, reprezentujących podstawę i wykładnik, zwracającą wartość obli-
czonej potęgi. Ograniczymy się przy tym do nieujemnych wartości podstawy i wykładnika, choć
uogólnienie obliczeń na dowolne liczby całkowite nie jest zbyt wielkim problemem.
Testowanie obliczeń
Mimo iż samo potęgowanie jako takie nie wymaga specjalnych komentarzy, musimy wy-
korzystać pewne jego własności w celu upewnienia się, że klasa PowerCalculator funkcjo-
nuje prawidłowo.
Pierwszym szczególnym przypadkiem potęgowania, jaki poddamy testowi, jest zerowa war-
tość wykładnika — wartość potęgi powinna być wówczas równa 1. Dla uproszczenia zakła-
damy, że 0° także równa się 1.
public void testAnythingRaisedToThePowerOfZeroIsOne() {
PowerCalculator calculator = PowerCalculator.INSTANCE:
Kolejny przypadek szczególny to wykładnik równy 1 — potęga równa jest wówczas swej
podstawie.
public void testAnythingRaisedToThePowerOfOnelsItself() {
PowerCalculator calculator = PowerCalculator.INSTANCE:
J a k to działa?
W pierwszym przypadku testowana jest wartość potęgi przy zerowym wykładniku — po-
winna być ona równa 1 także przy zerowej podstawie.
Drugi test dotyczy wykładnika równego 1 —- sprawdza się, czy potęga równa jest swej wła-
snej podstawie.
Ostatni z testów wykorzystuje znane wartości potęgi dla przypadkowych kombinacji „pod-
stawa-wykładnik".
Implementowanie kalkulatora
Mając gotowy zestaw testowy, możemy przystąpić do stworzenia klasy kalkulatora obli-
czającego wartość potęgi.
package com.wrox.algorithms.iteration;
int result = 1:
J a k to działa?
Przetwarzanie tablic
Iteracje, poza organizacją pętli wykonujących obliczenia, używane są powszechnie do
przetwarzania tablic. Wyobraźmy sobie operację udzielenia rabatu dla grupy zamówień:
w poniższym fragmencie kodu przebiegane są kolejno wszystkie elementy tablicy zamó-
wień orders i dla każdego elementu wyliczana jest odpowiednia obniżka w wysokości per-
centage procent.
Orderf] orders = ... :
Tym razem zmienna indeksowa inicjowana jest wartością odpowiadającą ostatniemu ele-
mentowi (int i = customers.length-1) i sukcesywnie dekrementowana (--i) aż do osią-
gnięcia wartości 0 (odpowiadającej pierwszemu elementowi).
Rozdział 2. • Iteracja i rekurencja 45
Inną wadą wspomnianych schematów jest konieczność powielania ich logiki w sytuacji,
gdy mają zostać użyte w wielu miejscach kodu; ponadto wybór przetwarzanych elementów
następuje a priori, jeszcze przed rozpoczęciem iteracji. Jest więc oczywiste, iż potrzebuje-
my bardziej poręcznego mechanizmu, separującego logikę związaną z wyborem elementów
od reszty kodu.
Operacje iteratorów
Każdy iterator oferuje zestaw operacji zapewniających dostęp do danych i nawigowanie
wśród nich. Szybki rzut oka na tabelę 2.1 pozwala zauważyć, że możliwa jest nawigacja
w dwóch kierunkach: od elementu pierwszego do ostatniego albo odwrotnie.
Operacja Znaczenie
previous() Powoduje przejście do poprzedniego elementu; niezaimplementowana wywołuje wyjątek
UnsupportedOperationException.
isDoneO Zwraca wartość true, jeśli nie jest określony element bieżący ( m ó w i m y w ó w c z a s ,
że iterator znajduje się w stanie wyczerpanym — exhausted), i wartość false,
jeśli element bieżący jest określony.
current() Udostępnia wartość bieżącego elementu; jeśli bieżący element nie jest określony,
generowany jest wyjątek Iterator0ut0fBoundsException.
46 Algorytmy. Od podstaw
Nie wszystkie operacje mają sens w przypadku konkretnego iteratora związanego z kon-
kretną strukturą danych. Jest więc całkowicie naturalne, że niektóre z operacji nawigacyj-
nych — firstO, lastO, next() i previous() — mogą pozostać niezaimplementowane;
wywołanie niezaimplementowanej operacji powoduje wystąpienie wyjątku Unsupported-
OperationException oznaczającego niedozwolone lub niezdefiniowane zachowanie.
Interfejs iteratora
Zgodnie z operacjami opisanymi w tabeli 2.1 każdy iterator może być utożsamiony z nastę-
pującym interfejsem:
package com.wrox.aIgorithms.iteration;
/**
z**
* Pozycjonuje iterator na poprzednim elemencie.
* Gdy niezaimplementowana, wywołuje wyjątek UnsupportedOperationException
*/
public void previous():
/**
Ponieważ żądanie wartości bieżącego elementu w sytuacji, gdy bieżący element iteracji jest
nieokreślony, stanowi ewidentny błąd wykonania, naturalną reakcją na takie zdarzenie jest
wygenerowanie wyjątku typu unchecked exception, który nie może być obsłużony w ra-
mach aplikacji. Gdy jednak posługujemy się omawianymi dalej idiomami iteracyjnymi, wy-
stąpienie wyjątku IteratorOutOfBoundsException jest mało prawdopodobne.
Interfejs Iterable
W uzupełnieniu do interfejsu reprezentującego iterator, za pomocą kolejnego interfejsu mo-
żemy uzyskać dostęp do iteratora związanego z konkretną strukturą danych:
package com.wrox.algorithms.iteration;
public interface Iterable {
public Iterator iteratorO;
_ J
Idiomy iteracyjne
Podobnie jak proste iteracje tablicowe, tak również iteratory mogą być wykorzystywane na
jeden z dwóch sposobów: w ramach pętli for i w ramach pętli whi le. W obydwu przypadkach
zasady są podobne: mając konkretny iterator — zadeklarowany jawnie stanowiący parametr
48 Algorytmy. Od podstaw
while (!iterator.isDoneO) {
Object object = iterator.currentO:
Schemat ten jest szczególnie wygodny w przypadku, gdy iterator jest parametrem wywoła-
nia metody; początkowe pozycjonowanie go na pierwszym lub ostatnim elemencie może
być wówczas niepotrzebne, a nawet niewskazane.
Kombinacja iteratora z pętlą for przypomina jeszcze bardziej prostą iterację po tablicy:
Iterator iterator - ...;
)
Po początkowym ustawieniu iteratora na pierwszym elemencie (firstO) następuje prze-
mieszczanie się na elementy następne (next()), a sytuacja wyczerpania elementów wykry-
wana jest za pomocą metody i sDoneO. Równie naturalnie przebiega iterowanie w kierunku
odwrotnym:
Iterator iterator = ...;
Iteratory standardowe
W uzupełnieniu do iteratorów implementowanych przez poszczególne struktury danych
oraz iteratorów definiowanych ad hoc przez programistów, użyteczne bywają pewne stan-
dardowe konstrukcje, które w połączeniu z innymi iteratorami pozwalają na tworzenie cał-
kiem wyrafinowanych algorytmów przetwarzania danych.
Rozdział 2. • Iteracja i rekurencja 49
Iterator tablicowy
Najbardziej oczywistym przykładem standardowej konstrukcji iteracyjnej jest iterator umoż-
liwiający nawigowanie po elementach tablicy. Zastosowanie go w aplikacji zamiast „naiw-
nego" iterowania po kolejnych elementach umożliwi w przyszłości łatwe uogólnienie tej
aplikacji na inne struktury danych.
Jedną z zalet używania iteratora tablicowego jest możliwość ograniczenia jego działania do
wycinka tablicy określonego np. przez indeks pierwszego elementu i listę elementów; przetwa-
rzanie tego wycinka odbywa się tak jak przetwarzanie pełnej, „normalnej" tablicy. Z tą wła-
śnie możliwością związany jest nasz pierwszy test.
public void testlterationRespectsBoundst) {
ObjectH array = new Object[] {"A". "B". "C". "D". "E". "F"}:
Arraylterator iterator = new Arraylteratortarray, 1 , 3 ) :
iterator.first():
assertFalsetiterator.isDonet));
assertSame(array[l]. iterator.current());
iterator.next():
assertFalsetiterator. i sDoneO):
assertSame(array[2]. iterator.currentt)):
iterator.next();
assertFalsetiterator.isDone());
assertSame(array[3], iterator.currentt)):
iterator.next():
assertTruet i terator.i sDonet));
try {
iterator.currentt):
failt): // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
1
iterator.lastO;
assertFalse(iterator isDoneO);
assertSame(array[2]. iterator.currentO);
iterator.previous():
assertFalseCiterator isDoneO);
assertSame(array[l], iterator.currentO);
iterator.previous():
assertFalse(iterator isDoneO);
assertSame(array[0]. iterator.currentO);
iterator.previous():
assertTruet i terator.i sDonet)):
try {
iterator.currentC
failO:// zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
}
}
Jak to działa?
Strukturą danych na użytek pierwszego testu jest tablica sześcioelementowa. Testowany
iterator ogranicza się jednak tylko do trzech elementów, począwszy od elementu o indeksie
1 (drugiego) — wynika to z wywołania konstruktora. Iterator powinien więc zwrócić kolej-
no elementy „B", „C" i „D". Rozpoczynamy więc od ustawienia go na pierwszym elemen-
cie i sprawdzamy najpierw, czy bieżący element jest w ogóle określony (wywołanie metody
isDoneO) po czym upewniamy się, że jest to istotnie element o indeksie 1. Wywołując
metodę next(), czynimy następnie to samo z elementami o indeksach 2 i 3. Kolejne, trzecie
wywołanie metody next() powinno spowodować, że bieżący element we jest określony;
sprawdzamy więc, czy wywołanie metody currentO w tej sytuacji spowoduje wystąpienie
wyjątku IteratorOutOfBoundsException.
Drugi test oparty jest na podobnej zasadzie: pozycjonujemy iterator na ostatnim elemencie,
po czym dwukrotnie przesuwany się na element poprzedni. Na końcu upewniamy się, że
trzecie wywołanie metody previous() i następnie metody currentO spowoduje wystąpie-
nie wyjątku.
Możliwe są oczywiście jeszcze inne scenariusze testowania, wszystkie one powinny jednak
kontrolować zarówno poprawność przemieszczania się na sąsiednie elementy, jak i zacho-
wanie się iteratora na elementach „granicznych". Zajmijmy się teraz konstrukcją samego
iteratora.
_array = array;
_first = start;
_last = start + length - 1;
}
Oczywiście iterator powinien mieć możliwość operowania na całej tablicy. Chociaż „cała
tablica" jest szczególnym przypadkiem „wycinka", to jednak wygodnie (i elegancko) byłoby
mieć na tę okazję osobny konstruktor, którego jedynym parametrem jest wspomniana tablica:
Elementem bieżącym (o ile taki jest określony) jest element tablicy o bieżącym indeksie:
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsExceptionO:
}
return _array[_current];
} "
J a k to działa?
Jak łatwo zauważyć na pierwszym listingu, iterator utrzymuje prywatny wskaźnik na odnośną
tablicę oraz trzy indeksy reprezentujące elementy pierwszy, ostatni i bieżący. W wywołaniu
konstruktora sprawdza się sensowność przekazanych parametrów — niedopuszczalne jest
na przykład rozpoczęcie wycinka na 20. elemencie w przypadku tablicy 10-elementowej.
Jeśli indeks bieżącego elementu nie przekracza wartości granicznych, metoda isDoneO
zwraca wartość fal se; bieżący element jest wówczas określony, a dostęp do niego odbywa
się w sposób bezpośredni.
Rozdział 2. • Iteracja i rekurencja 53
Iterator odwracający
Dla danego iteratora użyteczne może być niekiedy stworzenie „bliźniaczego" iteratora wy-
konującego iterację w kierunku przeciwnym. Wyobraźmy sobie mianowicie jakąś strukturę
przechowującą dane pracowników oraz iterator udostępniający te dane w kolejności alfa-
betycznej nazwisk. Iterator ten wykorzystywany jest przez pewną metodę wywołującą naj-
pierw metodę firstO, a następnie przemieszczającą się na kolejne elementy za pomocą
sukcesywnych wywołań metody next(). Poszczególni pracownicy przetwarzani są wów-
czas według alfabetycznej kolejności nazwisk; gdybyśmy chcieli zmienić tę kolejność na
przeciwną moglibyśmy po prostu zmienić sposób korzystania z iteratora — najpierw wy-
wołać metodę lastO, po czym, wywołując wielokrotnie metodę previous(), przemiesz-
czać się na elementy poprzednie. Alternatywnym rozwiązaniem, niewymagającym zmiany
sposobu korzystania z iteratora, jest zdefiniowanie iteratora odwracającego działanie itera-
tora oryginalnego: funkcje firstO i łastO zamienią się wówczas rolami, podobnie jak
f u n k c j e next () i previ ous ().
import junit.framework.TestCase;
iterator.first();
assertFalse(iterator.isDone()):
assertSame(ARRAV[2], iterator.currentt)):
iterator.nextO:
assertFalse(iterator.isDone());
assertSame(ARRAY[l]. iterator.currentO);
iterator.next():
assertFalset i terator.i sDone()):
assertSame(ARRAY[0]. iterator.currentO);
iterator.next();
assertTrue(iterator.i sDone());
try {
iterator.currentO:
failO: // zachowanie nieoczekiwane
54 Algorytmy. Od podstaw
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
iterator.lastO;
assertFalse( iterator. isDoneO):
assertSame(ARRAY[0]. iterator,current());
iterator.previous();
assertFalse(iterator.isDone());
assertSame(ARRAY[l], iterator.currentt));
iterator.previous();
assertFa1se(i terator.i sDone()):
assertSame(ARRAY[2], iterator.currentO);
iterator.previous();
assertTrue(iterator.i sDone());
try {
iterator.currentO;
failO; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
}
}
Jak to działa?
Wywołanie metody firstO iteratora odwracającego powinno być równoważne wywołaniu
metody lastO iteratora oryginalnego; elementem bieżącym powinien stać się ostatni ele-
ment tablicy i to właśnie stanowi przedmiot pierwszego testu.
Test na widoczny na drugim listingu stanowi lustrzane odbicie testu widocznego na pierw-
szym: wywołanie metody lastO iteratora odwracającego powinno ustawić wskaźnik ele-
mentu bieżącego na pierwszym elemencie tablicy, zaś wywoływanie metody previous()
powinno przemieszczać ów wskaźnik w przód tablicy, w kierunku rosnących indeksów.
Mając już stosowny zestaw testowy dla iteratora odwracającego, zajmijmy się teraz imple-
mentacją samego iteratora.
Rozdział 2. • Iteracja i rekurencja 55
* Konstruktor.
* parametry wywołania: iterator oryginalny.
*/
public ReverseIterator(Iterator iterator) {
assert iterator != nuli : "nie określono iteratora":
_iterator = iterator;
}
public boolean IsDoneO {
return _iterator.isDone():
}
public Object currentO throws IteratorOutOfBoundsException {
return _iterator.currentO;
}
public void firstO {
_iterator.1ast();
}
public void lastO {
_iterator.fi r s t O ;
}
public void next() {
_iterator.previous():
}
public void previous() {
_iterator.next();
}
}
Jak to działa?
Iterator filtrujący
Inną użyteczną rzeczą, jaką można zrobić z danym iteratorem, jest filtrowanie — według
pewnego kryterium —udostępnianych przez niego elementów. Koncepcję te wykorzystują
wzorce projektowe zwane otoczkami (wrappers) i dekoratorami (decorators) [Gamma,
1995], W taki oto prosty sposób można np. uwzględniać tylko co drugi element przetwa-
rzanej struktury (np. tablicy) lub odrzucać wyniki kwerend niespełniające określonych kryte-
riów użytkownika.
Klasa predykatowa
Predykator może być utożsamiany z następującym interfejsem:
package com.wrox.a 1gori thms.i terat i on;
Interfejs ten posiada tylko jedną metodę evaluate(). Jej parametrem jest klasyfikowany
obiekt, a wynikiem — wynik klasyfikacji: wartość true oznacza akceptację obiektu, war-
tość false jego odrzucenie.
Mimo swej prostoty interfejs ten umożliwia tworzenie nawet wyrafinowanych predykato-
rów — w treści metody eval uate() mogą przecież ukrywać się bardzo złożone obliczenia.
import junit.framework.TestCase;
(
Rozdział 2. • Iteracja i rekurencja 57
Predykator wywoływany jest jednokrotnie dla każdego elementu zwracanego przez iterator
oryginalny. Na potrzeby naszego testu skonstruujemy więc klasę predykatową, której pa-
rametrami będą: wspomniany iterator oraz żądany wynik — akceptacja (true) albo odrzu-
cenie (false) — j a k i zostać ma zwrócony przez predykator.
private static finał class DummyPredicate implements Predicate {
private finał Iterator Jterator:
private finał boolean _result:
iterator. firstO;
assertFalse(iterator.i sDone()):
assertSame(ARRAY[0]. iterator.currentO);
iterator.nextO:
assertFalset iterator.i sDone());
assertSaire(ARRAY[l], iterator.currentO):
iterator.next():
assertFalset i terator.i sDone());
assertSame(ARRAY[2]. i terator. currentO);
iterator.next();
assertTruetiterator.isDonet));
try {
iterator.currentO;
failO; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertTrue(expectedIterator.i sDone());
assertTrue(underlyi nglterator.i sDone());
}
58 Algorytmy. Od podstaw
iterator.firstO:
assertTrue(iterator.i sDone());
try {
iterator.currentt):
failO:// zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertTrue(expectedIterator.isDone());
assertTrue(underlyingIterator.isDone()):
1
iterator.last():
assertFalse(i terator.i sDone()):
assertSame(ARRAY[2]. iterator.currentt)):
iterator.previous():
assertFalset i terator.i sDonet)):
assertSame(ARRAY[l]. iterator.currentt));
iterator.previoust):
assertFalset i terator.i sDonet)):
assertSame(ARRAY[0]. iterator.currentt));
iterator.previous():
assertTruet i terator.i sDonet));
try {
iterator.currentt):
failO; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertTruet expected Iterator. isDoneO):
assertTruetunderlyinglterator.isDonet)):
}
Rozdział 2. • Iteracja i rekurencja 59
iterator.lastO:
assertTrue(iterator.i sDone());
try {
iterator.currentO ;
failO;// zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertTrue(expectedIterator. isDone()):
assertTruetunderlyinglterator. isDoneO);
}
Jak to działa?
Nasza klasa testowa Fi lterlteratorTest zawiera coś więcej niż tylko proste dane testowe:
celem testu jest jednak nie tylko obserwowanie wartości zwracanych przez iterator, lecz także
upewnienie się, że predykator wywoływany jest właściwie.
W ramach testu dla predykatora akceptującego musimy więc stworzyć dwa niezależne ite-
ratory: testowany iterator filtrujący oraz pomocniczy iterator dla klasy predykatowej.
Pierwszy z nich ustawiany jest na pierwszym (lub ostatnim) elemencie (first() lub 1 ast ())
w sposób jawny, drugi ustawiany jest na pierwszym (lub ostatnim) elemencie przez kon-
struktor klasy predykatowej. Podobnie odbywa się nawigowanie po tablicy: metoda next()
(lub previous()) testowanego iteratora filtrującego wywoływana jest jawnie, natomiast metoda
next() (previous()) iteratora pomocniczego wywoływana jest w ramach metody evaluate()
klasy predykatowej. Zwróćmy uwagę na końcowe asercje weryfikujące wyczerpanie oby-
dwu wspomnianych iteratorów.
/** Predykator */
private finał Predicate _predicate;
/**
* Konstruktor.
* parametry: iterator oryginalny, predykator
*/
public Fi łterlteratortIterator iterator. Predicate predicate) {
assert iterator != nuli : "nie określono iteratora":
assert predicate !- nuli : "nie określono predykatora";
_iterator - iterator;
_predicate - predicate:
}
public boolean isDoneO {
return _iterator.isDonet);
}
public Object currentO throws IteratorOutOfBoundsException {
return _iterator.current():
}
}
Realizacja metod firstO i next() rozpoczyna się od wywołania analogicznych metod ite-
ratora oryginalnego, po czym następuje poszukiwanie (w przód) najbliższego elementu
spełniającego kryteria predykatora:
public void firstO {
_iterator.firstt);
filterForwardst):
}
public void next() {
_i terator.nextO;
filterForwardst):
}
private void filterForwardst) {
Rozdział 2. • Iteracja i rekurencja 61
Jak to działa?
Klasa Fi lterlterator implementuje interfejs Iterator i przechowuje informację o orygi-
nalnym iteratorze i predykatorze filtrującym; w konstruktorze następuje sprawdzenie, czy
informacje te są określone (w parametrach wywołania). Metody currentO i isDoneO nie
mają bezpośredniego związku z predykatorem — ich wywołania delegowane są wprost do
klasy iteratora zasadniczego; jest to możliwe między innymi dzięki temu, że tylko obiekt
akceptowany przez predykator może być obiektem bieżącym.
Specyfika iteratora filtrującego uwidacznia się w momencie, gdy wywołana zostaje jedna
z metod nawigacyjnych — firstO, next(), lastO lub previous(). Do głosu dochodzi
wówczas predykator, który musi zwrócić akceptowalny element, zachowując jednocześnie
semantykę iteratora.
public void firstO {
_iterator. firstO;
filterForwardsO:
}
public void next() {
_iterator.next();
filterForwardsO:
}
62 Algorytmy. Od podstaw
Rekurencja
„ Żeby zrozumieć rekurencją, trzeba najpierw zrozumieć rekurencję " — (autor nieznany)
Wyobraź sobie system plików — taki jak na dysku Twojego komputera. Jak zapewne wiesz,
w systemie tym istnieje katalog najwyższego poziomu (root), w którym znajdują się pliki
i podkatalogi. Każdy z tych podkatalogów może także zawierać pliki i podkatalogi. Owa
zagnieżdżana struktura nazywana bywa powszechnie drzewem katalogów (directory tree)
— drzewo to zakorzenione jest w katalogu najwyższego poziomu, zaś pliki mogą być uwa-
żane za liście tego drzewa. Przedstawiono to schematycznie na rysunku 2.1; widoczne tam
drzewo jest dość niezwykłe, bo zwrócone korzeniem do góry.
Rozdział 2. • Iteracja i rekurencja 63
Rysunek 2.1.
Struktura katalogów
reprezentowana
w formie drzewa
dev
ttyO
tmp
var
Rysunek 2.2.
Gałęzie drzewa
same są drzewami
64 Algorytmy. Od podstaw
Podobieństwo dwóch obiektów, różniących się skalą lub granulacją, jest interesującą kon-
cepcją niezwykle użyteczną w rozwiązywaniu problemów. Strategia podziału oryginalnego
problemu na „mniejsze" podproblemy tej samej natury — zwana strategią „dziel i zwycię-
żaj" (divide and conąuer) — j e s t jednym z przykładów rekurencji. Rekurencja jest w pew-
nym sensie przykładem wielokrotnego wykorzystywania tych samych rzeczy: metoda wy-
wołuje samą siebie.
package com.wrox.algorithms.iteration;
if (args.length != 1) {
System.err.println("Wywołanie: RecursiveDirectoryTreePrinter <katalog>");
System.exit(-l);
}
printtnew File(args[0]), "");
Powyższy program wymaga pojedynczego parametru, którym jest nazwa katalogu (lub pliku).
Po wykonaniu wstępnego sprawdzenia parametrów wywołania następuje utworzenie obiektu
java.io.File i przekazanie go do metody p r i n t O .
Rozdział 2. • Iteracja i rekurencja 65
Zwróćmy uwagę na to, że drugi argument wywołania metody print() jest łańcuchem pu-
stym. Ów pusty łańcuch oznacza brak wcięcia przy drukowaniu nazwy katalogu (pliku) na
najwyższym poziomie. Na każdym niższym poziomie łańcuch oznaczający wcięcie rozsze-
rzany jest o ciąg spacji określony przez stałą SPACES.
Metoda printO wywoływana jest z dwoma argumentami: obiektem File i łańcuchem ozna-
czającym wcięcie:
public static void print(File file. String indent) {
assert file != nuli : "nie określono obiektu File";
assert indent != nuli : "nie określono wcięcia";
if (file.isDirectoryO) {
print(file.listFiles(). indent + SPACES);
}
)
Działanie metody rozpoczyna się od wydrukowania nazwy pliku/katalogu poprzedzonej
wcięciem określonym przez parametr indent. Jeśli mamy do czynienia z katalogiem (w ję-
zyku Java obiekt File może reprezentować zarówno plik, jak i katalog), metoda printO
wywoływana jest rekurencyjnie, z wcięciem powiększonym o stałą SPACES. Początkowo —
tj. przy pierwszym, nierekurencyjnym wywołaniu metody, gdy parametr indent jest pusty
— wcięcia nie ma. Na każdym kolejnym poziomie rekurencji, odpowiadającym kolejnemu
poziomowi zagnieżdżenia plików/katalogów, parametr ten powiększany jest o wspomnianą
stałą SPACES, w efekcie czego nazwy zagnieżdżonych plików/katalogów drukowane są z prze-
sunięciem w prawo w stosunku do nazwy katalogu macierzystego.
Jeżeli obiekt File reprezentuje katalog, to jego metoda listfilesO zwraca tablicę będącą
listą plików tego katalogu. Konieczne było więc stworzenie metody printO akceptującej
tablicę obiektów File w roli pierwszego parametru:
public static void print(File[] files, String indent) {
assert files != nuli: "Nie określono listy plików";
Rekurencyjny charakter algorytmu drukowania drzewa katalogów przejawia się więc w re-
kurencyjnym wywoływaniu metody printO. Metoda ta, wywołana z argumentem typu File
reprezentującym pojedynczy katalog, wywoływana jest rekurencyjnie dla tablicy plików/
podkatalogów tego katalogu, czyli pośrednio dla każdego obiektu File wchodzącego w skład
tej tablicy. Oczywiście rekurencja ta nie może zagłębiać się w nieskończoność — i fak-
tycznie się nie zagłębia, bowiem wywołanie metody printO dla obiektu reprezentującego
plik (nie katalog) nie powoduje dalszych wywołań rekurencyjnych.
66 Algorytmy. Od podstaw
Oto fragment wydruku utworzonego przez prezentowany program dla katalogu zawierają-
cego pliki źródłowe przykładów dla niniejszej książki.
source
main
com
wrox
algorithms
bsearch
IterativeBinaryListSearcher.java
LinearListSearcher.java
Listlnserter.java
ListSearcher.java
package.html
RecursiveBi naryLi stSearcher.java
bstrees
Bi narySearchTree.java
Node.java
package.html
btrees
BTreeMap.java
package.html
geometry
BruteForceClosestPairFinder.java
ClosestPairFinder.java
Line.java
package.html
PIaneSweepClosestPai rFi nder.java
PlaneSweepOptimizedClosestPairFinder.java
Point.java
Slope.java
XYPoi ntComparator.java
hashing
Bucketi ngHashtable.java
Hashtable.java
Hashtablelterator.java
LinearProbingHashtable.java
package.html
PrimeNumberGenerator.java
SimplePrimeNumberGenerator.java
iteration
AndPredicate.java
Arraylterator.java
EmptyIterator.java
Filterlterator.java
Iterable.java
IterativePowerCalculator.java
Iterator.java
IteratorOutOfBoundsException.java
package.html
PowerCalculator.java
Predicate.java
Recursi veDi rectoryTreePri nter. java
Recursi vePowerCalculator.java
ReverseIterator.java
Singletonlterator.java
Rozdział 2. • Iteracja i rekurencja 67
lists
AbstractList.java
ArrayList.java
EmptyList. java
GenericListIterator. java
LinkedList.java
List.java
package.html
maps
OefaultEntry.java
EmptyMap.java
HashMap.java
ListMap.java
Map.java
MapKeyIterator.java
MapSet.java
MapValueIterator.java
package.html
TreeMap.java
queues
BlockingOueue.java
Cali.java
Cal 1 Center.java
CalICenterSimulator.java
CallGenerator.java
CustomerServiceAgent.java
EmptyQueueException.java
HeapOrderedL i stPri ori tyOueue.j ava
ListFifoOueue.java
Mi ni mumOri entedHeapOrderedLi stPri ori tyOueue.java
package.html
Pri ori tyOueueFi foOueue.java
Queue.java
RandomLi stOueue.java
SortedL i stPri ori tyOueue.j ava
Synchroni zedOueue.java
UnsortedListPriorityOueue.java
sets
EmptySet.java
HashSet.java
ListSet.java
package.html
Set.java
SortedListSet. java
TreeSet.java
sorting
BubblesortLi stSorter.java
CallCountingComparator.java
CaseInsensitiveStringComparator.java
Comparator.java
CompoundComparator.java
FileSorti ngHelper.java
Hybri dQui cksortLi stSorter.java
InPlacelnsertionSortLi stSorter.java
InsertionSortLi stSorter.java
Iterati veMergesortLi stSorter.java
Iterati veQui cksortLi stSorter.java
ListSorter.java
68 Algorytmy. Od podstaw
MergesortLi stSorter.java
Natura1Comparator.java
Opti mi zedFi1eSort i ngHelper.j ava
package.html
PriorityOueueListSorter.java
OuicksortLi stSorter.java
ReverseComparator.java
ReverseStringComparator.java
SelectionSortListSorter.java
ShellsortLi stSorter.java
ssearch
BoyerMooreStri ngSearcher.java
BruteForceStringSearcher.java
Cal 1Counti ngCharSequence.java
Comparati veStri ngSearcher.java
package.html
StringMatch.java
Stri ngMatchlterator. java
StringSearcher.java
stacks
CallCountingList.java
EmptyStackException.java
ListStack.java
package.html
PriorityQueueStack.java
Stack.java
UndoableList.java
tstrees
CrosswordHelper.java
package.html
Terna rySea rchTree.j ava
wmatch
Levenshtei nWordDi stanceCalculator.java
package.html
PhoneticEncoder.java
SoundexPhoneti cEncoder. java
Przypadek bazowy
Gdy metoda printO wywołana zostaje dla argumentu będącego plikiem (nie katalogiem),
nie powoduje to dalszych jej wywołań rekurencyjnych — działanie metody sprowadza się
jedynie do wydrukowania nazwy pliku.
Łańcuch wywołań rekurencyjnych kończy się w tym miejscu, dzięki czemu w ogóle kończy
się cały proces rekurencyjny. Takie wywołanie, niepociągające za sobą wywołań rekuren-
cyjnych, nazywamy przypadkiem bazowym rekurencji.
Niewłaściwie skonstruowane algorytmy rekurencyjne, w których obliczenia nie
osiągają przypadku bazowego, zostają ostatecznie zakończone w sposób awaryjny
z powodu wyczerpania dostępnego stosu, co sygnalizowane jest wystąpieniem
wyjątku StackOverfl owExcepti on. Wyjątek ten niekoniecznie jednak musi oznaczać
rekurencję nieskończoną, lecz może być także spowodowany próbą wywołania
rekurencyjnego zagnieżdżonego zbyt głęboko w stosunku do rozmiaru dostępnego stosu.
Przypadek ogólny
Przypadek ogólny wywołania rekurencyjnego jest jego typową postacią powodującą kolejne
wywołania rekurencyjne (które same mogą stanowić przypadki ogólne lub bazowe). W na-
szym przykładzie sytuacja taka występuje, gdy metoda printO wywołana zostaje z argu-
mentem reprezentującym katalog: może to rodzić jej wywołania rekurencyjne dla każdego
elementu — pliku lub podkatalogu — tego katalogu.
Podsumowanie
Iteracja i (lub) rekurencja są niezbędne do implementacji każdego niemal algorytmu. Treść
dalszych rozdziałów niniejszej książki w ogromnych stopniu bazuje na tych dwóch techni-
kach i dlatego ich zrozumienie jest tak istotne.
• proste algorytmy iteraeyjnego przetwarzania tablic nie dają się łatwo przystosowywać
do iterowania po bardziej skomplikowanych strukturach danych, w związku z czym
zdefiniowano koncepcję iteratora i zaimplementowano rozmaite warianty iteratorów,
• rekurencja jest techniką rozwiązywania problemów opartą na zasadzie „dziel
i zwyciężaj" — oryginalny problem dzielony jest na prostsze podproblemy
rozwiązywane w sposób identyczny jak problem oryginalny; rekurencja nadaje się
więc szczególnie do przetwarzania zagnieżdżonych struktur danych,
• dla wielu problemów istnieją naturalne rozwiązania obydwu kategorii: iteracyjne
i rekurencyjne.
Ćwiczenia
Odpowiedzi do ćwiczeń z tego i następnych rozdziałów znajdują się w dodatku D.
1. Skonstruuj iterator filtrujący udostępniający tylko co n-ty element spośród
elementów zwracanych przez iterator oryginalny.
2. Skonstruuj predykator równoważny koniunkcji (&&) dwóch innych predykatorów.
3. Skonstruuj rekurencyjnąwersję procedury PowerCalculator równoważną
prezentowanej w rozdziale wersji iteracyjnej.
4. Skonstruuj algorytm rekurencyjnego drukowania drzewa katalogów bazujący
na iteratorach zamiast na tablicach plików.
5. Skonstruuj iterator zwracający tylko jedną wartość.
6. Skonstruuj iterator pusty — czyli taki, który zawsze znajduje się w stanie
wyczerpanym.
3
Listy
Po zrozumieniu podstaw algorytmiki i roli, jaką spełniają iteracja i rekurencja, czas zająć się
konkretną strukturą danych — listami. Listy sąjednak z najbardziej uniwersalnych struktur
danych — na ich bazie implementowana jest znacząca część bardziej złożonych struktur i al-
gorytmów.
Przykłady rozmaitych list nietrudno znaleźć w życiu codziennym: listy zakupów, listy spraw
do załatwienia, rozkłady jazdy, paragony kasowe czy rozmaite katalogi będące często „li-
stami list". Listy wykorzystywane są w aplikacjach równie często jak tablice; tak naprawdę
listy stanowią wspaniałą alternatywę dla tablic i warto zastanowić się nad jej urzeczywist-
nieniem, o ile tylko wykorzystanie pamięci i czas wykonania aplikacji nie są czynnikami
krytycznymi.
Czym są listy?
Lista jest uporządkowaną kolekcją elementów zapewniającą dostęp do dowolnego ele-
mentu, podobnie jak w przypadku tablicy — można mianowicie zażądać dostarczenia
wartości elementu po (mówiąc ogólnie) jego zidentyfikowaniu. Pozycja elementu w liście
pozostaje niezmienna od momentu jego wstawienia (o ile oczywiście nie zostaną wykonane
jakieś dodatkowe czynności zmieniające tę kolejność) — wielokrotne żądanie wartości z tej
72 A l g o r y t m y . Od podstaw
samej pozycji listy zawsze zwraca tę samą wartość. Podobnie jak w przypadku tablicy nie
wymaga się unikalności elementów w liście: jeśli do przykładowej listy zawierającej ele-
menty „pływanie", „kolarstwo" i „taniec" dodany zostanie element „pływanie", dwa spo-
śród czterech elementów zmodyfikowanej listy będą miały identyczną wartość. Podstawową
własnością różniącą listy od tablic jest ich rozmiar — podczas gdy rozmiar tablicy ustalony
zostaje w momencie jej deklarowania lub tworzenia, rozmiar listy może się zmniejszać lub
zwiększać w miarę potrzeby.
Każda lista musi implementować przynajmniej cztery operacje opisane w tabeli 3.1.
Operacja Znaczenie
insert Wstawia element na wskazaną pozycję listy (0, 1 , 2 , ...), w wyniku czego rozmiar listy
zwiększony zostaje o 1. Jeśli wskazana pozycja jest ujemna lub większa od aktualnego rozmiaru
listy (index < Oalboindex > size()) generowany jest wyjątek IndexOutOfBoundsException.
delete U s u w a element ze wskazanej pozycji listy (0, 1, 2, ...), w wyniku c z e g o rozmiar listy
zostaje zmniejszony o 1. Zwraca wartość usuniętego elementu. Jeśli wskazana pozycja
wykracza poza listę (i ndex < Oalboindex >= s i ze 0), generowany jest wyjątek
Index0ut0fBoundsExcepti on.
get Zwraca wartość elementu znajdującego się na wskazanej pozycji listy (0, 1 , 2 , ...).
Jeśli pozycja ta wykracza poza listę (index < 0 albo index >= size()), generowany
jest wyjątek IndexOutOfBoundsException.
To jednak tylko absolutne minimum; jeśli ograniczymy się jedynie do niego, rychło może
się okazać, że zmuszeni jesteśmy do wielokrotnego powtarzania tych samych fragmentów
kodu implementujących bardziej zaawansowane, lecz często wykonywane operacje. Przy-
kładowo, wśród wymienionych operacji brak jest takiej, która zmieniałaby wartość ele-
mentu znajdującego się na wskazanej pozycji (co w przypadku tablicy jest operacją ele-
mentarną); choć można osiągnąć ten sam efekt za pomocą operacji delete i insert, to
jednak wymagałoby to wielokrotnego programowania tej samej logiki. Warto więc rozsze-
rzyć nasz „minimalny" interfejs o kilka operacji wykonywanych najczęściej na listach; jedną
z propozycji rozszerzeń przedstawiamy w tabeli 3.2.
Operacja Znaczenie
set Zmienia wartość elementu znajdującego się na wskazanej pozycji (0, 1 , 2 , ...), zwracając
jednocześnie wartość oryginalną (sprzed zmiany). Jeśli wskazana pozycja wykracza poza listę
(index < 0alboindex >= size()), generowany jest wyjątek IndexOutOfBoundsException.
delete Usuwa element stanowiący pierwsze wystąpienie wskazanej wartości w liście, zmniejszając
rozmiar listy o 1 i zwracając wartość true. Jeśli w liście nie występuje żaden element
o wskazanej wartości, operacja pozostawia listę bez zmian i zwraca wartość false.
Określenie „element o wskazanej wartości" oznacza element, którego metoda equals()
stwierdzi zgodność j e g o własnej wartości z wartością poszukiwaną'.
i ndexOf Zwraca pozycję pierwszego wystąpienia wskazanej wartości (0, 1, 2, ...); jeśli element
o wskazanej wartości nie występuje w liście, zwracana jest wartość - 1 . Określenie
„wystąpienie wskazanej wartości" ma takie samo znaczenie jak w opisie operacji delete.
isEmpty Zwraca true dla listy pustej (czyli takiej, dla której size() = 0) i fal se dla listy zawierającej
choć jeden element.
elear Usuwa z listy wszystkie elementy — lista staje się listą pustą.
1
Wbrew pozorom zgodność wartości elementu z wartością poszukiwaną nie z a w s z e jest sprawą
oczywistą, na przykład wyszukując żądaną nazwę, m o ż e m y ignorować wielkość liter, a szukając
wartości liczbowej ( w zapisie t e k s t o w y m ) — ignorować nieznaczące zera, utożsamiając
(na przykład) liczby 1, 001 i 1.00. Takie i podobne niuanse rozstrzygane są w ramach metody
equals() e l e m e n t u — p r z y p . tłum.
74 Algorytmy. Od podstaw
Jak to działa?
Przedstawiony interfejs uwzględnia operacje opisane w tabelach 3.1 i 3.2 — każdej opera-
cji odpowiada metoda o określonej nazwie, parametrach, typie zwracanego wyniku i moż-
liwym do wystąpienia wyjątku. Nie jest to interfejs banalny pod żadnym względem, ponie-
waż wymaga zaimplementowania pewnej liczby metod; jak jednak zobaczymy za chwilę,
metody uznane przez nas za uzupełniające (patrz tabela 3.2) da się stosunkowo łatwo zre-
alizować na bazie metod odpowiadających operacjom podstawowym (tym z tabeli 3.1).
Zwróćmy uwagę na ważny fakt, że interfejs List wywodzi się z interfejsu Iterable. Roz-
wiązanie to wynika z faktu, że dla danej listy może być określony iterator umożliwiający
nawigowanie po jej elementach; iterator ten dostępny jest za pośrednictwem metody i tera -
tor(), jaką interfejs List dziedziczy po interfejsie Iterable. Aby to lepiej zrozumieć,
spójrzmy na poniższe fragmenty kodu. W pierwszym z nich tworzona jest trójelementowa
lista, której elementy są następnie drukowane:
String[] anArray = ... :
anArray[0] = "Jabłko":
anArray[l] = "Banan";
anArray[2] = "Wiśnia":
aList.addCJabłko");
aList.addCBanan");
aList. addCWiśnia");
Iterator i = aList.iteratorO:
for (i.firstO: ! i. i sDone: i.next()) {
System.out.pri ntln(i.current()):
}
Druga wersja jest nie tylko bardziej ogólna, lecz dzięki użyciu metody add() i iteratora
czytelniejsze stają się intencje programisty.
Testowanie list
Nawet jeżeli nigdy dotąd nie implementowałeś jakiejś konkretnej listy, z pewnością potra-
fisz sobie wyobrazić i opisać rozmaite scenariusze wykorzystywania list w rzeczywistych
aplikacjach. Aby się upewnić co do poprawności różnych implementacji listowych, musisz
przeprowadzić kilka testów, przez które pozytywnie powinna przejść każda implementacja;
w szczególności powinieneś sprawdzić, czy działanie metod wymienionych w tabelach 3.1
Rozdział 3. • Listy 75
i 3.2 zgodne jest z ich opisem. Co więcej, w trakcie tworzenia zestawu testowego zrozu-
miesz być może lepiej kilka subtelności związanych z funkcjonowaniem list, a to z pewno-
ścią okaże się dla Ciebie pomocne w trakcie tworzenia wybranej implementacji listy.
import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.a1gori thms.i terati on.IteratorOutOfBoundsExcept i on;
import junit.framework.TestCase:
Jak widać, oprócz wspólnych danych testowych deklarowana jest tu abstrakcyjna metoda
createListO zwracająca konkretną instancję testowanej listy. Definiując konkretną klasę
testową na bazie klasy AbstractListTestCase, należy metodę tę zdefiniować tak, by two-
rzyła i zwracała instancję listy stosownie do konkretnej implementacji tej listy. I w ten oto
sposób zależność klasy testowej od konkretnej implementacji listy sprowadzona została do
jednej tylko metody.
assertEquałs(0, list.sizeO):
assertTruet1 i st.i sEmpty()):
76 Algorytmy. Od podstaw
list.insertCO. VALUE_A);
assertEquals(l. list.sizeO);
assertFalse(1 i st.i sEmpty());
assertSame(VALUE_A, list.get(O));
j
Przedmiotem kolejnego testu jest wstawianie nowego elementu między dwa inne elementy;
należy się upewnić, że „na prawo" od miejsca wstawiania elementu znajduje się już jakiś
element.
public void testlnsertBetweenElementsO {
List list - createListO; // utworzenie pustej listy
assertEquals(2. list.sizeO);
assertSame C VALUE_B. 1 i st. get(0)):
assertSame(VALUE_A. list.get(D);
}
i na jej koniec:
public void testlnsertAfterLastElementC) {
List list = createListO: // utworzenie pustej listy
list.insertCO. VALUE_A);
list.insertu. VALUE_B);
assertEquals(2. list.sizeO):
assertSameC VALUE_A, 1 i st.get(0)):
assertSameC VALUE_B. 1 i st.get(1)):
}
Ponieważ wstawianie elementu na ostatnią pozycję listy, czyli po prostu dołączanie ele-
mentu do listy, jest najczęstszym chyba przypadkiem wstawiania, dedykowaliśmy mu od-
rębną metodę add() jako wygodniejszą (mniej pisania) i czytelniejszą (nazwanie „po imie-
niu" wykonywanej operacji). Tę właśnie metodę przetestujemy jako ostatnią.
list.add(VALUE_A);
1 i st.add(VALUE_C):
list.add(VALUE_B):
assertEquals(3. list.sizeO):
assertSame(VALUE_A. list.get(O)):
assertSame(VALUE_C, list.get(l)):
assertSame(VALUE_B, 1ist.get(2));
}
Jak to działa?
Zadaniem metody testlnsertlntoEmptyListO jest sprawdzenie, czy w wyniku dodania
elementu długość listy zwiększy się o 1 i czy na pozycji o indeksie 0 rzeczywiście znajduje
się wstawiony element.
Jak łatwo zauważyć, element B został przesunięty na sąsiednią pozycję w prawo, w celu
zrobienia miejsca dla elementu C.
W ramach ostatniego testu, wykonywanego przez metodę testAddO, sprawdza się, czy
elementy dołączone do (pustej) listy za pomocą metody add() występują w tej liście we
właściwej kolejności, a więc czy metoda add() istotnie realizuje dołączanie elementów na
koniec listy. Ponieważ wywołanie add(...) jest czytelniejsze niż wywołanie insert (size(),
. . . ) , przeto sama metoda testAddO jest nieco prostsza od metody testlnsertAfterLast-
ElementO.
Nie można oczywiście nie sprawdzić reakcji zaimplementowanej listy na próbę pobrania
wartości nieistniejącego elementu, czyli elementu o indeksie ujemnym lub wykraczającym
poza listę. Próba taka powinna kończyć się wygenerowaniem wyjątku IndexOutOfBounds-
Exception, podobnie jak w przypadku metody insert():
public void testGetOutOfBoundsO {
List list = createListO: // utworzenie pustej listy
try {
list.get(-l);
failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
try {
list.get(O): // lista jest nadal pusta
failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
list.add(VALUE_A):
try {
list.get(l); // ostatni element ma indeks 0
failO: // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
1
try {
list.setOl. VALUE_A):
failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
try {
1ist.setCO. VALUE_B);
failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
list.insertCO. VALUE_C):
try {
list.set(l. VALUE_C);
failO: // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
80 Algorytmy. Od podstaw
Jak to działa?
Działanie metody set O jest podobne do modyfikowania wartości elementu tablicy. W meto-
dzie testSetO do pustej listy wstawiony zostaje pojedynczy element, po czym jego wartość
jest modyfikowana. Dwie asercje assertSameO testują wartość elementu przed i po mody-
fikacji.
list. addCVALUE_A);
assertEquals(l. list.sizeO);
assertSame(VALUE_A. 1 i st.get(0));
assertSame(VALUE_A. 1 i st.delete(0));
assertEquals(0. list.sizeO);
}
Usunięcie pierwszego elementu z listy zawierającej więcej niż jeden element powinno
spowodować przesunięcie pozostałych elementów o jedną pozycję „w lewo":
public void testDeleteFirstElementO {
List list - createListO; // utworzenie pustej listy
list.add(VALUE_A);
list. add(VALUE_B):
1 i st.add(VALUE_C):
assertEquals(3, list.sizeO):
assertSame(VALUE_A. 1 i st.get(0));
assertSame(VALUE_B. 1 i st.get(1));
assertSame(VALUE_C, list.get(2));
assertSame(VALUE_A. list.delete(O));
assertEquals(2, list.sizeO):
assertSame(VALUE_B. 1 i st.get(0));
assertSame(VALUE_C. list.get(l)):
Rozdział 3. • Listy 81
liSt.add(VALUE_A);
1 i st.add(VALUE_B):
list.add(VALUE_C);
assertEquals(3. list.sizeO);
assertSameC VALUE_A. 1 i st.get(0)):
a s sertSame(VALUE_B. 1 i st. get(1)):
assertSame(VALUE_C. list.get(2)):
assertSame(VALUE_C, list.delete(2)):
assertEquals(2. list.sizeO):
assertSame(VALUE_A. list.get(O)):
assertSame(VALUE_B. 1 i st.get(1)):
}
list.add(VALUE_A);
list.add(VALUE_C):
list.add(VALUE_B):
assertEquals(3. list.sizeO):
assertSame(VALUE_A, 1 i st.get(0)):
a s se rtSame(VALUE_C. 1 i s t. get(1)):
assertSame(VALUE_B. 1 i st.get(2));
assertSame(VALUE_C. list.delete(l)):
assertEquals(2. list.sizeO);
assertSame(VALUE_A. 1 i st.get(0)):
a s sertSame(VALUE_B. 11st.get(1)):
}
try {
list.delete(-l); // indeks ujemny
failO: // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
82 Algorytmy. Od podstaw
try {
list.delete(O); // indeks poza zakresem
failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
}
Oprócz możliwości usuwania elementu o wskazanym indeksie interfejs listowy oferuje także
możliwość usuwania elementu o określonej wartości. Jeżeli w liście znajduje się kilka ele-
mentów o wskazanej wartości, usunięty zostaje tylko ten z nich, który ma najmniejszy in-
deks — operacja dokonuje usunięcia pierwszego wystąpienia danej wartości.
public void testDeleteByValue() {
List list = createListO: // utworzenie pustej listy
list.add(VALUE_A):
list.add(VALUE_B):
list.add(VALUE_A);
assertEquals(3. list.sizeO);
assertSame C VALUE_A. 1 ist.get(O));
assertSame(VALUE_B. 1ist.get(l));
assertSame(VALUE_A, 1ist,get(2));
assertTrue(list.delete(VALUE_A));
assertEquals(2, list.sizeO):
assertSame(VALUE_B, list.get(O));
assertSame(VALUE_A, list.get(l));
assertTrue(1 i st.delete(VALUE_A));
assertEquals(l, list.sizeO);
assertSame(VALUE_B. list.get(O));
assertFa1se(1 i st.delete(VALUE_C)):
assertEquals(l, list.sizeO);
assertSame(VALUE_B. list.get(O)):
assertTrue(list.delete(VALUE_B));
assertEquals(0, list.sizeO);
1
Jak to działa?
Pierwsze cztery testy weryfikują poprawność usuwania elementu o wskazanym indeksie.
Usuwanie elementu stanowi odwrotność wstawiania, zatem w wyniku usunięcia elementu
długość listy zmniejsza się o 1, a (ewentualne) elementy następujące po usuwanym elemen-
cie przesuwane są o jedną pozycję „w lewo". Ponadto metoda deleteO usuwająca element
o wskazanym indeksie powinna zwrócić wartość tego elementu.
Rozdział 3. • Listy 83
try {
iterator.currentt):
failO: // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
list.add(VALUE_A):
list.add(VALUE_B):
list.add(VALUE C):
84 Algorytmy. Od podstaw
iterator.first();
assertFalse(iterator.i sDonet));
assertSame(VALUE_A, iterator.currentO);
iterator.next();
assertFalsetiterator.isDoneO);
assertSameC VALUE_B. i terator. currentO):
i terator. n e x t O ;
assertFal se( i terator .isDoneO):
assertSame(VALUE_C. iterator.currentO):
iterator.next();
assertTrue( Iterator. isDoneO);
try {
iterator.current():
fai 1(); // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
list.add(VALUE_A);
list.add(VALUE_B);
list.add(VALUE_C);
iterator. lastO:
assertFalse(iterator.isDone());
assertSame(VALUE_C. iterator.currentO);
iterator.previous();
assertFalse(iterator.isDonet));
assertSameC VALUE_B, i terator.current()):
iterator.previous():
assertFalse(iterator.isDone());
assertSame(VALUE_A, i terator. currentO);
iterator.previous();
assertTrue(i terator.i sDone());
try {
iterator.currentt);
f a i l O ; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
Rozdział 3. • Listy 85
Jak to działa?
W przypadku pustej listy jej iterator znajduje się permanentnie w stanie wyczerpanym —
jego metoda isDonet) konsekwentnie zwraca wartość true.
Analogicznie odbywa się przejście wstecz przez listę: po utworzeniu listy i uzyskaniu jej
iteratora wywołana zostaje metoda last(), po czym sukcesywnie wywoływana jest metoda
previous().
Po wyczerpaniu iteratora — gdy metoda isOone zwraca wartość true — próba uzyskania
dostępu do bieżącego elementu listy (za pomocą metody currentC)) powinna skończyć się
wystąpieniem wyjątku IteratorOutOfBoundsException.
Metoda indexOf() zwraca indeks (0, 1,2, ...) elementu stanowiącego pierwsze wystąpienie
wskazanej wartości w liście; jeśli w liście nie ma elementu o wskazanej wartości, metoda
zwraca-1.
public void testlndex0f() {
List list = createListO; // utworzenie pustej listy
list.add(VALUE_A):
list.add(VALUE_B);
list.add(VALUE_A);
assertEquals(0. 1 i st.indexOf(VALUE_A));
assertEquals(1, 1 i st.indexOf(VALUE_B));
assertEquals(-1. 1ist.i ndex0f(VALUE_C));
J
Metoda containsO zwraca wartość true, jeśli w liście występuje przynajmniej jeden ele-
ment o wskazanej wartości, i wartość false w przeciwnym przypadku.
public void testContainsO {
List list = createListO; // utworzenie pustej listy
list.add(VALUE_A);
list.add(VALUE_B);
list.add(VALUE_A);
Jak to działa?
W obydwu przypadkach do pustej listy wstawiane są trzy elementy, z których dwa mają
identyczną wartość.
W pierwszym teście sprawdza się, czy metoda indexOf() prawidłowo wskazuje pozycję
elementów o wartościach VALUE_A i VALUE_B; ponieważ wartość VALUE_A jest zdublowana,
metoda powinna wskazać jej pierwsze wystąpienie. Sprawdza się także, czy metoda prawi-
dłowo sygnalizuje nieobecność w liście wartości VALUE_C, zwracając wartość —1.
Drugi test sprawdza działanie metody containsO dla obecnych w liście wartości VALUE_A
i VALUE_B i nieobecnej wartości VALUE_C.
1 i St,add(VALUE_A);
liSt.add(VALUE_B);
1 ist.add(VALUE_C);
list.clearO;
Jak to działa?
Do pustej początkowo listy wstawione zostają trzy elementy, po czym sprawdza się, czy lista
nie jest pusta i czy jej rozmiar wynosi 3. Po wywołaniu metody clear() sprawdza się, czy
lista stałą się listą pustą i czy jej rozmiar wynosi obecnie 0.
Implementowanie list
Po zapoznaniu się z funkcjonalnością oferowaną przez struktury listowe możesz zająć się
ich różnymi implementacjami. Dzięki skonstruowanym zestawom testowym łatwo będzie
weryfikować na bieżąco poprawność dowolnej implementacji, jaką tylko chciałbyś stworzyć.
Rozdział 4. • Kolejki 87
Jak już kilkakrotnie wspominaliśmy, najczęściej implementuje się listy na bazie tablic i list
wiązanych. W pierwszym przypadku, jak sama nazwa wskazuje, elementy listy są fizycznie
elementami pewnej tablicy; w drugi przypadku elementy powiązane są ze sobą za pomocą
odwołań (wskaźników, łączników) — każdy element zawiera odwołanie do elementu na-
stępnego i — w niektórych implementacjach — elementu poprzedniego.
Usta tablicowa
Lista tablicowa, zgodnie z nazwą wykorzystuje tablicę do przechowywania swych elemen-
tów. Ponieważ odczyt i modyfikacja elementu tablicy o wskazanym indeksie to sprawy ba-
nalne, więc równie banalny jest problem dostępu do elementów wspomnianej listy. Tablicowa
implementacja listy jest więc najefektywniejsza w przypadku, gdy dostęp do elementów tej
listy odbywać się będzie głównie na podstawie indeksów lub w sposób sekwencyjny.
list.add(VALUE_A);
list.add(VALUE_A);
list.add(VALUE_A);
assertEquals(3. list.sizeO):
assertSame(VALUE_A. list.get(O)):
assertSameCVALUE_A. 1 i st.get(1)):
assertSame(VALUE_A. 1ist.get(2));
}
1ist.add(VALUE_A);
list.delete(O):
}
I
Jak to działa?
Znakomitą większość „filozofii testowej" implementacji list omówiliśmy już w związku
z klasą bazową AbstractListTestCase. Tym, co w klasie ArrayListTest nowe, jest metoda
createlistO tworząca i zwracająca instancję klasy ArrayList oraz kilka testów specyficz-
nych dla implementacji tablicowej.
Druga metoda, zgodnie z tym, co sugeruje jej nazwa, testuje poprawność usuwania ostat-
niego elementu z listy. Jak zobaczymy później, błędne zaprogramowanie tej operacji może
doprowadzić do wystąpienia wyjątku ArrayIndexOutOfBoundsException.
* domyślny konstruktor
*/
public ArrayListO {
this(DEFAULT_INITIAL_CAPACITY):
}
* Konstruktor.
* Parametr: początkowy rozmiar tablicy
*/
public ArrayListtint initialCapacity) {
assert initialCapacity > 0 : "Początkowy rozmiar tablicy musi być dodatni":
_initialCapacity = initialCapacity;
clearO;
}
public void clearO {
_array = new Object[_initialCapacity]:
_size = 0
}
}
Jak to działa?
Klasa ArrayList nie jest sama z siebie zbyt skomplikowana. Implementuje interfejs List
oraz definiuje kilka pól reprezentujących tablicę przechowującą elementy, rozmiar tej tablicy
i aktualny rozmiar listy. Nie należy mylić ze sobą obydwu rozmiarów, bowiem w tablicy
może istnieć pewien „zapas" miejsca, czyli niewykorzystane elementy, a więc rozmiar ta-
blicy może być większy od rozmiaru listy.
Klasa posiada dwa konstruktory. Drugi z nich tworzy tablicę o wskazanym (przez parametr
wywołania) rozmiarze początkowym, natomiast pierwszy istnieje tylko ze względu na wy-
godę użytkowania — j e g o działanie sprowadza się do wywołania drugiego z domyślnym
rozmiarem tablicy jako parametrem. Konstruktor sprawdza, czy początkowy rozmiar tworzonej
90 Algorytmy. Od podstaw
tablicy jest dodatni; teoretycznie można by dopuścić wartość zerową w tej roli, lecz takie
posunięcie wymusiłoby reorganizację tablicy już przy wstawianiu pierwszego elementu. Po-
czątkowy rozmiar utworzonej tablicy zapamiętywany jest w polu _initialCapacity, po
czym lista zostaje zainicjowana przez wywołanie metody clearO.
Jak to działa?
Metoda insertO rozpoczyna swe działanie od weryfikacji poprawności parametrów. Po
pierwsze, niedopuszczalne jest wstawianie wartości pustej (zgodnie z wcześniej uczynio-
nym założeniem), po drugie, próba wstawienia wartości na pozycję o indeksie ujemnym lub
indeksie zbyt dużym generuje wyjątek IndexOutOfBoundsException.
Następnie sprawdza się, czy w tablicy przechowującej elementy jest jeszcze miejsce na
chociaż jeden element. Jeśli tablica jest całkowicie zapełniona, konieczna jest jej zamiana
na większą, oczywiście w połączeniu ze skopiowaniem istniejących elementów. Czynności
te wykonywane są przez metodę ensureCapacityO.
Rozdział 3. • Listy 91
Metoda add() jest po prostu szczególnym przypadkiem metody insertO — element wsta-
wiany jest na koniec listy.
Jak to działa?
Metoda get O po zweryfikowaniu poprawności indeksu udostępnia wartość elementu znaj-
dującego się na pozycji identyfikowanej przez ten indeks. Metoda set O zastępuje ponadto
wskazany element nową wartością poprzednia wartość zwracana jest jako wynik.
Nietrudno spostrzec, że dostęp do elementów listy na podstawie ich indeksów jest najefek-
tywniejszy w przypadku implementacji tablicowej. Generalnie dostęp indeksowy do ele-
mentów listy zwykło się uważać za operację o złożoności 0{ 1), a implementacja tablicowa
gwarantuje wartość (9(1) tej złożoności w przeciętnym, najlepszym i najgorszym przypadku.
92 Algorytmy. Od podstaw
Pokrewną metodzie indexOf() jest metoda containsO badająca obecność w liście elementu
o danej wartości, bez związku z pozycją tego elementu.
public boolean contains(Object value) {
return 1ndex0f(value) != -1;
}
Jak to działa?
Metoda indexOf() dokonuje liniowego przeszukania listy w celu znalezienia elementu o wska-
zanej wartości. Sprawdzenie rozpoczyna się na pierwszym elemencie i kończy się w mo-
mencie znalezienia żądanego elementu albo wyczerpania listy.
Metoda containsO wykorzystuje metodę indexOf() w celu określenia pozycji elementu o wska-
zanej wartości. Jeśli metoda indexOf() zwróci rzeczywistą pozycję (nieujemną), funkcja
constains() zwraca wartość true, w przeciwnym wypadku zwraca wartość false.
Przeszukiwanie liniowe, mimo iż bardzo proste, nie podaje się dobrze skalowaniu dla du-
żych list. Wyobraźmy sobie listę zawierającą elementy Kot, Pies, Mysz i Zebra, a następnie
cztery kolejne operacje wyszukiwania każdej z tych wartości (najpierw Kot, potem Pies
itd.) i policzmy liczbę niezbędnych do tego porównań. Wartość Kot znaleziona zostanie już
po pierwszym porównaniu, znalezienie wartości Pies wymagać będzie dwóch porównań,
wartości Mysz — trzech, a wartości Zebra — czterech. Średnia liczba porównań przypadają-
cych na jedną wyszukiwaną wartość wynosić będzie * + " + + ^ = 2,5 .Ogólnie dla listy
4
, M . , l + 2 + ... + Af N(N +1) N + l .
N-elementowei będzie ona równa =— -= - = 0 ( ^ 1 . Jestt fto war-
V
N 2N 2 '
tość charakterystyczna dla najgorszego przypadku, przeszukiwanie liniowe nie może więc
być uważane za efektywną metodę wyszukiwania.
Rozdział 3. • Listy 93
Jak to działa?
Po sprawdzeniu poprawności indeksu metoda deleteO (w pierwszym wariancie) dokonuje
przesunięcia o jedną pozycję w lewo wszystkich elementów znajdujących się „na prawo"
od usuwanego elementu. Pole, w którym przechowywany jest rozmiar listy, jest następnie
zmniejszane o 1, a na zwolnioną skrajną pozycję z lewej strony wpisywana jest wartość pusta
(nuli).
Zwróćmy uwagę na sprawdzenie, czy usuwany element jest ostatnim elementem listy;
sprawdzenie to zapobiega wystąpieniu wyjątku ArrayIndexOutOfBoundsException, który
pojawiłby się przy wywołaniu metody arraycopy() w tej sytuacji (gdy usuwany jest ostatni
element listy, na prawo od niego nie ma już żadnych elementów, które trzeba byłoby prze-
suwać w lewo). Przed usunięciem elementu zapamiętywana jest jego wartość, która zwra-
cana jest jako wynik metody.
Zwróćmy uwagę na interesujący fakt, że zmiana rozmiaru tablicy następuje tylko w jednym
kierunku — rozmiar tablicy jest zwiększany, gdy okazuje się to konieczne, nie jest on jednak
nigdy zmniejszany. W przypadku „chwilowego" dodania do listy wielu elementów, a na-
stępnie ich usunięcia pojawi się wiele niewykorzystanych pozycji, czyli po prostu będzie
miało miejsce marnotrawienie pamięci. Aby zapobiec temu zjawisku, należałoby stworzyć
metodę „symetryczną" do metody ensureCapacity() powodującą zmniejszenie rozmiaru
tablicy do niezbędnego minimum w przypadku, gdy liczba niewykorzystanych pozycji
przekroczy (powiedzmy) 50% wszystkich pozycji. Dla uproszczenia zrezygnowaliśmy jednak
z tej możliwości, ponieważ nie ma ona wpływu na poprawność implementacji.
Co ciekawe, implementacja klasy ArrayLi st w JDK zachowuje się dokładnie tak samo.
W większości przypadków nie ma to znaczenia, warto jednak być świadomym tego
zjawiska.
Metoda deleteO w wariancie z wartością elementu jako parametrem rozpoczyna swe działanie
od przeliczenia tej wartości na indeks pierwszego wystąpienia elementu za pomocą funkcji
indexOf(). Gdy indeks ten okaże się nieujemny — czyli gdy element o żądanej wartości
zostaje znaleziony — wywoływany jest pierwszy wariant metody. Ponieważ efektywność
pierwszego wariantu metody jest rzędu 0(1), efektywność drugiego wariantu równoważna
jest efektywności metody index0f(), czyli 0(N).
Jak to działa?
Metoda iteratorO zwraca iterator stosowny do implementacji listy — w tym przypadku
jest to iterator tablicowy Arraylterator opisywany w rozdziale 2.
Rozdział 3. • Listy 95
Metoda i sEmpty O testuje „zerowość" rozmiaru listy. Mimo iż jest prosta w implementacji
— jak większość „uzupełniających" metod interfejsu List — może znacząco poprawić
czytelność kodu aplikacji.
Usta wiązana
W przeciwieństwie do monolitycznego charakteru listy tablicowej lista wiązana stanowi
łańcuch elementów połączonych (powiązanych) ze sobą wskaźnikami (łącznikami): zgod-
nie z rysunkiem 3.3 każdy element posiada wskaźnik na elementy poprzedni i następny.
Elementy listy
podwójnie wiązanej
posiadają łączniki
w obydwu kierunkach
Dokładniej rzecz biorąc, przedstawiona lista nazywana jest listą podwójnie wiązaną (do-
ubly linked) — każdy element posiada dwa łączniki — w przeciwieństwie do listy pojedyn-
czo wiązanej (singly linked), której elementy mają łączniki tylko w jednym kierunku. W li-
ście podwójnie wiązanej łatwiejsze jest nawigowanie w obydwu kierunkach, prostsze są też
operacje wstawiania i usuwania elementów.
Jak pamiętamy, w implementacji tablicowej wstawienie lub usunięcie elementu może wiązać
się z przesuwaniem (kopiowaniem) znacznej nawet porcji danych. Wstawianie i usuwanie
elementów w listach wiązanych wymaga jedynie aktualizacji kilku łączników, więc koszt
tych operacji jest w większości przypadków pomijalny. Dla dużych list czas dostępu do
elementu na wskazanej pozycji może być jednak poważnym problemem wydajnościowym.
Jak to działa?
Przede wszystkim zaimplementowano abstrakcyjną metodę createListO zwracającą utwo-
rzoną instancję listy wiązanej. Nie jest konieczne definiowanie żadnych dodatkowych metod
testowych, bowiem te zdefiniowane w klasie AbstractListTestCase okazują się wystarczające.
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.i terati on.IteratorOutOfBoundsExcepti on:
* Domyślny konstruktor
*/
public LinkedListO {
clearO:
}
}
Jak to działa?
Jak w przypadku każdej innej implementacji listy, tak i w tej najważniejsze jest zaimple-
mentowanie metod interfejsu List. Podobnie jak w przypadku implementacji tablicowej
aktualny rozmiar listy przechowywany będzie w prywatnym polu _size. Teoretycznie rzecz
biorąc, rozmiar ten można by ustalać dynamicznie, poprzez zliczanie elementów, lecz roz-
wiązanie takie naprawdę trudno byłoby nazwać skalowalnym!
ment JieadAndTail. Jeśli jest to początkowo trudne do zrozumienia, rychło okazuje się
oczywiste w praktyce — wystarczy tylko zaimplementować jakiś algorytm listowy w dwóch
wersjach: ze wspomnianym wartownikiem i bez niego.
_next - next:
}
public void attachBeforetElement next) {
assert next != nuli : "wskaźnik na element następny nie może być pusty":
setNext(next);
setPrevious(previous);
next.setPrevious(this);
previous,setNext(this):
}
public void detachO {
_previ ous.setNext(_next);
_next,setPrevious(_previous);
}
}
Jak to działa?
Znaczenie większości elementów klasy Element jest oczywiste. Oprócz pola reprezentującego
wartość (_va1ue), każdy element zawiera łączniki do elementu poprzedniego (_previous)
i następnego (_next) oraz proste metody służące do odczytywania i modyfikowania poszcze-
gólnych pól.
Wstawianie elementu do listy wykonywane jest przez metodę attachBefore(). Jak sugeruje
jej nazwa, element wstawiany jest bezpośrednio przed elementem wymienionym jako pa-
rametr wywołania. Modyfikowane są łączniki _next i _previous zarówno elementu wsta-
wianego, jak i tych elementów, z którymi będzie on sąsiadował po wstawieniu.
W podobny sposób odbywa się usuwanie elementu przez metodę detachO: wskaźniki są-
siadujących z nim elementów zostają odpowiednio uaktualnione.
Zwróćmy uwagę na ważny fakt, że dzięki użyciu wartownika (który sam jest egzemplarzem
klasy El ement) każdy element posiada obydwu sąsiadów — poprzedniego i następnego —
nie ma więc konieczności sprawdzania „niezerowości" łączników _next i _previous ani
uaktualniania „głowy" lub „ogona".
Podobnie jak w implementacji tablicowej, dołączenie elementu do listy (addO) jest rów-
noważne wstawieniu go na koniec listy;
public void add(Object value) {
insert(size(). value);
}
Jak to działa?
Metoda insertO zwyczajowo rozpoczyna swą pracę od zweryfikowania poprawności pa-
rametru. Następnie tworzony jest element o wartości równej parametrowi, znajdowany jest
punkt wstawiania, element zostaje wstawiony do listy, a pole przechowujące długość listy
zostaje zwiększone o 1.
Metoda getElementO jest intensywnie wykorzystywana przez wiele innych metod listy
wiązanej. Zwraca ona wartość elementu znajdującego się na podanej pozycji; znajdowanie
tego elementu odbywa się przez zwyczajne zliczanie elementów, począwszy od pierwszego
elementu listy. To prymitywne rozwiązanie sprawia, że metody insert() i deleteO, korzy-
stające z metody getElement(), wykonują się w średnim czasie 0(N).
Metodę getElementO można odrobinę usprawnić, opierając się na nieco żartobliwym spo-
strzeżeniu, że długość listy jest dwa razy większa niż odległość od jej środkowego ele-
mentu do któregoś z elementów skrajnych. Znając aktualną długość listy, możemy stwier-
dzić, czy poszukiwany element leży bliżej początku listy czy bliżej jej końca i zależnie od
tego rozpocząć zliczanie elementów od elementu (odpowiednio) pierwszego albo ostatniego.
Skraca to średnio o połowę czas wykonywania metody getElementO i choć nadal wyko-
nuje się ona w czasie 0(N), to jednak czas wykonywania podstawowych operacji listowych
może się wydatnie skrócić — średnio dwukrotnie. Implementację metody w tej nowej po-
staci pozostawiamy do wykonania Czytelnikowi jako ćwiczenie nr 5.
Jak to działa?
W obydwu przypadkach po zweryfikowaniu poprawności indeksu następuje znalezienie
elementu identyfikowanego przez ten indeks i odczytanie albo zmiana jego wartości.
int index = 0:
++index;
return -1;
Rozdział 3. • Listy 101
Jak to działa?
Podstawowa różnica w realizacji metody index0f() między obydwiema implementacjami
listy — tablicową i wiązaną — sprowadza się do sposobu przemieszczania się do sąsied-
niego elementu: w przypadku tablicy jest to zwykła inkrementacja indeksu, w przypadku li-
sty wiązanej jest to przejście do elementu następnego wskazywanego przez jeden z łączni-
ków. Wyszukiwanie kończy się w momencie natrafienia na element o szukanej wartości lub
na element-wartownik. W pierwszym przypadku zwracany jest indeks znalezionego ele-
mentu, w drugim — zwracana jest wartość - 1 .
Metoda contains() zwraca wartość true albo false na podstawie wartości zwróconej przez
metodę index0f().
Jak to działa?
Metoda deleteO w pierwszym wariancie po sprawdzeniu poprawności indeksu wyszukuje
(za pomocą metody getElementO) element identyfikowany przez ten indeks i wywołuje
metodę detachO tego elementu. Wartość usuwanego elementu zwracana jest jako wynik
metody.
Drugi wariant metody deleteO podobny jest do metody index0f(): poszukiwany jest ele-
ment o wskazanej wartości i w przypadku jego znalezienia wywoływana jest jego metoda
detachO, a metoda deleteO zwraca wartość true. Jeżeli żądany element nie zostanie zna-
leziony, metoda deleteO zwraca wartość false i na tym jej działanie się kończy. Po wy-
wołaniu metody detachO usuwanego elementu wartość pola przechowującego rozmiar listy
zmniejszana jest o 1.
I M l i f f l M f h Definiowanie iteratora
Nawigowanie po liście wiązanej ma zdecydowanie inny charakter niż nawigowanie po ta-
blicy — podobnie jak w przypadku wyszukiwania elementu jest ono kwestią poruszania się
(w dowolnym kierunku) zgodnie z łącznikami, aż do natrafienia na element-wartownik.
Właściwość ta odzwierciedlona jest w postaci wewnętrznej klasy ValueIterator:
private finał class ValueIterator implements Iterator {
private Element _current = _headAndTai1;
Instancja tej klasy zwracana jest przez (definiowaną w interfejsie List) metodę iterator():
public Iterator iteratorO {
return new ValueIteratorO;
}
Jak to działa?
Iterator ValueIterator różni się od opisywanego w rozdziale 2. iteratora tablicowego Ar-
raylterator pod dwoma względami. Po pierwsze, przejście do sąsiedniego elementu od-
bywa się z wykorzystaniem łączników, a nie przez inkrementację czy dekrementację indeksu.
Po drugie, warunkiem „wyczerpania" iteratora jest napotkanie elementu-wartownika, a nie
przekroczenie granicznej wartości indeksu.
Kompletowanie interfejsu
Do pełnej implementacji interfejsu List pozostało nam jeszcze zrealizowanie metod sizeO,
i sEmpty O i clearO.
Jak to działa?
Jak można było oczekiwać, metody sizeO i isEmptyO są identyczne jak w implementacji
tablicowej.
Usunięcie wszystkich elementów z listy, realizowane przez metodę clear(), dokonywane jest
przez „zapętlenie" wartownika, czyli takie ustawienie jego łączników _next i _previous, by
wskazywały na niego samego. W wyniku wstawienia do listy pierwszego elementu war-
townik ów stanie się dla niego elementem zarówno poprzednim, jak i następnym, i vice
versa — wstawiony element stanie się zarówno następnym, jak i poprzednim dla wartownika.
104 Algorytmy. Od podstaw
Podsumowanie
W niniejszym rozdziale omawialiśmy listy, które w tworzonych aplikacjach stanowić mogą
atrakcyjną alternatywę dla tablic.
Listy zachowują kolejność umieszczanych w nich elementów, ponadto same z siebie nie
wymagają, by wartości elementów były unikalne.
Mimo iż opisywane w rozdziale listy okazują się użyteczne w wielu sytuacjach, niekiedy
pojawia się potrzeba użycia struktur o nieco innym zachowaniu. W następnych dwóch roz-
działach omówimy dwie takie struktury stanowiące odmianę list: kolejki i stosy. Są one
strukturami typowymi dla rozwiązywania wielu specyficznych problemów obliczeniowych.
Ćwiczenia
1. Stwórz konstruktor klasy ArrayList zapełniający tworzoną listę elementami
zawartymi w tablicy podanej jako parametr wywołania.
2. Napisz uniwersalną metodę equals() prawdziwą dla dowolnej implementacji
interfejsu List.
3. Napisz metodę toString() prawdziwą dla dowolnej implementacji listy,
przekształcającą listę w łańcuch, w którym wartości elementów rozdzielone są
przecinkami, a całość zamknięta jest w nawias prostokątny. Przykładowo,
dla trójelementowej listy zawierającej elementy A, B i C wspomniany łańcuch
powinien mieć postać „[A. B. C]", zaś dla listy pustej—postać „[]".
4. Stwórz iterator uniwersalny dla dowolnej implementacji interfejsu List.
Jakie są efektywnościowe implikacje jego uniwersalności?
5. Zmodyfikuj implementację wyszukiwania w liście wiązanej elementu o wskazanym
indeksie w taki sposób, by w sytuacji, gdy element znajduje się „w drugiej połowie"
listy, zliczanie elementów prowadzone było od jej końca, a nie od początku.
6. Napisz uniwersalną metodę index0f() prawdziwą dla dowolnej implementacji
interfejsu Li st.
7. Zaimplementuj listę, która jest permanentnie pusta, a próba wstawienia do niej
elementu powoduje wystąpienie wyjątku UnsupportedOperationException.
4
Kolejki
Kolejki (ąueues) stanowią podstawę konstrukcji wielu algorytmów związanych z przy-
działem pracy, harmonogramami, zdarzeniami i przetwarzaniem komunikatów. Są też wy-
korzystywane jako środek komunikacji pomiędzy procesami działającymi na tym samym
komputerze lub różnych komputerach.
Czym są kolejki?
Kolejki do bankomatów, kolejki do kas w supermarketach, kolejki samochodów na granicy,
oczekiwanie w nieskończoność przy słuchawce telefonu („proszę czekać na zgłoszenie się
konsultanta"...) — oto przykłady „kolejek" w znaczeniu potocznym. Z obliczeniowego
punktu widzenia kolejka jest jednak listą której elementy przechowywane są w sposób
umożliwiający ich przetwarzanie w określonej kolejności. Tym, co odróżnia kolejką od
„zwykłej" listy, jest dostępność elementów: podczas gdy w liście możemy uzyskać dostęp
do dowolnego elementu (na przykład na podstawie jego indeksu), w kolejce dostępny jest
(w danej chwili) tylko jeden wyróżniony element, zwany jej czołem (head). To, który ele-
ment kolejki pełni tę wyróżnioną rolę, zależne jej od implementacji.
Rysunek 4.1.
Producenci ^^Producent^J ^^onŚum^t^)
i konsumenci
korzystający ze
Kolejka
wspólnej kolejki
> Konsument
^^Producent^^
Kolejki mogą być ograniczone lub nieograniczone. Liczba elementów znajdujących się
jednocześnie w kolejce ograniczonej nie może przewyższać pewnego limitu, co jest szcze-
gólnie użyteczne w przypadku ograniczonych zasobów, na przykład w routerze czy kolejce
pamięciowej. Kolejki nieograniczone mogą osiągać dowolne rozmiary, ograniczane jedynie
przez dostępną konfigurację sprzętową.
Operacje kolejkowe
W niniejszym rozdziale opisujemy kilka kolejek różniących się od siebie kolejnością pobie-
rania elementów. Wszystkie one jednak posiadają funkcjonalność dającą się opisać wspól-
nym interfejsem, którego operacje zaprezentowano w skrócie w tabeli 4.1.
Operacja Znaczenie
enqueue U m i e s z c z a element w kolejce. Rozmiar kolejki zostaje zwiększony o 1.
dequeue Pobiera element z czoła kolejki. Rozmiar kolejki zostaje zmniejszony o 1. Próba pobrania
elementu z pustej kolejki powoduje wystąpienie wyjątku EriptyQueueException.
elear U s u w a wszystkie elementy z kolejki — kolejka staje się pusta, jej rozmiar wynosi wówczas 0.
Size Zwraca rozmiar kolejki, czyli liczbę znajdujących się w niej aktualnie elementów.
isEmpty Zwraca true dla pustej kolejki (dla której size() równa się 0) i false w przeciwnym razie.
Rozdział 4. • Kolejki 107
Interfejs kolejki
Wymienione w tabeli 4.1 operacje przekładają się wprost na odpowiednie metody interfejsu
w języku Java:
package com.wrox.algorithms.queues:
Kolejka FIFO
W niniejszym podrozdziale opiszemy kolejkę typu FIFO. Rozpoczniemy od przedstawienia
jej podstawowych cech, po czym stworzymy niezbędny zestaw testowy dla interfejsu Queue,
by w końcu zaimplementować nieograniczoną kolejkę typu FIFO na bazie operacji listo-
wych (omawianych w poprzednim rozdziale).
108 Algorytmy. Od podstaw
Rysunek 4.2.
Dodanie elementu Kot Pies Jabłko
na koniec listy
za pomocą
metody enqueue() Banan
Rysunek 4.3.
Usunięcie elementu Pies Jabłko Banan
początkowego
listy za pomocą
metody dequeue() Kot
Oczywiście to, że czoło kolejki znajduje się na początku listy, jest tylko sprawą przyjętej
konwencji: równie dobrze moglibyśmy wstawiać nowe elementy na początek listy, a pobie-
rać je z jej końca (który byłby wówczas czołem kolejki). Utożsamienie czoła kolejki z po-
czątkiem listy jest jednak zdecydowanie bardziej naturalne i intuicyjne.
Skoro znamy już podstawową koncepcję kolejki FIFO, napiszmy dla niej kilka niezbędnych
testów.
import junit.framework.TestCase:
Rozdział 4. • Kolejki 109
_queue - createFifoQueue():
}
protected abstract Queue createFifoOueueO:
try {
_queue.dequeue();
failO: // zachowanie nieoczekiwane
} catch (EmptyQueueException e) {
// zachowanie oczekiwane
Drugi test, nieco dłuższy, lecz wciąż bardzo prosty, weryfikuje poprawność umieszczania
elementów w kolejce i ich pobierania z kolejki:
public void testEnqueueDequeue() {
_queue.enqueue(VALUE_B):
_queue.enqueue(VALUE_A):
_queue.enqueue(VALUE_C);
assertEquals(3. _queue.sizeO):
assertFalse(_queue.isEmptyO):
assertSame(VALUE_B. _queue.dequeue()):
assertEquals(2. _queue.SizeO):
assertFalse(_queue.i sEmpty());
assertSameCVALUE_A, _queue.dequeue()):
assertEquals(l, _queue.sizeO):
assertFalse(_queue.isEmpty());
assertSameCVALUE_C. _queue.dequeueO);
assertEquals(0, _queue.sizeO);
assertTrue(_queue.isEmpty()):
110 Algorytmy. Od podstaw
try {
_queue.dequeue();
failO; // zachowanie nieoczekiwane
} catch (EmptyQueueException e) {
// zachowanie oczekiwane
}
}
Ostatni test wykonywany jest w celu zapewnienia, że w wyniku wykonania metody clearO
kolejka staje się kolejką pustą:
public void testClearO {
_queue.enqueue(VALUE_A);
_queue.enqueue(VALUE_B):
_queue.enqueue(VALUE_C):
assertFalse(_queue.isEmpty()):
_queue.clear():
assertEquals(0. _queue.sizeO);
assertTrue(_queue.i sEmpty()):
try {
_queue.dequeue():
failO; // zachowanie nieoczekiwane
} catch (EmptyQueueException e) {
// zachowanie oczekiwane
}
}
__}
Mając już zdefiniowaną klasę abstrakcyjną klasę bazową dla testowania dowolnej kolejki
FIFO, możemy z niej wyprowadzić klasę dedykowaną kolejce FIFO w implementacji
listowej:
package com.wrox.algori thms.queues;
Jak to działa?
Abstrakcyjna klasa testowa AbstractFifoOueueTestCase predefiniuje kilka wartości ele-
mentów, które będą używane przez poszczególne metody testowe. Definiuje ona także lo-
kalną zmienną _queue, reprezentującą instancję testowanej kolejki; instancja ta tworzona
jest w treści metody setUpO wywoływanej przed wywołaniem każdej z metod testowych.
Metoda tworząca tę instancję — createFifoQueue() — j e s t metodą abstrakcyjną wyma-
gającą zaimplementowania w konkretnej klasie testowej.
Rozdział 4. • Kolejki 111
W trzecim teście do pustej kolejki dodawane są trzy elementy, po czym wywoływana jest
metoda clear() i następuje sprawdzenie, czy w wyniku tego wywołania kolejka stałą się na
powrót pusta.
*/
Lista wiązana idealnie nadaje się do implementowania kolejek ze względu na łatwość do-
dawania i usuwania elementów zarówno na początku, jak i na końcu. Lista w implementacji
tablicowej jest do tego celu znacznie mniej przydatna, bowiem dołączanie i usuwanie ele-
mentów na jej początku wiąże się z koniecznością kopiowania dużych porcji danych.
Dodanie elementu do kolejki realizowane jest jako dołączenie go na końcu odnośnej listy:
public void enqueue(Object va1ue) {
_list.add(value);
}
Analogicznie usunięcie elementu z kolejki równoważne jest z usunięciem pierwszego ele-
mentu tejże listy:
public Object dequeue() throws EmptyQueueException {
if (isEmptyO) {
throw new EmptyQueueExceptionO;
}
return _1ist.delete(O);
}
Oczywiście w sytuacji, gdy lista jest pusta, generowany jest wyjątek EmptyQueueException(),
zgodnie z wymogami interfejsu Queue.
Dociekliwy Czytelnik mógłby w tym miejscu zapytać, dlaczego w ogóle sprawdzać,
czy kolejka jest pusta, skoro próba wykonania operacji delete na pustej liście i tak
spowodowałaby wystąpienie wyjątku IndexOutOfBoundsException; wyjątek ten można
by zamknąć w ramach bloku try... catch i konwertować w sekcji catch do wyjątku
EmptyQueueException. Otóż, zgodnie z uwagą z poprzedniego rozdziału, wyjątek
IndexOutOfBoundsExcept i on, jako przejaw poważnego błędu w programowaniu, jest
wyjątkiem nieobsługiwalnym, więc nie da się go w bloku try... catch zniwelować.
Jego uniknięcie i wygenerowanie w zamian wyjątku Emp tyQueueExcep 11 on ma tę
zaletę, że wskazuje prawdziwą przyczynę błędu.
Kolejki blokujące
Kolejki wykorzystywane są bardzo często jako środek komunikacji międzyprocesowej lub
między wątkowej. Niestety, nasza kolejka ListFifoQueue nie nadaje się do tego celu, nie
jest bowiem w ogóle przystosowana do wykorzystywania przez wiele wątków równocze-
śnie. Brakuje jej niezbędnych mechanizmów synchronizacji dostępu do danych; synchroni-
zację taką zapewnia specjalny rodzaj kolejki, który — ze względu na jeden z aspektów jej
funkcjonowania — nazywamy kolejką blokującą.
Kolejka blokująca jest kolejką ograniczoną — liczba jej elementów nie może przekroczyć
pewnego ustalonego maksimum. Jedną z cech, która istotnie odróżnia zwykła kolejkę od
kolejki blokującej, jest zachowanie się tej ostatniej w sytuacjach „granicznych". Po pierwsze,
próba dodania elementu do kolejki całkowicie zapełnionej nie spowoduje niczego w ro-
dzaju wyjątku; zamiast tego proces (wątek) usiłujący dodać element zostaje zablokowany
— wykonywanie metody enqueue() zostaje zawieszone do czasu, aż w kolejce zwolni się
przynajmniej jedna pozycja wskutek wywołania metody dequeue() lub c l e a r ( ) przez inny
wątek.
Po drugie, próba pobrania elementu z pustej kolejki nie powoduje wystąpienia wyjątku
EmptyQueueException, lecz zawieszenie wykonania wątku (na metodzie dequeue()) do cza-
su, aż w kolejce znajdzie się choć jeden element. Stwarza to idealne warunki do wielowątko-
wej współpracy producentów i konsumentów: przy braku elementów do „skonsumowania"
konsumenci zostają zawieszeni aż do pojawienia się jakiegoś elementu, podobnie zawie-
szeni zostają producenci, jeśli w kolejce nie ma (chwilowo) miejsca na kolejne elementy.
1
Na tej samej zasadzie, zgodnie z którą kolejka jako taka zrealizowana została jako rezultat
narzucenia pewnych ograniczeń na ogólnie rozumianą listę — przyp. tłum.
114 Algorytmy. Od podstaw
Jak to „zrezygnujemy"?
* Konstruktor.
* parametry: odnośna kolejka, maksymalny rozmiar kolejki
*/
public BlockingQueue(Queue queue, int maxSize) {
assert queue != nuli : "nie określono kolejki":
assert maxSize > 0 : "maksymalny rozmiar musi być dodatni";
_queue = queue;
_maxSize = maxSize:
}
* Konstruktor.
* parametr: odnośna kolejka
Klasa posiada dwa konstruktory. Parametrami pierwszego z nich są: kolejka przechowująca
elementy oraz maksymalna dopuszczalna liczba elementów w kolejce; konstruktor ten
umożliwia utworzenie ograniczonej kolejki blokującej. Jedynym parametrem wywołania
drugiego konstruktora jest kolejka zawierająca elementy; liczba elementów w kolejce jest
praktycznie nieograniczona, a dokładniej — ograniczona jest maksymalną wartością typu
Integer.
J a k to działa?
Pierwszą rzeczą jaką wykonuje metoda enqueue() (i wszystkie inne metody), jest upew-
nienie się, że żaden inny wątek nie posiada aktualnie dostępu do kolejki. W języku Java
pewność tę zyskuje się za pomocą instrukcji synchronized powodującej nałożenie blokady
dostępu na wskazany obiekt, w tym przypadku muteks. Instrukcja synchronized specyfi-
kuje mianowicie sekcję krytyczną wewnątrz której przebywać może co najwyżej jeden
wątek; wszystkie inne wątki oczekiwać muszą na wejście do tej sekcji do momentu, aż
przebywający w niej wątek opuści ją lub wywoła metodę wait() synchronizującego muteksu2.
Dzięki temu każdy z wątków może operować na odnośnej kolejce w sposób wyłączny, bez
niebezpieczeństwa interferencji ze strony innych wątków.
Po uzyskaniu wyłącznego dostępu do kolejki metoda enqueue() musi się upewnić, że w kolej-
ce jest jeszcze miejsce na co najmniej jeden element (czyli że liczba elementów jest mniej-
sza od ustalonego maksimum). Jeśli kolejka jest całkowicie zapełniona, wywoływana jest
metoda waitForNotification(), wskutek czego bieżący wątek (w ramach którego wywoła-
no metodę enqueue()) zostaje zawieszony w oczekiwaniu na powiadomienie (sygnał) od
innego wątku. Efekt ten uzyskuje się przez wywołanie metody wait() muteksu. Wspomniane
powiadomienie wysyłane jest przez wątek wskutek wywołania przezeń metody notifyAllO
muteksu. Każdy wątek wywołuje tę metodę po udanej próbie dodania lub usunięcia ele-
mentu (m.in. wewnątrz metod enqeueue() i dequeue()).
2
Ten drugi warunek, notabene specyficzny dla języka Java, jest tym czynnikiem, który zapobiega
wystąpieniu zastoju (deadlock), jaki skłonni bylibyśmy w pierwszej chwili podejrzewać przyp. tłum.
116 Algorytmy. Od podstaw
J a k to działa?
Jeżeli okaże się, że odnośna kolejka jest pusta, metoda dequeue() wywołuje metodę wait-
ForNoti fication() w oczekiwaniu na sygnał od innego wątku, który (być może) wprowadzi
choć jeden element do kolejki. Po upewnieniu się, że w kolejce obecny jest choć jeden element,
metoda pobiera z kolejki element czołowy i wysyła powiadomienie (jnutex.notifyA110)
do wszystkich innych wątków.
J a k to działa?
Po uzyskaniu wyłącznego dostępu do kolejki metoda usuwa z niej wszystkie elementy i powia-
damia, w tym wszystkie inne wątki, wśród których mogą być również te zawieszone na
metodzie enqueue() z powodu braku miejsca w kolejce.
Rozdział 4. • Kolejki 117
}
public boolean isEmptyO {
synchronized (_mutex) {
return _queue.isEmptyO:
J a k to działa?
Skonstruujemy mianowicie symulator centrum zdalnej obsługi (cali center), do którego na-
pływają losowo generowane zgłoszenia oczekujące następnie w kolejce na obsługę przez
któregoś z konsultantów. Koncepcyjny schemat symulatora przedstawiono na rysunku 4.4.
Rysunek 4.4.
Ogólny projekt
symulatora centrum
zdalnej obsługi
Generator
zgłoszeń
118 Algorytmy. Od podstaw
Każdy agent funkcjonuje niezależnie, w ramach osobnego wątku, czyli tak samo jak w rze-
czywistym centrum obsługi. Kolejka zgłoszeń przystosowana jest specjalnie do obsługi wie-
lowątkowej. Ponieważ jest ona jedynym miejscem, w którym odbywa się rywalizacja wąt-
ków, żadne inne elementy aplikacji synchronizowania nie wymagają.
Opisywany symulator jest odrębną aplikacją manifestującą swe działanie poprzez wypisy-
wanie na konsolę komunikatów o zachodzących zgłoszeniach. Scenariusz przeprowadzanej
symulacji sterowany jest następującymi parametrami:
• liczbą konsultantów,
• liczbą zgłoszeń,
• maksymalnym czasem obsługi zgłoszenia,
• maksymalnym odstępem między kolejnymi zgłoszeniami.
Projekt aplikacji symulatora jest tak prosty, jak to tylko możliwe, i oczywiście wykorzy-
stuje on — opisaną wcześniej szczegółowo — kolejkę BlockingQueue. Pozostałe klasy apli-
kacji opisane zostaną szczegółowo w następnym punkcie.
* Konstruktor.
* parametry: identyfikator zgłoszenia, czas trwania obsługi zgłoszenia
*/
public CalKint id. int duration) {
assert duration >= 0 : "czas obsługi nie może być ujemny";
_id = id;
_duration = duration;
_startTime = System.currentTimeMillisO:
}
public String toStringO {
return "Zgłoszenie " + _id;
J a k to działa?
Klasa Cal 1 posiada tylko jedną metodę answer() symbolizującą obsługę zgłoszenia.
public void answerO {
System.out.printlntthis + " rozpoczęcie obsługi po oczekiwaniu "
+ (System.currentTimeMillisO - _startTime)
+ " milisekund");
120 Algorytmy. Od podstaw
try {
Thread.sleep(_duration);
} catch (InterruptedException e) {
// Ignoruj
* Konstruktor.
* parametry: identyfikator konsultanta, kolejka zgłoszeń
*/
public CustomerServiceAgent(int id. Oueue calls) {
assert calls != nuli : "Nie określono kolejki zgłoszeń"
J d = id;
_calls = calls:
}
public String toStringO {
return "Konsultant " + id:
}
Podobnie jak każde zgłoszenie, tak i każdy konsultant otrzymuje unikalny identyfikator.
Pozwala to na bieżące śledzenie, co robią poszczególni konsultanci. Każdy obiekt konsul-
tanta utrzymuje wskaźnik na kolejkę, w której magazynowane są zgłoszenia.
while (true) {
System.out.pri nt1n(thi s + " oczekuje");
if (cali — G0_H0ME) {
break:
}
cali .answerO;
}
System.out.println(this + " zakończył pracę");
}
J a k to działa?
* Konstruktor.
* parametr: liczba działających konsultantów
}
Praca centrum obsługi rozpoczyna się od jego otwarcia — j a k w rzeczywistym świecie;
symbolizuje to metoda open().
public void o p e n O {
thread.startt);
_threads.add(thread);
}
System.out.println("Centrum obsługi otwarte"):
)
Gdy tylko centrum obsługi zostanie otwarte, może rozpocząć przyjmowanie zgłoszeń.
Rozdział 4. • Kolejki 123
_calIs,enqueue(cal 1);
J a k to działa?
Pierwszą rzeczą jaką wykonuje klasa CallCenter, jest utworzenie kolejki przeznaczonej do
przechowywania nadchodzących zgłoszeń — j e s t to kolejka blokująca BlockingOueue. Po-
nieważ kolejka ta będzie obsługiwana wielowątkowo, a każdy z uruchomionych wątków
musi być prawidłowo zakończony, więc konieczne jest utrzymywanie listy przechowującej
obiekty reprezentujące te wątki. Lista taka tworzona jest w konstruktorze. Ostatnią czynno-
ścią wykony waną przez konstruktor jest zapamiętanie liczby funkcjonujących konsultantów
podanej jako parametr wywołania.
Tworzenie symulatora zbliża się ku końcowi, pozostały nam już tylko do zaimplementowa-
nia dwie klasy.
* Konstruktor.
* parametry:
* macierzyste centrum obsługi
* liczba zgłoszeń do wygenerowania
* maksymalny czas obsługi zgłoszenia
* maksymalny odstęp czasowy między kolejnymi zgłoszeniami
*/
3
Należy przy t y m zwrócić u w a g ę na w a ż n y fakt, że wspomniane zgłoszenia GO HOME s ^ o s t a t n i m i
kierowanymi do kolejki — p r z y p . tłum.
Rozdział 4. • Kolejki 125
_callCenter = callCenter;
jnumberOfCalls = numberOfCalls;
_maxCalIDuration - maxCallDuration;
maxCal1Interval = maxCallInterval;
}
Klasa Ca 11 Generator posiada tylko jedną metodę publiczną która — jak nietrudno się do-
myślić — dokonuje fizycznej symulacji generowania zgłoszeń.
public void generateCallsO {
for (int i = 0: i < _numberOfCalls; ++i) {
sleepO;
_callCenter.accept(new C a l K i . (int) (Math.randomO *
_maxCallDuration)));
}
}
private void sleepO {
try {
Thread.sleep((int) (Math.randomO * _maxCallInterval));
} catch (InterruptedException e) {
// Ignoruj
}
}
Jak to działa?
Treścią metody generateCallsO jest pętla, która generuje zgłoszenia w liczbie zadanej a priori,
w losowych odstępach czasu, przyporządkowując każdemu zgłoszeniu identyfikator będący
kolejną liczba naturalną oraz losowy czas obsługi, nieprzekraczający założonego a priori
limitu. Odstęp czasu między kolejnymi zgłoszeniami jest wartością losową nie większą niż
określony limit.
package com.wrox.algorithms.queues;
if (args.length != NUMBER_OF_ARGS) {
System.out.println(
"Wywołanie: CalIGenerator <konsultanci><zgłoszenia>"
+ "<czas obsługi> <odstęp czasowy>");
System.exit(-1);
}
Cali Center cali Center =
new Cal 1 Center(Integer.parselnttargs[NUMBER_OF_AGENTS_ARG])):
callCenter.openO;
try {
cal 1 Generator.generateCal 1 s():
} finally {
calICenter.cl oset);
}
}
}
Rozdział 4. • Kolejki 127
Jak to działa?
Interpreter języka Java rozpoczyna wykonywanie aplikacji od wywołania jej metody main(),
której argumentem jest tablica zawierająca parametry wywołania. Tablicę tę sprawdza się
pod kątem tego, czy zostały prawidłowo określone następujące parametry:
• liczba działających konsultantów,
• liczba generowanych zgłoszeń,
• maksymalny czasem obsługi zgłoszenia,
• maksymalny czas między generowaniem kolejnych zgłoszeń.
Uruchomienie aplikacji
Przed skompilowaniem i uruchomieniem aplikacji podsumujmy krótko jej komponenty.
Generator zgłoszeń (Ca 11 Generator) generuje zgłoszenia (Cali) o losowym rozkładzie cza-
sowym i losowym czasie obsługi. Zgłoszenia te trafiają do centrum obsługi (CallCenter),
gdzie umieszczone zostają \v kolejce (BlockingOueue). Z kolejki tej pobierane są przez jed-
nego lub kilku konsultantów (CustomerServi ceAgent), którzy przetwarzają konsekwentnie
zawartość kolejki aż do napotkania zgłoszenia końcowego G0_H0ME. Wszystko to połączone
jest w jedną całość przez klasę symulatora (Cal ICenterSimul ator).
Widzimy tu obsługę pięciu pierwszych i pięciu ostatnich zgłoszeń, niemniej jednak widoczne
są najważniejsze momenty: otwieranie centrum, rejestracja trzech konsultantów, a następnie
cykliczne kolejkowanie i obsługiwanie zgłoszeń. Zwróćmy uwagę, że po początkowo bły-
skawicznej obsłudze (1 milisekunda) czas oczekiwania zgłoszeń wydłużył się znacznie (20
sekund). Interesujące będzie zapewne powtórzenie symulacji dla większej liczby konsul-
tantów czy innego odstępu czasowego między kolejnymi zgłoszeniami.
Podsumowanie
Czytając niniejszy rozdział, miałeś okazję przekonać się, że
• kolejki podobne są do list, posiadają jednak prostszy interfejs i ściśle określoną
kolejność dodawania i pobierania (usuwania) elementów,
• kolejki mogą podlegać ograniczeniom co do maksymalnej liczby elementów
przechowywanych w tym samym czasie,
• idealną strukturą danych do implementacji kolejki jest lista wiązana,
• możliwe jest łatwe wyposażenie dowolnej kolejki w mechanizmy bezpiecznej
pracy wielowątkowej.
Ćwiczenia
1. Zaimplementuj wątkowo bezpieczną kolejkę niepowodującą oczekiwania.
W niektórych zastosowaniach wielowątkowych użyteczne są bowiem kolejki
nieblokujące.
Za pomocą stosów implementować można także struktury typu MRU (Most Recenlty Used
— „ostatnio używany") powszechnie wykorzystywane przez kompilatory języków progra-
mowania.
Czym są stosy?
Stos (stack) jest listą której elementy dostępne są tylko z jednego końca. Graficznie stos
przedstawia się najczęściej w pionowym układzie elementów, jak na rysunku 5.1. Ten jego
kraniec, do którego można dołączać elementy i z którego można je pobierać, nazywany jest
wierzchołkiem stosu (top of stack), zaś dostępny element stosu — elementem szczytowym
lub wierzchołkowym.
Stos może być także rozpatrywany jako odmiana kolejki, z której elementy pobierane są
w kolejności odwrotnej do porządku ich wstawiania — kolejka taka oznaczana jest akroni-
mem L1FO (Last-In, First-Out—„ostatni wchodzący jest pierwszym wychodzącym"), a je-
dynym dostępnym jej elementem jest ten, który przebywa w niej najkrócej.
Operacja Znaczenie
push Odłożenie elementu na stos. Rozmiar stosu zwiększa się o 1.
pop Zdjęcie i udostępnienie elementu z wierzchołka stosu. Rozmiar stosu zmniejsza się o 1.
Próba zdjęcia elementu z pustego stosu powoduje wystąpienie wyjątku EmptyStackException.
size Zwrócenie liczby elementów aktualnie znajdujących się na stosie.
peek Zwrócenie wartości s z c z y t o w e g o elementu stosu bez zdejmowania go ze stosu. Próba
wykonania tej operacji na pustym stosie powoduje wystąpienie wyjątku EmptyStackException.
isEmpty Sprawdzenie, czy stos jest pusty (tj. czy sizeC) równe jest 0).
elear Usunięcie wszystkich elementów ze stosu — stos staje się stosem pustym.
Operację dołączenia elementu do stosu nazywa się często odłożeniem lub położeniem na
stos (push). Odłożenie elementu D na stos z rysunku 5.1 przedstawione jest na rysunku 5.2.
Trzy pozostałe operacje — peek, isEmpty i elear — nie są niezbędne, można je bowiem
zaimplementować na bazie operacji push, pop i Size; ich istnienie zwiększa jednak znacznie
wygodę programowania.
Opisane w tabeli 5.1 operacje można łatwo przełożyć na stosowny interfejs odzwierciedla-
jący funkcjonalność dowolnego stosu.
package com.wrox.a1gori thms.stacks;
import com.wrox.algorithms.queues.Queue;
Interfejs ten jest stosunkowo prosty ze względu na niewielką liczbę metod. Zwróćmy uwagę,
że dwie z tych metod — pop() i peekO — mogą generować wyjątek EmptyStackException,
konieczne jest więc zadeklarowanie klasy tego wyjątku:
package com.wrox.algorithms.stacks;
Zauważmy także, iż interfejs Stack zdefiniowany został jako rozszerzenie interfejsu Queue.
Nic w tym dziwnego, skoro — j a k wspominaliśmy wcześniej — stos może być utożsamiany
z kolejką LIFO, a operacje push i pop mogą być traktowane jako synonimy operacji (odpo-
wiednio) enqueue i degueue.
Testy
Przejdźmy teraz do stworzenia zestawów testowych weryfikujących poprawność operacji
stosowych w dowolnej implementacji stosu. Zdefiniujemy odrębne metody testowe dla
metod pushO, popO, peekO i clearO; m e t o d y sizeO i isEmptyO nie w y m a g a j ą o d r ę b n e g o
testowania, odbywa się ono bowiem „przy okazji" testowania czterech wcześniej wymie-
nionych metod.
Mimo iż w niniejszym rozdziale opisujemy tylko jedną implementację stosu, wskazane jest
stworzenie testu uniwersalnego, niezależnego od konkretnej implementacji. Podobnie jak
w dwóch poprzednich rozdziałach zdefiniujemy więc abstrakcyjną klasę definiującą te aspekty
testu, które nie są związane z konkretną implementacją powierzając pozostałe aspekty
metodom abstrakcyjnym wymagającym zdefiniowania w konkretnej klasie testowej.
import junit.framework.TestCase:
Jak to działa?
Prostota interfejsu Stack przekłada się bezpośrednio na prostotę przypadków testowych.
Nie należy nigdy ulegać mylnemu przeświadczeniu, że testowanie rzeczy prostych jest wła-
ściwie niepotrzebne!
stack.push(VALUE_B);
stack.push(VALUE_A):
stack.push(VALUE_C):
assertEquals(3, stack.sizeO);
assertFalsetstack.isEmpty());
assertSame(VALUE_C, stack.pop()):
assertEquals(2, stack.sizeO);
assertFa1se(stack.i sEmpty());
assertSame(VALUE_A. stack.pop());
assertEquals(l. stack.sizeO);
assertFalset stack.i sEmpty()):
assertSame(VALUE_B. stack.pop());
assertEquals(0, stack.sizeO);
assertTrue(stack.isEmpty());
Powinniśmy ponadto się upewnić, że wywołanie metody pop O dla pustego stosu spowo-
duje wystąpienie wyjątku EmptyStackException.
public void testCantPopFromAnEmptyStackO {
Stack stack = createStack();
assertEquals(0, stack.sizeO);
assertTrue(stack.i sEmpty());
try {
stack.pop():
failO; // zachowanie nieoczekiwane
Rozdział 5. • Stosy 135
catch (EmptyStackException e) {
// zachowanie oczekiwane
Jak to działa?
W ramach metody testCantPopFromAnEmptyStack() na pustym początkowo stosie umiesz-
czane są kolejno trzy elementy A, B i C, po czym są one pojedynczo zdejmowane i następuje
sprawdzenie, czy udostępniane są we właściwej kolejności — C. B i A.
stack,push(VALUE_C);
stack.push(VALUE_A);
assertEquals(2. stack.sizeO);
Na stosie umieszczane są kolejno dwa elementy C i A, po czym sprawdza się, czy metoda
peekO zwraca element A, a rozmiar stosu nie ulega zmianie wskutek jej wywołania. Próba
wywołania metody peek () dla pustego stosu powinna spowodować wystąpienie wyjątku Empty-
StackException, podobnie jak w przypadku metody pop().
public void testCantPeekIntoAnEmptyStack() {
Stack stack = createStackO;
assertEquals(0, stack.sizeO):
assertTrue(stack.isEmpty());
try {
stack. peekO:
failO; // zachowanie nieoczekiwane
} catch (EmptyStackException e) {
// zachowanie oczekiwane
1
Peek to po angielsku „zerkać" — przyp. tłum.
136 Algorytmy. Od podstaw
Pozostaje jeszcze tylko sprawdzenie, czy metoda clearO istotnie „czyści" stos, usuwa-
jąc z niego wszystkie elementy.
public void testClearO {
Stack stack - createStackO;
stack.push(VALUE_A);
stack.push(VALUE_B):
Stack,push(VALUE_C);
assertFalse(stack.i sEmpty()):
stack.clearO;
assertTrue(stack.i sEmpty());
assertEquals(0, stack.sizeO);
try {
stack. p o p O ;
failO; // zachowanie nieoczekiwane
} catch (EmptyStackException e) {
// zachowanie oczekiwane
Jak to działa?
Po umieszczeniu trzech elementów na początkowo pustym stosie i zweryfikowaniu jego
rozmiaru następuje wywołanie metody clearO, która powinna uczynić stos pustym. To,
czy dzieje się tak istotnie, weryfikowane jest na trzy sposoby: przez wywołanie metody
isEmptyO, przez sprawdzenie wartości zwracanej przez metodę sizeO oraz przez próbę
zdjęcia kolejnego elementu.
Implementacja
Mimo iż implementację stosu można by wykonać wyłącznie na podstawie zachowania opi-
sanego w tabeli 5.1, w rzeczywistości można postąpić prościej i — podobnie jak w przy-
padku kolejki — oprzeć tę implementację na zaimplementowanej już liście, ta bowiem za-
wiera wszystkie elementy funkcjonalne niezbędne do prawidłowego działania stosu.
Implementacja metod stosu na bazie metod listy jest zadaniem niemal elementarnym,
wszystko bowiem, co da się wykonać za pomocą stosu, jest wykonalne także za pomocą listy.
Owa zbieżność implementacyjna nie powinna bynajmniej zacierać różnic koncepcyjnych
między listą a stosem — różnic tak bardzo istotnych przy projektowaniu oprogramowania.
Ponadto określenie „implementacja stosu na bazie listy" nie jest bynajmniej jednoznaczne,
implementację tę przeprowadzić można bowiem na kilka sposobów: rozbudowując istniejącą
implementację, tworząc klasę pochodną na bazie klasy istniejącej implementacji bądź defi-
niując całkowicie nową klasę.
Rozdział 5. • Stosy 137
package com.wrox.algorithms.stacks:
Jak to działa?
Jedyną zmienną klasy Li StStack jest zmienna Jlist reprezentująca instancję klasy, na ba-
zie której stos jest implementowany. Wybraliśmy listę wiązaną (Li nkedLi st) ze względu na
efektywność, z jaką przeprowadzane są operacje dołączania do niej elementu i usuwania
z niej elementu końcowego — czyli odpowiedniki operacji stosowych push i pop. Ponieważ
jednak — jak przed chwilą wyjaśnialiśmy — szczegóły implementacyjne wykorzystywanej
listy skrywane są wewnątrz jej instancji (dostępnej jedynie za pośrednictwem interfejsu
Li st — nie rozszerzamy bowiem klasy listy, lecz tworzymy „otoczkę" wokół wspomnianej
instancji), równie dobrze moglibyśmy użyć w tym celu np. listy tablicowej ArrayLi st.
Jak łatwo zauważyć, operacja push równoważna jest dołączeniu elementu na koniec wspo-
mnianej listy, zaś metoda enqueue() — dziedziczona po interfejsie List — deleguje wy-
wołanie bezpośrednio do metody push O.
Nie sprawdzamy przy tym, czy argument metody push() nie jest przypadkiem wartością pustą,
bowiem odpowiedzialność za zarządzanie wartościami pustymi spoczywa na implementacji
odnośnej listy.
return pop();
Metoda peekO udostępnia szczytowy element stosu — czyli ostatni element listy _list —
bez zdejmowania go ze stosu, czyli bez usuwania ze wspomnianej listy.
public Object peek( throws EmptyStackException {
Object result = p o p O ;
push(result);
return result:
}
Rozdział 5. • Stosy 139
Jak to działa?
Zwróćmy uwagę na kilka „nieoczywistych" szczegółów — tym razem musimy zachować
pewną dozę ostrożności. Po pierwsze, zauważmy, że przed próbą zdjęcia szczytowego ele-
mentu w ramach operacji pop() następuje sprawdzenie, czy element szczytowy w ogóle ist-
nieje; jeśli stos jest pusty, generowany jest wyjątek EmptyStackException. Tym, którzy
uważaliby to za zbytek ostrożności, argumentując, że próba usunięcia elementu końcowego
z pustej listy i tak spowodowałaby wyjątek EmptyQueueException, przypominamy, że wy-
jątek ten jest wyjątkiem nieobshigiwalnym i nie da się go po prostu skonwertować na wy-
jątek EmptyStackException. Innymi słowy, wyjątek EmptyQueueException nie jest tym, czego
życzyłby sobie programista, należy więc zapobiegać jego występowaniu.
Po drugie, metoda dequeue() deleguje swe wywołanie do metody popO, jednakże po uprzed-
nim sprawdzeniu, czy kolejka nie jest pusta (jeśli jest, generowany jest wyjątek EmptyQueu-
eException). Może się to wydawać zbytnią zapobiegliwością wszak wywołanie pop() dla
pustej listy spowodowałoby powstanie wyjątku EmptyStackException; zgoda, ale ten ostatni
jest wyjątkiem nieobsługiwalnym, nie da się go więc przechwycić i „skonwertować" na
wyjątek EmptyQeueExcetion.
Metoda peekO skonstruowana jest w sposób cokolwiek okrężny: element szczytowy naj-
pierw zdejmowany jest ze stosu i zwracany jako wynik metody, po czym ponownie odkła-
dany na stos. W ten oto sposób zyskujemy absolutną uniwersalność metody — uniezależ-
nienie jej działania nie tylko od konkretnej implementacji odnośnej listy, ale w ogóle od
konkretnej implementacji samego stosu.
Przykład—implementacja operacji
„Cofnij/Powtórz"
W literaturze trudno jest raczej o jakieś „konkretne" przykłady zastosowań stosu. Do naj-
częściej spotykanych należą słynne wieże z Hanoi, kalkulatory obliczające wartości wyra-
żeń w notacji polskiej odwrotnej czy odwracanie kolejności wyrazów w ciągu —jednym
słowem typowo akademickie przypadki mające raczej luźny związek z tworzonymi na co
dzień aplikacjami.
Wyobraźmy sobie aplikację utrzymującąjakąś listę — listę zakupów, listę wiadomości e-mail
czy w ogóle listę czegokolwiek. Interfejs użytkownika tej aplikacji wyświetla zawartość
wspomnianej listy, umożliwiając dodawanie i (być może) usuwanie elementów.
Użytkownicy aplikacji są — j a k wszyscy ludzie — omylni, jej nieocenioną zaletą może się
więc okazać możliwość anulowania skutków wykonywanych przez nich działań. W tym
celu, każdorazowo, gdy użytkownik przystępuje do wykonania pewnej czynności (na przy-
kład usunięcia pewnego elementu z listy), konieczne jest zapamiętywanie („nagrywanie")
pewnych informacji o stanie aplikacji, na przykład o zawartości wspomnianej listy (patrz
Memento [Gamma, 1995]), umożliwiające późniejsze odtworzenie tego stanu w razie po-
trzeby. Do zapamiętywania „migawek" stanu stos nadaje się idealnie: każda z migawek może
być traktowana jako pojedynczy element tego stosu, a powrót do jednego z wcześniejszych
stanów odbywać się może przez zdjęcie jednego lub więcej elementów i przywrócenie stanu
aplikacji na podstawie ostatnio zdjętej migawki.
Testowanie cofania/powtarzania
Zestaw testowy dla klasy realizującej cofanie i powtarzanie operacji na pojedynczej liście
skonstruujemy przez sformułowanie opisanych wcześniej wymagań w postaci przypadków
testowych.
Jeżeli do listy dodana zostanie jakaś wartość, powinna istnieć możliwość przywrócenia po-
przedniej postaci listy przez wywołanie odpowiedniej metody wycofującej undo():
public void testUndoInsert() {
UndoableList list - new UndoableListtnew ArrayListO);
list.insertCO. VALUE_A);
assertTruet 1 i st. canllndoO);
list.undoO;
assertEquals(0, list.sizeO);
assertFal set list. canllndoO):
}
public void testllndoAdd() {
UndoableList list = new UndoableListtnew ArrayListO);
assertFalse(list.canUndoO);
list.add(VALUE_A):
assertTruet1 i st.canUndot)):
list.undoO:
assertEquals(0, list.sizeO);
assertFalset 1ist.canUndot));
142 Algorytmy. Od podstaw
Analogiczna możliwość przywrócenia poprzedniego stanu listy powinna istnieć także w przy-
padku usunięcia z niej elementu na wskazanej pozycji:
public void testUndoDeleteByPositionC) {
UndoableList list = new UndoableList(
new ArrayList(new Object[]{VALUE_A. VALUE_B}));
assertSame(VALUE_B. 1 i st.delete(1));
assertTruet1 i st.canUndo()):
list.undoO;
assertEquals(2. list.sizeO):
assertSame C VALUE_A. 1 i st.get(0)):
assertSame(VALUE_B. 1 i st.get(1)):
assertFal se(l i st.canUndoO):
}
public void testUndoDeleteByValue() {
UndoableList list = new UndoableListt
new ArrayList(new Object[] {VALUE_A. VALUE_B}));
assertTruet1 i st.delete(VALUE_B));
assert TrueO ist ,canUndo());
list.undoO;
assertEquals(2. list.sizeO):
assertSame(VALUE_A. list.get(O));
a ssertSame(VALUE_B. 1 i st.get(1));
assertFal se(l i st.canUndoO);
}
Operacja set(), mimo iż nie usuwa ani nie dodaje elementów do listy, modyfikuje wartość
pewnego elementu. Metoda undoO powinna więc przywracać zmodyfikowanemu elemen-
towi poprzednią wartość.
public void testUndoSet() {
UndoableList list = new UndoableList(new ArrayList(new Object[] {VALUE_A})):
assertFalse(1 i st.canUndo()):
list.undot);
assertEquals(l. list.sizeO):
assertSame(VALUE_A. 1 i st.get(0)):
assertFalse(1ist.canUndo()):
}
Rozdział 5. • Stosy 143
assertFal s e d i st.canUndoO):
list.add(VALUE_A);
assertTruedist .canUndo());
list.clearO;
assertFal s e d i st.canUndoO); // skutków operacji elear nie można cofnąć
}
Jak dotąd testowaliśmy możliwość wycofywania tylko ostatnio wykonanej operacji. Gdyby
była to jedyna funkcja wykonywana przez metodę undoO, prawdopodobnie nie potrzebo-
walibyśmy stosu. Dzięki użyciu stosu mamy jednak możliwość wycofywania wielopozio-
mowego, należy jednak się upewnić, że anulowanie poszczególnych operacji odbywa się
we właściwej kolejności:
public void testUndoMultipleO {
UndoableList list = new UndoableList(new ArrayListO);
list.add(VALUE_A);
list.add(VALUE_B);
list.undoO:
assertEquals(l. list.sizeO);
assertSame(VALUE_A. 1 i st.get(0));
assertTrue(1ist.canUndot));
list.delete(O);
list.undoO;
assertEquals(l, list.sizeO);
assertSame(VALUE_A. list.get(O)):
assertTrue(list.canUndot));
list.undoO;
assertEquals(0, list.sizeO):
assertFalsed ist.canUndo()):
}
Jak to działa?
W pierwszym teście weryfikuje się oczywisty fakt, że w stosunku do nowo utworzonej listy
nie istnieją żadne operacje, które można by wycofać (bo żadnych operacji na liście jeszcze
nie wykonano). Następnie do listy dodawany jest (lub wstawiany) pojedynczy element; po-
nieważ klasa testowa stworzona została jako rozszerzenie klasy AbstractListTestCase,
144 Algorytmy. Od podstaw
Po stworzeniu stosownych testów dla klasy UndoableList zajmijmy się teraz implementacją
samej klasy.
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.1 i sts.Li st;
2
Autorzy pomijajątu milczeniem niezwykle istotny fakt, że sama operacja undo nie zalicza się
do operacji, które podlegają w y c o f y w a n i u . D w a kolejne wywołania metody undo() nie znoszą się
nawzajem, bo nie mają ze s o b ą żadnego związku. W y c o f y w a n i u podlegają jedynie operacje
dodawania i usuwania elementów A i B — p r z y p . tłum.
Rozdział 5. • Stosy 145
}
public boolean delete(Object value) {
int index = indexOf(value):
if (index == -1) {
return false;
}
delete(index);
return true;
}
Drugi z wariantów metody deleteO, usuwający element na podstawie jego wartości, roz-
poczyna swą pracę od określenia pozycji tego elementu i po jego znalezieniu deleguje wła-
ściwą operację usuwania do wariantu pierwszego; z tego względu konieczne jest prze-
chwytywanie odwołań jedynie do pierwszego wariantu.
W ten oto sposób przed wykonaniem jakiekolwiek operacji mającej wpływ na zawartość li-
sty _1 i st na stosie _undoStack odkładany jest element reprezentujący (uwaga) akcję niwe-
lującą tę operację. Wykonanie metody undoO sprowadza się więc do zdjęcia szczytowego
elementu ze wspomnianego stosu i wykonania reprezentowanej przez ten element akcji:
public void u n d o O throws EmptyStackException {
((UndoAction) _undoStack.pop()),execute():
}
Żadna w powyższych metod nie ma wpływu na zawartość listy, ich wywołania można więc
bezpośrednio (bez przechwytywania) delegować do odnośnej instancji listy (_1 i st).
Jak to działa?
Klasa UndoableList, oprócz instancji odnośnej listy (_łist), wykorzystuje także wewnętrz-
ny stos (_undoStack), którego elementy są instancjami interfejsu UndoAction reprezentują-
cego akcję anulującą skutki pewnej operacji modyfikującej stan listy; fizyczne wykonanie
tej akcji odbywa się w (zaimplementowanej) metodzie execute() tego interfejsu. Szczytowy
element stosu reprezentuje operację ostatnio wykonaną (najbardziej „zagnieżdżoną").
148 Algorytmy. Od podstaw
Metoda insertO klasy UndoableList wywołuje najpierw metodę insertO odnośnej listy
(_list), po czym rejestruje wstawienie elementu, umieszczając na stosie element będący
instancją klasy UndoInsertAction. Analogiczne rozwiązanie dla metody add(), jakkolwiek
możliwe, nie jest konieczne, łatwiej i oszczędniej jest bowiem delegować jej wywołanie do
metody insertO.
Wywołanie metody setO modyfikuje wartość elementu na wskazanej pozycji, więc odkła-
dana na stos instancja klasy UndoSetAction zapamiętuje zarówno tę pozycję, jak i oryginal-
ną wartość elementu (notabene zwracaną jako wynik metody set O). Zwróćmy przy tym
uwagę, iż we wszystkich trzech opisanych klasach — UndoInsertAction, UndoDeleteAction
i UndoSetActi on — metoda execute() wywołuje stosowną metodę odnośnej listy J i st, a nie
oryginalną metodę klasy UndoableList, w tym drugim przypadku następowałoby wówczas
nieuzasadnione odkładanie elementu na stos _undoStack3.
Ponieważ konkretna akcja wycofująca określona jest przez odkładaną na stosie instancję
klasy implementującej interfejs UndoAction, wykonanie akcji wycofującej sprowadza się do
zdjęcia ze stosu szczytowego elementu i wykonania jego metody execute() — co właśnie
jest (jedyną) czynnością wykonywaną przez metodę done().
I w efekcie, mimo wciąż rosnącego stosu, powtórne wywołanie metody undo() byłoby wycofaniem
wycofania operacji, czyli de facto jej przywróceniem. Cykliczne wycofywania i przywracanie
ostatnio wykonanej operacji byłoby jedyną opcją bez możliwości wycofywania wcześniejszych
operacji. — p r z y p . tłum.
Rozdział 5. • Stosy 149
Podsumowanie
Stosy, mimo iż są koncepcyjnie niezmiernie prostymi strukturami, odgrywają bardzo ważną
rolę w implementacji wielu algorytmów. Czytając niniejszy, rozdział mogłeś się przekonać, że:
• większość procesorów posiada w swym repertuarze instrukcje korzystania
z pamięci na sposób stosowy, a wiele języków programowania, w tym język Java,
implementowanych jest przy wydatnym udziale stosów,
• stos zawsze „rośnie" i „kurczy" się tylko z jednej strony, często jest więc
utożsamiany z kolejką typu LIFO,
• stos daje się bardzo łatwo zaimplementować w oparciu o listę, i to bez związku
z jej konkretną implementacją
• możliwości wykorzystywania stosów są wręcz nieograniczone; opisaliśmy
szczegółowo jedno z (arbitralnie) wybranych zastosowań — implementację
wielopoziomowego wycofywania operacji wykonywanych na liście.
Znaczenie sortowania
Przeglądając na co dzień książkę telefoniczną, spis teleadresowy itp., najczęściej nie uświa-
damiamy sobie, iż wykorzystujemy fakt ich posortowania. Szukając określonego nazwiska
czy firmy, po prostu próbujemy zgadnąć, w którym miejscu spisu możemy się go spodziewać,
152 Algorytmy. Od podstaw
i już po kilku takich próbach trafiamy na żądaną stronę, na której w ciągu kilku sekund od-
najdujemy to, czego szukamy (bądź stwierdzamy, że taki to a taki abonent w spisie tele-
adresowym nie figuruje). Wyobraźmy sobie teraz, że taki spis teleadresowy nie jest posor-
towany — abonenci występują w nim w kolejności przypadkowej 1 . Trzeba mieć dużo
dobrej woli i determinacji, by w ogóle podjąć się próby znalezienia w nim czegokolwiek
lub kogokolwiek — próby raczej z góry skazanej na niepowodzenie. Wiele zbiorów danych
byłoby zupełnie bezużytecznych, gdyby nie zostały posortowane według pewnego użytecz-
nego kryterium — dotyczy to nie tylko nazwisk czy nazw w spisie teleadresowym, lecz także
np. książek na półkach bibliotecznych. Jako że często zbiory te posortowane są a priori,
uważamy to za coś naturalnego i w ogóle o sortowaniu nie myślimy. W przypadku kom-
puterowego przetwarzania danych jest zupełnie inaczej: trudno oczekiwać, że użytkownik
aplikacji dostarczać będzie dane w kolejności posortowanej, a w każdym razie byłoby czymś
kuriozalnym wymagać od niego czegokolwiek, co znacznie efektywniej może za niego wy-
konać komputer. Sortowanie rozmaitych danych staje się więc nieodłączną czynnością
wielu aplikacji, a dobra znajomość różnych metod sortowania jest warunkiem wykonywa-
nia tej czynności w sposób efektywny.
Podstawy sortowania
Warunkiem wstępnym możliwości posortowania danych według pewnego kryterium jest
istnienie struktury zdolnej przechowywać elementy tych danych w określonej kolejności.
Jak widzieliśmy w rozdziale 3., to właśnie listy są strukturami zachowującymi (względną)
kolejność wstawianych elementów — interfejs List nie zawiera metod zmieniających tę
kolejność w sposób bezpośredni, zmiana pozycji elementu w liście nie jest możliwa bez jego
usunięcia i ponownego wstawienia.
Każdy algorytm sortowania listy elementów opiera się na dwóch fundamentalnych operacjach:
• porównywaniu elementów w celu stwierdzenia, czy ich względna kolejność
w liście zgodna jest z kryterium sortowania,
• przesuwaniu elementów na pozycje wyznaczane przez kryterium sortowania.
Zalety i wady danego algorytmu sortowania wynikają przede wszystkim z tego, ile wymienio-
nych wyżej operacji należy wykonać w celu posortowania określonego zbioru danych i jak
efektywna jest każda z tych operacji. Porównywanie elementów jest czynnością znacznie
mniej oczywistą, niż mogłoby się to w pierwszej chwili wydawać; w kolejnym podrozdziale
omawiamy wynikającą z niego koncepcję komparatora. Dokładny opis wykorzystywanych
operacji listowych — get(), set(), insertO i deleteO —znajdzie Czytelnik w rozdziale 3.
1
Albo w kolejności wyznaczonej przez kryterium nieznane użytkownikowi, na przykład
w kolejności zgłoszenia swych danych do wydawcy — przyp. tłum.
Rozdział 6. • Sortowanie — proste algorytmy 153
Komparatory
W języku Java i w większości innych języków programowania porównywanie wartości
dwóch zmiennych całkowitoliczbowych jest czynnością niewymagającą komentarza:
int x, y;
if (x < y) {
} "'
Podobnie ma się rzecz w przypadku podstawowych (primitive) typów danych, lecz w miarę
postępującej komplikacji struktur danych porównywanie ich elementów (obiektów) szybko
traci swą oczywistość. Wyobraźmy sobie na przykład listę plików znajdujących się w pewnym
katalogu: listę tę można (stosownie do różnych potrzeb) sortować według rozmaitych kryte-
riów — nazwy, rozszerzenia, rozmiaru, daty utworzenia, daty ostatniej modyfikacji itp.
Ważne jest więc oddzielenie kryterium sortowania elementów od samej czynności sorto-
wania. Mechanizm narzucający na listę obiektów pewne kryterium porządkujące nosi na-
zwę komparatora; dla danej listy (na przykład wspomnianej listy plików) określić można
kilka różnych komparatorów wyrażających rozmaite kryteria uporządkowania. Dzięki temu
sortowanie listy według różnych kryteriów — nazwy pliku, jego rozszerzenia, rozmiaru itp.
— da się zrealizować w sposób jednolity, za pomocą tego samego algorytmu sortowania.
Operacje komparatora
Komparator wykonuje tylko jedną operację — jest nią określenie względnej kolejności
dwóch porównywanych obiektów. Zależnie od tego, czy pierwszy z wymienionych obiek-
tów jest mniejszy, równy lub większy od drugiego (w sensie przyjętego kryterium porów-
nywania), wynikiem tej operacji jest (odpowiednio) wartość ujemna, zero lub wartość dodat-
nia. Jeśli typ któregokolwiek z wymienionych obiektów wyklucza możliwość porównywania
go z innymi obiektami, próba wykonania porównania powoduje wystąpienie wyjątku Class-
CastException.
154 Algorytmy. Od podstaw
Interfejs komparatora
Jedyna operacja komparatora przekłada się na jedyną metodę interfejsu Comparator, okre-
ślającą względną relację (porządek) między obiektami określonymi przez jej argumenty:
public interface Comparator (
public int compare(Object left, Object right):
_J
Argumenty metody nieprzypadkowo określone zostały jako „lewy" (left) i „prawy" {right),
mogą być bowiem utożsamiane z (odpowiednio) lewym i prawym argumentem operatora
porównania — metoda compareO w istocie stanowi uogólnienie operatora porównania dla
typów podstawowych języka. Zależnie od tego, czy obiekt left jest (w sensie przyjętego
kryterium porównywania) mniejszy od obiektu right, równy mu lub od niego większy,
metoda zwraca (odpowiednio) wartość ujemną (zwykle - 1 , choć niekoniecznie), zero (ko-
niecznie) lub wartość dodatnią (zwykle 1, choć niekoniecznie)
Komparator naturalny
W wielu typach danych, szczególnie typach podstawowych, jak łańcuchy czy liczby całko-
wite, zdefiniowane jest a priori uporządkowanie naturalne: 1 poprzedza 2, A poprzedza B, B
poprzedza C itp. Komparator narzucający taki właśnie naturalny porządek nazywamy (jak-
żeby inaczej) komparatorem naturalnym. Jak pokażemy za chwilę, dla danych określonego
typu zdefiniować można ich naturalne uporządkowanie, bazując na konwencjach obowią-
zujących w języku Java — umożliwiającym to środkiem jest interfejs Comparabie.
Interfejs Comparable
Interfejs Comparable posiada tylko jedną metodę:
public interface Comparable {
public int compareTo(Object other):
_ J
Jest więc jasne, że aby zdefiniować naturalny porządek w stosunku do wartości danej klasy,
należy zaimplementować w tej klasie interfejs Comparable. Przykładowo dla rekordów za-
wierające dane pracowników za uporządkowanie naturalne można przyjąć uporządkowanie
według nazwiska i imienia. Koncepcja ta stanowi uogólnienie operatorów <, = i > na złożone
typy danych — i faktycznie wiele powszechnie używanych klas z pakietu java.lang im-
plementuje interfejs Comparable.
J a k to działa?
}
Aby uniemożliwić samodzielne tworzenie kolejnych instancji, uczyniono konstruktor klasy
prywatnym, a więc niewidocznym na zewnątrz niej. Ponadto sama klasa oznaczona została
jako finalna (finał) w celu zapobieżenia jej (być może błędnemu) rozszerzaniu.
Po upewnieniu się, że lewy argument nie jest argumentem pustym, następuje jego rzutowa-
nie na instancję interfejsu Comparable i wywołanie metody compareToO tego interfejsu z pra-
wym obiektem jako argumentem.
Rzutując obiekt left na instancję interfejsu Comparable nie sprawdzamy, czy rzutowanie to
jest wykonalne, tzn. czy typ tego obiektu nie wyklucza wykonywania jego porównań z in-
nymi obiektami. Sprawdzenie takie jest niepotrzebne, bowiem interfejs Comparator dopusz-
cza występowanie wyjątku ClassCastException w sytuacji, gdy wymieniony wyżej warunek
nie jest spełniony.
J a k to dziata?
Komparator odwrotny
Zdarza się, że mając zdefiniowane pewne uporządkowanie wartości jakiegoś typu, chcieli-
byśmy posortować te wartości w kolejności dokładnie odwrotnej niż wynikająca z tego
uporządkowania, na przykład wypisać nazwy plików pewnego katalogu w kolejności od-
Rozdział 6. • Sortowanie — proste algorytmy 157
Po upewnieniu się, że lewy argument nie jest argumentem pustym, następuje wywołanie
jego metody compareTo() z prawym argumentem jako parametrem wywołania.
Mimo iż to doraźne rozwiązanie sprawdza się nieźle w tym szczególnym przypadku, jest
mało uniwersalne, bowiem dla bardziej złożonych struktur danych, jak lista plików czy lista
danych pracowniczych, wymaga definiowania dwóch komparatorów, po jednym dla każdego
„kierunku" uporządkowania.
Jeśli lewy argument metody compareO komparatora NaturalComparator jest mniejszy od jej
prawego argumentu, metoda ta powinna zwrócić wartość ujemną. Jeżeli jednak na bazie
komparatora NaturalComparator stworzymy komparator odwrotny, to jego metoda compare()
zwrócić musi w takiej sytuacji wartość dodatnią:
public void testLessThanBecomesGreaterThan() {
ReverseComparator comparator -
new ReverseComparator(NaturalComparator.INSTANCE);
Analogicznie, jeśli lewy argument komparatora odwrotnego jest większy niż prawy, metoda
compare() tego komparatora powinna zwrócić wartość ujemną:
public void testGreaterThanBecomesLessThanO {
ReverseComparator comparator =
new ReverseComparator(Natura 1 Comparator.INSTANCE):
W przypadku porównywania identycznych argumentów nic się nie zmienia, zarówno dla
komparatora oryginalnego, jak i odwrotnego metoda compareO powinna zwrócić 0:
public void testEqualsRemainsllnchanged() {
ReverseComparator comparator =
new ReverseComparator(NaturalComparator.INSTANCE);
J a k to działa?
J a k to działa?
Ponieważ nie interesują nas żadne atrybuty porównywanych obiektów, a jedynie wynik ich
porównania, opisane rozwiązanie jest w pełni uniwersalne: implementacja komparatora
ReverseComparator jest całkowicie niezależna od implementacji komparatora oryginalnego.
Skoro opisaliśmy już porównywanie elementów i jego implikacje w postaci komparatorów,
zajmijmy się teraz trzema różnymi algorytmami sortowania.
Sortowanie bąbelkowe
Zanim przejdziemy do sortowania bąbelkowego (bubblesort), musimy zdefiniować kilka
przypadków testowych dla różnych implementacji sortowania. Ponieważ każdy algorytm
sortowania testowany będzie pod kątem spełnienia tego samego kryterium — poprawnego
porządkowania sortowanych obiektów — zwyczajowo rozpoczniemy od zdefiniowania
klasy bazowej definiującej te aspekty testowania, które są wspólne dla wszystkich algoryt-
mów. Specyfikę konkretnych algorytmów powierzymy natomiast poszczególnym klasom
pochodnym. W ten sposób otrzymamy zestaw testowy, który łatwo będzie można przysto-
sowywać do dowolnych algorytmów sortowania — nawet takich, których być może jeszcze
dziś nie znamy.
Rysunek 6.1.
Rodzina
w przypadkowym
szyku
Porównując osobę drugą i trzecią, stwierdzamy, że ich względna kolejność jest prawidłowa.
Nie można tego powiedzieć o osobie czwartej i piątej, które muszą zamienić się miejscami,
doprowadzając do konfiguracji przedstawionej na rysunku 6.3.
160 A l g o r y t m y . Od podstaw
Rysunek 6.2.
Po pierwszej
zamianie miejsc
Rysunek 6.3.
Po wykonaniu
pierwszego kroku
— najstarsza osoba
znajduje się już
na swoim miejscu,
czyli na skrajnej
prawej pozycji
Choć senior rodu zajmuje już właściwą pozycję, kolejność, w jakiej ustawione są pozostałe
osoby, nadal pozostawia wiele do życzenia, mimo że wykonaliśmy już kilka porównań i prze-
stawień. Na razie musimy się pogodzić z tak nieefektywnym sortowaniem, w następnym
rozdziale poznamy jego efektywniejsze algorytmy.
Kolejny krok sortowania bąbelkowego przebiega identycznie jak pierwszy z tąjednak róż-
nicą, że skrajna prawa pozycja jest już „właściwie obsadzona" i możemy j ą pominąć w po-
równaniach. Ostatecznie krok ten doprowadza do tego, że druga co do starszeństwa osoba
trafia na przeznaczoną dla niej pozycję, jak na rysunku 6.4.
Rysunek 6.4.
Po wykonaniu
drugiego kroku
sortowania
dwie najstarsze
osoby stoją już na
swoich miejscach
U U
Wykonując jeszcze dwa kroki sortowania, z udziałem najpierw trzech, a potem dwóch osób,
otrzymamy ostatecznie pożądany układ widoczny na rysunku 6.5.
Rysunek 6.5.
Rodzina prawidłowo
ustawiona według
starszeństwa
A A
U U
Rozdział 6. • Sortowanie — proste algorytmy 161
Interfejs ListSorter
Jak wiele interfejsów interfejs ListSorter jest skrajnie prosty, zawiera bowiem tylko jedną
metodę, odpowiedzialną za posortowanie listy.
Metoda sortO otrzymuje listę jako argument wejściowy i zwraca jako wynik jej posorto-
waną wersję. Zależnie od implementacji lista wynikowa może być listą oryginalną w której
poprzestawiano elementy (sortowanie „w miejscu") lub listą nowo utworzoną zawierającą
kopie elementów pierwszej listy.
public interface ListSorter {
public List sort(List list);
}
_sortedList.add("dla");
_sortedList.add("dziejach");
_sortedList.add("krok"):
_sortedLi st.add("1ecz");
_sortedList.addOmały"):
_sortedList.add("olbrzymi");
_sortedLi st.add("programi sty"):
_sortedList.add("programowani a");
_sortedLi st.add("programowanie");
_sortedL i st.add("skok");
_sortedList.add("sterowane");
_sortedList,add("testami");
_sortedList.add("to"):
_sortedList.add("w");
}
assertEquals(result.sizeO. _sortedList,size());
while (!expected.isDoneO) {
assertEquals(expected.currentO, actual .currentO);
expected.next();
actual,next();
J a k to działa?
W pierwszym wierszu tworzona jest instancja klasy realizującej określony algorytm sorto-
wania; sortowanie odbywa się w naturalnej kolejności alfabetycznej łańcuchów — specyfi-
kowanym komparatorem jest bowiem komparator naturalny. W drugim wierszu wspomnia-
ny algorytm jest fizycznie realizowany w testowej liście _unsortedLi St. Po zakończeniu
sortowania jego wynik porównywany jest ze wzorcem: w stosunku do obydwu list — wy-
nikowej i wzorcowej — najpierw porównywane są ich rozmiary, a następnie przy użyciu
iteratorów porównywane są kolejne pary odpowiadających sobie elementów. Identyczność
obydwu list jest warunkiem, który spełniać musi dowolna implementacja algorytmu sorto-
wania, jeżeli w ogóle zamierzamy jej użyć do posortowania czegokolwiek!
J a k to działa?
* Konstruktor
* parametr: komparator określający uporządkowanie elementów
*/
public BubblesortListSorter(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
}
Teraz przed nami najważniejsze — implementacja samego algorytmu sortowania bąbelko-
wego. Jak pamiętamy, algorytm ten wymaga wielu przejść przez sortowaną listę; w wyniku
każdego przejścia kolejny element w pobliżu końca listy ustawiany jest na swej właściwej
pozycji. Wynika stąd, że dla N-elementowej listy po wykonaniu N-\ kroków na swych do-
celowych pozycjach znajdzie się N-\ końcowych elementów, a więc także i element po-
czątkowy, ergo — liczba kroków potrzebnych do posortowania dowolnej listy jest o jeden
mniejsza od liczby elementów zawartych w tej liście. Kod odpowiedzialny za powtarzanie
wspomnianych kroków nazwiemy pętlą zewnętrzną (outer loop).
for (int left = 0: left < (size - pass); ++left) { // pętla wewnętrzna
int right = left + 1;
if (_comparator.compare(list.get(left), list.get(right)) > 0) {
swapdist. left, right):
}
return list:
j
Jak przed chwilą wspomnieliśmy, jeśli kolejność sąsiadujących elementów nie jest zgodna
z kryterium określonym przez komparator, elementy te zamieniane są miejscami. Musimy
więc dysponować metodą zamieniającą miejscami wartości elementów o wskazanych in-
deksach.
private void swapdist list. int left, int right) {
Object temp = list.get(left):
list.setdeft. list.get(right));
list.set(right, temp);
Rysunek 6.6.
Pólka z losowo
ustawionymi
książkami
166 Algorytmy. Od podstaw
Sortowanie bąbelkowe raczej się do tego nie nada, bo przestawianie sąsiednich par byłoby
stratą czasu — zamiana miejscami dwóch książek trwa bowiem znacznie dłużej niż porów-
nanie ich wysokości. Zdecydowanie lepszą metodą na uzyskanie żądanego ułożenia książek
będzie sortowanie przez wybieranie, zwane także sortowaniem przez selekcję (selectionsort).
Znajdź na półce najwyższą książkę i zdejmij j ą z półki. Powinieneś ją ustawić jako pierw-
szą od lewej; zamiast przesuwać w prawo być może dużą liczbę innych książek, po prostu
zamień ją z t ą która aktualnie znajduje się najbardziej na lewo (nie unikniesz całkowicie
przesuwania książek, bowiem zapewne różnią się one od siebie grubością, ten szczegół nie
ma jednak znaczenia w sytuacji, gdy zamiast książek sortowane są elementy listy). Opisana
zamiana książek, zamiast przesuwania całej ich grupy, pozbawia sortowanie pewnej wła-
sności zwanej stabilnością, zajmiemy się nią w rozdziale 7., na razie jest ona bez znacze-
nia. Układ książek po pierwszej zamianie przedstawiony jest na rysunku 6.7.
Rysunek 6.7.
Najwyższa książka
znajduje się
już na skrajnej
lewej pozycji
Jak łatwo się domyślić, w kolejnym kroku należy odszukać najwyższą z pozostałych ksią-
żek i zamienić j ą miejscami z tą, która aktualnie zajmuje pozycję drugą od lewej. Efekt tej
zamiany przedstawiony jest na rysunku 6.8.
Rysunek 6.8.
Druga co do
wysokości książka
znajduje się na
właściwej pozycji
Rozdział 6. • Sortowanie — proste algorytmy 167
Rysunek 6.9.
Kolejne pozycje
od lewej strony
zapełniane
są właściwymi
książkami
Ł
<£71
Ł
168 Algorytmy. Od podstaw
Oczywiście może się tak zdarzyć, że w którymś stadium sortowania książka będzie już
znajdować się na swej pozycji docelowej i żadne przestawianie nie będzie wówczas ko-
nieczne. Tak czy inaczej nie zmienia to podstawowej własności sortowania przez wybór —
tej mianowicie, że grupa elementów jeszcze nieposortowanych, początkowo obejmująca
wszystkie elementy, zmniejsza się systematyczne, rozrasta się natomiast grupa elementów
już posortowanych, początkowo pusta, a w końcu obejmująca wszystkie elementy. Co wię-
cej, wybierana książka od razu trafia na swą docelową pozycję, w przeciwieństwie do sor-
towania bąbelkowego, gdzie elementy stopniowo przesuwane są małymi krokami.
Znaczna część kodu testowego stworzonego przy okazji sortowania bąbelkowego może być
wykorzystana przy okazji sortowania przez wybieranie. Rozpoczniemy od stworzenia ze-
stawu testowego, po czym zajmiemy się samym algorytmem sortowania.
*/
public class SelectionSortlistSorterTest extends AbstractListSorterTest {
protected ListSorter createListSorter(Comparator comparator) {
return new SelectionSortListSorter(comparator):
J a k to działa?
Klasa testowa Sel ecti onSortLi stSorterTest dziedziczy po swej klasie bazowej Abstrac-
tLi stSorterTest wszystkie dane testowe i całą logikę testową. Jedynym elementem specy-
ficznym dla sortowania przez wybieranie jest zaimplementowana metoda createListSor-
ter(), dostarczająca instancji klasy realizującej algorytm sortowania.
* Konstruktor
* parametr: komparator określający uporządkowanie elementów
*/
Rozdział 6. • Sortowanie — proste algorytmy 169
J a k to działa?
Po drugie, w pętli wewnętrznej nie dokonuje się żadnych przestawień, a jedynie wyszukuje
(w grupie nieposortowanych jeszcze elementów) element o najmniejszej wartości. Co prawda
jest to sytuacja odwrotna do przykładu z książkami, gdzie sortowanie następowało według
malejącej wysokości, lecz dla algorytmu jako takiego nie ma to większego znaczenia —
w razie potrzeby zawsze można użyć komparatora odwrotnego.
• w ramach danego koloru as (A), 2, 3, ..., 10, walet (J), dama (Q) i król (K).
Rysunek 6.10.
„Ręka karciana"
— pięć nieznanych
jeszcze kart
Odkrywamy pierwszą kartę; nie ma nic prostszego jak „posortowanie" jednego elementu,
więc po prostu odkładamy kartę do grupy elementów posortowanych. W sytuacji na rysunku
6.11 odkrytą kartąjest siódemka karo.
Rysunek 6.11.
7
Pojedyncza karta •
jest zawsze
„posortowana"
Niech druga odkryta karta będzie waletem pik (rysunek 6.12). Według przyjętego kryte-
rium poprzedza ona siódemkę karo, wstawiamy ją więc na pierwszą pozycję.
Trzecia karta okazuje się być asem trefl i według przyjętej kolejności plasuje się między
dwiema już odkrytymi (rysunek 6.13).
Rozdział 6. • Sortowanie — proste algorytmy 171
Rysunek 6.12. J 7
Druga karta
zostaje wstawiona
4 •
przed pierwszą
Rysunek 6.13. J A 7
Trzecia karta
zostaje wstawiona * 4 •
między dwie
pozostałe
Jak więc widzimy, sortowanie przez wstawianie polega na podziale sortowanych elemen-
tów na dwie grupy: posortowaną (początkowo pustą) i nieposortowaną (obejmującą po-
czątkowo wszystkie elementy). W każdym z kolejnych kroków z grupy nieposortowanej
brany jest kolejny element i wstawiany na odpowiednie miejsce do grupy posortowanej —
tak by pozostała ona nadal posortowana. W ten sposób grupa nieposortowana stopniowo się
zmniejsza, a grupa posortowana powiększa się, by w końcu objąć wszystkie elementy —
jak na rysunku 6.14, po odkryciu wszystkich pięciu kart.
Rysunek 6.14. J A 7 Q
Odkrycie
przedostatniej * * • V
i ostatniej karty
J A 9 7 Q
4 * A •
J a k to działa?
* Konstruktor
* parametr: komparator określający uporządkowanie elementów
*/
Iterator it = list.iteratorO;
for (it.firstO; lit.isDoneO; it.next()) { // pętla zewnętrzna
int slot = result.sizeO;
while (slot > 0) { // pętla wewnętrzna
if (_comparator.compare(it.currentO. result.get(slot - 1)) >= 0) {
break:
}
--slot;
}
result. insert (slot, it.currentO);
}
return result;
}
J a k to działa?
W zewnętrznej pętli for za pomocą iteratora pobierane są kolejne elementy listy wejścio-
wej; użycie iteratora jest rozwiązaniem bardziej uniwersalnym niż bezpośredni dostęp do
elementów na podstawie ich indeksów. W pętli wewnętrznej — która nie jest pętlą for, lecz
pętlą while — w (stopniowo zapełnianej) liście wynikowej poszukiwana jest pozycja, na
którą należy wstawić element pobrany z listy wejściowej. W przeciwieństwie do listy wej-
ściowej, której implementacja jest bez znaczenia, lista wynikowa jest listą wiązaną LinkedLi St,
a dostęp do jej elementów odbywa się w sposób bezpośredni. Wybraliśmy listę wiązaną ze
względu na efektywność, z jaką można wstawiać do niej elementy. Lista wynikowa pozostaje
cały czas posortowana, a po wstawieniu do niej ostatniego elementu sortowanie się kończy.
Stabilność sortowania
Niektóre algorytmy sortowania cechują się interesującą własnością zwaną stabilnością. Aby
zrozumieć jej istotę, rozpatrzmy listę pracowników posortowaną według imion (tabela 6.1).
Załóżmy teraz, że chcemy posortować powyższą listę według nazwisk. Ponieważ niektóre
nazwiska się powtarzają (Smith i Barnes), można to zrobić na kilka sposobów i ostateczna
kolejność może być różna dla różnych algorytmów sortowania. Ponieważ pozycje o jedna-
kowych nazwiskach występować mogą w dowolnej kolejności względem siebie, więc w ra-
mach tego samego nazwiska posortowanie według imion może zostać zachowane lub nie.
Innymi słowy, algorytm sortowania może, lecz nie musi zachowywać istniejącą względną
kolejność pozycji osób o tym samym nazwisku. Te algorytmy, które kolejność tę zachowują
nazywamy algorytmami stabilnymi. Efekt posortowania listy z tabeli 6.1 w sposób stabilny
przedstawiony jest w tabeli 6.2.
174 A l g o r y t m y . Od p o d s t a w
Imię Nazwisko
Albert Smith
Brian Jackson
David Barnes
John Smith
John Wilson
Mary Smith
Tom Barnes
Vince De Marco
Walter Ciarkę
Imię Nazwisko
David Barnes
Tom Barnes
Walter Ciarkę
Vince De Marco
Brian Jackson
Albert Smith
John Smith
Mary Smith
John Wilson
P r z y k ł a d n i e s t a b i l n e g o p o s o r t o w a n i a w s p o m n i a n e j listy w e d ł u g n a z w i s k p r z e d s t a w i o n y j e s t
w tabeli 6.3 — w r a m a c h n a z w i s k a Smith nie z o s t a ł a z a c h o w a n a o r y g i n a l n a k o l e j n o ś ć
imion.
Tabela 6.3. Lista z tabeli 6.1 posortowana według nazwisk w sposób niestabilny
Imię Nazwisko
David Barnes
Tom Barnes
Walter Ciarkę
Vince De Marco
Brian Jackson
Albert Smith
Mary Smith
John Smith
John Wilson
Rozdział 6. • Sortowanie — proste algorytmy 175
Spośród trzech opisanych dotąd algorytmów sortowania algorytmem stabilnym jest sorto-
wanie bąbelkowe. To, czy stabilne jest sortowanie przez wstawianie, zależne jest od kolej-
ności pobierania elementów z listy wejściowej i sposobu ich wstawiania do listy wynikowej;
prezentowana przez nas implementacja jest implementacją stabilną. Podobnie stabilność
sortowania przez wybieranie zależy od szczegółów jego implementacji. Omawiane w na-
stępnym rozdziale bardziej zaawansowane algorytmy sortowania, choć cechują się znaczą-
co lepszą efektywnością, nie są algorytmami stabilnymi i jest to jedna z ich wad w porów-
naniu z prostymi algorytmami sortowania, o czym trzeba pamiętać przy tworzeniu aplikacji
o konkretnych wymaganiach.
CallCountingListComparator
Ponieważ za wszystkie porównania, jakie wykonywane są w ramach algorytmu sortowania,
odpowiedzialny jest komparator, a dokładniej — jego metoda compareO, najprostszym sposo-
bem zliczania porównań wydaje się przechwycenie wywołania tej metody, czyli wzbogace-
nie jej o fragment kodu dokonujący zliczania wszystkich wywołań. Można by też posunąć
się jeszcze dalej i wyposażyć w taki mechanizm zliczania w jakąś klasę bazową z której
wyprowadzane byłby wszystkie „zliczające" komparatory. Wymagałoby to jednak ponow-
nego zaimplementowania od podstaw tych komparatorów, które chcemy uczynić zliczają-
cymi. Chcąc wykorzystać w jak największym stopniu istniejący kod, postąpimy więc ina-
czej i funkcję zliczającą komparatora zrealizujemy za pomocą jego otoczki („dekoratora"),
podobnie jak czyniliśmy to w przypadku odwracania uporządkowania za pomocą klasy
ReverseComparator.
* Konstruktor.
* Parametr: oryginalny komparator
*/
public CallCountingComparator(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
_callCount = 0;
}
public int compare(Object left. Object right) throws ClassCastException {
++_callCount;
return _comparator.compare(left. right);
}
public int getCallCountO {
return callCount;
Podobnie jak komparator odwrotny ReverseComparator, tak i komparator zliczający Cal lCo-
untingComparator definiowany jest na bazie dowolnego komparatora przekazywanego jako
parametr wywołania konstruktora. Wywołanie metody compare() komparatora zliczającego
jest rejestrowane poprzez zwiększenie wartości zmiennej callCount, po czym delegowane
jest do metody compareO komparatora oryginalnego. Wartość zmiennej _callCount, równa
liczbie dokonanych wywołań, dostępna jest za pośrednictwem metody getCal lCountC).
ListSorterCallCountingTest
Mimo iż tym razem nie zamierzamy testować poprawności zachowania się kodu, lecz mie-
rzyć liczbę porównań wykonywanych przez algorytmy sortowania, skorzystamy z biblioteki
JUnit, bowiem podobnie jak w przypadku testów modułów będziemy musieli wykonać kil-
ka dyskretnych scenariuszy dla każdego algorytmu poprzedzonych pewnymi czynnościami
przygotowawczymi (setup). Zdefiniujemy więc klasę testową, a w ramach niej stałą okre-
ślającą rozmiar sortowanej listy, trzy listy o charakterystykach wcześniej wymienionych
(posortowaną, posortowaną odwrotnie i nieposortowaną) oraz instancję komparatora zli-
czającego.
package com.wrox.algorithms.sorting;
// lista posortowana
}
for (int i = 1; i < TEST_SIZE; ++i) {
_randomArrayList.add(new Integer((int)(TEST_SIZE * Math.randomO)));
}
}
By zaobserwować działanie każdego algorytmów dla listy posortowanej odwrotnie, należy
utworzyć kolejno trzy odpowiednie implementacje interfejsu ListSorter i użyć każdej
z nich do posortowania listy _reverseArrayList utworzonej w ramach metody setUpO.
Wnikliwy Czytelnik mógłby w tym miejscu stwierdzić, że po pierwszym posortowaniu listy
_reverseArrayList dalsze sortowania nie mają sensu, bo lista ta przestanie być lista posor-
towaną odwrotnie. Otóż jest zupełnie inaczej: lista _reverseArrayList tworzona jest na
nowo przed każdym z sortowań — przed wywołaniem każdej z metod testowych wywoły-
wana jest metoda setUpO i to jest główny powód, dla którego użyliśmy biblioteki JUnit w za-
stosowaniu niemającym nic wspólnego z weryfikacją poprawności kodu. Dzięki temu wszyst-
kie trzy metody testowe działają niezależnie od siebie.
Jak widać, wszystkie trzy algorytmy sortowania wykonały taką samą liczbę porównań dla
listy — wygląda na to, że jest ona , jednakowo trudnym" przypadkiem dla każdego z nich.
Nie należy jednak przyjmować tego jako reguły, a w przypadku danych o charakterze wy-
łącznie empirycznym (jak tutaj) należy wystrzegać się formułowania pochopnych, być mo-
że z gruntu fałszywych wniosków, choć oczywiście nie można nie zastanawiać się nad
przyczynami obserwowanych faktów.
Wyniki analogicznej analizy dla listy już posortowanej wyglądają zgoła odmiennie:
testDirectCaseBubblesort: 498501 wywołań
testDirectCaseSelectionSort: 498501 wywołań
testDirectCaselnsertionSort: 998 wywołań
Tak duża wrażliwość sortowania przez wstawianie na fakt posortowania listy wejściowej
nie powinna być zaskoczeniem. Jej przyczynę wyjaśnialiśmy wcześniej — jest nią szczególny
sposób przeszukiwania listy wynikowej, począwszy od jej końca, nie początku.
Na koniec pozostaje porównanie zachowania się algorytmów sortowania dla typowej, nie-
uporządkowanej listy:
testRandomCaseBubblesort: 498501 wywołań
testRandomCaseSelectionSort: 498501 wywołań
testRandomCaselnsertionSort: 262095 wywołań
180 Algorytmy. Od podstaw
Algorytm sortowania przez wstawianie wykonuje, jak widać, dwukrotnie mniej porównań
niż każdy z dwóch pozostałych algorytmów.
Podsumowanie
W niniejszym rozdziale:
• zaimplementowaliśmy trzy proste algorytmy sortowania — bąbelkowe,
przez wybieranie i przez wstawianie — i zweryfikowaliśmy poprawność ich
implementacji za pomocą odpowiednich zestawów testowych,
• opisaliśmy koncepcję komparatora i zaimplementowaliśmy kilka komparatorów
— komparator naturalny, komparator odwrotny i komparator zliczający,
Rozdział 6. • Sortowanie — proste algorytmy 181
Ćwiczenia
1. Stwórz zestawy testowe weryfikujące poprawność sortowania — przez każdy
z algorytmów — losowo wygenerowanej listy obiektów typu double.
2. Stwórz zestawy testowe udowadniające, że sortowanie bąbelkowe i sortowanie
przez wstawianie (w implementacjach prezentowanych w niniejszym rozdziale)
są stabilnymi metodami sortowania.
3. Skonstruuj komparator wyznaczający alfabetyczną kolejność łańcuchów,
bez rozróżniania małych i wielkich liter.
4. Napisz program-sterownik zliczający liczbę przestawień obiektów w ramach
każdego z opisywanych w rozdziale algorytmów sortowania.
182 Algorytmy. Od podstaw
7
Sortowanie zaawansowane
W rozdziale 6. opisaliśmy trzy proste algorytmy sortowania, które okazują się wystarczają-
ce dla danych o małym lub średnim rozmiarze. Ich prostota jest niekwestionowaną zaletą
jednakże do sortowania dużych porcji danych są one niewystarczające, bowiem sortowanie
to mogłoby trwać niewyobrażalnie długo. Algorytmy sortowania opisywane w niniejszym
rozdziale są trudniejsze do zrozumienia, ich implementacja wymaga zdecydowanie więk-
szych umiejętności, jednak w rzeczywistych aplikacjach, operujących nieraz ogromnymi
zestawami danych, nie ma dla nich alternatywy. Geneza tych algorytmów sięga lat 50., 60. i 70.
dwudziestego wieku, a więc wytrzymały one próbę czasu i zostały w ciągu tych kilku dzie-
sięcioleci bardzo dokładnie zbadane. Czas poświęcony na ich przestudiowanie z pewnością
nie okaże się czasem straconym!
Algorytm sortowania o nazwie Shellsort, zaproponowany przez Shella [Shell, 1959], osiąga
ten cel przez (koncepcyjny) podział dużej listy na wiele mniejszych podlist, z których każ-
da poddawana jest sortowaniu przez wstawianie (patrz rozdział 6.). W kolejnych krokach
sortowania liczba wspomnianych podlist systematycznie się zmniejsza, zwiększa się za to
długość każdej podlisty. W ostatnim kroku pojedyncza już podlista sortowana jest przez
wstawianie. Jak pamiętamy z poprzedniego rozdziału, algorytm sortowania przez wstawia-
nie wrażliwy jest na stopień uporządkowania danych wejściowych, skoro więc owa pojedyn-
cza lista jest już prawie posortowana, jej sortowanie końcowe odbywa się bardzo szybko.
Rysunek 7.1. B E G 1 N N 1 N G A L G 0 R 1 T H M S
Przykładowe dane
poddawane sortowaniu
metodą Shella
Działanie algorytmu Shellsort opiera się na koncepcji tzw. H-sortowania. Ten zagadkowy
termin oznacza po prostu sortowanie podlisty złożonej z co H-tego elementu listy oryginal-
nej (podlistę tę nazywa się niekiedy H-listą). Wyróżnione na rysunku 7.2 elementy tworzą
4-listę rozpoczynającą się od elementu na pozycji 0.
Rysunek 7.2. B E G 1 N N 1 N G A L G 0 R 1 T H M S
4-lista złożona
z co czwartego elementu,
poczynając od elementu
na pozycji 0
Rysunek 7.3. B 1 G N N H A L G N R 1 T M S
LU
1 0
4-Lista rozpoczynająca
się od elementu 0
została posortowana
alfabetycznie
Rysunek 7.4. B E G 1 G N 1 N H A L G N R 1 T 0 M S
Wynik posortowania
4-listy rozpoczynającej
się od elementu 1. B A G 1 G E 1 N H M L G N N 1 T 0 R S
Na rysunkach 7.5 i 7.6 widoczny jest efekt 4-sortowania dwóch pozostałych 4-list, rozpo-
czynających się od elementu na pozycji (odpowiednio) 2 i 3.
Rozdział 7. • Sortowanie z a a w a n s o w a n e 185
Rysunek 7.5. B A G 1 G E 1 N H M L G N N 1 T 0 R S
Wynik
posortowania 4-listy
rozpoczynającej się B ;A G 1 G E 1 N H M 1 G N N L T 0 R S
1
od elementu 2.
Rysunek 7.6. B A G 1 G E 1 N H M 1 G N N L T 0 R S
Wynik
posortowania 4-listy
rozpoczynającej się B A G G G E 1 1 H M 1 N N N L T 0 R S
od elementu 3
Nie ma już więcej 4-list, mimo posortowania wszystkich z osobna ciąg widoczny na rysunku
7.6 za posortowany bynajmniej uważany być nie może. Jest on jedynie 4-posortowany.
Sortowanie metodą Shella szybko przenosi — jak widać — elementy na znaczne nawet
odległości. Dla dużych list wartość H może początkowo sięgać nawet połowy długości listy.
W kolejnych krokach wartość H jest systematycznie zmniejszana, by ostatecznie osiągnąć
wartość 1.
W naszym przykładzie kolejny krok to sortowanie podlist dla H równego 3 (zwróćmy uwagę
na cztery ostatnie litery po sortowaniu, to jednak tylko zbieg okoliczności).
Rysunek 7.7. B A G G G E 1 1 H M 1 N N N L T 0 R S
Sortowanie 3-listy
rozpoczynającej się
od elementu 0 B A G G G E 1 1 H M 1 N N N L S 0 R T
3-lista rozpoczynająca się od elementu 1 jest już (przez przypadek) posortowana, co widać
na rysunku 7.8.
Rysunek 7.8. B A G G G E 1 1 H M 1 N N N L S 0 R T
„Sortowanie" 3-listy
rozpoczynającej się
od elementu 1 B A jG G G E 1 1 H M 1 N N N L S 0 R T
Rysunek 7.9. B A G G G E 1 1 H M 1 N N N L S 0 R T
Sortowanie 3-listy
rozpoczynającej się
od elementu 2 B A E G G G 1 1 H M 1 L N N N S 0 R T
Cała lista staje się coraz bardziej uporządkowana: każdy z elementów jest już bądź to na
swej docelowej pozycji, bądź nie dalej jak dwie pozycje od niej. Z jej ostatecznym posor-
towaniem — którego efekt widoczny jest na rysunku 7.10 — algorytm sortowania przez
wstawianie poradzi sobie z pewnością bardzo szybko.
Rysunek 7.10. A B E G G G H 1 1 1 L M N N N 0 R S T
Lista ostatecznie
posortowana
186 Algorytmy. Od podstaw
J a k to działa?
Mając już — stworzony minimalnym wysiłkiem — stosowny zestaw testowy, zajmijmy się
implementacją samego algorytmu.
package com.wrox.algorithms.sorting;
* Konstruktor
* Parametr: komparator wyznaczający porządek sortowanych wartości.
*/
public ShellsortListSorter(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
}
}
Metoda sortO przeprowadza kolejne sortowania //-list dla określonej sekwencji wartości
//; szczegóły sortowania podlist dla danego H ukryte są w metodzie hSort(). Wykorzysty-
wana sekwencja wartości H (zapamiętana w tablicy _increments) nie jest bezwzględnie
obowiązująca i można ją zastąpić inną ale pod następującymi warunkami: po pierwsze,
musi to być sekwencja malejąca, a po drugie, musi kończyć się wartością 1 — w przeciw-
nym razie lista nie zostanie ostatecznie posortowana, lecz będzie ^-posortowana dla ostat-
niej wartości k w sekwencji.
Jak to działa?
Działanie algorytmu Shellsort polega na sukcesywnym sortowaniu (wirtualnych) podlist
(//-list), które tworzone są poprzez wybór z oryginalnej listy kolejnych elementów odle-
głych od siebie o pewien ustalony dystans (//). Duża początkowo liczba małych podlist
przekształca się systematycznie w małą listę dużych podlist; wspomniany dystans między
elementami staje się coraz mniejszy. Zewnętrzna pętla procedury s o r t t ) organizuje kolejne
cykle sortowania dla kolejnych, coraz mniejszych wartości //, kończąc na wartości H= 1,
w wyniku czego cała lista zostaje ostatecznie posortowana.
Metoda hSortO, realizująca cykl sortowania dla danej wartości //, rozpoczyna swą pracę
od sprawdzenia, czy wartość ta jest mniejsza od połowy długości sortowanej listy —jeżeli
tak, wywołanie delegowane jest do procedury sortSublistO. Ta ostatnia wykonuje sorto-
wanie (przez wstawianie — patrz rozdział 6.) //-listy rozpoczynającej się od wskazanego
elementu, czyli dla ustalonego elementu początkowego o indeksie i dokonuje uporządko-
wania elementów o indeksach s, s+H, s+2H, s+3H itd.
1
Z tąjednak różnicą, że w opisywanej w rozdziale 6. implementacji sortowanie nie odbywa się
„w miejscu" —przyp. tłum.
Rozdział 7. • Sortowanie zaawansowane 189
Sortowanie szybkie
Sortowanie szybkie (znane powszechnie pod oryginalną nazwą Quicksort), opisane po raz
pierwszy w pracy [Hoare, 1962], jest pierwszym (z prezentowanych przez nas) rekurencyj-
nym algorytmem sortowania. Mimo iż możliwe jest stworzenie jego wersji nierekurencyj-
nej, wersja rekurencyjna jest zdecydowanie czytelniejsza i bardziej naturalna. Algorytm
Quicksort funkcjonuje zgodnie z zasadą „dziel i zwyciężaj", przetwarzając rekurencyjnie
coraz mniejsze fragmenty oryginalnej listy. Na każdym poziomie rekurencji przetwarzanie
to obejmuje trzy następujące etapy:
• umieszczenie (arbitralnie) wybranego elementu na jego pozycji docelowej,
• umieszczenie na lewo od niego wszystkich elementów od niego mniejszych,
• umieszczenie na prawo od niego wszystkich elementów od niego większych.
W wyniku tego lista podzielona zostaje na dwie części („części", niekoniecznie połówki),
które muszą być następnie posortowane niezależnie od siebie.
Rysunek 7.11. Q U I C K S 0 R T I S G R E A T F U N
Przykładowa
lista poddawana
sortowaniu
szybkiemu
Rysunek 7.12. Q U I C K S 0 R T I S G R E A T F U N
Sytuacja wyjściowa
dla algorytmu
Quicksort
W czasie działania algorytmu wspomniane indeksy przesuwają się w kierunku siebie tak
długo, aż lewy indeks napotka na element większy od elementu dzielącego, a prawy indeks
— na element mniejszy od elementu dzielącego. Elementy wskazywane przez indeksy za-
mieniane są miejscami, same zaś indeksy dalej zbliżają się do siebie; proces ten kończy się
w momencie, gdy indeksy spotkają się ze sobą.
190 A l g o r y t m y . Od podstaw
Na rysunku 7.12 lewy indeks wskazuje literę Q, czyli element większy od elementu dzielą-
cego (N). Prawy indeks początkowo wskazuje literę U, czyli element większy od elementu
dzielącego; przesuwając się w lewo, napotka element mniejszy od elementu dzielącego —
literę F. Sytuację tę przedstawiono na rysunku 7.13.
Rysunek 7.13. Q U 1 C K S 0 R T 1 S G R E A T F U N
Pierwsze
zatrzymanie
indeksów na
„konfliktowych"
elementach
Elementy wskazywane przez obydwa indeksy zostają zamienione miejscami, przez co zbli-
żają się do swych pozycji docelowych (rysunek 7.14).
Rysunek 7.14. F U 1 C K S 0 R T 1 S G R E A T Q U N
Zamiana
konfliktowych
elementów
miejscami
Rysunek 7.15. F U 1 c K S 0 R T 1 S G R E A T Q U N
Kolejna para
elementów
konfliktowych
Rysunek 7.16. F A 1 C K S 0 R T 1 S G R E U T Q U N
Kolejna zamiana
elementów
konfliktowych
Rysunek 7.17. F A 1 C K S 0 R T 1 S G R E U T Q U N
Ponowne
zatrzymanie
indeksów na nowej
parze elementów
konfliktowych
Po zamianie miejscami tych elementów lista znajdzie się w stanie widocznym na rysunku
7.18. Wszystkie elementy mniejsze od elementu dzielącego znajdować się będą po lewej
stronie lewego indeksu, zaś wszystkie elementy większe od elementu dzielącego — po
prawej stronie prawego indeksu. Elementy znajdujące się między indeksami to elementy
czekające na przetworzenie.
Rozdział 7. • Sortowanie z a a w a n s o w a n e 191
Rysunek 7.18. F A 1 C K E 0 R T 1 S G R S U T Q U N
Sytuacja
po zamianie
miejscami liter E i S
Rysunek 7.19. F A 1 C K E 0 R T 1 S G R S U T Q U N
Konfliktowe
elementy O i G
Rysunek 7.20. F A 1 C K E G R T 1 S 0 R S U T Q U N
Po zamianie
elementów O i G
Rysunek 7.21. F A 1 C K E G R T 1 S 0 R S U T Q U N
Konfliktowe
elementy R i I
Rysunek 7.22. F A 1 C K E G 1 T R S 0 R S U T Q U N
Po zamianie
elementów R i I
W tym momencie zaczynają się dziać interesujące rzeczy. Lewy indeks, poruszając się w pra-
wo, zatrzymuje się na literze T. Prawy indeks, poruszając się w lewo, nie napotka już jednak
elementu mniejszego niż element dzielący, ponieważ spotka się z lewym indeksem. Sytu-
ację tę przedstawiono na rysunku 7.23.
Rysunek 7.23. F A 1 C K E G 1 T R S 0 R S U T Q U N
Indeksy spotykają
się ze sobą
i przestają się
poruszać
Jak zauważyliśmy wcześniej, elementy mniejsze od elementu dzielącego znajdują się lewej
stronie lewego indeksu, zaś elementy większe od elementu dzielącego — po prawej stronie
prawego indeksu. Wynika stąd ważny wniosek, że miejsce spotkania się indeksów wyzna-
cza jednocześnie docelową pozycję elementu dzielącego; należy więc zamienić miejscami
element dzielący (N) z elementem stanowiącym miejsce spotkania indeksów (T). Dopro-
wadzi to listę do stanu widocznego na rysunku 7.24.
192 Algorytmy. Od podstaw
Rysunek 7.24. Elementy mniejsze niż „N" Elementy większe niż „N"
Lista została
podzielona, element
dzielący znajduje się F A 1 C K E G 1 N R 5 0 R S | U T Q U T
na swej docelowej
pozycji
J a k to działa?
i mpo rtcom.wrox.algorithms.lists.List;
* Konstruktor.
* Parametr: komparator wyznaczający porządek sortowanych wartości.
*/
public QuicksortListSorter(Comparator comparator) {
assert comparator ! = nuli : "nie określono komparatora";
_comparator - comparator;
}
}
Działanie metody sortO sprowadza się do wywołania metody quickSort() dla całej listy.
Metoda quickSort() będzie w wyniku tego wywoływać rekurencyjnie samą siebie dla każdej
z części listy powstającej w wyniku kolejnych podziałów.
public List sort(List ist) {
assert list != nul : "nie określono 1 i sty";
return list:
}
Metoda quickSort() dokonuje podziału listy względem elementu dzielącego, po czym re-
kurencyjnie wywołuje samą siebie w celu posortowania każdej z obydwu części powstałych
w wyniku tego podziału.
private void quicksort(List list. int startlndex, int endlndex) {
if (startlndex < O || endlndex >= list.sizeO) {
return;
}
if (endlndex <= startlndex) {
return;
}
Object value = list.get(endlndex); // elementem dzielącym jest ostatni
//element
Sam podział listy względem wskazanego elementu dzielącego wykonywany jest przez me-
todę partitionO:
private int partition(List list. Object value, int leftlndex. int rightlndex) {
int left = leftlndex;
int right - rightlndex;
Jak to działa?
Metoda quickSort() rozpoczyna od weryfikacji wartości indeksów i sprawdzenia ich wza-
jemnej relacji: jeśli indeksy s ą j u ż „po spotkaniu", bądź któryś z nich wykracza poza listę,
nie są podejmowane żadne dalsze działania. Wykonywanie tej weryfikacji umożliwia
uproszczenie pozostałego kodu. Jeśli wzajemna relacja indeksów jest prawidłowa, doko-
nywany jest podział listy za pomocą metody partitionO.
Metoda partition() zawiera fragment kodu badający, czy element, który ma zostać zamie-
niony miejscami z elementem dzielącym, jest od elementu dzielącego mniejszy; może się
tak zdarzyć, gdy element dzielący ma największą wartość w sortowanej liście. Zamiana nie
jest wtedy wykonywana, a element dzielący pozostaje na swoim miejscu. Działanie metody
partition() kończy się, gdy obydwa indeksy spotykają się na tej samej pozycji; pozycja ta
zwracana jest jako wynik metody.
Rozdział 7. • Sortowanie zaawansowane 195
Jak pamiętamy z rozdziału 6., algorytm sortowania nazywamy stabilnym, jeśli zachowuje
on względne położenie elementów o tym samym kluczu. Algorytmy Shellsort i Quicksort
własności tej nie mają co swoją drogą nie dziwi wobec dużej dynamiki żonglowania ele-
mentami przez każdy z tych algorytmów. Brak ten można jednak skompensować za pomo-
cą mechanizmu zwanego komparatorem złożonym (compound comparator).
W przykładzie z rozdziału 6. (tabele 6.1 i 6.2) sortowanie prowadzone było wyłącznie we-
dług imienia, a następnie wyłącznie według nazwiska. Ponieważ sortowanie, którego wynik
widzieliśmy w tabeli 6.2, było sortowaniem stabilnym, zachowana została względna kolej-
ność elementów o jednakowych nazwiskach — istniejące uporządkowanie względem imion
(w ramach tego samego nazwisko) nie zostało zaburzone. Zwróćmy w tym momencie uwagę
na niezmiernie istotny fakt: otóż posortowanie rekordów względem imion, a następnie sta-
bilne posortowanie ich względem nazwisk, daje efekt taki sam jak pojedyncze sortowanie
względem złożonego klucza „nazwisko+imię". Spostrzeżenie to stanowi podstawę ogólniej-
szej koncepcji — koncepcji komparatora złożonego, który — mówiąc krótko — komasuje
działanie kilku komparatorów wyznaczających porządek elementów na podstawie kluczy
cząstkowych („imię" i „nazwisko"). Porządek wyznaczany przez komparator złożony opiera
się na kluczu stanowiącym złożenie wspomnianych kluczy cząstkowych („nazwisko+imię").
* Konstruktor.
* Parametr: predefiniowana wartość zwracana jako wynik porównania
*/
public FixedComparator(int result) {
_result = result;
}
public int compare(Object left, Object right) throws ClassCastException {
return _result;
}
}
196 Algorytmy. Od podstaw
import junit.framework.TestCase:
Jak to działa?
Komparatory cząstkowe pierwszego z testowanych komparatorów złożonych konsekwent-
nie zwracają wartość 0. W przełożeniu na porównywanie elementów oznacza to, że ele-
menty te równe są ze względu na każdy z kluczy cząstkowych, a więc także ze względu na
klucz złożony. Komparator złożony powinien zatem zwrócić wartość 0.
import com.wrox.algorithms.iteration.Iterator:
import com.wrox.algorithms.lists.ArrayList;
i mport com.wrox.a1gori thms.1 i sts.Li st;
_comparators.add(comparator);
}
Studiując kod metody compareO, możemy zrozumieć, jak wyniki zwracane przez poszcze-
gólne komparatory cząstkowe przekładają się na wynik zwracany przez komparator złożony.
public int comparetObject left. Object right) throws ClassCastException {
int result = 0;
Iterator i = _comparators.iteratorO;
return result:
Łączenie list
Algorytm sortowania przez łączenie bazuje na koncepcji łączenia dwóch posortowanych
list w j e d n ą (oczywiście też posortowaną). Przykład takich list przedstawiony jest na ry-
sunku 7.25.
Rysunek 7.25. A F M D G L
Dwie posortowane
listy przeznaczone
do połączenia w jedną
posortowaną listę
Proces łączenia dwóch list rozpoczyna się od ustawienia indeksów na początku, czyli na
najmniejszym elemencie każdej z nich, jak pokazuje rysunek 7.26.
Rysunek 7.26. A F M D G L
Łączenie list rozpoczyna
się od ich najmniejszych
elementów
Rysunek 7.27. A F M D G L
Pierwszy element
kierowany do listy
wynikowej
WYNIK
Proces łączenia jest kontynuowany, jako trzeci element do listy wynikowej trafia litera F
(rysunek 7.29).
Rozdział 7. • Sortowanie zaawansowane 199
Rysunek 7.28. A F M D G L
Drugi element
kierowany
do listy wynikowej
WYNIK
Rysunek 7.29. M D G L
Trzeci element
kierowany
do listy wynikowej
WYNIK
D
Łączenie kończy się, gdy obydwie listy zostaną wyczerpane; połączona, posortowana lista
widoczna jest na rysunku 7.30.
Rysunek 7.30. M D
Zakończony
proces łączenia
WYNIK
A D F G L M
Algorytm Mergesort
Podobnie jak Quicksort, algorytm Mergesort jest algorytmem rekurencyjnym. W przeci-
wieństwie do algorytmu Quicksort, który jest algorytmem typu „dziel i zwyciężaj", algo-
rytm Mergesort scharakteryzować można raczej jako algorytm o charakterze „łącz i zwy-
ciężaj" — sortowanie na wyższym poziomie rekurencji przeprowadzane jest dopiero wtedy,
gdy zakończone zostanie na wszystkich niższych poziomach. Jest to sytuacja zupełnie inna
niż w algorytmie Quicksort, gdzie element dzielący umieszczany jest na docelowej pozycji
przed rozpoczęciem sortowania części stanowiących wynik podziału. W odróżnieniu od
sortowania metodą Shella i sortowania szybkiego, sortowanie przez łączenie jest sortowa-
niem stabilnym.
Rysunek 7.31. R E C U R S 1 V E M E R G E S 0 R T
Przykładowa lista
do posortowania
Jak wszystkie algorytmy sortowania algorytm Mergesort opiera się na zadziwiająco pro-
stym pomyśle: dokonuje on podziału sortowanej listy na dwie części (zwykle — dwie po-
łówki), sortuje niezależnie każdą z nich, po czym dokonuje połączenia obydwu posortowa-
nych list w jedną. Na rysunku 7.32 widzimy dwie listy powstałe w wyniku podziału listy
z rysunku 7.31 — zostaną one po posortowaniu złączone w jedną posortowaną całość.
200 A l g o r y t m y . Od podstaw
Rysunek 7.32. R E C U R S 1 V E M E R G E 5 0 R T
Wynik podziału
oryginalnej listy
na dwie części
Inaczej niż w algorytmie Quicksort podział dokonywany przez algorytm Mergesort odbywa
się bez związku z konkretnymi wartościami elementów sortowanej listy: w przeciwieństwie
do partycjonowania listy względem wybranego elementu mamy tu do czynienia z jej pro-
stym podziałem na pół.
Każda z dwóch połówek stanowiących wynik podziału musi zostać teraz posortowana nie-
zależnie. Jak? Oczywiście, że za pomocą algorytmu Mergesort. Na rysunku 7.33 widzimy
zastosowanie tej zasady do pierwszej połówki.
Rysunek 7.33. R E C U R S 1 V E M E R G E S 0 R T
Rekurencyjne
sortowanie jednej
z części stanowiących R E C U R S 1 V E
wynik podziału
Mamy więc do czynienia z typową rekurencją, a skoro tak, to musimy określić jej przypa-
dek bazowy. Jest nim oczywiście lista jednoelementowa, z definicji posortowana i niewy-
magająca żadnych zabiegów. Lista wieloelementowa wymaga dalszego podziału i dalszych
wywołań rekurencyjnych.
Rysunek 7.34. R E C U R S 1 V E M E R G E S 0 R T
Efekt trzeciego
poziomu
rekurencyjnego R E C U R S 1 V E
zastosowania
algorytmu
Mergesort R E C U R
Rysunek 7.35. R E C U R S 1 V E M E R G E S 0 R T
Czwarty poziom
rekurencji
R E C U R s 1 V E
R E C IU R
R
0
Widoczna na rysunku 7.35 dwuelementowa lista (R, E) zostanie na kolejnym poziomie po-
dzielona na dwie listy jednoelementowe (rysunek 7.36).
Rozdział 7. • Sortowanie zaawansowane 201
Rysunek 7.36. R E C U R S I V E M E R G E S O R T
Najgłębszy
poziom rekurencji
— nie ma już R E C U R S I V E
wieloelementowych
list do podziału
R E C U R
R E
0
Kończy to zagłębianie się wywołań rekurencyjnych; podczas powrotu z tych wywołań owe
jednoelementowe listy zapoczątkowują proces łączenia, którego pierwszy krok przedsta-
wiony jest na rysunku 7.37.
Rysunek 7.37. R E C U R S 1 V E M E R G | E S 0 R T
Wynik pierwszej
operacji łączenia list
R E C U R I V E
R E C U R
E R C
Widoczne na rysunku 7.37 listy (E, R) i (C) są już posortowane, zostają więc połączone
w jednąlistę (C, E, R), jak na rysunku 7.38.
Rysunek 7.38. R E C U R S 1 V E M E R G E S 0 R T
Sytuacja po
zakończeniu drugiej
operacji łączenia R E C U R S 1 V E
R U R
„Równoległa" do podlisty (C, E, R) podlista (U, R) także musi zostać posortowana. W tym
celu najpierw dzielona jest na dwie jednoelementowe listy (U) i (R) — co widać na rysunku
7.39 — po czym listy te są łączone, co daje efekt widoczny na rysunku 7.40.
Rysunek 7.39. R E C U R S 1 V E M E R G | E S 0 R T
Początek
rekurencyjnego
sortowania R E C U R S 1 V E
listy (U, R)
C E R U
202 A l g o r y t m y . Od podstaw
Rysunek 7.40. R E C U R S 1 V E M E R G E S 0 R T
Lista (U, R)
posortowana
do postaci (R, U) R E C U R S 1 V E
R R U
Listy (C, E, R) i (R, U) po połączeniu dają listę (C, E, R, R, U) stanowiącą wynik posor-
towania listy (R, E, C, U, R) (rysunek 7.41).
Rysunek 7.41. R E C U R S 1 V E M E R G E S 0 R T
Wynik kolejnego
łączenia na kolejno
wyższym poziomie C E R R U S 1 V E
rekurencji
Rysunek 7.42. R E C U R S 1 V E M E R G E S 0 R T
Kolejna operacja
łączenia zakończy
sortowanie C E R R U E 1 S V
pierwszej
połówki listy
Wynik sortowania pierwszej połówki oryginalnej listy widoczny jest na rysunku 7.43.
Rysunek 7.43. C E E 1 R R S U V M E R G E S 0 R T
Pierwsza połówka
listy jest już
posortowana
Rysunek 7.44. C E E 1 R R S U V E E G M 0 R R S T
Obydwie połówki
oryginalnej listy są
już posortowane
Rysunek 7.45. C E E 1 R R S U V E E G M 0 R R S T
Efekt końcowy
sortowania listy
z rysunku 7.31. C E E E E G 1 M 0 R R R R S S T U V
import com.wrox.algorithms.iteration.Iterator;
import com.wrox.algori thms.1 i sts.ArrayLi st;
i mport com.wrox.a1gori thms.1 i sts.L i st;
j-k-k
* Konstruktor
* Parametr; komparator wyznaczający porządek sortowanych wartości
*/
public MergesortListSorter(Comparator comparator) {
assert comparator != nuli ; "nie określono komparatora";
_comparator = comparator;
}
}
Podobnie jak w (także rekurencyjnym) algorytmie Quicksort, działanie metody sortO spro-
wadza się do wywołania rekurencyjnej procedury mergesort() dla całości listy.
public List sorttList list) {
assert list != nuli : "nie określono listy";
Gdy metoda mergesortO wywołana zostaje dla listy jednoelementowej, zwraca po prostu
kopię tej listy, w przeciwnym razie następuje podział oryginalnej listy na dwie części i para
wywołań rekurencyjnych na rzecz każdej z nich:
204 Algorytmy. Od podstaw
1.firstO:
r.firstO;
return result:
j
Rozdział 7. • Sortowanie zaawansowane 205
J a k to działa?
W metodzie mergeO poruszanie się po łączonych listach zorganizowano w sposób jak naj-
bardziej ogólny — za pomocą iteratorów. Główną przyczyną pewnej komplikacji kodu tej
metody jest konieczność poprawnego obsłużenia sytuacji, gdy jedna z list wyczerpana zo-
stanie wcześniej od drugiej — wszystkie pozostałe elementy niewyczerpanej jeszcze listy
powinny wówczas zostać dołączone na koniec listy wynikowej. W sytuacji, gdy żadna z list
nie została jeszcze wyczerpana, porównywane są bieżące elementy obydwu list i mniejszy
z elementów dołączany jest na koniec listy wynikowej.
Porównanie zaawansowanych
algorytmów sortowania
Podobnie jak uczyniliśmy to w przypadku podstawowych algorytmów sortowania w roz-
dziale 6., tak i tym razem dokonamy porównania o charakterze empirycznym, unikając for-
malnej, teoretycznej analizy. Czytelnikom zainteresowanym taką analizą możemy polecić
wspaniała książkę R. Sedgewicka Algorithms in Java [Sedgewick, 2002], w tym miejscu
chcielibyśmy przede wszystkim zwrócić uwagę na korzyści wynikające z rzeczywistych
obserwacji zachowania się tworzonego kodu jako wartościowego uzupełnienia matema-
tycznej analizy algorytmów.
Jak można się domyślić, to niekorzystne zachowanie algorytmu Quicksort można złago-
dzić, a nawet zniwelować, stosując bardziej „inteligentną" strategię wyboru elementu dzie-
lącego, na przykład wybierając w jego charakterze medianę z małej próbki elementów za-
miast któregoś z elementów skrajnych.
Ponownie sortowanie szybkie okazuje się gorsze od dwóch pozostałych algorytmów i znów
dzieje się to z tej samej przyczyny — skrajnej dysproporcji długości list powstających w wy-
niku podziału. Nieco mniejsza liczba porównań niż w przypadku listy odwrotnie posorto-
wanej bierze się stąd, że w przypadku listy posortowanej wprost ostatni element znajduje
się już na swej docelowej pozycji, a pozostałe elementy — po jego właściwej stronie.
Zwróćmy ponadto uwagę, że prosty algorytm sortowania (Insertionsort) może dla pewnych
szczególnych danych okazać się znacznie efektywniejszy niż algorytm „zaawansowany"
(Quicksort).
Fakt, że sortowanie przez wstawianie (Insertionsort) okazuje się bardzo efektywne dla danych
„prawie" posortowanych, wykorzystywany jest w pewnej metodzie usprawniania sortowa-
nia szybkiego. Otóż w sytuacji, gdy lista stanowiąca wynik podziału okaże się krótsza niż
założona a priori wartość m, listę tę pozostawia się już bez dalszego sortowania. W rezulta-
cie, gdy algorytm Quicksort kończy pracę, zamiast całkowicie posortowanej listy mamy
ciąg /w-elementowych grup o szczególnej własności: dla dwóch sąsiadujących grup każdy
element grupy położonej na prawo jest nie mniejszy od każdego elementu grupy położonej
na lewo. Lista stanowiąca taki ciąg daje się bardzo efektywnie posortować za pomocą sor-
towania przez wstawianie2. (Implementacja sortowania szybkiego w takiej postaci jest przed-
miotem ćwiczenia 5. na końcu rozdziału.)
2
Alternatywnym rozwiązaniem m o ż e być wykonanie m 1 kroków sortowania bąbelkowego
— przyp. tłum.
208 Algorytmy. Od podstaw
Pozostaje nam jeszcze skomentowanie wyników analizy dla listy o przypadkowej zawartości:
testDirectCaseBubblesort: 498501 wywołań
testDirectCaseSelectionsort: 498501 wywołań
testDirectCaselnsertionsort: 251096 wywołań
testDirectCaseShellsort: 13717 wywołań
testDirectCaseOuicksort: 19727 wywołań
testDi rectCaseMergesort: 8668 wywołań
Podsumowanie
W zakończonym właśnie rozdziale omówiliśmy trzy zaawansowane algorytmy sortowania,
które choć bardziej skomplikowane i trudniejsze w implementacji od prostych algorytmów
omawianych w rozdziale poprzednim, są od nich bardziej odpowiednie dla rozwiązywania
rzeczywistych problemów, czyli sortowania dużych zestawów danych napotykanych przez
rzeczywiste aplikacje. Dla każdego z trzech opisywanych algorytmów — sortowania metodą
Shella (Shellsort), sortowania szybkiego (Quicksort) i sortowania przez łączenie (Merge-
sort) — wyjaśniliśmy zasadę działania, zaimplementowaliśmy klasę wykonującą sortowa-
nie oraz skonstruowaliśmy zestaw testowy weryfikujący poprawność jej funkcjonowania.
Jako że dwa pierwsze z wymienionych algorytmów nie zapewniają sortowania danych w spo-
sób stabilny (pojęcie stabilności sortowania wyjaśniliśmy w rozdziale 6.), konieczne jest zasto-
sowanie środków zapewniających tę stabilność w inny sposób. Jednym z takich środków
jest komparator złożony, umożliwiający zastąpienie ciągu sortowań cząstkowych względem
różnych kluczy pojedynczym sortowaniem względem klucza złożonego, stanowiącego kon-
katenację kluczy cząstkowych. Na zakończenie dokonaliśmy porównania efektywności opi-
sywanych algorytmów i zestawiliśmy otrzymane wyniki z wynikami podobnej analizy prze-
prowadzonej w poprzednim rozdziale dla prostych algorytmów. Zestawienie takie może
okazać się pomocne przy ogólnej ocenie popularnych algorytmów sortowania.
Rozdział 7. • Sortowanie zaawansowane 209
Ćwiczenia
1. Zaimplementuj iteracyjną wersję sortowania przez łączenie.
2. Zaimplementuj iteracyjną wersję sortowania szybkiego.
3. Podaj liczbę operacji listowych — s e t ( ) , add() i i n s e r t O — wykonywanych
przez algorytmy Quicksort i Shellsort.
4. Zaimplementuj wersję sortowania przez wstawianie wykonywanego „w miejscu".
5. Zaimplementuj odmianę sortowania szybkiego pozostawiającego bez sortowania
podlisty zawierające mniej niż 5 elementów i sortującego otrzymaną listę przez
wstawianie.
210 Algorytmy. Od podstaw
8
Kolejki priorytetowe
Po obszernym opisie wybranych algorytmów sortowania wracamy do studiowania struktur
danych. Kolejka priorytetowa jest specjalnym typem kolejki (patrz rozdział 4.) umożliwia-
jącej dostęp do największego z przechowywanych elementów — o wykorzystywaniu tej
możliwości będziemy jeszcze niejednokrotnie pisać w niniejszej książce. Nieprzypadkowo
przedstawiliśmy wcześniej algorytmy sortowania, niektóre typy kolejek priorytetowych
mają bowiem bezpośredni związek właśnie z sortowaniem.
Żeby łatwiej zrozumieć użyteczność kolejki priorytetowej, wyobraź sobie, że jesteś pierw-
szoplanową postacią gry typu RPG; poruszasz się po wrogim terytorium, a zewsząd pod
różnymi postaciami czai się zagrożenie. Owych postaci jest mnóstwo, jedne są bardzo agre-
sywne, inne mniej; nie możesz ich wszystkich od razu unieszkodliwić, bo masz możliwość
oddania tylko jednego strzału naraz, więc z konieczności powinieneś zacząć od postaci naj-
groźniejszej — umiejętność eliminowania zagrożeń w kolejności stosownej do ich wagi
stanowi dobrą strategię przeżycia! Pozostali wrogowie chwilowo Cię nie interesują — za
chwilę i tak będziesz musiał rozpoznać najgroźniejszego z nich.
W niniejszym rozdziale:
• opiszemy i zilustrujemy na konkretnym przykładzie koncepcję kolejek
priorytetowych,
• skonstruujemy kolejki priorytetowe w oparciu o listy posortowane
i nieuporządkowane,
• opiszemy strukturę zwan^stogiem z zaimplementujemy na jej bazie kolejkę
priorytetową
• porównamy różne implementacje kolejek priorytetowych.
212 Algorytmy. Od podstaw
Kolejki priorytetowe
Kolejka priorytetowa (priority ąueue) to kolejka zapewniającą dostęp do swych elementów
w kolejności od największego do najmniejszego. W przeciwieństwie do „zwykłej" kolejki,
udostępniającej elementy w kolejności ich przybywania, oraz do stosu, udostępniającego
elementy w kolejności odwrotnej do ich umieszczania, kolejka priorytetowa zapewnia znacz-
nie bardziej elastyczny dostęp do swych elementów.
W danej chwili jedynym dostępnym elementem kolejki priorytetowej jest jej największy
element, przy czym słowo „największy" należy tu rozumieć w sensie pewnego kryterium
porządkującego wyznaczanego przez określony komparator. Posiłkując się komparatorem
odwrotnym (patrz rozdział 6.), możemy równie łatwo uzyskać dostęp do elementu naj-
mniejszego.
Rysunek 8.1. T H E Q U I C K B R 0 W N O X
Dane wejściowe
dla przykładowej
kolejki priorytetowej J U M P E D 0 V E R
T H E L A Z Y D 0 G
Po dodaniu do kolejki pierwszego słowa jej zawartość przedstawia się tak jak na rysunku 8.2.
The ąuick brown fox jumped over łozy dog — „zwinny brunatny lis przeskoczył przez leniwego psa"
— przyp. dum.
Rozdział 8. • Kolejki priorytetowe 213
Rysunek 8.2. Q U 1 C K B R 0 W N
Litery tworzące
pierwsze słowo
zostały umieszczone J U M P E D 0 V E R
w kolejce
priorytetowej
T H E L A Z Y D 0 G
Kolejka
priorytetowa
Na rysunku 8.3 przedstawiono wynik żądania sformułowanego przez program kliencki: za-
żądał on elementu z kolejki i otrzymał element „największy", czyli literę T jako najstarszą
alfabetycznie.
Rysunek 8.3. Q U 1 C K B R 0 W N O
Element największy
został usunięty
z kolejki J U M P E D 0 V E R
priorytetowej
T H E L A Z Y D 0 G
Kolejka
priorytetowa
WYJŚCIE
0
W kolejnym kroku swego działania program kliencki „dorzucił" do kolejki kolejne słowo
i ponownie zażądał elementu z kolejki, otrzymując w efekcie element aktualnie największy,
czyli literę U. Sytuację tę przedstawia rysunek 8.4.
Rysunek 8.4. B R O W N F O X
Dodanie kolejnego
słowa do kolejki
i ponowne usunięcie J U M P E D O V E R
z niej elementu
największego
T H E L A Z Y D 0 G
Kolejka
priorytetowa
WYJŚCIE
0 0
Rysunek 8.5. o
Dodanie trzeciego
słowa do kolejki
i ponowne usunięcie J U M P E D 0 V E R
z niej elementu
największego
T H E L A Z Y D 0 G
Kolejka
priorytetowa
WYJŚCIE
00 W
Zasada funkcjonowania kolejki priorytetowej powinna stać się już jasna, pominiemy więc
kolejne kroki działania programu klienckiego i przejdziemy bezpośrednio do stanu końco-
wego, gdy program ten po wyczerpaniu wszystkich słów po raz ostatni zażądał elementu
z kolejki i otrzymał go (rysunek 8.6).
Zwróćmy uwagę, że w kolejce priorytetowej znajdują się teraz dwa elementy, z których
każdy można uważać za element największy. Gdyby program kliencki jeszcze raz zażądał
elementu z kolejki, mógłby otrzymać dowolny z tych elementów.
Rozdział 8. • Kolejki priorytetowe 215
Rysunek 8.6.
Końcowa
konfiguracja
scenariusza
Kolejka
współpracy kolejki priorytetowa
priorytetowej
z programem
klienckim
WYJŚCIE
Na początku zdefiniujemy specyficzne wartości testowe, które pełnić będą rolę elementów
kolejki.
package com.wrox.algorithms.ąueues;
import junit.framework.TestCase:
import com.wrox.algorithms.sorti ng.NaturalComparator;
import com.wrox.algori thms.sorti ng.Comparator:
}
216 Algorytmy. Od podstaw
Musimy także zdefiniować metody setUpO i tearDownO, których zadaniem będzie (odpo-
wiednio) utworzenie instancji testowanej kolejki priorytetowej oraz wyzerowanie wskaźnika
na tę instancję, czyli przeznaczenie instancji do automatycznego zwolnienia (w ramach „od-
śmiecania" pamięci). Pierwsza z tych funkcji wykonywana jest przez (zaimplementowaną)
metodę createQueue().
_queue = createQueue(NaturalComparator.INSTANCE):
}
protected void tearDownO throws Exception {
_queue - nuli;
Pierwszy z testów weryfikował będzie zachowanie się pustej kolejki przy próbie pobrania
z niej elementu. Poniższy fragment kodu jest identyczny z fragmentem odpowiedniego testu
w rozdziale 4.; moglibyśmy uniknąć dublowania kodu przez staranniejsze zaprojektowanie
hierarchii przypadków testowych dla kolejek, lecz zrezygnowaliśmy z tej możliwości ze
względu na prostotę przykładu. Mimo wszystko nie polecamy jednak takiego postępowania
w odniesieniu do „zasadniczego" kodu aplikacji.
try {
_queue.dequeue();
"fal 1 0 : // zachowanie nieoczekiwane
} catch (EmptyQueueException e) {
// zachowanie oczekiwane
W kolejnym teście dodajemy do pustej kolejki trzy elementy i sprawdzamy, czy metody
sizeO i isEmptyO zwracają prawidłowe wartości:
public void testEnqueueDequeue() {
_queue.enqueue(VALUE_B);
_queue.enqueue(VALUE_D):
_queue.enqueue(VALUE_A);
assertEquals(3. _queue.sizeO);
assertFalse(_queue.i sEmpty()):
Teraz musimy się upewnić, że metoda dequeue() zwróci największą z dodanych do listy
wartości, czyli element D. Element ten był dodany jako drugi (z trzech), więc ani kolejka
typu FIFO, ani kolejka typu LIFO nie zaliczyłyby poniższego testu. Dodatkowo po usunięciu
elementu z kolejki ponownie weryfikujemy wartości zwracane przez metody si ze() i i sEmpty ().
Rozdział 8. • Kolejki priorytetowe 217
assertSame(VALUE_D. _queue.dequeue()):
assertEquals(2. _queue.size()):
assertFa1se(_queue.1sEmpty());
W typowych aplikacjach wywołania metod enqueue() i dequeue() mieszają się ze sobą, nie
poprzestaniemy więc na dodawaniu elementów do pustej kolejki, lecz dodamy klika ele-
mentów do kolejki w aktualnym stanie:
_queue.enqueue(VALUE_E):
_queue.enqueue(VALUE_C):
assertEquals(3. _queue.size()):
assertFalse(_queue.i sEmpty()):
assertSame(VALUE_C, _queue.dequeue());
assertEquals(l. _queue.sizeO);
assertFalse(_queue.isEmpty()):
assertSame(VALUE_A, _queue.dequeue()):
assertEquals(0, _queue.sizeO);
assertTrue(_queue.i sEmptyC)):
)
assertFal se(_queue.isEmpty()):
_queue.clear();
assertTrue(_queue.isEmpty()):
}
218 Algorytmy. Od podstaw
/**
* Konstruktor
* Parametr: komparator wyznaczający porządek (priorytet) elementów
*/
public UnsortedListPriorityQueue(Comparator comparator) {
assert comparator !- nuli : "nie określono komparatora";
_comparator = comparator;
_list = new LinkedListO ;
}
}
Implementacja operacji enqueue nie może być prostsza — polega ona na dołączeniu ele-
mentu do listy.
public void enqueue(Object value) {
_list.add(value);
}
Implementacja operacji dequeue jest nieco bardziej skomplikowana. Przede wszystkim na-
leży sprawdzić, czy kolejka przypadkiem nie jest pusta i jeżeli jest, wygenerować odpo-
wiedni wyjątek. Dla kolejki niepustej należy ponadto odnaleźć w liście największy element
— zadanie to wykonuje metoda getIndexOfLargestel ement() — i pobrać (usunąć) go z listy.
public Object dequeue() throws EmptyQueueException {
if (isEmptyO) {
throw new EmptyQueueException():
1
return _1i st.delete(getIndexOfLa rgestE1ement());
}
J a k to działa?
Ponieważ elementy kolejki mogą być przechowywane w dowolnej kolejności, więc wsta-
wienie elementu do kolejki zrealizowaliśmy najprościej jak tylko można — przez dołącze-
nie elementu na koniec listy. Dowolność przechowywania elementów wymaga jednak prze-
szukiwania całej listy w celu znalezienia elementu największego. Po zidentyfikowaniu tego
elementu jego indeks staje się parametrem wywołania metody deleteO odnośnej listy, zwra-
cającej wskazany element po uprzednim usunięciu go z listy.
* Konstruktor
* Parametr: komparator wyznaczający porządek (priorytet) elementów
*/
Jak przed chwilą wspominaliśmy, metoda enqueue() poszukuje pozycji dla nowo wstawia-
nego elementu poprzez przeszukiwanie listy wstecz, począwszy od ostatniego elementu, i po-
równywanie napotykanych elementów ze wstawianym elementem:
public void enqueue(Object value) {
int pos = J i s t . s i z e O :
while (pos > 0 && _comparator.compare(_list.get(pos - 1). value) > 0) {
--pos:
}
_list.insert(pos. value):
Także zgodnie z wcześniejszą informację metoda dequeue() pobiera ostatni element listy,
oczywiście pod warunkiem, że lista ta nie jest pusta.
public Object dequeue() throws EmptyQueueException
if (isEmptyO) {
throw new EmptyQueueException():
}
return list.delete( list.sizeO - 1):
J a k to działa?
Metoda enqueue() jest w przypadku listy posortowanej nieco bardziej skomplikowana niż
w przypadku listy z dowolnym porządkiem elementów, bowiem wstawienie nowego
elementu do listy posortowanej nie może zaburzyć jej posortowania — element największy
zawsze musi znajdować się na końcu listy. Dzięki temu upraszcza się znacznie metoda
dequeue(), której działanie sprowadza się do prostego pobrania tego elementu.
Stóg jest drzewem binarnym, w którym każdy węzeł, posiadający jednego lub dwóch synów,
jest większy2 niż jego synowie — j e s t to tzw. warunek stogowy (heap conditionf. W struktu-
rze na rysunku 8.7 warunek ten — j a k łatwo sprawdzić — spełniony jest dla każdego węzła.
Rysunek 8.7.
Przykładowy stóg
Nie należy bynajmniej utożsamiać stogu z listą posortowaną, bowiem jego elementy w ża-
den sposób posortowane nie są. Warto natomiast zwrócić uwagę na niezmiernie istotną ce-
chę stogu: skoro każdy węzeł jest większy od swoich synów, a relacja większości jest prze-
chodnia (jeśli a jest większe od b i b jest większe od c, to a jest większe od c), więc korzeń
i Dla uproszczenia zakładamy, że elementy stogu nie powtarzają się, w przeciwnym razie zamiast
„większy" musielibyśmy pisać „nie mniejszy" — przyp. tłum.
3
Jest to warunek konieczny, lecz nie wystarczający do tego, by drzewo binarne było stogiem.
Otóż każdy węzeł nie będący liściem musi mieć dokładnie dwóch synów, z wyjątkiem być może
skrajnie prawych węzłów na przedostatnim poziomie — przyp. tłum.
Rozdział 8. • Kolejki priorytetowe 223
stogu (na rysunku 8.7 jest nim element X) jest zawsze jego największym elementem. Orga-
nizacja pozostałych elementów nie jest już tak oczywista; zauważmy na przykład, że ele-
ment najmniejszy (na rysunku 8.7 jest nim element A) wcale nie znajduje się u dołu stogu,
jak można by domniemywać przez analogię.
Rysunek 8.8.
Ponumerowanie
elementów stogu
1 D
Rysunek 8.9.
Rozwinięcie
stogu w listę
7 D
X M K E A F D D B
0 8
• lewy syn elementu znajdującego się na pozycji x znajduje się na pozycji 2x+1,
• prawy syn elementu znajdującego się na pozycji x znajduje się na pozycji 2x+2,
• ojciec elementu znajdującego się na pozycji x znajduje się na pozycji (x-1 )/2
z zaokrągleniem w dół; oczywiście element na pozycji x=0 ojca nic posiada.
Rysunek 8.10.
Proste dołączenie
elementu niszczące
uporządkowanie
stogowe
Rysunek 8.11.
Pierwszy etap
wynurzania
elementu — nowy,
konfliktowy element
zostaje zamieniony
ze swym ojcem
Rozdział 8. • Kolejki priorytetowe 225
Warunek stogowy nie został jednak przywrócony, bowiem element P w dalszym ciągu jest
większy od swego (nowego) ojca M. Dokonujemy więc kolejnej zamiany elementów (M i P),
otrzymując układ widoczny na rysunku 8.12.
Rysunek 8.12.
Nowy element
znajduje się już na
właściwej pozycji
Wykonanie tej operacji w sensie dosłownym spowodowałoby rozpad stogu na dwa odrębne
drzewa i konieczność ponownego „poskładania" w stóg ich elementów. Aby temu rozpa-
dowi zapobiec, zapełnimy miejsce po usuniętym korzeniu, przenosząc doń „ostatni" ele-
ment stogu — czyli ostatni w jego listowym rozwinięciu — w tym przypadku element A,
jak pokazuje to rysunek 8.13.
Rysunek 8.13.
Zastąpienie
korzenia stogu
przez jego ostatni
element
Oczywiście rodzi to kolejny problem, bowiem warunek stogowy znowu został zaburzony:
element A jest mniejszy od każdego ze swych synów. Musi wiec zostać przeniesiony w głąb
drzewa — całokształt związanych z tym operacji nazywamy zatapianiem elementu.
Ponieważ warunek stogowy nie jest w dalszym ciągu spełniony, konieczna jest następna
zamiana — zamiana elementów A i E przedstawiona na rysunku 8.15. Warunek stogowy
zostaje przywrócony, a największy (poprzednio) element stogu — usunięty.
226 Algorytmy. Od podstaw
Rysunek 8.14.
Pierwszy etap
zatapiania
elementu A
Rysunek 8.15.
Drugi i ostatni
etap zatapiania
elementu A
/**
* Konstruktor
* Parametr: komparator wyznaczający porządek (priorytet) elementów
*/
Parametrem metody SwimO jest indeks elementu, którego dołączenie zaburzyło strukturę
stogu. Metoda dokonuje wynurzania tego elementu aż do przywrócenia warunku stogowego.
private void swim(int index) {
if (index == 0) { // korzenia nie da się wynurzyć
return;
}
int parent = (index - 1) / 2; // indeks elementu-ojca
if (_comparator.compare(_list.get(index). Jist.get(parent)) > 0) {
swap(index, parent);
swim(parent);
}
}
}
_1 i st. del eteC_l 1 st. sizeO - 1):
return result;
Metoda sinkO dokonuje (w razie potrzeby) zamiany węzła z większym z jego synów. Na-
leży pamiętać, że węzeł może mieć tylko jednego syna lub nie mieć synów w ogóle.
private void sink(int index) {
int left = index * 2 + 1; // indeks lewego syna
int right = index * 2 + 2; // indeks prawego syna
J a k to działa?
Metoda enqueue() jest bardzo prosta, ponieważ większość działań związanych z dodaniem
nowego elementu do stogu wykonywana jest przez metodę swimO. Jak łatwo spostrzec,
metoda ta ma budowę rekurencyjną — bazowym przypadkiem rekurencji jest jedna z dwóch
sytuacji: wynurzany element staje się korzeniem bądź staje się synem węzła większego od
siebie. Zwróćmy uwagę na sposób obliczania indeksu elementu-ojca, zgodnie z formułą
podaną wcześniej w niniejszym rozdziale.
Rozdział 8. • Kolejki priorytetowe 229
Metoda dequeue() zwraca pierwszy element listy, który — j a k o korzeń stogu — j e s t ele-
mentem największym. Zauważmy jednak, że nie jest on tym elementem, który zostanie ze
stogu usunięty. Ponieważ usunięcie ostatniego elementu ze stogu nigdy nie narusza warun-
ku stogowego, więc to właśnie ten element jest usuwany z listy po uprzednim skopiowaniu
jego wartości na jej pierwszy element (korzeń). Skopiowanie to może jednak naruszać
strukturę stogu i dlatego konieczne jest wykonanie operacji zatapiania elementu-korzenia.
Przede wszystkim nie można zapominać, że węzeł może mieć obydwu synów, tylko lewego
syna lub nie mieć synów w ogóle. Po obliczeniu indeksów elementów będących synami na-
stępuje sprawdzenie, która z tych trzech sytuacji ma miejsce w rzeczywistości. Brak synów
oznacza koniec zatapiania i jest jednocześnie jednym z przypadków bazowych rekurencji.
Jak pamiętamy, warunek stogowy jest spełniony, jeśli każdy węzeł jest większy od więk-
szego ze swych synów. Początkowo zakładamy, że to lewy syn jest „większym" synem; po
sprawdzeniu, czy w ogóle istnieje prawy syn, weryfikujemy to założenie. Następnie wie-
dząc już, który z synów jest większy, porównujemy go z ojcem; jeśli ojciec okaże się więk-
szy, zatapianie jest zakończone — to drugi przypadek bazowy rekurencji.
Kolejka priorytetowa o organizacji stogowej cechuje się większą efektywnością niż opisy-
wane wcześniej kolejki oparte na „płaskiej" liście. W przeciwieństwie do tych ostatnich,
gdzie co najmniej jedna z operacji enqueue lub dequeue wykonywała się w czasie 0(N),
w kolejce stogowej obydwie te operacje wykonują się w czasie 0(log N), czyli w czasie
proporcjonalnym do głębokości drzewa binarnego przechowującego elementy. Jest to
szczególny przypadek generalnie większej efektywności algorytmów „drzewiastych" w po-
równaniu z algorytmami operującymi stricte „linearnymi" strukturami danych — o czym
Czytelnik będzie miał okazję przekonać się niejednokrotnie w trakcie dalszej lektury książki.
Oczywiście warto się o tym przekonać osobiście już teraz — dokonamy w tym celu (empi-
rycznego) porównania wszystkich trzech implementacji kolejek priorytetowych opisywa-
nych w niniejszym rozdziale.
drugim, określanym jako przypadek najgorszy (worst case), kolejność ta będzie odwrotna
(malejąca). Wreszcie scenariusz trzeci — przypadek przeciętny (average case) — oznaczać
będzie dodawanie do kolejki elementów o wartości przypadkowej, generowanej losowo,
nie przekraczającej jednak rozmiaru kolejki (w celu zgodności z dwoma poprzednimi sce-
nariuszami).
Metoda inicjacyjna setUp() tworzy instancję komparatora oraz wypełnia każdą z trzech list
stosownymi wartościami:
protected void setUpO throws Exception {
super.setUpO:
_comparator = new CallCountingComparator(NaturalComparator.INSTANCE);
Każda z metod testowych dedykowana jest konkretnej parze „implementacja kolejki — lista
wejściowa". Rozpoczynamy od scenariuszy dla najgorszego przypadku:
public void testWorstCasellnsortedListO (
runScenario(new UnsortedListPriorityQueue(_comparator), _reverseList);
}
public void testWorstCaseSortedListO {
runScenario(new SortedListPriorityQueue( comparator). _reverseList);
}
public void testWorstCaseHeapOrderedListO {
runScenario(new HeapOrderedListPriorityQueue(_comparator). _reverseList);
) I
potem przechodzimy do przypadku najlepszego.
Rozdział 8. • Kolejki priorytetowe 231
Najbardziej jednak interesujący dla programisty powinien być przypadek przeciętny, gdyż
odzwierciedla on zachowanie testowanych kolejek wobec typowych danych, z jakimi w więk-
szości mają do czynienia rzeczywiste aplikacje:
testAverageCaseUnsortedList: 386226 wywołań
testAverageCaseSortedList: 153172 wywołań
testAverageCaseHeapOrderedList: 17324 wywołań
Przewaga kolejki stogowej okazuje się miażdżąca i odzwierciedla ogólną zaletę algoryt-
mów drzewiastych, o której pisaliśmy wcześniej. Przy okazji warto porównać sam kod im-
plementacji wszystkich trzech implementacji, by zrozumieć jeszcze jedną ważną zasadę:
algorytmy efektywniejsze bywają na ogół bardziej skomplikowane.
Podsumowanie
Oto najważniejsze elementy zakończonego właśnie rozdziału:
• Przedstawiliśmy koncepcję kolejki priorytetowej jako uogólnienie „zwykłej"
kolejki omawianej w rozdziale 4.
• Jako że jedynym dostępnym elementem kolejki priorytetowej jest jej największy
element, wyjaśniliśmy znaczenie terminu „największy" (jako największy w sensie
porządku wyznaczanego przez komparator) i przedstawiliśmy w tym kontekście
koncepcję kolejek FIFO oraz LIFO.
• Szczegółowo opisaliśmy trzy różne implementacje kojek priorytetowych. Pierwsza
z nich opierała się na liście przechowującej elementy w dowolnej kolejności;
dodawanie elementu do kolejki polegało na dołączaniu go na koniec wspomnianej
listy, lecz znalezienie elementu największego wymagało skanowania tej listy
w całości. Druga z opisywanych implementacji utrzymywała listę w postaci
posortowanej, wskutek czego element największy był zawsze ostatnim elementem
tej listy, lecz dodawanie nowego elementu stawało się bardziej pracochłonne.
Trzecia z prezentowanych implementacji wykorzystywała strukturę zwaną stogiem;
wyjaśniliśmy pokrótce koncepcję samego stogu oraz operacje dodawanie i usuwania
jego elementów, następnie pokazaliśmy, jak można ponumerować elementy stogu,
przechowywać je w postaci listy i zaimplementować w oparciu o tę listę kolejkę
priorytetową.
Ćwiczenia
1. Zaimplementuj stos jako kolejkę priorytetową.
2. Zaimplementuj kolejkę FIFO jako kolejkę priorytetową.
3. Zaimplementuj interfejs ListSorter w postaci kolejki priorytetowej.
4. Zaprojektuj kolejkę udostępniającą najmniejszy element zamiast największego.
5. Napisz metody swimO i sinkO klasy HeapOrderedListPriorityOueueTest w postaci
nierekurencyjnej.
6. Metoda enqueue() klasy SortedListPriorityQueue ze względu na swą prostotę
skrywa w sobie pewną nieefektywność (jaką?). Napisz jej równoważną wersję
z użyciem iteratora i wyjaśnij, dlaczego jest to wersja bardziej efektywna.
234 Algorytmy. Od podstaw
9
Binarne wyszukiwanie i wstawianie
Jak dotąd zajmowaliśmy się głównie strukturami danych umożliwiającymi przechowywanie
i sortowanie elementów, o wyszukiwaniu elementów wspominając raczej tylko przy okazji.
Tymczasem wiele aplikacji musi efektywnie radzić sobie z ogromem danych i efektywne
wyszukiwanie informacji staje się w ich przypadku zagadnieniem pierwszorzędnym: spro-
stanie zadaniu szybkiego znajdowania żądanego rekordu wśród tysięcy czy milionów innych
staje się problemem na miarę „być albo nie być" aplikacji. W tym i w kilku następnych
rozdziałach zajmiemy się więc dokładniej strukturami danych i algorytmami zaprojektowany
specjalnie dla efektywnego przechowywania i wyszukiwania danych.
Wyszukiwanie binarne
Wyszukiwanie binarne jest techniką lokalizowania elementu w posortowanej liście. Dzięki
posortowaniu listy wyszukiwanie to osiąga efektywność niedostępną zwykłemu wyszukiwa-
niu linowemu — podczas gdy to ostatnie wykonuje się w średnim czasie 0(N), wyszukiwanie
binarne jest operacją o koszcie logarytmicznym 0(log N).
236 Algorytmy. Od podstaw
Wyszukiwanie binarne, zwane też połówkowym, bierze swą nazwę stąd, że w każdym jego
kroku obszar przeszukiwanych danych zmniejszany jest o połowę i postępowanie to konty-
nuowane jest aż do znalezienia żądanego elementu albo stwierdzenia, że element taki jest
w liście nieobecny.
Jeśli jednak chciałbyś znaleźć słowo „lama", najprawdopodobniej otworzysz słownik gdzieś
w pobliżu środka. Właśnie: dlaczego w pobliżu środka, a nie raczej przy końcu? Ano dlatego,
że — j a k doskonale wiesz — hasła ułożone są w słowniku alfabetycznie. Jeżeli, szukając
hasła „lama", otworzysz słownik na haśle „mandarynka", zorientujesz się, że sięgnąłeś za
daleko i musisz cofnąć się kilka (kilkadziesiąt?) stron; analogicznie, gdybyś natrafił na ha-
sło „kangur", wiedziałbyś, że musisz przemieścić się pewną liczbę stron w przód. Tak czy
inaczej, jeśli Twój „strzał" okaże się chybiony, powstaje pytanie, jak daleko należy prze-
mieścić się w przód lub wstecz?
1
Szukany element, o ile w ogóle znajduje się w liście, może znajdować się na jednej z N pozycji
Dziewięcioliterowa A D F H I K L M P
posortowana
rosnąco lista
Rysunek 9.2. 0 1 2 3 4 5 6 7 8
Przeszukiwanie A D F H I K L M P
od elementu
środkowego
Ponieważ litera I nie jest tym elementem, którego szukamy, i jest mniejsza od poszukiwa-
nego klucza (litery K), zawężamy poszukiwanie do prawej połówki (rysunek 9.3).
Rysunek 9.3. o 1 2 3 4
Zawężamy obszar A i D F 1 H I K L M P
poszukiwania
do prawej połówki
Nowy obszar poszukiwania zawiera parzystą liczbę elementów (litery K, L, M, P), nie ma
więc w nim elementu środkowego — są dwa elementy środkowe L i M! Ponieważ „połówki",
na jakie dzielony jest przeszukiwany obszar, nie muszą być dokładnie równe, możemy dowolnie
wybrać jeden z tych dwóch elementów; arbitralnie wybieramy literę L (rysunek 9.4).
Rysunek 9.4. 0 1 2 3
Poszukiwanie A :D F H K L M P
kontynuowane jest
od nowego elementu
„środkowego"
Ponownie nie jest to element, którego szukamy, lecz element od niego większy; poszuki-
wanie zostaje zawężone do tej części obszaru przeszukiwania, która leży na lewo od ele-
mentu L. Ta „część" to pojedyncza litera K (rysunek 9.5). Litera ta jest tą, której szukamy,
poszukiwanie zostaje więc zakończone.
238 Algorytmy. Od podstaw
Rysunek 9.5. 0 1 2 3
Poszukiwanie zostaje A ;D 1 F 1 H
ostatecznie zawężone do
jednego elementu, jest nim
element, którego szukamy
Znak wyniku zwracanego przez metodę searchO rozróżnia więc obydwie sytuacje — zna-
lezienie i nieznalezienie elementu — powstaje jednak problem, jak zanegować wartość 0,
gdy się okaże, że nieobecny w liście element byłby jej pierwszym elementem? Wartości
minus 0 i plus 0 są przecież nierozróżnialne.
J a k to działa?
Interfejs ListSearch posiada jedną metodę search(), której parametry oraz znaczenie zwra-
canego wyniku opisaliśmy przed chwilą. Zwróćmy uwagę, iż do metody tej nie jest przeka-
zywany żaden komparator, mimo że — j a k wcześniej wspominaliśmy — korzysta ona z kom-
paratora w celu porównywania elementów. Otóż zakładamy, że odnośny komparator będzie
wewnętrznym elementem klasy implementującej interfejs ListSearch, a (zaimplementowana)
metoda searchO będzie wykorzystywać go w swym ciele. Umożliwia to oddzielenie samej
logiki wyszukiwania od sposobu wyznaczania porządku porównywanych elementów. Jeśli
nie jest to w tej chwili do końca jasne, stanie się oczywiste przy rozpatrywaniu konkretnych
implementacji.
_searcher = createSearcher(NaturalComparator.INSTANCE):
J i s t = new ArrayList(VALUES):
}
}
J a k to działa?
Metoda inicjacyjna setUpO tworzy wspomnianą instancję wyszukiwarki oraz listę przechowu-
jącą testowane wartości.
Podobnie dla elementu nieobecnego w liście, większego od wszystkich elementów tej listy,
metoda search() powinna zwrócić wynik odpowiadający ostatniej pozycji:
public void testSearchForNonExistingValueGreaterThanLastItem() {
assertEquals(-13, _searcher.search(_list. "Z")):
}
Analogiczny test wykonamy dla elementu nieobecnego w liście, posiadającego jakąś war-
tość pośrednią w stosunku do elementów listy.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 241
J a k to działa?
W pierwszym teście z przeszukiwanej listy _11 st pobierane są (za pomocą metody getO)
kolejne elementy i wyszukiwarce zlecane jest ich wyszukiwanie. Dla każdego elementu
metoda search() wyszukiwarki powinna zwrócić jego własna pozycję. Można by co prawda
pobierać kolejne elementy listy za pomocą iteratora, lecz wówczas musielibyśmy osobno
kontrolować bieżącą pozycję każdego z nich. Dlatego wygodniejszym rozwiązaniem jest
sięganie do explicite wskazanych pozycji za pomocą metody get().
W teście trzecim weryfikujemy w podobny sposób zachowanie się wyszukiwarki dla ele-
mentu Z, większego od wszystkich elementów listy. Element ten po wstawieniu do listy
znajdowałby się na jej końcu, czyli za ostatnim elementem obecnej listy, a więc na pozycji
sizeO liczonej dla bieżącej listy. Metoda searchO powinna wobec tego zwrócić wynik
-(Jist.size()+1) = -(12+1) = -13.
* Konstruktor.
* Parametr: komparator wyznaczający porządek elementów listy
*/
public RecursiveBinaryListSearcher(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
if (cmp < 0) {
index = searchRecursively (list. key. lowerIndex. index - 1);
} else if (cmp > 0) {
index = searchRecursively (list. key, index + 1, upperlndex):
}
return index;
}
public int search(List list. Object value) {
assert list != nuli : "nie określono listy";
J a k to działa?
Jeśli znaleziony element jest większy od szukanej wartości, zmienna cmp przyjmuje wartość
ujemną analogicznie, gdy znaleziony element jest mniejszy od szukanej wartości, zmienna
cmp przyjmuje wartość dodatnią; gdy porównywane wartości są równe, zmienna cmp przyj-
muje wartość 0. Każdy z tych trzech przypadków wymaga odrębnego postępowania.
Gdy szukana wartość okazuje się zbyt mała w stosunku do elementu środkowego (cmp<0),
dalsze poszukiwanie należy prowadzić w obszarze położonym na lewo od tego elementu,
czyli w obszarze ograniczonym elementami na pozycjach lowerIndex i index-l:
if (cmp < 0) {
index = searchdist, key. lowerIndex, index - 1);
Jeżeli natomiast szukana wartość okazuje się zbyt duża w stosunku do elementu środkowe-
go (cmp>0), dalsze poszukiwanie należy prowadzić w obszarze położonym na prawo od tego
elementu, czyli w obszarze ograniczonym elementami na pozycjach index+l i upperlndex:
} else if (cmp > 0) {
index = searchdist, key, index + 1, upperlndex);
}
244 Algorytmy. Od podstaw
Jeśli nie jest spełniony żaden z tych warunków, pozostaje trzecia możliwość — zmienna
cmp ma wartość 0, więc element środkowy jest tym, którego szukamy. Poszukiwanie zostaje
zakończone, nie ma już zagnieżdżonych wywołań metody searchRecursively(). Indeks
elementu środkowego (wartość zmiennej index) zwracany jest jako wynik metody. Przypa-
dek ten jest jednym z bazowych przypadków rekurencji.
Zwróćmy uwagę, że w sytuacji, gdy element środkowy nie jest tym, którego szukamy, ob-
szar poszukiwania zostaje zawężony. Jego indeksy graniczne coraz bardziej zbliżają się do
siebie i może się zdarzyć, że metoda searchRecursively() wywołana zostanie z takimi ich
wartościami, że lowerIndex > upperlndex — takie „skrzyżowanie" indeksów granicznych
oznacza oczywiście obszar pusty. Dalsze poszukiwanie jest już niemożliwe, szukanego
elementu nie ma w liście — to drugi z bazowych przypadków rekurencji.
J
Rozdział 9. • Binarne wyszukiwanie i wstawianie 245
/•k*
* Konstruktor.
* Parametr: komparator wyznaczający porządek elementów listy
*/
public IterativeBinaryListSearcher(Comparator comparator) {
assert comparator !- nuli : "nie określono komparatora":
_comparator = comparator;
}
public int search(List list. Object key) {
assert list != nuli : "nie określono listy";
int lowerIndex = 0:
int upperlndex = list.sizeO - 1:
if (cmp = 0) {
return index;
} else if (cmp < 0) {
upperlndex = index - 1;
} else {
lower!ndex = index + 1;
J a k to działa?
}
return -OowerIndex + 1);
Warunkiem zakończenia pętli może być jedna z dwóch sytuacji: skrzyżowanie indeksów gra-
nicznych (lowerIndex > upperlndex) albo znalezienie żądanego elementu. W tym pierwszym
przypadku, podobnie jak w rekurencyjnej wersji wyszukiwarki, lewy indeks graniczny wskazuje
miejsce wstawienia nieobecnego elementu i metoda zwraca wynik zgodny z przyjętą kon-
wencją:
return -(lowerIndex + 1):
Jeśli nie zachodzi żadna z dwóch wymienionych sytuacji, wyliczany jest indeks elementu
środkowego, po czym wartość tego elementu porównywana jest z szukaną wartością.
int index = lowerIndex + (upperlndex - lowerIndex) / 2:
int cmp = _comparator.compare(key. list.get(index)):
2
Autorzy pomijają tu milczeniem niezwykle istotny fakt, iż rekurencja występująca w metodzie
searchRecursi vely() jest koronnym przykładem rekurencji końcowej (taił recursion), która daje
się w sposób niemal mechaniczny przekształcić na r ó w n o w a ż n ą j e j rekurencję — mechaniczny
do tego stopnia, iż niektóre kompilatory wykonują to przekształcenie automatycznie. Czytelnikom
zainteresowanym zagadnieniem eliminacji rekurencji końcowej i jej praktycznymi przykładami
polecamy m.in. książkę A.V. Aho, J.E. Hopcrofta i J.D. Ullmana Algorytmy i struktury danych
(http://helion.pl/ksiazki/alstrd.htm) oraz książkę R. Stephensa Algorytmy i struktury danych
z przykładami w Delphi (http://helion.pl/ksiazki/algdel.htm) —przyp. tłum.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 247
Gdy porównywane wartości okażą się równe, pętla przerywa swą pracę, co przed chwilą
wyjaśniliśmy. Jeśli poszukiwana wartość okaże się mniejsza od wartości elementu środko-
wego, obszar przeszukiwania zostaje zawężony do lewej połówki:
} else if (cmp < 0) {
upperlndex = index - 1;
jeśli natomiast poszukiwana wartość okaże się większa od wartości elementu środkowego,
obszar przeszukiwania zostaje zawężony do połówki prawej:
} else {
lowerIndex - index + 1:
* Konstruktor.
* Parametr: komparator wyznaczający porządek porównywanych elementów
*/
public LinearListSearcher(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
}
public int search(List list. Object key) {
assert list != nuli : "nie określono listy";
int index = 0;
Iterator i = list.iteratorO;
J a k to działa?
Metoda searchO stanowi w dużej mierze kopię metody indexOf() z rozdziału 2., jednak
z kilkoma zmianami. Pierwszą z nich jest porównywanie elementów za pomocą komparatora,
zamiast za pomocą metody equals(); gdy porównywane elementy są równe, metoda compa-
re() komparatora zwraca wartość 0 i poszukiwanie elementu można uznać za zakończone:
int cmp = _comparator.compare(key. i .currentO);
if (cmp = 0) {
return index;
Poza tymi dwiema zmianami metoda searchO nie różni się już niczym od metody indexOf().
Testowanie wydajności
Mimo iż naszym obecnym zadaniem nie jest weryfikowanie poprawności stworzonych im-
plementacji wyszukiwarek, lecz porównywanie ich wydajności, skorzystamy z biblioteki
JUnit z tego względu, iż bardzo dobrze nadaje się ona do tworzenia środowiska dla takiej
analizy — wyjaśnialiśmy już tę kwestię w rozdziale 6., przy okazji analizy porównawczej
prostych algorytmów sortowania. Każdą z trzech opisywanych wcześniej wyszukiwarek —
rekurencyjną, iteracyjną i sekwencyjną — obarczymy identycznym zadaniem polegającym
na wyszukiwaniu elementów w (tej samej) posortowanej liście.
Podobnie jak w kilku poprzednich analizach tego rodzaju za miarę wydajności wyszukiwa-
nia przyjmiemy nie czas jego wykonywania, lecz liczbę wykonywanych w ramach niego
porównań, udostępnianą przez komparator zliczający.
J a k to działa?
Mając już opisaną wyżej „bazę testową", można przystąpić do tworzenia metod mierzących
wydajność każdej z wyszukiwarek.
J a k to działa?
W pierwszym z testów dla każdej wyszukiwarki tworzona jest posortowana lista liczb cał-
kowitych, po czym każda z nich wyszukiwana jest (w metodzie performInOrderSearch())
w kolejności posortowania (inorder).
Wyniki te podsumowano w tabeli 9.1, uzyskując tym samym czytelniejsze porównanie różnych
metod wyszukiwania (ze względu na losowy charakter danych wartości w drugim i trzecim
wierszu mogą się różnić w kolejnych analizach).
Najważniejszym i najbardziej interesującym wnioskiem z całej tej analizy jest natomiast za-
sadnicza różnica między wydajnością wyszukiwarki sekwencyjnej a każdej z wyszukiwarek
binarnych. Średnia liczba porównań przypadających na 1 element listy — 9 w przypadku
wyszukiwania binarnego i ponad 500 w wyszukiwaniu sekwencyjnym — stanowi potwier-
dzenie oczekiwanej złożoności każdego z tych procesów: <9(log N) dla wyszukiwania bi-
narnego i 0(N) dla wyszukiwania sekwencyjnego.
Zachwycając się wspaniałą wydajnością wyszukiwania binarnego, nie można jednak zapo-
minać o pewnym istotnym fakcie. Jako że wyszukiwanie to wiąże się z „wyrywkowym"
dostępem do elementów sortowanej listy, warunkiem sine qua non owej wydajności jest
więc zapewnienie szybkiego dostępu do dowolnego elementu na podstawie jego indeksu.
Dostęp taki zagwarantowany jest w przypadku listy tablicowej; w przypadku listy wiązanej
liczba porównań pozostanie co prawda taka sama, lecz ze względu na znacznie mniej efektywną
realizację metody getO — wymagającą ciągłego, czasochłonnego nawigowania wśród ele-
mentów — czas samego wyszukiwania znacząco się wydłuża.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 253
Wstawianie binarne
Wstawianie binarne (bindry insertioń) jest techniką pokrewną wyszukiwaniu binarnemu
umożliwiającą utrzymanie zawartości listy w postaci posortowanej przy dodawaniu do niej
nowych elementów. Oczywiście cel ten można by osiągnąć w inny sposób, dokonując sor-
towania listy (przy użyciu jednego z opisanych wcześniej algorytmów) po każdym dodaniu
do niej elementu, byłoby to jednak posunięcie niezwykle czasochłonne. Nawet jednokrotne
posortowanie listy po zakończeniu dodawania do niej całej serii elementów także jest roz-
wiązaniem mniej efektywnym niż wstawianie elementów od razu na ich właściwe pozycje.
Powróćmy do rysunku 9.1 i wyobraźmy sobie dodawanie litery G do widocznej na nim li-
sty. Jak poprzednio identyfikujemy element środkowy — jest nim litera I. Ponieważ jest
ona „większa" od wstawianej litery G, ta ostatnia musi zostać wstawiona gdzieś w zakresie
pierwszej połówki.
Rysunek 9.6. 0 1 2 3 4 5 6 7 8
Zawężenie A D F H K JL -W P
potencjalnego obszaru — —
wstawienia elementu do
pierwszej połówki listy
Elementem „środkowym" tego obszaru jest litera F (rysunek 9.7), mniejsza niż wstawiana
litera G; potencjalny obszar, w którym można wstawić nowy element, skurczył się do jed-
nego elementu H (rysunek 9.8).
Rysunek 9.7. 0 1 2 3 4 5 6 7 8
Kolejne zawężenie A D F H
obszaru
Rysunek 9.8. 0 1 2 3 4 5 6 7 8
Zawężenie obszaru A D H K 1 M P
wstawiania do jednego
elementu
Nie można już dalej zawężać obszaru wstawiania; ponieważ wstawiany element (G) jest
mniejszy od bieżącego (H), trzeba go wstawić do listy tak, by znalazł się na lewo od tego
ostatniego. Trzeba w tym celu zrobić miejsce, przesuwając w prawo wszystkie elementy
począwszy od litery H (rysunek 9.9).
254 Algorytmy. Od podstaw
Rysunek 9.9. o 1 2 3 4 5 6 7 8 9
Wstawianie A :D i K t. V, P
elementu w sposób
niezaburzający
posortowanego
charakteru listy
Skoro wiemy już, na czym polega wstawianie binarne, zajmijmy się teraz szczegółami wstawia-
nia elementu do posortowanej listy.
Inserter binarny
W niniejszym punkcie przedstawimy realizację prostej klasy wykonującej wstawianie ele-
mentu do posortowanej listy na odpowiednią jej pozycję — tak, by lista pozostała posorto-
wana. Wykorzystamy przy tym fragmenty kodu wyszukiwarki binarnej w celu znalezienia
pozycji dla wstawianego elementu — po cóż bowiem na nowo wynajdywać koło?
_J
Jak to działa?
Podstawowymi elementami klasy testowej są dwie instancje — insertera oraz listy, do któ-
rej wstawiane są elementy. Obydwie te instancje inicjowane są przez metodę setUpO.
Metoda verify() wywoływana jest na zakończenie każdej sesji wstawiania w celu upew-
nienia się, że elementy listy istotnie występują w kolejności posortowanej (rosnącej). Fakt
ten weryfikowany jest bardzo prosto: za pomocą iteratora pobierane są z listy kolejne ele-
menty i dla każdego z nich sprawdza się, czy nie jest mniejszy od elementu poprzedniego.
Dla pierwszego elementu „zastępczą" wartością „poprzedniego" elementu jest minimalna
wartość typu Integer (Integer.MINJ/ALUE) i opisane sprawdzenie zawsze daje wynik po-
zytywny — wszak pierwszy element listy zawsze znajduje się „na swoim miejscu".
256 Algorytmy. Od podstaw
W drugim teście wstawianie wartości odbywa się w kolejności malejącej. Wstawiany ele-
ment jest zawsze mniejszy od elementów już obecnych w liście, powinien więc zostać wsta-
wiony na pozycję 0, co weryfikowane jest za pomocą stosownej asercji. Obecne w liście
elementy przesuwane są o jedną pozycję do przodu i choć ich względna kolejność nie po-
winna się zmieniać, to jednak nie mamy co do tego pewności, dlatego na zakończenie testu
wywoływana jest metoda verify().
Trzeci test różni się od dwóch poprzednich tym, że wstawiane do listy elementy mają war-
tość losową. Wylosowana wartość rzeczywista nie mniejsza niż zero i mniejsza od 1.0 mno-
żona jest przez TEST SIZE, w rezultacie czego po obcięciu iloczynu do liczby całkowitej
otrzymujemy losową wartość całkowitą z przedziału 0 + TEST_SIZE-1. Określenie a priori
pozycji, jaką powinien zająć w liście tak wygenerowany element, jest ogólnie niemożliwe
i dlatego jedynym sposobem zweryfikowania poprawności wstawiania jest wywołanie me-
tody verify().
* Konstruktor.
* Parametr: instancja pomocniczej wyszukiwarki elementów
*/
public ListInserter(ListSearcher searcher) {
assert searcher != nuli : "nie określono wyszukiwarki":
_searcher = searcher;
}
/**
if (index < 0) {
index - -(index + 1 ) :
}
list.insert(index, value):
return index;
Jak to działa?
Jak łatwo zauważyć, klasa insertera posługuje się pomocniczą wyszukiwarką, której zada-
niem jest określenie pozycji dla wstawianego elementu — binarne wstawianie niczym się
bowiem nie różni pod tym względem od wyszukiwania binarnego.
Porównywanie wydajności
Mając gotową klasę realizującą wstawianie binarne, warto skonfrontować wydajność tego
wstawiania z alternatywnym podejściem polegającym na jawnym sortowaniu listy za po-
mocą rozmaitych algorytmów opisywanych w rozdziałach 6. i 7. Być może wstawienie
wszystkich elementów w przypadkowej kolejności i jednorazowe posortowanie listy okaże
się bardziej efektywne niż binarne wstawianie każdego z elementów?
private List J i s t :
private CallCountingComparator _comparator:
J i s t - new ArrayList(TESTJIZE):
_comparator = new CallCountingComparator(NaturalComparator.INSTANCE):
}
}
W pierwszym teście dokonamy binarnego wstawienia losowo wygenerowanych elementów
do listy, mierząc jednocześnie liczbę porównań wykonywanych przez inserter.
public void testBi nary InsertO {
Listlnserter inserter - new Listlnserter(
new IterativeBinaryListSearcher(_comparator));
reportCallsO:
}
Jak to działa?
Klasa testowa BinarylnsertCallCountingTest zawiera testową listę, do której wstawiane są
elementy, oraz instancję komparatora wyznaczającego porządek porównywanych i sorto-
wanych elementów. Podobnie jak w przypadku porównywania wyszukiwarek lista ma or-
ganizację tablicową a wspomniany komparator umożliwia zliczanie porównań dokonywa-
nych w związku ze wstawianiem binarnym i sortowaniem.
W metodzie testBi nary Insert najpierw tworzona jest instancja binarnej wyszukiwarki ite-
racyjnej (w przeciwieństwie do wersji rekurencyjnej jest ona wolna od kłopotliwych na-
rzutów czasowych związanych z zagnieżdżonymi wywołaniami), po czym za jej pomocą
określane są pozycje dla kolejno wstawianych, generowanych losowo elementów (w spo-
sób opisany w poprzednim podpunkcie „Jak to działa?"). Po zakończeniu wstawiania wy-
woływana jest metoda reportCallsO wyświetlająca raport o liczbie wykonanych porów-
nań w postaci:
<nazwa_testu>: <liczba_porównań> wywołań
Ich podsumowanie znajduje się w tabeli 9.2 (ze względu na losowe wartości generowanych
elementów wyniki kolejnych analiz mogą różnić się od prezentowanych).
Zawartość tabeli 9.2 jest dobitnym świadectwem wyraźnej przewagi wstawiania binarnego
nad końcowym sortowaniem elementów wstawianych do listy bez zachowywania porząd-
ku. Najbardziej zbliżoną wydajność przejawia metoda sortowania przez łączenie, nie należy
jednak zapominać, że cechuje się ona zwiększonym zapotrzebowaniem na pamięć (kopia
listy wejściowej), zgodnie z opisem z rozdziału 7. Algorytmy Shellsort i Quicksort pozo-
stają daleko w tyle.
Tabela 9.3. Porównanie wydajności dołączania 4091 elementów przy sortowaniu po dołączeniu każ-
dego elementu
Na zakończenie, by być w pełni uczciwym, nie można nie zwrócić uwagi na pewien dość
istotny szczegół. Otóż we wszystkich przykładach dotyczących wyszukiwania i wstawiania
binarnego wykorzystywaliśmy listę w postaci tablicowej, ta bowiem zapewnia natychmia-
stowy dostęp do elementu na podstawie jego indeksu (w przeciwieństwie do np. listy wią-
zanej). Jak jednak pamiętamy z poprzednich rozdziałów, wstawianie elementu do tablicy
wiąże się z koniecznością przesuwania sporych nieraz porcji elementów, co przy dużej ich
liczbie może powodować wyraźne pogorszenie wydajności. W przeciwieństwie do wsta-
wiania binarnego niektóre algorytmy sortowania — j a k Quicksort czy Mergesort — są na
ten efekt zupełnie niewrażliwe.
Podsumowanie
Czytając niniejszy rozdział, można było poznać kilka interesujących faktów:
• Wyszukiwanie binarne jest algorytmem typu „dziel i zwyciężaj", a zlokalizowanie
elementu o danym kluczu, obecnego w 7V-elementowej liście, wymaga średnio
O(logN) porównań.
• Wyszukiwanie binarne można zaimplementować zarówno w wersji rekurencyjnej,
jak i iteracyjnej.
• Wyszukiwanie binarne nadaje się idealnie dla struktur danych zapewniających
szybki dostęp do elementu na podstawie jego indeksu.
• Wstawianie binarne, zrealizowane w oparciu o wyszukiwanie binarne, wymaga
średnio 0{N log N) porównań dla wstawienia N elementów do pustej listy.
• Binarne wstawianie elementów do listy wykazuje znaczącą przewagę nad
sortowaniem całej listy, a zwłaszcza sortowaniem jej po dołączeniu każdego
elementu.
262 Algorytmy. Od podstaw
10
Binarne drzewa wyszukiwawcze
W rozdziale 9. opisywaliśmy algorytmy efektywnego wyszukiwania informacji w posorto-
wanej liście tablicowej. Tablicowa implementacja listy ma jednak ten mankament, że
wstawianie do niej i usuwanie z niej elementów wiąże się z (pracochłonnym) przesuwaniem
dość dużych porcji danych. Nie mają tej wady binarne drzewa wyszukiwawcze (binary se-
arch trees), w których nie tylko wyszukiwanie, ale i wstawianie oraz usuwanie elementów wy-
konywane jest w średnim czasie 0(log N) bez dodatkowego nakładu pracy. Zapamiętywanie
więc elementów w strukturze drzewiastej — gdzie są one odpowiednio ze sobą połączone
— umożliwia ich efektywne usuwanie i wstawianie nowych.
Liście
Minimum
Minimum drzewa binarnego nazywamy węzeł o najmniejszej wartości. Zgodnie z warun-
kiem spełnianym przez binarne drzewo wyszukiwawcze wyszukiwanie w nim minimum
nie może już być prostsze: wychodząc od korzenia, poruszamy się po ścieżce utworzonej
przez lewych synów aż do napotkania liścia. Innymi słowy, w binarnym drzewie wyszuki-
wawczym minimum stanowi jego skrajnie lewy węzeł.
Maksimum
Analogicznie maksimum drzewa binarnego nazywamy węzeł o największej wartości. Po-
szukiwanie maksimum w binarnym drzewie wyszukiwawczym jest podobne do poszuki-
wania minimum: wychodząc od korzenia, poruszamy się po ścieżce utworzonej przez pra-
wych synów aż do napotkania liścia. Innymi słowy, w binarnym drzewie wyszukiwawczym
maksimum stanowi jego skrajnie prawy węzeł.
Można się o tym przekonać w drzewie z rysunku 10.1. Wychodząc z węzła I, trafiamy w końcu
na liść P — największą alfabetycznie literę w drzewie.
Następnik
Następnikiem (successor) danego węzła w drzewie nazywamy węzeł o wartości bezpośred-
nio większej. W drzewie pokazanym na rysunku 10.1 następnikiem węzła A jest węzeł D,
następnikiem węzła H — węzeł I, zaś następnikiem węzła I —węzeł K. Poszukiwanie na-
stępnika nie jest takie trudne, lecz należy w związku z nim wyróżnić dwa oddzielne przy-
padki.
Pierwszy przypadek stanowi sytuacja, gdy węzeł posiada prawego syna. Następnikiem takiego
węzła jest wówczas minimum poddrzewa, którego korzeń stanowi ów prawy syn. W drzewie
pokazanym na rysunku 10.1 następnikiem węzła I jest minimum drzewa o korzeniu L,
czyli węzeł K. To samo dotyczy węzła L: posiada on prawego syna M, który jednocześnie
stanowi minimum poddrzewa o korzeniu M.
W drugim przypadku, gdy węzeł nie posiada prawego syna — j a k w przypadku litery H —
sprawa jest trochę bardziej skomplikowana. Musimy mianowicie poruszać się „w górę" od
danego węzła (czyli po ścieżce wyznaczanej przez kolejnych ojców) tak długo, aż napo-
tkamy „skręt w prawo", czyli natrafimy na węzeł, który jest (czyimś) lewym synem; ojciec
tego ostatniego jest szukanym następnikiem. W przypadku litery H wspomniana ścieżka
prowadzi przez węzeł F do węzła D, który jest lewym synem węzła I — ten ostatni to wła-
śnie szukany następnik węzła H.
266 Algorytmy. Od podstaw
Poprzednik
Poprzednikiem {precedessor) danego węzła w drzewie nazywamy węzeł o wartości bezpo-
średnio mniejszej. W drzewie z rysunku 10.1 poprzednikiem węzła P jest węzeł M, po-
przednikiem węzła F — węzeł D, zaś poprzednikiem węzła I — węzeł H.
W drzewie z rysunku 10.1 węzeł F nie posiada lewego syna; poruszając się w górę drzewa
stwierdzamy, że węzeł ten sam już jest prawym synem — prawym synem węzła D, który
tym samym jest jego poprzednikiem.
Szukanie
Poszukując określonej wartości w binarnym drzewie wyszukiwawczym, rozpoczynamy
wędrówkę od korzenia i posuwamy się w głąb drzewa, wybierając odpowiednio lewe albo
prawe połączenia na każdym poziomie. Postępowanie to kończy się w przypadku znalezie-
nia szukanej wartości albo natrafienia na liść. Proces ten można podsumować następująco:
1. Rozpocznij od korzenia.
2. Jeśli wędrówka zaprowadziła Cię donikąd, to znaczy jeśli nie jest określony
bieżący węzeł, szukanej wartości nie ma w drzewie — proces szukania kończy się.
W przeciwnym razie przejdź do punktu 3.
3. Porównaj szukaną wartość z kluczem bieżącego węzła.
4. Jeśli porównywane wartości są równe, bieżący węzeł jest tym, którego szukamy,
a szukania kończy się. W przeciwnym razie przejdź do punktu 5.
5. Jeśli szukana wartość okazuje się mniejsza niż klucz bieżącego węzła,
spróbuj przejść po lewym łączu do lewego syna i uczyń go bieżącym węzłem.
Następnie przejdź do punktu 2.
6. Jeśli szukana wartość okazuje się większa niż klucz bieżącego węzła, spróbuj
przejść po prawym łączu do prawego syna i uczyń go bieżącym węzłem.
Następnie przejdź do punktu 2.
Rozpoczynamy od korzenia (krok 1.), porównując szukany klucz K z kluczem I, jak przed-
stawia to rysunek 10.2.
Ponieważ K > I, przechodzimy wzdłuż prawego łącza do węzła L (krok 6.), jak na rysunku 10.3.
Rozdział 10. • Binarne drzewa wyszukiwawcze 267
Rysunek 10.2.
Poszukiwanie
rozpoczyna się
zawsze od korzenia
Rysunek 10.3.
Jeśli szukana
wartość jest
większa od klucza
bieżącego węzła,
schodzimy
w głąb drzewa
po prawym łączu
Szukana wartość (K) jest teraz mniejsza od klucza bieżącego węzła (L), schodzimy więc
w głąb po lewym łączu (krok 5.), jak na rysunku 10.4.
Rysunek 10.4.
Jeśli szukana
wartość jest
mniejsza od klucza D
bieżącego węzła,
schodzimy / \
w głąb drzewa M
po lewym łączu
H P
Odnaleźliśmy w ten sposób szukaną wartość (krok 4.), wykonując trzy porównania w drzewie
złożonym z dziewięciu węzłów. Nieprzypadkowo liczba porównań równa jest wysokości
drzewa.
Przy każdym zejściu w głąb drzewa — wzdłuż lewego albo prawego łącza — z poszukiwa-
nia wyeliminowana zostaje połowa pozostałych jeszcze węzłów. Przypomina to jako żywo
wyszukiwanie binarne w posortowanej liście; istotnie, każda posortowana lista może być
odwzorowana w wyważone drzewo binarne, jak na rysunku 10.5.
Rysunek 10.5.
i
Posortowana lista A D F H 1 K L M P
i jej odpowiednik
w postaci
wyważonego
binarnego drzewa
wyszukiwawczego
Wstawianie
Wstawianie węzłów do drzewa binarnego jest niemal identyczne z ich wyszukiwaniem z tą
jednak różnicą, że jeżeli klucz wstawianego węzła nie występuje jeszcze w drzewie, węzeł
ten zostaje wstawiony do drzewa jako liść. Gdybyśmy chcieli wstawić węzeł J do drzewa
widocznego na rysunku 10.5, musielibyśmy, począwszy od węzła K, poruszać się wzdłuż
lewych łączy aż do napotkania węzła pozbawionego lewego syna; lewym synem należy
wówczas uczynić węzeł wstawiany. Ponieważ już węzeł K nie posiada lewego syna, więc
wstawiany węzeł J staje się jego lewym synem. Wygląd drzewa po wykonaniu tej operacji
przedstawia rysunek 10.6.
Rysunek 10.6.
Wstawianie
nowo utworzonego
węzła do drzewa
binarnego
Pamiętając, że nowo dodawane węzły zawsze stają się liśćmi oraz że węzły o kluczach
większych od kluczy swych ojców stają się ich prawymi synami, w efekcie wykonania opi-
sanej operacji otrzymamy drzewo skrajnie niewyważone, widoczne na rysunku 10.7.
Rysunek 10.7.
Niewyważone
drzewo powstałe
wskutek dodawania
węzłów w kolejności
uporządkowanej
Takie zdegenerowane drzewo jest w istocie listą wiązaną, a jego wysokość — a co za tym
idzie średni czas wyszukiwania — stają się proporcjonalne do liczby węzłów ( 0(N)).
Jednak nie wszystko wówczas stracone. Istnieje wiele odmian drzew binarnych, w których
wyważenie przywracane jest automatycznie: mowa tu m.in. o drzewach czerwono-czarnych,
drzewach AVL, drzewach rozchylanych (splay trees) itp. — wszystkie te struktury im-
plementują złożone operacje restrukturyzacji w celu przywrócenia co najmniej przybliżone-
go wyważenia, a ich omówienie wykraczałoby poza zakres niniejszej książki. W rozdziale 15.
opisujemy jednak jednąz interesujących odmian drzew wyszukiwawczych — B-drzewa.
Usuwanie
Usuwanie węzła z drzewa binarnego jest nieco bardziej skomplikowane niż wyszukiwanie
i wstawianie węzłów. Węzeł przeznaczony do usunięcia może znajdować się w jednym z trzech
następujących stanów:
• jest liściem (czyli nie posiada synów) — można go wówczas po prostu usunąć,
• posiada jednego syna (tylko lewego lub tylko prawego), który zajmuje miejsce
usuwanego ojca,
• posiada dwóch synów; należy go wówczas zastąpić jego następnikiem, w wyniku
czego znajdzie się on w jednym ze stanów opisanych wyżej.
Najprostszym przypadkiem jest oczywiście usunięcie liścia. Ponieważ liść nie posiada sy-
nów, wystarczy po prostu zerwać jego połączenie z ojcem. Na rysunku 10.8 przedstawiono
usuwanie węzła H.
Rysunek 10.8.
Liść usuwany jest
z drzewa poprzez
zerwanie jego
połączenia z ojcem
Trochę bardziej skomplikowane jest usuwanie węzła posiadającego dokładnie jednego sy-
na. Łącza prowadzące do ojca i syna tego węzła zostają „sklejone", w wyniku czego ojciec
usuwanego węzła staje się ojcem swego dotychczasowego wnuka. Na rysunku 10.9 przed-
stawiono usuwanie litery M: łącza prowadzące od L do M oraz od M do P zostały sklejone
w jedno łącze prowadzące od L do P.
Rysunek 10.9.
Usuwanie węzła
posiadającego
tylko jednego syna
polega na sklejeniu
łączy wychodzących
z tego węzła
Usuwanie węzła posiadającego obydwu synów jest już bardziej wyrafinowane Wyobraźmy
sobie na przykład usuwanie korzenia (węzła I) z drzewa widocznego na rysunku 10.1: który
węzeł powinien zająć miejsce węzła usuwanego? W pierwszej chwili mogłoby się wyda-
wać, że można użyć w tym celu jednego z węzłów D lub L — warunek binarnego drzewa
wyszukiwawczego nie zostałby naruszony: każdy z tych węzłów posiada jednak dwóch sy-
nów, nie można więc sprowadzać jego usuwania do prostego sklejania łączy.
W związku z tym pierwszym krokiem usuwania węzła posiadającego dwóch synów jest
znalezienie jego następnika; węzeł zostaje następnie zamieniony miejscami z tym następni-
kiem. Jak widać na rysunku 10.10, usuwanie węzła I polega na zamianie jego wartości
zwartością jego następnika K. Zwróćmy uwagę, że operacja ta prowadzi do chwilowego
naruszenia warunku binarnego drzewa wyszukiwawczego.
Rysunek 10.10.
Wartość usuwanego
węzła zostaje
zamieniona
z wartością
jego następnika
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 271
Zauważmy, że na skutek wymiany wartości między węzłami usunięty musi być teraz nie
węzeł przeznaczony pierwotnie do usunięcia (ten mający poprzednio wartość I, a teraz
wartość K), lecz węzeł, z którym ten wymienił swoją wartość (obecny węzeł I). Mamy
więc do czynienia z jednym z dwu poprzednich przypadków. Oryginalnie usuwany węzeł
posiadał dwóch synów, a więc posiadał prawego syna. Następnikiem węzła posiadającego
prawego syna jest minimum (skrajny lewy węzeł) w poddrzewie, którego ten prawy syn
jest korzeniem. Wspomniany następnik albo więc jest liściem, albo posiada przynajmniej
prawego syna (gdyby posiadał lewego syna, z definicji nie mógłby stanowić minimum).
Ostatecznie więc drugim etapem opisywanej operacji jest usunięcie węzła I z drzewa poka-
zanego na rysunku 10.10, co prowadzi do drzewa widocznego na rysunku 10.11. Zamiast
następnika usuwanego węzła równie dobrze można by użyć w opisanej roli także poprzed-
nika tego węzła.
Rysunek 10.11.
Węzeł-następnik
został usunięty
Usuwanie elementów może naruszać wyważenie drzewa, czcgo konsekwencją może być
znaczące pogorszenie wydajności: podobnie jak dodawanie uporządkowanych danych, tak-
że usuwanie danych w kolejności uporządkowanej łatwo doprowadzić może do degeneracji
drzewa pokazanej na rysunku 10.7. Na rysunku 10.12 widoczne jest drzewo powstałe w wyniku
usunięcia z oryginalnego drzewa z rysunku 10.1 węzłów (kolejno) A, D, F i H.
Rysunek 10.12.
Niewyważone
drzewo powstałe
w wyniku
uporządkowanego
usunięcia węzłów
Trawersacja in-order
Trawersacją (przechodzeniem) drzewa nazywamy wykonanie określonej czynności dokładnie
raz na każdym jego węźle, w pewnej ustalonej kolejności „odwiedzania" poszczególnych
węzłów.
W efekcie powyższy scenariusz oznacza przejście metodą in-order przez drzewo, którego
węzeł Xjest korzeniem.
Trawersacja pre-order
W kolejności pre-order odwiedzamy najpierw korzeń drzewa, a następnie przechodzimy
w kolejności pre-order kolejno przez jego lewe i prawe poddrzewo. W odniesieniu do drzewa
z rysunku 10.1 oznacza to kolejność odwiedzania węzłów I, D, A, F, H, L, K, M, P.
Podobnie jak w przypadku kolejności in-order realizacja trawersacji pre-order posiada na-
turalną implementację rekurencyjną. Dla każdego węzła X wykonany zostanie następujący
scenariusz:
1. Odwiedź węzeł X.
2. Przejdź przez lewe poddrzewo węzła X w kolejności pre-order.
3. Przejdź przez prawe poddrzewo węzła X w kolejności pre-order.
Aby przejść przez drzewo w kolejności pre-order, należy wykonać ten scenariusz, podsta-
wiając za X korzeń tego drzewa.
Trawersacja post-order
W kolejności post-order przechodzimy najpierw przez lewe poddrzewo korzenia, potem
przez prawe (w obydwu przypadkach w kolejności post-order), po czym odwiedzamy sam
korzeń. W odniesieniu do drzewa z rysunku 10.1 oznacza to odwiedzanie węzłów w kolej-
ności A, H, F, D, K, P, M, I, L.
Podobnie jak trawersacja pre-order także trawersacja post-order posiada naturalną imple-
mentację rekurencyjną— dla każdego węzłaXwykonany zostaje następujący scenariusz:
1. Przejdź przez lewe poddrzewo węzła X w kolejności post-order.
2. Przejdź przez prawe poddrzewo węzła X w kolejności post-order.
3. Odwiedź węzeł X.
Wyważanie drzewa
Jak wyjaśnialiśmy wcześniej, wstawianie i (lub) usuwanie elementów w drzewie binarnym
może naruszać jego wyważenie, aż do kompletnej degeneracji w postaci listy wiązanej
(patrz rysunek 10.7). Zwykle odbija się to w niekorzystny sposób na efektywności operacji
wykonywanych na drzewie i z tego względu wymaga podjęcia pewnych zabiegów zarad-
czych. Zabiegi te, zwane ogólnie wyważaniem (balancing) drzewa, polegają na przekształ-
caniu drzew binarnych do postaci równoważnej, lecz cechującej się mniejszą wysokością
(która, jak uprzednio wyjaśnialiśmy, jest głównym wyznacznikiem kosztu operacji wyko-
nywanych na drzewie). Mimo iż wyczerpujący opis różnych metod wyważania drzew bi-
narnych wykracza poza ramy niniejszej książki, ze względu na ich znaczenie ograniczymy
się do ich krótkiego podsumowania, choć nie będziemy prezentować żadnych konkretnych
przykładów1.
1
Czytelników zainteresowanych szczegółami wyważania drzew i ich implementacją w Delphi
odsyłamy do rozdziału 7. książki R. Stephensa Algorytmy i struktury danych z przykładami
w Delphi, Helion 2000 (http://helion.pl/kiiazki/algdel.htm) — przyp. tłum.
274 A l g o r y t m y . Od podstaw
Na rysunku 10.13 wysokości poddrzew węzła I (korzenia) różnią się o 2, w świetle powyż-
szego kryterium widoczne tam drzewo jest niewyważone.
+2
Rysunek 10.13.
Różnica wysokość
obydwu poddrzew
korzenia jest
większa niż 1.
Gdy drzewo binarne okaże się drzewem niewyważonym, należy jego wyważenie przywró-
cić — no właśnie, jak? Związane z tym elementarne operacje nazywane są rotacjami i wy-
konywane są począwszy od wstawionego/usuwanego węzła w kierunku korzenia. Zależnie
od natury zaistniałego niewyważenia stosowane są cztery typy rotacji: w lewo i w prawo,
każda w wariancie pojedynczym i podwójnym. Stosowalność każdej z nich do konkretnej
przyczyny niewyważenia podsumowano krótko w tabeli 10.1.
Rodzaj przeciążenia Gdy syn zrównoważony Gdy syn przeciążony w lewo Gdy syn przeciążony w prawo
Prawe poddrzewo węzła I na rysunku 10.13 ma wysokość większą niż lewe, dlatego węzeł
ten nazywamy węzłem przeciążonym w prawo (right-heavy). Jego syn (węzeł L) jest zrów-
noważony (wysokości jego poddrzew są identyczne). Zgodnie z tabelą 10.1 powinniśmy
wykonać pojedynczą rotację „promującą" węzeł L i degradującą węzeł I, otrzymując w re-
zultacie drzewo przedstawione na rysunku 10.14.
Rysunek 10.14.
Przywrócona
własność drzewa
AVL w wyniku
pojedynczej rotacji
W drzewie widocznym na rysunku 10.15 wymagana jest rotacja podwójna, bowiem korzeń (I)
jest przeciążony w prawo, zaś jego syn (L) — przeciążony w lewo. Sytuacja taka mogła
zaistnieć na przykład bezpośrednio po wstawieniu węzła K.
Rysunek 10.15. +2
Drzewo wymagające
dwóch rotacji
w celu wyważenia
K
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 275
Rysunek 10.16. +2
Pierwsza rotacja
przesuwa węzeł-syna
L w prawo, druga
przesuwa przeciążony
węzeł I w lewo
Mimo iż drzewa AVL nie gwarantują doskonałego wyważenia, to jednak okazują się struktu-
rami o zadziwiająco dużej efektywności 2 . Przykładowo, znalezienie węzła w doskonale
wyważonym drzewie złożonym z miliona węzłów wymaga wykonania średnio log2l 000 000
« 20 porównań, podczas gdy w drzewie AVL liczba ta wynosi średnio 1,44 * log 2 l 000 000
« 28. To i tak wspaniały wynik w porównaniu z 500 000 porównań potrzebnych (średnio)
do znalezienia żądanego węzła w takim samym drzewie zdegenerowanym do listy wiązanej.
Testowanie i implementowanie
binarnych drzew wyszukiwawczych
W książce traktującej o algorytmach nie sposób uniknąć konkretnego kodu źródłowego,
nawet w rozdziale o charakterze raczej teoretycznym. Jak zwykle rozpoczniemy od skon-
struowania zestawu testowego dla drzew binarnych, po czym zajmiemy się ich implemen-
towaniem. Przedmiotem tej implementacji będą dwie klasy: pierwsza z nich — Node — re-
prezentować będzie pojedynczy węzeł drzewa, druga — BinarySearchTree — stanowić
będzie de facto otoczkę wokół korzenia drzewa binarnego i odpowiedzialna będzie za wy-
konywanie na tym drzewie operacji searchO, deleteO i insertO.
Ponieważ węzły drzewa są jego budulcem, wiec klasa BinarySearchTree nie może funkcjo-
nować w oderwaniu od klasy Node i to właśnie od tej ostatniej rozpoczniemy tworzenie ze-
stawu testowego.
2
Zgodnie z twierdzeniem udowodnionym przez Velskiego i Landisa wysokość drzewa AVL może
być co najwyżej o 45% większa od wysokości doskonale wyważonego drzewa binarnego złożonego
z tych samych węzłów — p r z y p . tłum.
276 Algorytmy. Od podstaw
import junit.framework.TestCase;
_a = new NodeCA");
_h = new NodeCH"):
_k = new NodeCK"):
_p = new NodeCP");
_f = new NodeCF", nuli. _h);
_m = new NodeCM", nuli. _p):
_d = new NodeCD", _a, _f);
_1 = new NodeCL". _k, _m);
_i = new NodeCI". _d. _1);
}
public void testMinimum() {
assertSame(_a. _a.minimum());
assertSame(_a, _d.minimum());
assertSame(_f. _f,minimum());
assertSame(_h, _h.minimum()):
assertSame(_a, _i .minimumO);
assertSame(_k. _k.minimumO);
assertSame(_k. _l.minimumO);
assertSame(_m, _m.minimum());
assertSame(_p, _p.minimum());
}
public void testMaximum() {
assertSame(_a, _a.maximum()):
assertSame(_h, _d.maximum());
assertSame(_h. _f.maximum());
assertSame(_h, _h.maximum());
assertSame(_p, _i.maximum());
assertSame(_k. _k.maximum());
assertSame(_p. _1 ,maximumO);
assertSame(_p, _m.maximum());
assertSame(_p. _p.maximum());
}
public void testSuccessorO {
assertSame(_d. _a.successor());
assertSame(_f. _d.successor());
assertSame( h. f.successorO);
Rozdział 10. • Binarne drzewa wyszukiwawcze 277
a s s e r t S a m e M . _h.successor());
assertSame(_k, _i .successorO);
a s s e r t S a m e M . _k.successor());
assertSame(_m. _1 .successorO);
assertSame(_p, _m.successorO);
assertNul1(_p.successor());
}
public void testPredecessor() {
assertNul1(_a.predecessor()):
assertSame(_a, _d.predecessor()
assertSame(_d, _f.predecessor()
a s s e r t S a m e M , _h.predecessor()
assertSame(_h, _i,predecessor()
a s s e r t S a m e M , _k.predecessor()
assertSame(_k, _1,predecessor()
a s s e r t S a m e M . _m.predecessor()
a s s e r t S a m e M , _p.predecessor()
}
public void testlsSmallerO {
assertTrue(_a.i sSmal1er()):
assertTrue(_d.isSmaller());
assertFal se(_f. isSmal lerO);
assertFalse(_h.isSmal1 er());
assertFalse(_i .isSmallerO);
assertTrue(_k.i sSmaller());
assertFalse(_l.isSmaller()):
assertFalse(_m.isSmal1 er());
assertFalse(_p.i sSmaller());
}
public void testlsLargerO {
assertFalse(_a.isLarger());
assertFalse(_d.isLarger());
assertTrue(_f.isLarger());
assertTrue(_h.isLarger());
assertFalse(_i.isLargerO);
assertFal se(_k. i sLargerOJ;
assertTrue(_l.i sLargerC));
assertTrue(_m.isLarger());
assertTrue(_p.isLarger());
}
public void testSizeO {
assertEquals(l, _a.SizeO);
assertEqualsC4, _d.sizeO):
assertEquals(2, _f.sizeO);
assertEquals(l, _h.sizeO);
assertEqualsC9. _i.sizeO);
assertEquals(l. _k.sizeO);
assertEquals(4, _1.sizeO);
assertEquals(2, _m.sizeO);
assertEquals(l, _p.sizeO);
}
public void testEquals() {
Node a = new NodeOA");
278 Algorytmy. Od podstaw
assertEquals(a. _a)
assertEquals(d. _d)
assertEquals(f, _f)
assertEquals(h, _h)
assertEquals(i. _i)
assertEquals(k. _k)
assertEquals(l. _1)
assertEquals(m. jn)
assertEquals(p. _p)
assertFalset i,equals(null))
assertFalse( f.equals( .d)):
}
}
J a k to działa?
Wszystkie testy opierają się na węzłach powiązanych ze sobą tak jak na rysunku 10.1.
Klasa NodeTest definiuje kilka instancji klasy Node, odpowiadających poszczególnym wę-
złom z rysunku 10.1, i inicjuje odpowiednio te instancje w ramach metody setUpO. Cztery
pierwsze węzły są liśćmi (jak na wspomnianym rysunku), więc przypisywana jest im tylko
jedna wartość — klucz. Każdy z pozostałych węzłów ma jednego lub dwóch synów, prze-
kazywanych jako dwa kolejne parametry konstruktora:
public class NodeTest extends TestCase {
private Node _a:
private Node _d:
private Node _f;
private Node _h:
private Node _i:
private Node _k;
private Node _1;
private Node _m:
private Node _p;
_a = new NodeCA");
_h = new NodeCH");
_k = new NodeCK");
_p = new NodeCP");
_f = new NodeCF", nuli. _h);
_m = new NodeCM", nuli, _p):
d - new NodeOD". a, f);
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 279
3
Korzeń drzewa, jako nieposiadający ojca, nie jest ani „większy", ani „mniejszy" —przyp. tłum.
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 281
assertEquals(a, _a)
assertEquals(d, _d)
assertEquals(f, _f)
assertEquals(h, _h)
assertEquals(i, _i)
assertEquals(k, _k)
assertEquals(l, _1)
assertEquals(m, _m)
assertEquals(p, _p)
assertFalse(_i,equals(null));
assertFalse(_f,equals(_d)):
}
Mając gotowy zestaw testów dla klasy Node, zajmijmy się implementacją samej klasy.
* - prawy syn
*/
public Node(Object value, Node smaller, Node larger) {
setValue(value);
setSmaller(smaller);
setLarger(larger);
if (smaller != nuli) {
smaller.setParent(this);
}
if (larger != nuli) {
larger.setParent(this);
}
}
/ * *
* Przypisanie węzła-ojca
* Parametr; węzeł-ojciec lub wartość pusta
*/
public void setParent(Node parent) {
_parent - parent;
}
/ * *
*/
public boolean isSmallerO {
return getParentO !- nuli && this — getParentO .getSmallerO;
}
*/
* Poszukiwanie następnika
*/
public Node successorO {
if (getLargerO != nuli) {
return getLargerO.minimumO;
}
Node node = this;
* Poszukiwanie poprzednika
*/
while (node.isSmallerO) {
node = node.getParentO;
}
return node.getParentO;
}
return getValue().equals(other.getValue())
&& equa1sSmal1 er(other.getSma11 er())
&& equalsLarger(other.getLargerO);
/ * *
J a k to działa?
}
Klasa Node definiuje też dwa konstruktory. Pierwszy z nich służy do utworzenia węzła-
liścia i wywoływany jest z pojedynczym argumentem — wartością reprezentowaną przez
węzeł:
public Node(Object value) {
this(value, nuli. nuli);
}
Drugi konstruktor, oprócz utworzenia węzła, ma możliwość przypisania mu synów. Kon-
struktor ten nie jest niezbędny, bowiem przypisanie to można zrealizować explicite za po-
mocą metod setSmal ler(), setLargerO i setParentO, z których zresztą korzysta:
public Node(Object value, Node smaller, Node larger) {
setValue(value);
setSmaller(smaller);
setLarger(larger);
if (smaller != nuli) {
smal 1 er.setParent(this);
}
if (larger != nuli) {
larger.setParent(this);
}
}
Po skonstruowaniu węzła należy zapewnić dostęp do jego wartości, ojca i synów. Zreali-
zowaliśmy to w sposób typowy, za pomocą kilku metod pobierających i ustawiających
wartości. Nie od rzeczy było przy okazji wykonanie kilku dodatkowych kontroli — j a k na
przykład ta, czy obydwa łącza — lewe i prawe —nie wskazują na ten sam węzeł.
public Object getValue() {
return _value;
}
public void setValue(Object value) {
assert value != nuli : "podano pustą wartość";
_value = value:
}
public Node getParentO {
return _parent;
}
_parent = parent;
}
public Node getSmallerO {
return _smaller;
}
public void setSmaller(Node smaller) {
assert smaller != getLargerO : "lewy syn nie może być tożsamy z prawym";
_smaller = smaller;
}
Metody isSmallerO i isLargerO sprawdzają czy węzeł jest (odpowiednio) lewym czy
prawym synem swego ojca:
public boolean isSmallerO {
return getParentO !- nuli && this == getParentO .getSmallerO:
}
public boolean isLargerO {
return getParentO !- nuli && this == getParent().getLargert);
}
Znajdowanie minimum i maksimum jest zdecydowanie bardziej złożone. Węzłem-minimum
dla węzła Jfjest węzeł o najmniejszej wartości w drzewie, którego korzeniem jest węzeł X.
Innymi słowy, jest to węzeł najmniejszy wśród potomków 4 węzła X. Podobnie węzłem-
maksimum węzła X jest węzeł największy wśród jego potomków. Metody minimumO
i maximum() są więc bardzo do siebie podobne:
public Node minimumO {
Node node = this;
4
Potomkiem węzła jest j e g o syn, syn syna itd. Pod w z g l ę d e m matematycznym relacja „bycia
potomkiem" jest przechodnim domknięciem relacji „bycia synem". Jest to domknięcie niepuste,
bo węzeł nie jest sam swoim potomkiem — przyp. tłum.
288 Algorytmy. Od podstaw
}
public Node maximum() {
Node node - this:
Podobnie jak w przypadku metod minimumO i maximum() metoda precedessorO jest bardzo
podobna do metody sucessorO — podczas gdy pierwsza wywołuje metodę isLargerO,
druga korzysta z metody isSmallerO.
public Node successorO {
if (getLargerO != nuli) {
return getLargerO .minimumO;
}
Node node = this:
while (node.isLargerO) {
node = node.getParentO;
}
return node.getParentO:
}
while (node.isSmallerO) {
node = node.getParentO:
}
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 289
return node.getParentO;
}
Ostatnia z implementowanych metod — equal s() —jest wbrew pozorom najbardziej skompli-
kowaną metodą klasy Node. Będzie ona wykorzystywana intensywnie w klasie BinarySe-
archTree do sprawdzania struktury drzew reprezentowanych przez tę klasę.
W celu sprawdzenia równości dwóch węzłów metoda ta porównuje trzy ich aspekty: warto-
ści lewych synów i prawych synów. Sprawdzenie, czy identyczne są wartości reprezento-
wane przez obydwa węzły, jest nieskomplikowane: ponieważ żadna z porównywanych
wartości nie może być pusta, można po prostu delegować wywołanie metody equals() do
analogicznie nazwanej metody jednej z nich:
return getVa1ue().equals(other.getValue())
Porównanie synów jest skomplikowane o wiele bardziej, sprowadza się bowiem do porów-
nywania całych poddrzew, a nie tylko pojedynczych węzłów: lewi synowie dwóch węzłów
są identyczni, jeśli identyczne są poddrzewa, dla których synowie ci stanowią korzenie.
Cały proces porównywania ma więc charakter rekurencyjny, na jego potrzeby zdefiniowali-
śmy dwie metody pomocnicze: equalsSmaller() i equalsLargerO dokonujące porówny-
wania (odpowiednio) lewych i prawych synów dla danej pary węzłów. Porównywanie to
komplikuje się o tyle, że jeden (lub obydwa) węzeł może (mogą) nie mieć lewego (lub prawego)
syna. Nieistnienie lewego syna u obydwu porównywanych węzłów uważane jest za równość
tych węzłów pod względem lewych synów, analogicznie jest z prawymi synami.
private boolean equalsSmaller(Node other) {
return getSmallerO — nuli
&& other == nuli || getSmal l e r O !- nuli
&& getSmal1 er().equals(other);
}
private boolean equalsLarger(Node other) {
return getLargerO == nuli
&& other == nuli || getLargerO != nuli
&& getLargerO.equals(other);
}
Tak oto uporaliśmy się z implementacją klasy Node. Czas zająć się klasą BinarySearchTree
— zaczniemy jak zwykle od stosownego zestawu testowego.
_a - new NodeCA");
_h = new NodeCH");
_k = new NodeCK");
_p = new NodeCP");
_f = new NodeCF", nuli, _h);
_m = new NodeCH", nuli. _p);
_d = new NodeCD", _a, _f);
_1 = new NodeCL", _k, jti);
_i = new NodeCI", _d, _1);
_root = _i;
assertNu11(_tree.sea rch("NIEISTNIEJĄCY"));
}
public void testDeleteLeafNodeO {
Node deleted = _tree.delete(_h.getValue()):
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 291
assertNotNul1(deleted);
assertEquals(_h.getValue(). deleted.getVa1ue()):
_f.setLarger(null);
assertEquals(_root. _tree.getRoot());
}
public void testDeleteNodeWithOneChildO {
Node deleted = _tree.delete(_m.getValueO);
assertNotNull(deleted);
assertEquals(_m.getValue(), deleted.getValue()):
_1 ,setLarger(_p);
a ssertEqua1s(_root. _tree.getRoot());
}
public void testDeleteNodeWithTwoChi1 dren() {
Node deleted - _tree.delete(_i,getValue());
assertNotNull(deleted);
assertEquals(_i,getValue(), deleted.getValue());
_i.setValue(_k.getValue());
_1.setSma11er(nu11):
assertEguals(_root, _tree.getRoot()):
J a k to działa?
Identycznie jak w przypadku klasy Node zestaw testowy dla klasy BinarySearchTree wzo-
rowany jest na drzewie binarnym widocznym na rysunku 10.1. Łatwo więc określić ocze-
kiwane wyniki zwracane przez testowane metody i porównać je z wynikami faktycznie
zwracanymi w implementacji.
_a = new NodeOA");
_h - new NodeCH");
_k - new Node("K");
_p = new NodeOP");
_f - new NodeCF", nuli, _h);
_m = new NodeCM", nuli. _p);
_d = new NodeOD". _a, _f);
_1 = new NodeCL", _k, _m);
_i = new NodeCI", _d. _1):
_root = _i;
Przedmiotem kolejnej weryfikacji jest metoda searchO, która powinna zwracać albo węzeł
reprezentujący szukaną wartość, albo wartość pustą gdy szukanej wartości w drzewie nie
ma. W tym właśnie celu tworzymy (w metodzie setUp()) węzły porównawcze — potrzebne
nam są bowiem konkretne węzły o znanych wartościach, by porównać je z rezultatami po-
szukiwania tychże wartości. W ostatnim teście celowo użyliśmy wartości nieistniejącej,
oczekując zwrócenia pustego wyniku.
public void testSearchO {
assertEquals(_a. _tree.search(_a.getValue()));
assertEquals(_d. _tree.search(_d.getValue()));
assertEquals(_f, _tree.search(_f,getValue())):
assertEquals(_h. _tree.search(_h.getValue()));
assertEquals(_i, _tree.search(_i,getValue()));
assertEquals(_k, _tree.search(_k.getValue())):
assertEquals(_l, _tree.search(_l,getValue()));
assertEquals(_m, _tree.search(_m.getValue()));
assertEquals(_p, _tree.search(_p.getValue()));
assertNul1(_tree.search("NIEISTNIEJĄCY"));
}
Testując metodę deleteO, musimy pamiętać, że — zgodnie z wcześniejszym opisem —
z punktu widzenia usuwania węzłów istnieją trzy szczególne przypadki wymagające odręb-
nego potraktowania: liść, węzeł z jednym synem i węzeł z dwoma synami.
_f.setLargertnull);
assertEquals(_root. _tree.getRoot());
}
Jako kandydata do testowania kolejnego przypadku — węzła z jednym synem — użyjemy
węzła M, którego usuwanie przedstawiono na rysunku 10.9. Po usunięciu tego węzła z drzewa
metoda testDeleteNodeWithOneChildO weryfikuje poprawność wartości zwróconej przez me-
todę deleteO. Ponadto usunięcie węzła M powoduje, że prawym synem węzła L staje się
teraz węzeł P i zmianę tę należy uwzględnić także w zakresie węzłów porównawczych _1 i _p.
public void testDeleteNodeWithOneChildO {
Node deleted - _tree.delete(_m.getValueO);
assertNotNull(deleted);
assertEquals(_m.getValue(), deleted.getValue());
_1,setLarger(_p);
assertEquals(_root. _tree.getRoot());
}
294 Algorytmy. Od podstaw
_i.setValue(_k.getValue()):
_l .setSmaller(null):
assertEquals(_root, _tree.getRoot());
}
Dysponując narzędziami weryfikacji klasy drzewa binarnego, zajmijmy się jej implementacją.
*/
public class BinarySearchTree {
/** Komparator wyznaczający porządek wartości reprezentowanych przez węzły */
private finał Comparator _comparator:
* Konstruktor
* Parametr: Komparator wyznaczający porządek wartości
*/
public BinarySearchTree(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora":
_comparator = comparator:
}
/•kie
* Poszukiwanie wartości w drzewie
* Parametr: szukana wartość
* Wynik: węzeł reprezentujący wartość lub wartość pusta
*/
public Node search(0bject value) {
assert value != nuli : "nie podano wartości";
if (parent == nuli) {
_root = inserted;
} else if (cmp < 0) {
parent.setSmaller(inserted);
} else {
parent.setLarger(inserted):
}
return inserted;
}
Node replacement =
deleted.getSmallerO !- nuli ? del eted. getSmallerO
deleted.getLarger():
if (replacement !- nuli) {
replacement.setPa rent(deleted.getPa rent O ) :
}
if (deleted — _root) {
_root = replacement;
} else if (deleted.isSmallerO) {
deleted. getParentO .setSmall er( repl acement);
} else {
deleted. getParentO ,setLarger( repl acement):
}
if (deleted != node) {
Object deletedValue - node.getValue():
node. setVa 1 ue (del eted. getVa 1 u e O ) ;
deleted.setVa1ue(deletedVa1ue);
}
return deleted:
}
J a k to działa?
* Konstruktor
* Parametr: Komparator wyznaczający porządek wartości
*/
public BinarySearchTreetComparator comparator) {
assert comparator != nuli : "nie określono komparatora":
_comparator = comparator:
}
public Node getRootO {
return _root:
}
}
Najprostszą w implementacji metodą klasy jest metoda searchO, której zdaniem jest po-
szukiwanie podanej wartości i zwrócenie węzła reprezentującego tę wartość; jeśli węzła ta-
kiego nie ma w drzewie, metoda powinna zwrócić wynik pusty. Metoda dokonuje porów-
nywania szukanej wartości z wartościami kolejno odwiedzanych węzłów, począwszy od
korzenia, i zależnie od wyniku porównania albo kończy pracę (zwracając węzeł reprezen-
tujący szukaną wartość), albo przemieszcza się do jednego z synów, albo sygnalizuje, że
szukanej wartości w drzewie nie ma.
public Node search(0bject value) {
assert value != nuli : "nie podano wartości":
Zwróćmy uwagę na pewną istotną różnicę między metodami searchO i insertO związaną
z sytuacją znalezienia specyfikowanej wartości. Metoda search() po prostu kończy w tej sytuacji
pracę, natomiast metoda insertO traktuje znalezioną wartość tak, jak gdyby była ona mniejsza
od wartości wstawianej (to kwestia umowy, równie dobrze można by j ą potraktować jako
298 Algorytmy. Od podstaw
if (parent == nuli) {
_root = inserted:
} else if (cmp < 0) {
parent.setSmaller(inserted);
} else {
parent.setLarger(inserted);
}
return inserted:
}
Metoda deleteO, jak łatwo sobie wyobrazić, jest bardziej skomplikowana od metod searchO
i insertO, jak bowiem pamiętamy, musimy w jej implementacji rozróżnić kilka przypadków
szczególnych. Chodzi o to, by umiejętnie połączyć je w spójny i czytelny kawałek kodu.
Metoda deleteO rozpoczyna swą pracę od znalezienia węzła, który należy usunąć. Jeśli taki
węzeł nie zostanie znaleziony (node = nul 1), nie ma już nic do roboty i metoda kończy pracę.
Gdy jednak znaleziony zostanie węzeł reprezentujący usuwaną wartość, należy przede
wszystkich określić, czy do usunięcia kwalifikuje się on sam, czy też należy to zrobić z jego
następnikiem. Jak pamiętamy, ta druga ewentualność ma miejsce wówczas, gdy węzeł re-
prezentujący szukaną wartość ma dwóch synów.
To jeszcze nie wszystko. Jeżeli węzeł reprezentujący usuwaną wartość pozostaje w drzewie (bo
usuwany jest jego następnik), należy zastąpić jego wartość wartością usuwanego następnika.
public Node delete(Object value) {
Node node = search(value):
if (node == nuli) {
return nuli:
}
Node deleted =
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 299
Node replacement =
deleted.getSmallerO != nuli ? del eted. getSmallerO :
del eted. getLargerO;
if (replacement != nuli) {
replacement.setPa rent(deleted.getPa rent O ) ;
}
if (deleted == _root) {
_root = replacement:
} else if (deleted.isSmallerO) {
deleted.getParent().setSma11 er(replacement);
} else {
deleted.getParent().setLarger(replacement):
if (deleted != node) {
Object deletedValue = node.getValue():
node.setVa1ue(deleted.getVa1ue O ) ;
deleted.setValue(deletedValue);
return deleted:
}
import junit.framework.TestCase;
reportCallsO:
}
private void preOrderInsert(List list, int lowerIndex. int upperlndex) {
if (lowerIndex > upperlndex) {
return;
}
int index = lowerIndex + (upperlndex - lowerIndex) / 2:
J a k to działa?
Dla wygody wszystkie przeprowadzane wcześniej eksperymenty budowaliśmy tak, jak bu-
duje się przypadki testowe — z wykorzystaniem biblioteki JUnit — i nie inaczej będzie
tym razem.
Losowa 11 624
Rosnąca 499 500
Eksperyment ten potwierdza tylko sformułowaną uprzednio tezę, że binarne drzewa wy-
szukiwawcze generalnie preferują dane nieuporządkowane, zapewniając efektywność ope-
racji rzędu 0(log N). Dla danych nadchodzących w kolejności uporządkowanej efektyw-
ność ta może się pogorszyć do poziomu 0(N). Istotnie: średnia wartość 11 624 porównania
na j e d n ą losową wartość w przypadku uporządkowania wartości wzrasta tu 45-krotnie. Przy
większych drzewach dysproporcje te mogą być jeszcze bardziej drastyczne.
Podsumowanie
W zakończonym właśnie rozdziale przedstawiliśmy podstawy organizacji i funkcjonowania
binarnych drzew wyszukiwawczych; wiedza ta okaże się niezbędna do studiowania bardziej
„konkretnych" odmian drzew w rozdziałach 12. i 13. Wśród omówionych w rozdziale na
szczególne zapamiętanie zasługują następujące fakty:
• Binarne drzewa wyszukiwawcze składają się z węzłów, z których każdy może mieć
jednego lub dwóch synów albo nie mieć ich w ogóle.
• W binarnym drzewie wyszukiwawczym lewy węzeł-syn ma zawsze wartość
mniejszą niż węzeł-ojciec, a prawy syn — większą niż węzeł-ojciec.
• Drzewo binarne może być wyważone lub niewyważone; idealnie wyważone
drzewo binarne o N węzłach ma wysokość log2;V.
Ćwiczenia
1. Napisz metodę min~imum() w postaci rekurencyjnej.
2. Napisz metodę maximum() w postaci rekurencyjnej.
3. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności in-order.
4. Napisz iteracyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności in-order.
5. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności pre-order.
6. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności post-order.
7. Napisz metodę wstawiającą elementy posortowanej listy do drzewa binarnego
w taki sposób, by dodatkowe zabiegi przywracające wyważenie drzewa nie były
potrzebne.
304 Algorytmy. Od podstaw
11
Haszowanie
Haszowanie (hashing), zwane także kodowaniem mieszającym, jest techniką niosącą obiet-
nicę wyszukiwania żądanej wartości w czasie 0(1), czyli za pomocą liczby porównań nie-
zależnej od liczebności przeszukiwanej kolekcji. Brzmi to imponująco w zestawieniu z linio-
wym (0(N)) wyszukiwaniem w liście wiązanej czy nawet wyszukiwaniem logarytmicznym
(0(log N)) w posortowanej tablicy.
W niniejszym rozdziale:
• przedstawimy podstawy haszowania,
• zaprezentujemy wykorzystywanie różnych funkcji haszujących,
• dokonamy empirycznej oceny i porównania efektywności poszczególnych technik
haszowania.
Podstawy haszowania
Może to zadziwiające, ale haszowaniem posługujemy się często w życiu codziennym,
przeważnie nieświadomie. Gdy w księgarni zamierzasz przejrzeć nowości literatury infor-
matycznej, bez wahania udajesz się właśnie w kierunku stoiska z tymi, a nie innymi książ-
kami; gdy w katalogu tematycznym poszukujesz nagrań utworów ulubionego kompozytora,
bezbłędnie sięgasz do sekcji oznaczonej pierwszą literą jego nazwiska. W obydwu tych
przypadkach nieświadomie korzystasz z pewnych własności czegoś, czego poszukujesz —
nazwy dziedziny wiedzy lub nazwiska kompozytora; dzięki temu znacząco zawężasz ob-
szar swych poszukiwań: jedno stoisko zamiast całej księgarni, jedna sekcja katalogu za-
miast całego katalogu.
Podstawą techniki haszowania jest funkcja haszująca, zwana też mieszającą (hash func-
tioń). Na podstawie pewnego obiektu — łańcucha, liczby czy czegokolwiek innego — pro-
dukuje ona wartość (hash value) zwaną wyciągiem, skrótem lub znacznikiem haszowania.
Wartość ta jest najczęściej liczbą całkowitą bądź inną wielkością numeryczną wyznaczają-
cą położenie wspomnianego obiektu w kolekcji zwanej tablicą haszowaną (hash table).
306 Algorytmy. Od podstaw
Aby przybliżyć nieco ideę haszowania, zobaczmy przykładowe tworzenie skrótu haszowego
dla obiektu będącego łańcuchem. Skrót ten będzie liczbą całkowitą określającą lokalizację
łańcucha w tablicy.
Najprostszym sposobem haszowania łańcucha jest sumowanie kodów jego liter. Jeżeli się
umówimy, że w łańcuchu mogą występować tylko litery od A do Z, możemy przypisać tym
literom kody od 1 do 26. Wtedy haszowanie nicków trzech sławnych postaci wyglądać może tak:
E + L + V + I + S = 5 + 12 + 2 2 + 9 + 19 = 6 7
M + A + D + 0 + N + N + A = 13 + 1 + 4 + 15 + 14 + 14 + 1 = 62
S + T+ I + N + G = 19 + 20 + 9 + 14 + 7 = 69
Wynika stąd, że „ELVIS" powinien zajmować w tablicy haszowanej pozycję o indeksie 67,
„MADONNA" — pozycję o numerze 62, a „STING" — pozycję o numerze 69. Zwróćmy
uwagę, że wzajemna kolejność tych pozycji nie ma nic wspólnego z kolejnością haszowa-
nych łańcuchów — pozycje mają wartości raczej losowe, przez co haszowanie często na-
zywane bywa randomizacją (od random — „losowy"). Jest to sytuacja skrajnie odmienna
od technik opisywanych w poprzednich rozdziałach, które to techniki bazują na określonym
uporządkowaniu danych w celu osiągnięcia zakładanej efektywności.
Łatwe zapamiętywanie wartości na unikalnej pozycji i dzięki temu łatwe jej odnajdywanie
— czyż nie brzmi to zbyt pięknie, by mogło być prawdziwe? Po części tak, bowiem z ha-
szowaniem związane są nieodłącznie dwa poważne problemy.
Spójrzmy pod nieco innym kątem na wygenerowane skróty. Jeśli mają one pełnić rolę in-
deksów, to potrzebujemy tablicy o co najmniej 70 pozycjach (numerowanych od 0 do 69),
z których tylko 3 zostaną wykorzystane. Wyobraźmy sobie teraz jakiś łańcuch dający w wyniku
haszowania wartość 169 — wtedy niewykorzystanych pozostanie nie 67 pozycji, a 167. Wy-
gląda na to, że za wspaniałą efektywność wypada niestety słono zapłacić utratą efektywności
wykorzystywania pamięci.
Na szczęście cena nie zawsze jest beznadziejnie słona. Jednym ze sposobów rozwiązania
opisanego problemu jest ograniczenie wyników haszowania do pewnego przedziału. Jeżeli
w opisywanym przykładzie zamierzamy używać tablicy (powiedzmy) dziesięcioelemento-
wej, otrzymaną sumę liter należy wziąć modulo 10 (czyli obliczyć resztę z jej dzielenia
przez 10), jak poniżej:
E + L + V + I + S = (5 + 12 + 22 + 9 + 19) % 10 = 67 X 10 = 7
M + A + D + 0 + N + N + A = (13 + 1 + 4 + 15 + 14 + 14 + 1) % 10 = 62 % 10 = 2
S + T + I + N + G = (19 + 20 + 9 + 14 + 7) % 10 = 69 * 10 = 9
Wygląda na to, że pozbyliśmy się problemu i każdy łańcuch można ulokować w ten sposób
na właściwej pozycji w tablicy o 10 pozycjach.
Niestety, to tylko pozory. Gdyby spróbować zapamiętać w tej tablicy (powiedzmy) osiem
elementów, pewnikiem dałoby znać o sobie kolejne zjawisko nierozłącznie związane z ha-
szowaniem — kolizyjność. Kolizją nazywamy sytuację, w której dwa różne łańcuchy produ-
kują ten sam wynik haszowania. Skoro w naszym przykładzie podstawą haszowania łańcu-
cha jest sumowanie kodów jego liter, to wobec przemienności dodawania jest ono niewrażliwe
na zmianę kolejności liter:
E + L + V + I + S = (5 + 12 + 22 + 9 + 19) X 10 = 67 % 10 = 7
L + I + V + E + S = (12 + 9 + 22 + 5 + 19) % 10 = 67 X 10 = 7
Rozdziali!. • Haszowanie 307
Innym sposobem ograniczania kolizji jest staranniejszy wybór rozmiaru tablicy, a dokład-
niej — modułu, względem którego bierzemy resztę z dzielenia. Jeżeli moduł ten jest liczbą
pierwszą prawdopodobieństwo występowania kolizji może się znacząco zmniejszyć. Udo-
wodnienie tego twierdzenia wykracza poza ramy niniejszej książki.
Oczywiście ideałem było haszowanie doskonałe (perfect hashing) czyli haszowanie niepo-
wodujące w ogóle kolizji. Być może dla niewielkich kolekcji danych o wysoce przewidy-
walnych własnościach da się konstruować algorytmy haszowania zbliżone do doskonałości,
lecz w ogólnym przypadku nawet najlepszy algorytm haszujący gwarancji unikania kolizji
nie daje. Rozsądnym kompromisem pozostaje więc ograniczanie występowania kolizji do
poziomu, na którym da się nimi efektywnie zarządzać.
Istotą algorytmu CRC jest ważone sumowanie pozycyjne znaków łańcucha. Każdemu zna-
kowi nadawana jest waga w postaci kolejnej potęgi określonej podstawy. Jeśli podstawą tą
będzie liczba 31, to wynik haszowania łańcucha „ELVIS" będzie następujący:
314 * E + 313 * L + 312 * V + 31 * I * S
co zapisać można w równoważnej postaci znacznie wygodniejszej dla obliczeń:
(UE * 31 + L) * 31 + V) * 31 + I) * 31 + S
308 Algorytmy. Od podstaw
Wyniki haszowania są różne nawet dla anagramów, zwróćmy jednak uwagę na to, jak duże
są to wartości. Oczywiście nikt nie traktowałby poważnie pomysłu wykorzystania tablicy
o rozmiarze 11 570 331 842 pozycji, ponieważ, jak poprzednio, otrzymany wynik ważonego
sumowania można wziąć modulo jakąś wartość, na przykład 11 (to najmniejsza liczba pierw-
sza przekraczająca rozmiar tablicy 10):
"ELVIS" = 4996537 % 11 = 7
"MADONNA" = 11570331842 % 11 = 3
"STING" = 18151809 * 11 = 5
"LIVES" = 11371687 % 11 = 8
Jak na razie pozbyliśmy się kolizji, nie miejmy jednak złudzeń: pojawią się one niechybnie,
gdy tablica zacznie zapełniać się nowymi łańcuchami. Nasz algorytm daleki jest od dosko-
nałości.
czyli kod taki sam jak dla łańcucha „ELVIS" — a więc wystąpi kolizja.
Spróbujmy zatem zwiększyć rozmiar tablicy i ponownie obliczyć skróty dla poszczegól-
nych łańcuchów. Zamiast modułu 11 użyjemy 17:
"ELVIS" = 4996537 % 17 = 16
"MADONNA" = 11570331842 % 17 = 7
"STING" = 18151809 * 17 = 8
"LIVES" = 11371687 % 17 = 13
"FRED" = 196203 % 17 = 6
Teraz każdy łańcuch ma swój unikalny indeks i może być efektywnie zapamiętany i wy-
szukiwany. Zwróćmy jednak uwagę na to, jaką cenę musieliśmy zapłacić za zapewnienie
unikalności adresów: zwiększyliśmy rozmiar tablicy o połowę — z 11 do 17 — by dodać
tylko jedną wartość. Spośród 17 dostępnych pozycji wykorzystanych jest jedynie 5, co daje
raptem — ~ 29 procent. Jeszcze nie najgorzej, lecz w miarę dodawania kolejnych łańcu-
chów bezwzględne unikanie będzie możliwe tylko za cenę coraz poważniejszego zwięk-
szania rozmiarów tablicy i coraz większego marnotrawienia pamięci. Jest więc oczywiste,
iż w praktyce kolizji tak naprawdę uniknąć się nie da, a skoro tak, to koniecznie są jakieś
mechanizmy ich rozwiązywania.
Jedną z technik rozwiązywania kolizji jest próbkowanie liniowe (linear probing). Gdy po-
zycja obliczona w wyniku haszowania jest już zajęta, poszukujemy (aż do skutku) najbliż-
szej wolnej pozycji, badając kolejne pozycje (i nie zapominając, że następną po ostatniej
pozycji jest pierwsza, ta identyfikowana przez indeks 0). Na rysunku 11.1 przedstawiono
proces rozwiązywania kolizji w związku z dodawaniem łańcucha „FRED".
Rozdziali!. • Haszowanie 309
Rysunek 11.1.
Próbkowanie
0 0
liniowe jako metoda
rozwiązywania kolizji i 1 1
powstałej w związku
z dodawaniem
2 2
łańcucha „FRED"
4 4 4
5 STING 5 5
6 6 6
9 9 9 FRED
10 10 10
Gdy poszukiwanie osiągnie koniec tablicy, nie oznacza to bynajmniej, że wolnych pozycji
już nie ma: po prostu przechodzimy do pierwszej pozycji i kontynuujemy poszukiwanie.
Na rysunku 11.2 przedstawiono dodawanie łańcucha „TIM" (obliczony indeks 9) w sytu-
acji, gdy pozycja 10 jest już zajęta przez łańcuch „MARY".
Oryginalnie wyliczona pozycja (o indeksie 9) jest już zajęta przez łańcuch „FRED". Pozy-
cja o indeksie 10 zajęta jest przez łańcuch „MARY". Pozycji o indeksie 11 już nie ma,
osiągnęliśmy koniec tablicy. Przechodzimy do pozycji o indeksie 0 — pozycja ta jest wolna
i w niej zapisujemy łańcuch „TIM".
Zawracanie poszukiwań na początek tablicy w przypadku osiągnięcia jej końca grozi zapętle-
niem poszukiwań w sytuacji, gdy w tablicy rzeczywiście nie ma już wolnych pozycji. W takiej
sytuacji niezbędne jest oczywiście zwiększenie rozmiaru tablicy. Poza tym zastrzeżeniem tech-
nika próbkowania liniowego okazuje się (jak na razie) zadowalająca.
Próbkowanie liniowe jest proste w implementacji i spisuje się świetnie w tablicach o nie-
wielkim stopniu zapełnienia. Gdy tablica zaczyna się wypełniać coraz szczelniej, efektyw-
ność poszukiwań pozycji spada stopniowo od 0(1) do 0(N)\ efektywne haszowanie prze-
istacza się powoli w zwykłe wyszukiwanie liniowe.
310 Algorytmy. Od podstaw
4 4 4
5 STING S 5
6 6 6
Inną metodą rozwiązywania kolizji jest porcjowanie, czyli łączenie kolidujących pozycji w por-
cje (buckets). W jednym elemencie tablicy haszowanej zapisywane są więc wszystkie pozy-
cje generujące dany indeks. Na rysunku 11.3 przedstawiono 11-elementową tablicę haszo-
waną przechowującą 16 pozycji. Łańcuchy „LYNN", „PAULA", „JOSHUA" i „MERLE"
generują wartość indeksu 1.
Rysunek 11.3.
Każda porcja
zawiera wszystkie
elementy
generujące daną
wartość indeksu
Rozdział 11. • Haszowanie 311
Jak widzimy, porcjowanie jest techniką skuteczniejszą niż zwykłe próbkowanie liniowe,
pozwala bowiem na zapamiętanie większej liczby elementów.
Ta elastyczność może jednak sporo kosztować, bowiem w miarę rozrastania się poszcze-
gólnych porcji zwiększa się czas ich przeszukiwania, choć może nie tak znacząco jak przy
przeszukiwaniu liniowym. Zmniejszenie rozmiaru pojedynczej porcji można osiągnąć
poprzez zwiększenie liczby porcji. Dobra funkcja haszująca powinna generować porcje o zbli-
żonym rozmiarze, a właściwe określenie momentu, w którym należy zwiększyć rozmiar ta-
blicy (a więc i liczbę porcji) staje się prawdziwym wyzwaniem.
Jednym z kryteriów powiększenia tablicy może być jej stopień zapełnienia (load factor),
czyli stosunek liczby przechowywanych elementów do ogólnej liczby pozycji w tablicy:
gdy stopień ten przekroczy pewną wartość progową wykonuje się reorganizację tablicy. Na
rysunku 11.3 stopień zapełnienia tablicy wynosi 16/11, czyli ok. 145%, co stanowi raczej
dobrą okazję do podjęcia takiego kroku.
Elementami tablic haszowanych będą łańcuchy, będziemy więc potrzebować dla nich od-
powiedniej funkcji haszującej. Na szczęście funkcja taka jest dostępna jako standardowa
funkcja języka Java — nosi ona nazwę hashCodeO i mogą ją implementować wszystkie
klasy. Co więcej, implementacja tej metody zastosowana w klasie String w JDK bardzo
przypomina ważone sumowanie pozycyjne znaków łańcucha, które wcześniej prezentowa-
liśmy. Nie musimy więc zajmować się jej samodzielnym tworzeniem, choć warto jednak
mieć świadomość tego, jak mniej więcej wygląda:
public int hashCodeO {
int hash = 0:
for (int i = 0; i < lengthO; ++i) {
hash = 31 * hash + charAt(i):
}
return hash;
}
Początkowa wartość zmienne hash równa jest zero. Zmienną tę na przemian mnoży się
przez 31 i dodaje do niej kody kolejnych znaków łańcucha 1 .
1
Jest to de facto obliczanie wartości wyrażenia w systemie pozycyjnym o podstawie 31.
Takie naprzemienne mnożenie przez podstawę i dodawanie kolejnych „cyfr" nosi nazwę
schematu Homera — przyp. tłum.
312 Algorytmy. Od podstaw
J a k to działa?
Interfejs Hashtable składa się z trzech metod: add(), containsO i sizeO. Każdą z nich
trzeba będzie zaimplementować w klasach reprezentujących obydwa warianty tablicy ha-
szowanej: z próbkowaniem liniowym i z porcjowaniem. Znaczenie tych metod powinno
być intuicyjnie jasne, jest bowiem zbliżone do znaczenia identycznie nazwanych metod in-
terfejsu List. Jest jednak pewna istotna różnica: podczas gdy dublowanie się elementów listy
jest czymś naturalnym, to próba dodania do haszowanej tablicy wartości już w niej obecnej
nie daje żadnego efektu.
import junit.framework.TestCase;
Jashtable = createTable(TESTJIZE);
J a k to działa?
_hashtable = createTable(TESTJIZE);
Jeśli indeks dla wartości k oblicza się według formuły h{k) = k mod N, gdzie A^jest rozmiarem
tablicy haszowanej, to dla każdego k < N zachodzi h{k) = k — przyp. tłum.
314 Algorytmy. Od podstaw
Wreszcie, skoro dodawanie do tablicy haszowanej wartości już w niej obecnej nie powo-
duje żadnych skutków, to ponowne dodanie do niej tych samych wartości powinno pozo-
stać bez wpływu na licznik obecnych wartości:
public void testAddingTheSameValuesDoesntChangeTheSize() {
assertEquals(TESTJIZE. Jashtable.SizeO);
Próbkowanie liniowe
Pierwsza z naszych implementacji tablicy haszowanej wykorzystywać będzie próbkowanie
liniowe do rozwiązywania kolizji. Zaletą próbkowania liniowego jest jego prostota koncep-
cyjna i łatwość w implementacji.
* Konstruktor
* Parametr: początkowy rozmiar tablicy
*/
public LinearProbingHashtable(int initialCapacity) {
assert initialCapacity > 0 : "rozmiar początkowy musi być dodatni";
_values = new Object[initialCapacity];
1
public void add(Object value) {
ensureCapacityForOneMore();
if (_values[index] == nuli) {
_values[index] = value:
}
}
public boolean contains(Object value) {
return indexOf(value) !- -1:
}
public int sizeO {
int size = 0;
for (int i = 0 ; i < _values.length; ++i) {
if (_values[i] != nuli) {
++size;
}
}
return size;
}
return index;
}
/**
* Parametry:
* - wartość do zapamiętania
* - początkowy indeks poszukiwania
* - końcowy indeks poszukiwania
* Wynik: indeks pozycji dla wartości
*/
* Parametry:
* - wartość do zapamiętania
* - początkowy indeks poszukiwania
* - końcowy indeks poszukiwania
* Wynik: indeks pozycji dla wartości lub -1. gdy nieznaleziona
*/
return -1:
}
/**
* Wylicza naturalny indeks dla podanej wartości, bazując na jej skrócie haszowym
*
* reorganizacja tablicy
*/
private void resizeO {
LinearProbingHashtable copy = new LinearProbingHashtable(_values.length * 2):
J a k to działa?
Magazynem dla zapamiętanych wartości jest tablica _values; jej początkowy rozmiar okre-
ślony jest przez parametr wywołania konstruktora:
package com.wrox.algorithms.hashing;
if (_values[index] == nuli) {
_values[index] = value:
}
}
320 Algorytmy. Od podstaw
Porcjowanie
Drugą z naszych implementacji tablicy haszowanej, bazującą na porcjowaniu danych, także
poprzedzimy stworzeniem odpowiedniej klasy testowej.
import com.wrox.algorithms.iteration.Iterator;
import com.wrox.algori thms.1 i sts.Li nkedLi st:
i mport com.wrox.a1gori thms.1ists.List:
* Konstruktor.
*
* Parametry:
* - początkowa liczba porcji
* - graniczny stopień wypełnienia tablicy
*/
public BucketingHashtable(int initialCapacity. float loadFactor) {
assert initialCapacity > 0 : "początkowa liczba porcji musi być dodatnia";
assert loadFactor > 0 : "graniczny stopień wypełnienia musi być dodatni";
JoadFactor = loadFactor:
_buckets = new List[initialCapacity];
}
public void add(Object value) {
List bucket = bucketFor(value):
if (!bucket.contains(value)) {
322 Algorytmy. Od podstaw
bucket.add(value);
maintainl_oad();
}
}
public boolean contains(Object value) {
List bucket = _buckets[bucketIndexFor(value)];
return bucket != nuli && bucket.contains(value);
}
public int sizeO {
int size - 0;
for (int i - 0; i < buckets.length; ++1) {
if (_buckets[i] != nuli) {
size += _buckets[i].size;
return size;
}
/**
*/
private boolean loadFactorExceeded() {
return sizeO > buckets.length * JoadFactor:
}
/**
J a k to działa?
Każda porcja danych jest listą, a tablica haszowana jest tablicą list. W klasie Bucketi ngHashtabl e
nosi ona nazwę buckets; początkowo ma rozmiar określony przez pierwszy parametr kon-
struktora; przekazywany jako drugi parametr graniczny stopień zapełnienia tablicy zapa-
miętywany jest w zmiennej _loadFactor.
package com.wrox.algorithms.hashing;
* Konstruktor.
*
* Parametry:
* - początkowa liczba porcji
* - graniczny stopień wypełnienia tablicy
*/
public BucketingHashtable(int initialCapacity, float loadFactor) {
assert initialCapacity > 0 : "początkowa liczba porcji musi być dodatnia":
assert loadFactor > 0 : "graniczny stopień wypełnienia musi być dodatni":
JoadFactor = loadFactor;
_buckets = new List[initialCapacity];
}
}
Metoda maintainLoad() odpowiedzialna jest za utrzymywanie rozmiaru tablicy wystarcza-
jąco dużego, by stopień jej wypełnienia nie przekraczał założonej wartości granicznej.
Metoda dokonuje sprawdzenia bieżącego stopnia wypełnienia i, jeśli przekracza on wspo-
mnianą wartość, wywoływana jest metoda resizeO dokonująca reorganizacji tablicy, czyli
ponownego rozmieszczenia jej zawartości w dwukrotnie większym obszarze — odbywa się
to identycznie jak w przypadku poprzedniej tablicy opartej na próbkowaniu liniowym.
Czynnik, o jaki zwiększany jest każdorazowo rozmiar tablicy (2), wybrany został dość
przypadkowo i z powodzeniem mógłby być inny, w każdym razie jego wartość jest wyni-
kiem kompromisu między efektywnością a konsumpcją pamięci — im mniejszy, tym czę-
ściej wykonywana bezie reorganizacja, zaś im większy, tym więcej pamięci będzie się
marnować.
private void maintainLoad() {
if (loadFactorExceeded()) {
resizeO:
Rozdziału. • Haszowanie 325
buckets = copy._buckets:
if (!bucket.contains(value)) {
bucket.add(value);
maintainLoadO;
}
}
Badanie obecności wskazanej wartości w tablicy haszowanej sprowadza się do badania dwóch
warunków: istnienia odpowiedniej porcji oraz istnienia odpowiedniej wartości w tejże porcji:
public boolean contains(Object value) {
List bucket = _buckets[bucketlndexFor(value)];
return bucket != nuli && bucket.contains(value);
return size;
}
Ponownie jako ćwiczenie proponujemy Czytelnikowi opracowanie implementacji porcjo-
wania z bieżącym śledzeniem aktualnej liczby wartości zamiast każdorazowego iterowania
po zawartości porcji.
3
M e t o d a add() interfejsu List dopuszcza dublowanie elementów, stąd konieczne jest sprawdzenie
w y k o n y w a n e za p o m o c ą metody contai ns () — przyp. tłum.
Rozdział 11. • Haszowanie 327
import junit.framework.TestCase;
J a k to działa?
Klasa pomiarowa HashtableCa U Counti ngTest jest w istocie klasą testową wywodzącą się
z klasy TestCase biblioteki JUnit. Prywatnymi zmiennymi klasy są: _counter, przechowu-
jąca aktualną liczbę zarejestrowanych porównań, oraz hashtable, wskazująca na aktualnie
testowaną instancję tablicy.
package com.wrox.algorithms.hashing;
import junit.framework.TestCase;
}
Rozdziału. • Haszowanie 329
Jak łatwo się domyślić, podstawowym problemem związanym z opisywaną metodą pomia-
ru jest przechwytywanie wywołań metody equals() porównywanych wartości. Zadanie to
jest o tyle trudne do wykonania, że wspomniane wartości mogą być instancjami klas final-
nych, których nie można już rozszerzać w celu przedefiniowania tej metody — najbardziej
typowym przykładem takich wartości są łańcuchy, czyli obiekty klasy String. Problem ten
rozwiązaliśmy, definiując ad hoc własną (wewnętrzną) klasę-otoczkę Value dla zapamięty-
wanych łańcuchów; metoda equals() tej klasy, przed delegowaniem wywołania do metody
equals() odnośnego łańcucha, inkrementuje zmienną _counter, rejestrując w ten sposób
jedno wywołanie. Zwróćmy uwagę, że w momencie tworzenia instancji klasy Va1ue kon-
struktor przypisuje jej losową wartość, by uniknąć nieobiektywnego pomiaru w przypadku
dodawania wartości w kolejności uporządkowanej.
private finał class Value {
private fina! String _value;
Tabela 11.1. Liczba porównań w trakcie lOOO wywołań każdej z metod add() i containsO
75% jedno porównanie przypada na każdą z 60% wartości ogółem itd. Niezależnie jednak od
dopuszczalnego wypełnienia efektywność haszowania porcjowanego zdaje się być bliska 0( 1).
Haszowanie porcjowane wydaje się więc być metodą godną polecenia, jednakże jego duża
efektywność — jak zaobserwowana w naszym eksperymencie — uwarunkowana jest uży-
ciem dobrej funkcji haszującej.
Podsumowanie
W niniejszym rozdziale poznaliśmy następujące fakty:
• Haszowanie jest rodzajem randomizacji danych niszczącym ich uporządkowanie
w jakimkolwiek sensie.
• Doskonała funkcja haszująca to funkcja niepowodująca kolizji; skonstruować taką
funkcję jest jednak niezmiernie trudno.
• Trafność wyboru funkcji haszującej uwarunkowana jest w dużym stopniu
własnościami haszowanych danych, w większości przypadków własności te są
jednak raczej nieznane; skoro więc nie da się skonstruować funkcji haszującej
niestwarzającej kolizji, to przynajmniej powinno się dążyć do minimalizowania
prawdopodobieństwa wystąpienia kolizji i skutecznie eliminować kolizje już zaistniałe.
• Liczbę kolizji można zmniejszyć, wybierając odpowiednio rozmiar tablicy —
powinien on być liczbą pierwszą; zmniejszenie liczby kolizji można też osiągnąć
przez zwiększenie rozmiaru tablicy haszowanej, co oczywiście stwarza dodatkowe
zapotrzebowanie na pamięć.
• Próbkowanie liniowe degraduje efektywność haszowania do poziomu
wyszukiwania sekwencyjnego.
• Haszowanie porcjowane, w połączeniu z wyborem dobrej funkcji haszującej,
może osiągnąć efektywność bliską <9(1).
Ćwiczenia
1. Zmodyfikuj klasę BucketingHashtable tak, by liczba porcji była zawsze liczbą
pierwszą. Czy i jaki ma to wpływ na efektywność?
2. Zmodyfikuj klasę LinearProbingHashTable tak, by liczba zapamiętanych wartości
śledzona była na bieżąco, a nie była obliczana przy każdorazowym wywołaniu
metody sizeO.
3. Zmodyfikuj klasę BucketingHashtable tak, by liczba zapamiętanych wartości
śledzona była na bieżąco, a nie była obliczana przy każdorazowym wywołaniu
metody sizeO.
4. Skonstruuj iterator zapewniający dostęp do wszystkich pozycji zapamiętanych
w porcjowanej tablicy haszowanej (BucketingHashtable).
12
Zbiory
Zbiór (set) jest kolekcją danych, w której każdy element jest różny od pozostałych — okre-
śloną wartość można dodać do zbioru tylko raz. Różni to zbiór od listy, w której na warto-
ści elementów nie nakłada się żadnego ograniczenia. Swoją drogą w wielu konkretnych
rozwiązaniach, w których wykorzystano listę, zbiór okazałby się bardziej odpowiedni.
Rysunek 12.1.
Zbiór stanowi
pulę unikalnych,
nieuporządkowanych
wartości
334 A l g o r y t m y . Od podstaw
Operacja Znaczenie
add Dodaje wskazaną wartość do zbioru. Jeśli wartość taka jest już w zbiorze, wynikiem operacji
jest fal se i zawartość zbioru nie zmienia się; w przeciwnym razie rozmiar zbioru powiększa
się o 1 i operacja zwraca wartość true.
delete Usuwa wskazaną wartość ze zbioru. Jeśli wartości takiej w zbiorze nie ma, operacja zwraca
wartość false i zawartość zbioru nie zmienia się; w przeciwnym razie rozmiar zbioru
zmniejsza się o 1 i operacja zwraca wartość true.
iterator Udostępnia iterator wyznaczający kolejność przechodzenia przez poszczególne elementy zbioru.
isEmpty Sprawdza, czy zbiór jest pusty; gdy sizeO == 0, zwraca wartość true, w przeciwnym razie
zwraca wartość fal se.
elear Usuwa ze zbioru wszystkie wartości, zbiór staje się zbiorem pustym.
Na zbiorach można wykonywać różne użyteczne operacje. Załóżmy istnienie dwóch zbio-
rów przedstawionych na rysunku 12.2.
Rysunek 12.2.
Dwa zbiory:
X = (A, B, D, E, F, I, J)
oraz Y = (C, D, F, G, H, I, K)
X Y
Rozdział 12. • Zbiory 335
Sumą lub unią dwóch zbiorów X i Y nazywamy zbiór zawierający wszystkie elementy nale-
żące do któregokolwiek ze zbiorów X i Y, oczywiście z zastrzeżeniem unikalności (jeżeli jakiś
element obecny jest w obydwu zbiorach, w ich sumie pojawi się tylko raz). Sumę dwóch
zbiorów z rysunku 12.2 przedstawiliśmy na rysunku 12.3; elementy D, I oraz F, mimo iż
występują zarówno w X, jak i Y, do sumy X u Y wchodzą tylko raz.
Rysunek 12.3.
Suma dwóch zbiorów:
X uY
Iloczynem lub przecięciem dwóch zbiorów X i Y nazywamy zbiór tych i tylko tych elemen-
tów, które obecne są w każdym ze zbiorów X i Y. Iloczyn dwóch zbiorów jest więc zbiorem
ich wspólnych elementów, oczywiście uwzględnianych jednokrotnie. Na rysunku 12.4 wi-
doczny jest iloczyn zbiorów z rysunku 12.2.
Rysunek 12.4.
Iloczyn dwóch zbiorów:
Xn Y
Różnicą zbiorów X i Y nazywamy zbiór wszystkich tych elementów, które należą do zbioru X
i nie należą do zbioru Y. Efekt „odjęcia" zbioru Y od zbioru X przedstawiono na rysunku 12.5.
Rysunek 12.5.
Różnica dwóch zbiorów:
X-Y
336 Algorytmy. Od podstaw
Inlerfejs zbioru
Opisane w tabeli 12.1 operacje wykonywane na zbiorach znajdują swe odzwierciedlenie
w postaci następującego interfejsu:
package com.wrox.algori thms.sets;
import com.wrox.algorithms.iteration.Iterable;
J a k to działa?
Metody interfejsu Set odpowiadają poszczególnym operacjom opisanym w tabeli 12.1. In-
terfejs ten stanowi rozszerzenie interfejsu Iterable, definiuje więc zbiór jako strukturę ite-
rowalną implementującą metodę i t e r a t o r () i przydatną do użycia w kontekście wszelkich
iteracji.
_set.add(C);
_set.add(A);
_set.add(B);
set.add(D);
assertTrueC_set.add(E));
assertTrue(_set.contai ns(E)):
assertEquals(5, _set.sizeO):
assertTrue(_set.add(F));
assertTrue(_set.contains(F));
assertEquals(6. _set.sizeO);
assertFalse(_set.add(A));
assertEquals(4, _set.sizeO);
assertFalse(_set.add(B));
assertEquals(4. _set.sizeO):
assertFalse(_set.addCC)):
assertEquals(4. _set.sizeO);
338 Algorytmy. Od podstaw
assertFalset set.add(D));
assertEquals(4, _set.sizeO);
}
public void testDeleteExisting() {
assertTrue(_set.delete(B));
assertFalse(_set.contains(B));
assertEquals(3, _set.sizeO);
assertTrue(_set.delete(A));
assertFalse(_set.contains(A));
assertEquals(2, _set.sizeO):
assertTrue(_set.delete(C));
assertFalse(_set.contains(C));
assertEquals(l. _set.sizeO);
assertTrue(_set.delete(D));
assertFalse(_set.contains(D));
assertEquals(0, _set.sizeO);
_set.clearO;
assertEquals(0, _set.sizeO);
assertTrue(_set.i sEmpty());
assertFalse(_set.contains(A));
assertFalse(_set.contains(B));
assertFalse(_set.contains(C));
assertFalse(_set.containsCD));
}
public void testlteratorForwardsO {
checkIterator(_set. iteratorO);
}
public void testIteratorBackwards() {
checkIterator(new ReverseIterator(_set.iterator()));
}
private void checkIterator(Iterator i) {
List values = new LinkedListO;
}
try {
i.currentt);
failt); // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertEquals(4, values.sizeO);
assertTruetvalues.contains(A));
assertTruetvalues.contai ns(B));
assertTruetvalues.contains(C));
assertTruetvalues.contains(D)):
}
1
J a k to działa?
Klasa AbstractSetTest stanowi rozszerzenie klasy TestCase biblioteki JUnit. Definiuje ona
kilka przykładowych obiektów, z których cztery pierwsze stają się początkową zawartością
zbioru tworzonego przez metodę setUpO:
package com.wrox.a 1 gori thms.sets:
_set.add(C);
_set.add(A);
_set.add(B);
_set.add(D);
}
protected abstract Set createSett);
}
340 Algorytmy. Od podstaw
Metoda containsO powinna zwracać wartość true dla każdej wartości obecnej w zbiorze
i f a l s e dla każdej innej wartości. Skoro znamy już a priori zawartość zbioru _set (ustaloną
w metodzie setUpO), to możemy zweryfikować ten fakt zarówno dla wartości w zbiorze
obecnych:
public void testContainsExisting() {
assertTrue(_set.contai ns(A));
assertTrue(_set.contains(B));
assertTrue(_set.contains(C)):
assertTrue(_set.contains(D)):
}
jak i kilku wartości, których w tym zbiorze na pewno nie ma:
public void testContainsNonExisting() {
assertFalse(_set.contains(E));
assertFal se(_set.contains(F));
}
Metoda testAddNewValue() rozpoczyna pracę od zweryfikowania poprawności rozmiaru zbioru,
po czym dodaje do niego dwie wartości. Po każdym dodaniu weryfikowany jest nowy, ocze-
kiwany rozmiar zbioru oraz badana jest obecność dopiero co dodanego elementu:
public void testAddNewValue() {
assertEquals(4, _set.sizeO);
assertTrue(_set.add(E));
assertTrue(_set.contains(E));
assertEquals(5. _set.sizeO).•
assertTrue(_set.add(F));
assertTrue(_set.contains(F)):
assertEquals(6, _set.sizeO):
}
Ze względu na wymóg unikalności elementów zbioru dodawanie wartości już w nim obec-
nych nie powinno odnosić żadnego skutku — metoda add() konsekwentnie zwracać po-
winna wartość false, a rozmiar zbioru powinien pozostawać niezmieniony.
public void testAddExistingValueHasNoEffect() {
assertEquals(4, _set.sizeO);
assertFalse(_set.add(A)):
assertEquals(4, _set.sizeO):
assertFalse(_set.add(B));
assertEquals(4. _set.sizeO):
assertFal se(_set.add(C));
assertEquals(4, _set.sizeO);
assertFalse(_set.add(D));
assertEquals(4. set.sizeO);
}
W metodzie testDeleteExisting() usuwany jest kolejno każdy z czterech elementów tworzą-
cych początkową zawartość zbioru. Po każdym usunięciu weryfikowany jest nowy, oczekiwany
rozmiar zbioru, sprawdzana jest także nieobecność w nim usuniętego właśnie elementu.
Rozdział 12. • Zbiory 341
assertTrue(_set.delete(A)):
assertFalse(_set.contains(A)):
assertEquals(2, _set.sizeO):
assertTrue(_set.delete(C)):
assertFalse(_set.contains(C));
assertEquals(l. _set.sizeO);
assertTrue(_set.delete(D));
assertFalse(_set.contains(D)):
assertEquals(0, _set.sizeO);
}
Próba usuwania wartości nieobecnych w zbiorze nie powinna zmieniać rozmiaru zbioru,
a metoda deleteO konsekwentnie powinna zwracać wartość false.
public void testDeleteNonExisting() {
assertEquals(4, _set.sizeO):
assertFalse(_set.delete(E)):
assertEquals(4. _set.sizeO):
assertFalse(_set.delete(F));
assertEquals(4, _set.sizeO):
}
Metoda testClearO rozpoczyna pracę od upewnienia się, że ma do czynienia ze zbiorem
czteroelementowym. Po wywołaniu metody clearO sprawdza, czy metoda ta uczyniła
zbiór zbiorem pustym, a następnie weryfikuje nieobecność w tym zbiorze elementów, które
początkowo składały się na jego zawartość.
public void testClearO {
assertEquals(4, _set.sizeO):
assertFalse(_set.i sEmptyt));
_set.clearO;
assertEquals(0, _set.sizeO);
assertTrue(_set.i sEmpty());
assertFalse(_set.contains(A)):
assertFalse(_set.contains(B));
assertFalse(_set.contains(C));
assertFalse(_set.contains(D));
}
Ponieważ — jak przed chwilą wyjaśnialiśmy — zbiór jest strukturą iterowalną powinniśmy
się upewnić, że w wyniku iterowania po jego zawartości (zarówno w przód, jak i wstecz)
otrzymujemy wszystkie elementy. W tym celu — w metodzie checkIterator() — prowa-
dzimy iterację aż do wyczerpania iteratora, dodając do listy kolejne elementy zwracane
przez ten iterator i na końcu sprawdzamy, czy w liście tej znajdują się wszystkie elementy
zbioru. Dodatkowo następuje sprawdzenie samego iteratora — po jego wyczerpaniu od-
wołanie się do elementu bieżącego (current()) powinno spowodować wyjątek.
342 Algorytmy. Od podstaw
Aby wykonać iterację w przód, przekazujemy iterator zbioru bezpośrednio do metody check-
IteratorO:
public void testlteratorForwardsO {
checkIterator(_set.iterator()):
}
Iterację wstecz wykonujemy, obudowując iterator zbioru iteratorem odwracającym (Reverse-
Iterator), opisywanym w rozdziale 2. W wyniku tego odwrócenia metody f i r s t O i l a s t O
oryginalnego iteratora zamieniają się rolami, podobnie jak metody next() i previous().
i mport com,wrox.algorithms.iteration.Iterator;
import com.wrox.algorithms.1 ists.LinkedList:
import com.wrox.algorithms.1 ists.List:
J a k to działa?
W charakterze medium przechowującego elementy zbioru użyliśmy listy wiązanej, choć nie
jest to specjalnie istotne i technicznie można by użyć dowolnej klasy implementującej interfejs
List. Większość metod klasy ListSet deleguje swe wywołania do identycznie nazwanych
344 Algorytmy. Od podstaw
metod listy przechowującej elementy, wyjątkiem jest jednak metoda add(). Ponieważ lista
jako taka nie jest wyposażona w żaden mechanizm kontroli unikalności elementów, więc
przez dodaniem elementu do listy należy się upewnić, że jeszcze go w tej liście nie ma.
public boolean add(Object value) {
if (contains(value)) {
return false;
_values.add(value);
return true:
Zbiór haszowany
W razie potrzeby przechowywania dużej ilości danych, których uporządkowanie jest nie-
istotne, dobry wybór stanowi zbiór implementowany na bazie tablic haszowanych (patrz
rozdział 11.). Wykorzystamy porcjowany wariant haszowania jako znacznie efektywniej-
szy od próbkowania liniowego.
import com.wrox.algorithms.hashing.Hashtablelterator:
import com.wrox.algorithms.iterati on.ArrayIterator:
i mport com.wrox.a1gori thms.i terati on.Iterator;
* Konstruktor
* Parametry:
* - początkowy rozmiar tablicy porcji
* - progowa wartość współczynnika zapełnienia
*/
public HashSettint initialCapacity, float loadFactor) {
assert initialCapacity > 0 : "początkowy rozmiar tablicy musi być dodatni";
assert loadFactor > 0 : "progowa wartość zapełnienia musi być dodatnia":
JnitialCapacity = initialCapacity;
JoadFactor = loadFactor;
clear();
}
public boolean containsCObject value) {
ListSet bucket - Juckets[bucketIndexFor(value)]:
return bucket != nuli && bucket.contains(value);
}
public boolean addCObject value) {
ListSet bucket - bucketFor(value);
if (bucket.add(value)) {
++_size;
maintainLoadO;
return true:
}
346 Algorytmy. Od podstaw
return false;
}
public boolean delete(Object value) {
int bucketlndex = bucketIndexFor(value):
ListSet bucket = _buckets[bucketlndex];
if (bucket != nuli && bucket.delete(value)) {
--_size:
if (bucket.isEmptyO) {
_buckets[bucketlndex] = nuli:
}
return true;
}
return false:
}
public Iterator iteratorO {
return new HashtableIterator(new ArrayIterator(_buckets)):
}
public void clearO {
_buckets = new ListSet[_initialCapacity]:
_size = 0:
}
public int sizeO {
return _size:
}
public boolean isEmptyO {
return sizeO == 0:
* Parametr: wartość
Rozdział 12. • Zbiory 347
/**
*/
private boolean loadFactorExceeded() {
return sizeO > _buckets.length * JoadFactor:
}
J a k to działa?
Kod klasy HashSet jest w dużej części kopią kodu klasy BucketingHashtable opisywanej
w rozdziale 11., skoncentrujemy więc naszą dyskusję jedynie na nowościach klasy HashSet
i jej różnicach w stosunku do pierwowzoru.
Pierwszą z wymienionych różnic jest ta, że porcje elementów, które w ramach klasy Bucke-
tingHashtable były zwykłymi listami (List), tym razem są zbiorami listowymi (ListSet).
Ma to sens o tyle, iż porcje tablicy haszowanej są w istocie zbiorami — ich elementy są
unikalne i nieuporządkowane. Dzięki takiemu posunięciu nie tylko upraszcza się sam kod,
ale także czytelniejsze są intencje programisty. Staje się to widoczne na przykład w przy-
padku metody add(), gdzie zbyteczne jest wywołanie metody containsO w celu sprawdze-
nia, czy dodawana wartość jest już obecna w zbiorze:
public boolean add(Object value) {
ListSet bucket = bucketFor(value);
if (bucket.add(value)) {
++_size:
maintainLoad():
return true:
}
return false;
}
Druga różnica wynika z konieczności zaimplementowania metody deleteO interfejsu Set
— interfejs tablicy haszowanej (Hashtable) metody takiej nie zawierał, nie przewidywali-
śmy bowiem możliwości usuwania elementów z tablicy. Dzięki temu, że poszczególne por-
cje są zbiorami listowymi, po zidentyfikowaniu porcji zawierającej wartość przeznaczoną
do usunięcia należy wywołać metodę deleteO tej porcji.
public boolean delete(Object value) {
int bucketlndex = bucketIndexFor(value);
ListSet bucket - _buckets[bucketlndex];
if (bucket != nuli && bucket.delete(value)) {
--_size;
if (bucket.isEmptyO) {
_buckets[bucketlndex] = nuli:
}
return true:
}
return false;
}
Iterator zbioru haszowanego, umożliwiający trawersację zbioru, oparty jest bezpośrednio
na iteratorze tablicy haszowanej (HashtableIterator) będącym przedmiotem ćwiczenia 4.
z rozdziału 11.
Rozdział 12. • Zbiory 349
Przed dalszą lekturą zachęcamy Czytelnika do przypomnienia sobie koncepcji i zasad funk-
cjonowania drzew binarnych, bowiem opis niniejszej implementacji koncentrować się będzie
tylko na różnicach w stosunku do klasy BinarySearchTree.
/** wskazanie na korzeń drzewa lub wartość pusta dla pustego zbioru */
private Node _root;
public TreeSetO {
this (Natura Komparator. INSTANCE);
}
if (parent — nuli) {
_root = inserted;
} else if (cmp < 0) {
parent.setSma11 er(i nserted);
} else (
parent.setLarger(inserted);
}
++_size;
return true:
1
public boolean delete(Object value) {
Node node = search(value);
if (node == nuli) {
return false;
}
Rozdział 12. • Zbiory 351
Node deleted =
node.getSmaller() != nuli && node.getLargerO !« nuli
? node.successorO : node;
assert deleted !- nuli : "podano pustą wartość";
/** ojciec */
private Node _parent;
while (node.isLargerO) {
node = node.getParentO;
}
return node.getParentO;
}
354 Algorytmy. Od podstaw
while (node.isSmallerO) {
node = node.getParentO;
}
return node.getParentO;
}
}
private finał class ValueIterator implements Iterator {
private Node _current;
}
}
Rozdział 12. • Zbiory 355
J a k to działa?
Kod klasy TreeSet wzorowany jest w dużym stopniu na kodzie klasy BinarySearchTree
z rozdziału 10., skoncentrujemy się więc jedynie na jego różnicach w stosunku do pierwo-
wzoru.
Dodano także kilka metod wymaganych przez interfejs Set, których nie ma w klasie Bina-
rySearchTree: clearO, isEmptyO, sizeO i iteratorO. K l a s ę Node u c z y n i o n o k l a s ą pry-
watną— węzły są jedynie wewnętrznym mechanizmem implementacyjnym zbioru i jako
takie nie są interesujące dla jego użytkownika. Sam iterator ma natomiast postać wewnętrz-
nej klasy ValueIterator. Rozpoczyna on iterację od elementu najmniejszego (minimumO)
lub największego (maximumO) i posuwa się po kolejnych jego następnikach (successorO)
lub poprzednikach (precedessorO).
Mamy więc to, czego oczekiwaliśmy: zbiór w implementacji o efektywności 0(log N) — patrz
rozdział 10. — udostępniający swe elementy w kolejności posortowanej.
356 Algorytmy. Od podstaw
Podsumowanie
Czytając zakończony właśnie rozdział, miałeś okazję dowiedzieć się, że:
• zbiór jest nieuporządkowaną kolekcją unikalnych elementów,
• w wyniku iteracji po zbiorze jego elementy udostępniane są w przypadkowej
kolejności,
• zbiór w implementacji listowej cechuje się efektywnością 0(N) i przydatny jest
raczej dla niezbyt liczebnych kolekcji danych,
• zbiór haszowany może osiągać efektywność zbliżoną do 0( 1), nie gwarantując
uporządkowania elementów udostępnianych przez iterator,
• zbiór implementowany na bazie drzewa binarnego charakteryzuje się efektywnością
0(log N) i zdolny jest do udostępniania elementów w kolejności określonej
przez wskazany komparator.
Ćwiczenia
1. Napisz metodę badającą czy dwa podane zbiory są równe.
2. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący ich
sumę (unię).
3. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący ich
iloczyn (przecięcie).
4. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący
różnicę pierwszego i drugiego.
5. Zmodyfikuj metodę deleteC) klasy HashSet w ten sposób, by po usunięciu
jedynego elementu w porcji usuwana była cała porcja.
6. Stwórz implementację zbioru bazującą na posortowanej liście.
7. Stwórz implementację zbioru „zawsze pustego" —jakakolwiek próba modyfikacji jego
zawartości powinna powodować wystąpienie wyjątku UnsupportedOperationexception.
13
Mapy
Mapy — zwane także słownikami, tablicami przeglądowymi, tablicami skojarzeniowymi itp.
— okazują się szczególnie użyteczne dla tworzenia wszelkiego rodzaju indeksów.
W niniejszym rozdziale:
• wyjaśnimy koncepcję mapy,
• opiszemy podstawowe operacje wykonywane na mapach,
• przedstawimy trzy różne implementacje map: listową przydatną dla małych
kolekcji danych, haszowaną przeznaczoną dla olbrzymich kolekcji danych
nieuporządkowanych, oraz drzewiastą, dającą przewidywalną kolejność
wyników iteracji.
Jedną z najważniejszych cech mapy jest wymóg unikalności kluczy — każdy klucz musi
być różny od pozostałych — i jednocześnie brak takiego wymogu odnośnie wartości. Wy-
obraźmy sobie na przykład, że kluczami są numery telefonów: ponieważ jeden człowiek
może posługiwać się kilkoma różnymi numerami — telefonu domowego, firmowego, ko-
mórkowego itp. — j e s t oczywiste, że kilka różnych kluczy (numerów) prowadzić może o tego
samego nazwiska. W przykładzie przedstawionym na rysunku 13.2 Leonardo da Vinci do-
stępny jest pod dwoma numerami: 555-123-4560 i 555-991-4511.
358 Algorytmy. Od podstaw
Rysunek 13.1.
Indeks wiążący
nazwiska Leonardo
z numerami da Vinci
rekordów
bazy danych Raphael
Rekord nr 5
Rekord nr 2
Michelangelo
Renoir
Rekord nr 1 Monet
Rekord nr 4
Rekord nr 3
Rysunek 13.2.
Klucze mapy są
unikalne, wartości
przypisane kluczom
niekoniecznie
Mapy często nazywane są także słownikami (dictionaries) — i nic w tym dziwnego: w słow-
niku języka polskiego kluczami są hasła, a wartościami objaśnienia tych haseł, zaś w słow-
niku polsko-angielskim kluczami są polskie słowa, a wartościami — ich angielskie odpo-
wiedniki. Nieprzypadkowo więc klasa JDK, oryginalnie implementująca mapę, nosi nazwę
Dictionary.
Innym synonimem pojęcia mapy jest tablica skojarzeniowa (associative array). Wszak ta-
blica składa się z elementów identyfikowanych za pomocą indeksów, więc jeśli indeksy te
Rozdział 13. • Mapy 359
potraktować jako klucze, to tablicę uważać można za mapę, której wartościami są wartości
elementów 1 .
Operacja Znaczenie
get Odnajduje wartość skojarzoną z danym kluczem (o ile klucz taki istnieje w mapie).
set Przypisuje danemu kluczowi n o w ą wartość, zwracając wartość dotychczas związaną z tym
kluczem (jeśli taka istnieje).
delete U s u w a wartość przypisaną do danego klucza i zwraca j ą (jeśli taka wartość istnieje).
isEmpty Sprawdza, czy mapa jest pusta (tj. czy size() == 0), zwracając true w takiej sytuacji i false
w przeciwnym razie.
elear Usuwa z mapy wszystkie pary „klucz-wartość". Rozmiar mapy resetuje się tym samym do zera.
Interfejs mapy
Programistycznym odzwierciedleniem funkcjonalności mapy jest interfejs określony nastę-
pująco:
import com.wrox.algorithms.iteration.Iterable;
1
Wydaje się, że autorzy niewłaściwie zinterpretowali tu pojęcie tablicy skojarzeniowej, której istotą
jest odnajdywanie lokalizacji na podstawie zawartości, a nie odwrotnie; jeśli jednak wartości
elementów tablicy będą unikalne, to tablicę tę faktycznie uważać można za mapę, w której wartości
te pełnią rolę kluczy — p r z y p . tłum.
360 Algorytmy. Od podstaw
J a k to działa?
Interfejs Map składa się z metod odpowiadających poszczególnym operacjom opisanym w ta-
beli 13.1. Definiuje on mapę jako strukturę iterowalną, wywodzi się bowiem z interfejsu
Iterable, po którym dziedziczy metodę iteratorO. Wewnętrzny interfejs Map.Entry sta-
nowi natomiast abstrakcję elementu mapy, jakim jest jej pozycja, czyli para „klucz-wartość".
Instancje interfejsu Map. Entry udostępniane są przez iterator mapy.
/**
* Konstruktor
*
* Parametry:
* - klucz
* - wartość przypisana do klucza
*/
public DefaultEntry(Object key. Object value) {
assert key != nuli : "podano pusty klucz":
_key = key:
setValue(value);
}
public Object getKeyO {
return _key;
}
Rozdział 13. • Mapy 361
J a k to działa?
Zwróćmy uwagę, iż po utworzeniu instancji klasy jej klucz nie może być modyfikowany —
zmienna _key opatrzona jest atrybutem fina! — modyfikacja taka byłaby niecelowa, skoro
ma ona jednoznacznie reprezentować daną wartość. Oczywiście wartość przypisana klu-
czowi może być modyfikowana. Zauważmy ponadto, że o ile klucz instancji musi być zdefi-
niowany (patrz odpowiednia asercja w konstruktorze), to nic nie stoi na przeszkodzie przy-
pisywaniu kluczom pustych wartości. Teoretycznie możliwe byłoby wykorzystywanie także
pustych kluczy, lecz ich przydatność byłaby raczej ograniczona; puste wartości są natomiast
czymś jak najbardziej normalnym, czego przykład widzimy na rysunku 13.3 — ponieważ
człowiek reprezentowany przez widoczny rekord bazy danych nie korzysta z telefonu komór-
kowego i nie posiada prawa jazdy, numery tychże atrybutów są wartościami pustymi (nul 1).
Zwróćmy także uwagę na to, iż metody interfejsu realizują nie tylko uaktualnianie wartości
dla danego klucza, lecz także zwracają wówczas wartość dotychczas przypisaną temu klu-
czowi. Filozofia ta odzwierciedlona jest m.in. w metodzie SetValue().
Rysunek 13.3.
Klucze muszą
być określone, Oata
natomiast wartości urodzenia
przypisywane Telefon
kluczom mogą komórkowy
być wartościami 1 stycznia
1967
pustymi
Prawo jazdy
David Adres
Gdzieś
w Londynie
jnap - createMapt);
_map.set(C.getKey(). C.getValue());
jnap.set(A.getKeyO. A.getValueO);
_map.set(B.getKeyC). B.getValue());
jnap. set (D. getKey O . D.getValue());
}
protected abstract Map createMapO;
assertNul1(jnap.set(E.getKey O , E.getValue())):
assertEquals(E.getValue(). jnap.get(E.getKeyO)):
assertEquals(5, jnap.sizeO);
assertNul1(jnap.set(F.getKeyO, F.getValue()));
assertEqua1s(F.getValue(). jnap.get(F.getKey())):
assertEquals(6, jnap.sizeO):
assertEqua1s(B,getValue(). _map.delete(B.getKey())):
assertFalse(jnap.contains(B. getKeyO)):
assertEquals(3, _rrap.sizeO);
assertEqua1s(A.getVa1ue(), jnap.delete(A.getKey O ) ) :
assertFalse(_map.contains(A.getKey()));
assertEquals(2, _map.sizeO);
assertEquals(D.getValue(). jnap.delete(D.getKeyO));
assertFalse(jnap.contains(D.getKey()));
assertEquals(0, _map.size()):
jnap. clearO:
assertEquals(0, jnap.sizeO);
assertTrue( jnap.i sEmpty());
try {
i .currentO;
failO; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertEquals(4, entries.sizeO):
assertTruetentries.contains(A));
assertTruetentries.contains(B)):
assertTruetentries.contains(C)):
assertTruetentries.contains(D));
J a k to działa?
Klasa AbstractMapTest wywodzi się ze standardowej klasy testowej TestCase biblioteki JUnit.
Definiuje ona kilka przykładowych par „klucz-wartość", z których cztery pierwsze w me-
todzie setUp() dodawane są do tworzonej mapy jako jej zawartość początkowa.
Instancja testowanej mapy zwracana jest przez abstrakcyjną metodę createMapO podlega-
jącą konkretyzacji w klasie testowej przeznaczonej dla danej implementacji.
package com.wrox.algorithms.maps;
import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.a 1gori thms.i terati on.IteratorOutOfBoundsExcepti on;
import com,wrox.algorithms.iteration.ReverseIterator;
i mport com.wrox.a1gori thms.1 i sts.Li nkedL i st:
i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase:
map = createMapO;
map.set(C.getKeyO. C. getVa1ue O )
map.set(A.getKey(). A. getVa1ue O )
"map. set (B. getKey O . B.getValueO)
"map.set(D.getKey(). D.getValueO)
Metoda containsO powinna zwrócić wartość true dla każdego klucza, który zawarty jest
w mapie, i f a l s e dla każdego innego klucza. W metodzie testContainsExisting() weryfi-
kujemy ten fakt, bazując na czterech kluczach stanowiących początkową zawartość mapy:
public void testContainsExisting() {
assertTruet jnap. contains( A. getKeyO)):
assertTruet jnap.contai ns(B.getKey())):
assertTruet jnap.contai ns(C.getKeyt))):
assertTruet jnap.contai ns(D.getKey())):
}
Analogicznie w metodzie testContainsNonExisting() bazujemy na kluczach, co do których
wiadomo, że na pewno ich w mapie nie ma:
public void testContainsNonExisting() {
assertFalset_map.containstE.getKey t)));
assertFalsetjnap.contai ns(F.getKey())):
}
Sprawdzamy ponadto, czy dla znanych (obecnych w mapie) kluczy metoda get O zwraca
prawidłowo przypisane im wartości:
public void testGetExistingt) {
assertEquals(A.getValue(). jnap.gettA.getKeyt))):
assertEquals(B.getValue(). jnap.get(B.getKeyO)):
assertEqua1s(C.getVa1ue O . jnap.get(C.getKey())):
a s sert Equa1s t D.getVa1ue t), jnap.getto.getKey()));
}
Dla kluczy nieobecnych w mapie metoda get() powinna zwracać wartości puste (nul 1):
public void testGetNonExisting() {
assertNul1(jnap.get(E.getKey()));
assertNul1(jnap.get(F.getKey ()));
}
Metoda testSetNewKeyO weryfikuje poprawność odnajdywania zapamiętywanych warto-
ści. Po zweryfikowaniu prawidłowości początkowego rozmiaru mapy dodawane są do niej
dwie pary „klucz-wartość"; ponieważ w obydwu przypadkach dodawany klucz nie był do-
tąd w mapie obecny, więc jego „poprzednia wartość", zwracana jako wynik metody set O,
powinna być wartością pustą. Po każdym wywołaniu metody set O weryfikuje się ponadto
poprawność rozmiaru mapy.
public void testSetNewKeyO {
assertEquals(4. jnap.sizeO):
assertNul1(jnap.set(E.getKey(). E.getValue())):
assertEquals(E.getValue(), _map.get(E.getKeyO)):
assertEquals(5, _map.sizeO):
W przypadku klucza obecnego w mapie wywołanie metody set O nie powinno zwiększyć
rozmiaru mapy i powinno zwrócić jako wynik poprzednią wartość przypisaną kluczowi.
W metodzie testSetExistingKey() trzeciemu kluczowi (ckey) przypisywana jest nowa war-
tość cvalue2, przy czym wywołanie metody s e t ( ) powinno zwrócić wartość cvalue przypi-
saną poprzednio do tego klucza.
public void testSetExistingKey() {
assertEquals(4. jnap.sizeO):
assertEquals(C.getValue(), jnap.set(C.getKeyO. "cvalue2")):
assertEquals("cvalue2", jnap.get(C.getKeyO)):
assertEquals(4, map.sizeO);
}
W metodzie testDeleteExisting() dokonujemy sukcesywnego usuwania z mapy czterech
pozycji stanowiących jej zawartość (ustaloną w metodzie setUpO). Po każdorazowym usu-
nięciu pozycji weryfikuje się poprawność zwracanej wartości, a ponadto sprawdza, czy roz-
miar mapy zmniejszył się o 1.
public void testDeleteExisting() {
assertEquals(4, jnap.sizeO);
assertEquals(B.getValue(). jnap.delete(B.getKeyO)):
assertFalseC_map.contai ns(B.getKey()));
assertEquals(3. _map.sizeO):
assertEquals(A.getValue(), jnap.delete(A.getKeyO)):
assertFalse(_map.contains(A.getKey())):
assertEquals(2, jnap.sizeO);
assertEquals(C.getValue(). jnap.delete(C.getKeyO)):
assertFalse(_map.contains(C.getKey())):
assertEquals(l. _map.sizeO):
assertEquals(D.getValue(). _map.delete(D.getKey())):
assertFa lse( jnap. contai ns(D. getKeyO)):
assertEquals(0, jnap.sizeO):
}
Próba usuwania pozycji nieistniejących w mapie nie powinna zmieniać rozmiaru mapy,
a metoda delete() konsekwentnie powinna zwracać wartość nuli:
public void testDeleteNonExisting() {
assertEquals(4. jnap.sizeO):
assertNul1(jnap.delete(E.getKey())):
assertEquals(4. jnap.sizeO):
assertNul 1 (jnap.delete(F.getKeyO)):
assertEquals(4, jnap.sizeO):
}
Metoda testClearO najpierw upewnia się, że mapa nie jest aktualnie pusta, po czym wy-
wołuje metodę clearO i sprawdza, czy doprowadziło to do opróżnienia mapy. Dodatkowo
kontroluje się, czy dla każdego z usuniętych kluczy metoda contai ns() zwraca wartość pustą.
public void testClearO {
assertEquals(4. _map.sizeO);
assertFalse(_map.i sEmpty()):
368 Algorytmy. Od podstaw
_map.clear();
assertEquals(0. _map.sizeO):
assertTrue(_map.i sEmpty());
assertFalse(_map.contai ns(A.getKey())):
assertFalse(_map.contains(B.getKey()));
assertFalse(_map.contains(C.getKey())):
assertFalse(_map.contains(D.getKey()));
}
Ponieważ interfejs Map definiuje mapę jako strukturę iterowalną, powinniśmy się upewnić,
że w wyniku iterowania po jej zawartości (zarówno w przód, jak i wstecz) otrzymujemy
wszystkie jej pozycje. W tym celu — w metodzie checkIterator() — prowadzimy iterację
aż do wyczerpania iteratora, dodając do listy instancje klasy DefaultEntry tworzone na
podstawie pozycji zwracanych ten iterator i na końcu sprawdzamy, czy w liście tej znajdują
się wszystkie oczekiwane elementy. Dodatkowo następuje sprawdzenie samego iteratora —
po jego wyczerpaniu odwołanie się do elementu bieżącego (current()) powinno spowodować
wyjątek.
Uważny Czytelnik mógłby zapytać w tym miejscu, jaki sens ma tworzenie instancji klasy
DefaultEntry i czy nie można byłoby dodawać do wspomnianej listy bezpośrednio samych
pozycji zwracanych przez iterator. Otóż kwestia ta jest kwestią dość istotną nie tylko w tym
konkretnym przypadku, lecz ogólnie w każdym przypadku operowania (nieznanymi) in-
stancjami interfejsów. Otóż jedyne, co na pewno możemy powiedzieć o pozycjach zwraca-
nych przez iterator, jest to, że są one instancjami interfejsu Map.Entry; nie wiemy natomiast
nic na temat tych instancji, czyli na temat implementacji metod tego interfejsu. W szczegól-
ności nie mamy żadnej informacji o implementacji metody equals(), za pomocą której
chcemy sprawdzać obecność pozycji w liście — nie wiemy nawet, czy w ogóle została ona
zaimplementowana, a jeżeli tak, to czy prawidłowo porównywać będzie oryginalną pozycję
mapy z wzorcową pozycją klasy DefaultEntry. Konwertując oryginalne pozycje mapy na
instancje klasy DefautlEntry, pozbywamy się wszystkich tych wątpliwości. (Wyczerpującą
dyskusję na temat metody equals() Czytelnicy znaleźć mogą w książce Efektywne progra-
mowanie w języku Java [Bloch 2002]).
assertTrue(entries.contains(C));
assertTrue(entries.contains(D));
}
Podobnie jak w przypadku zbioru, aby wykonać iterację w przód, przekazujemy iterator
mapy bezpośrednio do metody check IteratorO:
public void testlteratorForwardsO {
check Iterator(_map.iteratorO);
}
Iterację wstecz wykonujemy natomiast, obudowując iterator mapy iteratorem odwracają-
cym (ReverseIterator), opisywanym w rozdziale 2. W wyniku tego odwrócenia metody
firstO i lastO oryginalnego iteratora zamieniają się rolami, podobnie jak metody next()
i previous().
public void testIteratorBackwards() {
checkIterator(new ReverseIterator(_map.iterator()));
}
import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.algorithms.1 i sts.Li nkedLi st;
i mport com.wrox.a1gori thms. 1 i sts.Li st;
J a k to działa?
if (entry.getKeyO ,equals(key)) {
return entry;
}
}
return nul 1;
}
Na bazie metody entryFor() łatwo jest już zbudować metodę zwracającą wartość przypisaną
danemu kluczowi. Metoda get O po prostu odczytuje wartość ze zwróconej pozycji, a jeśli
pozycji takiej nie ma, sama zwraca wartość pustą:
public Object get(Object key) {
DefaultEntry entry = entryFor(key);
return entry != nuli ? entry.getValue() : nuli;
}
W równie nieskomplikowany sposób na bazie metody entryFor()zaimplementować można
metodę contains():
public boolean contains(Object key) {
return entryFor(key) != nuli;
}
Metoda set O rozpoczyna pracę od zbadania, c z y j e j wywołanie wiąże się z dodaniem nowej
pozycji do mapy. Wywołuje ona w tym celu metodę entryForO. Jeżeli pozycja o wskaza-
nym kluczu zostanie znaleziona, jej dotychczasowa wartość zwracana jest jako wynik i za-
stępowana n o w ą wskazaną wartością. Jeśli natomiast metoda entryForO zwróci wyniku
nul 1, na koniec listy dodawana jest pozycja zawierająca wskazana parę „klucz-wartość", a sama
metoda set O zwraca wynik nul 1.
public Object set(Object key, Object value) {
DefaultEntry entry = entryFor(key);
if (entry != nuli) {
return entry.setValue(value);
}
_entries.add(new DefaultEntry(key, value));
return nul 1;
}
Metoda delete() usuwa z mapy pozycję identyfikowaną wskazanym kluczem po uprzed-
nim upewnieniu się, że pozycja ta faktycznie istnieje w mapie; wartość usuwanej pozycji
zwracana jest jako wynik. Jeśli natomiast metoda entryForO zwróci wynik pusty — co
oznacza, że w mapie nie ma pozycji o wskazanym kluczu — j e d y n ą czynnością wykony-
waną przez metodę deleteC) jest zwrócenie wartości pustej.
public Object delete(Object key) {
DefaultEntry entry = entryFor(key):
if (entry == nuli) {
return nul 1;
}
_entries.delete(entry);
return entry,getValue();
}
Rozdział 13. • Mapy 373
Listowa implementacja mapy okazuje się bardzo prosta — zdecydowana większość jej
funkcjonalności scedowana bowiem została na funkcjonalność samej listy. Za tę prostotę
trzeba niestety zapłacić cenę w postaci efektywności — na szczęście cenę dość umiarko-
waną: efektywność ta porównywalna jest z efektywnością przeszukiwania sekwencyjnego
listy i kształtuje się na poziomie O(N), co dla niewielkich map okazuje się w pełni akcep-
towalne.
public HashMap() {
this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR);
}
* Konstruktor
*
* Parametry:
* - początkowy rozmiar tablicy porcji
* - progowa wartość współczynnika zapełnienia
*/
JnitialCapacity = initialCapacity;
_loadFactor = loadFactor;
clearO;
}
public Object get(Object key) {
ListMap bucket = _buckets[bucketIndexFor(key)];
return bucket != nuli ? bucket.get(key) : nuli;
}
public Object set(Object key, Object value) {
ListMap bucket = bucketFor(key);
J a k to działa?
Kod klasy HashMap jest w znacznej części kopią kodu klasy Bucketi ngHashtabl e opisywanej
w rozdziale 11., skoncentrujemy więc naszą dyskusję jedynie na nowościach klasy HashMap
i jej różnicach w stosunku do pierwowzoru.
Wreszcie, ponieważ mapa jest strukturą iterowalną iterator mapy haszowanej — zwracany
przez metodę iteratorO — tworzony jest na bazie iteratora Hashtablelterator, o którym
wspominamy w jednym z ćwiczeń do rozdziału 1 I.
Rozdział 13. • Mapy 377
Przed dalszą lekturą zachęcamy Czytelnika do przypomnienia sobie treści rozdziału 10.,
traktującego o binarnych drzewach wyszukiwawczych; opis niniejszej implementacji kon-
centrować się będzie bowiem tylko na różnicach w stosunku do klasy BinarySearchTree.
/** wskazanie na korzeń drzewa lub wartość pusta dla pustej mapy */
private Node _root;
if (parent == nuli) {
_root = inserted:
} else if (cmp < 0) {
parent.setSmaller(inserted);
} else {
parent.setLarger(inserted);
}
++_size;
return nuli:
}
public Object delete(Object key) {
Node node - search(key);
if (node == nuli) {
return nuli;
}
Node deleted =
node.getSmallerO != nuli && node.getl_arger() != nuli
? node.successorO : node;
assert deleted != nuli : "podano pusty klucz";
if (deleted == _root) {
_root = replacement;
} else if (deleted.i sSmal1er()) {
deleted.getPa rent().setSma11 er(replacement);
} else {
deleted.getParent().setLargert replacement);
}
if (deleted != node) {
Object deletedValue = node.getValue();
node.setKey(deleted.getKey());
node.setValue(deleted.getValue());
deleted.setValue(deletedValue);
}
--_size;
return deleted.getValue();
}
public Iterator iteratorO {
return new EntryIterator();
}
public void clearO {
_root = nuli;
_size - 0;
}
public int sizeO {
return _si ze;
}
public boolean isEmptyO {
return _root == nul 1;
}
private Node search(Object value) {
assert value != nuli : "podano pustą wartość";
/** Wartość */
private Object _value:
/** Ojciec */
private Node _parent;
while (node.isLargerO) {
node = node.getParentO;
_}
382 Algorytmy. Od podstaw
return node.getParentO;
}
while (node.isSmallerO) {
node = node.getParentO:
}
return node.getParentO;
}
}
J a k to działa?
Kod klasy TreeSet wzorowany jest w dużym stopniu na kodzie klasy BinarySearchTree
z rozdziału 10. Oprócz implementowania interfejsu Map klasa TreeMap cechuje się jedną wy-
raźną różnicą w stosunku do klasy BinarySearchTree: podstawą uporządkowania węzłów są
nie wartości, lecz klucze pozycji, co staje się oczywiste po przyjrzeniu się parametrom wy-
woływania metody compa re() użytego komparatora.
Kolejną różnicą jest podejście do kwestii wyszukiwania elementów. Ponieważ metoda se-
archO klasy BinarySearchTree nie ma zastosowania do map (bo interfejs Map nie definiuje
żadnej metody wyszukującej pozycje), została uczyniona metodą prywatną — i jednocze-
śnie fundamentem metody containsO. Ta ostatnia zwraca wartość true tylko wtedy, gdy
metoda sea rch () odnajdzie węzeł ze wskazanym kluczem.
384 Algorytmy. Od podstaw
Dodano także kilka metod wymaganych przez interfejs Map, których nie ma w klasie Bina-
rySearchTree: clearO, isEmptyO, sizeO i iteratorO. Sam iterator m a postać wewnętrz-
nej klasy Entrylterator. Rozpoczyna on iterację od elementu najmniejszego (minimumO)
lub największego (maximum()) i posuwa się po kolejnych jego następnikach (successorO)
lub poprzednikach (precedessor()).
Tak oto otrzymaliśmy drzewiastą implementację mapy, po której, zgodnie z treścią roz-
działu 10., można spodziewać się „logarytmicznej" efektywności 0(log N) i która udostęp-
nia pozycje w kolejności kluczy określonej przez wskazany komparator.
Podsumowanie
Czytając niniejszy rozdział, mogłeś dowiedzieć się, że:
• mapa jest kolekcją danych przechowującą wartości identyfikowane kluczami,
• każdy klucz mapy jest różny od pozostałych i jednoznacznie identyfikuje
przypisaną mu wartość,
• mapy znane są pod różnymi nazwami — tablic skojarzeniowych, słowników,
indeksów, tablic przeglądowych itp.,
• mapy jako takie nie definiują żadnego uporządkowania swych pozycji,
• mapy implementować można między innymi w oparciu o listy, tablice haszowane
i drzewa binarne,
• mapa w implementacji listowej jest prosta koncepcyjnie, lecz jej mała efektywność
— rzędu 0{N) — ogranicza jej przydatność do niewielkich kolekcji danych,
• mapa haszowana umożliwia operowanie na dużych, nieuporządkowanych zbiorach
danych z efektywnością sięgającą <3(1) pod warunkiem użycia dobrej funkcji
haszującej,
• mapa bazująca na drzewie binarnym zapewnia efektywność rzędu 0(log N)
i przewidywalną kolejność iterowania po pozycjach.
Ćwiczenia
1. Stwórz iterator udostępniający wyłącznie klucze obecne w mapie.
2. Stwórz iterator udostępniający wyłącznie wartości obecne w mapie.
3. Stwórz implementację zbioru wykorzystującą mapę jako medium przechowujące
wartości.
4. Stwórz mapę „zawsze pustą" powodującą wystąpienie wyjątku
UnsupportedOperationException w przypadku jakiejkolwiek próby jej modyfikacji.
14
Ternarne drzewa wyszukiwawcze
W poprzednich rozdziałach opisywaliśmy różne struktury służące do przechowywania da-
nych — od prostych list nieuporządkowanych, poprzez listy posortowane, drzewa binarne
do tablic haszowanych. W każdej z tych struktur zapamiętywać można obiekty dowolnych
typów. Pora na ostatniąjuż w tej książce prezentację struktury danych — drzewa ternarnego,
zaprojektowanego specjalnie do przechowywania łańcuchów i cechującego się efektywnym
wyszukiwaniem realizowany inaczej niż w drzewie binarnym.
Na rysunku 14.1 widoczne jest drzewo ternarne przechowujące pięć łańcuchów: CUP, APE,
BAT, MAP i MAN. Tradycyjne powiązania typu „lewy-prawy", wywodzące się z binarnego
drzewa wyszukiwawczego, zaznaczyliśmy liniami ciągłymi, „środkowe" łączniki prowadzące
od węzłów do ich poddrzew kontynuacyjnych zaznaczone są natomiast liniami przerywanymi.
386 Algorytmy. Od podstaw
Rysunek 14.1.
Przykładowe drzewo
ternarne. Jego
korzeniem jest węzeł
przechowujący literę C,
wyróżnione węzły
reprezentują
natomiast słowo BAT
0 0
Zwróćmy uwagę, że połączone ciągłymi liniami, tworzące drzewo binarne węzły C, A, M, B,
mimo iż powiązane zależnością typu „mniejszy-większy", są jednak w pewnym sensie
równouprawnione, reprezentują bowiem ten sam poziom informacji — pierwsze litery za-
pamiętanych słów. Podobna zależność zachodzi między węzłami P i N, reprezentującymi
trzecie litery słów (odpowiednio) MAP i MAN. Z tego powodu, zamiast tradycyjnych relacji
„ojciec-syn" typowych dla drzewa binarnego, węzły tworzące jeden poziom w drzewie ter-
narnym zwykło się nazywać braćmi lub rodzeństwem (sibling), zaś korzeń drzewa konty-
nuacyjnego — potomkiem lub dzieckiem (chiłd).
Poszukując więc węzła zawierającego pierwszą literę żądanego słowa, postępujemy analo-
gicznie jak w binarnym drzewie wyszukiwawczym, pamiętając, że lewy brat zawiera literę
(alfabetycznie) „mniejszą", zaś prawy — literę (alfabetycznie) „większą"; następnie w celu
odnalezienia węzłów reprezentujących kolejne litery przemieszczamy się do drzewa po-
tomka. Szukając słowa BAT w drzewie z rysunku 14.1, odnajdujemy najpierw węzeł B. Roz-
poczynamy od korzenia C, ponieważ zawiera on literę większą od szukanej, przemieszcza-
my się do lewego brata A, ten z kolei zawiera literę mniejszą od szukanej, przemieszczamy
się do prawego brata i znajdujemy B. Przemieszczając się dwukrotnie wzdłuż środkowego
łącznika, trafiamy na węzły zawierające kolejne litery A i T.
Wyszukiwanie słowa
Wyszukiwanie słowa w drzewie ternarnym rozpoczyna się od znalezienia jego pierwszej
litery. Podobnie jak w binarnym drzewie wyszukiwawczym rozpoczynamy wędrówkę od
korzenia i w zależności od relacji wiążącej aktualny węzeł poszukiwaną literą kierujemy się
wzdłuż lewego lub prawego łącznika. Znalazłszy węzeł zawierający pierwszą literę słowa,
przechodzimy do jego poddrzewa kontynuacyjnego i w taki sam sposób poszukujemy dru-
giej litery. Postępowanie to kontynuujemy aż do zidentyfikowania pełnej ścieżki węzłów
reprezentującej szukane słowo.
Rysunek 14.2.
Poszukiwania
pierwszej litery
słowa rozpoczynamy
od korzenia drzewa
Litera C jest „większa" od szukanej litery B, przemieszczamy się więc zgodnie z lewym
łącznikiem, trafiając na węzeł z literą A (rysunek 14.3).
Rysunek 14.3.
Szukana litera (B)
jest mniejsza niż
litera w aktualnym
węźle (C),
przemieszczamy
się więc wzdłuż
lewego łącznika
do węzła
zawierającego
literę A
Litera A jest mniejsza od szukanej (B), przemieszczamy się więc do prawego brata bieżącego
węzła, osiągając cel poszukiwań (rysunek 14.4).
Rysunek 14.4.
Szukana litera (B)
jest większa niż
litera w aktualnym
węźle (A),
przemieszczamy
się więc wzdłuż
prawego łącznika do
węzła zawierającego
(szukaną) literę B
W celu znalezienia węzła zawierającego drugą literę słowa BAT przechodzimy do drzewa
kontynuacyjnego węzła bieżącego (B) i natychmiast odnajdujemy szukany węzeł jako ko-
rzeń tego drzewa (rysunek 14.5). Węzeł ten staje się bieżącym węzłem poszukiwań.
388 Algorytmy. Od podstaw
Rysunek 14.5.
Druga litera zostaje
odnaleziona
w korzeniu drzewa
kontynuacyjnego
Rysunek 14.6.
Znalezienie węzła
zawierającego
ostatnią literę
słowa kończy
poszukiwania
W ten oto sposób zidentyfikowaliśmy kompletną ścieżkę reprezentującą słowo BAT, wyko-
nując tylko pięć porównań pojedynczych znaków.
Powtórzmy teraz nasz myślowy eksperyment dla binarnego drzewa wyszukiwawczego sta-
nowiącego odpowiednik drzewa ternarnego z rysunku 14.1, czyli zawierającego w swych
węzłach kompletne słowa (patrz rysunek 14.7). Rozpoczynamy od porównania słów MAN i CUP
— po wykonaniu jednego porównania znakowego stwierdzamy, że konieczne jest prze-
mieszczenie się wzdłuż prawego łącznika, do węzła zawierającego słowo MAP. Zidentyfiko-
wanie relacji między słowami MAN i MAP wymaga trzech porównań znakowych; wynik tego
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 389
Rysunek 14.7.
Binarne drzewo
wyszukiwawcze
równoważne drzewu
ternarnemu
z rysunku 14.1
porównania nakazuje przemieszczenie się do węzła zawierającego słowo MAN. O tym, że węzeł
ten stanowi cel naszych poszukiwań, dowiemy się jednak dopiero po wykonaniu kolejnych
trzech porównań znakowych. Jak łatwo policzyć, poszukiwanie słowa MAN wymagało wy-
konania łącznie siedmiu porównań.
Nawet na tak prostym przykładzie można się było przekonać o przewadze drzewa ternarnego
nad binarnym drzewem wyszukiwawczym — przewadze pod względem efektywności mie-
rzonej liczbą porównań pojedynczych znaków. Podstawowym czynnikiem decydującym
o tej przewadze jest kompresja informacji — wspólny początek kilku słów przechowywany
jest w drzewie tylko jednokrotnie; gdy więc wchodzące w jego skład węzły wezmą udział
w porównaniach, nie będą już później ponownie im poddawane. Nie ma tej zalety binarne
drzewo wyszukiwawcze, w którym kompletne słowa poddawane są (niezależnie od siebie)
wielokrotnym porównaniom.
Oprócz wyszukiwania zapamiętanych słów drzewa ternarne wykazują się także dużą efek-
tywnością pod względem rozpoznawania nieobecnych. Podczas gdy w binarnym drzewie
wyszukiwawczym fakt nieobecności danej wartości staje się wiadomy dopiero po dotarciu
przeszukiwania na poziom liści, w drzewie ternarnym staje się on oczywisty już w momen-
cie nieznalezienia którejś (niekoniecznie ostatniej) litery szukanego słowa.
Znając już ogólne zasady wyszukiwania słów w drzewach ternarnych, możemy pokusić się
0 oszacowanie ogólnej efektywności tego wyszukiwania. Wyobraźmy sobie, że każdy po-
ziom drzewa zawiera wszystkie litery jakiegoś alfabetu ( A - Z dla alfabetu angielskiego)
1 oznaczmy liczbę tych liter przez M (w tym przypadku M-26). Jak pamiętamy z rozdziału
10., wyszukanie konkretnej litery na tym poziomie wymaga średnio 0(\ogM) porównań;
dla słowa AMiterowego oznacza to średnio 0(N log M) porównań. W praktyce jednak wy-
szukiwanie słów odbywa się znacznie szybciej, głównie za sprawą omawianej wcześniej
kompresji informacji; poza tym jest mało prawdopodobne, by każda litera alfabetu wystę-
powała w każdej gałęzi drzewa.
Wstawianie słowa
Wstawienie nowego słowa do drzewa ternarnego jest tylko nieznacznie trudniejsze niż wy-
szukanie słowa— wymaga ewentualnego dodania nowych węzłów zawierających niezbędne
litery, które jeszcze w drzewie nie występują. Na rysunku 14.8 pokazano dodawanie słowa
BATS do drzewa z rysunku 14.1, w związku z czym w drzewie pojawia się nowy węzeł S,
dodany jako poddrzewo kontynuacyjne do węzła zawierającego ostatnią literę słowa BAT.
Dodanie słowa MAT wymaga natomiast dodania nowego węzła T jako prawego brata węzła P
(kończącego ścieżkę słowa MAP).
390 Algorytmy. Od podstaw
Rysunek 14.8.
Wstawienie słów
BATS i MAT wymaga
dodania po jednym
węźle dla każdego
z nich
m
Przy okazji pojawia się pewien ważny problem: jak odróżnić wszystkie możliwe prefiksy
zapamiętanych słów od słów rzeczywiście zapamiętanych? Innymi słowy, jak na przykład
zaznaczyć obecność w drzewie słów BAT oraz BATS i jednocześnie nieobecność słów BA i B?
Albo obecność słowa MAP i nieobecność słowa AP?
Rozwiązanie tego problemu jest prostsze niż mogłoby się wydawać: w każdym węźle, który
kończy ścieżkę jakiegoś słowa, należy ustawić jakiś znacznik (bit, zmienną boolowską itp.).
Na rysunku 14.9 węzły takie wyróżniono kolorem szarym. W procesie wyszukiwania słów
— na przykład w słowniku zbudowanym na bazie drzewa ternarnego — należy uwzględ-
niać jedynie ścieżki kończące się tak oznakowanymi węzłami.
Rysunek 14.9.
Wyróżnienie węzłów
kończących ścieżki
poszczególnych
słów zapamiętanych
w drzewie
0
E
Podczas gdy wyszukiwanie słowa w zrównoważonym drzewie wymaga średnio 0(N log M)
porównań, w przypadku drzewa zdegenerowanego wartość ta może się zwiększyć do 0(NM).
Dla dużych M oznacza to poważną różnicę, choć nie zawsze jest tak źle, jak mogłoby się
wydawać: dużą rolę odgrywa jednokrotne przechowywanie wspólnego początku słów oraz
fakt, że nie wszystkie litery alfabetu występują na wszystkich poziomach.
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 391
Rysunek 14.10.
Zdegenerowany
odpowiednik drzewa
z rysunku 14.9
powstały w wyniku
dodawania słów
w kolejności
posortowanej
0 0
Poszukiwanie prefiksu
W wielu aplikacjach, na przykład w przeglądarkach WWW, obecne są menu udostępniające
do wyboru listę słów rozpoczynających się od sekwencji wpisanej w pole wyszukiwania.
W miarę wydłużania tej sekwencji lista dostępnych słów maleje, aż do jedynego słowa bę-
dącego swym własnym prefiksem.
1. Przejdź metodą in-order przez drzewo, którego korzeniem jest lewy brat węzła
bieżącego
2. Odwiedź węzeł bieżący.
3. Przejdź metodą in-order przez drzewo kontynuacyjne węzła bieżącego.
4. Przejdź metodą in-order przez drzewo, którego korzeniem jest prawy brat węzła
bieżącego.
Na rysunku 14.11 widoczny jest efekt wyszukiwania pierwszego słowa (MAN) rozpoczynają-
cego się od prefiksu MA. Węzeł zawierający ostatnią literę prefiksu (A) nie posiada braci, a je-
dynie drzewo kontynuacyjne. Przechodząc przez to drzewo metodą in-order, natrafiamy naj-
pierw na węzeł N kończący słowo MAN, a następnie na węzeł kończący słowo MAP (rysunek 14.12).
Samego węzła zawierającego ostatnią literę prefiksu uwzględnić nie możemy, bowiem nie
jest on oznakowany jako mogący kończyć słowo.
392 A l g o r y t m y . Od podstaw
Rysunek 14.11.
Pierwsze słowo
rozpoczynające się
od prefiksu MA
— MAN
Rysunek 14.12.
Drugie słowo
rozpoczynające
się od prefiksu MA A
— MAP
P
a
E
Przechodząc w ten sposób przez drzewo, można drukować otrzymywane słowa lub wyko-
rzystywać je w inny sposób, na przykład jako zawartość wyświetlanego menu.
Dopasowywanie wzorca
Czy zdarzyło Ci się godzinami myśleć nad znalezieniem słowa pasującego do rozwiązywanej
krzyżówki lub puzzli — „A--R---T", tylko tyle, i nic sensownego nie przychodzi do głowy.
Odgadywanie słów na podstawie znajomości tylko niektórych liter jest szczególnym przy-
padkiem procesu dopasowywania wzorca (pattern matching), a drzewa ternarne również
i w tym mogą okazać się wielce pomocne. W dalszym ciągu „wzorcem" nazywać będziemy
ciąg znaków, z których każdy może być bądź to „konkretnym" znakiem (na przykład literą
A - Z ) bądź znakiem blankietowym (wildcard) zastępującym dowolny konkretny znak. W po-
przednim akapicie w charakterze znaku blankietowego użyliśmy myślnika („—"), lecz można
w tej roli użyć dowolnego znaku niebędącego „konkretnym" znakiem w słowie.
Rysunek 14.13.
Znak blankietowy
wymaga
sprawdzenia
wszystkich węzłów
na danym poziomie
Zakładając, że litera zawarta w bieżącym węźle (A) może być pierwszym znakiem słowa
pasującego do wzorca, przemieszczamy się do poddrzewa kontynuacyjnego tego węzła w celu
dopasowywania następnych liter.
Jak widać na rysunku 14.14, drugą literą słowa może być tylko P; ponieważ jest to nie-
zgodne z wzorcem — drugą literą musi być „konkretnie" A — eliminujemy całe poddrzewo
kontynuacyjne z dalszych poszukiwań i jednocześnie eliminujemy jego węzeł macierzysty A.
Rysunek 14.14.
Brak dopasowania
powoduje
wykluczenie całej
gałęzi z poszukiwań
Rysunek 14.15.
Pierwszy znak
wzorca jest
szablonem, więc
przechodzimy
do następnego
węzła na poziomie
pierwszym
Rysunek 14.16.
Udało się
dopasować drugi
znak wzorca
Rysunek 14.17.
Znaleziono pierwsze
pasujące słowo
Ponieważ bieżący węzeł (T) oznaczony jest jako kończący słowo, znaleźliśmy tym samym
pierwsze pasujące do wzorca słowo — BAT. Kontynuując dopasowywanie, sięgamy po na-
stępny alfabetycznie węzeł na poziomie pierwszym — C (rysunek 4.18).
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 395
Rysunek 14.18.
Kontynuujemy
dopasowywanie,
sięgając
po następny
alfabetycznie
węzeł na pierwszym
poziomie
Proces ten powtarzamy dla wszystkich węzłów na pierwszym poziomie; ostateczny efekt
poszukiwań — z zaznaczeniem pasujących słów i wyeliminowanych gałęzi — widoczny
jest na rysunku 14.19.
Rysunek 14.19.
Ostateczny efekt
dopasowania
Znaleźliśmy zatem trzy słowa pasujące do wzorca „ - A - " — BAT, MAN i MAP — wykonując tylko
11 porównań pojedynczych znaków. To bardzo niewiele w konfrontacji z „siłowym" wy-
próbowywaniem wszystkich 26*26 = 676 kombinacji od AAA do ZAZ, z których każda wy-
magać może od jednego do trzech porównań.
_tree.add("prefabricate"):
_tree.add("presume"):
_tree.add("prejudice");
_tree.add("preliminary");
_tree.add("apple");
_tree.add("ape"):
_tree.add("appeal"):
_tree.add("car");
_tree.add("dog");
_tree.add("cat");
_tree.add("mouse");
_tree.add("mince");
_tree.add("minty");
_tree.prefixSearch(prefix, words);
assertEquals(expected. words):
}
private void assertPatternEquals(String[] expected. String pattern) {
List words » new LinkedListO:
_tree.patternMatch(pattern. words):
assertEquals(expected, words);
}
private void assertEquals(String[] expected. List actual) {
assertEquals(expected.length, actual .sizeO):
J a k to działa?
_tree.add("prefabricate"):
_tree.add("presume"):
398 Algorytmy. Od podstaw
assertFalse(_tree.contains("pre")):
assertFalse(_tree.contai ns("dogs")):
assertFal se(_tree.conta i ns("NIEZNANY"));
}
Oprócz metody containsO klasa implementująca drzewo ternarne posiadać będzie jeszcze
tylko dwie metody publiczne: jedną dla odnajdywania słów o wspólnym prefiksie (początku),
drugą dla dopasowywania słów do wzorca. Wynikiem każdej z nich jest lista znalezionych
słów, a ramach przypadków testowych następuje sprawdzenie, czy listy te mają zawartość
zgodną z oczekiwaniami.
Porównanie listy słów zwróconej w wyniku wyszukiwania z listą słów oczekiwanych do-
konywane jest przez wariant metody assertEquals(), otrzymujący jako parametry tablicę
łańcuchów i listę. Porównuje on najpierw rozmiary obydwu tych obiektów, a po stwierdze-
niu ich równości dokonuje porównania kolejnych pozycji.
private void assertEquals(String[] expected, List actual) {
assertEquals(expected.length. actual .sizeO):
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 399
assertEquals(expected. words);
}
W podobny sposób metoda testPatternMatch() weryfikuje poprawność dopasowywania
wzorca. Tworzy ona listę złożoną z kilku słów i wzorca, do którego pasują, delegując resztę
pracy do metody pomocniczej assertPatternEquals():
public void testPatternMatch() {
assertPatternEquals(new String[] {"mince", "mouse"). "m???e"):
assertPatternEquals(new String[] {"car", "cat"}. "?a?"):
}
Metoda assertPatternEquałs() wywołuje metodę patternMatch() drzewa ternarnego i kon-
frontuje zwrócony przez nią wynik z zawartością oczekiwaną. Zwróćmy uwagę, że w cha-
rakterze znaku blankietowego użyty został znak zapytania („?"). Wybór ten jest cokolwiek
arbitralny, lecz znak zapytania jest w tej roli bodaj najbardziej intuicyjny, poza tym jest
bardzo mało prawdopodobne, że mógłby pojawić się w danym słowie przez pomyłkę, jako
„literówka".
private void assertPatternEquals(String[] expected, String pattern) {
List words = new LinkedList():
_tree.patternMatch(pattern, words);
assertEquals(expected, words);
}
Dysponując odpowiednimi testami, możemy zająć się teraz właściwą implementacją drzewa
ternarnego.
400 Algorytmy. Od podstaw
if (node == nul 1) {
return nul 1;
}
Rozdział 14. • Ternarne drzewa wyszukiwawcze 401
char c = word.charAt(index);
if (c == node.getCharO) {
if (index + 1 < word.lengtht)) {
result = search(node.getChild(), word. index + 1);
}
} else if (c < node.getCharO) {
result = search(node.getSmallerO, word. index):
} else {
result = search(node.getLargerO, word, index);
}
return result;
char c = word.charAt(index):
if (node — nul 1) {
return insert(new Node(c), word. index);
}
if (c — node.getCharO) {
if (index + 1 < word.lengthO) {
node.setChild(insert(node.getChild(). word. index + 1));
} else {
node.setWord(word.toString());
}
} else if (c < node.getCharO) {
node.setSmaller(insert(node.getSmallerO, word. index));
} else {
node.setLarger(insert(node.getLargerO, word, index));
}
return node:
}
private void patternMatch
(Node node. CharSequence pattern, int index. List results) {
assert pattern != nuli : "nie określono wzorca":
assert results != nuli : "nie określono listy wynikowej":
if (node == nuli) {
return;
}
char c = pattern.charAt(index):
results.add(node.getWord());
}
}
if (c == WILDCARD || c > node.getCharO) {
patternMatch(node.getLarger(), pattern. index. results);
}
}
private void inOrderTraversal(Node node. List results) {
assert results != nuli : "nie określono listy wynikowej";
if (node — nuli) {
return;
}
inOrderTraversal(node.getSmaller(). results);
if (node.isEndOfWordO) {
results.addtnode.getWordO);
}
inOrderTraversal(node.getChild(). results);
inOrderTraversal(node.getLarger(). results);
public Node(char c) {
_c = c;
}
public char getCharO {
return _c;
}
public Node getSmallerO {
return _smaller;
}
public void setSmaller(Node smaller) {
_smaller = smaller;
}
public Node getLargerO {
return _larger;
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 403
}
public void setl_arger(Node larger) {
J a r g e r = larger;
}
public Node getChildO {
return _child;
}
public void setChild(Node child) {
_child = child;
}
public String getWordO {
return _word:
}
public void setWord(String word) {
_word = word:
}
public boolean isEndOfWordO {
return getWordO != nuli:
}
}
}
J a k to działa?
Klasa TernarySearchTree nie jest zbyt skomplikowana. Jej specyficznymi elementami są:
zmienna reprezentująca korzeń drzewa oraz stała definiująca znak blankietowy.
package com.wrox.algori thms.tstrees;
}
Każdy z węzłów tworzących strukturę drzewa reprezentowany jest przez klasę Node. Każdy
węzeł posiada pole przechowujące pojedynczy znak oraz trzy łączniki do (odpowiednio)
lewego brata, prawego brata i poddrzewa kontynuacyjnego. Jak pamiętamy z wcześniejsze-
go opisu, konieczne jest także zapewnienie specjalnego oznaczania wybranych węzłów ja-
ko kończących słowo; moglibyśmy w tej roli użyć pola typu boolean, jednak ze względów
praktycznych postanowiliśmy w takim węźle przechowywać kompletne słowo, którego
ostatnią literę węzeł ten zawiera — celowi temu służy pole _word. Rozwiązanie takie po-
woduje co prawda zwiększone zapotrzebowanie na pamięć, lecz za to znakomicie ułatwia
404 Algorytmy. Od podstaw
zidentyfikowanie słowa, które kończy się na danym węźle. Aby ułatwić odróżnianie wę-
złów kończących słowa od „zwykłych" węzłów pośrednich, zdefiniowano pomocniczą
metodę i sEndOfWordO.
private static finał class Node {
/** znak przechowywany w węźle */
private finał char _c;
public Node(char c) {
_c = c;
}
public char getCharO {
return _c;
}
public Node getSmallerO {
return _smaller:
}
public void setSmal1 er(Node smaller) {
_smaller = smaller;
}
public Node getLargerO {
return Jarger;
}
public void setLarger(Node larger) {
J a r g e r = larger;
}
public Node getChildO {
return _child;
}
public void setChi1d(Node child) {
_child = child;
}
public String getWordO {
return _word;
}
public void setWord(String word) {
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 405
_word = word;
}
public boolean isEndOfWord() {
return getWordO != nuli;
}
}
Przed dalszą lekturą kodu źródłowego drobna uwaga: ponieważ operacje wykonywane
na drzewie ternarnym są z natury rekurencyjne, więc rekurencyjnymi są też wszystkie
metody klasy TernarySearchTree.
Metoda containsO zwraca wartość true wtedy i tylko wtedy, gdy argument jej wywołania
jest kompletnym słowem (nie prefiksem) znajdującym się w drzewie; w przeciwnym razie
metoda ta zwraca wartość false. Po upewnieniu się, że argument wywołania metody jest
słowem niepustym, wywoływana jest metoda searchO z korzeniem drzewa jako argumentem
(oznaczającym początek poszukiwań). Jeżeli szukane słowo faktycznie występuje w drzewie,
powinna ona zwrócić wskazanie na węzeł, który jest końcowym węzłem słowa:
public boolean contains(CharSequence word) {
assert word != nuli : "nie określono słowa";
assert word.lengthO > 0 : "nie można używać pustych słów";
Jeśli nie jest aktualnie określony bieżący węzeł (node == nuli), metoda searchO natych-
miast kończy swą pracę. W przeciwnym razie odczytywany jest znak słowa (na pozycji
wskazywanej przez trzeci argument) i rozpoczyna się jego poszukiwanie.
Jeśli znak ten jest identyczny ze znakiem znajdującym się w bieżącym węźle i nie jest ostatnim
znakiem w słowie (i ndex+l < word .length), przechodzimy do następnego znaku w słowie,
zaś korzeń poddrzewa kontynuacyjnego bieżącego węzła staje się nowym węzłem bieżącym.
Jeśli wspomniany znak jest większy (odpowiednio: mniejszy) od znaku zapamiętanego w bie-
żącym węźle, należy przejść do prawego (odpowiednio: lewego) brata węzła bieżącego i uczy-
nić go nowym węzłem bieżącym.
if (node == nuli) {
return nul 1;
}
char c = word.charAt(index);
if (c == node.getCharO) {
if (index + 1 < word.lengthO) {
result = search(node.getChild(), word, index + 1);
}
} else if (c < node.getCharO) {
result = search(node.getSmaller(), word, index);
} else {
result - search(node.getLarger(), word, index);
}
return result;
}
Metody addO i insertO współdziałają ze sobą, a ich zadaniem jest dodawanie nowych słów
do drzewa.
Jeśli bieżący znak słowa jest mniejszy (odpowiednio: większy) od znaku zapamiętanego
w bieżącym węźle, następuje (rekurencyjne) przejście do lewego (odpowiednio: prawego)
brata jako nowego węzła bieżącego, bez zmiany pozycji bieżącego znaku w słowie.
Zwróćmy uwagę na to, w jaki sposób zwracana wartość wykorzystywana jest do uaktual-
niania wskaźnika na węzeł-brata lub korzeń poddrzewa kontynuacyjnego: metoda insertO
zwraca zawsze węzeł właśnie wstawiony (lub odpowiedni węzeł istniejący w drzewie) —
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 407
oznacza to, że węzeł zwracany przez tę metodę w ciele metody add() reprezentuje pierwszy
znak wstawianego słowa, a nie znak ostatni, jak mogłoby się zrazu wydawać.
private Node insert(Node node. CharSequence word. int index) {
assert word != nuli : "nie określono wstawianego słowa";
char c = word.charAt(index);
if (node == nuli) {
return insert(new Node(c), word. index);
}
if (c == node.getCharO) {
if (index + 1 < word.lengthO) {
node.setChild(insert(node.getChild(). word. index + 1));
} else {
node. setWord(word. toStri n g O ) ;
}
} else if (c < node.getCharO) {
node.setSmaller(insert(node.getSmallerO. word. index));
} else {
node.setLarger(insert(node.getLargerO, word, index)):
}
return node;
}
Metoda prefixSearch() wykonuje najpierw szukanie ogólne w celu znalezienia węzła re-
prezentującego ostatni znak prefiksu. Węzeł ten jest następnie przekazywany do metody
inOrderTraversal () wraz z listą przeznaczoną do zapamiętania słów wynikowych:
public void prefixSearch(CharSequence prefix. List results) {
assert prefix != nuli : "nie określono prefiksu";
assert prefix.length() > 0 : "prefiks nie może być pusty":
if (node == nul 1) {
return;
}
inOrderTraversal(node.getSmal1er(). results);
if (node.isEndOfWordO) {
results.add(node.getWord());
}
inOrderTraversal(node.getChi1d(). results):
inOrderTraversal(node.getLargerO. results);
}
408 Algorytmy. Od podstaw
Metoda patternMatchC) w pierwszym wariancie wywołuje prywatną metodę o tej samej na-
zwie, przekazując jako argumenty wywołania (kolejno) korzeń drzewa, dopasowywany wzo-
rzec, pozycję pierwszego znaku w tym wzorcu i oczywiście listę przeznaczoną do magazy-
nowania wyników pośrednich.
public void patternMatch(CharSequence pattern. List results) {
assert pattern !- nuli : "nie określono wzorca";
assert pattern.lengthO > 0 : "wzorzec nie może być pusty";
assert results != nuli : "nie określono listy wynikowej";
Po drugie, jeśli bieżący znak wzorca jest znakiem blankietowym (WILDCARD), trawersacja
dotyczy obydwu braci — znak blankietowy zastępuje bowiem dowolny znak.
if (node — nuli) {
return;
}
char c = pattern.charAt(index);
Przykład zastosowania
—rozwiązywanie krzyżówek
Uzbrojeni w przetestowaną implementację drzewa ternarnego możemy przystąpić do bu-
dowy przykładowej aplikacji ilustrującej jego praktyczne wykorzystanie. Będzie to prosta
aplikacja wspomagająca rozwiązywanie krzyżówek i ogólnie układanek, których istotą jest
dopasowywanie słów. Aplikacja wywoływana będzie z wiersza poleceń z dwoma parametrami
reprezentującymi (kolejno) plik zawierający poprawne słowa (po jednym w wierszu) i wzo-
rzec dopasowania, mogący oczywiście zawierać znaki blankietowe.
import java.io.BufferedReader:
import java.io.FileReader;
import java.io.I0Exception:
if (args.length < 2) {
System.out.println(
"Wywołanie: CrosswordHelper <1 i sta słów> <wzorzec> [powtórzenia]");
System.exit(-l);
}
int repetitions = 1;
if (args.length > 2) {
repetitions = Integer.parselnt(args[2]);
}
searchForPattern(loadWords(args[0]), args[l], repetitions);
}
private static void searchForPattern
(TernarySearchTree tree, String pattern. int repetitions) {
assert tree != nuli : "nie określono drzewa";
try {
String word;
J a k to działa?
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.1 i sts.Li nkedLi st;
i mport com.wrox.a1gori thms.1 i sts.Li st;
if (args.length < 2) {
System.out.println(
"Wywołanie: CrosswordHelper <1 i sta słów> <wzorzec> [powtórzenia]");
System.exit(-l):
}
searchForPattern(loadWords(args[0]). args[l], repetitions);
}
}
Metoda l o a d W o r d s O otrzymuje jako parametr nazwę pliku zawierającego poprawne słowa
— po jednym w wierszu. Następnie tworzy instancję drzewa ternarnego, otwiera wspo-
mniany plik i, odczytując z niego kolejne słowa, dodaje je do tegoż drzewa. Po wyczerpaniu
zawartości pliku następuje jego zamknięcie, a wypełnione drzewo ternarne zwracane jest
jako wynik:
private static TernarySearchTree loadWords(String fileName) throws IOException {
TernarySearchTree tree = new TernarySearchTree():
try {
String word;
Przy rozwiązywaniu kolejnej krzyżówki ten prosty program może okazać się nieoceniony...
Podsumowanie
W niniejszym rozdziale przedstawiliśmy następujące fakty dotyczące drzew ternarnych:
• są one wyjątkowo dobrze przystosowane do przechowywania łańcuchów znaków,
• oprócz regularnego wyszukiwania słów można za ich pomocą wyszukiwać słowa
o zadanym początku (prefiksie),
• mogą być używane do dopasowywania wzorców, na przykład przy rozwiązywaniu
krzyżówek,
• ich węzły zawierają trzy łączniki — do lewego i prawego brata oraz do poddrzewa
kontynuacyjnego,
• w przeciwieństwie do drzew binarnych, przechowujących w swych węzłach
kompletne wartości, przechowują w nich tylko pierwsze litery łańcuchów,
• podobnie jak drzewa binarne mogą stawać się drzewami niewy ważonymi,
• są generalnie bardziej efektywne od binarnych drzew wyszukiwawczych, jeśli
efektywność tę mierzy się liczbą niezbędnych porównań pojedynczych znaków.
Ćwiczenie
1. Napisz metodę searchO w wersji iteracyjnej.
15
B-drzewa
Wszystkie omawiane dotychczas struktury danych — od list (rozdział 3.) po tablice haszo-
wane (rozdział 11.) — miały tę cechę wspólną, że przechowywane były w całości w pa-
mięci operacyjnej, co oczywiście miało decydujący wpływ na postać przetwarzających je
algorytmów. Większość rzeczywistych baz danych jest jednak zbyt duża na to, by prze-
chowywać je całkowicie w pamięci operacyjnej, są więc przechowywane w pamięciach
dyskowych. Jak wówczas efektywnie zorganizować wyszukiwanie żądanego rekordu wśród
kilku miliardów rekordów? W niniejszym rozdziale poznamy struktury danych, które to
umożliwiają. W szczególności odpowiemy na następujące pytania:
Wczytywanie i zapisywanie takiej dużej struktury wymaga czasu: nie zapominajmy, że pa-
mięci dyskowe bywają tysiące, a nawet miliony razy wolniejsze od pamięci operacyjnych.
Nawet gdyby udało się wczytać całe drzewo w pojedynczym strumieniu, przy zapewnieniu
414 Algorytmy. Od podstaw
transferu rzędu 10 MB/s, to i tak wymagałoby to 2,6 sekundy oczekiwania. Dla większości
współczesnych aplikacji, obsługiwanych wielodostępnie przez setki użytkowników jedno-
cześnie, jest to sytuacja zdecydowanie nie do przyjęcia.
Skoro tak, to dlaczego nie wczytać od razu wszystkich tych 20 węzłów, które okazują się
w danej sytuacji potrzebne? Otóż nie da się tego zrobić z tej prostej przyczyny, że powią-
zane ze sobą węzły niekoniecznie muszą znajdować się w tym samym bloku — znacznie
bardziej prawdopodobne jest to, że będą one rozproszone po wielu blokach. Nie zapomi-
najmy, że dostęp do poszczególnych bloków wiąże się nieraz ze sporymi opóźnieniami,
wynikającymi ze skończonej prędkości obrotowej dysku i repozycjonowania głowic. Nawet
przy zastosowaniu wymyślnych mechanizmów cache'ujących, zmniejszających liczbę fi-
zycznych operacji wejścia-wyjścia, jest to nie do zaakceptowania ze względu na wymogi
efektywności.
Podobnie jak drzewa binarne, także B-drzewa składają się z węzłów. W przeciwieństwie
jednak do drzewa binarnego węzeł B-drzewa przechowuje nie jeden, lecz wiele kluczy —
aż do pewnego ustalonego maksimum, wynikającego zwykle z wielkości bloku dyskowego.
Poszczególne klucze w węźle uporządkowane są rosnąco, a niejako „między" kluczami
znajdują się łączniki do węzłów potomnych — każdy węzeł zawierający k kluczy zawierać
musi k+\ takich łączników; wyjątkiem od tej zasady są rzecz jasna liście.
Na rysunku 15.1 pokazane jest B-drzewo przechowujące klucze o A do K; każdy jego węzeł
zawiera maksymalnie trzy klucze. W korzeniu znajdują się tylko dwa klucze — D i H —
a trzy łączniki prowadzą do węzłów potomnych, zawierających klucze (odpowiednio) mniej-
sze od D, pośrednie między D i H oraz większe niż H.
1
Litera „B" pochodzi od nazwiska pomysłodawcy, R.Bayera, który opisał ideę B-drzew w pracy
[Bayer, 1972] — p r z y p . tłum.
Rozdział 15. • B - d r z e w a 415
Rysunek 15.1. D H
B-drzewo
o maksymalnie trzech
kluczach w węźle,
przechowujące A B C E F G I J K
klucze od A do K
Rysunek 15.2. D H
Rozpoczynamy
poszukiwanie
od pierwszego
klucza w korzeniu A B C E F G J K
Ponieważ szukany klucz (G) jest mniejszy od klucza bieżącego (D), przechodzimy do na-
stępnego klucza w korzeniu — H (rysunek 15.3).
Rysunek 15.3. D H
Kontynuujemy
poszukiwanie,
przechodząc
do następnego A B C E F G I J K
klucza w korzeniu
Drugi klucz (H) jest większy od szukanego, musimy zatem zejść w głąb drzewa, do węzła
potomnego wskazywanego przez łącznik znajdujący się między kluczami D i H, trafiając do
węzła zawierającego klucze E, F i G (rysunek 15.4).
Rysunek 15.4. D H
Szukany klucz ma wartość
pośrednią między dwoma
sąsiednimi kluczami,
schodzimy więc A B C E F G I J K
w głąb drzewa zgodnie
z łącznikiem znajdującym
się między tymi węzłami
Rysunek 15.5. D H
Pomyślne zakończenie
poszukiwań
A B C E F G I J K
416 Algorytmy. Od podstaw
Zwróćmy uwagę, że mimo iż dla znalezienia węzła G musieliśmy wykonać pięć porównań,
to odwiedziliśmy jedynie dwa węzły. Podobnie jak w przypadku binarnego drzewa wyszu-
kiwawczego średnia liczba węzłów odwiedzonych w procesie poszukiwania równa jest wy-
sokości drzewa. Ponieważ jednak w węźle B-drzewa znajduje się wiele kluczy, wysokość
B-drzewa jest znacznie mniejsza od wysokości binarnego drzewa wyszukiwawczego prze-
chowującego tę samą liczbę kluczy; w efekcie wyszukiwanie konkretnego klucza wymaga
odwiedzenia mniejszej liczby węzłów i w efekcie mniejszej liczby operacji odczytu-zapisu
dyskowego.
WxK + (W + \)xL<B
Przyjmując B = 8 000, K= 10, L = 4, dostajemy W < 571. Dla miliona kluczy daje to ogó-
łem N = 1 000 000/571 a 1752 węzły w drzewie. Średnia liczba węzłów, które odwiedzić
musimy w procesie wyszukiwania, wynosi więc logty./V = log57i 1752 « 2. To wartość o rząd
wielkości mniejsza od log 2 l 000 000 » 20 węzłów, jakie musielibyśmy odwiedzić w drze-
wie binarnym złożonym z miliona węzłów.
Rysunek 15.6.
Nowe klucze
zawsze dodawane
są do liści
A B C E F G 1 J K L
Rysunek 15.7. D H
Przepełniony
węzeł dzielony
jest na dwa węzły
A B C E F G 1 J K L
Rozdział 15. • B - d r z e w a 417
Następnie „środkowy" klucz dzielonego węzła (środkowy przed dodaniem nowego klucza)
zostaje wywindowany do węzła macierzystego dla nowo powstałych węzłów. Na rysunku
15.8 windowany jest klucz J i tworzony jest nowy łącznik potomny do węzła zawierającego
klucze K i L.
Rysunek 15.8. D H J
Środkowy
(oryginalnie) klucz \
węzła windowany
jest na wyższy A B C E F G K L
poziom
Jak widać, dodawanie nowego klucza do B-drzewa, choć powoduje jego rozrost, nieko-
niecznie musi zwiększać jego wysokość. W istocie, B-drzewa okazują się szersze i „płytsze"
od większości innych struktur drzewiastych, dzięki czemu wykonywane na nich operacje
wymagają odwiedzania średnio mniejszej liczby węzłów.
Jedyną sytuacją powodującą zwiększenie wysokości B-drzewa jest podział jego korzenia
wynikający z przekroczenia maksymalnej liczby kluczy. Na rysunku 15.9 przedstawiono
efekt dodania kluczy M i N do B-drzewa z rysunku 15.8. Przepełniony liść zawierający klu-
cze K, L, M i N zostaje podzielony, w związku z czym środkowy klucz L windowany jest do
korzenia.
Rysunek 15.9. D H J
\
Przepełniony liść
wymagający
podziału
A B C E F G K L M N
Windowanie to sprawia jednak, że tym razem przepełniony staje się korzeń drzewa (rysu-
nek 15.10).
Rysunek 15.10. D H J L
Sytuacja, w której
sam korzeń drzewa
wymaga podziału
A B C E F G 1 K M N
Tym razem nie istnieje „wyższy" poziom, na który wywindować by można środkowy klucz H.
Konieczne jest więc utworzenie nowego węzła, który stanie się nowym korzeniem drzewa,
i wywindowanie do tegoż węzła klucza H (rysunek 15.11). Obydwa łączniki otaczające węzeł H
prowadzą do węzłów powstałych w wyniku podziału poprzedniego korzenia.
Rysunek 15.11.
Podział korzenia
powodujący
zwiększenie
wysokości B-drzewa J L
/
K
418 A l g o r y t m y . Od podstaw
Podobnie jak dodawanie nowych kluczy powodować może konieczność dzielenia przepeł-
nionych węzłów, tak usuwanie kluczy może powodować konieczność łączenia węzłów. Na
rysunku 15.12 przedstawiono efekt usunięcia klucza K z drzewa widocznego na rysunku
15.11; widoczna na rysunku struktura nie jest już B-drzewem, nie istnieje bowiem łącznik
potomny między kluczami J i L, a przecież węzeł o dwóch kluczach musi zawierać trzy
łączniki potomne.
Rysunek 15.12.
Usunięcie klucza K
doprowadziło
do naruszenia
struktury B-drzewa
A B C E F G 1 M N
Aby przywrócić strukturze z rysunku 15.12 postać poprawnego B-drzewa, konieczna jest
redystrybucja kluczy „niepoprawnego" węzła wśród jego węzłów potomnych. W związku z tym
klucz J przeniesiony zostaje do węzła zawierającego dotychczas tylko klucz I (rysunek 15.13).
Rysunek 15.13.
Redystrybucja
f\
kluczy wśród
węzłów potomnych
przywraca poprawną D L
strukturę B-drzewa
A B
/ \
C E F G 1 J M N
Rysunek 15.14.
Ponownie konieczna
jest redystrybucja
kluczy, by przywrócić
poprawną strukturę
B-drzewa
A B C E F G M N
Ponownie konieczna jest redystrybucja kluczy; można j ą wykonać na wiele sposobów, zawsze
jednak będzie to łączenie kluczy z węzłów macierzystych z kluczami węzłów potomnych.
Jeżeli redystrybucja ta będzie się wiązać z łączeniem korzenia z którymś z jego węzłów
potomnych, nastąpi zmniejszenie wysokości B-drzewa.
Aby przywrócić poprawną strukturę B-drzewa w sytuacji z rysunku 15.14, należy połączyć
klucze D i H oraz przenieść klucz L do węzła zawierającego klucze M i N.
Jak zatem widać, usuwanie klucza z B-drzewa jest procesem trudniejszym niż dodawanie
klucza i dopuszczającym wiele różnych scenariuszy. Czytelników zainteresowanych szcze-
gółami tego zagadnienia odsyłamy do książki [Cormen, 2001].
Rozdział 15. • B-drzewa 419
Rysunek 15.15. D H
Łączenie korzenia
z węzłem potomnym
powoduje
zmniejszenie A B C E F G| L M N
wysokości B-drzewa
Implementacja metod interfejsu Map opierać się będzie na B-drzewie jako strukturze maga-
zynującej dane. Bazując na omawianych wcześniej algorytmach wyszukiwania i wstawia-
nia kluczy, zaimplementujemy metody get O, containsO i setO. Jeśli chodzi natomiast
o usuwanie kluczy — czyli metodę deleteO — pozwoliliśmy sobie na pewne uproszczenie:
jako że usuwanie kluczy z B-drzewa jest procesem bardzo skomplikowanym, obejmującym
co najmniej trzy różne scenariusze redystrybucji węzłów, zamiast fizycznego usuwania
kluczy będziemy jedynie oznaczać te klucze jako usunięte. Choć ma to tę oczywistą wadę,
że nieistniejące w rzeczywistości klucze w dalszym ciągu zajmują pamięć, to jednak na po-
trzeby niniejszego przykładu okazuje się całkowicie wystarczające. Wyczerpujący opis fi-
zycznego usuwania kluczy z B-drzewa znajduje się w książce [Cormen, 2001],
import com.wrox.algorithms.maps.AbstractMapTestCase;
import com.wrox.algori thms.maps.Map;
import com.wrox.algorithms.sorting.NaturalComparator;
J a k to działa?
Po stworzeniu klasy testowej dla B-drzewa implementującego mapę pora na szczegóły sa-
mej implementacji.
/* korzeń B-drzewa */
private Node _root;
_comparator = comparator;
Rozdział 15. • B-drzewa 421
_maxKeysPerNode = maxKeysPerNode;
clear();
}
public Object get(Object key) {
Entry entry - _root.search(key);
return entry != nuli ? entry,getValue() : nuli;
}
public Object setCObject key, Object value) {
Object oldValue = _root.set(key, value);
if (_root.isFull()) {
Node newRoot = new NodeCfalse);
_root.split(newRoot, 0);
_root = newRoot;
}
return oldValue;
}
public Object delete(Object key) {
Entry entry = _root.search(key);
if (entry == nuli) {
return nul 1;
}
entry.setOeleted(true):
--_size:
return entry.setValue(null);
}
public boolean contains(Object key) {
return _root.sea rch (key) !=null;
}
public void clearO {
_root = new Mode(true):
_size = 0;
}
public int sizeO {
return _size:
}
public boolean isEmptyO {
return sizeO == 0;
}
public Iterator iteratorO {
_root.traverse(list);
return list.iteratorO;
}
private finał class Node {
422 Algorytmy. Od podstaw
i f (child.isFullO) {
child.splittthis. index):
}
return oldValue:
}
private int index0f(0bject key) {
int lowerIndex = 0:
int upperlndex = _entries.sizeO - 1:
return index;
} else if (cmp < 0) {
upperlndex - index - 1;
} else {
lower!ndex = index + 1;
parent._entries.insert(insertionPoint, _entries.delete(middle));
if (parent._children.isEmptyO) {
parent,_chi1 dren.i nsert(i nserti onPoi nt. this):
}
parent._children.insert(insertionPoint + 1, sibling):
children.firstO:
entries.fi r s t O ;
target.add(source.delete(from));
}
}
private boolean isLeafO {
return _children — EmptyList.INSTANCE:
}
}
private static finał class Entry extends DefaultEntry {
private boolean _deleted:
J a k to działa?
/* korzeń B-drzewa */
private Node _root;
_comparator - comparator;
_maxKeysPerNode = maxKeysPerNode;
clearO;
}
}
Wewnątrz klasy BTreeMap definiowane są ponadto dwie prywatne klasy pomocnicze — Entry
i Node — reprezentujące (odpowiednio) pozycję mapy (Map. Entry) i węzeł B-drzewa.
Pozycja implementowana przez klasę Entry stanowi rozszerzenie domyślnej pozycji De-
faultEntry (patrz rozdział 13.) o boolowskązmienną deleted informującąo tym, czy wę-
zeł jest logicznie usunięty z drzewa (true) czy też jest w tym drzewie logicznie obecny
(false). Manipulowanie wartością tej zmiennej oznacza usuwanie pozycji z drzewa i ich
przywracanie.
private static finał class Entry extends DefaultEntry {
private boolean _deleted;
Ponieważ znakomita większość funkcjonalności B-drzewa ukrywa się w klasie Node repre-
zentującej jego węzły, omówimy szczegółowo tę klasę w pierwszej kolejności, przed omó-
wieniem „głównej" klasy BTreeMap.
Konstruktor węzła (jako instancji klasy Node) posiada jeden parametr boolowski (leaf) in-
formujący o tym, czy tworzony węzeł ma być liściem (true) czy też węzłem pośrednim
(false). Rozróżnienie to wynika z faktu, że węzeł pośredni, w przeciwieństwie do liścia,
posiada także pewną liczbę łączników do węzłów potomnych i do przechowywania tych
łączników należy przydzielić odpowiednią tablicę. Rozróżnienie typu istniejącego węzła — liść
albo węzeł pośredni — j e s t możliwe za pomocą metody i sLeaf () zwracającej wartość true
426 Algorytmy. Od podstaw
dla liścia. Stan przepełnienia węzła — przekroczenie maksymalnej liczby pozycji — można
wykryć za pomocą metody i sFull () zwracającej dla przepełnionego węzła wartość true.
private finał class Node {
private finał List _entries = new ArrayList(_maxKeysPerNode + 1):
private finał List _children;
Ponieważ w węźle B-drzewa może znajdować się wiele pozycji, potrzebujemy metody do
znajdowania konkretnego klucza w konkretnym węźle. Metoda indexOf() dokonuje w tym
celu binarnego wyszukiwania klucza w posortowanej liście pozycji, zwracając bądź to nu-
mer klucza (0, 1, 2, ...) w przypadku jego znalezienia, bądź wartość ujemną informującą
(zgodnie z konwencją opisaną w rozdziale 9.) o miejscu, na które powinna zostać wstawio-
na pozycja zawierająca nowy klucz. Zwróćmy uwagę, że porównywaniu podlegają nie
kompletne pozycje, lecz same klucze wyłuskiwane z tych pozycji.
private int index0f(0bject key) {
int lowerIndex = 0:
int upperlndex = _entries.sizeO - 1;
if (cmp == 0) {
return index:
} else if (cmp < 0) {
upperlndex = index - 1;
} else {
lowerIndex = index + 1;
zycja oznakowana jest jako usunięta). Jeżeli jednak klucz nie zostanie znaleziony w pierw-
szym podejściu, wyszukiwanie przenoszone jest rekurencyjnie do odpowiedniego węzła
potomnego:
public Entry search(Object key) {
int index = indexOf(key);
if (index >= 0) {
Entry entry = (Entry) _entries,get(index);
return lentry.isDeletedO ? entry : nuli:
}
return lisLeafO ? ((Node) _children.get(-(index + l))).search(key) : nuli:
}
Ponieważ wstawienie pozycji do węzła może spowodować jego przepełnienie i wiązać się
z koniecznością jego podziału, konieczne jest opracowanie metody realizującej taki podział.
if (parent._children.isEmptyO) {
pa rent._chi1 dren.i nsert(i nserti onPoi nt, this):
}
parent. children.insert(insertionPoint + 1, sibling):
}
private void move(List source. int from. List target) {
assert source != nuli : "nie określono argumentu źródłowego";
assert target != nuli : "nie określono argumentu docelowego":
Dysponując już metodą wykonującą podział węzła, możemy w zasadzie przystąpić do do-
dawania nowych pozycji do B-drzewa. Jak jednak pamiętamy, w każdej mapie konieczne
jest zachowanie unikalności kluczy, a zatem próba dodania pozycji zawierającej istniejący
klucz powinna być zrealizowana nie jako dodanie tej pozycji de facto, lecz jako aktualiza-
cja wartości w pozycji istniejącej, identyfikowanej wspomnianym kluczem.
Ów drugi wariant rozpoczyna pracę od sprawdzenia, czy bieżący węzeł nie jest liściem —
jeśli jest, oznacza to, że specyfikowanego klucza faktycznie nie ma w drzewie i pozycja
zawierająca specyfikowaną parę „klucz-wartość" istotnie powinna zostać wstawiona, a licznik
pozycji zwiększony o jeden. Jeśli jednak bieżący węzeł jest węzłem pośrednim, odnajdy-
wany jest ten z jego węzłów potomnych, który może zawierać specyfikowany klucz — to,
czy rzeczywiście go zawiera, rozstrzygane jest przez pierwszy wariant metody. Jeśli wstawie-
nie pozycji do węzła doprowadzi do jego przepełnienia, węzeł należy poddać podziałowi:
public Object set(Object key. Object value) {
int index = indexOf(key);
if (index >= 0) {
return ((Entry) _entries.get(index)),setValue(value);
}
return set(key. value, -(index + 1)):
}
private Object set(Object key, Object value. int index) {
if (isLeafO) {
_entries.insert(index, new Entry(key. value));
++_size;
return nul 1;
}
Node child = ((Node) _children.get(index));
Object oldValue = child.set(key, value):
if (child.isFullO) {
child.split(this, index);
}
return oldValue:
}
Metoda traverse() dokonuje iterowania po zapamiętanych w drzewie pozycjach mapy: dodaje
ona do listy wynikowej wszystkie (nieusunięte) pozycje z bieżącego węzła, po czym wy-
wołuje samą siebie dla każdego z węzłów potomnych. Jest to więc w istocie przejście przez
B-drzewo metodą pre-order (implementację przejścia metodą in-order pozostawiamy do
wykonania jako ćwiczenie).
Rozdział 15. • B-drzewa 429
children.firstO;
entries.firstO;
if (lentries.isDoneO) {
Entry entry = (Entry) entries.currentO;
if (lentry.isDeletedO) {
list.add(entry):
entries.next();
Metoda get() udostępnia wartość identyfikowaną przez wskazany klucz. Pozycja zawiera-
jąca ten klucz poszukiwana jest w drzewie, począwszy od jego korzenia, za pomocą metody
searchO; gdy zostanie znaleziona, zwracana jest zawarta w niej wartość, w przeciwnym
razie zwracana jest wartość pusta.
public Object get(Object key) {
Entry entry = _root.search(key):
return entry != nuli ? entry.getValue() : nuli;
}
W podobny sposób korzysta z metody searchO metoda contains() informującą czy wska-
zany klucz jest obecny w drzewie:
public boolean contains(Object key) {
return _root.search(key) != nuli:
}
Metoda s e t ( ) dodaje do drzewa pozycję zawierającą specyfikowaną parę „klucz-wartość"
albo uaktualnia wartość w już istniejącej pozycji o specyfikowanym kluczu. Jej wywołanie
delegowane jest najpierw do metody s e t O korzenia drzewa, po powrocie z której sprawdza
się, czy korzeń nie stał się przypadkiem węzłem przepełnionym — j e ś l i tak, następuje jego
podział związany z utworzeniem nowego korzenia. Zgodnie z wymogami interfejsu Map
poprzednia wartość identyfikowana wskazanym kluczem (jeśli takowa w ogóle w drzewie
istniała) zwracana jest jako wynik metody.
public Object set(Object key. Object value) {
Object oldValue = _root.set(key. value);
430 Algorytmy. Od podstaw
if (_root.isFullO) {
Node newRoot = new Node(false);
_root.split(newRoot. 0);
_root - newRoot;
}
return oldValue:
}
Metoda deleteO dokonuje usunięcia z mapy pozycji identyfikowanej wskazanym klu-
czem. Usunięcie to ma charakter logiczny — pozycja zostaje jedynie oznaczona jako usu-
nięta. Poszukiwanie wspomnianej pozycji odbywa się za pomocą metody searchO korzenia
drzewa i, jeżeli pozycja ta zostanie znaleziona, zostaje wywołana jej metoda setDeletedO.
Licznik pozycji w mapie zmniejszany jest o 1, a poprzednia wartość pozycji (lub wartość
pusta, gdy pozycji nie ma w drzewie) zwracana jest jako wynik.
public Object delete(Object key) {
Entry entry = _root.search(key):
if (entry — nuli) {
return nuli;
}
entry.setDeleted(true):
--_size:
return entry.setValue(null);
}
Ponieważ mapa jest strukturą iterowalną klasa BTreeMap musi więc implementować metodę
i t e r a t o r O . Metoda ta zwraca iterator umożliwiający przejście przez wszystkie pozycje
mapy, przy czym nie określa się jakiejś szczególnej kolejności odwiedzania poszczegól-
nych pozycji. Zwróćmy przy tym uwagę, że B-drzewo iteratora jako takiego nie definiuje;
zamiast tego tworzona jest ad hoc lista zapełniana następnie pozycjami z drzewa przez
metodę traverse(), po czym iterator tejże listy zwracany jest jako wynik;
public Iterator iteratorO {
_root.traverse(list):
return list.iteratorO;
}
Metoda c l e a r O , usuwająca wszystkie pozycje z mapy, zaimplementowana jest cokolwiek
ciekawie — nowym korzeniem drzewa staje się mianowicie nowo tworzony liść, a rozmiar
mapy resetowany jest do zera:
public void clearO {
_root = new Node(true);
_size - 0:
}
Implementację interfejsu Map wieńczą metody sizeO i isEmptyO:
public int sizeO {
return _size;
}
Rozdział 15. • B-drzewa 431
Podsumowanie
W zakończonym właśnie rozdziale poznaliśmy następujące fakty dotyczące B-drzew:
• B-drzewa idealnie nadają się do wyszukiwania informacji magazynowanej
w pamięciach zewnętrznych — dyskach twardych, dyskach kompaktowych itp.,
• B-drzewa rozrastają się w górę począwszy od poziomu liści — nowy klucz
dodawany jest zawsze do liścia,
• w każdym węźle — być może z wyjątkiem korzenia — liczba kluczy nie jest nigdy
mniejsza od połowy wartości maksymalnej, być może po zaokrągleniu w dół,
• węzeł, w którym liczba kluczy przekracza wartość maksymalną zostaje podzielony
na dwa węzły, przy czym środkowy klucz windowany jest do węzła macierzystego,
• wysokość B-drzewa zwiększa się jedynie wówczas, gdy podziałowi ulega jego
korzeń,
• B-drzewo zawsze pozostaje drzewem wyważonym, gwarantując logarytmiczny
czas wyszukiwania 0(log TV), gdzie N oznacza liczbę kluczy.
Ćwiczenie
1. Napisz metodę traverse() w wersji zwracającej pozycje w kolejności rosnących kluczy.
432 Algorytmy. Od podstaw
16
Wyszukiwanie tekstu
Z problemem wyszukiwania określonego tekstu wewnątrz innego spotykamy się bardzo
często — przy przeszukiwaniu zawartości plików, znajdowaniu stron WWW przez wy-
szukiwarkę czy nawet dopasowywaniu fragmentów kodu DNA. Każdy niemal edytor tekstu
i generalnie edytory większości narzędzi programistycznych posiadają w repertuarze swych
opcji opcję Znajdź, Find, Szukaj lub równoważną umożliwiającą znajdowanie w edytowa-
nym tekście określonych fraz, być może spełniających pewne dodatkowe kryteria.
_text - text;
_pattern = pattern;
_index = index;
}
public CharSequence getPatternO {
return _pattern;
}
public CharSequence getText() {
return _text;
}
public int getlndex() {
return _index;
}
j
Rozdział 16. • Wyszukiwanie tekstu 435
J a k to działa?
Interfejs StringSearcher zawiera tylko jedną metodę — searchO. Jest ona wywoływana
z dwoma argumentami: tekstem, w którym prowadzone jest poszukiwanie, oraz pozycją, od
której się ono rozpoczyna. Poszukiwany wzorzec nie jest parametrem wywołania, zakłada
się bowiem, że jest on przechowywany przez instancję klasy implementującej interfejs.
Wynik zwracany przez metodę searchO jest instancją klasy StringMatch zawierającej
kompletną informację na temat przeprowadzonego wyszukiwania: poszukiwany wzorzec,
tekst, w którym prowadzone było poszukiwanie, i pozycję (0, 1, 2, ...) pierwszego wystą-
pienia wzorca w przeszukiwanym tekście. Jeśli jednak wzorzec nie występuje w przeszu-
kiwanym tekście, metoda sea rch () zwraca wartość pustą (nuli).
import junit.framework.TestCase;
436 Algorytmy. Od podstaw
r
Pierwszy z przypadków testowych jest banalnie prosty, polega bowiem na testowaniu wy-
szukiwania prowadzonego w łańcuchu pustym — pusty łańcuch stanowi jeden z „przypadków
granicznych" 1 , prawidłowo zaimplementowana metoda searchO powinna zawsze zwracać
wartość pustą.
public void testNotFoundInAnEmptyText() {
StringSearcher searcher = createSearcher("I TAK MNIE TAM NIE MA");
assertNul1(searcher.searcht"", 0));
}
1
O znaczeniu różnego rodzaju „przypadków granicznych" w testowaniu programów m o g ą
Czytelnicy przeczytać w książce Sztuka testowania oprogramowania, Helion 2005
(http://helion.pl/ksiazki/artteo.htm) —przyp. tłum.
Rozdział 16. • Wyszukiwanie tekstu 437
. . . i gdzieś pośrodku:
public void testFindInTheMiddle() {
String text = "Jestem gdzieś pośrodku tego łańcucha":
String pattern = "pośrodku":
J a k to działa?
Możliwe jest oczywiście skonstruowanie innych jeszcze testów, jednak przedstawione tutaj
dają dość dobre „pokrycie" przypadków wyszukiwania występujących w praktyce. Mając
gotowe narzędzie do weryfikacji poprawności wyszukiwania łańcuchów, zajmijmy się teraz
detalami samego wyszukiwania.
Dalsze przesuwanie wzorca w prawo jest bezcelowe, bowiem nie byłoby już z czym po-
równywać jego końcowych znaków. Innymi słowy, dwa łańcuchy o różnej długości nie
mogą być równe, więc porównywanie wzorca ri ng z łańcuchami rch, ch i h byłoby tylko
stratą czasu. Jeżeli zatem poszukiwany wzorzec ma długość M, a przeszukiwany tekst —
długość N, to ostatnim porównaniem wzorca będzie to, gdy jego pierwszy znak pokrywa
się ze znakiem na pozycji N-M (przy założeniu, że pozycje liczymy od zera). W naszym
przykładzie JV=13, M= 4,a zatem przy ostatnim porównaniu pierwszy znak wzorca ring po-
krywa się ze znakiem na pozycji 13-4= 9 w przeszukiwanym tekście. I tak właśnie należy
rozumieć sformułowanie „osiągnięto koniec przeszukiwanego tekstu" w punkcie 4. przed-
stawionego scenariusza.
Skoro znamy już zasady „siłowego" wyszukiwania wzorca tekście, przejdźmy do jego
praktycznej realizacji.
J a k to działa?
K l a s a BruteForceStringSearcherTest s t a n o w i r o z s z e r z e n i e k l a s y a b s t r a k c y j n e j Abstract-
StringSearcherTest p o p r z e z k o n k r e t y z a c j ę m e t o d y c r e a t e S e a r c h c e r O t a k , b y t a z w r a c a ł a
i n s t a n c j ę k l a s y BruteForceStri ngSearcher — z o d p o w i e d n i m w z o r c e m w y s z u k i w a n i a .
_pattern = pattern;
}
public StringMatch search(CharSequence text. int from) {
assert text != nuli : "nie określono przeszukiwanego łańcucha":
assert from >= 0 : "pozycja startowa nie może być ujemna";
int s = from:
J a k to działa?
K l a s a BruteForceStringSearcher i m p l e m e n t u j e i n t e r f e j s StringSearcher. K o n s t r u k t o r , p o
sprawdzeniu, czy p o d a n o niepusty wzorzec, zapamiętuje ten wzorzec do późniejszego wy-
korzystania.
Rozdział 16. • Wyszukiwanie tekstu 441
Metoda searchO ma postać dwóch zagnieżdżonych pętli. Zewnętrzna pętla while odpo-
wiedzialna jest za przesuwanie wzorca względem przeszukiwanego tekstu, zaś pętla we-
wnętrzna dokonuje porównywania kolejnych znaków wzorca z odpowiadającymi im zna-
kami tekstu.
Gdy zakończy się pętla wewnętrzna i wszystkie jej porównania dały wynik pomyślny,
oznacza to znalezienie dopasowania (wystąpienia) wzorca — zwracana jest instancja klasy
StringMatch zawierająca informację o znalezionym wystąpieniu i obydwie pętle się kończą.
Jeśli natomiast pętla wewnętrzna skończy się na skutek nierówności porównywanych zna-
ków, pozycja startowa dla porównywania wzorca jest inkrementowana i pętla zewnętrzna
jest kontynuowana. Zakończenie pętli zewnętrznej z powodu wyczerpania tekstu wejściowego
powoduje zwrócenie wartości pustej (nuli) oznaczającej brak dopasowania.
Algorytm Boyera-Moore'a
Mimo iż algorytm „siłowy" spisuje się całkiem nieźle w wielu sytuacjach, to jednak jest on
niewątpliwie daleki od optymalności. Nawet w przypadku typowego tekstu i typowego
wzorca wykonuje on wiele dopasowań jedynie częściowych, a niektóre rozpoczynane se-
kwencje porównywania nie mają w ogóle uzasadnienia. Sytuację tę można jednak znacznie
ulepszyć, wprowadzając kilka prostych usprawnień.
Powróćmy do problemu poszukiwania wzorca ring w łańcuchu String Sea rch i zobaczmy,
jak spostrzeżenie to można wykorzystać w praktyce, redukując liczbę porównań z 10 do 4.
S t r i n g S e a r c h
1 r i n g
3 r i n g
4 r i n g
Cały sekret algorytmu tkwi w znajomości dystansu, o jaki należy przesunąć wzorzec w związku
z kolejnym porównaniem. W przeciwieństwie do algorytmu „siłowego" porównywanie
rozpoczyna się od ostatniego znaku wzorca, a nie od pierwszego, choć na początku pierwszy
442 Algorytmy. Od podstaw
Ponieważ ostatni punkt scenariusza może nie być do końca jasny, wyjaśnimy go na prostym
przykładzie. Wyobraźmy sobie mianowicie poszukiwanie wzorca over w łańcuchu everyth1ng.
e v e r y t h i n g
o v e r
Rozpoczynamy porównywanie od końca wzorca i trafiamy na parę różnych znaków „e" (w tek-
ście) i „o" (we wzorcu). Zgodnie z punktem 1. heurystyki powinniśmy przesunąć wzorzec
o dwie pozycje w lewo, by jego litera „e" pokryła się z literą „e" w tekście:
e v e r y t h i n g
o v e r
Podobnie jak w przypadku algorytmu „siłowego", sporządzimy teraz zestaw testowy dla
klasy implementującej algorytm Boyera-Moore'a i zajmiemy się implementacją tej klasy.
J a k to działa?
wzorzec zawiera dwukrotne wystąpienie każdego znaku („a" i „b"), co stwarza okazję do
wykrycia ewentualnego błędu obliczenia ostatniej pozycji znaku we wzorcu — błędu obja-
wiającego się zbyt dużym lub zbyt małym dystansem przesunięcia.
_pattern = pattern;
JastOccurrence = computeLastOccurrence(pattern);
}
)
J a k to działa?
Początek definicji klasy wygląda podobnie jak w przypadku algorytmu „siłowego", z jedną
istotną różnicą: tablicą _lastOccurrence i tworzącą ją metodą computeLastOccurrenceO.
Jak pamiętamy, algorytm Boyera-Moore'a wymaga informacji na temat ostatniego wystą-
pienia we wzorcu każdego znaku, jaki może wystąpić w przeszukiwanym tekście. Oczywi-
ście najprostszą metodą uzyskiwania takiej informacji jest każdorazowe skanowanie wzorca,
znacznie efektywniejszym posunięciem będzie jednak przechowywanie jej w tablicy.
Skonstruowanie tablicy przeglądowej zawierającej wspomnianą informację jest
czynnością jednorazową, wykonywaną w czasie proporcjonalnym do sumy długości
wzorca i liczebności wykorzystywanego zestawu znaków (charset). Dla małych
zestawów znaków — jak 256-znakowy kod ASCII — nie stanowi to problemu,
jednak dla zestawów reprezentujących niektóre języki azjatyckie niezbędne mogą
być specjalne metody inicjowania tablicy — ich omówienie wykraczałoby jednak
poza zakres niniejszej książki.
Rozdział 16. • Wyszukiwanie tekstu 445
J a k to działa?
Przeglądając wzorzec od strony lewej do prawej, rejestrujemy w tablicy każdy napotkany w nim
znak. Po przetworzeniu całego wzorca pozycja o indeksie n zawierać będzie pozycję ostatniego
wystąpienia we wzorcu znaku o kodzie n lub wartość -1, gdy znak o kodzie n we wzorcu
nie występuje.
Rysunek 16.1.
Tablica ostatnich A B C D E
wystąpień dla
wzorca DECADE
i zestawu znaków 3 -1 2 4 5
(A,B,C,D,E)
Wyszukiwanie wzorca
Teoretycznie moglibyśmy konsekwentnie przesuwać wzorzec o jedną pozycję w prawo po
każdym porównaniu, jak w przypadku algorytmu „siłowego" — od czegóż jednak mamy
skonstruowaną dopiero co tablicę ostatnich wystąpień?
446 Algorytmy. Od podstaw
int s = from;
char c = 0;
while (i >= 0 && _pattern.charAt(i) == (c = text.charAt(s + i))) {
--i:
}
if O < 0) {
return new StringMatch(_pattern, text. s);
}
s += Math.max(i - _lastOccurrence[c], 1);
}
return nul 1;
}
J a k to działa?
Metoda searchO jest pod względem strukturalnym podobna do identycznie nazwanej me-
tody algorytmu „siłowego", z dwiema istotnymi różnicami:
• porównywanie wzorca z fragmentem tekstu odbywa się od strony prawej do lewej,
• wielkość przesunięcia wzorca ustalana jest na podstawie tablicy ostatnich wystąpień
i pewnych dodatkowych obliczeń.
a b c d
Pierwsze „niedopasowanie" występuje już na skrajnej prawej pozycji (we wzorcu jest to
pozycja znaku „d", czyli 3) — w tekście znakiem „niepasującym" jest „a". We wzorcu znak
„a" występuje (ostatnio) na pozycji 0. Odejmując te dwie wartości, otrzymamy wielkość
niezbędnego przesunięcia 3-0=3. Przesuwając wzorzec o trzy pozycje w prawo, dokonujemy
następnego porównania — wzorca abcd z fragmentem tekstu aedc:
Rozdział 16. • Wyszukiwanie tekstu 447
b d a a e d c c d a
a b c d
Ponownie niedopasowanie występuje na skrajnej pozycji. Kolidujący znak tekstu („c") wy-
stępuje we wzorcu ostatnio na pozycji 2, bieżącym znakiem wzorca jest jego znak na pozy-
cji 3 („d"). Przesuwamy wzorzec w prawo o jedną (3-2=1) pozycję.
b d a a e d c c d a
a b c d
Tym razem niedopasowanie występuje na pozycji pierwszej wzorca (znak „b"), kolidujący
znak tekstu („c") występuje we wzorcu ostatnio na pozycji 2. W wyniku analogicznego jak
poprzednio odejmowania (1-2=-1) otrzymujemy ujemną wartość zalecanego przesunięcia;
zgodnie z punktem 3. przedstawionego wcześniej scenariusza ignorujemy to zalecenie i prze-
suwamy wzorzec o jedną pozycję w prawo. W przełożeniu na kod źródłowy decyzja taka
wynika z zastosowania funkcji Math.max(... , 1) gwarantującej, że wzorzec będzie prze-
suwany zawsze w prawo, co najmniej o jedną pozycję.
b d a a e d c c d a
a b c d
import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.algori thms.i teration.IteratorOutOfBoundsExcepti on;
_searcher = searcher;
_text = text;
}
public void firstO {
_current = _searcher.search(_text. 0):
}
public void lastO {
throw new UnsupportedOperationException();
}
public boolean isDoneO {
return _current == nuli;
}
public void next() {
if (!isDoneO) {
_current = _searcher.search(_text. _current.getlndex() + 1);
}
}
public void previousO {
throw new UnsupportedOperationException();
}
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsException();
Rozdział 16. • Wyszukiwanie tekstu 449
}
return current:
J a k to działa?
Podczas gdy iterowanie w przód, czyli iterowanie po kolejnych wystąpieniach wzorca, po-
cząwszy od pierwszego, wydaje się być naturalne i niezbyt trudne do zrealizowania, to już
samo znalezienie ostatniego wystąpienia wzorca i iterowanie wstecz jest od takiej oczywi-
stości dość dalekie, przynajmniej w kontekście metod wyszukiwania opisywanych w ni-
niejszym rozdziale. Ograniczyliśmy więc funkcjonalność naszego iteratora wyłącznie do
iterowania w przód — wywołanie metody l a s t O lub previous() powoduje wystąpienie
wyjątku UnsupportedOperati onExcepti on.
Pomiar efektywności
Najbardziej oczywistą metodą pomiaru efektywności algorytmu jest niewątpliwie czas wy-
konywania realizującego go programu. Metoda ta jest jednak tyleż prosta, co mało wiary-
godna: na czas wykonywania programu składają się różne czynniki, trudne do przewidzenia
a priori i często niemające związku z samym algorytmem: wymiana stron pamięci wirtual-
nej między dyskiem a pamięcią fizyczną przełączanie zadań, zdarzenia sieciowe i innego
rodzaju interakcje z systemem operacyjnym. Dla celów naszego pomiaru potrzebujemy
więc metody nieco bardziej precyzyjnej.
Po uważnej analizie implementacji obydwu algorytmów staje się oczywiste, że każde po-
równanie znaków poprzedzone jest ich pobraniem — odpowiednio z tekstu i wyszukiwa-
nego wzorca. Liczba tych pobrań ma więc bezpośredni związek z liczbą wykonywanych
porównań znakowych i jako taka może stanowić podstawę pomiaru efektywności każdej ze
wspomnianych implementacji.
J a k to działa?
Klasa Cali Counti ngChrSequence, poza tym iż sama implementuje interfejs CharSequence,
stanowi otoczkę dla instancji innej klasy będącej implementacją tego interfejsu. Wywołania
wszystkich metod interfejsu delegowane są do tej właśnie instancji bazowej, z jednym wy-
jątkiem — w metodzie charAtO przed delegowaniem inkrementowany jest licznik wywołań
tej metody. Wartość tego licznika dostępna jest za pośrednictwem metody getCall Count (),
dzięki czemu możliwe jest zliczanie porównań pojedynczych znaków.
import com.wrox.algorithms.iteration.Iterator;
import java.io.FilelnputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
452 Algorytmy. Od podstaw
_filename = filename:
_pattern = pattern;
}
public static void main(String[] args) throws IOException {
assert args != nuli : "nie określono argumentów wywołania";
searcher.run();
}
public void run() throws IOException {
FileChannel fc = new Filelnputstream(_filename).getChannel();
try {
ByteBuffer bbuf = fc.map(Fi 1eChannel.MapMode.READ_0NLY, 0. (int)
fc.sizeO);
CharBuffer file =
Cha rset. f orName (CHARSETJAME). newDecoder (). decode (bbuf);
int occurrence = 0:
J a k to działa?
W metodzie run() otwierany jest plik o nazwie określonej przez parametr wywołania kon-
struktora, po czym na bazie tego pliku tworzona jest (przy użyciu mapowania pamięciowego)
instancja klasy CharBuffer. Instancja ta przekazywana jest jako parametr metody searchO
wyszukiwarki dwukrotnie — dla obydwu omawianych implementacji wyszukiwarek.
Wykonywanie programu rozpoczyna się od metody mainO. Dokonuje ona weryfikacji po-
prawności parametrów — oczekiwane są dwa parametry określające (odpowiednio) nazwę
pliku i poszukiwany wzorzec — po czym zasadnicza logika aplikacji realizowana jest w ra-
mach metody run().
Tajemnicza (być może) stała 8859_1 jest nazwą zestawu znaków wymaganą przez klasę
CharBuffers — bez znajomości wykorzystywanego zestawu znaków nie byłoby możliwe
poprawne dekodowanie zawartości pliku. Zestaw 8859_1 koresponduje ze stroną kodową
454 A l g o r y t m y . Od podstaw
ISO Latin-1 używaną przez wszystkie języki zachodnioeuropejskie, w tym język angielski
(więcej informacji na temat zestawów znaków i ich dekodowania można znaleźć pod adre-
sem www.unicode.org).
Wyniki eksperymentu
W charakterze materiału porównawczego dla efektywności obydwu algorytmów wyszuki-
wania wykorzystaliśmy angielskie tłumaczenie powieści Lwa Tołstoja Wojna i pokój, do-
stępne — w ramach Projektu Gutenberg — pod adresem www.gutenberg.org i zajmujące
nieco ponad 3 MB. Szczegóły związane z wynikami wyszukiwania zestawione są w tabeli
16.1 (dane dla algorytmu Boyera-Moore'a zawyżone są o jedno pobranie każdego znaku
w związku z budowaniem tablicy ostatnich wystąpień).
Tabela 16.1. Wyniki wyszukiwania wzorców w treści powieści Wojna i pokój Lwa Tołstoja
Wzorzec Liczba wystąpień Liczba pobrań znaku Liczba pobrań znaku Stosunek liczby
w algorytmie ..siłowym" w algorytmie Boyera-Moore a porównań
a 198 999 3 284 649 3 284 650 100,00%
the 43 386 3 572 450 1 423 807 39,86 %
zebra 0 3 287 664 778 590 23,68 %
military 108 3 349 814 503 199 15,02%
independence 8 3 500 655 342 920 9,80 %
Podsumowanie
W niniejszym rozdziale opisaliśmy dwa najczęściej używane i najlepiej poznane algorytmy
wyszukiwania wzorca tekstowego: naiwny algorytm „siłowy" oraz algorytm Boyera-Moore'a.
Wielokrotne wyszukiwanie wzorca — w tym wyszukiwanie wszystkich jego wystąpień w da-
nym tekście — ułatwione jest dzięki (opisanemu w treści rozdziału) iteratorowi posadowio-
nemu na bazie wyszukiwarki. Spośród omawianych w rozdziale zagadnień szczególnie warte
zapamiętania są następujące fakty:
Oprócz dwóch omawianych w treści rozdziału, znane sąjeszcze inne algorytmy wyszukiwania
wzorców tekstowych, między innymi algorytm Rabina-Karpa [Cormen, 2001] i Knutha-
Morrisa-Pratha [Cormen, 2001], Nie dorównują one jednak efektywnością algorytmowi
Boyera-Moore'a, a w wielu przypadkach okazują się nie lepsze (lub niewiele lepsze) od al-
gorytmu „siłowego". Algorytm Rabina-Karpa, korzystający z funkcji haszującej, okazuje się
szczególnie użyteczny przy wyszukiwaniu wielu wzorców jednocześnie. Tak czy inaczej
właściwy dobór algorytmu wyszukiwania uwarunkowany jest dokładną analizą charaktery-
styki przeszukiwanego tekstu i (lub) wyszukiwanego wzorca — być może uda się dzięki
temu uniknąć wielu porównań bezsensownych w sposób oczywisty.
456 Algorytmy. Od podstaw
17
Dopasowywanie łańcuchów
W rozdziale 16. omawialiśmy problematykę znajdowania wystąpień jednego łańcucha we-
wnątrz innego, zaś w niniejszym koncentrować się będziemy na kompletnych łańcuchach,
a konkretnie na związkach zachodzących między łańcuchami różnymi, lecz bardzo podob-
nymi. Identyfikowanie takich związków ma bardzo duże znaczenie praktyczne, między in-
nymi dla wyszukiwania zdublowanych pozycji w bazach danych, kontroli poprawności pi-
sowni w dokumentach, a nawet dla poszukiwania określonych genów w kodzie DNA.
• kod Soundex,
• koncepcję odległości słów Levenshteina.
Sounde*
Kod Soundex jest jednym z przedstawicieli obszernej klasy kodów zwanych kodami fone-
tycznymi. Kodowanie fonetyczne to takie, które przekształca podobnie brzmiące łańcuchy
na tę samą wartość kodową (w sposób zbliżony do funkcji haszującej).
Soundex, opracowany przez R.C. Russela w celu przetworzenia danych zebranych w ra-
mach narodowego spisu powszechnego w USA w 1980 roku, stosowany był — zarówno
w postaci oryginalnej, jak i z rozmaitymi zmianami — w wielu aplikacjach, począwszy od
zarządzania zasobami ludzkimi, poprzez drzewa genealogiczne, aż do opracowywania da-
nych administracyjnych, przede wszystkim w celu wyeliminowania zdublowanych danych
stanowiących konsekwencję błędów w zapisywaniu nazwisk.
W roku 1970 Robert L. Taft, pracujący na rzecz projektu New York State Identification and
Intelligence Project (NYSII), opublikował pracę zatytułowaną „Name Search Techniąues"
(„Techniki wyszukiwania nazwisk"), w której zaproponował metody wyszukiwania na-
zwisk (i ogólnie nazw) w oparciu o dwa schematy kodowania fonetycznego. Jednym z tych
458 Algorytmy. Od podstaw
Kod Soundex jest stosunkowo prosty koncepcyjnie, opiera się bowiem na kilku dobrze
określonych regułach przetwarzania łańcuchów złożonych wyłącznie z liter. Łańcuch taki
— będący najczęściej nazwiskiem, choć niekoniecznie — przetwarzany jest od strony le-
wej do prawej, przy zastosowaniu wspomnianych reguł do kolejnych znaków. Wynikiem
tego przetwarzania jest czteroznakowy kod w postaci LDDD, gdzie Z, jest (wielką) literą a D
— cyfrą z zakresu od 0 do 6.
Konkretnie, każdy kolejny znak łańcucha przetwarzany jest według następujących reguł
(zwróć uwagę na związki między literami należącymi do tej samej grupy)1:
1. Małe litery traktowane są tak jak ich wielkie odpowiedniki.
2. Pierwsza litera łańcucha jest zawsze zachowywana — staje się pierwszym znakiem
kodu wynikowego (być może po zamianie na wielką literę).
3. Litery A, E, I, 0, U, H, W oraz Y są całkowicie ignorowane2.
4. Pozostałym literom przypisywane są kody numeryczne w sposób następujący:
Litera Kod
B, F, P, V i
C, G, J, K, Q,S, X, Z 2
D, T 3
L 4
M, N 5
R 6
5. Spośród ciągu sąsiadujących liter dających ten sam kod zachowywana jest tylko
pierwsza.
6. Jeżeli otrzymany kod jest krótszy niż 4 znaki, zostaje uzupełniony zerami do tej
długości.
1
Zwracam uwagę Czytelników, że reguły te opracowane zostały na podstawie zasad wymowy
angielskiej i nie sprawdzają się w odniesieniu do polskich nazwisk — przyp. tłum.
Z wyjątkiem przypadku, gdy któraś z nich jest pierwszą literą łańcucha — przyp. tłum.
Rozdział 17. • Dopasowywanie łańcuchów 459
Głoski B, F, P i V są podobne nie tylko pod względem wymowy, lecz także w zakresie ukła-
du ust w jej trakcie — spróbuj szybko wypowiedzieć P po B, T po D czy też N po M.
Zgodnie z regułą nr 2 pierwsza litera nazwiska Smith staje się pierwszym znakiem kodu
wynikowego (rysunek 17.2).
Kolejna litera — t — zgodnie z regułą nr 4 kodowana jest jako cyfra 3 (rysunek 17.5).
460 A l g o r y t m y . Od podstaw
Kodowanie nazwiska Smythe także rozpoczynamy od wypełnienia bufora spacjami (rysunek 17.8).
Postępując analogicznie jak w przypadku nazwiska Smith, otrzymujemy wartość S530 jako
kod Soundex nazwiska Smythe (rysunek 17.9).
Ponieważ zakodowanie łańcucha wymaga jednokrotnej analizy każdego jego znaku, wyko-
nywane jest w czasie 0(N) (TV jest długością łańcucha).
Po przedstawieniu teoretycznych podstaw kodu Soundex zajmijmy się jego obliczem pro-
gramistycznym. Jak zwykle przed zaimplementowaniem klasy dokonującej kodowania stwo-
rzymy zestaw testowy weryfikujący poprawność jej działania.
Rozdział 17. • Dopasowywanie łańcuchów 461
import junit.framework.TestCase:
encoder = SoundexPhoneticEncoder.INSTANCE;
}
public void testFirstLetterlsAlwaysUsedO {
for (char c = 'A'; c <- 'Z'; ++c) {
String result = _encoder.encode(c + "-"):
assertNotNull(result);
assertEquals(4. result.length()):
assertEquals(c. result.charAt(O));
}
}
public void testVowelsAreIgnored() {
assertAl1Equals('0'. new char[] {'A'. 'E". 'I'. '0'. 'U'. 'H', 'W'. 'Y•}):
}
public void testLettersRepresentedByOne() {
assertAl1Equals('1', new char[] {'B'. 'F'. 'P', 'V'});
}
public void testLettersRepresentedByTwo() {
assertAllEquals('2'. new char[] {'C'. 'G'. "J'. 'K', 'Q'. 'S'. 'X', 'Z'});
}
public void testLettersRepresentedByThree() {
assertAllEquals('3', new char[] {"D'. 'T'});
}
public void testLettersRepresentedByFour() {
assertAllEquals('4'. new char[] {'L'});
}
public void testLettersRepresentedByFive() {
assertAllEquals('5'. new char[] {'M'. 'N'});
}
public void testLettersRepresentedBySix() {
assertAllEquals('6'. new char[] {'R'}):
}
462 Algorytmy. Od podstaw
assertNotNull(result):
assertEquals(4. result.length()):
J a k to działa?
import junit.framework.TestCase;
_encoder = SoundexPhoneticEncoder.INSTANCE;
}
}
Zgodnie z regułą nr 2 pierwszy znak kodowanego łańcucha staje się pierwszym znakiem
kodu wynikowego. Weryfikująca tę właściwość kodera metoda testFirstLetterlsAlway-
sllsedt) dokonuje w związku z tym kodowania łańcuchów jednoliterowych, począwszy od
„A", a na „Z" skończywszy, i sprawdza (kolejno) czy wynik kodowania jest niepusty, czy
Rozdział 17. • Dopasowywanie łańcuchów 463
ma długość 4 (zgodnie z regułą 6) i czy pierwszym jego znakiem jest pierwszy znak orygi-
nalnego łańcucha:
public void testFirstLetterlsAlwaysUsedO {
for (char c = 'A'; c <= 'Z'; ++c) {
String result - _encoder.encode(c + "-");
assertNotNul1(result):
assertEqua1s(4, result.1ength());
assertEquals(c, result.charAt(O)):
}
}
Testy związane z pozostałymi regułami przebiegają podobnie. Wykorzystują one pomocni-
czą metodę, a konkretnie przeciążony wariant metody assertEquals(). Parametrami jego
wywołania są oczekiwana wartość kodowa i tablica znaków; każdy znak tej tablicy staje się
drugim znakiem łańcucha podlegającego następnie kodowaniu. Wynik tego kodowania powi-
nien być niepusty, mieć długość czterech znaków i być identyczny z (znaną a priori) wartością
oczekiwaną. W każdym przypadku pierwszy znak kodowanego łańcucha staje się pierwszym
znakiem wyniku (nie ma znaczenia fakt, że nie jest on literą!), drugi znak wyniku jest re-
zultatem kodowania kolejnego znaku ze wspomnianej tablicy — j e ś l i jest to znak ignoro-
wany na mocy reguły nr 3, drugim znakiem wyniku jest 0. Dwa ostatnie znaki wyniku to
zera dopełniające go do długości cztery.
private void assertAHEquals(char expectedValue, char[] chars) {
for (int i = 0: i < chars.length: ++i) {
char c = chars[i]:
String result - _encoder.encode("-" + c):
assertNotNull(result):
assertEquals(4, result.1ength());
assertAllEquals('2\ new char[] {'C'. "G". 'J1. 'K\ 'Q'. 'S'. 'X', 'Z'});
}
public void testLettersRepresentedByThreeO {
assertAllEquals('3'. new char[] {'D'. 'T'});
}
public void testLettersRepresentedByFourO {
assertAHEquals('4'. new char[] {"L'});
}
public void testLettersRepresentedByFive() {
assertAllEquals('5', new char[] {'M'. 'N'}):
}
public void testLettersRepresentedBySix() {
assertAllEquals('6'. new char[] {'R"});
}
Zgodnie z regułą nr 5 spośród ciągu znaków dających ten sam kod zachowywany jest tylko
pierwszy. Sposób, w jaki metoda testDupl icateCodesAreDropped() weryfikuje spełnienie
tego wymogu, nie jest tak oczywisty jak inne testy i wymaga pewnego komentarza.
Jak pamiętamy, pierwszy znak kodowanego łańcucha kopiowany jest do wyniku bez
zmian, drugi znak wyniku jest rezultatem kodowania drugiego znaku łańcucha (tym razem
znak ten na pewno nie jest znakiem ignorowanym). Jeśli wszystkie następne znaki łańcucha
kodowane są do tej samej wartości co drugi znak, są one po prostu ignorowane, a dwuzna-
kowy (na razie) wynik wymaga dopełnienia zerami do długości 4, ergo — dwa ostatnie znaki
wyniku muszą być zerami:
public void testDuplicateCodesAreDroppedO {
assertEqua1s("B100". _encoder.encodet"BFPV"));
assertEquals("C200". _encoder.encode("CGJKQSXZ"));
assertEquals("D300". _encoder.encode("DDT")):
assertEquals("L400". _encoder.encode("LLL")):
assertEquals("M500". _encoder.encode("MNMN")):
assertEqua1s("R600". _encoder.encode("RRR")):
}
Na zakończenie metoda testSomeRealStrings() dokonuje konfrontacji wyniku kodowania
kilku przykładowych łańcuchów z oczekiwaną wartością tego wyniku:
public void testSomeRealStringsO {
assertEquals("S530". _encoder.encode("Smith"));
assertEquals("S530". _encoder.encode("Smythe"));
assertEquals("M235". _encoder.encode("McDonald")):
assertEquals("M235". _encoder.encode("MacDonald")):
assertEquals("H620". _encoder.encode("Harris")):
assertEquals("H620", _encoder.encode("Harrys")):
}
Wyposażeni w zestaw testowy weryfikujący poprawność kodera możemy przystąpić do
implementacji tego ostatniego.
Rozdział 17. • Dopasowywanie łańcuchów 465
Implementująca ten interfejs klasa kodera Soundex zdefiniowana jest natomiast następująco:
package com.wrox.a1gori thms.wmatch;
private SoundexPhoneticEncoder() {
}
result[0] - Character.toUpperCase(string,charAt(0)):
int stringlndex = 1;
int resultlndex = 1:
J a k to działa?
Centralną częścią klasy jest tablica CHARACTER_MAP odwzorowująca poszczególne litery al-
fabetu angielskiego na odpowiadające im cyfry kodu Soundex. Oczywiście ogranicza to
stosowalność klasy wyłącznie do nazw angielskich, co jednak nie jest niczym niezwykłym
wobec faktu, że sam kod Soundex nadaje się wyłącznie dla takich właśnie nazw.
public finał class SoundexPhoneticEncoder implements PhoneticEncoder {
/** pojedyncza instancja klasy (singleton) */
public static finał SoundexPhoneticEncoder INSTANCE = new
SoundexPhoneticEncoder();
private SoundexPhoneticEncoder() {
w granicach tablicy, zwracana jest identyfikowana przez niego wartość, w przeciwnym ra-
zie zwracana jest cyfra 0, podobnie jak dla znaków ignorowanych:
private static char map(char c) {
int index = Character.toUpperCase(c) - 'A';
return isValid(index) ? CHARACTER_MAP[index] : '0';
}
result[0] = Character.tollpperCase(string.charAtCO));
int stringlndex = 1:
int resultlndex = 1;
Jest oczywiste, iż mając dwa dowolne słowa, jedno z nich przekształcić można na drugie za
pomocą trojakiego rodzaju (wykonywanych wielokrotnie) operacji: wstawiania, usuwania
i zastępowania znaku. Każdej z tych operacji przypisać można określony koszt, a sumę
kosztu wykonanych operacji uważać za koszt całego przekształcenia. Ponieważ dla dwóch
ustalonych słów istnieje wiele sposobów przekształcenia jednego na drugie, sensownie jest
znaleźć wówczas przekształcenie o minimalnym koszcie i uważać ów koszt za miarę podo-
bieństwa dwóch słów. Miara ta, zwana odległością Levenshtcina lub odległością edycyjną,
może stanowić kryterium uznania dwóch słów za identyczne w tym sensie, że jedno z nich
powstało wskutek przypadkowego zniekształcenia drugiego; można na przykład założyć, że
słowa o odległości nie większej niż pewien ustalony próg (na przykład 4) są z dużym
prawdopodobieństwem identyczne. Koncepcja ta znajduje szerokie zastosowanie w proce-
sie sprawdzania poprawności ortograficznej dokumentów, wykrywania plagiatów, a nawet
dopasowywania (zniekształconych) fragmentów kodu DNA.
W celu obliczenia odległości Levenshteina dwóch słów konstruujemy macierz, której wiersze
odpowiadają poszczególnym literom jednego słowa, a kolumny — poszczególnym literom
drugiego. Na rysunku 17.10 widoczna jest macierz dla obliczania odległości między sło-
wami „msteak" i „mistake".
Rysunek 17.10. m i s t a k e
Początkowa 0 1 2 3 4 5 6 7
postać macierzy
1
dla obliczania
odległości s 2
Levenshteina t 3
między słowami
„msteak" e 4
i „mistake" a 5
k 6
Rysunek 17.11. m i s t a k e
Obliczenie wartości 0 1 2 3 4 5 6 7
pierwszej komórki
1 0
(m, m) m
2
s
3
t
4
e
5
a
6
k
Dla komórki (m, i) analogiczne obliczenie kształtuje się następująco (rysunek 17.12):
Cul = min(\ + 1,2 + 1 , 0 + 1) = min (2,3, 1)= 1
Rysunek 17.12. m i s t a k e
Obliczenie 0 1 2 3 4 5 6 7
wartości kolejnej
1 0 1
komórki (m, i) m
2
s
3
t
4
e
5
a
6
k
Kontynuując ten proces, otrzymamy ostatecznie kompletną macierz widoczną na rysunku 17.13.
3
Ponieważ jednak funkcja odległości Levenshteina jest funkcją symetryczną— dla dwóch słów ,v i y
odległość S(x,y) równa jest odległości S{y,x) — koszt wstawiania znaku musi być tożsamy
z kosztem usuwania znaku — p r z y p . tłum.
470 A l g o r y t m y . Od podstaw
Rysunek 17.13. m i s t a k e
Kompletnie 0 1 2 3 4 5 6 7
wypełniona macierz;
wartość komórki m 1 0 1 2 3 4 5 6
w prawym dolnym
s 2 1 1 1 2 3 4 5
rogu (k, ej jest
odległością t 3 2 2 2 1 2 3 4
Levenshteina
słów „mistake" e 4 3 3 3 2 2 3 3
i „msteak" a 5 4 4 4 3 2 3 4
k 6 5 5 5 4 3 2 3
Rysunek 17.14. m
Jedna ze ścieżek
monotonicznych
0 1 2 3 4 5 6 7
przekształcenia m 1 V \
2 3 4 5 6
słowa „msteak"
w słowo „mistake"
s 2 1 1 2 3 4 5
t 3 2 2 2 2 3 4
i
•
e 4 3 3 3 2 2 3 3
a 5 4 4 4 3 3 4
k 6 5 5 5 4 3 V *3
Wstawienie ,,i' i i
Usunięcie „e" 1 2
Wstawienie ,,e' 1 3
Opisany algorytm wykonuje się w czasie O(MN), czyli w czasie proporcjonalnym do ilo-
czynu długości obydwu słów, ponieważ w celu obliczenia wartości wszystkich komórek
macierzy konieczne jest porównywanie znaków na zasadzie „każdy z każdym". Ów kom-
Rozdział 17. • Dopasowywanie łańcuchów 471
import junit.framework.TestCase;
calculator = LevenshteinWordDistanceCalculator.DEFAULT:
}
public void testEmptyToEmptyO {
assertDistance(0, "", ""):
}
public void testEmptyToNonEmptyO {
String target = "any";
assertDistance(target.lengthO. "". target):
}
public void testSamePrefix() {
assertDistance(3, "unzip". "undo");
}
public void testSameSuffix() {
assertDistance(4, "eating", "running");
}
public void testArbitrary() {
assertDistance(3, "msteak", "mistake");
assert0istance(3, "necassery", "neccessary");
assertDistance(5. "donkey". "mule");
}
private void assertDistance(int distance, String source, String target) {
assertEquals(distance. _calculator.calculate(source. target));
assertEquals(distance. calculator.calculate(target. source));
}
}
472 Algorytmy. Od podstaw
J a k to działa?
import junit.framework.TestCase;
_calculator = LevenshteinWordDistanceCalculator.DEFAULT;
}
}
Podstawową metodą weryfikacji poprawności testowanego kalkulatora jest pomocnicza
metoda assertDistanceO. Otrzymuje ona jako parametry obydwa słowa i oczekiwaną od-
ległość między nimi, po czym porównuje tę ostatnią z odległością faktycznie obliczoną.
Zwróćmy uwagę na ważny fakt, że metoda ta testuje także symetryczność funkcji odległości
Levenshteina — odległość ta powinna być niezależna od kolejności porównywanych słów.
private void assertDistance(int distance. String source. String target) {
assertEquals(distance. _calculator.calculate(source, target)):
assertEquals(distance. _calculator.calculateCtarget, source));
}
Za pomocą metody testEmpty() weryfikowany jest oczywisty fakt, że odległość Levensh-
teina dwóch pustych łańcuchów jest równa zeru — choć puste, są one jednak identyczne.
public void testEmptyToEmptyO {
assertDistanceCO, "", "");
}
W metodzie testEmptyToNonEmpty() obliczana jest odległość dwóch łańcuchów — pustego
i niepustego; odległość ta powinna być równa długości niepustego łańcucha.
public void testEmptyToNonEmpty() {
String target = "any";
assertDistance(target.lengthO, "". target);
}
W metodzie testSamePrefix() obliczana jest odległość dwóch łańcuchów o wspólnym po-
czątku (prefiksie); powinna być ona równa różnicy między długością dłuższego łańcucha
a długością wspólnego prefiksu.
public void testSamePrefix() {
assertDistanceO. "unzip", "undo"):
}
Rozdział 17. • Dopasowywanie łańcuchów 473
_costOfSubstitution = costOfSubstitution;
_costOfDeletion = costOfDeletion;
_cost0flnsertion = costOflnsertion;
}
public int calculate(CharSequence source. CharSequence target) {
assert source != nuli : "nie określono pierwszego słowa":
assert target != nuli : "nie określono drugiego słowa";
474 Algorytmy. Od podstaw
grid[0][0] = 0;
for (int row = 1; row <= sourceLength; ++row) {
grid[row][0] = row;
}
for (int col = 1; col <= targetLength; ++col) {
grid[0][col] = col;
}
for (int row = 1 ; row <= sourceLength; ++row) {
for (int col = 1; col <= targetLength; ++col) {
grid[row][col] = minCost(source, target, grid. row col)-
}
}
return grid[sourceLength][targetLength];
}
private int minCost
(CharSequence source, CharSequence target, int[][] grid, int row, int
col) {
return min(
substitutionCost(source. target. grid. row, col).
deleteCost(grid. row. col),
insertCost(grid, row, col)
);
}
private int substitutionCost
(CharSequence source. CharSequence target. int[][] grid, int row, int
col) {
int cost = 0;
if (source.charAt(row - 1) != target.charAttcol - 1)) {
cost = _costOfSubstitution;
}
return grid[row - 1][col - 1] + cost;
J a k to działa?
Klasa LevenshteinWordDistanceCal cul ator utrzymuje trzy zmienne zawierające wartość jed-
nostkowego kosztu każdej z operacji elementarnych — zastąpienia, usunięcia i wstawienia
znaku. Klasa deklaruje także swą domyślną instancję (DEFAULT), w której wszystkie trzy wy-
mienione koszty równe są 1. Za pomocą publicznie dostępnego konstruktora można warto-
ści tych kosztów dowolnie kształtować.
package com.wrox.algorithms.wmatch;
public LevenshteinWordDistanceCalculator
(int costOfSubstitution. int costOfDeletion. int costOflnsertion) {
assert costOfSubstitution >= 0 ; "koszt zastąpienia znaku nie może być ujemny";
assert costOfDeletion >= 0 : "koszt usunięcia znaku nie może być ujemny";
assert costOflnsertion >= 0 : "koszt wstawienia znaku nie może być ujemny";
_costOfSubstitution = costOfSubstitution;
_costOfDeletion = costOfDeletion;
_cost0flnsertion = costOflnsertion;
}
}
Obliczanie wartości poszczególnych komórek macierzy jest zadaniem trzech metod pośred-
niczących. Pierwsza z nich — substitutionCost() — oblicza koszt zastąpienia jednego
znaku przez inny. Przypomnijmy, że koszt ten równy jest zero, gdy znaki te są identyczne;
w przeciwnym razie jest on sumą jednostkowego kosztu zastąpienia oraz wartości lewodia-
gonalnej.
Metoda rozpoczyna swą pracę, zakładając równość obydwu znaków, przyjmując począt-
kową wartość kosztu jako 0 i uaktualniając j ą gdy znaki te okażą się różne. Wartość ta jest
następnie sumowana z wartością komórki lewodiagonalnej:
private int substitutionCost
(CharSequence source. CharSequence target, int[][] grid. int row, int col) {
int cost = 0;
if (source.charAt(row - 1) != target.charAtCcol - 1)) {
cost = _costOfSubstitution;
}
return grid[row - 1][col - 1] + cost;
}
476 Algorytmy. Od podstaw
Podobnie metoda deleteCost( )oblicza koszt usunięcia znaku, sumując jednostkowy koszt
usunięcia w wartością komórki położonej powyżej:
private int deleteCost(int[][] grid, int row. int col) {
return grid[row - l][col] + _costOfDeletion:
}
Wreszcie metoda insertCostO oblicza koszt wstawienia znaku. Dodaje ona jednostkowy
koszt wstawienia do zawartości komórki położonej bezpośrednio na lewo.
private int insertCost(int[][] grid. int row, int col) {
return grid[row][col - 1] + _cost0flnsertion:
}
Ostatecznie spośród trzech wartości pośrednich obliczonych przez metody substitution-
CostO, deleteCostO i insertCostO wybierana jest wartość najmniejsza:
private int minCost
(CharSequence source, CharSequence target, int[][] grid. int row, int col) {
return min(
substitutionCosttsource, target, grid, row, col),
deleteCost(grid. row, col),
insertCost(grid, row, col)
);
}
private static int min(int a. int b, int c) {
return Math.min(a, Math.mintb, c));
}
Na bazie opisanych metod pomocniczych możemy już zbudować zasadniczą metodę cał-
cul ate() obliczającą odległość Levenshteina między podanymi łańcuchami.
grid[0][0] = 0;
Podsumowanie
• Kodowanie fonetyczne — którego przykładem jest kod Soundex — umożliwia
efektywną identyfikację podobnie brzmiących słów.
• Kod Soundex wykorzystywany jest najczęściej do wyszukiwania równoważnych
pozycji w bazach danych
• Kod Soundex jest wartością czteroznakową a jego obliczanie przebiega w czasie
liniowym, czyli proporcjonalnym do długości kodowanego słowa (0(N)).
• Odległość Levenshteina między dwoma słowami to minimalny skumulowany koszt
operacji niezbędnych do przekształcenia jednego słowa w drugie. Im mniejsza jest
odległość Levenshteina między dwoma słowami, tym większe podobieństwo
między nimi.
• Obliczanie odległości Levenshteina znajduje zastosowanie między innymi
w weryfikacji poprawności ortograficznej dokumentów, wykrywaniu plagiatów
i dopasowywaniu zniekształconych fragmentów kodu DNA.
• Złożoność czasowa algorytmu Levenshteina, podobnie jak jego zapotrzebowanie
na pamięć, jest proporcjonalna do iloczynu długości obydwu słów (0(NM)).
478 Algorytmy. Od podstaw
18
Geometria obliczeniowa
Treść niniejszego rozdziału można potraktować jako wstęp do fascynującego obszaru algo-
rytmiki, określanego potoczną nazwą geometrii obliczeniowej. „Wstęp" — bo o geometrii
obliczeniowej napisano już tuziny (jeśli nie setki) książek i na kilkudziesięciu stronach nie
sposób zawrzeć nawet ogólnego zarysu tej tematyki.
Współrzędne i punkty
Dwuwymiarowe zagadnienia geometryczne rozpatrywane są najczęściej w dwuwymiaro-
wym układzie współrzędnych „x-y". Układ ten tworzą dwie prostopadłe, skierowane linie
zwane osiami, jak przedstawiono to na rysunku 18.1.
480 A l g o r y t m y . Od podstaw
Rysunek 18.1. Oś Y
Osie układu f
współrzędnych „x-y"
Oś X
Oś pozioma tego układu nazywana jest osią X, zaś oś pionowa — osią Y. Wartości na osi
poziomej wzrastają w kierunku na prawo, zaś na osi pionowej — w kierunku ku górze.
Rysunek 18.2. Oś Y
Punkt
o współrzędnych (3, 4)
(3, 4) w układzie „x-y"
2 -
Oś X
Osie układu „x-y" można w naturalny sposób „przedłużyć" odpowiednio na lewo i w dół
od punktu ich przecięcia. Przedłużenia te reprezentować będą wówczas wartości ujemne,
jak przedstawiono to na rysunku 18.3.
i i i i i i i i i r
-5 -4 -3 -2 -1 1 2 3 4 5
-1 •
-2- (4,-2)
-5-
Rozdział 18. • Geometria obliczeniowa 481
Unie
Linia1 stanowi najkrótsze połączenie dwóch punktów; punkty te jednoznacznie określają
łączącą je linię — j e j długość, nachylenie itp. Na rysunku 18.4 widoczna jest linia łącząca
punkty o współrzędnych (1, 1) i (5, 4).
Rysunek 18.4.
Linia łącząca dwa 4-
punkty w układzie
„x-y" 3-
2 -
1-
Trójkąty
Trójkąt jaki jest, każdy widzi (jednocześnie przepraszamy za wcześniejsze wyjaśnienia, co
to jest linia). W niniejszym rozdziale szczególnie interesować nas będą trójkąty prostokątne,
czyli takie, które zawierają kąt prosty (równy 90 stopni). Boki przylegające do kąta prostego
nazywane są przyprostokątnymi, pozostały bok — przeciwprostokątną. Przykładowy trójkąt
prostokątny widoczny jest na rysunku 18.5.
Rysunek 18.5.
Przykładowy trójkąt
prostokątny
Najbardziej znanym faktem dotyczącym trójkąta prostokątnego jest zależność wiążąca dłu-
gości jego boków zwana twierdzeniem Pitagorasa: jeśli a i Z) są długościami przyprostokąt-
nych, a c — długością przeciwprostokątnej (jak na rysunku 18.5), to wiąże je zależność 2 :
a2+b2=c2
1
W niniejszym rozdziale pod pojęciem „linii" rozumiemy to, co w geometrii nazywa się odcinkiem.
Należy odróżniać „linię" od „prostej", której jest ona odcinkiem: dwie nierównoległe proste na
płaszczyźnie muszą się przecinać, dwa nierównoległe odcinki („linie") niekoniecznie — przyp• tłum.
2
Uogólnieniem twierdzenia Pitagorasa na dowolny trójkąt jest twierdzenie Carnota, na mocy którego:
c2 = a2 +b2-2ab*cosy
gdzie ^jest kątem między bokami a i b —przyp. tłum.
482 Algorytmy. Od podstaw
Trójkąt prostokątny, którego długości boków wyrażają się liczbami naturalnymi, nazywa
się trójkątem pitagorejskim. Na rysunku 18.6 pokazano trójkąt pitagorejski o bokach długo-
ści 3, 4 i 5 jednostek 3 — j e s t to jedyny trójkąt pitagorejski, którego długości boków wyra-
żają się kolejnymi liczbami naturalnymi.
Rysunek 18.6.
Przykładowy
trójkąt pitagorejski
Istotnie:
32 + 42 = 5 2
lub inaczej:
9 + 16 = 25
Rysunek 18.7.
Dwie linie 4_
przecinające się
w punkcie P 3-
2-
l -
1 2 3 4 5
3
Inne trójkąty pitagorejskie można tworzyć, dobierając długości ich boków zgodnie z formułą:
a = m" -n1
b = 2mn
c = m2 +n2
gdzie m i n są dowolnymi liczbami naturalnymi. Faktycznie:
(m1-n1)2+{2mnf = (m2+n2J
—przyp. tłum.
Rozdział 18. • Geometria obliczeniowa 483
y = mx + b
gdzie w jest nachyleniem linii, a b — miejscem jej przecięcia wspomnianej prostej z osią_y.
Nachylenie linii
Nachylenie linii (slope) interpretować można w kategoriach alpinistycznych: oznacza ono
stromość linii mierzonej jako stosunek różnicy poziomów (wzniesienia) do przesunięcia
w poziomie, jak na rysunku 18.8.
i
Rysunek 18.8.
Nachylenie linii 4 -
jako stosunek
wzniesienia 3 -
do przesunięcia wzniesienie
2 -
1 -
przesunięcie
Wzniesieniem jest różnica pionowa, czyli różnica między współrzędnymi y punktów wy-
znaczających linię, zaś przesunięciem — różnica pozioma tych punktów, czyli różnica między
ich współrzędnymi x. Stosunek tych różnic jest właśnie nachyleniem linii, co poglądowo
wyjaśniono na rysunku 18.9.
Rysunek 18.9.
(4,4)
Linia o nachyleniu 1
3 nachylenie = 1
wzniesienie = 3
2 -
1
(1,1) przesunięcie = 3
Nachylenie linii może mieć wartość ujemną jak na rysunku 18.10 — różnice pozioma i pio-
nowa mają przeciwne znaki, nachylenie linii równe j e s t - 2 .
Jako szczególne przypadki linii prostych wymienić należy linie poziome i pionowe. Na-
chylenie linii poziomej zawsze równe jest zero, bowiem niezależnie od wielkości przesu-
nięcia jej wzniesienie jest zerowe. Dla linii pionowej przesunięcie zawsze równe jest zero;
jako że dzielenie przez zero jest niewykonalne, linia pionowa ma nachylenie nieskończone.
Jak niebawem zobaczymy, stanowi to pewien problem przy programowaniu obliczeń geo-
metrycznych.
484 A l g o r y t m y . Od podstaw
Rysunek 18.10.
(2,4)
Linia o ujemnym
nachyleniu
3 -
Slope = - 2
Rise = - 3
1 - (3,5; 1)
Travel = 1,5
2 3 4 5
Rysunek 18.11.
Para linii
równoległych
4
Chodzi tu oczywiście o przecięcie przedłużenia linii z osiąy, sama linia (jako odcinek)
niekoniecznie musi oś y przecinać — p r z y p . tłum.
Rozdział 18. • Geometria obliczeniowa 485
Rysunek 18.12.
Przykładowa para
przecinających się
linii
Ponieważ punkt przecinania się dwóch linii należy do każdej z nich, musi więc spełniać
jednocześnie równania ich obydwu. Innymi słowy, jeśli pierwsza z linii opisana jest przez
równanie:
y = mx + b
to współrzędne punktu ich przecinania się wyznaczyć można przez porównanie prawych
stron obydwu równań:
mx + b = nx + c
mx - nx = c -b
c-b
lub:
yP =nxp +c
486 Algorytmy. Od podstaw
oraz:
yp=-2xp-2 = ( - 2 ) * ( - 1 , 6 ) - 2 = 1,2
Opisana metoda zawodzi w sytuacji, gdy jedna z linii (przyjmijmy, że pierwsza) jest linią
pionową, nie są bowiem wówczas określone wartości m i b. Linia opisana jest wówczas -
równaniem x = d, a skoro leży na niej punkt przecięcia (z drugą linią), to oczywiste jest, że:
xp=d
import junit.framework.TestCase:
}
Wartości współrzędnych osiągalne są za pomocą odpowiednich metod dostępowych:
public double getX() {
return _x:
}
public double getYO {
return _y:
)
Jak to działa?
Obiekt klasy Point przechowuje współrzędne reprezentowanego punktu w polach _x i _y.
Pola te inicjowane są przez konstruktor i — j a k o finalne — nie mogą być później zmienione.
W celu obliczenia odległości między dwoma punktami o współrzędnych (x2, y2)
stosuje się znany wzór:
d = ^ { x 2 - x y +{y1-yl)2
Dwa punkty równe są wtedy, gdy mają identyczne współrzędne. Metoda equals() rozpo-
czyna swą pracę od sprawdzenia, czy istotnie ma do czynienia z dwoma punktami, po czym
dokonuje porównania ich współrzędnych.
import junit.framework.TestCase:
Powinniśmy oczywiście przetestować poprawność obliczania nachylenia dla linii, dla których
znane jest ono a priori:
public void testAsDoubleForNonVerticalSlope() {
assertEquals(0. new SlopetO, 4).asDouble(), 0);
assertEquals(0, new Slope(0, -4).asDouble(), 0);
assertEquals(l. new Slope(3. 3).asDouble(), 0):
assertEquals(l, new Slopet-3, -3).asDouble(), 0);
assertEquals(-l, new Slope(3. -3) .asDoubleO. 0);
assertEquals(-l. new Slopet-3. 3) .asDoubleO . 0);
assertEquals(2. new Slope(6, 3).asDoublet). 0);
assertEquals(1.5. new Slope(6, 4).asDoubleO. 0);
}
Musimy także zweryfikować poprawność reakcji klasy Slope na próbę obliczenia nachyle-
nia linii pionowej jako wartości typu double:
public void testAsDoubleFailsForVerticalSlope() {
try {
new Slope(4, 0).asDoubleO;
failCTa instrukcja nie powinna się wykonać!");
} catch (IllegalStateException e) {
assertEquals(
"Nachylenie linii pionowej jest nieskończone", e.getMessageO);
}
}
Jak to działa?
Zakładamy, że nachylenie linii reprezentowane jest (w ramach klasy Slope) przez parę
wartości (wzniesienie,przesunięcie), zgodnie z wcześniejszym opisem. Zwróćmy uwagę, że
obiekt klasy Slope nie reprezentuje ani konkretnego punktu, ani konkretnej odległości, a je-
dynie nachylenie linii — różne linie, łączące różne punkty różnie od siebie oddalone, mogą
mieć to samo nachylenie, czyli być równoległe. W metodzie testEquals() badamy po-
prawność rozpoznawania par prostych równoległych o rozmaitych wartościach nachylenia
— ujemnych, dodatnich i wartości zerowej.
490 Algorytmy. Od podstaw
Metoda equals() rozstrzyga, czy dwa obiekty klasy Slope reprezentują to samo nachylenie:
public int hashCodeO {
return (int) (_wzniesienie * _przesuniecie);
}
public boolean equals(0bject object) {
if (this == object) {
return true;
}
if (object == nuli || object.getClassO != getClassO) {
return false;
}
5
Niestety, autorzy nie uwzględniają tu przypadku, gdy zerowe s ą obydwie wartości — przesunięcie
i wzniesienie. Taka para nie reprezentuje żadnej prostej, a jej eliminacja powinna następować
w treści konstruktora, za p o m o c ą odpowiedniej asercji w rodzaju:
Jak to działa?
Podobnie jak w przypadku klasy Point obiekty klasy Slope są niemodyfikowalne — ich
pola inicjowane są przez konstruktor i ich wartości (jako finalnych) nie można już później
zmieniać.
Badanie, czy dwa nachylenia linii — reprezentowane przez dwie instancje klasy Slope —
są równe, jest zadaniem cokolwiek skomplikowanym. Metoda equals() rozpoczyna swą
pracę od sprawdzenia, czy obydwa nachylenia reprezentują kierunek pionowy — j e ś l i tak,
uznawane są za równe. W kolejnym teście sprawdza się, czy dokładnie jedno z nachyleń
reprezentuje kierunek pionowy —jeśli tak, nachylenia uznawane są za nierówne. Wreszcie,
jeżeli obydwa nachylenia reprezentują kierunek różny od pionowego, porównywane są od-
powiadające im wartości numeryczne zwracane przez metodę asDoubleO. W ten oto sposób
unikamy (niewykonalnej) próby obliczania numerycznej wartości nachylenia dla (ewentu-
alnego) kierunku pionowego.
Metoda asDoubleO, obliczająca numeryczną wartość nachylenia, dzieli w tym celu wznie-
sienie przez przesunięcie; jeśli jednak nachylenie reprezentuje kierunek pionowy, zamiast
wykonywania dzielenia generowany jest wyjątek.
Po zdefiniowaniu klas reprezentujących punkt (Point) i nachylenie linii (Slope) pora teraz
na klasę Line reprezentującą linię jako taką. Rozpoczniemy oczywiście od testów, między
innymi od badania, czy dany punkt leży na danej linii oraz weryfikowania równoległości
bądź prostopadłości dwóch linii.
492 Algorytmy. Od podstaw
import junit.framework.TestCase;
assertTrued.containstp));
assertTrued ,contains(q));
assertTrued .contains(p)):
assertTrued .contains(q));
assertTrued .isParallelTo(m)):
assertTruetm. isParal lelTod));
}
Analogiczna para testów przeprowadzana jest dla dwóch linii pionowych (z definicji rów-
noległych)...
public void testIsParallelForTwoVerticalParallelLines() {
Point p = new Pointd, 1);
Point q = new Pointd. 6):
Point r = new Point(4, -2);
Point s = new Point(4, 0):
assertTrued .isParallelTo(m));
assertTrue(m. isParal lelTod)):
}
...i dwóch linii, z których dokładnie jedna jest pionowa (takie linie z definicji równoległe
być nie mogą):
public void testIsParallelForOneVerticalAndOneNonVerticalLine() {
Point p = new Pointd, 1);
Point q = new Pointd. 6):
Point r = new Point(4, -2);
Point s = new Point(6, 0);
494 Algorytmy. Od podstaw
assertFalsetl .isParallelTo(m));
assertFalsetm.isParallelTod));
]
Kolejne testy związane są ze znajdowaniem punktu przecinania się dwóch linii. Zadanie to
wykonywane jest przez metodę intersectionPoint() klasy Line — parametrem metody
jest inna, wskazana linia. Wynikiem metody jest obiekt klasy Point reprezentujący punkt
przecinania się obydwu linii albo wartość pusta (nuli), gdy linie te się nie przecinają. Po-
dobnie jak poprzednio, przypadki obejmujące linie pionowe potraktowane zostały odrębnie.
Najpierw zbadamy, czy dla dwóch niepionowych linii równoległych metoda intersection-
Point() zwraca wartość nuli — linie te z definicji przecinać się bowiem nie mogą:
public void testParallelNonVerticalLinesDoNotIntersect() {
Point p = new PointtO. 0);
Point q « new Point(3. 3);
Point r = new Point(5, 0):
Point s = new Point(8. 3);
assertNul1(1. intersectionPoint(m));
assertNul 1 (m.intersectionPointd ));
}
Analogiczny test przeprowadzimy dla dwóch linii pionowych (z definicji równoległych):
public void testVerticalLinesDoNotIntersect() {
Point p = new PointtO. 0);
Point q = new PointtO. 3):
Point r = new Point(5. 0);
Point s = new Point(5, 3);
assertNul1(1.intersectionPointtm));
assertNul1(m.i ntersecti onPoi nt(1)):
)
assertEquals(i. 1.intersectionPoint(m)):
assertEquals(i. m.intersectionPointd));
Analogiczny test przeprowadzimy dla dwóch przecinających się (w znanym punkcie) linii,
z których dokładnie jedna jest pionowa:
public void testIntersectionOfVerticalAndNonVerticalLines() {
Point p = new PointtO, 0);
Point q = new Point(4. 4);
Point r - new Point(2. 0);
Point s - new Point(2, 4);
assertEquals(i, 1.intersectionPoint(m));
assertEquals(i, m.intersectionPointd));
}
Wreszcie musimy rozpatrzyć dość ciekawy przypadek linii, które co prawda leżą na dwóch
prostych przecinających się, lecz same są zbyt krótkie na to, by mieć punktu wspólny.
Przekład pary takich linii, zwanych liniami rozłącznymi, przedstawiono na rysunku 18.13.
Rysunek 18.13.
Para linii
rozłącznych
assertNul1(1.intersectionPoint(m)):
assertNul 1 (m.intersectionPointd )):
j
496 Algorytmy. Od podstaw
Jak to działa?
Przedstawiona grupa metod testowych obejmuje przypadki linii, które przecinają się albo
nie przecinają i które są albo nie są pionowe. Solidne testowanie obejmować musi wszelkie
kombinacje takich przypadków i choć ich liczba wydaje się dość duża, to jednak jest to ko-
nieczne dla uwzględnienia wszystkich możliwych sytuacji. Prezentowane testy mogą być
uzupełnione testami bardziej szczegółowymi, przez podział testowanej funkcjonalności mię-
dzy kilka klas — między innymi w tym właśnie celu zdefiniowano klasę Slope.
_P = P:
_q = q:
_słope - new Slope(_p.getYO - _q.getYO, _p.getXO - _q.getXO);
}
}
Dzięki metodzie isParalellToO możliwe jest badanie, czy dana linia jest równoległa do
wskazanej:
public boolean isParallelTo(Line line) {
assert line != nuli : "nie określono linii":
return _slope.equals(line._slope):
i
Badanie, czy dana linia zawiera wskazany punkt, jest zadaniem metody containsO:
public boolean contains(Point a) {
assert a !- nuli : "nie określono punktu":
return false;
}
return _slope.isVertical() || a.getYO == solveY(a.getXO):
1
Znając współrzędną x punktu leżącego na danej linii, można — na podstawie równania tej
linii — obliczyć jego współrzędną^; jest to zadaniem metody sol veY():
private double solveY(double x) {
return _slope.asDouble() * x + baseO:
)
Metoda baseO oblicza miejsce przecięcia prostej zawierającej linię z osiąj', czyli wartość b
z równania y-mx + b:
Pomocnicza metoda isWithinO dokonuje sprawdzenia, czy dana liczba rzeczywista znaj-
duje się w przedziale domkniętym wyznaczonym przez dwa ograniczenia:
private static boolean isWithin(double test. double boundl. double bound2) {
return test >= Math.mintboundl, bound2) && test <= Math.[Tiax(boundl. bound2):
)
Punkt przecinania się dwóch linii obliczany jest przez (przywoływaną wcześniej) metodę
intersectionPointO:
if (isParallelTo(line)) {
return nul 1:
}
double x = getIntersectionXCoordinate(line):
double y = getlntersectionYCoordinateOine. x);
if Oine._slope.isVerticalO) {
return line._p.getXO;
}
double m = _slope.asDoubleO;
double b = b a s e O ;
double n = 1 ine._slope.asDoubleO;
double c - line.baseO;
return (c - b) / Cm - n);
Jak to działa?
Obiekty klasy Line posługują się trzema obiektami pomocniczymi. Dwa z tych obiektów
(_p i _q) są instancjami klasy Point i reprezentują punkty ograniczające linię — są one
przekazywane jako parametry konstruktora. Trzeci obiekt pomocniczy (_slope) — klasy
Slope — reprezentuje nachylenie linii i konstruowany jest wewnętrznie na podstawie
wspomnianych punktów. Większość funkcjonalności klasy Line zapewniania jest przez
owe obiekty pomocnicze, przykładowo badanie równoległości dwóch linii wykonywane
jest przez ich obiekty _slope.
{ X, < xp
y, ^ yp
< x2
< y2
Dla linii poziomych i pionowych jest to jednocześnie warunek wystarczający, jak jednak
widać na rysunku 18.14, nie musi to być prawdą w przypadku ogólnym.
Rysunek 18.14.
Mimo iż współrzędne
punktu zawierają się
w zakresie wyznaczonym
przez współrzędne
punktów ograniczających
linię, punkt ten wcale
nie musi leżeć na tej linii
Rozdział 18. • Geometria obliczeniowa 499
Konieczne jest więc sprawdzenie, czy punkt leży na prostej zawierającej linię. W tym celu
podstawiamy współrzędną x punktu do równania tej prostej ( y = mx + b) i — za pomocą
metody SOlveY() — wyliczamy jego oczekiwaną współrzędną ye (ye = mxp +b). Jeśli
jest ona równa faktycznej współrzędnej y p , oznacza to, że punkt o współrzędnych (xp , yp )
leży na linii. Opisany algorytm jest treścią procedury containsO.
W celu wyznaczenia współrzędnej x punktu przecinania się dwóch linii sprawdzamy naj-
pierw, czy którakolwiek z nich jest pionowa: jeśli tak, to zwracana jest współrzędna x jej
punktu początkowego, w przeciwnym razie wykorzystywany jest (wyprowadzony wcześniej)
c-b
wzór x = . Obliczenia te stanowią treść metody getIntersectionXCoordinate().
m-n
Chcielibyśmy znaleźć w tym zbiorze taką parę punktów, których odległość jest najmniejsza
w porównaniu z odległością każdej innej pary. W pierwszej chwili może się to wydawać
zadaniem banalnym — wystarczy po prostu zbadać odległość między każdą parą i wybrać
tę parę, dla której jest ona najmniejsza. Mimo oczywistej poprawności takiego rozwiązania
podstawową jego wadąjest konieczność obliczeń, jakie należałoby w związku z nim wyko-
nać: dla zbioru zawierającego N punktów musielibyśmy przebadać ^ ^ + ^ par, a więc
500 A l g o r y t m y . Od podstaw
Rysunek 18.15.
Zbiór punktów
rozproszonych
na płaszczyźnie
~i—i r~
-5 -4 -3
algorytm ten miałby złożoność kombinatoryczną — 0(N2). Zastosujemy więc bardziej in-
teligentny algorytm, nazywany potocznie „zamiataniem "płaszczyzny {piane sweeping).
Zacznijmy od prostego spostrzeżenia: jeżeli w danej chwili znamy wartość górnego ogra-
niczenia na szukaną minimalną odległość, można a priori zignorować te pary punktów, któ-
rych odległość na pewno ograniczenie to przekracza. Początkową wartością tego ograni-
czenia jest odległość między pierwszą analizowaną parą punktów, w miarę analizowania
kolejnych par wartość ta może się zmniejszać.
Rysunek 18.16.
Algorytm
„zamiatania"
płaszczyzny w akcji
V < >
Okno
skano-
wania
Kierunek „zamiatania"
Algorytm rozpatruje więc jedynie punkty znajdujące się wewnątrz okna skanowania, obli-
czając odległość każdego z nich od wspomnianego punktu odniesienia leżącego na prawej
krawędzi tego okna. Jeśli któraś z obliczonych odległości okaże się mniejsza od bieżącego
ograniczenia, staje się nowym ograniczeniem, a szerokość okna skanowania zmniejsza się
odpowiednio, ograniczając w konsekwencji jeszcze bardziej liczbę rozpatrywanych par
punktów. W bardziej zaawansowanej wersji algorytmu ograniczeniu polega także wysokość
okna skanowania, tak by wykluczyć z analizowania punkty zbyt odległe także w pionie od
analizowanego punktu (zaimplementowanie tej wersji pozostawiamy do wykonania Czytelni-
kowi jako jedno z ćwiczeń końcowych).
Rysunek 18.17.
Analiza punktów
bliska zakończeniu
Kierunek „zamiatania"
• Okno skanowania
import junit.framework.TestCase:
Wreszcie, jeżeli współrzędne x dwóch punktów są takie same, to przy wyznaczaniu ich po-
rządku komparator powinien kierować się ich współrzędnymi y — weryfikacja spełnienia
tego wymagania jest przedmiotem ostatniego testu:
public void testYCoordinatelsSecondaryKeyO {
Point p = new Point(4. -1);
Point q = new Point(4. 0):
Point r = new Point(4, 1):
Jak to działa?
Na potrzeby uporządkowania punktów w związku z „zamiataniem" płaszczyzny potrzebny
jest komparator porządkujący punkty według rosnących wartości współrzędnej x. Co jed-
nak zrobić w sytuacji, gdy dwa punkty mają identyczną współrzędną xl Komparator musi
ustalić jakąś ich kolejność i w związku z tym porządkuje takie punkty według rosnących
wartości współrzędnej y. Przypadek identycznych punktów rozstrzygany jest bardzo prosto:
zbiór punktów reprezentowany jest mianowicie przez strukturę typu Set (patrz rozdział
12.), której semantyka nie dopuszcza dublowania elementów, zatem problem rozstrzygania
o wzajemnej kolejności identycznych punktów po prostu nie istnieje.
Rozdział 18. • Geometria obliczeniowa 503
import com.wrox.algorithms.sorting.Comparator;
private XYPointComparator() {
}
J a k to działa?
Nawet jeśli nieco dziwnym może wydawać się fakt, że kod zestawu testowego dla kompa-
ratora punktów jest wyraźnie dłuższy od samej implementacji tego komparatora, to w rze-
czywistości nie jest to nic niezwykłego. Implementacja ta polega bowiem jedynie na kon-
kretyzacji metody compareO deklarowanej przez interfejs Comparator; metoda compareO
klasy XYPointComparator deleguje swe wywołanie do identycznie nazwanej metody dedy-
kowanej specjalnie obiektom klasy Point.
504 Algorytmy. Od podstaw
Interfejs Comparator nie precyzuje typu obiektów przekazywanych do swej metody compareO,
możliwe jest więc przekazanie do niej dowolnych obiektów. Gdy jednak obiekty te nie re-
prezentują punktów, czyli nie są kompatybilne z klasą Point, rzutowanie ich na tę klasę,
wykonywane w związku z wywołaniem metody dedykowanej, staje się niewykonalne i ge-
nerowany jest wyjątek ClassCastException.
i'"
Równie wyjątkowy jest zbiór zawierający jeden punkt — ponieważ nie można skonstru-
ować żadnej pary punktów, metoda fi ndCl osestPai r() również i tym razem powinna zwrócić
wynik nuli:
public void testASinglePointReturnsNullO {
ClosestPairFinder finder = createClosestPairFindert);
Rozdział 18. • Geometria obliczeniowa 505
assertNul1(finder.findClosestPair(points));
]
Dwa punkty tworzą jedyną możliwą parę i para ta jest rozwiązaniem problemu:
public void testASinglePairOfPointsO {
ClosestPairFinder finder = createClosestPairFinderO:
points.add(p):
points.add(q);
assertNotNull(pair);
assertEquals(2, pair.sizeO);
assertTruetpai r.contai ns(p));
assertTruetpair.contains(q)):
}
points.add(p);
points,add(q);
points.add(r);
assertNotNull(pair);
assertEquals(2. pair.sizeO);
assertTrue(pair.containstp));
assertTrue(pair.containstr));
1
tej pary, która wykryta zostanie jako pierwsza (zgodnie z kolejnością wyznaczoną przez
komparator).
public void testLargeSetOfPointsWithTwoEqualShortestPairsO {
ClosestPairFinder finder = createClosestPairFinderO;
assertNotNull(pair):
assertEquals(2, pair.sizeO);
assertTrue(pair.contains(new Point(-5. 3)));
assertTrue(pair.contains(new Point(-5. 4)));
1
Ponieważ w dalszym ciągu mamy zamiar zająć się jedynie algorytmem „zamiatania" płasz-
czyzny (pozostawiając Czytelnikowi implementację algorytmu „siłowego" jako jedno z ćwi-
czeń końcowych), musimy skonkretyzować naszą abstrakcyjną klasę testową tworząc klasę
dedykowaną wyłącznie „zamiataniu":
package com.wrox.a 1gorithms.geometry:
Jak to działa?
Jak większość zestawów testowych, tak i ten zasadza się na przypadkach nietypowych lub
wyjątkowych z punktu widzenia testowanego algorytmu — pustym zbiorze punktów, zbio-
rze jednopunktowym, zbiorze dwupunktowym, zbiorze, w którym jako rozwiązanie kwali-
fikuje się wiele równoważnych par, itd. Choć niekiedy liczba przypadków testowych może
wydawać się zbyt duża, to jednak wszystkie one odgrywają niebagatelną rolę z punktu widzenia
weryfikacji dość skomplikowanego algorytmu. Każdy z przypadków testowych z osobna
jest jednak zgoła nieskomplikowany.
}
Poszukiwanie pary najbliższych punktów jest zadaniem następującej metody:
public Set findClosestPair(Set points) {
assert points != nuli : "nie określono punktów";
508 Algorytmy. Od podstaw
if (points.sizeO < 2) {
return nuli;
}
List sortedPoints = sortPoints(points):
return result;
Powyższy kod zawiera wywołanie następującej metody, dokonującej sortowania zbioru punk-
tów (czyli ułożenia ich w posortowaną listę) na podstawie ich współrzędnych, przy użyciu
komparatora opisywanego wcześniej w niniejszym rozdziale:
private static List sortPoints(Set points) {
assert points != nuli : "nie określono zbioru punktów";
Iterator i = points.iteratorO;
for (i.firstO; !i.isDoneO; i.next()) {
INSERTER. i nsert (1 ist. i. currentO):
}
return list;
Rozdział 18. • Geometria obliczeniowa 509
Jak to działa?
Klasa PlaneSweepClosestPairFinder implementuje interfejs ClosestPairFinder. Jest do-
stępna dla aplikacji w postaci współdzielonej instancji (singletonu), ponieważ jej obiekty
nie przechowują żadnych informacji o stanie.
Jeżeli w zbiorze nie ma przynajmniej dwóch punktów (by można było utworzyć choć jedną
ich parę) algorytm kończy się zwróceniem wartości pustej. W przeciwnym razie punkty
układane są w posortowaną listę, a odległość między pierwszymi dwoma jej elementami
staje się początkową wartością najmniejszej dotychczas znanej odległości. Pozostałe punkty
przetwarzane są przez opisaną poniżej metodę wyszukującą ewentualną parę punktów po-
łożonych bliżej niż dwa punkty początkowe.
Poniższa metoda jest sercem całego algorytmu „zamiatania". Jest ona bardziej skompliko-
wana niż pozostałe i dlatego warto zająć się przestudiowaniem jej szczegółów, być może
posiłkując się rysunkami 18.16. i 18.17:
private Set findClosestPair(Point p. Point q. List sortedPoints) {
Set result = createPointPair(p, q);
double distance = p.distance(q);
int dragPoint = 0;
return result:
}
510 Algorytmy. Od podstaw
Algorytm ignoruje dwa pierwsze punkty z listy; początkowe okno skanowania ma szero-
kość równą odległości tych punktów, a jego prawa krawędź pokrywa się z trzecim punk-
tem. W każdym kroku algorytmu bada się odległość punktu odniesienia (tego położonego
na prawej krawędzi okna skanowania) od każdego z punktów znajdujących się wewnątrz tego
okna i zapamiętuje najmniejszą (dotychczas) odległość i odnośną parę punktów. Jeśli odległość
ta zostanie w danym kroku „poprawiona", zmniejsza się odpowiednio szerokość okna ska-
nowania i przesuwa je tak, by jego prawa krawędź pokryła się z kolejnym punktem z listy.
Podsumowanie
• Na początku rozdziału przypomnieliśmy Czytelnikom podstawowe pojęcia
geometryczne.
• W dalszym ciągu zajęliśmy się dwoma wybranymi problemami geometrii
dwuwymiarowej: znajdowaniem punktu przecinania się dwóch linii oraz
poszukiwaniem pary najbliżej położonych punktów.
• Rozwiązania obydwu tych problemów zaimplementowaliśmy w języku Java
i skonstruowaliśmy zestawy testowe do zweryfikowania poprawności tych
implementacji.
Nie sposób było zawrzeć w ograniczonych ramach rozdziału innej ciekawej tematyki, jak trila-
teracja (mechanizm wykorzystywany przez GPS), grafika 3D, projektowanie CAD/CAM itp.
Wierzymy jednak, że zainspirowaliśmy Czytelników do własnych poszukiwań w tym zakresie.
Ćwiczenia
1. Zaimplementuj „siłowe" rozwiązanie problemu poszukiwania pary najbliższych punktów.
2. Zoptymalizuj implementację algorytmu „zamiatania" płaszczyzny tak,
by ignorowane były także punkty zbyt oddalone w pionie od punktu odniesienia.
19
Optymalizacja pragmatyczna
Nieprzypadkowo pozostawiliśmy problematykę optymalizowania kodu na zakończenie, do
ostatniego rozdziału. Odzwierciedla to nasze głębokie przekonanie, iż optymalizacja zde-
cydowanie nie kwalifikuje się jako zagadnienie pierwszorzędne w procesie tworzenia apli-
kacji. W niniejszym rozdziale staramy się wyjaśnić znaczenie optymalizacji oraz to, jak i kiedy
j ą stosować, prezentujemy także kilka praktycznych technik zdolnych w znaczącym stopniu
poprawić wydajność tworzonych aplikacji. Zwracamy szczególną uwagę na fakt, że naj-
ważniejszą cechą aplikacji jest jej klarowny projekt, a jej wydajność jest odrębnym zagad-
nieniem; optymalizacja nie powinna być żywiołowym procesem determinującym sposób
tworzenia aplikacji, lecz działaniem zgodnie z przyjętymi regułami racjonalnego działania
i mierzenia jego efektów.
Świadomość taka nie powinna jednak mieć istotnego wpływu na sam projekt aplikacji: dą-
żenie do tworzenia za wszelką cenę kodu szybkiego, choć mało zrozumiałego i mało czy-
telnego — zwane przedwczesną (lub pochopną — premature) optymalizacją — jest pokusą
której należy się ze wszech miar opierać. Zawsze, gdy udało nam się zidentyfikować przy-
czyny nieoptymalności naszych własnych aplikacji, byliśmy zdumieni faktem, że znajdują
się one zupełnie gdzie indziej, niż pierwotnie się spodziewaliśmy. Tego rodzaju „wąskie
gardła" wydajnościowe są jedynymi obszarami, w których zabiegi optymalizacyjne mogą
przynosić istotne efekty; konsekwentne optymalizowanie całego kodu mija się z celem, po-
nieważ jego efekty nie są po prostu warte włożonego wysiłku. Wykrywanie owych wąskich
gardeł jest jednak sprawą systematycznego postępowania zasadzającego się na obserwacji
wykonywania kodu. Celowi temu służy profilowanie kodu, którym zajmiemy się w dalszej
części rozdziału.
Należy pamiętać, że — wbrew pozorom — prosty i czytelny projekt poddaje się optymali-
zacji znacznie łatwiej niż projekt, którego autor konsekwentnie hołdował względom opty-
malizacji jako naczelnemu celowi. Oczywiście nie można względów optymalności całko-
wicie lekceważyć, bowiem wydajność aplikacji uwarunkowana jest w pierwszym rzędzie
wyborem odpowiedniego algorytmu o określonej złożoności — 0(N), 0(log N) itd. —
ograniczeń wynikających z tej złożoności nie są bowiem wstanie przezwyciężyć żadne za-
biegi optymalizacyjne. To jeszcze jeden powód, dla którego odłożyliśmy aż do ostatniego
rozdziału rozważania na temat optymalizacji.
Jak pokazuje doświadczenie, pierwsza wersja programu rzadko kiedy jest wersją optymalną
pod względem wydajności. Niestety, pokazuje ono także, iż w nietrywialnej aplikacji próby
zgadywania przyczyn jej nieoptymalności z góry skazane są na niepowodzenie. Dlatego
najpierw należy skupić się na tym, by tworzony program działał poprawnie, a dopiero póź-
niej podejmować działania prowadzące do tego, by działał optymalnie. Podobnie jak celem
testowania aplikacji jest dostarczenie faktów przemawiających za jej poprawnością i uwol-
nienie programisty od konieczności (jedynie) domniemywania tej poprawności, tak opisy-
wane w niniejszym rozdziale techniki umożliwiają identyfikowanie prawdziwych przyczyn
nieoptymalności kodu, a nie (jedynie) ich domniemywanie.
wszelkich wąskich gardeł, lecz jedynie osiągnięcie założonych celów pod względem wy-
dajności. Należy przy tym unikać formułowania celów nierealnych — na przykład najbar-
dziej nawet optymalny program nie będzie w stanie przesłać w ciągu 3 sekund obrazka o roz-
miarze 2 MB przez łącze modemowe o przepustowości 56 kb/s. Optymalizację należy raczej
postrzegać jako jedno z narzędzi w zestawie służącym do zapewnienia wydajności niż jako
jedyne narzędzie zdolne uczynić aplikację superszybką. Znacznie ważniejszym w tym wzglę-
dzie jest sporządzanie dobrych projektów i umiejętność rozstrzygania rozmaitych kompro-
misów związanych z wyborem odpowiednich algorytmów i struktur danych.
Profilowanie
Profilowanie jest procesem polegających na dokonywaniu pomiarów rozmaitych aspektów
zachowania programu. Jak zobaczymy w dalszym ciągu rozdziału, maszyna wirtualna Javy
(JVM) posiada wbudowane mechanizmy zapewniające naturalne wsparcie dla profilowa-
nia; mechanizmy takie, w różnej postaci i w różnych stopniach zaawansowania, obecne są
także w wieku innych środowiskach programistycznych. Trzy główne aspekty zachowania
programu podlegające pomiarom w procesie profilowania to zużycie czasu procesora, wy-
korzystanie pamięci i interakcja z innymi, wykonywanymi współbieżnie wątkami.
Omówienie współbieżnościowych aspektów profilowania wykracza poza ramy
niniejszej książki, zainteresowanych tym tematem Czytelników odsyłamy do pozycji
wymienionych w dodatku A.
Pomiar wykorzystania czasu procesora polega na określeniu, jak wiele czasu spędza stero-
wanie wykonywanego programu w każdej z jego metod. Informacja w tym względzie uzy-
skiwana jest zazwyczaj przez profilator drogą okresowego próbkowania (w regularnych od-
stępach czasu) stosu wywołań każdego wątku maszyny wirtualnej. Próbkowanie to daje
rezultaty tym lepsze, im dłużej wykonywany jest profilowany program — to notabene bardzo
wyraźny argument przeciwko przedwczesnej optymalizacji kodu, czyli przedwczesnego czy-
nienia go zbyt szybkim.
Podobnie ma się rzecz z pomiarem wykorzystania pamięci obejmującym m.in. ogólne wy-
korzystanie pamięci, tworzenie obiektów i ich zwalnianie w ramach automatycznego od-
śmiecania (garbage collectioń). Profilatory udostępniają zwykle następujące statystyki w tym
zakresie:
514 Algorytmy. Od podstaw
Ten rodzaj informacji umożliwia bardziej wnikliwy wgląd w zachowanie się wykonywanego
kodu, a przy okazji bywa też źródłem różnych zaskakujących informacji, o czym będziemy
mogli przekonać się w dalszej części rozdziału, przy okazji optymalizowania przykładowego
programu.
giant
leap
for
programming
private ReverseStringComparator() {
}
public int compare(Object left. Object right) throws ClassCastException {
assert left != nuli : "nie określono lewego obiektu";
assert right != nuli : "nie określono prawego obiektu":
Nie będziemy zagłębiać się zbytnio w szczegóły powyższego kodu, ponieważ nie jest prze-
znaczony do wykorzystywania w rzeczywistych programach. Ogólnie rzecz biorąc, jest to
komparator implementujący interfejs Comparator, wyznaczający naturalną kolejność łańcu-
chów klasy String po uprzednim odwróceniu kolejności znaków w każdym z nich.
516 Algorytmy. Od podstaw
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.1 i sts.ArrayLi st;
i mport com.wrox.a 1gorithms.1 i sts.List:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
System.err.println(
"Koniec sortowania ... naciśnij CTRL+C. by zakończyć program");
try {
Thread.sleep(O);
} catch (InterruptedException e) {
// ignoruj wyjątek
}
}
}
J a k to działa?
Jak łatwo zauważyć, konstruktor klasy jest prywatny, co uniemożliwia samodzielne two-
rzenie jej instancji. Wykonywanie klasy rozpoczyna się od jej metody main(), która ceduje
całą pracę na metody loadWordsO i sortO. Po zakończeniu sortowania aplikacja wykonuje
rzecz dość dziwną: po wyświetleniu komunikatu o zakończeniu czeka w nieskończoność
(wskutek wywołania metody Thread.sleepO) na naciśnięcie kombinacji klawiszy Ctrl+C,
standardowo przerywającej wykonywanie. Ten pozornie dziwaczny scenariusz ma na celu
umożliwienie obserwacji wyników profilowania programu po jego zakończeniu.
Metoda sortO otrzymuje listę słów i sortuje ją metodą Shella w kolejności wyznaczonej
przez komparator reverseStri ngComparator, po czym wypisuje jej posortowaną zawartość.
private static void sortdist wordList) {
assert wordList != nuli : "nie określono listy słów";
System.out.printlnCPoczątek sortowania...");
Iterator i = sorted.iteratorO;
i.firstO;
while (!i .isDoneO) {
System.out.pri nt1n(i.current O ) :
i,next();
try {
String word;
Katalogiem bieżącym musi być wówczas katalog zawierający skompilowane pliki klas
Javy dla uruchamianego programu. Co do pliku zawierającego listę słów, to tego rodzaju
pliki znaleźć można łatwo w internecie, także na serwerze FTP wydawnictwa Helion,
w części poświęconej niniejszej książce (lista innych źródeł dostępna jest w dodatku B).
java -Xrunhprof:help
Opcja -Xrun powoduje załadowanie dodatkowych modułów maszyny wirtualnej Javy w mo-
mencie jej uruchamiania. W tym przypadku będzie to moduł hprof, do którego (po dwu-
kropku) przekazywane jest dodatkowe polecenie help, w celu uzyskania informacji na temat
sposobu wykorzystywania modułu:
Hprof usage: -Xrunhprof[:help]|[<option>=<value>. ...]
Jak widać, działanie profilatora może być sterowane różnymi parametrami. W naszym przy-
kładzie wykorzystamy parametr cpu=sampl es, co spowoduje profilowanie aplikacji metodą
próbkowania stosu wywołań, dokonamy także przekierowania standardowego wejścia i stan-
dardowego wyjścia do odpowiednich plików w bieżącym katalogu:
java -Xrunhprof:cpu=samples com.wrox.algorithms.sorting.FileSortingHelper <words.txt
>sorted.txt
Gdy uruchomimy nasz program w trybie profilowania, będzie się on wykonywał znacznie
wolniej niż normalnie, bowiem procesor część swego czasu poświęcić musi na zbieranie
rozmaitych statystyk. Generalnie każdy profilator spowalnia wykonywanie profilowanego
programu, co jednak nie ma wpływu na adekwatność pomiarów względnego wykorzystania
czasu przez poszczególne metody.
TRACĘ 23:
java.lang.StringBuffer.<init>(<Unknown>:Unknown 1ine)
java.lang.StringBuffer.<init>(<Unknown>:Unknown 1ine)
com.wrox.a1gori thms.sorti ng.ReverseStri ngComparator.reverse
(ReverseStri ngComparator.java:48)
com.wrox.a 1 gori thms.sorti ng.ReverseStringCompa rator.compa re
(ReverseStri ngComparator.java:44)
TRACĘ 21:
com.wrox.algorithms.sorti ng.ReverseStri ngComparator.reverse
(ReverseStri ngComparator.java:51)
com.wrox.a 1gori thms.sorti ng.ReverseStri ngComparator.compare
(ReverseStringComparator.java:44)
com.wrox.algori thms.sorti ng.Shel1sortLi stSorter.sortSubli st
(Shel1sortLi stSorter.java:79)
com.wrox.a 1gori thms.sorti ng.Shel1sortLi stSorter.hSort
(Shel1sortLi stSorter.java:69)
Długa seria takich migawek zajmuje dość pokaźny obszar pliku. Każda taka migawka sta-
nowi odzwierciedlenie stosu w ramach jednego próbkowania. Każdorazowo, gdy próbko-
wanie takie jest wykonywane, hprof analizuje wierzchołek stosu w celu określenia, czy
aktualna ścieżka (zagnieżdżenie) wywołań metod wystąpiła już wcześniej; jeśli tak, uaktu-
alniana jest statystyka w jednej z wcześniejszych migawek, w przeciwnym razie tworzona
jest nowa migawka. Numer po słowie TRACĘ (23 i 21 w powyższym przykładzie) jest identy-
fikatorem migawki; jego znaczenie poznamy już za chwilę.
Końcowa sekcja pliku jest najbardziej interesującą, w niej bowiem znajduje się informacja
o tym, gdzie sterowanie programu spędza większość czasu. Oto kilka początkowych wierszy
tej sekcji:
CPU SAMPLES BEGIN (total = 1100) Fri Apr 07 11:45:10 2006
rank self accum count tracę method
1 29.552 29.552 325 16 ReverseStringComparator.reverse
2 17.182 46.732 189 15 Li nkedLi st.getElementBackwards
3 16.002 62.732 176 18 LinkedList.getElementForwards
4 13.092 75.822 144 17 Li nkedLi st.getElementBackwards
5 11.552 87.362 127 14 LinkedList.getElementForwards
6 2.552 89.912 28 19 Li nkedLi st.getElementBackwards
7 2.092 92.002 23 29 Li nkedLi st.getElementBackwards
8 1.912 93.912 21 24 LinkedList.getElementForwards
Najważniejsze kolumny tej sekcji to sel f i accum oraz końcowa kolumna wskazująca, którą
metodę opisuje dany wiersz. W kolumnie self wykazywany jest względny udział wykony-
wania metody w ogólnym bilansie obciążenia procesora, natomiast w kolumnie accum wy-
kazywany jest analogiczny udział dla danej metody i wszystkich metod przez nią wywoły-
wanych. Łatwo zauważyć, że wiersze sekcji posortowane są malejąco względem kolumny
s e l f , przez co metody najbardziej interesujące z punktu widzenia potencjalnej optymaliza-
cji wykazywane są na początku sekcji. W sekcji tracę znajduje się numer migawki stosu
związanej z wywołaniem reprezentowanym przez dany wiersz.
Moduł JMP wyposażony jest w obszerną dokumentację, szczególnie przydatną dla począt-
kującego użytkownika, zalecamy więc jej uważne przestudiowanie. Należy zaznaczyć, że
JMP nie jest programem stworzonym w języku Java, więc jego instalacja może nie być
oczywista nawet dla programistów znakomicie obeznanych ze środowiskiem Javy. Przy-
kładowo, w systemie Windows konieczne jest skopiowanie biblioteki JMP.DLL do katalogu
systemowego.
Aby sprawdzić, czy JMP został prawidłowo zainstalowany, należy wydać polecenie:
java -Xrunjmp:help
Jak widać, JMP jest narzędziem w dużym stopniu konfigurowalnym. Na potrzeby naszego
przykładu uruchomimy go w konfiguracji standardowej, dla profilowania programu File-
SortingHelper, za pomocą polecenia:
a
Elle : _ $ Jsva Memory Profller - Objects • g s s
I (t (.li-irtuiy Profłler M t
fil* Qptions
Dump Restore || System.QC | tjeapdump || Monltors [| Ęreeze ul
Okno główne (widoczne u dołu rysunku) dostarcza graficznego obrazu stanu pamięci wy-
korzystywanej przez uruchomioną aplikację. Ukazuje ono dwie zmieniające się w czasie
wartości: całkowity rozmiar sterty przydzielonej przez system na potrzeby wirtualnej ma-
szyny Javy oraz sumaryczny rozmiar pamięci przydzielonej aktualnie dla obiektów. Warto-
ści te zmieniają się nieustannie wskutek tworzenia nowych obiektów i automatycznego
zwalniania obiektów już niepotrzebnych (w procesie odśmiecania — garbage collection). Jeśli
ogólne zapotrzebowanie na pamięć ze strony przekracza rozmiar sterty przydzielonej dla JVM
przez system operacyjny, JVM zwraca się do systemu z żądaniem zwiększenia przydziału.
Górne okno na rysunku 19.1 — okno obiektów — ukazuje interesującą statystykę dotyczą-
cą obiektów tworzonych w ramach JVM. W pierwszej kolumnie (Class) widzimy nazwę
klasy, w drugiej (Instances) natomiast widoczna jest liczba aktualnie istniejących instancji
(obiektów) tej klasy. Kolejne kolumny zawierają (dla każdej klasy) maksymalną liczbę jej
obiektów (Max instances) istniejących jednocześnie (od momentu uruchomienia aplikacji),
wielkość pamięci używanej przez aktualnie istniejące obiekty (Size) oraz liczbę obiektów
zwolnionych dotychczas w ramach automatycznego odśmiecania (#GC) — zwłaszcza ta
ostatnia kolumna jest interesująca jako punkt wyjścia dla różnych zabiegów optymalizacyjnych.
Widoczne w środkowej części rysunku okno metod udostępnia rozmaite statystyki związane
z każdą z metod wywoływanych w trakcie wykonywania programu: nazwę klasy i metody,
liczbę jej wywołań, sumaryczny czas jej wykonywania z uwzględnieniem metod przez nią
wywoływanych i bez tego uwzględnienia. Informacje te okazują się nieocenione z punktu
widzenia celowości wszelkich działań zmierzających do przyspieszenia pracy aplikacji.
522 Algorytmy. Od podstaw
Istota optymalizacji
Przed przystąpieniem do jakichkolwiek działań optymalizacyjnych należy najpierw się za-
stanowić, czy przyczyną małej wydajności aplikacji nie jest przypadkiem zły wybór algo-
rytmu. Przykładowo, sortowanie miliona rekordów metodą sortowania bąbelkowego musi
trwać długo, bowiem jest to algorytm o złożoności 0(N2) i żadna optymalizacja faktu tego
zmienić nie może — w czasie, gdy odbywa się wspomniane sortowanie, można spokojnie
udać się na kawę lub nawet na obiad. Wybór właściwego algorytmu ma dla aplikacji zna-
czenie o wiele ważniejsze niż najbardziej wymyślne techniki optymalizacyjne — dlatego
właśnie rozdział traktujący o optymalizacji nie jest pierwszym (ani nawet jednym z pierw-
szych) rozdziałów niniejszej książki.
Podobnie bezcelowe są próby optymalizowania tych części kodu, które z punktu widzenia
wydajności aplikacji nie są „wąskimi gardłami". Niby jest to oczywiste, a jednak często
spotyka się programistów z uporem optymalizujących kod, który wykonywany jest rzadko,
jednorazowo (na początku) bądź też wcale (bo związany jest z obsługą wyjątków, które
mogą w ogóle nie wystąpić). Optymalizacja takich fragmentów kodu z pewnością nie może
wpłynąć (w zauważalny sposób) na wydajność aplikacji, za to może skutecznie popsuć
czytelność programu i uczynić go trudniejszym w konserwacji.
Gdyby z treści niniejszego rozdziału trzeba było zapamiętać tylko jedno zdanie, prawdopo-
dobnie powinno ono brzmieć następująco: „Nigdy nie zgaduj, dlaczego Twój program jest
mało wydajny — znajdź prawdziwe tego przyczyny za pomocą profilowania". Działania
optymalizacyjne muszą być działaniami celowymi, a nie loterią. Zalecane przez nas podej-
ście do optymalizowania aplikacji streścić można w formie następującego scenariusza:
1. Zmierz wydajność aplikacji za pomocąprofilatora.
2. Zidentyfikuj te fragmenty kodu, które w znaczący sposób przyczyniają się
do obniżenia jego wydajności.
3. Usuń jedną z tych przyczyn — tę (przypuszczalnie) najważniejszą lub jedną
z najważniejszych.
4. Zmierz ponownie wydajność aplikacji.
5. Upewnij się, że dokonane zmiany istotnie wpłynęły na poprawę wydajności
—jeżeli nie, anuluj je.
6. Powtarzaj opisane postępowanie tak długo, aż uznasz uzyskaną wydajność
za akceptowalną bądź też możliwości jej poprawiania zostaną praktycznie
wyczerpane.
Optymalizacja w praktyce
Jak już wspominaliśmy, nasz program wykonuje się znacznie dłużej niż się spodziewali-
śmy, postanowiliśmy więc się przekonać, co zajmuje tak wiele czasu procesora. Na rysunku
19.2 reprodukujemy okno metod profdatora JMP zawierające dane będące odpowiedzią na
to pytanie.
Stąd wniosek, iż powinniśmy coś zrobić z kwestią „odwracania" łańcuchów, jednak nieco
bardziej oczywista wydaje się sprawa listy wiązanej. Zastosowaliśmy listę wiązaną nie
tablicową nie znaliśmy bowiem a priori liczby słów do zapamiętania. Dodawanie nowych
słów na koniec listy wiązanej jest (jak pamiętamy) szybkie i nieskomplikowane, podczas
gdy dodawanie nowych pozycji do listy tablicowej wiązać się może z kosztownymi jej re-
organizacjami. To jednak tylko połowa prawdy: sortowanie, z którym mamy do czynienia
w naszej aplikacji, wiąże się z wyrywkowym dostępem do poszczególnych elementów listy
na podstawie ich indeksów, a pod tym względem tablica spisuje się o niebo lepiej niż lista
wiązana. Z kolumny calls na rysunku 19.2 wyczytać można niezawodnie, że dwie metody
operujące na liście wywoływane są kilkaset tysięcy razy, podczas gdy liczba elementów tej
listy wynosi jedynie 10 tysięcy. Prowadzi to do konkluzji, że dokonaliśmy niewłaściwego
wyboru struktury danych — struktury optymalnej pod kątem 10 tysięcy operacji dodawania
elementów, lecz skrajnie nieoptymalnej pod kątem kilkuset tysięcy operacji uzyskiwania
dostępu do tych elementów. Zmieniamy więc nasze preferencje, zastępując listę wiązaną listą
w implementacji tablicowej.
try {
524 Algorytmy. Od podstaw
String word;
return result;
}
}
Po dokonaniu tej zmiany musimy ponownie skompilować program i poddać go profilowaniu
za pomocą polecenia:
java -Xrunjmp com.wrox.algorithms.sorting.FileSortingHelper <words.txt >sorted.txt
9:41 PM
Jak to działa?
Z okna metod profilatora zniknęła oczywiście pozycja związana z listą wiązaną (rysunek
19.4); ważniejsze jest jednak to, że w „czołówce" tego okna nie też śladu listy tablicowej.
Stanowi to niezaprzeczalnie pewien postęp; choć jednak zdarzają się przypadki, kiedy po-
jedyncza zmiana w kodzie powoduje usunięcie problemu (albo uczynienie go jeszcze bar-
dziej dotkliwym), tym razem nie mamy z niczym takim do czynienia. Zrozumiała staje się
natomiast konieczność ponownego pomiaru wydajności po dokonaniu każdej ze zmian, przez
przystąpieniem do zmian kolejnych.
IShownglCOdassMoutof 1)5
Spójrzmy na kolumnę #GC widocznej w oknie listy: w czasie wykonywania programu au-
tomatycznie zwolnionych zostało niemal 2,5 miliona obiektów w związku z obsługą listy
wejściowej zawierającej jedynie 10 000 słów! Proporcje te mogą wydawać się mocno nie-
naturalne.
Dokładniejsza analiza zawartości okna wykazuje, że znaczącą cześć tych obiektów stano-
wią łańcuchy klasy String. Jest ich około 800 000, a więc mniej więcej tyle samo ile wywołań
metody reverse(). Po krótkim zastanowieniu dochodzimy do ważnego wniosku wiążącego te
dwa fakty: odwracanie zawartości łańcucha (za pomocą metody reverse()) wykonywane jest
przy każdym wywołaniu metody compareO komparatora, co powoduje tworzenie dodatko-
wego łańcucha zwalnianego później automatycznie. Staje się oczywiste, iż kolejny zabieg
optymalizacyjny związany być musi z tą właśnie kwestią.
526 Algorytmy. Od podstaw
Rozwiązanie to rodzi jednak nowy problem, na szczęście niezbyt poważny. Otóż na produ-
kowanej przez program liście wynikowej (kierowanej na standardowe wyjście) posortowane
słowa mają pojawiać się w postaci oryginalnej, a nie odwróconej. Wymaga to wykonania
dodatkowych 10 tysięcy wywołań metody reverse(), co jednak i tak opłaca się wobec faktu,
że zaoszczędziliśmy kilkaset tysięcy tych wywołań. Ostateczne wnioski w tej kwestii pozo-
stawmy jednak do czasu ponownego zmierzenia wydajności przez profilator.
import java.io.BufferedReader;
import java.io.IOException:
import java.io.InputStreamReader:
}
Podobnie jak w przypadku klasy FileSortingHelper, konstruktor klasy OptimizedFileSor-
tingHelper jest konstruktorem prywatnym, co uniemożliwia samodzielne tworzenie jej obiek-
tów przez użytkownika. Klasa dostępna jest wyłącznie jako samodzielny program, którego
wykonanie sprowadza się do wykonania metody mainO:
public static void main(String[] args) throws IOException {
List words - loadWordsO:
reverseAll(words);
System.out.println("Początek sortowania..."):
printAll(sorted);
System.err.printlnCKoniec sortowania.. .Naciśni j CTRL-C, aby zakończyć
program");
try {
Thread.sleep(O);
} catch (InterruptedException e) {
// ignoruj wyjątek
}
j
Metoda mainO deleguje większość swych obowiązków do opisywanych poniżej dwóch in-
nych metod. Zwróćmy uwagę, że po wczytaniu słów ze standardowego wejścia następuje
odwrócenie każdego z nich — zadanie to wykonuje metoda reverseAl 1 (). Lista tak odwró-
conych słów jest następnie sortowana w kolejności naturalnej, czyli wyznaczanej przez
komparator NaturalComparator, który traktuje je jak „normalne" łańcuchy. Po posortowa-
niu listy zawarte w niej słowa odwracane są ponownie i drukowane.
Zmiany dokonane podczas optymalizacji nie mają związku z wczytywaniem słów, metoda
loadWords() pozostaje więc niezmieniona:
private static List loadWordsO throws IOException {
List result = new ArrayListO;
try {
String word;
Metoda reverseAll() przebiega kolejno wszystkie elementy listy, traktując każdy z nich
jako łańcuch, odwracając go i zapisując z powrotem na oryginalną pozycję.
528 Algorytmy. Od podstaw
Jak to działa?
Naszą zoptymalizowaną klasę powinniśmy teraz skompilować i poddać profilowaniu, wy-
dając polecenie:
java -Xrunjmp com.wrox.algorithms.sorting.OptimizedFileSortingHelper <words.txt
>sorted.txt
Szczególnie interesująca okazuje się zawartość okna metod (rysunek 19.7), w którym wy-
raźnie widać, dlaczego udało nam się zaoszczędzić 50 sekund czasu wykonania marnowa-
nych uprzednio na wielokrotne odwracanie łańcuchów.
Spójrzmy jeszcze na okno obiektów (rysunek 19.8), by się przekonać, czy potwierdziły się
nasze przewidywania odnośnie obiektów roboczych podlegających automatycznemu zwal-
nianiu.
< >
|5ho«**j 100 dasses out of 1H
Jak to działa?
Czas wykonania programu skrócił się ze 100 sekund do niespełna dwóch! Takie bywają za-
zwyczaj efekty optymalizowania rzeczywistych aplikacji w języku Java. Nie zapominajmy
o ważnym fakcie, iż jedynym aspektem optymalności, o jakim myśleliśmy w trakcie two-
rzenia kodu aplikacji, był wybór właściwego algorytmu. Dzięki temu udało nam się zacho-
wać czytelność i prostotę kodu, który przez to łatwo poddawał się profilowaniu i optymalizacji.
530 Algorytmy. Od podstaw
Tylko dzięki czytelnej postaci kodu udało nam się wymienić listę elementów (na lepiej do-
stosowaną do losowego dostępu do elementów) oraz wyeliminować komparator Reverse-
StringComparator, a właśnie te elementy okazały się być główną przyczyna słabej wydaj-
ności.
Podsumowanie
Czytając niniejszy rozdział miałeś okazję się dowiedzieć, że:
• Optymalizacja jest istotnym aspektem tworzenia oprogramowania, jednakże mniej
istotnym niż zrozumienie użytych algorytmów.
• Profilowanie jest techniką zbierania ilościowych informacji na temat różnych
aspektów zachowania się wykonywanego kodu.
• Wirtualna maszyna Javy (JVM) zapewnia wsparcie dla profilowania aplikacji
dzięki standardowo wbudowanym mechanizmom tego rodzaju.
• Profilator Java Memory Profiler (JMP) udostępnia rozmaite informacje na temat
wykorzystywania pamięci przez profilowaną aplikację w czytelnej postaci
graficznej, umożliwiając szybką identyfikację obszarów mogących sprawiać
problemy z wydajnością.
• Dzięki metodycznemu podejściu do optymalizacji udało nam się 50-krotnie
przyspieszyć wykonywanie przykładowej aplikacji przez zidentyfikowanie
i usunięcie jedynie dwóch jej „wąskich gardeł".
A
Zalecana literatura uzupełniająca
Mamy nadzieję, że lektura niniejszej książki stanowić będzie dla Czytelników inspirację do
dalszych poszukiwań w interesującym świecie algorytmów. Spodziewamy się także, iż do-
cenią oni korzyści płynące z klarownego i czytelnego projektowania oraz programowania
sterowanego testami. Spośród rozlicznych pozycji poświęconych algorytmice chcielibyśmy
polecić tych kilka niżej wymienionych. Zwracamy także uwagę, iż wiele interesujących in-
formacji znaleźć można w internecie, po wpisaniu w dowolnej wyszukiwarce żądanego
słowa kluczowego.
David Astels, Test-Driven Development: A Practical Guide, Prentice Hall PTR, 2003.
Wikipedia: http://pl.wikipedia.org
|Bloch 2001] Joshua Bloch, Effective Bloch. Effective Java, Addison-Wesley, 2001. Tłu-
maczenie polskie: Efektywne programowanie w języku Java, Helion, 2002.
[Crispin, 2002] Lisa Crispin i Tip House, Testing Extreme Programming, Addison-Wesley,
2002.
[Gamma 1995) Erich Gamma, Richard Heim, Ralph Johnson u John Vlissides, Wzorce
projektowe, Wydawnictwo Naukowo- Techniczne, Warszawa, 2005.
[Hunt, 2000) Andy Hunt i Dave Thomas, The Pragmatic Programmer, Addison-Wesley, 2000.
|Massol, 2004] Yincent Massol i Ted Husted, JUnit in Action, Manning, 2004.
536 Algorytmy. Od podstaw
Rozdział 2.
Ćwiczenia
1. Skonstruuj iterator filtrujący udostępniający tylko co n-ty element spośród
elementów zwracanych przez iterator oryginalny.
6. Skonstruuj iterator pusty — czyli taki, który zawsze znajduje się w stanie
wyczerpanym.
538 Algorytmy. Od podstaw
Rozwiązania
2.
package com.wrox.algorithms.iteration:
J e f t - left:
_right - right;
}
public boolean evaluate(Object object) {
return _left.evaluate(object) && _right.evaluate(object);
}
]
3.
package com,wrox.algorithms.iteration;
public finał class RecursivePowerCalculator implements PowerCalculator {
/** pojedyncza, publicznie dostępna instancja komparatora */
public static finał RecursivePowerCalculator INSTANCE
= new RecursivePowerCalculator();
private RecursivePowerCalculator() {
}
4.
package com.wrox.algorithms.iteration;
import java.io.File:
private RecursiveDirectoryTreePrinter() {
}
540 Algorytmy. Od podstaw
if (args.length != 1) {
System.err.println("Wywołanie: RecursiveDi rectoryTreePrinter
<katalog>");
System.exit(-l);
}
System.out.printlnORekursywne drukowanie drzewa katalogów: " +
args[01):
printtnew File(args[0]). "");
System.out.print(indent);
System.out.pri nt1n(fi 1e.getName());
if (file.isDirectoryO) {
print(new Arraylterator(file.listFilesO), indent + SPACES);
}
}
_]
5.
6.
package com.wrox.algori thms.i terati on:
private EmptylteratorO {
// tu nie ma nic do roboty
}
public void firstO {
// tu nie ma nic do roboty
}
public void lastO {
// tu nie ma nic do roboty
}
public boolean isDoneO {
II iterator jest zawsze wyczerpany!
return true:
}
public void next() {
// tu nie ma nic do roboty
}
public void previous() {
// tu nie ma nic do roboty
}
public Object currentO throws IteratorOutOfBoundsException {
throw new IteratorOutOfBoundsException();
}
}
542 Algorytmy. Od podstaw
Rozdział 3.
Ćwiczenia
1. Stwórz konstruktor klasy ArrayLi st zapełniający tworzoną listę elementami
zawartymi w tablicy podanej jako parametr wywołania.
2. Napisz uniwersalną metodę equals() prawdziwą dla dowolnej implementacji
interfejsu List.
3. Napisz metodę toString() prawdziwą dla dowolnej implementacji listy,
przekształcającą listę w łańcuch, w którym wartości elementów rozdzielone są
przecinkami, a całość zamknięta jest w nawias prostokątny. Przykładowo,
dla trójelementowej listy zawierającej elementy A, B i C wspomniany łańcuch
powinien mieć postać „[A, B. C]", zaś dla listy pustej — postać „[]".
4. Stwórz iterator uniwersalny dla dowolnej implementacji interfejsu List.
Jakie są efektywnościowe implikacje jego uniwersalności?
5. Zmodyfikuj implementację wyszukiwania w liście wiązanej elementu o wskazanym
indeksie w taki sposób, by w sytuacji, gdy element znajduje się „w drugiej połowie"
listy, zliczanie elementów prowadzone było od jej końca, a nie od początku.
6. Napisz uniwersalną metodę indexOf() prawdziwą dla dowolnej implementacji
interfejsu Li St.
Rozwiązania
1.
2.
3.
public String toStringO {
StringBuffer buffer = new StringBuffer();
buffer.append('[');
if (!isEmptyO) {
Iterator i = iteratort);
for (i.firstO: !i.isDoneO: i.next()) {
buffer.append(i.current()).append(", "):
}
buffer.setLength(buffer.length()-2);
}
buffer.appendO] '):
return buffer.toString();
}
4.
package com.wrox.a1gori thms.1 i sts;
import com.wrox.algorithms.iteration.Iterator;
import com.wrox.algorithms.iteration.IteratorOutOfBoundsException;
5.
6.
public int indexOf(Object value) {
assert value != nuli : "nie określono wartości";
int index = 0;
Iterator i = iteratorO;
7.
private EmptyListO {
}
Rozdziali
Ćwiczenia
1. Zaimplementuj wątkowo bezpieczną kolejkę niepowodującą oczekiwania.
W niektórych zastosowaniach wielowątkowych użyteczne są bowiem kolejki
nieblokujące.
2. Zaimplementuj kolejkę, z której elementy pobierane są w kolejności losowej.
Kolejka taka może być użyteczna w przypadku konieczności losowego wyboru
elementów do przetworzenia lub w innych zastosowaniach związanych
z „tasowaniem" danych.
Rozwiązania
i.
2.
package com.wrox.algori thms.queues;
public RandomListQueue() {
this(new Li nkedLi st()):
}
}
public Object dequeue() throws EmptyQueueException {
if (isEmptyO) {
throw new EmptyQueueException();
}
return list.delete((int) (Math.randomO * sizeO)):
Rozdział 6.
Ćwiczenia
1. Stwórz zestawy testowe weryfikujące poprawność sortowania — przez każdy
z algorytmów — losowo wygenerowanej listy obiektów typu double.
2. Stwórz zestawy testowe udowadniające, że sortowanie bąbelkowe i sortowanie
przez wstawianie (w implementacjach prezentowanych w niniejszym rozdziale)
są stabilnymi metodami sortowania.
3. Skonstruuj komparator wyznaczający alfabetyczną kolejność łańcuchów,
bez rozróżniania małych i wielkich liter.
4. Napisz program-sterownik zliczający liczbę przestawień obiektów w ramach
każdego z opisywanych w rozdziale algorytmów sortowania.
Rozwiązania
i.
2.
package com,wrox.algorithms.sorting;
3.
package com.wrox.algori thms.sorti ng;
4.
package com.wrox.algori thms.sorti ng:
reportCallsO:
}
public void testWorstCaseInsertionSort() {
new InsertionSortListSorter(_comparator),sort(_reverseArrayList):
reportCallsO;
}
public void testWorstCaseShellsortO {
new Shel1sortListSorter(_comparator).sort(_reverseArrayLi st);
reportCallsO;
}
public void testWorstCaseOuicksortO {
new QuicksortListSorter(_comparator),sort(_reverseArrayList);
reportCallsO;
}
public void testWorstCaseMergesortO {
new MergesortListSorter(_comparator),sort(_reverseArrayList);
reportCallsO;
}
public void testBestCaseBubblesort() {
new BubblesortListSorter(_comparator).sort(_sortedArrayList);
reportCallsO;
}
public void testBestCaseSelectionSort() {
new SelectionSortListSorter(_comparator),sort(_sortedArrayList);
reportCallsO:
}
public void testBestCaselnsertionSortO {
new InsertionSortListSorter(_comparator),sort(_sortedArrayList):
reportCallsO:
}
public void testBestCaseShellsort() {
new ShellsortListSorter(_comparator).sort(_sortedArrayList);
reportCallsO:
}
public void testBestCaseQuicksort() {
new QuicksortListSorter(_comparator),sort(_sortedArrayList);
reportCallsO;
}
public void testBestCaseMergesort() {
new MergesortListSorter(_comparator),sort(_sortedArrayList);
reportCallsO;
}
public void testAverageCaseBubblesort() {
new BubblesortLi stSorter(_comparator).sort(_randomArrayLi st);
reportCallsO;
}
Dodatek D • Odpowiedzi do ćwiczeń 553
Rozdział 7.
Ćwiczenia
t. Zaimplementuj iteracyjną wersję sortowania przez łączenie.
2. Zaimplementuj iteracyjną wersję sortowania szybkiego.
3. Policz liczbę operacji listowych — set(), add() i insert() — wykonywanych
przez algorytmy Quicksort i Shellsort.
Rozwiązania
}
return (List) remaining.get(O):
}
private List mergeSublistPairs(List remaining) {
List result = new ArrayList(remaining.size() / 2 + 1 ) :
Iterator i - remaining.iteratorO:
i.firstO;
while (!i.isDoneO) {
List left = (List) i.currentO;
i,next():
if (i.isDoneO) {
result.add(left):
} else {
List right = (List) i.currentO:
i.next();
result.add(merge(left. right)):
}
}
return result;
}
private List createSublists(List list) {
List result = new ArrayListO ist. sizeO);
Iterator i - list.iteratorO;
Dodatek D • Odpowiedzi do ćwiczeń 555
i.firstO:
while (! i .isDoneO) {
List singletonList = new ArrayListO):
singletonList.addO .currentO);
result.add(singletonList):
i,next():
}
return result:
}
private List merge(List left. List right) {
List result = new ArrayListO:
1 firstO:
r.firstO:
quicksort(list);
return list;
}
private void quicksort(List list) {
Stack jobStack = new ListStackO;
while (!jobStack.isEmptyO) {
Rangę rangę - (Rangę) jobStack. pop O ;
if (rangę.sizeO <= 1) {
continue;
}
int startlndex = rangę.getStartIndex():
int endlndex - rangę.getEndIndex();
3.
}
package com.wrox.algorithms.sorti ng;
4.
package com.wrox.a1gori thms.sorti ng;
5.
package com.wrox.algorithms.sorti ng;
return list:
}
private void quicksort(List list. int startlndex, int endlndex) {
if (startlndex < 0 || endlndex >= list.sizeO) {
return:
}
if (endlndex <= startlndex) {
return;
}
if (endlndex - startlndex < THRESHOLD) {
doInsertionSortdist. startlndex. endlndex);
} else {
doQuicksort(list. startlndex. endlndex):
}
}
private void doInsertionSort(List list. int startlndex, int endlndex) {
for (int i = startlndex + 1; i <= endlndex; ++i) {
Object value = list.get(i):
int j;
for (j = i: j > startlndex: --j) {
Object previousValue = list,get(j - 1):
if (_comparator.compare(value. previousValue) >= 0) {
break;
}
list.settj, previousValue);
}
list.settj. value):
}
}
private void doQuicksort(List list, int startlndex. int endlndex) {
Object value = list.get(endlndex);
Rozdział 8.
ćwiczenia
1. Zaimplementuj stos jako kolejkę priorytetową.
2. Zaimplementuj kolejkę FIFO jako kolejkę priorytetową.
3. Zaimplementuj interfejs ListSorter w postaci kolejki priorytetowej.
4. Zaprojektuj kolejkę udostępniającą najmniejszy element zamiast największego.
Dodatek D • Odpowiedzi do ćwiczeń 563
Rozwiązania
i.
package com.wrox.algorithms.stacks;
public PriorityOueueStackO {
Super(COMPARATOR);
}
public void enqueue(Object value) {
super.enqueue(new StackItem(++_count. value));
}
public Object dequeue() throws EmptyQueueException {
return ((Stackltem) super.dequeue()).getValue();
}
public void push(Object value) {
enqueue(value);
}
public Object p o p O throws EmptyStackException {
try {
return dequeue();
} catch (EmptyQueueException e) {
throw new EmptyStackException();
}
}
public Object peekO throws EmptyStackException {
Object result = p o p O ;
push(result);
return result;
}
private static finał class Stackltem {
private finał long _key;
private finał Object _value;
public Stackltemdong key, Object value) {
_key = key;
_value = value;
}
564 Algorytmy. Od podstaw
2.
package com.wrox.a1gori thms.queues:
import com.wrox.algorithms.sorting.Comparator;
public PriorityQueueFifoQueue() {
super(COMPARATOR);
}
public void enqueue(Object value) {
super.enqueue(new Queueltem(--_count. value));
}
public Object dequeue() throws EmptyQueueException {
return ((Oueueltem) super.dequeue()),getValue():
}
private static finał class Oueueltem {
private finał long _key:
private finał Object _value:
Iterator i - list.iteratorO;
i. firstO;
while (!i.isDoneO) {
queue.enqueue(i.current());
i.next():
566 Algorytmy. Od podstaw
return queue;
}
}
4.
package com.wrox.algorithms,queues;
RozdziaMO.
Ćwiczenia
1. Napisz metodę minimum() w postaci rekurencyjnej.
2. Napisz metodę maximum() w postaci rekurencyjnej.
3. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności in-order.
4. Napisz iteracyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności in-order.
5. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności pre-order.
6. Napisz rekurencyjną metodę drukującą wartości drzewa (począwszy od korzenia)
w kolejności post-order.
7. Napisz metodę wstawiającą elementy posortowanej listy do drzewa binarnego w taki
sposób, by dodatkowe zabiegi przywracające wyważenie drzewa nie były potrzebne.
Rozwiązania
i.
2.
public Node search(Object value) {
return search(value. _root);
}
private Node search(Object value, Node node) {
if (node != nuli) {
return nuli:
}
int cmp = _comparator.compare(value. node.getValue()):
if (cmp == 0) {
return node;
}
return search(value. cmp < 0 ? node.getSmallerO : node.getLarger());
}
3.
4.
5.
6.
public void postOrderPrint(Node node) {
if (node == nuli) {
return;
}
postOrderPri nt(node.getSma11 er());
postOrderPri nt (node. getLargerO);
System, out. pri nt 1 n(node. getVal u e O ) ;
}
7.
public void preOrderlnserttBinarySearchTree tree. List list) {
preOrderlnsertttree. list. 0, list.sizeO - 1);
}
private void preOrderInsert(BinarySearchTree tree. List list,
int lowerIndex, int upperlndex) {
if lowerIndex > upperlndex {
return;
}
int index = lowerIndex + (upperlndex - lowerIndex) / 2;
Rozdziału.
Ćwiczenia
1. Zmodyfikuj klasę BucketingHashtable tak, by liczba porcji była zawsze liczbą
pierwszą. Czy i jaki ma to wpływ na efektywność?
2. Zmodyfikuj klasę LinearProbingHashTable tak, by liczba zapamiętanych wartości
śledzona była na bieżąco, a nie była obliczana przy każdorazowym wywołaniu
metody sizeO.
3. Zmodyfikuj klasę BucketingHashtable tak, by liczba zapamiętanych wartości
śledzona była na bieżąco, a nie była obliczana przy każdorazowym wywołaniu
metody size().
4. Skonstruuj iterator zapewniający dostęp do wszystkich pozycji zapamiętanych
w porcjowanej tablicy haszowanej (BucketingHashtable).
Dodatek D • Odpowiedzi do ćwiczeń 569
Rozwiązania
i.
package com.wrox.algorithms.hashing;
private SimplePrimeNumberGenerator() {
}
while (MsPrime(prime)) {
++prime;
}
return prime;
}
return true;
}
}
package com.wrox.algorithms.hashing;
i mport com.wrox.a 1gori thms.i terat i on.Iterator;
i mport com.wrox.a1gori thms.1 i sts.L i nkedLi st;
i mport com.wrox.a1gor i thms.1 i sts.L i st;
Joadfactor = loadFactor;
_buckets = new Bucket(
SimplePrimeNumberGenerator.INSTANCE.generate(initialCapacity);
}
}
570 Algorytmy. Od podstaw
2.
package com.wrox.algorithms.hashing:
if (_values[index] == nuli) {
_values[index] - value:
++_size;
}
}
public int sizeO {
return size;
3.
package com.wrox.algorithms.hashi ng;
if (!bucket.contains(value)) {
bucket.add(value);
++_s i ze;
maintainLoad();
}
}
public int s i z e O {
return size;
Dodatek D • Odpowiedzi do ćwiczeń 571
4.
package com.wrox.algori thms.hashi ng;
RozdziaM2.
Ćwiczenia
1. Napisz metodę badającą, czy dwa podane zbiory są równe.
2. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący ich
sumę (unię).
3. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący ich
iloczyn (przecięcie).
4. Napisz metodę otrzymującą dwa zbiory i zwracającą trzeci zbiór stanowiący
różnicę pierwszego i drugiego.
5. Zmodyfikuj metodę delete() klasy HashSet w ten sposób, by po usunięciu
jedynego elementu w porcji usuwana była cała porcja.
6. Stwórz implementację zbioru bazującą na posortowanej liście.
7. Stwórz implementację zbioru „zawsze pustego" —jakakolwiek próba modyfikacji jego
zawartości powinna powodować wystąpienie wyjątku UnsupportedOperati onexception.
Rozwiązania
i.
iterator i = a.iteratorO;
for (i.firstO; !i.isDoneO; i.next()) {
if (!b.contains(i,current())) {
return false;
}
}
return a.sizeO — b.sizeO;
}
Dodatek D • Odpowiedzi do ćwiczeń 573
2.
public Set union(Set a. Set b) {
assert a != nuli: "nie określono lewego argumentu":
assert b !- nuli: "nie określono prawego argumentu":
Iterator i = a.iteratorO;
for (i .firstO; !i .isDoneO; i,next()) {
result.add(i.current);
}
Iterator j = b.iteratorO:
for (j.firstO; !j.isDoneO; j.next()) {
result.add(j.current);
}
return result:
}
3.
public Set intersection(Set a. Set b) {
assert a != nuli: "nie określono lewego argumentu";
assert b != nuli: "nie określono prawego argumentu";
Iterator i = a.iteratorO;
for (i.firstO: !i.isDoneO: i n e x t O ) {
if (b.containsO .currentO) {
result. add (i .currentO)
i
}
)
return result;
}
4.
public Set difference(Set a. Set b) {
assert a != nuli: "nie określono lewego argumentu":
assert b != nuli: "nie określono prawego argumentu":
Iterator i - a.iteratorO;
for (i. firstO: ! i. i sDoneO: i.next()) {
if (!b.contains(i .currentO)) {
result,add(i.current()):
return result:
1
574 Algorytmy. Od podstaw
5.
public boolean delete(Object value) {
int bucketlndex = bucketIndexFor(va1ue);
ListSet bucket = _buckets[bucketIndex];
if (bucket != nuli && bucket.delete(value)) {
--_size;
if (bucket.isEmptyO) {
_buckets[bucketIndex] = nuli;
}
return true;
}
return false;
J
6.
package com.wrox.a1gori thms.sets;
public SortedListSetO {
this(NaturalComparator.INSTANCE);
}
7.
package com.wrox.algori thms.sets;
private EmptySetO {
}
Rozdzial13.
Ćwiczenia
1. Stwórz iterator udostępniający wyłącznie klucze obecne w mapie.
2. Stwórz iterator udostępniający wyłącznie wartości obecne w mapie.
3. Stwórz implementację zbioru wykorzystującą mapę jako medium przechowujące
wartości.
i . Stwórz mapę „zawsze pustą" powodującą wystąpienie wyjątku
UnsupportedOperationException w przypadku jakiejkolwiek próby jej modyfikacji.
Rozwiązania
2.
pack age com.wrox.a 1gor i thms.ma ps;
3.
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.a1gori thms.sets.Set;
4.
package com.wrox.a 1gori thms.maps;
private EmptyMapO {
}
Rozdział 14.
Ćwiczenie
1. Napisz metodę searchO w wersji iteracyjnej.
580 Algorytmy. Od podstaw
Rozwiązanie
i.
RozdziaM5.
Ćwiczenie
1. Napisz metodę traverse() w wersji zwracającej pozycje w kolejności rosnących
kluczy.
Rozwiązanie
1.
RozdziaM8.
Ćwiczenia
1. Zaimplementuj „siłowe" rozwiązanie problemu poszukiwania pary najbliższych
punktów.
2. Zoptymalizuj implementację algorytmu „zamiatania" płaszczyzny tak,
by ignorowane były także punkty zbyt oddalone w pionie od punktu odniesienia.
Rozwiązania
package com.wrox.algorithms.geometry;
if (points.sizeO < 2) {
return nuli:
}
List list = sortPoints(points):
582 Algorytmy. Od podstaw
Point p = nuli;
Point q = nul 1;
double distance = Double.MAX_VALUE:
Iterator i = points.iteratorO;
for (i.firstO; !i.isDoneO; i.next()) {
INSERTER.insertdist. i .currentO);
}
return list:
}
private Set createPointPair(Point p. Point q) {
Set result = new ListSetO;
result.add(p);
result.add(q);
return result;
2.
package com.wrox.algorithms.geometry;
private PlaneSweepOptimizedClosestPairFinderO {
}
if (points.sizeO < 2) {
return nul 1:
}
List sortedPoints = sortPoints(points);
Iterator i = points.iteratorO;
for (i.firstO: ii.isDoneO; i.next()) {
584 Algorytmy. Od podstaw
INSERTER.insert(list. i .currentO);
}
return list;
}
private Set createPointPair(Point p. Point q) {
Set result = new ListSetO;
result.add(p);
result.add(q);
return result;
Skorowidz
JieadAndTail, 97 wyszukiwanie łańcuchów, 433
_next, 98 zamiatanie płaszczyzny, 500
_previous, 98 złożoność, 26
arraycopy(), 94
A ArrayIndexOutOfBoundsException, 88, 94
Arraylterator, 53, 103
AbstractClosestPairFinderTest, 504 ArrayList, 88, 89, 138, 523
AbstractFifoQueueTestCase, 110 ArrayListTest, 87
AbstractHashtableTest, 312, 313, 317, 321 arytmetyczny wzrost złożoności obliczeniowej, 31
AbstractListSearcherTest, 239, 241, 242 asercje, 19, 33
AbstractListSorterTest, 161, 171 assertDistance(), 472
AbstractListTestCase, 87, 88, 96, 141, 143 assertEquals(), 37, 398, 463
AbstractMapTest, 362, 371, 420 assertPatternEquals(), 399
AbstractPriorityOueueTest, 215, 220 assertPrefixEquals(), 399
AbstractSetTest, 337, 339 assertSame(), 80
AbstractStackTestCase, 133 assertTrue(), 37
AbstractStringSearcherTest, 435,439, 443 associative array, 358
add(), 73, 77, 90, 99, 326, 334 attachBefore(), 98
addComparator(), 197 automatyczne odśmiecanie, 513
algorytm, 23 average case, 230
Boyera-Moore'a, 441 AVL, 269, 273, 275
CRC, 307
deterministyczny, 25
formalny zapis, 24
B
haszowanie, 307 Bxdrzewa, 414
heurystyczny, 25 B+drzewa, 414
Insertionsort, 170 bad character heuristic, 442
Mergesort, 199 balanced tree, 264
odległość Levenshteina, 470 balancing, 273
pseudokod, 24 base case, 68
rekurencja, 41, 68 B-drzewa, 269,413
siłowy, 438 BTreeMap, 420, 424
skalowainość, 26 BTreeMapTest, 419
sortowanie, 151, 175 clear(), 430
sortowanie bąbelkowe, 159 contains(), 429
sortowanie metodą Shella, 183 delete(), 430
sortowanie przez łączenie, 198 Entry, 425
sortowanie przez wstawianie, 170 get(), 429
sortowanie przez wybieranie, 165 implementacja mapy, 420
sortowanie szybkie, 189 indexOf(), 426
Soundex, 467 isLeaf(), 426
stabilny, 173 iterowanie, 428
wybór, 24 klucze, 414
wydajność, 26 komparator wyznaczający porządek kluczy, 424
586 Algorytmy. Od podstaw
interfejs ReverseIterator, 53
Comparator, 154 standardowe, 48
Hashtable, 312 tablice haszowane, 348
Heap, 223 tablicowy, 49
Iterable, 47 testowanie, 49, 53
iteratory, 46 while, 47
List, 73, 94 zbiory haszowane, 348
ListSearcher, 238, 239
ListSorter, 161
Map, 359 J
Map.Entry, 360
operacje dodatkowe, 17 Java, 14
operacje podstawowe, 17 Java Development Kit, 19
PhoneticEncoder, 465 Java Memory Profiler, 520
Queue, 107 java.lang, 14
Set, 336 JDK, 19
Stack, 137 jednostki testowe, 33
StringSearcher, 434 język Java, 14
UndoAction, 148 JMP, 520
intersectionPoint(), 494, 497, 499 interfejs, 521
inżynieria tworzenia oprogramowania, 25 okno główne, 521
isDone(), 45, 52,61, 85,449 okno obiektów, 521
isEmptyO, 72, 73, 78, 95, 103, 106, 116, 132, 216, JUnit, 18, 20,35
assertEquals(), 37
334, 359 assertTrueO, 37
isEndOfWord(), 404 klasy testowane, 36
isLarger(), 287 RandomListQueueTest, 36, 37
isParalellToO, 496 SetUpO, 36
isSmallerO, 287
środowiska projektowe, 38
isWithin(), 497
tearDown(), 36
Iterable, 47, 74, 83
TestCase, 35, 36
iteracja, 24, 41
uruchamiacze, 38
iteracyjna wyszukiwarka binarna, 244 junit.framework.TestCase, 35
createSearcherO, 245 JVM, 513
implementacja, 244
IterativeBinaryListSearcher, 245
IterativeBinaryListSearcherTest, 244 K
testowanie, 244
IterativeBinaryListSearcher, 245 kalkulator, 43, 140
ItcrativeBinaryListSearcherTest, 244 odległość Levenshteina, 471
Iterator, 46 katalogi, 62
iteratorO, 47, 73, 74, 94, 103, 334, 336, 359 KISS, 16
IteratorOutOfBoundsException, 47, 50 klasy predykatowe, 56
iteratory, 45 klucz, 357
Arraylterator, 53 kod
dopasowywanie wzorca, 447 fonetyczny, 457
Filterlterator, 61 Soundex, 458
filtrujący, 56 koder Soundex, 461
for, 47 kodowanie
idiomy, 47 asertywne, 18
implementacja, 50, 55 fonetyczne, 457
interfejs, 46 mieszające, 305
Iterable, 47 kolejki, 105
odwracające, 53 centrum obsługi, 117
operacje, 45 clear(), 106
predykator, 56 czoło, 105
Skorowidz 591
stos
ListStack, 137 T
odłożenie elementu, 132 tablice, 44
operacje, 132 listy, 87
peek(), 132, 135, 138 przeglądowe, 444
położenie elementu, 132 skojarzeniowe, 358
pop(), 132, 134, 138 tablice haszowane, 305
push(), 132, 134 AbstractHashtableTest, 312, 321
size(), 132 add(), 312, 319
Stack, 137 BucketingHashtable, 321
testowanie peek(), 135 contains(), 312, 314, 320
testowanie pop(), 134 Hashtable, 312
testowanie push(), 134 HashtableCallCountingTest, 328
testy, 133 interfejs, 312
umieszczanie elementu, 137 LinearProbingHashtable, 315,317
wierzchołek, 131 LinearProbingHashtableTest, 317
wieże Hanoi, 140 ocena efektywności, 326
zastosowanie, 140 porcjowanie, 321
zdejmowanie elementu, 138 próbkowanie liniowe, 314
zdjęcie elementu, 132 resize(), 318
stóg, 222 size(), 312
startingIndexFor(), 320
kolejka priorytetowa, 222 testowanie, 312
korzeń, 223 zestaw pomiarowy, 326
numerowanie elementów, 223 TDD, 18
rozwinięcie w listę, 223 tearDownO, 36, 216
warunek stogowy, 222 tearing down, 33
wynurzanie elementu, 224 ternarne drzewa wyszukiwawcze, 385
zatapianie elementu, 224, 225 add(), 406
string searching, 433 assertPatternEquaIs(), 399
StringMatch, 434 contains(), 405
StringMatchlterator, 448 CrosswordHelper, 410
StringSearcher, 434, 440, 453 dopasowywanie wzorca, 392
search(), 435 implementacja, 400
strony, 414 inOrderTraversal(), 407
struktury MRU, 131 insert(), 406, 407
substitutionCostO, 475 isEndOfWord(), 404
successorO, 288 Node, 403
suma zbiorów, 335 patternMatch(), 408
swap(), 165, 169, 194,227 poszukiwanie prefiksu, 391
swim(), 227, 229 prefixSearch(), 407
symulacja centrum zdalnej obsługi, 117 realizacja, 395
synchronizacja rozwiązywanie krzyżówek, 409
dostęp do danych, 113 TernarySearchTree, 400, 403
wątki, 114 TernarySearchTreeTest, 396, 397
testContains(), 398
synchronized, 115
testowanie, 395
synchronizujący muteks, 115
testPatternMatch(), 399
system plików, 62
testPrefixSearch(), 399
trawersacja in-order, 391
Ś węzły, 403
węzły kończące słowa, 404
środowisko IDE, 20 wstawianie słowa, 389
wyszukiwanie słowa, 386
zastosowanie, 409
Skorowidz 599