125 58 10MB
Polish Pages [349] Year 2010
Spis treści
Przedmowa ................................................................................................................................ 7 Zasada: oddziel algorytm od rozwiązywanego problemu Zasada: wprowadzaj tylko tyle matematyki, ile trzeba Zasada: analizę matematyczną stosuj doświadczalnie Odbiorcy Treść książki Konwencje stosowane w tej książce Zastosowanie przykładów w kodzie Podziękowania Literatura
8 9 9 10 11 11 12 13 13
Część I ................................................................................................................15 1. Algorytmy są ważne ......................................................................................................17 Postaraj się zrozumieć problem Jeśli to konieczne, eksperymentuj Kwestia uboczna Nauka płynąca z opowiedzianej historii Literatura
18 19 23 23 25
2. Algorytmy w ujęciu matematycznym ..........................................................................27 Rozmiar konkretnego problemu Tempo rośnięcia funkcji Analiza przypadku najlepszego, średniego i najgorszego Rodziny efektywności Mieszanka działań Operacje do pomiarów wzorcowych Uwaga końcowa Literatura
27 29 33 37 49 50 52 52
3
3. Wzorce i dziedziny .......................................................................................................53 Wzorce — język komunikacji Forma wzorca pseudokodu Forma projektowa Forma oceny doświadczalnej Dziedziny a algorytmy Obliczenia zmiennopozycyjne Ręczne przydzielanie pamięci Wybór języka programowania
53 55 57 59 59 60 64 66
Część II . ............................................................................................................ 69 4. Algorytmy sortowania ..................................................................................................71 Przegląd Sortowanie przez wstawianie Sortowanie medianowe Sortowanie szybkie Sortowanie przez wybieranie Sortowanie przez kopcowanie Sortowanie przez zliczanie Sortowanie kubełkowe Kryteria wyboru algorytmu sortowania Literatura
71 77 81 91 98 99 104 106 111 115
5. Wyszukiwanie . ............................................................................................................117 Przegląd Wyszukiwanie sekwencyjne Wyszukiwanie z haszowaniem Przeszukiwanie drzewa binarnego Literatura
117 118 128 140 146
6. Algorytmy grafowe .................................................................................................... 147 Przegląd Przeszukiwania w głąb Przeszukiwanie wszerz Najkrótsza ścieżka z jednym źródłem Najkrótsza ścieżka między wszystkimi parami Algorytmy minimalnego drzewa rozpinającego Literatura
4
|
Spis treści
147 153 160 163 174 177 180
7. Znajdowanie dróg w AI ...............................................................................................181 Przegląd Przeszukiwania wszerz A*SEARCH Porównanie Algorytm minimaks Algorytm AlfaBeta
181 198 201 211 214 222
8. Algorytmy przepływu w sieciach . ............................................................................ 231 Przegląd Przepływ maksymalny Dopasowanie obustronne Uwagi na temat ścieżek powiększających Przepływ o minimalnym koszcie Przeładunek Przydział zadań Programowanie liniowe Literatura
231 234 243 246 249 250 252 253 254
9. Geometria obliczeniowa ............................................................................................255 Przegląd Skanowanie otoczki wypukłej Zamiatanie prostą Pytanie o najbliższych sąsiadów Zapytania przedziałowe Literatura
255 263 272 283 294 300
Część III ............................................................................................................301 10. Gdy wszystko inne zawodzi .......................................................................................303 Wariacje na temat Algorytmy aproksymacyjne Algorytmy offline Algorytmy równoległe Algorytmy losowe Algorytmy, które mogą być złe, lecz z malejącym prawdopodobieństwem Literatura
Spis treści
303 304 304 305 305 312 315
|
5
11. Epilog ............................................................................................................................317 Przegląd Zasada: znaj swoje dane Zasada: podziel problem na mniejsze problemy Zasada: wybierz właściwą strukturę Zasada: dodaj pamięci, aby zwiększyć efektywność Zasada: jeśli nie widać rozwiązania, skonstruuj przeszukanie Zasada: jeśli nie widać rozwiązania, zredukuj problem do takiego, który ma rozwiązanie Zasada: pisanie algorytmów jest trudne, testowanie — trudniejsze
317 317 318 319 319 321 321 322
Dodatki . ......................................................................................................... 325 Dodatek. Testy wzorcowe ..........................................................................................327 Podstawy statystyczne Sprzęt Przykład Raportowanie Dokładność
327 328 329 335 337
Skorowidz . ..................................................................................................................339
6
|
Spis treści
Przedmowa
Trinity z filmu Matrix wypowiada takie słowa: Kieruje nami pytanie, Neo, i pytanie sprowadza ciebie tutaj. Znasz to pytanie, tak samo jak ja. Jako autorzy tej książki odpowiadamy na pytanie, które Cię tutaj zaprowadziło: Czy do rozwiązania mojego problemu mogę użyć algorytmu X? Jeśli tak, to jak go zrealizować? Zakładamy, że nie musisz rozumieć, dlaczego dany algorytm jest poprawny. Gdyby tak było, sięgnij do innych źródeł, takich jak drugie wydanie 1180-stronicowej biblii1 pt. Wprowadzenie do algorytmów autorstwa Thomasa H. Cormena i in. [2004]. Znajdziesz tam lematy, twierdzenia i dowody; znajdziesz ćwiczenia i krok po kroku podane przykłady, ukazujące algorytmy w trakcie działania. Może to zabrzmi zaskakująco, ale nie znajdziesz tam żadnego prawdziwego kodu, tylko fragmenty „pseudokodu”, używanego w niezliczonych podręcznikach do ogólnego zapisywania algorytmów. Podręczniki te mają istotne znaczenie w trakcie studiów, zawodzą jednak praktyków programowania, ponieważ zakłada się w nich, że opracowanie prawdziwego kodu na podstawie fragmentów pseudokodu nie przysporzy trudności. Zakładamy, że ta książka będzie często używana przez osoby doświadczone w programowaniu, poszukujące odpowiednich rozwiązań swoich problemów. Tutaj znajdziesz rozwiązania zagadnień, z którymi stykasz się w programowaniu na co dzień. Zobaczysz, na czym polega poprawianie efektywności najważniejszych algorytmów — takich, które przesądzają o sukcesie Twoich aplikacji. Znajdziesz tu prawdziwy kod, który można zaadaptować stosownie do potrzeb, i metody rozwiązywania zadań, których możesz się nauczyć. Wszystkie algorytmy są zrealizowane w pełni i wyposażone w zestawy testów do oceny poprawności ich implementacji. Ich kod jest całkowicie udokumentowany i zmagazynowany (w Sieci) jako uzupełnienie książki. Podczas planowania, pisania i redagowania tej książki przestrzegaliśmy rygorystycznie kilku zasad. Jeśli je pojmiesz, będziesz miał z niej pożytek.
1
W wydaniu polskim (WNT, Warszawa 2004) „Cormen” liczy 1196 stron. Dalej w odwołaniach do tej książki będziemy się powoływać na jej polskie wydanie — przyp. tłum.
7
Zasada: używaj prawdziwego kodu, a nie pseudokodu Co może zrobić praktyk z zamieszczonym na rysunku P.1 opisem algorytmu F ORDAFULKERSONA obliczania maksymalnego przepływu w sieci?
Rysunek P.1. Przykład pseudokodu powszechnie spotykanego w podręcznikach
Podany na tym rysunku opis algorytmu pochodzi z Wikipedii (http://en.wikipedia.org/wiki/Ford_ Fulkerson)2 i jest niemal identyczny z pseudokodem zamieszczonym w [Cormen i in., 2004]. Doprawdy, trudno się spodziewać, aby osoba zajmująca się programowaniem od strony praktycznej wytworzyła na podstawie przedstawionego tu opisu algorytmu FORDA-FULKERSONA działający kod! Zajrzyj do rozdziału 8., aby porównać, jak to wygląda zakodowane przez nas. W opisywaniu algorytmów posługujemy się wyłącznie dobrze zaprojektowanym i udokumentowanym kodem. Z kodu takiego możesz skorzystać od ręki lub możesz wyrazić jego logikę we własnym języku i systemie programowania. W niektórych podręcznikach algorytmów można spotkać prawdziwe, kompletne rozwiązania zakodowane w językach C lub Java. Podręczniki takie są jednak zazwyczaj przeznaczone dla nowicjuszy uczących się języka lub mają wyjaśniać sposoby realizacji abstrakcyjnych struktur danych. Ponadto aby pomieścić kod na ograniczonej szerokości strony podręcznika, autorzy z reguły omijają w nim dokumentowanie i obsługę błędów lub korzystają ze skrótów nigdy nie używanych w praktyce. Uważamy, że programiści mogą się więcej nauczyć z udokumentowanego, dobrze zaprojektowanego kodu, toteż włożyliśmy bardzo dużo wysiłku w opracowanie rzeczywistych rozwiązań naszych algorytmów.
Zasada: oddziel algorytm od rozwiązywanego problemu3 Niełatwo jest przedstawić realizację algorytmu „w sensie ogólnym”, bez wchodzenia w szczegóły konkretnego rozwiązania. Krytycznie oceniamy książki, w których są podawane pełne implementacje algorytmów, lecz w których detale konkretnego problemu są tak splecione z kodem problemu ogólnego, że trudno jest z nich wyodrębnić strukturę oryginalnego algorytmu. Co gorsza, 2
W prezentowanej postaci usunięto z niego 1 błąd — przyp. tłum.
3
Znana w ogólniejszym ujęciu jako zasada oddzielania mechanizmu od polityki (jego użycia) — przyp. tłum.
8
|
Przedmowa
wiele dostępnych realizacji opiera się na zbiorach tablic do przechowywania informacji, co ma „uprościć” kodowanie, lecz utrudnia rozumienie. Zbyt często jest tak, że czytelnik zrozumie zasadę na podstawie dodatkowych wyjaśnień, lecz nie będzie potrafił jej wdrożyć! My podchodzimy do każdej realizacji w ten sposób, aby oddzielić w niej algorytm ogólny od specyficznego zagadnienia. Na przykład, w rozdziale 7. podczas opisywania algorytmu A*SEARCH korzystamy z przykładu układanki zwanej „ósemką” (przesuwanie płytek oznaczonych numerami od 1 do 8 w kwadratowym polu o wymiarach 3 na 3). Realizacja A*S EARCH zależy wyłącznie od zbioru dobrze zdefiniowanych interfejsów. Szczegóły konkretnego problemu, tj. ułożenia „ósemki”, są starannie obudowane w klasach realizujących interfejs. Stosujemy w książce kilka języków programowania i przestrzegamy ścisłej metody projektowania, dbając o czytelność kodu i wydajność rozwiązań. Ponieważ naszą specjalnością zawodową jest inżynieria programowania, projektowanie przejrzystych interfejsów między ogólnymi algorytmami a rozwiązaniami z danej dziedziny leży niejako w naszej naturze. W wyniku takiego kodowania powstaje oprogramowanie, które łatwo jest testować i pielęgnować, jak również rozszerzać w celu rozwiązywania bieżących zadań. Dodatkową zaletą jest i to, że tak zredagowane opisy algorytmów są przystępniejsze do czytania i łatwiejsze do zrozumienia przez współczesnych odbiorców. W przypadku niektórych algorytmów pokazujemy, jak zmienić opracowany przez nas czytelny i efektywny kod w wysoce zoptymalizowany (choć mniej czytelny), o zwiększonej sprawności. Ostatecznie jednak jeśli chodzi o czas, powinno się go optymalizować tylko wtedy, kiedy problem został rozwiązany, a klient domaga się szybszego kodu. Nawet wówczas warto mieć w pamięci powiedzenie C.A.R. Hoare’a: „Przedwczesna optymalizacja jest źródłem wszelkiego zła”.
Zasada: wprowadzaj tylko tyle matematyki, ile trzeba Wiele rozpraw o algorytmach koncentruje się wyłącznie na dowodzeniu ich poprawności i wyjaśnianiu ich w sposób jak najbardziej ogólny. My skupiamy się zawsze na pokazaniu, jak należy algorytm zrealizować w praktyce. W związku z tym wprowadzamy matematykę tylko tam, gdzie jest to niezbędne do zrozumienia struktur danych i przebiegu sterowania w rozwiązaniu. Na przykład, w wielu algorytmach jest konieczna znajomość własności zbiorów i drzew binarnych. Jednocześnie jednak, żeby wyjaśnić, jak jest zrównoważone binarne drzewo czerwono-czarne, nie trzeba się odwoływać do indukcyjnego dowodu dotyczącego wysokości drzewa binarnego; jeśli interesują Cię te szczegóły, przeczytaj rozdział 13. w [Cormen i in., 2004]. Wyniki takie wyjaśniamy stosownie do potrzeb, odsyłając Czytelnika do innych źródeł, jeśli chodzi o zrozumienie ich matematycznych dowodów. Z tej książki nauczysz się podstawowych pojęć i technik analitycznych różnicowania zachowania algorytmów na podstawie użytych struktur danych i pożądanej funkcjonalności.
Zasada: analizę matematyczną stosuj doświadczalnie Sprawność wszystkich algorytmów zamieszczonych w tej książce analizujemy matematycznie, aby pomóc osobom programującym zrozumieć warunki, w których każdy z nich działa najlepiej. Dostarczamy żywych przykładów kodu, a w uzupełniającym książkę magazynie kodu4 znajdują 4
Nie używamy w przekładzie terminu repozytorium, gdyż trąci on zatęchłą szafą z zetlałymi aktami — przyp. tłum. Zasada: analizę matematyczną stosuj doświadczalnie
|
9
się liczne warianty testów JUnit (http://sourceforge.net/projects/junit), dokumentujące poprawność implementacji każdego algorytmu. Generujemy testowe dane porównawcze, aby unaocznić doświadczalnie sprawność działania każdego algorytmu. Każdy algorytm zaliczamy do pewnej rodziny efektywnościowej i podajemy dane porównawcze (wzorcowe), pomocne w analizie sprawności jego działania. Unikamy algorytmów ciekawych tylko ze względów matematycznych dla projektanta-algorytmika próbującego udowodnić, że jakaś metoda jest obliczeniowo sprawniejsza, jeśli nie uwzględnia się przy tym możliwości uzyskania jej praktycznej realizacji. Wykonujemy nasze algorytmy na różnych platformach, aby wykazać, że to sposób zbudowania algorytmu, a nie użytkowana platforma, jest czynnikiem przesądzającym o efektywności. W dodatku do książki pomieściliśmy wszystkie szczegóły dotyczące naszych testów wzorcowych. Można z niego korzystać do niezależnego uprawomocnienia wyników dotyczących sprawności, które podajemy w książce. Przestroga, której Ci przy tej okazji udzielamy, jest powszechnie znana wśród miłośników otwartego kodu: „Twój licznik może wskazywać inaczej”. Choć nie zdołasz uzyskać dokładnie naszych wyników, zweryfikujesz dokumentowane przez nas tendencje, toteż zachęcamy Cię do stosowania takiego samego, doświadczalnego podejścia przy wybieraniu algorytmów na własny użytek.
Odbiorcy Gdyby przyszło Ci się wyprawić na bezludną wyspę tylko z jedną książką o algorytmach, polecilibyśmy pudło z zestawem książek The Art of Computer Programming, t. 1 – 3, Donalda Knutha [1998]5. Knuth opisuje wiele struktur danych i algorytmów oraz dostarcza wysmakowanych przemyśleń i takiejże analizy. Wraz z przypisami historycznymi i zestawami ćwiczeń książki te dostarczyłyby programiście zajęcia i zadowolenia przez długie lata. Atoli byłoby niemałym wyzwaniem przełożyć bezpośrednio pomysły zawarte w książce Knutha na praktykę. Nie żyjesz jednak na bezludnej wyspie, nieprawdaż? Przeciwnie — masz kod, który działa w żółwim tempie i który trzeba ulepszyć z piątku na sobotę, a Ty musisz się dowiedzieć, jak tego dokonać! Chcemy, aby nasza książka była pierwszym miejscem, do którego się udasz, ilekroć staniesz wobec pytania o algorytm i trzeba będzie: (a) rozwiązać jakiś konkretny problem lub (b) poprawić sprawność istniejącego rozwiązania. Zawarliśmy tutaj wybór znanych algorytmów służących do rozwiązywania wielu problemów, przy czym przestrzegamy następujących reguł: • W opisie każdego algorytmu stosujemy pewien ustalony wzorzec, tworzący odpowiednie
ramy do omawiania i wyjaśniania istotnych elementów algorytmu. Dzięki użyciu wzorców książka zyskuje na czytelności, ukazując w sposób zwarty i jednolity wpływ podobnych decyzji projektowych na różne algorytmy.
• Do zapisywania algorytmów w książce używamy rozmaitych języków (w tym C, C++, Java
i Ruby). W ten sposób nasze omawianie algorytmów nabiera konkretnych kształtów i przemawiamy językami, które już znasz.
• Do każdego algorytmu dodajemy omówienie jego oczekiwanej sprawności i doświadczalnie
wykazujemy, że te obietnice są spełniane. Niezależnie od tego, czy ufasz matematyce, czy demonstrowanym czasom wykonania — dasz się przekonać. 5
Wydanie polskie: Sztuka programowania, WNT, Warszawa 2004 — przyp. tłum.
10
|
Przedmowa
Polecamy tę książkę przede wszystkim osobom zajmującym się programowaniem w praktyce, programist(k)om i projektant(k)om. Aby osiągnąć swe cele, musisz mieć dostęp do wysokiej jakości materiałów, w których znajdziesz wyjaśnienie rzeczywistych rozwiązań rzeczywistych algorytmów potrzebnych Ci do rozwiązywania rzeczywistych problemów. Umiesz już programować w wielu językach programowania. Znasz najważniejsze informatyczne struktury danych, takie jak tablice, listy powiązane, stosy, kolejki, tablice z haszowaniem, drzewa binarne oraz grafy i digrafy (grafy skierowane). Nie musisz implementować tych struktur, ponieważ są one zazwyczaj zawarte w bibliotekach. Zakładamy, że skorzystasz z tej książki, aby poznać wypróbowane i przetestowane metody skutecznego rozwiązywania problemów. Zaznajomisz się z pewnymi zaawansowanymi strukturami danych i niektórymi nowymi sposobami stosowania typowych struktur danych do poprawiania efektywności algorytmów. Twoje umiejętności rozwiązywania problemów zwiększą się, gdy poznasz zasadnicze rozstrzygnięcia przyjęte w poszczególnych algorytmach w celu osiągnięcia sprawnych rozwiązań.
Treść książki Książka jest podzielona na trzy części. Część I (rozdziały 1. – 3.) zawiera matematyczne wprowadzenie do algorytmów, niezbędne do zrozumienia stosowanych w książce opisów; omawiamy w niej również oparty na wzorcach schemat prezentowania każdego algorytmu. Schemat ten został starannie przemyślany, aby zapewnić spójność i klarowność przedstawiania istotnych aspektów poszczególnych algorytmów. Na część II składają się rozdziały 4. – 9. Każdy zawiera zbiór algorytmów powiązanych tematycznie. Poszczególne ich podrozdziały stanowią kompletne opisy algorytmów. Część III (rozdziały 10. i 11.) dostarcza materiału, za pomocą którego zainteresowani Czytelnicy mogą podążyć dalej tropem poruszonych zagadnień. Rozdział poświęcony metodom stosowanym w wypadkach, gdy „wszystko inne zawodzi”, zawiera pożyteczne rady odnośnie do rozwiązywania problemów, które (jak dotąd) nie mają bezpośrednich, efektywnych rozwiązań. Kończymy tę część omówieniem ważnych dziedzin badań, które pominęliśmy w części II dlatego, że były zbyt zaawansowane, nazbyt niszowe lub zbyt nowe, aby zdążyły się sprawdzić. W części IV zawarliśmy dodatek z testami wzorcowymi. Opisaliśmy w nim metodę używaną do generowania danych doświadczalnych występujących w książce, pomocnych w analizie matematycznej stosowanej w każdym rozdziale. Tego rodzaju testy porównawcze są typowe w przemyśle, lecz w podręcznikach algorytmów zauważa się ich wyraźny brak.
Konwencje stosowane w tej książce W książce przyjęto następujące konwencje typograficzne: Kod Wszystkie przykłady kodu są złożone tym krojem. Kod taki jest kopią pobraną wprost z magazynu kodu i odzwierciedla kod rzeczywisty6.
6
W wersji polskiej zachowano w nim oryginalne nazewnictwo elementów programowych; tłumaczeniem objęto tylko komentarze — przyp. tłum.
Konwencje stosowane w tej książce
|
11
Kursywa Zaznaczone są nią najważniejsze terminy używane do opisywania algorytmów i struktur danych. Jest również stosowana do oznaczania zmiennych w opisach przykładów towarzyszących pseudokodowi. Krój o stałej szerokości
Tym krojem są złożone nazwy elementów prawdziwego oprogramowania użyte w implementacji, takie jak nazwy klas Javy, nazwy tablic w implementacji w języku C oraz stałe w rodzaju true i false. MAŁE WERSALIKI Wskazują nazwę algorytmu. W całej książce odwołujemy się do wielu innych książek, artykułów oraz do witryn internetowych. Odsyłacze takie występują w tekście w nawiasach kwadratowych, na przykład [Cormen i in., 2004], a każdy rozdział zamyka wykaz literatury użytej w rozdziale. Jeśli odsyłacz występuje bezpośrednio po nazwisku autora w tekście, nie powtarzamy w nim nazwy dzieła. Tak więc do The Art of Computer Programming Donalda Knutha [1998] odwołujemy się tylko przez podanie roku w nawiasach. Wszystkie występujące w książce lokalizatory URL zweryfikowano w sierpniu 2008 r., przy czym staraliśmy się korzystać tylko z takich, które powinny istnieć przez pewien czas. Krótkie lokalizatory URL, takie jak http://www.oreilly.com, zamieszczamy wprost w tekście7; jeśli są długie, to występują w przypisach i w wykazie literatury na końcu rozdziału.
Zastosowanie przykładów w kodzie Ta książka ma pomagać w tym, co masz do zrobienia. Ogólnie biorąc, możesz używać kodu z tej książki w swoich programach i dokumentacji. Nie musisz się z nami kontaktować, aby uzyskać na to pozwolenie, chyba że reprodukujesz znaczną ilość materiału zawartego w książce. Na przykład, aby użyć w pisanym programie kilku fragmentów kodu z tej książki, nie trzeba pozwolenia. Na sprzedaż lub dystrybucję płyty CD-ROM z przykładami z książek wydawnictwa O’Reilly pozwolenie takie jest wymagane. Udzielenie odpowiedzi na czyjeś na pytanie z zacytowaniem przykładowego kodu z tej książki i odesłaniem do niej nie wymaga pozwolenia. Wcielenie znacznej ilości przykładowego kodu z książki do dokumentacji Twojego wyrobu będzie wymagało uzyskania pozwolenia. Doceniamy — choć nie wymagamy — dokonywanie należnych przypisań. Przypisanie takie zwykle zawiera tytuł, autora, wydawcę i klasyfikator ISBN. Przykład: „George T. Heineman, Gary Pollice, Stanley Selkow, Algorithms in a Nutshell, Copyright 2009 George T. Heineman, Gary Pollice, Stanley Selkow 978-0-596-51624-6”. Jeśli uznasz, że sposób używania przez Ciebie przykładów kodu wykracza poza uczciwe, przedstawione tutaj zasady, nie obawiaj się skontaktować z nami pod adresem [email protected].
7
Dzieląc je w razie konieczności, tak jak długie wyrazy, z dodaniem dywizu w rozsądnym miejscu — przyp. tłum.
12
|
Przedmowa
Podziękowania Chcielibyśmy złożyć podziękowania recenzentom książki za uwagę, z jaką skupili się na szczegółach, i sugestie, które przyczyniły się do poprawienia ujęcia materiału i usunięcia wad z wcześniejszych maszynopisów. Są to: Alan Davidson, Scot Drysdale, Krzysztof Dulęba, Gene Hughes, Murali Mani, Jeffrey Yasskin i Daniel Yoo. George Heineman pragnie podziękować tym, którzy zaszczepili w nim głębokie zainteresowanie algorytmami, a w szczególności profesorom Scotowi Drysdale’owi (Dartmouth College) i Zvi Galil (Columbia University). Jak zawsze George dziękuje żonie Jennifer i dzieciom: Nicholasowi (który zawsze chciał wiedzieć, co to za „nuty”, nad którymi tata pracuje) i Alexandrowi (który przyszedł na świat, gdy przygotowywaliśmy ostatnią wersję maszynopisu książki). Gary Pollice chciałby podziękować swojej żonie Vikki za 40 wspaniałych lat. Chce on także podziękować instytutowi informatyki WPI za świetną atmosferę i znakomitą robotę. Stanley Selkow chce złożyć podziękowania swojej żonie Deb. Ta książka była kolejnym krokiem na ich długiej, wspólnej drodze.
Literatura Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest i Clifford Stein: Wprowadzenie do Algorytmów. Wydawnictwa Naukowo-Techniczne (WNT), Warszawa 2004. Donald E. Knuth: Sztuka programowania. Tomy 1 – 3. Wydawnictwa Naukowo-Techniczne (WNT), Warszawa 2002.
Literatura
|
13
14
|
Przedmowa
CZĘŚĆ I
Rozdział 1. Algorytmy są ważne Rozdział 2. Algorytmy w ujęciu matematycznym Rozdział 3. Wzorce i dziedziny
15
16
|
Rozdział 1. Algorytmy są ważne
ROZDZIAŁ 1.
Algorytmy są ważne
Algorytmy są ważne! Znajomość tego, który algorytm zastosować w określonych warunkach, może znacznie zmienić opracowywane przez Ciebie oprogramowanie. Jeśli nam nie wierzysz, to przeczytaj następującą historię o tym, jak Gary obrócił porażkę w sukces dzięki odrobinie analizy i wyborowi algorytmu odpowiedniego do zadania1. Dawno temu Gary pracował w firmie, w której roiło się od błyskotliwych konstruktorów oprogramowania. Jak to bywa w przedsiębiorstwach, w których jest pełno rozgarniętych ludzi, i w tej firmie było co niemiara wspaniałych pomysłów i chętnych do ich wdrażania w wyrobach programowych. Jedną z takich osób był Graham, zatrudniony w firmie od jej powstania. Przyszedł mu do głowy pomysł, jak wykryć, czy w programie występują ubytki pamięci — typowy w tamtych czasach problem w programach pisanych w językach C lub C++. Jeśli program z ubytkami pamięci pracował dłuższy czas, to dochodziło w końcu do jego załamania z braku pamięci. Każdy, kto programował w systemie programowania bez automatycznego zarządzania pamięcią, czyli bez odśmiecania, dobrze zna ten problem. Graham postanowił zbudować małą bibliotekę, która opakowywała procedury systemu operacyjnego służące do przydziału i zwalniania pamięci — malloc() i free() — jego własnymi funkcjami. Każdy przydział i każde zwolnienie pamięci przez aplikację funkcje Grahama odnotowywały w strukturze danych, którą można było badać po zakończeniu programu. Funkcje opakowujące zapisywały informacje, po czym wywoływały prawdziwe funkcje systemu operacyjnego do wykonania na pamięci faktycznych czynności administracyjnych. Zaprogramowanie rozwiązania zajęło Grahamowi zaledwie kilka godzin i voilà — działało! Pojawił się jednak problem. Program zaopatrzony w biblioteczne funkcje Grahama działał tak wolno, że nikt nie miał ochoty go używać. Mówimy tu o naprawdę wolnym działaniu. Można było rozpocząć program, pójść na filiżankę kawy — albo i na cały dzbanek — wrócić… i zastać program wciąż ślimaczący się z wykonaniem. To było nie do przyjęcia. Trzeba przyznać, że Graham wiedział naprawdę niemało, jeśli chodziło o rozumienie systemów operacyjnych i ich wewnętrznego działania. Był znakomitym programistą. Potrafił napisać więcej kodu w godzinę niż kto inny przez cały dzień. Uczył się algorytmów, struktur danych i wszystkich typowych przedmiotów w college’u — dlaczego więc jego kod aż tak spowalniał działanie po wstawieniu opakowań? W tym wypadku problem polegał na tym, że posiadanej wiedzy wystarczyło, aby zbudować program, który działa, lecz nie na szczegółowe przemyślenie, co zrobić, 1
Imiona i nazwiska osób — z wyjątkiem autorów — oraz nazwy firm zostały zmienione, aby zachować niewinność i uniknąć wszelkich kłopotów, czyli… pozwów. :-)
Postaraj się zrozumieć problem
|
17
aby działał on szybko. Jak wielu twórczych ludzi, Graham myślał już o swoim następnym pro gramie i nie chciało mu się wracać do programu ubytków pamięci, aby sprawdzić, co było w nim nie tak. Poprosił więc Gary'ego, by rzucił na to okiem -a nuż uda mu się znaleźć przyczynę błędu. Gary'ego bardziej interesowały kompiłatory i inżynieria oprogramowania, wydawał się więc dobrym kandydatem do szlifowania kodu przed jego ostatecznym oddaniem. Gary pomyślał, że zanim zacznie kopać w kodzie, lepiej pogada o programie z Grahamem. Dawało mu to nadzieję na lepsze zrozumienie struktury, którą Graham nadał swemu rozwiązaniu, i powodów wyboru tych czy innych możliwości implementacyjnych. Zanim pójdziesz dalej, zastanów się, o co warto było zapytać Grahama. Przekonaj się, czy udałoby Ci się uzyskać te informacje, które wydobył Gary, co omawiamy w następ nym punkcie.
Postaraj się zrozumieć problem
��
Zabierając się do rozwiązywania problemów, dobrze jest ro oczA ogólnego spojrzenia: staraj się zrozumieć problem, zidentyfikować potencjalne prz c y;�opiero potem zagłębiaj się w szczegóły. Jeśli podejmiesz próbę rozwiązania problemu tylko nadziejr;, że znasz jego przyczynę, może się zdarzyć, że rozwiążesz zły (nie te�pk lub nie uwzględnisz innych -być może lepszych -rozwiązań. Pierwsze, o co Gary W Grahama, to przedstawienie problemu i jego rozwiązania. '1-.: Graham wyjaśnił, że chciał ustalić, czy program �)uje ubytki pamięci. Pomyślał, że najlep szym na to sposobem będzie odnotowywan��ego przydziału pamięci wykonywanego w programie i tego, czy została ona zwol�Yd zakończeniem programu, ora� rejestrowanie miejsca, w którym wystąpiło zamówie��ięci w programie użytkownika. Zeby to zrobić, musiał zbudować małą bibliotekę złoż� trzech funkcji:
� ·
malloc()
�
•
opakowanie funkcji syst��� �acyjnego powodującej przydział pamięci,
free()
�� temu operacyjnego powodującej zwolnienie pami�ci,
opakowanie funl«
. exlt()
opakowanie funkcji systemu operacyjnego wywoływanej na zakończenie programu. Taką specjalną bibliotekę można było konsolidować z testowanym programem, powodując, że adaptowane funkcje były wywoływane w miejsce funkcji systemu operacyjnego. Adaptowane funkcje malloc() i free () mogły śledzić każdy przydział i każde zwolnienie pamięci. Brak ubyt ków pamięci można było stwierdzić pod koniec działania programu, jeśli okazało się, że każdy dokonany przydział został później zwolniony. Gdyby dochodziło do ubytków pamięci, informacje zgromadzone przez procedury Grahama umożliwiłyby programiście znalezienie kodu, który je powodował. Wskutek wywołania funkcji exit ()ze specjalnej biblioteki Grahama program wy świetlał przed swoim zakończeniem te wyniki. Graham naszkicował swoje rozwiązanie mniej więcej w taki sposób, jak na rysunku 1.1.
18
Rozdział 1. Algorytmy są ważne
T@5towany program
l
� free()
Specjalna bi bliote k
( Rysunek
1.1.
Usługi SO
)
l
Zapisy przydziałów pamięc:i
mollocO
�i� 't() Raport ubytkach o
pamięci
Rozwiązanie Grahama
Opis ten wyglądał dostatecznie jasno. Trudno było wyobrazić sobie, że kod opakowujący funkcje systemu operacyjnego mógł powodować problem ze sprawnością pro�mu, chyba że Graham popełnił w nim jakiś wyjątkowo paskudny błąd. Gdyby jednak tak �� wszystkie programy działałyby proporcjonalnie wolniej. Gary spytał, czy w testowan h e Grahama programach dało się zauważyć jakąś różnicę w tej mierze. Graham odparł, ż�� o� konania wynikałoby, iż małe programy -takie, które robiły stosunkowo niewiele ��� lały w czasie możliwym do zaakceptowania, niezależnie od tego, czy miały ubytki � czy nie. Natomiast programy, które miały dużo do przetwarzania i w których wrtf �Y ubytki pamięci, działały nieproporcjonalnie wolno.
��
�
�
�=�� ���e��:��;� �� � ����:� L U
k oznać się lepiq z proffiem wykonywania programów. Usiadł razem G ah m i wspólnie napisali kilka krótkich programów, aby zobaczyć, jak będzie wyglądało i ylonanie po skonsolidowaniu ich ze specjalną biblioteką Grah ma. Liczyli, że uda s ·eJ zrozumieć, w jakich warunkach pojawia się problem.
�
�
'
� �
Jak myśl� akiego typu eksperymenty warto by tu wykonać? Jak wyglądałby Twój progra programy)? ..
Pierwszy test, który napisali Gary i Graham (program A), pokazano jako przykład 1.1. Przykład
1.1.
Kod programu A
int main(int argc, int i
for (i
}
char **argv)
O;
=
=
O;
i
number
Przykład
|
333
;; Znajduje maks niepustej listy liczb (define (find-max nums) (foldl max (car nums) (cdr nums))) ;; find-min: (nonempty-listof number) -> number ;; Znajduje min niepustej listy liczb (define (find-min nums) (foldl min (car nums) (cdr nums))) ;; sum: (listof number) -> number ;; Sumuje elementy w nums (define (sum nums) (foldl + 0 nums)) ;; average: (listof number) -> number ;; Znajduje średnią niepustej listy liczb (define (average nums) (exact->inexact (/ (sum nums) (length nums)))) ;; square: number -> number ;; Oblicza kwadrat x (define (square x) (* x x)) ;; sum-square-diff: number (listof number) -> number ;; Pomocnicza metoda odchylenia standardowego (define (sum-square-diff avg nums) (foldl (lambda (a-number total) (+ total (square (- a -number avg)))) 0 nums)) ;; standard-deviation: (nonempty-listof number) -> number ;; Oblicza odchylenie standardowe (define (standard-deviation nums) (exact->inexact (sqrt (/ sum-square-diff (average nums) nums) (length nums)))))
Pomocnicze funkcje z przykładu A.6 są używane przez kod pomiaru czasu w przykładzie A.7, wykonujący serię wariantów testów rozpatrywanej funkcji. Przykład A.7. Kod pomiaru czasu w języku Scheme ;; Tu właśnie wykonuje się testowaną funkcję na rozmiarze problemu ;; result: (number -> any) -> number ;; Oblicza czas działania f na danym probSize (define (result f probSize) (let* ((start-time (current-inexact-miliseconds)) (result (f probSize)) (end-time (current-inexact-miliseconds))) (- end-time start-time))) ;; trials: (number -> any) number number -> (listof number) ;; Buduj listę wyników prób (define (trials f numTrials probSize) (if (= numTrials 1) (list (result f probSize)) (cons (result f probSize) (trials f (- numTrials 1) probSize)))) ;; Generuj wiersz tabeli raportu dotyczącego rozmiaru problemu (define (smallReport f numTrials probSize) (let* ((results (trials f numTrials probSize))
334 |
Dodatek. Testy wzorcowe
(reduced (remove-number (remove-number results (find-min results)) (find-max results)))) (display (list 'probSize: probSize 'numTrials: numTrials (average reduced))) (newline))) ;; Generuj cały raport dotyczący danej funkcji, zwiększając o jeden ;; rozmiar problemu (define (briefReport f inc numTrials minProbSize maxProbSize) (if (>= minProbSize maxProbSize) (smallReport f numTrials minProbSize) (begin (smallReport f numTrials minProbSize) (briefReport f inc numTrials (inc minProbSize) maxProbSize)))) ;; Standardowe funkcje podwajania i dodawania 1 do postępu w raportowaniu (define (double n) (* 2 n)) (define (plus1 n) (+ 1 n))
Funkcja largeAdd z przykładu A.8 dodaje do siebie zbiór n liczb. Wynik generowany przez (briefReport largeAdd millionplus 30 1000000 5000000) jest pokazany w tabeli A.2. Przykład A.8. Funkcja largeAdd w języku Scheme ;; Metoda pomocnicza (define (millionplus n) (+ 1000000 n)) ;; Sumuj liczby z przedziału 1..probSize (define (largeAdd probSize) (let loop ([i probSize] [total 0]) (if (= i 0) total (loop (sub1 i) (+ i total)))))
Tabela A.2. Czas wykonania 30 prób largeAdd n
Czas wykonania (ms)
1 000 000
382,09
2 000 000
767,26
3 000 000
1155,78
4 000 000
1533,41
5 000 000
1914,78
Raportowanie Pouczające jest przyjrzenie się rzeczywistym wynikom obliczonym na tej samej platformie, w danym wypadku jest nią Linux 2.6.9 – 67.0.1.ELsmp i686 (ta maszyna różni się od biurkowego PC-ta i od komputera wysokiej klasy, o których mówiono wcześniej w rozdziale). Przedstawiamy trzy tabele (A.3, A.5 i A.6): jedna z wynikami testu w Javie, druga — w C, i trzecia — w Scheme. W każdej tabeli wyniki są podane w milisekundach; przedstawiamy też zwięzłą tabelę z histogramem dotyczącą wyników w Javie.
Raportowanie
|
335
Tabela A.3. Wyniki pomiarów 30 obliczeń w Javie n
Średnia
Min
Max
Odchylenie std.
#
1 000 000
8,5
8
18
0,5092
28
2 000 000
16,9643
16
17
0,1890
28
3 000 000
25,3929
25
26
0,4973
28
4 000 000
33,7857
33
35
0,4179
28
5 000 000
42,2857
42
44
0,4600
28
Zachowanie zagregowane w tabeli A.3 jest przedstawione w szczegółach w postaci histogramu w tabeli A.4. Usunęliśmy z tabeli rzędy zawierające same zera; wszystkie wartości niezerowe są w tabeli zacieniowane. Tabela A.4. Wyniki pomiarów w ujęciu szczegółowym Czas (ms)
1 000 000
2 000 000
3 000 000
4 000 000
5 000 000
8
15
0
0
0
0
9
14
0
0
0
0
16
0
2
0
0
0
17
0
28
0
0
0
18
1
0
0
0
0
25
0
0
18
0
0
26
0
0
12
0
0
33
0
0
0
7
0
34
0
0
0
22
0
35
0
0
0
1
0
42
0
0
0
0
21
43
0
0
0
0
8
44
0
0
0
0
1
Aby zinterpretować te wyniki testu w Javie, sięgniemy do statystyki. Jeśli założymy, że pomiary czasu wykonania każdej próby są niezależne, to odnosimy się do przedziałów ufności opisanych wcześniej. Poproszeni o przewidzenie sprawności danego wykonania dla n = 4 000 000 możemy wówczas powiedzieć, że z prawdopodobieństwem 95,45% oczekiwany czas będzie się mieścić w przedziale [32,9499, 34,6215]. Tabela A.5. Wyniki pomiarów 30 obliczeń w C n
Średnia
Min
Max
Odchylenie std.
#
1 000 000
2,6358
2,589
3,609
0,1244
28
2 000 000
5,1369
5,099
6,24
0,0672
28
3 000 000
7,6542
7,613
8,009
0,0433
28
4 000 000
10,1943
10,126
11,299
0,0696
28
5 000 000
12,7272
12,638
13,75
0,1560
28
336
|
Dodatek. Testy wzorcowe
Patrząc na same liczby, można odnieść wrażenie, że realizacja w C jest trzy razy szybsza. Wyniki histogramu nie są zbyt komunikatywne, ponieważ pomiary czasu zawierają ułamki milisekund, podczas gdy w pomiarach w Javie przyjęto zasadę, że są raportowane tylko wartości całkowite. Ostatnia tabela zawiera wyniki testu wykonanego w Scheme. Różnice czasów wykonania testów w Scheme są znacznie większe niż w przypadku Javy lub C. Jedną przyczyn może być to, że rozwiązanie rekurencyjne wymaga więcej wewnętrznych czynności administracyjnych w trakcie wykonywania obliczeń. Tabela A.6. Wyniki pomiarów 30 obliczeń w Scheme n
Średnia
Min
Max
Odchylenie std.
#
1 000 000
1173
865
1274
7,9552
28 28
2 000 000
1921,821
1824
2337
13,1069
3 000 000
3059,214
2906
3272
116,2323
28
4 000 000
4040,607
3914
4188
81,8336
28
5 000 000
6352,393
6283
6452
31,5949
28
Dokładność Zamiast używania czasomierzy milisekundowych można zastosować czasomierze nanosekundowe. Na platformie Java jedyna zmiana w podanym uprzednio kodzie pomiaru czasu polegałaby na wywołaniu metody System.nanoTime() zamiast podającej czas milisekundowy. Żeby zrozumieć, czy istnieje korelacja między czasomierzami milisekundowym i nanosekundowym, kod zmieniono tak, jak to przedstawia przykład A.9. Przykład A.9. Zastosowanie czasu nanosekundowego w Javie TrialSuite tsM = new TrialSuite(); TrialSuite tsN = new TrialSuite(); for (long len = 1000000; len