Algorytmy i struktury danych/Wstęp: poprawność i złożoność algorytmu: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Diks (dyskusja | edycje)
Rytter (dyskusja | edycje)
Linia 124: Linia 124:
Przyspieszenie wynika z następującej własności problemu: jeśli mamy dwa różne elementy w tablicy to możemy je usunąć i przywódca pozostanie taki sam.
Przyspieszenie wynika z następującej własności problemu: jeśli mamy dwa różne elementy w tablicy to możemy je usunąć i przywódca pozostanie taki sam.


Algorytm mozna zmodyfikowac tak aby w czasie liniowym liczyl on slabego przywodce: element, ktory wystepuje w tablicy wiecej niz n/3 razy. W tym przypadku potrzebne sa dwa liczniki il1, ile2, odpowiadajace dwom kandydatom na slabego przywodce. Jesli istnieje slaby przywodca i mamy trzy rozne elementy to mozna je usunac bez zmiany wyniku. Pozostawiamy napisanie odpowiedniego algorytmu jako cwiczenie.
Algorytm mozna zmodyfikowac tak aby w czasie liniowym liczyl on slabego przywodce: element, ktory wystepuje w tablicy wiecej niz na przykłas n/5 razy. W tym przypadku potrzebne sa cztery liczniki odpowiadajace czterem kandydatom na slabego przywodce. Jesli istnieje slaby przywodca i mamy pięć roznych elementów to mozna je usunac bez zmiany wyniku. Pozostawiamy napisanie odpowiedniego algorytmu jako cwiczenie.


Problem mozna rozwiazac inaczej sortujac tablice, wtedy mamy zlozonosc O(n log n). Podamy potem rowniez rozwiazanie metoda dziel i zwyciezaj.
Problem mozna rozwiazac inaczej sortując tablicę, wtedy mamy zlozonosc O(n log n). Podamy potem również rozwiązanie metodą "dziel i zwyciężaj".


====Szukanie sumy==== Mamy dane dwie posortowane rosnąco tablice <math>A,B</math> i liczbę x,pytamy  czy są  <math>a \in A,\ b \in B</math> takie, że <math>x=a+b</math>.
====Szukanie sumy==== Mamy dane dwie posortowane rosnąco tablice <math>A,B</math> i liczbę x,pytamy  czy są  <math>a \in A,\ b \in B</math> takie, że <math>x=a+b</math>.

Wersja z 11:58, 1 sie 2006

Wstęp: poprawność i złożoność algorytmów



Podstawowym elementem przy rozwiązywaniu zadanego problemu na komputerze jest dobór algorytmu i struktury danch. Najważnejszymi aspektami algorytmu są jego "poprawność" i złożoność. Będziemy zasadniczo rozpatrywać tylko złożoność czasową i pamięciową.

W przypadku złoźoności czasowej z reguły wyróżnimy pewną operację dominującą i czas będziemy traktować jako liczbę wykonanych operacji dominujących. W ten sposób nasza analiza będzie zależna jedynie od algorytmu a nie od implementacji i sprzętu. W przypadku sortowania przeważnie operacją dominującą jest porównanie dwóch elementów (mniejsze, równe, mniejsze), a w przypadku przeglądania drzewa jedno przejście w drzewie między wierzchołkami. W przypaku algorytmów tekstowych operacją dominującą jest porównanie dwóch symboli. Z reguły będziemy przyjmować, że każda operacja arytmetyczna na małych liczbach daje się zrobić w jednym kroku. Przez małe rozumiemy liczby mające O(logn) bitów. Z reguły określamy pewien parametr n, będący rozmiarem problemu wejściowego i określamy złożoność jako funkcję T(n), której arguementem jest rozmiar problemu.

