ANALIZA ALGORYTMÓW Analiza algorytmów polega między innymi na odpowiedzi na pytania: 1) Czy problem może być rozwiązany na komputerze w dostępnym czasie i pamięci? 2) Który ze znanych algorytmów należy zastosować w danych okolicznościach? 3) Czy istnieje lepszy algorytm od rozważanego? Czy jest on optymalny? Konstruując algorytm należy zwracać uwagę na : - poprawność semantyczną - prostotę - czas działania - ilość zajmowanej pamięci - optymalność - okoliczności w jakich należy go używać, a w jakich nie Złożoność obliczeniową algorytmu definiuje się jako ilość zasobów komputerowych, potrzebnych do jego wykonania. Wyróżniamy złożoność pamięciową i czasową. Będziemy się zajmować głównie złożonością czasową. Miara złożoności musi być uniwersalna czyli oderwana od szczegółów natury "sprzętowej" tj. - Jaki komputer jest używany? - Jaka jest częstotliwość zegara taktującego procesor? - Czy program będzie jedynym wykonywanym na komputerze? Jeśli nie to jaki jest jego priorytet? - Jakiego kompilatora używamy? - Czy w kompilatorze włączono opcje optymalizacji kodu?... etc
Parametrem najczęściej decydującym o czasie wykonania algorytmu jest rozmiar danych, z którymi ma on do czynienia. Parametr ten może mieć różne znaczenie: - dla funkcji sortującej tablicę parametrem będzie rozmiar tablicy - dla funkcji liczącej n! będzie to wielkość danej wejściowej W algorytmie zawsze można wyróżnić tzw. operacje dominujące (najbardziej czasochłonne) - takie, że łączna ich liczba jest proporcjonalna do liczby wszystkich operacji jednostkowych w dowolnej realizacji algorytmu. Dla sortowania operacją tą będzie zwykle porównanie dwóch elementów, czasem także przestawienie elementów ciągu. Jednostką złożoności czasowej jest czas wykonania jednej operacji dominującej. Wyróżniamy: - złożoność pesymistyczną W(n) - zdefiniowaną jako ilość zasobów komputerowych, potrzebnych przy "najgorszych" danych wejściowych rozmiaru n - złożoność oczekiwaną A(n) - definiowaną jako ilość zasobów komputerowych, potrzebnych przy "typowych" danych wejściowych rozmiaru n Faktyczna/praktyczna złożoność czasowa algorytmu (czas działania) różni się od wyliczonej teoretycznie współczynnikiem proporcjonalności zależnym od konkretnej realizacji algorytmu. Istotną informacją zawarta w W(n), A(n) jest rząd wielkości, czyli zachowanie asymptotyczne, gdy n ->. Będziemy zatem szukać złożoności teoretycznej, tj. funkcji matematycznej występującej w T(n), która odgrywa w niej najważniejszą rolę, wpływając najsilniej na czas wykonania programu.
Najczęściej spotykane złożoności czasowe algorytmów: 1) log(n) - występuje, np. dla algorytmów, w których zadanie rozmiaru n zostaje sprowadzone do zadania rozmiaru n/2 + pewna stała liczba działań ( np. przeszukiwanie binarne uporządkowanego ciągu) 2) n - występuje, np. dla algorytmów, w których jest wykonywana pewna stała liczba działań dla każdego z n elementów danych wejściowych (np. algorytm Hornera wyznaczania wartości wielomianu) 3) n*log(n) - występuje, np. dla algorytmów, w których zadanie rozmiaru n zostaje sprowadzone do dwóch podzadań rozmiaru n/2 + pewna liczba działań liniowa w n (np. niektóre metody sortowania - quicksort ) 4) n 2 - występuje, np. dla algorytmów w których jest wykonywana pewna stała liczba działań dla każdej pary elementów danych wejściowych (podwójna instrukcja iteracyjna, np. operacje na tablicach) 5) 2 n - występuje, np. dla algorytmów, w których jest wykonywana stała liczba działań dla każdego podzbioru danych wejściowych 6) n! - występuje, np. dla algorytmów, w których jest wykonywana stała liczba działań dla każdej permutacji danych wejściowych Czas działania algorytmu/ programu o danym rozmiarze danych n silnie zależy od złożoności algorytmu ( T = 1µs ):
Wychodzenie z labiryntu i pakowanie plecaka. Zadania te nazywane są problemami optymalizacji, które dotyczą znajdowania najlepszego rozwiązania wśród wielu możliwych rozwiązań spełniających pewne warunki. Rozwiązania powyższych zadań są jednocześnie przykładem metod heurystycznych, wykorzystujących intuicyjne sposoby otrzymania możliwie najlepszych rozwiązań - metody te są szybkie i mają duże znaczenie praktyczne. Znajdowanie wyjścia z labiryntu. Labirynt jest zamknięty w prostokącie, ma tylko jedno wyjście/wejście i wszystkie ściany wewnętrzne są równoległe do zewnętrznych. W labiryncie nie ma zamkniętych obszarów, tzn. z każdego pola istnieje droga prowadząca do wyjścia. Pola labiryntu można ponumerować/nazwać jak na szachownicy. Naszym celem jest podanie algorytmu, który z każdego punktu labiryntu zaprowadzi nas do wyjścia, bez zbędnego kluczenia. W algorytmie takim zawsze można wyróżnić dwa elementy: - regułę gwarantującą, że żadnego odcinka drogi w labiryncie nie przechodzimy więcej niż jeden raz - strategię jak najszybszego wyjścia z labiryntu Metoda po omacku (z ręką na ścianie ). Po wybraniu kierunku poruszamy się, trzymając cały czas jedną (ale tę samą ) rękę na ścianie - idziemy wzdłuż ścian. Poruszając się w ten sposób albo trafimy do wyjścia, albo wrócimy do punktu, w którym już byliśmy.
Metoda z nawrotami: W każdym punkcie (polu) labiryntu są co najwyżej cztery możliwości występowania następnego kroku: { w górę, w lewo, w prawo, w dół } - {G,L,P,D} Opis metody: 1) w polu w którym jesteśmy wybieramy z listy kierunków pierwszy, jeszcze nie zbadany kierunek przejścia z tego pola, taki że: - w tym kierunku istnieje pole nie oddzielone ścianą od "naszego" - dotychczas jeszcze nie odwiedziliśmy tego pola 2) przechodzimy na to pole 3) jeśli z danego pola nie można już przejść w żadnym kierunku, to wracamy do pola z którego przyszliśmy i kontynuujemy postępowanie
Krok będący powrotem oznaczymy B, a każdy ruch możemy opisać nazwą kroku (kierunku) i nazwą pola np. G-2b, B-3a etc. Kierunek poruszania się po labiryncie określamy w zależności od naszego ustawienia i przyjmujemy, że cały czas poruszamy się "twarzą" do przodu oprócz ruchów B. Metoda z nawrotami zawsze znajduje wyjście, ale jej szybkość nie jest zadowalająca i droga wyjścia nie jest nakrótsza. Metoda ta jest przykładem przeszukiwania w głąb, gdzie w kolejnych krokach przeszukiwanie zagłębia się coraz bardziej, tak daleko jak to możliwe - teoria grafów. Metody powyższe można stosować w sytuacji, gdy znajdujemy się w labiryncie i nie znamy jego schematu, tzn. możemy korzystać tylko z lokalnych informacji, które jesteśmy w stanie zgromadzić, rozglądając się wokół siebie.
Metoda z nawrotami - zapis rekurencyjny: Dane: Labirynt, czyli prostokąt z jednym wyjściem, wypełniony ścianami, które są równoległe do zewnętrznych ścian i nie tworzą zamkniętych obszarów. Dany jest punkt ν wewnątrz labiryntu. Wynik: Droga w labiryncie, która prowadzi z punktu ν do wyjścia. Krok 1. Dla każdego kolejnego kierunku (G,L,P,D) poruszania się z punktu ν, jeśli istnieje w tym kierunku nieodwiedzone pole w i nie jest ono odgrodzone od pola ν ścianą, to przejdź do kroku 2, a w przeciwnym razie zakończ to wywołanie algorytmu. Krok2. Jeśli wyjście z labiryntu jest w jednej ze ścian pola w, to zakończ algorytm. W przeciwnym razie oznacz pole w jako odwiedzone i wywołaj ten algorytm dla tego pola w. W zapisie tym pozornie nie ma ruchu do tyłu B. W praktyce ruch ten jest wykonywany zawsze, gdy w wyniku wywołań rekurencyjnych docieramy do miejsca, w którym nie możemy przejść do nowego pola labiryntu i przechodzimy do drugiego etapu rekurencji - powrotu z kolejnych wywołań. Znajdowanie najkrótszej drogi wyjścia z labiryntu - generowanie pól. Metoda z nawrotami zawsze znajduje drogę wyjścia z labiryntu, ale nie można być zadowolonym z szybkości wykonania zadania - długo trzeba krążyć, aby trafić do wyjścia. Należy pamiętać, że zarówno metoda po omacku, jak metoda z nawrotami może służyć do znajdowania wyjścia z labiryntu którego układ jest nieznany. Metoda generacji pól będzie działać dla labiryntu, którego schemat znamy.
Metoda taka mogłaby polegać na wygenerowaniu wszystkich dróg prowadzących do wyjścia i wybraniu najkrótszej. Dróg wyjścia może być jednak bardzo dużo, choć ich liczba jest skończona, a zatem najkrótsza droga zawsze istnieje. Aby skonstruować algorytm według, którego z danego pola podążamy bezpośrednio do wyjścia, oprzemy się prostej obserwacji - każdy fragment najkrótszej drogi między dowolnymi jej punktami jest również najkrótszą drogą między tymi punktami. Metoda znajdowania najkrótszej drogi z pola s : - generujemy pola odległe od s o jedno pole (pola przyległe) - generujemy pola odległe od s o dwa pola (które oddzielone są od s polem przyległym) - generujemy pola odległe od s o trzy pola etc., aż do osiągnięcia wyjścia {metodę tą nazywamy bliższe najpierw, wynikiem jej działania jest labirynt wypełniony liczbami} - odczytujemy od strony wyjścia pola od ległe od s o L pól, później pole odległe o L-1, następnie L-2 etc., postępujemy tak, aż do osiągnięcia pola s Aby zapisać algorytm musimy podać sposób zapamię-tywania kolejno odwiedzanych i przeglądanych pól. Zakładamy, że na początku algorytmu wszystkie pola są nieodwiedzone. Aby mieć pewność, że pola przechodzimy w kolejności ich odległości od s, umieszczamy je w kolejności osiągania, jedno po drugim w ciągu. W tej samej kolejności opuszczają one ten ciąg, gdy przechodzimy na nowe pola, leżące o jedno pole dalej od s.
Do zapamiętywania pól nadaje się tradycyjna kolejka, którą nazwiemy Q. Algorytm: Krok 0. Przyjąć, że na początku wszystkie pola są nie-odwiedzone. Krok 1. Umieścić w kolejce Q pole s. W polu s umieścić liczbę 0. Krok 2. Dopóki kolejka Q nie jest pusta, wykonywać kroki 3-5. Krok 3. Usuń z kolejki Q jej pierwszy element (pole v). Krok 4. Dla każdego pola sąsiedniego względem v i nie oddzielonego od niego ścianą wykonaj krok 5. Krok 5. Jeśli pole w nie było jeszcze odwiedzone, to umieścić w nim liczbę o jeden większą od liczby w polu v. Jeśli pole w zawiera wyjście, to przejdź do kroku 6, a w przeciwnym razie dołącz pole w na końcu kolejki Q. Krok 6. {Budujemy od końca listę pól tworzących najkrótszą drogę z pola s do pola w na którym zakończył działanie krok 5. } Dopóki w nie jest polem s : za kolejne (od końca ) pole drogi przyjąć w i za nową wartość w przyjąć pole sąsiednie względem w, w którym znajduje się liczba o jeden mniejsza od liczby znajdującej się w obecnym polu w. Algorytm ten jest szczególnym przypadkiem algorytmu Dijkstry wyznaczania najkrótszej drogi w dowolnej sieci połączeń, w której odległości między punktami są nieujemne (np. problem komiwojażera).
Problem komiwojażera Należy wskazać najkrótsza drogę odwiedzenia stolic wszystkich województw wyruszając z Warszawy i wracając do Warszawy