Algorytmy i struktury danych/Wstęp: elementarne techniki algorytmiczne i struktury danych: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
m
Linia 1: Linia 1:
 
 
Nie ma formalnej teorii ani gotowych "recept" na konstrukcję efektywnych algorytmów. Opiszemy  
 
Nie ma formalnej teorii ani gotowych "recept" na konstrukcję efektywnych algorytmów. Opiszemy  
 
nieformalnie kilka podstawowych technik.
 
nieformalnie kilka podstawowych technik.
Linia 31: Linia 30:
  
 
== Metoda zachłanna ==
 
== Metoda zachłanna ==
Metoda ta dobrze działa w sytuacjach gdy maksymalizujemy lub minimalizujemy pewną wartość. Algorytm  
+
Metoda ta dobrze działa w sytuacjach, gdy maksymalizujemy lub minimalizujemy pewną wartość. Algorytm  
 
w każdej iteracji ma do wyboru pewną  
 
w każdej iteracji ma do wyboru pewną  
 
liczbę "lokalnych" akcji. W przypadku maksymalizacji wybiera tę, która lokalnie maksymalizuje wartość  
 
liczbę "lokalnych" akcji. W przypadku maksymalizacji wybiera tę, która lokalnie maksymalizuje wartość  
 
docelową, w przypadku minimalizacji  
 
docelową, w przypadku minimalizacji  
wybiera akcję o minimalnej wartości. Przedyskutujemy tę metodę na następujących dwóch pzykładach.
+
wybiera akcję o minimalnej wartości. Przedyskutujemy tę metodę na następujących dwóch przykładach.
  
 
===Wieże na szachownicy===
 
===Wieże na szachownicy===
 
Przypuśćmy, że mamy szachownicę n na n, na polu (i,j)-tym leży x(i,j) monet. Chcemy umieścić n wież na  
 
Przypuśćmy, że mamy szachownicę n na n, na polu (i,j)-tym leży x(i,j) monet. Chcemy umieścić n wież na  
szachownicy tak aby żadne dwie się nie biły.
+
szachownicy tak, aby żadne dwie się nie atakowały.
 
Zyskiem jest suma monet na wybranych pozycjach. Lokalna akcja to wybranie jednej dopuszczalnej pozycji  
 
Zyskiem jest suma monet na wybranych pozycjach. Lokalna akcja to wybranie jednej dopuszczalnej pozycji  
 
