Download as pdf or txt
Download as pdf or txt
You are on page 1of 602

P R O G R A M M E R T O P R O G R A M M E R

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

Simon Harris, James Ross

A
wrox I HHK .KAMMJK Helion
http://helion.pl
Tytuł oryginału: Beginning Algorithms

Tłumaczenie: Andrzej Grażyński

ISBN: 83-246-0372-7

Copyright © 2006 by Wiley Publishing, Inc.


Ali Rights Reserved. This translation published under license.

Translation copyright © 2006 by Wydawnictwo Helion.

Polish language edition published by Wydawnictwo Helion.


Copyright © 2006

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.

Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości


lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione.
Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie
książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie
praw autorskich niniejszej publikacji.

Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi


bądź towarowymi ich właścicieli.

The Wrox Brand trade dress is a trademark of Wiley Publishing, Inc.


in the United States and/or other countries. Used by permission.
The Wrox Brand jest zastrzeżonym znakiem towarowym Wiley Publishing, Inc.
na terenie Stanów Zjednoczonych i innych krajów. Wyszkorzystano za zgodą
właściciela.

Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte


w tej książce informacje były kompletne i rzetelne. Nie biorąjednak żadnej
odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne
naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION
nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe
z wykorzystania informacji zawartych w książce.

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)

Pliki z przykładami omawianymi w książce można znaleźć pod adresem:


ftp://ftp.helion.pl/przyklady/algpo.zip

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ł 2. Iteracja i rekurencja Al


Wykonywanie obliczeń 42
Przetwarzanie tablic 44
Iteratory jako uogólnienie przetwarzania tablicowego 45
Rekurencja 62
Przykład — rekurencyjne drukowanie drzewa katalogów 64
Anatomia algorytmu rekurencyjnego 68
Podsumowanie 69
Ćwiczenia 70
4 Algorytmy. Od podstaw

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

Kolejka FIFO 107

Implementowanie kolejki FIFO m


Kolejki blokujące 113
Przykład — symulacja centrum obsługi H7
Uruchomienie aplikacji I27
Podsumowanie 128
Ćwiczenia 129

Rozdział 5. Stosy 131

Czym są stosy? 131


Testy 133
Implementacja 136
Przykład — implementacja operacji „Cofnij/Powtórz" 140
Testowanie cofania/powtarzania 141
Podsumowanie 149

Rozdział 6. S o r t o w a n i e — p r o s t e algorytmy 151


Znaczenie sortowania 151
Podstawy sortowania 152
Komparatory 153
Operacje komparatora 153
Interfejs komparatora 154
Niektóre komparatory standardowe 154
Sortowanie bąbelkowe 159
Interfejs ListSorter 161
Abstrakcyjna klasa testowa dla sortowania list 161
Sortowanie przez wybieranie 165
Sortowanie przez wstawianie 170
Stabilność sortowania 173
Porównanie prostych algorytmów sortowania 175
CallCountingListComparator 176
ListSorterCallCountingTest 177
Jak interpretować wyniki tej analizy? 180
Podsumowanie 180
Ćwiczenia 181
Spis treści 5

Rozdział 7. Sortowanie zaawansowane - 183


Sortowanie metodą Shella 183
Sortowanie szybkie 189
Komparator złożony i jego rola w zachowaniu stabilności sortowania 195
Sortowanie przez łączenie 198
Łączenie list 198
Algorytm Mergesort 199
Porównanie zaawansowanych algorytmów sortowania 205
Podsumowanie 208
Ćwiczenia 209

Rozdział 8. Kolejki priorytetowe 211


Kolejki priorytetowe 212
Prosty przykład kolejki priorytetowej 212
Wykorzystywanie kolejek priorytetowych 215
Kolejka priorytetowa oparta na liście nieposortowanej 218
Kolejka priorytetowa wykorzystująca listę posortowaną 220
Kolejki priorytetowe o organizacji stogowej 222
Porównanie implementacji kolejek priorytetowych 229
Podsumowanie 233
Ćwiczenia 233

Rozdział 9. Binarne wyszukiwanie i wstawianie 235


Wyszukiwanie binarne 235
Dwa sposoby realizacji wyszukiwania binarnego 238
Interfejs wyszukiwania binarnego 238
Iteracyjna wyszukiwarka binarna 244
Ocena działania wyszukiwarek 247
Wstawianie binarne 253
Inserter binarny 254
Porównywanie wydajności 257
Podsumowanie 261

Rozdział 10. Binarne drzewa w y s z u k i w a w c z e 263


Binarne drzewa wyszukiwawcze 264
Minimum 265
Maksimum 265
Następnik 265
Poprzednik 266
Szukanie 266
Wstawianie 268
Usuwanie 269
Trawersacja in-order 272
Trawersacja pre-order 272
Trawersacja post-order 273
Wyważanie drzewa 273
Testowanie i implementowanie binarnych drzew wyszukiwawczych 275
Ocena efektywności binarnego drzewa wyszukiwawczego 299
Podsumowanie 302
Ćwiczenia 303
6 Algorytmy. Od podstaw

Rozdziału. Haszo wanie 305


Podstawy haszowania 305
Praktyczna realizacja haszowania 311
Próbkowanie liniowe 314
Porcjowanie 321
Ocena efektywności tablic haszowanych 326
Podsumowanie 332
Ćwiczenia 332

Rozdział 12. Zbiory 333


Podstawowe cechy zbiorów 333
Testowanie implementacji zbiorów 336
Zbiór w implementacji listowej 342
Zbiór haszowany 344
Zbiór w implementacji drzewiastej 349
Podsumowanie 356
Ćwiczenia 356

RozdziaH3. Mapy 357


Koncepcja i zastosowanie map 357
Testowanie implementacji map 362
Mapa w implementacji listowej 369
Mapa w implementacji haszowanej 373
Mapa w implementacji drzewiastej 377
Podsumowanie 384
Ćwiczenia 384

Rozdział 14. Ternarne d r z e w a w y s z u k i w a w c z e 385


Co to jest drzewo ternarne? 385
Wyszukiwanie słowa 386
Wstawianie słowa 389
Poszukiwanie prefiksu 391
Dopasowywanie wzorca 392
Drzewa ternarne w praktyce 395
Przykład zastosowania — rozwiązywanie krzyżówek 409
Podsumowanie 412
Ćwiczenie 412

Rozdział 15. B-drzewa 413


Podstawowe własności B-drzew 413
Praktyczne wykorzystywanie B-drzew 419
Podsumowanie 431
Ćwiczenie 431

Rozdział 16. Wyszukiwanie tekstu 433


Interfejs wyszukiwarki łańcuchów 433
Zestaw testowy dla wyszukiwarki łańcuchów 435
Prymitywny algorytm wyszukiwania 438
Algorytm Boyera-Moore'a 441
Tworzenie testów dla algorytmu Boyera-Moore'a 443
Implementowanie algorytmu Boyera-Moore'a 444
Spis treści 7

Iterator dopasowywania wzorca 447

Porównanie efektywności wyszukiwania 449

Pomiar efektywności 450

Wyniki eksperymentu 454

Podsumowanie 454

Rozdział 17. Dopasowywanie łańcuchów 457


457

Odległość Levenshteina dwóch słów 468


Podsumowanie 477

RozdziaM8. Geometria obliczeniowa 479


Podstawowe pojęcia geometryczne 479

Współrzędne i punkty 479

Linie 481

Trójkąty 481

Znajdowanie punktu przecięcia dwóch linii 482

Punkt przecięcia dwóch linii 485

Znajdowanie pary najbliższych punktów 499

Podsumowanie 51°
Ćwiczenia 510

Rozdziałl9. Optymalizacja pragmatyczna 511


Kiedy optymalizowanie ma sens? 511
Profilowanie 513
Przykładowy program FileSortingHelper 514
Profilowanie za pomocą modułu hprof 517
Profilowanie za pomocą JMP 520
Istota optymalizacji 522
Optymalizacja w praktyce 523
Podsumowanie 530

Dodatek A Zalecana literatura uzupełniająca 531

Dodatek D Wybrane zasoby internetowe 533

Dodatek C Literatura cytowana 535

Dodatek D Odpowiedzi do ćwiczeń 537

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.

Na koniec chciałbym wyrazić głębokie uznanie i wdzięczność wszystkim tym z wydaw-


nictwa Wiley, którzy współuczestniczyli w tworzeniu książki, oraz mojej rodzinie i znajo-
mym, którzy nieustannie zachęcali mnie do pracy i podtrzymywali na duchu — szczególnie
wtedy, gdy zdawało się, iż niebo wali mi się na głowę. Było to bezsprzecznie bardzo po-
uczające doświadczenie.

Od Jamesa Rossa: Na początku chciałbym podziękować Simonowi za to, że pozwolił mi


zostać współautorem swej pierwszej książki. Zyskałem dzięki temu okazję do zajęcia się
tematem na serio, a praca z Simonem zawsze była dla mnie przyjemna i pouczająca. Sły-
szeliśmy wiele historii o tym, jak wspólna praca nad książką zdolna jest zniszczyć pozy-
tywne relacje międzyludzkie, i cieszymy się niezmiernie, że nas to nieszczęście ominęło.

Wszystkim z wydawnictwa Wiley chciałbym podziękować za wyjątkową wyrozumiałość


dla dwóch autorów-nowicjuszy i nieomylne poprowadzenie nas do pomyślnego końca.
Szczególne dzięki dla Ami Sullivan i Carol Long.

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

i który przejrzał napisane przeze mnie rozdziały z niewiarygodną wnikliwością. Dziękuję


również Jonowi Eavesowi, redaktorowi technicznemu niniejszej książki, który nauczył
mnie wielu rzeczy i któremu zawsze niezawodnie udawało się mnie rozśmieszać. Szcze-
gólną pomoc podczas powstawania pierwszych szkiców okazał mi także Simon Stewart,
a Gregor Hohpe i Martin Fowler dodawali mi sił i zachęty do pisania podczas tych długich,
bezsennych nocy.

Co do owych długich nocy, to muszę uczciwie przyznać, że książka ta (przynajmniej


w części napisanej przeze mnie) nie mogłaby ujrzeć światła dziennego, gdyby nie miłość
i zrozumienie ze strony najwspanialszych kobiet mojego życia: mojego słoneczka Catherine,
Jessiki, Ruby i malutkiej Elli — gdy rozpoczynałem pisanie, miała pół roku i podczas gdy
ja oddawałem się swemu pisarstwu, ona spała obok przez 12 godzin (lub dłużej). Być może
nigdy nie przeczytasz tej książki, dziecinko, lecz ja zawsze będę myślał o Tobie, ilekroć
będę po nią sięgał.
Wprowadzenie
Witamy w książce Algorytmy. Od podstaw — przewodniku krok po kroku po algorytmach
i ich zastosowaniu w praktycznych obliczeniach.

Programiści nieustannie wykorzystują w swej pracy różnorodne algorytmy i struktury danych,


zatem ich dobre zrozumienie i poznanie zasad ich praktycznego stosowania jest warunkiem
niezbędnym tworzenia programów zarówno poprawnych, jak i efektywnych w działaniu.

Treść niniejszej książki koncentruje się na algorytmach i strukturach danych wykorzysty-


wanych powszechnie w codziennym tworzeniu oprogramowania. Rzeczowe i zwięzłe opisy
ilustrowane są licznymi przykładami i wolne są od zbędnych dygresji, które mogłyby jedynie
rozpraszać uwagę Czytelnika.

Kto powinien czytać tę książkę?


Książka adresowana jest przede wszystkim do Czytelników zajmujących się tworzeniem
aplikacji i tych, którzy zamierzają to robić. Generalnie jednak z pewnością okaże się intere-
sująca dla każdego zainteresowanego tematyką algorytmów i struktur danych — programi-
sty, projektanta, a także studenta informatyki czy ucznia stawiającego pierwsze kroki pro-
gramistyczne.

Mimo iż pełne zrozumienie treści książki wymaga znajomości programowania przynajm-


niej na poziomie podstawowym, to jednak treść ta może okazać się cenna także z koncep-
cyjnego punktu widzenia, w oderwaniu od konkretnego kodu programów. Fakt ten czyni j ą
przydatną także dla osób nie związanych bezpośrednio z programowaniem — kierowników
zespołów, architektów systemów czy nawet analityków biznesowych.
14 Algorytmy. Od podstaw

Co powinieneś już wiedzieć?


Ponieważ zawarte w książce przykładowe programy stworzone zostały w języku Java, z pew-
nością pożyteczną będzie praktyczna znajomość tego języka, jak również jego standardo-
wych bibliotek, z pakietem java. 1 ang na czele. Wskazana jest także znajomość podstawowych
struktur danych i sterowania — tablic, pętli itp. — i oczywiście umiejętność tworzenia,
kompilowania i uruchamiania klas Javy.

Nie jest natomiast wymagana jakaś szczególna wiedza z zakresu omawianych w książce
poszczególnych algorytmów czy struktur danych.

Czego możesz się nauczyć?


Na stronach niniejszej książki znajdziesz szczegółowe opisy i wyjaśnienia dotyczące wielu
algorytmów, wraz z przykładowymi implementacjami i wskazówkami związanymi z ich
zastosowaniem w rzeczywistych programach. Prezentowane przykłady rzadko kiedy mają
naturę „akademicką", a ich kod nadaje się w większości do natychmiastowego zastosowa-
nia, bez konieczności wprowadzania jakichkolwiek modyfikacji.

Staramy się trzymać dobrych, powszechnie akceptowanych praktyk i technik programi-


stycznych, między innymi wzorców projektowych [Cormen, 2001], konwencji kodowania,
kontroli jakości i w pełni zautomatyzowanych modułów testowych. Mamy nadzieję, że —
w połączeniu z gruntowną znajomością algorytmów i ich roli w programowaniu — przyczyni
się to do tworzenia oprogramowania solidnego, elastycznego i oczywiście funkcjonalnego.

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

Jak czytać tę książkę?


Zasadniczo należałoby j ą przeczytać od początku do końca. Tak bowiem ułożona jest jej
treść, by prowadzić Czytelnika przez podstawy algorytmów, struktury danych oraz zagad-
nienia wydajności poszczególnych algorytmów sortowania, wyszukiwania itp. Książka po-
dzielona jest na cztery następujące części:
• W pięciu pierwszych rozdziałach wyjaśniamy podstawowe pojęcia związane
z algorytmami, jak iteracja, rekurencja itd., po czym zapoznajemy Czytelników
z podstawowymi strukturami danych, m.in. listami, stosami i kolejkami.
• Rozdziały 6. - 10. poświęcone są różnym algorytmom sortowania oraz pojęciom
nierozerwalnie związanym z sortowaniem, jak klucze i uporządkowanie elementów.
• Treść rozdziałów 11.-15. związana jest z efektywnymi technikami przechowywania
i wyszukiwania informacji, opartymi na drzewach, zbiorach, mapach i funkcjach
mieszających (haszujących).
• Cztery końcowe rozdziały (16. - 19.) obejmują kilka bardziej zaawansowanych,
specjalistycznych zagadnień, zawierają także ogólne omówienie problematyki
efektywności algorytmów i technik optymalizacyjnych.

Treść każdego rozdziału bazuje na pojęciach wprowadzonych w rozdziałach poprzednich,


definiując jednocześnie pojęcia wykorzystywane w następnych rozdziałach. Stąd sugestia
„sekwencyjnego" studiowania książki, choć oczywiście możliwe jest wyrywkowe czytanie
poszczególnych rozdziałów — tak czy inaczej zalecamy staranne przestudiowanie zamiesz-
czonych implementacji i zweryfikowanie swej wiedzy za pomocą ćwiczeń wieńczących
czytany rozdział (rozdziały). W końcowych dodatkach zamieszczamy ponadto wykaz zale-
canej przez nas literatury uzupełniającej, wykorzystywanych pozycji bibliograficznych oraz
innych zasobów.

Zasady podejścia programistycznego


Częstą przyczyną trudności w zrozumieniu analizowanego kodu są konstrukcje językowe
będące konsekwencjami przyjęcia pewnych niepisanych „oczywistych" założeń oraz zasad
rządzących procesami decyzyjnymi. W związku z tym czujemy się zobowiązani do wyja-
śnienia Czytelnikom zasad, którymi my kierowaliśmy się przy tworzeniu przykładowych
programów, jakie prezentujemy w niniejszej książce. Jesteśmy mianowicie przekonani, że
Czytelnicy należycie docenią znaczenie następujących reguł wyznaczających kanon dobrego
programowania:
• zachowanie prostoty ułatwia uzyskanie dobrego kodu,
• optymalizacja kodu me jest najważniejsza,
• interfejsy czynią kod wysoce elastycznym,
16 Algorytmy. Od podstaw

• zasadniczemu kodowi programu powinien towarzyszyć weryfikujący jego


funkcjonalność kod testowy,
• asercje należą do najlepszych przyjaciół programistów.

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.

Nie spiesz się z optymalizacją


Częstym zjawiskiem towarzyszącym programowaniu jest dążenie do tworzenia — od sa-
mego początku — kodu maksymalnie szybkiego. Tymczasem, jak uczy doświadczenie,
„wąskie gardła" efektywności programów pojawiają się zazwyczaj nie tam, gdzie zwykli-
śmy się ich pierwotnie spodziewać, a ich natura okazuje się różna od tej, jaką początkowo
skłonni jesteśmy podejrzewać. W efekcie trafność takich pre-optymalizacyjnych zabiegów
uwarunkowana jest pracochłonnymi eksperymentami, lepiej więc skupić się początkowo na
poprawności i czytelności kodu, pozostawiając jego optymalizację jako odrębne zadanie,
wymagające — j a k zobaczymy w rozdziale 19. — specyficznych umiejętności.

Ilekroć w niniejszej książce stajemy przed wyborem między efektywnością a czytelnością


kodu, zawsze dajemy pierwszeństwo czytelności. Wychodzimy bowiem z założenia, że
względy łatwego rozumienia kodu są znacznie ważniejsze od zaoszczędzenia kilku milise-
kund czasu procesora.

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.

Funkcjonalny związek implementacji z otoczeniem, w oderwaniu od jej szczegółów, okre-


ślony jest w formie kontraktu zwanego interfejsem. Oddzielenie interfejsu od szczegółów
implementacji nadaje tej implementacji charakter „podłączalny" — interfejs staje się czymś
w rodzaju „wtyczki" umożliwiającej podłączenie mechanizmu, którego szczegóły stają się
nieistotne, istotna pozostaje tylko funkcjonalność zewnętrzna. Różne implementacje posia-
dające identyczne interfejsy stają się wówczas całkowicie wymienne.

Konstruowanie każdej z przykładowych implementacji prezentowanych w niniejszej książce


rozpoczyna się od przetłumaczenia jej (zakładanej) funkcjonalności na operacje stosownego
interfejsu. W większości przypadków operacje te można podzielić na dwie kategorie: do
pierwszej zaliczają się operacje podstawowe („rdzenne" — core), do drugiej — operacje
dodatkowe („opcjonalne" — optional).

Operacje „rdzenne" odzwierciedlają zasadnicze elementy funkcjonalności reprezentowanej


przez dany interfejs. Mają one swe źródło w podstawowych cechach algorytmu, a każda
z nich jest zwykle w dużym stopniu niezależna od pozostałych.

Operacje opcjonalne są natomiast implementowane na bazie operacji podstawowych, a główną


racją ich bytu jest wygoda programisty — ich zestaw nie wynika bezpośrednio z własności
implementowanego algorytmu, lecz kształtowany jest przez sposób wykorzystywania tegoż
algorytmu przez resztę aplikacji. „Opcjonalny" charakter tych operacji nie oznacza by-
najmniej, iż mają one charakter drugorzędny i rangę mniejszą niż operacje podstawowe. Ich
implementacja powinna być wykonana równie starannie, jak implementacja operacji pod-
stawowych; niejako ubocznym wyrazem tej troski jest tendencja do równego traktowania
wszystkich operacji interfejsu — bez podziału na operacje rdzenne i opcjonalne, wszystkie
one bowiem mają równe znaczenie dla środowiska korzystającego z tego interfejsu. Jako że
uważamy taką tendencję ze słuszną ze wszech miar, można z łatwością dostrzec jej przejawy
w przykładowych programach naszego autorstwa.
18 Algorytmy. Od podstaw

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ń.

W przykładach prezentowanych w niniejszej książce do weryfikowania zgodności imple-


mentacji z założeniami funkcjonalnymi interfejsów wykorzystujemy bibliotekę JUnit, która
stała się już standardem de facto dla testowania aplikacji tworzonych w języku Java.

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.

Generalna zasada tworzenia przypadków testowych na podstawie jedynie interfejsów, w ode-


rwaniu od konkretnej implementacji modułów, nosząca nazwę programowania sterowanego
testami (TDD — Test-Driven Development), kładzie szczególny nacisk na publiczne za-
chowanie się danej klasy czy modułu, „zakontraktowane" w interfejsie. Pozwala to na trakto-
wanie przypadków testowych jako swego rodzaju wymagań lub tzw. przypadków użycia
(use cases) i w naszym przeświadczeniu upraszcza tworzenie aplikacji oraz czyni ich kod
bardziej czytelnym. Zachęcamy Czytelnika do tego, by — studiując zamieszczone w książce
przykłady — przekonał się o tym osobiście.

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

Jedna z przesłanek tzw. programowania defensywnego stanowi, że skoro nie potrafimy


zapobiegać popełnianiu błędów, powinniśmy przynajmniej starać się wychwycić możliwie
najwcześniej ich objawy. Przykładowo kontrolowanie niezerowości wskaźników przez ich
dereferencją czy też sprawdzenie spełnienia określonych założeń odnośnie stanu obiektu
bezpośrednio po wejściu do metody umożliwia skuteczne wykrywanie różnych zdradliwych
pułapek, zanim pojawią się lakoniczne, ogólnikowe komunikaty o wyjątkach w rodzaju
NullPointerException.

Co do wspomnianych założeń, to zasady programowania defensywnego nakazują sprawdzanie


ich spełnienia w określonych miejscach kodu. Nie wystarczy pewność programisty, iż „to
nie ma prawa się zdarzyć, więc nie będę się tym przejmował"; pewność ta musi być poparta
stosownym testem w postaci tzw. asercji.

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.

Czego będziesz potrzebować?


Jeśli chciałbyś skorzystać z gotowego kodu, by jak najszybciej rozpocząć interesującą lek-
turę, nic prostszego: możesz pobrać gotowe projekty z serwera FTP wydawnictwa Helion
— znajdują się one pod adresem ftp://ftp.helion.pl/przyklady/algpo.zip.

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

• Biblioteka JUnit, w postaci pojedynczego pliku .jar, który umieścić należy


na ścieżce klas (cl asspath); biblioteka ta jest niezbędna do kompilowania
i uruchamiania testów modułów.
• Edytor tekstowy lub środowisko IDE (IntegratedDevelopment Environment),
niezbędne do tworzenia i edytowania kodu źródłowego.

Dwa pierwsze z wymienionych składników dostępne są za darmo w internecie (patrz do-


datek B); co do trzeciego składnika wybór stosownego IDE pozostawiamy gustowi Czytelni-
ków (nie chcąc wszczynać kolejnej świętej wojny w tej kwestii). Opinia bardziej doświad-
czonych użytkowników tez może się okazać pomocna.

Programy stworzone w języku Java mogą być kompilowane i uruchamiane w środowisku


niemal każdego systemu operacyjnego. Pisząc niniejszą książkę, korzystaliśmy z komputera
Apple Macintosh oraz komputera PC pracującego pod kontrolą MS Windows. Żaden z pre-
zentowanych programów nie wymaga dużej mocy obliczeniowej, wszystkie one powinny
więc działać niezawodnie na dowolnym komputerze.

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.

HilJiMIIIf.liiI Obliczenia testowe


Sekcje „Spróbuj sam" zawierają ćwiczenia bezpośrednio związane z tekstem wykładu.
1. Każda sekcja „Spróbuj sam" (z nielicznymi wyjątkami) jest opisem kolejnych
etapów tworzenia konkretnego kodu.
2. W niektórych sekcjach etapy nie są numerowane; niektóre z nich są bardzo
zwięzłe, niektóre też podzielone są na podetapy, tak czy inaczej prowadzą
one ostatecznie do rozwiązania złożonych zagadnień.

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

Ważne, warte zapamiętania informacje, bezpośrednio związane z otaczającymi fragmen-


tami tekstu, wyróżnione są w postaci ramek.

Wskazówki, podpowiedzi, triki i dygresje luźno związane z bieżącą dyskusją złożone


są kursywą i nieco przesunięte są w prawo w stosunku do tekstu zasadniczego.

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 jest algorytm,


• jaka jest rola algorytmów nie tylko w tworzeniu oprogramowania, lecz także
w życiu codziennym,
• co rozumiemy pod pojęciem złożoności algorytmu,
• jakie są główne klasy złożoności algorytmów decydujące o odmiennych cechach
różnych rozwiązań tego samego problemu,
• co wyrażamy za pomocą notacji „dużego O",
• co to jest testowanie modułów i dlaczego jest ono takie istotne,
• jak tworzy się testy modułów przy użyciu biblioteki JUnit.

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ń:

Funkcja Mnóż(Całkowite A, Całkowite B)


Całkowite C = 0
Tak długo, jak A jest większe od zera, wykonuj sekwencję
C - C + B
A = A - 1
aż dotąd.
Zwróć wartość C
Koniec

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

rozwiązanie nie zawsze jest rozwiązaniem oczywistym. Systematyczne studiowanie algo-


rytmiki musi być połączone z dozą własnej pomysłowości i kreatywności, a poszukiwanie
dobrego algorytmu odbywa się zwykle metodą prób i błędów. Informatyka, jako nauka,
połączona zostaje z inżynierią tworzenia oprogramowania, dając w efekcie sztuką progra-
mistyczną, zgodnie z tytułem słynnej monografii D.E. Knutha 1 .Większość omawianych
w tej książce algorytmów to algorytmy deterministyczne — wynik obliczeń takiego algoryt-
mu zależny jest jedynie od danych wejściowych. Niektóre problemy są zbyt trudne, by
można je było efektywnie rozwiązywać tylko za pomocą algorytmów deterministycznych;
konieczne jest wówczas zastosowanie podejścia heurystycznego dającego, zamiast rozwią-
zania bezwzględnie dokładnego rozwiązanie przybliżone, lecz akceptowalne. Heurystyki
można też wykorzystywać do wstępnej eliminacji („odsiewania") potencjalnych danych
wejściowych do (kosztownych) obliczeń wykonywanych przez algorytm deterministyczny.
Wybór używanych heurystyk uwarunkowany jest specyfiką rozwiązywanego problemu i jest
w dużej mierze sprawą pomysłowości programisty.

Prostym przykładem algorytmu heurystycznego może być postępowanie pieszego zamie-


rzającego przejść przez (dwukierunkową) jednię. Jak pamiętamy, powinniśmy wówczas
najpierw spojrzeć, czy nie nadjeżdża pojazd z lewej strony, po czym powinniśmy skontro-
lować w tym względzie stronę prawą. Tak będzie jednak tyko przy ruchu prawostronnym,
bowiem przy ruchu lewostronnym — obowiązującym m.in. w Wielkiej Brytanii, Japonii
czy Australii — powinniśmy najpierw spojrzeć w prawo. To wszystko jest dziecinnie proste,
0 ile wiemy, z jakim rodzajem ruchu — prawostronnym czy lewostronnym — mamy aktu-
alnie do czynienia.

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

Co to iest złożoność algorytmu?


Dla każdego algorytmu nieodłącznym pozostaje pytanie o jego efektywność — oczywiście
programiści dążą do tworzenia algorytmów jak najbardziej efektywnych i chcieliby to dą-
żenie opierać na wiarygodnych przesłankach. Na początku jednak niezbędne jest sprecyzo-
wanie tego, co rozumiemy pod pojęciem „efektywności" algorytmu.

Jednym z najczęstszych błędów popełnianych w związku z analizą algorytmów jest myle-


nie wydajności algorytmu — mierzonej zużyciem czasu procesora, ząjętością pamięci ope-
racyjnej i wykorzystaniem pamięci dyskowej — z jego złożonością, określającą skalowal-
ność, czyli zachowanie w obliczu zmian rozmiaru rozwiązywanego problemu. Przykładowo
stwierdzenie, że dany algorytm zdolny jest przetworzyć 1000 rekordów bazy danych w cza-
sie 30 milisekund niewiele mówi o wydajności tego algorytmu. Wymieniony czas uwarun-
kowany jest bowiem wieloma czynnikami leżącymi poza samym algorytmem: szybkością
procesora, jakością kodu generowanego przez użyty kompilator itp. Z punktu widzenia
analizy algorytmu znacznie ważniejsze jest to, jak zmieni się wymieniony czas, gdy liczba
przetwarzanych rekordów będzie dwukrotnie większa — czy przetwarzanie będzie trwało
tak samo szybko, dwukrotnie dłużej czy może czterokrotnie dłużej? Przypuśćmy, że mamy
do dyspozycji konkurencyjny algorytm dokonujący przetworzenia 1000 rekordów w czasie
40 milisekund; czy możemy go jednoznacznie uznać za gorszy od pierwszego? Absolutnie
nie — może się bowiem okazać, że pierwszy („lepszy") algorytm do przetworzenia 10 000
rekordów potrzebował będzie 300 milisekund, podczas gdy drugi („gorszy") poradzi sobie
z tym zadaniem w ciągu 80 milisekund.

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

omawiany algorytm dla pewnych szczególnych przypadków danych wejściowych. W każ-


dym przypadku należy pamiętać o tym, że złożoność algorytmu nie stanowi precyzyjnej
miary jego wydajności, lecz jedynie wyznacza pewne ramy wydajności możliwej do osią-
gnięcia.

Porównywanie złożoności i notacja „dużego 0"


Wspominaliśmy wcześniej, że złożoność algorytmu wyrażana jest nie w formie bez-
względnej, lecz jako zależność liczby wykonywanych operacji od rozmiaru rozwiązywa-
nego problemu. Tak naprawdę jednak znajomość dokładnej formuły wyrażającej ową za-
leżność także nie jest konieczna, bowiem do oszacowania czasu rozwiązywania problemu
wystarczający okazuje się jedynie rząd tej wielkości. Do przybliżania wartości przez rząd
ich zmienności wykorzystywana jest powszechnie tzw. notacja dużego O — fakt, że dana
wielkość jest rzędu f , wyrażamy 2 mówiąc, że jest ona równa 0(f). Oto przykłady takiej no-
tacji — za chwilę przeanalizujemy je nieco dokładniej (./V oznacza rozmiar rozwiązywanego
problemu):

• 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

Powracając do listy przykładowych rzędów złożoności, zwróćmy uwagę, że nie użyliśmy


nigdzie żadnych współczynników proporcjonalności. Niezależnie od tego, czy złożoność
algorytmu rozwiązującego problem o rozmiarze A'jest proporcjonalna do N, 2 x N, 3 x N
czy nawet 100 x N, jest ona złożonością 0(N). W pierwszej chwili może się to wydawać
cokolwiek dziwne, bowiem algorytm o złożoności 2 x N jest z pewnością lepszy od algo-
rytmu o złożoności 100 x Nm, staje się to jednak całkiem zrozumiałe, gdy przypomnimy so-
bie, że badanie złożoności algorytmu nie ma na celu określenia dokładnej liczby wykony-
wanych przezeń operacji, lecz służy porównywaniu relatywnej wydajności różnych
algorytmów. Niewątpliwie algorytm o złożoności liniowej (czyli 0(N)) jest lepszy od algo-
rytmu o złożoności kwadratowej (czyli 0(N2)). Co więcej, w miarę wzrostu rozmiaru pro-
blemu (N) czynniki stałe okazują się mieć coraz mniejszy udział w ogólnej różnicy między
a N2
porównywanymi algorytmami: przy odpowiednio dużym N iloraz zmieni się nie-
P N
zauważalnie, jeśli początkowe wartości a = 1 000 000 000, P = 20 000 000 000 zamienimy
na a = 20 000 000 000, |3 = 1 000 000 000, mimo iż wartości te różnią się 20-krotnie.

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

Złożoność stała — 0(1)


Ponieważ — jak przed chwilą wyjaśnialiśmy — stałe czynniki w formule złożoności nie
mają wpływu na rząd tej złożoności, złożoność 0 ( 1) nie powinna być utożsamiana z algo-
rytmem wykonującym jedną operację, lecz rozumiana powinna być jako złożoność algo-
rytmu o stałej liczbie operacji, niezależnej od rozmiaru rozwiązywanego problemu.

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

Algorytmy o złożoności liniowej zwykło się uważać za algorytmy akceptowalne w więk-


szości przypadków; są one spotykane znacznie częściej niż (także efektywne) algorytmy
o stałej złożoności. Co ciekawe, gdy uda się znaleźć algorytm o złożoności 0(N), często
można go uczynić bardziej efektywnym dzięki niezbyt nawet wnikliwej analizie, być może
przy pewnej dozie własnej pomysłowości — czego przykłady zaprezentujemy w rozdziale
16. przy okazji omawiania algorytmów przeszukiwania łańcuchów.

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

W grupie siedmioosobowej liczba niezbędnych uścisków wynosić będzie 6 + 5 + 4 + 3


+ 2 + 1 = 21, a w przypadku grupy ośmioosobowej — 7 + 6 + ... + 2 + 1 =28. Generalnie
rzecz biorąc, każda nowo przybyła do grupy osoba przywitać się musi z każdą w pozosta-
łych obecnych już w grupie osób4.

-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

Tabela 1.1. Efekt odejmowania N od N 2 dla wzrastających wartości N


u
N N2 Różnica bezwzględna (N 2 - N) Różnica względna ( — )
N2
l 1 0 100,00%
10 100 90 10,00%
100 10 000 9 900 1,00%
1 000 1 000 000 999 000 0,10%
10 000 100 000 000 99 990 000 0,01 %

Niektóre z algorytmów o złożoności kwadratowej mogą stać się prawdziwym koszmarem


dla programistów, bowiem użyteczność większości spośród nich ograniczona jest do roz-
wiązywania problemów o niewielkich raczej rozmiarach. W poświęconych sortowaniu roz-
działach 6. i 7. podamy kilka interesujących tego przykładów.

Złożoność logarytmiczna—Odog N) i 0(N log N)


Jak widać na rysunku 1.1, złożoność logarytmiczna ć>(log N) lepsza jest od złożoności li-
niowej 0(N), lecz wciąż nie jest tak dobra jak złożoność stała 0(1).

Czas wykonania algorytmu o złożoności logarytmicznej jest proporcjonalny do logarytmu


z rozmiaru problemu — w większości przypadków logarytmu dwójkowego, czyli logarytmu
o podstawie 5 2. Oznacza to, że geometryczny wzrost rozmiaru problemu („ileś razy") prze-
kłada się na arytmetyczny („o ileś") wzrost złożoności obliczeniowej: zwiększenie rozmiaru
problemu milion razy może powodować wydłużenie czasu obliczeń. Jeśli tysiąckrotne
zwiększenie rozmiaru problemu spowoduje wydłużenie czasu wykonywania algorytmu
o (powiedzmy) 20 sekund, to zwiększenie tego rozmiaru milion razy wydłuży czas obliczeń
jedynie o 40 sekund. Dobrym przybliżeniem wartości logarytmu dwójkowego liczby natu-
ralnej jest ilość cyfr tej liczby w rozwinięciu binarnym, przykładowo l o g 2 3 0 0 9 , bowiem
(dziesiętna) liczba 300 ma w układzie binarnym postać 100101100.

Logarytmiczna złożoność algorytmu osiągalna jest najczęściej dzięki możliwości pominię-


cia znaczącej porcji danych wejściowych. Zachowanie takie wykazuje większość algoryt-
mów mających związek z wszelkiego rodzaju wyszukiwaniem. W rozdziałach 9. i 10.
omawiamy algorytmy wyszukiwania binarnego o złożoności 0(log N).

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.

Złożoność rzędu silni—0(N!)


Mimo iż złożoność kwadratowa algorytmów wyklucza możliwość ich użycia do rozwiązy-
wania problemów o dużych rozmiarach, to jednak istnieją algorytmy o jeszcze gorszej zło-
żoności. Gdy pomnożymy liczbę naturalną N przez wszystkie mniejsze od niej liczby natu-
ralne, otrzymamy wartość zwanĄN silnia i oznaczaną przez N\:
N\=Nx(N-\)x ••• x 2 x l

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

10 100 3 628 800

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 kilku następnych punktach omówimy podstawowe elementy testowania modułów oraz


przedstawimy bibliotekę JUnit wykorzystywaną w tym celu przez aplikacje tworzone w ję-
zyku Java. Z biblioteki tej będziemy w niniejszej książce korzystać regularnie, zatem zro-
zumienie zasad jej używania konieczne jest do zrozumienia sposobu testowania prezento-
wanych implementacji. Znawcy tematu mogą spokojnie pominąć dalszą część niniejszego
rozdziału.

Czym jest testowanie modułów?


Testowanie modułów (zwane także testowaniem jednostek — unit testing) opiera się na jed-
nostkach testowych — pod tym pojęciem rozumiemy fragmenty kodu przeznaczone do te-
stowania innych fragmentów. W języku Java owe „fragmenty" mają postać klas — „zasad-
nicza" klasa testowana jest za pomocą stowarzyszonej z nią klasy testowej. Z pewnością
nie jest to trudne do zrozumienia, lecz (jak to zwykle bywa) osiągnięcie dobrych efektów
w testowaniu modułów wymaga niemałego wysiłku i pomysłowości. Samo testowanie, jako
oparte na solidnych podstawach, może być traktowane jako nauka; ponieważ jednak wiele
zależy w nim od inwencji i doświadczenia testerów, można je równie dobrze uważać za
sztukę. Ponieważ napisano już na ten temat bardzo wiele, nie będziemy rozwijać tego inte-
resującego zagadnienia, odsyłając Czytelników do literatury uzupełniającej wyszczególnio-
nej w dodatku A.

W ramach każdej klasy testowej wyróżnić można trzy następujące, fundamentalne operacje:

1. Skonstruowanie obiektów wykonujących test i wspomagających go, na przykład


specjalnie spreparowanych danych; etap ten nosi nazwę umocowania (fixture).
2. Wykonywanie testowych operacji zapewniających, że testowana klasa istotnie
wykonuje czynności, do których została skonstruowana. Fragmenty kodu
weryfikujące spełnienie oczekiwanych warunków nazywane są asercjami.
3. Po zakończeniu testu wszystkie związane z nim (i niepotrzebne do czegokolwiek
innego) obiekty zostają zwolnione, co nazywane jest (po prostu) ich niszczeniem
(tearing down).

W niniejszej książce konsekwentnie stosujemy konwencję nazywania klas testowych: na-


zwa klasy testowej powstaje przez dodanie przyrostka Test do nazwy testowanej klasy za-
sadniczej, tak więc na przykład klasa o nazwie Widget testowana jest za pomocą klasy o na-
zwie WidgetTest. Jednolitej konwencji nazewniczej towarzyszy inna konwencja związana
z umiejscowieniem plików źródłowych klas testowych: są one lokowane w drzewie o struktu-
rze równoległej do (drzewiastej) struktury klas zasadniczych. Jeżeli (przykładowo) klasa
Widget znajduje się w pakiecie com.wrox.algorithms, to umiejscowienie klasy WidgetTest
34 Algorytmy. Od podstaw

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

Przy okazji warto wspomnieć o innych powszechnie stosowanych technikach testowania


programów. Ponieważ w niniejszej książce koncentrujemy się (jeśli chodzi o testowanie)
wyłącznie na testowaniu modułów, więc ograniczymy się jedynie do samych definicji, od-
syłając zainteresowanych Czytelników do literatury wymienionej w dodatku A.
• Testowanie czarnoskrzynkowe (black-box testing). Wyobraź sobie, że zamierzasz
przetestować nowo zakupiony odtwarzacz DVD. Jeżeli nie planujesz zaglądać
do jego wnętrza (i tracić w związku z tym gwarancji), wszystko, co masz do
dyspozycji, to przyciski i pokrętła na panelu czołowym oraz gniazda na tylnej
ściance obudowy. Wewnętrzne komponenty sprzętowe pozostają dla Ciebie
niedostępne. W przełożeniu na testowanie oprogramowania oznacza to testowanie
aplikacji wyłącznie na podstawie jej interfejsu użytkownika, bez dostępu do
poszczególnych komponentów i w całkowitym oderwaniu od jej kodu źródłowego.
• Testowanie funkcjonalne jest (w przybliżeniu) synonimem testowania
czarnoskrzynkowego.
• Testowanie białoskrzynkowe (white-box testing) to testowanie poszczególnych
komponentów aplikacji drogą analizy (mniej lub bardziej szczegółowej)
funkcjonowania jej poszczególnych komponentów. Interfejs użytkownika
nie jest przedmiotem zainteresowania tej formy testowania.
• Testowanie integracyjne. Pod tym pojęciem kryje się testowanie współpracy
poszczególnych komponentów dużego systemu rozproszonego z jednej strony,
a niezależności funkcjonowania tych komponentów z drugiej. Każdy komponent,
mimo iż konstruowany niezależnie od pozostałych, spełniać musi wymagania
określone przez protokół (kontrakt) organizujące jego interakcje z resztą systemu.

Testowanie modułów uważane jest za „drobnoziarnistą" technikę testowania, bowiem przed-


miotem testu jest każda z osobna klasa wchodząca w skład aplikacji. Poszczególne testy,
dzięki wzajemnej niezależności, są mało skomplikowane i stosunkowo łatwe do przepro-
wadzania.
Rozdział 1. • Zaczynamy 35

Dlaczego testowanie modułów jest ważne?


Aby zrozumieć, dlaczego eksponujemy znaczenie testowania modułów w książce poświę-
conej algorytmom, zastanówmy się nad rolą kompilatora: czy w ogóle inwestowalibyśmy
swój czas i wysiłek w tworzenie kodu, którego nie sposób skompilować i wykonać? Oczy-
wiście nie; kompilator jawi się w tym kontekście jako pierwszy stopień weryfikacji kodu
— weryfikacji pod względem składniowym. Najbardziej nawet jednak inteligentny kompi-
lator nie jest w stanie — nie znając intencji programisty — zweryfikować logicznej po-
prawności programu, czyli zgodności jego działania z oczekiwaniami autora (autorów).
Skoro poprawność składniowa programu, mimo iż mniej istotna od jego poprawności lo-
gicznej, poddawana jest rygorystycznym testom na etapie kompilacji, to poprawność po-
winna stać się przedmiotem kontroli nie mniej rygorystycznej — to zadanie realizowane
jest właśnie w ramach testowania modułów. Co prawda, w odróżnieniu od kompilacji wy-
krywającej wszystkie błędy składniowe, testowanie modułów (ani jakiekolwiek inne testo-
wanie) nie daje możliwości stwierdzenia, iż program jest całkowicie wolny od błędów,
stwarza jednak swoiste bariery skutecznie powstrzymujące rozprzestrzenianie się błędów
wszelkiego rodzaju.

Dodatkową korzyścią wynikającą z testowania modułów jest uzyskanie wiarygodnej do-


kumentacji zachowania się danej klasy w czasie testów. Już po przeprowadzeniu kilku te-
stów można się przekonać, że zrozumienie funkcjonowania danej klasy łatwiej osiąga się
przez wgląd w rezultaty jej testowania niż przez analizę jej kodu źródłowego (która to analiza
dostarczyć może odpowiedzi na pytanie, jak zrealizowane zostały poszczególne aspekty za-
chowania danej klasy, ale to już zupełnie inna sprawa).

Biblioteka JUnit i jej wykorzystywanie


Bibliotekę JUnit ściągnąć można za darmo (w postaci pliku junit.rar) ze strony www.junit.org,
zawierającej ponadto informacje związane z używaniem jej (biblioteki) w środowiskach
IDE oraz odnośniki do różnych jej rozszerzeń dedykowanych specyficznym potrzebom.

Po ściągnięciu pliku junit.rar wystarczy umieścić go na ścieżce klas (classpath) i już


można przystąpić do tworzenia pierwszej klasy testowej. Klasa taka wywodzi się zawsze
z klasy junit.framework.TestCase, bazowej dla wszystkich klas testowych. Oto fragment
przykładowego modułu testowego wykorzystującego bibliotekę JUnit:
package com.wrox. algori thms.queues;

i mport com.wrox.algori thms.1 i sts.Li nkedLi st;


i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase;

public class RandomListQueueTest extends TestCase {


private static finał String VALUE_A = "A";
private static finał String VALUE_B = "B";
private static finał String VALUE_C = "C";

private Queue _queue:

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;

_queue = new RandomListOueueO:


}

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

public void testAccessAnEmptyOueueO {


assertEquals(0, _queue.size());
assertTrue(_queue.isEmptyO);

try {
_queue.dequeue():
failO; // nieoczekiwane zachowanie
} catch (EmptyQueueException e) {
// oczekiwane zachowanie

Zwróć uwagę na następujące ważne elementy widocznego powyżej kodu:


• Nazwa metody rozpoczyna się od przedrostka test; jest to wymagane przez
bibliotekę JUnit dla odróżnienia metod testowych od metod wspomagających.
• W pierwszym wierszu ciała metody wywoływana jest metoda assertEquals()
w celu upewnienia się, że kolejka jest pusta. Generalnie zadaniem metody
assertEquals() jest weryfikowanie równości dwóch obiektów, co odzwierciedlane
jest w składni metody:
assertEquals(wartość_oczekiwana, wartość_faktyczna)

Istnieje kilka przeciążonych aspektów tej metody przeznaczonych dla różnych


typów podstawowych języka Java, co widoczne będzie wielokrotnie w przykładach
prezentowanych w następnych rozdziałach. Wywołanie tej metody jest najczęściej
bodaj spotykanym rodzajem asercji w zestawach testowych; jeśli badane wielkości,
reprezentowane przez parametry wywołania, okażą się (wbrew oczekiwaniom)
różne, wykonywanie testu zostanie przerwane, a testerzy otrzymają komunikat
o niepowodzeniu. Konwencja ta nadaje jednostkom testowym zwięzły i czytelny
charakter.
• Na podobnej zasadzie metoda assertTrue( )wywoływana w drugim wierszu
wykorzystywana jest do zweryfikowania, czy spełniony jest warunek
reprezentowany przez dane wyrażenie boolowskie. W naszym przykładzie
wyrażenie to powinno mieć wartość równą wartości logicznej zdania „kolejka
jest pusta" — metoda isEmpty() testowanej klasy służy właśnie do badania
„pustości" kolejki.
• Zgodnie z intencjami programisty wywołanie metody dequeue() dla pustej kolejki
powinno spowodować wystąpienie wyjątku. Widoczny blok try/catch jest tu o tyle
nietypowy, że — w przeciwieństwie do „normalnej" obsługi wyjątków — wystąpienie
wyjątku (czyli „wpadnięcie sterowania" w sekcję catch) jest tu zdarzeniem
oczekiwanym; poprawne wykonanie instrukcji _queue.dequeue oznacza błąd,
stąd następna instrukcja fai 1 () mająca za zadanie załamanie testu w tej sytuacji
(jeśli jest to dla Ciebie niejasne, spróbuj przeczytać niniejszy punkt jeszcze raz).

Oto przykład innej metody klasy RandomListQueueTest:


public void testClearO {
_queue.enqueue(VALUE_A);
_queue.enqueue(VALUE_B):
_queue.enqueue(VALUE_C);
38 Algorytmy. Od podstaw

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.

Programowanie sterowane testami


Wszystkim prezentowanym w niniejszej książce algorytmom i strukturom danych towarzy-
szą testy modułów weryfikujące zgodność ich działania z oczekiwaniami; ważny jest przy
tym fakt, że testy te powstawały wcześniej niż zasadniczy („produkcyjny") kod podlegający
testowaniu. Chociaż może to brzmieć trochę dziwnie, to jednak stanowi istotę techniki coraz
bardziej popularnej wśród programistów przywiązujących należytą wagę do jakości swych
produktów — techniki programowania sterowanego testami (test-driven development).

Nazwę tę stworzył i upowszechnił Kent Beck, twórca programowania ekstremalnego


(eXtreme Programming, w skrócie XP) i autor wielu książek o tej tematyce. Istotą progra-
mowania sterowanego testami jest naprzemienne tworzenie kodu testowego i podlegającego
testowaniu kodu produkcyjnego: po stworzeniu stosownego testu modułu tworzony jest sam
moduł, który następnie jest testowany i (zwykle) poddawany zabiegowi zwanemu refakto-
ryzacją (refactoring) — do kodu źródłowego wprowadzane zostają poprawki, ulepszenia
Rozdział 1. • Zaczynamy 39

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.

Rekurencja jest innym sposobem rozwiązywania problemów — dla niektórych problemów


algorytmy rekurencyjne są bardziej naturalne, dla wielu znane są wyłącznie algorytmy re-
kurencyjne. Istotą rekurencji jest wywoływanie metody przez samą siebie, bezpośrednio
lub pośrednio, co jest prostym odzwierciedleniem koncepcji podziału dużego problemu na
podproblemy o tej samej naturze, lecz mniejszych rozmiarach.

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.

W niniejszym rozdziale wyjaśniamy:


• jak wykonywać obliczenia za pomocą iteracji,
• jak przetwarzać tablice w sposób iteracyjny,
• jak uogólniać iterowanie po tablicy na abstrakcyjną koncepcję iteracyjnego
przetwarzania dowolnych struktur danych,
• jak prawidłowo wykorzystywać rekurencję w konstruowaniu algorytmów.
42 Algorytmy. Od podstaw

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.

Rozpoczniemy od skonstruowania klasy stosownej testowej drogą nieznacznego rozszerze-


nia klasy bazowej TestCase:
package com.wrox.algorithms.iteration;
import junit.framework.TestCase
public class PowerCalculatorTest extends TestCase {
} "'

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:

assertEquals(l, calculator.calculate(t), 0)):


assertEquals(l, calculator.calculated, 0)):
assertEquals(l. calculator.calculate(27. 0));
assertEquals(l. calculator.calculate(143. 0));
}

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:

assertEquals(0. calculator.calculate(0, 1)):


assertEquals(l. calculator.calculated. 1));
assertEquals(27, calculator.calculate(27. 1)):
assertEquals(143. calculator.calculate(143, 1));
Rozdział 2. • Iteracja i rekurencja 43

Na koniec sprawdźmy obliczenia dla kilku przypadkowo wybranych wartości.


public void testArbitrary O {
PowerCalculator calculator = PowerCalculator.INSTANCE:

assertEquals(0, calculator.calculateCO. 2));


assertEquals(l. calculator.calculated, 2));
assertEquals(4, calculator.calculate(2, 2));
assertEquals(8. calculator.calculate(2, 3));
assertEquals(27. calculator.calculate(3, 3));

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;

public finał class PowerCalculator {


/** Pojedyncza, publicznie dostępna instancja kalkulatora */
public static finał PowerCalculator INSTANCE = new PowerCalculator();

/** Prywatny konstruktor uniemożliwia samodzielne tworzenie instancji */


private PowerCalculator() {
}

public int calculate(int base. int exponent) {


assert exponent >= 0 : "Wykładnik nie może być ujemny":

int result = 1:

for (int i = 0 : i < exponent: ++i) {


result *= base:
}
return result;
44 Algorytmy. Od podstaw

J a k to działa?

Metoda calculateO rozpoczyna swą pracę od sprawdzenia, czy wykładnik ma wartość


nieujemną. Zmiennej przechowującej tymczasowy wynik obliczeń nadawana jest wartość
początkowa 1, po czym zmienna ta mnożona jest wielokrotnie przez wartość podstawy w ra-
mach pętli for. Liczba iteracji tej pętli równa jest wartości wykładnika; jeśli wykładnik ma
wartość 0, mnożenie w ogóle nie następuje, bo nie jest wykonywana ani jedna iteracja —
wspomniana zmienna robocza pozostaje z początkową wartością 1 i wartość ta zwracana
jest jako wynik funkcji.
Prywatny charakter konstruktora uniemożliwia tworzenie instancji klasy poza jej
własnym zakresem. Na zewnątrz klasy dostępna jest tylko jej pojedyncza instancja
ukrywająca się pod stałą INSTANCE. Rozwiązanie takie jest przykładem wzorca
projektowego zwanego singletonem [Gamma, 1995J.

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 = ... :

for (int i = 0: i < orders.length; ++i) {


orders[i].applyDiscount(percentage)
)

Przetwarzanie rozpoczyna się od zainicjowania zmiennej indeksowej wartością wskazującą


pierwszy element (int i = 0). Następnie dzięki inkrementacji tej zmiennej (++i) przetwa-
rzane są kolejne elementy; trwa to tak długo, jak długo wartość zmiennej i nie przekracza
indeksu ostatniego elementu (i < orders.length) —zauważ, że porównanie to następuje
pod czas każdej iteracji.

Niekiedy pożądane jest przetwarzanie tablicy w kolejności indeksów malejących. W poniż-


szym przykładzie tablica customers, zawierająca dane klientów, posortowana jest według
alfabetycznej kolejności ich nazwisk. Nazwiska te zostają wydrukowane w kolejności od-
wrotnej .
Customer[] customers = ...;

for (int i = customers.length-1; i >= 0: --i) {


System.out,println(customers[i],getName());
_)

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

Iteratory jako uogólnienie przetwarzania tablicowego


Przedstawione powyżej schematy, mimo iż na swój sposób użyteczne, mają raczej ograni-
czone zastosowanie, dokonują bowiem przetwarzania kolejno wszystkich elementów tablicy,
od pierwszego do ostatniego lub odwrotnie. Trudno byłoby uogólnić je na przypadki bar-
dziej skomplikowane, na przykład przetwarzanie co drugiego elementu czy też ogranicze-
nie przetwarzania tylko do elementów spełniających pewne kryterium. Schematy te bazują
ponadto na tablicach sensu stricto i zastosowanie ich do struktur bardziej złożonych wyma-
gałoby uprzedniego skopiowania tychże do specjalnie utworzonych tablic.

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.

Mechanizm taki, zwany iteratorem lub enumeratorem, dostarcza rodzajowego interfejsu


umożliwiającego iterowanie po dowolnym zbiorze danych określonym przez jakąś struktu-
rę danych lub inny, bardziej ogólny schemat. Konkretna postać tego schematu — tablica,
baza danych itp. — j e s t z punktu widzenia tego interfejsu nieistotna. Ponadto, podczas gdy
prezentowane wcześniej przykłady prostych iteracji wymagają jawnego zaprogramowania
czynności pobierania danych ze źródła, a nawet jawnego określenia uporządkowania lub
przetwarzania wstępnego, iteratory oferują prostsze koncepcyjnie, bardziej ogólne podejście.

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.

Tabela 2.1. Operacje iteratorów

Operacja Znaczenie
previous() Powoduje przejście do poprzedniego elementu; niezaimplementowana wywołuje wyjątek
UnsupportedOperationException.

next() Powoduje przejście do następnego elementu; niezaimplementowana w y w o ł u j e wyjątek


UnsupportedOperati onExcepti on.

firstO Powoduje przejście do pierwszego elementu; niezaimplementowana w y w o ł u j e wyjątek


UnsupportedOperationExcepti on.

lastO Powoduje przejście do ostatniego elementu; niezaimplementowana w y w o ł u j e 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

Iterator jest koncepcją, a nie konkretną implementacją. Wjęzyku Java zdefiniowany


jest standardowy interfejs Iterator jako część standardowego środowiska Jara
Collections Framework; omawiany tu przez nas iterator jest koncepcyjnie i fizycznie
czymś innym, bliższym wzorcom projektowym dyskutowanym w książce [Gamma, 1995],

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.

Z podobnych względów dopuszczalne jest pozostawienie operacji currentO w stanie nie-


zdefiniowanym do czasu wykonania jednej z operacji fi rst() lub 1 ast(); niektóre iteratory
automatycznie pozycjonują się na pierwszym lub ostatnim elemencie, inne wymagają na-
tomiast jawnego pozycjonowania za pomocą jednej z tych operacji. Poleganie na domyśl-
nym pozycjonowaniu nie jest jednak dobrą praktyką nosi bowiem znamiona „programo-
wania na bazie zbiegów okoliczności" (programming by coincidence). W zamian zalecamy
posługiwanie się idiomami iteracyjnymi, opisywanymi w dalszej części niniejszego rozdziału.

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;
/**

* Iterator bazujący na wzorcach projektowych (Gamma i in. 1995).


*/
public interface Iterator {
/**

* (Re)pozycjonuje iterator na pierwszym elemencie.


* Gdy niezaimplementowana, wywołuje wyjątek UnsupportedOperationException
*/
public void firstO;
/**

* (Re)pozycjonuje iterator na ostatnim elemencie.


* Gdy niezaimplementowana, wywołuje wyjątek UnsupportedOperationException
*/
public void lastO:
/**

* Sprawdza, czy określony jest bieżący element iteracji


* zwraca false. gdy jest, i true. gdy nie jest
*/
public boolean isDoneO:

* Pozycjonuje iterator na następnym elemencie.


* Gdy niezaimplementowana, wywołuje wyjątek UnsupportedOperationException
*/
public void next();
Rozdział 2. • Iteracja i rekurencja 47

z**
* Pozycjonuje iterator na poprzednim elemencie.
* Gdy niezaimplementowana, wywołuje wyjątek UnsupportedOperationException
*/
public void previous():
/**

* Zwraca wartość bieżącego elementu;.


* Gdy bieżący element nie jest określony, wywołuje wyjątek
* IteratorOutOfBoundsException
*/
public Object currentO throws IteratorOutOfBoundsException:
}

Zgodnie z komentarzami każda z metod interfejsu odpowiada określonej operacji iteratora.

Wyjątek IteratorOutOfBoundsException jest klasą zdefiniowaną na bazie standardowej klasy


wyjątków RuntimeException:
package com.wrox.a1gori thm.i terati on;
public class IteratorOutOfBoundsException extends RuntimeException {
}

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

Interfejs Iterable deklaruje jedną metodę — iterator() — wyłuskujący iterator z ogólnie


rozumianej struktury danych, na której iterator ów operuje. Choć nie korzystamy z tego w ni-
niejszej książce, podejście takie umożliwia jednolite traktowanie wszelkich struktur danych
wykorzystujących iteratory, niezależnie od innych specyficznych właściwości tych struktur.

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

wywołania metody — pozycjonujemy go na pierwszym albo ostatnim elemencie, po czym


przechodzimy przez pozostałe elementy (o ile takowe istnieją).

W przełożeniu na kod źródłowy w języku Java wygląda to następująco:


Iterator iterator = ...;
iterator.firstO: // ew. "iterator.lastO"

while (!iterator.isDoneO) {
Object object = iterator.currentO:

iterator.next(): // ew. iterator.previous():


}

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 - ...;

for (iterator.firstO: !iterator.isDoneO;iterator.nextO) {


Object object - iterator.currentO:

)
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 = ...;

for (iterator.lastO : !iterator.isDone():iterator.previousO) {


Object object = iterator.currentO;
}

Każdy z przedstawionych idiomów jest schematem zarówno zalecanym, jak i spotykanym


często w rzeczywistych aplikacjach. Posługując się iteratorem, należy jednak zawsze się
upewnić (przed wywołaniem jego operacji currentO, next() lub previous()), że został on
początkowo ustawiony na pierwszym lub ostatnim elemencie. Zignorowanie tej zasady może
prowadzić do trudnych do wykrycia błędów, ujawniających się być może tylko w niektórych
implementacjach.

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.

iMŁflnl Testowanie iteratora tablicowego


Testy iteratora tablicowego przeprowadzimy w sposób typowy dla biblioteki JUnit, za po-
mocą następującej klasy:
package com.wrox.algorithm.iteration:
import junit.framework.TestCase
public class ArraylteratorTest extends TestCase {
} "'

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

W powyższym przykładzie elementy wycinka tablicy przetwarzane są w kolejności rosną-


cych indeksów, warto więc przetestować ich przetwarzanie także w kierunku przeciwnym.
public void testBackwardsIterationt) {
Object[] array - new Object[] {"A", "B". "C"};
Arraylterator iterator = new Arraylteratortarray):
50 Algorytmy. Od podstaw

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.

spróbuj sam Implementowanie iteratora tablicowego


Iterator tablicowy zbudujemy, rozszerzając standardowy interfejs Iterator o niezbędne odwo-
łania do tablicy i jej wyróżnionych elementów.
Rozdział 2. • Iteracja i rekurencja 51

Gdybyśmy założyli, że iterator przetwarza całą tablicę, jedynym wyróżnionym elementem


byłby element bieżący tej tablicy. Ponieważ jednak dopuszczamy możliwość operowania
także na wycinku tablicy, musimy również identyfikować elementy graniczne tego wycinka.
package com.wrox.a1gori thms.i terat i on;

public class Arraylterator implements Iterator {


/** Tablica, po której iterator nawiguje */
private finał Objectf] _array;

/** Indeks pierwszego elementu wycinka */


private finał int _first;

/* Indeks ostatniego elementu wycinka */


private finał int _last;

/** Indeks bieżącego elementu */


private int _current = -1;
/**

* Konstruktor specyfi kujący wycinek tablicy


* parametrami są: tablica, indeks pierwszego elementu i liczba elementów
*/

public ArrayIterator(Object[] array. int start, int length) {


assert array != nuli : "nie określono tablicy":
assert start >= 0 : "ujemny indeks elementu początkowego":
assert start < array.length : "indeks elementu początkowego poza tablicą";
assert length >= 0 : "ujemna liczba elementów";

_array = array;
_first = start;
_last = start + length - 1;

assert _last < array.length : "wycinek wykracza poza tablice";


}

}
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:

* Konstruktor specyfikujący całą tablicę


*/

public ArrayIterator(Object[] array) {


assert array != nuli : "nie określono tablicy";
_array = array:
_first = 0:
_last = array.length - 1;
1
52 Algorytmy. Od podstaw

Skoro dysponujemy już odwołaniem do tablicy i granicznymi indeksami wycinka, zaim-


plementowanie metod firstO i last() nie powinno stanowić problemu:
public void firstO {
_current = _first:
}
public void lastO {
_current = _last:
}
Równie nieskomplikowane jest przejście do elementów sąsiednich:
public void next() {
++_current:
}
public void previous() {
--_current;
}
Metoda isDoneO powinna zwrócić wartość true, jeśli nie jest określony bieżący element.
W naszym przypadku element ten nie jest określony, jeśli indeks bieżącej pozycji wykracza
poza graniczne indeksy wycinka ustalonego w wywołaniu konstruktora:
public boolean isDoneO {
return _current < _first || _current > _last:
}

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.

Przemieszczenie się na następny lub poprzedni element sprowadza się do (odpowiednio)


inkrementacji lub dekrementacji indeksu bieżącego elementu.

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 ().

Testowanie iteratora odwracającego


Działanie iteratora odwracającego najprościej będzie przetestować w oparciu o inny iterator
tablicowy. Istotą testu będzie sprawdzenie, czy iterowanie „w przód" w nowym iteratorze
równoważne jest iterowaniu wstecz w iteratorze oryginalnym — i vice versa.
package com.wrox.a1gori thms.i terati on:

import junit.framework.TestCase;

public class ReverseIteratorTest extends TestCase {


private static finał Object[] ARRAY = new Object[] {"A". "fi". "C"}:

Po utworzeniu „zwykłego" iteratora tablicowego (Arraylterator) tworzymy dla niego ite-


rator odwracający (ReverseIterator) i wykonujemy (za pomocą tego ostatniego) iterację
w przód, kontrolując jej rezultaty:
public void testForwardsIterationBecomesBackwardsO {
ReverseIterator iterator = new ReverseIterator(new Arraylterator(ARRAY));

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

Użyteczność iteratora odwracającego staje się oczywista, jeśli uświadomimy sobie, że


udało nam się odwrócić kierunek iterowania bez wnikania w szczegóły struktury danych, na
której operuje iterator oryginalny. Analogiczny test można przeprowadzić dla iteracji wstecz
— równoważnej iterowaniu „w przód" przez iterator oryginalny.
public void testBackwardsIterationBecomesForwardsO {
ReverseIterator iterator - new ReverseIterator(new Arraylterator(ARRAY));

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.

Wskutek wywoływania metody next() iteratora odwracającego — które równoważne jest


wywoływaniu metody previous() iteratora oryginalnego — wskaźnik elementu bieżącego
powinien przemieszczać się wstecz tablicy, w kierunku malejących indeksów.

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

spróbuj sam Implementowanie iteratora odwracającego


Zgodnie z wcześniejszymi uwagami implementacja iteratora odwracającego jest dość prosta:
należy zamienić (w stosunku do iteratora oryginalnego) znaczenie funkcji firstO i lastO
oraz funkcji next() i previousO, pozostawiając bez zmiany pozostałe.
Dla ułatwienia prezentujemy całą deklaracją klasy w jednym fragmencie,
bez dzielenia jej na poszczególne metody.
package com.wrox.algorithms.iteration;

public class ReverseIterator implements Iterator {


/** Iterator oryginalny */
private finał Iterator _iterator:

* 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?

Oprócz implementowania interfejsu Iterator klasa ReverseIterator utrzymuje wskaźnik


na iterator oryginalny. Metody isDoneO i currentO przejmowane są z iteratora oryginal-
nego bez zmian, znaczenie funkcji nawigacyjnych jest natomiast odwrócone w stosunku do
iteratora oryginalnego.
56 Algorytmy. Od podstaw

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.

Iteratorem filtrującym nazywamy iterator działający na bazie innego iteratora i procedury


klasyfikującej (akceptującej lub odrzucającej) elementy zwracane przez ten ostatni — pro-
cedura taka nosi nazwę predykatora. Iterator filtrujący ignoruje wszystkie te elementy, które
nie spełniają warunków określonych przez predykator.

Klasa predykatowa
Predykator może być utożsamiany z następującym interfejsem:
package com.wrox.a 1gori thms.i terat i on;

public interface Predicate {


public boolean evaluate(Object object);
_}

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.

miiłiiiB Testowanie klasy predykatowe)


Tym, co chcemy tak naprawdę uzyskać w wyniku testowania, jest pewność, że iterator fil-
trujący „przepuszcza" wszystkie elementy zaakceptowane przez predykator i ukrywa
wszystkie elementy przez predykator odrzucone. Użyjemy w tym celu dwóch różnych klas
predykatowych o skrajnym zachowaniu: jedna z nich będzie konsekwentnie akceptować
wszystkie elementy, a druga — konsekwentnie wszystkie odrzucać. W połączeniu z dwoma
kierunkami iterowania — w przód i wstecz — daje to cztery kombinacje testowe. Testowany
iterator funkcjonować będzie w oparciu o przykładową tablicę.
package com.wrox.aIgorithms.iterati on:

import junit.framework.TestCase;

public class FilterlteratorTest extends TestCase {


private static finał Object[] ARRAY = {"A". "B". "C"};

(
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:

public DummyPredicatetboolean result. Iterator iterator) {


_i terator = iterator;
_result = result;
iterator.firstO;

public boolean evaluate(Object object) {


assertSame(_iterator,current(). object);
_i terator.next();
return result:

Testowanie rozpoczniemy od sprawdzenia predykatora akceptującego w połączeniu z ite-


rowaniem w przód — powinny być widoczne wszystkie elementy tablicy:
public void testForwardsIterationIncludesItemsWhenPredicateReturnsTrue() {
Iterator expectedIterator = new Arraylterator(ARRAY);
Iterator underlyinglterator = new Arraylterator(ARRAY);

Iterator iterator = new FilterIterator(underlyingIterator.


new DummyPredicate(true, expectedIterator));

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

Jeśli zamiast predykatora akceptującego zastosujemy predykator konsekwentnie odrzucający,


nie powinien być widoczny żaden element:
public void testForwardsIterationExcludesItemsWhenPredicateReturnsFalset) {
Iterator expectedIterator = new ArrayIterator(ARRAY):
Iterator underlyinglterator - new Arraylterator(ARRAY):

Iterator iterator = new FilterIterator(underlyingIterator.


new DummyPredicatetfalse, expectedIterator)):

iterator.firstO:
assertTrue(iterator.i sDone());
try {
iterator.currentt):
failO:// zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertTrue(expectedIterator.isDone());
assertTrue(underlyingIterator.isDone()):
1

Obydwa testy należy jeszcze powtórzyć w warunkach iteracji wstecz:


public void testBackwardssIterationlncludesItemsWhenPredicateReturnsTrueO {
Iterator expectedIterator = new ReverseIterator(new Arraylterator(ARRAY));
Iterator underlyinglterator - new Arraylterator(ARRAY):

Iterator iterator = new Filterlteratortunderlyinglterator.


new DummyPredicatettrue. expectedIterator)):

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

public void testBackwardsIterationExcludesItemsWhenPredicateReturnsFalse() {


Iterator expectedIterator = new ReverseIterator(new Arraylterator(ARRAY)):
Iterator underlyinglterator = new Arraylterator(ARRAY);

Iterator iterator = new FilterIterator(underlyingIterator,


new DummyPredicate(false, expectedIterator)):

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 tym celu zdefiniowaliśmy wewnętrzną klasę DummyPredicate wyposażoną w dodatkowy


iterator testowy. Iterator ten zwraca elementy w takiej kolejności, w jakiej powinny one tra-
fiać do predykatora; przy każdym wywołaniu predykatora argument jego wywołania po-
równywany jest z elementem udostępnianym przez tenże iterator. W ten sposób zyskujemy
pewność, że do metody evaluate() przekazywane będą właściwe wartości. Metoda ta zwraca
wynik określony a priori, co pozwala nam przeprowadzić test w warunkach zarówno bez-
względnego akceptowania elementów, jak i bezwzględnego ich odrzucania.

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.

W przypadku predykatora odrzucającego sprawa jest znacznie prostsza, bowiem po wywo-


łaniu metody fi rst O lub 1 ast () iterator powinien od razu przejść do stanu wyczerpania — sko-
ro predykator odrzuca wszystkie elementy, to nie ma elementów pierwszego ani ostatniego.
Warto przy tym zwrócić uwagę na pewien ciekawy szczegół w testach związanych z iterowaniem
wstecz: otóż testowy iterator w klasie predykatowej konsekwentnie nawiguje w przód tablicy
— w konstruktorze klasy wywoływana jest jego metoda firstO, a w metodzie evaluate() —
metoda next(). Jak więc zmusić go do iterowania wstecz zgodnie z kierunkiem iteratora
60 Algorytmy. Od podstaw

zasadniczego? Oczywiście wykorzystując iterator odwracający (ReverseIterator). Iterator


expectedIterator przy nawigowaniu w przód będzie udostępniał elementy tablicy w kolejności
malejących indeksów, poczynając od ostatniego elementu — czyli tak samo jak iterator
underlyinglterator przy nawigowaniu w przód.

Implementowanie klasy predykatowej


Interfejs predykatora zdefiniowaliśmy już na potrzeby testu, pozostaje więc zaimplemen-
towanie samej klasy iteratora filtrującego.
package com.wrox.a 1gori thms.i terat i on;
public class Filterlterator implements Iterator {
/** iterator oryginalny */
private finał Iterator _iterator:

/** 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

while ( Mterator.isDone() && !_predicate.evaluate(_iterator.currentO)) {


iterator.next();
}
)
Analogicznie wygląda implementacja metod lastO i previous(): ich wywołanie delego-
wane jest do analogicznych metod iteratora oryginalnego, po czym następuje poszukiwanie
(wstecz) najbliższego elementu akceptowanego przez predykator.
public void lastO {
_iterator.last():
fi IterBackwardsO;
}
public void previous() {
_iterator.previous();
filterBackwardsO;
}
private void filterBackwardsO {
while ( M t e r a t o r . i s D o n e O && !_predicate.evaluate(_iterator.currentO)) {
_iterator.previousO:
}
}
Utworzony iterator filtrujący może być stosowany do każdej struktury, z którą związany
jest jakiś iterator. Należy tylko zdefiniować odpowiednią klasę predykatową reprezentującą
kryteria filtrowania.

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

private void filterForwardsO {


while ( M t e r a t o r . i s D o n e O && !_predicate.evaluate(_iterator.currentO)) {
_iterator.next();
}
}
Metoda filterForwardsO dokonuje poszukiwania elementu spełniającego kryteria predy-
katora, poczynając od elementu, na którym aktualnie pozycjonowany jest iterator zasadni-
czy, i w razie potrzeby poruszając się w przód za pomocą metody next() tego iteratora.
Możliwe jest, że element akceptowalny dla predykatora nie zostanie znaleziony, dlatego
przed wywołaniem predykatora sprawdzane jest (za pomocą wywołania metody isDone())
istnienie elementu bieżącego. Zwróćmy uwagę, że opisany proces poszukiwania odbywa
się przez bezpośrednie wywoływanie metod iteratora zasadniczego; pozwala to uniknąć
niepożądanych zapętleń mogących prowadzić do załamania programu.

W analogiczny sposób prowadzone jest poszukiwanie elementu wstecz, wykonywane przez


metodę filterBackwardsO:
public void lastO {
Jterator.lastO:
filterBackwardsO;
}
public void previous() {
_iterator.previous();
filterBackwardsO;
}
private void filterBackwardsO {
while ( M t e r a t o r . i s D o n e O && !_predicate.evaluate(_iterator.currentO)) {
_iterator.previous():
}
}
Przeszukiwanie prowadzone jest wstecz i, podobnie jak w przypadku metody filterFor-
wardst ), rozpoczyna się od elementu, na którym pozycjonowany jest aktualnie iterator za-
sadniczy; przez cały czas kontrolowane jest istnienie elementu bieżącego poprzez wywoła-
nia metody isDoneO.

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

Jedną z najbardziej interesujących właściwości takiego drzewa (z programistycznego punktu


widzenia) jest fakt, iż każda z jego gałęzi może być traktowana jak inne, mniejsze drzewo.
Widać to wyraźnie na rysunku 2.2, gdzie wyróżniono jedną z gałęzi.

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.

Przykład—rekurencyjne drukowanie drzewa katalogów


Podręczniki programowania obfitują w przykłady algorytmów rekurencyjnych — poszuki-
wania liczb pierwszych, obliczania liczb Fibonnacciego, poszukiwania drogi w labiryncie
i wielu innych rzeczy, rzadko raczej spotykanych na co dzień. Dorzucimy do tego zestawu
kolejną pozycję — drukowanie zawartości drzewa katalogów.

Wydruk odzwierciedlać ma w czytelnej formie hierarchiczną zależność katalogów: nazwy


podkatalogów i plików mają być przesunięte w prawo („wcięte") w stosunku do nazwy ka-
talogu macierzystego, podobnie jak w tekstowej wersji eksploratora Windows lub wyszu-
kiwarce Max OS Finder. Rekurencyjna natura algorytmu rozwiązującego to zadanie bierze
się stąd, że sposób przetwarzania danego katalogu zastosowany zostaje do przetworzenia
wszystkich jego podkatalogów.

Rozpoczniemy od zdefiniowania klasy wykonującej opisane zadanie; by można było uru-


chamiać tę klasę z wiersza poleceń, wyposażymy ja w metodę main().

package com.wrox.algorithms.iteration;

import java.i o.File:


public finał class RecursiveDirectoryTreePrinter {
/** ciąg spacji oznaczający pojedyncze wcięcie */
private static finał String SPACES = " ";
/**

* Parametr wywołania: nazwa katalogu


*/
public static void main(String[] args) {
assert args != nuli : "nie podano nazwy katalogu";

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";

System.out.pri nt(i ndent):


System.out.pri nt1n(fi 1e.getName()):

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";

for (int i = 0; i < files.length; ++i) (


print(files[i], indent):
}
_}
Powyższa metoda iteruje tablicę plików, wywołując oryginalną metodę print() dla każdego
pliku.

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

Rekurencyjny charakter algorytmu drukowania drzewa katalogów odzwierciedlony zostaje


poniekąd w formatowaniu wydruku — wiersze drukowane w ramach bardziej zagnieżdżo-
nych wywołań metody printO przesunięte są bardziej w prawo.
Każdy problem mający rozwiązanie rekurencyjne daje się także rozwiązać w sposób
iteracyjny, choć jego rozwiązanie iteracyjne może być mniej czytelne w porównaniu
z rekurencyjnym, a niekiedy wręcz sztuczne. Rekurencja może być ponadto symulowana
w sposób iteracyjny, przy użyciu struktur danych zwanych stosami, które omawiamy
w rozdziale 5.

Anatomia algorytmu rekurencyjnego


W każdym algorytmie rekurencyjnym można wyodrębnić dwa elementy: przypadek bazowy
(base case) oraz przypadek ogólny (generał case). Opiszemy je krótko w kontekście pre-
zentowanego algorytmu drukowania drzewa katalogów.
Rozdział 2. • Iteracja i rekurencja 69

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.

Dokładniej rzecz ujmując, metoda printO występuje w dwóch przeciążonych aspektach.


Pierwszy z nich otrzymuje w wywołaniu pojedynczy obiekt Fi le, drugi — całą tablicę ta-
kich obiektów. W efekcie pierwszy z nich zawsze wywoływany jest z poziomu drugiego,
zaś drugi wywoływany jest z poziomu pierwszego tylko wtedy, gdy argument wywołania
(Fi 1 e) reprezentuje katalog.
Przypadek wzajemnego wywoływania się dwóch metod (lub dwóch przeciążonych
aspektów metody, jak w prezentowanym przykładzie) nazywamy rekurencją
wzajemną (mutual recursion).

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.

Czytając zakończony właśnie rozdział, miałeś okazję się przekonać, że:


• niektóre problemy posiadają oczywiste rozwiązania iteracyjne,
podczas gdy dla innych bardziej naturalne są rozwiązania rekurencyjne,
• iteracje są prostym środkiem wykonywania wielu powszechnych czynności,
jak wykonywanie powtarzalnych obliczeń i przetwarzanie tablic,
70 Algorytmy. Od podstaw

• 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.

Niniejszy rozdział rozpoczynamy od przedstawienia podstawowych operacji wykonywa-


nych na listach. Następnie przechodzimy do stworzenia testów dla dwóch najczęściej wy-
korzystywanych implementacji list: list tablicowych i list wiązanych. Funkcjonalność każdej
z tych implementacji daje się opisać tym samym interfejsem, lecz ich własności są całkowi-
cie odmienne i to właśnie one decydują o wyborze konkretnej implementacji w konkretnym
zastosowaniu. Treść rozdziału przynosi odpowiedź na następujące pytania:
• Czym są listy i jak są one skonstruowane?
• Jak wykorzystuje się listy w aplikacjach?
• W jaki sposób można implementować listy?

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.

Tabela 3.1. Podstawowe operacje listowe

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.

size Zwraca rozmiar listy, czyli liczbę jej elementów.

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.

Każda z wymienionych operacji uzupełniających może być zaimplementowana na bazie


czterech operacji podstawowych wymienionych na wstępie: przykładowo operację set()
zrealizować można jako złożenie deleteO i insertO, operację add() — j a k o kombinację
sizeO i insertO, a operację isEmptyO przez porównanie z zerem wartości zwracanej
przez sizeO. Mimo to włączenie ich do interfejsu listy znakomicie podnosi komfort ko-
rzystania z tej struktury danych i daje świadectwo jej wyjątkowej użyteczności.

Tworzenie interfejsu listowego


Mając już zdefiniowaną listę operacji listowych, możemy zadeklarować w języku Java inter-
fejs dla struktury listowej.
Rozdział 3. • Listy 73

Tabela 3,2. Przykładowa propozycja rozszerzenia interfejsu listowego

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.

add Dołącza element na koniec listy. Rozmiar listy zostaje z w i ę k s z o n y o 1.

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ą'.

contains Sprawdza, czy w liście występuje element o wskazanej wartości.

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.

iterator Zwraca iterator zapewniający nawigowanie po elementach listy.

elear Usuwa z listy wszystkie elementy — lista staje się listą pustą.

package com.wrox.algori thms.1 i sts:

i mport com.wrox.algori thms.i terati on.Iterable;

public interface List extends Iterable {


public void inserttint index. Object value) throws IndexOutOfBoundsException;
public void add(Object value);
public Object delete(int index) throws IndexOutOfBoundsException:
public boolean delete(0bject value):
public void clearO;
public Object settint index. Object value) throws IndexOutOfBoundsException;
public Object get(int index) throws IndexOutOfBoundsException:
public int indexOf(Object value);
public boolean contains(Object value);
public int sizeO:
public boolean isEmptyO;

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":

for (int i = 0 : i < anArray.length: ++i) {


System.out.println(anArray[i]):

Zachowanie to możemy zaprogramować w postaci bardziej ogólnej, abstrahując od kon-


kretnej implementacji listy (tablica łańcuchów String[]) i opierając się na interfejsie List:
List aList = ... :

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.

Tworzenie klasy testowej


Wspominaliśmy już, że spośród potencjalnie wielu możliwych implementacji list najczę-
ściej stosowane są dwie: tablica i lista wiązana. Można by w związku z tym przypuszczać,
że konieczne jest stworzenie co najmniej dwóch klas testowych, po jednej dla każdej z wy-
mienionych implementacji, my jednak uprościmy sobie nieco zadanie, tworząc pojedynczy,
na wpół uniwersalny test, który łatwo będzie przystosowywać do konkretnej implementacji.
W tym celu stworzymy najpierw abstrakcyjną klasę bazową definiującą przypadki testowe;
z klasy tej wyprowadzane będą ostateczne klasy testowe dedykowane poszczególnym im-
plementacjom.

package com.wrox.a 1gori thms.1 i sts:

import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.a1gori thms.i terati on.IteratorOutOfBoundsExcept i on;
import junit.framework.TestCase:

public abstract class AbstractListTestCase extends TestCase {


protected static finał Object VALUE_A = "A":
protected static finał Object VALUE_B = "B":
protected static finał Object VALUE_C = "C":

protected abstract List createListO:


}

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.

Przejdźmy więc teraz do konkretnych testów.

spróbuj sam Testowanie metod wstawiających i dołączających elementy do listy


Wstawianie elementów do listy jest chyba najbardziej fundamentalną operacją listową;
gdyby nie ona, wszystkie utworzone listy musiałby pozostać puste. Oto prosty kod testujący
poprawność wstawiania elementu do pustej listy:
public void testlnsertlntoEmptyListO {
List list = createListO: // utworzenie pustej listy

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

list.insertCO. VALUE_A): // lewy element


list.insertd. VALUE_B): // prawy element
list.insertd. VALUE_C): // wstawienie między lewy i prawy element
assertEquals(3. list.sizeO);

assertSame(VALUE_A. 1ist .get(O)):


assertSame(VALUE_C, list.get(D);
assertSame(VALUE_B, list.get(2));
}

Naturalną koleją rzeczy musimy teraz przetestować poprawność wstawiania elementu na


początek listy
public void testlnsertBeforeFirstElementO {
List list - createListO; // utworzenie pustej listy

list.insertCO. VALUE_A); // wstawienie na początek


list.insertCO. VALUE_B): // wstawienie na początek

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)):
}

Po upewnieniu się, że w naszej implementacji poprawnie realizowane są legalne operacje


wstawiania elementów, musimy przekonać się co do tego, że niewykonalna próba wstawie-
nia elementu — będąca ewidentnym błędem programistycznym — skończy się wygenero-
waniem wyjątku IndexOutOfBoundsException:
public void testlnsertOutOfBoundsC) {
List list = createListO; // utworzenie pustej listy
tryj
Rozdział 3. • Listy 77

1 i st.i nsert(-1. VALUE_A); // próba wstawienia przed początkiem listy


failO; // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane
}
try {
list.insertd. VALUE_B); // próba wstawienia poza końcem listy
failO: // zachowanie nieoczekiwane
} catch (IndexOutOfBoundsException e) {
// zachowanie oczekiwane

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ą.

public void testAddO {


List list = createListO; // utworzenie pustej listy

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.

Metoda testlnsertBetweenElementsO testuje poprawność wstawiania elementu między


dwa istniejące elementy. Na początku do pustej listy wstawione zostają dwa elementy, A i B,
w wyniku czego lista przyjmuje postać pokazaną na rysunku 3.1.

Rysunek 3.1. Indeks 0 Indeks 1


Zawartość listy
przed wstawieniem A B
elementu C

Po wstawieniu elementu C między elementy A i B lista powinna przyjąć postać pokazaną na


rysunku 3.2.
78 Algorytmy. Od podstaw

Rysunek 3.2. Indeks 0 Indeks 1 Indeks 2


Zawartość listy
po wstawieniu A C B
elementu C między
elementy A i B

Jak łatwo zauważyć, element B został przesunięty na sąsiednią pozycję w prawo, w celu
zrobienia miejsca dla elementu C.

W wyniku wstawienia elementu na początek listy wszystkie pozostałe elementy powinny


zostać przesunięte „w prawo" o jedną pozycję. W metodzie testlnsertBeforeFirstEle-
mentO element A zostaje wstawiony do pustej jeszcze listy na pozycję 0. Wstawienie ele-
mentu B na pozycję 0 powinno spowodować, że element A zostanie przesunięty z pozycji 0
na pozycję 1.

Wstawianie elementu na koniec listy — testowane w ramach metody testlnsertAfterLa-


stEl ement() — oznacza wstawianie na pozycję o 1 większą niż indeks (ewentualnego) ostat-
niego elementu, czyli na pozycję o indeksie równym rozmiarowi listy. W przypadku listy
pustej jest to pozycja 0, w przypadku listy jednoelementowej pozycja 1, w przypadku listy
zawierającej 2 elementy — pozycja 2.

Metoda testlnsertOutOfBoundsC) testuje zachowanie się listy wobec najczęściej popełnia-


nych błędów programistycznych, czyli próby wstawienia elementu na pozycję o indeksie
ujemnym lub indeksie przekraczającym rozmiar listy. W przypadku listy pustej element
można wstawić więc jedynie na pozycję 0. Użycie niewłaściwego indeksu powinno powo-
dować wystąpienie wyjątku IndexOutOfBoundsException; jeżeli wyjątek ten nie wystąpi, test
zostaje zakończony awaryjnie przez wywołanie metody fai 1 ().

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.

Testowanie metod pobierających i zapamiętujących wartości elementów


Wartości elementów listy można odczytywać i modyfikować. Służą do tego głównie metody
get O i set O, w połączeniu z badaniem rozmiaru listy (za pomocą metod sizeO i i sEmptyC)).
Rozpocznijmy od metody setO:
public void testSetO {
List list = createListO: // utworzenie pustej listy
list.insertCO. VALUE_A);
assertSame(VALUE_A, list.get(O)):

assertSame(VALUE_A. list.setCO. VALUE_B)):


assertSame(VALUE_B, 1 i st.get(0)):
}
Rozdział 3. • Listy 79

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

Analogiczny test należy przeprowadzić także dla metody s e t ( ) :


public void testSetOutOfBounds() {
List list = createListO; // utworzenie pustej listy

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.

W metodzie testGet0ut0fBounds() następuje utworzenie pustej listy i próba pobrania ele-


mentu o indeksie - 1 ; powinno to oczywiście spowodować wystąpienie wyjątku Index0ut0f-
BoundsException. Identyczny efekt powinna dać kolejna próba — próba zmodyfikowania
wartości elementu o indeksie 0; lista jest przecież pusta.

Na podobnej zasadzie funkcjonuje metoda testSet0ut0fBounds() z tą jednak różnicą że


zamiast próby pobrania wartości nieistniejącego elementu podejmujemy próbę jego zmody-
fikowania za pomocą metody set ().

Testowanie metod usuwających elementy z listy


Jednym ze szczególnych przypadków usuwania elementu jest usuwanie jedynego elementu;
po jego usunięciu lista powinna stać się listą pustą.
public void testDeleteOnlyElement() {
List list = createListO; // utworzenie pustej listy

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

Podobnie interesujący jest przypadek usuwania ostatniego elementu z listy zawierającej


więcej niż jeden element:
public void testDeleteLastElementO {
List list = createListO: // utworzenie pustej listy

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)):
}

Usunięcie jednego z pośrednich elementów powinno spowodować przesunięcie następnych


elementów o jedną pozycje w lewo, bez zmiany pozycji elementów poprzednich:
public void testDeleteMiddleElementO {
List list = createListO: // utworzenie pustej listy

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)):
}

Nie można wreszcie zapomnieć o przetestowaniu reakcji na próbę usunięcia nieistniejącego


elementu:
public void testDeleteOutOfBoundsO {
List list = createListO: // utworzenie pustej listy

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

Próba usunięcia nieistniejącego elementu powinna spowodować wystąpienie wyjątku In-


dexOutOfBoundsException. W metodzie testDeleteOutOfBounds() podejmuje się dwie takie
próby: w pierwszej specyfikuje się indeks ujemny, w drugiej indeks zbyt duży w stosunku
do rozmiaru listy.

Metoda testDeleteByValue() testuje operację usuwania elementu o znanej wartości, lecz


nieznanym indeksie. Do pustej początkowo listy wstawione zostają trzy elementy, przy
czym pierwszy i trzeci element mają identyczną wartość VALUE_A. Następnie żąda się usu-
nięcia elementu o wartości VALUE_A; powinno to spowodować usunięcie elementu o indek-
sie 0. Kolejne żądanie usunięcia elementu o wartości VALUE_A spowoduje usunięcie ele-
mentu ostatniego, w liście pozostanie jeden element o wartości VALUE_B. W tych warunkach
metoda deleteO wywołana z wartością VALUE_A zwróci wartość false, ponieważ żądanego
elementu nie ma w liście (udane próby usunięcia elementu o wskazanej wartości powodują
zwrócenie wartości true). Ostatecznie (udana) próba usunięcia elementu o wartości VALUE_B
pozostawia listę pustą.

spróbuj sam Testowanie iteratora


Najtrudniejszym elementem implementowania listy jest implementowanie iteratora umoż-
liwiającego nawigowanie po jej elementach. Jak pamiętamy, iterator ten dostępny jest za
pośrednictwem metody iteratorO dziedziczonej przez interfejs List po interfejsie Iterable
(patrz rozdział 2.).

Funkcjonowanie iteratora listy przetestowane zostanie w kontekście trzech scenariuszy: nawi-


gowania po pustej liście, nawigowania w przód począwszy od pierwszego elementu i nawi-
gowania wstecz począwszy od elementu ostatniego.

Rozpoczniemy od iterowania pustej listy:


public void testEmptyIteration() {
List list = createListO: // utworzenie pustej listy

Iterator iterator = list.iteratorO:


assertTrue( i terator. i sDoneO):

try {
iterator.currentt):
failO: // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}

a następnie przetestujemy nawigację przez całą listę w przód, począwszy od pierwszego


elementu...
public void testForwardlterationO {
List list - createListO: // utworzenie pustej listy

list.add(VALUE_A):
list.add(VALUE_B):
list.add(VALUE C):
84 Algorytmy. Od podstaw

Iterator iterator = list.iteratorO:

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

... i wstecz, począwszy od ostatniego elementu:


public void testReverseIteration() {
List list = createListO: // utworzenie pustej listy

list.add(VALUE_A);
list.add(VALUE_B);
list.add(VALUE_C);

Iterator iterator = list.iteratorO;

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.

W metodzie testForwardIteration() tworzona jest trój elementowa lista i uzyskiwany jest


jej iterator. Następnie, poprzez wywołanie metody fi rst() i sukcesywne wywołania metody
next() tego iteratora, następuje przejście w przód listy przez jej kolejne elementy. Po przej-
ściu przez wszystkie elementy iterator przechodzi do stanu wyczerpanego, a jego metoda
isDone() zaczyna 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.

spróbuj sam Testowanie metod wyszukujących elementy


Metodami „wyszukującymi elementy" są metody indexOf() i constains().

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);

assertTruet1 i st.contai ns(VALUE_A));


assertTruet1 ist.containstVALUE_B)):
assertFa1 set 1 i st.conta i ns(VALUE C));
86 Algorytmy. Od podstaw

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.

Testowanie czyszczenia listy


Ostatni, lecz nie mniej ważny test dotyczy metody clearO; jej wywołanie usuwa z listy
wszystkie elementy.
public void testClearO {
List list = createListO; // utworzenie pustej listy

1 i St,add(VALUE_A);
liSt.add(VALUE_B);
1 ist.add(VALUE_C);

assertFal s e O i st. i sEmpty O ) ;


assertEquals(3. list.sizeO):

list.clearO;

assertTrueC1 i st.i sEmpty());


assertEquals(0, list.sizeO):
}

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.

Rozpoczniemy od implementacji tablicowej jako zdecydowanie prostszej niż lista wiązana.


Każda z tych dwóch implementacji posiada pewne cechy charakterystyczne, które — z punktu
widzenia konkretnego zastosowania — mogą okazywać się korzystne albo kłopotliwe.
Wyjaśnimy to szczegółowo w kontekście konkretnego kodu.

Niezależnie od konkretnej implementacji musimy przyjąć pewne założenia dotyczące typu


elementów przechowywanych w liście. W szczególności wykluczamy możliwość przecho-
wywania w liście wartości pustych (nuli). Pozwoli nam to znacznie uprościć kod dzięki
pominięciu wielu ograniczeń związanych z używaniem pustych wartości, a ponadto nie jest
szczególnie krepujące, ponieważ z większości aplikacji wartości puste przechowywane są
w listach bardzo rzadko, jeśli w ogóle.

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.

Słabą stroną implementacji tablicowej jest natomiast nieefektywność operacji wstawiania


i usuwania elementów, operacje te wiążą się bowiem z koniecznością przemieszczania du-
żych grup elementów sąsiednich — celu zrobienia miejsca dla elementu wstawianego i dla
zniwelowania „dziury" po usuniętym elemencie. Owo „przesuwanie" elementów polega de
facto na ich fizycznym kopiowaniu.

Ponadto, jako że tablice są strukturami o ustalonym rozmiarze, każdorazowe zwiększenie


rozmiaru listy ponad aktualny rozmiar tablicy wymaga przydzielenia nowej, większej tablicy
i skopiowania do niej aktualnej zawartości listy. Podobnie, gdy zbyt duża część tablicy okaże
się nieużywana, może być wskazane zastąpienie jej inną mniejszą tablicą. Wpływa to zna-
cząco na efektywność operacji wstawiania i usuwania elementów. Tablicowa implementa-
cja listy może być jednak dobrym punktem wyjściowym dla programisty przyzwyczajonego
do tablic i rozpoczynającego pracę z bardziej skomplikowanymi strukturami danych.

Tworzenie klasy testowej


Zgodnie z wcześniejszym założeniem testową klasę ArrayListTest wyprowadzimy z ogól-
nej klasy abstrakcyjnej AbstractListTestCase:
package com.wrox.a1gori thms.1 i sts:
public class ArrayListTest extends AbstractListTestCase {
protected List createListO {
88 Algorytmy. Od podstaw

return new ArrayListO;

public void testResizeBeyondlnitialCapacityO {


List list = new ArrayList(l);

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));
}

public void testDeleteFromLastElementlnArrayt) {


List list = new ArrayList(l);

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.

Pierwszy test, wykonywany przez metodę testResizeBeyondInitialCapacity(), jest ko-


nieczny z tego względu, że zwiększenie rozmiaru listy może wymagać zwiększenia rozmiaru
tablicy przechowującej elementy, a to pociąga za sobą kopiowanie istniejących elementów
i niezbędne jest upewnienie się, że kopiowanie to przebiega prawidłowo. W tym celu two-
rzona jest lista oparta na tablicy jednoelementowej, po czym do listy tej dodawane są trzy
elementy, co nieuchronnie spowodować musi przynamniej jednorazowe zwiększenie roz-
miaru tablicy.

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.

Tworzenie klasy testowej


Mając gotowy zestaw testowy dla listy tablicowej, zajmijmy się klasą ArrayList imple-
mentującą tę listę.
Rozdział 3. • Listy 89

package com.wrox.a 1gori thms.1 i sts:

import com.wrox.algori thms.i terat i on.ArrayIterator;


import com.wrox.algorithms.iteration.Iterator:

public class Arraylist implements List {


/** domyślny rozmiar początkowy tablicy */
private static finał int DEFAULT_INITIAL_CAPACITY = 16:

/** początkowy rozmiar tablicy */


private finał int _initialCapacity:

/** tablica przechowująca elementy listy */


private Object[] _array;

/** rzeczywisty rozmiar listy, niezależny od rozmiaru tablicy */


private int _size;

* 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.

spróbuj sam Metody realizujące wstawianie i dołączanie elementów


Poniższa metoda wstawia element do listy na wskazaną pozycję.
public void inserttint index, Object value) throws IndexOutOfBoundsException {
assert value != nuli : "Nie można wstawiać wartości pustych";

if (index < 0 || index > _size) {


throw new IndexOutOfBoundsException();
}
ensureCapacity(_size + 1);
System.arraycopy(_array. index. _array. index + 1. _size - index);
_array[index] = value:
++_size;
}
private void ensureCapacity(int capacity) {
assert capacity > 0 : "rozmiar musi być dodatni";

if (_array.length < capacity) {


0bject[] copy = new Object[capacity + capacity / 2];
System.arraycopy(_array, 0. copy, 0. _size);
_array = copy:

Dołączenie elementu do listy jest oczywiście tożsame z wstawieniem go na pozycję nastę-


pującą bezpośrednio po ostatnim elemencie, czyli na pozycję o indeksie równym rozmia-
rowi listy.
public void add(Object value) {
insert(_size, value):
}

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 ensureCapacity() wykonuje dynamiczną zmianę rozmiaru (dynamie resizing) ta-


blicy przechowującej elementy. Gdy rozmiar istniejącej tablicy jest mniejszy od rozmiaru
żądanego, następuje utworzenie nowej tablicy, skopiowanie do niej zawartości tablicy do-
tychczasowej i zastąpienie tablicy dotychczasowej tablicą nowo utworzoną. Tablica do-
tychczasowa zostaje automatycznie zwolniona przez mechanizm automatycznego odśmie-
cania (garbage collection). Aby uniknąć zbyt częstych reorganizacji tablicy, nową tablicę
tworzy się z 50-procentowym zapasem — jeśli N jest wymaganym (minimalnym) rozmia-
rem, nowa tablica ma rozmiar N + (N/2).

Metoda add() jest po prostu szczególnym przypadkiem metody insertO — element wsta-
wiany jest na koniec listy.

Metody zapamiętujące i odczytujące wartość elementu


na podstawie jego indeksu
Tytułowe metody noszą nazwy get O i set O. Jako że elementy listy zorganizowane są
w tablicę, odczytywanie i zapamiętywanie ich wartości jest sprawą bardzo prostą.
public Object getCint index) throws IndexOutOfBoundsException {
checkOutOfBounds(index);
return _array[index];
}
public Object set(int index. Object value) throws IndexOutOfBoundsException {
assert value != nuli : "wartość nie może być pusta":
checkOutOfBounds(i ndex);
Object oldValue = _array[index]:
_array[index] = value:
return oldValue:
}
private void checkOutOfBoundstint index) {
if (isOutOfBounds(index)) {
throw new IndexOutOfBoundsException();
}
}
private boolean isOutOfBounds(int index) {
return index < 0 || index >= sizeO:
1

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

mimiiiB Metody wyszukujące elementy na podstawie wartości


Metody get O i set O organizują dostęp do elementów o znanych pozycjach, co czyni je
szczególnie przydatnymi dla niektórych typów sortowania (patrz rozdziały 6. i 7.) i wyszu-
kiwania (rozdział 9.). Znalezienie w nieposortowanej liście elementu o znanej wartości (czyli
określenie pozycji tego elementu) wymaga prymitywnej i nieskomplikowanej metody prze-
szukiwania liniowego. Metoda indexOf() zwraca pozycję elementu o wartości wskazanej przez
parametr wywołania; jeśli element taki nie występuje w liście, metoda zwraca wartość - 1 .
public int indexOf(Object value) {
assert value != nuli : "Wartość nie może być pusta";

for (int i = 0 ; i < _size; ++i) {


if (value.equals(_array[i])) {
return i;
}
}
return -1;

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

W rozdziale 9. poznamy bardziej efektywną metodę wyszukiwania binarnego, na razie nie


pozostaje nam nic innego jak zadowolić się „siłowym" (brute force) przeszukiwaniem li-
niowym.

Metody usuwające elementy


Interfejs List deklaruje dwie metody usuwania elementów, a dokładniej — dwa warianty
(aspekty) przeciążonej metody deleteO. Pierwszy z nich bazuje na indeksie usuwanego
elementu.
public Object delete(int index) throws IndexOutOfBoundsException {
checkOutOfBounds(i ndex);
Object value = _array[index];
int copyFromIndex = index + 1;
if (copyFromIndex < _size) {
System.arraycopy(_array, copyFromlndex,
_array. index,
_size - copyFromIndex);
}
_array[--_size] = nuli;
return value:
}
Drugi wariant metody deleteO wywoływany jest z parametrem określającym wartość
usuwanego elementu. Wartość ta zostaje „przeliczona" na pozycję elementu (podobnie jak
w metodzie containsO) za pomocą funkcji indexOf().
public boolean delete(Object value) {
int index = indexOf(value);
if (index != -1) {
delete(index);
return true;
}
return false:
]

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).

Zerowanie zwolnionej pozycji, mimo iż niekonieczne z punktu widzenia (prawidłowego)


funkcjonowania (przetestowanej) listy, jest jednak wskazane z punktu widzenia automa-
tycznego odśmiecania — gdyby przypadkiem nastąpiło utworzenie kopii bezużytecznej
wartości trzymanej na zwolnionej pozycji, wartość ta nie mogłaby zostać automatycznie
zwolniona. Zjawisko pozostawania w pamięci niepotrzebnych instancji klas nazywane jest
popularnie wyciekiem (lub przeciekiem) pamięci (memory leak).
94 Algorytmy. Od podstaw

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).

spróbuj sam Kompletowanie interfejsu


Zaimplementowaliśmy już niemal wszystkie metody interfejsu List — z wyjątkiem trzech
następujących:
public Iterator iteratorO {
return new ArrayIterator(_array. 0, _size):
}
public int sizeO {
return _size;
}
public boolean i sEmptyC) (
return sizeO == 0:
1

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

Implementacja metody sizeO nie wymaga wielu komentarzy — metoda ta udostępnia po


prostu wartość pola _size na bieżąco aktualizowanego przez metody insert() i deleteO.

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.

Rysunek 3.3. Indeks 0 Indeks 1 Indeks 2

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.

Z listą podwójnie wiązaną skojarzone są zazwyczaj dwa dodatkowe łączniki, do pierwszego


i ostatniego elementu, zwane (odpowiednio) głową (head) i ogonem (taił) albo po prostu
początkiem i końcem listy. Umożliwiają one dostęp do obydwu „krańców" listy z jednakową
efektywnością.

spróbuj sam Tworzenie klasy testowej


W celu przetestowania zgodności listy wiązanej z opisem zawartym w tabelach 3.1 i 3.2
stworzymy klasę testową Li nkedLi stTest na bazie abstrakcyjnej klasy AbstractLi stTestCase:
package com.wrox.algorithms.1 ists:

public class LinkedListTest extends AbstractListTestCase {


protected List createListO {
return rew LinkedListC):
}
}
96 Algorytmy. Od podstaw

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.

mifrf.iiiB Definiowanie klasy reprezentującej listę wiązaną


Definiowanie klasy LinkedList rozpoczniemy od zdefiniowania jej pól i konstruktora:
package com.wrox.a1gori thms.1 i sts;

import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.i terati on.IteratorOutOfBoundsExcepti on:

public class LinkedList implements List {


/** element-wartownik posiadający wskaźniki na pierwszy i ostatni
element listy */
private finał Element JieadAndTail = new Elementtnull);

/** Rozmiar listy */


private int _size;

* 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!

Niewątpliwie najbardziej zagadkowym elementem przedstawionej definicji jest niemodyfi-


kowalny, pojedynczy element JieadAndTai 1 zastępujący dwa wymienione wcześniej wskaźniki
(„głowę" i „ogon"). Otóż element ten pełni rolę wartownika (sentinel) zwanego także często
„obiektem pustym" (nuli object). Umieszczając taki obiekt przed pierwszym elementem li-
sty i po ostatnim jej elemencie, unikamy konieczności specjalnego postępowania z pierw-
szym i ostatnim elementem, w szczególności uaktualniania „głowy" i „ogona" (i pustych
wskaźników na nie) po usunięciu pierwszego (ostatniego) elementu lub wstawieniu ele-
mentu na początek (koniec) listy. „Głowa" i „ogon" wskazują wówczas na (nienaruszalne)
elementy-wartowniki. Po krótkim zastanowieniu można jednak dojść do wniosku, że tak
naprawdę wystarczy tylko jeden wartownik — jako element następny dla ostatniego i po-
przedni dla pierwszego. Taką właśnie rolę pełni w naszej implementacji wspomniany ele-
Rozdział 3. • Listy 97

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.

Metoda clear(), wywoływana w ramach konstruktora, zostanie omówiona w dalszej części


rozdziału. W tej chwili wystarczy założyć, że sprowadza ona klasę listy do pewnego stanu
początkowego.

Definiowanie klasy elementu


W przeciwieństwie do implementacji tablicowej w liście wiązanej nie istnieją żadne „ro-
dzime" miejsca do przechowywania elementów, konieczne jest więc zdefiniowanie klasy
reprezentującej element listy w sposób jawny. Rolę tę pełni wewnętrzna klasa Element.
private static finał class Element {
private Object _value:
private Element _previous;
private Element _next;

public Element(Object value) {


setValue(value):
}
public void setValue(Object value) {
_value = value:
}
public Object getValue() {
return _value;
}
public Element getPrevious() {
return _previous:
}
public void setPrevious(Element previous) {
assert previous != nuli :
"wskaźnik na element poprzedni nie może być pusty";
_previous = previous;
}
public Element getNext() {
return _next;
}
public void setNext(Element next) {
assert next != nuli : "wskaźnik na element następny nie może być pusty";

_next - next:
}
public void attachBeforetElement next) {
assert next != nuli : "wskaźnik na element następny nie może być pusty":

Element previous - next.getPrevious();


98 Algorytmy. Od podstaw

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".

Metody realizujące wstawianie i dołączanie wartości


Wstawianie elementu do listy wiązanej jest prostsze niż w przypadku listy tablicowej, nie
występuje bowiem konieczność zmiany rozmiarów jakiejś struktury. Pewnego namysłu
wymaga za to właściwy wybór miejsca wstawienia elementu.
public void insert(int index. Object value) throws IndexOutOfBoundsException (
assert value != nuli : "nie można wstawiać pustych wartości":

if (index < 0 || index > _size) {


throw new IndexOutOfBoundsException():
}
Element element = new Element(value);
element,attachBefore(getElement(index));
++_size:
}
private Element getElement(int index) {
Element element = JieadAndTai 1 ,getNext();
Rozdział 3. • Listy 99

for (int i = index; i > 0; --i) {


element = element.getNext();
}
return element;
}

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.

mimiiii Metody odczytujące i modyfikujące wartości elementów


Sposób wyszukiwania i zapamiętywania wartości elementów listy wiązanej jest niemal
identyczny jak w przypadku implementacji tablicowej, podstawowa różnica związana jest
ze znajdowaniem elementu o wskazanym indeksie — zamiast dostępu bezpośredniego uży-
wana jest metoda getElementO.
public Object gettint index) throws IndexOutOfBoundsException {
checkOutOfBounds(i ndex):
return getE1ement(i ndex).getVa1ue();
}
public Object set(int index, Object value) throws IndexOutOfBoundsException {
assert value != nuli : "Nie można umieszczać w liście wartości pustych ";
checkOutOfBounds(i ndex):
100 Algorytmy. Od podstaw

Element element = getElement(index);


Object oldValue = element.getValue();
element,setValue(value):
return oldValue;

private void checkOutOfBoundstint index) {


if (isOutOfBounds(index)) {
throw new IndexOutOfBoundsException():
}
}
private boolean isOutOfBounds(int index) {
return index < 0 || index >= sizeO;

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.

Ponieważ metody get () i set() wykorzystują metodę getElementO do poszukiwania odno-


śnego elementu, czas ich wykonywania jest proporcjonalny do rozmiaru listy. Indeksowy
dostęp do elementów listy wiązanej jest więc znacznie mniej efektywny niż w przypadku
listy tablicowej.

spróbuj sam Metody wyszukujące wartości


Koncepcyjnie wyszukiwanie wartości w liście wiązanej jest takie samo jak w przypadku listy
tablicowej. Rozpoczynając od dowolnego z krańców listy, poruszamy się po jej kolejnych
elementach aż do znalezienia żądanego elementu lub natrafienia na drugi kraniec.
public int index0f(Object value) {
assert value != nuli : "w nie ma pustych wartości"

int index = 0:

for (Element e = JieadAndTail.getNext();


e != JieadAndTail:
e = e.getNext()) {
if (value.equals(e.getValue())) {
return index;

++index;

return -1;
Rozdział 3. • Listy 101

Metoda contains() jest identyczna jak w implementacji tablicowej:


public boolean contains(Object value) {
return indexOf(value) != -1:
]

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().

Metody usuwające wartości


Usuwanie elementu o wskazanym indeksie delegowane jest do metody detachO usuwanego
elementu po uprzednim jego zlokalizowaniu:
public Object delete(int index) throws IndexOutOfBoundsException {
checkOutOfBounds(i ndex);
Element element = getElement(index);
element.detachO;
--_size;
return element.getValue():

W podobny sposób odbywa się usuwanie elementu na podstawie jego wartości:


public boolean delete(0bject value) {
assert value != nuli : "w liście nie ma wartości pustych":

for (Element e = _headAndTail.getNext();


e != JieadAndTail;
e = e.getNext()) {
if (value.equals(e.getValue())) {
e.detacht);
--_size;
return true;
}
}
return false:
102 Algorytmy. Od podstaw

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;

public void firstO {


_current = JieadAndTail,getNext():
}
public void lastO {
_current = JieadAndTail ,getPreviousO:
}
public boolean isDoneO {
return _current == JieadAndTail:
}
public void next() {
_current = _current.getNext():
}
public void previous() {
_current = _current.getPrevious():
. }

public Object currentO throws IteratorOutOfBoundsException {


if (isDoneO) {
throw new IteratorOutOfBoundsException();
}
return _current.getValueO;
Rozdział 3. • Listy 103

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.

public int sizeO {


return size:
}
public boolean isEmptyO {
return sizeO == 0;
}
public void clearO {
_headAndTail,setPrevious(_headAndTail):
_headAndTai1.setNext(_headAndTai 1);
_size = 0;
}

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.

Lista tablicowa i lista wiązana to dwie najczęściej wykorzystywane implementacje list.


Różnią się one od siebie kilkoma cechami: w obydwu przypadkach wyszukiwanie elementu
o żądanej wartości jest proporcjonalne do rozmiaru listy, jednakże w przypadku tablicy do-
stęp na podstawie indeksu jest znacznie efektywniejszy (bo natychmiastowy), za to w liście
wiązanej znacznie efektywniej wykonuje się wstawianie i usuwanie elementów (w przeci-
wieństwie do tablicy nie jest konieczne kopiowanie grup elementów).

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.

W niniejszym rozdziale wyjaśnimy:


• czym kolejki różnią się od list,
• jakie są cechy charakterystyczne kolejki FIFO i jak się j ą implementuje,
• jak tworzy się kolejki wykorzystywane jednocześnie przez wiele wątków,
• jak można ograniczyć liczbę elementów w kolejce,
• jak można stworzyć wielowątkową symulację zdalnego biura obsługi (cali center).

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.

Najczęściej kolejność pobierania elementów z kolejki tożsama jest z kolejnością ich


umieszczania w kolejce (kolejkę taką określa się skrótem FIFO — First-In, First-Out,
„pierwszy wchodzący jest pierwszym wychodzącym"), lecz możliwe są także inne scena-
riusze — na przykład omawiane w rozdziale 5. kolejki z kolejnością pobierania odwrotną
106 A l g o r y t m y . Od podstaw

do kolejności przybywania (LIFO — Last-In, First-Out, „ostatni wchodzący jest pierw-


szym wychodzącym") oraz kolejki priorytetowe, omawiane w rozdziale 8., w których ko-
lejność pobierania elementów zależna jest od ich względnego priorytetu. Elementy mogą
być także pobierane z kolejki w kolejności losowej — kolejka taka nazywana jest kolejką
losową (random ąueue) lub tasującą (shuffling ąueue).

W dalszej części rozdziału, i w większości miejsc w rozdziałach następnych, mówiąc o „kolejce",


będziemy mieli na myśli kolejkę typu FIFO.

Funkcjonowanie kolejek opisywane jest często w kategoriach producenta i konsumenta.


Producent to cokolwiek (na przykład proces), co umieszcza elementy w kolejce, zaś kon-
sumentem jest cokolwiek (na przykład proces), co elementy z kolejki pobiera. Wzajemną
relację między producentami a konsumentami korzystającymi ze wspólnej kolejki przed-
stawia rysunek 4.1.

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.

Tabela 4.1. Operacje kolejkowe

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

Nietrudno zauważyć, że opisany zestaw operacji jest znacznie prostszy od analogicznego


zestawu dla „zwykłej" listy (patrz tabele 3.1 i 3.2). Za pobieranie elementów z kolejki od-
powiedzialna jest operacja enqueue, za umieszczanie ich w kolejce — operacja dequeue.
Pozostałe operacje wymienione w tabeli 4.1 mają znaczenie takie samo jak w przypadku
„zwykłej" listy. Zwróćmy uwagę, że ze względu na niemożność dostępu naraz do wszyst-
kich elementów, z kolejką nie jest skojarzony iterator (patrz rozdział 2.) — jedynym środ-
kiem dostępu do zawartości kolejki jest dostęp do jej czołowego elementu za pomocą ope-
racji dequeue.

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:

public interface Queue {


public void enqueue(Object value);
public Object dequeue() throws EmptyQueueException;
public void clearO;
public int sizeO;
public boolean isEmptyO;
)

Konieczne jest ponadto zadeklarowanie wyjątku EmptyQueueException generowanego w wy-


niku próby wykonania operacji dequeue na pustej kolejce:
package com,wrox.algorithms.queues:

public class EmptyQueueException extends RuntimeException {


}

Zwróćmy uwagę, że wyjątek EmptyQueueException jest wyjątkiem nieobsiugiwalnym —


wywodzi się z klasy RuntimeException. Naszym zdaniem próba pobrania elementu z pustej
kolejki jest poważnym błędem programistycznym, którego „załatwienie" za pomocą bloku
try.. .catch nie powinno być dopuszczalne. Błędu tego można uniknąć, sprawdzając „nie-
pustość" kolejki (za pomocą metody isEmptyO) każdorazowo przed wywołaniem metody
dequeue().

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

Nazwa kolejki — FIFO, „pierwszy wchodzący jest pierwszym wychodzącym" — mówi


sama za siebie: w wyniku wywołania metody dequeue() z kolejki pobierany jest ten ele-
ment, który przebywał w kolejce najdłużej. Jeśli więc na przykład za pomocą operacji
enqueue() dodamy do kolejki elementy (kolejno) Kot, Pies, Jabłko i Banan, to sukcesywne
wywołania metody dequeue() udostępnią je dokładnie w takiej samej kolejności.

Mimo iż każdą kolejkę — w szczególności kolejkę FIFO — zaimplementować można na


wiele różnych sposobów, najprostszą i najbardziej naturalną wydaje się implementacja na
bazie listy. Jak bowiem stwierdziliśmy na wstępie, kolejka jest w istocie listą na którą na-
łożone zostały pewne ograniczenia dotyczące dodawania i usuwania elementów.

Wywołanie metody enqueue() powoduje dodanie („zakolejkowanie") elementu na koniec


listy-kolejki, jak przedstawiono to na rysunku 4.2.

Rysunek 4.2.
Dodanie elementu Kot Pies Jabłko
na koniec listy
za pomocą
metody enqueue() Banan

Analogicznie, wywołanie metody dequeue() powoduje usunięcie elementu z czoła kolejki,


czyli z początku listy:

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.

illIŁłilnl Testowanie kolejki FIFO


Mimo iż w niniejszym rozdziale zajmować się będziemy tylko jedną implementacją kolejki
FIFO, uczynimy nasz test — zgodnie z konsekwentnie wyznawaną filozofią — uniwersalnym
dla dowolnej implementacji. Podobnie jak w przypadku testowania list stworzymy w tym
celu abstrakcyjną klasę testową definiującą te aspekty testu, które są od konkretnej imple-
mentacji niezależne, i pozostawiającą do sprecyzowania kilka aspektów specyficznych dla
implementacji.

package com.wrox.al gori thms.queues:

import junit.framework.TestCase:
Rozdział 4. • Kolejki 109

public abstract class AbstractFifoOueueTestCase extends TestCase {


private static finał String VALUE_A = "A";
private static finał String VALUE_B = "B";
private static finał String VALUE_C = "C";

pr1vate Queue _queue:

protected void setUpO throws Exception {


super. setUpO:

_queue - createFifoQueue():
}
protected abstract Queue createFifoOueueO:

Pierwsza z metod testowych przeprowadza elementarne sprawdzenie warunków granicznych:


dla kolejki pustej metoda isEmptyO powinna zwrócić wartość true, a metoda dequeue()
spowodować wyjątek EmptyQueueException:
public void testAccessAnEmptyOueueO {
assertEquals(0. _queue.sizeO);
assertTrue(_queue.isEmpty()):

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;

public class ListFifoQueueTest extends AbstractFifoOueueTestCase {


protected Queue createFifoQueue() {
return new ListFifoQueue();
}
__}

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 drugim teście następuje sprawdzenie, czy wywoływanie metod enqueue() i dequeue()


poprawnie jest odzwierciedlane w rozmiarze kolejki oraz, co ważniejsze, czy kolejność, w ja-
kiej metoda dequeue() pobiera elementy z kolejki, tożsama jest z kolejnością, w jakiej me-
toda enqueue() je tam umieszcza.

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.

Nazwa pochodnej klasy testowej — Li stFi foQueueTest — odzwierciedla fakt, że testowana


za jej pomocą kolejka zaimplementowana została na bazie listy. Jej metoda createFi foQueue()
zwraca utworzoną instancje klasy Li stFi foOueue.

Implementowanie kolejki FIFO


Mając gotowy zestaw testów dla klasy ListFifoQueue, możemy zająć się implementacją
samej klasy.
package com.wrox.a 1gori thms.queues;

import com.wrox.algorithms.1 ists.LinkedList;


i mport com.wrox.a1gori thms.1 i sts.L i st;

public class ListFifoQueue implements Queue {


/** lista, na bazie której implementowana jest kolejka */
private finał List _list;

* Konstruktor tworzący kolejkę na bazie wskazanej listy


*

*/

public ListFifoQueue(List list) {


assert list != nuli: "nie określono listy"
_list = list;
}
/**

* Domyślny konstruktor tworzący kolejkę na bazie na bazie


* utworzonej ad hoc listy wiązanej
*/
public Li stFi foQueueC) {
this(new LinkedListO);

Poza implementacją interfejsu Queue i deklaracją zmiennej reprezentującej listę, na bazie


której realizowana jest kolejka, klasa ListFifoQueue definiuje także dwa konstruktory:
pierwszy z nich tworzy kolejkę ba bazie wskazanej listy, drugi — domyślny — na bazie two-
rzonej ad hoc listy wiązanej.
112 Algorytmy. Od podstaw

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.

Implementacja pozostałych metod interfejsu Queue jest elementarna i nieprzypadkowo iden-


tyczna z implementacją analogicznych metod dla listy, na bazie której zaimplementowano
kolejkę:
public void clearO {
_list.clearO:
}
public int sizeO {
return _1ist.sizeC):
}
public boolean isEmptyO {
return _1 ist.isEmptyO:
}

We wszystkich trzech przypadkach następuje po prostu delegowanie wywołania do iden-


tycznie nazwanych metod odnośnej listy.
Rozdział 4. • Kolejki 113

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.

Zamknięcie opisanych mechanizmów synchronizacyjnych w ramach interfejsu Queue jest


o tyle dobrym pomysłem, iż całkowicie uwalnia producentów i konsumentów od subtelno-
ści związanej z synchronizacją wątków. Pozostaje tylko wybór między rozszerzeniem gotowej
implementacji (w tym przypadku ListFifoQueue) a zrealizowaniem wspomnianej synchro-
nizacji jako otoczki wokół pewnej, ogólnie rozumianej kolejki 1 . Wybierając pierwszy wa-
riant, uzależnilibyśmy się od konkretnej implementacji kolejek, wybierając wariant drugi,
zyskujemy natomiast rozwiązanie uniwersalne, stosujące się do dowolnej kolejki, na przy-
kład kolejek priorytetowych omawianych w rozdziale 8.

W celu zapewnienia synchronizacji dostępu do kolejki wykorzystamy jeden ze standardo-


wych środków programowania współbieżnego, zwany wykluczaniem wzajemnym (mutual
exclusion). Poszczególne wątki, rywalizujące ze sobą o dostęp do kolejki, wykluczają się
wzajemnie w tym sensie, że w danej chwili dostęp ten uzyskać może co najwyżej jeden z nich,
pozostałe muszą poczekać, aż wątek operujący aktualnie na kolejce zwolni ją. Realizujący
to wykluczanie obiekt nosi nazwę muteksu (to skrót od angielskiej nazwy wzajemnego wy-
kluczania) i jest często wykorzystywany z tej przyczyny, iż jest stosunkowo mało podatny
na błędy programowania.

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

spróbuj sam Wykorzystywanie kolejki blokującej


W tym momencie powinniśmy zwyczajowo napisać „rozpocznijmy więc od stworzenia
niezbędnych testów", tym razem jednak zrobimy wyjątek i z testów po prostu zrezygnujemy.

Jak to „zrezygnujemy"?

No, niezupełnie. Ponieważ testowanie poprawnej synchronizacji wątków w dostępie do


współdzielonych danych wykraczałoby poza ramy niniejszej książki, nie będziemy tu w ogóle
poruszać tej tematyki.
Czytelnikom zainteresowanym tworzeniem aplikacji wielowątkowych w Javie możemy
polecić książkę Douga Lea,, Concurrent Programming in Java: Design Principles
andPatterns" wyd. trzecie, Addison-Wesley 2006, ISBN 0-201-31009-0.

Dyskusję na temat kolejki blokującej rozpoczniemy od zadeklarowania reprezentującej ją


klasy.
package com.wrox.algorithms,queues;

public class BlockingQueue implements Queue {


/** obiekt synchronizujący */
private finał Object _mutex = new ObjectO;

/** Odnośna kolejka */


private finał Queue _queue;

/** maksymalny dopuszczalny rozmiar kolejki */


private finał int _maxSize:

* 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

public BlockingQueue(Queue queue) {


this(queue. Integer.MAX_VALUE);
}

Klasa BlockingQueue implementuje interfejs Queue i definiuje kilka zmiennych: zmienna


_queue wskazuje instancję odnośnej kolejki przechowującej elementy, zmienna _maxsize
zawiera maksymalny dopuszczalny rozmiar kolejki, natomiast zmienna _mutex wskazuje
instancję obiektu synchronizującego, którego rolę już wyjaśniliśmy
Rozdział 4. • Kolejki 115

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.

Opis funkcjonowania kolejki blokującej rozpoczniemy od metody enqueue(), pozornie tylko


skomplikowanej:
public void enqueue(Object value) {
synchronized (_mutex) {
while (sizeO = _maxSize) {
waitForNotification();
}
_queue.enqueue(value);
_mutex.notifyAU():
}
}
private void waitForNotificationO {
try {
_mutex.wait();
} catch (InterruptedException e) {
// ignoruj wyjątek
}
}

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

spróbuj sam Implementowanie metody dequeueO


Implementowanie metody dequeue() rodzi ten sam problem synchronizacyjny co w przy-
padku metody enqueue() z tą różnicą że czynnikiem blokującym jest teraz nie przepełnie-
nie kolejki, lecz brak elementów w kolejce.
public Object dequeue() throws EmptyQueueException {
synchronized (_mutex) {
while (isEmptyO) {
waitForNotificationO;
}
Object value = _queue.dequeue();
_mutex.notifyA110;
return value:

Po uzyskaniu wyłącznego dostępu do kolejki metoda dequeue() musi zyskać pewność, że


w kolejce jest co najmniej jeden element.

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.

spróbuj sam Implementowanie metody clearO


Implementacja metody cl ear() jest znacznie prostsza od pozostałych:
public void clearO {
synchronized (_mutex) {
_queue.clearO:
_mutex.notifyA110:

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

spróbuj sam Implementowanie metod sizeO i isEmptyO


Pozostaje jeszcze zaimplementowanie dwóch tytułowych metod:
public int sizeO {
synchronized (_mutex) {
return _queue.sizeO;

}
public boolean isEmptyO {
synchronized (_mutex) {
return _queue.isEmptyO:

J a k to działa?

Wywołania wspomnianych metod delegowane są wprost do odnośnej kolejki, po uzyskaniu


do niej wyłącznego dostępu. Ponieważ metody te nie zmieniają liczby elementów w kolejce,
nie wysyłają powiadomień dla innych wątków (za pomocą metody _mutex. noti fyAl 10).

Przykład—symulacja centrum obsługi


Po opisaniu własności kolejek i sposobów ich implementowania czas zobaczyć, do czego
kolejki mogą się przydać w rzeczywistych aplikacjach. Pokazaliśmy już przykład komuni-
kowania się wątków za pośrednictwem kolejki blokującej, pora teraz na bardziej skompli-
kowany przykład z życia codziennego.

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

Zgłoszenia wytwarzanie przez generator wysyłane są do centrum obsługi, gdzie trafiają do


kolejki blokującej w oczekiwaniu na obsługę przez pierwszego wolnego konsultanta. Każ-
dy konsultant, gdy tylko zakończy obsługę bieżącego zgłoszenia, próbuje pobrać z kolejki
następne zgłoszenie do obsługi; gdy próba ta się powiedzie, przystępuje do obsługi pobrane-
go zgłoszenia. Gdy jednak kolejka okaże się pusta, działanie konsultanta zostaje zawieszone
do czasu, aż w kolejce pojawi się jakieś zgłoszenie. Konsultant nie jest jednak świadom tego
zawieszenia — z jego punktu widzenia następuje po prostu pobranie elementu z kolejki,
wszelkie aspekty synchronizacyjne „załatwiane" są bowiem wewnątrz samej kolejki.

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.

Liczba konsultantów równa się liczbie uruchomionych wątków konsumenckich pobierają-


cych z kolejki elementy reprezentujące zgłoszenia. Im więcej konsultantów, tym generalnie
krótsze oczekiwanie na obsługę pojedynczego zgłoszenia — i jednocześnie większa liczba
wątków zawieszonych z powodu braku elementów w kolejce.

Ponieważ symulator nie ma działać w nieskończoność, więc określono liczbę zgłoszeń po


obsłużeniu których zakończy on pracę. Gdy liczba ta będzie bardzo duża, otrzymamy wra-
żenie pracy ciągłej.

Manipulowanie maksymalnym czasem obsługi zgłoszenia pozwala zaobserwować wpływ


czasu obsługi pojedynczego zgłoszenia — dłuższego lub krótszego — na ogólny scenariusz
obsługi nadchodzących zgłoszeń.

Podobnie różne wartości maksymalnego odstępu między zgłoszeniami odzwierciedlać mogą


zróżnicowanie natężenie zgłoszeń napływających do centrum.

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.

Podobnie jak w przypadku klasy BlockingOueue — i z tych samych powodów — pomi-


niemy problem testowania aplikacji. Czytelnicy mogą znaleźć odnośny zestaw testowy
wśród kodu źródłowego dołączonego do książki, lecz opisywanie jego szczegółów w tym
miejscu wykraczałoby poza zakres tematyczny publikacji poświęconej (przede wszystkim)
algorytmom.
Rozdział 4. • Kolejki 119

Stworzymy teraz kolejno poszczególne klasy odpowiadające poszczególnych elementom


schematu z rysunku 4.4, po czym połączymy je wszystkie w jedną całość — aplikację uru-
chamianą z wiersza poleceń. Każda ze wspomnianych klas wypisywać będzie stosowne
komunikaty na konsolę, w wyniku czego zobaczyć możemy istny zalew komunikatów.
Przykład takiego raportu przedstawimy na zakończenie podrozdziału.

spróbuj sam Tworzenie klasy reprezentującej zgłoszenie


Zgłoszenie reprezentowane jest przez klasę o nazwie Cali. Zgłoszenia kierowane są do
centrum obsługi, gdzie oczekują na obsługę przez konsultantów (klasy reprezentujące cen-
trum obsługi i konsultantów opiszemy za chwilę).
package com.wrox.a 1gori thms.queues;

public class Cali {


/** identyfikator zgłoszenia */
private finał int _id:

/** czas trwania obsługi zgłoszenia */


private finał int _duration;

/** czas wygenerowania zgłoszenia */


private finał long _startTime;
/**

* 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?

Każdemu zgłoszeniu przyporządkowywany jest unikalny identyfikator oraz pewien czas


obsługi. Identyfikatory zgłoszeń umożliwiają śledzenie ich losu na konsoli. W chwili utwo-
rzenia klasy Cali zapamiętywany jest bieżący czas systemowy, reprezentujący czas przy-
bycia zgłoszenia co centrum. W momencie rozpoczęcia obsługi zgłoszenia będzie można
dzięki temu obliczyć, jak długo zgłoszenie to oczekiwało w kolejce.

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

Rozpoczęcie (symboliczne) obsługi zgłoszenia rozpoczyna się od wyświetlenia na konsoli


komunikatu podającego czas jego oczekiwania w kolejce. Sama „obsługa" reprezentowana
jest przez „uśpienie" wątku na czas określony w momencie tworzenia instancji klasy Cali.
Sam charakter obsługi nie ma tu bowiem znaczenia, istotny jest jedynie czas trwania tej obsługi.

spróbuj sam Tworzenie klasy reprezentującej konsultanta


Kolejna klasa — CustomerServiceAgent — odpowiada konsultantowi na schemacie z ry-
sunku 4.4. Klasa ta jest odpowiedzialna za pobieranie zgłoszeń oczekujących w kolejce i ich
obsługę.
public class CustomerServiceAgent implements Runnable {
/** zgłoszenie sygnalizujące konsultantowi.
* że następnych zgłoszeń już nie będzie
*/
public static finał Cali G0_H0ME = new Cali(-1. 0);

/** identyfikator konsultanta */


private finał int _id;

/** kolejka zgłoszeń */


private finał Oueue _calls;

* 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.

Zwróćmy uwagę, że klasa CustomerServiceAgent implementuje interfejs Runnable. Umoż-


liwia to funkcjonowanie każdej instancji tej klasy w ramach osobnego wątku — tak właśnie
funkcjonować mają konsultanci w naszym modelu centrum obsługi. Interfejs Runnable po-
Rozdział 4. • Kolejki 121

siada jedną metodę — run(); w ramach jej implementacji w klasie CustomerServiceAgent


następuje pobranie zgłoszenia z kolejki i jego obsłużenie.
public void run() {
System.out.println(this + " obecny");

while (true) {
System.out.pri nt1n(thi s + " oczekuje");

Cali cali = (Cali) _cal s.dequeue():


System.out,println(this + " obsługuje " + cali):

if (cali — G0_H0ME) {
break:
}
cali .answerO;
}
System.out.println(this + " zakończył pracę");
}

J a k to działa?

Rozpoczęcie pracy każdego konsultanta kwitowane jest krótkim komunikatem informują-


cym o tym fakcie. Potem następuje pętla cyklicznego pobierania zgłoszeń z kolejki i ich
obsługiwania. Zarówno sięgnięcie konsultanta do kolejki po nowe zgłoszenie, jak i rozpo-
częcie obsługi pobranego zgłoszenia dokumentowane są komunikatem zawierającym sto-
sowne identyfikatory.

Zwróćmy uwagę, że — wbrew zasadzie, którą sformułowaliśmy na początku rozdziału —


przed wywołaniem metody dequeue() nie następuje wywołanie metody IsEmptyO spraw-
dzającej, czy kolejka przypadkiem nie jest pusta. Nie oznacza to jednak, że ryzykujemy
wystąpienie wyjątku EmptyQueueException — j a k pisaliśmy wcześniej, próba pobrania ele-
mentu z pustej kolejki blokującej powoduje zawieszenie wątku wykonującego metodę
dequeue(), a nie wygenerowanie wyjątku.

Konsultant nie działa w nieskończoność — zwróćmy uwagę na następujący fragment prze-


rywający pętlę obsługi zgłoszeń:
if (cali == G0_H0ME) {
break:
}
Oznacza on istnienie pewnego wyróżnionego zgłoszenie G0_H0ME, którego otrzymanie (po-
branie z kolejki) jest dla konsultanta sygnałem do zakończenia pracy. Analizując definicję
klasy CustomerServiceAgent, łatwo zauważyć, że zgłoszenie to ma identyfikator —1 i zero-
wy czas obsługi. Gdyby nie owo zgłoszenie kończące, zakończenie napływania zgłoszeń
spowodowałoby, że wątek konsultanta ugrzązłby na zawsze w metodzie dequeue()! Pro-
blem ten, jak również opisana idea „zdarzenia końcowego", jest wspólny dla wszystkich
scenariuszy typu „producent-konsument" działających w oparciu o wspólną kolejkę.
122 Algorytmy. Od podstaw

iiiifrtiiiB Tworzenie Klasy reprezentujące! centrum obsługi


Ramą zamykającą współdziałanie trzech elementów ze schematu na rysunku 4.4 — zgło-
szeń, kolejki i konsultantów —-jest klasa reprezentująca centrum obsługi. Jest ona odpo-
wiedzialna za uruchamianie i zatrzymywanie wątków konsultantów oraz za umieszczanie
nadchodzących zgłoszeń w kolejce.
package com.wrox.a1gori thms.queues;

i mport com.wrox.algori thms.iterati on.Iterator:


i mport com.wrox.al gori thms. 1 i sts.ArrayLi st;
import com.wrox.algorithms. 1 ists.List;

public class CallCenter {


/** kolejka zgłoszeń */
private finał Oueue _ca 11 s = new BlockingQueue(new ListFifoOueueO):

/** liczba działających konsultantów */


private finał int jiumberOfAgents;

/** lista wątków konsultantów


*/ pusta, gdy centrum zamknięte, i niepusta, gdy otwarte
*/
private finał List _threads;
/**

* Konstruktor.
* parametr: liczba działających konsultantów

public CallCenter(int numberOfAgents) {


_threads = new ArrayList(numberOfAgents):
jiumberOfAgents = numberOfAgents;
1

}
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 {

assert _threads.isEmpty() : "Centrum obsługi jest już otwarte";

System.out.printlnCOtwieranie centrum obsługi");

for (int i = 0 ; i < _numberOfAgents: ++i) {


Thread thread = new Thread(new CustomerServiceAgent(i, _calis)):

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

public void accept(Call cali) {


assert !_threads.isEmpty() : "Centrum obsługi jest zamknięte":

_calIs,enqueue(cal 1);

System.out.println(call + " umieszczone w kolejce");


}
Po pracowitym dniu centrum obsługi zostaje zamknięte, a konsultanci udają się do domów.
public void closeO {
assert !_threads.isEmpty() : "Centrum obsługi jest już zamknięte";

System.out.println("Zamykanie centrum obsługi"):

// wysłanie "zgłoszenia kończącego" do każdego z agentów


for (int i = 0; i < JiumberOfAgents; ++i) {
accept(CustomerServi ceAgent.G0_H0ME);
}
Iterator i = _threads.iteratorO;
for (i.firstO: !i.isDoneO: i.next()) {
waitForTermination((Thread) i .currentO);
}
_threads,clear():

System.out.println("Centrum obsługi jest zamknięte");


}
private void waitForTermination(Thread thread) {
try {
thread. joinO;
} catch (InterruptedException e) {
// Ignoruj
}
}

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.

Metoda open() odpowiedzialna jest na uruchomienie wątków reprezentujących konsultan-


tów — w liczbie określonej przy wywołaniu konstruktora. Każdy wątek tworzony jest na
bazie odrębnej instancji klasy CustomerServi ceAgent, każda taka instancja nadaje „swemu"
konsultantowi unikalny numer (faktycznie kolejnym konsultantom przyporządkowywane
są w pętli kolejne numery). Utworzony wątek dodawany jest następnie do listy działających
wątków.
124 A l g o r y t m y . Od podstaw

Każde zgłoszenie przychodzące do centrum obsługi umieszczane jest w kolejce niezależnie


od tego, czy któryś z bezczynnych konsultantów mógłby je obsłużyć bezpośrednio. Nie
oznacza to bynajmniej lekceważenia klientów, lecz jest prostą konsekwencją faktu, że po-
szczególni konsultanci są wstanie obsługiwać zgłoszenia pojedynczo, a nie po kilka naraz;
właśnie kolejka pełni rolę swoistego bufora umożliwiającego szeregowanie zgłoszeń nad-
chodzących być może całymi strumieniami.

Zamykanie centrum obsługi rozpoczyna się od zakończenia pracy konsultantów. Teore-


tycznie można by natychmiast zakończyć pracę wszystkich wątków reprezentujących kon-
sultantów, to jednak stwarzałoby ryzyko pozostawienia w kolejce nieobsłużonych zgłoszeń,
które w momencie zamknięcia centrum zostałyby bezpowrotnie utracone. Ponieważ cen-
trum obsługi nie lekceważy swoich klientów, więc zdecydowano się na inne rozwiązanie.
Otóż w kolejce umieszczane są specjalne zgłoszenia „kończące" G0_H0ME w liczbie równej
licznie konsultantów. Gwarantuje to, że każdy konsultant pobierze „do obsługi" dokładnie
jedno takie zgłoszenie i w efekcie zakończy pracę 3 .

Metoda waitForTerminationO, kończąca wskazany wątek, wykorzystuje jego metodę joinO


powodującą zawieszenie wykonywania aż do zakończenia obliczeń przez tenże wątek.

Tworzenie symulatora zbliża się ku końcowi, pozostały nam już tylko do zaimplementowa-
nia dwie klasy.

Tworzenie generatora zgłoszeń


Za symulację generowania zgłoszeń odpowiedzialna jest klasa Cali Generator.

public class Cali Generator {


/** macierzyste centrum obsługi */
private finał CallCenter _callCenter;

/** ogólna liczba zgłoszeń do wygenerowania */


private finał int jnumberOfCalls;

/** maksymalny czas obsługi zgłoszenia */


private finał int _maxCallDuration;

/** maksymalny odstęp czasowy między kolejnymi zgłoszeniami */


private finał int _maxCallInterval;
/**

* 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

public CallGenerator(CallCenter callCenter.


int numberOfCalIs.
int maxCallDuration,
int maxCal1Interval) {
assert callCenter != nuli : "nie określono macierzystego centrum obsługi":
assert numberOfCalls > 0 : "liczba zgłoszeń musi być dodatnia":
assert maxCallDuration > 0 : "czas obsługi zgłoszenia musi być dodatni":
assert maxCallInterval > 0 : "czas miedzy zgłoszeniami musi być dodatni";

_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.

Tworzenie klasy symulatora


Ostatnia implementowaną klasą jest klasa samego symulatora — CalICenterSimulator.
Symulator ten jest prostą aplikacją uruchamianą z wiersza poleceń, jego klasa organizuje
współpracę klasy centrum obsługi i klasy generatora zgłoszeń. Większość symulacji sensu
stricto wykonywana jest w ramach klas wcześniej opisanych, funkcjonalność klasy Cal 1 -
CenterSimulator koncentruje się głównie na analizie parametrów wywołania aplikacji.
126 Algorytmy. Od podstaw

package com.wrox.algorithms.queues;

public finał class CalICenterSimulator {


/** liczba spodziewanych parametrów wywołania */
private static finał int NUMBER_OF_ARGS = 4;

/** indeks parametru oznaczającego liczbę konsultantów */


private static finał int NUMBER_OF_AGENTS_ARG = 0:

/** indeks parametru oznaczającego liczbę generowanych zgłoszeń */


private static finał int NUMBER_OF_CALLS_ARG = 1;

/** indeks parametru oznaczającego maksymalny czas obsługi zgłoszenia */


private static finał int MAX_CALL_DURAT10N_ARG = 2;

/** indeks parametru oznaczającego maksymalny odstęp czasowy


* między kolejnymi zgłoszeniami
*/
private static finał int MAX_CALL_INTERVAL_ARG = 3;

* Konstruktor prywatny, uniemożliwia samodzielne tworzenie instancji


*/
private CallCenterSimulatorO {
}

public static void main(String[] args) {


assert args != nuli : "nie podano parametrów":

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])):

Cal IGenerator calIGenerator =


new CallGenerator(calICenter,
Integer.parseInt(a rgs[NUMBER_OF_CALLS_ARG]).
Integer.pa rseInt(a rgs[MAX_CALL_DURATION_ARG]).
Integer.pa rseInt(args[MAX_CALL_INTERVAL_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ń.

W przypadku podania niewłaściwej liczby parametrów aplikacja wypisuje komunikat in-


formacyjny na temat sposobu wywołania i natychmiast kończy swą pracę. Jeśli natomiast
parametry okażą się poprawne, tworzone są instancje centrum obsługi i generatora zgło-
szeń. Następuje otwarcie centrum obsługi, wygenerowanie ciągu zgłoszeń (zgodnie z zada-
nymi parametrami), po czym centrum obsługi zostaje zamknięte, po uprzednim poprawnym
zakończeniu obsługi wszystkich otrzymanych 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).

Raport, którego fragment prezentujemy poniżej, otrzymano w wyniku wygenerowania 200


zgłoszeń, maksymalny czas obsługi określono na 1 sekundę (1 000 milisekund), maksy-
malny odstęp między zgłoszeniami — na 100 milisekund. Obsługą zgłoszeń zajmowało się
trzech konsultantów.

Otwieranie centrum obsługi


Konsultant 0 obecny
Konsultant 0 oczekuje
Konsultant 1 obecny
Konsultant 1 oczekuje
Konsultant 2 obecny
Konsultant 2 oczekuje
Centrum obsługi otwarte
Konsultant 0 obsługuje Zgłoszenie 0
Zgłoszenie 0 rozpoczęcie obsługi po oczekiwaniu 1 milisekund
Zgłoszenie 0 umieszczone w kolejce
Konsultant 1 obsługuje Zgłoszenie 1
Zgłoszenie 1 rozpoczęcie obsługi po oczekiwaniu 1 milisekund
Zgłoszenie 1 umieszczone w kolejce
Konsultant 2 obsługuje Zgłoszenie 2
128 Algorytmy. Od podstaw

Zgłoszenie 2 rozpoczęcie obsługi po oczekiwaniu 1 milisekund


Zgłoszenie 2 umieszczone w kolejce
Zgłoszenie 3 umieszczone w kolejce
Zgłoszenie 4 umieszczone w kolejce
Zgłoszenie 5 umieszczone w kolejce
Zgłoszenie 6 umieszczone w kolejce
Zgłoszenie 7 umieszczone w kolejce
Konsultant 2 oczekuje
Konsultant 2 obsługuje Zgłoszenie 3
Zgłoszenie 3 rozpoczęcie obsługi po oczekiwaniu 203 milisekund
Zgłoszenie 8 umieszczone w kolejce
Zgłoszenie 9 umieszczone w kolejce
Zgłoszenie 10 umieszczone w kolejce
Zgłoszenie 11 umieszczone w kolejce
Konsultant 1 oczekuje
Konsultant 1 obsługuje Zgłoszenie 4
Zgłoszenie 4 rozpoczęcie obsługi po oczekiwaniu 388 milisekund

Zgłoszenie 195 rozpoczęcie obsługi po oczekiwaniu 22320 milisekund


Konsultant 1 oczekuje
Konsultant 1 obsługuje Zgłoszenie 196
Zgłoszenie 196 rozpoczęcie obsługi po oczekiwaniu 22561 milisekund
Konsultant 0 oczekuje
Konsultant 0 obsługuje Zgłoszenie 197
Zgłoszenie 197 rozpoczęcie obsługi po oczekiwaniu 22510 milisekund
Konsultant 0 oczekuje
Konsultant 0 obsługuje Zgłoszenie 198
Zgłoszenie 198 rozpoczęcie obsługi po oczekiwaniu 22634 milisekund
Konsultant 1 oczekuje
Konsultant 1 obsługuje Zgłoszenie 199
Zgłoszenie 199 rozpoczęcie obsługi po oczekiwaniu 22685 milisekund
Konsultant 2 oczekuje
Konsultant 2 obsługuje Zgłoszenie -1
Konsultant 2 zakończył pracę
Konsultant 0 oczekuje
Konsultant 0 obsługuje Zgłoszenie -1
Konsultant 0 zakończył pracę
Konsultant 1 oczekuje
Konsultant 1 obsługuje Zgłoszenie -1
Konsultant 1 zakończył pracę
Centrum obsługi jest zamknięte

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.

Ponieważ naszym celem było jedynie zademonstrowanie funkcjonowania kolejki w warunkach


wielowątkowości, sprowadziliśmy funkcjonalność kodu symulatora do niezbędnego mini-
mum. Czytelnicy być może pokuszą się o jego rozbudowę, na przykład o zbieranie staty-
styki w rodzaju przeciętnego czasu obsługi dla danego konsultanta albo o mechanizm wielu
generatorów zgłoszeń działających wielowątkowo w oparciu o pojedyncze centrum obsługi.
Rozdział 4. • Kolejki 129

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.

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.
130 Algorytmy. Od podstaw
5
Stosy
Po listach i kolejkach przyszła kolej na stosy. Przykłady różnych stosów spotykamy na co
dzień — w stos układane są talerze, bo (bez kłopotu) dostępny jest tylko ten leżący na samym
wierzchu, gazety, książki na biurku itd.

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.

W niniejszym rozdziale wyjaśniamy:

• czym są stosy i jak są skonstruowane,

• do czego wykorzystuje się stosy.

• jakie są możliwe implementacje stosów.

Rozpoczniemy od zdefiniowania podstawowych operacji stosowych, po czym skonstruujemy


niezbędny zestaw testowy dla dowolnej implementacji stosu, by wreszcie zająć listową im-
plementacją stosu jako najczęściej wykorzystywaną.

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.

Rysunek 5.1. Wierzchołek • C


Graficzne —
przedstawienie B
stosu a
132 A l g o r y t m y . Od podstaw

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.

Podstawowe operacje stosowe opisane zostały w tabeli 5.1.

Tabela 5.1. Podstawowe operacje stosowe

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.

Rysunek 5.2. Wierzchołek D


Odłożenie
elementu na stos Wierzchołek C C
B
A A

Usunięcie szczytowego elementu ze stosu, zwane po prostu zdjęciem go ze stosu (pop),


przedstawia natomiast rysunek 5.3.

Rysunek 5.3. Wierzchołek-


Zdjęcie ze stosu
szczytowego Wierzchołek- B
elementu A

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;

public interface Stack extends Queue {


public void push(Object value);
public Object pop() throws EmptyStackException;
Rozdział 5. • Stosy 133

public Object peekO throws EmptyStackException:


public void clearO;
public int sizeO;
public boolean isEmptyO:

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;

public class EmptyStackException extends RuntimeException {


j

Zwróćmy uwagę, że wyjątek EmptyStackException — podobnie jak wyjątek EmptyOueue-


Exception opisywany w rozdziale 4. — zaliczony został do wyjątków nieobsługiwal-
nych, czyli wyprowadzanych z klasy RuntimeException. Sytuacja, w której jest on gene-
rowany — próba wykonania operacji pop lub peek na pustym stosie — należy do ewident-
nych błędów programowania (podobnie jak próba pobrania elementu z pustej kolejki),
nie ma bowiem żadnego rozsądnego uzasadnienia zamierzone jej wywołanie przez pro-
gramistę.

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.

spróbuj sam Tworzenie abstrakcyjnej klasy testowej


package com.wrox.a 1gori thms.stacks;

import junit.framework.TestCase:

public abstract class AbstractStackTestCase extends TestCase {


protected static finał Object VALUE_A = "A";
134 Algorytmy. Od podstaw

protected static finał Object VALUE_B - "B";


protected static finał Object VALUE_C = "C";

protected abstract Stack createStack();

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!

spróbuj sam Testowanie metod pushO i popO


Metody pushO i popO stanowią— obok metody peekO, której poświęcimy odrębny test
— podstawowy środek dostępu do elementów stosu, nie sposób więc testować ich w ode-
rwaniu od siebie.
pubłic void testPushAndPopO {
Stack stack = createStack();

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.

Metoda testCantPopFromAnEmptyStack rozpoczyna swą pracę od upewnienia się, że stos


rzeczywiście jest pusty, po czym wywoływana jest jego metoda pop() w oczekiwaniu, że
spowoduje to wyjątek EmptyStackException. Jeśli wyjątek ten nie wystąpi, wywoływana
jest metoda fai 1 () klasy testowej oznaczająca załamanie testu.

Testowanie metody peekO


Metoda peekO udostępnia szczytowy element stosu, w przeciwieństwie do metody pop O
jest jednak metodą . .przeglądową" , bowiem szczytowy element nie jest zdejmowany ze
stosu.
public void testPeekO {
Stack stack = createStackO:

stack,push(VALUE_C);
stack.push(VALUE_A);
assertEquals(2. stack.sizeO);

assertSame(VALUE_A. stack. peek O ) ;


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

W każdym z wymienionych przypadków istnieją argumenty „za" i „przeciw". Dwa pierw-


sze rozwiązania sprowadzają się do zdefiniowania klasy implementującej interfejs Stack
(oprócz interfejsu List) i wyposażenia jej w metody realizujące zachowanie właściwe dla
stosu. Konsekwencją tej prostoty jest jednak zupełny brak elastyczności: dla różnych, po-
tencjalnie bardzo wielu możliwych implementacji listy (w tym dwóch implementacji opisa-
nych w poprzednim rozdziale) należałoby zdefiniować odrębną klasę stosu — co jest za-
równo niewygodne, jak i skrajnie nieeleganckie.

Skorzystamy więc z trzeciej możliwości i zdefiniujemy klasę Li StStack stanowiącą „otoczkę"


instancji odnośnej listy. Rozwiązanie takie, zwane kompozycją, ma tę zasadniczą zaletę, że
— wykonane umiejętnie — umożliwia odseparowanie szczegółów implementacyjnych listy
od „reszty" funkcjonalności stosu, a w konsekwencji wymianę wspomnianej instancji listy
bez ingerencji w pozostały kod.

Doceniając znaczenie testowania wyprzedzającego, stworzenie klasy Li stStack poprzedzi-


my stworzeniem odpowiedniej klasy testowej wyprowadzonej z omawianej wcześniej abs-
trakcyjnej klasy testowej dla stosu w ogólności:
package com.wrox.a 1gori thms.stacks:

public class ListStackTest extends AbstractStackTestCase {


protected Stack createStackO {
return new ListStackO:

spróbuj sam Implementowanie klasy ListStack


Klasa ListStack musi oczywiście implementować metody interfejsu Stack:

package com.wrox.algorithms.stacks:

import com.wrox.algori thms.1 i sts.Li nkedLi st:


import com.wrox.algori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.queues.EmptyQueueExcepti on:

public class ListStack implements Stack {


/** lista, na bazie której implementowany jest stos */
private finał List _list - new LinkedList();

Umieszczenie elementu na stosie jest oczywiście równoważne dołączeniu go na koniec listy:


public void push(Object value) {
_list.add(value):
}
public void enqueue(Object value) {
push(value);
}
138 Algorytmy. Od podstaw

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.

spróbuj sam Zdejmowanie elementu ze stosu


Zdjęcie szczytowego elementu ze stosu jest równoważne usunięciu ostatniego elementu od-
nośnej listy.
public Object p o p O throws EmptyStackException
if (isEmptyO) {
throw new EmptyStackException();
}
return list.deleteC list.sizeO - 1);

public Object dequeue() throws EmptyQueueException


if isEmptyO {
throw new EmptyQueueException();

return pop();

Jest oczywiste, że efektywność metod pushO i pop O w powyższej implementacji zależna


jest całkowicie od efektywności metod (odpowiednio) add() i deleteO listy, której instancję
wskazuje zmienna _1 i St.

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

Pozostałe metody klasy ListStack delegowane są bezpośrednio do identycznie nazwanych


metod odnośnej listy (_1 i st).
public void clearO {
_list.clearO;
}
public int sizeO {
return list.sizeO;
}
public boolean isEmptyO {
return list. isEmptyO:
}

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.

Tak oto zakończyliśmy implementowanie stosu bazującego na liście o dowolnej imple-


mentacji. Gdy — dzięki omawianym wcześniej zestawom testowym — przekonamy się, że
funkcjonuje ona poprawnie, wskazane byłoby jej wykorzystanie w jakimś „rzeczywistym"
zastosowaniu.
140 Algorytmy. Od podstaw

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.

Spośród przykładów o bardziej „przyziemnym" charakterze wymienić można przetwarza-


nie dokumentów XML, zarządzanie „ekranami" aplikacji (na przykład obsługa przycisków
„Wstecz" i „Dalej" w przeglądarkach WWW) i kontrolowanie poczynionych zmian w apli-
kacji z możliwością ich wycofywania (undo) i ponawiania (redo). Przyjrzyjmy się bliżej
ostatniej z wymienionych ewentualności.

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.

Najbardziej oczywistą postacią migawki w przypadku przywoływanej na początku aplikacji


operującej na liście może być zawartość tej listy in extenso. Rozwiązanie to, jakkolwiek
poprawne, jest skrajnie niepraktyczne, chociażby z powodu konieczności sporządzania ko-
pii listy przed wykonaniem każdej z operacji. Znacznie lepszym pomysłem jest idea opera-
cji komplementarnych — przykładowo operacja „usunięcia elementu z pozycji 5" może
być anulowana przez „wstawienie na pozycję 5 usuniętej wartości", a „wstawienie ele-
mentu na pozycję 3" jest odwrotnością „usunięcia elementu z pozycji 3".
Mimo iż wyczerpująca dyskusja na temat„ undo-redo " wykraczałaby poza zakres
tematyczny niniejszej książki, prezentowane tu rozwiązanie daje się łatwo rozszerzyć
na zarządzanie anulowaniem operacji na kilku Ustach (i innych strukturach danych)
równocześnie za pomocą pojedynczego stosu, przez enkapsulację operacji undo
na wspomnianych strukturach w postaci zewnętrznych klas.
Rozdział 5. • Stosy 141

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.

nMiłiiii Tworzenie i uruchamianie klasy testowej


Ponieważ nasza lista z możliwością wycofywania wykonywanych na niej operacji zachowuje
wszystkie elementy funkcjonalne listy w ogólności (zdefiniowane przez interfejs List),
więc stosowną klasę testową skonstruujemy na bazie ogólnej (abstrakcyjnej) klasy testowej
dla list (AbstractLi stTestCase), zyskując „na starcie" wiele gotowych, predefiniowanych
testów.
package com.wrox.algorithms.stacks;

import com.wrox.a1gori thms. 1 i sts.AbstractLi stTestCase;


i mport com.wrox.a1gori thms.11sts.ArrayList;
i mport com.wrox.a 1gori thms.1 i sts.L i st;

public class UndoableListTest extends AbstractListTestCase {


protected List createListO {
return new UndoableList(new ArrayListO);
}
J

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);

assertFal se(l i st. canllndoO);

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

Metody undo() i canilndoC) nie są metodami interfejsu L ist, lecz są specyficzne


dla klasy Undoab leL ist. Ich implementacją zajmiemy się nieco później.

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}));

assertFal se(l i st.canUndoO):

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}));

assertFal se( 1 ist. canilndoC)):

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()):

assertSame(VALUE_A. list.set(0. VALUE_B)):


assertTruet 1 i st.canUndoO);

list.undot);
assertEquals(l. list.sizeO):
assertSame(VALUE_A. 1 i st.get(0)):
assertFalse(1ist.canUndo()):
}
Rozdział 5. • Stosy 143

Kierując się względami prostoty przykładu, zrezygnowaliśmy z możliwości anulowania


skutków wywołania metody clearO. Zaimplementowanie tej możliwości — prawdopo-
dobnie przez „nagranie" kompletnej zawartości listy przed wywołaniem metody — może
być interesującym ćwiczeniem dla Czytelnika.
public void testClearResetsUndoStack() {

UndoableList list - new UndoableList(new ArrayListO):

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);

assertFal seCl i st.canUndoO);

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

weryfikującej m.in. poprawność dodawania (wstawiania) elementów do listy, mamy prawo


oczekiwać, że operacja ta wykonywana jest prawidłowo. Należy więc tylko upewnić się jesz-
cze, że metoda undo() powoduje usunięcie dodanego (wstawionego) elementu.

W przypadku testowania możliwości wycofania operacji usunięcia elementu także nie ma


potrzeby troszczenia się o poprawność wykonywania metody deleteO, ta bowiem zwery-
fikowana została już w ramach superklasy. Test jest więc stosunkowo prosty. Najpierw
tworzy się listę zapełnioną predefiniowanymi wartościami; ponieważ na liście nie wykona-
no jeszcze żadnej operacji, metoda canllndoO powinna zwrócić wartość false. Następnie
usuwa się jeden z elementów, po czym sprawdza, czy wywołanie metody undoO umieści
na powrót usunięty element na jego oryginalnej pozycji.

W ostatnim teście weryfikuje się poprawność wycofywania wielopoziomowego. Do listy


dodane zostają dwa elementy — A i B, po czym dodanie elementu B zostaje wycofane. Na-
stępnie usuwany jest (jedyny) element A i to usunięcie także zostaje wycofane. Kolejne
wywołanie metody undoO powinno spowodować wycofanie operacji dodania elementu A
(to pierwsza operacja, jaka wykonana została na liście); fakt, że w międzyczasie nastąpiło
wycofanie dwóch operacji, nie ma przy tym znaczenia2. Zauważmy, że dopiero wielopo-
ziomowe wycofywanie operacji stanowi należyty sprawdzian dla poprawnego funkcjono-
wania stosu, bowiem przy wycofywaniu tylko ostatniej operacji stos nie jest w ogóle po-
trzebny — o czym zresztą pisaliśmy już wcześniej.

Po stworzeniu stosownych testów dla klasy UndoableList zajmijmy się teraz implementacją
samej klasy.

illlŁtlńl Tworzenie klasy UndoableList


Ponieważ wymagania pod adresem klasy UndoableList zostały już należycie sprecyzowane
w dedykowanym jej zestawie testowym, sama jej implementacja nie nastręcza większych
problemów. Rozpoczniemy od opisania samej klasy w ogólności, po czym wyjaśnimy działa-
nie jej poszczególnych metod. Zwróćmy przy tym uwagę, jak dzięki dobremu projektowi
można zminimalizować wysiłek kodowania przy dodawaniu nowej funkcjonalności. Two-
rząc nową klasę jako otoczkę rzeczywistej listy, nie tylko uniezależniamy się od konkretnej
implementacji tej listy, lecz także znacznie upraszczamy sobie zadanie zaimplementowania
interfejsu List (patrz także opis dekoratorów w publikacji [Gamma, 1995]).

package com.wrox.a 1gori thms.stacks:

import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.algori thms.1 i sts.Li st;

public class UndoableList implements List {


/** odnośna lista */
private finał List _list;

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

/** stos dla operacji wycofywania */


private finał Stack _undoStack = new ListStackO;

public UndoableList(List list) {


assert list != nuli : "nie określono listy";
_list = list;
}
private static interface UndoAction {
public void execute();
}
}
Aby możliwe było wycofywanie operacji wstawiania i dołączania elementu, konieczne jest
„przechwytywanie" wywołań każdej z tych operacji, co zapewnią nam odpowiednio skon-
struowane metody insert() i add():
private finał class UndoInsertAction implements UndoAction {
private finał int _index;

public UndoInsertActiontint index) {


_index = index;
}
public void execute() {
_list.delete(_index);
}
}
public void insert(int index. Object value) throws IndexOutOfBoundsException {
_1i st.insert(i ndex. value):
_undoStack.push(new UndolnsertActi on(index)):
}
public void addtObject value) {
insert(size(), value);
}

W podobny sposób należy zapewnić przechwytywanie odwołań do operacji usuwania ele-


mentów:
private finał class UndoDeleteAction implements UndoAction {
private finał int _index;
private finał Object _value;

public UndoDeleteAction(int index. Object value) {


_index = index:
_value = value;
}
public void execute() {
list.insert( index, value);

public Object delete(int index) throws IndexOutOfBoundsException {


Object value - _list.delete(index);
_undoStack.push(new UndoDeleteAction(index. value));
return value;
146 Algorytmy. Od podstaw

}
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.

Konieczne jest ponadto przechwytywanie wywołań metody setO modyfikującej wartość


wskazanego elementu.
private finał class UndoSetAction implements UndoAction {
private finał int _index;
private finał Object _value:

public UndoSetAction(int index. Object value) {


_index = index;
_value = value;
}
public void execute() {
_list.set(_index. _value);
}
}
public Object set(int index, Object value) throws IndexOutOfBoundsException {
Object originalValue = _list.set(index. value);
_undoStack.push(new UndoSetAction(index. originalValue));
return originalValue:
1

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():
}

Sprawdzenie możliwości cofnięcia operacji, wykonywane przez metodę canUndoO, spro-


wadza się natomiast do sprawdzenia, czy na stosie _undoStack znajduje się jakiś element:
public boolean canUndoO {
return !_undoStack.isEmptyO;
}
Rozdział 5. • Stosy 147

Zgodnie z wcześniejszymi uwagami metodę clear() potraktowaliśmy odmiennie. Nie tylko


nie można jej cofnąć, lecz także jej wywołanie anuluje możliwość cofnięcia ewentualnych
wcześniejszych operacji, bowiem (dla prostoty) czyszczony jest stos _undoStack.
public void clearO {
_list.clearO;
_undoStack.clear();
}

Implementacja pozostałych metod interfejsu Li st staje się nieomal formalnością:


public Object get(int index) throws IndexOutOfBoundsException {
return _list.get(index);
}
public int indexOf(Object value) {
return _list.indexOf(value);
}
public Iterator iteratorO (
return _1 ist.iteratorO;
}
public boolean contains(Object value) {
return _list.contains(value);
}
public int sizeO {
return _list.sizeO;
}
public boolean isEmptyO {
return _1ist.isEmpty();
}
public String toStringO {
return _1 ist.toStringO:
}
public boolean equals(Object object) {
return _list.equals(object):
}

Ż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

Interfejs UndoAction jest przykładem tzw. wzorca polecenia (command patern


—patrz [Gamma, 1995]). Wzorzec ten stanowi abstrakcyjną reprezentację pewnego
zachowania — tu: wykonania akcji anulującej skutki operacji — ukonkretnianą
(implementowaną) w momencie, gdy zachowanie to trzeba sprecyzować. W naszym
przykładzie alternatywnym rozwiązaniem mógłby być wybór stosowanego zachowania
przez instrukcję switch bazującą na symbolicznych reprezentacjach anulowanych
operacji; byłoby to jednak rozwiązanie mniej eleganckie i zdecydowanie mniej
elastyczne, bo trudniej poddające się uogólnieniu na inne operacje.

Klasa UndoInsertAction implementuje interfejs UndoAction (a dokładniej — j e g o metodę


execute()) w ten sposób, że wstawiony element jest usuwany ze swej pozycji (przekazy-
wanej jako parametr konstruktora).

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.

Instancja klasy UndoDeleteAction zapamiętuje zarówno pozycję, jak i wartość usuwanego


elementu. Efektem wywołania jej metody execute() jest wstawienie na zapamiętaną pozy-
cję elementu o zapamiętanej wartości.

Metoda deleteO — w wariancie wykorzystującym indeks elementu — wywołuje metodę


deleteO odnośnej listy (_list), a następnie odkłada na stos odpowiednią instancję klasy
UndoDeleteAction. W wariancie bazującym na wartości usuwanego elementu metoda dele-
teO dokonuje obliczenia pozycji (indeksu) tego elementu, po czym przekazuje ten indeks
jako parametr wywołania pierwszego wariantu.

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().

Tak oto uzyskaliśmy w pełni zaimplementowaną i przetestowaną listę w wersji zapewniają-


cej wycofywanie wykonywanych na niej operacji.

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.

Po przedstawieniu trzech fundamentalnych struktur danych — list, kolejek i stosów — oraz


kilku prostych sposobów poszukiwania określonego łańcucha w liście, czas zająć się roz-
wiązywaniem problemów nieco bardziej skomplikowanych.
150 Algorytmy. Od podstaw
6
Sortowanie—proste algorytmy
Opisywane w poprzednich rozdziałach struktury danych pełnią fundamentalną rolę w two-
rzonych aplikacjach jako środki organizujące przetwarzanie ogromnych ilości danych. W szcze-
gólności sortowanie danych według pewnego kryterium stanowi nieodłączny element wielu
algorytmów, w tym algorytmów opisywanych w dalszych rozdziałach niniejszej książki.
Jednocześnie staje się ono często wąskim gardłem wydajności aplikacji, nic więc dziwnego,
że sortowanie danych rozmaitych typów było przedmiotem intensywnych badań w ostat-
nich dziesięcioleciach i nadal stanowi jeden z kluczowych punktów zainteresowań infor-
matyki. W niniejszym rozdziale omawiamy trzy proste algorytmy sortowania, łatwe w im-
plementacji, lecz przydatne raczej dla niewielkich ilości danych, a to ze względu na złożoność
kwadratową 0(N2) (N jest liczbą elementów w sortowanym zestawie). Bardziej wydajnym
— i jednocześnie bardziej skomplikowanym — algorytmom sortowania poświęcony będzie
rozdział 7.

W niniejszym rozdziale omawiamy:

• znaczenie sortowania — w tworzonych aplikacjach i nie tylko,

M rolę komparatorów w konstrukcji i przetwarzaniu struktur danych,

• algorytm sortowania bąbelkowego,

• algorytm sortowania przez wybieranie,

• algorytm sortowania przez wstawianie,

• własność stabilności sortowania,

• zalety i wady prostych metod sortowania.

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.

Wspomniane oddzielenie kryterium sortowania od samego sortowania jest przykładem bar-


dziej ogólnej koncepcji projektowej, zwanej rozdzielaniem zagadnień (separation of con-
cerns). Umożliwia ono rozszerzanie użyteczności samego algorytmu dzięki zastosowaniu
rozmaitych „wtyczek" (plugins) — nawet takich, które trudno byłoby sobie wyobrazić w mo-
mencie implementowania samego algorytmu. Właśnie komparatory są przykładem takich
„wtyczek" dla algorytmów sortowania. Można także spojrzeć na całą sprawę z drugiej strony:
zastosowanie tego samego komparatora do różnych algorytmów sortowania umożliwia miaro-
dajne porównywanie wydajności tych algorytmów.

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)

Niektóre komparatory standardowe


Oprócz nieograniczonych wręcz możliwości definiowania komparatorów przez programistę
istnieje w języku Java kilka komparatorów standardowych w dużym stopniu upraszczających
tworzenie kodu aplikacji. Każdy z nich jest prosty pod względem koncepcyjnym i wielce
użyteczny w konstrukcji wielu złożonych algorytmów prezentowanych w niniejszej książce.

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

zwracającą — podobnie jak metoda compareO interfejsu Comparator — wartość ujemną


zero lub wartość dodatnią zależnie od tego, czy obiekt stanowiący podmiot wywołania metody
jest (odpowiednio) mniejszy, równy lub większy od obiektu reprezentowanego przez para-
metr other. Zasadnicza różnica między metodą compare() a metodą compareTo() polega na
tym, iż ta pierwsza porównuje ze sobą dwa wskazane obiekty, podczas gdy druga porów-
nuje dany obiekt z innym obiektem.
Rozdział 6. • Sortowanie — proste algorytmy 155

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.

Gdy wyobrazimy sobie funkcjonowanie komparatora naturalnego (którego klasę nazwiemy


NaturalComparator), szybko stanie się jasne, iż należy go przetestować w kontekście trzech
przypadków: porównania obiektu „mniejszego z większym", „większego z mniejszym"
oraz dwóch „równych" obiektów. Aby ułatwić sobie zadanie, wykorzystamy fakt, że inter-
fejs Comparable zdefiniowany jest standardowo m.in. dla łańcuchów języka Java.

Testowanie komparatora naturalnego


Rozpoczniemy od konfiguracji, w której metoda compareO komparatora naturalnego po-
winna zwrócić wartość ujemną:
public void testLessThan() {
assertTrue(NaturalComparator. INSTANCE.compareCA". "B") < 0);
_J

Zamieniając miejscami porównywane obiekty, otrzymamy konfigurację, w której metoda


compare() powinna zwrócić wartość dodatnią:
public void testGreaterThanO {
assertTrue(Natura 1Comparator.INSTANCE.compare("B" "A") > 0):
}
Przy porównywaniu dwóch identycznych wartości metoda compare() powinna zwrócić 0:
public void testEqualTo O {
assertTrue(NaturalComparator. INSTANCE.compareCA". "A") = 0):
}

J a k to działa?

Dla każdego z trzech możliwych przypadków relacji między porównywanymi obiektami


(mniejszy, równy, większy) zdefiniowano osobny przypadek testowy, wykonujący oczywiste
porównanie łańcuchów jednoznakowych. Wszystkie przypadki testowe bazują na założe-
niu, że istnieje tylko jedna statyczna instancja klasy NaturalComparator i nie należy two-
rzyć innych jej instancji.

Implementowanie komparatora naturalnego


Ponieważ klasa NaturalComparator nie przechowuje żadnych informacji o stanie, wystar-
czające jest istnienie tylko jednej, publicznie dostępnej jej instancji:
156 Algorytmy. Od podstaw

public finał class Natura 1Comparator implements Comparator {


/** jedyna, publicznie dostępna instancja komparatora */
public static finał NaturalComparator INSTANCE = new NaturalComparatorO:
/**

* konstruktor prywatny, nie jest możliwe samodzielne tworzenie instancji


*/
private NaturalComparatorO {
}

}
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.

Ponieważ metoda compareO komparatora naturalnego implementowana jest na bazie inter-


fejsu Comparable, zasadnicza jej czynność scedowana została na jej argumenty, co czyni jej
implementację niemal banalną:
public int compare(Object left. Object right) throws ClassCastException {
assert left != nuli : "nie określono lewego obiektu ";
return ((Comparable) left).compareTo(right):
)

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?

Klasa Natural Comparator skonstruowana została w celu porównywania dwóch obiektów


implementujących interfejs Comparable. Interfejs ten implementowany jest standardowo
przez wiele klas Javy i oczywiście można go implementować ad hoc w klasach definiowa-
nych samodzielnie. Implementacja taka polega każdorazowo na zrzutowaniu lewego argu-
mentu na instancję interfejsu Comparable i wywołaniu na jego rzecz metody compareToO
z prawym argumentem jako parametrem. Wszelka „logika porównywania" nie jest w tej
implementacji widoczna, skrywa się bowiem całkowicie w implementacji metody compareToO.

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

wrotnej do kolejności alfabetycznej. Trywialnym sposobem wykonania tego zadania jest


zamiana znaczeń obiektów stanowiących argumenty wywołania metody compareO kompa-
ratora NaturalComparator:
public int compare(Object left, Object right) throws ClassCastException {
assert right != nuli : "nie określono prawego obiektu ";
return ((Comparable) right).compareTo(left):
J

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.

Znacznie bardziej eleganckie rozwiązanie, które za chwilę zaprezentujemy, polega na od-


wróceniu kierunku wskazanego komparatora poprzez jego „udekorowanie" (otoczenie) innym
komparatorem, w wyniku czego otrzymuje się komparator zwany komparatorem odwrotnym.
Dla każdego typu danych wystarczy wówczas zdefiniować tylko jeden komparator i w razie
potrzeby „odwracać" wyznaczane przez niego uporządkowanie w sposób uniwersalny, za
pomocą opisanego odwracania.

iiliŁfliil Testowanie komparatora odwrotnego


Podobnie jak w przypadku komparatora NaturalComparator, tak i w przypadku komparatora
odwrotnego — który nazwiemy ReverseComparator — przetestować musimy trzy możliwe
przypadki relacji między porównywanymi obiektami. „Dekorowanym" komparatorem, którego
kierunek będziemy odwracać, będzie przy tym sam komparator naturalny Natural Comparator.

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);

assertTrue(comparator.compare("A". "B") > 0):


}

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):

assertTrue(comparator.compare("B". "A") < 0):


)
158 Algorytmy. Od podstaw

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);

assertTrue(comparator.compareCA". "A") == 0);


}

J a k to działa?

Na bazie komparatora naturalnego NaturalComparator tworzony jest komparator odwrotny


ReverseComparator, którego działanie (czyli wyniki porównań) powinno być dokładnie od-
wrotne w stosunku do pierwowzoru. W szczególności łańcuch „A" powinien być uznany za
„większy" od łańcucha „B" i vice versa — łańcuch „B" powinien zostać uznany za „mniej-
szy" od łańcucha „A". W przypadku porównywania dwóch identycznych obiektów kompa-
rator odwrotny także powinien uznać je za identyczne.

wirami Implementowanie komparatora odwrotnego


Implementacja komparatora ReverseComparator składa się z niewielu linii kodu:
package com.wrox.a 1gori thms.sorti ng:

public class ReverseComparator implements Comparator {


private finał Comparator _comparator:

public ReverseComparator(Comparator comparator) {


assert comparator != nuli : "nie określono oryginalnego komparatora";
_comparator = comparator:
}
}
W konstruktorze klasy przekazywany jest oryginalny komparator, którego działanie ulec
ma odwróceniu; jest on zapamiętywany w prywatnej zmiennej _comparator.

Pozostaje tylko zaimplementowanie metody compareO — j e d y n e j metody implementowa-


nego interfejsu Comparator:
public int compare(0bject left, Object right) throws ClassCastException {
return _comparator.compare(right. left);
}

J a k to działa?

Na pierwszy rzut oka wygląda to ma zwykłe delegowanie wywołania do metody compareO


komparatora oryginalnego; jeśli jednak spojrzeć uważnie po raz drugi, łatwo można zauważyć,
że delegowaniu temu towarzyszy zmiana kolejności argumentów: przykładowo wywołanie
Rozdział 6. • Sortowanie — proste algorytmy 159

compare( "A", "B") w interfejsie odwrotnym (ReverseComparator) delegowane jest do inter-


fejsu oryginalnego jako wywołanie compare("B", "A"). Zamiana kolejności argumentów
wywołania metody daje w konsekwencji odwrotny wynik samej metody.

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.

spróbuj sam Przeprowadzanie sortowania bąbelkowego


Wyobraźmy sobie rodzinę udającą się do fotografa. Na wspólnej fotografii członkowie ro-
dziny powinni ustawić się według starszeństwa, od najmłodszego do najstarszego, gdy tym-
czasem ustawieni są w sposób przypadkowy, jak na rysunku 6.1.

Rysunek 6.1.
Rodzina
w przypadkowym
szyku

Aby dokonać przestawienia członków rodziny według algorytmu sortowania bąbelkowego,


porównamy dwie skrajne osoby z lewej strony; nie są one ustawione zgodnie z wymaga-
niami — pierwsza z nich jest starsza od drugiej — poprosimy je więc, by zamieniły się
miejscami, co da efekt widoczny na rysunku 6.2.

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);
}

Abstrakcyjna klasa testowa dla sortowania list


Mimo iż nie napisaliśmy jeszcze ani jednej linijki algorytmu sortującego, rozpoczniemy od
stworzenia zestawu testowego weryfikującego poprawność dowolnej implementacji inter-
fejsu ListSorter. Zgodnie z wcześniejszymi uwagami za podstawę konstrukcyjną wszyst-
kich testów posłuży nam abstrakcyjna klasa AbstractLi stSorterTest, obejmująca wszelkie
aspekty testowe niezależne od konkretnego algorytmu sortowania, w szczególności:
• utworzenie nieposortowanej listy łańcuchów,
• utworzenie posortowanej listy tych samych łańcuchów służącej jako wzorzec
oczekiwanego rezultatu sortowania,
• utworzenie instancji klasy implementującej interfejs ListSorter — ta operacja
wykonywana jest w ramach metody abstrakcyjnej wymagającej zdefiniowania
w klasie pochodnej,
• posortowanie oryginalnej listy za pomocą metody s o r t O instancji utworzonej
w poprzednim punkcie,
• porównanie wyników sortowania z wzorcem utworzonym w punkcie drugim.

spróbuj sam Tworzenie abstrakcyjnej klasy testowej


Dwie pierwsze z wymienionych przed chwilą czynności — utworzenie listy wejściowej i jej
posortowanego odpowiednika — dokonywane są przez metodę setUp() klasy testowej.
package com.wrox.algori thms.sorti ng;

i mport com.wrox.algori thms.1 i sts.Li nkedLi st;


i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase;

public abstract class AbstractListSorterTest extends TestCase {


private List _unsortedList;
private List _sortedList:

protected void setUpO throws Exception {


162 Algorytmy. Od podstaw

jjnsortedList - new LinkedList();


_unsortedLi st.add("programowanie"):
jjnsortedLi st.add("sterowane");
_unsortedLi st.add("testami");
_unsortedList.add("to");
jjnsortedList .addOmały");
_unsortedLi st.add("krok");
jjnsortedList.add("dl a");
jjnsortedLi st.add("programi sty");
jjnsortedList.add("lecz");
_unsortedList,add("olbrzymi");
jjnsortedLi st.add("skok");
jjnsortedList.add("w");
_unsortedLi st.add("dziejach");
_unsortedLi st.add("programowani a");

_sortedList = new LinkedList();

_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");
}

Obydwie listy zostają zwolnione przez metodę tearDownO:


protected void tearDownO throws Exception {
_sortedList = nuli:
jjnsortedList = nuli:
1

Musimy jeszcze zadeklarować abstrakcyjną metodę tworzącą instancję implementującą in-


terfejs ListSorter:
protected abstract ListSorter createListSortertComparator comparator);

i zdefiniować metodę wykonującą właściwy test:


public void testListSorterCanSortSampleListO {
ListSorter sorter = createListSorter(naturalComparator.INSTANCE):
List result = sorter.sort(_unsortedList):

assertEquals(result.sizeO. _sortedList,size());

Iterator actual = result.iteratorO;


actual .firstO;
Rozdział 6. • Sortowanie — proste algorytmy 163

Iterator expected = _sortedL1st.iteratorO;


expected. firstO;

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!

Przechodząc od ogółu do szczegółów, zajmijmy się testowaniem sortowania bąbelkowego.

spróbuj sam Testowanie klasy BubbleListSorter


Testową klasę dla sortowania bąbelkowego — BubbleListSorterTest — wyprowadzimy
z klasy abstrakcyjnej AbstractListSorterTest, implementując odpowiednio jej metodę
createListSorterO.

package com.wrox.algori thms.sorti ng;

public class BubblesortListSorterTest extends AbstractListSorterTest {


protected ListSorter createListSorter(Comparator comparator) {
return new BubblesortListSorter(comparator);
}

Z kompilacją powyższego kodu musimy jednak poczekać, aż zdefiniujemy klasę Bubble-


sortLi stSorter — uczynimy to niebawem.

J a k to działa?

Klasa BubbleListSorterTest, mimo iż jej zdefiniowanie sprowadzało się do zdefiniowania


jednej metody, dziedziczy po klasie bazowej AbstractListSorterTest zestaw danych te-
stowych oraz metodę testListSorterCanSortSampleList() zawierającą całą „logikę testową".
Konkretyzuje ona jedyny abstrakcyjny element tej logiki — metodę createListSorterO
tworzącą instancję klasy reprezentującej algorytm sortujący.
164 Algorytmy. Od podstaw

Implementowanie algorytmu sortowania bąbelkowego


—klasa BubbleListSorter
Implementacja klasy realizującej algorytm sortowania bąbelkowego musi spełniać trzy na-
stępujące kryteria:
• musi implementować interfejs ListSorter,
• musi dopuszczać dowolny komparator określający uporządkowanie elementów,
• musi przejść pozytywnie testy opisane przed chwilą

Mając na uwadze powyższe wymogi, rozpocznijmy od zdefiniowania konstruktora:


package com,wrox.algorithms.sorting;

i mport com.wrox.a1gori thms.1 i sts.Li st:

public class BubblesortListSorter implements ListSorter {


private finał Comparator _comparator:
/**

* 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).

W każdym kroku porównywane są pary sąsiadujących elementów; jeżeli względna kolej-


ność elementów pary nie jest zgodna z kryterium określonym przez komparator, elementy
zamieniane są miejscami — ten cykl nazwiemy pętlą wewnętrzną (inner loop). Ponieważ
w każdym kroku kolejny element końcowy „ląduje" na swej pozycji docelowej, liczba ele-
mentów porównywanych w kolejnych krokach systematycznie się zmniejsza: w pierwszym
kroku musimy wykonać N-\ porównań, w drugim N—2 itd. Wyjaśnia to warunek kontynu-
owania pętli wewnętrznej left < (size - pass).
public List sort(List list) {
assert list != nuli : "nie określono listy wejściowej";

int size = list.sizeO;

for (int pass = 1; pass < size: ++pass) { // pętla zewnętrzna


Rozdział 6. • Sortowanie — proste algorytmy 165

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);

Po zaimplementowaniu i (pomyślnym) przetestowaniu klasy BubblesortLi stSorter można


celowo sprowokować załamanie testu, na przykład zmieniając wzorcową listę w taki spo-
sób, by nie spełniała kryterium sortowania. Prędzej czy później trzeba jednak zająć się ko-
lejnym algorytmem sortowania.

Sortowanie przez wybieranie


Wyobraź sobie książki o różnej wysokości, przypadkowo ułożono na półce, jak przedstawia
to rysunek 6.6. Właśnie spodziewasz się odwiedzin mamy i chcesz jej zaimponować swo-
ich zamiłowaniem do porządku domowego, postanawiasz więc poukładać książki według
malejącej wysokości od lewej do prawej.

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

Kontynuując konsekwentnie to postępowanie, wybieramy z nieposortowanej jeszcze grupy


książek najwyższą i wstawiamy ją na kolejne miejsce od lewej — dlatego właśnie opisana
metoda nazywa się sortowaniem przez wybieranie. Kolejne stadia sortowania z użyciem tej
metody przedstawione są na rysunku 6.9.

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.

IiiMŁf.Tili Testowanie klasy SelectionSortListSorter


Klasę testującą sortowanie przez wybieranie — Sel ecti onSortLi stSorterTest — skonstru-
ujemy w taki sam sposób jak klasę testową dla sortowania bąbelkowego — zaimplementu-
jemy odpowiednio metodę abstrakcyjną createListSorterO tak, by zwracała instancję klasy
Selecti onSortLi stSorter.

package com.wrox.a Igori thms.sorti ng;

*/
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.

spróbuj sam Implementowanie klasy SelectionSortListSorter


Klasa SelectionSortListSorter jest pod wieloma względami podobna do klasy BubbleSort-
ListSorter: implementuje interfejs ListSorter, działa w oparciu o komparator wyznaczający
kryterium sortowania i oczywiście musi pomyślnie „zaliczyć" testy przeprowadzane w oparciu
o odpowiednią klasę testową. Rozpoczniemy od konstruktora klasy:
public class SelectionSortListSorter implements ListSorter {
private finał Comparator _comparator:
/**

* Konstruktor
* parametr: komparator określający uporządkowanie elementów
*/
Rozdział 6. • Sortowanie — proste algorytmy 169

public SelectionSortListSorter(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
}

J a k to działa?

Implementacja sortowania przez wybieranie ma postać dwóch zagnieżdżonych pętli — ze-


wnętrznej i wewnętrznej — podobnie jak w przypadku sortowania bąbelkowego. Jest jed-
nak kilka istotnych różnic, nie od razu zauważalnych. Po pierwsze, pętla zewnętrzna prze-
biega indeksy od 0 do N—2, a nie od 1 do AM. Liczba kroków pozostaje ta sama, lecz
zmienna sterująca pętli równa jest pozycji docelowej, na której umieszczany jest kolejny
element — w pierwszym kroku jest to pozycja 0, w drugim — pozycja 1 itd. Po wykonaniu
N—\ kroków ostatni, :V-ty element samoczynnie znajduje się już na właściwej pozycji.

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.

public List sort(List list) {


assert list ! = nuli ; "nie określono listy";

int size = list.sizeO;

for (int slot = 0; slot < size - 1: ++slot) {


int smaliest = slot;
for (int check = slot + 1: check < size; ++check) {
if (_comparator.compare(list.get(check). list.get(smallest)) < 0) {
smaliest = check;
}
}
swapdist, smallest. slot):
}
return list:
}
Po trzecie, istnieje pewna drobna, lecz istotna różnica w procedurze przestawiającej ele-
menty. Może się otóż zdarzyć, że kolejny element będzie się już znajdował na swoim miej-
scu i przestawianie go (z samym sobą) będzie niepotrzebne (w sortowaniu bąbelkowym
sytuacja taka nie mogła się zdarzyć, bowiem przestawianie dotyczyło zawsze sąsiadujących
elementów). Metoda swapO sprawdza więc każdorazowo, czy elementy specyfikowane do
przestawienia są istotnie różne:

private void swap(List list. int left. int right) {


if (left == right) { // czy istotnie chodzi o różne elementy?
return; // nie. nic nie rób.
}
170 Algorytmy. Od podstaw

Object temp = list.get(left);


list.setdeft. list.get(right)):
list.set(right. temp);

Sortowanie przez wstawianie


Sortowanie przez wstawianie (insertionsort) charakterystyczne jest dla układania trzyma-
nych w ręku kart w kolejności wzrastającej ważności. Załóżmy, że leży przed Tobą pięć
odwróconych kart (rys. 6.10), które chciałbyś posortować według następującego kryterium:

• najpierw piki ( p o t e m trefle ( «!•), potem kara ( a na końcu kiery (^fp),

• 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 •

spróbuj sam Testowanie klasy InsertionSortUstSorter


Podobnie jak w przypadku dwóch poprzednich algorytmów sortowania klasę testową wy-
prowadzimy z abstrakcyjnej klasy AbstractListSorterTest, konkretyzując jej metodę create-
ListSorterO.
172 Algorytmy. Od podstaw

package com.wrox.a1gori thms.sorti ng;

public class InsertionSortListSorterTest extends AbstractListSorterTest {


protected ListSorter createListSorter(Comparator comparator) {
return new InsertionSortListSorter(comparator);

J a k to działa?

Tak jak poprzednio klasa testowa (InsertionSortListSorterTest) dziedziczy po swej kla-


sie bazowej AbstractListSorterTest wszystkie dane testowe i całą logikę testową. Jedy-
nym elementem specyficznym dla sortowania przez wstawianie jest zaimplementowana meto-
da createListSorter(), dostarczająca instancji klasy realizującej algorytm sortowania.

Implementowanie klasy InsertionSortUstSorter


Podobnie jak dwie poprzednie klasy implementujące algorytmy sortowania klasa Inser-
t i onSortLi stSorter implementuje interfejs ListSorter, jej działanie opiera się na porządku
wyznaczanym przez komparator i może być weryfikowane za pomocą odpowiedniej klasy
testowej.
package com.wrox.algori thms.sorti ng;

i mport com.wrox,a1gori thms.1 i sts.Li st:


import com.wrox.algorithms.1 i sts.Li nkedLi st:
i mport com.wrox.a1gori thms.i terati on.Iterator;

public class InsertionSortListSorter implements ListSorter {


private finał Comparator _comparator;
/**

* Konstruktor
* parametr: komparator określający uporządkowanie elementów
*/

public InsertionSortListSorter(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
}
}
Metoda s o r t O klasy InsertionSortListSorter różni się zasadniczo od tej implementowa-
nej w klasach BubbleSortListSorter i SelectionSortListSorter pod jednym względem:
zamiast sortowania zawartości listy „w miejscu" tworzymy nową pustą listę wynikową i sukce-
sywnie wstawiamy do niej (na właściwą pozycję) elementy pobierane kolejno z listy wej-
ściowej .
public List sort(List list) {
assert list != nuli : "nie określono listy wejściowej";

finał List result = new LinkedListO:


Rozdział 6. • Sortowanie — proste algorytmy 173

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.

Zwróćmy ponadto uwagę, że poszukiwanie (w pętli wewnętrznej) właściwej pozycji w li-


ście wynikowej rozpoczyna się od jej końca. Mimo iż nie wpływa to na wydajność sorto-
wania przeciętnej listy, to jednak drastycznie poprawia tę wydajność w przypadku, gdy lista
wejściowa jest już posortowana (lub prawie posortowana) — wstawienie elementu (a wła-
ściwie jego dołączenie) odbywa się już po wykonaniu jednego porównania. Powrócimy do
tej kwestii przy okazji porównywania prostych algorytmów sortowania w dalszej części ni-
niejszego rozdziału. Kierunek przeglądania posortowanej listy wynikowej nie jest nato-
miast obojętny z punktu widzenia stabilności sortowania.

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

Tabela 6.1. Lista posortowana według imion

Imię Nazwisko

Albert Smith
Brian Jackson
David Barnes
John Smith
John Wilson
Mary Smith
Tom Barnes
Vince De Marco
Walter Ciarkę

Tabela 6.2. Lista z tabeli 6.1 stabilnie posortowana według nazwisk

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.

Porównanie prostych algorytmów sortowania


Po zapoznaniu się z trzema prostymi algorytmami sortowania — bąbelkowego, przez wy-
bieranie i przez wstawianie — nie sposób nie zastanawiać się, który z nich okaże się naj-
lepszy w danym zastosowaniu, a dokładniej —jakimi kryteriami należy się kierować przy
dokonywaniu jego wyboru. W niniejszym podrozdziale dokonamy porównania wymienio-
nych algorytmów; nie będzie to formalne porównanie matematyczne, lecz porównanie
praktyczne oparte na obserwacji sortowania rzeczywistych danych. Nie jest naszym zada-
niem definitywne sformułowanie kryteriów wyboru konkretnego algorytmu, lecz raczej po-
kazanie, jak wspomniana analiza porównawcza może dokonanie takiego wyboru ułatwić.

Na początku tego rozdziału informowaliśmy, ze istotą każdego sortowania jest intensywne


wykonywanie dwóch operacji: porównywania elementów i ich przestawiania. Nasza anali-
za porównawcza koncentrować się będzie na pierwszej z tych operacji, a używane na jej
potrzeby zestawy danych będą znacznie większe niż w zestawach testowych weryfikują-
cych poprawność implementacji algorytmów; jest to konieczne z tego względu, że praw-
dziwy charakter każdego algorytmu, odzwierciedlany głównie przez jego zachowanie asymp-
totyczne wyrażone w notacji dużego O, uwidacznia się dopiero przy rozwiązywaniu problemów
o dużych rozmiarach. Ponadto, ponieważ konkretne dane wejściowe algorytmu mają zwy-
kle wpływ na jego efektywność, analizę naszą przeprowadzimy w oparciu o trzy szczególne
rodzaje danych wejściowych:

• listę już posortowaną,


• listę posortowaną w kolejności odwrotnej do żądanej,
• listę o przypadkowej kolejności elementów.

Obserwując zachowanie się — czyli zliczając wykonywane porównania — wszystkich


trzech algorytmów dla każdego z wymienionych przypadków, będzie można w przybliże-
niu ocenić, który algorytm nadaje się najlepiej dla danego przypadku napotkanego w rze-
czywistej aplikacji.
176 Algorytmy. Od podstaw

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.

public finał class CallCountingComparator implements Comparator {


/** komparator oryginalny, który wyposażamy w funkcję zliczania */
private finał Comparator _comparator:

/** zmienna przechowująca liczbę zarejestrowanych wywołań komparatora */


private int _callCount:

* 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).

Mając do dyspozycji komparator zliczający, możemy tworzyć zestawy testowe badające


zachowanie się poszczególnych algorytmów sortowania w odniesieniu do danych o różnym
charakterze.
Rozdział 6. • Sortowanie — proste algorytmy 177

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;

import com.wrox.algorithms.1 ists.ArrayList;


i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase;

public class ListSorterCallCountingTest extends TestCase {


private static finał int TEST_SIZE - 1000;

// lista posortowana

private finał List _sortedArrayList = new ArrayList(TEST_SIZE);

// lista posortowana odwrotnie


private finał List _reverseArrayList - new ArrayList(TEST_SIZE);

// lista o przypadkowej kolejności elementów


private finał List _randomArrayList = new ArrayList(TEST_SIZE);

private CallCountingComparator _comparator:

Samo zdefiniowanie list _sortedArrayList, _reverseArrayList i _randomArrayList to dopiero


początek, musimy bowiem wypełnić te listy wartościami w sposób odpowiadający ich zało-
żonej charakterystyce. Zakładamy, że elementami tymi będą liczby całkowite, czyli obiekty
typu i nteger. W przypadku dwóch pierwszych list będą to kolejne liczby naturalne od 1 do
1 000 uszeregowane w kolejności (odpowiednio) rosnącej i malejącej; w przypadku trzeciej
listy będą to losowe liczby całkowite z tego zakresu. Musimy także zdefiniować kompara-
tor zliczający, który oprzemy na komparatorze naturalnym (NaturalComparator). Jest to do-
puszczalne, bowiem typ java.lang.integer implementuje interfejs Comparable, podobnie
jak implementują go łańcuchy wykorzystywane we wcześniejszych przykładach.

protected void setUpO throws Exception {


super. setUpO;
_comparator = new CallCountingComparatortNaturalComparator.INSTANCE);

for (int i = 1; i < TEST_SIZE; ++1) {


_sortedArrayList.add(new Integer(i));
}
for (int i = TEST_SIZE; i > 0; --i) {
_reverseArrayList.add(new Integer(i));
178 Algorytmy. Od podstaw

}
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.

public void testReverseCaseBubblesort O {


new BubblesortListSorter(_comparator),sort(_reverseArrayList):
reportCallsO:
}
public void testReverseCaseSelectionSort O {
new SelectionSortListSorter(_comparator).sort(_reverseArrayList);
reportCallsO;
}
public void testReverseCaseInsertionSort O {
new InsertionSortListSorter(_comparator).sort(_reverseArrayList);
reportCallsO;
}
Wyniki obserwacji każdego z sortowań, czyli informacja o liczbie wywołań metody compa-
reO odnośnego komparatora, wyświetlane są za pomocą metody reportCallsO, którą opi-
szemy za chwilę. W podobny sposób przeprowadzimy obserwację dla listy posortowanej
w żądanej kolejności...
public void testDirectCaseBubblesort O {
new BubblesortListSorter(_comparator),sort(_sortedArrayList);
reportCallsO;
}
public void testDirectCaseSelectionSort O {
new Selecti onSortLi stSorter(_comparator).sort(_sortedArrayLi st);
reportCallsO;
}
public void testDirectCaselnsertionSort O {
new InsertionSortListSorter(_comparator),sort(_sortedArrayList);
reportCallsO;
}
i dla listy o losowym układzie elementów:
Rozdział 6. • Sortowanie — proste algorytmy 179

public void testRandomCaseBubblesort O {


new BubblesortListSorter(_comparator),sort(_randomArrayList);
reportCallsO:
}
public void testRandomCaseSelectionSort O {
new SelectionSortListSorter(_comparator),sort(_randomArrayList):
reportCalls();
}
public void testRandomCaselnsertionSort O {
new InsertionSortListSorter(_comparator).sort(_randomArrayList);
reportCallsO;
}
Wspomniana wcześniej metoda reportCallsO odczytuje — za pomocą metody callCo-
unt() — licznik dokonanych porównań i wyprowadza jego wartość poprzedzoną nazwą klasy
testowej:
private void reportCallsO {
System.out.println(getName() + ": " + _comparator.getCallCountO + " wywołań");
}
Nazwa klasy testowej — j a k łatwo się zorientować — udostępniana jest przez metodę get-
Name(), która jest metodą klasy bazowej TestCase biblioteki JUnit. Oto przykładowy raport
dla listy posortowanej odwrotnie:
testReverseCaseBubblesort: 499500 wywołań
testReverseCaseSelectionSort: 499500 wywołań
testReverseCaseInsertionSort: 499500 wywołań

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.

Jak interpretować wyniki tej analizy?


Z przeprowadzonej analizy porównawczej powinniśmy oczywiście wyciągnąć pewne wnio-
ski, musimy jednak być przy tym świadomi warunków, w jakich analiza ta została prze-
prowadzona. Aby mianowicie poznać prawdziwe oblicze każdego z algorytmów, należałoby
wzbogacić tę analizę o co najmniej następujące elementy:
• zliczanie także operacji przestawiania elementów, a nie tylko operacji ich
porównywania,
• wykorzystanie różnych implementacji list, na przykład tablicowej, a nie tylko wiązanej,
• pomiar rzeczywistego czasu wykonania każdego z sortowań.

Mimo wspomnianych ograniczeń możemy jednak pokusić się o następujące ustalenia:


• Algorytmy sortowania bąbelkowego i sortowania przez wybieranie wykonują
zawsze tę samą liczbę porównań dla tych samych danych wejściowych.
• Liczba operacji wykonywanych zarówno w sortowaniu bąbelkowym, jak i w sortowaniu
przez wybieranie, jest niezależna od charakteru sortowanych danych.
• Liczba operacji wykonywanych w sortowaniu przez wstawianie jest w dużym
stopniu zależna od charakteru sortowanych danych. W najgorszym przypadku
liczba ta jest równa liczbie porównań wykonywanych przez dwa pozostałe
algorytmy (dla tych samych danych), w najlepszym przypadku jest ona mniejsza
od liczby sortowanych elementów.

Być może najważniejszym wnioskiem podsumowującym analizę jest niewrażliwość sorto-


wania bąbelkowego i sortowania przez wybieranie na charakter sortowanych danych. W prze-
ciwieństwie do nich sortowanie przez wstawianie wykazuje duże zdolności adaptacyjne: jeśli
można posortować dane mniejszym nakładem pracy, algorytm istotnie wykorzystuje tę możli-
wość. Jest to główną przyczyną faworyzowania w praktyce sortowania przez wstawianie
w stosunku do sortowania bąbelkowego i sortowania przez wybieranie.

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

• porównaliśmy liczbę porównań wykonywanych przez każdy z trzech wymienionych


algorytmów sortowania dla trzech szczególnych rodzajów danych wejściowych:
listy już posortowanej, listy posortowanej odwrotnie i listy o losowym układzie
elementów oraz sformułowaliśmy ogólne wnioski na temat charakteru każdego
z algorytmów,
• wyjaśniliśmy pojęcie stabilności algorytmu sortowania.

Lektura niniejszego rozdziału z pewnością pozwoli Czytelnikom lepiej zrozumieć znacze-


nie sortowania dla innych czynności algorytmicznych, na przykład wyszukiwania. Treść
rozdziału jest ponadto dowodem na to, że rozmaite problemy algorytmiczne mogą być roz-
wiązywane na różne sposoby — w szczególności istnieje kilka różnych metod porządko-
wania elementów w zadanej kolejności. W następnym rozdziale poznamy inne, bardziej
złożone metody sortowania, które dla bardzo dużych rozmiarów danych wejściowych oka-
zują się znacznie efektywniejsze od metod dotychczas opisanych.

Ć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!

W niniejszym rozdziale wyjaśniamy następujące zagadnienia:


• sortowanie metodą Shella,
• sortowanie szybkie,
• komparatory złożone i ich rola w zapewnieniu stabilności sortowania,
• sortowanie przez łączenie,
• porównanie opisywanych algorytmów sortowania.

Sortowanie metodą Shella


Jednym z podstawowych ograniczeń właściwych prostym metodom sortowania jest duży
nakład pracy, jaką trzeba wykonać w celu przemieszczania elementów znajdujących się
daleko od swej pozycji docelowej. Jedną z cech wspólnych dla algorytmów opisywanych
w niniejszym rozdziale jest możliwość szybkiego przemieszczania elementów na znaczne
odległości, przez co algorytmy te znacznie przewyższają swą efektywnością algorytmy opi-
sywane w rozdziale poprzednim.
184 A l g o r y t m y . Od podstaw

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.

Działanie algorytmu Shellsort wyjaśnimy na przykładzie alfabetycznego sortowania liter


tworzących początkowo ciąg pokazany na rysunku 7.1.

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

Gdy poddamy tę listę 4-sortowaniu — ignorując na razie wszystkie pozostałe elementy —


otrzymamy ciąg widoczny na rysunku 7.3: elementy tej podlisty ułożone zostaną w kolej-
ności alfabetycznej (B, G, H, N, 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

W podobny sposób posortować należy 4-listę rozpoczynającą się od elementu na pozycji


postać tej listy przed i po sortowaniu widoczna jest na rysunku 7.4.

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

t rozpoczyna się od elementu 2; jej sortowanie przedstawia rysunek 7.9.

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

Przeprowadzono wiele teoretycznych dociekań i eksperymentów w związku z optymalnym


doborem ciągu wartości, jaki przyjmować powinna zmienna H, czyli długości //-list sorto-
wanych w kolejnych krokach. Zaproponowany przez autora geometryczny ciąg potęg licz-
by 2 (...32, 16, 8, 4, 2, 1) okazuje się, niestety, niezbyt szczęśliwy, bowiem (z wyjątkiem
ostatniego kroku sortowania) elementy na pozycjach nieparzystych porównywane są tylko
z elementami na pozycjach nieparzystych, a elementy na pozycjach parzystych — tylko
z elementami na pozycjach parzystych. Tymczasem algorytm Shellsort działa efektywnie
wtedy, gdy dany element porównywany jest z jak największą liczbą innych elementów.
Często używanym w praktyce ciągiem wartości / / j e s t odwrotność ciągu określonego for-
mułą Hlt] = 3 x / / . +1 , czyli ciąg kończący się wartościami ..., 121, 40, 13, 4, 1. Ten wła-
śnie ciąg wykorzystamy na potrzeby prezentowanych dalej przykładów.

Testowanie algorytmu Shellsort


Do przetestowania implementacji sortowania metodą Shella użyjemy tego samego zestawu
testowego i tych samych danych, z których korzystaliśmy w poprzednim rozdziale.

Odpowiednia klasa testowa stanowić więc będzie rozszerzenie klasy AbstractListSorter-


Test i testować będzie poprawność funkcjonowania (nieistniejącej jeszcze) klasy imple-
mentującej algorytm Shellsort.

package com.wrox.a 1gori thms.sorti ng:

public class ShellsortListSorterTest extends AbstractListSorterTest {


protected ListSorter createListSorter(Comparator comparator) {
return new ShellsortListSorter(comparator);

J a k to działa?

Wyprowadzona z abstrakcyjnej klasy testowej AbstractListSorterTest (zdefiniowanej w roz-


dziale 6.) klasa ShellsortLi stSorterTest implementuje jej abstrakcyjną metodę createListSor-
t e r ( ) w ten sposób, iż ta zwraca instancję klasy Shel 1 sortLi stSorter.

Mając już — stworzony minimalnym wysiłkiem — stosowny zestaw testowy, zajmijmy się
implementacją samego algorytmu.

Implementowanie algorytmu Shellsort


Implementacja algorytmu Shellsort podobna jest pod wieloma względami do implementacji
prostych algorytmów sortowania omawianych w poprzednim rozdziale. Implementuje ona
interfejs ListSorter, a jej działanie opiera się na komparatorze wyznaczającym porządek
sortowanych wartości. W swych prywatnych zmiennych przechowuje ona wskaźnik na
wspomniany komparator (_comparator) oraz sekwencję kolejnych wartości H dla sortowa-
nych podlist (_increments[]).
Rozdział 7. • Sortowanie zaawansowane 187

package com.wrox.algorithms.sorting;

i mport com.wrox.a1gori thms.1 i sts.Li st:

public class ShellsortListSorter implements ListSorter {


/** Komparator wyznaczający porządek sortowanych wartości */
private fina! Comparator _comparator;

/** sekwencja wartości H dla sortowanych H-list */


private finał int[] _increments = {121, 40. 13. 4. 1};
/**

* 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.

public List sort(List list) {


assert list != nuli : "nie określono listy";

for (int i = 0: i < _increments.length; ++i) {


int increment = _increments[i];
hSortdist, increment);
}
return list;
}
Tworząc metodę hSortO, musimy uważać, by właściwie określić granice poszczególnych
//-list, czyli po prostu nie sięgać po elementy, których w tablicy nie ma. W szczególności
należy właściwie określić początkową wartość H — w liście liczącej 10 elementów nie ma
na przykład sensu sortowanie 50-listy, ta bowiem zawiera tylko jeden element, którego nie
ma z czym porównywać. Jeśli początkowa wartość H będzie mniejsza od połowy długości
listy, będziemy mieli do porównania co najmniej 3 elementy. Po zweryfikowaniu sensow-
ności otrzymanej wartości H metoda hSortO deleguje wywołanie do metody sort-
SubListO wykonującej właściwe sortowanie. W wywołaniu tym określony jest indeks po-
czątkowego elementu //-listy i wartość H.

private void hSort(List list. int increment) {


if (list.sizeO < (increment * 2)) {
return;
}
188 Algorytmy. Od podstaw

for (int i = 0; i < increment; ++1) {


sortSublistdist. i. increment);
}
}
Wykonywane przez metodę s o r t S u b l i s t O //-sortowanie jest w istocie sortowaniem
„w miejscu" przez wstawianie, oczywiście przy uwzględnieniu tylko co H-tego elementu li-
sty począwszy od elementu na wskazanej pozycji. Zastępując każde wystąpienie +increment
i -increment sekwencją (odpowiednio) +1 i -1 otrzymamy klasyczne sortowanie przez wstawia-
nie1, opisane z detalami w rozdziale 6.
private void sortSublistCList list. int startlndex, int increment) {
for (int i = startlndex + increment; i < list.sizeO: i += increment) {
Object value = list.get(i);
int j;
for (j = i; j > startlndex; j -= increment) {
Object previousValue = list.gettj - increment);
if (_comparator.compare(value. previousValue) >= 0) {
break;
}
list.settj. previousValue);
}
list.settj. value);

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.

Działanie algorytmu Quicksort wyjaśnimy na przykładzie sortowania ciągu liter przedsta-


wionego na rysunku 7.11.

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

Zgodnie z przedstawionym przed chwilą trzyetapowym scenariuszem w pierwszym kroku


następuje wybór elementu dzielącego listę. Po zakończeniu scenariusza element ten będzie
znajdował się już na swej pozycji docelowej — wszystkie mniejsze od niego elementy
znajdować się będą (w przypadkowej kolejności) na lewo od niego, zaś wszystkie elementy
od niego większe znajdować się będą (także w kolejności przypadkowej) na prawo od niego.
Istnieje wiele różnych strategii wyboru elementu dzielącego, my będziemy konsekwentnie
obsadzać w tej roli ostatni (po prostu) element listy. Zainicjujemy także wartości dwóch in-
deksów: pierwszy z nich (lewy) wskazywać będzie początkowo pierwszy element listy,
drugi (prawy) — większy z dwóch elementów: element dzielący lub element poprzedzający go.
Sytuację tę przedstawiono na rysunku 7.12

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

Kontynuujemy przesuwanie indeksów: lewy indeks zatrzymuje się na literze U, prawy —


na literze A (rysunek 7.15).

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

Jak poprzednio, elementy konfliktowe zamienione zostają miejscami (rysunek 7.16).

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

Ponowne zatrzymanie indeksów nastąpi na literach S i E, jak na rysunku 7.17.

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

K o n t y n u u j ą c , napotkamy nową parę elementów konfliktowych (rysunek 7.19).

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

Jak poprzednio zamieniamy je miejscami, co prowadzi do sytuacji przedstawionej na ry-


sunku 7.20.

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

Ostatnią parę elementów konfliktowych tworzą litery R i I (rysunek 7.21).

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

Zamieniamy je oczywiście miejscami (rysunek 7.22).

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

Wykonanie opisanego scenariusza powoduje, że element dzielący trafia na swą docelową


pozycję, natomiast pozostałe elementy zostają umieszczone po właściwych stronach ele-
mentu dzielącego. Obydwie części listy, na które podzielił j ą element dzielący, muszą teraz
zostać posortowane niezależnie od siebie — i na tym właśnie polega rekurencyjny charak-
ter algorytmu Quicksort.

Podobnie jak w przypadku każdego algorytmu rekurencyjnego, tak i w przypadku algorytmu


Quicksort musimy zidentyfikować przypadek bazowy rekurencji, czyli takie wywołanie,
które nie powoduje już dalszych wywołań rekurencyjnych. Przypadkiem takim jest oczywi-
ście lista jednoelementowa, która zawsze jest z definicji posortowana, więc jej „sortowa-
nie" nie wymaga żadnych działań.

Znając już zasady działania algorytmu Quicksort, przejdźmy do jego zaimplementowania,


przedtem jednak musimy stworzyć odpowiednią klasę testową weryfikującą poprawność
(przyszłej) implementacji.

Testowanie algorytmu Ouicksort


Klasę testową dla algorytmu Quicksort skonstruujemy w taki sam sposób jak dla innych
omawianych dotąd algorytmów sortowania.
package com.wrox.algorithms.sorting:

public class QuicksortListSorterTest extends AbstractListSorterTest {


protected ListSorter createListSorter(Comparator comparator) {
return new QuicksortListSorter(comparator):
}
}

J a k to działa?

Klasa testowa OuicksortListSorterTest, wyprowadzona z abstrakcyjnej klasy testowej Abs-


tractListSorterTest (zdefiniowanej w rozdziale 6.), implementuje jej abstrakcyjną metodę
createListSorterO w ten sposób, iż ta zwraca instancję klasy QuicksortListSorter.

spróbuj sam Implementowanie algorytmu Ouicksort


Każda z klas implementujących dotychczas omawiane algorytmy sortowania z założenia
implementowała interfejs ListSorter, funkcjonowała w oparciu o komparator wyznaczający
porządek sortowanych elementów, a jej poprawność weryfikowana była przez stworzony
zawczasu zestaw testowy. Klasa OuicksortLi stSorter nie jest w tym względzie wyjątkiem.
Rozdział 7. • Sortowanie zaawansowane 193

package com.wrox.a1gori thms.sorti ng:

i mpo rtcom.wrox.algorithms.lists.List;

public class OuicksortListSorter implements ListSorter {


/** Komparator wyznaczający porządek sortowanych wartości */
private finał Comparator _comparator:
/**

* 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";

quicksort(list. 0. list.sizeO - 1):

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

int partition = partitiondist, value, startlndex, endlndex - 1);


if (_comparator.compare(list.get(partition). value) < 0) {
++partition;
}
swapdist, partition. endlndex);

quicksort(list. startlndex, partition - 1);


quicksort(list, partition + 1, endlndex);
}
194 Algorytmy. Od podstaw

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;

while (left < right) {


if (_comparator.compare(list.get(left). value) < 0) {
++left:
continue;
}
if (_comparator.compare(list.get(right), value) >= 0) {
--right;
continue;
}
if left > right {
swapdist, left, right);
}
++1eft;
}
return left:
}
Metoda zamieniająca elementy — swapO — podobnie jak w przypadku sortowania przez
wybieranie wykrywa próbę zamiany elementu z samym sobą.
private void swap(List list. int left. int right) {
if (left == right) {
return;
}
Object temp = list.get(left):
list.setdeft. 1 i st. get (right)):
1 i st.set(right. temp):
}

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

Komparator złożony i jego rola


w zachowaniu stabilności sortowania
Zanim zajmiemy się kolejnym algorytmem sortowania, powróćmy na chwilę do problemu
stabilności sortowania (pojęcie to wyjaśniliśmy w rozdziale 6.). Tak się bowiem składa, że
obydwa opisane algorytmy — Shellsort i Quicksort — nie są algorytmami stabilnymi.

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ę").

spróbuj sam Testowanie komparatora złożonego


Testowany przez nas komparator złożony komasować będzie działanie kilku komparatorów
„ustalonych" (fixed) zwracających zdefiniowany a priori wynik (liczbę ujemną zero lub
liczbę dodatnią) niezależnie od porównywanych argumentów. Oto oczywista implementa-
cja takiego „ustalonego" komparatora.
package com.wrox.a 1gori thms.sorti ng;

public class FixedComparator implements Comparator {


private finał int _result;
/**

* 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

Po zdefiniowaniu komparatora „ustalonego" możemy przetestować działanie trzech kompa-


ratorów złożonych, zwracających (kolejno) wartość ujemną zero i wartość dodatnią Każdy
z tych trzech komparatorów utworzony zostanie w wyniku skomasowania — we właściwej
kolejności — odpowiednich „ustalonych" komparatorów cząstkowych. Pierwszy z tych
komparatorów cząstkowych zawsze zwracać będzie wartość zero, co oznacza, że kompa-
rator złożony uwzględnić musi wynik zwracany przez co najmniej jeden kolejny komparator
cząstkowy.
package com.wrox.a1gori thms.sorti ng;

import junit.framework.TestCase:

public class CompoundComparatorTest extends TestCase {


public void testComparisonContinuesWhileEqual() {
CompoundComparator comparator = new CompoundComparator();
comparator.addComparator(new FixedComparator(0)):
comparator.addComparator(new FixedComparator(0)):
comparator.addComparator(new Fi xedComparator(0));

assertTrue(comparator.compare("NIEISTOTNY". "NIEISTOTNY") == 0):


}
public void testComparisonStopsWhenLessThant) {
CompoundComparator comparator = new CompoundComparator();
compa rator.addCompa rator(new Fi xedCompa rator(0)):
compa rator.addCompa rator(new Fi xedCompa rator(0));
comparator,addComparator(new FixedComparator(-57)):
compa rator.addCompa rator(new Fi xedCompa rator (91));

assertTrue(comparator.compare("NIEISTOTNY ". "NIEISTOTNY") < 0);


}
public void testComparisonStopsWhenGreaterThanO {
CompoundComparator comparator = new CompoundComparator();
comparator.addComparator(new FixedComparator(0)):
comparator.addComparator(new FixedComparator(0));
comparator.addComparator(new FixedComparator(91));
comparator.addComparator(new FixedComparator(-57));

assertTrue(comparator.compare("NIEISTOTNY", "NIEISTOTNY") > 0);


}
1

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.

W drugim komparatorze złożonym dwa pierwsze komparatory cząstkowe zwracają wartość 0,


rozstrzygająca jest więc niezerowa wartość zwracana przez trzeci komparator — w tym
przypadku wartość ujemna, którą zwrócić powinien komparator złożony. Na tej samej za-
sadzie trzeci komparator złożony powinien zwrócić wartość dodatnią.
Rozdział 7. • Sortowanie zaawansowane 197

spróbuj sam Implementowanie komparatora złożonego


Klasa CompoundComparator implementująca komparator złożony implementuje oczywiście in-
terfejs Comparator, a jej komparatory cząstkowe przechowywane są w prywatnej liście.
package com.wrox.algorithms.sorting;

import com.wrox.algorithms.iteration.Iterator:
import com.wrox.algorithms.lists.ArrayList;
i mport com.wrox.a1gori thms.1 i sts.Li st;

public class CompoundComparator implements Comparator {


/** lista komparatorów cząstkowych */
private finał List _comparators = new ArrayListO:
}

Poszczególne komparatory cząstkowe dodawane są do wspomnianej listy za pomocą metody


addComparatorO:

public void addComparator(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
assert comparator != this :
"komparator nie może być własnym komparatorem
cząstkowym";

_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;

for (i.firstO; !i.isDoneO; i.next()) {


result = ((Comparator) i .currentO).compareOeft. right);
if (result != 0) { // pierwszy niezerowy wynik - rozstrzygający
break;

return result:

Klasa CompoundComparator jest niezwykle użyteczna, pozwala bowiem na zniwelowanie bra-


ku stabilności algorytmu sortującego, umożliwia także łatwą implementację sortowania wzglę-
dem kluczy złożonych.
198 A l g o r y t m y . Od podstaw

Sortowanie przez łączenie


Algorytm sortowania przez łączenie (Mergesort) jest ostatnim z zaawansowanych algorytmów
sortowania opisywanych w niniejszym rozdziale. Podobnie jak sortowanie szybkie (Quick-
sort) może on być zrealizowany zarówno iteracyjnie, jak i rekurencyjnie; w niniejszym
podrozdziale omówimy jedynie wersję rekurencyjną. W przeciwieństwie do sortowania szyb-
kiego sortowanie przez łączenie nie odbywa się w miejscu — elementy z listy wejściowej
przenoszone są w posortowanej kolejności do listy wyjściowej.

Łą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

Elementy wskazywane przez indeksy są ze sobą porównywane; mniejszy z nich kierowany


jest do listy wynikowej, a wskazujący go indeks przesuwany jest na kolejną pozycję, jak
przedstawiono to na rysunku 7.27.

Rysunek 7.27. A F M D G L
Pierwszy element
kierowany do listy
wynikowej

WYNIK

Ponownie porównywane są elementy wskazywane przez indeksy i znów mniejszy z nich


(tym razem litera D) kierowany jest do listy wynikowej, a wskazujący go indeks przesuwany
o jedną pozycję w przód (rysunek 7.28).

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.

Działanie algorytmu Mergesort wyjaśnimy na przykładzie sortowania listy liter widocznej


na rysunku 7.31.

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.

Sytuację na trzecim poziomie rekurencji przedstawiono na rysunku 7.34.

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

Na poziomie czwartym napotkamy pierwszą listę jednoelementową (rysunek 7.35).

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

W podobny sposób posortowana zostaje lista (S, I, V, E) — dla uproszczenia pominęliśmy


opis związanych z tym kilku etapów pośrednich (rysunek 7.42).

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

Postępują analogicznie z drugą połówką doprowadzamy do jej posortowania (rysunek 7.44).

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

Całość wieńczy połączenie obydwu połówek przedstawione na rysunku 7.45.

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

Mergesort jest więc — j a k widać — eleganckim algorytmem, nietrudnym do zaimplemen-


towania, zwyczajowo jednak rozpoczniemy od stworzenia testów weryfikujących popraw-
ność jego implementacji.
Rozdział 7. • Sortowanie zaawansowane 203

spróbuj sam Testowanie algorytmu Mergesort


Klasa testowa dla algorytmu Mergesort różni się od innych klas testujących poprawność
sortowania jedynie typem klasy tworzonej przez metodę createLi stSorter().
package com.wrox.algori thms.sorti ng;

public class MergesortListSorterTest extends AbstractListSorterTest {


protected ListSorter createListSorter(Comparator comparator) {
return new MergesortListSorter(comparator);

spróbuj sam Implementowanie algorytmu Mergesort


Tak jak inne prezentowane wcześniej klasy sortujące, tak i klasa MergesortLi stSorter ba-
zuje na dwóch podstawowych elementach — interfejsie ListSorter i komparatorze wyzna-
czającym kolejność elementów.
package com.wrox.algorithms.sorti ng:

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;

public class MergesortListSorter implements ListSorter {


/** Komparator wyznaczający porządek sortowanych wartości */
private finał Comparator _comparator;

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";

return mergesort(1 ist. 0, list.sizeO - 1);


}

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

private List mergesort(List list, int startlndex, int endlndex) {


if (startlndex == endlndex) { // lista jednoelementowa
List result = new ArrayListO;
result.add(list.get(startlndex));
return result;
}
// indeks graniczny lewej połówki
int splitlndex = startlndex + (endlndex - startlndex) / 2:

// sortowanie lewej połówki


List left = mergesortdist, startlndex. splitlndex);

// sortowanie prawej połówki


List right = mergesortdist, splitlndex + 1. endlndex);

// łączenie posortowanych połówek


return merge(left. right):
}
Metoda merge() jest nieco bardziej skomplikowana, niż można by oczekiwać w pierwszej
chwili, głównie za sprawą konieczności radzenia sobie z pewnymi nietypowymi sytuacja-
mi, na przykład wcześniejszym wyczerpaniem jednej z list czy równością porównywanych
elementów obydwu list.
private List merge(List left. List right) {
List result - new ArrayListO:

Iterator 1 = left. iteratorO:


Iterator r = right.iteratorO;

1.firstO:
r.firstO;

while (!(1.isDoneO && r.isDoneO)) {


if (1.isDoneO) {
result. add(r. currentO):
r.next():
} else if (r.isDoneO) {
result.addd .currentO):
1,next();
} else if (_comparator.compareO .currentO. r.currentO) <= 0) {
result.add(l .currentO);
1,next();
} else {
result.add(r.current());
r.next():

return result:
j
Rozdział 7. • Sortowanie zaawansowane 205

J a k to działa?

Jak w przypadku każdego algorytmu rekurencyjnego, tak i w przypadku tej implementacji


konieczne jest właściwe określenie i zaimplementowanie przypadku bazowego. W metodzie
mergesort () przypadek bazowy oddzielony jest wyraźnie od przypadku ogólnego: przetwo-
rzenie listy jednoelementowej „załatwiane jest" w czterech początkowych wierszach kodu.
Dalszy ciąg kodu metody obejmuje podział listy wieloelementowej na dwie podlisty, reku-
rencyjne posortowanie każdej z nich i ich połączenie za pomocą metody merge().

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.

Niniejszym kończymy prezentację trzech zaawansowanych metod sortowania i najbardziej


naturalną rzeczą, jaką należałoby teraz zrobić, jest ich porównanie, które może w przyszło-
ści ułatwić podjęcie decyzji co do ich wyboru na potrzeby konkretnej aplikacji.

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.

Klasę ListSorterCallCountingTest, która skonstruowaliśmy w rozdziale 6. na potrzeby po-


równywania omawianych tam algorytmów, możemy z powodzeniem wykorzystać na po-
trzeby algorytmów omawianych w niniejszym rozdziale. Metody testowe dla sortowania li-
sty o odwrotnym porządku elementów mają obecnie następującą postać:
public void testReverseCaseShellsort O {
new ShellsortListSorter(_comparator),sort(_reverseArrayList):
reportCallsO:
}
public void testReverseCaseQuicksort O {
new QuicksortListSorter(_comparator),sort(_reverseArrayList):
reportCallsO:
}
206 Algorytmy. Od podstaw

public void testReverseCaseMergesort O {


new MergesortListSorter(_comparator),sort(_reverseArrayList);
reportCallsO:
}
Dla listy już posortowanej postać ta jest następującą:
public void testDirectCaseShellsort O {
new ShellsortListSorter(_comparator).sort(_sortedArrayList);
reportCallsO;
}
public void testDirectCaseOuicksort O {
new QuicksortListSorter(_comparator).sort(_sortedArrayList);
reportCallsO;
}
public void testDirectCaseMergesort O {
new MergesortListSorter(_comparator).sort(_sortedArrayList);
reportCallsO;
)
Zaś dla typowej listy o przypadkowej zawartości następująca:
public void testRandomCaseShellsort O {
new Shel1sortListSorter(_comparator).sort(_randomArrayList):
reportCallsO;
}
public void testRandomCaseOuicksort O {
new QuicksortListSorter(_comparator),sort(_randomArrayList):
reportCallsO;
}
public void testRandomCaseMergeSort O {
new MergesortListSorter(_comparator),sort(_randomArrayList):
reportCallsO;
}

Podobnie ja w rozdziale 6. analiza przeprowadzana przez klasę ListSorterCallCountingTest


uwzględnia jedynie porównania wykonywane przez klasy implementujące algorytmy sor-
towania, ignorując inne ważne czynniki — j a k liczba przestawień elementów — które mo-
gą mieć istotny wpływ na decyzję o wyborze konkretnego algorytmu w konkretnym zasto-
sowaniu. Zainteresowani Czytelnicy mogą jednak pokusić się o przeprowadzenie bardziej
dogłębnej analizy we własnym zakresie.

Spójrzmy na wyniki analizy przeprowadzonej dla listy posortowanej w kolejności odwrot-


nej do wymaganej; zestawiliśmy je z analogicznymi wynikami z poprzedniego rozdziału.
testReverseCaseBubblesort: 499500 wywołań
testReverseCaseSelectionsort: 499500 wywołań
testReverseCaseInsertionsort: 499500 wywołań
testReverseCaseShellsort: 9894 wywołań
testReverseCaseQuicksort: 749000 wywołań
testReverseCaseMergesort: 4932 wywołań
Rozdział 7. • Sortowanie zaawansowane 207

Chyba największym zaskoczeniem w powyższym zestawieniu jest liczba porównań wyko-


nywanych przez sortowanie szybkie (Quicksort), które stało się sortowaniem powolnym —
0 połowę wolniejszym niż proste metody sortowania. To jedna z wad sortowania szybkie-
go, którego efektywność może się znacząco pogorszyć, gdy strategia wyboru elementu
dzielącego jest skrajnie niedopasowana do charakteru sortowanych danych. Przykładem ta-
kiego niedopasowania jest obsadzanie w roli elementu dzielącego ostatniego elementu listy
posortowanej w kolejności odwrotnej. Element ten jest zawsze najmniejszym elementem
1 wszystkie inne elementy muszą zostać przemieszczone z jego lewej strony na prawą.
Znacznie ważniejsze jest jednak to, że w wyniku podziału <¥-elementowcj listy powstają
dwie listy — pusta i (Af-l)-elementowa; tę drugą sortuje się rekurencyjnie, w wyniku czego
głębokość zagnieżdżenia rekurencji sięga 0(N), zamiast — jak w przeciętnym przypadku
— 0(log N), a liczba wykonywanych porównań jest rzędu 0(N2).

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.

Popatrzmy teraz na dane dotyczące listy już posortowanej:


testDirectCaseBubblesort: 498501 wywołań
testDirectCaseSelectionsort: 498501 wywołań
testDirectCaselnsertionsort: 998 wywołań
testDirectCaseShellsort: 4816 wywołań
testDirectCaseOuicksort: 498501 wywołań
testDi rectCaseMergesort: 5041 wywołań

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ń

No właśnie: algorytmy sortowania opisywane w niniejszym rozdziale dlatego zasługują na


miano „zaawansowanych", że okazują się wydajniejsze (tu: 20-60-krotnie) od prostych al-
gorytmów (z rozdziału 6.) dla danych o układzie losowym — a z takimi aplikacje mają do
czynienia w większości przypadków.

Sugerując się wyjątkowo dużą efektywnością algorytmu Mergesort, widoczną w ostatnim


zestawieniu, należy jednak pamiętać o trzech następujących rzeczach. Po pierwsze, prze-
prowadzona analiza ma charakter czysto empiryczny i jej wyniki dla innych zestawów danych
mogą różnić się od prezentowanych w niniejszym rozdziale. Po drugie, jak już informowa-
liśmy, widoczna w zestawieniu liczba porównań nie jest jedynym czynnikiem decydującym
o wydajności algorytmu. Po trzecie wreszcie, algorytm Mergesort charakteryzuje się zwięk-
szonym zapotrzebowaniem na pamięć w stosunku do dwóch pozostałych algorytmów: two-
rzy on mianowicie kopię każdej sortowanej listy (a sporządzanie tej kopii też wymaga
pewnego czasu). To tylko jeden z przykładów ogólnej zasady, że nie należy wyciągać zbyt
daleko idących wniosków z danych empirycznych odzwierciedlających rzeczywistość w sposób
fragmentaryczny.

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

W następnym rozdziale zajmiemy się wyrafinowanymi strukturami danych tworzonymi na


bazie kolejek opisywanych w rozdziale 4. W szczególności pokażemy, jak struktury te mogą
być wykorzystane (między innymi) do efektywnego sortowania danych.

Ć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.

Omawiane we wcześniejszych rozdziałach „zwykłe" kolejki (FIFO) i stosy (będące de facto


kolejkami LIFO) mogą być uważane za przypadki szczególne kolejek priorytetowych. W pierw-
szym przypadku kryterium porządkującym elementy jest czas ich przebywania w kolejce
— „największym" elementem jest ten, który przebywa w niej najdłużej; w drugim przy-
padku kryterium porządkującym jest moment umieszczenia elementu na stosie — „naj-
większym" jest ten element, który położony został na stos najpóźniej. Metody operowania
na kolejkach priorytetowych stosują się więc w pełni także do „zwykłych" kolejek i stosów.

Prosty przykład kolejki priorytetowej


Załóżmy, że elementami naszej przykładowej kolejki priorytetowej są litery, a jakiś hipo-
tetyczny program-klient cyklicznie umieszcza w tej kolejce poszczególne słowa (a dokład-
niej — litery, z których słowa te są zbudowane) i pobiera z niej literę „najstarszą" w kolej-
ności alfabetycznej. Ciąg wspomnianych słów1 przedstawiony jest na rysunku 8.1.

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.

Wyróżniliśmy „najstarszą" alfabetycznie literę — tę, która zostanie udostępniona progra-


mowi-klientowi, gdy ten zażąda elementu z kolejki. Celowo przedstawiliśmy zawartość
kolejki jako nieuporządkowany zbiór, a nie listę o określonym porządku elementów; co
prawda wiele kolejek priorytetowych fizycznie przechowuje swe elementy w postaci listy,
wynika to jednak tylko z przyjętej ich implementacji i nie ma żadnego związku z ogólnym
(abstrakcyjnym) rozumieniem kolejki priorytetowej.

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.

Po kolejnym powtórzeniu wspomnianego cyklu — dodaniu słowa do kolejki i zażądaniu jej


elementu — sytuacja zmieniła się tak, jak przedstawia to rysunek 8.5.
214 Algorytmy. Od podstaw

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

Wykorzystywanie kolejek priorytetowych


Skonstruujemy teraz trzy różne kolejki priorytetowe, od prostej kolejki listowej do skom-
plikowanej kolejki opartej na strukturze stogu. Wszystkie te kolejki implementować będą
interfejs Queue zdefiniowany w rozdziale 4. i dla wszystkich interfejs ten będzie wystar-
czający — nie ma konieczności dodawania do niego nowych metod, jedynie metoda dequ-
eue() nabiera nowego znaczenia semantycznego (zwracając największy element z kolejki).

IHIŁMIII Definiowanie abstrakcyjnej klasy testowej


Rozpoczniemy oczywiście od zdefiniowania zestawu testowego weryfikującego popraw-
ność wspomnianych implementacji. Podobnie jak w przypadku testowania algorytmów
sortowania zdefiniujemy klasę bazową obejmującą swą funkcjonalnością elementy wspólne
dla wszystkich kolejek, niezależne od ich konkretnej implementacji. Element zależny od im-
plementacji — stworzenie instancji klasy implementującej kolejkę — reprezentowany będzie
przez metodę abstrakcyjną której zaimplementowanie będzie zadaniem konkretnej klasy
testowej.

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:

public abstract class AbstractPriorityOueueTest extends TestCase {


private static finał String VALUE_A = "A";
private static finał String VALUE_B = "B":
private static finał String VAtUE_C = "C":
private static finał String VALUE_D = "D";
private static finał String VALUE_E = "E":

private Oueue _queue:

}
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().

protected void setUpO throws Exception {


super. setUpO;

_queue = createQueue(NaturalComparator.INSTANCE):
}
protected void tearDownO throws Exception {
_queue - nuli;

super, tea rDownO;


}
protected abstract Queue createQueue(Comparator comparable);

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.

public void testAccessAnEmptyOueueO {


assertEquals(0, _queue.sizeO);
assertTrue(_queue.i sEmpty()):

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());

Spośród pozostałych w kolejce elementów największym jest element B i to on powinien


zostać zwrócony w wyniku następnego wywołania metody dequeue():
assertSame(VALUE_B, _queue.dequeue());
assertEquals(l. _queue.size());
assertFalse(_queue.i sEmpty()):

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()):

Testowana kolejka powinna teraz zawierać trzy elementy: A, E i C. Kolejne wywołania


metody dequeue() powinny udostępniać te elementy w kolejności E, C i A — będzie to
przedmiotem kolejnego testu, zwyczajowo sprawdzającego także wartości zwracane przez
metody sizeO i isEmptyO.
assertSame(VALUE_E, _queue.dequeue()):
assertEquals(2, _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)):
)

Testowanie instancji kolejki priorytetowej zakończymy weryfikacją działania jej metody


clearO: pierwsze wywołanie tej metody powinno kolejkę opróżnić, a po dodaniu do niej
trzech elementów drugie jej wywołanie powinno uczynić to samo.
public void testClearO {
_queue.enqueue(VALUE_A);
_queue.enqueue(VALUE_B):
_queue.enqueue(VALUE_C);

assertFal se(_queue.isEmpty()):

_queue.clear();

assertTrue(_queue.isEmpty()):
}
218 Algorytmy. Od podstaw

Zakończyliśmy w ten sposób tworzenie zestawu testowego dla kolejek priorytetowych,


przejdźmy więc do implementowania pierwszej prostej kolejki przechowującej swe ele-
menty w nieposortowanej liście i wyszukującej w niej na żądanie element największy.

Kolejka priorytetowa oparta na liście nieposortowanej


Najprostszym sposobem zaimplementowania kolejki priorytetowej jest przechowywanie jej
elementów w ramach jakiejś kolekcji i wyszukiwanie elementu maksymalnego każdorazowo,
gdy wywoływana jest metoda dequeue(). Rzecz jasna każdy algorytm przeszukujący listę
w sposób bezpośredni musi wykonywać się w czasie 0 ( N ) , choć dla niektórych aplikacji
czas ten może być akceptowalny, zwłaszcza gdy liczba elementów listy (N) jest niewielka
lub gdy metoda dequeue() nie jest wywoływana zbyt często. Zaletą wynikającą z możliwo-
ści przechowywania elementów w dowolnej kolejności jest natomiast efektywność operacji
enqueue() — wykonuje się ona w czasie (9(1).

spróbuj sam Testowanie i implementowanie kolejki


Do przechowywania elementów listy użyjemy listy wiązanej (LinkedList), choć lista tabli-
cowa (ArrayLi st) byłaby równie dobra w tej roli.

Zgodnie z wcześniejszymi uwagami jedynym specyficznym elementem klasy testującej ko-


lejkę jest zaimplementowana metoda createQueue() tworząca i zwracająca instancję kolejki
(chwilowo jeszcze niezaimplementowanej).
package com.wrox.algorithms,queues:

i mport com.wrox.a1gori thms.sort i ng.Compa rator;

public class UnsortedListPriorityOueueTest extends AbstractPriorityOueueTest {


protected Queue createQueue(Comparator comparator) {
return new UnsortedListPriorityOueue(comparator):

Prezentowana tu implementacja kolejki priorytetowej ma wiele elementów wspólnych


z implementacją kolejek prezentowanych w rozdziale 4. Przytaczamy ponownie
niektóre fragmentu z tego rozdziału, by uwolnić Czytelnika od kłopotliwego
wertowania stron.

Klasa implementująca kolejkę priorytetową na bazie listy nieposortowanej funkcjonuje w opar-


ciu o instancje dwóch innych klas: wspomnianej listy oraz komparatora wyznaczającego
porządek jej elementów. Instancja listy tworzona jest w konstruktorze klasy, komparator
jest parametrem wywołania konstruktora.
public class UnsortedListPriorityOueue implements Oueue {
/** lista przechowująca elementy */
private finał List J i s t ;

/** komparator wyznaczający porządek (priorytet) elementów */


private finał Comparator _comparator:
Rozdział 8. • Kolejki priorytetowe 219

/**
* 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());
}

Znalezienie elementu największego wymaga przeszukania całej listy, z zapamiętywaniem


indeksu największego elementu w części już przeszukanej. Notabene czynność ta wykony-
wana byłaby efektywniej w liście tablicowej niż w liście wiązanej (czy wiesz, dlaczego?).
private int getIndexOfLargestElement() {
int result = 0;

for (int i = 1: i < list.sizeO: ++i) {


if ( comparator.compare( list.get(i). list.get(result)) > 0) {
result = i;
}
1
return result;
}
Wywołania pozostałych metod delegowane są wprost do listy przechowującej elementy, jak
w przypadku każdej kolejki o strukturze listowej.
public void clearO {
_list.clearO;
}
public int sizeO {
return list.sizeO;
220 Algorytmy. Od podstaw

public boolean isEmptyO {


return _1 ist. i sEmpty O ;
}

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.

Po upewnieniu się — dzięki skonstruowanej wcześniej klasie testowej — że powyższa im-


plementacja funkcjonuje jak należy, pora zająć się implementacjami cechującymi się znacz-
nie efektywniejszym wyszukiwaniem największego elementu.

Kolejka priorytetowa wykorzystująca listę posortowaną


Operację znajdowania największego elementu można przyspieszyć, przechowując elementy
listy w kolejności posortowanej (zgodnie z porządkiem wyznaczanym przez komparator).
Ceną za to przyspieszenie jest komplikacja (i spowolnienie) operacji enqueue, która nie mo-
że polegać na prostym dołączeniu elementu na koniec listy, lecz musi zachowywać posor-
towany charakter tej listy.

Wstawianie elementu na właściwą pozycję — w metodzie enqueue() — odbywa się w taki


sam sposób jak w algorytmie sortowania przez wstawianie (Insertionsort). Za to metoda
dequeue() staje się bardzo prosta: żądany element największy jest ostatnim elementem listy
i wystarczy go z tej listy usunąć i zwrócić jako wynik.

iHIŁłlnl Testowanie i implementowanie kolejki priorytetowej


na bazie listy posortowanej
Klasę testową dla kolejki zdefiniujemy w dotychczasowy sposób, jako rozszerzenie abs-
trakcyjnej klasy AbstractPriorityOueueTest:
public class SortedListPriorityOueueTest extends AbstractPriorityOueueTest {
protected Oueue createQueue(Comparator comparator) {
return new SortedListPriorityOueue(comparator):
}
}

Podstawowa struktura klasy implementującej kolejkę priorytetową na bazie listy posorto-


wanej jest taka sama jak w przypadku listy nieposortowanej — takie same są instancje klas
roboczych i taki sam jest konstruktor.
Rozdział 8. • Kolejki priorytetowe 221

public class SortedListPriorityOueue implements Queue {


/** lista przechowująca elementy */
private finał List _list;

/** komparator wyznaczający porządek (priorytet) elementów */


priyate finał Comparator _comparator;

* Konstruktor
* Parametr: komparator wyznaczający porządek (priorytet) elementów
*/

public SortedListPriorityQueue(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
_list = new LinkedListO;
}

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):

Podobnie jak w przypadku kolejki bazującej na nieposortowanej liście wywołania pozo-


stałych metod delegowane są wprost do instancji listy przechowującej elementy:
public void clearO {
list.clearO;

public int sizeO {


return list.sizeO;

public boolean isEmptyO {


return Jist.isEmptyO:
}
222 Algorytmy. Od podstaw

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.

Wybór między dwiema przedstawionymi implementacjami — z listą nieposortowaną i z listą


posortowaną — jest głównie wyborem między tym, która z metod — enqueue() i dequeue()
— ma być metodą bardziej efektywną. Jedna z tych metod musi bowiem dokonywać prze-
szukiwania całej listy. W następnym punkcie poznamy bardziej efektywną i bardziej prak-
tyczną implementację kolejki priorytetowej, opartej nie na liście, lecz na strukturze zwanej
stogiem. Jakkolwiek w implementacji tej także mamy do czynienia z porównywaniem ele-
mentów, to jednak skanowanie zawartości kolejki ma zupełnie inny charakter i odbywa się
znacznie efektywniej.

Kolejki priorytetowe o organizacji stogowej


Stóg (heap), zwany też niekiedy kopcem lub stertą, jest strukturą o interesujących własno-
ściach. Przedstawimy więc krótko jego koncepcję, po czym zajmiemy się jego zastosowa-
niem w implementacji efektywnych kolejek priorytetowych.

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ę.

Także przez analogię do sposobu realizacji struktur danych zaprezentowanego w poprzed-


nich rozdziałach Czytelnik mógłby w tym miejscu oczekiwać interfejsu Heap lub Tree, któ-
ry wyrażałby abstrakcję stogu i który należałoby implementować w konkretnej realizacji
tego stogu. Tym razem zrezygnujemy z takiego podejścia, korzystać będziemy bowiem ze
stogu w taki sposób, jak korzysta się z listy implementującej interfejs List. Na rysunku 8.8
przedstawiono sposób ponumerowania elementów stogu: korzeniowi nadajemy numer 0, po
czym posuwamy się z góry na dół i z lewa na prawo, nadając napotykanym elementom ko-
lejne numery.

Rysunek 8.8.
Ponumerowanie
elementów stogu

1 D

Możliwość ponumerowania elementów stogu, czyli de facto ich uporządkowania, pozwala


na (koncepcyjne) utożsamienie stogu z pewną listą. Przykładowy stóg i odpowiadająca mu
lista przedstawione są na rysunku 8.9.

Rysunek 8.9.
Rozwinięcie
stogu w listę

7 D

X M K E A F D D B
0 8

Oczywiście natychmiast pojawia się pytanie o metodę nawigowania po wspomnianej liście


na sposób stogowy. Odpowiedź na to pytanie staje się oczywista, jeśli spojrzeć na rysunek
8.8 — zależności „drzewiaste" elementów odzwierciedlone są w tej liście w sposób następujący:
224 Algorytmy. Od podstaw

• 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.

Wynurzanie i zatapianie elementu


Aby stóg mógł stanowić wygodną strukturę dla implementacji kolejki, musimy zdefiniować
efektywne operacje dodawania do niego elementów i usuwania ich z niego, oczywiście w taki
sposób, by warunek stogowy pozostawał nienaruszony.

Rozpocznijmy od dodawania elementu — do stogu widocznego na rysunku 8.7 dołączmy


literę P. Wykorzystamy w tym celu wolne miejsce, czyli „brakującego syna" w węźle A —
jak na rysunku 8.10. W przełożeniu na listowe rozwinięcie stogu odpowiada to dołączeniu
elementu na koniec listy.

Rysunek 8.10.
Proste dołączenie
elementu niszczące
uporządkowanie
stogowe

Niestety, operacja ta doprowadziła do naruszenia warunku stogowego: węzeł A jest mniej-


szy od swego syna P. Aby warunek stogowy został przywrócony, konieczne jest przenie-
sienie elementu P na wyższy poziom; całość związanych z tym operacji reorganizujących
układ elementów nazywamy wynurzaniem elementu.

Wynurzanie to sprowadza się w istocie do sukcesywnej zamiany elementu psującego szyk


z jego węzłem-ojcem do momentu, gdy warunek stogowy zostanie przywrócony. Sytuację
po pierwszej zamianie węzła P ze swym ojcem przedstawia rysunek 8.11.

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

Warunek stogowy został tym samym przywrócony. Ponieważ zamierzamy potraktować


stóg jako kolejkę priorytetową, więc naturalną operacją tej kolejki będzie usunięcie naj-
większego elementu, którym jest korzeń stogu.

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.

Zatapianie sprowadza się do sukcesywnej zamiany konfliktowego elementu z większym z jego


synów (lub z jedynym synem) aż do przywrócenia warunku stogowego. Pierwszą zamianą
w tym scenariuszu jest (w naszym przypadku) zamiana elementów A i P przedstawiona na
rysunku 8.14.

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

Dysponując operacjami wynurzania i zatapiania elementu, możemy przystąpić do imple-


mentowania trzeciej — najbardziej skomplikowanej, ale też i najefektywniejszej — wersji
kolejki priorytetowej.

Testowanie i implementacja stogowej kolejki priorytetowej


Rozpoczniemy jak zwykle od zdefiniowania odpowiedniej klasy testowej weryfikującej
poprawność tworzonej implementacji.
public class HeapOrderedListPriorityOueueTest extends AbstractPriorityOueueTest {
protected Queue createQueue(Comparator comparator) {
return new HeapOrderedListPriorityQueue(comparator);

Ponieważ stóg przechowujący elementy fizycznie zrealizujemy w postaci listy, struktura


klasy implementującej stogową kolejkę priorytetową nie będzie się niczym różnić od ana-
logicznych klas implementujących kolejki listowe.
public class HeapOrderedListPriorityOueue implements Queue {
/** lista przechowująca elementy stogu */
private finał List J i s t ;

/** komparator wyznaczający porządek (priorytet) elementów */


private finał Comparator _comparator;
Rozdział 8. • Kolejki priorytetowe 227

/**
* Konstruktor
* Parametr: komparator wyznaczający porządek (priorytet) elementów
*/

public HeapOrderedListPriorityQueue(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator:
_list = new ArrayListO;
}
}
Metoda enqueue(), dodająca do stogu nowy element, dołącza go najpierw na koniec wspo-
mnianej listy, po czym dokonuje jego wynurzania.
public void enqueue(Object value) {
_list.add(value);
swim(_list.size() - 1);
}

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);
}
}

Wynurzanie to sprowadza się do szeregu zamian odpowiednich elementów, każda zamiana


realizowana jest przez metodę swapO:
private void swap(int indexl, int index2) {
Object temp = _list.get(indexl);
_list.set(indexl. _list.get(index2)):
_list.set(index2. temp);
}

Metoda dequeue() najpierw pobiera ze stogu element-korzeń, po czym zastępuje go kopią


„ostatniego" elementu. Po „zatopieniu" nowego korzenia oryginalny „ostatni" element jest
usuwany ze stogu.
public Object dequeue() throws EmptyQueueException {
if (isEmptyO) {
throw new EmptyQueueException();
}
Object result = Jist.get(O);
if (_list.sizeO > 1) {
_list.set(0, _list.get(_list.sizeO - 1));
sink(0);
228 Algorytmy. Od podstaw

}
_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

if (left >= _list.sizeO) {


return; // węzeł nie ma synów
}
int largestChild = left; // początkowo zakładamy, że większy jest lewy syn
if (right < _list.sizeO) { // czy prawy syn istnieje ?
if (_comparator.compare(_list.get(left). Jist.get(right)) < 0) {
largestChild = right; // jednak prawy syn jest większy

if (_comparator.compare(_list.get(index), _1i st.get(1argestChi Id)) < 0) {


swap(index, largestChild);
sink(1argestChild);
}
}
Podobnie jak w przypadku dwóch poprzednich implementacji, wywołania pozostałych
metod delegowane są wprost do listy elementów:
public void clearO {
_list. clearO;
}
public int sizeO {
return _1ist.sizet);
}
public boolean isEmptyO {
return _list.isEmptyO;
}

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.

Wykonująca zatapianie metoda sinkO — rekurencyjna, podobnie jak metoda swimO —


jest jednak od metody swimO bardziej skomplikowana, bowiem samo zatapianie elementu
jest nieco bardziej skomplikowane od jego wynurzania.

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.

Porównanie implementacji kolejek priorytetowych


Podobnie jak w poprzednim rozdziale, nasza analiza będzie miała charakter czysto empi-
ryczny i bazować będzie na porównaniach dokonywanych przez komparator zliczający
CallCountingComparator. Wyniki takiej analizy mogą więc raczej inspirować do własnych
badań i poszukiwań niż stanowić podstawę formułowania jakichś kategorycznych wnio-
sków. Czytelników zainteresowanych analizą o charakterze bardziej formalnym odsyłamy
do źródeł wymienionych w dodatku B.

Podobnie jak w przypadku algorytmów sortowania efektywność funkcjonowania porów-


nywanych implementacji kolejek zbadamy przy użyciu trzech szczególnych scenariuszy.
W pierwszym — który nazwiemy umownie najlepszym przypadkiem (best case) — ele-
menty dodawane będą do kolejki w kolejności posortowanej (rosnącej). W scenariuszu
230 Algorytmy. Od podstaw

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).

Podstawowa struktura klasy testowej dokonującej opisywanego porównywania obejmuje trzy


listy elementów odpowiadające opisanym scenariuszom oraz komparator zliczający.
public class PriorityQueueCallCountingTest extends TestCase {
private static finał int TEST_SIZE = 1000; // rozmiar listy wejściowej

private finał List _sortedList = new ArrayList(TEST_SIZE);


private finał List _reverseList - new ArrayList(TEST_SIZE);
private finał List _randomList = new ArrayList(TEST_SIZE);

private CallCountingComparator _comparator;

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);

for (int i = 1: i < TEST_SIZE; ++i) {


_sortedList.add(new Integer(i));
}
for (int i = TEST_SIZE; i > 0; --i) {
_reverseList.add(new Integer(i));
}
for (int i - 1: i < TEST_SIZE; ++i) {
_randomList.add(new Integer((int)(TEST_SIZE * Math.randomO)));
}
}

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

public void testBestCaseUnsortedListO {


runScenariotnew UnsortedListPriorityQueue( comparator) _sortedList);
}
public void testBestCaseSortedList() {
runScenariotnew SortedListPriorityQueue( comparator). _sortedList):
}
public void testBestCaseHeapOrderedListO {
runScenario(new HeapOrderedListPriorityQueue( comparator). sortedList):
}
... i kończymy na przypadku przeciętnym.
public void testAverageCasellnsortedList() {
runScenario(new UnsortedListPriorityQueue(_comparator). _randomList):
}
public void testAverageCaseSortedList() {
runScenariotnew SortedListPriorityQueue(_comparator). _randomList):
}
public void testAverageCaseHeapOrderedList() {
runScenariotnew HeapOrderedListPriorityQueue(_comparator). _randoml_ist);
}

Wszystkie przedstawione metody testowe wykorzystują metodę runScenarioO. Metoda ta


wywoływana jest z dwoma parametrami: instancją testowanej kolejki oraz listą elementów
wejściowych umieszczanych w kolejce. Po dodaniu każdych 100 elementów do kolejki na-
stępuje pobranie z niej 25 (największych) elementów. Liczby 100 i 25 wybrane tu zostały
całkowicie arbitralnie i odzwierciedlają względną proporcję liczby wywołań metod enqueue()
i dequeue() w celu symulacji zachowania kolejki w rzeczywistej aplikacji. Przed zakończe-
niem swego działania metoda runScenario() dokonuje całkowitego opróżnienia kolejki, po
czym wywołuje metodę reportCallsO w celu wyświetlenia wyników symulacji.

private void runScenario(Queue queue. List input) {


int i = 0:
Iterator iterator = input.iteratorO:
i terator. firstO:
while (!iterator.isDoneO) {
++i:
queue.enqueue(i terator.current()):
if (i % 100 =- 0) {
for (int j = 0: j < 25: ++ j) {
queue.dequeue():
}
)
iterator.next():
}
while (!queue.isEmptyO) {
queue.dequeue():
}
reportCallsO:
1
232 Algorytmy. Od podstaw

Metoda reportCallsO wyświetla nazwę klasy testowej i liczbę porównań zarejestrowanych


przez odnośny komparator.
private void reportCallsO {
int callCount - _comparator.getCallCountO;
System.out.printlntgetNameO + ": " + callCount + " wywołań");
}

W wyniku przeprowadzonej symulacji uzyskano następujące wyniki dla przypadku najgor-


szego:
testWorstCasellnsortedList: 387000 wywołań
testWorstCaseSortedList: 387000 wywołań
testWorstCaseHeapOrderedList: 15286 wywołań

Kolejka stogowa wykazuje tu niekwestionowaną przewagę, podczas gdy obydwie kolejki


listowe zachowują się jednakowo (mało)efektywnie. Wyniki dotyczące najlepszego przy-
padku są nieco bardziej interesujące:
testBestCaseUnsortedList: 386226 wywołań
testBestCaseSortedList: 998 wywołań
testBestCaseHeapOrderedList: 22684 wywołań

Niesamowita efektywność kolejki bazującej na sortowanej liście wynika tu z faktu, że prze-


szukiwanie tej listy w celu znalezienia miejsca dla nowego elementu odbywa się począw-
szy od jej końca; w sytuacji, gdy elementy przybywają w kolejności rosnącej, element trafia
na swą pozycję już po jednym porównaniu. Niezależnie od tego kolejka stogowa znów
okazuje się znacznie efektywniejsza od kolejki opartej na liście nieposortowanej.

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.

Zwróćmy także uwagę na ponaddwukrotnie większą efektywność kolejki z sortowaną listą


w porównaniu z listą nieposortowaną: mimo iż utrzymywanie posortowanego charakteru li-
sty wymaga dodatkowego nakładu pracy, to jednak może się per saldo opłacać, między in-
nymi dlatego, że pobieranie elementu z kolejki wykonywane jest wówczas bardzo szybko.
Rozdział 8. • Kolejki priorytetowe 233

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ą.

• Empiryczne porównanie wszystkich trzech implementacji wykazało znacząco


wyższą efektywność kolejki stogowej w najgorszym i przeciętnym przypadku
oraz wyjątkową efektywność kolejki z listą posortowaną w przypadku najlepszym.

Ć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.

Jedną z metod efektywnego wyszukiwania informacji przechowywanej w pamięci jest wy-


szukiwanie binarnej zagadnieniem pokrewnym jest binarne wstawianie elementów zmie-
rzające — mówiąc krótko — do utrzymania ich organizacji w postaci umożliwiającej prze-
prowadzanie wyszukiwania binarnego.

W niniejszym rozdziale pokażemy:


• na czym polega wyszukiwanie binarne,
• w jaki sposób implementuje się wyszukiwanie binarne w sposób iteracyjny
i rekurencyjny,
• jak efektywne jest wyszukiwanie i wstawianie binarne w porównaniu z innymi
technikami wyszukiwania i wstawiania.

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

Najprostszym sposobem wyszukiwania elementu w liście nieuporządkowanej jest — jak


pamiętamy z rozdziału 2. — porównywanie szukanej wartości z wartościami kolejnych ele-
mentów, poczynając od pierwszego, a na ostatnim kończąc. Średnia liczba porównań jest
wówczas równa połowie liczby elementów1, algorytm wyszukiwania wykonuje się więc w cza-
sie 0(N) i lepiej być nie może, jeśli lista jest nieposortowana. Posortowanie listy stwarza
natomiast nowe możliwości.

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.

Wyobraź sobie, że zamierzasz znaleźć słowo „algorytm" w słowniku języka polskiego.


Prawdopodobnie otworzysz słownik na pierwszej stronie i ewentualnie będziesz musiał spraw-
dzić tylko kilka kolejnych.

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?

Szukając odpowiedzi na to pytanie, prawdopodobnie będziesz kierował się swoją znajomo-


ścią języka polskiego, a konkretnie przybliżoną liczbą słów występujących w słowniku,
rozpoczynających się od każdej z liter. Nawet jednak ta wiedza nie zdałaby się na nic, gdy-
byś nie miał pojęcia o charakterze tematycznym słownika albo gdy — co ważniejsze — hasła
występowałyby w tym słowniku w kolejności przypadkowej, nie alfabetycznej.

Wyszukiwanie binarne polega na sukcesywnym dzieleniu przeszukiwanej listy na pół i za-


wężaniu przeszukiwania do jednej z tak otrzymanych połówek. Szczegółowy scenariusz tego
postępowania przedstawia się następująco:
1. Rozpocznij od środkowego elementu przeszukiwanego obszaru — uczyń go
elementem bieżącym.
2. Porównaj poszukiwany klucz z bieżącym elementem.
3. Jeśli poszukiwany klucz równy jest elementowi bieżącemu, poszukiwanie zostaje
zakończone.

1
Szukany element, o ile w ogóle znajduje się w liście, może znajdować się na jednej z N pozycji

z jednakowym prawdopodobieństwem — . Liczba porównań potrzebnych do jego znalezienia


N

może więc z jednakowym prawdopodobieństwem — w y n o s i ć od 1 do A'. Daje to średnią liczbę


N
r 1 , 1 - , 1 / \ 1 i N(N
5 + I)1 N +1
porownan: E = — x l + — x 2 + . . . + — (A' - 11 + — N = = przyp. tłum.
N N N N N 2 2
Rozdział 9. • Binarne wyszukiwanie i wstawianie 237

4. Jeśli poszukiwany klucz jest mniejszy od elementu bieżącego, zawęź obszar


przeszukiwania do elementów leżących na lewo od tego ostatniego; jeżeli elementy
takie nie istnieją, oznacza to, że nie istnieje także szukany element i poszukiwanie
kończy się, w przeciwnym razie przejdź do kroku 2.
5. Jeśli poszukiwany klucz jest większy od elementu bieżącego, zawęź obszar
przeszukiwania do elementów leżących na prawo od tego ostatniego; jeżeli
elementy takie nie istnieją, oznacza to, że nie istnieje także szukany element
i poszukiwanie kończy się, w przeciwnym razie przejdź do kroku 2.

Scenariusz ten przeanalizujemy na przykładzie poszukiwania litery K w liście widocznej na


rysunku 9.1; oczywiście jej zawartość jest posortowana alfabetycznie.

Dziewięcioliterowa A D F H I K L M P
posortowana
rosnąco lista

Rozpoczynamy od wyznaczenia elementu środkowego —- jest nim li


wiono to na rysunku 9.2.

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

Znalezienie litery K w dziewięeioelementowej liście wymagało tylko trzech porównań:


dwóch pośrednich z literami I oraz L i jednego końcowego. „Naiwne" przeszukiwanie se-
kwencyjne wymagałoby sześciu porównań szukanego klucza, z literami A, D, F, H, I oraz K.

Można by w tym momencie stwierdzić, że wszystko to jest sprawą położenia szukanego


elementu: gdyby chodziło o element A, przeszukiwanie sekwencyjne wymagałoby tylko
jednego porównania, zaś wyszukiwanie połówkowe — aż czterech. Trzeba jednak uczciwie
przyznać, że sytuacje takie należą do wyjątków i w większości przypadków wyszukiwanie
binarne okazuje się znacznie efektywniejsze niż wyszukiwanie sekwencyjne — co posta-
ramy się wykazać w dalszej części rozdziału.

Dwa sposoby realizacji wyszukiwania binarnego


Skoro znamy już zasadę wyszukiwania połówkowego, pora zająć się jej implementacją.
Przedstawimy dwie wersje implementacji — rekurencyjną i iteracyjną; są one zbliżone do
siebie pod względem wydajności, lecz jedna wydaje się bardziej intuicyjna od drugiej.

Interfejs wyszukiwania binarnego


Obydwie wymienione implementacje -— rekurencyjną i iteracyjna — opierać się będą na
tym samym interfejsie, co umożliwi łatwą ich wymianę w zestawach testujących oraz ze-
stawach porównujących wydajność. Interfejs ten — nazwiemy go ListSearcher — posia-
dać będzie tylko jedną metodę — searchO — której parametrem wywołania będzie klucz
poszukiwanego elementu oraz oczywiście lista (z definicji posortowana), w której przeszu-
kiwanie to będzie prowadzone. Ponieważ przeszukiwanie to w naturalny sposób wiąże
się z porównywaniem elementów, metoda korzystać będzie w tym celu z odpowiedniego
komparatora. Gdy element o specyfikowanym kluczu zostanie znaleziony w liście, metoda
zwróci jego indeks (0, 1, 2, ...); w przypadku nieznalezienia elementu metoda zwróci na-
tomiast zanegowaną pozycję, na której znajdowałby się ów element, gdyby tylko był w tej
liście obecny.

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.

Rozwiązaniem jest więc rezygnacja z prostego negowania pozycji i sztuczne zmniejszenie


zwracanych wartości ujemnych — wartość - 1 odpowiadać będzie wówczas pozycji 0,
wartość - 2 pozycji 1; mówiąc ogólnie — pozycji k odpowiadać będzie wartość -{k+1).
Rozdział 9. • Binarne wyszukiwanie i wstawianie 239

Definicja interfejsu UstSearcher


Zgodnie z powyższym opisem definicja interfejsu UstSearcher w języku Java przedstawia
się następująco:
package com.wrox.a1gori thms.bsea rch;
i mport com.wrox.a1gori thms.1 i sts.Li st;

public interface ListSearcher {


public int search(List list, Object key);

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.

Definiowanie klasy testowej


Oprócz dwóch wymienionych realizacji wyszukiwania binarnego — rekurencyjnej i iteracyjnej
— w dalszym ciągu rozdziału skonstruujemy jeszcze jedną wyszukiwarkę. Każda z tych
implementacji testowana będzie za pomocą stosownej klasy testowej, zaś elementy wspólne
dla testowania wszystkich implementacji zdefiniowane zostaną w ramach abstrakcyjnej
klasy bazowej.
package com.wrox.algori thms.bsea rch;

i mport com.wrox.algori thms.1 i sts.ArrayLi st:


i mport com.wrox.a1gori thms. 1 i sts.Li st:
i mport com.wrox.algori thms.sorti ng.Compa rator;
i mport com.wrox.algori thms.sorti ng.NaturalComparator;
import junit.framework.TestCase:

public abstract class AbstractListSearcherTest extends TestCase {


private static finał Object[] VALUES =
{"B". "C". "D". "F". "H", "I". "J". "K". "L". "M". "P". "Q"}:

private ListSearcher _searcher;


private List _list:

protected abstract ListSearcher createSearcher(Comparator comparator);

protected void setUpO throws Exception {


super.setUp();
240 Algorytmy. Od podstaw

_searcher = createSearcher(NaturalComparator.INSTANCE):
J i s t = new ArrayList(VALUES):
}
}

J a k to działa?

W klasie AbstractLi stSearcherTest definiowane są pewne dane testowe (VALUES), zmienna


wskazująca instancję testowanej wyszukiwarki (_searcher) i lista zawierająca kolejno wy-
szukiwane elementy (Jist). Abstrakcyjna metoda createSearcherO, pozostawiona do zdefi-
niowania w konkretnej klasie testowej, tworzy i zwraca testowaną instancję wyszukiwarki
(tę wskazywaną przez zmienną _searcher).

Metoda inicjacyjna setUpO tworzy wspomnianą instancję wyszukiwarki oraz listę przechowu-
jącą testowane wartości.

Zwróćmy uwagę, że argumentem metody createSearcherO jest komparator, który — jako


część implementowanej wyszukiwarki — będzie jedynym elementem odpowiedzialnym za
wyznaczanie porządku porównywanych elementów (dzięki czemu nie będziemy musieli
troszczyć się o szczegóły porównywania elementów w innych fragmentach kodu).

Definiowanie metod testowych


Najbardziej oczywistym testem jest weryfikowanie poprawności wyszukiwania kolejnych
elementów — metoda search() powinna dla każdego z nich zwrócić jego własną pozycję
w liście:
public void testSearchForExistingValues() {
for (int i = 0; i < J i s t . s i z e O : ++1) {
assertEquals(i, _searcher.search(_list, Jist.get(i))):

W kolejnym teście zweryfikujemy działanie wyszukiwarki dla elementu nieobecnego w li-


ście, mniejszego od wszystkich elementów tej listy. Metoda searchO powinna dla tego ele-
mentu zwrócić wartość - 1 .
public void testSearchForNonExistingValueLessThanFirstItem() {
assertEquals(-l. _searcher.search(_list, "A")):
}

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

public void testSearchForArbitraryNonExistingValue() {


assertEquals(-4. _searcher.search(_list, "E"));
}

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 drugim teście wyszukiwarce zlecamy poszukiwanie elementu A, który jest mniejszy od


wszystkich elementów listy, czyli wstawiony do tej listy zajmowałby w niej pozycję 0.
Zgodnie z przyjętą konwencją metoda searchO powinna zwrócić w tym wypadku wartość
-(0+1) = - 1 .

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.

Element E, którego poszukujemy w czwartym teście, posiada wartość pośrednią między


wartościami elementów na pozycjach 2 i 3; po wstawieniu do listy znalazłby się więc na pozycji
3, zatem metoda search() musi w tym przypadku zwrócić wartość -(3+1) = - 4 .

Rekurencyjna wyszukiwarka binarna


Wyszukiwanie binarne jest procesem polegającym na postępującym dzieleniu przeszuki-
wanego obszaru na pół i zawężaniu poszukiwań do jednej z powstałych w ten sposób po-
łówek. Jest to przykład postępowania typu „dziel i zwyciężaj", które w sam raz nadaje się
do implementacji rekurencyjnej.

spróbuj sam Testowanie i implementowanie wyszukiwarki rekurencyjnej


Dla zweryfikowania poprawności tworzonej wyszukiwarki musimy zdefiniować stosowną
klasę testową poprzez konkretyzację abstrakcyjnej metody createSearcher() klasy Abs-
tractLi stSearcherTest.

package com.wrox.a 1gori thms.bsearch;

import com.wrox.a1gori thms.sort i ng.Comparator;

public class RecursiveBinaryListSearcherTest extends AbstractListSearcherTest {


protected ListSearcher createSearchertComparator comparator) {
return new RecursiveBinaryListSearcher(comparator);
242 Algorytmy. Od podstaw

Sama implementacja wyszukiwarki przedstawia się natomiast następująco:


package com.wrox.a1gori thms.bsea rch;

i mport com.wrox.algori thms.1 i sts.Li st:


import com,wrox.algorithms.sorting.Comparator;

public class RecursiveBinaryListSearcher implements ListSearcher {


/** Komparator wyznaczający porządek elementów listy */
private finał Comparator _comparator;
/**

* Konstruktor.
* Parametr: komparator wyznaczający porządek elementów listy
*/
public RecursiveBinaryListSearcher(Comparator comparator) {
assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}

private int searchRecursively(List list, Object key,


int lowerIndex, int upperlndex) {
assert list != nuli : "nie określono listy";

if (lowerIndex > upperlndex) {


return -OowerIndex + 1);
}
int index = lowerIndex + (upperlndex - lowerlndex) / 2:

int cmp = _comparator.compare(key. list.get(index));

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";

return searchRecursively(list, key, 0, list.sizeO - 1);


}

J a k to działa?

Zdefiniowanie klasy testowej RecursiveBinaryListSearcherTest sprowadza się do zaim-


plementowania metody createSearcher() klasy bazowej AbstractListSearcherTest tak, by
metoda ta tworzyła i zwracała instancję klasy RecursiveBinaryListSearcher funkcjonującą
w oparciu o komparator naturalny.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 243

Wspomniany komparator jest parametrem konstruktora klasy. Skrywa on w sobie szczegóły


wyznaczania porządku porównywanych elementów, dzięki czemu samo wyszukiwanie może
odbywać się bez związku z konkretnym kryterium tego porządku.

Na każdym z poziomów rekurencji aktualny obszar wyszukiwania przeszukiwany jest za


pomocą metody searchRecursively(). Obszar ten wyznaczany jest przez dwa indeksy gra-
niczne — lowerIndex i upperlndex — będące dwoma ostatnimi parametrami wywołania
metody. W scenariuszu wyszukiwania przedstawionym na rysunkach od 9.1 do 9.5 położe-
nie tego obszaru nieustannie się zmienia: początkowo (rysunek 9.1) obejmuje on całą listę
(pozycje od 0 do 8 włącznie), w pierwszym kroku zostaje zawężony do elementów od 5 do
8 (rysunek 9.3), by w końcu stać się obszarem jednoelementowym (5 — rysunek 9.5).

Zapomnijmy na chwilę o kryterium zatrzymania rekurencji, czyli jej przypadku bazowym.


Pierwszym krokiem przeszukiwania obszaru jest wyznaczenie jego „środkowego" elemen-
tu; dla obszaru ograniczonego elementami na pozycjach lowerIndex i upperlndex element
środkowy znajduje się na pozycji równej:
lowerIndex + (upperlndex - lowerIndex) / 2

Wyrażenie (upperlndex - lowerIndex) / 2 określa odległość elementu środkowego od po-


czątku obszaru; chcąc otrzymać indeks tego elementu, musimy powiększyć tę wartość o in-
deks elementu początkowego. Dla obszaru z rysunku 9.1 element środkowy ma indeks
0+(0+8)/2 = 0+4 = 4. Na rysunku 9.3 widzimy już obszar zawężony, ograniczony elemen-
tami na pozycjach 5 i 8. Środkowym elementem tego obszaru jest element na pozycji 5+(8-
5)/2 = 5+3/2 = 5+1 = 6 (dokładnie tak jak pokazano na rysunku 9.4).

Po wyznaczeniu pozycji elementu środkowego (oznaczmy tę pozycję przez index) nastę-


puje sprawdzenie, czy nie jest on przypadkiem elementem poszukiwanym: jego wartość oraz
wartość poszukiwana przekazywane są do komparatora, a wynik porównania zapamięty-
wany jest w zmiennej roboczej cmp:
int cmp = _comparator.compare(key, list.get(index));

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.

W sytuacji przedstawionej na rysunku 9.5 obszar poszukiwania (lowerIndex=upperIndex=5)


jest obszarem jednoelementowym; jedyny element obszaru (K) jest jednocześnie jego ele-
mentem środkowym (bo i ndex + (index-index)/2 = index) i w tym konkretnym przypadku
jest elementem szukanym. Gdybyśmy jednak poszukiwali wartości J, to wobec faktu, że jest
ona mniejsza od elementu środkowego, dalsze poszukiwanie prowadzone byłoby w obszarze
ograniczonym pozycjami lowerIndex i index-l, czyli pozycjami 5 i 4 — a więc w obszarze
pustym nastąpiłoby wspomniane skrzyżowanie indeksów.

Gdy metoda searchRecursively() wywołana zostanie z takimi skrzyżowanymi wartościami


indeksów, jest to dla niej sygnał do zakończenia poszukiwań i jedynym problemem pozostaje
wówczas wartość zwracanego wyniku. Otóż zauważmy, że lewy indeks graniczny (lowerIndex)
równy jest wówczas pozycji, na którą należałoby wstawić do listy nieobecny element; zgodnie
z przyjętą konwencją zwracanym wynikiem jest wówczas wartość - (1 ower Index+1):
if (lowerIndex > upperlndex) {
return -Oowerlndex + 1);
}
Metodą interfejsu ListSearcher, odpowiedzialną za wyszukiwanie elementu, jest (jak pa-
miętamy) metoda searchO. Jej działanie sprowadza się do wywołania metody searchRe-
cursively() dla całej listy, czyli dla granicznych wartości indeksów 0 i _list. size( )-l.

Iteracyjna wyszukiwarka binarna


Po zrozumieniu działania wyszukiwarki rekurencyjnej, w szczególności sposobu operowa-
nia granicznymi indeksami przeszukiwanego obszaru, zrozumienie zasad działania jej ite-
racyjnego odpowiednika staje się znacznie uproszczone.

spróbuj sam Testowanie i implementowanie wyszukiwarki iteracyjnej


Podobnie jak w poprzednio rozpoczniemy od zdefiniowania stosownej klasy testowej:
package com.wrox.a1gori thms.bsea rch;

import com.wrox.algorithms.sorti ng.Comparator;

J
Rozdział 9. • Binarne wyszukiwanie i wstawianie 245

public class IterativeBinaryListSearcherTest extends AbstractListSearcherTest {


protected ListSearcher createSearcher(Comparator comparator) {
return new IterativeBinaryListSearcher(comparator);
}
}
Metoda createSearcherO tej klasy tworzy i zwraca instancję klasy implementującej wy-
szukiwarkę iteracyjną zdefiniowaną następująco:
package com.wrox.a 1gori thms.bsea rch;

i mport com.wrox.a 1gori thms.1 i sts.Li st;


import com.wrox.algorithms.sorting.Comparator;

public class IterativeBinaryListSearcher implements ListSearcher {


/** komparator wyznaczający porządek elementów listy */
private finał Comparator _comparator:

/•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:

while (lowerIndex <= upperlndex) {


int index = lowerIndex + (upperlndex - lowerIndex) / 2;

int cmp = _comparator.compare(key. list.get(index)):

if (cmp = 0) {
return index;
} else if (cmp < 0) {
upperlndex = index - 1;
} else {
lower!ndex = index + 1;

return -OowerIndex + 1);


}
}

J a k to działa?

Podobnie jak klasa wyszukiwarki rekurencyjnej, tak klasa IterativeBinaryListSearcher


implementuje interfejs ListSearcher oraz podejmuje w konstruktorze komparator dokonu-
jący porównywania elementów. Implementowana metoda searchO interfejsu ListSearcher
246 Algorytmy. Od podstaw

stanowi wynik bezpośredniego niemal tłumaczenia metody searchRecursively() klasy Re-


cursi veBi naryListSearcher.

Dokładna analiza metody searchRecursi vely() pozwala stwierdzić, że podstawową czyn-


nością wykonywaną między kolejnymi poziomami wywołania rekurencyjnego jest odpo-
wiednia zmiana indeksów granicznych obszaru poszukiwania, a więc w zasadzie można by
zrezygnować z rekurencji, zastępując ją pętlą while, której każdy „obrót" stosownie mody-
fikowałby wartość wspomnianych indeksów 2 .

Wspomniana pętla poprzedzona jest inicjacją indeksów granicznych, tak by początkowo


wyznaczały one całą listę:
int lowerIndex = 0:
int upperlndex = list.sizeO - 1;

Podobna inicjacja odbywa się na początku metody searchO w implementacji rekurencyj-


nej, przed pierwszym wywołaniem metody searchRecursi vely().

Potem kręci się już pętla whi le:


while (lowerIndex <= upperlndex) {

int cmp = _comparator.compare(key, list.get(index)):


if (cmp == 0) {
return index; // znaleziono element, koniec poszukiwań

}
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):

W drugim przypadku następuje zwrócenie indeksu znalezionego elementu:


if (cmp == 0) {
return index:

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:

Ocena działania wyszukiwarek


W niniejszym punkcie zaobserwujemy zachowanie się obydwu opisanych wersji wyszuki-
warek binarnych w obliczu różnych scenariuszy. Dodatkowo, by uwidocznić znacząco lepszą
efektywność wyszukiwania binarnego wobec wyszukiwania sekwencyjnego, skonstruujemy
ad hoc wyszukiwarkę liniową. Podobnie jak w przypadku porównywania wydajności algo-
rytmów sortowania w rozdziałach 6. i 7. posłużymy się komparatorem zliczającym (Cali -
CountingComparator) w celu rejestrowania liczby porównań elementów dokonywanych przez
każdą z implementacji.

Dla porównania—wyszukiwarka sekwencyjna


Oprócz porównania z sobą obydwu implementacji wyszukiwarek binarnych, porównamy
każdą z nich z wyszukiwarką sekwencyjną stosującą zwykłe porównywanie kolejnych ele-
mentów listy z szukaną wartością. Oczywista zdawałaby się realizacja wyszukiwania li-
niowego — wykorzystanie metody index0f() interfejsu List — j e s t w tym przypadku bez-
użyteczna z banalnego powodu: metoda ta, nie korzystając z komparatora, nie udostępnia
żadnej informacji o liczbie wykonywanych przez siebie porównań. Zmuszeni jesteśmy więc
stworzyć klasę podobną w swej strukturze do klas realizujących wyszukiwanie binarne,
opierającą się jednak na wyszukiwaniu liniowym.

imifrf.iiiB Testowanie i implementowanie wyszukiwarki sekwencyjnej


Mimo iż naszą wyszukiwarkę sekwencyjną skonstruowaliśmy jedynie w celu porównania
jej efektywności z efektywnością wyszukiwarek binarnych, to w przypadku jej wadliwego
funkcjonowania porównanie to nie byłoby wiele warte. Starym zwyczajem skonstruujemy
więc dla niej odrębną klasę testową za pomocą której zweryfikować można poprawność jej
funkcjonowania.
package com.wrox.a 1gori thms.bsea rch:

i mport com.wrox.a 1gori thms.sorti ng.Comparator;

public class LinearListSearcherTest extends AbstractListSearcherTest {


protected ListSearcher createSearcher(Comparator comparator) {
return new LinearListSearcher(comparator);
}
_}
248 Algorytmy. Od podstaw

Sama wyszukiwarka sekwencyjna implementowana jest natomiast przez następującą klasę:


package com.wrox.a 1gori thms.bsearch:

i mport com.wrox.a1gori thms.i terat i on.Iterator:


import com.wrox.aIgorithms.1 ists.List;
import com.wrox.algorithms.sorting.Comparator:

public class LinearListSearcher implements ListSearcher {


/** komparator wyznaczający porządek porównywanych elementów */
private finał Comparator _comparator:

* 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;

for (i.firstO: !i.isDoneO: i.nextO) {


int cmp = _comparator.compare(key. i,current());
if (cmp ~ 0) {
return index;
} else if (cmp < 0) {
break:
}
++index:
}
return -(index + 1 ) ;
}
_J

J a k to działa?

Ponieważ pod względem zewnętrznych aspektów zachowania wyszukiwarka sekwencyjna


nie różni się od opisywanych wyszukiwarek binarnych, można utworzyć jej klasę testową
LinearListSearcherTest na bazie abstrakcyjnej klasy AbstractLi stSearcherTest, imple-
mentując metodę createSearcherO tak, by ta tworzyła i zwracała instancję klasy Linear-
ListSearcher.

Podobnie jak klasy Recursi veBinaryListSearcher i IterativeBinaryListSearcher, klasa


LinearListSearcher implementuje interfejs ListSearcher i opiera swe działanie na kompa-
ratorze wyznaczającym porządek porównywanych elementów.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 249

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;

Druga ze wspomnianych zmian stanowi drobną optymalizację w porównaniu z wersją ory-


ginalną z rozdziału 2. Jeżeli mianowicie szukanego elementu nie ma w liście, metoda in-
dexOf() może wykryć ten fakt dopiero po przejrzeniu całej listy i w przypadku listy o do-
wolnym uporządkowaniu elementów lepiej być nie może. W naszym przypadku mamy
jednak do czynienia z listą posortowaną, a więc nieobecność szukanego elementu w liście
staje się wiadoma już w momencie natrafienia na element o wartości większej niż wartość
szukana; wyszukiwanie zostaje wówczas zakończone:
int cmp = _comparator.compare(key. i.currentO);

} else if (cmp < 0) {


break;

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.

spróbuj sam Tworzenie klasy testowej


Zadaniem testowej klasy porównawczej — Bi narySearchCal lCountingTest — j e s t , zgodnie
z jej nazwą, zliczanie porównań wykonywanych przez testowaną wyszukiwarkę.
package com,wrox.algori thms.bsearch;

i mport com.wrox.algori thms.1 i sts.ArrayLi st;


i mport com.wrox.algori thms.1 i sts.Li st:
import com.wrox.algori thms.sorti ng.CallCounti ngComparator;
i mport com.wrox.algori thms.sorti ng.Natura 1Comparator;
import junit.framework.TestCase;
250 Algorytmy. Od podstaw

public class BinarySearchCallCountingTest extends TestCase {


private static finał int TEST_SIZE = 1021;

private List _sortedList;


private CallCountingComparator _comparator;

protected void setUpO throws Exception {


super.setUpO;

_sortedList - new ArrayList(TEST_SIZE);

for (int i = 0; i < TEST_SIZE; ++i) {


_sortedList.add(new Integer(i));
}
_comparator = new CallCountingCoinparator(NaturalComparator.INSTANCE);
}
private void reportCallsO {
System.out.println(getName() + ": " + _comparator.getCallCount() + "
wywołań");
}
}

J a k to działa?

W klasie BinarySearchCal lCountingTest zdefiniowano stałą TEST_SIZE określającą rozmiar


listy testowej, w której prowadzone będzie poszukiwanie. Zdefiniowano także dwie pry-
watne zmienne wskazujące (odpowiednio) wspomnianą instancję listy (_sortedList) i kompa-
rator zliczający porównania (_comparator).

W metodzie inicjacyjnej setUpO następuje utworzenie instancji listy testowej i zapełnienie


jej (w rosnącym porządku) liczbami całkowitymi z zakresu od 0 do TEST_SIZE-1. Następuje
także utworzenie komparatora zliczającego na bazie komparatora naturalnego — jak pa-
miętamy, klasa liczb całkowitych (integer) implementuje interfejs Comparable, jest więc
dla niej zdefiniowany komparator naturalny.

Metoda reportCallsO wykorzystywana jest przez każdy z testów do wyświetlania wyni-


ków analizy w postaci:
<nazwa_testu>: <liczba_porównań> wywołań

Mając już opisaną wyżej „bazę testową", można przystąpić do tworzenia metod mierzących
wydajność każdej z wyszukiwarek.

Implementowanie metod testowych


W pierwszym teście każda z wyszukiwarek dokonuje wyszukiwania kolejno każdego ele-
mentu listy, w kolejności zgodnej z ich porządkiem wyznaczanym przez komparator (inorder):
Rozdział 9. • Binarne wyszukiwanie i wstawianie 251

public void testRecursiveBinarySearch() {


performlnOrderSearchtnew RecursiveBinaryListSearcher(_comparator));
}
public void testIterativeBinarySearch() {
performInOrderSearch(new IterativeBinaryListSearcher(_comparator));
1
public void testLinearSearchO {
performInOrderSearch(new LinearListSearchert comparator)):
}
private void performInOrderSearch(ListSearcher searcher) {
for (int i = 0: i < TESTJIZE: ++i) {
searcher.search(_sortedList. new Integer(i)):
}
reportCallsO:

W drugim teście kolejność sięgania po elementy jest przypadkowa (random), z konieczno-


ści inna dla każdej wyszukiwarki:
public void testRandomRecursiveBinarySearch() {
performRandomSearch(new RecursiveBinaryListSearcher(_comparator));
}
public void testRandomIterativeBinarySearch() {
performRandomSearchCnew IterativeBinaryListSearcher(_comparator));
}
public void testRandomLinearSearchO {
performRandomSearchCnew LinearListSearcher(_comparator));
}
private void performRandomSearch(ListSearcher searcher) {
for (int i - 0: i < TESTJIZE: ++i) {
searcher.search(_sortedList,
new Integert(int) (TESTJIZE * Math.randomO))):
}
reportCallsO:
}

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).

W drugim teście przedmiotem wyszukiwania są losowo generowane liczby całkowite; zwróćmy


uwagę, że metoda performRandomSearch() prowadzi generowanie w taki sposób, by każdo-
razowo otrzymać wartość znajdującą się w liście.
252 A l g o r y t m y . Od podstaw

W wyniku wykonania jednak z takich analiz otrzymano następujące wyniki wyświetlone


przez metodę reportCallsO:

testRecursiveBinarySearch: 9197 wywołań


testIterativeBinarySearch: 9197 wywołań
testLinearSearch: 521731 wywołań
testRandomRecursiveBinarySearch: 9197 wywołań
testRandomIterativeBinarySearch: 9132 wywołań
testRandomLinearSearch: 531816 wywołań

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).

Tabela 9.1. Porównanie efektywności wyszukiwania 1021 elementów w posortowanej liście

Wyszukiwanie Binarne rekurencyjne Binarne iteracyjne Sekwencyjne


Porównań (kolejność rosnąca) 9 197 9 197 521 731

Porównań (kolejność losowa) 9 158 9 132 531 816

Porównań (średnio na element) 9 9 515

Obydwie wyszukiwarki binarne — rekurencyjna i iteracyjna — wykonują tę samą liczbę


porównań w przypadku „rosnącego" pobierania elementów; niewielka różnica w przypadku
pobierania losowego związana jest z różnicą w wartościach generowanych losowo ele-
mentów — dla każdej wyszukiwarki generowanie prowadzone jest oddzielnie. Tego można
się było spodziewać i analiza potwierdziła nasze przypuszczenia. Choć nie wynika to z ta-
beli, wyszukiwarka rekurencyjna okazałą się nieco wolniejsza w działaniu ze względu na
realizację samej rekurencji, choć w ogólnym bilansie efektywności efekt ten można całko-
wicie zignorować

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.

Wstawianie binarne ma wiele wspólnego w wyszukiwaniem binarnym. Najważniejsza róż-


nica między nimi polega na tym, że wyszukiwanie (zgodnie z nazwą) sprowadza się do
określenia pozycji elementu obecnego w liście, podczas gdy istotą wstawiania binarnego
jest określenie właściwej pozycji w liście dla nowego elementu.

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.

Środkowym elementem pierwszej (lewej) połówki jest litera D, mniejsza od wstawianego


elementu (rysunek 9.6). Musimy zatem skoncentrować swoją uwagę na drugiej ćwiartce,
czyli na obszarze tworzonym przez elementy F i H.

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

Po umieszczeniu elementu G na jego docelowej pozycji lista pozostaje nadal posortowana.

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?

Tworzenie testów dla wstawiania binarnego


Działanie insertera testować będziemy przez wstawianie liczb całkowitych do listy w kolej-
ności rosnącej oraz w kolejności przypadkowej. W obydwu przypadkach lista powinna perma-
nentnie pozostawać w stanie posortowanym.

Oto podstawowe elementy klasy testowej weryfikującej tę właściwość:


package com.wrox.a1gori thms.bsearch;

i mport com.wrox.a1gori thms.i terati on.Iterator;


i mport com.wrox.a1gori thms.1 i sts.ArrayLi st;
i mport com.wrox.a1gor i thms.1 i sts.L i st;
import com.wrox.algori thms.sorti ng.NaturalComparator;
import junit.framework.TestCase:

public class ListlnserterTest extends TestCase {


private static finał int TESTJIZE = 1023:

private Listlnserter _inserter;


private List J i s t ;

protected void setUpO throws Exception {


super. setUpO:

inserter = new Listlnserter(


new IterativeBinaryListSearchertNaturalComparator.INSTANCE)):
list = new ArrayList(TESTJIZE):

private void verify() {


int previousValue = Integer.MINJ/ALUE;
Iterator i = _1 ist.iteratorO;
Rozdział 9. • Binarne wyszukiwanie i wstawianie 255

for (i.firstO; !i.isDoneO: i.next()) {


int currentValue = ((Integer) i.currentt)),intValue();
assertTrue(currentValue >= previousValue);
previousValue = currentValue:

_J

W pierwszym teście wstawiać będziemy do listy liczby całkowite 0, 1, 2, ..., TEST_SIZE-1


w kolejności rosnącej:
public void testAscendingInOrderInsertion() {
for (int 1 = 0 ; i < TESTJIZE; ++1) {
assertEquals(i. jnserter.insertijist, new Integerti)));
}
verify();
}
W drugim teście zastosujemy wstawianie tych samych liczb w kolejności malejącej:
public void testDescendinglnOrderlnsertionO {
for (int i = TESTJIZE - 1; i > - 0; --i) {
assertEquals(0, _inserter.insert(_list, new Integer(i)));
}
verify():
}
W trzecim, ostatnim teście wstawiać będziemy do listy losowo wygenerowane liczby cał-
kowite z zakresu 0 -5- TEST_SIZE-1.
public void testRandomlnsertionO {
for (int i = 0; i < TESTJIZE; ++i) {
_inserter.insert(_list. new Integer((int) (TESTJIZE * Math.randomO)));
}
verify();
)

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 pierwszym teście do listy kolejno wstawiane są elementy 0, 1, 2, ... TEST_SIZE-1. Nie-


trudno zauważyć, że jeżeli wstawianie to odbywać się będzie prawidłowo, to wartość ele-
mentu równa będzie pozycji, jaką przyjmie on w liście w efekcie wstawienia. W pętli for
przebiegającej wymienione wartości porównuje się więc wartość zmiennej sterującej (równą
wstawianemu elementowi) z wynikiem zwracanym przez metodę insertO insertera. Po-
nieważ mimo wszystko nie daje to gwarancji poprawności — pozycje elementów mogą się
zmieniać, choć nie powinny — na zakończenie wywoływana jest metoda verify() weryfi-
kująca kolejność elementów w liście.

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().

Mając gotowy zestaw testowy, zajmijmy się implementacją samego insertera.

wiHiiMi Implementowanie Klasy insertera binarnego


Inserter implementowany jest przez klasę List Inserter zdefiniowaną następująco:
package com.wrox.a 1gori thms.bsearch:

i mport com.wrox.a 1gori thms.1 i sts.Li st:

public class Listlnserter {


/** pomocnicza wyszukiwarka elementów */
private finał ListSearcher _searcher:
/**

* Konstruktor.
* Parametr: instancja pomocniczej wyszukiwarki elementów
*/
public ListInserter(ListSearcher searcher) {
assert searcher != nuli : "nie określono wyszukiwarki":
_searcher = searcher;
}
/**

* Wstawienie wartości do listy z zachowaniem jej posortowania *


* Parametry:
* - lista, do której wstawiane są elementy
* - wstawiana wartość
Rozdział 9. • Binarne wyszukiwanie i wstawianie 257

* Zwracany wynik: pozycja, na którą element został wstawiony


*/
public int insertdist list, Object value) {
assert list != nuli : "nie określono listy";

int index = _searcher.search(list, value):

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.

Metoda i n s e r t O może zwrócić wartość nieujemną— będzie to oznaczać, że w liście ist-


nieje już element równy co do wartości elementowi, który mamy dopiero wstawić do listy.
Nie stanowi to problemu, bowiem wartości elementów w liście mogą się dublować. Ele-
ment wskazany przez wyszukiwarkę i wszystkie elementy następne przesuwane są o jedną
pozycję w prawo, a wstawiany element umieszczany jest na pozycji zwolnionej w wyniku
tego przesuwania. Pozycja wstawionego elementu jest więc identyczna z pozycją wskazaną
przez wyszukiwarkę.

Jeżeli wstawiany element nie ma jeszcze swojego odpowiednika w liście, wyszukiwarka


zwraca wartość ujemną. Jak wiadomo z wcześniejszego opisu, wartość ta — oznaczmy ją w
— powiązana jest z docelową pozycją elementu (oznaczmy j ą x) zależnością w = -(x+l).
Odwracając tę zależność, otrzymujemy x = —(w+1) i obliczamy wspomnianą pozycję do-
celową.

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?

By uzyskać odpowiedź na to pytanie, musimy skonstruować odpowiednie testy porównawcze


w taki sam sposób, jak czyniliśmy to już przy okazji sortowania i wyszukiwania.
258 Algorytmy. Od podstaw

iwiił.iiii Porównywanie wstawiania binarnego z sortowaniem


Podobnie do wcześniejszego porównywania wydajności różnych wyszukiwarek skonstru-
ujemy zestaw testowy dokonujący porównania wydajności binarnego wstawiania elementów
do listy z wydajnością sortowania tej listy post factum za pomocą wybranych algorytmów.
package com.wrox.a 1gori thms.bsea rch;

i mport com.wrox.a1gori thms.1 i sts.ArrayLi st;


i mport com.wrox.a 1gori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.sorti ng.Ca11Counti ngComparator;
i mport com.wrox.a1gori thms.sorti ng.Li stSorter;
i mport com.wrox.a 1 gori thms.sorti ng.MergesortLi stSorter:
i mport com.wrox.a1gori thms.sorti ng.Natura1Comparator;
import com.wrox.algorithms.sorting.QuicksortListSorter;
import com.wrox.algorithms.sorti ng.Shel1sortLi stSorter;
import junit.framework.TestCase:

public class BinarylnsertCallCountingTest extends TestCase {


private static finał int TEST_SIZE = 4091:

private List J i s t :
private CallCountingComparator _comparator:

protected void setUpO throws Exception {


super. setUpO;

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));

for (int i = 0: i < TESTJIZE: ++i) {


inserter.insert(_list. new Integer((int) (TESTJIZE * Math.randomO)));
}
reportCallsO;
}
private void reportCallsO {
System.out.println(getName() + ": " + _comparator.getCa11CountO + " wywołań"):
}

Dla porównania dokonamy wstawienia elementów do pustej listy w kolejności generowania


(czyli dołączając nowy element na koniec listy) i posortujemy tę listę, zliczając porównania
wykonywane w ramach tego sortowania; proces ten powtórzymy niezależnie dla każdego
z algorytmów sortowania.
Rozdział 9. • Binarne wyszukiwanie i wstawianie 259

public void testMergeSortO {


populateAndSorttnew MergesortListSorter(_comparator));
}
public void testShellsortO {
populateAndSort(new ShellsortListSorter(_comparator)):
}
public void testQuicksort() {
populateAndSort(new QuicksortListSorter(_comparator)):
}
private void populateAndSort(ListSorter sorter) {
for (int i - 0: i < TESTJIZE: ++i) {
_list.add(new Integer((int) (TESTJIZE * Math. randomO))):
}
_list = sorter. sort(Jist);

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ń

identycznej jak w przypadku porównywania wyszukiwarek binarnych. Każdy z trzech ko-


lejnych testów, wykonywanych przez metody testMergeSortO, testShellsortO i testQu-
icksort(), polega na dołączaniu losowo generowanych elementów na koniec listy i następnie
jednorazowym jej sortowaniu za pomocą algorytmu odzwierciedlonego w nazwie metody.
Zapełnienie (pustej) listy elementami z zakresu 0 -s- TEST_SIZE-1 i jej posortowanie za po-
mocą wskazanej instancji klasy sortującej jest zadaniem metody populateAndSortListO.

W wyniku przeprowadzonego eksperymentu otrzymano następujące wyniki:


testBinary Insert: 41471 wywołań
testMergeSort: 43928 wywołań
testShellsort: 102478 wywołań
testOuicksort: 97850 wywołań
260 A l g o r y t m y . Od podstaw

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).

Tabela 9.2. Porównanie wydajności wstawiania 4091 losowych elementów do listy

Strategia Liczba porównań


Wstawianie binarne 41 471

Sortowanie przez łączenie ( M e r g e s o r t ) 43 928

Sortowanie metodą Shella ( S h e l l s o r t ) 102 478

Sortowanie szybkie ( Q u i c k s o r t ) 97 850

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.

Przedstawione wyniki potwierdzają jednocześnie teoretyczne przewidywania dotyczące


złożoności wstawiania binarnego. Wstawienie nowego elementu do listy pustej nie wymaga
żadnych porównań, wstawienie do listy jednoelementowej — log2 1 porównań, do listy
A>elementowej — l o g 2 k porównań. Łączna liczba porównań związanych z utworzeniem
posortowanej listy ./V elementów równa jest więc:

log2\ + !og22 + ... + log2(N-\) = log 2 (l x 2 x . . . x (w - 1 ) ) = log2((N -1))

co pod względem rzędu wielkości równoważne jest 0(N log N).

Mimo iż wykonany eksperyment i tak wykazuje wyraźną przewagę wstawiania binarnego


na sortowaniem listy, to jest on w pewnym stopniu dla wstawiania binarnego niesprawie-
dliwy. Otóż wstawianie binarne zapewnia ciągle utrzymywanie listy w postaci posortowanej,
podczas gdy sortowanie przywraca tę postać listy dopiero po zakończeniu jej kompletowania.
Aby więc uczynić naszą analizę w pełni obiektywną powinniśmy dokonywać sortowania
listy po dołączeniu każdego elementu.
private void populateAndSortdistSorter sorter) {
for (int i = 0; i < TESTJIZE; ++i) {
J i s t . add (new Integer(Cint) (TESTJIZE * Math.randomO)));
J i s t = sorter. sort(Jist);
}
reportCallsO;
}
Po przeprowadzeniu tak zmodyfikowanego eksperymentu otrzymano wyniki zestawione
w tabeli 9.3 (ponownie zaznaczamy, że ze względu na losowe wartości elementów wyniki
kolejnych eksperymentów mogą się nieco różnić).
Rozdział 9. • Binarne wyszukiwanie i wstawianie 261

Tabela 9.3. Porównanie wydajności dołączania 4091 elementów przy sortowaniu po dołączeniu każ-
dego elementu

Strategia Liczba porównań


Wstawianie binarne 41 481

Sortowanie przez łączenie ( M e r g e s o r t ) 48 852 618

Sortowanie metodą Shella (Shellsort) 44 910 616

Sortowanie szybkie (Quicksort) Brak danych — obliczenia przerwano po upływie 5 minut

Ponadtysiąckrotnie większa wydajność to już nie są żarty. Sortowanie zamiast wstawiania


binarnego zdecydowanie nie jest dobrym pomysłem.

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.

W odróżnieniu od większości pozostałych rozdziałów niniejszy rozdział ma charakter ra-


czej teoretyczny. Nie zawiera więc zbyt wielu praktycznych przykładów; ponieważ binarne
drzewa wyszukiwawcze stanowią jedynie podstawę konstrukcji struktur bardziej złożo-
nych, treść rozdziału stanowi raczej dyskusję na temat binarnych drzew wyszukiwawczych
niż pokazuje przykłady ich praktycznego wykorzystania. W dalszym ciągu książki zapre-
zentujemy konstruowanie i wykorzystywanie zbiorów (rozdział 12.), map (rozdział 13.) i B-
drzew (rozdział 15.) na bazie szablonowego kodu zamieszczonego w niniejszym rozdziale.

W rozdziale poruszamy następującą tematykę:


• najbardziej interesujące cechy binarnych drzew wyszukiwawczych,
• różnorodne operacje wykonywane na binarnych drzewach wyszukiwawczych,
• wpływ uporządkowania danych na wydajność przetwarzania binarnych drzew
wyszukiwawczych,
• proste sposoby przywracania wyważenia drzewa po wstawieniu lub usunięciu elementu,
• testowanie i implementowanie niewyważonych binarnych drzew wyszukiwawczych.
264 Algorytmy. Od podstaw

Binarne drzewa wyszukiwawcze


W rozdziale 2. nazywaliśmy system plików „drzewem katalogów". Formalnie rzecz biorąc,
drzewo (tree) jest zbiorem połączonych węzłów takim, że każdy z tych węzłów posiada zero
lub więcej węzłów-synów i co najwyżej jednego ojca. Jeden z węzłów jest wyróżniony jako
korzeń (root) i jest jedynym węzłem nieposiadającym ojca. Węzły nieposiadające synów
nazywane są liśćmi (leaves).

Podobnie jak w drzewie katalogów z dowolną liczbą katalogów, podkatalogów i plików,


węzeł drzewa w ogólności może posiadać dowolną liczbę synów. Najbardziej popularną
odmianą drzewa jest jednak drzewo binarne, w którym węzeł może mieć co najwyżej
dwóch synów (którzy nazywani są odpowiednio „lewym" i „prawym").

Binarnym drzewem wyszukiwawczym nazywamy drzewo binarne spełniające dodatkowy


warunek: wszystkie węzły położone „na lewo" od danego węzła posiadają mniejsze od nie-
go wartości, podczas gdy wszystkie węzły położone „na prawo" od danego węzła posiadają
wartości od niego większe.

Przykład binarnego drzewa wyszukiwawczego przedstawiono na rysunku 10.1. Korzeniem


tego drzewa jest węzeł I, zaś liśćmi — węzły A, H, K i P. Wartość każdego lewego syna
jest mniejsza niż wartość jego ojca, zaś wartość każdego prawego syna — większa.
Korzeń
Rysunek 10.1.
Proste drzewo
binarne

Liście

Wymieniony warunek umożliwia efektywne wyszukiwanie, wstawianie i usuwanie węzłów:


średni czas wyszukiwania w drzewie binarnym jest proporcjonalny do jego wysokości —
czyli równy jest O(h). W przypadku przedstawionym na rysunku 10.1 wysokość drzewa —
czyli najdłuższa ścieżka od korzenia do liścia — równa jest 3, można więc oczekiwać, że
wyszukiwanie elementu wymagać będzie średnio trzech porównań.

W drzewie wyważonym (balanced) — j a k to z rysunku 10.1 •— wysokość drzewa wynosi


0(log N) (N jest liczbą węzłów); w pewnych sytuacjach wyważenie to może jednak ulec
degeneracji, prowadząc (w najgorszym wypadku) do wysokości równej N.
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 265

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ł.

W przykładzie pokazanym na rysunku 10.1, poruszając się w opisany sposób począwszy od


węzła I, napotykamy liść A stanowiący minimum.

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.

Algorytm poszukiwania poprzednika jest dokładną odwrotnością algorytmu szukania na-


stępnika. Jeżeli mianowicie dany węzeł posiada lewego syna, to ten jest jego poprzednikiem.
W przeciwnym razie musimy poruszać się w górę drzewa aż do napotkania węzła będącego
czyimś prawym synem; ojciec tego ostatniego jest szukanym poprzednikiem.

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.

Wyjaśnimy powyższy scenariusz na przykładzie poszukiwania węzła z kluczem K w drze-


wie z rysunku 10.1.

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.

Jeśli porównamy opisany proces poszukiwania klucza K w drzewie binarnym z analogicz-


nym procesem szukania tego klucza w posortowanej liście (patrz rozdział 9.), stwierdzimy,
że kolejność porównywania węzłów jest w obydwu przypadkach identyczna. Wynika stąd,
że wyważone binarne drzewo wyszukiwawcze jest strukturą tak samo efektywną pod względem
wyszukiwania wartości jak posortowana lista.
268 Algorytmy. Od podstaw

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

Opisane dodanie węzła K nie miało wpływu na wysokość drzewa.

Wstawianie do drzewa losowych wartości zazwyczaj powoduje utrzymanie wysokości


drzewa w wielkości proporcjonalnej do liczby węzłów 0(log N). Cóż jednak się stanie, gdy
rozkład dodawanych wartości odbiega znacznie od rozkładu losowego — jest tak na przy-
kład wtedy, gdy nowe wartości dodawane są w kolejności rosnącej (alfabetycznej), bo są na
przykład pobierane ze słownika lub książki telefonicznej ? Jaki będzie na przykład efekt
dodania do (początkowo pustego) drzewa wartości (kolejno) A, D, E, H, I, K, L, M i P?
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 269

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.

Zaprezentujemy szczegółowo wymienione przypadki, zakładając każdorazowo, że począt-


kowa postać drzewa jest taka jak pokazana na rysunku 10.1.
270 A l g o r y t m y . Od podstaw

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

Ponieważ wszystkie usunięte węzły znajdowały się w lewym poddrzewie, otrzymaliśmy


ostatecznie drzewo wykrzywione.

Niezależnie od tego, z którym z opisywanych przypadków mamy faktycznie do czynienia


(i czy w wyniku usuwania zostaje naruszone wyważenie drzewa), większość czasu operacji
usuwania zajmuje znajdowanie węzła przeznaczonego do usunięcia (i być może jego na-
stępnika). Podobnie zatem jak wyszukiwanie i wstawianie elementów, także ich usuwanie
odbywa się w średnim czasie proporcjonalnym do wysokości drzewa ( O(h)).
272 Algorytmy. Od podstaw

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.

Trawersacja in-order to odwiedzanie węzłów w kolejności ich uporządkowania, od naj-


mniejszego, do największego. Jest ona często wykonywana w związku np. z drukowaniem
pozycji pewnej kolekcji, w kolejności alfabetycznej. W odniesieniu do drzewa z rysunku
10.1 trawersacja in-order oznacza odwiedzenie jego węzłów w kolejności następującej:
A, D, F, I, K, L, M, P.

Trawersację in-order zrealizować można na dwa sposoby — rekurencyjnie i iteracyjnie.


Wersja rekurencyjną oznacza wykonanie następującego scenariusza na każdym węźle X:
1. Przejdź przez lewe poddrzewo węzła X w kolejności in-order.
2. Odwiedź węzeł X.
3. Przejdź przez prawe poddrzewo węzła X w kolejności in-order.

W efekcie powyższy scenariusz oznacza przejście metodą in-order przez drzewo, którego
węzeł Xjest korzeniem.

Wersja iteracyjna wynika wprost z samej definicji: rozpoczynamy od minimum drzewa


(czyli jego węzła o najmniejszej wartości) i poruszamy się po jego kolejnych następnikach
aż do ich wyczerpania.

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.

W przeciwieństwie do trawersacji in-order trawersacja pre-order nie ma oczywistej im-


plementacji iteracyjnej. Choć implementacja taka jest oczywiście możliwa, i tak musi ona
symulować rekurencję, wykorzystując w tym celu stos (patrz rozdział 5.) w taki sam spo-
sób, jak odbywa się to „pod podszewką" zwykłych wywołań rekurencyjnych.
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 273

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.

Podobnie jak w przypadku trawersacji pre-order iteracyjna realizacja trawersacji post-order


jest w istocie symulowaną rekurencją.

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.

Jednym z najtrudniejszych zagadnień dotyczących wyważania drzew jest ich wyważanie na


bieżąco, czyli utrzymywanie ich w stanie wyważonym lub zbliżonym do wyważenia. Wy-
obraźmy sobie drzewo zawierające kilka tysięcy węzłów: jakie dodatkowe czynności nale-
żałoby podejmować po każdej operacji wstawienia lub usunięcia węzła, by kolejne takie
operacje nie powodowały postępującej degeneracji drzewa?

Jedna z najprostszych metod, którą zaproponowali dwaj rosyjscy matematycy — G. M.


Adelson-Velskij i E. M. Landis (stąd powszechnie znany akronim AVL), polega na śledze-
niu wysokości obydwu poddrzew każdego z węzłów; jeśli dla każdego z węzłów wysokości
jego poddrzew są identyczne lub różnią się o 1, drzewo uważane jest za wyważone i nazy-
wane jest drzewem A VL.

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.

Tabela 10.1. Wybór rotacji przywracającej wyważenie w drzewie AVL

Rodzaj przeciążenia Gdy syn zrównoważony Gdy syn przeciążony w lewo Gdy syn przeciążony w prawo

w lewo pojedyncza pojedyncza podwójna


w prawo pojedyncza podwójna pojedyncza

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

Zaczynamy od (pojedynczej) rotacji węzła L w prawo, po czym obracamy (pojedynczo)


węzeł I w lewo, jak pokazano na rysunku 10.16.

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.

Inną odmianą drzew „samowyważających" są drzewa czerwono-czarne (red-black trees).


Nie będziemy się nimi tu zajmować, a zainteresowanych Czytelników odsyłamy do książki
Introduction to Algorithms [Cormen, 2001].

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.

spróbuj sam Tworzenie testów dla klasy Node


Oto pierwsze testy:

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

package com.wrox.algori thms.bstrees;

import junit.framework.TestCase;

public class NodeTest extends TestCase {


private Node _a;
private Node _d;
private Node _f:
private Node _h;
private Node _1:
private Node _k;
private Node _1:
private Node _m;
private Node _p;

protected void setUpO throws Exception {


super.setUp();

_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

Node h = new NodeCH")


Node k = new NodeCK")
Node p = new NodeCP")
Node f = new NodeCF". nuli. h):
Node m = new NodeCH", nuli. P):
Node d - new NodeCD". a. f)
Node 1 = new NodeCL". k, m)
Node i = new NodeCI". d. 1)

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;

protected void setUpO throws Exception {


super.setUp();

_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

_1 = new NodeOL", _k, _m);


i « new NodeCI". _d. _1):
}
}
Zakładamy istnienie w klasie Node metod minimumO i maximum() zwracających (odpowied-
nio) węzeł-minimum i węzeł-maksimum drzewa, którego korzeniem jest wskazany węzeł.
Ułatwia to znacznie testowanie drzewa, bowiem łatwo można określić a priori minimum i mak-
simum dla każdego z węzłów pełniących rolę korzenia poddrzewa:
public void testMinimumO {
assertSame(_a, _a.minimumO);
assertSame(_a. _d.minimumO);
assertSame(_f. _f.minimumO);
assertSame(_h, _h.minimumO);
assertSame(_a, _i .minimumO);
assertSame(_k. _k.minimumO);
assertSame(_k. _1 .minimumO);
assertSame(_m. _m.minimumO);
assertSame(_p. _p.minimumO);
}
public void testMaximumO {
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.maximum());
assertSame(_p, _m.maximum());
assertSame(_p. _p.maximum());
}
W podobny sposób operacje znajdowania następnika i poprzednika danego węzła powie-
rzyliśmy klasie Node, zamiast uczynienia ich metodami „użytkowymi" klasy BinarySearch-
Tree.

I tak metody testSuccessorO i testPrecedessorO wykorzystują oczywiste zależności


między węzłami z rysunku 10.1: „D" jest następnikiem „A", „F" jest następnikiem „D" itd.
Zwróćmy uwagę, że węzeł A nie posiada poprzednika, a węzeł P — następnika, więc wy-
nikiem wywołań _p. successor() i _a ,precedessor() powinna być wartość pusta (nuli).
public void testSuccessorO {
assertSame(_d. _a.successor());
assertSame(_f, _d.successor());
assertSame(_h. _f.successorO);
assertSame(_i, _h.successorO);
assertSame(_k. _i .successorO);
assertSame(_l, _k.successorO);
assertSame(_m. _1 .successorO);
assertSame(_p, _m.successorO);
assertNul1(_p.successor());
}
280 Algorytmy. Od podstaw

public void testPredecessorO {


assertNul 1 (_a.predecessor());
assertSame(_a. _d.predecessor()):
assertSame(_d, _f.predecessor());
assertSameM, _h.predecessor());
assertSame(_h, _1 .predecessorO);
assertSameM . _k. predecessor()):
assertSame(_k. _1.predecessor());
assertSame(_l. _m.predecessor());
assertSame(_m, _p.predecessor());
}
Metody testlsSmallerC) i testIsLarger() testują relacje „mniejszy syn" i „większy syn"
rozumiane następująco: węzeł jest „mniejszym synem", jeśli jest lewym synem swego ojca,
i analogicznie — węzeł jest „większym synem", jeśli jest prawym synem swego ojca3.
public void testlsSmallerC) {
assertTrue(_a.isSmal 1 er());
assertTrue(_d. isSmal lerO);
assertFalse(_f.i sSmal 1er());
assertFalse(_h.i sSmal 1 er());
assertFalseM .isSmallerO);
assertTrue(_k.i sSmallerC));
assertFalseM .isSmallerO):
assertFalse(_m.isSmaller());
assertFalse(_p.isSmallert)):
}
public void testlsLargerO {
assertFal se(_a. i sLargerO);
assertFalse(_d.i sLarger()):
assertTrueM. isLarger()):
assertTrue(_h.isLarger());
assertFalseM .isLargerO);
assertFalse(_k.i sLargert));
assertTrueM .isLargerO):
assertTrue(_m. i sLargerO);
assertTrue(_p. i sLargerO):
}
Ostatnia z testowanych metod klasy Node — equals() — j e s t niezbędna do identyfikacji
węzłów w operacjach ich wyszukiwania, wstawiania i usuwania implementowanych przez
klasę BinarySearchTree. Implementacja każdej z tych operacji rozpoczyna działanie od bie-
żącego węzła, po czym rozpoczyna się wędrówka w głąb drzewa.

Aby przetestować prawidłowość działania metody equals(), w metodzie testEquals()


tworzymy replikę drzewa z rysunku 10.1, po czym porównujemy każdy z węzłów orygi-
nalnych z jego odpowiednikiem w replice. Dwa końcowe testy dają natomiast pewność, że
metoda equal s() nie jest zaprogramowana tak, by permanentnie zwracać wartość true, nie-
zależnie od argumentów wywołania!
public void testEquals() {
Node a - new NodeOA");
Node h = new NodeOH");

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

Node k = new NodeCK");


Node p = new NodeCP");
Node f = new NodeCF", nuli, h);
Node m = new NodeCM", nuli, p);
Node d = new NodeCD", a, f);
Node 1 = new NodeCL", k, m);
Node i = new NodeCI", d. 1);

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.

spróbuj sam Implementowanie klasy Node


Definicja klasy Node przedstawia się następująco.
package com.wrox.algorithms.bstrees;

public class Node implements Cloneable {


/** Wartość reprezentowana przez węzeł */
private Object _value:

/** Węzeł-ojciec lub wartość pusta */


private Node _parent:

/** Lewy syn ("mniejszy" węzeł) lub wartość pusta */


private Node _smaller;

/** Prawy syn ("większy" węzeł) lub wartość pusta */


private Node Jarger;
/**

* Konstruktor - tworzy węzeł-liść


* Parametr: wartość reprezentowana przez węzeł
*/
public Node(Object value) {
this(value, nuli. nuli);
}
/ * *

* Konstruktor. Tworzy węzeł pośredni


* Parametry:
* - wartość reprezentowana przez węzeł
* - lewy syn
282 Algorytmy. Od podstaw

* - 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);
}
}
/ * *

* Pobranie wartości reprezentowanej przez węzeł


*/
public Object getValue() {
return _value;
}
/ * *

* Zmiana wartości reprezentowanej przez węzeł


* Parametr - nowa wartość
*/
public void setValue(Object value) {
assert value != nuli : "podano pustą wartość";
_value = value;
}
/ * *

* Węzeł-ojciec lub wartość pusta


*/
public Node getParentO {
return _parent;
}
/ * *

* Przypisanie węzła-ojca
* Parametr; węzeł-ojciec lub wartość pusta
*/
public void setParent(Node parent) {
_parent - parent;
}
/ * *

* Lewy syn lub wartość pusta


*/
public Node getSmallerO {
return _smaller;
}
/**

* Przypisanie lewego syna


* Parametr: lewy syn lub wartość pusta
*/
Rozdział 10. • Binarne drzewa wyszukiwawcze 283

public void setSmaller(Node smaller) {


assert smaller != getLargerO : "lewy syn nie może być tożsamy z prawym":
_smaller = smaller:
}
/ * *

* Prawy syn lub wartość pusta


*/
public Node getLargerO {
return _larger;
}

* Przypisanie prawego syna


* Parametr: prawy syn lub wartość pusta
*/
public void setLarger(Node larger) {
assert larger != getSmallerO : "prawy syn nie może być tożsamy z lewym";
J a r g e r = larger;
}

* Sprawdzenie, czy węzeł jest lewym synem swego ojca


*

*/
public boolean isSmallerO {
return getParentO !- nuli && this — getParentO .getSmallerO;
}

* Sprawdzenie, czy węzeł jest prawym synem swego ojca


*

*/

public boolean isLargerO {


return getParentO !- nuli && this == getParent(),getLarger():
}
/**

* Poszukiwanie węzła-minimum w drzewie o określonym korzeniu


*/
public Node minimumO {
Node node - this:

while (node.getSmaller() != nuli) {


node - node.getSmallerO:
}
return node;
}

* Poszukiwanie węzła-maksimum w drzewie o określonym korzeniu


*/
public Node maximumO {
284 Algorytmy. Od podstaw

Node node = this:

while (node.getLargerO !- nuli) {


node = node.getLargerO;
}
return node;
}
/ * *

* Poszukiwanie następnika
*/
public Node successorO {
if (getLargerO != nuli) {
return getLargerO.minimumO;
}
Node node = this;

while (node. i sLargerO) {


node = node.getParentO;
}
return node.getParentO;
}
/ * *

* Poszukiwanie poprzednika
*/

public Node predecessor() {


if (getSmaller() !- nuli) {
return getSmaller().maximum();
}
Node node = this;

while (node.isSmallerO) {
node = node.getParentO;
}
return node.getParentO;
}

* Liczba węzłów drzewa o danym korzeniu


*/
public int sizeO {
return size(this);
}
/**

* Wysokość drzewa o danym korzeniu


*/
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 285

public int heightO {


return height(this) - 1;

public boolean equals(0bject object)


if (this == object) {
return true:

if (object == nuli || object.getClassO != getClassO) {


return false:

Node other = (Node) object:

return getValue().equals(other.getValue())
&& equa1sSmal1 er(other.getSma11 er())
&& equalsLarger(other.getLargerO);

/ * *

* Rekurencyjne obliczanie liczby węzłów w drzewie o danym korzeniu


* Parametr: korzeń drzewa
*/
private int size(Node node) {
if (node — nuli) {
return 0:

return 1 + size( node. getSmallerO) + size(node.getLargerO);

private boolean equalsSmaller(Node other) {


return getSmallerO == nuli
&& other == nuli || getSmallerO != nuli
&& getSmallerO,equals(other):

private boolean equalsLarger(Node other) {


return getLargerO — nuli
&& other — nuli || getLargerO != nuli
&& getLargerO ,equals(other):

J a k to działa?

W każdym węźle — będącym instancją klasy Node — zapamiętywana jest reprezentowana


przez niego wartość oraz trzy łączniki — do ojca, lewego syna i prawego syna.
public class Node {
/** Wartość reprezentowana przez węzeł */
private Object _value;

/** Węzeł-ojciec lub wartość pusta */


286 Algorytmy. Od podstaw

private Node _parent:

/** Lewy syn ("mniejszy" węzeł) lub wartość pusta */


private Node _smaller;

/** Prawy syn ("większy" węzeł) lub wartość pusta */


private Node J a r g e r :

}
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;
}

public void setParent(Node parent) {


Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 287

_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;
}

public Node getLargerO {


return _larger;
}

public void setLargertNode larger) {


assert larger != getSmallerO : "prawy syn nie może być tożsamy z lewym";
_larger « larger;
)
W dalszej kolejności definiujemy klika metod z myślą przede wszystkim o wygodzie pro-
gramowania. Metody te udostępniają informacje o rozmaitych cechach danego węzła.

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;

while (node.getSmallerO != nuli) {


node = node.getSmal lerO:
}
return node;

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:

while (node.getLargerO !=null) {


node = node.getLargerO:
}
return node:
}
Jeszcze bardziej skomplikowane jest znajdowanie następnika i poprzednika danego węzła.
Jak pamiętamy, następnikiem jest bądź to węzeł-minimum prawego syna (jeśli ten w ogóle
istnieje) bądź też węzeł napotkany przy pierwszym „skręcie w prawo" podczas poruszania
się w górę drzewa. Z owym skrętem w prawo mamy do czynienia w momencie, gdy poru-
szając się w kierunku korzenia, napotkamy węzeł będący lewym synem.

Poszukując następnika w metodzie successorO, rozpoczynamy więc od sprawdzenia, czy


odnośny węzeł posiada prawego syna (getLargerO != nuli)) i jeśli tak, zwracamy jego
węzeł-minimum. W przeciwnym razie poruszamy się wzdłuż ścieżki wyznaczonej przez łą-
cza do węzłów ojców tak długo, aż natrafimy na węzeł będący lewym synem albo na ko-
rzeń. W pierwszym przypadku szukanym następnikiem jest ojciec wspomnianego lewego
syna, w drugim przypadku szukany następnik nie istnieje — odnośny węzeł jest węzłem-
maksimum.

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:
}

public Node predecessorO {


if (getSmallerO != nuli) {
return getSmallerO.maximum():
}
Node node = this:

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.

Testowanie klasy BinarySearchTree


Prawidłowość implementacji binarnego drzewa wyszukiwawczego weryfikowana będzie za
pomocą następującej klasy testowej.
package com.wrox.a1gori thms.bstrees;

import com.wrox.algori thms.sorti ng.NaturalComparator:


import junit.framework.TestCase;

public class BinarySearchTreeTest extends TestCase {


private Node _a;
290 Algorytmy. Od podstaw

private Node _d;


private Node _f;
private Node _h;
private Node _i;
private Node _k;
private Node _1;
private Node _m;
private Node _p:
private Node _root:
private BinarySearchTree _tree;

protected void setUpO throws Exception {


super. setUpO;

_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;

_tree = new BinarySearchTree(NaturalComparator.INSTANCE);


_tree.insert(_i ,getValueO);
_tree.insert(_d.getValue());
_tree.insert(_l,getValue());
_tree.insert(_a.getValue());
_tree.insert(_f,getValue());
_tree.insert(_k,getValue());
_tree.i nsert(_m.getValue());
_tree.i nsert(_h.getVa1ue());
_tree.i nsert(_p.getValue());
}
public void testlnsertO {
assertEquals(_root, _tree.getRoot());
}
public void testSearchO {
assertEquals(_a, _tree.search(_a.getValue()));
assertEqua1s(_d, _tree.search(_d.getVa1ueC))):
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()));
assertEqua1s(_p, _tree.search(_p.getValue())):

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()):

public void testDeleteRootNodeUntilTreelsEmptyO {


while (_tree.getRootO != nuli) {
Object key = _tree.getRootO,getValue();
Node deleted = _tree.delete(key):
assertNotNull(deleted);
assertEqua1s(key. deleted.getVa1ue());
}
}
i

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.

W inicjacyjnej metodzie setUp() definiowanych jest — dla celów porównawczych — kilka


węzłów; wartości tych węzłów są następnie wstawiane do nowo utworzonego drzewa bi-
narnego w kolejności specyficznej, dalekiej od uporządkowania. Ma to zapobiec degenera-
cji drzewa, być może nawet do postaci przedstawionej na rysunku 10.7; oczekujemy raczej
drzewa wyważonego, dlatego tak dobraliśmy kolejność wstawiania poszczególnych warto-
ści, by otrzymać drzewo widoczne na rysunku 10.1. Uważny Czytelnik z pewnością spo-
strzeże, że kolejność ta jest identyczna z kolejności ^pre-order trawersacji drzewa — drzewo
rozrasta się równomiernie od korzenia w głąb, węzeł-ojciec zostaje wstawiony przed wsta-
wieniem jego synów.
292 Algorytmy. Od podstaw

package com.wrox.algori thms.bstrees;

i mport com.wrox.algori thms.sorti ng.NaturalComparator;


import junit.framework.TestCase;

public class BinarySearchTreeTest 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:
private Node _root:
private BinarySearchTree _tree;

protected void setUpO throws Exception {


super.setUpO:

_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;

_tree = new BinarySearchTree(NaturalComparator.INSTANCE);


_tree.insert(_i,getValue());
_tree.i nsert(_d.getValue()):
_tree.insert(_1,getValue());
_tree.i nsert(_a.getValue());
_tree.i nsert(_f.getValue()):
_tree.i nsert(_k. getVa 1 u e O ) ;
_tree.i nsert(_m.getValue());
_tree.i nsert(_h.getValue());
_tree.i nsert(_p.getValue()):
}
Skoro zbudowaliśmy już testowane drzewo, trzeba teraz zweryfikować jest strukturę z ocze-
kiwaną, przedstawioną na rysunku 10.1.

W metodzie t e s t l n s e r t O zakładamy, że w klasie BinarySearchTree zdefiniowana jest


metoda getRootO zwracająca węzeł-korzeń drzewa reprezentowanego przez instancję tej
klasy. Wykorzystując metodę equals() klasy Node, możemy dzięki temu weryfikować po
każdym wstawieniu węzła (za pomocą metody insert()), czy zmienna _root, która powinna
wskazywać na korzeń drzewa, wskazuje nań istotnie.
public void testlnsertO {
assertEquals(_root. _tree.getRoott));
}
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 293

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.

Testując przypadek najprostszy — usuwanie liścia — weryfikujemy prawidłowość usuwania


węzła H, jak na rysunku 10.8. Metoda testDel eteLeafNodeO najpierw usuwa dany węzeł
z drzewa i zapamiętuje jego wartość, po czym sprawdza, czy nie jest to wartość pusta i czy
jest ona zgodna ze znaną a priori wartością węzła porównawczego _h. Ponadto, jako że wę-
zeł H był synem węzła F, należy zerwać relację ojciec-syn między węzłami porównaw-
czymi _f i _h — węzły porównawcze funkcjonują bowiem niezależnie od klasy BinarySe-
archTree.
public void testDel eteLeafNodeO {
Node deleted - _tree.delete(_h.getValue()):
assertNotNull(deleted):
assertEqual s(_h. getVa 1 u e O . del eted. getVal ue());

_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

Przypadkiem najbardziej skomplikowanym jest usuwanie węzła posiadającego dwóch sy-


nów — użyjemy w tym celu węzła I, którego usuwanie przedstawiono na rysunkach 10.10
i 10.11. Metoda testDel eteNodeWithTwoChil dren() po usunięciu węzła z drzewa weryfikuje
poprawność jego struktury i aktualizuje odpowiednio węzły porównawcze.
public void testDeleteNodeWithTwoChildrenO {
Node deleted = _tree.delete(_i,getValue()):
assertNotNul1(deleted);
assertEquals(_i,getValue(), deleted.getValue());

_i.setValue(_k.getValue()):
_l .setSmaller(null):
assertEquals(_root, _tree.getRoot());
}
Dysponując narzędziami weryfikacji klasy drzewa binarnego, zajmijmy się jej implementacją.

niimiiiB Implementowanie klasy BinarySearchTree


Oto definicja klasy implementującej binarne drzewo wyszukiwawcze:
package com.wrox.algorithms.bstrees:

i mport com.wrox.a1gori thms.sorti ng.Compa rator;


/**

* Binarne drzewo wyszukiwawcze bez mechanizmów równoważenia


*

*/
public class BinarySearchTree {
/** Komparator wyznaczający porządek wartości reprezentowanych przez węzły */
private finał Comparator _comparator:

/** Wskazanie na korzeń lub wartość pusta dla pustego drzewa */


private Node _root;
/**

* 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";

Node node = _root;

while (node != nul 1) {


Rozdział 10. • Binarne drzewa wyszukiwawcze 295

int cmp - _comparator.compare(value, node.getValue());


if (cmp == 0) {
break;
}
node = cmp < 0 ? node.getSmal lerO : node.getLargerO:
}
return node;

* Wstawianie węzła do drzewa


*

* Parametr: wartość do wstawienia


* Wynik: węzeł reprezentujący wartość
*/
public Node insert(0bject value) {
Node pa rent = nuli;
Node node = _root;
int cmp = 0;

while (node != nuli) {


parent = node;
cmp = _comparator.compare(value, node.getValue());
node = cmp <- 0 ? node. getSmal 1 er O : node.getLargerO;
}
Node inserted = new Node(value):
inserted.setParent(parent):

if (parent == nuli) {
_root = inserted;
} else if (cmp < 0) {
parent.setSmaller(inserted);
} else {
parent.setLarger(inserted):
}
return inserted;
}

* Usuwanie wartości z drzewa


* Parametr: wartość do usunięcia
* Wynik: usunięty węzeł lub wartość pusta
*/
public Node delete(Object value) {
Node node = search(value):
if (node == nuli) {
return nuli:
}
Node deleted =
node. getSmal lerO != nuli && node.getLargerO !- nuli
? node.successorO : node;
assert deleted != nuli : "podano pustą wartość";
296 Algorytmy. Od podstaw

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:
}

* Zwraca korzeń drzewa


* Wynik: korzeń drzewa lub wartość pusta
*/
public Node getRootO {
return root;

J a k to działa?

Ponieważ funkcjonowanie binarnego drzewa wyszukiwawczego nierozłącznie związane


jest z porównywaniem elementów, implementująca je klasa funkcjonować musi w oparciu
o komparator ustalający kryteria tego porównywania. Komparator ten reprezentowany jest
przez zmienną prywatną _comparator klasy BinarySearchTree. Inna zmienna prywatna —
_root — przechowuje wskazanie na korzeń drzewa lub wskazanie puste, gdy drzewo nie
zawiera ani jednego węzła. Wartość tej zmiennej dostępna jest na zewnątrz klasy za po-
średnictwem metody getRootO. Zwróćmy uwagę, że klasa BinarySearchTree nie imple-
mentuje żadnego interfejsu, nie jest też wyprowadzana z żadnej klasy bazowej, nie jest bowiem
tak naprawdę przeznaczona do praktycznego wykorzystywania w postaci tu prezentowanej
(w przeciwieństwie do bardziej „praktycznych" klas, jak te opisywane w rozdziałach 12. i 13.).
package com.wrox.a1gori thms.bstrees:

i mport com.wrox.a1gori thms.sort i ng.Comparator;

public class BinarySearchTree {


/** Komparator wyznaczający porządek wartości reprezentowanych przez węzły */
private finał Comparator _comparator;

/** Wskazanie na korzeń lub wartość pusta dla pustego drzewa */


Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 297

private Node _root;

* 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":

Node node = _root;

while (node != nuli) {


int cmp = _comparator.compare(value, node.getValue()):
if (cmp == 0) {
break;
}
node = cmp < 0 ? node.getSmallert) : node.getLargerO;
}
return node;
}
Metoda insert() rozpoczyna pracę od znalezienia węzła, który powinien stać się ojcem dla
nowo utworzonego węzła reprezentującego szukaną wartość. Gdy zakończy się pętla whi le,
zmienna parent może zawierać wartość pustą lub wskazywać na pewien węzeł. W pierw-
szym przypadku drzewo jest po prostu puste i nowo utworzony węzeł będzie jego jedynym
węzłem (i jednocześnie korzeniem). W drugim przypadku nowo utworzony węzeł powinien
stać się synem węzła wskazywanego przez parent — synem lewym albo prawym, zależnie
od wyniku ostatniego porównania wciąż przechowywanego w zmiennej cmp.

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

większą). Po chwili zastanowienia można wysnuć z tego wniosek, że wielokrotne dodawanie


tej samej wartości przyczynia się do postępującej degeneracji drzewa.
public Node insert(Object value) {
Node parent = nuli:
Node node = _root;
int cmp = 0:

while (node != nuli) {


parent = node:
cmp = _comparator.compare(value. node.getValueO):
node = cmp <= 0 ? node.getSmaller() : node.getLargerO;
}
Node inserted = new Node(value):
i nserted.setParent(parent):

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.

Mając już jasność co do węzła faktycznie podlegającego usunięciu, musimy ewentualnie


znaleźć dla niego węzeł zastępczy. Gdy usuwany jest liść, problem ten nie istnieje. W prze-
ciwnym razie usuwany węzeł posiada co najmniej jednego syna; zmieniamy wówczas połą-
czenia w drzewie tak, by dotychczasowy dziadek tego syna stał się jego ojcem (gdy synów
jest dwóch, można wybrać któregokolwiek z nich). Jeśli jednak usuwany węzeł jest korze-
niem drzewa, to dowolny z jego synów staje się nowym korzeniem.

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.getSmallerO != nuli && node.getLargerO !- nuli


? node.successorO : node:
assert deleted != nuli : "podano pusta wartość";

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:
}

Ocena efektywności binarnego


drzewa wyszukiwawczego
Dotychczas mówiliśmy jedynie ogólnie o efektywności, jaką cechują się binarne drzewa
wyszukiwawcze, zaś obecnie pokusimy się o empiryczną ocenę tej efektywności. W tym
celu skonstruujemy kilka testów umożliwiających zliczanie porównań wykonywanych
w związku ze wstawianiem węzłów do drzewa. Porównamy także efektywność wstawiania
wartości generowanych losowo ze wstawianiem wartości następujących w kolejności zgodnej
z komparatorem.

spróbuj sam Implementowanie klasy


mierzącej efektywność binarnego drzewa wyszukiwawczego
Opisana w tytule klasa ma definicję następującą:
package com.wrox.a1gori thms.bst rees;

i mport com.wrox.a1gori thms.1 i sts.ArrayLi st;


import com.wrox.algorithms.lists.List;
import com.wrox.algori thms.sorti ng.CaliCounti ngComparator;
import com.wrox.algorithms.sorti ng.NaturalComparator;
300 Algorytmy. Od podstaw

import junit.framework.TestCase;

public class BinarySearchTreeCalICountingTest extends TestCase {


private static finał int TEST_SIZE - 1000:

private CallCountingComparator _comparator;


private BinarySearchTree _tree;

protected void setUpO throws Exception {


super.setUp();

_comparator = new CallCountingComparator(NaturalComparator. INSTANCE):


_tree = new BinarySearchTree(_comparator);
}
public void testRandomInsertion() {
for (int i = 0; i < TESTJIZE: ++1) {
_tree.insert(new Integer((int) (Math.randomO * TESTJIZE)));
}
reportCallsO;
}
public void testlnOrderlnsertionO {
for (int i - 0; i < TESTJIZE; ++i) {
_tree.inserttnew Integer(i)):
}
reportCal I s O ;
}
public void testPreOrderlnsertionO {
List list = new ArrayList (TESTJIZE):

for (int i - 0; i < TESTJIZE: ++i) {


list.add(new Integer(i));
}
preOrderlnsertdist, 0. list.sizeO - 1):

reportCallsO:
}
private void preOrderInsert(List list, int lowerIndex. int upperlndex) {
if (lowerIndex > upperlndex) {
return;
}
int index = lowerIndex + (upperlndex - lowerIndex) / 2:

_tree.i nsert(1 i st.get(i ndex));


preOrderlnsertdist, lowerIndex, index - 1);
preOrderlnsertdist, index + 1, upperlndex):
}
private void reportCallsO {
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 301

System.out.println(getName() + ": " + _comparator.getCallCount() + "


wywołań");
}
j

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.

Klasa testowa BinarySearchTreeCallCountingTest definiuje drzewo binarne, na którym prze-


prowadzane będą pomiary, komparator umożliwiający zliczanie porównań oraz stałą TEST_
SIZE oznaczającą liczbę wstawianych elementów. Wyniki eksperymentu wyświetlane są za
pomocą metody reportCal 1 s() w postaci:
<nazwa testu>: <liczba porównań> "wywołań"

package com.wrox.a 1gori thms.bstrees;

i mport com.wrox.a 1gori thms.1 i sts.ArrayLi st;


i mport com.wrox.a1gori thms.1 i sts.Li st:
i mport com.wrox.a1gori thms.sorti ng.Ca11Counti ngComparator;
import com.wrox.algorithms.sorti ng.NaturalComparator;
import junit.framework.TestCase;

public class BinarySearchTreeCa11CountingTest extends TestCase { '


private static finał int TESTJIZE = 1000;

private CaliCountingComparator _comparator:


private BinarySearchTree _tree;

protected void setUpO throws Exception {


super.setUpO:

_comparator - new CallCountingComparator(NaturalComparator.INSTANCE);


_tree = new BinarySearchTree(_comparator):
}
private void reportCallsO {
System.out.printlntgetNameO + ": " + _comparator.getCallCount() + "
wywołań");
}
}
W metodzie testowej testRandomInsertion() generowane losowo elementy (o wartościach
z przedziału 0 + TEST_SIZE-1) w ilości TEST_SIZE wstawiane są do drzewa, które — przy-
najmniej w zamierzeniu — ma być drzewem bliskim wyważenia.
public void testRandomInsertion() {
for (int i - 0: i < TEST_SIZE; ++i) {
_tree.insert(new Integer((int) (Math.randomO * TEST_SIZE)));
}
reportCallsO;
}
302 A l g o r y t m y . Od podstaw

W metodzie t e s t l n 0 r d e r l n s e r t i o n ( ) ta sama liczba elementów wstawiana jest do drzewa


w kolejności rosnącej (są to po prostu kolejne liczby całkowite od 0 do TEST_S I ZE -1), co
niechybnie powoduje postępującą utratę wyważenia drzewa.
public void testln0rderlnsertion() {
for (int i - 0: i < TESTJIZE; ++i) {
_tree.insert(new Integer(i));
}
reportCallsO:
}
Rezultaty przeprowadzonych testów zestawione są w tabeli 10.2 (wyniki kolejnych testów
mogą się różnić ze względu na losowe wartości elementów).

Tabela 10,2. Porównanie efektywności wstawiania 1 OOO elementów do binarnego


drzewa wyszukiwawczego

Kolejność wstawiania Liczba porównań

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.

• Średni czas wyszukiwania wartości w drzewie binarnym jest proporcjonalny


do jego wysokości (O(h)).
• Wysokość skrajnie niewyważonego drzewa binarnego o TV węzłach może wynosić 0(N).
Rozdział 10. • Binarne d r z e w a w y s z u k i w a w c z e 303

• Wstawianie i usuwanie wartości w kolejności losowej powoduje pozostawanie


drzewa w stanie wyważonym lub zbliżonym do wyważonego.
• Wstawianie i usuwanie wartości w kolejności uporządkowanej przyczynia się
do postępującej utraty wyważenia.
• Najprostszą techniką kontrolowania wyważenia drzewa binarnego jest utrzymywanie
na minimalnym poziomie różnicy między wysokościami lewego i prawego
poddrzewa.

Ć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

Prawdopodobieństwo występowania kolizji można zmniejszyć, zwiększając rozmiar tablicy


haszowanej: gdy zapamiętywaliśmy nasze łańcuchy w tablicy 70-elementowej, o kolizjach
nie było nawet mowy, lecz prawie 96% pojemności tablicy pozostało niewykorzystane.
W istocie większość algorytmów haszowania opiera się na nieuchronnym kompromisie mię-
dzy efektywnością wyszukiwania informacji a efektywnym gospodarowaniem pamięcią.

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ć.

Bywa i tak, że z powodu niefortunnie wybranej funkcji haszującej zwiększanie rozmiaru


tablicy haszowanej nic a nic nie pomaga. Jeżeli haszowanie łańcucha odbywa się (jak w na-
szym przykładzie) przez sumowanie kodów jego liter (być może modulo rozmiar tablicy, to
bez znaczenia) to wszystkie anagramy (czyli łańcuchy różniące się tylko kolejnością liter)
dawać będą dokładnie ten sam wynik, niezależnie od rozmiaru docelowej tablicy. Wynika
stąd prosty wniosek, że rozsądne haszowanie łańcucha powinno być wrażliwe przynajmniej
na kolejność jego liter.

Przykładem prostego, lecz efektywnego algorytmu haszowania jest algorytm wykorzysty-


wany przez klasę String w pakiecie JDK. Algorytm ten opiera się na złożonych zależno-
ściach matematycznych, których rozważać tu nie będziemy, jego implementacja nie jest
jednak zbyt trudna do zrozumienia.
Jednym z bardziej znanych algorytmów haszowania, mających genezę ściśle
teoretyczną, jest tzw. cykliczny kod nadmiarowy, oznaczany akronimem CRC (Cyclic
Redundancy Code). Wiele aplikacji przesyłających przez sieć skompresowane dane
wykorzystuje ów kod do weryfikacji integralności przesyłanych danych. Dla danego
strumienia danych algorytm CRC oblicza liczbę całkowitą, a jedną z najważniejszych
cech tego „ obliczania" jest zależność generowanego wyniku od kolejności
występowania poszczególnych znaków, dzięki czemu anagramy takie jak ,,ELVIS"
i „LIYES" prawie na pewno dadzą inny wynik haszowania („prawie", bowiem ani
CRC, ani żaden inny algorytm nie jest doskonały i nie może wykluczyć kolizji,
choć może minimalizować prawdopodobieństwo ich występowania).

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

W odniesieniu do prezentowanych wcześniej łańcuchów wynik haszowania za pomocą tego


algorytmu przedstawia się następująco:
"ELVIS" = (((5*31 + 12)*31 + 22)*31 + 9)*31 + 19 = 4 996 537
"MADONNA" = (((((13*31 + 1)*31 + 4)*31 + 15)*31 + 14)*31 + 14)*31 + 1 =
11 570 331 842
"STING" = (((19*31 + 20 )*31 + 9)*31 + 14)*31 + 7 = 18 151 809
"LIVES" = (((12*31 + 9)*31 + 22)*31 + 5)*31+ 19 = 11 371 587

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.

Jeżeli spróbujemy dodać do tablicy łańcuch „FRED", otrzymamy:


"FRED" = (((6*31 + 18)*31 + 5)*31 + 4) % 11 - 196203 % 11 = 7

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"

3 MADONNA 3 MADONNA 3 MADONNA

4 4 4

5 STING 5 5

6 6 6

7 ELViS 7 ELVIS 7 ELVIS

8 LIVES 8 LIVES 8 LIVES

9 9 9 FRED

10 10 10

Przeglądanie pozycji rozpoczyna się od indeksu 7 — oryginalnie wyliczonego w wyniku


haszowania. Pozycja o tym indeksie jest już zajęta, sprawdzamy więc następną — ta rów-
nież; wolna okazuje się pozycja o indeksie 9 i w niej zapisujemy łańcuch „FRED".

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

Rysunek 11.2. 0 0 TIM


0
Próbkowanie
liniowe w związku
z dodawaniem 1 1 1
łańcucha „TIM"
wymaga „zawrócenia" 2 2 2
poszukiwań na
początek tablicy 3 MADONNA 3 MADONNA 3 MADONNA

4 4 4

5 STING S 5

6 6 6

7 ELVIS 7 ELVIS 7 ELV!S

8 LIVES 8 LIVES 8 LIVES

9 FRED 9 FRED 9 FRED

10 MARY 10 MARY 10 MARY

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.

Nie zapominajmy, że wszystko to wymaga pogodzenia ze sobą dwóch przeciwstawnych ra-


cji: efektywności działania i efektywnego gospodarowania pamięcią. Zbyt mała wartość
progowa stopnia zapełnienia oznacza marnotrawstwo pamięci, zbyt duża — dużą liczbę
kolizji i ogólnie niewielką efektywność działania. Rozsądną wartością wspomnianego progu,
po przekroczeniu którego należy wykonać reorganizację tablicy, wydaje się być 75%.

Praktyczna realizacja haszowania


Po omówieniu podstaw haszowania przejdźmy teraz do jego praktycznej prezentacji. Roz-
poczniemy od skonstruowania zestawów testowych dla tablicy haszowanej, po czym przed-
stawimy dwie implementacje takiej tablicy oparte na próbkowaniu liniowym i porcjowaniu.
Na zakończenie porównamy efektywność tych implementacji.

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

spróbuj sam Definiowanie interfejsu tablicy baszowanej


Funkcjonalność dowolnej tablicy haszowanej opisana jest przez następujący interfejs:
package com.wrox.algorithms.hashing;

public interface Hashtable {


public void add(Object value);
public boolean contains(Object value);
public int sizeO:
J

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.

Testowanie tablicy haszowanej


Przed stworzeniem implementacji tablic haszowanych skonstruujemy zestawy testowe we-
ryfikujące poprawność tych implementacji. Ponieważ zewnętrzne przejawy funkcjonowa-
nia tablicy haszowanej są niezależne od jej implementacji, można więc ich testowanie za-
mknąć w ramy generycznej klasy bazowej zdefiniowanej następująco:
package com.wrox.algori thms.hashi ng:

import junit.framework.TestCase;

public abstract class AbstractHashtableTest extends TestCase {


private static finał int TESTJIZE - 1000:

private Hashtable Jashtable;


protected abstract Hashtable createTable(int capacity);

protected void setUpO throws Exception {


super.setUp();

Jashtable = createTable(TESTJIZE);

for (int i = 0: i < TESTJIZE; ++1) {


Jiashtable.addtString.valueOf(i)):
}
}
public void testContains() {
for (int i = 0; i < TESTJIZE; ++i) {
assertTrue( Jashtabl e. contai ns (Stri ng. va 1 ueOf (i))):
}
_J
Rozdział 11. • Haszowanie 313

public void testDoesntContainO {


for (int i = 0; i < TESTJIZE: ++1) {
assertFal se(_hashtabl e. contai ns(Stri ng. val ueOf (i + TESTJIZE)));
}
}
public void testAddingTheSameValuesDoesntChangeTheSize() {
assertEqual s(TESTJIZE. Jiashtable.si ze());

for (int i = 0; i < TESTJIZE; ++i) {


_hashtable.add(String.valueOf(i));
assertEqual s (TESTJIZE. _hashtable. size()):
}
}
}

J a k to działa?

Klasa AbstractHashtableTest definiuje pojedynczą zmienną _hashtable, reprezentującą


(zaimplementowaną) tablicę haszowaną podlegającą testowaniu. Instancja tej tablicy two-
rzona jest przez metodę createTable(), specyficzną dla konkretnej klasy implementacyjnej
(w klasie AbstractHashtabl eTest jest to metoda abstrakcyjna). Zwróćmy szczególną uwagę
na sposób dodawania wartości do tablicy haszowanej w treści metody setUpO. Gdybyśmy
dodawali do niej kolejne liczby całkowite, liczby te byłyby jednocześnie indeksami swych
docelowych pozycji 2 ; jakiekolwiek kolizje byłyby wykluczone, a sam test mało reprezen-
tatywny. Będziemy więc dodawać do tablicy nie same liczby, lecz ich znakowe reprezenta-
cje, te bowiem stanowić będą odpowiedni sprawdzian dla (wykorzystywanej w późniejszych
implementacjach) funkcji haszującej, którą będzie metoda hashCode() klasy String:
public abstract class AbstractHashtableTest extends TestCase {

private static finał int TESTJIZE = 1000;

private Hashtable _hashtable:

protected void setUpO throws Exception (


super.setUp();

_hashtable = createTable(TESTJIZE);

for (int i = 0: i < TESTJIZE: ++1) {


_hashtable.add(String.valueOf(i));
}
)
}
Najbardziej oczywistą rzeczą jaką można zrobić natychmiast po dodaniu wartości do tablicy
haszowanej, jest wywołanie metody containsO w celu sprawdzenia, czy wartość ta fak-
tycznie znajduje się w tablicy. Samo magazynowanie wartości bez możliwości dostępu do
nich wydaje się bowiem raczej mało sensowne...

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

public void testContains() {


for (int i = 0; i < TESTJIZE; ++1) {
assertTruet Jashtabl e. contains(Stri ng. val ueOf (i)));

Wykazywanie przez metodę containsO wartości faktycznie obecnych w tablicy to jeszcze


nie wszystko — metoda ta powinna także wykazywać brak wartości, których w tablicy rze-
czywiście nie ma:
public void testDoesntContain() {
for (int i = 0; i < TESTJIZE: ++1) {
assertFal se( Jashtabl e.contains( String. valueOf( i + TESTJIZE))):

W powyższym przykładzie badamy obecność w tablicy wartości z przedziału od TEST_SIZE


do 2*TEST_SIZE-1; żadnej z tych wartości w tablicy być nie może, bo dodawaliśmy do niej
wyłącznie wartości z przedziału od 0 do TEST_SIZE-1.

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);

for (int i - 0; i < TESTJIZE; ++1) {


Jashtable.add(String. valueOf(i)):
assertEquals(TESTJIZE. Jashtable. size());
}
}
Niezmienność wspomnianego licznika weryfikowana jest po dodaniu każdej z wartości.

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.

spróbuj sam Testowanie i implementowanie próbkowania liniowego


Rozpoczniemy oczywiście od stworzenia odpowiedniej klasy testowej:
package com.wrox.algori thms.hashi ng;

public class LinearProbingHashtableTest extends AbstractHashtableTest {


protected Hashtable createTable(int capacity) {
return new LinearProbingHashtable(capacity):
Rozdziału. • Haszowanie 315

po czym stworzymy implementację tablicy haszowanej:


package com.wrox.a 1gori thms.hashi ng:

public class LinearProbingHashtable implements Hashtable {


/** Magazyn wartości */
private Object[] _values;
/**

* 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();

int index = indexFor(value):

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;
}

* Wylicza indeks dla podanej wartości


*

* Parametr: wartość do zapamiętania


* Wynik: indeks pozycji dla wartości
*/
private int indexFor(Object value) {
int start = startingIndexFor(value);

int index = indexFor(value, start. _values.length):


if (index -« -1) {
index = indexFor(value. 0. start);
assert index — -1 : "tablica całkowicie zapełniona":
}
316 Algorytmy. Od podstaw

return index;
}
/**

* Wylicza indeks dla podanej wartości


*

* Parametry:
* - wartość do zapamiętania
* - początkowy indeks poszukiwania
* - końcowy indeks poszukiwania
* Wynik: indeks pozycji dla wartości
*/

private int indexFor(Object value, int start, int end) {


assert value != nuli : "podano pustą wartość";

for (int i = start; i < end; ++i) {


if (_values[i] == nuli || value.equa1s(_values[i])) {
return i;
}
}
return -1;
}

* Wylicza indeks dla istniejącej wartości


*

* Parametr: poszukiwana wartość


* Wynik: indeks szukanej wartości lub -1, gdy nieznaleziona
*/
private int indexOf(Object value) {
int start = startingIndexFor(value);

int index = indexOf(value, start, _values.length):


if (index == -1) {
index = indexOf(value. 0, start);
}
return index:
}
/**

* Wylicza indeks dla szukanej 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
*/

private int index0f(0bject value. int start, int end) {


assert value != nuli : "podano pustą wartość";

for (int i = start; i < end; ++i) {


if (value.equals(_values[i])) {
return i;
Rozdział 11. • Haszowanie 317

return -1:
}
/**

* Wylicza naturalny indeks dla podanej wartości, bazując na jej skrócie haszowym
*

* Parametr: wartość, dla której wyliczany jest indeks


* Wynik: indeks dla wartości
*/
private int startingIndexFor(Object value) {
assert value != nuli : "podano pustą wartość":
return Math.abs(value.hashCode() X _values.length);
}

* Zapewnienie miejsca w tablicy na co najmniej jeszcze jedną wartość


*/
private void ensureCapacityForOneMore() {
if (sizeO == _values.length) {
resizeO;
}
}

* reorganizacja tablicy
*/
private void resizeO {
LinearProbingHashtable copy = new LinearProbingHashtable(_values.length * 2):

for (int i = 0 ; i < _values.length; ++i) {


if (_values[i] != nuli) {
copy.add(_values[i]):
}
}
_values - copy,_values;
}
1

J a k to działa?

Test dla klasy implementującej próbkowanie liniowe (LinearProbingHashtableTest) wy-


prowadzony został z ogólnej klasy testowej dla tablic haszowanych (AbstractHashtableTest).
Abstrakcyjna metoda createTable() skonkretyzowana została w ten sposób, że tworzy i zwraca
instancję klasy LinearProbingHashtable reprezentującą tablicę haszowanąo początkowym
rozmiarze określonym przez parametr konstruktora.
public class LinearProbingHashtableTest extends AbstractHashtableTest {
protected Hashtable createTabletint capacity) {
return new LinearProbingHashtable(capacity):
}
}
318 Algorytmy. Od podstaw

Prostota próbkowania liniowego przekłada się bezpośrednio na prostotę kodu implementu-


jącego tablicę haszowaną opartą na tym próbkowaniu.

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;

public class LinearProbingHashtable implements Hashtable {


/** Magazyn wartości */
private Object[] _values;

public LinearProbingHashtable(int initialCapacity) {


assert initialCapacity > 0 : "rozmiar początkowy musi być dodatni":
_values = new Object[initialCapacity];
}
}
Skoro mowa o rozmiarze (pojemności) tablicy, to musi on być co jakiś czas zwiększany, by
tablica pomieścić mogła nowo dodawane wartości. Z tego względu należy uzyskać zapew-
nienie, że w tablicy istnieje miejsce na jeszcze co najmniej jedną wartość; zapewnienia tego
dostarcza metoda ensureCapacityForOneMore() wykonująca w razie potrzeby reorganizację
tablicy:
private void ensureCapacityForOneMore() {
if (sizeO == _values.length) {
resizeO:

Sama reorganizacja jest przedmiotem metody resizeO. Metoda ta dokonuje przenoszenia


kolejno wszystkich wartości z tablicy oryginalnej do dwukrotnie większej tablicy tymcza-
sowej. Następnie oryginalna tablica zastępowana jest przez wspomnianą tablicę tymczasową:
private void resizeO {
LinearProbingHashtable copy = new LinearProbingHashtable(_values.length * 2);

for (int i = 0: i < _values.length; ++1) {


if (_values[i] != nuli) {
copy.add(_values[i]);
}
}
_values - copy._values;
}
Metoda startingIndexFor() ma znaczenie centralne dla operacji na tablicy haszowanej.
Metoda ta otrzymuje jako parametr pewną wartość i zwraca indeks, który w wyniku ha-
szowania wyliczony został jako oryginalna („naturalna") pozycja dla tej wartości. Dokład-
niej, wartość zwracana przez metodę hashCodeO — metodę tę implementują wszystkie
obiekty w Javie — brana jest następnie modulo rozmiar tablicy, co gwarantuje, że wyliczona
wartość poza ten rozmiar nie wykroczy:
Rozdział 11. • Haszowanie 319

private int startingIndexFor(Object value) {


assert value != nuli : "podano pustą wartość";
return Math.abs(value.hashCode() % _values.length);
}
Do wyliczenia ostatecznej lokalizacji wartości w tablicy wykorzystywana jest metoda in-
dexFor() w dwóch wariantach (aspektach).

Pierwszy wariant rozpoczyna sprawdzanie pozycji tablicy od „naturalnie" wyliczonego in-


deksu i posuwa się w przód, a po ewentualnym osiągnięciu końca tablicy przechodzi do jej
pierwszej pozycji.
private int indexFor(Object value) {
int start = startingIndexFor(value);

int index = indexFor(value, start. _values.length);


if (index == -1) {
index = indexFor(value, 0. start):
assert index == -1 : "tablica całkowicie zapełniona";
}
return index;
}
Drugi wariant metody prowadzi analogiczne sprawdzanie w ustalonych granicach indeksu.
Zwróćmy uwagę na warunek zatrzymania poszukiwań dla nowej wartości: jest nim nie tyl-
ko znalezienie pozycji pustej (_values[i] == nuli), lecz także znalezienie wartości, która
ma zostać dodana (value.equals(_values[i])). Ta prosta sztuczka zapobiega wielokrot-
nemu zapamiętywaniu tej samej wartości na różnych pozycjach: jeśli dana wartość istnieje
już w tablicy, zostanie zapisana ponownie na tej samej pozycji.
private int indexFor(Object value, int start, int end) {
assert value != nuli : "podano pustą wartość":

for (int i = start: i < end; ++i) {


if (_values[i] — nuli || value.equals(_values[i])) (
return i;
}
}
return -1:
}
Implementacja metody add() okazuje się zgoła nieskomplikowana. Po upewnieniu się, że
w tablicy istnieje co najmniej jedna wolna pozycja, metoda zapisuje żądaną wartość w po-
zycji wyliczonej przez metodę indexFor(). Zwróćmy uwagę na to, że znalezienie dodawa-
nej wartości nie powoduje jej ponownego dodawania.
public void add(Object value) {
ensureCapacityForOneMoret):

int index = indexFor(value):

if (_values[index] == nuli) {
_values[index] = value:
}
}
320 Algorytmy. Od podstaw

Podobnie dwa warianty metody index0f() współpracują z obydwoma wariantami metody


indexFor() w celu znalezienia pozycji zawierającej żądana wartość.

W pierwszym wariancie metody poszukiwanie rozpoczyna się od pozycji określonej przez


metodę Start1ngIndexFor(); jeżeli mimo dojścia do końca tablicy żądana wartość nie zo-
stanie odnaleziona, poszukiwanie kontynuowane jest w nieprzeszukanym jeszcze począt-
kowym fragmencie tablicy.
private int index0f(Object value) {
int start = startingIndexFor(value);

int index = indexOf(value. start. _values.length);


if (index == -1) {
index = index0f(value. 0. start):
}
return index;
}
W drugim wariancie mamy do czynienia z bezpośrednim przeszukiwaniem wskazanego
zakresu tablicy.
private int indexOf(Object value, int start, int end) {
assert value != nuli : "podano pustą wartość";

for (int i = start; i < end; ++i) {


if (value.equals(_values[i])) {
return i;
}
}
return -1;
}
W świetle powyższego implementacja metody containsO nie wymaga już żadnych ko-
mentarzy:
public boolean contains(Object value) {
return indexOf(value) != -1;
}
Metoda sizeO (definiowana przez interfejs Hashtable) analizuje kolejne pozycje tablicy,
zliczając pozycje zajęte (jako ćwiczenie proponujemy odmianę implementacji z bieżącym
śledzeniem liczby zapamiętywanych wartości zamiast każdorazowego nawigowania po ta-
blicy).
public int sizeO {
int size = 0;
for (int i = 0: i < _values.length; ++i) {
if (_values[i] != nuli) {
++size;
)
}
return size;
}
Rozdział 11. • Haszowanie 321

Porcjowanie
Drugą z naszych implementacji tablicy haszowanej, bazującą na porcjowaniu danych, także
poprzedzimy stworzeniem odpowiedniej klasy testowej.

Testowanie i implementowanie porcjowanej tablicy haszowanej


Podobnie jak poprzednio, wspomniana klasa testowa wyprowadzona jest z klasy abstrak-
cyjnej AbstractHashtableTest:
package com.wrox.algorithms.hashing;

public class BucketingHashtableTest extends AbstractHashtableTest {


protected Hashtable createTable(int capacity) {
return new BucketingHashtable(capacity. 0.75f);

Sama implementacja tablicy przedstawia się natomiast tak:


package com.wrox.algorithms.hashing;

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:

public class BucketingHashtable implements Hashtable {


/** graniczny stopień wypełnienia tablicy, po przekroczeniu którego
* konieczna jest reorganizacja
*/
private finał float JoadFactor;

/** porcje danych */


private List[] _buckets;
/**

* 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;
}
/**

* Odszukuje porcje zawierającą wskazaną wartość


*

* Parametr: szukana wartość


* Wynik: porcja zawierająca tę wartość
*/
private List bucketFor(Object value) {
int bucketIndex = bucketIndexFor(va1ue);

List bucket = _buckets[bucketlndex];


if (bucket == nuli) {
bucket = new LinkedList():
_buckets[bucketlndex] = bucket;
}
return bucket;
}
J-k-k
* Wylicza indeks porcji, do której należy zapisać podaną wartość
*

* Parametr: wartość, dla której obliczany jest indeks


* Wynik: indeks porcji
*/
private int bucketIndexFor(Object value) {
assert value != nuli : "podano pustą wartość";
return Math.abs(value.hashCode() % _buckets.length);
}
/**

* Zapewnienie właściwych rozmiarów tablicy


*/
private void maintainLoad() {
if OoadFactorExceededO) {
resize():
Rozdział 11. • Haszowanie 323

* Sprawdzenie, czy osiągnięto lub przekroczono graniczny stopień wypełnienia


*

*/
private boolean loadFactorExceeded() {
return sizeO > buckets.length * JoadFactor:
}
/**

* Zwiększenie rozmiarów tablicy po przekroczeniu granicznego stopnia


wypełnienia
*/
private void resizeO {
BucketingHashtable copy =
new BucketingHashtable(_buckets.length * 2. JoadFactor);

for (int i - 0; i < Juckets.length: ++i) {


if (Juckets[i] != nuli) {
copy. addAl 1 (Juckets[i ]. iterator());
}
}
Juckets - copy.Juckets:
}
/**

* Dodanie do tablicy wszystkich wartości danej porcji


*

* Parametry: dodawane wartości


*/
private void addAl1(Iterator values) {
assert values != nuli : "nie określono wartości":

for (values.firstO: !values.isDoneO: values.nextO) {


add(values. currentO);
}
}
)

J a k to działa?

Ponownie klasa testowa wyprowadzona została z abstrakcyjnej klasy AbstractHashtable-


Test. Ukonkretniona metoda createTable() zwraca instancję klasy BucketingHashtable;
zwróć uwagę na dodatkowy parametr konstruktora — 0.75f. Jest to graniczna wartość wy-
pełnienia tablicy: jeśli wypełnienie to osiągnie 75%, rozmiary tablicy zostają zwiększone.
package com.wrox.algori thms.hashi ng:

public class BucketingHashtableTest extends AbstractHashtableTest {


protected Hashtable createTabletint capacity) {
return new BucketingHashtable(capacity. 0.75f):
}
}
324 Algorytmy. Od podstaw

Porcjowanie haszowanych danych jest bardziej skomplikowane niż próbkowanie liniowe,


niniejsza implementacja wymaga więc objaśnień nieco bardziej szczegółowych.

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;

i mport com.wrox,a1gori thms.i terati on.Iterator;


i mport com.wrox.a1gori thms.1 i sts.Li nkedL i st:
i mport com.wrox.a1gori thms.1 i sts.Li st;

public class BucketingHashtable implements Hashtable {


/** graniczny stopień wypełnienia tablicy, po przekroczeniu którego
* konieczna jest reorganizacja
*/
private finał float JoadFactor;

/** porcje danych */


private List[] _buckets:

* 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

private boolean loadFactorExceeded() {


return sizeO > _buckets.length * _loadFactor;
}

private void resizeO {


BucketingHashtable copy =
new Bucketi ngHashtabl e(_buckets. length * 2, JoadFactor);

for (int i = 0; i < _buckets.length: ++i) {


if (_buckets[i] != nuli) {
copy.addAl1(_buckets[i].i terator O ) :

buckets = copy._buckets:

private void addAl1(Iterator values) {


assert values != nuli : "nie określono wartości":

for (values.firstO; !values.isDoneO; values.next()) {


add(values. currentO);
}
}
Metoda bucketIndexFor() dokonuje przyporządkowania wskazanej wartości do określonej
porcji. Podobnie jak w przypadku tablicy Li nearProbi ngHashtabl e wywoływana jest metoda
hashCode(), a zwrócony przez nią wynik brany jest modulo liczbę porcji. Gwarantuje to, że
wyliczony indeks będzie zawsze mieścił się w zakresie wyznaczonym przez rozmiar tablicy
porcji.
private int bucketIndexFor(Object value) {
assert value != nuli : "podano pustą wartość";
return Math.abs(value.hashCodeO % _buckets.length);
}
Metoda bucketForO zwraca listę porcji, w której znajduje się (lub powinna się znajdować)
wskazana wartość. Oprócz wyliczenia indeksu dla porcji metoda ta tworzy nową porcję w przy-
padku, gdyby ta jeszcze (na danej pozycji) nie istniała.
private List bucketFor(Object value) {
int bucketlndex = bucketIndexFor(value):

List bucket = _buckets[bucketlndex];


if (bucket — nuli) {
bucket = new LinkedListO;
_buckets[bucketlndex] = bucket;
}
return bucket:
}
326 Algorytmy. Od podstaw

Metoda add() odnajduje porcję odpowiadającą wskazanej wartości i dodaje tę wartość do


porcji, oczywiście pod warunkiem, że wartość ta jeszcze nie jest w porcji obecna3.
public void add(Object value) {
List bucket = bucketFor(value);

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);

Wreszcie metoda size() sumuje liczbę wartości w poszczególnych porcjach:


public int sizeO {
int size - 0:
for (int i = 0; i < buckets.length: ++1) {
if (_buckets[i] != nuli) {
size += buckets[i].size;

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.

Ocena efektywności tablic haszowanych


Po skonstruowaniu obydwu implementacji naturalne będzie empiryczne zmierzenie i po-
równanie ich efektywności. Efektywność tę mierzy się przede wszystkim liczbą porównań
wartości — im mniej porównań, tym implementacja bardziej efektywna. W przygotowa-
nym zestawie pomiarowym mierzyć będziemy liczbę wywołań metody equa1s() — jest
ona wywoływana wewnętrznie przez metody add() i constains().

spróbuj sam Tworzenie zestawu pomiarowego dla tablic haszowanycb


Definicja klasy dokonującej pomiaru efektywności różnych implementacji tablic haszowa-
nych jest następująca:

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

package com.wrox.algori thms.hashi ng;

import junit.framework.TestCase;

public class HashtableCallCountingTest extends TestCase {


private static finał int TEST SIZE = 1000;
private static finał int INITIAL_CAPACITY = 17;

private int _counter;


private Hashtable _hashtable;

public void testLinearProbingWithResizing() {


hashtable - new LinearProbingHashtable(INITIAL CAPACITY);
runAl 1 0 ;
}
public void testLinearProbingNoResizingO {
hashtable - new LinearProbingHashtableOEST SIZE);
runAl 1 0 ;
}
public void testBucketsLoadFactorlOOPercentO {
hashtable - new BucketingHashtablefINITIAL CAPACITY. l.Of):
runAl 1 0 ;
}
public void testBucketsLoadFactor75Percent() {
hashtable = new BucketingHashtableCINITIAL CAPACITY. 0.75f);
runAl 1 0 ;
}
public void testBuckets50Percent() {
hashtable = new BucketingHashtable(INITIAL CAPACITY. 0.50f):
runAl 1 0 :
}
public void testBuckets25Percent() {
hashtable = new BucketingHashtableCINITIAL CAPACITY, 0.25f);
runAl1();
}
public void testBucketsl50Percent() {
hashtable = new BucketingHashtableCINITIAL CAPACITY. 1.50f):
runAl1();
}
public void testBuckets200Percent() {
hashtable - new BucketingHashtablefINITIAL CAPACITY. 2.0f);
runAl 1 0 ;
}
private void runAl10 {
runAddt):
runContainsO:
}
328 Algorytmy. Od podstaw

private void runAddO {


counter = 0;
for (int i - 0: i < TESTJIZE; ++1) {
_hashtable.add(new Value(i));
i
)
reportCallsfadd"):
}
private void runContains() {
counter = 0;
for (int i = 0; i < TESTJIZE; ++i) {
_hashtable.contains(new Value(i));
i
)
reportCa11s("contains");
}
private void reportCalls(String method) {
System.out.println(getNamet) + "(" + method + "): " + counter + " wywołań");
}
private finał class Value {
private finał String _value:

public Value(int value) {


value = String.valueOf(Math.randomO * TEST SIZE):
}
public int hashCodeO {
return value. hashCodeO;
}
public boolean equals(Object object) {
++_counter:
return object != nuli && value.equals(((Value) object). va ue):
}
1
}

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;

public class HashtableCallCountingTest extends TestCase {


private static finał int TEST_SIZE = 1000:
private static finał int 1NITIAL_CAPACITY = 17:

private int _counter;


private Hashtable _hashtable;

}
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;

public Value(int value) {


_value = String.valueOf(Math.random() * TESTJIZE):
}
public int hashCodeO {
return _value. hashCodeO:
}
public boolean equals(Object object) {
++_counter:
return object != nuli && _value.equals(((Value) object),_value);
}
}
Dla każdej testowanej implementacji wynik testu wyświetlany jest przez metodę report-
CallsO w postaci:
<nazwa testu>(<nazwa metody>): <licznik> wywołań

gdzie <nazwa metody> to „add" lub „contains", zależnie od aktualnego pomiaru:


private void reportCalls(String method) {
System.out.println(getName() + "(" + method + "): " + _counter + " wywołań"):
}
Metody runAddO i runContainsO resetują (zerują) licznik porównań przez wykonaniem
TEST_SIZE wywołań metody (odpowiednio) add() i containsO.
private void runAddO {
_counter = 0:
for (int i = 0: i < TESTJIZE: ++1) {
Jashtable.add(new Value(i));
}
reportCallsCadd");
}
private void runContainsO {
_counter = 0:
for (int i = 0: i < TESTJIZE: ++1) {
Jashtable. contains (new Value(i));
}
reportCallsOcontains") ;
}
330 Algorytmy. Od podstaw

Kolejne wywołanie obydwu tych metod jest treścią metody runAl 1 O :

private void runAl 1 0 {


runAddO:
runContainsO;
}
Przejdźmy teraz do kolejnych poszczególnych przypadków testowych. Dwa pierwsze zwią-
zane są z próbkowaniem liniowym w dwóch wariantach: ze zwiększaniem rozmiaru tablicy
i bez jego zwiększania. W pierwszym wariancie początkowy rozmiar tablicy celowo wy-
brany został jako znacznie mniejszy od ogólnej liczby wartości — stworzy to bowiem wiele
okazji do reorganizacji. W wariancie drugim początkowy rozmiar tablicy jest wystarczający do
przechowania wszystkich wartości, więc reorganizacja nie będzie nigdy wykonywana.
public void testLinearProbingWithResizingO {
Jiashtable = new LinearProbingHashtable(INITIAL_CAPACITV);
runAl 1 0 :
}
public void testLinearProbingNoResizingO {
Jiashtable = new LinearProbingHashtable(TEST_SIZE):
runAl 1 0 :
}
Kolejne przypadki testowe odnoszą się do porcjowania danych. Ich zadaniem jest nie tylko
porównanie efektywności z efektywności próbkowania liniowego, lecz także obserwacja
zależności tej wydajności od konfiguracji początkowej. W każdym z tych przypadków
rozmiar początkowy tablicy jest na tyle mały, że duża liczba reorganizacji jest wręcz gwa-
rantowana. Jak łatwo zauważyć, poszczególne przypadki różnią się od siebie progową war-
tością współczynnika zapełnienia tablicy.
public void testBucketsLoadFactorlOOPercent() {
Jiashtable - new BucketingHashtable(INITIAL_CAPACITY, l.Of):
runAl 1 0 ;
}
public void testBucketsLoadFactor75Percent() {
Jiashtable = new BucketingHashtable(INITIAL_CAPACITY, 0.75f):
runAl 1 0 ;
}
public void testBuckets50Percent() {
Jiashtable = new BucketingHashtable(INITIAL_CAPACITY. 0.500;
runAl 1 0 ;
}
public void testBuckets25Percent() {
Jiashtable = new BucketingHashtableCINITIAL_CAPACITY. 0.250;
runAl 1 0 :
}
public void testBucketsl50Percent() {
Jiashtable = new BucketingHashtableCINITIAL_CAPACITY. 1.500;
runAl 1 0 ;
}
public void testBuckets2()OPercentO {
Jiashtable = new BucketingHashtable(INITIAL_CAPACITY. 2.0f);
runAl 1 0 ;
}
Rozdział 11. • Haszowanie 331

Uruchomienie przedstawionego zestawu testowego powinno dać wyniki zbliżone do poniż-


szych (pamiętajmy, że w kolejnych przebiegach mogą się one różnić ze względu na losowy
charakter wartości poddawanych haszowaniu).
public void testLinearProbingWithResizing(add): 14704 wywołań
public void testLinearProbingWithResizing(contains): 1088000 wywołań
public void testLinearProbingNoResizing(add): 18500 wywołań
public void testLinearProbingNoResizing(contains): 1000000 wywołań
public void testBucketsLoadFactorlOOPercent(add): 987 wywołań
public void testBucketsLoadFactorlOOPercent(contains): 869 wywołań
public void testBucketsLoadFactor75Percent(add): 832 wywołań
public void testBucketsLoadFactor75Percent(contains): 433 wywołań
public void testBuckets50Percent(add): 521 wywołań
public void testBuckets50Percent(contains): 430 wywołań
public void testBuckets25Percent(add): 262 wywołań
public void testBuckets25Percent(contains): 224 wywołań
public void testBucketsl50Percent(add): 1689 wywołań
public void testBucketsl50Percent(contains): 903 wywołań
public void testBuckets200Percent(add): 1813 wywołań
public void testBuckets200Percent(contains): 1815 wywołań

Analizę powyższych wyników zawiera tabela 11.1.

Tabela 11.1. Liczba porównań w trakcie lOOO wywołań każdej z metod add() i containsO

Konfiguracja add contains Ogółem Średnio


Próbkowanie liniowe z reorganizacją 14 7 0 4 1 088 0 0 0 1 102 7 0 4 551,35

Próbkowanie liniowe bez reorganizacji 18 5 0 0 1 000 000 1 018 5 0 0 509,25

Porcjowanie — maks. wypełnienie 100% 987 869 1 856 0,93

Porcjowanie — maks. wypełnienie 75% 832 433 1 265 0,63

Porcjowanie — maks. wypełnienie 50% 521 430 951 0,48

Porcjowanie — maks. wypełnienie 25% 262 224 486 0,24

Porcjowanie — maks. wypełnienie 150% 1 689 903 2 592 1,30

Porcjowanie — maks. wypełnienie 200% 1 813 1 815 3 628 1,81

Najbardziej uderzającym wnioskiem wypływającym z przeprowadzonej analizy jest dra-


styczna przewaga implementacji „porcjowanej" nad implementacją stosującą próbkowanie
liniowe. Gdy spojrzy się na średnią liczbę porównań przypadających na jedną wartość,
próbkowanie liniowe nie okazuje się w niczym lepsze niż sekwencyjne przeszukiwanie li-
sty — czas potrzebny na znalezienie żądanej wartości w tablicy ^/-elementowej jest rzędu
O(N). W wariancie „porcjowanym" natomiast, nawet w najgorszym przypadku, czyli za-
pełnieniu rzędu 200%, na jedną wartość przypadają średnio nie więcej niż dwa porównania!
W przypadku skrajnym — 25% dopuszczalnego wypełnienia — mamy średnio jedno po-
równanie na cztery wartości, choć jednocześnie na jedną zapamiętaną wartość przypadają
trzy pozycje niewykorzystane. Tak czy inaczej haszowanie „porcjowane" zostawia daleko
w tyle haszowanie z próbkowaniem liniowym.

Z tablicy można też w przybliżeniu odczytać zależność między dopuszczalnym (progo-


wym) współczynnikiem zapełnienia tablicy a średnią liczbą porównań przypadających na
jedną wartość: zapełnienie 100% daje średnio jedno porównanie na wartość, przy zapełnieniu
332 Algorytmy. Od podstaw

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.

W niniejszym rozdziale opisujemy:


• podstawowe operacje wykonywane na zbiorach,
• listową implementację zbioru zaprojektowaną dla niewielkiej kolekcji danych,
• haszowaną implementację zbioru zdolną efektywnie uporać się z ogromnymi
porcjami nieuporządkowanych danych,
• drzewiastą implementację zbioru szczególnie przydatną w sytuacji,
gdy przewidywalna jest kolejność iterowania po elementach zbioru.

Podstawowe cechy zbiorów


Zbiór stanowi nieuporządkowaną pulę danych niedopuszczającą dublowania elementów.
Cechy te zasadniczo różnią go od listy, która zachowuje kolejność wstawianych elementów
i nie wyklucza wielokrotnej obecności tych samych wartości. Na rysunku 12.1 przedsta-
wiono schematyczni zbiór liter A - K; zwróć uwagę, że nie są one uporządkowane w jakiś
szczególny sposób.

Rysunek 12.1.
Zbiór stanowi
pulę unikalnych,
nieuporządkowanych
wartości
334 A l g o r y t m y . Od podstaw

Najczęściej wykonywane na zbiorach operacje opisane są w tabeli 12.1.

Tabela 21.1. Typowe operacje wykonywane na zbiorach

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.

contains Sprawdza obecność wskazanej wartości w zbiorze.

iterator Udostępnia iterator wyznaczający kolejność przechodzenia przez poszczególne elementy zbioru.

size Udostępnia liczbę elementów 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.

Ze względu na wymóg unikalności elementów próba dodania wartości do zbioru nieko-


niecznie musi się udać, bowiem dodawana wartość może się już w zbiorze znajdować;
metoda add() jest więc funkcją boolowską, której wynik informuje o powodzeniu (true)
lub niepowodzeniu (false) operacji. Podobnie ma się rzecz z metodą delete() — nie można
usunąć ze zbioru wartości w nim nieobecnej, więc wynik metody informuje o tym, czy usu-
nięcie faktycznie nastąpiło (true) czy też wskazanej wartości nie było w zbiorze (false).

Poza dodawaniem i usuwaniem elementów możliwe jest sprawdzanie obecności konkretnej


wartości w zbiorze, sprawdzanie liczby jego elementów i iterowanie po jego zawartości.
Iterowanie po zawartości zbioru różni się od iterowania po liście tym ważnym szczegółem,
że dla elementów zbioru nie istnieje nic w rodzaju „naturalnej" kolejności, a kolejne elementy
udostępniane są bez gwarancji zachowania jakiegoś szczególnego ich uporządkowania.

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

Po tym krótkim wprowadzeniu zajmiemy się programistycznym aspektem zbiorów. Roz-


poczniemy od zdefiniowania interfejsu odzwierciedlającego funkcjonalność zbioru jako
klasy, po czym zajmiemy się różnymi implementacjami zbiorów i oczywiście zestawami
testowymi weryfikującymi ich poprawność.

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;

public interface Set extends Iterable {


public boolean add(Object value);
public boolean delete(Object value);
public boolean containsCObject value);
public void clear();
public int sizeO;
public boolean isEmptyO:
J

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.

Testowanie implementacji zbiorów


Przed rozpoczęciem implementowania różnych wersji zbiorów stworzymy zestaw testowy
weryfikujący poprawność tworzonych implementacji.

Tworzenie generycznej klasy testowej dla zbiorów


Tak jak wielokrotnie czyniliśmy to wcześniej, wszystkie elementu testu niezależne od kon-
kretnej implementacji zbioru zamkniemy w ramy generycznej, abstrakcyjnej klasy testowej.
package com.wrox.algori thms.sets:

import com.wrox.aIgorithms.iterati on.Iterator:


i mport com.wrox.algori thms.i terati on.IteratorOutOfBoundsExcepti on:
i mport com.wrox.a 1gori thms.i terati on.Reverselterator;
i mport com.wrox.algori thms.1 i sts.Li nkedList:
i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase:
Rozdział 12. • Zbiory 337

public abstract class AbstractSetTest extends TestCase {


private static finał Object A - "a":
private static finał Object B = "b";
private static finał Object C - "c";
private static finał Object O - "d";
private static finał Object E = "e";
private static finał Object F = "f";

private Set _set;

protected void setUpO throws Exception {


_set = createSetO;

_set.add(C);
_set.add(A);
_set.add(B);
set.add(D);

protected abstract Set createSetO;

public void testContainsExisting() {


assertTrue(_set.contai ns(A));
assertTrue(_set.contai ns(B));
assertTrue(_set. contains ( O ) ;
assertTrue(_set.contains(D));
}
public void testContainsNonExistingO {
assertFalse(_set.contains(E)):
assertFalse(_set.contains(F)):
}
public void testAddNewValue() {
assertEquals(4, _set.sizeO);

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);

public void testAddExistingValueHasNoEffect() {


assertEquals(4. _set.size());

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);

public void testDeleteNonExistingO {


assertEqualsC4. _set.sizeO);
assertFalseC_set.delete(E));
assertEquals(4, _set.sizeO);
assertFalse(_set.delete(F));
assertEquals(4, _set.sizeO);
}
public void testClearO {
assertEquals(4, _set.sizeO);
assertFalse(_set.i sEmpty());

_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;

for (i.firstO; !i.isDoneO; i.nextO) {


values.add(i .currentO);
Rozdział 12. • Zbiory 339

}
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:

i mport com.wrox.algori thms.i teration.Iterator;


i mport com.wrox.algori thms.i terati on.IteratorOutOfBoundsExcepti on:
i mport com.wrox.a1gori thms.i terat i on.ReverseIterator;
i mport com.wrox.algori thms.1 i sts.Li nkedList:
i mport com.wrox.algori thms.1 i sts.Li st;
import junit.framework.TestCase:

public abstract class AbstractSetTest extends TestCase {


private static finał Object A - "a":
private static finał Object B - "b";
private static finał Object C - "c";
private static finał Object D - "d";
private static finał Object E = "e";
private static finał Object F = "f":

private Set _set;

protected void setUpO throws Exception {


_set = createSett):

_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

public void testDeleteExisting() {


assertTrue(_set. del ete( 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);
}
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

private void checkIterator(Iterator i) {


List values = new LinkedListO:

for (i.firstO: !i.isDoneO; i.next()) {


values.add(i .currentO):
}
try {
i,current();
failO: // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
II zachowanie oczekiwane
}
assertEquals(4. values.sizeO);
assertTrue(val ues.contai ns(A));
assertTruetvalues.contains(B)):
assertTruet values.contai ns(C));
assertTrue(values.contains(D));

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().

public void testlteratorBackwardsO {


checkIterator(new ReverseIterator( set.iteratorO)):

Zbiór w implementacji listowej


Pierwsza z prezentowanych przez nas implementacji zbioru bazuje na liście jako medium
przechowującym elementy. Jest bardzo prosta koncepcyjnie i łatwa do zrozumienia i cho-
ciaż nie jest specjalnie efektywna, to jednak z powodzeniem spełnia swe zadanie w odnie-
sieniu do małych zbiorów.

spróbuj sam Testowanie i implementowanie zbioru listowego


Rozpoczniemy od klasy testowej:
package com.wrox.a 1gori thms.sets;

public class ListSetTest extends AbstractSetTest {


protected Set createSetO {
return new ListSetO:
Rozdział 12. • Zbiory 343

Właściwa implementacja przedstawia się natomiast następująco:


package com.wrox.algori thms.sets;

i mport com,wrox.algorithms.iteration.Iterator;
import com.wrox.algorithms.1 ists.LinkedList:
import com.wrox.algorithms.1 ists.List:

public class ListSet implements Set {


/** lista przechowująca elementy */
private finał List _values = new LinkedListO;

public boolean containstObject value) {


return _values.contains(value);
}
public boolean add(Object value) {
if (contains(value)) {
return false;
}
_values.add(value):
return true;
}
public boolean delete(Object value) {
return _values.delete(value):
}
public void clearO {
_values.clearO;
}
public int sizeO {
return _values.sizeO;
}
public boolean isEmptyO {
return values.isEmptyO:
}
public Iterator iteratorO {
return _values.iterator();
}
}

J a k to działa?

Klasa ListSet Test rozszerza funkcjonalność generycznej klasy abstrakcyjnej AbstractSet-


Test o skonkretyzowanie metody createSetO w ten sposób, iż ta zwraca instancję klasy
ListSet.

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:

Efektywność listowej implementacji zbioru tożsama jest z efektywnością listy, na bazie


której zbiór ten został zrealizowany: metody add(), deleteO i containsO wykonują się
w średnim czasie proporcjonalnym do liczby elementów ( 0(N)), co w przypadku niewielkich
N okazuje się w zupełności wystarczające.

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.

spróbuj sam Testowanie i implementowanie zbioru haszowanego


Jak zwykle, najpierw zdefiniujemy klasę testową dla haszowanej implementacji zbioru:
package com.wrox.algori thms.sets:

public class HashSetTest extends AbstractSetTest {


protected Set createSett) {
return new HashSetO;

po czym zajmiemy się samą implementacją:


package com.wrox.algori thms.sets:

import com.wrox.algorithms.hashing.Hashtablelterator:
import com.wrox.algorithms.iterati on.ArrayIterator:
i mport com.wrox.a1gori thms.i terati on.Iterator;

public class HashSet implements Set {


/** domyślny rozmiar początkowy porcji */
public static finał int DEFAULT_CAPACITY - 17:

/** domyślna wartość progowa współczynnika zapełnienia */


public static finał float DEFAULT_LOAD_FACTOR - 0.75f:
Rozdział 12. • Zbiory 345

/** początkowa liczba porcji */


private finał int _initialCapacity;

/** wartość progowa współczynnika zapełnienia */


private finał float JoadFactor;

/** tablica porcji przechowujących elementy */


private ListSet[] Juckets;

/** liczba elementów w tablicy */


private int _size;

/** Domyślny konstruktor. Ustala początkowy rozmiar tablicy na 17 porcji


* i progową wartość zapełnienia na 75$
*/
public HashSetO {
thi s (DEFAULTJAPACITY. DEFAULTJOADJACTOR);
}

* Konstruktor. Ustala progową wartość zapełnienia na 75$


* Parametr: początkowy rozmiar tablicy porcji
*/
public HashSet(int initialCapacity) {
thistinitialCapacity. DEFAULTJ O A D JACTOR);
}

* 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:

* odnajduje porcję zawierającą wskazaną wartość


*

* Parametr: szukana wartość


* Wynik: porcja zawierająca wartość
*/
private ListSet bucketFor(Object value) {
int bucketlndex - bucketIndexFor(value):

ListSet bucket = _buckets[bucketlndex];


if (bucket == nuli) {
bucket = new ListSetO:
_buckets[bucketlndex] = bucket:
}
return bucket:
}
/**

* Wylicza indeks dla porcji zawierającej wskazaną wartość


*

* Parametr: wartość
Rozdział 12. • Zbiory 347

* Wynik: indeks porcji


*/
private int bucketIndexFor(Object value) {
assert value != nuli : "podano pustą wartość":
return Math.abs(value.hashCodeO % _buckets.length);
}
/**

* Utrzymuje rozmiar tablicy na odpowiednim poziomie


*/
private void maintainLoadO {
if OoadFactorExceededO) {
resizeO:

/**

* Sprawdza, czy przekroczono progową wartość zapełnienia


*

*/
private boolean loadFactorExceeded() {
return sizeO > _buckets.length * JoadFactor:
}

* Reorganizuje tablice, podwajając jej rozmiar


*/
private void resizeO {
HashSet copy = new HashSet(_buckets.length * 2. JoadFactor);

for (int i = 0: i < _buckets.length; ++i) {


if (_buckets[i] != nuli) {
copy.addAl1(_buckets[i].i terator());
}
}
_buckets = copy. jDuckets:
}

* Dodaje do tablicy wszystkie wartości danej porcji


*

* Parametr: porcja zawierająca wartości


*/
private void addAl1(Iterator values) {
assert values != nuli : "nie określono porcji":

for (values.firstO: !val ues. i sDoneO: values.next()) {


add(values. currentO):
}
}
]
348 Algorytmy. Od podstaw

J a k to działa?

Ponownie klasa HashSetTest rozszerza funkcjonalność generycznej klasy abstrakcyjnej Abs-


tractSetTest o skonkretyzowanie metody createSetO w ten sposób, iż ta zwraca instancję
klasy HashSet.

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

public Iterator iteratorO {


return new HashtableIterator(new ArrayIterator(_buckets));
}
Wreszcie, dla wygody wyposażono klasę w trzy konstruktory: w pierwszym zakłada się
domyślne wartości początkowego rozmiaru tablicy i jej granicznego zapełnienia, w drugim
specyfikuje się jawnie początkowy rozmiar, zaś w trzecim podaje się jawnie obydwa te pa-
rametry.

Znając wyjątkową efektywność porcjowanej odmiany haszowania i zakładając użycie do-


brej funkcji haszującej — porównywalnej z metodą hashCode przechowywanych w zbiorze
wartości — można mieć nadzieję na to, że efektywność zbioru haszowanego okaże się bli-
ska 0(1). Jak już wcześniej zaznaczaliśmy, haszowanie wyklucza uporządkowanie danych
w jakimkolwiek sensie, więc bazując na iteratorze Hashtablelterator nie możemy zakładać
jakiejkolwiek szczególnej kolejności udostępnianych elementów.

Zbiór w implementacji drzewiastej


Mimo iż zbiory generalnie nie wprowadzają uporządkowania swych elementów, to jednak
niekiedy chciałoby się, przy zachowaniu ogólnej semantyki operacji zbiorowych, uzyski-
wać dane w pewnej dającej się przewidzieć kolejności, na przykład w celu ich alfabetycz-
nego wydrukowania czy też wyświetlenia jako menu dla użytkownika. W takiej sytuacji
odpowiednim medium do przechowywania elementów zbioru okazuję się binarne drzewa
wyszukiwawcze, opisywane w rozdziale 10.

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.

nureriii Testowanie i implementowanie zbioru drzewiastego


Jak zwykle rozpoczynamy od klasy testowej:
package com.wrox.algori thms.sets:

public class TreeSetTest extends AbstractSetTest {


protected Set createSetO {
return new TreeSetO:
}
_}
A oto właściwa implementacja:
package com.wrox.a1gori thms.sets;

i mport com.wrox.algori thms.i terati on.Iterator;


import com.wrox.algorithms.iteration.IteratorOutOfBoundsException:
i mport com.wrox.a1gori thms.sort i ng.Compa rator:
import com.wrox.algori thms.sorti ng.NaturalCompa rator;
350 Algorytmy. Od podstaw

public class TreeSet implements Set {


/** komparator wyznaczający porządek elementów */
private finał Comparator _comparator;

/** wskazanie na korzeń drzewa lub wartość pusta dla pustego zbioru */
private Node _root;

/** liczba elementów w zbiorze */


private int _size:

public TreeSetO {
this (Natura Komparator. INSTANCE);
}

public TreeSet(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
public boolean contains(Object value) {
return search(value) != nuli;
}
public boolean add(0bject value) {
Node parent = nuli;
Node node = _root;
int cmp « 0;

while (node != nuli) {


parent = node;
cmp - _comparator.compare(value. node.getValue());
if (cmp == 0) {
return false:
}
node = cmp < 0 ? node.getSmallerO : node.getLargerO:
}
Node inserted = new Nodetparent, value);

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ść";

Node replacement - del eted. getSmallerO !- nuli


? deleted. getSmallerO; del eted. getLargerO;
if (replacement != nuli) {
replacement.setParent(deleted.getParent());
}
if (deleted — _root) {
_root = replacement:
} else if (deleted.isSmallerO) {
deleted.getParent().setSmal1er(replacement):
} else {
del eted. getParentO. setLargert repl acement);
}
if (deleted != node) {
Object deletedValue = node.getValue();
node. setVal ue( del eted. getVa 1 u e O ) ;
deleted.setValue(deletedValue):
}
--_size;
return true;
}
public Iterator iteratorO {
return new ValueIterator();
}
public void clearO {
_root = nuli;
_size = 0;
}
public int sizeO {
return _size:
}
public boolean isEmptyO {
return _root == nul 1;
}

private Node search(Object value) {


assert value !- nuli : "podano wartość pustą";

Node node = _root:

while (node != nuli) {


int cmp = _comparator.compare(value. node.getValue()):
if (cmp — 0) {
break;
352 Algorytmy. Od podstaw

node = cmp < 0 ? node.getSmaller() : node.getLargerO:


}
return node;
}

private static finał class Node {


/** wartość */
private Object _value;

/** ojciec */
private Node _parent;

/** lewy syn */


private Node _smaller;

/** prawy syn */


private Node Jarger;

public NodeCNode parent. Object value) {


setParent(parent);
setValue(value);
}

public Object getValue() {


return _value;
}

public void setValue(Object value) {


assert value != nuli : "podano pustą wartość";
_value = value;
}

public Node getParentO {


return _parent;
}

public void setParent(Node parent) {


_parent = parent:
}
public Node getSmal l e r O {
return _smal ler;
}
public void setSmaller(Node node) {
assert node != getLargerO : "synowie nie mogą być identyczni";
_smaller = node;
}
Rozdział 12. • Zbiory 353

public Node getLargerO {


return Jarger:
}

public void setLarger(Node node) {


assert node != getSmallerO : "synowie nie mogą być identyczni ";
J a r g e r = node;
}
public boolean isSmallerO {
return getParentO != nuli && this == getParentO.getSmallerO;
}

public boolean isLargerO {


return getParentO != nuli && this == getParent O . getLargerO;
}

public Node minimumO {


Node node = this;

while (node.getSmallerO != nuli) {


node = node.getSmallerO;
1
return node;
}

public Node maximum() {


Node node = this;

while (node.getLargerO != nuli) {


node - node.getLargerO;
}
return node;
}

public Node successorO {


if (getLargerO != nuli) {
return getLargerO.minimumO;
}
Node node = this:

while (node.isLargerO) {
node = node.getParentO;
}
return node.getParentO;
}
354 Algorytmy. Od podstaw

public Node predecessor() {


if (getSmallerO != nuli) {
return getSmallerO.maximum();
}
Node node = this;

while (node.isSmallerO) {
node = node.getParentO;
}
return node.getParentO;
}
}
private finał class ValueIterator implements Iterator {
private Node _current;

public void firstO {


_current = _root != nuli ? _root,minimum() : nuli;
}
public void lastO {
_current = _root != nuli ? _root.maximum() : nuli:
}
public boolean isDoneO {
return _current — nuli;
}
public void next() {
if (!isDoneO) {
_current = _current. successorO;
}
}
public void previous() {
if (!isDoneO) {
_current = _current.predecessor();
1
}
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsException():
}
return _current.getValue();

}
}
Rozdział 12. • Zbiory 355

J a k to działa?

Klasa TreeSetTest rozszerza funkcjonalność genetycznej klasy abstrakcyjnej AbstractSet-


Test o skonkretyzowanie metody createSetO w ten sposób, iż ta zwraca instancję klasy
TreeSet.

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.

Pierwsza ze wspomnianych różnic jest konsekwencją faktu, że klasa TreeSet implementuje


interfejs Set. Oznacza to, że nazwę metody insertO należy zmienić na addO; należy po-
nadto zadbać o to, by nie dodawać do drzewa wartości już w nim obecnej. Jak pamiętamy,
metoda i nsert () dopuszcza dublowanie elementów — zdublowana wartość wstawiana jest
jako lewy syn swego (obecnego już w drzewie) synonimu:
while (node != nuli) {
parent = node:
cmp = _comparator.compare(value, node.getValue()):
node = cmp <= 0 ? node.getSmaller() : node.getLargerO;
}
W metodzie addO należy takie dublowanie wykluczyć. Robi się to bardzo łatwo: w przy-
padku stwierdzenia (przez metodę compareO komparatora), że dodawana wartość jest już
w drzewie obecna (cmp == 0), następuje przerwanie pętli i zwrócenie wartości false:
while (node != nuli) {
parent = node;
cmp = _comparator.compare(value. node.getValueO);
if (cmp == 0) {
return false:
}
node = cmp < 0 ? node. getSmal 1 er O : node.getLargerO;
}
Drugą różnicą jest podejście do kwestii wyszukiwania elementów. Ponieważ metoda se-
archO klasy BinarySearchTree nie ma zastosowania do zbiorów (bo interfejs Set nie defi-
niuje żadnej metody wyszukującej elementy), została uczyniona metodą prywatną — i jed-
nocześnie fundamentem metody contains(). Ta ostatnia zwraca wartość true tylko wtedy,
gdy metoda search() odnajdzie węzeł ze wskazaną wartością.

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.

Koncepcja i zastosowanie map


Mapa jest obiektem ustanawiającym związek między kluczami a wartościami. Każdy klucz
mapy jest różny od pozostałych, a związaną z tym kluczem wartość można efektywnie od-
najdować i modyfikować. Staje się to użyteczne w przypadku różnego rodzaju tablic prze-
glądowych udostępniających żądaną informację na podstawie identyfikującego j ą klucza
oraz w odniesieniu do bazy danych, których indeksy umożliwiają szybkie wyszukiwanie in-
formacji na podstawie jej wybranych szczegółów. Na rysunku 13.1 przedstawiona jest przy-
kładowa mapa, której kluczami są nazwiska, a wartościami — numery rekordów bazy danych
zawierających szczegółowe informacje na temat osób noszących poszczególne nazwiska.

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 .

Podstawowe operacje wykonywane na mapach opisane są w tabeli 13.1.

Tabela 13.1. Operacje wykonywane na mapach

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).

contains Sprawdza istnienie danego klucza w mapie.

iterator Zwraca iterator udostępniający pary „klucz-wartość" danej mapy.

size Zwraca liczbę par „klucz-wartość" zdefiniowanych aktualnie w mapie.

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.

Jak więc widać, mapy umożliwiają odnajdywanie i modyfikowanie wartości związanych


z poszczególnymi kluczami, usuwanie wskazanych kluczy wraz z ich wartościami oraz ite-
rowanie po zawartości rozumianej jako zbiór par „klucz-wartość", zwanych pozycjami lub
zapisami (entries). Podobnie jak zbiory mapy nie określają żadnej szczególnej kolejności
swych elementów (pozycji).

Po zdefiniowaniu funkcjonalności mapy — w postaci zbioru operacji opisanych w tabeli


13.1 — możemy tworzyć różne implementacje map, zależnie od potrzeb i wymogów wyni-
kających z konkretnych zastosowań. Rozpoczniemy od zdefiniowania interfejsu odzwier-
ciedlającego funkcjonalność mapy jako klasy, po czym zaprezentujemy wybrane imple-
mentacje map i oczywiście zestawy testowe weryfikujące ich poprawność.

Interfejs mapy
Programistycznym odzwierciedleniem funkcjonalności mapy jest interfejs określony nastę-
pująco:

package com.wrox.a 1gori thms.maps;

import com.wrox.algorithms.iteration.Iterable;

public interface Map extends Iterable {


public Object get(Object key);

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

public Object set(Object key. Object value);


public Object delete(Object key);
public boolean containstObject key);
public void clearO;
public int sizeO:
public boolean isEmptyO;

public static interface Entry {


public Object getKeyO;
public Object getValueO:
}
]

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.

Po abstrakcyjnym zdefiniowaniu funkcjonalności mapy przejdźmy do zdefiniowania do-


myślnej implementacji jej pozycji reprezentowanej przez interfejs Map. Entry.

spróbuj sam Tworzenie domyślnej implementacji pozycji mapy


Domyślna klasa implementująca interfejs Map.Entry nosi nazwę DefaultEntry i zdefinio-
wana jest następująco:
package com.wrox.a1gori thms.maps;

public class DefaultEntry implements Map.Entry {


private finał Object _key;
private Object _value;

/**

* 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

public Object setValue(Object value) {


Object oldValue = _value:
_value = value;
return oldValue:
}
public Object getValue() {
return _value:
}
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == nuli || getClassO != object.getClassO) {
return false;
}
DefaultEntry other = (DefaultEntry) object:

return _key.equals(other._key) && _value.equals(other._value);


}
}

J a k to działa?

Klasa DefaultEntry przechowuje klucz i wartość w prywatnych zmiennych (odpowiednio)


_key i _value i udostępnia wartości tych zmiennych za pośrednictwem metod (odpowied-
nio) getKey() i getValue(). Zadaniem metody equals() jest porównywanie dwóch instancji
klasy, czyli orzekanie o ich równości lub nierówności; będziemy korzystać z tej możliwości
w zestawach testowych dla map.

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().

Zdefiniowaliśmy już wszystko, co do implementacji mapy jest konieczne, przystąpmy więc


do tworzenia testów ma użytek tych implementacji.
362 Algorytmy. Od podstaw

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

Testowanie implementacji map


Tradycyjnie, cześć wspólną wszystkich testów, niezależną od konkretnej implementacji mapy,
zamkniemy w ramy abstrakcyjnej klasy testowej, którą w niezbędnym zakresie będziemy
mogli rozszerzać dla dowolnej implementacji.

spróbuj sam Tworzenie generycznej klasy testowej dla map


Abstrakcyjna klasa testowa weryfikująca własności mapy wspólne wszystkim możliwym
jej implementacjom zdefiniowana jest następująco:
package com.wrox.a 1gori thms.maps;

i mport com.wrox.a1gori thms.i terat i on.Iterator;


import com.wrox algorithms.i terati on.IteratorOutOfBoundsExcepti on;
import com.wrox.algori thms.i terati on.ReverseIterator;
i mport com.wrox.a1gori thms.1 i sts.Li nkedL i st;
import com.wrox.algorithms.lists.List;
import junit.framework.TestCase;

public abstract class AbstractMapTest extends TestCase {


private static finał Map.Entry A = new DefaultEntryOakey"
private static finał Map.Entry B = new DefaultEntryCbkey" "bvalue")
private static finał Map.Entry C = new DefaultEntryCckey" "cvalue")
private static finał Map.Entry D = new DefaultEntryt"dkey" "dvalue")
private static finał Map.Entry E = new DefaultEntryt"ekey" "evalue")
private static finał Map.Entry F = new DefaultEntryt"fkey" "fvalue")
Rozdział 13. • Mapy 363

private Map _map;

protected void setUpO throws Exception {


super. setUpO;

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;

public void testContainsExisting() {


assertTruet jnap.contai ns(A.getKey()));
assertTrue( jnap.contai ns(B.getKey()));
assertTruet jnap.contai ns(C.getKey()));
assertTruet jnap.contai ns(D.getKey()));
}
public void testContainsNonExisting() {
assertFalse(jnap.contains(E.getKey()));
assertFalse(jnap.contains(F.getKey()));
}
public void testGetExisting() {
assertEquals(A.getValue(), jnap. get (A. getKey O ) ) :
assertEquals(B.getValue(), _map.get(B.getKeyO));
assertEquals(C.getValue(). jnap.get(C.getKeyO));
assertEqual s(D.getVal ue(). jnap.get(D.getKeyO));
}
public void testGetNonExisting() {
assertNul l(jnap.get(E. getKeyO)):
assertNul1(jnap.get(F.getKey())):
}
public void testSetNewKey() {
assertEquals(4, _map.size());

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):

public void testSetExistingKey() {


assertEquals(4. jnap.sizeO);
assertEquals(C.getValue(), jnap.set(C.getKeyO. "cvalue2")):
assertEquals("cvalue2", jnap.get(C.getKeyO)):
assertEquals(4, jnap.sizeO):
}
364 Algorytmy. Od podstaw

public void testOeleteExisting() {


assertEquals(4. 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);

assertEqual s(C.getVa 1 ue(). _map.delete(C.getKeyO));


assertFalse(_map.contains(C. getKeyO));
assertEquals(l, jnap.sizeO);

assertEquals(D.getValue(). jnap.delete(D.getKeyO));
assertFalse(jnap.contains(D.getKey()));
assertEquals(0, _map.size()):

public void testDeleteNonExisting() {


assertEquals(4, _map.sizeO);
assertNul1(jnap.delete(E.getKey())):
assertEquals(4, jnap.sizeO):
assertNul1(jnap.delete(F.getKey()));
assertEquals(4, jnap.sizeO);

public void testClearO {


assertEquals(4, jnap.sizeO);
assertFalset jnap.i sEmpty());

jnap. clearO:

assertEquals(0, jnap.sizeO);
assertTrue( jnap.i sEmpty());

assertFalse( jnap.contai ns(A.getKey()));


assertFalse(_map.contains(B.getKey()));
assertFalse(_map.contains(C. getKeyO));
assertFalse(_map.contains(D. getKeyO));
}
public void testIteratorForwards() {
checkIterator(_map.iterator());
}
public void testlteratorBackwardsO {
check Iterator (new ReverseIterator(_map. iteratorO));
}
private void checkIteratortIterator i) {
List entries = new LinkedListO;

for (i.firstO; !i.isDoneO: i.next()) {


Map.Entry entry = (Map.Entry) i.currentO;
entries .add (new DefaultEntry (entry. getKey (). entry ,getValueO)):
Rozdział 13. • Mapy 365

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:

public abstract class AbstractMapTest extends TestCase {


private static finał Map.Entry A = new DefaultEntryt"akey". "avalue")
private static finał Map.Entry B = new DefaultEntryt"bkey". "bvalue")
private static finał Map.Entry C - new DefaultEntryt"ckey". "cvalue")
private static finał Map.Entry D - new DefaultEntryt"dkey". "dvalue")
private static finał Map.Entry E - new DefaultEntryt"ekey". "evalue")
private static finał Map.Entry F = new DefaultEntryt"fkey". "fvalue")

private Map _map;

protected void setUpO throws Exception {


super.setUpt):

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)

protected abstract Map createMapO:


366 Algorytmy. Od podstaw

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):

assertNul 1(jnap.set(F.getKey(). F.getValue()));


assertEquals(F.getValue(). jnap.get(F.getKeyO)):
assertEquals(6. _map.size()):
}
Rozdział 13. • Mapy 367

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]).

private void checkIteratortIterator i) {


List entries = new LinkedListO;

for (i.firstO; !i.isDoneO; i.next()) {


Map.Entry entry = (Map.Entry) i.currentO:
entries.add(new Defaul tEntry(entry .getKey(). entry.getValueO)):
}
try {
i,current():
failO; // zachowanie nieoczekiwane
} catch (IteratorOutOfBoundsException e) {
// zachowanie oczekiwane
}
assertEquals(4. entries.sizeO);
assertTruet entri es.conta i ns(A));
assertTrue(entries.contains(B));
Rozdział 13. • Mapy 369

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()));
}

Mapa w implementacji listowej


Pierwsza z prezentowanych przez nas implementacji map wykorzystuje listę jako medium
przechowującym pozycje. Implementacja ta jest bardzo prosta koncepcyjnie oraz łatwa do
zrozumienia i chociaż nie jest specjalnie efektywna, to jednak z powodzeniem spełnia swe
zadanie w odniesieniu do niewielkich map.

iHllłliil Testowanie i implementowanie mapy listowej


Rozpoczniemy od klasy testowej:
package com.wrox.algorithms.maps;

public class ListMapTest extends AbstractMapTest {


protected Map createMapO {
return new ListMapO;

Implementacja samej mapy przedstawia się natomiast następująco:


package com.wrox.a 1gori thms.maps:

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;

public class ListMap implements Map {


/** Lista przechowująca pozycje */
private finał List _entries = new LinkedListO:
public Object get(Object key) {
DefaultEntry entry = entryFor(key);
370 Algorytmy. Od podstaw

return entry != nuli ? entry.getValue() : nuli;


}
public Object settObject key, Object value) {
DefaultEntry entry = entryFor(key);
if (entry !- nuli) {
return entry,setValue(value);
}
_entries.adcKnew DefaultEntry(key. value));
return nul 1;
}
public Object delete(Object key) {
DefaultEntry entry = entryFor(key);
if (entry == nul 1) {
return nul 1;
}
_entries.delete(entry);
return entry.getValue():
}
public boolean contains(0bject key) {
return entryFor(key) !=null;
}
public void clearO {
_entries,clear();
}
public int sizeO {
return _entries.sizeO;
}
public boolean isEmptyO {
return _entries.isEmpty();
}
public Iterator iteratorO {
return _entries.iteratorO;
}
private DefaultEntry entryFor(Object key) {
Iterator i = iteratorO;
for (i.firstO; !i.isDoneO; i.next()) {
DefaultEntry entry = (DefaultEntry) i.currentO;
if (entry.getKey().equals(key)) {
return entry;
}
}
return nul 1;
Rozdział 13. • Mapy 371

J a k to działa?

Klasa ListMapTest rozszerza funkcjonalność generycznej klasy abstrakcyjnej AbstractMap-


Test o skonkretyzowanie metody createMapO w ten sposób, iż ta zwraca instancję klasy
ListMap.
package com.wrox.a 1gori thms.maps;

public class ListMapTest extends AbstractMapTest {


protected Map createMapO {
return new ListMap();
}
}
Jedyną prywatną zmienną klasy ListMap jest lista przechowująca pary „klucz-wartość". Wy-
wołania metod clearO, sizeO, isEmptyO i iteratorO delegowane są wprost do identycznie
nazwanych metod wspomnianej listy.
package com.wrox.a 1gori thms.maps;

i mport com.wrox.algori thms.i terati on.Iterator;


i mport com.wrox.algorithms.1 i sts.Li nkedLi st;
i mport com.wrox.a 1gorithms.1 i sts.Li st;

public class ListMap implements Map {


/** Lista przechowująca pozycje */
private finał List _entries = new LinkedListO;

public void clearO {


_entries.clearO;
}
public int sizeO {
return _entries.sizeO;
}
public boolean isEmptyO {
return _entries.isEmptyO;
}
public Iterator iteratorO {
return _entri es. iteratorO;
}
}
Prywatna metoda entryForO odnajduje pozycję związaną ze wskazanym kluczem; jeśli pozy-
cji takiej nie ma w mapie, zwracana jest wartość pusta. Metoda wykonuje swą pracę, iteru-
jąc po wszystkich pozycjach mapy i porównując ich klucze z kluczem wzorcowym:
private DefaultEntry entryFor(Object key) {
Iterator i = iteratorO:
for (i.firstO: !i.isDoneO; i.next()) {
DefaultEntry entry = (DefaultEntry) i.currentO:
372 A l g o r y t m y . Od podstaw

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.

Mapa w implementacji haszowanej


Kolejna prezentowana przez nas implementacja mapy bazować będzie na tablicy haszowa-
nej opisywanej w rozdziale 11. Zachęcamy Czytelników do przypomnienia sobie zasad
funkcjonowania tej tablicy, a szczególnie jej odmiany „porcjowanej" implementowanej
przez klasę Bucketinghashtable.

unmmi Testowanie i implementowanie mapy haszowanej


Jak zwykle, najpierw zdefiniujemy klasę testową dla haszowanej implementacji zbioru:
package com.wrox.algori thms.maps;

public class HashMapTest extends AbstractMapTest {


protected Map createMapO {
return new HashMapO:
}
_ J
po czym zajmiemy się samą implementacją:
package com.wrox.a1gori thms.maps:

i mport com.wrox.algori thms.hashi ng.Hashtablelterator;


import com.wrox.algorithms.iteration.ArrayIterator;
import com.wrox.algorithms.iteration.Iterator;

public class HashMap implements Map {


/** domyślny rozmiar początkowy porcji */

public static finał int DEFAULT_CAPACITY = 17;

/** domyślna wartość progowa współczynnika zapełnienia */


public static finał float DEFAULT_LOAD_FACTOR = 0.75f;

/** początkowa liczba porcji */


private finał int JnitialCapacity;

/** wartość progowa współczynnika zapełnienia */


private finał float JoadFactor:

/** tablica porcji przechowujących pozycje mapy */


private ListMapH _buckets;
374 Algorytmy. Od podstaw

/** Liczba pozycji w tablicy */


private int _size;

/** Domyślny konstruktor. Ustala początkowy rozmiar tablicy na 17 porcji


* i progową wartość zapełnienia na 75$
*/

public HashMap() {
this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR);
}

* Konstruktor. Ustala progową wartość zapełnienia na 75$


* Parametr: początkowy rozmiar tablicy porcji
*/

public HashMap(int initialCapacity) {


thi s(i ni ti al Capaci ty. DEFAULT_LOAD_FACTOR);
}

* Konstruktor
*

* Parametry:
* - początkowy rozmiar tablicy porcji
* - progowa wartość współczynnika zapełnienia
*/

public HashMap(int 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;
_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);

int sizeBefore = bucket.sizeO;


Object oldValue = bucket.settkey, value);
if (bucket.sizeO > sizeBefore) {
++_size;
maintainLoad():
}
return oldValue;
}
Rozdział 13. • Mapy 375

public Object delete(Object key) {


ListMap bucket = _buckets[bucketIndexFor(key)];
if (bucket == nuli) {
return nul 1:
}
int sizeBefore = bucket.size();
Object value = bucket.delete(key);
if (bucket.sizeO < sizeBefore) {
--_size;
}
return value;
}
public boolean contains(Object key) {
ListMap bucket = _buckets[bucketIndexFor(key)]:
return bucket != nuli && bucket.contains(key);
}
public Iterator iteratorO {
return new HashtableIterator(new ArrayIterator(_buckets));
}
public void clearO {
_buckets = new ListMap[_initialCapacity];
_size = 0;
}
public int sizeO {
return _size:
}
public boolean isEmptyO {
return sizeO == 0;
}
private int bucketIndexFor(Object key) {
assert key !- nuli : "klucz nie może być pusty";
return Math.abst key .hashCodeO % _buckets. length);
}
private ListMap bucketFor(Object key) {
int bucketlndex = bucketIndexFor(key);
ListMap bucket = _buckets[bucketlndex];
if (bucket == nuli) {
bucket - new ListMap();
_buckets[bucketlndex] = bucket;
}
return bucket;

private void maintainLoadO {


if OoadFactorExceededO) {
resize();
}
376 Algorytmy. Od podstaw

private boolean loadFactorExceeded() {


return sizeO > _buckets.length * JoadFactor:
}

private void resizeO {


HashHap copy - new HashMap(_buckets.length * 2. JoadFactor);

for (int i = 0; i < _buckets.length; ++i) {


if (_buckets[i] != nuli) {
copy.addAl1(_buckets[i].iterator ());
}
}
_buckets = copy._buckets;
}
private void addAl1(Iterator entries) {
assert entries != nuli : "nie podano listy pozycji":

for (entries.firstO: ! entries .isDoneO; entries.next()) {


Map.Entry entry = (Map.Entry) entries.currentO;
set(entry,getKey(). entry ,getValueO):
}
}
1

J a k to działa?

Jak poprzednio, klasa HashMapTest rozszerza funkcjonalność generycznej klasy abstrakcyj-


nej AbstractMapTest o skonkretyzowanie metody createMapO w ten sposób, iż ta zwraca
instancję klasy HashMap.

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.

Poza implementowaniem interfejsu Map i definiowaniem kilku stałych i trzech konstrukto-


rów klasa HashMap wprowadza istotną różnicę w stosunku do klasy BucketingHashtable: li-
sta przechowująca pozycje mapy haszowanej nie jest już zwykłą listą (implementacja inter-
fejsu List), lecz mapą listową ListMap. Upraszcza to znacznie kodowanie i czyni bardziej
widocznymi zamiary programisty: elementami tablicy haszowanej są porcje złożone z (miejmy
nadzieję równomiernie rozłożonych) par „klucz-wartośc". Ponieważ każda porcja jest de
facto mapą listową więc realizacja metod getO, setO, deleteO i containsO mapy ha-
szowanej staje się skrajnie prosta, abstrahuje bowiem od rozmaitych szczegółów haszowa-
nia, jak np. reorganizacja tablicy.

Drugą istotna różnicą w stosunku do klasy BucketingHashtable jest fakt, że wywoływana


w związku z reorganizacją tablicy metoda addAl 1 () iteruje po parach „klucz-wartość", a nie
jedynie po samych wartościach.

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

Znając wyjątkową efektywność porcjowanej odmiany haszowania i zakładając użycie do-


brej funkcji haszującej, można mieć nadzieję na to, że efektywność mapy haszowanej okaże
się bliska 0(1).

Mapa w implementacji drzewiastej


Jak już wspominaliśmy na wstępie, mapy jako takie nie wprowadzają uporządkowania
swych pozycji — przykładowo pozycje mapy listowej ułożone są w kolejności ich doda-
wania (a nie w kolejności kluczy), a kolejność pozycji zwracanych przez iterator klasy
HashMap jest całkowicie przypadkowa. Mimo to niekiedy pożądane byłoby przetwarzanie
pozycji mapy w pewnej dającej się przewidywać kolejności kluczy; do tego celu idealnie
nadaje się implementacja mapy na bazie drzewa binarnego.

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.

mifrfiiiiB Testowanie i implementowanie mapy drzewiastej


Jak zwykle rozpoczynamy od klasy testowej.
package com.wrox.algori thms.maps;

public class TreeMapTest extends AbstractMapTest {


protected Map createMapO {
return new TreeMapO;

A oto właściwa implementacja mapy drzewiastej:


package com.wrox.algori thms.maps;

i mport com.wrox.algori thms.i terati on.Iterator;


i mport com.wrox.algori thms.i terati on.IteratorOutOfBoundsExcepti on;
i mport com.wrox.algori thms.sorti ng.Comparator;
i mport com.wrox.algori thms.sorti ng.NaturalComparator;

public class TreeMap implements Map {


/** komparator wyznaczający porządek pozycji */
private finał Comparator _comparator;

/** wskazanie na korzeń drzewa lub wartość pusta dla pustej mapy */
private Node _root;

/** liczba pozycji w mapie */


private int _size;
public TreeMapO {
this(NaturalComparator.INSTANCE);
}
378 Algorytmy. Od podstaw

public TreeMap(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
public boolean contains(Object key) {
return search(key) != nuli;
}
public Object get(Object key) {
Node node = search(key);
return node != nuli ? node.getValue() : nuli;
}
public Object set(Object key. Object value) {
Node parent - nul 1;
Node node = _root;
int cmp = 0;

while (node != nuli) {


parent = node;
cmp = _comparator,compare(key. node.getKey());
if (cmp == 0) {
return node.setValue(value);
}
node - cmp < 0 ? node.getSmaller() : node.getLargert);
}
Node inserted = new Node(parent. key. value);

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";

Node replacement = del eted. getSmallerO != nuli


? del eted. getSmallerO : deleted.getLarger():
if (replacement != nuli) {
replacement.setParent(deleted.getParent()):
}
Rozdział 13. • Mapy 379

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ść";

Node node = _root:

while (node != nuli) {


int cmp = _comparator.compare(value. node.getKey());
if (cmp == 0) {
break;
}
node = cmp < 0 ? node. getSmal l e r O : node.getLargerO:
}
return node;
}

private static finał class Node implements Map.Entry {


/** Klucz */
priyate Object _key;
380 Algorytmy. Od podstaw

/** Wartość */
private Object _value:

/** Ojciec */
private Node _parent;

/** Lewy syn */


private Node _smaller;

/** Prawy syn */


private Node _larger;

public Node(Node parent, Object key. Object value) {


setKey(key);
setValue(value):
setParent(parent):
}

public Object getKeyO {


return _key;
}

public void setKey(Object key) {


assert key != nuli : "klucz nie może być pusty";
_key = key;
}

public Object getValue() {


return _value;
}

public Object setValue(Object value) {


Object oldValue = _value;
_value = value;
return oldValue:
}

public Node getParentO {


return _parent;
}

public void setParenttNode parent) {


_parent = parent:
}

public Node getSmallerO {


return _smaller;
}
public void setSmaller(Node node) {
Rozdział 13. • Mapy 381

assert node != getLargerO : "synowie nie mogą być identyczni";


_smaller = node;
1
public Node getLargerO {
return _larger;
}

public void setLarger(Node node) {


assert node != getSmallerO : "synowie nie mogą być identyczni":
J a r g e r = node;
}

public boolean isSmallerO {


return getParentO != nuli && this — getParentO .getSmallerO;
}

public boolean isLargerO {


return getParentO !» nuli && this == getParentO.getLargerO;
}

public Node minimumO {


Node node = this;

while (node.getSmallerO != nuli) {


node = node. getSmallerO;
}
return node;
}

public Node maximum() {


Node node = this;

while (node.getLargert) != nuli) {


node = node.getLarger();
}
return node;
}

public Node successorO {


if (getLargerO != nuli) {
return getLargerO.minimumO;
}
Node node = this;

while (node.isLargerO) {
node = node.getParentO;
_}
382 Algorytmy. Od podstaw

return node.getParentO;
}

public Node predecessorO {


if (getSmallerO !- nuli) {
return getSmal l e r O .maximumO ;
}
Node node = this;

while (node.isSmallerO) {
node = node.getParentO:
}
return node.getParentO;
}
}

private finał class Entrylterator implements Iterator {


private Node _current:

public void firstO {


_current = _root != nuli ? _root.minimum() : nuli;
}
public void lastO {
_current = _root != nuli ? _root.maximum() : nuli;
}
public boolean isDoneO {
return _current == nuli;
}
public void next() {
if (!isDoneO) {
_current = _current.successor();
}
}
public void previous() {
if (!isDoneO) {
_current = _current. predecessorO:
}
}
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsException();
}
return _current;
}
}
}
Rozdział 13. • Mapy 383

J a k to działa?

Klasa TreeMapTest rozszerza funkcjonalność generycznej klasy abstrakcyjnej AbstractMap-


Test o skonkretyzowanie metody createMapO w ten sposób, iż ta zwraca instancję klasy
TreeMap.

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.

Zamiast zapamiętywania instancji interfejsu Map.Entry w każdym węźle, uczyniliśmy pry-


watną klasę węzła Node klasą implementującą ten interfejs. Ponieważ „oryginalny" węzeł
drzewa binarnego zawierał już wartość, należało tylko przedefiniować nieznacznie metodę
setVal ue() tak, by ta zwracała wartość z pozycji Map. Entry implementowanej przez węzeł.

Poza tym oryginalna metoda insertO drzewa binarnego (BinarySearchTree) przemiano-


wana została — zgodnie z wymogami interfejsu Map — na set(). Co więcej, jako że orygi-
nalna metoda insertO dopuszcza dublowanie wartości w drzewie, zaś klucze mapy muszą
być unikalne, konieczne stało się rozróżnienie sytuacji modyfikowania wartości istniejącej
pozycji od wstawiania nowej pozycji do mapy.

Oryginalna pętla whi 1 e iterująca po węzłach drzewa w metodzie i nsert:


while (node != nuli) {
parent = node:
cmp = _comparator.compare(value, node.getValueO):
node = cmp <= 0 ? node.getSmallerO : node.getLargerO;
}
musiała w związku z tym zostać zastąpiona przez następujący wariant:
while (node != nuli) {
parent = node:
cmp = _comparator.compare(key. node.getKey()):
if (cmp == 0) {
return node.setValue(value):
}
node = cmp < 0 ? node.getSmallerO : node.getLargerO;
}
Sytuacja istnienia węzła zawierającego wskazany klucz (cmp == 0) jest teraz wyłapywana
w środku pętli i zamiast dodawania nowego węzła przeprowadzane jest modyfikowanie
wartości w węźle istniejącym.

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.

W niniejszym rozdziale omawiamy i prezentujemy:


• ogólne własności ternarnych drzew wyszukiwawczych,
• zapamiętywanie i wyszukiwanie słów w drzewach ternarnych,
• budowanie słowników na bazie drzew ternarnych,
• prostą aplikację wspomagającą rozwiązywanie krzyżówek.

Co to jest drzewo ternarne?


Drzewa ternarne są specjalizowanymi strukturami zaprojektowanymi w celu efektywnego
przechowywania i wyszukiwania łańcuchów. Podobnie jak binarne drzewo wyszukiwaw-
cze, tak i drzewo ternarne realizuje uporządkowanie elementów na zasadzie „mniejsze na
lewo, większe na prawo". W przeciwieństwie jednak do drzewa binarnego, w którego wę-
złach zapamiętywane są kompletne wartości, węzeł drzewa ternarnego zawiera jedynie
pierwszą literę reprezentowanego przez siebie łańcucha; pozostałe litery przechowywane są
w tzw. poddrzewie kontynuacyjnym, które samo jest drzewem ternarnym i które wskazywa-
ne jest przez dodatkowy, trzeci („środkowy") łącznik w węźle (stąd nazwa drzewa: ternary
— „trójkowy").

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.

Wyjaśnimy tę zasadę na przykładzie wyszukiwania słowa BAT w drzewie z rysunku 14.1.


Rozpoczynamy od korzenia drzewa przechowującego literę C (rysunek 14.2).
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 387

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

Przechodząc ponownie do drzewa kontynuacyjnego bieżącego węzła, odnajdujemy w jego


korzeniu trzecią (i ostatnią) literę słowa BAT (rysunek 14.6.).

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.

Przypomnijmy: na każdym poziomie poszukujemy kolejnej litery słowa w drzewie binar-


nym utworzonym ze wszystkich liter tworzących ten poziom, a po jej znalezieniu powta-
rzamy tę czynność w drzewie kontynuacyjnym na bezpośrednio niższym poziomie. Można
by stąd wyciągnąć ważny wniosek, że wyszukiwanie w drzewie ternarnym musi być co
najmniej tak samo efektywne jak wyszukiwanie w binarnym drzewie wyszukiwawczym; to
prawda, zwykle jednak drzewa ternarne okazują się bardziej efektywne od swych binarnych
odpowiedników.

Wyobraźmy sobie, że poszukujemy słowa MAN w drzewie widocznym na rysunku 14.1 —


policzmy wykonywane w związku z tym porównania. Najpierw porównujemy C z M (pierwsze),
potem M z M (drugie), następnie A z A (trzecie), N z P (czwarte) i ostatecznie N z N (piąte).

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

Prezentowane dotychczas na rysunkach drzewa ternarne były drzewami wyważonymi, jed-


nak podobnie jak binarne drzewo wyszukiwawcze także i drzewo ternarne może utracić
wyważenie. Drzewo widoczne na rysunku 14.9 stanowi wynik kolejnego dodawania słów
CUP, APE, BAT, MAP i MAN, lecz gdyby kolejność tę zmienić na APE, BAT, CUP, MAN i MAP (czyli
kolejność posortowaną), drzewo to zdegenerowałoby się do postaci widocznej na rysunku 14.10.

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.

Jedną z cech charakterystycznych drzewa ternarnego jest możliwość wyszukiwania w nim


wszystkich słów o ustalonym początku (prefiksie). Wyszukiwanie to rozpoczyna się od znale-
zienia tego prefiksu, po czym przeprowadzana jest trawersacja drzewa metodą in-order, w ra-
mach której uwzględniane są tylko węzły oznakowane zawierające ostatnią literę w słowie.

Trawersacja in-order drzewa ternarnego przebiega podobnie do trawersacji in-order binar-


nego drzewa wyszukiwawczego z tą jednak różnicą że uwzględnić należy także łączniki
prowadzące do drzew kontynuacyjnych. Przy założeniu, że węzłem bieżącym jest węzeł za-
wierający ostatnią literę prefiksu, scenariusz takiej trawersacji przedstawia się następująco:

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.

Najbardziej bodaj prymitywną i naiwną metodą poszukiwania słów pasujących do wzorca


jest wypróbowywanie wszystkich możliwych kombinacji liter — w przypadku wzorca „A- -
R---T" mogłoby to być sprawdzanie kolejno ciągów „AAARAAAT", „AAARAABT", „AAARAACT"
itd. aż do „AZZRZZZT". Mimo iż jest to metoda w pełni poprawna i gwarantująca znalezienie
wszystkich pasujących do wzorca słów, to jednak jest nie do przyjęcia choćby ze względu
na wyjątkowo dużą liczbę kombinacji do sprawdzenia i potencjalnie ogromny odsetek kom-
binacji niewystępujących w przeszukiwanym słowniku. Metodę bardziej wyszukaną i znacznie
efektywniejszą oferują drzewa ternarne.
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 393

Dopasowywanie wzorca podobne jest do „normalnego" wyszukiwania słów, jeśli jednak


natrafimy na znak blankietowy, to zamiast poszukiwać (i tak nieistniejącego) węzła zawie-
rającego ten znak, musimy uwzględnić wszystkie węzły na danym poziomie.

Wyobraźmy sobie poszukiwanie w drzewie z rysunku 14.1 wszystkich słów pasujących do


wzorca „-A-". Podobnie jak w przypadku „normalnego" szukania rozpoczynamy od korze-
nia drzewa; ponieważ jednak pierwszy znak wzorca jest znakiem blankietowym, musimy
„wypróbować" wszystkie węzły na pierwszym poziomie, poczynając od najmniejszego,
czyli A — j a k przedstawiono to na rysunku 14.13.

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ń

Przechodzimy więc do następnego w kolejności alfabetycznej węzła B, który potencjalnie


może być pierwszym znakiem szukanego słowa (rysunek 14.15).
394 A l g o r y t m y . Od podstaw

Rysunek 14.15.
Pierwszy znak
wzorca jest
szablonem, więc
przechodzimy
do następnego
węzła na poziomie
pierwszym

Jak poprzednio, przechodzimy do poddrzewa kontynuacyjnego węzła B. Drugim znakiem


słowa może być tylko litera A — i tym razem pasuje ona do wzorca (rysunek 14.16).

Rysunek 14.16.
Udało się
dopasować drugi
znak wzorca

Przechodzimy więc do poddrzewa kontynuacyjnego na niższym poziomie; ponieważ trzeci


znak jest znakiem blankietowym, bieżący węzeł (T) może być trzecim znakiem szukanego
słowa (rysunek 14.17).

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ń.

Drzewa ternarne w praktyce


Po opisaniu podstawowych własności drzew ternarnych i sposobu ich wykorzystywania
czas zająć się ich praktyczną realizacją. Jak zwykle stworzymy także kilka przypadków te-
stowych pozwalających się upewnić, że tworzone przez nas implementacje funkcjonują po-
prawnie. Na zakończenie zbudujemy prostą aplikację, pomagającą w doborze słów na po-
trzeby rozwiązywania krzyżówek i innych układanek słownych.
396 Algorytmy. Od podstaw

spróbuj sam Testowanie drzewa ternarnego


Klasa weryfikująca poprawność implementacji drzewa ternarnego nosi nazwę TernarySe-
archTreeTest i zdefiniowana jest następująco:
package com.wrox.algorithms.tstrees:

import com.wrox.algorithms.1 i sts.LinkedLi st:


i mport com.wrox.algori thms.1 i sts.Li st;
import junit.framework.TestCase;

public class TernarySearchTreeTest extends TestCase {


private TernarySearchTree _tree;

protected void setUpO throws Exception {


super.setUpt):

_tree = new TernarySearchTreet);

_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");

public void testContainsO {


assertTrue(_tree.contai ns("prefabri cate"));
assertTrue(_tree.contains("presume")):
assertTrue(_tree.contains("prejudice"));
assertTrue(_tree.contai ns("preliminary"));
assertTrue(_tree.contains("apple"));
assertTrue(_tree.contains("ape"));
assertTrue(_tree.contains("appeal"));
assertTrue(_tree.contai ns("car"));
assertTrue(_tree.contai ns C"dog"));
assertTrue(_tree.contai ns("cat"));
assertTrue(_tree.conta i ns("mouse"));
assertTrue(_tree.contai ns("mi nce"));
as sertTrue(_tree.conta i ns("mi nty")):

assertFal se(_tree.contai ns("pre"));


assertFalse(_tree.contai ns("dogs"));
assertFalse( tree.containsCNIEZNANY"));

public void testPrefixSearch() {


assertPrefixEquals(new String[]
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 397

{"prefabricate". "prejudice", "preliminary". "presume"}. "pre");


assertPrefixEquals(new String[] {"ape". "appeal". "apple"}. "ap"):
}
public void testPatternMatchO {
assertPatternEquals(new String[] {"mince", "mouse"}. "m???e"):
assertPatternEquals(new String[] {"car", "cat"). "?a?"):
}
private void assertPrefixEquals(String[] expected. String prefix) {
List words = new LinkedList():

_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):

for (int i = 0 ; i < expected.length; ++i) {


assertEquals(expected[i]. actual,get(i));
}
}
1

J a k to działa?

Klasa TernarySearchTreeTest utrzymuje prywatną instancję testowanego drzewa ternarnego.


Instancja ta tworzona jest w ramach metody setUpO i inicjowana przykładową zawartością
początkową.
package com.wrox.a1gori thms.tstrees;

i mport com.wrox.algori thms.1 i sts.L i nkedL i st;


i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase:

public class TernarySearchTreeTest extends TestCase {


private TernarySearchTree _tree;

protected void setUpO throws Exception {


super.setUpO:

_tree = new TernarySearchTreet);

_tree.add("prefabricate"):
_tree.add("presume"):
398 Algorytmy. Od podstaw

_t ree.a dd("prej ud i ce"):


_t ree.add("pre1 i mi na ry");
_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");
}
}
W metodzie testContai ns() następuje sprawdzenie, czy słowa umieszczone w drzewie
przez metodę setUp() są w nim rzeczywiście obecne; dla pewności następuje też weryfika-
cja nieobecności w drzewie słów, które nie zostały do niego dodane. Zwróćmy uwagę na to,
że zawartość drzewa wybrana została szczególnie starannie: ciągi „pre" i „dogs" występują
wyłącznie jako prefiksy, lecz niejako kompletne słowa. Słowo „NIEZNANY" też powinno
zostać uznane jako nieobecne.
public void testContains() {
assertTrue(_tree.contai ns("prefabricate"));
assertTrue(_tree.contains("presume")):
assertTrue(_tree.contains("prejudice")):
assertTrue(_tree. contai nsCprel iminary"));
assertTrue(_tree.contai ns("apple")):
assertTrue(_tree.contains("ape")):
assertTrue(_tree.conta i ns("appea1"));
assertTrue(_tree.contai ns("car")):
assertTrue(_tree.contains("dog")):
assertTrue(_tree.conta i ns("cat")):
assertTrue(_tree.conta i ns("mouse")):
assertTrue(_tree.contains("mince")):
assertTrue(_tree.conta i ns("mi nty")):

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

for (int i = 0 ; i < expected.length; ++i) {


assertEquals(expected[i], actual.get(i));
}
}
Wyszukiwanie słów o ustalonym początku testowane jest przez metodę testPrefixSearch().
Tworzy ona tablicę złożoną z kilku słów i ich wspólnego prefiksu, delegując resztę pracy
do metody pomocniczej assertPrefixEquals():
public void testPrefixSearch() {
assertPrefixEquals(new String[]
{"prefabricate", "prejudice", "preliminary". "presume"}.
"pre");
assertPrefixEquals(new String[]
{"ape". "appeal", "apple"},
"ap");
}
Metoda assertPrefixEquals() tworzy instancję listy i wywołuje metodę prefixSearch()
drzewa ternarnego w celu zapełnienia tej listy słowami stanowiącymi wynik wyszukiwania.
Zawartość tej listy jest następnie porównywana z zawartością oczekiwaną.
private void assertPrefixEquals(String[] expected. String prefix) {
List words = new LinkedListt);

_tree.prefi xSearch(prefi x, words):

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

spróbuj sam Implementowanie drzewa ternarnego


Klasa TernarySearchTree implementująca ternarne drzewo wyszukiwawcze zdefiniowana
jest następująco:
package com.wrox.algori thms.tstrees:

i mport com.wrox.a1gori thms.1 i sts.Li st:

public class TernarySearchTree {


/* znak blankietowy, zastępujący dowolny "konkretny" znak */
public static finał char WILDCARD = '?';

/** Korzeń drzewa lub wartość pusta dla pustego drzewa */


private Node _root;

public void add(CharSequence word) {


assert word != nuli : "nie określono słowa":
assert word.lengtht) > 0 : "nie można używać pustych słów":

Node node = insert(_root. word. 0):


if (_root == nuli) {
_root = node:
}

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":

Node node = search(_root. word. 0):


return node != nuli && node.isEndOfWordO:
}
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":

patternMatch(_root. pattern. 0. results);


}
public void prefixSearch(CharSequence prefix. List results) {
assert prefix != nuli : "nie określono prefiksu";
assert prefix.length() > 0 : "prefiks nie może być pusty":

inOrderTraversal(search(_root. prefix, 0). results):


}
private Node search(Node node. CharSequence word. int index) {
assert word != nuli : "nie określono poszukiwanego słowa":

Node result = node:

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;

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 — 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):

if (c == WILDCARD || c < node.getCharO) {


patternMatch(node.getSmaller(). pattern, index. results);
}
if (c == WILDCARD || c == node.getCharO) {
if (index + 1 < pattern.lengthO) {
patternMatch(node.getChild(), pattern. index + 1, results);
} else if (node.isEndOfWordO) {
402 Algorytmy. Od podstaw

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);

private static finał class Node {


/** znak przechowywany w węźle */
private finał char _c;

/** lewy brat (jeśli istnieje) */


private Node _smaller;

/** prawy brat (jeśli istnieje) */


private Node J a r g e r ;

/** korzeń poddrzewa kontynuacyjnego (jeśli istnieje) */


private Node _child;

/** kompletne słowo przechowywane w węźle zawierającym jego ostatni znak */


private String _word;

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;

i mport com.wrox.a1gori thms.1 i sts.Li st;

public class TernarySearchTree {


/* znak blankietowy, zastępujący dowolny "konkretny" znak */
public static finał char WILDCARD = '?';

/** Korzeń drzewa lub wartość pusta dla pustego drzewa */


private Node _root;

}
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;

/** lewy brat (jeśli istnieje) */


private Node _smaller;

/** prawy brat (jeśli istnieje) */


private Node Jarger;

/** korzeń poddrzewa kontynuacyjnego (jeśli istnieje) */


private Node _child:

/** kompletne słowo przechowywane w węźle zawierającym jego ostatni znak */


private String _word;

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";

Node node = search(_root. word. 0);


return node != nuli && node.isEndOfWordO;
}
Pierwszym argumentem wywołania prywatnej metody searchO jest korzeń drzewa, w któ-
rym prowadzone jest poszukiwanie słowa, zaś drugim argumentem — samo poszukiwane
słowo; trzeci argument określa pozycję znaku (w słowie), którego właśnie szukamy. Wyni-
kiem zwracanym przez metodę jest wskazanie na węzeł zawierający ostatni znak szukanego
słowa lub wartość pusta, gdy szukanego słowa nie ma w drzewie.

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.

Poszukiwanie rozpoczyna się w przypadku zaistnienia co najmniej jednej z dwóch sytuacji:


wyczerpania znaków w słowie bądź wyczerpania węzłów w drzewie. W obydwu przypad-
kach metoda search() kończy pracę, a bieżący węzeł zwracany jest jako jej wynik.
private Node search(Node node. CharSequence word. int index) {
assert word != nuli : "nie określono poszukiwanego słowa":

Node result = node;


406 Algorytmy. Od podstaw

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.

Metoda addO, po zweryfikowaniu poprawności argumentu wywołania (niepuste słowo),


wywołuje metodę insertO, przekazując jako argumenty (kolejno) korzeń drzewa, wsta-
wiane słowo i pozycję pierwszego znaku w słowie. Jeżeli wywołanie metody addO nastę-
puje dla pustego drzewa, należy jeszcze uaktualnić zmienną wskazującą jego korzeń — ko-
rzeniem tym staje się oczywiście jedyny (aktualnie) węzeł drzewa.
public void add(CharSequence word) {
assert word != nuli : "nie określono słowa";
assert word.lengthO > 0 : "nie można używać pustych słów":

Node node = insert(_root, word. 0);


if (_root == nuli) {
_root = node;
}
}
Metoda insertO dokonuje określenia bieżącego znaku w słowie. Następnie, jeżeli nie jest
określony węzeł bieżący, tworzony jest nowy węzeł, który staje się bieżącym — było nie
było, dokonujemy przecież dodawania słów.

Wspomniany bieżący znak słowa porównywany jest ze znakiem zapamiętanym w bieżą-


cym węźle. Jeżeli znaki te są równe, możliwe są dwa przypadki: jeśli bieżący znak nie jest
ostatnim w słowie, następuje (rekurencyjne) przejście do następnego znaku i do korzenia
poddrzewa kontynuacyjnego, w przeciwnym razie dodawany węzeł znakowany jest jako
ostatni w słowie i wstawianie się kończy.

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":

inOrderTraversal(search(_root, prefix. 0), results):


}
Metoda inOrderTraversal O dokonuje rekurencyjnego przejścia metodą in-order przez
drzewo ternarne, co w związku z odwiedzeniem danego węzła sprowadza się do przejścia
metodą in-order przez (kolejno) poddrzewo zakorzenione w lewym bracie, poddrzewo
kontynuacyjne i poddrzewo zakorzenione w prawym bracie. Każdorazowo, gdy napotykany
jest końcowy węzeł słowa (node. isEndOfWord()), słowo to dodawane jest do listy wynikowej.
private void inOrderTraversal(Node node. List results) {
assert results !- nuli : "nie określono listy wynikowej":

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";

patternMatch(_root, pattern. 0. results);


}
Drugi wariant metody patternMatch() wygląda podobnie do przechodzenia przez drzewo me-
todą in-order, z pewnymi ograniczeniami.

Po pierwsze, zamiast konsekwentnej trawersacji in-order obydwu braci następuje spraw-


dzenie, który brat powinien zostać takiej trawersacji poddany: jeżeli bieżący znak wzorca
jest mniejszy (odpowiednio: większy) od znaku zapamiętanego w bieżącym węźle, należy
wykonać przejście metodą in-order przez poddrzewo, którego korzeniem jest lewy (odpowied-
nio: prawy) brat. Jeśli bieżący znak wzorca równy jest znakowi zapamiętanemu w węźle
bieżącym, należy przejść rekurencyjnie do poddrzewa kontynuacyjnego, biorąc następny
znak z wzorca.

Po drugie, jeśli bieżący znak wzorca jest znakiem blankietowym (WILDCARD), trawersacja
dotyczy obydwu braci — znak blankietowy zastępuje bowiem dowolny znak.

Wreszcie, wyszukiwanie powinno uwzględniać jedynie słowa o długości tożsamej z długo-


ścią wzorca.
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);

if (c — WILDCARD || c < node.getCharO) {


patternMatch(node.getSmaller(). pattern, index, results);
}
if (c == WILDCARD || c — node.getCharO) {
if (index + 1 < pattern.lengthO) {
patternMatch(node.getChild(), pattern. index + 1, results);
} else if (node.isEndOfWordO) {
results,add(node.getWord());
}
}
if (c == WILDCARD || c > node.getCharO) {
patternMatch(node.getLargerO, pattern. index, results);
}
}
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 409

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.

I M i f f T W l Tworzenie aplikacji wspomagającej rozwiązywanie krzyżówek


Klasa naszej aplikacji zdefiniowana jest następująco:
package com.wrox.algori thms.tstrees;

i mport com.wrox.algori thms.i terati on.Iterator;


i mport com.wrox.a 1gori thms.1 i sts.Li nkedL i st;
i mport com.wrox.algori thms.1 i sts.Li st;

import java.io.BufferedReader:
import java.io.FileReader;
import java.io.I0Exception:

public finał class CrosswordHelper {


private CrosswordHelper() {
}
public static void main(String[] args) throws I0Exception {
assert args != nuli : "nie podano parametrów";

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";

System.out.printlnCDopasowywanie wzorca "' + pattern + '"..." + repetitions +


"razy");

List words = nuli;


410 Algorytmy. Od podstaw

for (int i = 0; i < repetitions; ++i) {


words = new LinkedListO;
tree.patternMatch(pattern, words);
}
Iterator iterator = words.iteratorO;

for (iterator. firstO; ! i terator. isDoneO; i terator. next()) {


System.out.pri nt1n(i terator.current O ) :
}
}
private static TernarySearchTree loadWords(String fileName) throws IOException {
TernarySearchTree tree = new TernarySearchTreeO;

System.out.println("Ładowanie słów z pliku "' + fileName + "'...");

BufferedReader reader = new BufferedReader(new FileReader(fileName));

try {
String word;

while ((word = reader.readLineO) !- nuli) {


tree.add(word);
}
} finałly {
reader.close();
}
return tree;
}
}

J a k to działa?

Wykonywanie aplikacji rozpoczyna się od metody mainO klasy Crosswordhelper. Metoda


ta sprawdza, czy w wywołaniu określono przynajmniej dwa parametry — pierwszy z nich
reprezentuje nazwę pliku zawierającego listę słów i dopasowywany wzorzec. Pierwszy pa-
rametr, args[0], przekazywany jest do metody loadWordsO, której zadaniem jest ułożenie
słów zawartych w pliku w strukturę drzewa ternarnego. Drzewo to, wraz z drugim argu-
mentem, args[l], reprezentującym poszukiwany wzorzec, przekazywane jest do metody
searchForPatternO dokonującej właściwego dopasowania:
package com.wrox.algorithms.tstrees:

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;

import java.i o.BufferedReader;


import java.io.FileReader;
import java.io.IOException;

public finał class CrosswordHelper {


private CrosswordHelperO {
}

public static void main(String[] args) throws I0Exception {


assert args != nuli : "nie podano parametrów";
Rozdział 14. • Ternarne d r z e w a w y s z u k i w a w c z e 411

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():

System.out.println("Ładowanie słów z pliku "' + fileName + "'...");

BufferedReader reader = new BufferedReadertnew FileReader(fileName)):

try {
String word;

while ((word = reader.readLine()) != nuli) {


tree.add(word):
}
} finally {
reader.closeO:
}
return tree;
}
Właściwe dopasowywanie wzorca przeprowadzane jest w ramach metody searchForPat-
ternO. Metoda ta tworzy listę przeznaczoną na magazynowanie słów zwracanych jako
wynik wyszukiwania, przekazuje tę listę (wraz z wzorcem dopasowania) do metody pat-
ternMatch() drzewa ternarnego i po powrocie z metody iteruje po zawartości tej listy w celu
kolejnego wyświetlania dopasowanych słów.
private static void searchForPattern
(TernarySearchTree tree, String pattern, int repetitions) {
assert tree != nuli : "nie określono drzewa";

System.out.println("Dopasowywanie wzorca "' + pattern + "'..." + repetitions + "razy");


List words = nul 1;

for (int i - 0; i < repetitions: ++i) {


words = new LinkedList();
tree.patternMatch(pattern. words);
}
Iterator iterator = words.iterator();
412 Algorytmy. Od podstaw

for (iterator.firstO; Mterator.isDoneO; iterator.next()) {


System.out.printlntiterator.currentt));
}
}
Aplikację tę uruchomiliśmy dla pliku zawierającego 144 000 słów angielskich i zażądali-
śmy znalezienia wszystkich słów pasujących do wzorca a?r???t. Na konsoli ujrzeliśmy na-
stępujący wynik:
Ładowanie słów z pliku 'words.txt'...
Dopasowywanie wzorca 'a?r???t"...
abreact
abreast
acrobat
aeriest
airboat
airiest
airlift
airport
airpost
alright
apricot

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:

• Dlaczego omawiane dotychczas struktury danych okazują się nieodpowiednie


do przetwarzania danych dyskowych?
• Jak B-drzewa radzą sobie z problemami typowymi dla innych struktur danych?
• Jak można zaimplementować mapę na bazie B-drzewa?

Podstawowe własności B-drzew


W rozdziale 13. omawialiśmy implementację mapy w oparciu o binarne drzewo wyszuki-
wawcze. Oczywiście nietrudno sobie wyobrazić przechowywanie takiego drzewa na dysku,
wczytywanie go w całości do pamięci, dokonywanie odpowiednich modyfikacji i zapisy-
wanie z powrotem na dysk. Rzecz jasna rozmiar takiego drzewa rośnie wraz z rozmiarem
bazy danych: wyobraźmy sobie bazę z milionem rekordów indeksowaną kluczami dziesię-
cioznakowymi. Jeśli w każdym węźle drzewa, oprócz dziesięcioznakowego klucza, należy
przechować numer odnośnego rekordu (4 bajty) i trzy czterobajtowe łączniki — do ojca i dwóch
synów — to łatwo policzyć, że rozmiar węzła wynosić musi co najmniej 26 bajtów. Drzewo
złożone z miliona takich węzłów zajmie — j a k łatwo policzyć — 26 000 000 bajtów.

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.

Można by w tym momencie argumentować, że można by wczytywać tylko te węzły drze-


wa, które okazują się w danej chwili potrzebne, zamiast wczytywać od razu całe drzewo.
Nierealność tego pomysłu okazuje się ewidentna, jeśli wziąć pod uwagę sposób działania
pamięci dyskowych. Nawet przy założeniu idealnego zrównoważenia drzewo o milionie wę-
złów będzie mieć wysokość równą log 2 l 000 000 ~ 20 i tyleż średnio węzłów trzeba będzie
odwiedzić w związku z (na przykład) wyszukiwaniem informacji. Oznacza to 20 od-
rębnych odczytów z dysku; mimo iż każdy z wczytywanych węzłów jest stosunkowo mały,
to jednak dane dyskowe wczytywane są całymi blokami (określanymi niekiedy mianem
stroń) i wczytanie całego bloku zawierającego (powiedzmy) 20 węzłów trwa tyle samo co
wczytanie pojedynczego węzła.

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.

W celu rozwiązania tych — i kilku innych — problemów opracowano struktury zwane B-


drzewami] (B-trees) umożliwiające efektywne wstawianie, usuwanie i wyszukiwanie danych
przechowywanych w pamięciach dyskowych.
Istnieje wiele odmian B-drzew — B+drzewa, Bxdrzewa itd. — zaprojektowanych
w celu rozwiązywania pewnych konkretnych aspektów wyszukiwania informacji
w pamięciach dyskowych. Każda z tych odmian ma jednak swe źródło w podstawowej
wersji B-drzewa opisywanej w niniejszym rozdziale. Czytelników zainteresowanych
bardziej szczegółowymi informacjami na temat B-drzew odsyłamy do książek
[Cormen, 2001], [Sedgewick, 2002] i [Folk, 1991].

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

Poszukiwanie klucza w B-drzewie przebiega podobnie jak w drzewie binarnym z t ą oczy-


wistą różnicą, że w węźle B-drzewa znajduje się wiele łączników do węzłów potomnych;
zamiast więc wyboru między lewym a prawym synem w B-drzewie dokonywać musimy
wyboru wśród wielu potomków.

Zilustrujemy to na przykładzie poszukiwania klucza G w B-drzewie z rysunku 15.1. Rozpoczy-


namy od korzenia i porównujemy jego pierwszy klucz (D) z kluczem szukanym (rysunek 15.2).

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

Porównując kolejne klucze w tym węźle z kluczem poszukiwanym, osiągamy ostatecznie


cel poszukiwań (rysunek 15.5).

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.

Zakładając, że wielkość bloku dyskowego wynosi B, a długość klucza — K, i że łączniki do


węzłów potomnych zajmują po L bajtów, otrzymujemy oczywisty związek z maksymalną
liczbą W kluczy w bloku:

WxK + (W + \)xL<B

skąd natychmiast wyliczamy maksymalną liczbę kluczy w węźle o wielkości bloku:


B-L
W<
K + L

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.

W celu wstawienia klucza do B-drzewa należy rozpocząć wędrówkę od korzenia i poruszać


się w głąb drzewa, aż do poziomu liści, wybierając na każdym poziomie odpowiedni łącz-
nik potomny wynikający z relacji wstawianego klucza do kluczy zawartych w odwiedza-
nych węzłach. Dotrzemy w ten sposób do liścia, do którego należy dodać wstawiany klucz
— oczywiście wstawiając go na odpowiednią pozycję wśród kluczy już istniejących. Na ry-
sunku 15.6 przedstawiono dodawanie klucza L do drzewa z rysunku 15.1.

Rysunek 15.6.
Nowe klucze
zawsze dodawane
są do liści
A B C E F G 1 J K L

Dodanie nowego klucza do liścia może powodować przekroczenie jego maksymalnego


rozmiaru — i tak właśnie stało się w sytuacji przedstawionej na rysunku 15.6, gdzie węzły
nie mogą zawierać więcej niż trzy klucze. Taki „przepełniony węzeł" musi zostać podzie-
lony (równomiernie) na dwa mniejsze, jak na rysunku 15.7.

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

Jeżeli teraz usunęlibyśmy klucze I oraz J, znowu otrzymalibyśmy „patologiczną" strukturę


wymagającą korekcji (rysunek 15.14).

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

Praktyczne wykorzystywanie B-drzew


Po omówieniu zasad budowy i funkcjonowania B-drzew zajmijmy się ich praktyczną im-
plementacją. Jak wspominaliśmy wcześniej, B-drzewa wykorzystywane są zwykle do bu-
dowy indeksów, więc prezentowany przez nas praktyczny przykład stanowić będzie de
facto implementację interfejsu Map (opisywanego w rozdziale 13.) na bazie B-drzewa. By
jednak przykładu tego zbytnio nie komplikować, całe to B-drzewo przechowywane będzie
w pamięci operacyjnej, nie na dysku.

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],

Rozpoczniemy od zdefiniowania klasy testowej weryfikującej poprawność implementacji


B-drzewa.

spróbuj sam Testowanie B-drzew


Klasa testowa dla implementacji B-drzew zdefiniowana jest następująco:
package com.wrox.algori thms.btrees:

import com.wrox.algorithms.maps.AbstractMapTestCase;
import com.wrox.algori thms.maps.Map;
import com.wrox.algorithms.sorting.NaturalComparator;

public class BTreeMapTest extends AbstractMapTest {


protected Map createMap() {
return new BTreeMap(NaturalComparator.INSTANCE. 2 ) ;
420 Algorytmy. Od podstaw

J a k to działa?

Abstrakcyjną klasę testową AbstractMapTest obejmującą te aspekty testowania map, które


są niezależne od ich konkretnej implementacji, opisaliśmy w rozdziale 13. Jedyną rzeczą
jaka pozostała do zrobienia w ramach klasy BTreeMapTest, dedykowanej implementacji
mapy na bazie B-drzew, jest zdefiniowanie metody createMap() w taki sposób, by tworzyła
i zwracała instancję takiej właśnie implementacji, czyli klasy BTreeMap. Konstruktor klasy
BTreeMap posiada dwa parametry reprezentujące (odpowiednio) komparator wyznaczający
porządek kluczy oraz maksymalną liczbę kluczy w węźle B-drzewa. Fakt, że staramy się
utrzymać tę drugą wartość na jak najniższym poziomie, może zdawać się przeczyć samej
idei B-drzewa, czyli pakowania kluczy w jak największe węzły; tak naprawdę jednak mały
limit liczby kluczy w węźle skutkuje częstymi podziałami węzłów, a właśnie implementa-
cję tych podziałów chcielibyśmy przetestować jak najdokładniej.

Po stworzeniu klasy testowej dla B-drzewa implementującego mapę pora na szczegóły sa-
mej implementacji.

spróbuj sam Implementowanie mapy na bazie B-drzewa


Klas BTreeMap, implementująca — zgodnie z nazwą— mapę w oparciu o B-drzewo, zdefi-
niowana jest następująco.
package com.wrox.a1gori thms.btrees;

i mport com.wrox.algori thms.i terati on.Iterator;


i mport com.wrox.a1gori thms.1 i sts.ArrayLi st:
i mport com.wrox.algori thms.1 i sts.EmptyLi st:
i mport com.wrox.algori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.maps.DefaultEntry:
import com.wrox.algorithms.maps.Map;
i mport com.wrox.algori thms.sorti ng.Compa rator;

public class BTreeMap implements Map {


/* minimalna wartość maksymalnej liczby kluczy w węźle */
private static finał int MIN_KEYS_PER_NODE = 2:

/* komparator wyznaczający porządek kluczy */


private finał Comparator _comparator;

/* maksymalna liczba kluczy w węźle */


private finał int _maxKeysPerNode;

/* korzeń B-drzewa */
private Node _root;

/* liczba kluczy w B-drzewie */


private int _size;

public BTreeMap(Comparator comparator. int maxKeysPerNode) {


assert comparator !- nuli : "nie określono komparatora":
assert maxKeysPerNode > - MIN_KEYS_PER_N0DE :
"limit kluczy w węźle nie może być mniejszy niż " + MIN_KEYS_PER_NODE;

_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 {

List list = new ArrayList(_size);

_root.traverse(list);

return list.iteratorO;
}
private finał class Node {
422 Algorytmy. Od podstaw

private finał List _entries = new ArrayList(_maxKeysPerNode + 1):


private finał List _chi1 dren:

public Node(boolean leaf) {


_children = !leaf ? new ArrayList(_maxKeysPerNode + 2) :
(List) EmptyList.INSTANCE;
}
public boolean isFullO {
return _entries.sizeO > _maxKeysPerNode:
}
public Entry search(Object key) {
int index = indexOf(key):
if (index >= 0) {
Entry entry = (Entry) _entries,get(index);
return lentry.isDeletedO ? entry : nuli:
}
return !isLeaf() ? ((Node) _children.get(-(index + 1))).search(key) : nuli;
}
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 nuli:
}
Node child = ((Node) _children.get(index)):
Object oldValue - child.set(key. value);

i f (child.isFullO) {
child.splittthis. index):
}
return oldValue:
}
private int index0f(0bject key) {
int lowerIndex = 0:
int upperlndex = _entries.sizeO - 1:

while (lowerIndex <- upperlndex) {


int index = lowerIndex + (upperlndex - lowerIndex) / 2:

int cmp = _comparator.compare(key, (


(Entry) _entries.get(index)).getKeyO):
if (cmp —- 0) {
Rozdział 15. • B-drzewa 423

return index;
} else if (cmp < 0) {
upperlndex - index - 1;
} else {
lower!ndex = index + 1;

return -(lower!ndex + 1);

public void split(Node parent. int insertionPoint) {

assert parent != nuli : "nie określono węzła macierzystego";

Node sibling - new Node(isLeafO);

int middle = _entries.sizeO / 2;


move(_entries, middle + 1, sibling._entries);
move(_children. middle + 1, sibling._children);

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):

public void traverse(List list) {


assert list !- nuli : "nie określono listy wynikowej":

Iterator children = _children.iteratorO;


Iterator entries = _entries.iteratorO;

children.firstO:
entries.fi r s t O ;

while (Ichildren. isDoneO || lentries.isDoneO) {


if (Ichildren.isDoneO) {
((Node) chi 1 dren.current O).tra verse(list):
children.next():
}
if (lentries.isDoneO) {
Entry entry = (Entry) entries.currentO;
if (lentry.isDeletedO) {
list.add(entry);
}
entries.next();

private void move(List source. int from. List target) {


assert source !» nuli : "nie określono argumentu źródłowego";
assert target 1= nuli : "nie określono argumentu docelowego";
while (from < source.sizeO) {
424 Algorytmy. Od podstaw

target.add(source.delete(from));
}
}
private boolean isLeafO {
return _children — EmptyList.INSTANCE:
}
}
private static finał class Entry extends DefaultEntry {
private boolean _deleted:

public Entry(Object key. Object value) {


super(key. value);
}
public boolean isDeletedO {
return _deleted:
}
public void setDeleted(boolean deleted) {
_deleted = deleted;
1
}
)

J a k to działa?

Klasa BTreeMap definiuje zmienne reprezentujące komparator wyznaczający porządek kluczy,


maksymalną liczbę kluczy w węźle oraz liczbę pozycji w mapie. Zwróćmy uwagę na to, że
limit liczby kluczy w węźle nie może być mniejszy niż 2. Wynika to z faktu, że w węźle
podlegającym podziałowi muszą być co najmniej trzy klucze: środkowy jest wówczas win-
dowany do węzła macierzystego, a dwa skrajne zajmują (jako jedyne) miejsca w węzłach
potomnych. Gdyby limit ten określony został jako 1, nie byłoby możliwości podzielenia
węzła zawierającego dwa klucze.
package com.wrox.a 1gori thms.bt rees;

i mport com.wrox.a1gori thms.i terat i on.Iterator:


i mport com.wrox.algori thms.1 i sts.ArrayLi st:
i mport com.wrox.a1gori thms.łi sts.EmptyLi st;
import com.wrox.algorithms.lists.List;
i mport com.wrox.algori thms.maps.DefaultEntry;
import com.wrox.algori thms.maps.Map;
i mport com.wrox.a 1gori thms.sorti ng.Comparator;

public class BTreeMap implements Map {


/* minimalna wartość maksymalnej liczby kluczy w węźle */
private static finał int MIN_KEYS_PER_NODE = 2;

/* komparator wyznaczający porządek kluczy */


private finał Comparator _comparator;

/* maksymalna liczba kluczy w węźle */


Rozdział 15. • B-drzewa 425

private finał int _maxKeysPerNode;

/* korzeń B-drzewa */
private Node _root;

/* liczba kluczy w B-drzewie */


private int _size:

public BTreeMap(Comparator comparator, int max<eysPerNode) {


assert comparator != nuli : "nie określono komparatora";
assert maxKeysPerNode >= MIN_KEYS_PER_NODE :
"limit kluczy w węźle nie może być mniejszy niż " + MIN_KEYS_PER_NODE;

_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;

public Entry(Object key. Object value) {


supertkey, value);
}
public boolean isDeletedO {
return _deleted;
}
public void setDeleted(boolean deleted) {
_deleted = deleted;
}
1

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;

public Node(boolean leaf) {


_children = M e a f ? new ArrayList(_maxKeysPerNode + 2) :
(List) EmptyList.INSTANCE;
}
public boolean isFullO {
return _entries.sizeO > _maxKeysPerNode;
}
private boolean isLeafO {
return _children — EmptyList.INSTANCE;
}

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;

while (lowerIndex <= upperlndex) {


int index = lowerIndex + (upperlndex - lowerIndex) / 2;

int cmp = _comparator.compare(key, (


(Entry) _entries.get(index)),getKey());

if (cmp == 0) {
return index:
} else if (cmp < 0) {
upperlndex = index - 1;
} else {
lowerIndex = index + 1;

return -(lowerIndex + 1);


}
Oprócz wyszukiwania konkretnego klucza w danym węźle pozostaje jeszcze kwestia znaj-
dowania samego węzła zawierającego ten klucz. Wykonująca to zadanie metoda searchO
rozpoczyna swą pracę od próby znalezienia żądanego klucza w bieżącym węźle i w przy-
padku jego znalezienia (index >= 0) zwraca znalezioną pozycję (lub wartość pustą gdy po-
Rozdział 15. • B-drzewa 427

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ł.

Parametry wywołania realizującej taki podział metody s p l i t O reprezentują (kolejno) wę-


zeł macierzysty i miejsce, na którym w węźle tym znaleźć ma się łącznik do nowo tworzo-
nego węzła potomnego. Metoda rozpoczyna swą pracę od utworzenia nowego węzła jako
swego brata — zachowywany jest typ węzła, bowiem brat liścia (odpowiednio: węzła po-
średniego) też jest liściem (odpowiednio: węzłem pośrednim). Następnie do nowo utwo-
rzonego węzła przenoszona jest „górna połówka" pozycji i łączników potomnych, po czym
środkowa pozycja przenoszona jest do węzła macierzystego, a po niej umieszczany jest
łącznik do wspomnianego nowo utworzonego węzła. Łącznik do oryginalnego węzła pod-
legającego podziałowi jest umieszczany w węźle macierzystym jedynie wtedy, gdy ten
ostatni jest węzłem nowo utworzonym, czyli gdy nie posiada węzłów potomnych.
public void split(Node parent. int insertionPoint) {

assert parent != nuli : "nie określono węzła macierzystego":

Node sibling = new Node(isLeafO):

int middle = _entries.sizeO / 2;


move(_entries, middle + 1. sibling._entries):
move(_children. middle + 1. sibling._children):

parent._entri es.i nsert(i nserti onPoi nt. _entri es.delete(mi ddle)):

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":

while (from < source.sizeO) {


target.add(source.delete(from));
}
}
428 Algorytmy. Od podstaw

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 związku z tym metoda s e t ( ) zrealizowana została w dwóch wariantach. Pierwszy z nich


rozpoczyna swą pracę od próby znalezienia specyfikowanego klucza w węźle. Jeśli klucz
ten zostanie znaleziony (index >= 0), w pozycji, którą identyfikuje, aktualizowana jest
wartość, a wartość poprzednia zwracana jest jako wynik metody. Jeśli specyfikowany klucz
nie zostanie w węźle znaleziony, być może jest w ogóle w drzewie nieobecny i musi zostać
wstawiony, lecz równie dobrze może znajdować się w węźle potomnym. Związana z tym
logika jest treścią drugiego wariantu metody s e t ( ) .

Ó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

public void traverse(List list) {


assert list != nuli : "nie określono listy wynikowej";

Iterator children = _children.iteratorO;


Iterator entries = _entries.iteratorO;

children.firstO;
entries.firstO;

while (Ichildren. isDoneO || ! entries. i sDoneO) {


if (Ichildren.isDoneO) {
((Node) chi 1dren.current O).traverse(1 i st);
children.next();

if (lentries.isDoneO) {
Entry entry = (Entry) entries.currentO;
if (lentry.isDeletedO) {
list.add(entry):

entries.next();

Na tym zakończyliśmy omawianie wewnętrznej klasy Node, przejdźmy zatem do głównej


klasy BTreeMap implementującej metody interfejsu Map.

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 {

List list = new ArrayList(_size);

_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

public boolean isEmptyO {


return sizeO — 0:
}
Jak uprzedzaliśmy, przedstawiona implementacja B-drzewa jest implementacją czysto pa-
mięciową działającą bez związku z pamięcią dyskową. Zaimplementowanie B-drzewa w wersji
dyskowej nie jest specjalnie trudne, lecz jest oczywiście bardziej skomplikowane. Czytelników
zainteresowanych szczegółami takiej implementacji odsyłamy do książki [Cormen, 2001].

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.

Istnieje wiele algorytmów wyszukiwania łańcuchów(string searching) — i wciąż wynajdy-


wane są nowe — zoptymalizowanych pod kątem przetwarzania danych określonego typu;
niektóre z nich okazują się szczególnie efektywne w odniesieniu do zwykłego tekstu, inne
przeznaczone są raczej do rozpoznawania wzorców w treści charakteryzującej się dużą ilo-
ścią powtórzeń, jak na przykład fragmenty kodu DNA.

W niniejszym rozdziale omawiamy dwa algorytmy wyszukiwania łańcuchów w zwykłym


tekście. Pierwszy z nich jest prymitywnym, „siłowym" porównywaniem szukanego wzorca
z kolejnymi fragmentami tekstu, drugi, wynaleziony przez Boyera i Moore'a, jest bardziej
inteligentny i bardziej efektywny. W kolejnych punktach rozdziału:
• opiszemy i przedstawimy implementację wymienionych algorytmów,
• wyjaśnimy przyczyny różnic w efektywności każdego z nich,
• opiszemy i zaimplementujemy uogólniony iterator dopasowywania łańcuchów,
• stworzymy prostą aplikację do przeszukiwania zawartości plików.

Interfejs wyszukiwarki łańcuchów


Ponieważ, jak wspominaliśmy, istnieje wiele różnych algorytmów wyszukiwania łańcuchów,
a każdy z nich można dodatkowo przystosowywać do szczególnych potrzeb, konieczne jest
zdefiniowanie interfejsu ujmującego w sposób jednolity — niezależny od konkretnego al-
gorytmu — funkcjonalność takiego wyszukiwania z punktu widzenia programisty. Rzecz
434 Algorytmy. Od podstaw

jasna interfejs taki umożliwi tworzenie uniwersalnych zestawów testowych weryfikujących


poprawność dowolnych wyszukiwarek łańcuchów, niezależnie od szczegółów ich imple-
mentacji.

spróbuj sam Tworzenie interfejsu wyszukiwarki


Definicja wspomnianego interfejsu jest następująca:
package com.wrox.algorithms.ssearch;

public interface StringSearcher {


public StringMatch search(CharSequence text, int from);

Wykorzystuje ona klasę StringMatch do reprezentowania wyniku zwracanego przez metodę


searchO:
package com.wrox.algori thms.ssearch;

public class StringMatch {


/** poszukiwany wzorzec */
private finał CharSequence _pattern;

/** tekst, w którym prowadzone jest wyszukiwanie */


private finał CharSequence _text;

/** pozycja (0. 1, 2...) pierwszego wystąpienia wzorca w poszukiwanym tekście */


private finał int _index;

public StringMatch(CharSequence pattern, CharSequence text. int index) {


assert text != nuli : "nie określono tekstu";
assert pattern != nuli : "nie określono wzorca";
assert index >= 0 : "pozycja startowa nie może być ujemna":

_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).

Zauważmy, że zarówno przeszukiwany tekst, jak i poszukiwany wzorzec, reprezentowane


są w postaci klasy CharSequence, a nie w postaci klasy String. Jest to związane z faktem, że
większość edytorów przechowuje edytowany tekst nie w formie łańcucha klasy String,
lecz w formie bufora znakowego, reprezentowanego przez klasę StringBuffer. Ponieważ
klasy te nie mają z sobą wiele wspólnego, więc tak naprawdę z każdą z nich powinien być
związany odrębny interfejs wyszukiwarki i odrębna implementacja. Na szczęście jednak
w standardowej bibliotece Javy zdefiniowany jest interfejs CharSequence implementowany
przez obydwie klasy, dostarczający metod niezbędnych dla algorytmów wyszukiwania.

Zestaw testowy dla wyszukiwarki łańcuchów


Nawet jeżeli sama koncepcja wyszukiwania łańcuchów wydaje się oczywista, to jednak re-
alizujące to wyszukiwanie algorytmy zawierać mogą liczne subtelności stanowiące okazję
do popełniania rozmaitych błędów programistycznych. Konieczne jest więc opracowanie
testów weryfikujących poprawność implementacji algorytmów wyszukiwania; zadanie to
jest znacznie ułatwione dzięki istnieniu interfejsu StringSearcher, niezależnie bowiem od
stopnia wyrafinowania poszczególnych algorytmów metody weryfikowania ich poprawno-
ści pozostają takie same.

Wykonywać będziemy trzy rodzaje testów, polegające na wyszukiwaniu wzorca (podłań-


cucha) znajdującego się (odpowiednio) na początku, na końcu i pośrodku przeszukiwanego
łańcucha, oraz czwarty test polegający na wyszukiwaniu wzorca w łańcuchu zawierającego
wiele nakładających się jego wystąpień.

Tworzenie klasy testowej


Wszystkie opisywane w niniejszym rozdziale wyszukiwarki cechują się jednolitym zacho-
waniem, a wszelkie testy wspólne temu zachowaniu ujęte są w ramy abstrakcyjnej klasy
AbstractStringSearcherTest zadeklarowanej następująco:
package com.wrox.algori thms.ssearch;

import junit.framework.TestCase;
436 Algorytmy. Od podstaw

public abstract class AbstractStringSearcherTest extends TestCase {


protected abstract StringSearcher createSearcher(CharSequence pattern):

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));
}

Następnie testować będziemy poprawność wykrywania wzorca znajdującego się na początku


przeszukiwanego łańcucha...
public void testFindAtTheStart() {
String text = "Znajdź mnie na początku";
String pattern = "Znajdź";

StringSearcher searcher = createSearcher(pattern):

StringMatch match = searcher.search(text, 0);


assertNotNul1(match);
assertEquals(text. match.getText());
assertEquals(pattern. match.getPatternO);
assertEquals(0, match.getlndex());

assertNull(searcher.search(text. match.getIndex() + 1));


}

... na jego końcu...


public void testFindAtTheEnd() {
String text = "Znajdź mnie na końcu";
String pattern = "końcu";

StringSearcher searcher = createSearcher(pattern);

StringMatch match = searcher.search(text. 0);


assertNotNul1(match):
assertEquals(text, match.getText());
assertEquals(pattern. match.getPattern());
assertEquals(15. match.getIndex());

assertNull(searcher.search(text, match.getlndex() + 1));


}

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":

StringSearcher searcher = createSearcher(pattern):

StringMatch match = searcher.search(text, 0):


assertNotNull(match):
assertEquals(text. match.getText());
assertEquals(pattern. match.getPatternO);
assertEquals(14, match.getIndex());

assertNul1(searcher.search(text. match.getlndex() + 1)):


}

Zgodnie z wcześniejszą zapowiedzią przetestujemy też wykrywanie wzorca w łańcuchu


zawierającego wiele jego nakładających się wystąpień. Coś takiego nie zdarza się co praw-
da często w typowych tekstach, mimo to wyszukiwarka powinna takie sytuacje bezbłędnie
obsługiwać, wykrywając poprawnie każde wystąpienie:
public void testFindOverlapping() {
String text = "abcdefffff-fedcba":
String pattern = "fff";

StringSearcher searcher = createSearcher(pattern):

StringMatch match = searcher.search(text, 0):


assertNotNull(match);
assertEquals(text, match.getText());
assertEquals(pattern, match,getPattern());
assertEquals(5, match.getIndex());

match = searcher.search(text. match.getlndex() + 1);


assertNotNull(match);
assertEquals(text, match.getText()):
assertEquals(pattern. match,getPattern());
assertEquals(6, match.getIndex());

match - searcher.search(text. match.getlndex() + 1);


assertNotNull(match);
assertEqua1s(text. match.getText()):
assertEquals(pattern, match.getPattern()):
assertEquals(7, match.getIndex());

assertNull(searcher.search(text, match.getlndex() + 1));


}

J a k to działa?

Zgodnie z konwencją interfejsu StringSearcher poszukiwany wzorzec przechowywany jest


w ramach instancji klasy implementującej ten interfejs — nie jest on parametrem wywoła-
nia metody searchO; musi być więc przekazany jako parametr metody createSearcherO
438 Algorytmy. Od podstaw

abstrakcyjnej klasy testowej, która to metoda odpowiedzialna jest (po skonkretyzowaniu)


za tworzenie wspomnianej instancji. Wywołanie metody createSearcherO z odpowiednim
parametrem występuje więc na początku każdego testu.

W pierwszym teście weryfikujemy poprawność wyniku wyszukiwania w pustym łańcuchu


— wynik ten zawsze powinien być wartością pustą (nul 1).

Wyszukiwanie wzorca występującego na początku przeszukiwanego łańcucha powinno


zwracać niepusty wynik informujący o wykryciu wzorca na pozycji 0. Ponieważ wzorzec i łań-
cuch zostały tak dobrane, że początkowe wystąpienie wzorca jest jego jedynym wystąpie-
niem, próba znalezienia kolejnego wystąpienia powinna zwrócić jako wynik wartość pustą.

Analogicznie w trzecim teście poszukiwany wzorzec znajduje się na końcu przeszukiwanego


łańcucha i jest to zarazem jego jedyne wystąpienie w tym łańcuchu. Podobnie ma się rzecz
w teście czwartym, gdzie poszukiwany wzorzec znajduje się wewnątrz przeszukiwanego
łańcucha.

Ostatni test jest nieco bardziej skomplikowany od pozostałych, bowiem przeszukvNatvy


łańcuch (abcdefffff-fedcba) zawiera trzy nakładające się wystąpienia wzorca f f f , na po-
zycjach 5, 6 i 7. Przedmiotem testu jest weryfikacja poprawnego wykrywania wszystkich
tych wystąpień.

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.

Prymitywny algorytm wyszukiwania


Najbardziej oczywistą i banalną metodą wyszukiwania wzorca w tekście jest sekwencyjne
skanowanie tego ostatniego. Ów „siłowy" (brute-force) algorytm nie dość, że jest bardzo
prosty koncepcyjnie i łatwy w implementacji, to jeszcze sprawdza się znakomicie w wielu
przypadkach.

„Siłowe" wyszukiwanie wzorca polega na sukcesywnym porównywaniu go z kolejnymi


fragmentami tekstu, poczynając od początku tego ostatniego i przesuwając się każdorazowo
o jedną pozycje w przód. Postępowanie to da się opisać za pomocą następującego scenariusza:
1. Ustaw wzorzec tak, by jego pierwszy znak pokrywał się z pierwszym znakiem
przeszukiwanego tekstu.
2. Porównaj, kolejno od lewej do prawej, poszczególne znaki wzorca z odpowiadającymi
im znakami tekstu.
3. Jeśli we wszystkich porównywanych parach znaki są te same, oznacza to znalezienie
wystąpienia wzorca.
4. Jeśli osiągnięto koniec przeszukiwanego tekstu, oznacza to, że w tekście nie ma więcej
wystąpień wzorca. Wyszukiwanie zostaje zakończone.
Rozdział 16. • Wyszukiwanie tekstu 439

5. Jeśli nie zachodzi żaden z przypadków wymienionych w punktach 3. i 4., przesuń


wzorzec w prawo o jeden znak w stosunku do przeszukiwanego tekstu i przejdź
do punktu 2.

Poniżej prezentujemy zastosowanie opisanego algorytmu do znajdowania wystąpień wzorca


ring w łańcuchu String Search. Początkowo wzorzec ring porównywany jest z podłańcuchem
Stri, potem z podłańcuchem trin, wreszcie z podłańcuchem ring — zgodnie z punktem 3.
stanowiącym wystąpienie wzorca. Przesuwający się wzorzec porównywany jest konse-
kwentnie znak po znaku z odpowiednim fragmentem tekstu:
S t r i n g S e a r c h
1 r i n g
2 r i n g
3 r i n g

Możemy kontynuować ten proces, rozpoczynając od pozycji kolejnej po wystąpieniu wzorca,


a kończąc na wyczerpaniu przeszukiwanego tekstu zgodnie z punktem 4.:
S t r i n g S e a r c h
4 r i n g
5 r i n g
6 r i n g
7 r i n g
8 r i n g
9 r i n g
10 r i n g

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.

Tworzenie klasy testowej


Klasę testową dla „siłowego" wyszukiwania wzorca wyprowadzimy ze zdefiniowanej wcze-
śniej abstrakcyjnej klasy AbstractStringSearcherTest:
package com.wrox.algori thms.ssearch;

public class BruteForceStringSearcherTest extends AbstractStringSearcherTest {


protected StringSearcher createSearcher(CharSequence pattern) {
return new BruteForceStringSearcher(pattern);
440 A l g o r y t m y . Od p o d s t a w

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 .

spróbuj sam Implementacja algorytmu


S a m a k l a s a BruteForceStringSearcher z d e f i n i o w a n a j e s t n a s t ę p u j ą c o :

package com.wrox.al gori thms.ssearch:

public class BruteForceStringSearcher implements StringSearcher {


/** poszukiwany wzorzec */
private finał CharSequence _pattern;

public BruteForceStringSearcher(CharSequence pattern) {


assert pattern != nuli : "nie określono wzorca":
assert pattern.lengthO > 0 : "wzorzec nie może być pusty":

_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:

while (s <= text.lengthO - _pattern.lengthO) {


int i = 0:

while (i < _pattern.lengthO && _pattern.charAt(i) == text.charAt(s + i)) {


++i;
}
if (i == _pattern.lengthO) {
return new StringMatch(_pattern. text. s);
}
++s;
}
return nuli:

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.

Jak widać, przymiotnik „prymitywny" jest w odniesieniu do opisywanego algorytmu całkiem


zasadny: w algorytmie nie ma żadnych sztuczek, żadnych optymalizacji, żadnych „skrótów
myślowych", które mogłyby powodować redukcję liczby wykonywanych porównań tekstu
ze wzorcem. W najgorszym przypadku każdy znak wzorca porównywany jest z odpowia-
dającym mu znakiem tekstu, co daje złożoność pesymistyczną rzędu 0(MN)\ Na szczęście
w typowych tekstach przypadek taki trafia się dość rzadko i przeciętna złożoność jest o wiele
mniejsza, o czym będzie się można przekonać w dalszej części rozdziału.

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ń.

Algorytm wyszukiwania łańcucha zaprojektowany przez R. S. Boyera i J. S. Moore'a sta-


nowi podstawę dla wielu najszybszych znanych algorytmów tego typu. Podstawę konstrukcji
tego algorytmu stanowi spostrzeżenie, że wiele porównań wykonywanych przez algorytm
siłowy ma charakter redundantny — w wielu przypadkach znaki napotykane w przeszuki-
wanym tekście nawet nie występują we wzorcu, więc pewne fragmenty tego tekstu można
by całkowicie zignorować.

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

znak w z o r c a p o k r y w a się z pierwszym znakiem tekstu. Każdorazowo, gdy porównywane


znaki o k a z u j ą się różne, wzorzec przesuwany jest tak, by „niepasujący" znak tekstu pokrył
się z identycznym znakiem wzorca. Postępowanie to można formalnie opisać w postaci na-
stępującego scenariusza, nazywanego często „heurystyką błędnego z n a k u " (bad character
heuristic):

1. Jeśli „niepasujący" znak tekstu występuje we wzorcu, przesuwamy wzorzec


w prawo o tyle pozycji, by znak ten pokrył się z identycznym znakiem we wzorcu.
W powyższym przykładzie rozpoczynamy od porównania znaków „ i " (w tekście)
oraz „g" (we wzorcu); ponieważ znaki o k a z u j ą się różne, wzorzec przesuwany jest
w prawo tak, by jego znak „i" pokrył się ze znakiem „ i " w przeszukiwanym tekście —
i rozpoczyna się kolejne porównywanie wzorca z odpowiednim fragmentem tekstu.

2. Jeśli niepasujący znak tekstu nie występuje we wzorcu, przesuwamy wzorzec


o c a ł ą j e g o długość. W trzeciej serii porównań w powyższym przykładzie znak „g"
wzorca porównywany jest ze spacją, która we wzorcu nie występuje. Przesuwamy
więc wzorzec o c a ł ą j e g o długość, czyli o 4 pozycje.

3. Jeżeli z powyższej heurystyki wynikałoby przesunięcie wzorca w lewo, ignorujemy


tę heurystykę i przesuwamy wzorzec o j e d n ą pozycję w prawo, identycznie jak
w algorytmie „siłowym".

Oryginalna wersja algorytmu Boyera-Moore 'a bazuje na odmiennej heurystyce


— tzw. heurystyce dobrego przyrostka (good s uffix heuristic), która jednak (zdaniem
wielu publikacji) okazuje się mieć korzystny wpływ na efektywność jedynie w przypadku
wzorców bardzo długich i charakteryzujących się dużą liczbą powtórzeń. Ze względu
na chęć zachowania prostoty niniejszego przykładu posługujemy się uproszczoną
wersją algorytmu.

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

Zgodnie jednak z punktem 3. przesuwamy go o jedną pozycję w prawo i kontynuujemy po-


równywanie
e v e r y t h i n g
o v e r
o v e r -

W rzeczywistości przedstawiona sytuacja rozwiązywana jest w sposób nieco bardziej


efektywny niż proste przesuwanie wzorca o jedną pozycję w prawo. Nie poruszamy
tego zagadnienia, chcąc zachować przykład w postaci tak prostej, jak to tylko możliwe.
Mamy jednak świadomość faktu, że w najbardziej niekorzystnym przypadku efektywność
algorytmu w tej wersji może być tak zła jak efektywność ałgorytmu siłowego,
na szczęście w praktyce przypadki takie zdarzają się bardzo rzadko.
Rozdział 16. • Wyszukiwanie tekstu 443

Jak wykażemy w dalszej części rozdziału, „przeskakiwanie" całych fragmentów tekstu


przyczynia się do znacznego ulepszenia efektywności algorytmu w porównaniu z algoryt-
mem „siłowym". W skrajnym — choć mało prawdopodobnym — przypadku, gdy przeszu-
kiwany tekst nie zawiera żadnego znaku występującego we wzorcu, każde przesunięcie
wzorca odbywa się na odległość równąjego własnej długości, co prowadzi do efektywności
( N\
rzędu Ol — , gdzie Mjest długością wzorca, a N— długością przeszukiwanego tekstu.
\ M )

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.

Tworzenie testów dla algorytmu Boyera-Moore'a


Ponownie wykorzystamy abstrakcyjną klasę AbstractStringSearcherTest do zdefiniowa-
nia konkretnej klasy testowej, która wyposażymy w dodatkową metodę charakterystyczną
dla algorytmu Boyera-Moore'a.

miŁ-ftiiiB implementacja algorytmu


Definicja rzeczonej klasy testowej przedstawia się następująco:
package com.wrox.algori thms.ssearch;

public class BoyerMooreStringSearcherTest extends AbstractStringSearcherTest {


protected StringSearcher createSearcher(CharSequence pattern) {
return new BoyerMooreStringSearcher(pattern);
}
public void testShiftsDontErroneouslyIgnoreMatches() {
String text = "aababaa":
String pattern = "baba":

StringSearcher searcher = createSearcher(pattern):

StringMatch match = searcher.search(text. 0);


assertNotNull(match);
assertEquals(text. match.getText()):
assertEquals(pattern. match.getPattern());
assertEquals(2. match.getIndex());

assertNull(searcher.search(text, match.getlndex() + 1));

J a k to działa?

Ponieważ zgodnie z algorytmem Boyera-Moore'a wzorzec może być przesuwany na różne


odległości, konieczne jest upewnienie się, że przesuwanie to odbywa się każdorazowo o wła-
ściwą liczbę pozycji. Specyfikowany w metodzie testShiftsDontErroneously!gnoreMatches()
444 Algorytmy. Od podstaw

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.

Implementowanie algorytmu Boyera-Moore'a


Implementowanie algorytmu Boyera-Moore'a jest procesem kilkuetapowym. Najpierw mu-
simy zdefiniować klasę wyszukiwarki, a następnie skonstruować tablicę ostatnich wystąpień
znaków we wzorcu, by w końcu zająć się właściwych wyszukiwaniem wzorca.

spróbuj sam Definiowanie klasy implementującej algorytm


Oto podstawowe elementy klasy BoyerMooreStringSearcher:
package com.wrox.a 1gori thms.ssea rch;

public class BoyerMooreStringSearcher implements StringSearcher {


/** liczebność wykorzystywanego zestawu znaków */
private static finał int CHARSET_SI2E = 256;

/** poszukiwany wzorzec */


private finał CharSequence _pattern;

/** Pozycje ostatnich wystąpień poszczególnych znaków we wzorcu */


private finał short[] JastOccurrence;

public BoyerMooreStringSearcher(CharSequence pattern) {


assert pattern != nuli : "nie określono wzorca";
assert pattern.lengthO > 0 : "wzorzec nie może być pusty";

_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

spróbuj sam Tworzenie i inicjowanie tablicy ostatnich wystąpień znaków we wzorcu


Metoda computeLastOccurences() tworzy, dla wzorca przekazanego jako parametr wywo-
łania, tablicę zawierającą pozycję (0, 1 , 2 , . . . ) ostatniego wystąpienia w tym wzorcu każdego
ze znaków z wykorzystywanego zestawu. Tablica ta przypisywana jest następnie zmiennej
lastOccurence.

private static short[] computeLastOccurrence(CharSequence pattern) {


short[] lastOccurrence = new short[CHARSET_SIZE];

for (int i = 0; i < lastOccurrence.length; ++i) {


lastOccurrence[i] = -1;
}
for (int i = 0; i < pattern.lengthO: ++i) {
lastOccurrence[pattern.charAt(i)] = (short) i;
}
return lastOccurrence;

J a k to działa?

Zakładamy, że wykorzystywanym zestawem znaków jest 256-znakowy zestaw ASCII, ta-


blica ostatnich wystąpień ma więc 256 pozycji, po jednej dla każdego znaku. Początkowo
każda z tych pozycji inicjowana jest wartością -1, oznaczającą brak danego znaku we wzorcu.

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.

Wyobraźmy sobie (hipotetyczny) zestaw składający się z pięciu znaków A, B, C, 0, E i wzorzec


DECADE. Wspomniana tablica miałaby w tym przypadku zawartość następującą (rysunek 16.1).

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)

Wzorzec zawiera znak A na pozycji 3, znak C na pozycji 2, znak D na pozycjach 0 i 4 oraz


znak E na pozycjach 1 i 5. Dla znaków D i E w tablicy odnotowywana jest pozycja położona
najbardziej na prawo, czyli (odpowiednio) 4 i 5.

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

public StringMatch search(CharSequence text, int from) {


assert text != nuli : "nie określono tekstu";
assert from >= 0 : "pozycja startowa nie może być ujemna";

int s = from;

while (s <= text.lengthO - _pattern.lengthO) {


int i = _pattern.lengthO - 1;

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ń.

Dokładniej, nowe położenie wzorca ustalane jest przez następującą instrukcję:


s += Math.max(i - _lastOccurrence[c]. 1);

Obliczenia wykonywane przez tę instrukcję nie są zbyt skomplikowane. „Niepasujący"


znak tekstu — zapamiętany w zmiennej c — wykorzystywany jest jako indeks tablicy
ostatnich wystąpień, w wyniku czego odczytana zostaje pozycja ostatniego wystąpienia te-
go znaku we wzorcu. Pozycja ta odejmowana jest następnie od bieżącej pozycji wewnątrz
wzorca. Wyobraźmy sobie na przykład porównywanie wzorca abcd z tekstem bdaaedccda:
b d a a e d c c d a

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

Otrzymujemy sytuację identyczną jak poprzednio: niedopasowanie występuje na skrajnej


pozycji, kolidujący znak tekstu („c") występuje we wzorcu ostatnio na pozycji 2, bieżącym
znakiem wzorca jest jego znak na pozycji 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

Nie uzyskaliśmy dopasowania, jednocześnie jednak doszliśmy do końca skanowanego tekstu.


Kończymy ostatecznie poszukiwanie, nie znalazłszy poszukiwanego wzorca.

Iterator dopasowywania wzorca


Znalezienie pierwszego wystąpienia wzorca w tekście to tylko program minimum, czasami
bowiem chcielibyśmy odnaleźć wszystkie jego wystąpienia i w stosunku do każdego z nich
wykonać jakąś procedurę. Jednym z elementów informacji o wynikach wyszukiwania, re-
prezentowanej przez klasę StringMatch, jest pozycja wystąpienia poszukiwanego wzorca;
pozycję tę można zapamiętać i — po wykonaniu określonych czynności związanych z bie-
żącym wystąpieniem wzorca — podjąć próbę znalezienia następnego wystąpienia począwszy
od kolejnej pozycji. Konieczność pamiętania pozycji ostatniego wystąpienia wzorca jest
jednak mało wygodna, a zarazem mało elegancka: przecież wykonywanie określonych czyn-
ności względem kolejnych wystąpień wzorca w tekście nie jest niczym innym jak iterowaniem
po tych wystąpieniach i jako takie kwalifikuje się do realizacji w postaci iteratora (patrz
rozdział 2.) posadowionego na bazie wyszukiwarki.
448 Algorytmy. Od podstaw

Tworzenie klasy Iteratora wystąpień wzorca


W niniejszej książce wielokrotnie stykaliśmy się z iteratorami tworzonymi na bazie rozma-
itych struktur danych, realizującymi zasadnicze koncepcje opisywane w rozdziale 2. Pre-
zentowana poniżej klasa — StringMatchlterator — realizuje koncepcję iterowania po
wszystkich wystąpieniach wzorca w określonym tekście.
package com.wrox.al gorithms.ssearch;

import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.algori thms.i teration.IteratorOutOfBoundsExcepti on;

public class StringMatchlterator implements Iterator {


/** wyszukiwarka */
private finał StringSearcher _searcher;

/** przeszukiwany tekst */


private finał CharSequence _text:

/** informacja na temat ostatniego wystąpienia wzorca */


private StringMatch _current:

public StringMatchlteratortStringSearcher searcher, CharSeąuence text) {


assert searcher != nuli : "nie określono wyszukiwarki":
assert text != nuli : "nie określono tekstu do przeszukiwania":

_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?

Podstawowymi elementami iteratora, przechowującymi informację o jego stanie, są: wy-


szukiwarka (określająca wewnętrznie wyszukiwany wzorzec), wskaźnik na przeszukiwany
tekst oraz instancja klasy StringMatch zawierająca informacje na temat ostatnie wystąpienia
wzorca. Instancje tej klasy, zwracane jako rezultaty kolejnych poszukiwań, są elementami
zwracanymi przez iterator, dostępnymi jako wynik wywołań jego metody current ().

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.

Jak wiadomo, w sytuacji, gdy w przeszukiwanym tekście, począwszy od wskazanej jego


pozycji, brak jest już wystąpień wzorca, metoda searchO wyszukiwarki zwraca wynik pu-
sty (nuli) zamiast instancji klasy StringMatch. Sytuacja taka oznacza wyczerpanie iteratora,
co stanowi podstawę realizacji jego metody i sDoneO.

Metoda f i r s t O , znajdująca pierwsze wystąpienie wzorca, realizowana jest jako wyszuki-


wanie wzorca począwszy od pierwszego znaku tekstu, czyli od znaku na pozycji 0. Metoda
next() oznacza natomiast kontynuację poszukiwania od pozycji następnej w stosunku do
tej zwracanej w ramach instancji klasy StringMatch.

Wreszcie, jak już wspominaliśmy na początku, bieżącym elementem iteratora — dostęp-


nym za pośrednictwem metody currentO — j e s t instancja klasy StringMatch zwracana
w wyniku ostatniego wyszukiwania. Zauważmy, że jest to zawsze konkretna instancja, nie
wynik pusty; zwrócenie pustego wyniku przez wyszukiwarkę oznacza wyczerpanie iterato-
ra, w wyniku czego metoda isDoneO zwraca wartość true, a w konsekwencji wywołanie
metody currentO powoduje wystąpienie wyjątku IteratorOutOfBoundsException.

Porównanie efektywności wyszukiwania


Znając już zasady obydwu opisanych algorytmów wyszukiwania, warto pokusić się o po-
równanie ich efektywności. Rzecz jasna spodziewamy się znacznej przewagi w tym wzglę-
dzie algorytmu Boyera-Moore'a nad algorytmem „siłowym", lecz dobrze byłoby oczeki-
wanie to poprzeć wynikami jakiegoś konkretnego eksperymentu. Moglibyśmy w tym celu
zastosować podejście wykorzystywane już kilkakrotnie w poprzednich rozdziałach niniejszej
450 Algorytmy. Od podstaw

książki — porównanie wydajności obydwu algorytmów w przypadku najgorszym, prze-


ciętnym i najlepszym — tym razem wybierzemy jednak podejście bardziej praktyczne i bar-
dziej interesujące: przeszukiwanie pliku.

Prosta aplikacja, którą skonstruujemy, poszukiwać będzie wystąpień określonych wzorców


w zawartości podanego pliku. Zaprezentujemy prostą technikę umożliwiającą pomiar względ-
nej efektywności testowanych implementacji przeglądarek.

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.

Jak dotąd różnicę efektywności obydwu algorytmów — „siłowego" i Boyera-Moore'a —


postrzegaliśmy głównie w kategoriach liczby wykonywanych porównań. Przewaga drugie-
go z wymienionych algorytmów wynika nie tylko z mniejszej liczby porównywania łańcu-
chów (wzorca i odpowiadającemu mu fragmentu tekstu), lecz także mniejszej liczby po-
równań pojedynczych znaków — ich redukcja przekłada się w pierwszym rzędzie
(przynamniej teoretycznie) na redukcję czasu wykonywania programu. Stąd prosty wnio-
sek, iż dobrą metodą porównywania obydwu algorytmów może być zliczanie wykonywa-
nych przez nie porównań pojedynczych znaków.

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.

spróbuj sam Definiowanie klasy zliczającej pobrania znaków


Nieco wcześniej wyjaśnialiśmy powody, dla których zamiast klasą String posługujemy się
interfejsem CharSequence. Teraz interfejs ten przyda nam się z jeszcze jednego powodu —
łatwo będzie można stworzyć jego klasę-otoczkę (patrz opis dekoratora w książce [Gamma,
1995]) umożliwiającą łatwe przechwytywanie metody c h a r A t O dokonującej pobierania
znaków z wzorca i przeszukiwanego tekstu.

Oto definicja wspomnianej klasy-otoczki:


package com.wrox.algori thms.ssearch;

public class CallCountingCharSequence implements CharSequence {


/** odnośna sekwencja znaków */
private finał CharSequence _charSequence;
Rozdział 16. • Wyszukiwanie tekstu 451

/** licznik zliczający wywołania metody charAtO */


private int _callCount:

public CallCountingCharSequence(CharSequence charSequence) {


assert charSequence != nuli : "nie określono sekwencji";
_charSequence = charSequence;
}
public int getCal1Count() {
return _callCount;
}
public int lengthO {
return _charSequence.lengthO;
}
public char charAtCint index) {
++_cal1Count;
return _charSequence.charAt(i ndex);
}
public CharSequence subSequence(int start, int end) {
return _charSequence.subSequence(start, end);
}
]

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.

spróbuj sam Definiowanie klasy przeszukującej zawartość pliku


Dysponując klasą umożliwiającą zliczanie porównań pojedynczych znaków, możemy wy-
korzystać jej funkcjonalność w aplikacji mierzącej liczbę tych porównań w związku z prze-
szukiwaniem zawartości wskazanego pliku.
package com.wrox,algori thms.ssearch;

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

public finał class ComparativeStringSearcher {


/** spodziewana liczba argumentów wywołania aplikacji */
private static finał int NUMBER_OF_ARGS - 2:

/** nazwa wykorzystywanego zestawu znaków */


private static finał String CHARSET_NAME - "8859_1";

/** nazwa przeszukiwanego pliku */


private finał String _filename:

/** poszukiwany wzorzec */


private finał String _pattern;

public ComparativeStringSearcher(String filename. String pattern) {


assert filename != nuli : "nie określono nazwy pliku":
assert pattern != nuli : "nie określono poszukiwanego wzorca":

_filename = filename:
_pattern = pattern;
}
public static void main(String[] args) throws IOException {
assert args != nuli : "nie określono argumentów wywołania";

if (args.length < NUMBER_OF_ARGS) {


System.err.pri nt1n("Wywołani e: Comparati veStri ngSearcher <pli k>
<wzorzec>");
System.exit(-l);
}
ComparativeStringSearcher searcher =
new ComparativeStringSearcher(args[0].
args[l]):

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);

System.out.println("Poszukiwanie wzorca " + _pattern + " w pliku "


+ _filename+ '" (" + file.lengthO + ") ...");

search(new BruteForceStringSearcher(_pattern). file);


search(new BoyerMooreStringSearcher(_pattern), file);
} finałly {
fc.closeO;
}
}
Rozdział 16. • Wyszukiwanie tekstu 453

private void searchCStringSearcher searcher, CharSequence file) {


CallCountingCharSequence text = new CallCountingCharSequence(file):

Iterator i = new StringMatchlteratortsearcher. text);

int occurrence = 0:

long startTime = System.currentTimeMillisO:


for (i.firstO; !i.isDoneO; i.nextO) {
++occurrence;
1
long elapsedTime = System.currentTimeMillisO - startTime;

System.out.pri ntln(sea rcher.getClass().getName()


+ ": wystąpień: " + occurrence
+ ", porównań: " + text.getCallCountO
+ ", czas: " + elapsedTime);

J a k to działa?

Większość współczesnych systemów operacyjnych udostępnia mechanizm pamięciowego


odwzorowania plików (memory mapping offiles) umożliwiający operowanie zawartością
pliku tak, jak gdyby stanowiła ona zawartość spójnego obszaru pamięci. W programach
tworzonych w języku Java zalety tego mechanizmu dostępne są między innymi za pośred-
nictwem klasy java.nio.CharBuffer; tym, co w tej klasie jest najistotniejsze z punktu wi-
dzenia niniejszego rozdziału, jest implementowanie interfejsu CharSequence i dzięki temu
możliwość przeszukiwania zawartości pliku w sposób wcześniej opisywany, czyli w oparciu
o interfejs StringSearcher.

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.

Metoda searchO najpierw tworzy instancję interfejsu CallCountingCharSequence jako


otoczkę dla instancji klasy CharBuffer reprezentującej zawartość pliku (jak pamiętamy, klasa
CharBuffer implementuje interfejs CharSequence). Następnie za pomocą iteratora StringMa-
tchlterator w zawartości tej poszukiwane są wszystkie wystąpienia zadanego wzorca.

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).

Wystarczy teraz uruchomić aplikację, by uzyskać odpowiedź na intrygujące pytania — j a k


efektywne są obydwa algorytmy? Który z nich jest szybszy? O ile szybszy?

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 %

Łatwo zauważyć przewagę algorytmu Boyera-Moore'a — nawet dziesięciokrotną! Nie-


trudno spostrzec, że przewaga ta zwiększa się wraz ze wzrostem długości poszukiwanego
wzorca; w algorytmie Boyera-Moore'a często bowiem „przeskakiwane" są spore fragmenty
tekstu; tym (potencjalnie) dłuższe, im dłuższy jest ów wzorzec. Przeskakiwanie to zmniej-
sza liczbę wykonywanych porównań, zmniejszając jednocześnie czas realizacji algorytmu.

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:

• Algorytm „siłowy" dokonuje dopasowywania wzorca do poszczególnych fragmentów


tekstu, poczynając od pierwszego znaku tekstu i przesuwając wzorzec sukcesywnie
o jedną pozycję w prawo, aż do wyczerpania przeszukiwanego tekstu. W najbardziej
Rozdział 16. • Wyszukiwanie tekstu 455

niekorzystnym przypadku może to wymagać 0(NM) porównań poszczególnych


znaków (W jest długością przeszukiwanego tekstu, M — długością wzorca);
w przypadku optymistycznym, gdy porównywanie załamuje się każdorazowo
już na pierwszym znaku wzorca, liczba porównań spada jednak do 0(N+M).
Algorytm Boyera-Moore'a rozpoczyna porównywanie od ostatniego znaku wzorca,
posuwając się wstecz aż do pierwszego znaku. W kolejnych porównaniach wzorzec
przesuwany jest w prawo o pewną liczbę pozycji, zależną od wzajemnej relacji
układu znaków we wzorcu i przeszukiwanym tekście. W przypadku najgorszym
algorytm ten jest nieco gorszy od algorytmu „siłowego" w związku z koniecznością
wykonania dodatkowej pracy wynikającej z budowania tablicy pozycji ostatnich
wystąpień poszczególnych znaków we wzorcu. W odniesieniu do typowego tekstu
jest on jednak znacznie lepszy od algorytmu siłowego, może bowiem osiągać
( N\
efektywność rzędu 0\ — dzięki „przeskakiwaniu" przez fragmenty tekstu
M ,
o długości zbliżonej do długości wzorca.
• Wyszukiwanie wielokrotne — a konkretnie: zarządzanie informacją o stanie
wyszukiwania w przypadku wyszukiwania wielu wystąpień wzorca w tekście
— jest znacznie ułatwione dzięki iteratorowi, zbudowanemu na bazie wyszukiwarki.
Ponieważ iterator ten zależny jest jedynie od interfejsu StringSearcher, można
tworzyć go na bazie dowolnej wyszukiwarki implementującej ten interfejs. Staje się
to szczególnie użyteczne w przypadku kilku sesji wyszukiwania prowadzonych dla
tekstów o różnej charakterystyce, za pomocą różnych wyszukiwarek zoptymalizowanych
pod kątem poszczególnych charakterystyk.

• Porównanie efektywności obydwu algorytmów — w oparciu o kilkumegabajtową


próbkę tekstu — potwierdza spodziewaną przewagę algorytmu Boyera-Moore'a
nad algorytmem „siłowym". Przewaga ta jest zróżnicowana zależnie od długości
wzorca i typu przetwarzanego tekstu — w jednym przypadku jest ona
(w prezentowanym eksperymencie) nawet dziesięciokrotna.

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.

W niniejszym rozdziale opisujemy dwa zagadnienia związane bezpośrednio z dopasowy-


waniem łańcuchów:

• 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

algorytmów był właśnie Soundex, drugim — specyficzny algorytm wynaleziony w ramach


NYSII jako efekt wszechstronnej analizy statystycznej rzeczywistych danych. Zgodnie z osta-
teczną konkluzją publikacji kod Soundex zapewnia dokładność rzędu 95,99% z selektyw-
nością 0,213% na jedno wyszukiwanie, podczas gdy drugi z wymienionych kodów (nie bę-
dziemy go omawiać w niniejszej książce) — dokładność rzędu 98,72% z selektywnością
0,164% na wyszukiwanie.

Spośród innych schematów kodowania fonetycznego wymienić należy przede wszystkim


Metaphone, Double-Metaphone i wiele innych kodów stanowiących w istocie odmiany ory-
ginalnego kodu Soundex.

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

Zatem po uwzględnieniu pierwszej litery w kodzie wynikowym z łańcucha usuwane są


wszystkie samogłoski — notabene w języku angielskim niewiele zmienia to brzmienie tek-
stu; na równi z samogłoskami traktowane są także litery H i W, mające w zakresie wymowy
takie same właściwości jako samogłoski.

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.

Eliminowanie sąsiadujących liter odwzorowywanych na ten sam kod wynika z faktu, że


w języku angielskim pary „zdublowanych" spółgłosek wymawiane są tak jak pojedyncza
spółgłoska.

W celu zilustrowania praktycznego działania kodu Soundex dokonamy zakodowania dwóch


(identycznie brzmiących) nazwisk Smi th i Smythe.

Rozpoczniemy od zainicjowania bufora wynikowego spacjami (rysunek 17.1); bufor ten


ma cztery pozycje, bo taka jest maksymalna długość kodu wynikowego. Następnie prze-
twarzać będziemy każdy z łańcuchów, po jednym znaku począwszy od lewej.

Rysunek 17.1. Wejście s m i t h


Zainicjowanie
bufora wynikowego
spacjami Wynik

Zgodnie z regułą nr 2 pierwsza litera nazwiska Smith staje się pierwszym znakiem kodu
wynikowego (rysunek 17.2).

Rysunek 17.2. Wejście S m i t h


Pierwszy znak
kodowanego
łańcucha staje się Wynik
pierwszym znakiem
kodu wynikowego

Kolejny znak łańcucha - • zgodnie z regułą nr 4 przekształcony zostaje na cyfrę 5 (ry-


sunek 17.3).

Rysunek 17.3. Wejście S m i t h


Litera m kodowana
jest jako cyfra 5
Wynik

Kolejny znak — i — j e s t samogłoską i zgodnie z regułą nr 3 jest ignorowany (jak wszystkie


inne samogłoski), nie pozostawiając po sobie śladu w kodzie wynikowym (rysunek 17.4).

Rysunek 17.4. Wejście S m X t h


Wszystkie
samogłoski są T T
ignorowane Wynik s 5

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

Rysunek 17.5. Wejście S m X t h


Litera t kodowana
jest jako cyfra 3
Wynik

Ostatnia litera — h — j e s t ignorowana na mocy reguły nr 3, nie pozostawiając śladu w ko-


dzie wynikowym (rysunek 17.6).

Rysunek 17.6. Wejście S m X t X


Litery h i w
są ignorowane
na równi Wynik
z samogłoskami

Zgodnie z regułą nr 6 uzyskany wynik należy dopełnić zerem do wymaganej długości.


Otrzymujemy tym samym wartość S530 jako kod Soundex odpowiadający nazwisku Smith
(rysunek 17.7).

Rysunek 17.7. Wejście S m X t X


Otrzymany wynik
dopełniamy zerem T r
do wymaganej Wynik S 5 3 0
długości

Kodowanie nazwiska Smythe także rozpoczynamy od wypełnienia bufora spacjami (rysunek 17.8).

Rysunek 17.8. Wejście s m y t h e


Ponownie inicjujemy
bufor czterema
spacjami Wynik

Postępując analogicznie jak w przypadku nazwiska Smith, otrzymujemy wartość S530 jako
kod Soundex nazwiska Smythe (rysunek 17.9).

Rysunek 17.9. Wejście S m X t X X


Wynik kodowania
nazwiska Smythe r y
Wynik S 5 3 0

Tak więc obydwa nazwiska — Smith i Smythe — odwzorowywane są na tę samą wartość


kodu Soundex (S530). Gdyby w bazie danych indeksowanie oparte było na kodzie Soundex
przechowywanych nazwisk, wyszukiwanie nazwiska Smith udostępniłoby także rekordy
z nazwiskiem Smythe — i vice versa. Tak powinno być w systemie odpornym na zniekształce-
nia nazwisk i (lub) umożliwiającym wyszukiwanie osób o podobnie brzmiących nazwiskach.

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

Testowanie kodera Soundex


Kodowanie Soundex opiera się jedynie na kilku prostych regułach, warto więc zweryfiko-
wać prawidłową realizację każdej z nich.
package com.wrox.algorithms.wmatch;

import junit.framework.TestCase:

public class SoundexPhoneticEncoderTest extends TestCase {


private SoundexPhoneticEncoder _encoder:

protected void setUpO throws Exception {


super.setUp();

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

public void testDuplicateCodesAreDroppedO {


assertEquals("B100". _encoder.encodet"BFPV")):
assertEqualst"C200". _encoder.encodet"CGJKQSXZ")):
assertEquals("D300", _encoder.encodet"DDT"));
assertEquals("L400". _encoder.encodet"LLL"));
assertEqualst"M500", _encoder.encodet"MNMN"));
assertEqualst"R600". _encoder.encodet"RRR"));
}
public void testSomeRealStringst) {
assertEquals("S530", _encoder.encodet"Smith"));
assertEqualst"S530", _encoder.encodet"Smythe")):
assertEqualst"M235", _encoder.encodet"McDonald"));
assertEquals("M235". _encoder.encodet"MacDonald"));
assertEqualst"H620". _encoder.encodet"Harris")):
assertEqualst"H620". _encoder.encodet"Harrys")):
}
private void assertAllEquals(char expectedValue. char[] chars) {
for (int i = 0 : i < chars.length: ++i) {
char c = chars[i];
String result = _encoder.encodet"-" + c):

assertNotNull(result):
assertEquals(4. result.length()):

assertEquals("-" + expectedValue + "00". result):


}
}
j

J a k to działa?

Klasa SoundexPhoneticEncoderTest utrzymuje prywatną zmienną _encoder reprezentującą te-


stowaną instancję kodera (klasy SoundexPhoneti cEncoder) tworzoną w ramach metody setUpt):
package com.wrox.a 1gori thms.wmatch:

import junit.framework.TestCase;

public class SoundexPhoneticEncoderTest extends TestCase {


private SoundexPhoneticEncoder _encoder;

protected void setUpO throws Exception {


super. setUpO:

_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());

assertEquals("-" + expectedValue + "00". result):


}
}
Na mocy reguły nr 3 ignorowane są wszystkie samogłoski oraz litery W i H. Zachowanie to
weryfikowane jest przez metodę testVowelAreIgnored(), która dokonuje kodowania dwu-
znakowych łańcuchów: pierwszy znak każdego łańcucha jest arbitralnie wybranym znakiem,
kopiowanym bez zmiany do wyniku, drugi natomiast jest znakiem ignorowanym, zgodnie
ze wspomnianą regułą. W efekcie trzy ostatnie znaki wyniku kodowania powinny być do-
pełniającymi zerami:
public void testVowelsAreIgnored() {
assertAl lEquals( '0'. new cha r[] {'A'. 'E\ T . '0'. 'U'. 'H\ 'W'. 'Y'});
}
Reguła nr 4 dokonuje podziału przetwarzanych spółgłosek na 6 grup. Dla każdej grupy
sprawdza się więc, czy wchodzące w jej skład litery kodowane są zgodnie z oczekiwaniami:
public void testLettersRepresentedByOneO {
assertAllEqualsCl\ new char[] {'B', T . 'P\ 'V'}):
}
public void testLettersRepresentedByTwo() {
464 Algorytmy. Od podstaw

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

Implementowanie kodera Soundex


Każda klasa dokonująca fonetycznego kodowania ciągu znaków implementuje funkcjonal-
ność reprezentowaną przez następujący interfejs:
package com.wrox.a1gori thms.wmatch;

public interface PhoneticEncoder {


public String encode(CharSequence string):
}

Implementująca ten interfejs klasa kodera Soundex zdefiniowana jest natomiast następująco:
package com.wrox.a1gori thms.wmatch;

public finał class SoundexPhoneticEncoder implements PhoneticEncoder {


/** pojedyncza instancja klasy Csingleton) */
public static finał SoundexPhoneticEncoder INSTANCE = new
SoundexPhoneti cEncoder():

private static finał char[] CHARACTER_MAP =


"01230120022455012623010202" .toCharArrayO:
/** ABCDEFGHIJKLMNOPQRSTUVWXYZ */

private SoundexPhoneticEncoder() {
}

public String encode(CharSequence string) {


assert string != nuli : "nie określono tekstu źródłowego";
assert string.lengthO > 0 : "tekst źródłowy jest pusty";

char[] result = {'0'. '0'. '0'. '0'}:

result[0] - Character.toUpperCase(string,charAt(0)):

int stringlndex = 1;
int resultlndex = 1:

while (stringlndex < string.lengthO && resultlndex < result.length) {


char c = map(string.charAt(stringIndex));

if (c != '0' && c != result[resultlndex - 1]) {


result[resultlndex] = c;
++resultlndex:
1
++stringlndex;
}
return String.valueOf(result);
}

* Odwzorowanie pojedynczego znaku w jego wartość kodową Soundex


*/
private static char map(char c) {
int index = Character.toUpperCase(c) - 'A';
466 Algorytmy. Od podstaw

return isValid(index) ? CHARACTER HAP[index] : '0';


1

private static boolean isValid(int index) {


return index >= 0 && index < CHARACTER_MAP.length;
}
j

J a k to działa?

Zdefiniowanie funkcjonalności kodera fonetycznego w postaci interfejsu języka Java umożliwia


wykorzystywanie różnych jego odmian w tworzonych aplikacjach, bez względu na różnice
w szczegółach implementacyjnych tychże odmian.
package com.wrox.a1gori thms.wmatch;

public interface PhoneticEncoder {


public String encode(CharSequence string):
}
Klasa SoundexPhoneticEncoder implementuje interfejs PhoneticEncoder i jako taka może
być traktowana na równi z innymi implementującymi go klasami realizującymi kodowanie
fonetyczne według dowolnego schematu.

Zwróćmy uwagę, że konstruktor klasy SoundexPhoneticEncoder oznaczony został jako prywat-


ny. Uniemożliwia to użytkownikowi tworzenie wielu instancji tej klasy i zmusza go do po-
sługiwania się pojedynczą statyczną instancją dostępną za pośrednictwem (publicznej) stałej
INSTANCE.

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 static finał char[] CHARACTER_MAP =


"01230120022455012623010202".toCharArrayt);
/** ABCDEFGHIJKLMNOPQRSTUVWXYZ */

private SoundexPhoneticEncoder() {

W procesie kodowania wykorzystywane są dwie metody pomocnicze: map() i isValid().


Ich zadaniem jest pobieranie kolejnych znaków z łańcucha wejściowego i przeliczanie ich
na odpowiadający im cyfry, zgodnie z regułami kodu Soundex. Każdy znak jest najpierw
przekształcany na odpowiedni indeks do tablicy CHARACTER_MAP; jeśli indeks ten mieści się
Rozdział 17. • Dopasowywanie łańcuchów 467

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';
}

private static boolean isValid(int index) {


return index >= 0 && index < CHARACTER_MAP.length;
}
Dochodzimy wreszcie do sedna algorytmu Soundex, czyli metody encodeO. Metoda ta
rozpoczyna swą pracę od zainicjowania bufora wynikowego zerami — skoro być może ko-
nieczne będzie dopełnienie wyniku zerami, czemuż by nie wpisać ich do bufora od razu?
Następnie pierwszy znak kodowanego łańcucha kopiowany jest na pierwszą pozycję bufora
wynikowego, po ewentualnym przekształceniu na wielką literę — zgodnie z regułą nr 1.
Następnie w pętli while odbywa się iterowanie po kolejnych znakach kodowanego łańcu-
cha: każdy znak przekazywany jest do metody map() i jeżeli zwracany przez tę metodę wy-
nik równy jest 0 lub jest identyczny z wartością zwróconą dla poprzedniego znaku, do bu-
fora wynikowego nic nie jest wpisywane, w przeciwnym razie jest do niego (na kolejną
pozycję) wpisywany wspomniany wynik. Pętla kontynuowana jest aż do wyczerpania łań-
cucha wejściowego bądź zapełnienia bufora wynikowego. Ostatecznie zawartość bufora wy-
nikowego konwertowana jest na łańcuch (String) i zwracana jako wynik metody:
public String encode(CharSequence string) {
assert string != nuli : "nie określono tekstu źródłowego";
assert string.lengthO > 0 : "tekst źródłowy jest pusty";

char[] result = {'0'. '0'. '0'. '0'};

result[0] = Character.tollpperCase(string.charAtCO));

int stringlndex = 1:
int resultlndex = 1;

while (stringlndex < string.lengthO && resultlndex < result.length) {


char c = inap(string.charAt(stringIndeX));

if (c != '0' && c != result[resultlndex - 1]) {


result[resultlndex] = c;
++resultlndex;
}
++stringlndex;
}
return String.valueOf(result);
}
468 Algorytmy. Od podstaw

Odległość Levenshteina dwóch slow


Kodowanie fonetyczne — w tym kod Soundex — doskonale radzi sobie z utożsamianiem
słów o jednakowej lub zbliżonej wymowie, okazuje się jednak zupełnie nieprzydatne w ko-
rygowaniu błędów pisarskich: podczas gdy wartość kodu Soundex dla słów „mistakes"
i „msitakes" jest identyczna, to już dla słów „shop" i „sjop" jest ona różna mimo, iż litery
, j " i „h" łatwo pomylić, bowiem na klawiaturze o układzie QWERTY sąsiadują ze sobą.

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.

Załóżmy, że koszt operacji wstawiania i usuwania znaku „wyceniamy" na 1, podobnie jak


koszt zastępowania danego znaku innym znakiem.

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

Zwróćmy uwagę na dodatkowy („zerowy") wiersz i dodatkową („zerową") kolumnę w tej


macierzy. Kolejne elementy zerowego wiersza reprezentują narastający (skumulowany)
koszt wstawiania znaków prowadzącego do utworzenia słowa „mistake" ze słowa pustego.
Podobnie kolejne elementy zerowej kolumny reprezentują narastający koszt usuwania zna-
ków prowadzącego do redukcji słowa „msteak" do słowa pustego. Generalnie rzecz biorąc,
każdy ruch pionowo w dół reprezentuje usunięcie znaku, zaś każdy ruch poziomo w prawo
— wstawienie znaku. Jako że zastąpienie znaku może być rozpatrywane jako złożenie usu-
nięcia i wstawienia znaku, jest ono reprezentowane przez ruch ukośny w prawo w dół.
Rozdział 17. • Dopasowywanie łańcuchów 469

Jeżeli przez w,, wd i ws oznaczymy koszt (odpowiednio) wstawiania (insert), usuwania


(delete) i zastępowania (substitute) znaku, to wartość kolejnych komórek Cx v macierzy (x,
y = 1, 2, 3 ...) obliczać możemy w sposób rekurencyjny z wzoru:
Cxy = min[cx_xy_x + w,, Cxy_x + wd, Cx_ly + w,.)

Przykładowo, komórka C i j odpowiadająca parze liter (m, m) ma wartość równą:


min(0 + 0, 1 + 1, 1 + 1) = min(0, 2, 2) = 0
ponieważ zastąpienie litery m przez literę m jest de facto operacją pustą (o koszcie 0), zaś
wstawienie i usunięcie znaku są operacjami o koszcie 1 (rysunek 17.11).
Nic oczywiście nie stoi na przeszkodzie ustaleniu innych zasad wyceny operacji,
na przykład uznania operacji zastępowania znaku za mniej kosztowną od wstawiania
i usuwania znaku3.

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

Wartość komórki w prawym dolnym rogu — komórki etykietowanej ostatnimi literami


każdego ze słów — jest jednocześnie minimalnym kosztem przekształcenia jednego słowa
w drugie, czyli odległością Levenshteina obydwu słów. Konkretny scenariusz tego prze-
kształcenia reprezentowany jest przez dowolną ze ścieżek monofonicznych macierzy, czyli
ścieżek, na których elementy zachowują się niemalejąco. Jedna ze ścieżek monotonicznych
przekształcenia słowa „msteak" w słowo „mistake" widoczna jest na rysunku 17.14.

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

Ścieżkę tę można zinterpretować jako ciąg następujących operacji:

Operacja Koszt jednostkowy Koszt skumulowany

Zastąpienie ,,m' przez ,m" 0 0

Wstawienie ,,i' i i

Zastąpienie ,,s' przez ,s" 0 i

Zastąpienie ,,t' przez ,t" 0 i

Usunięcie „e" 1 2

Zastąpienie ,,a' przez 0 2

Zastąpienie ,,k' przez 0 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

binatoryczny koszt stanowi poważne ograniczenie stosowalności algorytmu do rozwiązy-


wania rzeczywistych problemów, na przykład weryfikacji poprawności ortograficznej do-
kumentów — i w istocie popularne edytory tekstu posługują się kombinacją technik po-
dobnych do opisywanych w niniejszym rozdziale, choć nieco bardziej wyrafinowanych.

Zgodnie z opisanym algorytmem skonstruujemy teraz prostą aplikację obliczającą odle-


głość Levenshteina dwóch podanych słów. Rozpoczniemy oczywiście od stworzenia zesta-
wu testowego weryfikującego poprawność klasy realizującej funkcjonalność tej aplikacji.

m i e l i l i Testowanie Kalkulatora odległości Levenshteina


Odnośna klasa testowa zdefiniowana jest następująco:
package com.wrox.algorithms.wmatch;

import junit.framework.TestCase;

public class LevenshteinWordDistanceCalculatorTest extends TestCase {


private LevenshteinWordDistanceCalculator _calculator;

protected void setUpO throws Exception {


super. setUpO;

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?

Klasa LevenshteinWordDistanceCalculatorTest utrzymuje prywatną zmienną _calcu1ator


reprezentującą instancję klasy LevenshteinWordDistanceCalculator realizującej funkcjo-
nalność testowanego kalkulatora. Domyślna (DEFAULT) instancja tej klasy przypisywana jest
wspomnianej zmiennej w treści metody setUpO.
package com.wrox.a1gori thms.wmatch;

import junit.framework.TestCase;

public class LevenshteinWordDistanceCalculatorTest extends TestCase {


private LevenshteinWordDistanceCalculator _calculator;

protected void setUpO throws Exception {


super. setUpO;

_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

Analogicznie w metodzie testSameSuffix() obliczana jest odległość dwóch łańcuchów


o wspólnym końcu (sufiksie); powinna być ona równa różnicy między długością dłuższego
łańcucha a długością wspólnego sufiksu.
public void testSameSuffix() {
assertDistance(4, "eating", "running"):
}
W ostatnim teście obliczana jest odległość arbitralnie wybranych par słów i porównywana
z (znaną a priori) odległością oczekiwaną.
public void testArbitrary() {
assertDistance(3, "msteak". "mistake");
assertDistance(3, "necassery", "neccessary");
assertDistance(5, "donkey", "mule"):
}
Wyposażeni w niezbędny zestaw testowy zajmijmy się implementacją samego kalkulatora.

nummi Implementowanie kalkulatora odległości Levenshteina


Klasa realizująca kalkulator odległości Levenshteina zdefiniowana jest jak następuje:
package com.wrox.a 1gori thms.wmatch:

public class LevenshteinWordDistanceCalculator {


/** domyślna instancja klasy */
public static finał LevenshteinWordDistanceCalculator DEFAULT =
new LevenshteinWordDistanceCalculator(l. 1. 1);

/** jednostkowy koszt zastąpienia znaku */


private finał int _costOfSubstitution;

/** jednostkowy koszt usunięcia znaku */


private finał int _costOfDeletion;

/** jednostkowy koszt wstawienia znaku */


private finał int _cost0flnsertion;

public Levenshtei nWordDistanceCalculator


(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;
}
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

int sourceLength = source.length();


int targetLength = target.lengthO;

int[][] grid = new int[sourceLength + l][targetLength + 1];

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;

private int deleteCost(int[][] grid. int row, int col) {


return grid[row - l][col] + _costOfDeletion;
}
private int insertCost(int[][] grid, int row, int col) {
return grid[row][col - 1] + _cost0flnsertion;
}
private static int min(int a, int b. int c) {
return Math.min(a. Math.min(b. c));
}
}
Rozdział 17. • Dopasowywanie łańcuchów 475

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 class LevenshteinWordDistanceCalculator {


/** domyślna instancja klasy */
public static finał LevenshteinWordDistanceCal cul ator DEFAULT =
new LevenshteinWordDistanceCalculator(l. 1. 1);

/** jednostkowy koszt zastąpienia znaku */


private finał int _costOfSubstitution;

/** jednostkowy koszt usunięcia znaku */


private finał int _costOfDeletion;

/** jednostkowy koszt wstawienia znaku */


private finał int ^costOflnsertion;

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.

Metoda rozpoczyna swą pracę od utworzenia macierzy o odpowiednich wymiarach i zaini-


cjowania odpowiednimi wartościami zerowymi jej lewego górnego elementu, zerowej ko-
lumny i zerowego wiersza, w wyniku czego macierz przyjmuje postać podobną do przed-
stawionej na rysunku 17.11.

Następnie obliczane są wartości komórek w kolejnych wierszach i kolumnach, a po zakoń-


czeniu obliczania — prowadzącego do stanu podobnego do przedstawionego na rysunku
17.13 — wartość prawej dolnej komórki zwracana jest jako minimalny koszt przekształce-
nia jednego słowa w drugie, czyli jako odległość Levenshteina między obydwoma słowami.
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";

int sourceLength = source.lengthO:


int targetLength = target.1ength():

int[][ł grid = new int[sourceLength + 1][targetLength + 1];

grid[0][0] = 0;

for (int row = 1 ; row <= sourceLength; ++row) {


grid[row][0] = row;
}
Rozdział 17. • Dopasowywanie łańcuchów 477

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];
}

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.

Geometria obliczeniowa stanowi jeden z fundamentów szeroko pojętej grafiki komputerowej,


toteż warunkiem profesjonalnego tworzenia wszelkich prezentacji, projektów architekto-
nicznych, no i oczywiście wyszukanych gier, jest dobre poznanie geometrii obliczeniowej.
Akcja niniejszego rozdziału rozgrywa się w dwóch wymiarach, stanowiąc niejako
wprowadzenie do geometrii trójwymiarowej (3D). Nie będziemy się nią tu zajmować,
odsyłamy natomiast zainteresowanych Czytelników do licznych pozycji jej poświęconych
— w tym literatury wymienionej w dodatku A.

Treść rozdziału podzielona jest na trzy części opisujące:


• podstawowe pojęcia geometryczne,
• znajdowanie punktu przecięcia dwóch linii prostych,
• znajdowanie pary punktów położonych najbliżej w dużym zbiorze rozproszonych
punktów.

Podstawowe pojęcia geometryczne


Przed przystąpieniem do omawiania konkretnych problemów z zakresu geometrii warto przy-
pomnieć sobie pewne podstawowe pojęcia.

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.

Punktem nazywamy miejsce w (dwuwymiarowej) przestrzeni określone jednoznacznie przez


parę liczb ( x , y ) , gdzie x jest miejscem prostopadłego rzutowania punktu na oś poziomą, zaś
y — miejscem jego rzutowania prostopadłego na oś pionową. Liczby x i y nazywane są
współrzędnymi punktu. Na rysunku 18.2 przedstawiono punkt o współrzędnych (3, 4).

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.

Rysunek 18.3. (3,4)


Osie układu „x-y"
można w naturalny
sposób przedłużyć
dla współrzędnych
ujemnych
(-5,1)

i i i i i i i i i r
-5 -4 -3 -2 -1 1 2 3 4 5

-1 •

-2- (4,-2)

(-2,• -3) -4-


-3

-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

Znajdowanie punktu przecięcia dwóch linii


Pierwszym konkretnym problemem, jakim teraz się zajmiemy, będzie znajdowanie punktu
przecięcia dwóch linii. Na rysunku 18.7 oznaczono ten punkt literą/ 5 .

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

Z obliczeniowego punktu widzenia problem ten oznacza znalezienie współrzędnych punktu P


na podstawie współrzędnych dwóch par punktów wyznaczających poszczególne linie. W tym
celu opiszemy każdą linię za pomocą równania prostej, która tę linie zawiera:

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

Przecięcie linii z osią y


Linie o jednakowym nachyleniu są liniami równoległymi, mogą jednak różnić się punktem
przecięcia 4 z osiąy (zapomnijmy na chwilę o liniach pionowych, które są o tyle wyjątkowe,
że nie przecinają osi y). Na rysunku 18.11 przedstawiono dwie linie o nachyleniu 0,5 prze-
cinające oś y w dwóch różnych miejscach.

Rysunek 18.11.
Para linii
równoległych

Ponieważ pierwsza (wyższa) z nich przecina oś y w punkcie 2, opisujące j ą równanie ma


postać:
y = 0,5x + 2

Druga, niższa linia, jako przecinająca oś y w punkcie - 1 , opisywana jest równaniem:


>> = 0,5 x - 1

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

Punkt przecięcia dwóch linii


Znając równania dwóch linii, można wyznaczyć punkt ich przecięcia. W tym celu posłużymy
się konkretnym przykładem przedstawionym na rysunku 18.12.

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

a druga przez równanie:


y = nx + c

to współrzędne punktu ich przecinania się wyznaczyć można przez porównanie prawych
stron obydwu równań:

mx + b = nx + c

Przekształcając to równanie, otrzymujemy:

mx - nx = c -b

i ostatecznie mamy współrzędną^ punktu przecięcia:

c-b

Współrzędną^ punktu przecięcia wyznaczyć możemy z oryginalnego równania dowolnej linii:


yP = mxp + b

lub:

yP =nxp +c
486 Algorytmy. Od podstaw

W przykładzie przedstawionym na rysunku 18.12 m = 0,5, b=2, n= -2 i c = - 2 , zatem:

t - C~Z> - ~2"2 _ z i - _i6


~ ~ 0,5-(-2) 2,5 '

oraz:

j>p =0,5xp + 2 = 0,5* ( - 1 , 6 ) + 2 = 1,2

yp=-2xp-2 = ( - 2 ) * ( - 1 , 6 ) - 2 = 1,2

a więc obydwie proste przecinają się w punkcie o współrzędnych (-1,6 , 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

Łącząc tę zależność z równaniem drugiej, „niepionowej" linii, otrzymujemy:


yp = nxp +c = nd + c

Po tym krótkim wprowadzeniu teoretycznym zajmijmy się programistycznym aspektem


przecinania się dwóch linii na płaszczyźnie. W kolejnych przykładach opisane koncepcje
odzwierciedlane są bezpośrednio przez obiekty języka Java, więc czas poświęcony na ich
zrozumienie z pewnością nie okaże się stracony. Rozpoczniemy od klasy reprezentującej
punkt.

spróbuj sam Testowanie i implementowanie klasy Point


Cóż może być interesującego wśród własności punktu do tego stopnia, by stać się przed-
miotem testowania? Zbadamy dwie takie własności: sprawdzimy, czy dwie instancje klasy
Point reprezentują ten sam punkt, a ponadto zweryfikujemy poprawność obliczania odle-
głości między dwoma punktami.

Oto definicja klasy testowej:


package com.wrox.a1gori thms.geometry:

import junit.framework.TestCase:

public class PointTest extends TestCase {


public void testEquals() {
assertEquals(new PointtO. 0). new Point(0. 0)):
assertEquals(new Point(5. 8). new Point(5. 8));
assertEquals(new Point(-4, 6). new Point(-4. 6)):

assertFalse(new PointtO. 0),equals(new Pointd. 0))):


assertFalset new PointtO. 0).egualstnew PointtO. 1))):
Rozdział 18. • Geometria obliczeniowa 487

assertFalse(new Point(4, 4),equals(new Point(-4. 4))):


assertFalse(new Point(4, 4),equals(new Point(4. -4))):
assertFalse(new Point(4. 4),equals(new Point(-4. -4)));
assertFalsetnew Point(-4. 4),equals(new PoiritC-4. -4)));
}
public void testDistance() {
assertEquals(13d, new Point(0. 0),distance(new Point(0, 13)). 0):
assertEquals(13d, new Point(0, 0).distancetnew Point(13. 0)). 0):
assertEquals(13d, new Point(0, 0).distancetnew PointtO. -13)). 0):
assertEquals(13d, new PointtO, 0).distancetnew Point(-13. 0)) 0):

assertEquals(5d. new Point(l, 1).distancetnew Point(4. 5)). 0)


assertEquals(5d. new Point(l, 1).distancetnew Point(-2. -3)). 0):
}
}
Implementację klasy Point rozpoczniemy od zdefiniowania zmiennych przechowujących
współrzędne * i y reprezentowanego punktu oraz konstruktora służącego do ich inicjowa-
nia. Wspomniane zmienne sąjinalnymi zmiennymi prywatnymi, a więc obiekty klasy Point
są obiektami niezmiennymi (immutable).
package com.wrox.a 1gori thms.geometry;

public class Point {


private finał double _x;
private finał double _y;

public Point(double x. double y) {


_x - x:
_y - y:
1

}
Wartości współrzędnych osiągalne są za pomocą odpowiednich metod dostępowych:
public double getX() {
return _x:
}
public double getYO {
return _y:
)

Odległość danego punktu od innego obliczamy zgodnie z zasadami geometrii euklidesowej:


public double distance(Point other) {
assert other != nuli : "nie określono punktu docelowego":

double wzniesienie = g e t Y O - other.getYO:


double przesuniecie = getX() - other.getXO;

return Math.sqrt(wzniesienie * wzniesienie + przesuniecie * przesuniecie);


}
488 Algorytmy. Od podstaw

Pozostaje tylko zdefiniowanie metody equals() rozstrzygającej o równości dwóch punktów


oraz metody hashcode() wyliczającego wartość funkcji haszującej dla obiektu klasy Point:
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == nuli || obj.getClassO !- getClassO) {
return false:
}
Point other = (Point) obj;

return getX() == other.getX() && g e t Y O — other.getYO:


}
public int hashCodeO {
return (int) (_x * _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.

Kolejna klasa reprezentować będzie nachylenie linii.

Testowanie klasy Slope


Rozpoczniemy od testu weryfikującego fakt, że linia pionowa istotnie rozpoznawana jest
jako taka:
package com.wrox.algori thms.geometry;

import junit.framework.TestCase:

public class SlopeTest extends TestCase {


public void testIsVertical() {
assertTruetnew Slope(4. 0).isVertical()):
assertTruetnew SlopetO. 0).isVertical());
assertTruetnew Slopet-5, 0).isVertical());
Rozdział 18. • Geometria obliczeniowa 489

assertFalse(new Slope(0, 5).isVertical());


assertFalse(new SlopetO, -5).isVerticalO);
}
}
Dwa obiekty klasy Slope są równe, jeśli reprezentują dwie linie o tym samym nachyleniu,
czyli dwie linie równoległe. Poniższy fragment testuje poprawność implementacji metody
equals():

public void testEquals() {


assertTrue(new SIope(0, -5),equals(new SIopeCO. 10)));
assertTrue(new Sloped, 3),equals(new Slope(2, 6)));
assertFalse(new Sloped, 3),equals(new Slope(-l, 3)));
assertFalse(new Sloped, 3),equals(new Sloped. -3)));
assertTrueCnew Slope(5. 0).equals(new Slope(9. 0)));
}

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

Jak pamiętamy, w równaniu prostej zawierającej linię ( y = mx + b) m jest wartością zmienno-


pozycyjną równą stosunkowi wzniesienia do przesunięcia. Prezentowany powyżej kod te-
stowy zawiera kilka asercji weryfikujących poprawność jej obliczania. Ponieważ dla linii
pionowej wartość ta jest nieskończona, próba jej obliczenia powinna powodować wyjątek.

Wyposażeni w zestaw testowy badający poprawność implementacji klasy Slope, zajmijmy


się samą implementacją.

spróbuj sam Implementowanie klasy Slope


Nachylenie linii reprezentowane jest przez parę zmiennych reprezentujących różnicę współ-
rzędnych pionowych (wzniesienie) i poziomych (przesunięcie):
package com.wrox.algorithms.geometry:

public class Slope {


private finał double _wzniesienie:
private finał double _przesuniecie;

public Slopetdouble wzniesienie, double przesuniecie) {


_wzniesienie = wzniesienie:
_przesuniecie = przesuniecie;
}
}
Linia jest pionowa wtedy i tylko wtedy, gdy jej przesunięcie jest zerowe5:
public boolean isVertical() {
return _przesuniecie — 0;
}

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:

assert wzniesienie != nuli || przesuniecie != nuli: "ta para nie reprezentuje


nachylenia linii";

której niestety zabrakło w prezentowanym przykładzie — p r z y p . ttum.


Rozdział 18. • Geometria obliczeniowa 491

Slope other - (Slope) object:

if (isVertical() & i other.isVert1calO) {


return true;
}
if (isVertical() | other.isVertical()) {
return false:
}
return (asDoubleO = (other.asDoubleO);
}
Numeryczna wartość nachylenia obliczana jest przez następującą metodę:
public double asDoubleO {
if (isVerticalO) {
throw new IIlegalStateException(
"linia pionowa ma nachylenie nieskończone"):
}
return _wzniesienie / _przesuniecie:
}

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

spróbuj sam Testowanie klasy Line


Ostatnią klasą związaną z problemem znajdowania punktu przecięcia dwóch linii jest klasa
Line. Pierwszym dedykowanym jej testem będzie badanie, czy na reprezentowanej przez
nią linii leży punkt reprezentowany przez obiekt klasy Point.
package com.wrox.a1gorithms.geometry;

import junit.framework.TestCase;

public class LineTest extends TestCase {


public void testContainsForNonVerticalLine() {
Point p = new PointtO, 0);
Point q = new PointO, 3);

Line 1 = new Linetp, q);

assertTrued.containstp));
assertTrued ,contains(q));

assertTrued .containstnew PointO, 1))):


assertTrued .containstnew Point(2, 2))):
assertTrued .containstnew PointtO.5 . 0.5))):

assertFalsetl .containstnew PointO. 1. 3.1))):


assertFalsetl .containstnew PointO, 3.1)));
assertFalsetl.containstnew PointtO. 1))):
assertFalsetl.containstnew Pointt-l , -1))):
>
}
Ponieważ linie pionowe traktujemy jako odrębne przypadki, badanie przynależności do nich
określonych punktów wykonywane jest przez odrębną metodę:
public void testContainsForVerticalLinet) {
Point p - new PointCO. 0):
Point q = new Point(0. 3);

Line 1 - new Line(p. q);

assertTrued .contains(p)):
assertTrued .contains(q));

assertTrued .contains(new Point(0. 1))):


assertTrued .contains(new PointtO, 2))):
assertTrued .containstnew PointtO. 0.5))):

assertFalsetl.containstnew PointtO. 3.1))):


assertFalsetl.containstnew PointtO.1. 1))):
assertFalsetl .containstnew PointO. 0))):
assertFalsetl.containstnew Pointt-l, -1))):
j
Rozdział 18. • Geometria obliczeniowa 493

Kolejnym, podlegającym testowaniu elementem funkcjonalności klasy Line jest badanie,


czy dana linia jest równoległa do wskazanej. Test wykonywany jest najpierw dla równole-
głych linii niepionowych:
public void testIsParallelForTwoNonVerticalParallelLines() {
Point p - new PointCl. 1):
Point q = new Point(6. 6):
Point r = new Point(4. -2):
Point s = new Point(6. 0);

Line 1 - new Line(p. q):


Line m = new LineCr, s);

assertTrued .isParallelTo(m)):
assertTruetm. isParal lelTod));
}

Kolejna metoda poświęcona jest liniom niepionowym i nierównoległym:


public void testIsParallelForTwoNonVerticalNonParallelLines() {
Point p = new Pointd. 1):
Point q = new Point(6. 4);
Point r = new Point(4. -2);
Point s = new Point(6, 0);

Line 1 = new Line(p, q);


Line m = new Line(r, s);

assertFal s e d . isParal 1 elTo(m)):


assertFalse(m. 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):

Line 1 - new Line(p, q);


Line m = new Line(r, s);

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

Line 1 = new Line(p, q);


Line m = new Line(r. s);

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);

Line 1 = new Linetp. q):


Line m = new Linetr, s):

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);

Line 1 = new Linetp, q);


Line m = new Linetr, s);

assertNul1(1.intersectionPointtm));
assertNul1(m.i ntersecti onPoi nt(1)):
)

Następnie zweryfikujemy poprawność obliczania znanego a priori punktu przecinania się


dwóch linii niepionowych:
public void testIntersectionOfNonParallelNonVerticalLines() {
Point p = new PointtO. 0):
Point q = new Point(4, 4);
Point r = new Point(4, 0):
Point s = new PointtO, 4):

Line 1 = new Linetp, q);


Line m = new Linetr, s):
Rozdział 18. • Geometria obliczeniowa 495

Point i - new Point(2, 2);

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);

Line 1 = new Linetp. q);


Line m = new Line(r, s):

Point i = new Point(2. 2);

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

Przypadkowi temu poświęcona jest następująca metoda testowa:


public void testDisjointLinesDoNotlntersectO {
Point p = new Point(0. 0):
Point q = new Point(0, 3):
Point r = new Point(5, 0):
Point s = new Point(-l, -3);

Line 1 = new Line(p. q);


Line m - new Line(r, s):

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.

Dysponując testami weryfikującymi poprawność implementacji klasy Line, zajmijmy się


teraz szczegółami tej implementacji.

spróbuj sam Implementowanie klasy Line


Klas Line definiuje trzy wewnętrzne obiekty: dwa typu Point, reprezentujące punkty ogra-
niczające linię, oraz jeden typu Slope, reprezentujący jej nachylenie. Wartości tych obiek-
tów inicjowane są przez konstruktor:
package com.wrox.algorithms.geometry;

public class Line {


private finał Point _p:
private finał Point _q;
private finał Slope _slope;

public Line(Point p. Point q) {


assert p != nuli : "nie określono punktu początkowego":
assert q != nuli : "nie określono punktu końcowego":

_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":

if (!isWithin(a.getXC). _p.getX(). _q.getX())) {


return false:
}
if (!isWithin(a.getYO. _p.getYO. _q.getYO)) {
Rozdział 18. • Geometria obliczeniowa 497

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:

private double baseO {


return _p.getYO - _slope.asDouble() * _p.getXO:
}

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:

public Point intersectionPoint(Line line) {


assert line != nuli : "nie określono linii":

if (isParallelTo(line)) {
return nul 1:
}
double x = getIntersectionXCoordinate(line):
double y = getlntersectionYCoordinateOine. x);

Point p = new Point(x. y);

if (line.contains(p) && this.contains(p)) {


return p;
}
return nul 1;
}
Powyższy kod wykorzystuje dwie metody pomocnicze — getIntersectionXCoordinate()
i getlntersectionYCoordinate. Zwracają one współrzędne (odpowiednio) x i y punktu przecię-
ciaprostych zawierających badane linie (same linie niekoniecznie muszą się przecinać):
private double getIntersectionXCoordinate(Line line) {
if (_slope.isVerticalO) {
return _p.getX();
498 Algorytmy. Od podstaw

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);

private double getIntersectionVCoordinate(Line line. double x) {


if (_slope.isVerticalO) {
return line.solveY(x);
}
return solveY(x):

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.

Warunkiem koniecznym do tego, by punkt o współrzędnych (x p , yp) leżał na linii łączącej


punktu o współrzędnych , jy,) i (x2 , jest zawieranie się jego współrzędnych mię-
dzy współrzędnymi punktów ograniczających linię:

{ 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.

Dochodzimy do sedna niniejszego podrozdziału, czyli znajdowania punktu przecięcia dwóch


linii — zadanie to wykonuje metoda intersectionPoint(). Jeżeli badane linie są równole-
głe, to nie mogą się przecinać i zwracana jest wartość nuli. W przeciwnym razie wyzna-
czany jest „teoretyczny" punkt przecinania się badanych linii, czyli punkt przecinania się
prostych zawierających te linie (punkt taki musi istnieć, ponieważ wykluczyliśmy równole-
głość tych prostych). Jeśli ów „teoretyczny" punkt należy do obydwu linii (co badane jest
za pomocą metody containsO), zwracany jest jako wynik, w przeciwnym razie zwracana
jest wartość nul 1.

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

Obliczenie współrzędnej y punktu przecięcia wykonywane jest przez metodę getlntersec-


tionYCoordinate(). Obliczenie wykonywane jest poprzez wywołanie metody solveY() ze
współrzędną x jako parametrem. Zwróćmy uwagę na sposób zapewnienia tego, by metoda
ta wywoływana była zawsze na rzecz tej linii, która na pewno nie jest pionowa (wywołanie
metody solveY() na rzecz linii pionowej spowodowałoby wystąpienie wyjątku w metodzie
asDoubleO klasy Slope).

Tak oto skonstruowaliśmy kilka klas reprezentujących podstawowe obiekty geometryczne


i zweryfikowaliśmy poprawności ich implementacji za pomocą stosownego zestawu testów.
Zajmiemy się teraz drugim z zapowiadanych na wstępie zagadnień — znajdowaniem pary
najbliższych sobie punktów.

Znajdowanie pary najbliższych punktów


Wyobraźmy sobie duży liczebnie zbiór punktów rozproszonych po płaszczyźnie, jak na
przykład ten widoczny na rysunku 18.15.

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ć.

Spójrzmy na rysunek 18.16. Przedstawia on stan algorytmu w momencie rozpoczynania


analizy piątej (licząc od lewej) pary punktów (dla lepszej czytelności usunięto z rysunku
osie x i y układu współrzędnych).

Rysunek 18.16.
Algorytm
„zamiatania"
płaszczyzny w akcji

V < >

Okno
skano-
wania
Kierunek „zamiatania"

Zaznaczona na rysunku odległość d jest górnym ograniczeniem szukanej najmniejszej od-


ległości. Każdy analizowany punkt traktowany jest tak, jak gdyby położony był na prawej
pionowej krawędzi prostokąta zwanego oknem skanowania (drag net). Szerokość (pozioma)
tego okna równa jest wspomnianemu ograniczeniu, bo — z powodów przed chwilą opisa-
nych — w charakterze pary dla analizowanego punktu nie ma sensu uwzględniać punktów
lezących poza tym oknem.
Rozdział 18. • Geometria obliczeniowa 501

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).

Na rysunku 18.17. Przedstawiono końcową fazę algorytmu, bezpośrednio przed zakończe-


niem obliczeń.

Rysunek 18.17.
Analiza punktów
bliska zakończeniu

Kierunek „zamiatania"

• Okno skanowania

Ponieważ opisany algorytm wymiatania zakłada analizowanie punktów w ustalonej kolej-


ności — zgodnie ze wzrastającymi współrzędnymi x — konieczne jest posortowanie tychże
punktów, a to wiąże się oczywiście z zaimplementowaniem stosownego komparatora. Jak
zwykle przed stworzeniem jego implementacji skonstruujemy odpowiednie testy weryfi-
kujące jej poprawność.

Testowanie komparatora punktów


Najprostszym bodaj testem dla naszego komparatora (implementowanego przez klasę XYPo-
intComparator) jest zweryfikowanie poprawności porównania identycznych punktów —
wynikiem tego porównania powinno być oczywiście zero:
package com.wrox.a1gori thms.geometry;

import junit.framework.TestCase:

public class XYPointComparatorTest extends TestCase {


private finał XYPointComparator _comparator = XYPointComparator.INSTANCE:

public void testEqualPointsCompareCorrectly() {


Point p = new Point(4, 4);
Point q = new Point(4. 4);
502 Algorytmy. Od podstaw

assertEquals(0, _comparator.compare(p, q));


assertEquals(0. _comparator.compare(p. p));
}
}
Kolejny test ma na celu zapewnienie, że punkty sortowane są poprawnie w kolejności
wzrastania ich współrzędnych x. W tym celu definiujemy trzy punkty i konfrontujemy re-
zultat ich sortowania z oczekiwaniami:
public void testXCoordinateIsPrimaryKey() {
Point p = new Point(-l, 4);
Point q - new PointtO. 4);
Point r = new Pointd, 4);

assertEquals(-l, _comparator.compare(p, q));


assertEquals(-l, _comparator.coinpare(p, r));
assertEquals(-l, _comparator.compare(q, r)):

assertEquals(l, _comparator.compare(q, p));


assertEquals(l, _comparator.compare(r. p));
assertEquals(l, comparator.compare(r. q)):
1

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):

assertEquals(-l, _comparator.compare(p, q)):


assertEquals(-l, _comparator.compare(p. r)):
assertEquals(-l. _comparator.compare(q, r));

assertEquals(l. _comparator.compare(q. p)):


assertEquals(l. _comparator.compare(r, p));
assertEquals(l, _comparator.compare(r, q)):
) _

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

Dysponując już wystarczająco bogatym zestawem testowym dla komparatora punktów,


zaimplementujmy go w postaci odpowiedniej klasy.

Implementowanie komparatora punktów


Ponieważ obiekt komparatora nie przechowuje żadnych informacji o stanie, wystarczająca
okazuje się pojedyncza instancja (singleton) klasy komparatora XYPointComparator tworzona
przez konstruktor prywatny:
package com.wrox.algori thms.geometry;

import com.wrox.algorithms.sorting.Comparator;

public finał class XYPointComparator implements Comparator {


/** Pojedyncza instancja klasy komparatora (singleton) */
public static finał XYPointComparator INSTANCE = new XYPointComparator():

private XYPointComparator() {
}

Metoda compareO, wywodząca się z interfejsu Comparator, implementowana jest na bazie


identycznie nazwanej metody dedykowanej obiektom klasy Point:
public int compare(0bject left. Object right) throws ClassCastException {
return comparet(Point) left. (Point) right):
}

Owa dedykowana dla klasy Point metoda compare() ma postać następującą:


public int compare(Point p. Point q) throws ClassCastException {
assert p != nuli : "nie określono pierwszego punktu":
assert q != nuli : " nie określono drugiego punktu":

int result = new Double(p.getX()).compareTo(new Double(q.getXO)):


if (result != 0) {
return result:
}
return new Double(p.getY()).compareTo(new Double(q.getYO)):

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.

Pierwszym kryterium rozstrzygającym o kolejności punktów, w ramach dedykowanej me-


tody compareO, jest wzajemna relacja ich współrzędnych x. Współrzędne y brane są pod
uwagę dopiero wtedy, gdy współrzędne x okazują się identyczne. Mając zaimplementowa-
ny i przetestowany komparator wyznaczający kolejność punktów, przejdźmy do samego
problemu wyszukiwania pary punktów położonych najbliżej siebie. Niezależnie od kon-
kretnego algorytmu rozwiązującego ten problem — „siłowego" typu „każdy z każdym" czy
też bardziej inteligentnego „zamiatania" płaszczyzny — rozwiązanie to powinno spełniać
określone kryteria poprawności, których spełnienie weryfikowane będzie przez abstrakcyj-
ną (z racji niezależności od konkretnego algorytmu) klasę testową.

lumenu Testowanie algorytmu wyszukującego parę najbliższych punktów


Jedynym elementem klasy AbstractClosestPairFinderTest, wymagającym konkretyzacji
w kontekście konkretnej implementacji (realizującej określony algorytm rozwiązania), jest
metoda createClosestPairFinder() zwracająca instancję klasy reprezentującej tę imple-
mentację. Wszystkie inne elementy testu są uniwersalne i od konkretnej implementacji nie-
zależne. W szczególności, rozwiązanie problemu zwracane jest przez metodę findClosest-
Pair() wspomnianej instancji (szczegółami tej kwestii zajmiemy się za chwilę).

package com.wrox.a1gor i thms.geomet ry:

i mport com.wrox.a1gori thms.sets.Li stSet;


i mport com.wrox.a 1gori thms.sets.Set;
import junit.framework.TestCase;

public abstract class AbstractClosestPairFinderTest extends TestCase {


protected abstract ClosestPairFinder createClosestPairFindert);

i'"

Jednym z przypadków szczególnych z punktu widzenia problemu znajdowania pary naj-


bliższych punktów jest zbiór pusty, czyli brak punktów. Metoda findCłosestPair() powinna
wówczas zwrócić wartość pustą czyli nul 1:
public void testEmptySetOfPointsO {
ClosestPairFinder finder = createClosestPairFindert);
assertNul1(finder.fi ndClosestPai r(new Li stSet())):
}

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

Set points = new ListSetO;


points. add (new Pointd, 1));

assertNul1(finder.findClosestPair(points));
]
Dwa punkty tworzą jedyną możliwą parę i para ta jest rozwiązaniem problemu:
public void testASinglePairOfPointsO {
ClosestPairFinder finder = createClosestPairFinderO:

Set points = new ListSetO:


Point p = new PointO. 1);
Point q = new Point(2. 4);

points.add(p):
points.add(q);

Set pair = finder.findClosestPair(points):

assertNotNull(pair);
assertEquals(2, pair.sizeO);
assertTruetpai r.contai ns(p));
assertTruetpair.contains(q)):
}

Dochodzimy do przypadku najbardziej interesującego — trzech współliniowych punktów,


z których środkowy jest równo odległy od pozostałych. Rozwiązaniem problemu może być
jedna z dwóch równoważnych par, my jednak sformułujemy dodatkowe wymaganie, by
była to para tworzona przez punkty występujące najwcześniej w kolejności wyznaczonej
przez komparator.

public void testThreePointsEquallySpacedApart() {


ClosestPairFinder finder = createClosestPairFinderO;

Set points = new ListSetO;


Point p = new Pointd. 0);
Point q = new Pointd, 4);
Point r = new Pointd. -4);

points.add(p);
points,add(q);
points.add(r);

Set pair = finder.findClosestPair(points);

assertNotNull(pair);
assertEquals(2. pair.sizeO);
assertTrue(pair.containstp));
assertTrue(pair.containstr));
1

Uogólnieniem przypadku trzech równomiernie rozłożonych współliniowych punktów jest


zbiór wielu punktów, w którym istnieją dwie pary o minimalnej odległości, czyli kwalifi-
kujące się jako rozwiązanie problemu. Tak jak poprzednio wymagamy od algorytmu zwrócenia
506 Algorytmy. Od podstaw

tej pary, która wykryta zostanie jako pierwsza (zgodnie z kolejnością wyznaczoną przez
komparator).
public void testLargeSetOfPointsWithTwoEqualShortestPairsO {
ClosestPairFinder finder = createClosestPairFinderO;

Set points = new ListSetO;

points.addtnew Point(0. 0));


points.add(new Point(4. -2));
points,add(new Point(2, 7));
points.add(new Point(3. 7));
points.add(new PointC-1. -5)):
points,add(new Point(-5. 3)):
points.add(new Point(-5, 4));
points.add(new Point(-0. -9));
points,add(new PointC-2. -2));

Set pair = finder.findClosestPair(points);

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:

public class PIaneSweepClosestPairFinderTest extends AbstractClosestPairFinderTest {


protected ClosestPairFinder createClosestPairFinderO {
return PlaneSweepClosestPairFinder.INSTANCE:
}
_}

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.

Mając gotowy zestaw testowy, stwórzmy implementację algorytmu, której poprawność za


pośrednictwem tego zestawu zweryfikujemy.
Rozdział 18. • Geometria obliczeniowa 507

niniłiiii Interfejs ClosestPairFinder


Nie mówiliśmy dotąd nic konkretnego na temat postaci, w jakiej spodziewamy się otrzymać
rozwiązanie problemu (ograniczając się jedynie do stwierdzenia, że w niektórych przypad-
kach powinien to być wynik pusty). Ten aspekt zagadnienia rozstrzygany jest przez defini-
cję poniższego interfejsu: poszukiwanie rozwiązania jest zadaniem metody o nazwie findClo-
sestPairO, której zwracanym wynikiem jest (w ogólnym przypadku) zbiór zawierający dwa
punkty położone najbliżej siebie. W przypadku, gdy punktów takich znaleźć niepodobna
(bo na przykład w zbiorze znajduje się tylko jeden punkt) metoda ta zwraca wynik pusty.
package com.wrox.a1gori thms.geometry;

i mport com.wrox.a1gori thms.sets.Set;

public interface ClosestPairFinder {


public Set findClosestPairtSet points):
}

spróbuj sam Implementacja algorytmu „zamiatania'


Klasa PlaneSweepClosestPairFinder wykorzystuje wewnętrznie klasę Listlnserter doko-
nującą binarnego wstawiania punktów do posortowanej listy:
package com.wrox.algorithms.geometry;

i mport com.wrox.al gori thms.bsea rch.Iterati veBi naryLi stSearcher;


i mport com.wrox.algori thms.bsearch.Li stInserter;
import com.wrox.algorithms.iteration.Iterator:
i mport com.wrox.a 1gori thms.1 i sts.ArrayLi st;
i mport com.wrox.a1gori thms.1 i sts.L i st:
i mport com.wrox.a1gori thms.sets.L i stSet:
import com.wrox.algorithms.sets.Set;

public finał class PlaneSweepClosestPairFinder implements ClosestPairFinder {


/** pojedyncza instancja klasy (singleton) */
public static finał PlaneSweepClosestPairFinder INSTANCE =
new PIaneSweepClosestPai rFi nder();

/** Binarny inserter wstawiający punkty do posortowanej listy */


private static finał Listlnserter INSERTER = new Listlnserter(
new IterativeBinaryListSearcher(XYPointComparator.INSTANCE)):

private PI aneSweepCl osestPai rFi nderO {


}

}
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):

Point p - (Point) sortedPoints.get(0);


Point q = (Point) sortedPoints.get(1);

return findClosestPair(p, q. sortedPoints);

Jak łatwo zauważyć, metoda ta korzysta z następującej metody pomocniczej (omówimy ją


niebawem):
private Set findClosestPair(Point p. Point q, List sortedPoints) {
Set result - createPointPairtp, q):
double distance = p.distance(q);
int dragPoint = 0;

for (int i = 2; i < sortedPoints.sizeO; ++1) {


Point r = (Point) sortedPoints,get(i);
double sweepX = r.getXO:
double dragX = sweepX - distance:

while (((Point) sortedPoints.get(dragPoint)),getXO < dragX) {


++dragPoint;
}
for (int j = dragPoint; j < i; ++j) {
Point test = (Point) sortedPoints.get(j):
double checkOistance = r.distance(test);
if (checkDistance < distance) {
distance = checkDistance;
result = createPointPair(r, test);
}

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";

List list = new ArrayList(points.sizeO);

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

Ostatnia z metod pomocniczych dokonuje skomasowania punktów stanowiących rozwiązanie


problemu w dwuelementowy zbiór:
private Set createPointPair(Point p. Point q) {
Set result = new ListSetO;
result.add(p);
result.add(q);
return result;
}

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;

for (int i = 2; i < sortedPoints.sizeO; ++i) {


Point r = (Point) sortedPoints.get(i);
double sweepX = r.getXO:
double dragX = sweepX - distance;

while (((Point) sortedPoints.get(dragPoint)),getX() < dragX) {


++dragPoint;
}
for (int j = dragPoint; j < i; ++j) {
Point test = (Point) sortedPoints.get(j):
double checkDistance = r.distance(test):
if (checkDistance < distance) {
distance = checkDistance;
result = createPointPair(r, test);
)

return result:
}
510 Algorytmy. Od podstaw

Warto w powyższym kodzie zwrócić uwagę na następujące szczegóły:


• para punktów stanowiąca (dotychczasowe) rozwiązanie problemu ukrywa się
pod identyfikatorem result,
• najmniejsza znana dotychczas odległość — i zarazem szerokość okna skanowania
— zapamiętana jest w zmiennej distance,
• dragpoint jest indeksem najbardziej na lewo położonego punktu wewnątrz okna
skanowania,
• sweepx jest współrzędną x punktu odniesienia położonego na prawej krawędzi okna
skanowania,
• dragx jest współrzędną x lewej krawędzi okna skanowania.

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.

Na tym kończymy implementację algorytmu zamiatania i zalecamy zweryfikować jej po-


prawność za pomocą skonstruowanego wcześniej zestawu testowego.

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.

W niniejszym rozdziale wyjaśniamy:


• jak optymalizacja wpisuje się w całościowy proces tworzenia aplikacji,
• co to jest profilowanie aplikacji i jak się je przeprowadza,
• jakie mechanizmy profilowania udostępniane są przez wirtualną maszynę Javy (J VM),
• jak przeprowadza się profilowanie aplikacji za pomocą profilatora Java Memory
Profiler (JMP),
• w jaki sposób identyfikuje się problemy wynikające z nieoptymalnego
wykorzystywania czasu procesora i pamięci operacyjnej,
• jak osiągnąć można istotne ulepszenie wydajności aplikacji za pomocą drobnych,
lecz strategicznie przemyślanych zabiegów.

Kiedy optymalizowanie ma sens?


Optymalizacja spełnia istotną rolę w procesie tworzenia aplikacji, roli tej nie można jednak
przeceniać Oczywiście programiści muszą być świadomi konsekwencji (w odniesieniu do
wydajności aplikacji) używania takich czy innych mechanizmów języka programowania
512 Algorytmy. Od podstaw

czy konkretnego środowiska; przykładowo tworzenie długich łańcuchów znakowych reali-


zowane jest efektywniej przy użyciu klasy StringBuffer niż za pomocą wielokrotnego
konkatenowania obiektów klasy String.

Ś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.

Dobrą wiadomością dla programistów będzie z pewnością fakt, że w typowym programie


liczba „wąskich gardeł" wydajności jest raczej niewielka, a opisywane w niniejszym roz-
dziale techniki umożliwiają ich skuteczne wykrywanie i niwelowanie oraz ocenę stopnia
poprawy wydajności w wyniku zastosowanych zabiegów optymalizacyjnych. Z tego
względu nie należy ograniczać się do „zgadywania" miejsca ich występowania ani hołdo-
wania stereotypowym opiniom na ich temat. Tak samo jak niebezpieczne jest wykonywanie
refaktoryzacji kodu bez uprzedniego opracowania zestawów testowych weryfikujących jego
poprawność, tak też niecelowe jest optymalizowanie kodu bez uprzednich pomiarów wy-
dajności jego poszczególnych elementów.

Wąskie gardła wydajnościowe są przyrodzoną cechą każdego programu wykonującego się


przez nietrywialny odcinek czasu — i może to dotyczyć także tych programów, których
wydajność jest akceptowalna dla użytkownika. Celem optymalizacji nie jest jednak usuwanie
Rozdział 19. • Optymalizacja pragmatyczna 513

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.

Profilatory udostępniają najczęściej następujące statystyki w tym zakresie:


• liczba wywołań każdej z metod,
• czas procesora konsumowany przez wykonywanie każdej z metod,
• czas procesora konsumowany przez wykonywanie każdej z metod wraz
z metodami przez nią wywoływanymi,
• względny udział czasu wykonywania każdej metody w ogólnym bilansie
wykorzystania czasu procesora.

Dzięki tym statystykom można z dużym prawdopodobieństwem zidentyfikować te fragmenty


kodu, w stosunku do których optymalizacja przynieść może najbardziej wyraźne efekty.

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

• liczbę tworzonych obiektów każdej z klas,


• liczbę automatycznie niszczonych (w procesie odśmiecania) obiektów każdej z klas,
• histogram wielkości pamięci przydzielonej przez system operacyjny wirtualnej
maszynie Javy w kolejnych przedziałach czasowych,
• histogram wielkości wolnego i używanego fragmentu sterty w poszczególnych
przedziałach czasowych.

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.

W kolejnym podrozdziale zaprezentujemy profilowanie tegoż programu przy użyciu dwóch


różnych technik. Pierwszą z nich będzie wykorzystanie profilatora wbudowanego w ma-
szynę wirtualną Javy — prostego, lecz natychmiast gotowego do użytku. Drugim wykorzy-
stywanym narzędziem będzie freeware'owy program Java Memory Profiler (JMP) dostar-
czający większej ilości użytecznych informacji w bardziej czytelnej i estetycznej formie
graficznej, wymagający jednak odrębnego instalowania. Rozpoczniemy jednak od wyja-
śnienia szczegółów programu, który będziemy poddawać profilowaniu.

Przykładowy program FileSortingHelper


Nasz — nieco wymyślny — program, opracowany jednak z myślą głównie o profilowaniu
i optymalizacji, jest de facto filtrem odczytującym wyrazy ze standardowego wejścia (po
jednym wyrazie w wierszu) i drukujący je w dość specyficznej kolejności: jest to alfabe-
tyczna kolejność lustrzanego odbicia wyrazów — i tak na przykład wyraz „ant" plasuje się
w tej kolejności po wyrazie „pie", bowiem w kolejności alfabetycznej „tna" następuje póź-
niej niż „eip". Jak uprzedzaliśmy, program ten stworzono głównie dla celów prezentacji
profilowania i optymalizacji, nie ma więc sensu wątpić w jego użyteczność — bo istotnie
z punktu widzenia wykorzystania praktycznego jest bezużyteczny! 1

Jeżeli skierujemy na wejście standardowe plik zawierający następujące wyrazy:


test
driven
development
is
one
smali
step
for
programmers
but
one

' Niekoniecznie: tak uporządkowany słownik m o ż e być niezwykle p o m o c n y dla poetów,


b o w i e m rymujące się wyrazy sąsiadują ze sobą! —- przyp. tłum.
Rozdział 19. • Optymalizacja pragmatyczna 515

giant
leap
for
programming

otrzymamy na wyjściu następujący ich ciąg:


one
one
programming
smali
driven
leap
step
for
for
is
programmers
giant
development
test
but

A oto kod komparatora wyznaczającego taką kolejność wyrazów:


package com.wrox.algori thms.sorti ng;

public finał class ReverseStringComparator implements Comparator {


/** Pojedyncza, dostępna publicznie instancja komparatora */
public static finał ReverseStringComparator INSTANCE
= new ReverseStringComparator():

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":

return reverse((String) left).compareTo(reverse((String) right));


}
private String reverse(String s) {
StringBuffer result = new StringBufferO;

for (int i = 0: i < s.lengthO; ++i) {


result.append(s.charAt(s.lengthO - 1 - i));
}
return result.toStringO;
}
J

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

spróbuj sam Implementowanie klasy FileSortingHelper


Główna klasa aplikacji — FileSortingHelper — zdefiniowana jest następująco:
package com.wrox.al gorithms.sorting:

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;

public finał class FileSortingHelper {


private FileSortingHelperO {
}

public static void main(String[] args) throws IOException {


sort(loadWordsO);

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...");

Comparator comparator - ReverseStringComparator.INSTANCE;


ListSorter sorter = new ShellsortListSorter(comparator);
Rozdział 19. • Optymalizacja pragmatyczna 517

List sorted = sorter.sorttwordList);

Iterator i = sorted.iteratorO;
i.firstO;
while (!i .isDoneO) {
System.out.pri nt1n(i.current O ) :
i,next();

Metoda loadWords() wczytuje kolejne słowa z wejścia standardowego aż do ich wyczerpa-


nia i dodaje je kolejno do nieuporządkowanej listy; lista ta zwracana jest jako wynik. Zwróćmy
uwagę, że metoda może generować wyjątki IOException wymagające obsłużenia przez metodę
wywołującą.
private static List loadWordsO throws IOException {
List result = new LinkedListC);

BufferedReader reader = new BufferedReader(new


InputStreamReader(System.in));

try {
String word;

while ((word = reader.readLineO) != nuli) {


result.add(word);
}
} finally {
reader.closeO;
}
return result;
}
_}
Po skompilowaniu programu należy go uruchomić, przekierowując jego wejście standar-
dowe na plik zawierający listę słów:
java com.wrox.algorithms.sorting.FileSortingHelper <words.txt

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).

Wykonanie prezentowanego programu na laptopie z procesorem Pentium 4, dla pliku za-


wierającego 10 000 słów, zajęło około minuty, przy pełnym obciążeniu procesora. Jest to
znacznie więcej niż się spodziewaliśmy, warto więc rozpatrzyć możliwości optymalizacji
programu. Rozpoczniemy oczywiście od jego profilowania.

Profilowanie za pomocą moduhl hprof


Mechanizmy profilowania wbudowane są standardowo w maszynę wirtualna Javy. W celu
sprawdzenia, czy są one dostępne w konkretnej instalacji, należy wydać następujące polecenie:
518 Algorytmy. Od podstaw

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>. ...]

Option Name and Value Description Default

heap=dump|sites|a11 heap profiling all


cpu=samples|times|old CPU usage off
monitor=y|n monitor contention n
format=a|b ascii or binary output a
file=<file> write data to file java.hproft.txt for ascii)
net=<host>:<port> send data over a socket write to file
depth=<size> stack tracę depth 4
cutoff=<value> output cutoff point 0.0001
lineno=y|n line number in traces? y
thread=y|n thread in traces? n
doe=y|n dump on exit? y

Example: java -Xrunhprof:cpu=samples.file=log.txt,depth=3 FooClass

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.

Po zakończeniu programu ujrzysz na ekranie następujący komunikat:


Dumping CPU usage by sampling running threads ... done.

Komunikat ten związany jest z tworzeniem (w bieżącym katalogu) pliku java.hprof.txt


zawierającego informacje zebrane w procesie profilowania. Jest to plik tekstowy o zawarto-
ści podobnej do poniższej:
THREAD START (obj=2b76bc0. id = 1. name="Finalizer". group="system")
THREAD START (obj=2b76cc8. id = 2, name="Reference Handler". group="system")
THREAD START (obj=2b76da8, id = 3. name="main", group="main")
THREAD START (obj-2b79bc0, id = 4, name="HPROF CPU profiler", group="system")

Powyższe stanowi informację na temat wątków uruchomionych w maszynie wirtualnej


Javy. Jak łatwo zauważyć, hprof tworzy swój własny wątek. W dalszej kolejności następuje
w pliku seria migawek ze śledzenia stosu podobna do poniższej:
Rozdział 19. • Optymalizacja pragmatyczna 519

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.

Zanim wyciągniemy konkretne wnioski z otrzymanych wyników i podejmiemy konkretne


działania optymalizacyjne, przeprowadzimy ponownie profilowanie naszego programu za
pomocą innego narzędzia — Java Memory Profiler (JMP).
520 Algorytmy. Od podstaw

Profilowanie za pomocą JMP


Java Memory Profiler (JMP) to darmowe narzędzie dostępne do pobrania w internecie pod
adresem:
http://www.khelekore.org/jmp

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

W rezultacie powinniśmy otrzymać następującą informację na temat możliwych sposobów


wywoływania JMP:
jmp/jmp/0.47-win initializing: (help):...
help wanted..
java -Xrunjmp[:[options]] package.Class
options is a comma separated list and may include:
help - to show this text.
nomethods - to disable method profiling.
noobjects - to disable object profiling.
nomonitors - to disable monitor profiling.
allocfollowsfilter - to group object allocations into filtered methods.
nogui - to run jmp without the user interface.
dodump - to allow to be called with signals.
dumpdir=<directory> - to specify where the dump-/headdumpfiles go.
dumptimer=<n> - to specify automatic dump every n:th second.
filter=<somefilter> - to specify an initial recursive filter.
threadtime - to specify that timing of methods and monitors
should use thread cpu time instead of absolute time.
Simulator - to specify that jmp should not perform any jni tricks.
probably only useful if you debug jmp.

An example may look like this:


java -Xrunjmp:nomethods.dumpdi r=/tmp/jmpdump/ rabbit.proxy.Proxy

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:

java -Xrunjmp com.wrox.algorithms.sorting.FileSortingHelper <words.txt >sorted.txt

Na ekranie pojawią się wówczas trzy okna widoczne na rysunku 19.1.


Rozdział 19. • Optymalizacja pragmatyczna 521

a
Elle : _ $ Jsva Memory Profller - Objects • g s s

[c:\di I U biass ! Instances Max instanc Size #GC Tenure


I I Total 47460 69969 1.64063 2402630 76.325622
R|java nio HeapCharBuTTer 14200 14200 665.625 5820 0.000000
fi; chart] 10472 17914 388.57031 802187 108.947956
H;java lang String 10486 17897 245 76563 797304 108.948026
R com wrox algonthms lists .Unkei 10001 10001 234 39844 0 109 000000
|byte|| 287 309 52.25 26 108 247387
g:javalang ObjectJl 494 569 25 05469 90 108 344130
1 shortj] 386 416 20 26563 39 108 160622 ~
Eu i [>
5 £liowvig IDO d«ses out of 116
• Ms;miłiy ProRłer filRthods
Class Method secs calls subs sec total
Java Jang Object void wait (long) 419 000000 5 0.000000 419
com.wrox algorWims.sortlng.Rejava.lang.Stnng reverse (Java I; 49 000000 796832 47 000000 97
ni com wrox algonthms llsts.Linkei com wrox algonthms llsts Unkei 45 000000 463975 0 000000 45
com wrox algonthms .lists Unkei com wrox .algonthms lists Unkei 38.000000 393243 0 000000 38
java Jang stnng char charAt (int) 10 000000 7390575 0 000000 10
s Java.lang.StringBu1Ter java lang.StnngBuffer append ( 10 000000 7169252 0 000000 10
Ifch(*wiglOOm«thc<kotfcf 180

I (t (.li-irtuiy Profłler M t
fil* Qptions
Dump Restore || System.QC | tjeapdump || Monltors [| Ęreeze ul

Heap 2.10156 MB Used 1.64063 MB Flltered 1.64063 MB


I cokction corr«leted: 7 cfcjects rtwyed, 19977 objectt freed n 0.000000 s«toflds

M.tfsndo... -j 38 . • Mapcrtii... jnBBBBB' 01 " f x 0>MI\ ą ^ O f l "»35pm

Rysunek 19.1. Okna modułu JMP podczas profilowania aplikacji

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.

Nic w tym tajemniczego — to jedynie właściwie ukierunkowany wysiłek zmierzający do


uzyskania rzeczywiście wymiernych efektów. W następnym podrozdziale pokażemy, jak
zastosować tę filozofię w praktyce.
Rozdział 19. • Optymalizacja pragmatyczna 523

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.

Rysunek 19.2. 9 Jwji IDemoiy Profiier Methorfs


Class Method secs calls subs sec total total/call ~
Okno metod
java lang.Object void wait (long) 419.000000 5 0 .000000 419 000000 83 000C
modułu JMP com wrox.algonthms sorting.Re javaJang String reverse (java I; 49 000000 796832 47.000000 97 000000 0.000C
com wrox.algorithms lists Linkei com.wrox.algorithms iists Linkei 45.000000 463975 0 000000 45 000000 0 00GC
com wrox algorithms lists.Unke(Com.wroxalgorithms.lists.Linke< 38.000000 393243 0 000000 38 000000 0.000C
java lang String char charAt (int) 10 000000 7390575 0 000000 10 000000 0 OOOC
java.lang StringBuffer java.lang StrmgBuffer append ( 10 000000 7169252 0.000000 10 000000 O.OOOC v
< >

£howmg 100 methods out of 180

Łatwo zauważyć, że głównymi składnikami w ogólnym bilansie obciążenia procesora są:


odwracanie kolejności znaków w łańcuchu (metoda reverse()) oraz obsługa listy wiązanej
(LinkedList). Ekstremalna wartość w pierwszym wierszu okna nie ma tu znaczenia, zwią-
zana jest bowiem z czasem oczekiwania na naciśnięcie kombinacji Ctrl+C.

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.

spróbuj sam Zmiana listy wiązanej na listę tablicową


Zmiana implementacji listy (z LinkedList na ArrayList) sprowadza się do zmiany jednego
wiersza w treści metody 1 oadWords ():
private static List 1oadWordsO throws I0Exception {
List result = new ArrayListO;

BufferedReader reader = new BufferedReader(new


InputStreamReader(System.in)):

try {
524 Algorytmy. Od podstaw

String word;

while ((word = reader.readLinet)) != nuli) {


result.add(word);
}
} finally {
reader.closeO:

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

W oknach profilatora ukazać się powinny wówczas wartości podobne do widocznych na


rysunku 19.3.

9:41 PM

Rysunek 19.3. Wyniki profilowania po wymianie listy wiązanej na listę tablicową


Rozdział 19. • Optymalizacja pragmatyczna 525

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.

Rysunek 19.4. $ Java Memory Proflter. MUttlUlil s s s


Class Method secs calls subs sec tot *
Okno metod java lang.OOject void wait (long) 1119699799.000000 5 0 000000 11
profilatora com wrox algorithms sorting Rejava.lang String revęrse (java lang stnng) 51.000000 796832 49 000000
dla programu java lang Stnng char charAt (int) 11 000000 7390497 0 000000
java lang.StringBuffer java lang StnngBuffer append (cnar) 10 000000 7169251 0 000000
używającego
java lang.StringBuffer Java lang String tostring () 8.000000 797027 8 000000
listy tablicowej java lang StnngBuffer vold <lnlt> (Int) 6.000000 797028 0 000000 „
< >

100 mdliotJ, out ot 100

Po spojrzeniu na okno metod widoczne na rysunku 19.4, zauważymy, że wykonanie metody


reverse() komparatora ReverseStringComparator() zajmuje łącznie 51 sekund, podczas
gdy następna w rankingu metoda charAtO zajmuje łącznie tylko 11 sekund. Kolejny krok
w poprawianiu wydajności aplikacji musi mieć więc związek z metodą reverse() — j e s t
ona wywoływana niemal 800 000 razy, zatem nawet najdrobniejsze jej usprawnienie może
stanowić duży krok naprzód. Nawet gdyby była ona wywoływana tylko raz, stosunkowo
długi czas jej wykonania świadczyć może o tym, iż zaprojektowana została od początku
źle. Przypuszczenie to zdaje się potwierdzać informacja widoczna w oknie obiektów profi-
latora (rysunek 19.5).

ivź» Memory Prufiler Ohjects


Rysunek 19.5. 16 . ; :, L
Class Instances Maxinstanc Size #GC T "
Okno obiektów
Total 37246 60359 1.44835 2402769
profilatora JMP
java.nio.HeapCHarBuffer 13989 13989 655.73438 6031
char]] 104 73 18116 388 67188 802149 1
java.lang String 10487 18100 245 78906 797281 1
java lang Object[] 494 578 72.42188 100 1
byte[| 287 308 52 25 2 5 1
iv
< >

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

Nie jest niczym odkrywczym spostrzeżenie, że skoro w procesie porównywania łańcuchów


potrzebne są ich „lustrzane odbicia", to można produkować je jednorazowo, w czasie wczyty-
wania słów, i porównywać za pomocą komparatora naturalnego (przy okazji pozbywając
się komparatora ReverseStringComparator). Powinno to spowodować zarówno znaczne przy-
spieszenie porównywania łańcuchów (wykonywanego około 800 000 razy!), jak i znaczącą
redukcję łańcuchów pomocniczych podlegających automatycznemu zwalnianiu.

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.

Optymalizowanie klasy FileSortingHelper


Ponieważ opisana wyżej optymalizacja wymaga znaczącej ingerencji w kod klasy File-
SortingHelper, skonstruujemy w tym celu odrębną klasę o nazwie OptimizedFileSortingHelper,
pozostawiając klasę oryginalną jako przedmiot do rozmaitych porównań. Rozpoczynamy
od niezbędnych definicji:
package com.wrox.a1gori thms.sorti ng:

import com.wrox.a 1gori thms.i terati on.Iterator;


i mport com.wrox.a1gorithms.1 i sts.ArrayLi st;
i mport com.wrox.a1gori thms.1 i sts.List:

import java.io.BufferedReader;
import java.io.IOException:
import java.io.InputStreamReader:

public finał class OptimizedFileSortingHelper {


private OptimizedFileSortingHelperO {
}

}
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..."):

Comparator comparator = NaturalComparator.INSTANCE:


ListSorter sorter = new ShellsortListSorter(comparator);

List sorted = sorter.sort(words):


reverseAl1(sorted):
Rozdział 19. • Optymalizacja pragmatyczna 527

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;

BufferedReader reader - new BufferedReader(new


InputStreamReader(System.in));

try {
String word;

while ((word = reader.readLinet)) != nuli) {


result.add(word):
}
} finałly {
reader.closeO:
}
return result;
}
Metoda reverse() pozostała niezmieniona, choć zmienił się sposób jej wykorzystywania:
nie ma ona już związku z porównywaniem łańcuchów (i komparatorem), lecz jedynie z metodą
reverseA11().

private static String reverse(String s) {


StringBuffer result = new StringBufferO;

for (int i - 0; i < s.lengthO; ++i) {


result.append(s.charAt(s.lengthO - 1 - i));
}
return result.toString();
}

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

private static void reverseAl1(List words) {


for (int i - 0: i < words.sizeO; ++i) {
words.set(i. reverse((String) words.get(i)));

Drukowanie kolejnych elementów listy jest zadaniem metody printAllO przebiegającej


kolejne elementy listy za pośrednictwem iteratora:
private static void printAl1(List stringList) {
Iterator iterator = stringList.iteratorO:
iterator.fi r s t O :
while (!iterator.isDoneO) {
String word = (String) iterator.currentt);
System.out.println(word);
iterator.next():

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

W oknach profilatora JMP powinniśmy ujrzeć zawartość podobną do przedstawionej na ry-


sunku 19.6.
jdOUI
Rysunek 19.6.
iv.jiij(.tHlitii-; ..'i0|«tiiTi(.-t-Hł it^SmungHelpei

$ M»niniv P»oHI«r Objer-ts 1


Wynik profilowania Class Instances Max Instanc Size #GC
ns. sorting.OptimizedFileSort
klasy Optimized- Total 25982 53503 1.07574 K 79020 lDE00)(probably java.lang.Cl
charil 10472 16503 535 08594 \ 20801
FileSortingHelper java lang String 10486 16458 245 76563 ł 20457
java nlo HeapCharButfer 2727 12107 127.82813 \ 17293
java.lang.Object|l 496 564 72 47656 ł 100
byte[J 288 305 52 20313> 25
@
>
1 phowng lOOdassesout of 114

ft Java Memory Piofiler • Mathods 1II31I3


Class subs sec total total/call
|java .lang Object ypidwalł(lonfl) 50 000000 6 0.000000 50.000000 8 0000(
| com wrox algorlthms lists ArrayLIst volfl checkOutOfBounds (Int) 4 000000 887218 1 000000 5 000000 0 0000C
' com wrox algorlthms sorting ShellsortL vold sortSubllst (com wrox algorlthms I 3 000000 179 16 000000 19.000000 0 0000(
i com.*wox.algontnms.llstsArrayUst Java.iang Object get (int) 2 000000 468237 2 000000 5.000000 0 0000(
j com wrox algonthms sorting NaturalCo int compare (Java lang Object. java lar 2 000000 398416 4 000000 6 000000 0 OOOOt
j com wrox algonthms lists ArrayList Java lang Object set (Int. java lang Obj 2 000000 418981 2 000000 5 000000 0 OOOOt

Ifchowing100 mrfhods out of 179


§ Java tównory PiófMer - Mviii.
Fila Qptlons
| Pump I] Beset |f System g C j| [Heapdump ij Monitora jj" Ęreeze ui J Threads

Heap 1.9375 MB Used 1.07574 MB Filtered 1 07574 MO


I j^aibaga colettion complntad: 0 objects moved, 12117 ob)«cts fread In 0.0000X1 tecoods
-y>ffr-rv7'rB~-~"—-—""!' —"rs^nwri^itrwin*'"^ — —
^ i o ^ J ' „ z? 5 n m- b wI < 3, 9 s »«««
Rozdział 19. • Optymalizacja pragmatyczna 529

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.

Rysunek 19.7. # Java Memory Profller Methods


Class Methoo secs całls suds sec total
'£3-0
total/call ~
Okno metod java lang Object void wait (long) 50 000000 6 0.000000 50 000000 8.00001
profilatora JMP com wrox algorithms lists ArrayList void checkOutOtEounds (int) 4 000000 687213 1 000000 5 000000 0 OOOOt
com wrox.algonthms sorting SheilsortL void sortSubllst (com wrox algorithms l 3.000000 179 16.000000 19 000000 0.00001
z wynikami
com wron algorithms hsts ArrayList java lang Object get (int) 2 000000 468237 2 000000 5 000000 0 OOOOt
profilowania com wroy algorithms.sorting NaturalCo Int compare (java lang Object. java lar 2000000 398416 4 000000 6 000000 0.0000(
klasy Optimized- com wro* algorithms lists ArrayList java lang Object set (int, java lang ObJ 2 000000 418981 2 000000 5 000000 0 oooot v
< >
FileSortingHelper
5ho«lq lOOfmthodsWtrf 175

Z czołówki okna zniknęła metoda reverse(), a wykonywanie metody obciążającej procesor


w największym stopniu zajmuje teraz tylko 4 sekundy. Wygląda to na bardzo duży postęp.

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.

Rysunek 19.8. l l l Java Memoiy Protilcr Olijects W


Class Instances Maxinstanc Size #GC T
Okno obiektów -

Total 25982 53503 1 07574 h 79020


profilatora JMP
char|) 10472 16503 535.08594 1 20801
z wynikami
java lang String 10486 16458 245.765631 20457
profilowania
java.nlo HeapCharSufier 2727 12107 127 8 2 8 1 3 1 17293
klasy Optimized- java.lang ObjectJ] 496 564 72.476561 100
FileSortingHelper byte|| 288 305 52 2 0 3 1 3 1 25 V

< >
|5ho«**j 100 dasses out of 1H

Liczba automatycznie zwalnianych obiektów w kolumnie #GC zmniejszyła się z ponad 2


milionów do niespełna 80 tysięcy, przy czym łańcuchy klasy String mają w tej liczbie je-
dynie 20-tysięczny udział, co związane jest z dwukrotnym odwracaniem każdego z 10 000
słów. Obserwując takie i podobne efekty optymalizacji, należy przede wszystkim odnosić
je do konkretnych zmian, dlatego tak ważne jest ponowne profilowanie aplikacji po każdej
zmianie w jej kodzie.

Drastyczna poprawa wydajności naszej aplikacji wymagała zlikwidowania jedynie dwóch


wąskich gardeł, które łatwo zidentyfikowaliśmy za pomocą profilatora. Ostatnią rzeczą, jaka
pozostaje nam do zrobienia, jest uruchomienie aplikacji w normalnym trybie i zaobserwo-
wania, jak efektywnie pracuje bez spowolnienia wnoszonego przezprofilator:
java com.wrox.algorithms.sorting.OptimizedFileSortingHelper <words.txt >sorted.txt

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.

Robert Sedgewick, Algorithms in Java. Third Edition, części 1 - 4: Fundamentals, Data


Structures, Sorting i Searching, Addison-Wesley, 2002.

Erich Gamma i in., Wzorce projektowe, Wydawnictwo Naukowo-Techniczne, Warszawa, 2005.

Michael Folk i Bill Zoelick, File Structures, Addison-Wesley, 1991.

Thomas H. Cormen i in., Wprowadzenie do algorytmów, Wydawnictwo Naukowo-Techniczne,


Warszawa, 2004.

Jack Shirazi, Java Performance Tuning. SecondEdition, 0'Reilly Associates, 2003.

Vincent Massol i Ted Husted, JUnit in Action, Manning, 2004.

Kent Beck, Test-Driven Development: By Example, Addisgn-Wesley, 2002.

David Astels, Test-Driven Development: A Practical Guide, Prentice Hall PTR, 2003.

Donald E. Knuth, Sztuka programowania, algorytmy podstawowe, Wydawnictwa Naukowo-


-Techniczne, Warszawa 2002.

Donald E. Knuth, Sztuka programowania, sortowanie i wyszukiwanie, Wydawnictwa Na-


ukowo-Techniczne, Warszawa 2002.
532 Algorytmy. Od podstaw
Wybrane zasoby internetowe
Apache J a k a r t a Commons: http://jakarta.apache.org/commons

Strona domowa J M P : http://www.khelekore.org/jmp

National Institute of Standard and Technology: http://www.nist.gov

Projekt Gutenberg: http://www.gutenberg.org

Strona domowa Unicode: http://www.unicode.org

University of Southern Denmark Department of Mathematics and C o m p u t e r Science


http://imada. sdu. dk

University of Calgary Department of Computer Science: http://www.cpsc.ucalgary.ca

Wikipedia: http://pl.wikipedia.org

Listy słów: http://wordIist.sourceforge.net/


534 Algorytmy. Od podstaw
Literatura cytowana
c
[Astels, 2003] David Astels, Test-Dńven Development: A Practical Guide, Prentice Hall
PTR, 2003.

[Beck, 2000) Kent Beck, Extreme Programming Explained, Addison-Wesley, 2000.

[Beck, 20021 Kent Beck, Test-Driven Development: By Example, Addison-Wesley, Long-


man, 2002.

|Bloch 2001] Joshua Bloch, Effective Bloch. Effective Java, Addison-Wesley, 2001. Tłu-
maczenie polskie: Efektywne programowanie w języku Java, Helion, 2002.

[Cormen 20011 Thomas H. Cormen i in., Wprowadzenie do algorytmów, Wydawnictwo


Naukowo- Techniczne, Warszawa, 2004.

[Crispin, 2002] Lisa Crispin i Tip House, Testing Extreme Programming, Addison-Wesley,
2002.

[Fowler, 1999| Martin Fowler, Refactoring, Addison Wesley, 1999.

[Gamma 1995) Erich Gamma, Richard Heim, Ralph Johnson u John Vlissides, Wzorce
projektowe, Wydawnictwo Naukowo- Techniczne, Warszawa, 2005.

[Hoare, 19621, C.A.R. Hoare „Quicksort", CompJ., 5, nr 1, 1962, s. 1 0 - 1 5 .

[Hunt, 2000) Andy Hunt i Dave Thomas, The Pragmatic Programmer, Addison-Wesley, 2000.

[Knuth, 1973] Donald E. Knuth, Sztuka programowania, algorytmy podstawowe, Wydaw-


nictwa Naukowo-Techniczne, Warszawa 2002.

|Knuth, 1998| Donald E. Knuth, Sztuka programowania, sortowanie i wyszukiwanie, Wy-


dawnictwa Naukowo-Techniczne, Warszawa 2002.

|Massol, 2004] Yincent Massol i Ted Husted, JUnit in Action, Manning, 2004.
536 Algorytmy. Od podstaw

|Sanchez, 20031 Daniel Sanchez-Crespo Dalmau, Core Techniąues and Algorithms in


Game Programming, New Riders Publishing, 2003.

[Sedgewick, 2002] Robert Sedgewick, Algorithms in Java. Third Edition, części 1 - 4:


Fundamentals, Data Structures, Sorting i Searching, Addison-Wesley, 2002.

[Shell, 1959] D.L. Shell, „A Highspeed Sorting Procedure", Commimication ACM, 2, nr 7,


1959, s. 3 0 - 3 2 .
D
Odpowiedzi do ćwiczeń
W niniejszym dodatku zamieszczamy odpowiedzi do pytań i ćwiczeń końcowych poszcze-
gólnych rozdziałów. Mimo iż nie wszystkim rozdziałom towarzyszą ćwiczenia końcowe,
mamy nadzieję, że staną się one dla Czytelników okazją do praktycznego sprawdzenia opi-
sywanych koncepcji. Gorąco zachęcamy także do samodzielnego eksperymentowania w tym
zakresie.

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.

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.
538 Algorytmy. Od podstaw

Rozwiązania

package com.wrox.a 1gorithms.i terati on;

public class Skiplterator implements Iterator {


private finał Iterator _iterator
private finał int _skip

public SkipIterator(Iterator iterator. int skip) {


assert iterator != nuli : "nie określono iteratora"
assert skip > 0 : "przeskok musi być dodatni"
_iterator = iterator:
_skip - skip:

public void firstO {


_iterator.firstO;
skipForwardsO;
}
public void lastO {
_iterator.last():
skipBackwardsO;

public boolean isDoneO {


return _iterator.isDoneO
}
public void next() {
_i terator.next():
skipForwardsO;
}
public void previous() {
_iterator.previous():
skipBackwardsO:
}
public Object currentO throws IteratorOutOfBoundsException {
retunr _iterator.current();
}
private void skipForwardsO {
for (int i = 0; i < _skip && !_iterator.isDoneO: _iterator.next()):
}
private void skipBackwardsO {
for (int i = 0; i < _skip && !_iterator.isDoneO;
_i terator. previous O ) ;
Dodatek D • Odpowiedzi do ćwiczeń 539

2.
package com.wrox.algorithms.iteration:

public finał class AndPredicate implements Predicate {


/** lewy argument */
private finał Predicate _left;

/** prawy argument */


private finał Predicate _right:

public AndPredicate(Predicate left, Predicate right) {


assert left !- nuli : "nie określono lewego argumentu":
assert right != nuli : "nie określono prawego argumentu":

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() {
}

public int calculate(int base. int exponent) {


assert exponent >= 0 : "wykładnik nie może być ujemny";

return exponent > 0 ? base * calculate(base. exponent - 1) : 1:

4.
package com.wrox.algorithms.iteration;

import java.io.File:

public finał class RecursiveDirectoryTreePrinter {


/** dodatkowe wcięcie na każdym poziomie*/
private static finał String SPACES = " ";

private RecursiveDirectoryTreePrinter() {
}
540 Algorytmy. Od podstaw

public static void main(String[] args) {


assert args != nuli : "nie określono parametrów";

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]). "");

private static void print(Iterator files. String indent) {


assert files != nuli : "nie określono listy plików";

for (files.firstO; ! files. i sDoneO: files.next()) {


printC(File) files.currentO. indent):
}
}
private static void print(File file. String indent) {
assert file != nuli : "nie określono pliku lub katalogu";
assert indent != nuli : "nie określono wcięcia";

System.out.print(indent);
System.out.pri nt1n(fi 1e.getName());

if (file.isDirectoryO) {
print(new Arraylterator(file.listFilesO), indent + SPACES);
}
}
_]
5.

package com.wrox.a1gori thms.i terat i on;

public class Singletonlterator implements Iterator {


/** pojedyncza wartość */
private finał Object _value;

/** wskaźnik zakończenia iteracji */


private boolean _done;

public SingletonIterator(Object value) {


assert value != nuli : "nie określono wartości";
_value = value;
}
public void firstO {
_done = false;
}
public void lastO {
_done = false;
}
Dodatek D • Odpowiedzi do ćwiczeń 541

public boolean isDoneO {


return _done;
}
public void next() {
_done = true:
1
public void previous() {
_done - true:
}
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsException();
}
return value;

6.
package com.wrox.algori thms.i terati on:

public finał class Emptylterator implements Iterator {


/** pojedyncza, publicznie dostępna instancja iteratora */
public static finał Emptylterator INSTANCE = new EmptylteratorO;

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.

7. Zaimplementuj listę, która jest permanentnie pusta, a próba wstawienia do niej


elementu powoduje wystąpienie wyjątku UnsupportedOperationException.

Rozwiązania

1.

public ArrayList(Object[] array) {


assert array != nuli : "nie określono tablicy":

_initial capacity = array.length;


clearO:

System,arraycopytarray. 0. _array. 0. array.length);


_size := array.length;
}

2.

public boolean equals(Object object) {


return object instanceof List ? equals((List) object) : false:
}
Dodatek D • Odpowiedzi do ćwiczeń 543

public boolean equals(List other) {


if (other == nuli || sizeO != other.sizeO) {
return false;
}
Iterator i = iteratorO;
Iterator j = other.iteratorO;

for (i.firstO. j.firstO:


!i.isDone && !j.isDone; i.next(). j.next() {
if (!i .currentO,equals(j.currentO)) {
break;

return i.isDoneO && j.isDoneO;


)

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;

public class GenericListlterator implements Iterator {


/** lista, której elementy iterator będzie iterował */
private finał List J i s t ;

/** bieżąca pozycja elementu w liście */


private int _current = -1:

public GenericListIterator(List list) {


assert list != nuli : "nie określono listy":
J i s t = list:
}
544 Algorytmy. Od podstaw

public void firstO {


_current = 0;
}
public void lastO {
_current = _1ist.size() - 1;
1
public boolean isDoneO {
return _current < 0 || _current >= J i s t . s i z e O ;
}
public void next() {
++_current:
}
public void previous() {
--_current;
}
public Object currentO throws IteratorOutOfBoundsException {
if (isDoneO) {
throw new IteratorOutOfBoundsException();
}
return _list.get(_current);

5.

private Element getElement(int index) {


if (index < _size / 2) {
return getElementForwards(index):
} else {
return getElementBackwards(index);
}

private Element getElementForwards(int index) {


Element element = JieadAndTail,getNext();

for (int i = index; i > 0; --i) {


element = element.getNext();
}
return element;
}
private Element getElementBackwards(int index) {
Element element - JieadAndTail;

for (int i = _size - index; i > 0 ; --i) {


element = element,getPrevious();
}
return element;
}
Dodatek D • Odpowiedzi do ćwiczeń 545

6.
public int indexOf(Object value) {
assert value != nuli : "nie określono wartości";

int index = 0;
Iterator i = iteratorO;

for (i.firstO; !i.isDoneO; i.next()) {


if (value.equals(i .currentO)) {
return index;
}
++index:
}
return -1;
i

7.

package com.wrox.a 1 gori thms.1 i sts;

i mport com.wrox.al gori thms.i terat i on.Emptylterator;


import com.wrox.algorithms.iterati on.Iterator;

public finał class EmptyList implements List {


/** pojedyncza instancja klasy */
public static finał EmptyList INSTANCE = new EmptyListO;

private EmptyListO {
}

public void insert(int index. Object value) throws


IndexOutOfBoundsException {
throw new UnsupportedOperationException();
}
public void add(0bject value) {
throw new UnsupportedOperationException();
1
public Object delete(int index) throws IndexOutOfBoundsException {
throw new UnsupportedOperationException();
}
public boolean delete(Object value) {
throw new UnsupportedOperationException();
}
public void clearO {
}

public Object set(int index. Object value) throws IndexOutOfBoundsException {


throw new UnsupportedOperationException();
}
546 Algorytmy. Od podstaw

public Object get(int index) throws IndexOutOfBoundsException {


throw new UnsupportedOperationException();
}
public int index0f(0bject value) {
return -1;
}
public boolean contains(Object value) {
return false;
}
public int sizeO {
return 0;
}
public boolean isEmptyO {
return true;
}
public Iterator iteratorO {
return EmptyIterator.INSTANCE;
}
)

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.

package com.wrox.algori thms.gueues;

public class SynchronizedQueue implements Queue {


/** obiekt synchronizacyjny */
private finał Object _mutex = new ObjectO;

/** odnośna kolejka */


private finał Queue _queue:
Dodatek D • Odpowiedzi do ćwiczeń 547

public SynchronizedQueue(Queue queue) {


assert queue !- nuli : "nie określono kolejki":
_queue - queue;
}
public void enqueue(Object value) {
synchronized (_mutex) {
_queue.enqueue(value);

public Object dequeue() throws EmptyQueueException {


synchronized (_mutex) {
return _queue.dequeue();

public void clearO {


synchronized (_mutex) {
_queue. clearO;

public int sizeO {


synchronized (_mutex) {
return _queue.size();

public boolean isEmptyO {


synchronized (_mutex) {
return _queue.isEmptyO;
}
}
}

2.
package com.wrox.algori thms.queues;

i mport com.wrox.a1gori thms.1 i sts.Li nkedLi st:


i mport com.wrox.a1gori thms.1 i sts.Li st;
public class RandomListOueue implements Oueue {
/** odnośna lista */
private finał List J i s t ;

public RandomListQueue() {
this(new Li nkedLi st()):
}

public RandomListQueue(List list) {


J i s t = list;
}
public void enqueue(Object value) {
list.add(value);
548 Algorytmy. Od podstaw

}
public Object dequeue() throws EmptyQueueException {
if (isEmptyO) {
throw new EmptyQueueException();
}
return list.delete((int) (Math.randomO * sizeO)):

public void clearO {


J i s t . clearO;
}
public int s i z e O {
return _1 ist.sizeO:
}
public boolean isEmptyO {
return J i s t . i s E m p t y O :
}
j

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.

package com.wrox.a1gori thms.sorti ng;

i mport com.wrox.a1gori thms.lists.ArrayL i st:


i mport com.wrox.a1gor i thms.1 i sts.L i st:
import junit.framework.TestCase;

public class ListSorterRandomDoublesTest extends TestCase {


private static finał int TEST_SIZE = 1000:
Dodatek D • Odpowiedzi do ćwiczeń 549

private finał List _randomList = new ArrayList(TESTJIZE);


private finał NaturalComparator _comparator - NaturalComparator.INSTANCE;

protected void setUpO throws Exception {


super. setUpO;

for (int i - 1; i < TESTJIZE; ++i) {


randomList.add(new Double((TEST SIZE * Math.randomO)));

public void testsortingRandomDoublesWithBubblesortO {


ListSorter listSorter = new BubblesortListSorter(_comparator);
List result = listSorter.sort(_randomList);
assertSorted(result);

public void testsortingRandomDoublesWithSelectionsortO {


ListSorter listSorter = new SelectionSortListSorter(_comparator);
List result = listSorter.sort(_randomList);
assertSorted(result);

public void testsortingRandomDoublesWithInsertionsort() {


ListSorter listSorter = new InsertionSortListSorter(_comparator);
List result = listSorter.sort(_randomList):
assertSorted(result);

private void assertSorted(List list) {


for (int i = 1; i < list.sizeO; i++) {
Object o = list.get(i);
assertTrue(_comparator.compare(list.get(i - 1). list.get(i)) <- 0);
}
}

2.
package com,wrox.algorithms.sorting;

i mport com.wrox.a 1gori thms.1 i sts.ArrayLi st;


i mport com.wrox.a 1gorithms.1 i sts.Li st;
import junit.framework.TestCase;

public class ListSorterStabilityTest extends TestCase {


private static finał int TESTJIZE = 1000;

private finał List J i s t = new ArrayList(TESTJIZE);


private finał Comparator _comparator - new FractionComparatorO;

protected void setUpO throws Exception {


super. setUpO;

for (int i - 1; i < TESTJIZE; ++i) {


list.add(new Fraction(i X 20, i));
}
1
550 Algorytmy. Od podstaw

public void testStabilityOfBubblesortt) {


ListSorter listSorter - new BubblesortListSorter(_comparator);
List result = listSorter.sort(Jist);
assertStableSorted(result);

public void testStabilityOflnsertionsortO {


ListSorter listSorter = new InsertionSortListSorter(_comparator);
List result - listSorter.sort(Jist);
assertStableSorted(result);

private void assertStableSorted(List list) {


for (int i = 1; i < list.SizeO; i++) {
Fraction fl = (Fraction) list.getti - 1);
Fraction f2 = (Fraction) list.getti):
if(!(fl.getNumeratort) < f2.getNumerator()
|| fl.getDenominatort) < f2.getDenominator())) {
fail("co takiego?!"):
}
)
}
private static class Fraction {
private finał int jnumerator:
private finał int _denominator;

public Fractiontint numerator. int denominator) {


_numerator = numerator:
_denominator - denominator:
}
public int getNumeratort) {
return jnumerator:
}
public int getDenominatort) {
return denominator;

private static class FractionComparator implements Comparator {


public int comparetObject left. Object right) throws ClassCastException
{
return comparet(Fraction) left, (Fraction) right):
}
private int compare(Fraction 1, Fraction r) throws ClassCastException {
return 1.getNumeratort) - r.getNumeratorO:
}
}
}
Dodatek D • Odpowiedzi do ćwiczeń 551

3.
package com.wrox.algori thms.sorti ng;

public finał class CaseInsensitiveStringComparator implements Comparator {


public int compare(Object left. Object right) throws ClassCastException {
assert left != nuli : "nie określono lewego argumentu":
assert right != nuli : "nie określono prawego argumentu":

String leftLower = ((String) left),toLowerCase();


String rightLower = ((String) right) .toLowerCaseO :
return leftLower,compareTo(rightLower);

4.
package com.wrox.algori thms.sorti ng:

i mport com.wrox.a1gori thms.1 i sts.ArrayLi st:


i mport com.wrox.a1gori thms.1 i sts.Li st;
import junit.framework.TestCase:

public class ListSorterCallCountingTest extends TestCase {


private static finał int TESTJIZE = 1000:

private finał List _sortedArrayList = new ArrayList(TEST_SIZE):


private finał List _reverseArrayList = new ArrayList(TEST_SIZE):
private finał List _randomArrayList - new ArrayList(TEST_SIZE):

private CallCountingComparator _comparator:

protected void setUpO throws Exception {


super.setUp():
_comparator = new CallCountingComparator(NaturalComparator.INSTANCE):

for (int i = 1: i < TESTJIZE: ++i) {


_sortedArrayList.add(new Integer(i));
}
for (int i = TESTJIZE: i > 0: --i) {
_reverseArrayList.add(new Integer(i));
}
for (int i = 1; i < TESTJIZE; ++i) {
_randomArrayList.add(new Integer((int)(TESTJIZE *
Math.randomO)));
}
}
public void testWorstCaseBubblesortO {
new BubblesortListSorter(_comparator),sort(_reverseArrayList):
reportCallsO:
}
public void testWorstCaseSelectionSort() {
new SelectionSortListSorter(_comparator).sort(_reverseArrayList):
552 Algorytmy. Od podstaw

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

public void testAverageCaseSelectionSortC) {


new SelectionSortListSorter(_comparator).sort(_randomArrayList);
reportCallsO;
}
public void testAverageCaseInsertionSort() {
new InsertionSortListSorter(_comparator),sort(_randomArrayList):
reportCallsO:
}
public void testAverageCaseShellsortO {
new ShellsortListSorter(_comparator),sort(_randomArrayList);
reportCallsO:
}
public void testAverageCaseQuicksort() {
new QuicksortListSorter(_comparator),sort(_randomArrayList);
reportCallsO;
}
public void testAverageCaseMergeSort() {
new MergesortListSorter(_comparator),sort(_randomArrayList):
reportCallsO;
}
private void reportCallsO {
System.out.println(getNameO + ": " + _comparator.getCallCount() + "
wywołań");
}
]

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.

4. Zaimplementuj wersję sortowania przez wstawianie wykonywanego „w miejscu".


5. Zaimplementuj odmianę sortowania szybkiego pozostawiającego bez sortowania
podlisty krótsze niż 5 elementów i sortującego otrzymaną listę przez wstawianie.
554 Algorytmy. Od podstaw

Rozwiązania

package com.wrox.a1gori thms.sorti ng:

i mport com.wrox.a1gori thms.i terat i on.Iterator;


i mport com.wrox.a1gori thms.lists.ArrayLi st:
i mport com.wrox.a 1gori thms.1 i sts.Li st:

public class IterativeMergesortListSorter implements ListSorter {


/** komparator wyznaczający porządek obiektów */
private finał Comparator _comparator;

public IterativeMergesortListSorter(Comparator comparator) {


assert comparator !- nuli : "nie określono komparatora":
_comparator = comparator:
}
public List sorttList list) {
assert list != nuli : "nie określono listy":

return mergeSubli sts(createSubli sts(1 i st)):


}
private List mergeSubliststList sublists) {
List remaining = sublists:
while (remaining.sizeO > 1) {
remaining = mergeSublistPairs(remaining);

}
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:

Iterator 1 = left. iteratorO;


Iterator r = right.iteratorO;

1 firstO:
r.firstO:

while (!(1.isDoneO && r.isDoneO)) {


if O . i s D o n e O ) {
result.add(r.currentO);
r.next();
} else if (r. i sDoneO) {
result.add(l .currentO);
1,next();
} else if (_comparator.compareO .currentO. r.currentO) <= 0) {
result.addd .currentO);
1,next();
} else {
result.add(r.currentO):
r.next();
}
}
return result;
}
]
2.
package com.wrox.algori thms.sorti ng;

i mport com.wrox.a1gori thms.1 i sts.L i st:


i mport com.wrox.a1gori thms.stacks.Stack;
i mport com.wrox.algori thms.stacks.Li stStack;

public class IterativeQuicksortListSorter implements ListSorter {


/** komparator wyznaczający porządek obiektów */
private finał Comparator _comparator:

public IterativeQuicksortListSorter(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator:
}
public List sorttList list) {
556 Algorytmy. Od podstaw

assert list != nuli : "nie określono listy":

quicksort(list);

return list;
}
private void quicksort(List list) {
Stack jobStack = new ListStackO;

jobStack.push(new Range(0, list.sizeO - 1));

while (!jobStack.isEmptyO) {
Rangę rangę - (Rangę) jobStack. pop O ;
if (rangę.sizeO <= 1) {
continue;
}
int startlndex = rangę.getStartIndex():
int endlndex - rangę.getEndIndex();

Object value = list.get(endlndex);

int partition = partitiondist. value, startlndex, endlndex - 1):


if (_comparator.compare(list.get(partition). value) < 0) {
++partition:
}
swapdist, partition, endlndex):

jobStack.push(new Range(startlndex. partition - 1)):


jobStack.push(new Rangetpartition + 1, endlndex)):
}
}
private int partition(List list. Object value. int leftlndex, int
rightlndex) {
int left - leftlndex:
int right = rightIndex;

while (left < right) {


if (_comparator.compare(1 i st.get(1eft), value) < 0) {
++left;
continue;
}
if (_compa rator.comparet1 i st.get(ri ght). value) > - 0) {
--right:
continue;
}
swapdist, left. right);
++left:
}
return left:
Dodatek D • Odpowiedzi do ćwiczeń 557

private void swap(List list. int left. int right) {


if (left =- right) {
return;
}
Object temp - list.get(left):
list.setdeft, list.get(right)):
list.set(right. temp);
}
private static finał class Rangę {
private finał int _startlndex;
private finał int _endlndex;

public Range(int startlndex. int endlndex) {


_startlndex = startlndex;
_endlndex = endlndex:
}
public int sizeO {
return _endlndex - _startlndex + 1:
}
public int getstartlndex() {
return _startlndex;
)
public int getEndIndex() {
return endlndex;

3.
}
package com.wrox.algorithms.sorti ng;

i mport com.wrox.a1gori thms.1 i sts.ArrayLi st:


i mport com.wrox.a1gori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.stacks.Ca 11Counti ngLi st;
import junit.framework.TestCase;

public class AdvancedListSorterCa11CountingListTest extends TestCase {


private static finał int TEST_SIZE = 1000;

private finał List _sortedArrayList = new ArrayList(TEST_SIZE);


private finał List _reverseArrayList = new ArrayList(TEST_SIZE);
private finał List _randomArrayList = new ArrayList(TEST_SIZE);

private Comparator _comparator = NaturalComparator.INSTANCE;

protected void setUpO throws Exception {


super. setUpO;

for (int i = 1; i < TESTJIZE: ++i) {


_sortedArrayL i st.add(new Integer(i));
558 Algorytmy. Od podstaw

for (int i = TEST SIZE; i > 0; --i) {


_reverseArray!Ii st.add(new Integer(i));

for (int i - 1: i < TESTJIZE; ++i) {


randomArrayList.add(new Integer((int)(TESTJIZE *
Math.randomO)));

public void testWorstCaseQuicksort() {


List list = new CallCountingList(_reverseArrayList);
new QuicksortListSorter(_comparator).sort(list);
reportCallsO ist);
}
public void testWorstCaseShellSortO {
List list = new CallCountingList(_reverseArrayList);
new Shel1sortLi stSorter(_comparator).sort(11 st):
reportCalls(list);

public void testBestCaseQuicksort() {


List list = new CallCountingList(_sortedArrayList):
new QuicksortLi stSorter(_comparator).sort(list);
reportCalls(list);

public void testBestCaseShellSortO {


List list - new CallCountingList(_sortedArrayList);
new Shel1sortListSorter(_comparator).sort(1 i st);
reportCalls(list);

public void testAverageCaseQuicksort() {


List list - new CallCountingList(_randomArrayList);
new QuicksortListSorter(_comparator).sort(list):
reportCallsO ist);

public void testAverageCaseShellSort() {


List list = new CallCountingList(_randomArrayList);
new ShellsortListSorter(_comparator).sort(list);
reportCalls(list);

private void reportCalls(List list) {


System.out.println(getName() + ": " + list);

public class CallCountingList implements List


private finał List J i s t ;

private int JnsertCount:


priyate int _addCount;
Dodatek D • Odpowiedzi do ćwiczeń 559

private int _deleteCount;


private int _getCount;
private int _setCount;

public CallCountingList(List list) {


assert list != nuli : "nie określono listy";
J i s t = list;
}
public void insert(int index, Object value) throws
IndexOutOfBoundsException {
++_insertCount;
_list.insert(index. value);
}
public void add(Object value) {
++_addCount:
_list.add(value);
}
public Object deletetint index) throws IndexOutOfBoundsException {
++_deleteCount;
return _list.delete(index);
}
public boolean deletetObject value) {
++_deleteCount;
return _1ist.delete(value);
}
public Object get(int index) throws IndexOutOfBoundsException {
++_getCount;
return Jist.get(index);
}
public Object set(int index. Object value) throws IndexOutOfBoundsException {
++_setCount;
return Jist.set(index. value);
}

public void clearO {


Jist.clearO:
}
public int indexOf(Object value) {
return _list.indexOf(value):
1
public boolean contains(Object value) {
return Jist.contains(value);
}
public boolean isEmptyO {
return J i s t . i sEmpty O ;
}
public Iterator iteratorO {
560 Algorytmy. Od podstaw

return list. iteratorO;


}
public int sizeO {
return list.sizeO:
}
public String toStringO {
return new StringBufferOCallCountingList: ")
.append("add: " + _addCount)
.append(" insert: " + _insertCount)
.append(" delete: " + _deleteCount)
.append(" set: " + _setCount)
.appendO get: " + _getCount).toStringO;

4.
package com.wrox.a1gori thms.sorti ng;

i mport com.wrox.a1gori thms.1 i sts.Li st;

public class InPlacelnsertionSortListSorter implements ListSorter {


private finał Comparator _comparator;

public InPlaceInsertionSortListSorter(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora":
_comparator = comparator;
}
public List sort(List list) {
assert list !- nuli : "nie określono listy":

for (int i = 1: i < list.sizeO: ++i) {


Object value = list.get(i):
int j:
for (j = i: j > 0: --j) {
Object previousValue - list.get(j - 1):
if (_comparator.compare(value. previousValue) >= 0) {
break;
}
list.set(j. previousValue):
}
list.settj. value):
}
return list:

5.
package com.wrox.algorithms.sorti ng;

i mport com.wrox.a1gori t hms.1 i sts.Li st:


Dodatek D • Odpowiedzi do ćwiczeń 561

public class HybridOuicksortListSorter implements ListSorter {


/** rozmiar porcji, poniżej którego stosuje się sortowanie*/
private static finał int THRESHOLD = 5;

/** komparator wyznaczający porządek elementów */


private finał Comparator _comparator;

public HybridQuicksortListSorter(Comparator comparator) {


assert comparator != nuli : "nie określono komparatora";
_comparator = comparator;
}
public List sort(List list) {

assert list != nuli : "nie określono listy";

quicksort(list. 0. list.sizeO - 1);

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);

int partition = partitiondist. value. startlndex. endlndex - 1):


if (_comparator.compare(list.get(partition). value) < 0) {
++partition:
}
swapdist. partition. endlndex):
562 Algorytmy. Od podstaw

quicksort(list. startlndex. partition - 1):


quicksort(list. partition + 1, endlndex);
)
private int partitiondist list, Object value. int leftlndex, int
rightlndex) {
int left - leftlndex;
int right = rightlndex;

while (left < right) {


if (_comparator.compare(list.get(left), value) < 0) {
++left;
continue;
}
if (_comparator.compare(list.get(right). value) >= 0) {
--right;
continue;
}
swapdist, left, right):
++left:
}
return left;
}
private void swap(List list. int left, int right) {
if (left == right) {
return;
}
Object temp = list.get(left);
list.setdeft. list.get(right));
1ist.set(right. temp);
}
}

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;

i mport com.wrox.a1gori thms.queues.EmptyQueueExcepti on;


import com.wrox.al gori thms.queues.HeapOrderedLi stPri ori tyOueue;
i mport com.wrox.a1gori thms.sort i ng.Comparator;

public class PriorityOueueStack extends HeapOrderedListPriorityOueue implements


Stack {
/** komparator wyznaczający porządek elementów */
private finał static Comparator COMPARATOR = new StackItemComparator();

/** licznik elementów */


private long _count = 0:

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

public long getKeyO {


return _key;
}
public Object getValue() {
return value;

private static finał class StackItemComparator implements Comparator {


public int compare(Object left. Object right) throws ClassCastException {
Stackltem sil = (Stackltem) left;
Stackltem si2 = (Stackltem) right:

return (int) (sil.getKeyO - si2.getKey());


}
}
}

2.
package com.wrox.a1gori thms.queues:

import com.wrox.algorithms.sorting.Comparator;

public class PriorityQueueFifoQueue extends HeapOrderedListPriorityOueue {


private static finał Comparator COMPARATOR = new QueueItemComparator();

/** licznik elementów */


private long _count = Long.MAX_VALUE:

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:

public Oueueltemdong key. Object value) {


_key = key;
_value = value;
}
public long getKeyO {
return _key;
}
Dodatek D • Odpowiedzi do ćwiczeń 565

public Object getValue() {


return value;

private static finał class OueueltemComparator implements Comparator {


public int compare(Object left, Object right) throws ClassCastException {
Oueueltem sil = (Oueueltem) left:
Oueueltem si2 = (Oueueltem) right;

return (int) (sil.getKeyO - si2.getKeyO);


}
}
]
3.
package com.wrox.a1gori thms.sorti ng;

i mport com.wrox.a1gori thms.i terati on.Iterator;


i mport com.wrox.a1gori thms.1 i sts.ArrayLi st:
i mport com.wrox.a 1 gori thms. 1 i sts. Li st;
i mport com.wrox,a 1gori thms.queues.HeapOrderedL istPri ori tyOueue:
i mport com.wrox.a1gori thms.queues.Oueue;

public class PriorityOueueListSorter implements ListSorter {


private finał Comparator _comparator;

public PriorityQueueListSorter(Comparator comparator) {


assert comparator !- nuli : "nie określono komparatora";
_comparator = comparator:
}
public List sort(List list) {
assert list !- nuli : "nie określono listy":

Oueue queue - createPriorityQueue(list);

List result = new ArrayListO ist. sizeO);


while (!queue.isEmptyO) {
result.add(queue.dequeue());
}
return result;
)
private Oueue createPriorityQueue(List list) {
Comparator comparator = new ReverseComparator(_comparator);
Oueue queue = new HeapOrderedListPriorityOueue(comparator);

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;

import com,wrox.algorithms.sorti ng.Comparator;


i mport com.wrox.algorithms.sorti ng.ReverseComparator;

public class MinimumOrientedHeapOrderedListPriorityOueue


extends HeapOrderedListPriorityOueue {
public MinimumOrientedHeapOrderedListPriorityQueue(Comparator comparator) {
super(new ReverseComparator(comparator));
}
}

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.

public Node minimumO {


return getSmaller() != nuli ? GetSmallerO : this
}
Dodatek D • Odpowiedzi do ćwiczeń 567

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.

public void inOrderPrint(Node node) {


if (node == nuli) {
return:
}
i nOrderPri nt(node.getSma11 er O ) :
System.out.pri nt1n(node.getVa 1 ue());
i nOrderPri nt(node.getLa rger());

4.

public void inOrderPrint(Node root) {


for (Node node = root minimumO; node != nuli; node - node.successorO) {
System.out.pri nt1n(node.getValue()):
}
J

5.

public void preOrderPrint(Node node) {


if (node — nuli) {
return:
}
System.out.pri nt1n(node.getVa1ue());
preOrderPri nt(node.getSma11 e r O ) ;
preOrderPrint(node.getLarger()):
568 Algorytmy. Od podstaw

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;

tree.i nsert(1 i st.get(i ndex));


pre0rderlnsert(tree. list, lowerIndex. index - 1);
preOrderInsert(tree, list. index + 1, upperlndex);
}

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;

public finał class SimplePrimeNumberGenerator implements PrimeNumberGenerator {


/** pojedyncza instancja klasy */
public static finał SimplePrimeNumberGenerator INSTANCE =
new SimplePrimeNumberGenerator():

private SimplePrimeNumberGenerator() {
}

public int generate(int candidate) {


int prime = candidate;

while (MsPrime(prime)) {
++prime;
}
return prime;
}

private boolean isPrime(int candidate) {


for (int i = candidate / 2; i >= 2; --i) {
if (candidate X i == 0) {
return false:

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;

public class BucketingHashtable implements Hashtable {

public BucketingHashtabletint initialCapacity. float loadFactor) {


assert initialCapacity > 0: "początkowa pojemność musi być dodatnia";
assert loadFactor > 0: "wypełnienie progowe musi być dodatnie";

Joadfactor = loadFactor;
_buckets = new Bucket(

SimplePrimeNumberGenerator.INSTANCE.generate(initialCapacity);
}

}
570 Algorytmy. Od podstaw

2.
package com.wrox.algorithms.hashing:

public class Linearprobinghashtable implements Hashtable {

private int _size:

public void addtObject value) {


ensureCapacityForOneMore():

int index = indexFor(value);

if (_values[index] == nuli) {
_values[index] - value:
++_size;
}
}
public int sizeO {
return size;

3.
package com.wrox.algorithms.hashi ng;

i mport com,wrox.algori thms.i terati on.Iterator;


i mport com.wrox.a 1gori thms.1 i sts.Li nkedLi st;
i mport com.wrox.a 1gorithms.1 i sts.Li st;

public class Linearprobinghashtable implements Hashtable {

private int _size:

public void add(Object value) {


List bucket = bucketFor(value);

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;

i mport com.wrox.a 1gori thms.i terat i on.EmptyIterator;


i mport com.wrox.a 1gori thms.i terat i on.Iterable:
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.a 1gori thms.i terat i on.IteratorOutOfBoundsExcept i on;

public class Hashtablelterator implements Iterator {


private finał Iterator _buckets;
private Iterator j/alues = Emptylterator.INSTANCE;

public HashtableIterator(Iterator buckets) {


assert buckets != nuli : "nie określono iteratora ";
_buckets » buckets;
}
public void firstO {
_buckets.fi r s t O ;
_values = Emptylterator.INSTANCE;
next();
}
public void lastO {
_buckets.last();
_values = Emptylterator.INSTANCE;
previous();
}
public boolean isDoneO {
return _val ues. isDoneO && _buckets. i sDoneO;
}
public void next() {
for (_values.next(); _values.isOone() && !_buckets.isDoneO;
_buckets.next()) {
Iterable bucket = (Iterable) _buckets.currentO;
if (bucket != nuli) {
_values = bucket. iteratorO;
_val ues. firstO;
}
}
1
public void previous() {
for (_values.previous();
j/al ues. i sDoneO && !_buckets. isDoneO;
_buckets.previous()) {
Iterable bucket = (Iterable) _buckets.currentC);
if (bucket != nuli) {
values = bucket. iteratorO;
_values.last();
}
572 Algorytmy. Od podstaw

public Object currentO throws IteratorOutOfBoundsException {


if (isDoneO) {
throw new IteratorOutOfBoundsException();
}
return values.currentO;

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.

public boolean equals(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()) {
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":

Set result = new HashsetO:

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";

Set result - new HashsetO;

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":

Set result = new HashsetO:

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;

i mport com.wrox.a 1gori thms.bsearch.Iterati veBi naryLi stSearcher;


import com.wrox.algorithms.bsearch.Li stSearcher;
import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.a 1gori thms.lists.ArrayLi st;
i mport com.wrox.a 1gori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.sorti ng.Compa rator;
i mport com.wrox.a 1gori thms.sorti ng.Natura1Compa rator;

public class SortedListSet implements Set {


/** lista przechowująca posortowane elementy */
private finał List _values = new ArrayListO;

/** wyszukiwarka listy */


private finał ListSearcher _searcher;

public SortedListSetO {
this(NaturalComparator.INSTANCE);
}

public SortedListSet(Comparator comparator) {


_searcher = new IterativeBinaryListSearcher(comparator);
}
public boolean contains(Object value) {
return indexOf(value) >= 0;
}
public boolean add(Object value) {
int index = indexOf(value);
if (index < 0) {
_values.insert(-(index + 1). value);
return true;
}
_values.set(index, value);
return false;
}
Dodatek D • Odpowiedzi do ćwiczeń 575

public boolean deleteCObject value) {


int index = indexOf(value):
if (index >= 0) {
_values.delete(index);
return true:
}
return false;
}
public Iterator iteratorO {
return _values.iteratorO;
}
public void clearO {
_values.clearO;
}
public int sizeO {
return _values,size():
}
public boolean isEmptyO {
return _values.isEmptyO;
}

private int indexOf(Object value) {


return searcher.search( values, value);

7.
package com.wrox.algori thms.sets;

i mport com.wrox.a1gor i thms.i terat i on.EmptyIterator;


i mport com.wrox.a 1 gori thms.i terati on.Iterator;

public finał class EmptySet implements Set {


/** pojedyncza publicznie dostępna instancja klasy */
public static finał EmptySet INSTANCE = new EmptySetO:

private EmptySetO {
}

public boolean contains(Object value) {


return false:
}
public boolean add(Object value) {
throw new UnsupportedOperationException();
}
public boolean delete(Object value) {
throw new UnsupportedOperationException();
}
576 Algorytmy. Od podstaw

public void clearO {

public int sizeO {


return 0;
}
public boolean isEmptyO {
return true;
}
public Iterator iteratorO {
return Emptylterator.INSTANCE;
}
j

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

package com.wrox.a1gori thms.maps;

i mport com,wrox.algori thms.iterati on.Iterator;


i mport com.wrox.a1gori thms.i terati on.IteratorOutOfBoundsExcepti on;

public class MapKeyIterator implements Iterator {


/** bazowy iterator nawigujący po pozycjach mapy */
private finał Iterator _entries:

public MapKeyIteratortIterator entries) {


assert entries != nuli ; "nie określono iteratora bazowego";
_entries - entries;
}
public void firstO {
_entries.firstO;
}
Dodatek D • Odpowiedzi do ćwiczeń 577

public void lastO {


_entries.last():
}
public boolean isDoneO {
return _entries.isDoneO;
}
public void next() {
_entries.next();
}
public void previous() {
_entries.previous():
}
public Object currentO throws IteratorOutOfBoundsException {
return ((Map.Entry) _entries.currentO).getKey():

2.
pack age com.wrox.a 1gor i thms.ma ps;

i mport com.wrox.a 1gori thms.i terati on.Iterator;


i mport com.wrox.a 1 gori thms.i terati on.IteratorOutOfBoundsExcept i on;

public class MapValueIterator implements Iterator {


/** iterator bazowy nawigujący po pozycjach mapy */
private finał Iterator _entries;

public MapValueIterator(Iterator entries) {


assert entries != nuli : "nie określono iteratora bazowego";
_entries = entries;
}
public void firstO {
_entries.first();
}
public void lastO {
_entries. lastO;
}
public boolean isDoneO {
return _entries.isDoneO;
}
public void next() {
_entries.next():
}
public void previous() {
_entries,previous();
}
578 Algorytmy. Od podstaw

public Object currentO throws IteratorOutOfBoundsException {


return ((Map.Entry) _entries,current()),getValue():
}
i

3.

package com.wrox.algori thms.maps;

import com.wrox.algorithms.iteration.Iterator;
i mport com.wrox.a1gori thms.sets.Set;

public class MapSet implements Set {


private static finał Object PRESENT = new ObjectO;

private fina! Map _map;

public MapSet(Map map) {


assert map != nuli : "nie określono mapy":
_map = map:
}
public boolean contains(Object value) {
return _map.contains(value):
}
public boolean add(Object value) {
return jnap.set(value, PRESENT) == nuli:
}
public boolean deletetObject value) {
return _map.delete(value) == PRESENT:
}
public Iterator iteratorO {
return new MapKeylteratort jnap.iteratorO):
}
public void clearO {
_map.clear();
}
public int s i z e O {
return _map.size();
}
public boolean isEmptyO {
return jnap.isEmptyO;
}
i
Dodatek D • Odpowiedzi do ćwiczeń 579

4.
package com.wrox.a 1gori thms.maps;

import com,wrox.al gorithms.iterati on.Emptylterator;


i mport com.wrox.a1gori thms.i terat i on.Iterator;

public finał class EmptyMap implements Map {


/** pojedyncza instancja klasy */
public static finał EmptyMap INSTANCE = new EmptyMapO;

private EmptyMapO {
}

public Object get(Object key) {


return nul 1;
}
public Object set(Object key, Object value) {
throw new UnsupportedOperationException():
}
public Object delete(Object key) {
throw new UnsupportedOperationException();
}
public boolean contains(Object key) {
return false:
}
public void clearO {
}

public int sizeO {


return 0;
}
public boolean isEmptyO {
return true:
}
public Iterator iteratorO {
return Emptylterator.INSTANCE:

Rozdział 14.

Ćwiczenie
1. Napisz metodę searchO w wersji iteracyjnej.
580 Algorytmy. Od podstaw

Rozwiązanie

i.

private Node search(Node node, CharSequence word, int index) {


assert word != nuli: "nie określono szukanego słowa":

while (node != nul 1) {


char c = word.charAt(index);
if (c == node.getCharO) {
if (index + 1 < word.lengthO) {
node = node.getChildO;
} else {
break:
}
} else {
node = c < node.getCharO ? node.getSmallerO : node.getLargerO:
}
}
return node:
}

RozdziaM5.

Ćwiczenie
1. Napisz metodę traverse() w wersji zwracającej pozycje w kolejności rosnących
kluczy.

Rozwiązanie

1.

public void traverse(List list) {


assert list != nuli: "nie określono listy";

Iterator children = _childern.iteratorO;


Iterator entries = _entries.iteratorO;

chi ldern. firstO;


entries. firstO;

while (Ichildren.isDoneO || lentries.isDoneO) {


if (Ichildren. isDoneO) {
((Node) chi 1 dren. currentO). i nOrderTraversal (1 i st);
children.next();
}
if (lentries.isDoneO) {
Dodatek D • Odpowiedzi do ćwiczeń 581

Entry entry = (Entry) entries,current():


if (lentry.isDeletedO) {
list.add(entry);
}
entries.next();

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;

i mport com.wrox.a1gori thms.i terat i on.Iterator:


i mport com.wrox.a1gori thms.lists.ArrayLi st:
i mpo rt com.wrox.a1gor i thms.lists.List:
i mport com.wrox.a1gori thms.sets.L istSet:
i mport com.wrox.a1gori thms.sets.Set;
import com.wrox.algorithms.bsearch.ListInserter:
import com,wrox.algorithms.bsearch.IterativeBinaryListSearcher.

public finał class BruteForceClosestPairFinder implements ClosestPairFinder {


/** pojedyncza instancja klasy */
public static finał BruteForceClosestPairFinder INSTANCE =
new BruteForceClosestPai rFinder() ;

private static finał Listlnserter INSERTER = new Listlnserter(


new IterativeBinaryListSearcher(XYPointComparator.INSTANCE)):

private BruteForceCl osestPai rFi nderO {


}
public Set findClosestPair(Set points) {
assert points != nuli : "nie określono zbioru punktów":

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:

for (int i = 0; i < list.sizeO; ++i) {


Point r = (Point) list.get(i):
for (int j = 0: j < list.sizeO; ++j) {
Point s - (Point) list.get(j):
if (r != s && r.distance(s) < distance) {
distance = r.distance(s);
p = r;
q = s;

return createPointPair(p, q);


}
private static List sortPoints(Set points) {
assert points != nuli : "nie określono zbioru punktów";

List list = new ArrayLi st (points. sizeO);

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;

import com,wrox.algorithms.bsearch.Iterati veBinaryLi stSearcher;


import com.wrox.algorithms.bsearch.ListInserter:
import com.wrox.algorithms.iterati on.Iterator;
import com.wrox.algorithms.1 ists.ArrayList;
i mport com.wrox.a1gori thms.1 i sts.Li st;
i mport com.wrox.a1gori thms.sets.L i stSet;
i mport com.wrox.a1gori thms.sets.Set;

public finał class PlaneSweepOptimizedClosestPairFinder implements


ClosestPairFinder {
/** pojedyncza instancja klasy */
public static finał PlaneSweepOptimizedClosestPairFinder INSTANCE
= new PlaneSweepOptimizedClosestPairFinderO;
Dodatek D • Odpowiedzi do ćwiczeń 583

private static finał Listlnserter INSERTER = new Listlnserter(


new IterativeBinaryListSearcher(XYPointComparator.INSTANCE)):

private PlaneSweepOptimizedClosestPairFinderO {
}

public Set findClosestPair(Set points) {


assert points != nuli : "nie określono zbioru punktów";

if (points.sizeO < 2) {
return nul 1:
}
List sortedPoints = sortPoints(points);

Point p = (Point) sortedPoints.get(O):


Point q - (Point) sortedPoints.get(l);

return findClosestPair(p. q, sortedPoints):


}
private Set findClosestPair(Point p. Point q, List sortedPoints) {
Set result = createPointPair(p, q);
double distance = p.distance(q);
int dragPoint = 0;

for (int i - 2; i < sortedPoints.size(); ++i) {


Point r = (Point) sortedPoints,get(i):
double sweepX = r.getXO:
double dragX = sweepX - distance:

while (((Point) sortedPoints.get(dragPoint)).getX() < dragX) {


++dragPoint:
}
for (int j - dragPoint; j < i; ++j) {
Point test - (Point) sortedPoints.get(j):
if (Math.abs(r.getYt) - test.getYO) > distance) {
continue;
}
double checkDistance = r.distance(test);
if (checkDistance < distance) {
distance - checkDistance;
result = createPointPair(r. test);
}
}
}
return result:
}
private static List sortPoints(Set points) {
assert points != nuli : "nie określono zbioru punktów";

List list = new ArrayList(points.sizeO):

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

B-drzewa usuwanie węzła, 269, 298


łączenie węzłów, 418 wstawianie węzła, 268, 297
Map, 419 wyważanie drzewa, 273
mapy, 420 binarne wstawianie, 235, 253
Node, 425 binarne wyszukiwanie, 235
podział korzenia, 417 binary insertion, 253
podział liścia, 417 binary search trees, 263
podział węzła, 427, 428 BinarylnsertCallCountingTest, 259
poszukiwanie klucza, 415 BinarySearchCallCountingTest, 249, 250
przepełniony węzeł, 416 BinarySearchTree, 275, 280, 289, 294, 377
redystrybucja kluczy, 418 implementacja, 294
search(), 426 testowanie, 289
set(), 428, 429 BinarySearchTreeCalICountingTest, 299, 301
setDeleted(), 430 BinarySearchTreeTest, 291
split(), 427 black-box testing, 34
testowanie, 419 BlockingQueue, 114, 118
traverse(), 428 bloki, 414
usuwanie pozycji, 418, 430 błędy, 112
węzły, 414 BoyerMooreStringSearcher, 444
własności, 413 BoyerMooreStringSearcherTest, 443
wstawianie kluczy, 416 bracia, 386
wykorzystanie, 419 brute force, 93
wyszukiwanie klucza, 426 BruteForceStringSearcher, 440
zwiększanie wysokości, 417 BTreeMap, 420, 424
Beck, Kent, 38 BTreeMapTest, 419
best case, 26, 229 B-trees, 414
biblioteka JUnit, 35 BubbleListSorter, 163
binarne drzewa wyszukiwawcze, 263, 264 BubbleListSorterTest, 163
_comparator, 296 bubblesort, 159
root, 296 BubblesortListSorter, 164
BinarySearchTree, 275, 280, 289, 294 bucketFor(), 325
bucketIndexFor(), 325
BinarySearchTreeCallCountingTest, 299, 301
BinarySearchTreeTest, 291 BucketingHashtable, 321, 323, 324, 348, 376
cechy węzła, 287 buckets, 310
delete(), 298 BUZI, 16
getRoot(), 296
implementacja, 275, 294
implementacja Node, 281
c
insert(), 297 calculateO, 476
maksimum, 265, 287 Cali, 119
minimum, 265, 287 cali center, 117
następnik, 265 CallCenter, 122, 123
Node, 275, 286 CallCenterSimulator, 125
NodeTest, 278 CallCountingCharSequence, 450
ocena efektywności, 299 CallCountingListComparator, 176
pomiar efektywności, 299 CallGenerator, 124
porównanie synów, 289 centrum zdalnej obsługi, 117
search(), 297 Cali, 119
szukanie, 266 CallCenter, 122, 123
testowanie, 275 CallCenterSimulator, 125
testowanie BinarySearchTree, 289 CallGenerator, 124
testowanie Node, 275 centrum obsługi, 121
trawersacja in-ordcr, 272 CustomerServiceAgent, 120
tworzenie węzła, 286 generator zgłoszeń, 124
Skorowidz 587

konsultant, 120 dequeue(), 106, 108, 112, 116, 216, 227


projekt symulatora, 117 detachO, 101
symulator, 125 dictionaries, 358
uruchamianie aplikacji, 127 Dictionary, 358
zgłoszenie, 119 directory tree, 62
charAt(), 450 distance(), 487
CharBuffer, 453 divide and conąuer, 64
CharSequence, 450 DNA, 457
charset, 444 dokumenty XML, 140
checkIterator(), 341, 369 dopasowywanie
child, 386 fragmenty kodu DNA, 433
clear(), 73, 86, 103, 106, 116, 132, 217, 334, 359, wzorce, 392
430 dopasowywanie łańcuchów, 457
ClosestPairFinder, 507 odległość Levenshteina, 468
Cofnij/Powtórz, 140 Soundex, 457
testowanie, 141 drag net, 500
UndoableList, 144 drukowanie drzewa katalogów, 64
UndoableListTest, 141 drzewo, 264
wielopoziomowe wycofywanie operacji, 144 AVL, 269, 273, 275
com.wrox.algorithms, 33 czerwono-czarne, 269, 275
command patern, 148 korzeń, 264
Comparable, 154 liść, 264
ComparativeStringSearcher, 452 mapy, 377
Comparator, 154 następnik, 265
compare(), 154 niewyważone, 274
compareO, 154, 155, 158, 243 poprzednik, 266
compare To(), 154, 157 rotacja węzła, 275
compoundcomparator, 195 rozchylane, 269
CompoundComparatorTest, 196 samowyważające, 275
computeLastOccurencesO, 445 trawersacja, 272
containsO, 73, 92, 101, 312, 320, 334, 359 węzły przeciążone w prawo, 274
CRC, 307 wykrzywione, 271
createFifoQueue(), 110, 111 wysokość, 264
createListSorterO, 163 wyważanie, 273
createPointPair(), 509 wyważone, 264
createQueue(), 216 zbiory, 349
createSearcher(), 241, 242, 245 drzewo binarne, 264
createTable(), 323 maksimum, 265
CrosswordHelper, 409, 410 minimum, 265
current(), 45, 46, 48, 50, 61, 449 niewyważone, 274
CustomerServiceAgent, 120 usuwanie węzła, 269
cykliczny kod nadmiarowy, 307 wstawianie węzła, 268
czoło, 105 drzewo katalogów, 62, 264
czytelność kodu, 16 drukowanie rekurencyjne, 64
drzewo ternarne, 385
D bracia, 386
dopasowywanie wzorca, 392
dane dyskowe, 414 dziecko, 386
deadlock, 115 poddrzewo kontynuacyjne, 385
decorators, 56 poszukiwanie prefiksu, 391
DefaultEntry, 360, 368 potomek, 386
dekoratory, 56 rodzeństwo, 386
deleteO, 72, 73, 83, 93, 101, 102, 334, 359, 372 rozpoznawanie nieobecnych, 389
deleteCost(), 476 testowanie, 395
588 Algorytmy. Od podstaw

drzewo ternarne filtrowanie, 56


trawersacja in-order, 391 finał, 156
wartości, 385 finalne zmienne prywatne, 487
węzły, 385 findClosestPair(), 507, 508, 509
wstawianie słowa, 389 firstO, 45, 46, 48, 53, 60, 61, 85, 449
wyszukiwanie słowa, 386 FixedComparator, 195
DummyPredicate, 57, 59 for, 47
duże O, 27 funkcje
dynamiczna zmiana rozmiaru, 91 clear(), 359
dziecko, 386 haszujące, 305
dziel i zwyciężaj, 64 isEmptyO, 359
mieszające, 305
size(), 359
E
Eclipse, 38
edytor tekstowy, 20
G
efektywność, 16 garbage collection, 36, 91, 513
Element, 97 generał case, 68
element środkowy, 237, 253 generator zgłoszeń, 124
EmptyQueueException, 107, 112, 113, 121, 139 geometria, 479
EmptyStackException, 132, 133, 139 trójwymiarowa, 479
enqueue(), 106, 108, 112, 115 geometria obliczeniowa, 479
ensureCapacity(), 91, 94 linie, 481
ensureCapacityForOneMore(), 318 linie równoległe, 484
Entry, 425 nachylenie linii, 483
entryFor(), 372 para najbliższych punktów, 499
enumeratory, 45 przecięcie linii z osiąy, 484
equals(), 329, 488, 490 punkt przecięcia dwóch linii, 482, 485
equalsLarger(), 289 punkty, 479
equalsSmaller(), 289 trójkąt, 481
evaluate(), 56 układ współrzędnych, 479
expectedIterator, 60 współrzędne, 479
eXtreme Programming, 38 geometryczny wzrost rozmiaru problemu, 31
get(), 72, 78,91,99,359,372
F getElementO, 99
getIndexOfLargestElement(), 219
fail(), 37 getIntersectionXCoordinate(), 497, 499
FIFO, 105, 107 getIntersectionYCoordinate(), 497, 499
dequeue(), 108, 112 getRoot(), 296
dodawanie elementu, 112 getX0, 487
enqueue(), 108, 112 getY(), 487
implementacja, 108, 111 głowa, 95
ListFifoQueue, 111 gry RPG, 211
testowanie, 108
usuwanie elementu, 112
File, 65 H
FileSortingHelper, 514, 526 hash fiinction, 305
implementacja, 516 hash table, 305
optymalizacja, 526 hashCodeO, 311, 318
filterBackwardsO, 62 HashMap, 373
filterForwardsO, 62 HashMapTest, 373
Filterlterator, 61 HashSet, 344
FilterlteratorTest, 59 HashSetTest, 344
Skorowidz 589

Hashtable, 312 algorytm Boyera-Moore'a, 444


HashtableCallCountingTest, 328 algorytm Mergesort, 203
Hashtablelterator, 348 algorytm sortowania bąbelkowego, 164
haszowanie, 305 binarne drzewa wyszukiwawcze, 275, 294
algorytm, 307 drzewa ternarne, 400
BucketingHashtable, 321 inserter binarny, 256
CRC, 307 iteracyjna wyszukiwarka binarna, 244
cykliczny kod nadmiarowy, 307 iterator odwracający, 55
doskonałe, 307 iterator tablicowy, 50
funkcja haszująca, 305 kalkulator odległości Levenshteina, 473
hashCodeO, 311 klasy predykatowe, 60
Hashtable, 312 koder Soundex, 465
implementacja porcjowania, 321 kolejka FIFO, 111
implementacja próbkowania liniowego, 314 kolejka priorytetowa na bazie listy
kolizje, 307 nieposortowanej, 218
kolizyjność, 306 kolejka priorytetowa na bazie listy
LinearProbingHashtable, 315 posortowanej, 220
mapy, 373 komparator naturalny, 155
porcjowanie, 310, 321 komparator odwrotny, 158
próbkowanie liniowe, 308, 309, 314 komparator złożony, 197
randomizacja, 306 list, 86
realizacja, 311 mapy drzewiaste, 377
rozwiązywanie kolizji, 308 mapy haszowane, 373
skrót, 305, 306 mapy listowe, 369
stopień zapełnienia, 311 porcjowana tablica haszowana, 321
sumowanie kodów, 306 próbkowanie liniowe, 314
tablice, 305 rekurencyjna wyszukiwarka binarna, 241
testowanie porcjowania, 321 sortowanie przez wybieranie, 169
testowanie próbkowania liniowego, 314 stos, 136
wartość, 305 wyszukiwarka sekwencyjna, 247
wyciąg, 305 zbiór drzewiasty, 349
zasada działania, 306 zbiór haszowany, 344
zbiory, 344 zbiór listowy, 342
znacznik haszowania, 305 indexOf(), 73, 85, 92, 100
head, 105 IndexOutOfBoundsException, 76, 78, 80, 83
heap, 222 informatyka, 25
Heap, 223 inner loop, 164
heap condition, 222 inorder, 250
HeapOrderedListPriorityQueue, 226 in-order, 272
HeapOrderedListPriorityQueueTest, 226 inOrderTraversal(), 407
heurystyki, 25 insert(), 72, 78, 90, 98, 256
błędny znak, 442 insertCostO, 476
dobry przyrostek, 442 inserter binarny, 254
H-lista, 184, 188 implementacja, 256
hprof, 517, 518 ListlnserterTest, 254
hSortO, 187, 188 verify(), 256
H-sortowanie, 184 Insertionsort, 170, 207, 220
InsertionSortListSorter, 171, 172
INSTANCE, 44
I IntelliJ IDEA, 38
interfejs, 17
IDE, 20
iloczyn zbiorów, 335 CharSequence, 450
immutable, 487 ClosestPairFinder, 507
implementacja Comparable, 154
590 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

dequeue(), 106 tearDown(), 216


enqueue(), 106 testowanie instancji, 217
FIFO, 105, 107 testowanie kolejki na bazie listy
interfejs, 107 nieposortowanej, 218
isEmptyO, 106 testowanie kolejki na bazie listy posortowanej,
LIFO, 106 220
losowe, 106 testowanie kolejki stogowej, 226
nieograniczone, 106 UnsortedListPriorityQueue, 218
ograniczone, 106, 113 UnsortedListPriorityQueueTest, 218
operacje, 106 usuwanie elementu największego, 214
Queue, 107 wykorzystanie, 215
sizeO, 106 zasada działania, 214
tasujące, ! 06 kolizja, 307
kolejki blokujące, 113 kolizyjność, 306
_maxsize, 114 komparator naturalny, 154
_mutex, 114 compareO, 156
BlockingQueue, 114 implementacja, 155
clear(), H6 NaturalComparator, 155, 156
dequeue(), 116 testowanie, 155
enqueue(), 115 komparator odwrotny, 156
isEmptyO, ' 16 implementacja, 158
size(), 116 ReverseComparator, 157
testowanie, 157
waitForNotificationO, 115 komparator złożony, 195
zastosowanie, 114 addComparator(), 197
kolejki priorytetowe, 211 CompoundComparatorTest, 196
AbstractPriorityOueueTest, 215, 220 implementacja, 197
abstrakcyjna klasa testowa, 215 komparatory cząstkowe, 197
clearO, 217 testowanie, 195
createQueue(), 216 komparatory, 153
dane wejściowe, 212 Comparator, 154
dequeue(), 216, 217, 227 cząstkowe, 196
dodawanie elementu, 214 interfejs, 154
efektywność funkcjonowania, 229 operacje, 153
enqueue(), 217, 222 punkty, 501
getIndexOfLargestElement(), 219 standardowe, 154
HeapOrderedListPriorityQueue, 226 ustalone, 196
HeapOrderedListPriorityQueueTest, 226 kompozycja, 137
implementacja kolejki stogowej, 226 kompresja informacji, 389
implementacja na bazie listy nieposortowanej, kopiec, 222
218 korzeń, 264
implementacja na bazie listy posortowanej, 220 stóg, 223
isEmptyO, 216 kryterium sortowania, 153
LinkedList, 218 krzyżówki, 409
lista nieposortowana, 218
lista posortowana, 220
porównanie implementacji, 229
przykład, 212
L
setUp(), 216 last(), 45,46,61
sink(), 228 leaves, 264
size(), 216 LevenshteinWordDistanceCalculator, 473
SortedListPriorityQueueTest, 220 LevenshteinWordDistanceCalculatorTest, 471, 472
stóg, 222 liczby
swap(), 227 Fibonnacciego, 64
swimO, 227 naturalne, 482
592 Algorytmy. Od podstaw

LIFO, 106, 132 mapy, 369


Line, 491,492,496 ogon, 95
base(), 497 operacje, 72
contains(), 496 pobieranie elementów, 78
implementacja, 496 podwójnie wiązane, 95
intersectionPoint(), 497 pojedynczo wiązane, 95
isParalellTo(), 496 set(), 72, 73, 78, 80
isWithin(), 497 size(), 72
linear probing, 308 sortowanie, 161
LinearListSearcher, 248 tablice, 87
LinearListSearcherTest, 247 testowanie, 74
LinearProbingHashtable, 315, 317 testowanie czyszczenia listy, 86
LinearProbingHashtableTest, 314, 317 testowanie iteratora, 83
LineTest, 492 testowanie metod dołączających elementy, 75
linie, 481 testowanie metod pobierających, 78
Line, 492 testowanie metod usuwających elementy z listy,
nachylenie, 483 80
pionowe, 490, 494 testowanie metod wstawiających, 75
punkt przecięcia dwóch linii, 482, 485 testowanie metod wyszukujących elementy, 85
rozłączne, 495 testowanie metod zapamiętujących wartości
równoległe, 484, 494, 496 elementów, 78
wzniesienie, 483 usuwanie elementów, 80
LinkedList, 96,218, 523 usuwanie elementu o określonej wartości, 82
LinkedListTest, 95 usuwanie pierwszego wystąpienia danej
List, 73, 94 wartości, 82
ListFifoQueue, 111, 113 wstawianie elementów, 75
ListFifoQueueTest, 111 wstawianie elementu na koniec, 78
listfiles(), 65 wstawianie elementu na początek, 78
Listlnserter, 256 wyszukiwanie elementów, 85
ListlnserterTest, 254 zapamiętywanie wartości elementów, 78
ListMap, 369 zbiory, 342
ListMapTest, 369 listy tablicowe, 87
ListSearcher, 238, 239 add(), 90
search(), 239 arraycopy(), 94
ListSet, 343 ArrayList, 88, 89
ListSetTest, 342, 343 ArrayListTest, 87
ListSorter, 161, 162 contains(), 92
ListSorterCallCountingTest, 177, 205 delete(), 93
ListStack, 137 dołączanie elementów, 90
listy, 71 ensureCapacityO, 91, 94
add(), 73, 77 get(), 91
clear(), 73, 86 indexOf(), 92
containsO, 73 insert(), 90
czyszczenie listy, 86 isEmptyO, 95
delete(), 72, 73, 83 iterator(), 94
dołączanie elementów, 75 klasa testowa, 87, 88
get(), 72, 78 odczytywanie wartości elementu na podstawie
implementacja, 75, 86 indeksu, 91
indexOf(), 73, 85 setO, 91
insert(), 72, 78 size(), 95
interfejs, 72 usuwanie elementów, 93
isEmptyO, 72, 73, 78 wstawianie elementów, 90
iterator, 73, 83 wyszukiwanie elementu, 92
klasa testowa, 75 zapamiętywanie wartości elementu na podstawie
łączenie, 198 indeksu, 91
Skorowidz 593

>ty wiązane, 95 checkIterator(), 369


JieadAndTail, 97 containsO, 359
add(), 99 DefaultEntry, 360, 368
attachBefore(), 98 delete(), 359
clear(), '03 Dictionary, 358
containsO, 101 domyślna implementacja pozycji, 360
deleteO, 101, 102 generyczna klasa testowa, 362
detachO, 101 get(), 359
dołączanie elementu, 98 HashMap, 373
Element, 97 implementacja drzewiasta, 377
get(), 99 implementacja haszowana, 373
getElementO, 99 implementacja listowa, 369
głowa, 95 implementacja pozycji, 360
inde\Of(), 100 interfejs, 359
insert(), 98, 99 iteratorO, 359
isEmptyO, 103 klucz, 357
iterator, 102, 103 klucz-wartość, 359
klasa elementu, 97 koncepcja, 357
klasa testowa, 95 ListMap, 369
LinkedList, 96 operacje, 359
LinkedListTest, 95 pozycje, 359
łączniki elementów, 98 puste wartości, 361
modyfikacja wartości elementów, 99 set(), 359
odczytywanie wartości elementów, 99 słowniki, 358
podwójnie, 95 tablice skojarzeniowe, 358
pojedynczo, 95 testClearO, 367
setO, 99 testContainsExisting(), 366
size(), 103 testContainsNonExisting(), 366
usuwanie elementu, 98, 101 testDeleteExisting(), 367
ValueIterator, 102 testGetExisting(), 366
wartownik, 96 testGetNonExisting(), 366
wstawianie elementu, 98 testIteratorBackwards(), 369
wyszukiwanie wartości, 100 testIteratorForwards(), 369
zliczanie elementów, 99 testowanie implementacji, 362
ść, 264 testSetExistingKey(), 367
ad factor, 311 testSetNewKeyO, 366
adWords(), 411,517, 523,527 TreeMap, 377
garytmiczna złożoność algorytmu, 31 unikalność kluczy, 357
strzane odbicie wyrazów, 514 wartość, 357
zapisy, 359
zastosowanie, 357
Ł mapy drzewiaste, 377
containsO, 383
czenie list, 198 implementacja, 377
setO, 383
M testowanie, 377
TreeMap, 377
ain(), 38 TreeMapTest, 377
mapy haszowane, 373
aintainLoad(), 324
HashMap, 373
laksimum drzewa binarnego, 265
HashMapTest, 373
lap, 359
testowanie, 373
lap.Entry, 360, 368
mapy listowe, 369
.apy, 357
clearO, 371
AbstractMapTest, 362
delete(), 372
B-drzewa, 420
594 Algorytmy. Od podstaw

mapy listowe notacja dużego O, 27


entryFor(), 371, 372 notifyAll(), 115
get(), 372 nuli object, 96
implementacja, 369 NYSII, 457
isEmptyO, 371
iteratorO, 371
ListMap, 369 0
ListMapTest, 369
set(), 372 0(1), 27, 29
size(), 371 O(logN), 27,31
testowanie, 369 0(N log N), 27,31
maximum(), 279, 287, 288 0(N!), 27, 32
memory leak, 93 0(N), 27, 29
memory mapping of files, 453 0(N2), 27, 30
merge(), 204 obiekty
Mergesort, 198 niezmienne, 487
mergesortO, 203 puste, 96
MergesortListSorter, 203 obliczenia, 42
MergesortListSorterTest, 203 odległość
metoda prób i błędów, 25 edycyjna, 468
metody o zasięgu pakietu, 34 punktów, 487
minimum drzewa binarnego, 265 odległość Levenshtcina, 468
minimum(), 279, 287, 288 algorytm, 470
mnożenie liczb naturalnych, 24 calculate(), 476
Most Recenlty Used, 131 deleteCostO, 476
MRU, 131 implementacja kalkulatora, 473
muteks, 113 insertCost(), 476
mutual exclusion, 113 koszt usunięcia znaku, 476
mutual recursion, 69 koszt wstawienia znaku, 476
koszt zastąpienia znaku, 475
LevenshteinWordDistanceCalculator, 473
N LevenshteinWordDistanceCalculatorTest, 471,
472
nachylenie linii, 483 macierz, 468
Slope, 490 obliczanie, 468
najgorszy przypadek, 26, 230 substitutionCost(), 475
najlepszy przypadek, 26, 229 ścieżki monotoniczne macierzy, 470
następnik, 265 testowanie kalkulatora, 471
NaturalComparator, 155, 156 odłożenie elementu na stos, 132
next(), 45,46, 48,53,55,61,85 odśmiecanie, 36, 91
niszczenie, 33 odwrotna notacja polska, 140
Node, 275, 285, 286, 403, 425 ogon, 95
equals(), 289 okno skanowania, 500
implementacja, 281 operacje
isLargerO, 287 Cofnij/Powtórz, 140
isSmallerO, 287 dodatkowe, 17
maximum(), 287 iteratory, 45
minimumO, 287 listowe, 72
precedessor(), 288 podstawowe, 17
setLarger(), 286 rdzenne, 17
setSmaller(), 286 opóźnienie, 414
successorO, 288 OptimizedFileSortingHelper, 526
testowanie, 275 optymalizacja, 16, 522
NodesetParent(), 286 FileSortingHelper, 526
NodeTest, 278 pochopna, 512
Skorowidz 595

pragmatyczna, 511 poprzednik, 266


praktyka, 523 porcje, 310
profilowanie, 513 porcjowanie, 310, 321, 324
przedwczesna, 512 implementacja, 321
przyczyny nieoptymalności kodu, 512 testowanie, 321
sens, 511 porównywanie
wąskie gardła, 512 wartości, 153
wykrywanie wąskich gardeł, 512 złożoność algorytmu, 27
zgadywanie przyczyn nieoptymalności, 512 post-order, 273
otoczki, 56, 113 poszukiwanie prefiksu, 391
outer loop, 164 potomek, 386
PowerCalculator, 42
P precedessor(), 288
Predicate, 56
package scoped methods, 34 predykator, 56
pamięciowe odwzorowanie plików, 453 prefiks, 391
pamięć wirtualna, 29 prefixSearch(), 407
para najbliższych punktów, 499 pre-order, 272, 291
AbstractClosestPairFinderTest, 504 previous(), 45, 46, 53,55,61
ClosestPairFinder, 507 print(), 65
findClosestPair(), 507, 508, 509 priority queue, 212
górne ograniczenie, 500 PriorityQueueCallCountingTest, 230
implementacja algorytmu zamiatania producent-konsument, 106, 121
płaszczyzny, 507 profilator, 513
implementacja komparatora punktów, 503 profilowanie, 512, 513
komparator punktów, 501 FileSortingHelper, 514
okno skanowania, 500 hprof, 517
PlaneSweepClosestPairFinder, 507, 509 Java, 517
PlaneSweepClosestPairFinderTest, 506 JMP, 520
testowanie algorytmu wyszukującego, 504 pomiar wykorzystania czasu procesora, 513
testowanie komparatora punktów, 501 śledzenie stosu, 518
XYPointComparator, 501, 503 programming by coincidence, 46
zamiatanie płaszczyzny, 500 programowanie, 15
partitionO, 194 defensywne, 19
pattern matching, 392 ekstremalne, 38
patternMatch(), 408 na bazie zbiegów okoliczności, 46
peekO, 132, 138 sterowane testami, 18, 38
perfect hashing, 307 próbkowanie liniowe, 308, 309, 314
pętla implementacja, 314
wewnętrzna, 164 prymitywny algorytm wyszukiwania, 438
zewnętrzna, 164 przecieki pamięci, 93
PhoneticEncoder, 465 przecięcie linii z osiąy, 484
piane sweeping, 500 przecięcie zbiorów, 335
PlaneSweepClosestPairFinder, 507, 509 przeciętny przypadek, 230
PlaneSweepClosestPairFinderTest, 506 przedwczesna optymalizacja, 512
pliki, 62 przetwarzanie dokumentów XML, 140
pochopna optymalizacja, 512 przetwarzanie tablic, 44
poddrzewo kontynuacyjne, 385 iteratory, 45
Point, 486, 487,488,491 przyczyny nieoptymalności kodu, 512
PointTest, 486 przypadki
polecenie, 148 bazowy rekurencji, 68, 69
położenie na stos, 132 najgorszy, 230
pomiar wykorzystania czasu procesora, 513 najlepszy, 229
popO, 132, 134, 138 ogólny, 68, 69
596 Algorytmy. Od podstaw

przypadki ReverseComparator, 157, 158, 176


przeciętny, 230 ReverseIterator, 53, 55, 60
testowe, 18 ReverseStringComparator, 515, 525
użycia, 18 right-heavy, 274
pseudokod, 24 rodzeństwo, 386
punkt przecięcia dwóch linii, 482, 485 root, 264
intersectionPoint(), 497 rotacja węzła, 275
Line, 492, 496 rozdzielanie zagadnień, 153
linia pionowa, 488 rozpoznawanie nieobecnych, 389
nachylenie linii, 488 rozwiązywanie kolizji, 308
Slope, 488 porcjowanie, 310
punkty, 479 próbkowanie liniowe, 308
odległość punktu od innego, 487 rozwiązywanie krzyżówek, 409
Point, 486 rozwinięcie stogu w listę, 223
push(), 132, 134 różnica zbiorów, 335
RPG,211
runAdd(), 329
Q runContainsO, 329
Runnable, 120
Queue, 107
runScenario(), 231
queues, 105
RuntimeException, 47, 107, 133
Quicksort, 189, 207
Russel, R.C., 457
quickSort(), 193
rząd złożoności algorytmu, 28
QuicksortListSorter, 192
QuicksortListSorterTest, 192
s
R search(), 238, 239, 241, 434, 435, 446,453
searchForPattern(), 410, 411
random queue, 106 searchRecursively(), 243, 244
randomizacja, 306 selectionsort, 166
RandomListQueueTest, 36, 37 SelectionSortListSorter, 168
RecursivcBinaryListSearcher, 242 SelectionSortListSorterTest, 168
RecursiveBinaryListSearcherTest, 242 separation of concerns, 153
red-black trees, 275 set, 333
redo, 140 Set, 336
refactoring, 38
setO, 72, 73,78,91,99, 359,372
refaktoryzacja, 38 setDeleted(), 430
rekurencja, 24, 41, 62 setLarger(), 286
algorytmy, 68 setParent(), 286
drukowanie drzewa katalogów, 64 setSmallerO, 286
nieskończona, 69 setUpO, 161,216, 230, 291
przypadek bazowy, 68, 69 SetUpO, 36
przypadek ogólny, 68, 69 Shellsort, 184
Quicksort, 189 ShellsortListSorter, 187
stos, 68 ShellsortListSorterTest, 186
wzajemna, 69 shuffling queue, 106
rekurencyjną wyszukiwarka binarna, 241 siłowe wyszukiwanie wzorca, 438
AbstractListSearcherTest, 241 siłowy algorytm, 438
implementacja, 241 singleton, 44
RecursiveBinaryListSearcher, 242 sink(), 228, 229
testowanie, 241 sizeO, 72, 95, 103, 106, 116, 132, 216, 312, 326,
reportCallsO, 179, 232 334, 359
resize(), 318 skalowalność algorytmu, 26
reverseAll(), 527 skrót, 305, 306
Skorowidz 597

Slope, 488, 491 sort(), 172


implementacja, 490 testowanie, 171
słowniki, 358 zasada działania, 170
sort(), 161, 169,516 sortowanie przez wybieranie, 165
SortedListPriorityQueueTest, 220 implementacja, 168
sortowanie, 151, 183 SelectionSortListSorter, 168
algorytm, 152 SelectionSortListSorterTest, 168
analiza porównawcza, 180 swapO, 169
CallCountingListComparator, 176 testowanie, 168
Insertionsort, 170 zasada działania, 166
komparatory, 153 sortowanie szybkie, 189
kryterium, 153 partitionO, 194
ListSorterCallCountingTest, 177 quickSort(), 193, 194
listy, 161 QuicksortListSorter, 192
Mergesort, 198 QuicksortListSorterTest, 192
operacje, 152 swapO, 194
porównanie prostych algorytmów, 175 testowanie, 192
porównanie zaawansowanych algorytmów, 205 zasada działania, 189
Quicksort, 189 sortPoints(), 508
Shellsort, 184 sortSublistO, 188
stabilność, 173, 199 Soundex, 457
sortowanie bąbelkowe, 159 budowa, 458
abstrakcyjna klasa testowa, 161 CHARACTERMAP, 466
BubbleListSorter, 163 encodeO, 467
BubbleListSorterTest, 163 implementacja kodera, 465
BubblesortListSorter, 164 isValid(), 466
implementacja, 164 kodowanie, 466
ListSorter, 161 kodowanie łańcuchów jednoliterowych, 462
pętla wewnętrzna, 164 mapO, 466
pętla zewnętrzna, 164 PhoneticEncoder, 465
testowanie, 163 SoundexPhoneticEncoder, 465
zasada działania, 159 SoundexPhoneticEncoderTest, 461, 462
sortowanie metodą Shella, 183 testowanie kodera, 461
hSort(), 187, 188 SoundexPhoneticEncoder, 465, 466
implementacja, 186 SoundexPhoneticEncoderTest, 461, 462
ShellsortListSorter, 187 splay trees, 269
ShellsortListSorterTest, 186 stabilność sortowania, 166, 173
sortSublistO, 188 komparator złożony, 195
testowanie, 186 stack, 131
zasada działania, 184 Stack, 137
sortowanie przez łączenie, 198 StackOverflowException, 69
implementacja, 203 startingIndexFor(), 318
łączenie list, 198 sterta, 222
merge(), 204 stopień zapełnienia, 311
mergesort(), 203 stos, 68, 131
MergesortListSorter, 203 AbstractStackTestCase, 133
MergesortListSorterTest, 203 abstrakcyjna klasa testowa, 133
partycjonowanie listy, 200 clear(), 132, 136
testowanie, 203 Cofnij/Powtórz, 140
zasada działania, 199 element szczytowy, 131, 135
sortowanie przez wstawianie, 170,171, 207 element wierzchołkowy, 131
implementacja, 172 enqueue(), 138
InsertionSortListSorter, 171, 172 implementacja, 136
isEmptyO, 132
598 Algorytmy. Od podstaw

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

TernarySearchTree, 400, 403 testIntersectionOfNonParallelNonVerticalLines(), 494


TernarySearchTreeTest, 396, 397 testIntersectionOfVerticalAndNonVerticalLines(),
testcases, 18 495
testAccessAnEmptyQueue(), 109 testIsLarger(), 280
testAdd(), 78 testIsParallelForOneVerticalAndOneNonVerticalLine(),
testAddExistingValueHasNoEffect(), 340 493
testAddNewValue(), 340 testIsParallelForTwoNonVerticalNonParallelLines(),
tcstAscendingInOrderInsertion(), 255 493
testAsDoubleFailsForVerticalSlope(), 489 testIsParallelForTwoNonVerticalParallelLinesO, 493
testAsDoubleForNonVerticalSlope(), 489 testIsParallelForTwoVerticalParallelLines(), 493
testASinglePairOfPoints(), 505 testIsSmaller(), 280
testASinglePointReturnsNull(), 504 testIsVertical(), 488
testAverageCaseHeapOrderedList(), 231 testIteratorBackwards(), 369
testAverageCaseSortedList(), 231 testIteratorForwards(), 342, 369
testAverageCaseUnsortedList(), 231 testLargeSetOfPointsWithTwoEqualShortestPairs(),
testBinaryInsert(), 259 506
testCantPeeklntoAnEmptyStackO, 135 testLessThan(), 155
testCantPopFromAnEmptyStack(), 135 testLessThanBecomesGreaterThan(), 157
TestCase, 35, 36, 42, 365 testLettersRepresentedByOne(), 463
testClearO, 341,367 testNotFoundInAnEmptyText(), 436
testClearResetsUndoStack(), 143 testowanie, 32
testContainsExistingO, 340, 366 algorytm Boyera-Moore'a, 443
testContainsForVerticalLine(), 492 algorytm Mergesort, 203
testContainsNonExistingO, 340, 366 algorytm wyszukujący parę najbliższych
testDeleteByValue(), 83 punktów, 504
testDeleteExisting(), 341, 367 B-drzewa, 419
testDeleteLeafNode(), 293 białoskrzynkowe, 34
testDeleteNodeWithOneChild(), 293 binarne drzewa wyszukiwawcze, 275
testDelctcNodeWithTwoChildren(), 293 czarnoskrzynkowe, 34
testDeleteNonExisting(), 341 drzewo ternarne, 395
testDeleteOnlyElementć), 80 funkcjonalne, 34
testDescendingInOrderInsertion(), 255 implementacja map, 362
testDisjointLinesDoNotlntersectO, 495 integracyjne, 34
Test-Driven Development, 18,38 iteracyjna wyszukiwarka binarna, 244
testDuplicateCodesAreDropped(), 464 iteratory odwracające, 53
testEmpty(), 472 iteratory tablicowe, 49
testEmptySetOfPoints(), 504 jednostek, 33
testEmptyToNonEmptyO, 472 JUnit, 18
testEnqueueDequeue(), 109 kalkulator odległości Levenshteina, 471
testEqualPointsCompareCorrectlyO, 501 klasy predykatowe, 56
testEquals(), 489 koder Soundex, 461
testEqualsRemainsUnchangedO, ' 58 komparator naturalny, 155
testFindAtTheEnd(), 436 komparator odwrotny, 157
testFindAtTheStart(), 436 komparator złożony, 195
testFindInTheMiddle(), 437 listy, 74
testFindOverlapping(), 437 mapy drzewiaste, 377
testForwardlterationO, 85 mapy haszowane, 373
testGetExisting(), 366 mapy listowe, 369
testGetNonExistingO, 366 na bieżąco, 18
testGetOutOfBoundsO, 80 obliczenia, 42
testGreaterThanBecomesLessThan(), 157 porcjowana tablica haszowana, 321
testlnsertAfterLastElementO, 78 powiązania, 18
testlnsertBeforeFirstElementO, 78 próbkowanie liniowe, 314
testlnsertBetweenElementsO, 77 rekurencyjną wyszukiwarka binarna, 241
testlnsertlntoEmptyListO, 77 sortowanie szybkie, 192
600 Algorytmy. Od podstaw

testowanie tree, 264


stogowa kolejka priorytetowa, 226 Tree, 223
tablice haszowane, 312 TreeMap, 377
wyprzedzające, 137 TreeMapTest, 377
wyszukiwarka sekwencyjna, 247 TreeSet, 349
zbiory drzewiaste, 349 TreeSetTest, 349
zbiory haszowane, 344 trójkąt, 481
zbiory listowe, 342 pitagorejski, 482
testowanie modułów, 32 prostokątny, 481
asercje, 33 twierdzenie Pitagorasa, 481
jednostki testowe, 33
JUnit, 35
klasy testowe, 33 U
metody o zasięgu pakietu, 34
niszczenie, 33 układ współrzędnych, 479
umocowanie, 33 umocowanie, 33
zastosowanie, 35 unchecked exception, 47
testParallelNonVerticalLinesDoNotIntersect(), 494 undo, 140
testPatternMatch(), 399 undo(), 142
testPrecedessor(), 279 UndoableList, 144, 147
testPrefixSearch(), 399 insert(), 148
tcstRandomInsertion(), 255 UndoableListTest, 141
testResizeBeyondInitialCapacity(), 88 UndoAction, 148
testSamcPrefix(), 472 UndoDeleteAction, 145, 148
testSameSuffix(), 473 UndoInsertAction, 148
testSearchForArbitraryNonExistingValue(), 241 unia zbiorów, 335
testSearchForExistingValues(), 240 unit testing, 32
testSearchForNonExistingValueGreaterThanLastItemO, UnsortedListPriorityQueue, 218
240 UnsortedListPriorityQueueTest, 218
testSearchForNonExistingValueLessThanFirstItem(), UnsupportedOperationException, 45
240 uruchamiacze, 38
testSetExistingKey(), 367 use cases, 18
testSetNewKey(), 366
testSetOutOfBounds(), 80
testShiftsDontErroneouslyIgnoreMatches(), 443
V
testSomeRealStrings(), 464 ValueIterator, 102
testSuccessor(), 279 verify(), 256
testThreePointsEquallySpacedApart(), 505
testUndoDeleteByPosition(), 142
testUndoInsert(), 141 W
testUndoMultiple(), 143
testUndoSet(), 142 wait(), 115
testVerticalLinesDoNotInterscct(), 494 waitForNotification(), 115
testVowelAreIgnored(), 463 wartość, 357
testWorstCaseHeapOrderedList(), 230 wartownik, 96
testWorstCaseSortedList(), 230 warunek stogowy, 222, 225
testWorstCaseUnsortedList(), 230 wąskie gardła, 16, 512
testXCoordinateIsPrimaryKey(), 502 wątki, 115
testYCoordinateIsSecondaryKey(), 502 węzły przeciążone w prawo, 274
Thread.sleep(), 516 while, 47
top of stack, 131 white-box testing, 34
trawersacja, 272 Widget, 33
in-order, 272, 391 wielopoziomowe wycofywanie operacji, 144
post-order, 273 wieże Hanoi, 140
pre-order, 272 wildcard, 392
Skorowidz 601

worst case, 26, 230 testSearchForNonExistingValueLessThanFirst-


wrappers, 56 -Item(), 240
współrzędne, 479 zasada działania, 236
wstawianie binarne, 235, 253 wyszukiwanie łańcuchów, 433
BinarylnsertCallCountingTest, 259 AbstractStringSearcherTest, 435, 439, 443
element środkowy, 253 algorytm Boyera-Moore'a, 441
implementacja insertera binarnego, 256 algorytm prymitywny, 438
insert(), 256 BoyerMooreStringSearcher, 444
inserter binarny, 254 BoyerMooreStringSearcherTest, 443
Listlnserter, 256 BruteForceStringSearcher, 440
ListlnserterTest, 254 CallCountingCharSequence, 450
porównywanie wydajności, 257 ComparativeStringSearcher, 452
porównywanie z sortowaniem, 258 computeLastOccurencesO, 445
sortowanie po dołączeniu każdego elementu, 261 implementacja algorytmu Boyera-Moore'a, 444
testy, 254 interfejs, 433
verify(), 255 iterator dopasowywania wzorca, 447
wstawianie losowych elementów do listy, 260 klasa testowa, 435, 439
wybór algorytmu, 24 pierwsze wystąpienie wzorca, 447
wyciąg, 305 pomiar efektywności, 450
wycieki pamięci, 93 porównanie, 449
wydajność algorytmu, 26 przeszukiwanie zawartości pliku, 451
wyjątki nieobsługiwalne, 112, 139 searchO, 435, 446
wykluczanie wzajemne, 113 siłowe wyszukiwanie, 438
wykonywanie obliczeń, 42 StringMatch, 434
wykrywanie wąskich gardeł, 512 StringMatchlterator, 448
wynurzanie elementu, 224 StringSearcher, 434
wysokość drzewa, 264 tablica ostatnich wystąpień znaków we wzorcu,
wyszukiwanie, 235 445
połówkowe, 236 tablica przeglądowa, 444
sekwencyjne, 247 testFindAtTheEnd(), 436
tekst, 433 testFindAtTheStart(), 436
wyszukiwanie binarne, 235 testFindInTheMiddle(), 437
AbstractListSearcherTest, 239 testFindOverlapping(), 437
BinarySearchCallCountingTest, 249, 250 testNotFoundlnAnEmptyText(), 436
element środkowy, 237 testowanie algorytmu Boyera-Moore'a, 443
implementacja iteracyjna, 244 zestaw testowy, 435
implementacja rekurencyjną, 241 zliczanie pobrań znaków, 450
implementacje, 238 wyszukiwarka łańcuchów, 433
interfejs, 238 wyszukiwarka sekwencyjna, 247
IterativcBinaryListScarcher, 245 implementacja, 247
klasa testowa, 239 LinearListSearcher, 248
ListSearcher, 238, 239 LinearListSearcherTest, 247
metody testowe, 240, 250 search(), 249
ocena działania wyszukiwarek, 247 testowanie, 247
porównanie efektywności, 252 wyważanie drzewa, 273
realizacja, 238 wzniesienie, 483
RecursivcBinaryListSearcher, 242
search(), 239
testowanie wydajności, 249 X
testSearchForArbitraryNonExistingValue(), 241 XP, 38
testSearchForExistingValues(), 240 XYPointComparator, 501, 503
testSearchForNonExistingValueGreatcrThanLast- XYPointComparatorTest, 501
-ItemO, 240
602 Algorytmy. Od podstaw

zbiory drzewiaste, 349


Z add(), 355
zamiatanie płaszczyzny, 500 containsO, 355
zapętlenie, 24 implementacja, 349
zapis formalny, 24 testowanie, 349
zasady TreeSet, 349
TreeSetTest, 349
KISS, 16
wyszukiwanie elementów, 355
podejście programistyczne, 15 zbiory haszowane, 344
zatapianie elementu, 225 add(), 348
zbiory, 333 delete(), 348
AbstractSetTest, 337, 339 HashSet, 344
add(), 334 HashSetTest, 344
cechy, 333 implementacja, 344
checkIterator(), 341 iterator, 348
clear(), 334 testowanie, 344
containsO, 334, 340 zbiory listowe, 342
delete(), 334 implementacja, 342
dodawanie elementów, 334 ListSet, 343
drzewa, 349 ListSetTest, 342
generyczna klasa testowa, 336 testowanie, 342
HashSet, 344 zdarzenia końcowe, 121
iloczyn, 335 zestaw przypadków testowych, 18
implementacja, 336 zestaw znaków, 453
implementacja listowa, 342 złożoność algorytmu, 26
interfejs, 336 best case, 26
isEmptyO, 334 czasowa, 26
iteratorO, 334 kwadratowa, 30
ListSet, 343 liniowa, 29
ListSetTest, 342 logarytmiczna, 31
najgorszy przypadek, 26
operacje, 334
najlepszy przypadek, 26
przecięcie, 335 notacja dużego O, 27
różnica, 335 0(1), 29
Set, 336 0()og N), 31
size(), 334 0(N log N), 31
suma, 335 0(N!), 32
testAddExistingValueHasNoEffect(), 340 0(N), 29
testAddNewValue(), 340 0(N2), 30
testClearO, 341 optymistyczna, 26
testContainsExisting(), 340 pesymistyczna, 26
testContainsNonExisting(), 340 rząd f, 27, 28
testDeleteExisting(), 341 rząd silni, 32
testDeleteNonExisting(), 341 stała, 29
testIteratorForwards(), 342 worst case, 26
testowanie implementacji, 336 wydajność, 26
TreeSet, 349 znacznik haszowania, 305
unia, 335 znajdowanie pary najbliższych punktów, 499
usuwanie elementów, 334 znak blankietowy, 392

You might also like