Wstęp do programowania Sito Eratostenesa

Transkrypt

Wstęp do programowania Sito Eratostenesa
Wieczorowe Studia Licencjackie
Wrocław, 7.11.2006
Wstęp do programowania
Wykład nr 6
(w oparciu o notatki K. Lorysia, z modyfikacjami)
Sito Eratostenesa
Zaprezentujemy teraz algorytm na wyznaczanie wszystkich liczb z przedziału [2,n] za pomocą metody zwanej
sitem Eratostenesa. W algorytmie tym skorzystamy z tablicy rozmiaru n, i-ta pozycja tej tablicy reprezentować
będzie informację o tym, czy liczba i jest liczbą pierwszą. Zauważmy, że każda liczba złożona z zakresu [2,n]
musi się dzielić przez mniejszą od siebie liczbę. W oparciu o tę metodę konstruujemy następujący algorytm:
-
Na początku „przyjmujemy”, że każda z liczb z zakresu [2,n] jest liczbą pierwszą. W praktyce oznacza
to, że wartość każdej komórki tablicy a[0..n] ustawiamy na zero.
-
Następnie, dla i=2,3,4,...,n-1 „zaznaczamy” wszystkie liczby dzielące się przez i oraz nie większe od n.
A zatem wstawiamy wartość 1 w komórkach a[2i], a[3i], a[4i], itd. aż do a[ki], gdzie (k+1)i jest
najmniejszą wielokrotnością i, która jest większa od n.
-
Po zakończeniu działania powyższej pętli spełniony jest warunek: i jest liczbą pierwszą wtedy i tylko
wtedy gdy a[i]=0 (dla każdego i z przedziału [2,n]).
main()
{ int li[limit], i, j, k, l;
for (i=0; i<limit; li[i++]=Yes)
finish = limit;
for (j=2; j<limit; j++)
for(k=j+j; k<limit; k=k+j) li[k]=No;
}
Wykorzystując spostrzeżenie: dzielnik liczby n jest nie większy niż n/2, możemy
zmienić:
finish = limit;
na
finish = limit / 2;
Wykorzystując spostrzeżenie: jeśli j jest liczbą pierwszą to j2 jest najmniejszą jej
wielokrotnością, która nie jest wielokrotnością żadnej liczby pierwszej mniejszej od j,
możemy zmienić
finish = limit;
na
finish = sqrt(limit);
Na koniec dokonamy jeszcze ważnej obserwacji:
- jeśli liczba n jest wielokrotnością liczby k, to n jest również wielokrotnością
każdego pierwszego dzielnika liczby k.
Przykład:
Skoro 30 jest wielokrotnością 10, to jest wielokrotnością 2 oraz 5.
A zatem, wystarczy „zaznaczać” wielokrotności liczb pierwszych.
main()
{ int li[limit], i, j, k, l;
for (i=0; i<limit; li[i++]=Yes)
finish = sqrt(limit);
for (j=2; j<limit; j++)
if (li[j]=Yes)
for(k=j+j; k<limit; k=k+j) li[k]=No;
}
Proste programy rekurencyjne
Rozważymy kilka prostych problemów, dla których rekurencja stanowi naturalne rozwiązanie. Rozwiązania te
poprzedzimy przedstawieniem nierekurencyjnych rozwiązań, dzięki czemu będzie można lepiej docenić moc
mechanizmu rekursji.
Potęgowanie
Zależność między kolejnymi potęgami liczby a można zapisać
n=0
 1
a n =  n−1
a ∗ a n > 0
O zależności takiej mówimy, że jest zależnością rekurencyjną, ponieważ wyznaczenie wartości funkcji dla
większych argumentów możemy sprowadzić (w prosty sposób) do wyznaczania wartości tej samej funkcji dla
argumentów mniejszych. W oparciu o taką zależność możemy skonstruować rekurencyjną funkcję języka C,
która wyznacza wartość an. Przez funkcję rekurencyjną rozumiemy tutaj taką funkcję, w której treści występuje
odwołanie do niej samej.
#include <iostream.h>
#include <iostream.h>
int potega(int a, int n)
{ int i, wyn;
wyn = 1;
for(i=0; i<n; i++) wyn = wyn * a;
return wyn;
int potega(int a, int n)
{ if (n==0) return 1;
return a * potega(a, n-1);
}
main()
{int n, a, wynik;
cin >> a >> n;
wynik = potega(a, n);
cout << wynik;
}
}
main()
{int n, a, wynik;
cin >> a >> n;
wynik = potega(a, n);
cout << wynik;
}
Algorytm Euklidesa
Konstruując wcześniej program wyznaczający największy wspólny dzielnik liczb n i m oparty na metodzie
Euklidesa, korzystaliśmy z następującej zależności:
n
m=0