Przeważnie rozważamy złożoność pesymistyczną - maksymalną złożoność dla danych tego samego rozmiaru n. W praktyce ważniejsą może się okazać złożoność średnią, lub oczekiwaną, w tym przypadku T(n) jest średnią (oczekiwaną) wartością złożoności dla wszystkich problemów rozmiaru n. Tego typu złożoność zależy istotnie od tego, jaka się od tym kryje przetsrzeń probablistyczna problemów. Z reguły zakładamy, że wszystkie problemy wejściowe tego samego rozmiaru mogą się pojawić z tym samym prawodpodobieństwem. Jednakże jest to często mało realistyczne założenie. Przestrzeń probabilistyczna danych wejściowych może być bardzo skomplikowana, prowadzić to może do bardzo trudnych (i wykraczających poza ten kurs) analiz.

Przykład

Przypuśćmy, że chcemy znaleźć pierwszą jedynkę w tablicy zerojedynkowej i nasz algorytm przegląda tablicę od strony lewej sprawdzając kolejne elementy. Niech operacją dominującą będzie sprawdzenie jednego elementu. Jeśli nie ma jedynki, to wykonamy n sprawdzeń, jest to maksymalna liczba, zatem złożoność pesymistyczna wynosi T(n)=n. Jeśli każdy ciąg binarny jest dany z tym samym prawdopodobieństwem to łatwo policzyć, że złożoność średnia jest ograniczona przez stałą.

Do wyrażania złożoności stosujemy opis aymptotycznego wzrostu funkcji: f(n) = O(g(n)) oznacza że f(n)cg(n) dla pewnej stałej n. Gdy g(n)=n to mówimy, że f(n) jest liniowa, oraz dla g(n)=n2 mówimy, że złożoność f(n) jest kwadratowa. Jeśli g(n) jest wielomianem to wtedy mówimy o złożoności wielomianowej.


Będziemy używać dodatkowych notacji:

f(n)=Θ(g(n)), f(n)=Ω(n). Były one wprowadzone na wykładach z matematyki dyskretnej.


Dla przypomnienia:


f(n)=Θ(g(n))f(n)=O(g(n)) & g(n)=O(f(n))

f(n)=Ω(g(n)), gdy dla nieskończenie wielu n i pewnej stałej c>0 zachodzi f(n)cg(n)

Przykład

1100n22n=Θ(n2)n5+2n=Θ(2n),n!=Ω(10n),

Jeśli f(n)=(1+(1)n)n, to f(n)=Ω(n), f(n)=O(n) ale nie zachodzi f(n)=Θ(n).



Konwencje językowe. Jaki jest najlepszy język do opisu algorytmu ? Jest to przykład problemu nierozstrzygalnego. Niewątpliwie język ojczysty jest najlepszym językiem potocznym , a ulubiony język programowania jest najlepszym językiem do implementacji algorytmu. Język, którym będziemy opisywaćalgorytmy jest gdzieś pomiędzy tymi językami, język potoczny nie wystarcza a konkretny język programowania może spowodować to, że "prosty" algorytm się zrobi nieczytelny. Będziemy używać, o ile się da, nieformalnych konstrukcji programistycznych, a w przypadku bardzo prostych algorytm będziemy się starali pisać algorytm w języku C-podobnym.

Poprawność algorytmu: niezmienniki, własność stopu

Przez poprawność algorytmu rozumiemy to, że daje on takie odpowiedzi jakich oczekujemy. Oczywiście algorytm musi być poprawny aby miało sens rozpatrywanie jego złożoność.

Pojęcie niezmiennika

Poprawność algorytmu sprowadza się do spełniania określonych niezmienników na różnych etapach tego algorytmu. Rozważmy kilka przykładów pozwalających zrozumieć znaczenie niezmiennika.

Załóżmy, że mamy zbiór S składający się z n przedmiotów, gdzie n jest nieparzyste, niektóre z przedmiotów są czarne a niektóre białe.

Algorytm


1 while  |S|>1
2    pobierz dwa przedmioty z S;
3    if przedmioty są różnego koloru to wstaw z powrotem czarny
4    else usuń oba przemioty;
5 return kolor ostatniego przedmiotu w S;

