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
Tprybick (dyskusja | edycje)
Nie podano opisu zmian
m Zastępowanie tekstu – „,...,” na „,\ldots,”
 
(Nie pokazano 467 wersji utworzonych przez 10 użytkowników)
Linia 1: Linia 1:
Moduł 1.\ Wstęp: poprawność i złożoność algorytmów.} \myskip Podstawowym elementem przy rozwiązywaniu
__TOC__
zadanego problemu na komputerze jest dobór algorytmu i struktury danych. Najważniejszymi 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
Wykład ''Algorytmy i struktury danych'' jest poświęcony przede wszystkim koncepcyjnym i strukturalnym metodom efektywnego rozwiązywania
  będziemy traktować jako liczbę wykonanych operacji dominujących. W ten sposób nasza analiza będzie
problemów na komputerze. Podstawowym elementem przy rozwiązywaniu zadanego problemu jest dobór
  zależna jedynie od algorytmu a nie od implementacji i sprzętu.
algorytmu i struktury danych. Najważniejszymi aspektami algorytmu są jego poprawność i  
  W przypadku sortowania przeważnie operacją dominującą jest porównanie dwóch elementów
złożoność (czasowa i pamięciowa).
  (mniejsze, równe, mniejsze), a w przypadku przeglądania drzewa jedno przejście w drzewie między
  wierzchołkami. W przypadku 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 <math>O(\log n)</math> bitów.
  Z reguły określamy pewien parametr <math>n</math>, będący rozmiarem problemu wejściowego i
określamy złożoność jako funkcję <math>T(n)</math>, której arguementem jest rozmiar problemu.


Przeważnie rozważamy złożoność pesymistyczną -maksymalną złożoność dla danych tego samego rozmiaru
W przypadku złożoności czasowej, z reguły wyróżnimy pewną operację dominującą, a czas będziemy
<math>n</math>.
traktować jako liczbę wykonanych operacji dominujących.  
W praktyce ważniejszą może się okazać złożoność średnią, lub oczekiwaną, w tym przypadku <math>T(n)</math> jest średnią (oczekiwaną) wartością
W ten sposób nasza analiza będzie zależna jedynie od algorytmu, a nie od implementacji i sprzętu. W
złożoności dla wszystkich problemów rozmiaru <math>n</math>. Tego typu złożoność zależy  istotnie od tego, jaka się od tym kryje
przypadku sortowania, operacją dominującą jest przeważnie porównanie dwóch elementów,
przestrzeń probablistyczna problemów. Z reguły zakładamy, że wszystkie problemy wejściowe tego
a w przypadku przeglądania drzewa - jedno przejście w drzewie między  
samego rozmiaru mogą się pojawić z tym samym prawodopodobieństwem. Jednakże jest to często mało
wierzchołkami. W przypadku algorytmów tekstowych operacją dominującą jest porównanie dwóch
realistyczne założenie. Przestrzeń probabilistyczna danych wejściowych może być bardzo skomplikowana,
symboli. Zazwyczaj określamy pewien parametr <math>n</math>, będący rozmiarem problemu wejściowego i
prowadzić to może do bardzo trudnych (i wykraczających poza ten kurs) analiz.
określamy złożoność jako funkcję <math>T(n)</math>, której argumentem jest rozmiar problemu. Z reguły będziemy przyjmować, że każda operacja arytmetyczna na małych liczbach daje się
\myskip '''Przykład.'''\
wykonać w jednym kroku. Przez "małe" rozumiemy liczby mające <math>O(\log n)</math> bitów.  
  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 <math>n</math> sprawdzeń, jest to maksymalna
liczba, zatem złożoność pesymistyczna wynosi <math>T(n)=n</math>. 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łą.
\myskip
  Do wyrażania złożoności stosujemy opis asymptotycznego wzrostu funkcji:


  <math>f(n)\ =\ O(g(n))</math>  oznacza
że <math>f(n) \le c\cdot g(n)</math> dla pewnej stałej <math>n</math>. \\
Gdy <math>g(n)=n</math> to mówimy, że <math>f(n)</math> jest liniowa, oraz dla <math>g(n)=n^2</math> mówimy, że złożoność <math>f(n)</math> jest
kwadratowa. Jeśli <math>g(n)</math> jest wielomianem to wtedy mówimy o złożoności wielomianowej. Będziemy używać
dodatkowych notacji


<math>f(n)=\Theta(g(n)),\ f(n)=\Omega(n)</math>. \myskip Były one wprowadzone na wykładach z matematyki dyskretnej.
Złożoność algorytmu może być rozumiana w sensie złożoności najgorszego przypadku lub złożoności średniej. Złożoność najgorszego przypadku nazywamy złożonością pesymistyczną - jest to maksymalna złożoność dla danych tego samego
  Dla przypomnienia:
rozmiaru <math>n</math>.  
<math><math>f(n)=\Omega(g(n)</math>, gdy  dla nieskończenie wielu <math>n</math> i pewnej stąlej <math>c>0</math> zachodzi\  <math>f(n) \ge c\cdot g(n)</math>.
W praktyce ważniejsza może się okazać złożoność średnia lub oczekiwana. W tym przypadku
<!--%--------------------------------------
<math>T(n)</math> jest średnią (oczekiwaną) wartością złożoności dla wszystkich problemów
-->\myskip '''Przykład'''. \\
rozmiaru <math>n</math>. Tego typu złożoność zależy  istotnie od tego, jaka się pod tym kryje
<math>\frac{1}{100}\cdot n^2- 2n = \Theta(n^2 )</math>.\
przestrzeń probabilistyczna danych wejściowych. Z reguły zakładamy, że wszystkie dane
<math>n^5+2^n = \Theta(2^n), n!=\Omega(10^n)</math>,\\
wejściowe tego samego rozmiaru mogą się pojawić z tym samym prawdopodobieństwem. Jednakże
Jeśli <math>f(n)=(1+(-1)^n)\cdot n</math>, to <math>f(n)=\Omega(n),\ f(n)=O(n)</math> ale nie zachodzi <math>f(n)=\Theta(n)</math>.
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)
-->\paragraph{Konwencje językowe.} Jaki jest najlepszy język do opisu algorytmu ? Jest to przykład problemu
analiz.
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.
<!--%
-->\section{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ść.
<!--%
-->\subsection*{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. \vskip 0.2cm \noindent
Załóżmy, że mamy zbiór <math>S</math> składający się z <math>n</math> przedmiotów, gdzie <math>n</math> jest nieparzyste,
niektóre z przedmiotów są czarne a niektóre białe.


'''Algorytm''' while  <math>|S|>1</math>
Rozważmy następujący przykład. Przypuśćmy, że chcemy znaleźć pierwszą jedynkę w
''n''-elementowej 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
<math>n</math> sprawdzeń. Jest to maksymalna liczba, zatem złożoność pesymistyczna wynosi
<math>T(n)=n</math>. 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łą.


  \hspace*{0.5cm} {pobierz dwa przedmioty z <math>S</math>;
Do wyrażania złożoności stosujemy opis asymptotycznego wzrostu funkcji:
<math>f(n)= O(g(n))</math> oznacza, że <math>f(n) \le c\cdot g(n)</math> dla pewnej stałej
<math>c</math>.
Gdy <math>g(n)=n</math>, to mówimy, że <math>f(n)</math> jest liniowa, oraz dla
<math>g(n)=n^2</math> mówimy, że złożoność <math>f(n)</math> jest
kwadratowa. Jeśli <math>g(n)</math> jest wielomianem, to wtedy mówimy o złożoności
wielomianowej.


\hspace*{0.5cm}  '''if''' przedmioty są różnego koloru to wstaw z powrotem czarny  \hspace*{0.5cm}  '''else''' usuń oba przedmioty;  return kolor ostatniego przedmiotu w <math>S</math>;
\myskip
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 \myskip
Będziemy używać dodatkowych notacji:
Rozpatrzmy modyfikację tego algorytmu:


<math>f(n)=\Theta(g(n)),\ f(n)=\Omega(n)</math>.
Były one wprowadzone na wykładach z matematyki dyskretnej.


'''Algorytm''' while  <math>|S|>1</math>
{{przyklad|||
<math>\frac{1}{100}\cdot n^2- 2n = \Theta(n^2 ), n^5+2^n = \Theta(2^n),
n!=\Omega(10^n)</math>,<br>
}}


\hspace*{0.5cm} {pobierz dwa przedmioty z <math>S</math>;


\hspace*{0.5cm}  '''if''' co najmniej jeden jest czarny to wstaw z powrotem jeden czarny; \hspace*{0.5cm}      '''else''' usuń oba przedmioty;  return kolor ostatniego przedmiotu w <math>S</math>.
\myskip 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.
<!--%
-->\subsection*{Własność stopu}
\noindent 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;    \hspace*{0.5cm} while ($(n <>
4)<math> and </math>(n<> 1))


    \hspace*{0.8cm} n := suma cyfr liczby 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
-->\myskip
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ć, że "prosty" algorytm
się zrobi nieczytelny.  Będziemy używać, o ile się da, nieformalnych konstrukcji programistycznych, a
w przypadkach bardzo prostych będziemy się starali pisać algorytm w języku Pascalopodobnym.


    '''Algorytm''' 6174;      \hspace*{0.5cm} wejściem jest czterocyfrowa liczba naturalna niepodzielna przez 1111
== 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ści.


      \hspace*{0.5cm} pierwszymi cyframi mogą być zera
=== Pojęcie niezmiennika ===
Poprawność algorytmu sprowadza się do spełniania określonych niezmienników na różnych etapach
wykonywania tego algorytmu.
Rozważmy kilka przykładów pozwalających zrozumieć znaczenie niezmiennika.  


Załóżmy, że mamy zbiór <math>S</math> składający się z <math>n</math> przedmiotów. Niektóre z
przedmiotów są czarne, a niektóre białe. Zakładamy, że liczba czarnych przedmiotów jest nieparzysta.


    \hspace*{0.5cm} '''while''' $(n<>
6174)$


    \hspace*{0.9cm} n1 := największa liczba czterocyfrowa której cyfry są permutacją cyfr liczby <math>n</math>;
{{algorytm|Biało-czarne1||
1  '''while'''  <math>|S|> 1</math> '''do''' '''begin'''
2    pobierz dwa przedmioty z S;
3    '''if''' przedmioty są różnego koloru '''then''' wstaw z powrotem czarny
4  '''end;'''
5  '''return''' kolor ostatniego przedmiotu w S;
}}


    \hspace*{0.9cm} n2 := najmniejsza liczba czterocyfrowa której  cyfry są permutacją cyfr liczby <math>n</math>;
Załóżmy, że mamy 10000001 czarnych przedmiotów i  1000000001 białych, jaki jest ostatni
przedmiot? Rozpatrzmy niezmiennik: parzystość liczby czarnych przedmiotów.


    \hspace*{0.9cm} n := n1 - n2;\
Ponieważ na początku mamy nieparzystą liczbę czarnych przedmiotów, zatem wynikiem jest kolor
    \myskip
czarny.


    '''Algorytm''' Tajemniczy;  %  \hspace*{0.5cm} n jest dodatnią liczbą naturalną;
<!--%
-->    \hspace*{0.5cm} while $(n<>
1)$


    \hspace*{1cm}  if n parzyste n := n div 2; else n := 3*n+1;
Rozpatrzmy modyfikację tego algorytmu, zakładamy że <math>n</math> jest niezerowe.
    %
    %----------------------------------
      \myskip Pierwsze dwa algorytmy mają własność stopu, w pierwszym łatwo to
sprawdzić gdyż dla <math>n>100</math> następna wartość jest istotnie mniejsza. Podobnie jest w drugim algorytmie.
Algorytm Tajemniczy jest dosyć zagadkowy, nie wiadomo, czy dla dowolnego naturalnego dodatniego <math>n</math> ma on
własność stopu.


\section{Złożoność algorytmów:\ analiza sześciu prostych algorytmów}
{{algorytm|Biało-czarne2||
Na przykładzie sześciu prostych algorytmów pokażemy, jak się analizuje i osiąga złożoność liniową.
1  '''while'''  <math>|S|> 1</math> '''do''' '''begin'''
Dokładniejsze uzasadnienia i analizy odsyłamy do ćwiczeń.  Naiwne wersje tych algorytmów działają w czasie
2    pobierz dwa przedmioty z S;
kwadratowym.
3    '''if''' co najmniej jeden jest biały '''then''' wstaw z powrotem jeden biały;
<!--%--------------------------------------------------------
  4  '''end;'''
-->\paragraph{Przywódca ciągu.}\ Przywódcą ciągu jest element, który występuje w ciągu więcej
'''return''' kolor ostatniego przedmiotu w S;
razy niż połowa długości tego ciągu. Naszym problemem jest policzenie przywódcy ciągu danego tablicą
}}
<math>A[1..n]</math>. Dla uproszczenia przyjmijmy, że w tym ciągu jest przywódca. łatwo można zmodyfikować algorytm
by sprawdzał istnienie przywódcy. \myskip
    \hspace*{.5cm}
    '''Algorytm''' Liczenie-Przywódcy;
    \begin{verbatim}
    j:=0; ile:=1;
    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];
\end{verbatim}
\myskip
  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. \myskip
<!--%
--><!--%
-->\paragraph'''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>.
<!--%
-->\myskip
        \hspace*{1.5cm} '''Algorytm''' Szukanie-Sumy;
\begin{verbatim}
        i := 1; j := n;
        while (i <= n and j > 0)
            if (A[i]+B[j]=x) return true; else
            if (A[i]+B[j]<x) i:=i+1; else j:=j-1;
        return false;
\end{verbatim}
\myskip Przyspieszenie jest możliwe dzięki odpowiedniej kolejności sprawdzania <math>i,j</math> i pominięciu zbędnych
sprawdzeń.
<!--%
-->\paragraph{Maksymalny segment.}\  Dla tablicy <math>A[1..n]</math> liczymy
maksymalną wartość z zera i ze wszystkich liczb <math>\sum_{k=i}^j\ A[k]</math>, gdzie <math>1\le i\le j\le n</math>.
\myskip
        \hspace*{1.5cm} '''Algorytm''' Maksymalny-Segment;
        \begin{verbatim}
        wynik := 0; sufiks := 0;
        for i := 1 to n do
            sufiks := max(A[i]+sufiks,0);
            wynik := max(wynik,sufiks);
\end{verbatim}
\myskip 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
<math>[1..i]</math> oraz sufiks jest maksymalną sumą segmentu który jest sufiksem przedziału <math>[1..i]</math>.
<!--%
-->\paragraph'''Najbliższy mniejszy sąsiad.'''\
<!--%
-->Dla każdego <math>i > 1</math> zdefiniujmy najbliższego mniejszego są siada <math>i</math> jako: \ {Lewy[i]\ =\ \max \{j<i\ :\
A[j]<A[i]$.}


\noindent Dla uproszczenia zakładamy, że  <math>A[i]> 0</math> dla <math>i>0</math> oraz <math>A[0]=0</math>.  \myskip
Załóżmy, że mamy 10000001 czarnych przedmiotów i 1000000001 białych, jaki jest ostatni
        \hspace*{1.5cm} '''Algorytm''' Najbliższy-Mniejszy-Sąsiad;
przedmiot? Tym razem rozważmy niezmiennik: znak liczby białych przedmiotów.  
        \begin{verbatim}
(Znak liczby jest równy 0, jeśli jest ona równa zeru, 1 - jeśli jest większa od zera.) Zatem ostatnim
        for i := 1 to n do
przedmiotem jest przedmiot biały.
            j := i-1;
            while ( A[j] >= A[i]) j := Lewy[j];
            Lewy[i] := j;
\end{verbatim}
\myskip Przyspieszenie następuje dzięki temu, że nie ma potrzeby sprawdzania tablicy dla indeksów istotnie
pomiędzy <math>i-1</math> i <math>Lewy[i-1]</math> itd. Niech <math>k_i</math> będzie liczbą tych <math>j</math> dla których <math>A[j]>=A[i]</math>. Wystarczy
pokazać, że suma wszystkich <math>k_i</math> jest liniowa. Może się zdarzyć, że niektóre <math>k_i</math> mają wartość
liniową. Zauważmy jednak, że dany indeks <math>j</math> pojawia się co najwyżej raz w sytuacji gdy <math>A[j] >= A[i]</math>,
potem będzie ''przeskoczony''.
<!--%****************
--><!--%
-->\paragraph'''Najdłuższy malejący podciąg.'''\
Niech <math>A[1], A[2],\ldots A[n]</math> będzie ciągiem dodatnich liczb. Następujący algorytm oblicza
długość najdłuższego  malejącego podciągu. \myskip
        \hspace{1.5cm}
        '''Algorytm''' Najdłuższy-Malejący;
        \begin{verbatim}
        wynik := 1;
        for i := 1 to n do
            x := A[i]; A[i]:=0;
            k := min { j : x  > A[j]}; A[k] := x ;
            wynik := max(k, wynik);
\end{verbatim}
<!--%
-->\paragraph{Liczba inwersji.}\ Mamy dane dwie posortowane rosnąco tablice <math>A[1..n],B[1..n]</math>, mamy policzyć liczbę par
<math>i,j</math> takich, że <math>A[i]>B[j]</math>
\myskip
        \hspace*{1.5cm} '''Algorytm''' Liczba-Inwersji;
        \begin{verbatim}
        wynik := 0; j := 0;
        for i := 1 to n do
            while (j<n and A[i]>B[j+1]) j := j+1;
            wynik := wynik + j;
\end{verbatim}
\myskip \noindent Naiwne wersje powyższych sześciu algorytmów  działają w czasie kwadratowym. W każdym z
tych algorytmów bardzo proste spostrzeżenia prowadzą do algorytmów liniowych.
<!--%------------------------------------------------------------------------
-->\section{Koszt zamortyzowany}
Jeśli mamy ciąg operacji <math>op_1,op_2,\ldots, op_n</math> to koszt zamortyzowany jednej z nich jest sumarycznym kosztem
wykonania wszystkich 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.




\noindent Na przykład w algorytmie Najbliższy-Mniejszy-Sąsiad rozważmy ciąg operacji \vskip 0.4cm
 
\centerline{<math>op_i</math>: while <math>( A[j] >= A[i])\ j = Lewy[j]</math>} \vskip 0.1cm \noindent  Koszt pojedynczej operacji
 
może być liniowy, również sumaryczny koszt ciągu tych operacji <math>op_1,op_2,\ldots, op_n</math> jest liniowy. Zatem
=== Własność stopu===
pesymistyczny koszt jednej operacji jest tutaj liniowy, natomiast zamortyzowany koszt jednej operacji jest
Jednym z podstawowych elementów  poprawności algorytmu jest  własność stopu: dla poprawnych
ograniczony przez stałą. W tym przypadku wiemy, że każde sprawdzenie <math>A[j]>=A[i])</math> z wynikiem negatywnym
danych wejściowych algorytm zatrzymuje się w skończonym czasie.<br>
odbywa się tylko raz dla danej wartości <math>j</math>. Możemy powiedzieć, że księgujemy koszt operacji elementom <math>j</math>
Na przykładzie czterech krótkich
o tej własności. Nieformalna metoda księgowania kosztów polega na rozdzielaniu (księgowaniu) kosztu, a
algorytmów pokażemy, że sprawdzanie własności stopu może nie być czynnością trywialną.
następnie szacowania sumarycznej złożoności poprzez sumowanie wszystkich zaksięgowanych kosztów. Operacje
 
pożyczają, w pewnym sensie, fundusze na pokrycie kosztów z różnych źródeł. Metoda ta będzie wykorzystana
{{algorytm|Suma-Kwadratów-Cyfr|suma_kwadratow_cyfr|
do analizy algorytmu dla interesującego problemu Find-Union. \myskip Typowym przykładem liczenia kosztu w
  1  '''while'''  <math>((n <>4)</math> '''and'''  <math>(n<> 1))</math> '''do'''
sposób zamortyzowany jest analiza generacji reprezentacji binarnych kolejnych liczb naturalnych od 0 do <math>2^n-1</math>,
  2    <math>n :=</math> suma kwadratów cyfr liczby <math>n</math>;
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 <math>2^n-1</math> jedynek w ciągu <math>2^n-1</math> operacji, to zamortyzowana
 
liczba operacji zamiany zera na jedynkę wynosi 1. W tym przykładzie możemy powiedzieć, że analizowaliśmy
 
koszt tzw. metoda ''magazynu''. W każdej operacji koszt jest proporcjonalny do liczby przedmiotów włożonych
{{algorytm|6174|algorytm_6174|
do magazynu lub do liczby przedmiotów wyjętych z magazynu. Magazyn początkowo jest pusty. Wtedy całkowity
// <math>n</math> jest czterocyfrową liczbą naturalną niepodzielną przez 1111
koszt jest proporcjonalny do liczby przedmiotów włożonych. W przypadku generowania liczb binarnych do magazynu
// pierwszymi cyframi ''n'' mogą być zera
1  '''while''' (n<>6174) '''do''' <br>
2      <math>n1 :=</math>  największa liczba czterocyfrowa, której cyfry są permutacją cyfr liczby <math>n</math>;
3      <math>n2 :=</math> najmniejsza liczba czterocyfrowa, której  cyfry są permutacją cyfr liczby <math>n</math>;
4      <math>n := n1 - n2</math>
}}
 
W przypadku liczb trzycyfrowych rolę liczby 6174 pełni liczba 495.
W przypadku liczb pięciocyfrowych nie ma takiej pojedyńczej liczby.
<br>
 
 
Rozpatrzmy następujący algorytm (zaprojektowany podobno przez<br>
Fibonacciego) na rozkład ułamka na sumę '''parami różnych''' ułamków Egipskich,<br>
<br>
tzn. ułamków postaci <math>\frac{1}{q_i}</math>.
 
 
Rozkład Egipski jest postaci
<math>\frac{p}{q}= \frac{1}{q_1}+\frac{1}{q_2}+\frac{1}{q_3}+\frac{1}{q_4}+\ldots</math>,
gdzie <math>q_1<q_2<q_3<\ldots</math> są liczbami  naturalnymi:
 
 
Każdy ułamek ma rozkład Egipski,
oto przykład takiego rozkładu:
 
 
<math>
31/311= 1/12 + 1/63 + 1/2799 + 1/8708</math>
 
 
Niech <math>DolnyEgipt(x)</math> oznacza najwięszy ułamek Egipski (tzn. postaci <math>\frac{1}{q}</math>)
nie przekraczający x.
<br>
<br>
Przykłady: <br>
<math>DolnyEgipt(2/3)=1/2,\ DolnyEgipt(4/5)=1/2,\ </math> <math>DolnyEgipt(2/5)=1/3,\ DolnyEgipt(2/7)=1/4</math>
 
 
Niech <math>1\le p<q</math> będą dwiema względnie pierwszymi liczbami naturalnymi.
Następujący algorytm oblicza rozkład (niekoniecznie najkrótszy) nieskracalnego ułamka <math>\frac{p}{q}</math>
na ułamki Egipskie.
 
 
{{algorytm|Rozkład-Egipski<math>(p,q)</math>|RozkładEgipski|
(algorytm Fibonacciego)<br>
1  <math>x:=\frac{p}{q};</math><br>
2  '''while''' (x>0) '''do''' <br>
3      <math>y:=DolnyEgipt(x);</math> '''output''' y;
4      <math>x:=x-y;</math>
}}
 
 
Nasz algorytm dla wejścia (31,311), tzn. <math>x=31/311</math>, wyprodukuje 10 składników w rozkładzie Egipskim.
 
 
Dla wejścia <math>\frac{7}{15}</math> otrzymamy jako wynik następujący ciąg
ułamków Egispskich:
<math>\frac{1}{3},\ \frac{1}{8},\ \frac{1}{120}</math>.
 
 
Algorytm ma własność stopu ponieważ liczniki kolejnych dodatnich wartości <math>x</math> (przedstawionych jako nieskracalne ułamki)  maleją<br>
(pozostawiamy dowód tego faktu jako ćwiczenie). <br>
Oto przykład ciągu kolejnych wartości <math>x</math>: 4/5 -> 3/10 -> 1/10. Liczniki maleją: <math>4>3>1</math>. <br>
W pewnym momencie licznik liczby <math>x</math> dochodzi do jedynki, wtedy następna wartość <math>x</math> staje się zerem i algorytm zatrzymuje się.<br>
Zatem algorytm wykonuje co najwyżej <math>p</math> iteracji dla wejścia <math>p/q</math>.
 
 
Rozpatrzmy jeszcze jeden abstrakcyjny algorytm.
 
 
 
{{algorytm|Bez-Sensu|algorytm_X|
1 '''while''' <math>(n<>1)</math> '''do'''
2    '''if''' <math>n</math> parzyste '''then''' <math>n := n \mbox{ div } 2</math>
3    '''else''' <math>n := 3*n+1</math>; 
}}
Pierwsze trzy algorytmy mają własność stopu. W pierwszym łatwo to sprawdzić, gdyż dla
<math>n>100</math> następna wartość jest istotnie mniejsza.
 
Pozostawiamy jako ćwiczenie znalezienie najkrótszego koncepcyjnie dowodu  własności stopu dwu pierwszych algorytmów
(nie chodzi nam tu o brutalny dowód polegający na sprawdzeniu wszystkich  przypadków przez
komputer).
 
 
 
Natomiast
algorytm ''Bez-Sensu'' jest najbardziej zagadkowy. Nie wiadomo, czy dla '''każdej''' dodatniej liczby całkowitej
<math>n</math> ma on własność stopu.
Problem ten postawił L. Collatz w roku 1937, wiadomo, że własność stopu zachodzi dla każdego <math>1 \le n \le 10^{16}</math>.
Problem stopu dwóch ostatnich algorytmów jest teoretycznym problemem matematycznym o dużym stopniu trudności.
 
===Opis  algorytmu za pomocą niezmienników===
 
 
Niezmienniki są często podstawową konstrukcji algorytmu na poziomie koncepcyjnym. Opisujemy jedynie co dana
część algorytmu ma wykonać w sensie zachowania odpowiedniego niezmiennika. Reszta jest czasami prostą
sprawą natury inżynieryjno-technicznej.
 
<br>
Zademonstrujemy to na następujacym przykładzie posegregowania tablicy
liczbowej <math>A[1..n]</math> względem elementu <math>x</math>.  Dla zbiorów pozycji <math>X,Y</math> tablicy <math>A</math> piszemy <math>X<Y</math> gdy każda pozycja w X jest mniejsza od każdej pozycji w <math>Y</math>. Piszemy <math>A[X]<a</math> gdy wartości na pozycjach X są mniejsze od a (podobnie z równością).
 
Zdefiniujmy następujący niezmiennik <math>\alpha(M,R,W):\ \ </math>  <br>
 
<center>
<math>M<R<W,\ A[M]<x,\ A[R]=x,\ A[W]>x</math></center>
 
<br>
Nazwy M,R,W biorą się od '''M'''niejsze, '''R'''ówne, '''W'''iększe.<br>
 
 
Naszym problemem jest
poprzestawiać elementy tablicy tak aby dla pewnych zbiorów M,R,W dających w sumie cały zbiór pozycji
zachodził niezmiennik <math>\alpha(M,R,W)</math> (algorytmy tego typu maja  zastosowanie w
implmentacji bardzo praktycznego algorytmu ''Quicksort'').
<br>
 
Algorytm wykonuje swoje zadanie  startując od zbiorów pustych i zwiększając zbiory.
Chcemy aby algorytm działał ''w miejscu'' (dodatkowa pamięć stała) i w każdej iteracji wykonywał stałą liczbę operacji.
 
 
{{algorytm|PosegregujTablicę|PosegregujTablicę|
0  <math>M := \emptyset; \ R := \emptyset;\  W:= \emptyset</math>;
1  '''while'''  <math>|M|+|R|+|W|<n</math> '''do'''
2    zwiększ o jeden sumę <math>|M|+|R|+|W|</math> zachowując niezmiennik <math>\alpha(M,R,W)</math> 
3    w czasie i dodatkowej pamięci <math>O(1)</math>
}}
 
 
Możliwe są różne scenariusze tego algorytmu poprzez dospecyfikowanie niezmiennika.
Na przykład możemy
zażądąc aby zbiory M,R,W były sąsiednimi przedziałami, tworzącymi razem sufiks (lub prefiks) tablicy, lub
aby M było prefiksem a W sufiksem tablicy. Otrzymamy różne algorytmy w pewnym sensie izomorficzne.
Naturalnym jest aby zażądać, by
każdy ze zbiorów M, R, W był przedziałem, nawet jeśli tego nie zażądamy to tak będzie po zakończeniu algorytmu.
Jeśli zbiory są przedziałami to pojedyńcza iteracja polega na ''manipulacji'' w okolicy końców przedziałów.
<br>
 
Możemy problem ouogólnić i segregować tablicę względem większej liczby elementów, np. względem
elementów <math>x<y</math>. Teraz zamiast dwóch segmentów M, R, W tablicy A mamy pięć zbiorów
pozycji, a niezmiennik zamienia się na
<br>
 
<center>
<math>Z1<Z2<Z3<Z4<Z5,\ A[Z1]<x,\ A[Z2]=x</math>,</center>
 
<center>
<math>
x<A[Z3]<y,\ A[Z4]=y,\ A[Z5]>y</math></center>
 
<br>
 
== Poprawność i złożoność 10 prostych algorytmów ==
 
Poniżej dokonamy pobieżnej analizy złożoności obliczeniowej siedmiu algorytmów i wykażemy, że ich złożoności są lepsze od złożoności kwadratowej
rozwiązań <br> naiwnych,
chociaż nasze algorytmy  będą niewiele bardziej skomplikowane od naiwnych.
 
Poza dwoma z nich  (działającymi w czasie czas <math>O(n \log n)</math>,)
wszystkie algorytmy działają w czasie liniowym.
 
Dokładne analizy pozostawiamy jako ćwiczenia. 
 
 
==== Algorytm 1. 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ódcy ciągu danego w tablicy <math>A[1..n]</math>. Dla
uproszczenia przyjmijmy, że w tym ciągu jest przywódca. Łatwo zmodyfikować algorytm tak, by
sprawdzał istnienie przywódcy.
 
 
{{algorytm|Liczenie-Przywódcy|algorytm_liczenie_przywodcy|
  1  <math>ile :=  0</math>;<br>
  2  '''for''' i <math>:=</math> 1 '''to''' n '''do'''<br>
  3      '''if''' <math>(ile = 0)</math> '''then'''
  4          <math>ile := ile+1; j := i</math> <br>
  5      '''else if''' <math>(A[i]=A[j])</math> '''then''' <math>ile:=ile+1;</math> <br>
  6      '''else''' <math>ile :=ile-1</math>;<br>
  7  '''return''' <math>A[j]</math>;
}}
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 można zmodyfikować tak, aby w czasie liniowym liczył  słabego przywódcę: element,
który występuje w tablicy więcej niż <math>n/5</math> razy.
W tym przypadku potrzebne są cztery liczniki  odpowiadające czterem kandydatom na słabego
przywódcę.
Algorytm liczy  element, który jest kandydatem na słabego przywódcę (jeśli istnieje taki przywódca, to
na pewno jest nim wyliczony element). Jeśli istnieje słaby przywódca i mamy pięć różnych elementów, to można je usunąć bez zmiany
wyniku. Pozostawiamy napisanie odpowiedniego algorytmu jako ćwiczenie.
 
Problem można rozwiązać inaczej, sortując tablicę, wtedy mamy złożoność <math>O(n log n)</math>. Podamy potem
również rozwiązanie metodą "dziel i zwyciężaj".
 
 
<center>
<flash>file=przywodca_slaby.swf|width=520|height=270</flash></center>
 
W animacji kolorem żółtym na końcu jest zaznaczony licznik słabego przywódcy,  a jego nazwa jest
umieszczona w
niebieskim kwadraciku.
 
==== Algorytm 2. Szukanie sumy====
 
Mamy dane dwie tablice  posortowane rosnąco <math>A,B</math> i liczbę <math>x</math>, pytamy, czy istnieją  <math>a \in A,\ b \in B</math> takie, że <math>x=a+b</math>.
 
 
{{algorytm|Szukanie Sumy|algorytm_szukanie_sumy|
1  <math>i := 1; j := n;</math><br>
2  '''while''' <math>(i <= n</math>  '''and''' <math>j > 0)</math> '''do'''<br>
3      '''if''' <math>(A[i]+B[j]=x)</math> '''then''' '''return''' true;
4      '''else''' '''if''' <math>(A[i]+B[j]<x)</math> '''then''' <math>i:=i+1;</math>
5      '''else''' <math>j:=j-1</math>;<br>
6  '''return''' false;
}}
Przyspieszenie jest możliwe dzięki odpowiedniej kolejności sprawdzania <math>i,j</math> i
pominięciu zbędnych sprawdzeń.
 
==== Algorytm 3. Maksymalny segment ==== 
 
Dla tablicy <math>A[1..n]</math>, liczymy maksymalną wartość z zera i
ze wszystkich liczb <math>\sum_{k=i}^j\ A[k]</math>, gdzie <math>1\le i\le j\le n</math>.
 
 
'''Algorytm''' Maksymalny-Segment;<br>
1  wynik := 0; sufiks := 0;<br>
2  '''for''' i := 1 '''to''' n '''do''' <br>
3        sufiks := max(A[i]+sufiks,0); wynik := max(wynik,sufiks);
 
<br>
Przyspieszenie w stosunku do algorytmu kwadratowego 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 <math>[1..i]</math>
oraz sufiks jest maksymalną sumą segmentu, który jest sufiksem przedziału <math>[1..i]</math>.
 
====Algorytm  4. Najbliższy mniejszy sąsiad w ciągu ====
Dla każdego <math>i > 1</math> zdefiniujmy najbliższego mniejszego (lewego) sąsiada <math>i</math> jako
 
<math>Lewy[i] =max \{j<i : A[j]<A[i] \}</math>.<br>
 
Dla uproszczenia zakładamy, że  <math>A[i]> 0</math> dla <math>i>0</math> oraz
<math>A[0]=0</math>.
 
{{algorytm|Najbliższy-Mniejszy-Sąsiad|algorytm_najblizszy_mniejszy_sasiad|
1  '''for''' <math>i := 1</math> '''to''' n '''do''' <br>
2    <math>j := i-1;</math><br>
3    '''while''' <math>( A[j] >= A[i])</math> '''do''' <math>j := Lewy[j];</math><br>
4    <math>Lewy[i] := j;</math>
}}
 
 
Przyspieszenie w stosunku do algorytmu naiwnego następuje dzięki temu, że nie ma potrzeby sprawdzania tablicy dla indeksów istotnie
wewnątrz przedziału  <math>[Lewy[i-1]...(i-1)]</math>.  Niech <math>k_i</math> będzie liczbą
tych <math>j</math>, dla których <math>A[j]>=A[i]</math> (w wierszu 3). Wystarczy
pokazać, że suma wszystkich <math>k_i</math> jest liniowa ze względu na ''n''. Może się zdarzyć, że niektóre
<math>k_i</math> mają wartość
liniową. Zauważmy jednak, że dany indeks <math>j</math> pojawia się co najwyżej raz w sytuacji,
gdy <math>A[j] >= A[i]</math>,
potem będzie "przeskoczony".
 
 
Opiszemy teraz nieformalnie alternatywny algorytm korzystajacy ze stosu. <br>
Zamiast indeksu lewego sasiada bedziemy teraz liczyc wartosc lewego sasiada, inaczej mowiac<br>
liczymy pierwsza wartosc na lewo mniejsza od danej wartosci.
<br> Niech y oznacza element na wierzcholku stosu.
<br>
 
 
Czytamy kolejne elementy.<br>
Po wczytaniu kolejnego elementu x usuwamy elementy ze stosu dopoki x<y.<br>
W tym momencie znajdujemy lewego sasiada x, jego wartoscia jest y.<br>
Wrzucamy x na stos.
 
 
Zalozmy ze A jest permutacja liczb 1,2,..n. Wtedy mozliwy jest jeszcze inny algorytm liniowy.
<br>
 
Trzymamy elementy tablicy w liscie dwukierunkowej.<br>
 
Dla k=n,n-1,..2 wykonujemy:<br>
lewym sasiadem
k zostaje jego poprzednik na liscie.<br> Nastepnie element k usuwamy z listy.
 
====Algorytm  5. Najdalszy mniejszy sąsiad w permutacji  ====
W tym algorytmie zakładamy że na wejściu jest permutacja A elementów od 1 do n. <br>
Dla każdego <math>i > 1</math> zdefiniujmy najdalszego mniejszego (prawego) sąsiada <math>i</math> jako
<br>
<math>Najd\_Prawy[i] =max \{j>i : A[j]<A[i] \}</math>.<br>
<br>
W trakcie algorytmu obliczamy tablicę Pozycja, będącą odwrotościa permutacji.
<br>
Załóżmy, że początkowo Najd_Prawy[i]=0, Pozycja[i]=0, dla każdego i.
 
{{algorytm|Najdalszy-Prawy-Mniejszy-Sąsiad|algorytm_najdalszy_mniejszy_sasiad|
1    <math>min:=1;</math> <br>
2    '''for''' <math>i := 1</math> '''to''' n '''do''' <br>
3    <math>Pozycja[A[i]]:=i;</math><br>
4    '''while''' <math>min \le n  \ \&\ Pozycja[min]\ne 0</math> '''do'''<br>
5            <math>Najd\_Prawy[Pozycja[min]]:=i;</math> <math>min := min+1;</math> 
}}
 
Algorytm ten dziła oczywiście w czasie liniowym.
Jego wadą jest ograniczenie się do permutacji.
 
====Algorytm  6. Najdłuższy malejący podciąg ====
 
 
Niech <math>A[1], A[2],\ldots A[n]</math> będzie ciągiem <math>n</math> '''dodatnich''' liczb. <br>
Następujący algorytm
oblicza długość najdłuższego  malejącego podciągu (w kolejności ''od lewej do prawej strony'').
 
 
{{algorytm|Najdłuższy-Malejący|algorytm_najdluzszy_malejacy|
1  <math>wynik := 1;</math><br>
2  '''for''' <math>i := 1</math> '''to''' n '''do'''<br> <br>
3    <math>x := A[i]; A[i]:=0;</math> <br>
4    <math>k := min \{ j \le i : x  \ge A[j]\};</math>  <br>
5    <math>A[k] := x</math>; <math>wynik := max(k, wynik);</math>   
}}
 
Poprawność algorytmu nie jest całkowicie oczywista, uzasadnienie można znależć w rozwiązaniu
stosownego zadania (patrz Ćwiczenia). Jeśli wejściem jest [5,2,1,3,7] to w momencie gdy algorytm kończy obliczenia, tablica A jest
równa [7,3,1,0,0]. Zauważmy, że [7,3,1] wcale nie jest podciągiem (w kierunku ''od lewej do prawej strony'') tablicy wejściowej. Niemniej wynik jest poprawny.
 
Złożoność alorytmu istotnie zależy od implementacji instrukcji: <br>
<center> <math>k := min \{ j : x  \ge A[j]\}</math>. </center>
Ponieważ wszystko się odbywa w tablicy, możemy minimum policzyć łatwo w czasie logarytmicznym stosując szukanie binarne w posortowanej tablicy
(segment tablicy <math>A[1..i]</math> jest posortowany).
Zatem całkowity koszt algorytm jest <math>O(n \log n)</math> przy dosyć prostej implementacji.
 
Algorytm może, po niewielkim ''dodatkowym  wysiłku  procesora'', podać najdłuższy malejący
podciąg, albo też rozkład na minimalną liczbę podciągów niemalejących.
Nie jest jasne, jak policzyć leksykograficznie minimalny i leksykograficznie maksymalny podciąg
malejący o długości <math>k</math>, gdzie
<math>k</math> jest wynikiem powyższego algorytmu. Możemy się też zastanowić nad efektywnym
algorytmem znajdowania liczby wszystkich takich ciągów długości <math>k</math>.
 
====Algorytm  7. Dwa-Pakowanie====
 
Załóżmy, że mamy dowolnie dużą liczbę  pudełek, każde o rozmiarze ''R'', oraz ''n'' przedmiotów o rozmiarach <math>
r[1], r[2],\ldots r[n]</math>. Zakładamy, że <center> <math>R \ge 
r[1]\ge r[2]\ldots \ge r[n]</math>.</center>
Mamy włożyć przedmioty do pudełek, co najwyżej dwa do jednego
pudełka.
 
Pozostawiamy jako ćwiczenie analizę następującego algorytmu, który oblicza minimalną
liczbę pudełek do zapełnienia.
 
{{algorytm|2-Pakowanie|algorytm_pakowanie|
1  <math>wynik := n;</math><br><br>
2  '''for''' <math>i := 1</math> '''to''' n '''do'''<br>
3    '''if''' <math>(i < wynik</math>  '''and''' <math>r[i]+r[wynik] <= R)</math> ''' <br>
4            <math>wynik := wynik-1;</math>
}}
 
<center><flash>file=Klocki.swf|width=450|height=250</flash></center>
 
==== Algorytm 8. Równoważność cykliczna ciągów ====
 
Niech <math>u,v</math> będą całkowitoliczbowymi ciągami długości <math>n</math> zadanymi tablicami o zakresie [0..n-1].
Jest to wyjątek, z reguły poprzednio przyjmowaliśmy, że tablica zaczyna się od pozycji jeden.
 
Mówimy, że u, v  sa cyklicznie równoważne gdy <math>v= u[k+1.. n-1]u[0.. k]</math> dla pewnego przesunięcia cyklicznego k.
 
Niech <math>x \mod n</math> oznacza resztę modulo n, np. dla <math>n=9</math> mamy:  <math>88 \mod n= 7</math> .  <br>
 
Naiwny algorytm sprawdzałby równość <math>v= u[k+1.. n-1]u[0.. k]</math> dla każego k, za każdym razem wykonując być może liniową
liczbę operacji (w sumie kwadratową).
 
Następujący algorytm jest niewiele bardziej skomplikowany w porówaniu z naiwnym,
działa w czasie liniowym i daje w wniku (zatrzymując się)  wartość ''true'' wtedy i tylko wtedy gdy <br>
ciągi są cyklicznie równoważne.
 
{{algorytm|Równoważność-Cykliczna|algorytm_równoważność_cykliczna|
1  <math>i:=-1</math>; <math>j:=-1</math>;<br>
2  '''while''' <math>true</math> '''do''' <br>
3    '''if ''' <math>(i>n-1)</math> '''or''' <math>(j>n-1)</math> '''then return''' false; <math>k:=1</math>; <br>
4    '''while''' <math>u[(i+ k) \mod\ n]=v[(j+ k)\mod\ n]</math> '''and''' <math>k\le n</math> '''do''' <math>k:=k+1</math>; <br>
5    '''if''' <math>k>n</math> '''then return''' true; <br>
6    '''if''' <math>u[(i+ k)\mod\ n]>v[(j+ k)\mod\ n]</math> '''then''' <math>i:=i+k</math> '''else''' <math>j:=j+k</math>;
}}
 
Problem poprawności jest tutaj znacznie bardziej skomplikowany niż dosyć oczywista złóżoność liniowa.  Pozostawiamy to jako ćwiczenie.
 
==== Algorytm 9. Liczba inwersji permutacji ====
 
Niech <math>P[0..n-1]</math> będzie permutacją liczb <math>0,\ 1,\ 2\ ..n-1</math> zadaną tablicą o zakresie [0..n-1].<br>
Liczbą inwersji jest liczba par <math>i<j</math>
takich że <math>P[i]>P[j]</math>.
<br>
Na przkład mamy 15 inwersji w permutacji
<br>
<center><math>P[0..6]= [5,\ 4,\ 3,\ 6,\ 0,\ 1,\ 2]</math></center>
 
 
Pomocniczą tablicą będzie <math>Licz[0..n]</math>, załóżmy że początkowo zawiera ona same zera.
<br>
Funkcja logiczna <math>odd()</math> sprawdza czy liczba jest nieparzysta.
Funkcja <math>div</math> jest dzieleniem całkowitoliczbowym,
pomijamy resztę.
<br>
Funkcja <math>\log n</math> zwraca całkowitoliczbową część logarytmu przy podstawie 2, np. <math>\log 1025= 10</math>.
 
Końcową wartością zmiennej <math>wynik</math> jest liczba inwersji permutacji P.
 
{{algorytm|Liczba-Inwersji (algorytm Ryttera)|algorytm_liczenie_inwersji|
1  <math>wynik:=0</math>; <br>
2  '''repeat''' <math>\log n</math> '''times''' <br>
3      '''for''' <math>i:=0</math> '''to''' <math>n-1</math> '''do'''<br>
4            '''if''' <math>odd(P[i])</math> '''then''' <math>Licz[P[i]]:=Licz[P[i]]+1</math><br>
5              '''else''' <math>wynik:=wynik+Licz[P[i]+1]</math>;<br>
6      '''for''' <math>i=0</math> '''to''' <math>n-1</math> '''do'''<br>
7            <math>Licz[i]:=0; P[i]:=P[i]\ div\ 2</math>
}}
 
 
 
Algorytm naiwny liczyłby wynik sprawdzając wszystkie pary <math>i<j</math>, a więc w czasie kwadratowym.
 
Nasz algorytm liczy to w czasie <math>O(n \log n)</math>. Złożoność jest oczywista, poprawność jest mniej oczywista.
 
Oznaczmy  <math>\delta(x)=x\ div\ 2,\ \  \delta^0(x)=x,\ \ \delta^{k+1}(x)=\delta(\delta^{k}(x))</math>.
<br>
Poprawność algorytmu wynika z nastepujacego faktu:
<br>
dla każdych liczb naturalnych <math>x<y</math> istnieje '''dokladnie jedna''' liczba naturalna k
taka że
<br>
<center>  <math>\delta^k(x)+1=\delta^k(y)\ \ \text{oraz}\ \  odd(\delta^k(y))</math>.</center>
 
 
 
Funkcję dzielenia można łatwo wyeliminowac i pozostawić jedynie operacje arytmetyczne dodawania i odejmowania.
 
 
Przy okazji algorytmów sortowania można skonstruować kilka innych algorytmów dla tego samego probemu.
<br> Możemy terza liczyć inwersję dla każdego ciągu elementów, niekoniecznie będącego permutacją.
<br>
Liczba zamian elementów w ''insertion-sort'' jest równa liczbie inwersji,<br>
podobnie algorytm
''merge-sort'' możemy rozbudować tak aby liczył liczbę inwersji w czasie rzędu n log n.<br>
Algorytmy inserion-sor, merge-sort poznamy w następnych modułach.<br> Ograniczymy się tutaj jedynie do
algorytmu licznia inwersji między dwiema posortowanymi rosnąco tablicami <math>A[1..n],B[1..n]</math>.<br>
 
W poniższym algorytmie zakładamy że tablice A, B są posortowane rosnąco.<br>
Wynikiem jest liczba par <math>(i,j)</math> taich,że <math>A[i]>B[j]</math>.
 
{{algorytm|Inwersje-Między-AB|algorytm_między|
1  <math>wynik:=0; i:=1; j:=1;</math>; <br>
2  '''while''' <math>i <= n</math> '''do''' <br>
3      '''if''' <math>j<=n</math> ''and'' <math>A[i]>B[j]</math> '''then''' <math>j:=j+1;</math>
5      '''else'''
6              <math>wynik:=wynik+j-1; i:=i+1;</math>
}}
 
==== Algorytm 10. Generacja permutacji ====
 
Niech <math>P[1..n]</math> będzie permutacją liczb 1,2,..n.
Oznaczmy przez <math>P_0=[1,2,3\ldots n]</math> permutację identycznościową.<br>
Operacja <math>rotacja(k,P)</math> polega na
rotacji cyklicznej pierwszych k elementów tablicy P: <math>k</math>-ty element wędruje na początek. Na przykład:
<br>
<center>
<math>rotacja(4,[a_1,a_2,a_3,a_4,a_5,a_6])= [a_4,a_1,a_2,a_3,a_5,a_6]</math>
</center>
 
 
Następujący algorytm generuje wszystkie permutacje,
każdą dokladnie raz. <br>
Operacja <math>wypisz</math> wypisuje w kolejnym wierszu tablicę P.
 
{{algorytm|Generacja-Permutacji|Generacja|
1  <math>P := P_0; \ j:=n;\ wypisz(P);</math><br><br>
2  '''while''' <math>j>1</math> '''do'''<br>
3    <math>rotacja(j,P);</math><br>
4    '''if''' <math>j\ne P[j]</math> 
5            <math>wypisz(P); j:=n;</math>
6    '''else'''
7            <math>j:=j-1</math>
}}
 
 
Algorytm ten ma następujacą ciekawą własność: jesli zdefiniujemy rotację jako
operację odwrotna do tej którą mamy (pierwszy element wędruje <br> za <math>k</math>-ty element) to
algorytm w dalszym ciągu jest poprawny: wypisuje dokładnie raz każdą permutację.
 
<br>
Mozna rozwazyc jeszcze prostsza operacje zmiany jednej permutacji w druga: wymiana elementu na pozycji 1-szej
z elementem na pozyci k-tej.<br> Pokazac ze mozna wygenerowac wszystkie permutacje stosujac za kazdym razem pojedyncza
operacje takiej zamiany.<br>
Jednakze ten algorytm ma musi miec zupelnie inna strukture.<br>
Na przyklad dla <math>n=4</math> ciag pozycji k moze byc nastepujacy: 2 3 2 3 2 4 3 2 3 2 3 4 2 3 2 3 2 4 3 2 3 2 3 .
 
<br>
Rozważmy podobny algorytm do algorytmu Generacja-Permutacji generowania podzbiorów k-elemntowych (kombinacji)<br> zbioru n-elementowego,
każda konfiguracja jest ciągiem K zerojedynkowym mającym dokładnie k jedynek.<br>
Inaczej mowiąc chcemy wygenerowąc każdy taki ciąg dokładnie raz.
K jest jednocześnie traktowane jako ciąg i jako tablica n-elementowa.
<br><br>
Oznaczmy przez
SzukajWzorzec(01,K)
długośc najkrótszego prefiksu
ciągu K kończącego się na 01. Na przykład SzukajWzorzec(01,111100001001) = 9.
<br>
 
{{algorytm|Generacja-kombinacji|Generacja|
1  <math>K := 1^k0^{n-k}; \ j:=n-1;</math> <br>
2  Załóżmy że <math>0<k<n</math><br><br>
3  '''while''' <math>j<n</math> '''do'''<br>
4    <math>wypisz(K);\ rotacja(j+1,K);</math><br>
5    <math>j:=  \text{SzukajWzorzec}(01,K)</math> 
}}
 
=== Koszt zamortyzowany ===
Jeśli mamy ciąg operacji <math>op_1,op_2,\ldots, op_n</math>, to koszt zamortyzowany jednej z
nich jest sumarycznym kosztem
wykonania wszystkich 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ówimy tu nic o prawdopodobieństwie - model jest deterministyczny. Na przykład w algorytmie
Najbliższy-Mniejszy-Sąsiad rozważmy ciąg operacji
 
<math>op_i</math>: while <math>( A[j] >= A[i])\ j = Lewy[j]</math>
 
Koszt pojedynczej operacji może być liniowy, również sumaryczny koszt ciągu tych operacji
<math>op_1,op_2,\ldots, op_n</math> jest liniowy. Zatem
pesymistyczny koszt jednej operacji jest tutaj liniowy, natomiast zamortyzowany koszt jednej operacji
jest  ograniczony przez stałą. W tym przypadku
wiemy, że każde sprawdzenie <math>A[j]>=A[i])</math> z wynikiem negatywnym
odbywa się tylko raz dla danej wartości <math>j</math>. Możemy powiedzieć, że księgujemy koszt  
operacji elementom <math>j</math>
o tej własności. Nieformalna metoda księgowania kosztów polega na rozdzielaniu (księgowaniu)  
kosztu, a
następnie szacowaniu sumarycznej złożoności poprzez sumowanie wszystkich zaksię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 <math>2^n-1</math> przez
dodawanie jedynki. 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 <math>2^n-1</math> jedynek w ciągu <math>2^n-
1</math> operacji, to zamortyzowana
liczba operacji zamiany zera na jedynkę wynosi 1.  
 