nwd (n, m) = nwd (m, n mod m) n > m
 nwd (m, n)
n≤m

W oparciu o taką zależność możemy skonstruować rekurencyjną funkcję języka C, która wyznacza wartość
nwd(n, m):
#include <iostream.h>
int nwd(int n, int m)
{ if (m==0) return n;
if (m>n) return nwd(m, n);
return nwd(m, n%m);
}
main()
{int n, m, wynik;
cin >> n >> m;
wynik = nwd(n, m);
cout << wynik;
}
O translacji programu i funkcji (rekurencyjnych) na kod maszynowy
Podział pamięci programu
Pamięć zajmowana przez program podzielona jest na kilka obszarów, m.in.:
Obszar zajmowany przez zmienne globalne (inaczej zewnętrzne).
Obszar zajmowany przez instrukcje programu.
Obszar przeznaczony na stos wywołań funkcji.
Obszar przeznaczony na obiekty dynamiczne (tzw. sterta)
Mechanizmu stosu:
Kompilator przekłada instrukcję wywołania funkcji na ciąg rozkazów, który wykonuje m.in. następujące
czynności:
1) Rezerwuje na szczycie stosu wywołań odpowiedni dla danej funkcji fragment pamięci, w którym zostanie
przydzielone miejsce m.in. na:
a) Parametry funkcji
b) Zmienne lokalne
c) Poprzednią wartość wskaźnika szczytu stosu
d) Adres powrotu
2) Oblicza wartości wyrażeń z wywołania funkcji przypisuje je odpowiednim parametrom.
3) Uaktualnia wskaźnik szczytu stosu.
4) Zmienia licznik rozkazów tak, by wskazywał na pierwszą instrukcję funkcji (a dokładniej na adres bajtu
pamięci, w którym znajduje się pierwsza instrukcja przekładu treści funkcji).
Zakończenie wykonywania funkcji (poprzez instrukcje return czy też poprzez wykonanie ostatniej instrukcji)
powoduje m.in.:
1) Przywrócenie wskaźnikowi szczytu stosu wartości jaką miał przed wywołaniem tej funkcji.
2) Przypisanie licznikowi rozkazów wartości adresu powrotu zapamiętanemu w trakcie wywołania.
Licznik rozkazów:
Program, który ma być wykonany sprowadzany jest do pamięci operacyjnej komputera a licznik rozkazów
(zwykle specjalny rejestr w procesorze) ustawiany jest na adres komórki (bajtu) pamięci, w którym znajduje się
pierwsza do wykonania instrukcja (a dokładniej początek jej przekładu). Wykonywanie programu sprowadza się
do wykonywania przez procesor pętli, w której każdej iteracji najpierw wykonywana jest instrukcja zapamiętana
w komórce wskazywanej przez licznik rozkazów, a następnie zwiększana jest zawartość licznika rozkazów tak,
by wskazywał on na komórkę z następną instrukcję. Wyjątkiem są instrukcje skoków, których działanie polega
przypisaniu licznikowi rozkazów zadanego adresu (po tych instrukcjach nie następuje automatyczne zwiększenie
zawartości licznika). W tym kontekście zakończenie wykonywania funkcji polega na wykonaniu rozkazu skoku
z argumentem równym adresowi komórki zawierającej przekład następnej po wywołaniu funkcji instrukcji
(adres ten nazywany jest adresem powrotu).
UWAGI:
- Funkcje mogą być wywoływane z treści innych funkcji. Wówczas stos zawiera informację o kolejnych
wywołaniach. Przyjmijmy, że
o program główny main() wywołuje funkcję f1,
o w treści funkcji f1 wywołamy funkcję f2 ,
o a w treści funkcji f2 zostanie wywołana funkcja f3.
Wówczas,
o na „spodzie” stosu umieszczone będą informacje o wywołaniu funkcji f1,
o nad nimi informacje o wywołaniu f2,
o i jeszcze „wyżej” o wywołaniu f3.
Usuwanie przebiegać będzie w odwrotnej kolejności:
o po zakończeniu f3 usuniemy informacje o jej wywołaniu, (i wrócimy do odpowiedniego
miejsca w kodzie f2)
o
o
po zakończeniu f2 usuniemy informacje o wywołaniu f2 ,
po zakończeniu f1 usuniemy informacje o wywołaniu f1.
Koszt pamięciowy:
Z powyższego wynika, że wywołania rekurencyjne funkcji F powodują utworzenie nowej kopii
informacji o wywołaniu F dla każdego wywołania (i nowej kopii zmiennych lokalnych). Oznacza to
dodatkowy koszt pamięciowy, który nie jest uwidoczniony w postaci zmiennych.
Koszt czasowy:
Ocena kosztu czasowego funkcji rekurencyjnych wymaga wyznaczenia liczby wywołań funkcji dla
parametrów o danej wartości (rozmiarze). Nie zawsze jest to proste, czasem okazuje się, że funkcja
rekurencyjna jest dużo bardziej kosztowna od wariantu iteracyjnego. Choć często daje bardziej zwarty i
zrozumiały zapis. Tę kwestię omawiamy na poniższych przykładach.
Obliczanie liczb Fibonacciego
Definicja. Liczby Fibonacciego definiujemy następująco:
F0=F1=1
oraz dla i>1
Fi = Fi-1+Fi-2
Początkowy fragment ciągu liczb Fibonacciego: 1,1,2,3,5,8,13,21,34,55,89,144,233,377,...
Własność. Liczby Fibonacciego rosną w tempie wykładniczym: Fn≈
1
5
 1+ 5 


 2 