Zaóżmy, że mamy 1000000 czarnych przedmiotów i 1000000001 białych, jaki jest ostatni przedmiot ? Rozpatrzmy niezmiennik: parzystość liczby czarnych przedmiotów.

Ponieważ na początku mamy parzystą liczbę czarnych przedmiotów, zatem na wyjściu mamy kolor biały. Rozpatrzmy modyfikację tego algorytmu:

Algorytm


1 while  |S|>1
2    pobierz dwa przedmioty z S;
3    if co najmniej jeden jest czarny to wstaw z powrotem jeden czarny;
4    else usuń oba przedmioty;
5 return kolor ostatniego przedmiotu w S.

Załóżmy, że mamy 10000001 czarnych przedmiotów i 1000000001 białych, jaki jest ostatni przedmiot? Tym razem rozważmy niezmiennik: znak liczby czarnych przedmiotów. (Znak liczby jest równy 0 jeśli jest ona równa zeru, 1 jeśli jest większa od zera.) Zatem ostatnim przedmiotem jest przedmiot czarny.

Własność stopu

Podstawowym elementem poprawności algorytmu jest to, że ma on własność stopu: dla poprawnych danych wejściowych da odpowiedź po skończonym czasie. Na przykładzie dwóch prostych algorytmów pokażemy, że sprawdzanie własności stopu może nie być czynnością trywialną.

Algorytm Suma-Kwadratów-Cyfr


 suma_kwadratow_cyfr
 1  while  ((n <>4)  and  (n<> 1))
 2  n:= suma cyfr liczby n;


Algorytm 6174


6174
wejściem jest czterocyfrowa liczba naturalna niepodzielna przez 1111
pierwszymi cyframi mogą być zera
while (n<>6174)
   n1:=  największa liczba czterocyfrowa której cyfry są permutacją cyfr liczby n;
   n2:= najmniejsza liczba czterocyfrowa której  cyfry są permutacją cyfr liczby n;
   n:=n1 - n2;

Algorytm X


X
while (n<>1)
   if n parzyste n:= n div 2; else n:=3*n+1;
   

Pierwsze dwa algorytmy mają własność stopu, w pierwszym łatwo to sprawdzić gdyż dla n>100 następna wartość jest istotnie mniejsza.

Jak najkrotszy koncepcyjny dowod wlasnosci stopu drugiego algorytmu pozostawiamy jako cwiczenie (nie chodzi nam tu o brutalny dowod polegajacy na sprawdzeniu wszystkich 9990 przypadkow przez komputer).

Algorytm X jest dosyć zagadkowy, nie wiadomo, czy dla dowolnego naturalnego dodatniego n ma on własność stopu.

Złożoność algorytmów: analiza sześciu prostych algorytmów

Na przykładzie sześciu prostych algorytmów pokażemy, jak się analizuje i osiąga złożoność liniową. Po dokładniejsze uzasadnienia i analizy odsyłamy do ćwiczeń. Naiwne wersje tych algorytmów działają w czasie kwadratowym.

Przywódca ciągu

Przywódcą ciągu jest element, który występuje w ciągu więcej razy niż połowa długości tego ciągu. Naszym problemem jest policzenie przywódcu ciągu danego tablicą A[1..n]. Dla uproszczenia przyjmijmy, że w tym ciągu jest przywódca. Łatwo można modyfikować algorytm by sprawdzał istnienie przywódcy.

Algorytm Liczenie Przywódcy


 Liczenie Przywódcy
   ile := 0;
   for i := 1 to n do
      if (ile=0){ile:=ile+1;j:=i};
      else if (A[i]=A[j]) ile := ile+1; else ile := ile-1;
return A[j];

Przyspieszenie wynika z następującej własności problemu: jeśli mamy dwa różne elementy w tablicy to możemy je usunąć i przywódca pozostanie taki sam.

