Wieczorowe Studia Licencjackie Wrocław, 28.11.2006 Wstęp do programowania Wykład nr 9 (w oparciu o notatki K. Lorysia z modyfikacjami) Sortowanie szybkie (Quicksort) Sortowanie przez scalanie opierało się na technice dziel i zwyciężaj, którą ogólnie scharakteryzować można poprzez wyspecyfikowanie następujących etapów rozwiązywania problemu: a) podział na podproblemy b) rozwiązanie podproblemów c) połączenie rozwiązań podproblemów i uzyskanie rozwiązania problemu głównego. W przypadku sortowania przez scalanie (rekurencyjnego) ciągu a[l..p-1], powyższy schemat realizowany jest następująco: a) podział na podproblemy: wyznaczenie pozycji środkowej s; b) rozwiązanie podproblemów: sortowanie podciągów a[l..s] i a[s+1..p-1] c) połączenie rozwiązań: scalenie podciągów a[l..s] i a[s+1..p-1] Bardzo popularna metoda Quicksort również opiera się na technice dziel i zwyciężaj. Sortowanie ciągu a[l..p-1] możemy opisać następująco: a) podział na podproblemy: wybór elementu x z a[l..p-1] i podział tablicy na elementy większe bądź równe i elementy mniejsze od tego elementu; w efekcie tego a[l..j] zawiera tylko elementy mniejsze od elementów w a[j+1..p-1] dla pewnego j b) rozwiązanie podproblemów: posortowanie podciągów a[l..j] i a[j+1..p-1]. c) połączenie rozwiązań: nie wymaga żadnych dodatkowych czynności! Poniżej znajduje się zapis powyższej idei w postaci funkcji rekurencyjnej: qsort(int d[],int l,int p) { int s; if (l<p) {s=partition(d,l,p); qsort(d,l,s); qsort(d,s+1,p); Funkcja qsort sortuje d[l..p], wykorzystując funkcję partition partition(int d[], int l, int p) { int x,y; x=d[l]; l--; p++; do { while (d[--p]<x); while (d[++l]>x); if (l<p) {y=d[p]; d[p]=d[l]; d[l]=y; else return p; while (1); której wartością jest taka liczba s, że po wykonaniu tej funkcji: - na pozycjach l..s znajdują się elementy mniejsze od x bądź równe x;
- na pozycjach s+1..p znajdują się elementy większe bądź równe x. Element x jest równy wartości d[l] przed uruchomieniem funkcji partition. Funkcja partition działa następująco: - przesuwamy się w tablicy t w prawo począwszy od pozycji l, aż do napotkania elementu mniejszego bądź równego x - przesuwamy się w tablicy t w lewo począwszy od pozycji p, aż do napotkania elementu większego bądź równego x - zamieniamy ze sobą elementy znalezione w dwóch powyższych krokach - kontynuujemy powyższe postępowanie począwszy od ostatnio odwiedzonych pozycji, kończymy po przejrzeniu całego obszaru t[l..p]. Koszt czasowy: - w najgorszym przypadku, n 2 (kiedy?) - w średnim przypadku n log n. - w praktyce: najszybszy ze stosowanych algorytmów. Iteracyjna implementacja Quicksort Zauważmy następującą odmienność sortowania przez scalanie i quicksort: - sortowanie przez scalanie pozwala na podział na podzadania w czasie stałym, a quicksort wymaga użycia partition, które działa w czasie liniowym - łączenie rozwiązań w przypadku sortowania przez scalanie wymaga scalenia posortowanych podciągów, natomiast w przypadku quicksort nie wymaga żadnych dodatkowych czynności. Oznacza to, że w przypadku (iteracyjnego) quicksort nie możemy zacząć od wykonywania najmniejszych podzadań, tak jak to robiliśmy w iteracyjnej wersji sortowania przez scalanie; Nasza metoda polegać będzie na naśladowaniu rekurencyjnej wersji quicksort w taki sposób, że: - dla każdej pary rekurencyjnych wywołań qsort(d,l,s) i qsort(d,s+1,p) w treści qsort(d,l,p), na stosie odkładamy parametry wywołania (s+1,p) a wywołanie qsort(d,l,s) czynimy wywołaniem aktualnym (poprzez nadanie odpowiednich wartości zmiennym l i p) - stos implementujemy przy pomocy dwóch tablic stosl[n] i stosp[n] na których umieszczamy lewe i prawe końce przedziałów do posortowania, dokładamy zawsze na koniec i pobieramy z końca; - po dojściu do przedziału długości jeden, pobieramy nowy przedział do posortowania z naszego stosu (czyniąc go przedziałem aktualnym ); - powyższe postępowanie kończymy gdy stos nie zawiera już danych o żadnych przedziałach do posortowania (zmienna pos wskazuje na ostatnią pozycję na stosie; pos<0 oznaczać będzie pusty stos). qsort(int d[],int l,int p) { int s, stosl[n], stosp[n], pos; pos=0; do { if (l==p) { pos--; if (pos>=0) { l=stosl[pos]; p=stosp[pos]; else { s=partition(d,l,p); stosl[pos]=s+1; stosp[pos++]=p; p=s; while(pos>=0);
Algorytmy z nawrotami (backtracking) Algorytmy z nawrotami poznamy na przykładzie dwóch problemów szachowych : rozstawienia hetmanów i wędrówki skoczka po szachownicy. Rozstawienie hetmanów Problem polega na rozstawieniu n hetmanów na szachownicy n n w taki sposób, by się nawzajem nie atakowały. Przypomnijmy, że hetman atakuje każde pole znajdujące się bądź w tym samym wierszu co on, bądź w tej samej kolumnie, bądź na jednej z dwóch przekątnych (patrz rysunek poniżej). Rys.1. Kółkiem oznaczone są pola atakowane przez hetmana znajdującego się na polu z literą Jak łatwo sprawdzić, dla n=2 i 3, rozwiązania nie istnieją. Dla większych wartości n rozwiązania istnieją, co więcej, dla dużych n jest ich bardzo dużo. Chcemy napisać program, który dla zadanego n będzie znajdować którekolwiek z tych rozwiązań. Rys.2. Przykładowe rozwiązania dla n=4 i n=5. Algorytm naiwny mógłby polegać na sprawdzaniu wszystkich możliwych ustawień hetmanów. Ze względu olbrzymią liczbę tych ustawień efektywność tego algorytmu jest co najmniej wątpliwa. Istnieją algorytmy znacznie bardziej efektywne. Bazują one na regularności rozwiązań. Przyglądając się rysunkowi 2 łatwo zauważyć, że w obydwu przedstawionych rozwiązaniach hetmany ustawione są wzdłuż dwóch linii. Łatwo się przekonać (do czego zachęcam czytelnika), że rozwiązania te dają się rozszerzyć na wiele innych wartości n, w szczególności na n nieparzyste. Algorytm z nawrotami, który rozważymy, jest dość prostą modyfikacją algorytmu naiwnego. Przedstawiamy go, by zilustrować klasyczną technikę programowania. Idea algorytmu. Algorytm próbuje ustawiać hetmany w kolejnych kolumnach począwszy od pierwszej kolumny (oczywiście w każdej kolumnie ustawia tylko jednego hetmana). W każdej z kolumn szuka pierwszego pola (począwszy od dolnego), na którym postawienie hetmana nie koliduje z hetmanami z wcześniejszych kolumn. Jeśli takie pole istnieje, ustawia na nim hetmana i przechodzi do kolejnej kolumny. Jeśli nie istnieje - algorytm powraca do
poprzedniej kolumny i próbuje przestawić hetmana na kolejne nie kolidujące pole. Może się zdarzyć, że algorytm będzie musiał cofnąć się aż do pierwszej kolumny. Taka sytuacja pokazana jest na Rysunku 3. Rys.3. W pewnym momencie działania algorytm zachłanny dochodzi do sytuacji przedstawionej obok. Próba znalezienia niekolidującego pola w czwartej kolumnie zakończy się niepowodzeniem. Podobnie zakończą się próby przestawienia hetmanów w 3-ciej, a następnie w 2-giej kolumnie. Skuteczne okaże się dopiero przestawienie hetmana w pierwszej kolumnie Implementacja Sprawdzanie czy pole jest atakowane Jednym z problemów, które będziemy musieli rozwiązać, jest znalezienie metody sprawdzania, czy dane pole (powiedzmy pole <i, j>) szachownicy nie jest atakowane przez hetmany ustawione we wcześniejszych kolumnach. W tym celu musimy sprawdzić, czy żaden z tych hetmanów nie znajduje się: w i-tym wierszu (to jest łatwe); na tych samych przekątnych co pole <i, j>. Jak łatwo zauważyć, pola dla których obydwa indeksy są sobie równe leżą na tej samej przekątnej, idącej od lewego górnego pola do prawego dolnego pola. Tuż nad nią leżą pola, których pierwszy indeks jest o jeden mniejszy od drugiego indeksu, a tuż pod nią pola, których pierwszy indeks jest o jeden większy od drugiego indeksu. Te obserwacje skłaniają nas do uważniejszego przyjrzenia się różnicom i sumom indeksów pól. (patrz Rysunek 4). i= 1 j= 1 2 3 4 5 6 7 8 i-j= i+j= 1 2 3 4 5 6 7 8 2 3 4 5 6 7 8-7 -6-5 -4-3 -2-1 2 3 4 5 6 7 8 i-j= 7 6 5 4 3 2 1 0 i+j= 9 10 11 12 13 14 15 16 Rys.4. Wszystkie pola leżące na tej samej przekątnej idącej z lewego górnego rogu do prawego dolnego rogu mają taką samą sumę indeksów (lewy rysunek). Natomiast pola leżące na tej samej przekątnej idącej z prawego górnego do lewego dolnego rogu maja taką samą różnicę indeksów (prawy rysunek). Tak więc sprawdzenie, czy pole leży na tej samej przekątnej co pole <i,j>, sprowadza się do porównania różnicy indeksów tego pola z wartością i-j oraz sumy jego indeksów z wartością i+j. Pamiętanie rozstawienia hetmanów Może się wydawać, że algorytm musi używać dwuwymiarowej tablicy [1..n,1..n] do pamiętania stanu wszystkich pól szachownicy. Zauważmy jednak, że wystarczy pamiętać pozycje (numery wierszy) hetmanów z kolejnych kolumn. Do tego celu użyjemy jednowymiarowej tablicy w:array[1..n] of integer; Element w[i] będzie równy j, jeśli w i-tej kolumnie hetman został ustawiony w j-tym wierszu. Przykładowo, dla rozstawienia hetmanów z Rysunku 3, tablica w przyjmie wartości: w[i] : 1 4 2 0 i : 1 2 3 4
Program #include <iostream.h> #define n 6 int w[n]; init() //wartosci poczatkowe tablicy w {int i; w[0]=0; for (i=1; i<n; i++) w[i]=-1; wolne(int x, int y) // sprawdzenie, czy pole <x,y> jest atakowane przez // hetmany ustawione w kolumnach 1,2,...,x-1 {int i; for (i=0; i<x; i++) if (w[i]-i==y-x w[i]+i==y+x w[i]==y)return 0; return 1; int hetmany() //algorytm z nawrotami; jeśli uda się //ustawić n hetmanów, funkcja przyjmuje wartość n // w przeciwnym razie przyjmuje wartość -1 {int k; k=1; while (k<n && k>=0) { do {w[k]++; while (w[k]<n &&!wolne(k,w[k])); if (w[k]<n) k++; else {w[k]=-1; k--; return k; main() {int i,j; init(); if (hetmany()==n) { cout << endl; for (i=0; i<n; i++) cout << w[i] << ; cout << endl << endl; for(i=0; i<n; i++) {for(j=0; j<n; j++) if (w[j]==i) cout << "x "); else cout << "o "; cout << endl; else cout << "nie mozna rozstawic hetmanow"; Komentarz do wybranych instrukcji: Funkcja init o w[0]:=0; - ustawienie hetmana z pierwszej kolumny na polu <1,1> o for (i=1; i<n; i++) w[i]=-1; - ustawienie pozostałych hetmanów poza szachownicą. Funkcja wolne o W pętli for (i=0; i<x; i++) sprawdzamy dla kolejnych i, począwszy od i=0, aż do i=x-1, czy hetman z i-tej kolumny nie atakuje pola <x,y>. Pętlę kończymy, jeśli natkniemy się na hetmana atakującego to pole (funkcja przyjmuje wartość 0), bądź też sprawdzimy wszystkie hetmany i żaden z nich nie koliduje (funkcja przyjmuje wartość 1). Funkcja hetmany() o Zmienna k pamięta numer ustawianego hetmana. Zaczynamy od drugiego hetmana (tj. od hetmana z kolumny numer 1). Następnie w każdej iteracji pętli while albo uda się ustawić k- tego hetmana (wówczas zmienną k należy zwiększyć o 1) albo sztuka ta się nie uda (wówczas k należy zmniejszyć o 1). o Teraz możemy docenić zalety nadania w procedurze init wartości -1 elementom tablicy w. Dzięki temu możemy teraz w pętli while jednolicie potraktować sytuację, w której hetmana z k-tej kolumny wstawiamy dopiero na szachownicę i taką, w której hetman ten już stał, lecz musimy poprawić jego pozycję (tj. przesunąć na następne nie atakowane pole w tej kolumnie), ponieważ przy obecnym ustawieniu, niepowodzeniem zakończyły się próby ustawienia hetmanów w dalszych kolumnach. W obydwu przypadkach wystarczy wartość w[k] zwiększać o jeden. Realizowane jest to w pętli: do {w[k]++; while (w[k]<n && wolne(k,w[k])==0); Kolejne instrukcje w[k]++ przesuwają hetmana po kolejnych polach k-tej kolumny tak długo, aż napotkamy nie atakowane pole bądź wyskoczymy hetmanem poza szachownicę. To, z którą sytuacją mamy do czynienia badane jest w instrukcji if (w[k]<n) k++; else {w[k]=-1; k--;
Jeśli w[k]<n, to hetmana w k-tej kolumnie udało się ustawić, a więc należy przejść do następnej kolumny (k++); w przeciwnym razie musimy cofnąć się do poprzedniej kolumny (k--), a wartość w[k] należy ustawić na -1, co odpowiada temu, że k-ty hetman jest poza szachownicą. Poniżej hetmany rekurencyjnie: int hetmany(int k) { // k numer kolumny if (k<n) { w[k]=-1; while (w[k]<n) { do {w[k]++; while (w[k]<n && wolne(k,w[k])==0); if (w[k]<n) { if (hetmany(k+1)) return n; else return -1; else return n;