Sortowanie przez porównania: BubbleSort, SelectionSort, InsertionSort

Ten wykład poświęcimy prostym algorytmom sortowania. Zanalizujemy ich wady i zalety, co przygotuje nas do poszukiwania algorytmów lepszych. Zacznijmy od zdefiniowania problemu.

Definicja problemu

Niech \( (U,\le) \) będzie zbiorem liniowo uporządkowanym z relacją porządkującą \( \le \) i niech \( c_1, c_2, \ldots, c_n \) będzie ciągiem \( n \) elementów z \( U \), dla pewnego całkowitego \( n > 0 \). Należy znaleźć permutację \( c_{i_1}, c_{i_2},\ldots,c_{i_n} \) taką, że \( c_{i_1}\le c_{i_2}\le \ldots \le c_{i_n} \).

Uwaga: dla pary elementów \( x, y \in U \) będziemy pisali \( x < y \) (\( x > y \)), gdy \( x \le y \) (\( y \le x \)) oraz \( x \ne y \).

W tym wykładzie przyjmujemy, że elementy ciągu \( c \) znajdują się w tablicy \( a[1..n] \), tzn. \( a[1] = c_1, a[2] = c_2, \ldots, a[n] = c_n \). Będziemy także chcieli, żeby posortowany ciąg \( c \) znajdował się nadal w tablicy \( a \), tzn. wynikiem sortowania ma być \( a[1] = c_{i_1} \le a[2] = c_{i_2} \le \ldots \le a[n] = c_{i_n} \). Dla wygody dalszych rozważań przyjmijmy, że tablica \( a \) jest indeksowana od \( 0 \), i że \( a[0] \) zawiera element, który nie jest większy od żadnego elementu z \( a[1..n] \), tzn. dla każdego \( i = 1,2, \ldots, n \), \( a[0] \le a[i] \).

Zauważmy, że w tak sformułowanym problemie sortowania nic nie wiemy o naturze elementów z \( U \). Na \( U \) mogą składać się zarówno liczby całkowite lub rzeczywiste, jak i \( U \) może być zbiorem rekordów, które należy posortować według ich kluczy. Jedynym sposobem ustalenie porządku w tablicy \( a \) jest porównywanie jej elementów parami. Operacja porównania będzie operacją dominującą w naszych algorytmach. Ponieważ będziemy chcieli ustalić wynik także w tablicy \( a \), potrzebna nam jest jeszcze operacja zamiany dwóch elementów w tablicy. Operacją tą będzie operacja \( Exchange(i,j) \) polegająca na zamianie elementów w tablicy \( a \) z pozycji \( i \) oraz \( j \), \( 1\le i, j \le n \).

Sortowanie bąbelkowe (BubbleSort)

Jest to jeden z najprostszych algorytmów sortowania. Sortowanie bąbelkowe jest wykonywane w \( n-1 \) fazach. W fazie \( i \)-tej wyznaczany jest \( i \)-ty najmniejszy element. Załóżmy, że po \( i-1 \) pierwszych fazach mamy wyznaczone i uporządkowane \( i-1 \) najmniejsze elementy, tzn. \( a[1]\le a[2]\le \ldots a[i-1] \) i dla każdego \( k = i, i+1, \ldots, n \) mamy \( a[i-1] \le a[k] \). W fazie \( i \)-tej porównywane są kolejno elementy w parach \( (a[n], a[n-1]) \), \( (a[n-1], a[n-2]) \), ..., \( (a[i+1], a[i]) \). Jeżeli elementy w parze nie są uporządkowane, dokonujemy ich zamiany. Oto algorytm sortowania bąbelkowego.

Algorytm Sortowanie bąbelkowe

BubbleSort 
for i:=1 to n-1 do 
    for j:=n downto i+1 do 
    if a[j-1] > a[j] then 
      Exchange(j-1,j);