n +1
Uwaga. Celem prezentowanych poniżej programów obliczających liczby Fibonacciego jest ilustracja rekursji. Dlatego
będziemy pomijać w nich szczegóły mogące tę ilustrację zaciemnić. W szczególności będziemy ignorować fakt szybkiego
wzrostu liczb Fibonacciego i na pamiętanie liczb Fibonacciego przeznaczymy pojedyncze zmienne.
Program nierekurencyjny
Wersja 1
Mankamenty:
- obliczenie stanowiące funkcjonalną
całość powinno wykonywać się w
odrębnej funkcji, a nie w programie
głównym;
- naturalniejsza byłaby pętla for (wiemy
ile razy należy wykonać iterację).
#include <iostream.h>
main()
{int n, i;
long f0,f1,x;
cin >> n;
f0=f1=1;
i=2;
while (i<=n)
{ x=f1; f1+=f0; f0=x;
i++;
}
cout << f1 << endl;
}
Wersja 2
#include <stdio.h>
long fib(int k)
{ long f0, f1,x;
f0=f1=1;
for (i=2; i; i++)
{ x=f1; f1+=f0;
return f1;
}
main()
{int n, i;
cin >> n;
cout << fib(n) << endl;
}
f0=x;}
Zwróć uwagę na to, że zmienne f0, f1 oraz
x uczyniliśmy zmiennymi lokalnymi w
funkcji. Ich znaczenie ogranicza się do
treści funkcji i nie powinny być widoczne
na „zewnątrz”. Korzystanie w ich miejsce
ze zmiennych globalnych nie jest błędem,
lecz świadczy o złym stylu.
Program rekurencyjny
#include <iostream.h>
long fib(int i)
{ return i<=1? 1 : fib(i-1)+fib(i-2);
}
main()
{int n;
cin >> n;
cout << fib(n) << endl;
}
Ostrzeżenie
Co prawda rekurencja stanowi bardzo naturalną metodę obliczania liczb Fibonacciego, to jednak nie powinna
być tu stosowana. Poniższy rysunek przedstawia drzewo wywołań funkcji fib podczas obliczania 6-tej liczby
Fibonacciego.
fib(6)
fib(5)
fib(4)
fib(2)
fib(0)
fib(1)
fib(3)
fib(1)
fib(4)
fib(3)
fib(2)
fib(0)
fib(1)
fib(1)
fib(2)
fib(0)
fib(1)
fib(2)
fib(0)
fib(1)
fib(3)
fib(1)
fib(2)
fib(0)
fib(1)
Jak widać w trakcie obliczeń funkcja jest wielokrotnie wywoływana dla tych samych wartości parametru. W
szczególności podczas obliczania Fn funkcja fib zostanie wywołana Fn-2 razy z parametrem 0 i Fn-1 razy z
parametrem 1. W efekcie mamy Fn-1+Fn-2=Fn wywołań funkcji fib!Ponieważ wartości Fn rosną szybko, ten
sposób obliczania jest niedopuszczalnie czasochłonny.
Dla porównania: wersja nierekurencyjna do obliczenia liczby Fn wymaga O(n) operacji.
Uwaga: Istnieje znacznie szybsza metoda obliczania liczb Fibonacciego. Do obliczenia liczby Fn używa ona
O(log n) operacji arytmetycznych. Oczywiście trzeba pamiętać, że będą to operacje na coraz dłuższych liczbach
i sama ich ilość nie świadczy o koszcie całego algorytmu.