(tzn. takiej, że wieża umieszczona  
 
(tzn. takiej, że wieża umieszczona  
na tej pozycji nie bije żadnej wieży umieszczonej dotychczas. Zysk akcji to liczba monet na pozycji.  
+
na tej pozycji nie atakuje żadnej wieży umieszczonej dotychczas. Zysk akcji to liczba monet na pozycji.  
 
Algorytm zachłanny działa trywialnie: wybieramy
 
Algorytm zachłanny działa trywialnie: wybieramy
 
pozycję z maksymalnym x(i,j). Łatwo widać, że ten algorytm niekoniecznie da optymalny zysk, ale da co  
 
pozycję z maksymalnym x(i,j). Łatwo widać, że ten algorytm niekoniecznie da optymalny zysk, ale da co  
Linia 57: Linia 56:
 
na pobraniu dwóch elementów z ciągu i zastąpieniu ich przez sumę ich wartości, kosztem akcji jest suma  
 
na pobraniu dwóch elementów z ciągu i zastąpieniu ich przez sumę ich wartości, kosztem akcji jest suma  
 
wartości "sklejanych" elementów.  
 
wartości "sklejanych" elementów.  
Ciąg operacji sklejania kończy się gdy skleiliśmy wszystko do jednej wartości.
+
Ciąg operacji sklejania kończy się, gdy skleiliśmy wszystko do jednej wartości.
  
Interesuje nas policzenie minimalnego sumarycznego kosztu sklejania <math>n</math> elementów w jeden  
+
Interesuje nas obliczenie minimalnego sumarycznego kosztu sklejania <math>n</math> elementów w jeden  
 
element.  
 
element.  
 
Metoda zachłanna zawsze wybiera akcję o minimalnej wartości.  
 
Metoda zachłanna zawsze wybiera akcję o minimalnej wartości.  
 
{{algorytm|Schemat-Zachłanny|algorytm_schemat_zachlanny|
 
{{algorytm|Schemat-Zachłanny|algorytm_schemat_zachlanny|
     '''while''' zbior mozliwych lokalnych akcji jest niepusty '''do'''
+
     '''while''' zbiór możliwych lokalnych akcji jest niepusty '''do'''
 
       wykonaj akcję o minimalnym koszcie;
 
       wykonaj akcję o minimalnym koszcie;
 
     '''return''' (suma kosztów wykonanych akcji)
 
     '''return''' (suma kosztów wykonanych akcji)
Linia 80: Linia 79:
 
Pozostawiamy jako ćwiczenie dowód tego, że algorytm ten daje minimalny  ciąg sklejeń. Co będzie, jeśli  
 
Pozostawiamy jako ćwiczenie dowód tego, że algorytm ten daje minimalny  ciąg sklejeń. Co będzie, jeśli  
 
zamiast  
 
zamiast  
liczyć minimalny koszt chcielibyśmy policzyć ciąg, który maksymalizuje sumaryczną sumę kosztów a+b?  
+
obliczać minimalny koszt chcielibyśmy wyznaczyć ciąg, który maksymalizuje sumaryczny koszt?  
 
Pozostawiamy to jako ćwiczenie.  
 
Pozostawiamy to jako ćwiczenie.  
  
Linia 86: Linia 85:
 
W naszym przykładzie mogliśmy sklejać elementy, które niekoniecznie są sąsiednie, kolejność elementów  
 
W naszym przykładzie mogliśmy sklejać elementy, które niekoniecznie są sąsiednie, kolejność elementów  
 
w ciągu nie odgrywała roli.
 
w ciągu nie odgrywała roli.
Zastanówmy się co będzie gdy wprowadzimy do gry kolejność elementów. Załóżmy teraz, że możemy  
+
Zastanówmy się co będzie, gdy wprowadzimy do gry kolejność elementów. Załóżmy teraz, że możemy  
sklejajać tylko elementy sąsiednie.  
+
sklejać tylko elementy sąsiednie.  
 
Tak zmodyfikowany problem nazwijmy problemem '''Minimalnego Sklejania  Sąsiadów'''. Możemy w  
 
Tak zmodyfikowany problem nazwijmy problemem '''Minimalnego Sklejania  Sąsiadów'''. Możemy w  
 
poprzednim algorytmie zastąpić zwrot  
 
poprzednim algorytmie zastąpić zwrot  
Linia 94: Linia 93:
  
  
Niespodziewanie, nasz algorytm nie zawsze liczy minimalną wartość, czyli nie jest poprawny.  
+
Niespodziewanie, nasz algorytm nie zawsze oblicza minimalną wartość, czyli nie jest poprawny.  
 
Kontrprzykładem jest ciąg
 
Kontrprzykładem jest ciąg
  
Linia 103: Linia 102:
 
Rozwiązaniem danego problemu często jest kombinacja wartości podproblemów na które można problem  
 
Rozwiązaniem danego problemu często jest kombinacja wartości podproblemów na które można problem  
 
rozłożyć. Natomiast nie bardzo od
 
rozłożyć. Natomiast nie bardzo od
razu wiemy jaka dekompozycja jest optymalna, początkowo mamy niedeterministyczny wybór wielu  
+
razu wiemy, jaka dekompozycja jest optymalna; początkowo mamy niedeterministyczny wybór wielu  
różnych dekompozycji. W sytuacji gdy nie  
+
różnych dekompozycji. W sytuacji, gdy nie  
wiemy jaka dekompozycja jest optymalna nie możemy uruchomić rekursji ponieważ na każdym etapie  
+
wiemy, jaka dekompozycja jest optymalna, nie możemy uruchomić rekursji, ponieważ na każdym etapie  
 
mielibyśmy wiele wyborów i w sumie  
 
mielibyśmy wiele wyborów i w sumie  
 
złożoność mogłaby być wykładnicza. Przykładem jest rekurencyjne liczenie liczb Fibonacciego.
 
złożoność mogłaby być wykładnicza. Przykładem jest rekurencyjne liczenie liczb Fibonacciego.
Linia 112: Linia 111:
 
grubsza biorąc, wygląda następująco.
 
grubsza biorąc, wygląda następująco.
 
Jeśli problem możemy rozbić na podproblemy i liczba wszystkich potencjalnych podproblemów jest  
 
Jeśli problem możemy rozbić na podproblemy i liczba wszystkich potencjalnych podproblemów jest  
wielomianowa to zamiast korzystać z rekursji  
+
wielomianowa, to zamiast korzystać z rekursji,
 
możemy obliczyć wartości wszystkich podproblemów stosując odpowiednią kolejność: od mniejszych  
 
możemy obliczyć wartości wszystkich podproblemów stosując odpowiednią kolejność: od mniejszych  
podproblemów do większych. Wartości policzone
+
podproblemów do większych. Wartości obliczone
dla podproblemów zapamiętujemy w tablicy. Mając policzone wartości podproblemów na które można  
+
dla podproblemów zapamiętujemy w tablicy. Mając obliczone wartości podproblemów, na które można  
rozbić dany problem wartośc tego problemu  
+
rozbić dany problem, wartość tego problemu obliczamy korzystając z wartości zapamiętanych w tablicy.
liczymy korzystając z wartości zapamiętanych w tablicy.
 
  
Najistotniejszym jest tutaj określenie zbioru potencjalnych podproblemów. Z reguły zbiór ten jest znacznie  
+
Najistotniejsze jest tutaj określenie zbioru potencjalnych podproblemów. Z reguły zbiór ten jest znacznie  
 
większy niż zbiór podproblemów  
 
większy niż zbiór podproblemów  
 
będących częściami jednego optymalnego rozwiązania.
 
będących częściami jednego optymalnego rozwiązania.
Linia 125: Linia 123:
 
Spróbujmy skonstruować wielomianowy algorytm dla problemu minimalnego sklejania sąsiadów  
 
Spróbujmy skonstruować wielomianowy algorytm dla problemu minimalnego sklejania sąsiadów  
 
korzystając z programowania dynamicznego.
 
korzystając z programowania dynamicznego.
Jeśli mamy dany ciąg <math>p_1,p_2, \ldots p_n</math> to w tym przypadku podproblem można  
+
Jeśli mamy dany ciąg <math>p_1,p_2, \ldots p_n</math>, to w tym przypadku podproblem można  
utożsamić z pewnym przedziałem <math>[i..j]</math>,
+
utożsamić z pewnym przedziałem <math>[i..j]</math>.
niech <math>wynik[i,j]</math> będzie wartością problemu minimalnego sklejania sąsiadów dla ciągu  
+
Niech <math>wynik[i,j]</math> będzie wartością problemu minimalnego sklejania sąsiadów dla ciągu  
<math>(p_i,p_{i+1}, \ldots p_j)</math>,
+
<math>(p_i,p_{i+1}, \ldots p_j)</math>;
oznaczmy ponadto <center> <math>\sigma_{i,j}\ =\ \sum_{k=i}^j\ p_i </math></center>
+
oznaczmy ponadto <center> <math>\sigma_{i,j}\ =\ \sum_{k=i}^j\ p_k </math></center>
 
{{algorytm|Optymalne-Sklejanie-Sąsiadow|algorytm_optymalne_sklejanie_sasiadow|
 
{{algorytm|Optymalne-Sklejanie-Sąsiadow|algorytm_optymalne_sklejanie_sasiadow|
 
     '''for each''' i <math>wynik[i,i]:=0;</math>
 
     '''for each''' i <math>wynik[i,i]:=0;</math>
Linia 138: Linia 136:
 
}}
 
}}
 
Algorytm ma złożoność czasową <math>O(n^3)</math> i jest to "typowa" złożoność algorytmów tego  
 
Algorytm ma złożoność czasową <math>O(n^3)</math> i jest to "typowa" złożoność algorytmów tego  
typu. Duża złożoność wynika stąd  
+
typu. Duża złożoność wynika stąd,
że liczymy wartości dla mnóstwa podproblemów, które mogą być zupełnie nieistotne ze względu na
+
że liczymy wartości dla mnóstwa podproblemów, które mogą być zupełnie nieistotne z punktu widzenia
optymalne rozwiązanie.  
+
optymalnego rozwiązania.  
  
 
===Dygresja===
 
===Dygresja===
Linia 159: Linia 157:
 
dla którego odpowiedź na pytanie "dlaczego to działa" jest niezwykle skomplikowana i wykracza poza  
 
dla którego odpowiedź na pytanie "dlaczego to działa" jest niezwykle skomplikowana i wykracza poza  
 
zakres tego kursu. Pozostawiamy jako  
 
zakres tego kursu. Pozostawiamy jako  
ćwiczenie implementację tego algorytmu w czasie <math>O(n \log n)</math> zakładając, że jest on  
+
ćwiczenie implementację tego algorytmu w czasie <math>O(n \log n)</math>, przy założeniu, że jest on  
 
poprawny. Jeśli liczby <math>p_i</math> są  
 
poprawny. Jeśli liczby <math>p_i</math> są  
 
liczbami naturalnymi z przedziału <math>[1..n]</math> to istnieje nawet (bardzo trudna) implementacja w  
 
liczbami naturalnymi z przedziału <math>[1..n]</math> to istnieje nawet (bardzo trudna) implementacja w  
Linia 167: Linia 165:
  
 
Algorytm efektywny otrzymujemy często startując od prostszego, ale mało efektywnego algorytmu.  
 
Algorytm efektywny otrzymujemy często startując od prostszego, ale mało efektywnego algorytmu.  
Następnie staramy się, za pomocą prostych  
+
Następnie staramy się za pomocą prostych  
transformacji, przekształcić prosty algorytm w algorytm docelowy. Można to również nazwać stosowaniem  
+
transformacji przekształcić prosty algorytm w algorytm docelowy. Można to również nazwać stosowaniem  
 
metody kolejnych przybliżeń w  
 
metody kolejnych przybliżeń w  
 
aspekcie inżynierii algorytmicznej. Większość prostych algorytmów z [[ASD_Moduł_1|Modułu 1]] można  
 
aspekcie inżynierii algorytmicznej. Większość prostych algorytmów z [[ASD_Moduł_1|Modułu 1]] można  
Linia 177: Linia 175:
  
 
===Liczba inwersji===
 
===Liczba inwersji===
Mamy dane dwie posortowane rosnąco tablice <math>A[1..n], B[1..n]</math>, mamy policzyć liczbę par  
+
Mając dane dwie posortowane rosnąco tablice <math>A[1..n], B[1..n]</math>, należy wyznaczyć liczbę par  
 
<math>i,j </math> takich,  
 
<math>i,j </math> takich,  
że <math>A[i]>B[j] </math>. Jeśli <math> A[1..n],B[1..n]</math> są posortowanymi rosnąco tablicami, to
+
że <math>A[i]>B[j] </math>.  
liczbę inwersji między A, B oblicza następujący naiwny algorytm.
+
Liczbę inwersji między tablicami A, B oblicza następujący naiwny algorytm:
 
{{algorytm|Liczba-Inwersji-Naiwnie|algorytm_liczba_inwersji_naiwnie|
 
{{algorytm|Liczba-Inwersji-Naiwnie|algorytm_liczba_inwersji_naiwnie|
 
  Liczba-Inwersji-Naiwnie
 
  Liczba-Inwersji-Naiwnie
Linia 186: Linia 184:
 
  '''for''' <math>i := 1</math> to n '''do'''
 
  '''for''' <math>i := 1</math> to n '''do'''
 
     <math>j := 0;</math>
 
     <math>j := 0;</math>
     '''while''' <math>(j<n and A[i]>B[j+1]) j := j+1;</math>
+
     '''while''' <math>j<n</math> and <math>A[i]>B[j+1]</math> '''do''' <math>j := j+1;</math>
 
       <math>wynik := wynik + j;</math>
 
       <math>wynik := wynik + j;</math>
 
}}
 
}}
Linia 192: Linia 190:
 
Algorytm ma złożoność kwadratową. Załóżmy, że początkową wartością j jest zero. Wtedy, przyglądając się  
 
Algorytm ma złożoność kwadratową. Załóżmy, że początkową wartością j jest zero. Wtedy, przyglądając się  
 
dokładniej algorytmowi,  
 
dokładniej algorytmowi,  
widzimy że instrukcję "j := 0;" można usunąć bez szkody dla poprawności i złożoność stanie się liniowa.  
+
widzimy że bez szkody dla poprawności instrukcję "j := 0;" można przesunąć przed pętlę '''for''' i złożoność stanie się liniowa.  
Pozostawiamy to jako ćwiczenie. W ten sposób mamy prostą
+
Dowód pozostawiamy jako ćwiczenie. W ten sposób mamy prostą
 
transformację kwadratowego algorytmu naiwnego na algorytm liniowy.  
 
transformację kwadratowego algorytmu naiwnego na algorytm liniowy.  
  
Linia 205: Linia 203:
 
===Wykrywanie fałszywej monety===
 
===Wykrywanie fałszywej monety===
  
Mamy zbiór monet o numerach 1,2,..,N, wszystkie o tej samej wadze, wiemy że wsśród nich jest dokładnie  
+
Mamy zbiór monet o numerach 1,2,..,N, wszystkie o tej samej wadze, i wiemy że wśród nich jest dokładnie  
 
jedna fałszywa moneta  
 
jedna fałszywa moneta  
 
o innej wadze. Modelem algorytmu jest ciąg ważeń na wadze szalkowej. Niech waga(A) oznacza sume wag  
 
o innej wadze. Modelem algorytmu jest ciąg ważeń na wadze szalkowej. Niech waga(A) oznacza sume wag  
 
monet ze zbioru A. W jednym  
 
monet ze zbioru A. W jednym  
wazeniu mozemy wykonac operacje Porównaj(A,B), gdzie A,B sa rozlacznymi podzbiorami 1,2,..,N.  
+
ważeniu możemy wykonać operację Porównaj(A,B), gdzie A,B sa rozłącznymi podzbiorami zbioru <math>\{ 1,2,\ldots,N\}</math>.  
 
Otrzymujemy jedną z trzech możliwych odpowiedzi:  
 
Otrzymujemy jedną z trzech możliwych odpowiedzi:  
  
Linia 217: Linia 215:
  
 
Algorytmem w naszym modelu jest ciąg operacji <math>op_1, op_2,..., op_n</math> taki, że  z  
 
Algorytmem w naszym modelu jest ciąg operacji <math>op_1, op_2,..., op_n</math> taki, że  z  
otrzymanego ciągu opdpowiedzi można jednoznacznie  
+
otrzymanego ciągu odpowiedzi można jednoznacznie  
wyznaczyc fałszywą monetę i określić czy jest ona cięższa czy lżejsza niż inne. Operację ''Porównaj(A,B)''  
+
wyznaczyć fałszywą monetę i określić, czy jest ona cięższa czy lżejsza niż inne. Operację ''Porównaj(A,B)''  
 
będziemy w skrócie zapisywać jako  
 
będziemy w skrócie zapisywać jako  
 
parę (A,B). Nasz algorytm można zatem zapisać jako ciąg par rozłącznych zbiorów, na przykład:
 
parę (A,B). Nasz algorytm można zatem zapisać jako ciąg par rozłącznych zbiorów, na przykład:
Linia 232: Linia 230:
 
ponumerowane <math>0,1,2.. 3^{n}-1</math> . Niech S(k,0), S(k,1) oznaczają zbiory numerów monet,  
 
ponumerowane <math>0,1,2.. 3^{n}-1</math> . Niech S(k,0), S(k,1) oznaczają zbiory numerów monet,  
 
które na k-tym bicie (licząc od końca)  
 
które na k-tym bicie (licząc od końca)  
w reprezentacji trójkowej mają odpowiednio 0, 1. Gdybyśmy wiedzieli od razu czy fałszywa moneta jest  
+
w reprezentacji trójkowej mają odpowiednio 0, 1. Gdybyśmy wiedzieli od razu, czy fałszywa moneta jest  
 
lżejsza czy cięższa,  
 
lżejsza czy cięższa,  
 
to mamy następujący prosty algorytm, który działa podobnie jak wyszukiwanie ternarne:
 
to mamy następujący prosty algorytm, który działa podobnie jak wyszukiwanie ternarne:
Linia 238: Linia 236:
 
<center><math> (S(1,0), S(1,1)), (S(2,0),S(2,1)), ... (S(n,0),S(n,1)) </math></center>
 
<center><math> (S(1,0), S(1,1)), (S(2,0),S(2,1)), ... (S(n,0),S(n,1)) </math></center>
  
Ponieważ nie znamy statusu fałszywej monety dodajemy jedno porównanie i otrzymujemy algorytm B(n)  
+
Ponieważ nie znamy statusu fałszywej monety, dodajemy jedno porównanie i otrzymujemy algorytm B(n),
 
który obsługuje za pomocą n ważeń  <math>N = 3^{n-1}</math>  monet (mamy teraz tylko n-1 bitów  
 
który obsługuje za pomocą n ważeń  <math>N = 3^{n-1}</math>  monet (mamy teraz tylko n-1 bitów  
 
ternarnych).
 
ternarnych).
Linia 244: Linia 242:
 
<center><math> B(n)= (S(1,0),S(1,2)), (S(1,0),S(1,1)), ... (S(n-1,0),S(n-1,1))</math></center>
 
<center><math> B(n)= (S(1,0),S(1,2)), (S(1,0),S(1,1)), ... (S(n-1,0),S(n-1,1))</math></center>
  
Dzięki dodaniu na początku jednego ważenia już po pierwszych dwóch ważeniach wiemy jaki jest status  
+
Dzięki dodaniu na początku jednego ważenia już po pierwszych dwóch ważeniach wiemy, jaki jest status  
 
fałszywej  
 
fałszywej  
 
monety (lżejsza, cięższa). Poza tym wynikiem pierwszych dwóch ważeń nie może być LR ani RL. Te dwie  
 
monety (lżejsza, cięższa). Poza tym wynikiem pierwszych dwóch ważeń nie może być LR ani RL. Te dwie  
Linia 258: Linia 256:
 
to definiujemy algorytm
 
to definiujemy algorytm
 
<center>
 
<center>
<math> A1 \cup A2 = (A1 \cup C1, B1 \cup D1), (A1 \cup C2, B2 \cup D2) ... (An \cup Cn, Bn \cup Dn)  
+
<math> A1 \cup A2 = (A_1 \cup C_1, B_1 \cup D_1), (A_1 \cup C_2, B_2 \cup D_2) ... (A_n \cup C_n, B_n \cup D_n)  
 
</math></center>
 
</math></center>
  
Linia 264: Linia 262:
 
Załóżmy, że mamy algorytm <math>A_{n-1} = (A_1,B_1),(A_2,B_2)...(A_{n-1},B_{n-1})</math> na  
 
Załóżmy, że mamy algorytm <math>A_{n-1} = (A_1,B_1),(A_2,B_2)...(A_{n-1},B_{n-1})</math> na  
 
zbiorze rozmiaru  
 
zbiorze rozmiaru  
<math>N_{n-1}</math> to oznaczmy przez <math>przeskaluj(A_{n-1})</math> algorytm, który działa na  
+
<math>N_{n-1}</math>, i oznaczmy przez <math>przeskaluj(A_{n-1})</math> algorytm, który działa na  
zmodyfikownych
+
zmodyfikowanych
numerach monet, do każdego numeru dodajemy <math>3^{n-1}</math>. Ponadto dodajemy jedno  
+
numerach monet: do każdego numeru dodajemy <math>3^{n-1}</math>. Ponadto dodajemy jedno  
 
porównanie:
 
porównanie:
  
Linia 277: Linia 275:
 
<center><math> A_n= przeskaluj(A_{n-1}) \cup B(n) </math></center>
 
<center><math> A_n= przeskaluj(A_{n-1}) \cup B(n) </math></center>
  
Poprawność takiej konstrukcji wynika stąd że na podstawie wyników 2 pierwszych ważeń wiemy, czy  
+
Poprawność takiej konstrukcji wynika stąd, że na podstawie wyników dwóch pierwszych ważeń wiemy, czy  
 
fałszywa moneta jest  
 
fałszywa moneta jest  
 
mniejsza od <math>3^{n-1}</math>. Jeśli tak to traktujemy odpowiedzi jak w B(n),jesli nie to jak w A(n-
 
mniejsza od <math>3^{n-1}</math>. Jeśli tak to traktujemy odpowiedzi jak w B(n),jesli nie to jak w A(n-
Linia 301: Linia 299:
  
 
Na podstawie tego wzoru można otrzymać  drugi algorytm, który pozostawiamy jako ćwiczenie. Jest jeszcze  
 
Na podstawie tego wzoru można otrzymać  drugi algorytm, który pozostawiamy jako ćwiczenie. Jest jeszcze  
następujący zwarty wzór,  
+
następujący zwarty wzór, z którego ''wynika'' trzeci algorytm rozwiązujący problem bezpośrednio (bez rekursji)  
z którego ''wynika'' trzeci algorytm rozwiązujący problem bezpośrednio (bez rekursji) <center><math>  
+
<center><math>  
 
N_n=(3^{n}-3)/2 </math></center>
 
N_n=(3^{n}-3)/2 </math></center>
 
Na razie byliśmy zainteresowani głównie zmaksymalizowaniem liczby <math> N_n </math> oraz ogólną  
 
Na razie byliśmy zainteresowani głównie zmaksymalizowaniem liczby <math> N_n </math> oraz ogólną  
 
strukturą algorytmów ważenia.   
 
strukturą algorytmów ważenia.   
Pozostawiamy jako ćwiczenie pokazanie że wszystkie trzy powyższe algorytmy można zaimplementować  
+
Pozostawiamy jako ćwiczenie pokazanie, że wszystkie trzy powyższe algorytmy można zaimplementować  
 
tak, aby wypisywały one na  
 
tak, aby wypisywały one na  
 
wyjściu odpowiadającą im ciągi ważeń w czasie  
 
wyjściu odpowiadającą im ciągi ważeń w czasie  
liniowym ze wzgędu na rozmiar wyjścia.
+
liniowym ze względu na rozmiar wyjścia.
  
 
==Znaczenie struktury danych==
 
==Znaczenie struktury danych==
Linia 317: Linia 315:
 
zwraca jako wartość "pewien" element S. Nie interesuje nas na razie który element zostanie usunięty.  
 
zwraca jako wartość "pewien" element S. Nie interesuje nas na razie który element zostanie usunięty.  
 
Niedeterminizm  pozwala nam użyć w takim  
 
Niedeterminizm  pozwala nam użyć w takim  
wypadku jednej z kilku struktur danych które dyskutujemy poniżej. W niektórych zastosowaniach istotne  
+
wypadku jednej z kilku struktur danych które omawiamy poniżej. W niektórych zastosowaniach istotne  
jest który element jest pobierany i  
+
jest, który element jest pobierany, i  
 
wtedy  nazwy operacji insert, delete często zmieniamy na nazwy bardziej odpowiadające terminologicznie  
 
wtedy  nazwy operacji insert, delete często zmieniamy na nazwy bardziej odpowiadające terminologicznie  
 
tym strukturom, ale będziemy  
 
tym strukturom, ale będziemy  
 
też używać nazewnictwa delete, insert, o ile nie prowadzi to do niejednoznaczności.  Elementarne struktury  
 
też używać nazewnictwa delete, insert, o ile nie prowadzi to do niejednoznaczności.  Elementarne struktury  
danych w których zdeterminowane są operacje insert, delete to:<br>
+
danych, w których określone są operacje insert, delete, to:<br>
 
*lista,  
 
*lista,  
 
*stos,  
 
*stos,  
Linia 330: Linia 328:
 
====Prosty przypadek kolejki priorytetowej====
 
====Prosty przypadek kolejki priorytetowej====
  
Wariantem kolejki jest kolejka priorytetowa, jest to struktura danych, która "obsługuje" ciąg operacji insert,  
+
Wariantem kolejki jest ''kolejka priorytetowa''. Jest to struktura danych, która "obsługuje" ciąg operacji insert,  
 
delete, gdzie operacja  
 
delete, gdzie operacja  
 
delete zawsze pobiera minimalny element. Operację tę nazwiemy w tym przypadku ExtractMin. Operacja  
 
delete zawsze pobiera minimalny element. Operację tę nazwiemy w tym przypadku ExtractMin. Operacja  
 
delete jest tutaj w dużym stopniu zdeterminowana.
 
delete jest tutaj w dużym stopniu zdeterminowana.
  
Zalozmy, ze ciag operacji insert można podzielić na dwa ciągi, następujące po sobie, w każdym z nich w  
+
Załóżmy, że ciąg operacji insert można podzielić na dwa ciągi następujące po sobie; w każdym z nich w  
 
operacji insert wstawiamy
 
operacji insert wstawiamy
 
elementy w porządku rosnącym. Wtedy kolejkę priorytetową można łatwo zaimplementować tak, by  
 
elementy w porządku rosnącym. Wtedy kolejkę priorytetową można łatwo zaimplementować tak, by  
operacje insert, delete mozna bylo wykonac w czasei stalym.
+
operacje insert, delete mozna było wykonac w czasie stalym.
  
 
Pokażemy na przykładzie algorytmu Optymalne-Sklejanie-Par zastosowanie tego typu kolejki priorytetowej.  
 
Pokażemy na przykładzie algorytmu Optymalne-Sklejanie-Par zastosowanie tego typu kolejki priorytetowej.  
Linia 344: Linia 342:
 
tym podstawową operacją jest:
 
tym podstawową operacją jest:
  
<blockquote>zastąp elementy <math>a,b</math> przez <math>a+b</math>. </blockquote>
+
<blockquote>zastąp dwa minimalne elementy <math>a,b</math> przez <math>a+b</math>. </blockquote>
  
 
Operacja ta jest równoważna operacjom:
 
Operacja ta jest równoważna operacjom:
Linia 367: Linia 365:
 
Optymalne-Sklejanie-Par  
 
Optymalne-Sklejanie-Par  
 
możemy zaimplementować w czasie liniowym gdy początkowy zbiór jest od razu posortowany. Widzimy na  
 
możemy zaimplementować w czasie liniowym gdy początkowy zbiór jest od razu posortowany. Widzimy na  
tym przykładzie w jaki sposób złożoność algorytm zależy od  struktury danych związanych z algorytmem.
+
tym przykładzie, w jaki sposób złożoność algorytm zależy od  struktury danych związanych z algorytmem.
  
W następujących dwóch przykłądach możemy sobie pozwolić na niedeterministyczny wariant operacji
+
W następujących dwóch przykładach możemy sobie pozwolić na niedeterministyczny wariant operacji
 
delete.
 
delete.
  
Linia 375: Linia 373:
  
 
Przypuśćmy, że mamy funkcję <math>f : \{1,2,\ldots n\} \rightarrow \{1,2,\ldots n\}</math>, zadaną tablicą  
 
Przypuśćmy, że mamy funkcję <math>f : \{1,2,\ldots n\} \rightarrow \{1,2,\ldots n\}</math>, zadaną tablicą  
<math>f[1..n] </math> i  
+
<math>f[1..n] </math>, i  
 
chcemy znaleźć rozmiar maksymalnego podzbioru, na którym ta funkcja jest bijekcją.
 
chcemy znaleźć rozmiar maksymalnego podzbioru, na którym ta funkcja jest bijekcją.
  
 
====Dwie funkcje====
 
====Dwie funkcje====
Jest to zadanie bardzo podobne, mamy dwie częściowo określone funkcje <math>f_1, f_2</math> ze zbioru  
+
Jest to zadanie bardzo podobne: mamy dwie częściowo określone funkcje <math>f_1, f_2</math> ze zbioru  
 
<math>[1..n]</math>  
 
<math>[1..n]</math>  
 
w siebie. Chcemy znaleźć taką permutację <math>\pi = (i_1,i_2,\ldots i_n)</math>, żeby <math>\pi(f_k(i))  
 
w siebie. Chcemy znaleźć taką permutację <math>\pi = (i_1,i_2,\ldots i_n)</math>, żeby <math>\pi(f_k(i))  
 
> \pi(i), \forall \ 1\le i\le n,\ k=1,2</math>,  
 
> \pi(i), \forall \ 1\le i\le n,\ k=1,2</math>,  
jesli <math>f_k(i) </math> okreslone.
+
jeśli <math>f_k(i) </math> określone.
  
  
Linia 389: Linia 387:
 
Oba te przykłady możemy wyrazić w terminach teorii grafów. Zakładamy, że czytelnik dowiedział się na  
 
Oba te przykłady możemy wyrazić w terminach teorii grafów. Zakładamy, że czytelnik dowiedział się na  
 
matematyce
 
matematyce
dyskretnej co to jest graf.  Zbiorem wierzchołków jest tutaj zbiór <math>[1..n]</math>.  
+
dyskretnej, co to jest graf.  Zbiorem wierzchołków jest tutaj zbiór <math>[1..n]</math>.  
 
W pierwszym przykładzie krawędzie są postaci <math>(i,f(i))</math>, w drugim postaci  
 
W pierwszym przykładzie krawędzie są postaci <math>(i,f(i))</math>, w drugim postaci  
 
<math>(i,f_k(i)</math>, gdzie <math>k=1,2</math>.  
 
<math>(i,f_k(i)</math>, gdzie <math>k=1,2</math>.  
Linia 396: Linia 394:
 
zbiorem cykli.   
 
zbiorem cykli.   
  
W drugim przypadku mamy szczególną instancję tzw. sortowania topologicznego grafu. WIerzchołek  
+
W drugim przypadku mamy szczególny przypadek tzw. sortowania topologicznego grafu. WIerzchołek  
nazywamy roboczym, gdy nie wchodzi do niego żadna krawę"dź.
+
nazywamy roboczym, gdy nie wchodzi do niego żadna krawędź.
 
 
 
Niech <math>S</math> będzie początkowo zbiorem wszystkich wierzchołków roboczych.  
 
Niech <math>S</math> będzie początkowo zbiorem wszystkich wierzchołków roboczych.  
 
Algorytmy dla obu powyższych problemów działają w podobny sposób.  Pobieramy  element <math>v \in  
 
Algorytmy dla obu powyższych problemów działają w podobny sposób.  Pobieramy  element <math>v \in  
S</math>, odpowiednio przetwarzamy <math>v</math>,
+
S</math>, odpowiednio przetwarzamy i usuwamy z grafu. Wskutek usunięcia  <math>v</math> pewne nowe wierzchołki stają się roboczymi i  
i usuwamy <math>v</math> z grafu. Wskutek usunięcia  pewne nowe wierzchołki stają się roboczymi i  
+
wstawiamy je do S. Kontynuujemy dopóki S jest niepusty.
wstawiamy je do S. Kontynuujemy,dopóki S jest niepusty.
 
  
W przypadku problemu maksymalnej bijekcji po prostu usuwamy v, w przypadku numeracji  
+
W przypadku problemu maksymalnej bijekcji po prostu usuwamy <math>v</math>, a w przypadku numeracji  
 
<math>\pi</math>, <math>\pi(v)</math> staje się
 
<math>\pi</math>, <math>\pi(v)</math> staje się
 
kolejnym numerem. Pomimo interpretacji teorio-grafowej nie musimy implementować żadnej reprezentacji  
 
kolejnym numerem. Pomimo interpretacji teorio-grafowej nie musimy implementować żadnej reprezentacji  
grafu,
+
grafu:
 
wszystko się dzieje w wejściowych tablicach i w dodatkowej tablicy licznik[v], w której trzymamy dla  
 
wszystko się dzieje w wejściowych tablicach i w dodatkowej tablicy licznik[v], w której trzymamy dla  
 
każdego <math>v</math>  
 
każdego <math>v</math>  
ile jest krawędzi aktualnie wchodzących do v. Konkretną implementację pozostawiamy jako ćwiczenie.  
+
liczbę krawędzi aktualnie wchodzących do <math>v</math>. Konkretną implementację pozostawiamy jako ćwiczenie.  
 
Zbiór S  
 
Zbiór S  
 
jest tutaj zbiorem wierzchołków roboczych, które są w pewnym sensie akcjami do wykonania. Do S  
 
jest tutaj zbiorem wierzchołków roboczych, które są w pewnym sensie akcjami do wykonania. Do S  
wkładamy akcje
+
wkładamy akcje,
które mamy wykonać, kolejność nie jest istotna. S może być listą, stosem lub kolejką.
+
które mamy wykonać; kolejność nie jest istotna. S może być listą, stosem lub kolejką.
  
Pokażemy jeszcze jeden ciekawy problem dla którego właśnie lista jest świetną strukturą danych.
+
Pokażemy jeszcze jeden ciekawy problem, dla którego właśnie lista jest świetną strukturą danych.
  
 
====Panorama Warszawy====
 
====Panorama Warszawy====
Linia 438: Linia 434:
 
Załóżmy, że trójki <math>(p,q,s)</math> są posortowane ze względu na <math>s</math>.  
 
Załóżmy, że trójki <math>(p,q,s)</math> są posortowane ze względu na <math>s</math>.  
 
Wtedy rozważamy kolejno funkcje <math>f_{p,q,s}</math>, w kolejności rosnącego <math>s</math>,  
 
Wtedy rozważamy kolejno funkcje <math>f_{p,q,s}</math>, w kolejności rosnącego <math>s</math>,  
i nadajemy za każdym razem końcowe wartości dla pozycji z przedziału <math>[p,q]</math> dla których  
+
i nadajemy za każdym razem końcowe wartości dla pozycji z przedziału <math>[p,q]</math>, dla których  
jeszcze wartości nie są policzone.
+
jeszcze wartości nie są obliczone.
 
Taki algorytm miałby złożoność kwadratową.
 
Taki algorytm miałby złożoność kwadratową.
  
Jeśli użyjemy listy dwukierunkowej i za każdym razem usuniemy zbiór pozycji dla których wartości  
+
Jeśli użyjemy listy dwukierunkowej i za każdym razem usuniemy zbiór pozycji, dla których wartości  
 
końcowe są  
 
końcowe są  
już policzone to otrzymamy algorytm działający w czasie liniowym. Dokładny zapis algorytmu  
+
już obliczone, to otrzymamy algorytm działający w czasie liniowym. Dokładny zapis algorytmu  
 
pozostawiamy czytelnikowi jako ćwiczenie.
 
pozostawiamy czytelnikowi jako ćwiczenie.
  
Linia 464: Linia 460:
 
<math>\pi=(3,2,1)</math> wynosi 2.
 
<math>\pi=(3,2,1)</math> wynosi 2.
  
Jak policzyć liczbę kolejkową w czasie liniowym ? Porównajmy ten problem z problemem maksymalnego  
+
Jak wyznaczyć liczbę kolejkową w czasie liniowym? Porównajmy ten problem z problemem maksymalnego  
 
malejącego podciągu.  
 
malejącego podciągu.  
 
  
 
Pozostawiamy to jako ćwiczenie.
 
Pozostawiamy to jako ćwiczenie.
Linia 475: Linia 470:
 
razem posortują daną permutację. Jest to trudne pytanie.
 
razem posortują daną permutację. Jest to trudne pytanie.
  
W poprzedniej wersji sortowania każdy element może trafić tylko do jednej kolejki. Rozważmy teraz wersję  
+
W poprzedniej wersji sortowania każdy element może trafić tylko do jednej kolejki. Rozważmy teraz wersję,
 
w której mamy <math>k</math> kolejek <math>Q_1,Q_2,Q_3, \ldots Q_k</math> i element może trafiać  
 
w której mamy <math>k</math> kolejek <math>Q_1,Q_2,Q_3, \ldots Q_k</math> i element może trafiać  
 
do kolejek o coraz mniejszych numerach.  
 
do kolejek o coraz mniejszych numerach.  
Linia 482: Linia 477:
 
wypisaniu bezpośrednio na wyjście o ile jest on pierwszym niepobranym elementem w <math>\pi</math>  
 
wypisaniu bezpośrednio na wyjście o ile jest on pierwszym niepobranym elementem w <math>\pi</math>  
 
lub pierwszym elementem pewnej kolejki,  
 
lub pierwszym elementem pewnej kolejki,  
lub przełożeniu pierwszego elementu pewnej kolejki <math>Q_i</math> do kolejki <math>Q_j</math>, dla  
+
albo przełożeniu pierwszego elementu pewnej kolejki <math>Q_i</math> do kolejki <math>Q_j</math>, dla  
 
<math>j<i</math>.  
 
<math>j<i</math>.  
  
Można pokazać, że wystarczy logarytmiczna liczba kolejek do posortowania każdej permutacji.
+
Można pokazać, że do posortowania każdej permutacji wystarczy logarytmiczna liczba kolejek.
  
Podobny fakt zachodzi, gdy kolejki zastąpimy stosami. Pozostawiamy ten problem (zarówno dla kolejek jak i
+
Podobny fakt zachodzi, gdy kolejki zastąpimy stosami. Pozostawiamy ten problem (zarówno dla kolejek jak i dla
 
stosów) jako ćwiczenie.
 
stosów) jako ćwiczenie.
  
 
====Sortowanie kolejkowe====
 
====Sortowanie kolejkowe====
Załóżmy, że każdy elemnet ciągu <math>\pi</math> jest początkowo listą jednolelementową, oznaczmy  
+
Załóżmy, że każdy element ciągu <math>\pi</math> jest początkowo listą jednolelementową; oznaczmy  
zbiór tych list przez <math>S</math>. Załóżmy też, że umiemy scalić dwie posortowane listy w czasie równym sumie
+
zbiór tych list przez <math>S</math>. Załóżmy też, że umiemy scalić dwie posortowane listy w czasie proporcjonalnym do sumy
 
ich długości za pomocą operacji ''merge''  
 
ich długości za pomocą operacji ''merge''  
 
(patrz następne wykłady).   
 
(patrz następne wykłady).   
Linia 502: Linia 497:
 
     <math>insert(merge(lista1,lista2),S) </math>
 
     <math>insert(merge(lista1,lista2),S) </math>
 
}}
 
}}
Pozostawiamy jako ćwiczenie pokazanie tego, że algorytm ten działa w czasie <math>O(n \log n)</math>, a  
+
Pozostawiamy jako ćwiczenie pokazanie tego, że jeśli S jest kolejką, to algorytm ten działa w czasie <math>O(n \log n)</math>, a  
jeśli <math>S</math> stanie się
+
jeśli <math>S</math> jest
stosem to działa w czasie kwadratowym. Widać na tym przykładzie przewagę kolejki nad listą.  Załóżmy, że  
+
stosem, to algorytm działa w czasie kwadratowym. Widać na tym przykładzie przewagę kolejki nad listą.  Załóżmy, że  
 
mamy posortować tablicę
 
mamy posortować tablicę
 
<math>A[0,1,\ldots, n-1]</math> i <math>n</math> jest potęgą dwójki. Wtedy następujący algorytm  
 
<math>A[0,1,\ldots, n-1]</math> i <math>n</math> jest potęgą dwójki. Wtedy następujący algorytm  

Wersja z 12:11, 25 wrz 2006

Nie ma formalnej teorii ani gotowych "recept" na konstrukcję efektywnych algorytmów. Opiszemy nieformalnie kilka podstawowych technik. Niektóre z nich są wstępnie omawiane na kursie metod programowania. Tutaj rozważymy je przede wszystkim w aspekcie złożoności obliczeniowej i analizy algorytmów.


Metoda dziel i zwyciężaj

Metoda ta polega na podzieleniu problemu na podproblemy, które rozwiązujemy niezależnie, a następnie "scalamy". Metoda działa dobrze, gdy "scalanie" podproblemów jest łatwe oraz podproblemy są "małe" w stosunku do rozmiaru problemu .

Jako przykład rozważmy jeszcze raz problem wyznaczenia przywódcy tablicy (patrz Moduł 1. Wstęp: poprawność i złożoność algorytmów.). Stosując metodę dziel i zwyciężaj, możemy otrzymać następujący algorytm:

Algorytm Rekurencyjny Przywódca


   if  then przywódcą jest pojedyńczy element tablicy
   else
      podziel tablicę na dwie połowy;
      rekurencyjnie oblicz przywódcę lewej i prawej połowy tablicy;
      sprawdź w czasie O(n), który z nich jest przywódcą całości;

Jeśli algorytm ten wykonuje kroków to:

Rozwiązaniem jest (jak wiadomo z kursu matematyki dyskretnej).

Metoda zachłanna

Metoda ta dobrze działa w sytuacjach, gdy maksymalizujemy lub minimalizujemy pewną wartość. Algorytm w każdej iteracji ma do wyboru pewną liczbę "lokalnych" akcji. W przypadku maksymalizacji wybiera tę, która lokalnie maksymalizuje wartość docelową, w przypadku minimalizacji wybiera akcję o minimalnej wartości. Przedyskutujemy tę metodę na następujących dwóch przykładach.

Wieże na szachownicy

Przypuśćmy, że mamy szachownicę n na n, na polu (i,j)-tym leży x(i,j) monet. Chcemy umieścić n wież na szachownicy tak, aby żadne dwie się nie atakowały. Zyskiem jest suma monet na wybranych pozycjach. Lokalna akcja to wybranie jednej dopuszczalnej pozycji (tzn. takiej, że wieża umieszczona na tej pozycji nie atakuje żadnej wieży umieszczonej dotychczas. Zysk akcji to liczba monet na pozycji. Algorytm zachłanny działa trywialnie: wybieramy pozycję z maksymalnym x(i,j). Łatwo widać, że ten algorytm niekoniecznie da optymalny zysk, ale da co najmniej połowę optymalnego zysku. Pozostawiamy to jako ćwiczenie. Bardziej formalnie można wyrazić ten problem w terminach skojarzeń w grafach. Najciekawszym przypadkiem jest sytuacja, gdy tablica x(i,j) jest zero-jedynkowa.

Przejdziemy teraz do wersji minimalizacyjnej.

Minimalne Sklejanie Par

Przypuśćmy, że mamy ciąg nieujemnych liczb . Lokalna akcja sklejania polega na pobraniu dwóch elementów z ciągu i zastąpieniu ich przez sumę ich wartości, kosztem akcji jest suma wartości "sklejanych" elementów. Ciąg operacji sklejania kończy się, gdy skleiliśmy wszystko do jednej wartości.

Interesuje nas obliczenie minimalnego sumarycznego kosztu sklejania elementów w jeden element. Metoda zachłanna zawsze wybiera akcję o minimalnej wartości.

Algorytm Schemat-Zachłanny


   while zbiór możliwych lokalnych akcji jest niepusty do
      wykonaj akcję o minimalnym koszcie;
   return (suma kosztów wykonanych akcji)


<flash>file=Sklejanie_par.swf|width=550|height=400</flash>

Można to zapisać bardziej formalnie:

Algorytm Optymalne-Sklejanie-Par


   ;
   while mamy co najmniej dwa elementy do
      zastąp dwa minimalne elementy a,b przez a+b
      

Pozostawiamy jako ćwiczenie dowód tego, że algorytm ten daje minimalny ciąg sklejeń. Co będzie, jeśli zamiast obliczać minimalny koszt chcielibyśmy wyznaczyć ciąg, który maksymalizuje sumaryczny koszt? Pozostawiamy to jako ćwiczenie.


W naszym przykładzie mogliśmy sklejać elementy, które niekoniecznie są sąsiednie, kolejność elementów w ciągu nie odgrywała roli. Zastanówmy się co będzie, gdy wprowadzimy do gry kolejność elementów. Załóżmy teraz, że możemy sklejać tylko elementy sąsiednie. Tak zmodyfikowany problem nazwijmy problemem Minimalnego Sklejania Sąsiadów. Możemy w poprzednim algorytmie zastąpić zwrot "dwa minimalne elementy" przez "dwa sąsiednie elementy o minimalnej sumie".


Niespodziewanie, nasz algorytm nie zawsze oblicza minimalną wartość, czyli nie jest poprawny. Kontrprzykładem jest ciąg

Programowanie dynamiczne

Rozwiązaniem danego problemu często jest kombinacja wartości podproblemów na które można problem rozłożyć. Natomiast nie bardzo od razu wiemy, jaka dekompozycja jest optymalna; początkowo mamy niedeterministyczny wybór wielu różnych dekompozycji. W sytuacji, gdy nie wiemy, jaka dekompozycja jest optymalna, nie możemy uruchomić rekursji, ponieważ na każdym etapie mielibyśmy wiele wyborów i w sumie złożoność mogłaby być wykładnicza. Przykładem jest rekurencyjne liczenie liczb Fibonacciego.

W takich sytuacjach stosujemy nieformalną metodę zwaną programowaniem dynamicznym. Metoda ta, z grubsza biorąc, wygląda następująco. Jeśli problem możemy rozbić na podproblemy i liczba wszystkich potencjalnych podproblemów jest wielomianowa, to zamiast korzystać z rekursji, możemy obliczyć wartości wszystkich podproblemów stosując odpowiednią kolejność: od mniejszych podproblemów do większych. Wartości obliczone dla podproblemów zapamiętujemy w tablicy. Mając obliczone wartości podproblemów, na które można rozbić dany problem, wartość tego problemu obliczamy korzystając z wartości zapamiętanych w tablicy.

Najistotniejsze jest tutaj określenie zbioru potencjalnych podproblemów. Z reguły zbiór ten jest znacznie większy niż zbiór podproblemów będących częściami jednego optymalnego rozwiązania.

Spróbujmy skonstruować wielomianowy algorytm dla problemu minimalnego sklejania sąsiadów korzystając z programowania dynamicznego. Jeśli mamy dany ciąg , to w tym przypadku podproblem można utożsamić z pewnym przedziałem . Niech będzie wartością problemu minimalnego sklejania sąsiadów dla ciągu ;

oznaczmy ponadto

Algorytm Optymalne-Sklejanie-Sąsiadow


   for each i 
   for  to n do
      for  to n do
         
   return 

Algorytm ma złożoność czasową i jest to "typowa" złożoność algorytmów tego typu. Duża złożoność wynika stąd, że liczymy wartości dla mnóstwa podproblemów, które mogą być zupełnie nieistotne z punktu widzenia optymalnego rozwiązania.

Dygresja

Problem sklejania sąsiadów można rozwiązać inaczej, modyfikując w sposób nietrywialny algorytm Optymalne-Sklejanie-Par. W algorytmie tym instrukcję

zastąp dwa minimalne elementy a,b przez a+b

zamieńmy na:

zastąp dwa sąsiednie elementy a,b o minimalnej sumie przez a+b
przesuń a+b przed najbliższy na prawo (w ciągu) element c większy od a+b
ewentualnie na koniec ciągu, jeśli takiego c nie ma

Otrzymany algorytm, wersja algorytmu Garsia-Wachsa, liczy koszt minimalnego sklejania sąsiadów. Jest to przykład pozornie prostego algorytmu, dla którego odpowiedź na pytanie "dlaczego to działa" jest niezwykle skomplikowana i wykracza poza zakres tego kursu. Pozostawiamy jako ćwiczenie implementację tego algorytmu w czasie , przy założeniu, że jest on poprawny. Jeśli liczby są liczbami naturalnymi z przedziału to istnieje nawet (bardzo trudna) implementacja w czasie liniowym.

Konstruowanie algorytmu metodą transformacji

Algorytm efektywny otrzymujemy często startując od prostszego, ale mało efektywnego algorytmu. Następnie staramy się za pomocą prostych transformacji przekształcić prosty algorytm w algorytm docelowy. Można to również nazwać stosowaniem metody kolejnych przybliżeń w aspekcie inżynierii algorytmicznej. Większość prostych algorytmów z Modułu 1 można potraktować jako produkty transformacji algorytmów naiwnych. Pozostawiamy jako ćwiczenie pokazanie tego. Pokażemy jedynie prosty przykład obliczania liczby inwersji


Liczba inwersji

Mając dane dwie posortowane rosnąco tablice , należy wyznaczyć liczbę par takich, że . Liczbę inwersji między tablicami A, B oblicza następujący naiwny algorytm:

Algorytm Liczba-Inwersji-Naiwnie


Liczba-Inwersji-Naiwnie

for  to n do
   
   while  and  do 
      

Algorytm ma złożoność kwadratową. Załóżmy, że początkową wartością j jest zero. Wtedy, przyglądając się dokładniej algorytmowi, widzimy że bez szkody dla poprawności instrukcję "j := 0;" można przesunąć przed pętlę for i złożoność stanie się liniowa. Dowód pozostawiamy jako ćwiczenie. W ten sposób mamy prostą transformację kwadratowego algorytmu naiwnego na algorytm liniowy.

Przykład ten był dosyć ubogi i przedyskutujemy jeszcze bardziej skomplikowany przykład. Podamy przykład transformacji pewnego prostego algorytmu B(n) w nietrywialny algorytm , transformacja ta bazuje na własnościach B(n). Kluczem do efektywnej transformacji jest analiza własności algorytmu B(n).


Wykrywanie fałszywej monety

Mamy zbiór monet o numerach 1,2,..,N, wszystkie o tej samej wadze, i wiemy że wśród nich jest dokładnie jedna fałszywa moneta o innej wadze. Modelem algorytmu jest ciąg ważeń na wadze szalkowej. Niech waga(A) oznacza sume wag monet ze zbioru A. W jednym ważeniu możemy wykonać operację Porównaj(A,B), gdzie A,B sa rozłącznymi podzbiorami zbioru . Otrzymujemy jedną z trzech możliwych odpowiedzi:

  • L - gdy waga(A)<waga(B)
  • P - gdy waga(A)>waga(B)
  • R - gdy wagi sa równe.

Algorytmem w naszym modelu jest ciąg operacji taki, że z otrzymanego ciągu odpowiedzi można jednoznacznie wyznaczyć fałszywą monetę i określić, czy jest ona cięższa czy lżejsza niż inne. Operację Porównaj(A,B) będziemy w skrócie zapisywać jako parę (A,B). Nasz algorytm można zatem zapisać jako ciąg par rozłącznych zbiorów, na przykład:

Algorytm dla n=2, N=3: ({1}, {2}), ({1}, {3})
Algorytm dla n=3, N=12: ({1,2,3,10},{4,5,6,11}), ({1,2,3,11},{7,8,9,10}), ({1,4,7,11},{2,5,8,12})

Naszym głównym zadaniem jest dla danego n znalezienie algorytmu ważeń, który maksymalizuje N.

Pokażemy najpierw jak rozwiązać zadanie dla . Załóżmy, że liczba monet jest potęgą trójki i monety są ponumerowane . Niech S(k,0), S(k,1) oznaczają zbiory numerów monet, które na k-tym bicie (licząc od końca) w reprezentacji trójkowej mają odpowiednio 0, 1. Gdybyśmy wiedzieli od razu, czy fałszywa moneta jest lżejsza czy cięższa, to mamy następujący prosty algorytm, który działa podobnie jak wyszukiwanie ternarne:

Ponieważ nie znamy statusu fałszywej monety, dodajemy jedno porównanie i otrzymujemy algorytm B(n), który obsługuje za pomocą n ważeń monet (mamy teraz tylko n-1 bitów ternarnych).

Dzięki dodaniu na początku jednego ważenia już po pierwszych dwóch ważeniach wiemy, jaki jest status fałszywej monety (lżejsza, cięższa). Poza tym wynikiem pierwszych dwóch ważeń nie może być LR ani RL. Te dwie własności algorytmu B(n) są kluczem do transformacji tego algorytmu w algorytm .

Jeśli mamy w naszym modelu algorytmy

oraz ,

to definiujemy algorytm


Załóżmy, że mamy algorytm na zbiorze rozmiaru , i oznaczmy przez algorytm, który działa na zmodyfikowanych numerach monet: do każdego numeru dodajemy . Ponadto dodajemy jedno porównanie:


Docelowy algorytm definiujemy rekurencyjnie:

Poprawność takiej konstrukcji wynika stąd, że na podstawie wyników dwóch pierwszych ważeń wiemy, czy fałszywa moneta jest mniejsza od . Jeśli tak to traktujemy odpowiedzi jak w B(n),jesli nie to jak w A(n- 1). Zostawiamy jako ćwiczenie opisanie sposobu takiego przełączania się.

W ten sposób mamy algorytym, który za pomocą n ważeń obsługuje monet, gdzie



Dla n = 2,3,4,5,6,7 mamy:


Dygresja

Teoretycznie interesujące w tym jest to, że są to maksymalne wartości N. Pozostawiamy dowód jako ćwiczenie. Istnieją różne optymalne algorytmy dla tego problemu. Wzór rekurencyjny na liczbę monet można zapisać również w postaci


Na podstawie tego wzoru można otrzymać drugi algorytm, który pozostawiamy jako ćwiczenie. Jest jeszcze następujący zwarty wzór, z którego wynika trzeci algorytm rozwiązujący problem bezpośrednio (bez rekursji)

Na razie byliśmy zainteresowani głównie zmaksymalizowaniem liczby oraz ogólną strukturą algorytmów ważenia. Pozostawiamy jako ćwiczenie pokazanie, że wszystkie trzy powyższe algorytmy można zaimplementować tak, aby wypisywały one na wyjściu odpowiadającą im ciągi ważeń w czasie liniowym ze względu na rozmiar wyjścia.

Znaczenie struktury danych

Podstawową strukturą danych jest struktura "obsługująca" operacje delete(S), insert(x,S), dla zadanego zbioru S. Operacja delete pobiera z S i zwraca jako wartość "pewien" element S. Nie interesuje nas na razie który element zostanie usunięty. Niedeterminizm pozwala nam użyć w takim wypadku jednej z kilku struktur danych które omawiamy poniżej. W niektórych zastosowaniach istotne jest, który element jest pobierany, i wtedy nazwy operacji insert, delete często zmieniamy na nazwy bardziej odpowiadające terminologicznie tym strukturom, ale będziemy też używać nazewnictwa delete, insert, o ile nie prowadzi to do niejednoznaczności. Elementarne struktury danych, w których określone są operacje insert, delete, to:

  • lista,
  • stos,
  • kolejka.

Są one punktem wyjścia do bardziej skomplikowanych struktur.

Prosty przypadek kolejki priorytetowej

Wariantem kolejki jest kolejka priorytetowa. Jest to struktura danych, która "obsługuje" ciąg operacji insert, delete, gdzie operacja delete zawsze pobiera minimalny element. Operację tę nazwiemy w tym przypadku ExtractMin. Operacja delete jest tutaj w dużym stopniu zdeterminowana.

Załóżmy, że ciąg operacji insert można podzielić na dwa ciągi następujące po sobie; w każdym z nich w operacji insert wstawiamy elementy w porządku rosnącym. Wtedy kolejkę priorytetową można łatwo zaimplementować tak, by operacje insert, delete mozna było wykonac w czasie stalym.

Pokażemy na przykładzie algorytmu Optymalne-Sklejanie-Par zastosowanie tego typu kolejki priorytetowej. W algorytmie tym podstawową operacją jest:

zastąp dwa minimalne elementy przez .

Operacja ta jest równoważna operacjom:

; ; ;

W dalszej części kursu pokażemy, jak każdą z operacji Insert, ExtractMin zaimplementować w czasie logarytmicznym. W szczególnym przypadku, rozważonym poniżej, można je zaimplementować w czasie stałym. Załóżmy, że początkowy zbiór jest posortowany i jego elementy są umieszczone na stosie w kolejności rosnącej (od wierzchołka "w dół"). Załóżmy, że mamy dodatkowo zwykłą kolejkę początkowo pustą. Wtedy ciąg operacji

; ;

możemy wykonać w czasie stałym: element minimalny jest na wierzchołu lub na początku kolejki , element wstawiamy na koniec . Zatem algorytm Optymalne-Sklejanie-Par możemy zaimplementować w czasie liniowym gdy początkowy zbiór jest od razu posortowany. Widzimy na tym przykładzie, w jaki sposób złożoność algorytm zależy od struktury danych związanych z algorytmem.

W następujących dwóch przykładach możemy sobie pozwolić na niedeterministyczny wariant operacji delete.

Maksymalna bijekcja

Przypuśćmy, że mamy funkcję , zadaną tablicą , i chcemy znaleźć rozmiar maksymalnego podzbioru, na którym ta funkcja jest bijekcją.

Dwie funkcje

Jest to zadanie bardzo podobne: mamy dwie częściowo określone funkcje ze zbioru w siebie. Chcemy znaleźć taką permutację , żeby , jeśli określone.


Oba te przykłady możemy wyrazić w terminach teorii grafów. Zakładamy, że czytelnik dowiedział się na matematyce dyskretnej, co to jest graf. Zbiorem wierzchołków jest tutaj zbiór . W pierwszym przykładzie krawędzie są postaci , w drugim postaci , gdzie .

W pierwszym przykładzie chcemy znaleźć maksymalny podzbiór grafu, na którym podgraf indukowany jest zbiorem cykli.

W drugim przypadku mamy szczególny przypadek tzw. sortowania topologicznego grafu. WIerzchołek nazywamy roboczym, gdy nie wchodzi do niego żadna krawędź. Niech będzie początkowo zbiorem wszystkich wierzchołków roboczych. Algorytmy dla obu powyższych problemów działają w podobny sposób. Pobieramy element , odpowiednio przetwarzamy i usuwamy z grafu. Wskutek usunięcia pewne nowe wierzchołki stają się roboczymi i wstawiamy je do S. Kontynuujemy dopóki S jest niepusty.

W przypadku problemu maksymalnej bijekcji po prostu usuwamy , a w przypadku numeracji , staje się kolejnym numerem. Pomimo interpretacji teorio-grafowej nie musimy implementować żadnej reprezentacji grafu: wszystko się dzieje w wejściowych tablicach i w dodatkowej tablicy licznik[v], w której trzymamy dla każdego liczbę krawędzi aktualnie wchodzących do . Konkretną implementację pozostawiamy jako ćwiczenie. Zbiór S jest tutaj zbiorem wierzchołków roboczych, które są w pewnym sensie akcjami do wykonania. Do S wkładamy akcje, które mamy wykonać; kolejność nie jest istotna. S może być listą, stosem lub kolejką.

Pokażemy jeszcze jeden ciekawy problem, dla którego właśnie lista jest świetną strukturą danych.

Panorama Warszawy

Rozważmy inny przykład algorytmu, którego złożoność istotnie zależy od (bardzo prostej) struktury danych. Przypuśćmy, że mamy na wejściu trójek postaci , gdzie . Każdej trójce odpowiada funkcja taka, że:


gdy , oraz w przeciwnym przypadku.


Naszym zadaniem jest dla każdego obliczyć wartość , będącą maksimum z danych funkcji dla argumentu . Można podać następującą interpretację. Każda funkcja opisuje kształt wieżowca w Warszawie patrząc z prawej strony Wisły. Wtedy funkcja opisuje panoramę centrum Warszawy.

Załóżmy, że trójki są posortowane ze względu na . Wtedy rozważamy kolejno funkcje , w kolejności rosnącego , i nadajemy za każdym razem końcowe wartości dla pozycji z przedziału , dla których jeszcze wartości nie są obliczone. Taki algorytm miałby złożoność kwadratową.

Jeśli użyjemy listy dwukierunkowej i za każdym razem usuniemy zbiór pozycji, dla których wartości końcowe są już obliczone, to otrzymamy algorytm działający w czasie liniowym. Dokładny zapis algorytmu pozostawiamy czytelnikowi jako ćwiczenie.

Sortowanie kolejkowe i stosowe

Działanie stosu i kolejki świetnie ilustrują różne warianty problemu sortowania z użyciem stosów i kolejek. Niech będzie permutacją liczb . Możemy posortować stosując niedeterministyczny algorytm:

while na wyjściu nie są wypisane wszystkie elementy do
   wykonaj dokładnie jedną z trzech instrukcji:
     (1) wstaw kolejny element  do jednej z kolejek; i=i+1
     (2) lub wypisz  na wyjściu; i=i+1 
     (3) lub pobierz i wypisz na wyjściu pierwszy element jednej z kolejek


Zdefiniujmy liczbę kolejkową permutacji jako minimalną liczbę kolejek potrzebnych do posortowania permutacji . Na przykład dla liczba ta wynosi 0, a dla wynosi 2.

Jak wyznaczyć liczbę kolejkową w czasie liniowym? Porównajmy ten problem z problemem maksymalnego malejącego podciągu.

Pozostawiamy to jako ćwiczenie.

Podobnie definiujemy liczbę stosową, w tym wypadku w powyższym nieformalnym algorytmie zastępujemy kolejkę przez stos. Można również zdefiniować liczbę kolejkowo-stosową, pytając o minimalną liczbę stosów i kolejek, które razem posortują daną permutację. Jest to trudne pytanie.

W poprzedniej wersji sortowania każdy element może trafić tylko do jednej kolejki. Rozważmy teraz wersję, w której mamy kolejek i element może trafiać do kolejek o coraz mniejszych numerach.

Pojedyńcza operacja polega na wstawieniu kolejnego elementu z do jednej z kolejek, wypisaniu bezpośrednio na wyjście o ile jest on pierwszym niepobranym elementem w lub pierwszym elementem pewnej kolejki, albo przełożeniu pierwszego elementu pewnej kolejki do kolejki , dla .

Można pokazać, że do posortowania każdej permutacji wystarczy logarytmiczna liczba kolejek.

Podobny fakt zachodzi, gdy kolejki zastąpimy stosami. Pozostawiamy ten problem (zarówno dla kolejek jak i dla stosów) jako ćwiczenie.

Sortowanie kolejkowe

Załóżmy, że każdy element ciągu jest początkowo listą jednolelementową; oznaczmy zbiór tych list przez . Załóżmy też, że umiemy scalić dwie posortowane listy w czasie proporcjonalnym do sumy ich długości za pomocą operacji merge (patrz następne wykłady).

Algorytm Sortowanie-Kolejkowe-1


Scalanie-Kolejkowe
while  do
   
   

Pozostawiamy jako ćwiczenie pokazanie tego, że jeśli S jest kolejką, to algorytm ten działa w czasie , a jeśli jest stosem, to algorytm działa w czasie kwadratowym. Widać na tym przykładzie przewagę kolejki nad listą. Załóżmy, że mamy posortować tablicę i jest potęgą dwójki. Wtedy następujący algorytm wykonuje ten sam ciąg scaleń co algorytm Scalanie-Kolejkowe. Dowód tego pozostawiamy jako ćwiczenie.

Algorytm Sortowanie-Kolejkowe-2


Scalanie-Kolejkowe bez kolejki

while do
   for  to  do