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.