''Zasada magazynu.''
W ostatnim  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.
wkładamy nowe jedynki, a wyjmujemy te jedynki, które zamieniamy na zera.
\subsection*{Potencjał - Fundusz Ubezpieczeń Kosztów Algorytmicznych}


Metodę magazynu można uogólnić na tzw. metodę potencjału. Niech <math>\Phi_i</math> będzie pewną liczbą naturalną
=== Potencjał - Fundusz Ubezpieczeń Kosztów Algorytmicznych===
(włączając zero) odpowiadającą potencjałowi po wykonaniu <math>i</math> operacji. Zakładamy, że potencjał jest
 
początkowo zero, nigdy nie jest ujemny, a każda operacja <math>op_i</math> ma koszt proporcjonalny do
Metodę magazynu można uogólnić na tzw. metodę potencjału. Niech <math>\Phi_i</math> będzie  
<math>c_i+|\Phi_i-\Phi_{i-1}|</math>. Wtedy całkowity koszt jest tego samego rzędu co <math> \sum c_i</math>. W naszych poprzednich
pewną liczbą naturalną (włączając zero) odpowiadającą potencjałowi  
przykładach rozmiar magazynu jest w tym sensie potencjałem.
po wykonaniu <math>i</math>-tej operacji. 
Wyglądałoby to mniej tajemniczo, gdybyśmy zamiast <math>\Phi(i)</math> pisali <math>Bilans(i)</math>.
 
