Znajdowanie liczby najmniejszej:

advertisement
Przegląd i analiza algorytmów sortowania
Sortowanie obok przeszukiwania jest jednym z najczęściej
spotykanych zadań informatycznych. Problem polega zwykle na
uporządkowaniu zestawu rekordów (np. danych personalnych) na
podstawie wartości wybranego klucza pola klucza (np. numeru
PESEL).
W naszym wypadku będziemy zajmować się głównie sortowaniem
zbiorów liczb, ale nie narzuca to żadnego ograniczenia stosowanym
algorytmom.
Problem sortowania można opisać następująco.
Mając tablicę S zawierającą n kluczy na pozycjach od 1 do n.
Zadaniem naszym jest ułożenie liczb (rekordów) w porządku
niemalejącym na pozycjach od 1 do n.
Dlaczego sortowanie ?
----------------------------------------------------------------------------------Istnieje wiele różnorodnych i ciekawych algorytmów sortowania, co
za tym idzie można porównując te algorytmy dowiedzieć się, w jaki
sposób należy wybierać najlepszy spośród wielu algorytmów
rozwiązujących ten sam problem oraz jak można udoskonalić dany
algorytm.
Po drugie, problem sortowania jest jednym z niewielu problemów,
dla których udało się stworzyć algorytmy o złożoności zbliżonej do
dolnego ograniczenia. Dla szerokiej klasy algorytmów sortowania
dolne ograniczenie to O(n*lgn) i znamy algorytmy o takiej właśnie
złożoności.
Można powiedzieć, że dopóki rozważamy tę klasę algorytmów,
problem sortowania rozwiązaliśmy w satysfakcjonujący sposób.
Do klasy algorytmów sortowania dla których udało nam się
stworzyć algorytmy o złożoności bardzo zbliżonej do dolnego
ograniczenia, należą wszystkie algorytmy sortujące wyłącznie na
podstawie operacji porównania kluczy.
Algorytmy sortujące wyłącznie w oparciu o porównanie kluczy
mogą porównywać dwa klucze celem określenia, który z nich
jest większy, oraz kopiować klucze, ale nie mogą wykonywać na
kluczach żadnych innych operacji.
Należy zwrócić uwagę że algorytmy sortowania należy analizować
nie tylko w oparciu o liczbę wykonanych porównań, ale też liczbę
przypisań. Dodatkowo należy także zbadać ilość dodatkowej
przestrzeni pamięciowej wymaganej przez algorytmy, poza
przestrzenią niezbędną do przechowywania danych wyjściowych.
Jeśli dodatkowa przestrzeń w algorytmie jest stała, tzn. nie zwiększa
się wraz ze wzrostem n, czyli liczby sortowanych kluczy, algorytm
nazywamy sortowaniem bezpośrednim.
Zaczynamy od przykładów najprostszych.
Sortowanie przez podstawienie.
Problem: posortuj n kluczy w porządku niemalejącym
Dane: całkowita liczba dodatnia n, tablica kluczy S indeksowana od
1 do n
Wynik: tablica S, zawierająca klucze w porządku niemalejącym
void exchange_sort(int n, keytype S[])
{
index i, j;
keytype x;
for(i=1; i<=n-1; i++)
for(j=i+1; j<=n; j++)
if(S[j] < S[i])
{x=S[j]; S[j]=S[i]; S[i]=x}
„Wyświetlanie tablicy S”
}
1) Metoda polega na porównaniu liczby z pozycji i-tej z liczbami z
pozycji od (i+1)-tej do n-tej.
2) Kiedy liczba na danej pozycji okazuje się mniejsza od liczby z
pozycji i-tej, obie liczby zostają zamienione.
3) W pierwszej iteracji pętli for-i najmniejsza liczba jest
umieszczana na pierwszej pozycji, kolejna najmniejsza liczba
odnaleziona zostaje umieszczona na pozycji drugiej etc.
Analiza algorytmu.
Analiza algorytmu bazuje na obserwacji , że mamy do czynienia z
podwójną pętlą.
Jeśli jako operacje dominująca wybierzemy porównanie:
- w pierwszym (i=1) przebiegu pętli for-j warunek sprawdzany jest
n-1 razy
- w drugim przebiegu (i=2) warunek sprawdzany jest n-2 razy, etc.
Zatem dla instrukcji porównania jako instrukcji dominującej
złożoność w każdym przypadku:
T(n) = 1+ 2 + 3 + … + n-2 + n-1 = (n-1)*n/2 ≈ n2/2
T(n) ≈ n2/2
Jeśli jako operacje dominująca wybierzemy przypisanie:
- przy każdym spełnionym warunku wykonywane jest 3 przypisania.
Zatem w najgorszym wypadku, gdy zawsze spełniony jest warunek:
W(n) ≈ 3n2/2
Jeśli liczby są zupełnie przypadkowe to w średnim wypadku tylko
połowa warunków będzie spełniona i nastąpi połowa przypisań, a
zatem
A(n) ≈ (3n2/2)/2 ≈ 3n2/4
Sortowanie przez wstawianie.
Problem: posortuj n kluczy w porządku niemalejącym
Dane: całkowita liczba dodatnia n, tablica kluczy S indeksowana od
1 do n
Wynik: tablica S, zawierająca klucze w porządku niemalejącym
void insertion_sort(int n, keytype S[])
{
index i, j;
keytype x;
for(i=2; i<=n; i++) {
x = S[i];
j = i-1;
while(j>0 && S[j]>x) {
S[j+1] = S[j];
j--;
}
S[j+1] = x;
}
„Wyświetlanie tablicy S”
}
Analiza algorytmu.
Złożoność czasowa w najgorszym przypadku.
Podstawowa operacja: porównanie wartości klucza S[j]
z wartością x.
Rozmiar danych: n, czyli liczba kluczy do posortowania.
1) Dla danych wartości i operacja porównania S[j] > x jest
wykonywana najczęściej w momencie opuszczania pętli while z
powodu przypisania zmiennej j wartości 0.
2) Zatem operacja porównania jest wykonywana co najwyżej i-1
razy dla danego i.
3) Ponieważ i przyjmuje wartości do 2 do n, łączna liczba operacji
porównania wynosi co najwyżej:
Ʃni=2 (i-1) = n*(n-1)/2
Zatem
W(n) = n*(n-1)/2
Złożoność czasowa w średnim przypadku.
Dla każdej wartości i istnieje i pozycji, na które można wstawić
klucz o wartości x. Oznacza to, że wartość x może pozostać na i-tej
pozycji lub może zostać przeniesiona na pozycję i-1 lub i-2 lub ...1.
Wstawienie x na każdą z pozycji jest jednakowo prawdopodobne z
prawdopodobieństwem 1/i.
Liczba operacji porównania dla wartości x wstawianej na
poszczególne pozycje wynosi:
Pozycja
Liczba porównań
i
1
i-1
2
…
2
i-1
1
i-1 ← jeśli j=0 to drugi warunek w while
w ogóle nie jest sprawdzany.
Dla danej wartości i średnia liczba wykonywanych operacji
porównania, niezbędnych do wstawienia wartości x wynosi:
i-1
1*(1/i) + 2*(1/i) + …+ (i-1)*(1/i) + (i-1)*(1/i) = (1/i)* Σk=1k +(i-1)/i
= (1/i)*(i-1)*i/2 + (i-1)/i = (i+1)/2 – 1/i
Średnia liczba wykonywanych operacji porównania, niezbędnych do
posortowania całej tablicy wynosi:
n
n
n
Σi=2( (i+1)/2 – 1/i ) = Σi=2(i+1)/2 – Σi=2(1/i) ≈ (n+4)(n-1)/4 – lnn
≈ n2/4
Zatem:
A(n) = (n+4)(n-1)/4 – lnn ≈ n2/4
Podobnie można pokazać,że jeśli wybierzemy operację przypisania
jako operację dominującą to liczba takich operacji wyniesie:
w najgorszym przypadku
W(n)=(n+4)(n-1)/2 ≈ n2/2
w średnim przypadku
A(n) = n(n+7)/4 – 1 ≈ n2/4
Jedyną przestrzenią pamięciową, powiększającą się wraz ze
wzrostem wartości n, jest rozmiar wejściowej tablicy S. Algorytm
sortowania przez wstawianie jest algorytmem bezpośrednim,
w którym dodatkowa przestrzeń pamięci wynosi O(1).
Wprowadźmy teraz znaczącą modyfikację do algorytmu sortowania
przez podstawianie i utwórzmy algorytm przez wybieranie.
Sortowanie przez wybieranie.
Problem: posortuj n kluczy w porządku niemalejącym
Dane: całkowita liczba dodatnia n, tablica kluczy S indeksowana od
1 do n
Wynik: tablica S, zawierająca klucze w porządku niemalejącym
void selection_sort(int n, keytype S[])
{
index i, j, smallest;
keytype x;
for(i=1; i<=n-1; i++) {
smallest=i;
for(j=i+1; j<=n; j++)
if(S[j] < S[smallest])
smallest = j;
x=S[smallest]; S[smallest]=S[i]; S[i]=x}
}
„Wyświetlanie tablicy S”
}
Analiza algorytmu.
Złożość w każdym przypadku.
Przyjmując porównanie za operację dominującą możemy
powiedzieć, że algorytm ma taką samą złożoność czasową jak
algorytm sortowania przez podstawienie, tj.
T(n) ≈ n2/2
Przyjmując przypisanie za operację dominującą, występuje duża
różnica w porównaniu z algorytmem sortowania przez podstawienie:
- zamiast wymieniać rekordy S[i] i S[j] za każdym razem, gdy
okazuje się że S[j] jest mniejsze od S[i], algorytm sortowania przez
wybieranie jedynie rejestruje indeks najmniejszego znalezionego
klucza wśród kluczy na pozycjach od i-tej do n-tej
- po znalezieniu takiego rekordu algorytm zamienia go z rekordem
na i-tej pozycji.
Zatem w każdej kolejnej iteracji pętli for algorytm wstawia kolejny
najmniejszy klucz na kolejną pozycje począwszy od pozycji 1 i w
każdej iteracji wykonywana jest tylko jedna zamiana kluczy.
Cała operacja sortowania wymaga dokładnie n-1 takich zamian.
Przyjmując że każda zamiana to 3 operacje przypisania, a zatem:
T(n) = 3(n-1)
Porównanie najprostszych algorytmów sortowania.
Algorytm
Porównanie
kluczy
Sortowanie przez
T(n) ≈ n2/2
podstawienia
Przypisanie
rekordów
W(n) ≈ 3n2/2
A(n) ≈ 3n2/4
Dodatkowa
pamięć
Algorytm
bezpośredni
----------------------------------------------------------------------------------Sortowanie przez
W(n) ≈ n2/2
W(n) ≈ n2/2
Algorytm
2
2
wstawianie
A(n) ≈ n /4
A(n) ≈ n /4
bezpośredni
-------------------------------------------------------------------------------------------Sortowanie przez
T(n) ≈ n2/2
T(n) ≈ 3n
Algorytm
wybieranie
bezpośredni
Algorytm sortowania przez wstawianie vs. przez podstawienia.
W zakresie liczby wykonywanych operacji porównywania kluczy
algorytm sortowania przez wstawianie nigdy nie jest mniej wydajny od
algorytmu przez podstawienie, a w średnim przypadku jest lepszy.
W zakresie liczby wykonywanych operacji przypisania rekordów
algorytm sortowania przez wstawianie jest lepszy od algorytmu
sortowania przez podstawienie zarówno w najgorszym jak i średnim
przypadku.
Ponieważ obie metody są algorytmami bezpośrednimi można stwierdzić
że algorytm sortowania przez wstawianie jest lepszy.
Algorytm sortowania przez wybieranie vs. przez podstawienia.
W zakresie liczby wykonywanych operacji porównywania kluczy
algorytmy sortowania przez podstawienie i algorytm sortowania przez
wybieranie są jednakowo wydajne.
W zakresie liczby wykonywanych operacji przypisania rekordów
algorytm sortowania przez wybieranie jest liniowy w n w
przeciwieństwie do dwóch pozostałych które reprezentują zależność n2.
Należy jednak pamiętać, że w niektórych sytuacjach algorytm sortowania
przez podstawienie sprawdza się lepiej niż algorytm sortowania przez
wybieranie. Jest tak np. gdy rekordy są już posortowane gdyż wówczas
algorytm sortowania przez podstawienie nie wykonuje żadnych operacji
przypisania.
Algorytm sortowania przez wybieranie vs. przez wstawianie:
− w zakresie liczby wykonywanych operacji porównywania wartości
kluczy algorytm sortowania przez wstawianie jest przynajmniej
tak dobry jak algorytm sortownia przez wybieranie, a w średnim
wypadku jest nawet nieco lepszy
− w zakresie liczby wykonywanych operacji przypisania rekordów
złożoność algorytmu sortowania przez wybieranie jest liniowa w n
podczas gdy w przypadku algorytmu sortowania przez wstawianie
jest kwadratowa
Różnica między złożonością liniową, a kwadratową jest szczególnie
istotna w przypadku wielkich wartości n. Jeśli więc wartość n jest duża
i duże są rozmiary rekordów (przez co czas wykonania operacji
przypisania jest znaczący), algorytm sortowania przez wybieranie
powinien okazać się bardziej wydajny.
W praktyce żaden z wymienionych algorytmów nie jest przydatny w
przypadku ogromnych danych wejściowych, ponieważ złożoność czasowa
wszystkich trzech jest kwadratowa zarówno w średnim, jak i najgorszym
przypadku.
Czy możemy poprawić kwadratową złożoność czasową dla ilości
operacji porównania jeśli ograniczymy się do algorytmów należących
do tej samej klasy jak trzy wymienione ?
Klasa o której mowa to algorytmy usuwające co najwyżej jedną inwersję
dla jednej operacji porównania przy czym inwersją jest taki układ pary
kluczy, że (ki , kj), i < j, ki > kj .
Można pokazać (choć dowód wymaga zrozumienia pojęcia permutacja i
kilku twierdzeń), że każdy algorytm sortujący n różnych kluczy wyłącznie
w oparciu o wyniki porównań ich wartości i usuwających co najwyżej jedną
inwersję po każdym porównaniu:
w najgorszym przypadku wykonuje co najwyżej
n*(n-1)/2 porównań kluczy
a w średnim przypadku wykonuje co najmniej
n*(n-1)/4 porównań kluczy.
INNE ALGORYTMY SORTOWANIA.
Sortowanie przez scalanie (przypomnienie).
Wersja 1.
funkcja sortująca
void mergesort2(index low, index high)
{
index mid;
}
if(low<high) {
mid = (low+high)/2;
mergesort2(low,mid);
mergesort2(mid+1,high);
merge2(low,mid,high);
}
funkcja scalająca
void merge2(index low, index mid, index high)
{
index i,j,k;
keytype U[low..high]; // Tablica lokalna do scalania
i=low;j=mid+1;k=low;
while(i <= mid && j <= high) {
if(S[i]<S[j]) {
U[k] = S[i];
i++;
}
else {
U[k] = S[j];
j++;
}
k++;
}
if(i>mid)
przenies S[j] do S[high] na miejsce U[k] do U[high];
else
przenies S[i] do S[mid] na miejsce U[k] do U[high];
przenies U[low] do U[high] na miejsce S[low] do S[high];
}
Analizując powyższy przykład stwierdzamy:
− gdy scalane są pod-tablice [3 4] i [1 2], po porównaniu usuwana jest
więcej niż jedna inwersja. Po porównaniu liczb 3 i 1 liczba 1 jest
umieszczana na pierwszej pozycji w tablicy, dzięki temu usuwane są
inwersje (3,1) i (4,1). Po porównaniu liczb 3 i 2 liczba 2 jest
umieszczana na drugiej pozycji w tablicy, co jest równoznaczne z
usunięciem inwersji (3, 2) i (4, 2).
Złożoność czasowa algorytmu ze względu na liczbę operacji porównania
w najgorszym przypadku wynosi:
W(n) = n*lgn – (n-1)
gdzie n jest potęgą 2.
Korzystając z funkcji tworzącej można wykazać, że złożoność czasowa
algorytmu ze względu na liczbę operacji porównania w średnim wypadku
wynosi:
lgn
A(n) = n*lgn - 2*n*Σ 1/(2i+2) ≈ n*lgn – 1.26*n
i=1
gdzie n jest potęgą 2.
Złożoność czasowa algorytmu ze względu na liczbę operacji przypisania
w każdym przypadku wynosi:
T(n) ≈ 2*n*lgn
Analiza wykorzystania dodatkowej pamięci
Algorytm sortowania przez scalanie wymaga dodatkowego zastosowania
całej tablicy o rozmiarze n. Kiedy algorytm sortuje pierwszą pod-tablicę,
wartości na pozycjach mid, mid+1, low i high muszą być przechowywane
na stosie rekordów aktywacji. Ponieważ tablica jest zawsze dzielona na
dwie równe części, rozmiar stosu rośnie do osiągnięcia głębokości lgn .
Przestrzeń wykorzystywana przez dodatkową tablicę rekordów dominuje, co
oznacza, że w każdym przypadku rozmiar dodatkowej pamięci jest nie
większa niż Θ(n) (liczba rekordów nie przekracza Θ(n) ).
Udoskonalony algorytm sortowania przez scalanie.
Sposób 1: wykorzystanie programowania dynamicznego.
Rozpoczynamy od początku od zbiorów jednoelementowych, scalamy je w
zbiory dwu-elementowe, te grupujemy w czwórki etc. Konstruujemy w ten
sposób wersję iteracyjną i unikamy kosztów związanych z operacjami na
stosie.
Problem: sortowanie n kluczy w porządku niemalejącym
Dane: n liczb całkowitych dodatnich, tablica kluczy S, indeksowanych od 1
do n
Wynik: tablica S, zawierająca klucze uporządkowane w kolejności
niemalejącej
void mergesort3(int n, keytype S[])
{
int m;
index low, mid, high, size;
// Traktuj rozmiar tablicy jako potęgę liczby 2.
lgn
m = 2
;
// Zmienna size reprezentuje rozmiar scalanych pod-tablic
size = 1;
repeat(lgm razy) {
for (low = 1; low <= m – 2*size+1; low = low+2*size) {
mid = low+size -1;
high = minimum(low+2*size – 1, n);
// Nie scalamy powyżej n
merge3(low,mid,high,S);
}
// Zwiekszamy dwukrotnie rozmiar pod-tablic
size = 2*size;
}
}
Gdzie funkcja merge3 może wyglądać podobnie jak merge2.
Umiejętne zapisanie scalania pozwala również zmniejszyć liczbę operacji
przypisania rekordów, tak że w każdym przypadku złożoność czasowa ze
względu na ilość operacji przypisania wynosi:
T(n) ≈ n*lgn
zatem dwukrotnie mniej niż w wersji standardowej.
Sposób2:
wykorzystanie
(wskaźników).
jednokierunkowej
listy
dowiązaniowej
Problem: sortowanie n kluczy w porządku niemalejącym
Dane: n liczb całkowitych dodatnich, tablica S, zawierająca rekordy danego
typu, indeksowane od 1 do n
Wynik: tablica S, zawierająca wartości pola key, uporządkowane w
kolejności niemalejącej. Rekordy znajdują się na posortowanej
jednokierunkowej liście (pole link).
struct node
{
keytype key;
index link;
};
void mergesort4(index low, index high, index& mergedlist)
{
index mid, list1, list2;
if(low==high) {
mergedlist=low;
S[mergedlist].link=0;
}
else {
mid= (low+high)/2;
mergesort4(low, mid, list1);
mergesort4(mid+1, high, list2);
merge4(list1, list2, mergedlist);
}
}
void merge4(index list1, index list2, index& mergedlist)
{
index lastsorted;
if(S[list1].key < S[list2].key) { //znajdz pocz scalanej listy
mergedlist = list1;
list1 = S[list1].link;
}
else {
mergedlist = list2;
list2 = S[list2].link;
}
lastsorted = mergedlist;
while ( list1 !=0 && list2 != 0)
if(S[list1].key < S[list2].key) { // dolacz mniejszy klucz do
// scalanej listy
S[lastsorted].link = list1;
lastsorted = list1;
list1 = S[last1].link;
}
else {
S[lastsorted].link = list2;
lastsorted = list2;
list2 = S[last2].link;
}
}
if(list1 == 0)
S[lastsorted].link = list2;
else
S[lastsorted].link = list1;
Wywołanie w programie głównym powinno mieć postać:
gdzie
mergesort4(1, n, listfront);
listfront będzie indeksem pierwszego rekordu
b
posortowanej listy.
Rozwiązanie powyższe jest szczególnie przydatne gdy sortujemy duże
rekordy, bo wówczas dodatkowa przestrzeń pamięci wykorzystywana przez
algorytm standardowy może być znaczna.
Sortowanie rekordów do postaci posortowanej listy jednokierunkowej
operuje tylko na dowiązaniach (wskaźnikach), nie ruszając samych
rekordów.
Można w ten sposób ograniczyć znacznie ilość używanej pamięci, gdyż
przestrzeń wymagana do przechowywania dowiązania jest znacznie
mniejsza od przestrzeni niezbędnej do przechowywania dużego rekordu.
Ograniczamy w ten sposób również czas działania, gdyż operacje na
dowiązaniach są znacznie szybsze niż przenoszenie dużych rekordów.
Kiedy rekordy są już posortowane można następnie umieścić je w tablicy co
wymaga dodatkowo algorytmu klasy Θ(n) biorąc pod uwagę operacje
przypisania.
Korzyści z wykorzystania listy:
– eliminacja konieczności przechowywania n dodatkowych rekordów na
rzecz przechowywania tylko n dowiązań
– ograniczenie złożoności czasowej liczby operacji przypisywania
rekordów do zera, lub Θ(n) gdy chcemy zwracać posortowane rekordy
w postaci tablicy
Sortowanie szybkie (przypomnienie).
void quicksort(index low, index high)
{
index povotpoint;
if(high > low) {
partition(low, high, pivotpoint);
quicksort(low, pivotpoint-1);
quicksort(pivotpoint+1, high);
}
}
Złożoność czasowa w najgorszym przypadku dla operacji porównania:
W(n) ≈ n*(n-1)/2
Złożoność czasowa w średnim przypadku dla operacji porównania:
A(n) ≈ 1.38*(n+1)*lgn
Cechy charakterystyczne sortowania szybkiego:
- kwadratowa złożoność czasowa w najgorszym przypadku
- złożoność czasowa w średnim przypadku niewiele gorsza od algorytmu
sortowania przez scalanie
- przewaga nad algorytmem sortowania przez scalanie polegająca na
wyeliminowaniu konieczności wykorzystania dodatkowej tablicy
- sortowanie nie jest bezpośrednie, bo podczas sortowania pierwszej
pod-tablicy pierwszy i ostatni indeks drugiej pod-tablicy jest
przechowywany na stosie rekordów aktywacji
- algorytm w przeciwieństwie do sortowania przez scalanie nie daje
gwarancji podziału tablicy pośrodku, co skutkuje znaczącym wzrostem
złożoności czasowej w najgorszym przypadku.
- w najgorszym przypadku na stosie znajduje się n-1 par indeksów, co
oznacza że wykorzystanie dodatkowej pamięci wynosi wówczas Θ(n)
Średnia liczba operacji zamian rekordów w algorytmie szybkiego
sortowania wynosi około 0.69*(n+1)*lgn.
Zakładając że operacja zamiany rekordów wymaga trzech przypisań, można
stwierdzić że złożoność czasowa w średnim przypadku dla operacji
przypisania wynosi:
A(n) ≈ 2.07*(n+1)*lgn
Udoskonalony algorytm sortowania szybkiego.
Algorytm sortowania szybkiego można udoskonalić
wykorzystanie dodatkowej pamięci) na kilka sposobów:
(zmniejszając
- w procedurze quicksort określić która pod-tablica jest większa, umieszczać
inf o tej pod-tablicy na stosie, a sortować tą drugą. Powoduje to że
najgorszy przypadek występuje, gdy tablica jest zawsze dzielona dokładnie
na połowy i wówczas głębokość stosu jest nie większa niż Θ(lgn)
- zbudować wersję procedury partition, która ogranicza średnią liczbę
wykonywanych operacji przypisania rekordów, tak że w średnim przypadku
wynosi
A(n) ≈ 0.69*(n+1)*lgn
void partition(index low, index high index& pivotpoint)
{
index i,j;
keytype pivotitem;
pivotitem=S[low];
i = low;
j = high+1;
do
i++;
while(i<high && S[i]<=pivotitem);
do
j--;
while(S[j]>pivotitem);
while(i<j) {
zamień S[i] i S[j];
do
i++;
while(S[i]<=pivotitem);
do
j--;
while(S[j]>pivotitem);
}
pivotpoint = j;
zamień S[low] i S[pivotpoint];//umieszcza pivotitem w pivotpoint
}
- można uniknąć niepotrzebnych operacji odkładania na stosie poprzez
zbudowanie iteracyjnej wersji procedury quicksort i manipulując stosem z
poziomu tej procedury (tzn. jawnego umieszczania i zdejmowania wartości
ze stosu)
- wyznaczyć wartość progową dla której algorytm wywołuje procedurę
iteracyjną zamiast dalej dzielić przetwarzane dane
- wybór jako punktu podziału (pivotitem) mediany z liczb S[low],
S[(low+high)/2], S[high] zamiast S[low]. Pozwala to uniknąć najgorszego
przypadku, gdy podana tablica jest już posortowana lub bliska
posortowania. Daje to też pewność, że dopóki mamy trzy różne wartości,
żadna z pod-tablic nie będzie pusta.
Stogi i algorytm sortowania stogowego.
Przypomnienie: głębokość węzła w drzewie to liczba krawędzi leżących na
unikalnej ścieżce prowadzącej od korzenia drzewa do tego węzła, głębokość
d drzewa to maksymalna głębokość wszystkich węzłów, a liściem w
drzewie nazywamy węzeł nie mający potomków.
Węzłem wewnętrznym drzewa jest dowolny węzeł, mający przynajmniej
jednego potomka (każdy węzeł nie będący liściem jest węzłem
wewnętrznym).
Pełnym drzewem binarnym nazywamy drzewo binarne, spełniające
warunki:
- wszystkie wewnętrzne węzły mają dwóch potomków
- głębokość wszystkich liści jest równa d
Częściowo pełnym drzewem binarnym nazywamy drzewo binarne,
spełniające warunki:
- drzewo jest pełnym drzewem binarnym do głębokości d-1
- wszystkie węzły na głębokości d znajdują się po lewej stronie
Stogiem nazywamy częściowo pełne drzewo binarne, spełniające warunki:
– wartości przechowywane w węzłach należą do zbioru
uporządkowanego
– wartość przechowywana w każdym węźle jest większa lub równa
wartościom przechowywanym w jego węzłach potomnych (własność
stogu)
Operacja sprowadzania klucza z korzenia rozpoczyna się od porównania
jego wartości z większym z pary kluczy w dwóch węzłach potomnych
korzenia.
Jeśli klucz w korzeniu jest mniejszy to wartości są zamieniane ze sobą.
Proces ten jest powtarzany w coraz niższych poziomach drzewa, aż klucz w
sprawdzanym węźle nie będzie mniejszy od większego z kluczy
przechowywanych w węzłach potomnych.
Proces ten jest realizowany przez procedurę shiftdown:
void shiftdown(heap& H) // H początkowo spełnia własność stogu
{
// we wszystkich węzłach z wyj. korzenia
node parent, largerchild; // H na końcu jest stogiem
parent=korzen stogu H;
largerchild=węzeł potomny węzła parent,zawierajacy wiekszy klucz;
while (klucz w parent jest mniejszy od klucza w largerchild)
{zamien klucz w parent z kluczem w largerchild;
parent = largerchild;
largerchild = wezel potomny parent,zawierajacy wiekszy klucz;
}
}
Zaś kod wysokiego poziomu usuwający klucz z korzenia i przywracający
własność stogu możemy sformułować:
keytype root(heap& H)
{
keytype keyout;
keyout = klucz w korzeniu root;
przenies klucz z najniższego skrajnie prawego węzła do root;
usuń najniższy wezeł;
shiftdown(H);
return keyout;
}
Możemy również zbudować kod umieszczający klucze ze stogu (n kluczy)
w posortowanej tablicy S:
void removekeys(int n, heap H, keytype S[])
{
index j
for(j=n;j>=1;j--)
S[j]=root(H);
}
Jeżeli klucze tworzą już częściowo pełne drzewo binarne to możemy je
przekształcić w stóg przez wielokrotne wywołanie procedury shiftdown.
Po pierwsze należy przekształcić w stogi wszystkie poddrzewa, których
korzenie mają głębokość d-1; po drugie należy przekształcić w stogi
wszystkie poddrzewa, których korzenie mają głębokość d-2, etc.
Zrealizujemy to za pomocą procedury makeheap:
void makeheap(int n, heap& H) // H przekształcamy w stóg
{
index j;
heap Hsub;
for(j=d-1;j>=0;j--)
for(wszystkie poddrzewa Hsub o korzeniach głębokości j)
shiftdown(Hsub);
}
8
1
Możemy teraz przedstawić pseudo-kod sortowania stogowego (zakładamy
że klucze tworzą częściowo pełne drzewo):
void heapsort(int n, heap H, keytype S[]) //H jest przekształcane
{
// w stóg
makeheap(n,H);
removekeys(n,H,S);
}
Algorytm sortowania stogowego jest algorytmem bezpośrednim (tzn. nie
wykorzystuje dodatkowej pamięci) jeśli zaimplementujemy stóg za pomocą
tablicy i ta sama tablica, która przechowuje dane wejściowe, jest
wykorzystywana jako stóg oraz żadnej pozycji w tablicy nie
wykorzystujemy do więcej niż jednego celu.
Częściowo pełne drzewo binarne możemy przedstawić za pomocą tablicy,
umieszczając na pierwszej pozycji korzeń, na drugiej i trzeciej pozycji lewy
i prawy węzeł potomny korzenia itd :
Indeks lewego potomka węzła jest dwukrotnie większy od indeksu tego
węzła, indeks prawego potomka jest dwukrotnie większy od indeksu węzła
+ 1. Jako punkt startowy możemy umieścić w tablicy klucze w dowolnej
kolejności, tak że będą one tworzyły strukturę częściowo pełnego drzewa
binarnego.
Reprezentacja stogu (struktura danych) i programy składowe sortowania
stogowego:
struct heap
{
keytype S[1..n];
int heapsize;
}
-----------------------------------------
void shiftdown(heap& H, index j)
{
index parent, largerchild;
keytype shiftkey;
bool spotfound;
shiftkey=H.S[j];
parent=j;
spotfound=false;
while(2*parent<=H.heapsize && !spotfound) {
if(2*parent < H.heapsize && H.S[2*parent]<H.S[2*parent+1])
largerchild = 2*parent+1;
else
largerchild=2*parent;
if(shiftkey < H.S[largerchild]) {
H.S[parent]=H.S[largerchild];
parent=largerchild
}
else
spotfound=true;
}
H.S[parent]=shiftkey;
}
keytype root(heap& H)
{
keytype keyout;
keyout=H.S[1];
H.S[1]=H.S[heapsize];
H.heapsize=H.heapsize-1;
shiftdown(H,1);
return keyout;
}
void removekeys(int n, heap& H, keytype S[])
{
index j;
for(j=n;j>=1;j--)
S[j]=root(H);
}
void makeheap(int n, heap& H)
{
index j;
H.heapsize=n;
for(j=n/2;j>=1;j--)
shiftdown(H,j);
}
oraz samego sortowania stogowego.
Sortowanie stogowe:
Problem: sortowanie n kluczy w porządku niemalejącym
Dane: n liczb całkowitych dodatnich, tablica zawierająca n kluczy
przechowywanych w tablicowej implementacji stogu
Wynik: tablica H.S, zawierająca klucze uporządkowane w kolejności
niemalejącej
void heapsort(int n, heap& S)
{
makeheap(n,H);
removekeys(n,H,H.S);
}
Działanie programu:
– zakładamy, że klucze do posortowania znajdują się już w tablicy H.S,
tzn. tworzą strukturę częściowo pełnego drzewa binarnego.
– po przekształceniu drzewa w stóg klucze są z niego usuwane,
począwszy od n-tej pozycji w tablicy w dół aż do pierwszej pozycji
Porównanie algorytmów sortowania o złożoności Θ(n*lgn).
Algorytm
Porównanie
kluczy
Sortowanie przez
W(n)≈n*lgn
scalanie
A(n)≈n*lgn
Przypisanie
rekordów
T(n)≈2 n*lgn
Dodatkowa
pamięć
Θ(n) rekordów
----------------------------------------------------------------------------------Sortowanie przez
W(n)≈n*lgn
T(n)≈0 lub
Θ(n) powiązań
scalanie z listą
A(n)≈n*lgn
Θ(n) jeśli
jednokierunkową
wymagamy tablicy
-------------------------------------------------------------------------------------------Sortowanie szybkie W(n)≈n2/2
Θ(lgn) indeksów
z ulepszeniami
A(n)≈1.38n*lgn A(n)≈0.69n*lgn
-------------------------------------------------------------------------------------------Sortowanie stogowe W(n)≈2n*lgn
W(n)≈2n*lgn
algorytm
A(n)≈2n*lgn
A(n)≈n*lgn
bezpośredni
Dolne ograniczenie dla najgorszego przypadku.
Każdy deterministyczny algorytm sortujący n różnych kluczy wyłącznie
na podstawie operacji porównania ich wartości musi w najgorszym
przypadku wykonać lgn! >= n*lgn -1.45*n .
Podobne ograniczenie można znaleźć dla liczby operacji w średnim
przypadku.
↓
Wydajność algorytmu sortowania przez scalanie w najgorszym
przypadku wynosi n*lgn – (n-1) i jest bliska wydajności optymalnej!!!.
Wydajność algorytmu sortowania przez scalanie w średnim przypadku
wynosi n*lgn – 1.26*n i jest bliska wydajności optymalnej.
Sortowanie przez podział (pozycyjne).
Żaden algorytm sortujący wyłącznie na podstawie operacji porównania
kluczy nie może być lepszy niż Θ(n*lgn).
Jeśli nie mamy żadnej wiedzy o kluczach do sortowania poza tym żę należą
do zbioru uporządkowanego to jesteśmy skazani na algorytm porównujący
wartości kluczy.
Jeśli wiemy coś więcej o elementach do posortowania, możemy rozważyć
zastosowanie innych algorytmów sortujących.
Założymy że klucze są nieujemnymi liczbami całkowitymi zapisanymi w
systemie dziesiętnym i składają się z tej samej liczby cyfr. Możemy
wówczas podzielić je między różne stosy na podstawie skrajnie lewej cyfry,
a każdy taki stos podzielić dalej na podstawie drugiej od lewej cyfry
dziesiętnej itd.
Po przeanalizowaniu wszystkich cyfr klucze są posortowane.
Trudność w tym algorytmie polega na konieczności sortowania zmiennej
liczby stosów.
Przykład:
Zamiast tego tworzymy 10 stosów (dla każdej cyfry dziesiętnej) i
analizujemy cyfry od prawej do lewej strony i zawsze umieszczamy klucz
na stosie odpowiadającym aktualnie analizowanej cyfrze.
W takim algorytmie klucze nadal będą prawidłowo sortowane, jeśli w
każdej iteracji jeśli dwa klucze mają być umieszczone na tym samym stosie,
klucz ze skrajnego lewego stosu (w poprzedniej iteracji) jest umieszczany
na lewo od drugiego z kluczy.
Przykład:
liczby do posortowania
Liczby rozdzielone zgodnie
ze skrajnie prawymi cyframi
Liczby rozdzielone zgodnie
z drugimi cyframi od prawej
Liczby rozdzielone zgodnie
z trzecimi cyframi od prawej
Sortowanie to przypomina sortowanie kubełkowe dyskutowane na jednym z
wcześniejszych wykładów.
W algorytmie tym liczba kluczy w poszczególnych stosach jest zmieniana w
każdej iteracji, dobrym rozwiązaniem w implementacji jest zastosowanie
list jednokierunkowych. Jedna lista powinna reprezentować jeden stos.
Zasady działania:
– po każdej iteracji klucze są usuwane z list (stosów) i łączone w jednej
głównej liście. Klucze są w tej liście uporządkowane zgodnie z
kolejnością list (stosów), z których zostały usunięte.
– w kolejnej iteracji lista główna jest od początku przeglądana, a każdy
klucz jest umieszczany na końcu listy (stosu), do której ma należeć w
bieżącej iteracji.
Zmiana założeń (dziesiętne liczby dodatnie) na inny sposób reprezentowania kluczy nie wpływa na złożoność czasową takiego algorytmu.
Algorytm wymaga struktury danych:
struct nodetype
{
keytype key;
nodetype* link;
};
typedef nodetype* node_pointer;
Algorytm sortowania pozycyjnego
Problem: sortowanie n nieujemnych dziesiętnych liczb całkowitych w
porządku niemalejącym.
Dane: lista jednokierunkowa masterlist, zawierająca n nieujemnych liczb
całkowitych oraz liczba całkowita numdigits, reprezentująca maksymalną
liczbę dziesiętnych cyfr w każdej z liczb zawartych na liście masterlist..
Wynik: lista jednokierunkowa masterlist, zawierająca liczby całkowite,
uporządkowane w kolejności niemalejącej.
void div_sort(node_pointer& masterlist, int numdigits)
{
index i;
node_pointer list[0..9];
for(i=1;i<=numdigits;i++) {
distribute(masterlist,i);
coalesce(masterlist);
}
//rozdzielamy masterlist na stosy
// łączymy stosy w masterlist
}
void distribute(node_pointer& masterlist,index i) //index badanej
{
//cyfry
index j;
node_pointer p;
for(j=0;j<=9;j++)
list[j]=NULL;
// czyszczenia bieżących stosów
p=masterlist;
while(p!=NULL)
{
j= wartość i-tej cyfry od prawej pola p->key;
powiąż (link) p z końcem listy list[j];
p=p->link;
}
}
void coalesce(node_pointer& masterlist)
{
index j;
}
masterlist = NULL;
// Czyszczenie masterlist
for(j=0;j<=9;j++)
powiąż (link) węzły w list[j] z końcem listy masterlist;
Analiza algorytmu
Brak w algorytmie operacji porównania więc operacja podstawowa musi
być inna niż proste porównanie.
W procedurze coalesce listy zawierające stosy powinny przechowywać
wskaźniki do swoich początków i końców, bo dzięki temu możemy łatwo
dodawać każdą z tych list na koniec poprzedniej bez konieczności jej
przeglądania.
W każdym cyklu pętli for w coalesce poprzez proste przypisanie adresu do
jednej zmiennej wskaźnikowej dodawana jest jedna lista na koniec
masterlist. Przypisanie takie potraktujemy jak operację podstawową.
W procedurze distribute jako operację podstawową w pętli while
wybieramy operację dodawania klucza na koniec listy poprzez przypisanie
adresu do zmiennej wskaźnikowej.
Jako rozmiar danych wejściowych przyjmujemy: n-liczba liczb całkowitych
na liście masterlist oraz numdigits czyli maksymalna liczba cyfr
dziesiętnych w każdej z n liczb.
W procedurze distribute zawsze przeglądana jest cała masterlist, a zatem w
pętli while wykonywane jest n iteracji.
Dodanie wszystkich list (stosów) do masterlist wymaga 10-ciu iteracji w
pętli for w procedurze coalesce.
Każda z procedur wywoływana jest numdigits razy z poziomu div_sort.
Zatem złożoność czasowa w każdym przypadku wynosi:
T(numdigits, n) = numdigits*(n+10) ∈ Θ(numdigits*n)
Wnioski:
– nie jest to algorytm o złożoności Θ(n), bo ta zależy zarówno od
numdigits i n.
– złożoność może być dowolnie duża jeśli przyjmiemy dużą wartość
numdigits. Liczba 1.000.000.000 ma 10 cyfr, zatem posortowanie
dziesięciu liczb z których największa jest równa 1.000.000.000 będzie
wymagało Θ(n2) operacji.
W praktyce liczba cyfr jest dużo mniejsza niż liczba liczb do posortowania,
np. gdy sortujemy 1.000.000 numerów PESEL, n wynosi 1.000.000
natomiast numdigits=11 (każdy PESEL zawiera 11 cyfr). Jeśli klucze są
unikalne, złożoność algorytmu sortowania pozycyjnego w najlepszym
przypadku wynosi Θ(n lgn).
Wykorzystanie dodatkowej pamięci w algorytmie.
Algorytm nie przydziela pamięci nowym węzłom, ponieważ żaden klucz
nigdy nie musi być przechowywany jednocześnie na liście masterlist i na
liście reprezentującej stos.
Dodatkowe wykorzystanie pamięci wiąże się tylko z reprezentowaniem
kluczy na liście jednokierunkowej, tzn. liczba powiązań nie jest większa niż
Θ(n).
Download