Algorytm mozna zmodyfikowac tak aby w czasie liniowym liczyl on slabego przywodce: element, ktory wystepuje w tablicy wiecej niz na przykłas n/5 razy. W tym przypadku potrzebne sa cztery liczniki odpowiadajace czterem kandydatom na slabego przywodce. Jesli istnieje slaby przywodca i mamy pięć roznych elementów to mozna je usunac bez zmiany wyniku. Pozostawiamy napisanie odpowiedniego algorytmu jako cwiczenie.

Problem mozna rozwiazac inaczej sortując tablicę, wtedy mamy zlozonosc O(n log n). Podamy potem również rozwiązanie metodą "dziel i zwyciężaj".

====Szukanie sumy==== Mamy dane dwie posortowane rosnąco tablice A,B i liczbę x,pytamy czy są aA, bB takie, że x=a+b.

Algorytm Szukanie Sumy


 Szukanie Sumy
   i:=1;j:=n;
   while (i<=nandj>0)
      if (A[i]+B[j]=x)return true; else
      if (A[i]+B[j]<x)i:=i+1;else j:=j1;
   return false;

Przyspieszenie jest możliwe dzięki odpowiedniej kolejności sprawdzania i,j i pominięciu zbędnych sprawdzeń.

==== Maksymalny segment ==== Dla tablicy A[1..n] liczymy maksymalną wartość z zera i ze wszystkich liczb k=ij A[k], gdzie 1ijn.

Algorytm Maksymalny-Segment;
wynik := 0; sufiks := 0; for i := 1 to n do sufiks := max(A[i]+sufiks,0); wynik := max(wynik,sufiks);

Przyspieszenie do algorytmu liniowego następuje dzięki wprowadzeniu dodatkowej zmiennej sufiks. Po każdym zakończeniu pętli "for" zachodzi: wynik jest maksymalną sumą przedziału zawierającego się w [1..i] oraz sufiks jest maksymalną sumą segmentu który jest sufiksem przedziału [1..i].


Najbliższy mniejszy sąsiad

Dla każdego i>1 zdefiniujmy najbliższego mniejszego sąsiada i jako: Lewy[i]=max{j<i:A[j]<A[i]} Dla uproszczenia zakładamy, że A[i]>0 dla i>0 oraz A[0]=0.

Algorytm Najbliższy-Mniejszy-Sąsiad


Najbliższy Mniejszy Sąsiad
for i:=1 to n do
 j:=i1;
   while (A[j]>=A[i])j:=Lewy[j];
      Lewy[i]:=j;

Przyspieszenie następuje dzięki temu, że nie ma potrzeby sprawdzania tablicy dla indeksów istotnie pomiędzy i1 i Lewy[i1] itd. Niech ki będzie liczbą tych j dla których A[j]>=A[i]. Wystarczy pokazać, że suma wszystkich ki jest liniowa. Może się zdarzyć, że niektóre ki mają wartość liniową. Zauważmy jednak, że dany indeks j pojawia się co najwyżej raz w sytuacji gdy A[j]>=A[i], potem będzie "przeskoczony".

Najdłuższy malejący podciąg

Niech A[1],A[2],A[n] będzie ciągiem dodatnich liczb. Następujący algorytm oblicza długość najdłuższgo malejącego podciągu.

Algorytm Najdłuższy-Malejący


Najdłuższy-Malejący
wynik:=1;
for i:=1 to n do
   x:=A[i];A[i]:=0;
   k:=minj:x>A[j]; 
   A[k]:=x;
   wynik:=max(k,wynik);

Proste Pakowanie

Załóżmy, że mamy n pudełek, każde o rozmiarze R, oraz n przedmiotów o rozmiarach Rr[1]r[2]r[n]. Mamy włożyć przedmioty do pudełek, co najwyżej dwa do jednego pudełka. Pozostawimay jako ćwiczenie analizę następującego algorytmu, który oblicza minimalną liczbę pudełek.

Algorytm Proste-Pakowanie