Niech <math>koszt(i)</math>
będzie rzeczywistym kosztem wykonania ''i''-tej operacji.
 
Zakładamy, że potencjał jest początkowo zero, nigdy nie jest  
ujemny oraz że:
 
<center> <math>\Phi_i=\Phi_{i-1}+wplata(i)-wyplata(i)</math> oraz <math>koszt(i)\le wyplata(i)</math>.</center>
 
Wtedy całkowity koszt jest tego samego  
rzędu co <math>\sum wplata(i)</math>.  
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. Jeśli wszystkie wpłaty są takie same, to koszt zamortyzowany jednej operacji jest wpłatą (składką),  którą ta operacja wpłaca do funduszu.
 
Operacja najpierw wpłaca swoją składkę, a następnie pobiera z funduszu tyle, żeby proporcjonalnie (być może z dokładnością do stałego współczynnika) zapłacić za swój koszt wykonania.
 
Dzięki temu, że wiele operacji pobiera z funduszu znacznie mniej niż wpłaca, niektóre operacje mogą
jednorazowo pobrać dużą kwotę, którą płacą za koszt wykonania. 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ę dodaje do sumy składek.
 
 
Rozważmy  przykłady ilustrujące wykorzystanie potencjału. Najistotniejsze jest określenie składek.
 