Zaletą sortowania bąbelkowego jest jego prostota i bardzo krótki kod. Niestety jego główną wadą jest to, że zawsze, niezależnie od danych, wykonuje tyle samo porównań: \( n-1 \) w pierwszej fazie, \( n-2 \) w drugiej, ..., \( 1 \) w fazie ostatniej, co daje łącznie \( \sum_{i=1}^{n-1}(n-i) = \frac{n(n-1)}{2} \) porównań. Liczba zamian może się zmieniać od \( 0 \) do \( \frac{n(n-1)}{2}. \) Zauważmy, że tak zapisany algorytm sortowania bąbelkowego zawsze wykonuje \( n-1 \) faz, nawet wtedy, gdy ciąg wejściowy jest już uporządkowany. Celem ćwiczenia 1 jest taka modyfikacja algorytmu, aby zakończyć jego działanie, kiedy tylko zostanie stwierdzone, że tablica \( a \) jest już uporządkowana.

Ćwiczenie 1
Zmodyfikuj algorytm sortowania bąbelkowego w taki sposób, żeby jego wykonywanie kończyło się z chwilą stwierdzenia, że tablica \( a \) jest już posortowana.

Sortowanie przez wybór (SelectionSort)

Na sortowanie bąbelkowe można spojrzeć jeszcze inaczej. W każdej fazie przepychamy na pierwsze miejsce w nieuporządkowanej części tablicy element najmniejszy. Symetrycznie moglibyśmy sortować poczynając od elementów nawiększych. W sortowaniu przez wybór nie przepychamy elementu największego (najmniejszego) na jego miejsce w ciągu uporządkowanym, tylko znajdujemy jego pozycję, a następnie znaleziony element - największy w części nieuporządkowanej - zamieniamy z elementem, który znajduje się na docelowej pozycji elementu największego. Niech \( Max(i) \) będzie funkcją, której wartością jest pozycja największego elementu w podtablicy \( a[1..i] \). Oto schemat algorytmu sortowania przez wybór:

Algorytm Schemat algorytmu sortowania przez wybór

schemat SelectionSort 
  for i := n downto 2 do 
  begin
    j := Max(i);  
    Exchange(i,j)  
  end;

Dlaczego użyliśmy słowa schemat? Zauważmy, że do pełnego zdefinowania algorytmu, a także dla jego analizy, niezbędne jest zdefiniowanie funkcji \( Max \). W klasycznym sortowaniu przez wybór pozycję elementu największego w podtablicy \( a[1..i] \) wyznaczamy przeszukując podtablicę od lewej do prawej, pamiętając w zmiennej pomocniczej pozycję dotychczas największego elementu i modyfikując wartość tej zmiennej każdorazowo po napotkaniu elementu większego od dotychczas największego. Oto pełny zapis algorytmu sortowania przez wybór - algorytmu SelectionSort.

Algorytm Sortowanie przez wybór

SelectionSort 
  for i := n downto 2 do 
    begin 
      j := 1;
      for  k := 2 to i do 
        if  a[k] > a[j] then  j := k; 
      Exchange(i,j)  
  end;

Nietrudno zauważyć, że algorytm SelectionSort wykonuje taką samą liczbę porównań, co algorytm bąbelkowy. Jednak w tym przypadku maksymalna liczba zamian wynosi tylko \( n-1 \).

Należy podkreślić, że w implementacji schematu sortowania przez wybór mamy swobodę w realizacji funkcji \( Max \). Wykorzystamy to w algorytmie sortowania z użyciem struktury danych zwanej kopcem.

Sortowanie przez wstawianie

Sortowanie przez wstawianie jest algorytmem szczególnie polecanym do sortowania krótkich ciągów. Jego złożoność pesymistyczna jest asymptotycznie taka sama jak dwóch poprzednich algorytmów, ale zachowuje się on znacznie lepiej dla ciągów "prawie" posortowanych. Co znaczy "prawie" wyjaśnimy w dalszej części wykładu.