Proste Pakowanie
wynik:=n;
for i:=1 to n do
   if (i<wynikandr[i]+r[wynik]<=R)
      wynik:=wynik1;

Naiwne wersje powyzszych sześciu algorytmów działają w czasie kwadratowym. W każdym z tych algorytmów bardzo proste spostrzeżenia prowadą do algorytmów liniowych.

Koszt zamortyzowany

Jeśli mamy ciąg operacji op1,op2,,opn to koszt zamortyzowany jednej z nich jest sumarycznym kosztem wykonania wsszystkich operacji podzielonym przez liczbę operacji, inaczej mówiąc jest to, dla danego ciągu operacji, średni koszt jednej z nich. Zauważmy, że nie mówmy tu nic o prawdopodobieństwie, model jest deterministyczny. Na przykład w algorytmie Najbliższy-Mniejszy-Sąsiad rozważmy ciąg operacji

opi: while (A[j]>=A[i]) j=Lewy[j]

Koszt pojedyńczej operacji może być liniowy, również sumaryczny koszt ciągu tych operacji op1,op2,,opn jest liniowy. Zatem pesymistyczny koszt jdnej operacji jest tutaj liniowy, natomiast zamortyzowany koszt jednej operacji jest ograniczony przez stałą. W tym przypadku wiemy, że każde sprawdzenie A[j]>=A[i]) z wynikiem negatywnym odbywa się tylko raz dla danej wartości j. Możemy powiedzieć, że księgujemy koszt operacji elementom j o tej własności. Nieformalna metoda księgowania kosztów polega na rozdzielaniu (księgowaniu) kosztu, a następnie szacowania sumarycznej złożoności poprzez sumowanie wszystkich zaksęgowanych kosztów. Operacje pożyczają, w pewnym sensie, fundusze na pokrycie kosztów z różnych źródeł. Metoda ta będzie wykorzystana do analizy algorytmu dla interesującego problemu Find-Union.

Typowym przykładem liczenia kosztu w sposób zamortyzowany jest analiza generacji reprezentacji binarnych kolejnych liczb naturalnych od 0 do 2n1, dodając jedynkę. W jednym kroku zastępujemy najdłuższy ciąg jedynek od końca zerami, następnie wstawiamy jedną jedynkę. Ponieważ w sumie wstawiliśmy 2n1 jedynek w ciągu 2n1 operacji, to zamortyzowana liczba operacji zamiany zera na jedynke wynosi 1. W tym przykładzie możemy powiedzieć, że analizowaliśmy koszt tzw. metodą magazynu. W każdej operacji koszt jest proporcjonalny do liczby przedmiotów włożonych do magazynu lub do liczby przedmiotów wyjętych z magazynu. Magazyn początkowo jest pusty. Wtedy całkowity koszt jest proporcjonalny do liczby przedmiotów włożonych. W przypadku generowania liczb binarnych do magazynu wkładamy nowe jedynki, a wyjmujemy te jedynki, które zamieniamy na zera.

Potencjał - Fundusz Ubezpieczeń Kosztów Algorytmicznych

Metodę magazynu można uogólnić na tzw. metodę potencjału. Niech Φi będzie pewną liczbą naturalną (włączając zero) odpowiadającą potencjałowi po wykonaniu i operacji. Zakładamy, że potencjał jest początkowo zero, nigdy nie jest ujemny, a każda operacja opi ma koszt proporcjonalny do ci+|ΦiΦi1|. Wtedy całkowity koszt jest tego samego rzędu co ci. W naszych poprzednich przykładach rozmiar magazynu jest w tym sensie potencjałem.