==== Tablica dynamiczna====
Przypuśćmy, że mamy dynamiczną tablicę. W każdym momencie wiemy, ile elementów w tablicy jest
aktywnych, elementy nieaktywne zaznaczamy. W
każdej operacji, jeśli liczba elementów nieaktywnych jest mniejsza od <math>\frac{1}{4}</math>
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 <math>n</math> 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 <math>S1,\ S2</math>. Jeśli pierwszy element stosu
lub kolejki w
reprezentacji poziomej jest w ciągu na pierwszej pozycji (tzn. pobieramy <math>e_1</math>,
stawiamy za <math>e_n</math>),
oraz <math>Q = (e_1,e_2,..,e_k)</math>, to dla pewnego <math>j</math> mamy:


<math>S1 = (e_n,e_{n-1},\ldots,e_j),\ S2 = (e_{1},e_{2}, ...,e_{j-1})</math>.


Można powiedzieć obrazowo że potencjał jest kapitałem Funduszu Ubezpieczeń Kosztów Algorytmicznych (FUKA,
Inaczej mówiąc, pierwszy element kolejki jest na wierzchołku drugiego stosu, a  
w skrócie). Koszt zamortyzowany jednej operacji jest składką, którą ta operacja wpłaca do funduszu. Dzięki
ostatni element kolejki jest na wierzchołku pierwszego stosu.
temu, że wiele operacji pobiera z funduszu znacznie mniej niż wpłaca niektóre operacje mogą jednorazowo
pobrać dużą kwotę, którą płacą za koszt wykonania. Operacje <math>op_i</math> ubezpieczają się od kosztów ich
wykonani. Poszczególne operacje płacą drobne składki <math>c_i</math>, a swój koszt za każdym razem opłacają
biorąc pieniądze z FUKA. Czasami 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 FUKA nie zbankrutował i kapitał nie zszedł poniżej
zera. Możliwa jest również sytuacja gdy FUKA startuje z kapitałem początkowym. Wtedy kapitał ten wlicza
się do całkowitego kosztu algorytmu, który się dodajemy do sumy składek.
<!--%------------------------------
--> \myskip Rozważmy  przykłady
ilustrujące wykorzystanie potencjału.
<!--%-------------------------------------------------------
-->\paragraph'''Tablica dynamiczna.'''\ Przypuśćmy, że mamy dynamiczną
tablicę. W każdym momencie wiemy ile elementów w tablicy jest aktywnych, elementy nieaktywne zaznaczamy. W
każdej operacji, jeśli liczba elementów nieaktywnych jest mniejsza od 1/4 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 <math>n</math> operacji to całkowity koszt kopiowania jest liniowy.
Wystarczy w każdej operacji dać składkę 4 jednostek do funduszu ubezpieczeń (potencjału).  Wtedy koszt
jednej dużej operacji przepisywania  zamortyzuje się zmianą potencjału.
<!--%-------------------------------------------------------
-->\paragraph'''Zastąpienie kolejki dwoma stosami.'''\
Jedną kolejkę Q można zastąpić dwoma stosami <math>S1,\ S2</math>. Jeśli pierwszy element stosu lub kolejki w
reprezentacji poziomej jest w ciągu na pierwszej pozycji, oraz <math>Q = (e_1,e_2,..,e_k)</math> to dla pewnego <math>j</math> mamy:


<math>S1 = (e_j,e_{j-1},...,e_1),\ S2 = (e_{j+1},e_{j+2}, ...,e_k)</math>.
Operacja wstawiania do ''Q'' odpowiada wstawieniu elementu do <math>S1</math>, operacja pobrania
z ''Q'' odpowiada pobraniu elementu z S2
z tym, że jeśli <math>S2</math> jest pusty, to przepisujemy najpierw wszystkie elementy z S1 do S2.
Niech operacją dominującą
będzie jedna operacja stosowa (wstawienie lub pobranie pojedynczego elementu ze stosu). Wtedy ciąg
<math>n</math> operacji
kolejkowych, startujących od pustej kolejki, ma koszt liniowy w tej implementacji. Wystarczy, że
każda operacja wkłada do Funduszu
składkę 3 jednostek. Dowód tego pozostawiamy jako ćwiczenie.


\noindent Operacja wstawiania do A odpowiada wstawieniu elementu do <math>S1</math>, operacja pobrania z Q odpowiada
==== Zastąpienie kolejki dwustronnej  trzema stosami====
pobraniu elementu z S2, z tym, że jeśli <math>S2</math> jest pusty to przepisujemy najpierw wszystkie elementy z S1 do S2.
Rozważmy podobny problem - z tym, że nasza kolejka jest dwustronna, możemy wkładać i pobierać
Niech operacją dominującą będzie jedna operacja stosowa (wstawienie lub pobranie pojedynczego elementu ze
element z każdego z dwóch końców kolejki.
stosu). Wtedy ciąg <math>n</math> operacji kolejkowych, sterujących od pustej kolejki, ma koszt liniowy w tej
Wtedy możemy taką kolejkę zastąpić trzema stosami tak, że teraz również każda operacja kolejkowa
implementacji. Wystarczy, że każda operacja da do FUKA składkę 3 jednostek. Dowód tego pozostawiamy jako
będzie mieć zamortyzowany koszt stały.
ćwiczenie.
Elementy kolejki trzymamy w dwóch stosach S1, S2 tak, jak poprzednio. Niezmiennikiem jest to, że
oba stosy są niepuste, lub mają w sumie co
najwyżej jeden element. Zapewniamy zachodzenie niezmiennika 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 i 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.

Aktualna wersja na dzień 21:56, 15 wrz 2023


Wykład Algorytmy i struktury danych jest poświęcony przede wszystkim koncepcyjnym i strukturalnym metodom efektywnego rozwiązywania problemów na komputerze. Podstawowym elementem przy rozwiązywaniu zadanego problemu jest dobór algorytmu i struktury danych. Najważniejszymi aspektami algorytmu są jego poprawność i złożoność (czasowa i pamięciowa).

W przypadku złożoności czasowej, z reguły wyróżnimy pewną operację dominującą, a 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, operacją dominującą jest przeważnie porównanie dwóch elementów, a w przypadku przeglądania drzewa - jedno przejście w drzewie między wierzchołkami. W przypadku algorytmów tekstowych operacją dominującą jest porównanie dwóch symboli. Zazwyczaj określamy pewien parametr n, będący rozmiarem problemu wejściowego i określamy złożoność jako funkcję T(n), której argumentem jest rozmiar problemu. Z reguły będziemy przyjmować, że każda operacja arytmetyczna na małych liczbach daje się wykonać w jednym kroku. Przez "małe" rozumiemy liczby mające O(logn) bitów.


Złożoność algorytmu może być rozumiana w sensie złożoności najgorszego przypadku lub złożoności średniej. Złożoność najgorszego przypadku nazywamy złożonością pesymistyczną - jest to maksymalna złożoność dla danych tego samego rozmiaru n. W praktyce ważniejsza może się okazać złożoność średnia lub oczekiwana. 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ę pod tym kryje przestrzeń probabilistyczna danych wejściowych. Z reguły zakładamy, że wszystkie dane wejściowe tego samego rozmiaru mogą się pojawić z tym samym prawdopodobień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.

Rozważmy następujący przykład. Przypuśćmy, że chcemy znaleźć pierwszą jedynkę w n-elementowej 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 asymptotycznego wzrostu funkcji: f(n)=O(g(n)) oznacza, że f(n)cg(n) dla pewnej stałej c. 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.

Przykład

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



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ć, że "prosty" algorytm się zrobi nieczytelny. Będziemy używać, o ile się da, nieformalnych konstrukcji programistycznych, a w przypadkach bardzo prostych będziemy się starali pisać algorytm w języku Pascalopodobnym.

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ści.

Pojęcie niezmiennika

Poprawność algorytmu sprowadza się do spełniania określonych niezmienników na różnych etapach wykonywania 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. Niektóre z przedmiotów są czarne, a niektóre białe. Zakładamy, że liczba czarnych przedmiotów jest nieparzysta.


Algorytm Biało-czarne1


1  while  |S|>1 do begin
2    pobierz dwa przedmioty z S;
3    if przedmioty są różnego koloru then wstaw z powrotem czarny
4  end;
5  return kolor ostatniego przedmiotu w S;

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

Ponieważ na początku mamy nieparzystą liczbę czarnych przedmiotów, zatem wynikiem jest kolor czarny.


Rozpatrzmy modyfikację tego algorytmu, zakładamy że n jest niezerowe.

Algorytm Biało-czarne2


1  while  |S|>1 do begin
2    pobierz dwa przedmioty z S;
3    if co najmniej jeden jest biały then wstaw z powrotem jeden biały;
4  end;
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 białych 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 biały.



Własność stopu

Jednym z podstawowych elementów poprawności algorytmu jest własność stopu: dla poprawnych danych wejściowych algorytm zatrzymuje się w skończonym czasie.
Na przykładzie czterech krótkich algorytmów pokażemy, że sprawdzanie własności stopu może nie być czynnością trywialną.

Algorytm Suma-Kwadratów-Cyfr


 1  while  ((n<>4)  and  (n<>1)) do
 2    n:= suma kwadratów cyfr liczby n;


Algorytm 6174


// n jest czterocyfrową liczbą naturalną niepodzielną przez 1111
// pierwszymi cyframi n mogą być zera
1  while (n<>6174) do 
2 n1:= największa liczba czterocyfrowa, której cyfry są permutacją cyfr liczby n; 3 n2:= najmniejsza liczba czterocyfrowa, której cyfry są permutacją cyfr liczby n; 4 n:=n1n2

W przypadku liczb trzycyfrowych rolę liczby 6174 pełni liczba 495. W przypadku liczb pięciocyfrowych nie ma takiej pojedyńczej liczby.


Rozpatrzmy następujący algorytm (zaprojektowany podobno przez
Fibonacciego) na rozkład ułamka na sumę parami różnych ułamków Egipskich,

tzn. ułamków postaci 1qi.


Rozkład Egipski jest postaci pq=1q1+1q2+1q3+1q4+, gdzie q1<q2<q3< są liczbami naturalnymi:


Każdy ułamek ma rozkład Egipski, oto przykład takiego rozkładu:


31/311=1/12+1/63+1/2799+1/8708


Niech DolnyEgipt(x) oznacza najwięszy ułamek Egipski (tzn. postaci 1q) nie przekraczający x.

Przykłady:
DolnyEgipt(2/3)=1/2, DolnyEgipt(4/5)=1/2,  DolnyEgipt(2/5)=1/3, DolnyEgipt(2/7)=1/4


Niech 1p<q będą dwiema względnie pierwszymi liczbami naturalnymi. Następujący algorytm oblicza rozkład (niekoniecznie najkrótszy) nieskracalnego ułamka pq na ułamki Egipskie.


Algorytm Rozkład-Egipski(p,q)


(algorytm Fibonacciego)
1 x:=pq;
2 while (x>0) do
3 y:=DolnyEgipt(x); output y; 4 x:=xy;


Nasz algorytm dla wejścia (31,311), tzn. x=31/311, wyprodukuje 10 składników w rozkładzie Egipskim.