Idea sortowania przez wstawianie jest następująca. Sortujemy w \( n-1 \) fazach, ponumerowanych dla wygody opisu od 2 do \( n \). Przed rozpoczęciem \( i \)-tej fazy wiemy, że podtablica \( a[1..i-1] \) jest już posortowana. Naszym celem jest uporządkowanie podtablicy \( a[1..i] \), co w rzeczywistości oznacza wstawienie na właściwe miejsce elementu \( a[i] \) w uporządkowany ciąg \( a[1..i-1] \). W tym celu odkładamy element z pozycji \( a[i] \) na bok (zapamiętujemy w pomocniczej zmiennej), a następnie przeglądamy elementy podtablicy \( a[1..i-1] \) od prawej do lewej, przesuwając każdy element większy od odłożonego o jedną pozycję w prawo. Po napotkaniu w tablicy \( a \) elementu nie większego od elementu odłożonego (nazwijmy go elementem blokującym), element odłożony wstawiany tuż przed element blokujący. Oto zapis sortowania przez wstawianie - algorytm InsertionSort.

Algorytm Sortowanie przez wstawianie

InsertionSort 
  for i := 2 to n do 
  begin 
    //a[1..i-1] jest już posortowana 
    x := a[i] ; //odkładamy element z pozycji  i  
    j := i ; 
    while x  <  a[j-1] do 
    begin 
      a[j] := a[j-1] ; //przesuwamy element większy od  x  o jedną pozycję w prawo 
      j&nbsp;:= j-1  
    end; 
     a[j] := x  
 end;

Dokonamy teraz wspólnie analizy przedstawionego algorytmu. W tym celu postaraj się drogi czytelniku samodzielnie wykonać następujące ćwiczenia.

Ćwiczenie 2
Jakie znaczenie dla poprawności algorytmu InsertionSort ma założenie z początku wykładu o tym, że \( a[0] \le a[i] \), dla każdego \( i = 1, 2, ..., n \)?

Ćwiczenie 3
Załóżmy, że sortujemy permutację liczb \( 1, 2, \ldots, n \). Podaj permutacje, dla których algorytm InsertionSort wykona odpowiednio najwięcej i najmniej porównań, a następnie wyznacz dokładne liczby porównań w najgorszym i najlepszym przypadku. Uwaga: pamiętaj o porównaniach z \( a[0] \).

Zastanówmy się, od czego zależy liczba porównań wykonywanych przez algorytm InsertionSort dla ustalonej permutacji \( \pi = < p_1, p_2, \ldots, p_n> \). W tym celu zdefiniujmy pojęcie inwersji w permutacji. Inwersją w permutacji \( \pi \) nazywamy każdą parę indeksów \( 1\le i < j \le \) takich, że \( p_i > p_j \).

Ćwiczenie 4
Zastanów się, jaka jest zależność pomiędzy liczbą porównań wykonywanych przez algorytm InsertionSort dla permutacji \( \pi \) a liczbą inwersji w tej permutacji.

Jedną z miar uporządkowania ciągu elementów jest liczba inwersji w nim występujących. Permutacja rosnąca nie zawiera wcale inwersji, natomiast permutacja malejąca zawiera \( {n(n-1)}/{2} \) inwersji. Na podstawie ćwiczenia 4 wnioskujemy, że jeżeli ciąg wejściowy zawiera liniową ze względu na \( n \) liczbę inwersji, to dla takiego ciągu algorytm InsertionSort działa w czasie liniowym. Jeżeli jednak w ciągu wejściowym jest kwadratowa liczba inwersji, to algorytm InsertionSort działa w czasie kwadratowym. Może jednak takie dane są niesłychanie rzadkie, a dla "przeciętnych" danych algorytm InsertionSort zachowuje się zdecydowanie lepiej? Niestety, to nie jest prawda. Żeby to wykazać, obliczymy oczekiwaną liczbę porównań wykonywanych przez nasz algorytm dla "losowych" danych. Pisząc "losowych", musimy precyzyjnie określić, co mamy na myśli. Dla naszych rozważań przyjmiemy model losowej permutacji, w którym to zakłada się, że początkową zawartością sortowanej tablicy \( a \) jest permutacja liczb \( 1, 2,, n \) i każda z \( n! \) permutacji może pojawić się z tym samym prawdopodobieństwem \( \frac{1}{n!} \). Jeśli już ustaliliśmy model probabilistyczny, to możemy określić średnią liczbę porównań wykonywanych przez algorytm InsertionSort. Nietrudno zauważyć, że wynosi ona