Można powiedzieć obrazowo że potencjał jest kapitałem Funduszu Ubezpieczeń Kosztów Algorytmicznych. Koszt zamortyzowany jednej operacji jest składką, którą ta operacja wpłaca do funduszu. Dzięki temu, że wiele operacji pobiera z funduszu znacznie mniej niż wpłaca niektóre operacje mogą jednorazowo pobrać dużą kwotę, kórą płacą za koszt wykonania. Operacje opi ubezpieczają się od kosztów ich wykonani. Poszczególne operacje płacą drobne składki ci, a swój koszt za każdym razem opłacają bioróac pieniądze z Funduszu. Czasmi koszt operacji jest duży ale do tego czasu wpłacono tyle drobnych składek,że możemy ten koszt pokryć. Istotne jest jedynie żeby Fundusz nie zbankrutował i kapitał nie zszedł poniżej zera. Możliwa jest również sytuacja gdy Fundusz startuje z kapitałem początkowym. Wtedy kapitał ten wlicza się do całkowitego kosztu algorytmu, który się dodajemy do sumy składek.

Rozważmy przykłady ilustrujące wykorzystanie potencjału.

Tablica dynamiczna

Przypuśćmy, że mamy dynamiczną tablicę. W każdym momencie wiemy ile elementów w tablicy jest akywnych, elementy niekatywne zaznaczamy. W każdej operacji, jeśli liczba elementów nieaktywnych jest mniejsza od 14 wielkości tablicy to tworzymy tablicę dwa razy mniejszą i tam przepisujemy elementy aktywne, starą tablicę zwalniamy. W przeciwnym wypadku jeśli chcemy dodać element, który spowoduje przepełnienie tablicy to całą tablicę kopiujemy do tablicy dwa razy większej. Początkowo tablica ma rozmiar 1. Zakładamy, że operacją dominującą jest kopiowanie aktywnego elementu do nowej tablicy. Jeśli mamy n operacji to całkowity koszt kopiowania jest liniowy. Wystarczy w każdej operacji dać składkę 4 jednostek do Funduszu (potencjału). Wtedy koszt jednej dużej operacji przepisywania zamortyzuje się zmianą potencjału.


Zastąpienie kolejki dwoma stosami

Jedną kolejkę Q można zastąpić dwoma stosami S1, S2. Jeśli pierwszy element stosu lub kolejki w reprezentacj poziomej jest w ciągu na pierwszej pozycji (tzn. pobieramy e1, stawimay za en), oraz Q=(e1,e2,..,ek) to dla pewnego j mamy:

S1=(en,en1,...,ej), S2=(e1,e2,...,ej1).

Operacja wstawiania do A odpowiada wstawieniu elementu do S1, operacja pobrania z Q odpowiada pobraniu elementu z S2, z tym, że jeśli S2 jest pusty to przepisujemy najpierw wszystkie elementy z S1 do S2. Niech operacją dominującą będzie jedna operacja stosowa (wstawienie lub pobranie pojedyńczego elementu ze stosu). Wtedu ciąg n operacji kolejkowych, starujących od pustej kolejki, ma koszt liniowy w tej imlementacji. Wystarczy, że każda operacja da do Funduszu składkę 3 jednostek. Dowód tego pozostawiamy jako ćwiczenie.

Roważmy podobny problem, z tym że nasza kolejka jest dwustronna, możemy wkładać i pobierać element z każdego z dwóch końców kolejki. Wtedy możemy taką kolejkę zastąpić trzema stosami tak, że teraz również każda operacja kolejkowa będzie mieć zamortyzowany koszt stały. Elementy kolejki trzymamy w dwóch stosach S1, S2 tak jak poprzednio. Niezmiennikem jest to, że oba stosy są niepuste lub mają w sumie co najwyżej jeden element. Zapewniamy zachodzenie niemiennika wykorzystując trzeci stos. W momencie gdy jeden ze stosów ma więcej niż jeden element a drugi jest pusty, korzystając z trzeciego stosu, doprowadzamy do reprezentacji aktualnej kolejki przez stosy S1, S2, tak aby miały one tę samą liczbę elementów (z dokładnością do 1). Pozostawiamy jako ćwiczenie dowód (metodą potencjału) tego, że zamortyzowany koszt jest stały.