Dla wejścia 715 otrzymamy jako wynik następujący ciąg ułamków Egispskich: 13, 18, 1120.


Algorytm ma własność stopu ponieważ liczniki kolejnych dodatnich wartości x (przedstawionych jako nieskracalne ułamki) maleją
(pozostawiamy dowód tego faktu jako ćwiczenie).
Oto przykład ciągu kolejnych wartości x: 4/5 -> 3/10 -> 1/10. Liczniki maleją: 4>3>1.
W pewnym momencie licznik liczby x dochodzi do jedynki, wtedy następna wartość x staje się zerem i algorytm zatrzymuje się.
Zatem algorytm wykonuje co najwyżej p iteracji dla wejścia p/q.


Rozpatrzmy jeszcze jeden abstrakcyjny algorytm.


Algorytm Bez-Sensu


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

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

Pozostawiamy jako ćwiczenie znalezienie najkrótszego koncepcyjnie dowodu własności stopu dwu pierwszych algorytmów (nie chodzi nam tu o brutalny dowód polegający na sprawdzeniu wszystkich przypadków przez komputer).


Natomiast algorytm Bez-Sensu jest najbardziej zagadkowy. Nie wiadomo, czy dla każdej dodatniej liczby całkowitej n ma on własność stopu. Problem ten postawił L. Collatz w roku 1937, wiadomo, że własność stopu zachodzi dla każdego 1n1016. Problem stopu dwóch ostatnich algorytmów jest teoretycznym problemem matematycznym o dużym stopniu trudności.

Opis algorytmu za pomocą niezmienników

Niezmienniki są często podstawową konstrukcji algorytmu na poziomie koncepcyjnym. Opisujemy jedynie co dana część algorytmu ma wykonać w sensie zachowania odpowiedniego niezmiennika. Reszta jest czasami prostą sprawą natury inżynieryjno-technicznej.


Zademonstrujemy to na następujacym przykładzie posegregowania tablicy liczbowej A[1..n] względem elementu x. Dla zbiorów pozycji X,Y tablicy A piszemy X<Y gdy każda pozycja w X jest mniejsza od każdej pozycji w Y. Piszemy A[X]<a gdy wartości na pozycjach X są mniejsze od a (podobnie z równością).

Zdefiniujmy następujący niezmiennik α(M,R,W):  

M<R<W, A[M]<x, A[R]=x, A[W]>x


Nazwy M,R,W biorą się od Mniejsze, Równe, Większe.


Naszym problemem jest poprzestawiać elementy tablicy tak aby dla pewnych zbiorów M,R,W dających w sumie cały zbiór pozycji zachodził niezmiennik α(M,R,W) (algorytmy tego typu maja zastosowanie w implmentacji bardzo praktycznego algorytmu Quicksort).

Algorytm wykonuje swoje zadanie startując od zbiorów pustych i zwiększając zbiory. Chcemy aby algorytm działał w miejscu (dodatkowa pamięć stała) i w każdej iteracji wykonywał stałą liczbę operacji.


Algorytm PosegregujTablicę


0  M:=; R:=; W:=;
1  while  |M|+|R|+|W|<n do 
2    zwiększ o jeden sumę |M|+|R|+|W| zachowując niezmiennik α(M,R,W)   
3    w czasie i dodatkowej pamięci O(1)


Możliwe są różne scenariusze tego algorytmu poprzez dospecyfikowanie niezmiennika. Na przykład możemy zażądąc aby zbiory M,R,W były sąsiednimi przedziałami, tworzącymi razem sufiks (lub prefiks) tablicy, lub aby M było prefiksem a W sufiksem tablicy. Otrzymamy różne algorytmy w pewnym sensie izomorficzne. Naturalnym jest aby zażądać, by każdy ze zbiorów M, R, W był przedziałem, nawet jeśli tego nie zażądamy to tak będzie po zakończeniu algorytmu. Jeśli zbiory są przedziałami to pojedyńcza iteracja polega na manipulacji w okolicy końców przedziałów.

Możemy problem ouogólnić i segregować tablicę względem większej liczby elementów, np. względem elementów x<y. Teraz zamiast dwóch segmentów M, R, W tablicy A mamy pięć zbiorów pozycji, a niezmiennik zamienia się na

Z1<Z2<Z3<Z4<Z5, A[Z1]<x, A[Z2]=x,
x<A[Z3]<y, A[Z4]=y, A[Z5]>y


Poprawność i złożoność 10 prostych algorytmów

Poniżej dokonamy pobieżnej analizy złożoności obliczeniowej siedmiu algorytmów i wykażemy, że ich złożoności są lepsze od złożoności kwadratowej rozwiązań
naiwnych, chociaż nasze algorytmy będą niewiele bardziej skomplikowane od naiwnych.

Poza dwoma z nich (działającymi w czasie czas O(nlogn),) wszystkie algorytmy działają w czasie liniowym.

Dokładne analizy pozostawiamy jako ćwiczenia.


Algorytm 1. 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ódcy ciągu danego w tablicy A[1..n]. Dla uproszczenia przyjmijmy, że w tym ciągu jest przywódca. Łatwo zmodyfikować algorytm tak, by sprawdzał istnienie przywódcy.


Algorytm Liczenie-Przywódcy


 1  ile:=0;
2 for i := 1 to n do
3 if (ile=0) then 4 ile:=ile+1;j:=i
5 else if (A[i]=A[j]) then ile:=ile+1;
6 else ile:=ile1;
7 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 można zmodyfikować tak, aby w czasie liniowym liczył słabego przywódcę: element, który występuje w tablicy więcej niż n/5 razy. W tym przypadku potrzebne są cztery liczniki odpowiadające czterem kandydatom na słabego przywódcę. Algorytm liczy element, który jest kandydatem na słabego przywódcę (jeśli istnieje taki przywódca, to na pewno jest nim wyliczony element). Jeśli istnieje słaby przywódca i mamy pięć różnych elementów, to można je usunąć bez zmiany wyniku. Pozostawiamy napisanie odpowiedniego algorytmu jako ćwiczenie.

Problem można rozwiązać inaczej, sortując tablicę, wtedy mamy złożoność O(nlogn). Podamy potem również rozwiązanie metodą "dziel i zwyciężaj".


<flash>file=przywodca_slaby.swf|width=520|height=270</flash>

W animacji kolorem żółtym na końcu jest zaznaczony licznik słabego przywódcy, a jego nazwa jest umieszczona w niebieskim kwadraciku.

Algorytm 2. Szukanie sumy

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


Algorytm Szukanie Sumy


1   i:=1;j:=n;
2 while (i<=n and j>0) do
3 if (A[i]+B[j]=x) then return true; 4 else if (A[i]+B[j]<x) then i:=i+1; 5 else j:=j1;
6 return false;

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

Algorytm 3. 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;
1 wynik := 0; sufiks := 0;
2 for i := 1 to n do
3 sufiks := max(A[i]+sufiks,0); wynik := max(wynik,sufiks);


Przyspieszenie w stosunku do algorytmu kwadratowego 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].

Algorytm 4. Najbliższy mniejszy sąsiad w ciągu

Dla każdego i>1 zdefiniujmy najbliższego mniejszego (lewego) 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


1  for i:=1 to n do 
2 j:=i1;
3 while (A[j]>=A[i]) do j:=Lewy[j];
4 Lewy[i]:=j;


Przyspieszenie w stosunku do algorytmu naiwnego następuje dzięki temu, że nie ma potrzeby sprawdzania tablicy dla indeksów istotnie wewnątrz przedziału [Lewy[i1]...(i1)]. Niech ki będzie liczbą tych j, dla których A[j]>=A[i] (w wierszu 3). Wystarczy pokazać, że suma wszystkich ki jest liniowa ze względu na n. 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".


Opiszemy teraz nieformalnie alternatywny algorytm korzystajacy ze stosu.
Zamiast indeksu lewego sasiada bedziemy teraz liczyc wartosc lewego sasiada, inaczej mowiac
liczymy pierwsza wartosc na lewo mniejsza od danej wartosci.
Niech y oznacza element na wierzcholku stosu.


Czytamy kolejne elementy.
Po wczytaniu kolejnego elementu x usuwamy elementy ze stosu dopoki x<y.
W tym momencie znajdujemy lewego sasiada x, jego wartoscia jest y.
Wrzucamy x na stos.


Zalozmy ze A jest permutacja liczb 1,2,..n. Wtedy mozliwy jest jeszcze inny algorytm liniowy.

Trzymamy elementy tablicy w liscie dwukierunkowej.

Dla k=n,n-1,..2 wykonujemy:
lewym sasiadem k zostaje jego poprzednik na liscie.
Nastepnie element k usuwamy z listy.

Algorytm 5. Najdalszy mniejszy sąsiad w permutacji

W tym algorytmie zakładamy że na wejściu jest permutacja A elementów od 1 do n.
Dla każdego i>1 zdefiniujmy najdalszego mniejszego (prawego) sąsiada i jako
Najd_Prawy[i]=max{j>i:A[j]<A[i]}.

W trakcie algorytmu obliczamy tablicę Pozycja, będącą odwrotościa permutacji.
Załóżmy, że początkowo Najd_Prawy[i]=0, Pozycja[i]=0, dla każdego i.

Algorytm Najdalszy-Prawy-Mniejszy-Sąsiad


1    min:=1; 
2 for i:=1 to n do
3 Pozycja[A[i]]:=i;
4 while minn & Pozycja[min]0 do
5 Najd_Prawy[Pozycja[min]]:=i; min:=min+1;

Algorytm ten dziła oczywiście w czasie liniowym. Jego wadą jest ograniczenie się do permutacji.

Algorytm 6. Najdłuższy malejący podciąg

Niech A[1],A[2],A[n] będzie ciągiem n dodatnich liczb.
Następujący algorytm oblicza długość najdłuższego malejącego podciągu (w kolejności od lewej do prawej strony).


Algorytm Najdłuższy-Malejący


1  wynik:=1;
2 for i:=1 to n do

3 x:=A[i];A[i]:=0;
4 k:=min{ji:xA[j]};
5 A[k]:=x; wynik:=max(k,wynik);

Poprawność algorytmu nie jest całkowicie oczywista, uzasadnienie można znależć w rozwiązaniu stosownego zadania (patrz Ćwiczenia). Jeśli wejściem jest [5,2,1,3,7] to w momencie gdy algorytm kończy obliczenia, tablica A jest równa [7,3,1,0,0]. Zauważmy, że [7,3,1] wcale nie jest podciągiem (w kierunku od lewej do prawej strony) tablicy wejściowej. Niemniej wynik jest poprawny.

Złożoność alorytmu istotnie zależy od implementacji instrukcji:

k:=min{j:xA[j]}.

Ponieważ wszystko się odbywa w tablicy, możemy minimum policzyć łatwo w czasie logarytmicznym stosując szukanie binarne w posortowanej tablicy (segment tablicy A[1..i] jest posortowany). Zatem całkowity koszt algorytm jest O(nlogn) przy dosyć prostej implementacji.