\( n-1 + \frac{1}{n!}\sum_{\{\pi: \mbox{ permutacja liczb } 1,2, \ldots, n\}}Inv(\pi). \)
W powyższym wzorze wartość wyrażenia \( \frac{1}{n!}\sum_{\{\pi: \mbox{ permutacja liczb } 1,2, \ldots, n\}}Inv(\pi) \) jest równa oczekiwanej liczbie inwersji w losowej permutacji. Żeby policzyć oczekiwaną liczbę porównań wykonywaną przez algorytm InsertionSort, wystarczy więc wyznaczyć oczekiwaną liczbę inwersji w losowej permutacji.

Wektorem inwersji dla permutacji \( \pi \) nazywamy ciąg liczb \( w = [w_1, w_2, \ldots, w_n] \) taki, że \( w_i = |\{j: 1 \le j < i \mbox{ oraz } p_j > p_i \}| \), dla każdego \( i = 1,2, \ldots, n \). Innymi słowy, \( w_i \) jest liczbą tych elementów permutacji \( \pi \), która znajdują się na lewo od elementu \(_{i}\) i są od niego większe.

Ćwiczenie 5

Podaj wektor inwersji dla permutacji \( \pi=[7,8,2,4,6,1,5,3] \).


Zauważmy, że elementy każdego wektora inwersji \( w \) muszą spełniać nierówności \( 0 \le w_i < i \), dla \( i=1, 2, \ldots, n \). Oznaczmy zbiór wszystkich \( n \)-elementowych wektorów o takich własnościach przez \({W}_n \). Liczba elementów zbioru \({W}_n \) wynosi \( n! \). Z naszych dotychczasowych rozważań wynika, że istnieje wzajemnie jednoznaczna odpowiedniość pomiędzy permutacjami liczb \( 1,2,\ldots, n \), a wektorami z \({W}_n \). Dlatego elementy tego zbioru będziemy nazywali po prostu wektorami inwersji.

Ćwiczenie 6
Podaj permutację o wektorze inwersji \( w=[0,0,1,1,2,1,4,5] \).

Ćwiczenie 7

Zaproponuj algorytm, który dla danego wektora inwersji \( w=[w_1,w_2,\ldots, w_n] \) znajdzie odpowiadającą mu permutację.


Sumą wektora inwersji nazwiemy sumę jego elementów. Z dotychczasowych rozważań wynika, że suma wektora inwersji jest równa liczbie inwersji w odpowiadającej temu wektorowi permutacji. Losowy wektor inwersji można otrzymać losując każdy jego element niezależnie w ten sposób, że na pozycji \( i \)-tej może pojawić się każda z wartości \( 0, 1, \ldots, i-1 \) z jednakowym prawdopodobieństwem \( \frac{1}{i} \). Tak więc oczekiwana wartość \( i \)-tego elementu w losowym wektorze inwersji wynosi \( \frac{0+1+\ldots+i-1}{i} = \frac{i-1}{2} \). Z liniowości wartości oczekiwanej otrzymujemy, że oczekiwana suma losowego wektora inwersji wynosi \( 0+\frac{1}{2}+\ldots+ \frac{n-1}{2} = \frac{n(n-1)}{4} \). Zatem taka jest też oczekiwana liczba inwersji w losowej permutacji. Wynika stąd, że algorytm sortowania przez wstawianie zachowuje się dla "przeciętnych" danych podobnie, jak dla danych "najgorszych" i działa w czasie kwadratowym.