Akademia
Górniczo-Hutnicza w Krakowie
Adrian Horzyk horzyk@agh.edu.pl
PROGRAMISTA vs. INFORMATYK
Czym różni się informatyk od programisty?
• Programista zajmuje się wyszukiwaniem odpowiednich algorytmów
oraz ich zestawianiem i translacją na postać programów komputerowych w konkretnym języku programowania, starając się ich działanie możliwie zoptymalizować na poziomie języka programowania poprzez dobór
odpowiednich metod i ew. również struktur danych.
• Informatyk powinien być programistą, który potrafi na bazie zdobytej wiedzy umieć rozwiązywać różne problemy poprzez tworzenie nowych algorytmów, które mogą być podobne do istniejących lub
wykorzystywać istniejące algorytmy i struktury danych, lecz w nowy innowacyjny sposób.
Ponadto informatyk powinien umieć dostosować i zoptymalizować
istniejące algorytmy do rozwiązywanego zadania poprzez uproszczenie, rozbudowanie lub modyfikację zwykle stosowanych struktur danych oraz uproszczenie lub modyfikacje pewnych części algorytmu
dopasowując go do wymogów rozwiązywanego zadania.
ALGORYTMIKA
Algorytmika – to dział informatyki zajmujący się teorią związaną z tworzeniem i badaniem algorytmów, poprawnością, złożonością oraz algorytmizowaniem procesów i zjawisk zachodzących w otaczającym nas świecie.
Tworzenie algorytmu to proces modelowania pewnego zjawiska lub procesu dla pewnego rodzaju danych wejściowych, które mają zostać przetworzone w celu osiągnięcia pewnego wyniku w skończonej ilości kroków.
Algorytmizacja jest więc procesem budowy algorytmu dla konkretnego zjawiska lub procesu, który chcemy zamodelować i zautomatyzować, celem jego implementacji i wykonania na maszynie obliczeniowej.
Jednym z podstawowych celów algorytmiki jest analiza algorytmów pod względem efektywności, dokładności i poprawności ich działania oraz poszukiwanie algorytmów o większej efektywności i dokładności.
Algorytmika bada również kwestie złożoności czasowej i pamięciowej algorytmów w celu weryfikacji ich możliwości wykonania na dostępnych zasobach i w pożądanym czasie.
Część problemów wymaga rozwiązania w czasie rzeczywistym (real time).
SCHEMAT BLOKOWY
Schemat blokowy jest jednym ze sposobów zapisu algorytmu prezentujący kolejne kroki (instrukcje), jakie trzeba wykonać w celu osiągnięcia postawionego celu.
Schemat blokowy wykorzystuje pewien zbiór figur geometrycznych
reprezentujących pewne kategorie operacji na danych oraz połączenia, które wskazują kierunek ich przetwarzania i możliwie alternatywne przejścia:
• Blok graniczny wskazujący początek, koniec, przerwanie lub wstrzymanie wykonywania algorytmu.
• Blok wejścia-wyjścia związany jest z wprowadzanie danych do przetworzenia oraz wyprowadzaniem wyników.
• Blok operacyjny służy do wykonywania operacji na danych przechowywanych w postaci zmiennych i stałych różnego typu.
• Blok decyzyjny (warunkowy) umożliwia dokonanie wyboru na podstawie wyniku operacji logicznej zapisanej w postaci pewnego warunku.
• Blok podprogramu umożliwia przejście do wydzielonego fragmentu algorytmu i wywołania go dla pewnych parametrów, ew. zwrot obliczonych wartości.
• Blok fragmentu przedstawiający wyodrębniony fragment kodu.
• Blok komentarza pozwala wstawić objaśnienia ułatwiające zrozumienie algorytmu.
• Łącznik wewnętrzny służy do łączenia odrębnych części schematu na tej samej stronie opatrzony etykietą przeniesienia.
• Łącznik zewnętrzny łączy schematy na różnych stronach,
więc powinien zawierać numer strony i etykietę przeniesienia.
TAK NIE
1 1
4.2 4.2
SCHEMAT BLOKOWY
Algorytm możemy opisać słownie, przy pomocy schematu blokowego lub kodu w języku programowania, np. wyznaczający NWD i NWW algorytmem Euklidesa:
START
Wczytaj a, b
r>0
TAK
NIE
Opis słowny (pseudokod)
1. Wczytaj dwie liczby naturalne a i b.
2. Jeśli a > b,
to przyporządkuj w = a oraz m = b,
a w przeciwnym przypadku przyporządkuj
w = b oraz m = a.
3. Następnie dopóki r jest większe od zera wyznacz resztę z
dzielenia w przez m i zapisz ją w zmiennej r oraz dokonaj przyporządkowania w = m, m = r.
4. Wypisz wartość w NWD oraz a*b/w jako NWW
Schemat blokowy Program w Pythonie
print "Podaj dwie liczby naturalne:”
a = input("Pierwsza liczba:") b = input("Druga liczba:") if a > b:
w = a; m = b else:
w = b; m = a while r:
r = w % m w = m m = r
print "NWD liczb %i i %i wynosi %i, a ich NWW
wynosi %i" % (a, b, w, a*b/w) Wypisz w
oraz a*b/w r = w % m
w = m m = r
w = a m = b a > b TAK
NIE
w = b m = a
SCHEMAT BLOKOWY – PRZYKŁADY
SORTOWANIE PRZEZ WYBIERANIE SORTOWANIE PRZEZ WSTAWIANIE
STRATEGIA: DZIEL I ZWYCIĘ ŻAJ
Strategia „dziel i zwyciężaj” (a divide and conquer strategy) polega
na rekurencyjnym dzieleniu zadania na mniejsze i stosowaniu tej strategii do zadań składowych tak długo, dopóki nie uzyska się rozwiązania całego zadania.
Następnie (w zależności od postawionego zadania) rozwiązanie końcowe składa się z rozwiązań cząstkowych uzyskanych dla podzielonych podzadań, jedynie że rozwiązanie cząstkowe jest zarazem rozwiązaniem końcowym.
Dzięki takiej strategii, ilość porównań (lub innych operacji) znacznie maleje (zwykle logarytmicznie).
Strategię tą stosujemy w wielu algorytmach, np.:
• wyszukiwanie połówkowe (binary search),
• wyszukiwanie interpolowane (interpolation search),
• sortowaniu szybkim (quick sort),
• sortowaniu przez scalanie (merge sort)
w celu osiągnięcia wysokiej efektywności ich działania.
WYSZUKIWANIE
Wyszukiwanie – to najczęstsze operacje wykonywane w informatyce!
Jego efektywna implementacja decyduje zwykle o szybkości całej aplikacji.
Nieuporządkowane struktury sekwencyjne musimy przeszukiwać sekwencyjnie element po elemencie, czyli tzw. wyszukiwanie liniowe (sequential search), co w pesymistycznym
przypadku zmusza nas do przeglądnięcia wszystkich N elementów, więc kosztuje N porównań!
PRZYKŁAD: N = 1.000.000.000 →Max ilość porównań: 1.000.000.000
W przypadku przeszukiwania uporządkowanej sekwencyjnej struktury danych, możemy zastosować algorytm wyszukiwania połówkowego (binary search), który wymaga maksymalnie log2 Noperacji porównywania.
PRZYKŁAD: N = 1.000.000.000 →Max ilość porównań: 30
W przypadku równomiernego rozkładu liczb w uporządkowanej sekwencji
możemy zastosować algorytm wyszukiwania interpolowanego (interpolation search), który próbuje „odgadnąć” pozycję (obliczyć indeks) poszukiwanej wartości,
co wiąże się z ilością operacji porównywania równą log2 log2N
PRZYKŁAD: N = 1.000.000.000 →Przewidywana ilość porównań ok. 5
Istnieje jeszcze możliwość wykorzystania tablicy haszującej do bardzo szybkiego wyszukiwania elementów w czasie stałym, o ile jesteśmy w stanie określić funkcję haszującą
dla przeszukiwanej sekwencji danych, od których zwykle również wymaga się pewnego
równomiernego rozkładu. Funkcja haszująca określa miejsce w tablicy wskaźnika krótkiej listy elementów, w której znajduje się poszukiwany element, stąd tak ważny jest odpowiedni
rozkład danych oraz możliwość szybkiego O(1) przejrzenia takiej listy.
Zwykle i tak wykonujemy pewną stałą ilość porównań, więc ich ilość jest zwykle porównywalna z wyszukiwaniem interpolowanym.
WYSZUKIWANIE SEKWENCYJNE
Przeszukiwanie sekwencyjne – stosowane jest do nieuporządkowanych liniowych struktur danych, przeglądając poszczególne elementy jeden po drugim aż do napotkania poszukiwanego elementu:
Pierwszy algorytm
przeszukuje listę od tyłu w celu eliminacji obliczania wartości indeksu w przypadku nieodnalezienia elementu.
Drugi algorytm stosuje
wartownika dodanego
na końcu listy w celu
uproszczenia warunku
sprawdzania zakończenia
pętli, co zwiększa szybkość
jego działania w stosunku
do poprzedniego.
WYSZUKIWANIE POŁÓWKOWE
Przeszukiwanie połówkowe – dzieli przeszukiwaną uporządkowaną strukturę liniową (np. listę, tablicę) na 2 części, wyznaczając indeks środkowego elementu i sprawdzając, czy jest on równy poszukiwanemu.
Jeśli to nie jest, wtedy powtarza tą samą procedurę rekurencyjnie na tej części struktury, która może zawierać poszukiwany element:
Algorytm wykonuje znacznie mniejszą ilość porównań niż te poprzednie!
WYSZUKIWANIE INTERPOLOWANE
Przeszukiwanie interpolowane – umożliwia bardzo szybkie wyszukiwanie elementów w posortowanej liniowej strukturze danych o mniej więcej równomiernym rozkładzie wartości w przeszukiwanym przedziale:
Przy tych założeniach algorytm wykonuje jeszcze mniejszą ilość porównań niż te poprzednie, gdyż próbuje obliczyć (zgadnąć) indeks poszukiwanego elementu na podstawie jego wartości oraz wartości pierwszego i ostatniego elementu w przeszukiwanym przedziale. Jeśli mu się to nie uda, wtedy
zawęża obszar poszukiwać na przedziału, który zawiera poszukiwany element.
TABLICE I FUNKCJE HASHUJĄCE
Wyszukiwanie z wykorzystaniem tablicy haszującej (hash table) umożliwia teoretycznie najszybsze wyszukiwanie (w czasie stałym) pod warunkiem określenia takiej funkcji haszującej, iż możliwe jest dokładne obliczenie w tablicy indeksu początku odpowiedniej krótkiej listy, w której znajduje się poszukiwany element.
Podobnie więc jak w wyszukiwaniu interpolowanym równomierny rozkład wartości danych jest zaletą.
Ponadto stworzenie funkcji haszującej wymaga zwykle znajomości wartości minimalnej i maksymalnej, co kosztuje czas liniowy O(n) i wymaga dodatkowego miejsca w pamięci zależnego liniowo od n, a więc również O(n).
Funkcja haszująca h(x) przekształca klucz w indeks w tablicy haszującej, której wielkość może być mniejsza niż ilość kluczy, lecz wtedy pod tym samym indeksem mieści się kilka kluczy, które zwykle organizowane są w postaci listy (nieposortowanej lub posortowanej przez proste wstawianie).
Funkcja haszująca modularna h(k) uniemożliwia przekroczenie przewidywanego zakresu kluczy:
h(k) = k % m, gdzie m to rozmiar tablicy mieszającej (haszującej).
Tablice haszujące nazywane są też tablicami mieszającymi lub tablicami z haszowaniem.
Niektóretablice haszujące można również wykorzystać do sortowania danych.
Najpierw tworzymy taką tablicę krótkich list, a następnie łączymy je ze sobą tworząc listę wynikową lub przepisujemy kolejno wyniki do tablicy wynikowej, co jednak kosztuje miejsce w pamięci zależne od ilości danych (not in place).
Słownikiw Pythonie są wewnętrznie implementowane jako tablice haszujące.
PRZYKŁAD TABLICY HASZUJ ĄCEJ
Typowa tablica haszująca to tablica krótkich posortowanych list, w których indeks początku listy w tablicy wyznaczamy przy pomocy funkcji haszującej, którą dobiera się do konkretnego rodzaju i rozkładu kluczy w przestrzeni.
Idealnie jest, gdy uda się znaleźć przyporządkowanie zwarte, które N kluczom przydziela unikalne N wartości z zakresu od 0 do N-1.
Listy najprościej i najszybciej O(1) uzupełnia się od przodu w miejscu głowy listy,
ew. od tyłu, dodając nowy obiekt na końcu listy (append), uzyskując nieposortowane listy obiektów, które potem są przeglądane liniowo w celu odnalezienie klucza.
Biorąc pod uwagę to, iż elementów na liście jest niewiele, przyjmuje się,
iż zabiera to stałą ilość czasu.
SORTOWANIE KUBEŁKOWE
Odpowiednio dobrana funkcja haszująca wespół z odpowiednim rozkładem danych
w przestrzeni pozwala wykorzystać funkcję haszującą do szybkiego O(n) sortowania danych:
Do krótkich list można przez proste wstawianie dodawać nowe elementy w kolejności rosnącej lub malejącej uzyskują tablicę posortowanych list. Takie sortowanie jest również wykonywane w czasie stałym przy założeniu niewielkich list.
W przypadku konieczności wyszukania kluczy elementów na poszczególnych podlistach możliwe jest wykorzystanie szybszych algorytmów, np. wyszukiwanie połówkowe.
Ponadto można takie posortowane listy wykorzystać do budowy posortowanej listy
wynikowej wszystkich obiektów, co odbywa się teoretycznie średnio w czasie liniowym
O(n) i nazywa się sortowaniem kubełkowym (bucket sort).
SORTOWANIE SZYBKIE
Sortowanie szybkie (quick sort) – jest jednym z najbardziej efektywnych algorytmów sortowania ogólnego przeznaczenia wykorzystującym strategię
„dziel i zwyciężaj”, lecz niestety nie jest sortowaniem stabilnym, co wyklucza go z pewnych zastosowań, gdzie stabilność jest wymagana!
Sortowanie stabilne to takie, które zachowuje względną kolejność kluczy o tych samych wartościach w ciągu posortowanym.
Przedstawiony algorytm jest algorytmem rekurencyjnym, gdyż wywołuje sam siebie i posiada warunek stopu, tj. dla first >= j oraz i >= last nie dojdzie do ponownego wywołania rekurencyjnego.
To typowa dla Pythona forma skrótowa, ale niezbyt efektywna.
REKURENCJA
Algorytm rekurencyjny to taki, który wywołuje sam siebie i posiada warunek stopu, który określa moment, w którym do dalszych wywołań nie dojdzie.
Warunek stopu umożliwia zatrzymanie kolejnych wywołań i wyjście z procedury rekurencyjnej.
Parametry wywołania funkcji rekurencyjnej mogą się zmieniać.
Należy pamiętać o tym, iż każde wywołanie funkcji pociąga za sobą odłożenia
pewnych danych na stosie systemowym, co dodatkowo wpływa na zmniejszenie efektywności wykonania programu!
Algorytm sortowania szybkiego można przedstawić w formie rekurencyjnej.
Algorytmy rekurencyjne można zawsze
przekształcić na algorytmy iteracyjne.
PRZYKŁAD REKURENCJ I
Skoczek szachowy ma za zadanie obskoczyć wszystkie pola szachownicy NxN dokładnie jeden raz. Można do tego wykorzystać algorytm rekurencyjny, który próbuje wykonać jeden z dostępnych ruchów, a jeśli dana sekwencja skoków nie prowadzi do sukcesu, wtedy się wycofać próbować ponownie inną:
algorytm w pseudokodzie
EFEKTYWNOŚ Ć
Efektywność wykonywania różnych operacji na danych w informatyce jest najczęściej związana z zastosowaniem odpowiednich struktur danych,
na których dodatkowo można wykonywać operacje zdecydowanie szybciej, gdy przechowywane dane w nich są uporządkowane.
Wobec tego duże znaczenie ma stosowanie odpowiednio szybkich
algorytmów sortowania dopasowanych do rodzaju i rozmiaru sortowanych danych.
Algorytmy sortowania różnią się kilkoma właściwościami:
• mogą sortować dane w miejscu lub nie w miejscu
• mogą być stabilne lub niestabilne
• mogą wykonywać sortowanie z różną złożonością obliczeniową
Sortowanie w miejscu nie wymaga wykorzystania dodatkowej pamięci zależnej od ilości sortowanych danych, lecz tylko pewną stałą jej ilość.
Sortowanie stabilne zapewnia, iż względna kolejność sortowanych kluczy
o tych samych wartościach będzie zachowana w ciągu posortowanym,
zaś w przypadku sortowania niestabilnego, takiej gwarancji nie ma.
ZŁOŻONOŚ Ć OBLICZENIOWA
Złożoność obliczeniowa i poprawność semantyczna algorytmów badana jest przez algorytmikę – dział informatyki zajmujący się analizą
algorytmów .
W trakcie analizy algorytmów próbujemy udzielić odpowiedzi na pytania:
• Czy możliwe jest rozwiązanie zadania w dostępnym czasie i pamięci?
• Który ze znanych algorytmów należy zastosować w danych okolicznościach?
• Czy istnieje lepszy, szybszy lub dokładniejszy algorytm od rozważanego?
• Jak udowodnić, że zastosowany algorytm poprawnie rozwiąże zadanie?
• Czy możliwe jest zrównoleglenie algorytmu i wykorzystanie większej ilości rdzeni obliczeniowych?
Złożoność obliczeniowa badana jest w dwóch podstawowych aspektach:
• czasu – złożoność czasowa
• pamięci – złożoność pamięciowa
Celem analizy algorytmów jest opracowanie metod formalnych i analiz
umożliwiających zbudowanie optymalnych, efektywnych i poprawnych
semantycznie algorytmów wykonalnych na dostępnych zasobach.
RODZAJE ZŁOŻONOŚ CI OBLICZENIOWEJ
Złożoność czasowa to ilość czasu potrzebnego na wykonanie zadania, wyrażona jako funkcja ilości danych, mierzona ilością elementarnych kroków, tj. instrukcji warunkowych, przypisania i operacji arytmetycznych.
Złożoność pamięciowa to ilość dodatkowej pamięci potrzebnej do wykonania zadania, wyrażona jako funkcja ilości danych.
Rozróżniamy kilka rodzajów złożoności:
➢ Pesymistyczna – określa ilość zasobów (czasu lub dodatkowej pamięci) potrzebnych do wykonania algorytmu przy założeniu wystąpienia „złośliwych” lub najgorszych danych. Oznaczamy ją O(f(n)) , gdzie f(n) jest funkcją ilości danych n.
➢ Praktyczna – określa dokładną liczbę elementarnych kroków potrzebnych do wykonania algorytmu rozwiązującego określone zadanie.
➢ Oczekiwana– określa ilość zasobów potrzebnych do wykonania algorytmu przy założeniu wystąpienia „typowych” lub oczekiwanych danych.
➢ Optymistyczna Ω(f(n)) – określa ilość zasobów potrzebnych do wykonania algorytmu przy założeniu wystąpienia „najlepszych” danych.
Złożoność wykonywania różnych algorytmów możemy szacować:
• od dołu stwierdzając, iż złożoność obliczeniowa jest nie mniejsza niż pewna klasa funkcji Ω(f(n))
• asymptotycznie, szacując dokładnie złożoność obliczeniową poprzez pewną klasę funkcji Θ(f(n))
• od góry ograniczając złożoność obliczeniową poprzez pewną klasę funkcji O(f(n))
KLASY ZŁOŻONOŚ CI OBLICZENIOWEJ
Klasy złożoności obliczeniowej określają pewne typowe funkcje zależne od ilości danych, które stosowane są wyznaczenia złożoności obliczeniowej danego zadania. Do najbardziej typowych zaliczamy złożoność:
---PROBLEMY ŁATWE--- Stałą Θ(1) – gdy czas wykonania algorytmu jest niezależny od rozmiaru danych
Logarytmiczną Θ(log n) – gdy czas wykonania zależny jest od logarytmu rozmiaru danych Liniową Θ(n) – gdy czas wykonania zależny jest liniowo od rozmiaru danych
Logarytmiczno-liniową Θ(n log n) – gdy czas wykonania algorytmu jest logarytmiczno-linowy Kwadratową Θ(n2) – gdy czas wykonania zależny jest od kwadratu rozmiaru danych
Sześcienną Θ(n3) – gdy czas wykonania zależny jest od sześcianu rozmiaru danych
Wielomianową Θ(nk+ nk-1 + … + n) – gdy czas wykonania zależny jest od wielomianu rozmiaru danych
--- PROBLEMY TRUDNE --- Wykładniczą Θ(2n) – gdy czas wykonania rośnie wykładniczo z rozmiarem danych
Silnia Θ(n!) – gdy czas wykonania wyrażona jest za pomocą silni rozmiaru danych, czyli związana jest z przeszukiwaniem wszystkich (kombinacji, wariacji czy permutacji danych)
PRZYKŁADY KLAS ZŁOŻONOŚ CI OBLICZENIOWEJ
---PROBLEMY ŁATWE--- Stała Θ(1): dowolna elementarna instrukcja przypisania, pojedynczej operacji arytmetycznej lub logicznej, porównania, np. x = 2, x += 1, 2+3, if x<y, z and w
Stała O(1): wyszukiwanie w tablicy haszującej, jeśli możliwe jest określenie funkcji haszującej w taki sposób, aby dostęp do poszczególnych elementów był stały.
Logarytmiczno-logarytmiczna Θ(log log n): algorytm wyszukiwania interpolowanego przy spełnieniu warunków jego zastosowania
Logarytmiczna Θ(log n): algorytm wyszukiwania połówkowego, bisekcji, przechodzenia od korzenia do liścia lub w drugą stronę w drzewie wyważonym zawierającym n węzłów, np. w drzewie BST, kopcu zupełnym, B-drzewach.
Liniowa Θ(n):wyszukiwanie liniowe w n-elementowych ciągach nieposortowanych, sortowanie
przez zliczanie i pozycyjne, wyszukiwanie wzorców metodą Knutha-Morrisa-Pratta w ciągach tekstów.
Logarytmiczno-liniowa Θ(n log n):najefektywniejsze metody sortowania elementów polegające na porównaniach (kopcowe, przez łączenie, szybkie) ogólnego przeznaczenia.
Kwadratowa Θ(n2):proste algorytmy sortowania przez wstawianie, wybieranie, bąbelkowe, szukanie najkrótszej ścieżki w grafie metodą ….
--- PROBLEMY TRUDNE ---
Wykładnicza Θ(2n) – gdy problem dzieli się w każdym kroku na dwa lub więcej składowych operujących na całym zbiorze danych, np. niektóre algorytmy wykorzystujące rekurencję, np. ciąg Fibonacciego.
Silnia Θ(n!) – różne problemy kombinatoryczne polegające na przeszukiwaniu wszystkich kombinacji, wariacji czy permutacji n danych, np. niektóre algorytmy operujące na grafach.
DZIAŁANIA NA KLASACH ZŁOŻONOŚ CI OBLICZ.
Próbując określić klasę złożoności obliczeniowej dla całego algorytmu lub programu analizujemy jego części wyznaczając ich klasy złożoności obliczeniowej, a następnie korzystając z poniższych operacji wyznaczamy ogólną złożoność:
Θ(c) = Θ(1) – dowolna liczba operacji c niezależna od n to klasa stałej złożoności obliczeniowej.
c · Θ(f(n)) = Θ(f(n)) – gdy c jest stałą niezależną od n.
Taki przypadek występuje np. w sytuacji, gdy wykonujemy pętlę obliczeniową pewną stałą ilość razy niezależną od wymiaru danych n, wtedy taka pętla nie zwiększa złożoności
obliczeniowej. Ważne jest ustalenie niezależności c od n!
Θ(f(n)) + Θ(g(n)) = max( Θ(f(n)), Θ(g(n)) ) – a więc sumę złożoności obliczeniowych charakteryzuje ta o większej klasie złożoności obliczeniowej.
To najczęstszy przypadek, gdy obliczamy złożoność na podstawie kolejnych fragmentów kodu następujących po sobie w programie lub algorytmie. O złożoności decyduje fragment kodu o największej złożoności obliczeniowej.
Θ(f(n)) · Θ(g(n)) = Θ( f(n) · g(n)) – w przypadku iloczynu złożoności obliczeniowych klasa będzie określona poprzez klasę iloczynu funkcji określających poszczególne klasy.
Mamy z tym do czynienia np. w przypadku pętli zagnieżdżonych lub podczas wywoływania funkcji / procedur / metod. Jeśli np. funkcja ma złożoność g(n) i jest wywoływana
w programie głównym w pętli o złożoności f(n), wtedy złożoność tego fragmentu kodu, tj. pętli zawierającej wywołanie tej funkcji będzie miało złożoność Θ( f(n) · g(n)).
DLACZEGO ZŁOŻONOŚ Ć J EST WAŻNA?
Problemy złożoności obliczeniowej bezpośrednio przekładają się na możliwość wykonania algorytmu na współczesnych maszynach obliczeniowych opartych na modelu maszyny Turinga, a ponadto istotnie wpływają na czas obliczeń:
gdzie wiek naszego wszechświata szacuje się na 13,7 mld lat.
Rozróżniamy więc dwie podstawowe i jedną dodatkową klasę algorytmów pod względem złożoności obliczeniowej:
Łatwe (P – polynomial) – czyli rozwiązywalne co najwyżej w czasie wielomianowym
Trudne (NP – non-polynomial) – wymagające czasu dłuższego niż wielomianowy, czyli np. wykładniczy lub silni
NP zupełne (NPC – NP complete) – nie udowodniono, iż nie posiadają rozwiązania wielomianowego.
PRZYKŁAD SORTOWANIA OBIEKTÓW
Najpierw definiujemy klasę zawierającą obiekty składające się z pewnej liczby atrybutów oraz ich nazwy, wczytując je z pliku CSV i zapisując w postaci listy list:
Następnie przeprowadzimy pomiar szybkości działania różnych algorytmów
sortowania, porządkujących te obiekty po kolei według wszystkich ich atrybutów.
ZBIÓR OBIEKTÓW DO POSORTOWANIA
ATRYBUTY OBIEKTÓW
OBIEKT
SPOSÓB DZIAŁANIA SORTOWANIA SZYBKIEGO
Sortowanie szybkie (quick sort) w podanym przedziale wyszukuje
niemniejszy klucz z lewej i niewiększy klucz z prawej i dokonuje
ich zamiany miejscami dopóki indeksy i, j się nie miną:
SPOSÓB DZIAŁANIA SORTOWANIA SZYBKIEGO
1
2 3
4 5
6
7
8
9
ANALIZA ZŁOŻONOŚ CI OBLICZENIOWEJ Sortowanie
szybkie
(quicksort):
Ile operacji
elementarnych?
Ile porównań?
Ile przypisań?
Niestabilne,
w miejscu.
PRZYKŁAD ANALIZY ZŁOŻONOŚ CI OBLICZENIOWEJ Sortowanie szybkie n obiektów względem wszystkich k atrybutów:
Próbując wyznaczyć złożoność obliczeniową całego programu,
wyznaczamy maksimum z następujących po sobie operacji na tym samym poziomie, wyznaczając złożoność operacji złożonych poprzez przemnożenie przez złożoność wnętrza pętli.
Θ(k) · Θ(wnętrze pętli) Θ(1)
Θ(1) Θ(1) Θ(n · k) Θ(1) Θ(1) Θ(1)
Θ(1)
Θ(QuickSort) Θ(wnętrze pętli)
Θ(1) · Θ(k) Θ(1)
Θ(1) Θ(1) Θ(1)
MAX
PRZYKŁAD ANALIZY ZŁOŻONOŚ CI OBLICZENIOWEJ
Sortowanie szybkie dla wybranego klucza (atrybutu):
Θ(1)
Θ(1) Θ(1) Θ(1)
Θ(m) · Θ(wnętrze pętli) Θ(1) · Θ(wnętrze pętli)
Θ(1)
Θ(1) · Θ(wnętrze pętli) Θ(1)
Θ(1)
Θ(Swap) Θ(1) Θ(1) Θ(1)
Θ(QuickSort dla średnio m/2 obiektów) Wywołany dla m sortowanych obiektów:
Θ(1)
Θ(QuickSort dla średnio m/2 obiektów)
Tutaj pętle wewnętrzne są sprzężone z pętlą nadrzędną, tzn. nie iterują po innym atrybucie zależnym od m, lecz po tym samym co pętla główna, stąd nie przemnażamy złożoności obliczeniowych Θ(m) · Θ(m)
Ilość sortowanych obiektów m w każdym rekurencyjnym wywołaniu maleje o 2,
więc otrzymujemy: Θ(m) + 2 · Θ(m/2) = Θ(m), a oczekiwana ilość takich rekurencyjnych wywołań jest: Θ(log n) dla n obiektów, co daje złożoność sortowaniaΘ(n log n).
DRZEWO
Drzewo to spójna struktura nieliniowa składająca się z węzłów (wierzchołków) i krawędzi, w której istnieje dokładnie jedna ścieżka pomiędzy parą dowolnych dwóch węzłów.
Rodzic (poprzednik, przodek) to węzeł w drzewie, posiadający przynajmniej jedno dziecko
(następnika, potomka) będący o jedną krawędź bliżej korzenia w stosunku do węzła będącego jego dzieckiem (następnikiem, potomkiem).
Korzeń to węzeł główny w drzewie nie mający rodzica (poprzednika, przodka).
Liście to takie węzły w drzewie, które nie mają dzieci (następników, potomków).
Węzły wewnętrzne to węzły posiadające rodzica i przynajmniej jedno dziecko.
Ścieżka w drzewie to droga wyznaczona przez ciąg krawędzi pomiędzy połączonymi węzłami prowadząca pomiędzy dwoma dowolnymi węzłami.
Istnieje zawsze tylko dokładnie jedna ścieżka
w drzewie pomiędzy dowolnymi dwoma węzłami.
Długością ścieżki nazywamy ilość krawędzi tworzących ścieżkę w drzewie.
Poziom węzła w drzewie wyznaczony jest
równy długości ścieżki łączącej go z korzeniem.
Wysokość drzewa wyznaczona jest przez maksymalny poziom wszystkich jego węzłów.
Drzewo binarne to takie drzewo, którego każdy węzeł (rodzic) ma co najwyżej dwójkę dzieci.
Drzewo regularne to drzewo o określonej maksymalnej ilości dzieci dla każdego węzła.
Drzewo jest zupełne, gdy ma wszystkie poziomy z wyjątkiem ostatniego całkowicie zapełnione, a ostatni jest spójnie zapełniony od strony lewej.
KORZEŃ
LIŚCIE WĘZŁY WEWNĘTRZNE
wysokość
poziom
IMPLEMENTACJA DRZEWA W PYTHONIE
KOPIEC
Kopiec to drzewo binarne, w którego węzłach znajdują się elementy reprezentowanego multizbioru, które spełniają warunek kopca.
Warunek kopca mówi, iż jeśli węzeł y jest następnikiem węzła x, to element w węźle y jest nie większy niż element w węźle x.
Drzewo ma uporządkowanie kopcowe, gdy wszystkie jego elementy zachowują porządek kopcowy, tzn. elementy na ścieżkach w drzewie od korzenia do liścia uporządkowane są w sposób nierosnący.
Kopiec zupełny to zupełne drzewo binarne o uporządkowaniu kopcowym, czyli takie, którego wszystkie poziomy są wypełnione całkowicie za wyjątkiem co najwyżej ostatniego, który jest spójnie wypełniony od strony lewej.
Każde drzewo binarne można w łatwy sposób reprezentować w tablicy, a indeksy określone są przy pomocy
następującej zależności:
SPOSÓB DZIAŁANIA SORTOWANIA STOGOWEGO
Sortowanie stogowe (heap sort) polega na zbudowaniu kopca zupełnego, który jest drzewem binarnym spełniającym warunek kopca, czyli rodzic jest nie mniejszy niż dziecko, zaś zupełność oznacza,
iż wszystkie poziomy drzewa poza
ostatnim są w pełni wypełnione,
a ostatni spójnie od strony lewej:
SPOSÓB DZIAŁANIA SORTOWANIA STOGOWEGO
Sortowanie stogowe (heap sort):
ANALIZA ZŁOŻONOŚ CI OBLICZENIOWEJ
Sortowanie stogowe (heap sort):
Ile operacji elementarnych?
Jak wyznaczyć złożoność czasową?
Które części programu wykonywane są zawsze, które jednorazowo,
a które czasami?
Stabilne, w miejscu.
PRZYKŁAD ANALIZY ZŁOŻONOŚ CI OBLICZENIOWEJ Sortowanie stogowe (heap sort):
Θ(1)
Θ(1) Θ(1) Θ(1) Θ(1)
Θ(1) Θ(1)
Θ(1) Θ(1)
Θ(Swap) Θ(1)
Θ(Heapify)
Wywołany dla n sortowanych obiektów rekurencyjnie Θ(log n) razy.
Θ(1) Θ(1) Θ(n)
Θ(Heapify)
Θ(Heapify) Θ(Swap) Θ(n)
Złożoność stała razy ilość rekurencyjnych wywołań: Θ(log n).
SPOSÓB DZIAŁANIA SORTOWANIA PRZEZ SCALANIE
Sortowanie przez scalanie (merge sort) na koniec listy wynikowej merged
dołącza obiekty o rosnących wartościach kluczy uzyskanych z podzielonych
posortowanych list we wcześniejszych rekurencyjnych etapach sortowania:
ANALIZA ZŁOŻONOŚ CI OBLICZENIOWEJ
Sortowanie przez scalanie (merge sort):
Na ile kolejność wykonywania porównań może zmniejszyć ilość wykonywanych operacji elementarnych?
Stabilne, nie w miejscu.
PRZYKŁAD ANALIZY ZŁOŻONOŚ CI OBLICZENIOWEJ Sortowanie przez scalanie (merge sort)
Θ(1) Θ(1) Θ(1) Θ(2)
Θ(MergeSort m/2 obiektach) Θ(MergeSort m/2 obiektach) Θ(5) · Θ(wnętrze pętli)
Θ(2)
Θ(2)
Θ(2) · Θ(wnętrze pętli)
Θ(2) · Θ(wnętrze pętli) Θ(2)
Θ(2)
Θ(1)
Θ(2) Θ(2) Θ(3)
Sortowanie przez scalanie na koniec listy wynikowej merged dołącza obiekty o rosnących wartościach kluczy uzyskanych
a podzielonych posortowanych list we wcześniejszych rekurencyjnych etapach sortowania.
Funkcja MergeSort wykonywana jest w czasie stałym,
lecz jest rekurencyjnie wywoływana Θ(log n) razy.
ZŁOŻONOŚ Ć PAMIĘ CIOWA
Złożoność pamięciowa określa nam, ile dodatkowych jednostek pamięci potrzeba do wykonania algorytmu (rozwiązania zadania).
Złożoność pamięciową również określamy jako funkcję
względem rozmiaru danych.
Jeśli w algorytmie sortowania przez scalanie korzystamy z dodatkowej listy merged[]
do scalania posortowanych list, której wielkość jest zależna
od ilości sortowanych danych, wtedy złożoność pamięciowa rośnie i mówimy, że dane sortowanie nie odbywa się w miejscu, lecz
nie w miejscu (not in place).
WBUDOWANE SORTOWANIE W PYTHONIE
Sortowanie jest jedną ze standardowych operacji wykonywanych
na danych, więc w Pythonie istnieją funkcje sortujące dane w miejscu:
list.sort() sorted(list)
sorted(dictionary) sorted(tuple)
PRZYKŁADY:
list = [['4'], ['5'], ['1'], ['9'], ['6'], ['3'], ['6'], ['8'], ['3'], ['1'], ['2'], ['5'], ['7']]
list.sort() print(list)
zwróci: [['1'], ['1'], ['2'], ['3'], ['3'], ['4'], ['5'], ['5'], ['6'], ['6'], ['7'], ['8'], ['9']]
print sorted("Ten wykład odkrywa przed nami tajemnice Pythona, algorytmiki oraz programowania".split(), key=str.lower)
zwróci: ['algorytmiki', 'nami', 'odkrywa', 'oraz', 'programowania', 'przed', 'Pythona,', 'tajemnice', 'Ten', 'wyk\xc5\x82ad']
gdzie key=str.lower sprawia, iż małe i duże litery są traktowane jednakowo w trakcie sortowania.