Algorytm może, po niewielkim dodatkowym wysiłku procesora, podać najdłuższy malejący podciąg, albo też rozkład na minimalną liczbę podciągów niemalejących. Nie jest jasne, jak policzyć leksykograficznie minimalny i leksykograficznie maksymalny podciąg malejący o długości k, gdzie k jest wynikiem powyższego algorytmu. Możemy się też zastanowić nad efektywnym algorytmem znajdowania liczby wszystkich takich ciągów długości k.

Algorytm 7. Dwa-Pakowanie

Załóżmy, że mamy dowolnie dużą liczbę pudełek, każde o rozmiarze R, oraz n przedmiotów o rozmiarach

r[1],r[2],r[n]

. Zakładamy, że

Rr[1]r[2]r[n].

Mamy włożyć przedmioty do pudełek, co najwyżej dwa do jednego pudełka.

Pozostawiamy jako ćwiczenie analizę następującego algorytmu, który oblicza minimalną liczbę pudełek do zapełnienia.


Algorytm 2-Pakowanie


1  wynik:=n;

2 for i:=1 to n do
3 if (i<wynik and r[i]+r[wynik]<=R)
4 wynik:=wynik1;
<flash>file=Klocki.swf|width=450|height=250</flash>

Algorytm 8. Równoważność cykliczna ciągów

Niech u,v będą całkowitoliczbowymi ciągami długości n zadanymi tablicami o zakresie [0..n-1]. Jest to wyjątek, z reguły poprzednio przyjmowaliśmy, że tablica zaczyna się od pozycji jeden.

Mówimy, że u, v sa cyklicznie równoważne gdy v=u[k+1..n1]u[0..k] dla pewnego przesunięcia cyklicznego k.

Niech xmodn oznacza resztę modulo n, np. dla n=9 mamy: 88modn=7 .

Naiwny algorytm sprawdzałby równość v=u[k+1..n1]u[0..k] dla każego k, za każdym razem wykonując być może liniową liczbę operacji (w sumie kwadratową).

Następujący algorytm jest niewiele bardziej skomplikowany w porówaniu z naiwnym, działa w czasie liniowym i daje w wniku (zatrzymując się) wartość true wtedy i tylko wtedy gdy
ciągi są cyklicznie równoważne.

Algorytm Równoważność-Cykliczna


1  i:=1; j:=1;
2 while true do
3 if (i>n1) or (j>n1) then return false; k:=1;
4 while u[(i+k)mod n]=v[(j+k)mod n] and kn do k:=k+1;
5 if k>n then return true;
6 if u[(i+k)mod n]>v[(j+k)mod n] then i:=i+k else j:=j+k;

Problem poprawności jest tutaj znacznie bardziej skomplikowany niż dosyć oczywista złóżoność liniowa. Pozostawiamy to jako ćwiczenie.

Algorytm 9. Liczba inwersji permutacji

Niech P[0..n1] będzie permutacją liczb 0, 1, 2 ..n1 zadaną tablicą o zakresie [0..n-1].
Liczbą inwersji jest liczba par i<j takich że P[i]>P[j].
Na przkład mamy 15 inwersji w permutacji

P[0..6]=[5, 4, 3, 6, 0, 1, 2]


Pomocniczą tablicą będzie Licz[0..n], załóżmy że początkowo zawiera ona same zera.
Funkcja logiczna odd() sprawdza czy liczba jest nieparzysta. Funkcja div jest dzieleniem całkowitoliczbowym, pomijamy resztę.
Funkcja logn zwraca całkowitoliczbową część logarytmu przy podstawie 2, np. log1025=10.

Końcową wartością zmiennej wynik jest liczba inwersji permutacji P.

Algorytm Liczba-Inwersji (algorytm Ryttera)


1  wynik:=0; 
2 repeat logn times
3 for i:=0 to n1 do
4 if odd(P[i]) then Licz[P[i]]:=Licz[P[i]]+1
5 else wynik:=wynik+Licz[P[i]+1];
6 for i=0 to n1 do
7 Licz[i]:=0;P[i]:=P[i] div 2


Algorytm naiwny liczyłby wynik sprawdzając wszystkie pary i<j, a więc w czasie kwadratowym.

Nasz algorytm liczy to w czasie O(nlogn). Złożoność jest oczywista, poprawność jest mniej oczywista.

Oznaczmy δ(x)=x div 2,  δ0(x)=x,  δk+1(x)=δ(δk(x)).
Poprawność algorytmu wynika z nastepujacego faktu:
dla każdych liczb naturalnych x<y istnieje dokladnie jedna liczba naturalna k taka że

δk(x)+1=δk(y)  oraz  odd(δk(y)).


Funkcję dzielenia można łatwo wyeliminowac i pozostawić jedynie operacje arytmetyczne dodawania i odejmowania.


Przy okazji algorytmów sortowania można skonstruować kilka innych algorytmów dla tego samego probemu.
Możemy terza liczyć inwersję dla każdego ciągu elementów, niekoniecznie będącego permutacją.
Liczba zamian elementów w insertion-sort jest równa liczbie inwersji,
podobnie algorytm merge-sort możemy rozbudować tak aby liczył liczbę inwersji w czasie rzędu n log n.
Algorytmy inserion-sor, merge-sort poznamy w następnych modułach.
Ograniczymy się tutaj jedynie do algorytmu licznia inwersji między dwiema posortowanymi rosnąco tablicami A[1..n],B[1..n].

W poniższym algorytmie zakładamy że tablice A, B są posortowane rosnąco.
Wynikiem jest liczba par (i,j) taich,że A[i]>B[j].

Algorytm Inwersje-Między-AB


1  wynik:=0;i:=1;j:=1;; 
2 while i<=n do
3 if j<=n and A[i]>B[j] then j:=j+1; 5 else 6 wynik:=wynik+j1;i:=i+1;

Algorytm 10. Generacja permutacji

Niech P[1..n] będzie permutacją liczb 1,2,..n. Oznaczmy przez P0=[1,2,3n] permutację identycznościową.
Operacja rotacja(k,P) polega na rotacji cyklicznej pierwszych k elementów tablicy P: k-ty element wędruje na początek. Na przykład:

rotacja(4,[a1,a2,a3,a4,a5,a6])=[a4,a1,a2,a3,a5,a6]


Następujący algorytm generuje wszystkie permutacje, każdą dokladnie raz.
Operacja wypisz wypisuje w kolejnym wierszu tablicę P.


Algorytm Generacja-Permutacji


1  P:=P0; j:=n; wypisz(P);

2 while j>1 do
3 rotacja(j,P);
4 if jP[j] 5 wypisz(P);j:=n; 6 else 7 j:=j1


Algorytm ten ma następujacą ciekawą własność: jesli zdefiniujemy rotację jako operację odwrotna do tej którą mamy (pierwszy element wędruje
za k-ty element) to algorytm w dalszym ciągu jest poprawny: wypisuje dokładnie raz każdą permutację.


Mozna rozwazyc jeszcze prostsza operacje zmiany jednej permutacji w druga: wymiana elementu na pozycji 1-szej z elementem na pozyci k-tej.
Pokazac ze mozna wygenerowac wszystkie permutacje stosujac za kazdym razem pojedyncza operacje takiej zamiany.
Jednakze ten algorytm ma musi miec zupelnie inna strukture.
Na przyklad dla n=4 ciag pozycji k moze byc nastepujacy: 2 3 2 3 2 4 3 2 3 2 3 4 2 3 2 3 2 4 3 2 3 2 3 .


Rozważmy podobny algorytm do algorytmu Generacja-Permutacji generowania podzbiorów k-elemntowych (kombinacji)
zbioru n-elementowego, każda konfiguracja jest ciągiem K zerojedynkowym mającym dokładnie k jedynek.
Inaczej mowiąc chcemy wygenerowąc każdy taki ciąg dokładnie raz. K jest jednocześnie traktowane jako ciąg i jako tablica n-elementowa.

Oznaczmy przez SzukajWzorzec(01,K) długośc najkrótszego prefiksu ciągu K kończącego się na 01. Na przykład SzukajWzorzec(01,111100001001) = 9.

Algorytm Generacja-kombinacji


1  K:=1k0nk; j:=n1; 
2 Załóżmy że 0<k<n

3 while j<n do
4 wypisz(K); rotacja(j+1,K);
5 j:=SzukajWzorzec(01,K)

Koszt zamortyzowany

Jeśli mamy ciąg operacji op1,op2,,opn, to koszt zamortyzowany jednej z nich jest sumarycznym kosztem wykonania wszystkich 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ówimy 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 pojedynczej operacji może być liniowy, również sumaryczny koszt ciągu tych operacji op1,op2,,opn jest liniowy. Zatem pesymistyczny koszt jednej 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 szacowaniu sumarycznej złożoności poprzez sumowanie wszystkich zaksię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 przez dodawanie jedynki. 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 jedynkę wynosi 1.

Zasada magazynu. W ostatnim 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-tej operacji. Wyglądałoby to mniej tajemniczo, gdybyśmy zamiast Φ(i) pisali Bilans(i).

Niech koszt(i) będzie rzeczywistym kosztem wykonania i-tej operacji.

Zakładamy, że potencjał jest początkowo zero, nigdy nie jest ujemny oraz że:

Φi=Φi1+wplata(i)wyplata(i) oraz koszt(i)wyplata(i).

Wtedy całkowity koszt jest tego samego rzędu co wplata(i). 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. Jeśli wszystkie wpłaty są takie same, to koszt zamortyzowany jednej operacji jest wpłatą (składką), którą ta operacja wpłaca do funduszu.

Operacja najpierw wpłaca swoją składkę, a następnie pobiera z funduszu tyle, żeby proporcjonalnie (być może z dokładnością do stałego współczynnika) zapłacić za swój koszt wykonania.

Dzięki temu, że wiele operacji pobiera z funduszu znacznie mniej niż wpłaca, niektóre operacje mogą jednorazowo pobrać dużą kwotę, którą płacą za koszt wykonania. 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ę dodaje do sumy składek.


Rozważmy przykłady ilustrujące wykorzystanie potencjału. Najistotniejsze jest określenie składek.

Tablica dynamiczna

Przypuśćmy, że mamy dynamiczną tablicę. W każdym momencie wiemy, ile elementów w tablicy jest aktywnych, elementy nieaktywne 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 reprezentacji poziomej jest w ciągu na pierwszej pozycji (tzn. pobieramy e1, stawiamy za en), oraz Q=(e1,e2,..,ek), to dla pewnego j mamy:

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

Inaczej mówiąc, pierwszy element kolejki jest na wierzchołku drugiego stosu, a ostatni element kolejki jest na wierzchołku pierwszego stosu.

Operacja wstawiania do Q 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 pojedynczego elementu ze stosu). Wtedy ciąg n operacji kolejkowych, startujących od pustej kolejki, ma koszt liniowy w tej implementacji. Wystarczy, że każda operacja wkłada do Funduszu składkę 3 jednostek. Dowód tego pozostawiamy jako ćwiczenie.

Zastąpienie kolejki dwustronnej trzema stosami

Rozważ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. Niezmiennikiem jest to, że oba stosy są niepuste, lub mają w sumie co najwyżej jeden element. Zapewniamy zachodzenie niezmiennika 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 i 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.