Quicksort

Tutorial
Na ten temat mamy również tutorial „Quicksort”, który ilustruje działanie algorytmu krok po kroku. Zapraszamy do zapoznania się z nim!
Quicksort (1) Przykładowe wykonanie algorytmu quicksort
Quicksort przerzucanie (2) Przykładowe wykonanie operacji przerzucania elementów wokół klucza osiowego
REKLAMA C++. Algorytmy i struktury danych
103,95 zł
Praktyczne projekty sieciowe
59,00 zł
WordPress 5 dla początkujących
−30%41,30 zł
Kubernetes. Tworzenie niezawodnych systemów rozproszonych
44,90 zł

Quicksort, sortowanie szybkie – algorytm sortowania działający w średnim przypadku w czasie liniowo-logarytmicznym. Algorytm jest oparty na metodzie dziel i zwyciężaj. Nie jest to algorytm stabilny ani wykazujący zachowanie naturalne, jednak ze względu na efektywność jest algorytmem bardzo popularnym.

Przebieg algorytmu

Quicksort jest algorytmem rekurencyjnym, jego działanie można opisać następująco:

  • Wybierz jeden z elementów jako klucz osiowy,
  • Przenieś wszystkie elementy mniejsze od klucza osiowego na jedną stronę tablicy, a pozostałe na drugą,
  • Wywołaj procedurę rekurencyjnie dla części tablicy na lewo od klucza osiowego i na prawo od klucza osiowego.

Wybór klucza osiowego może być dowolny, jednak powinien być jak najszybszy. W praktyce często stosuje się po prostu wybór pierwszego elementu.

Operacja przerzucania elementów powinna musi być zaimplementowana tak, aby wykonywała się w czasie liniowym. Przykładowo, może ona być zdefiniowana w następujący sposób:

  • Przyjmujemy pierwszy element za klucz osiowy.
  • Dla każdego kolejnego elementu:
    • Jeśli jest większy od klucza osiowego, zostawiamy go na miejscu.
    • W przeciwnym razie zamieniamy go miejscami z pierwszym elementem większym od klucza osiowego (wyjątek: jeśli nie znaleźliśmy wcześniej żadnego elementu większego od klucza osiowego, nie robimy nic).
  • Zamieniamy miejscami klucz osiowy z ostatnim elementem mniejszym od niego.

Analiza algorytmu

Jak już wspomniano, operacja przenoszenia elementów odbywa się w czasie liniowym. O złożoności algorytmu decyduje więc to, ile nastąpi rekurencyjnych wywołań funkcji. W przypadku optymistycznym tablica będzie dzielona na pół. Wówczas głębokość drzewa wywołań zależy logarytmicznie od liczby danych wejściowych, czyli optymistyczna złożoność czasowa algorytmu jest rzędu O(n logn). Możemy wykazać, że taki sam rząd złożoności wystąpi w przypadku średnim (dowód jest dostępny w książkach podanych w bibliografii).

Przypadek pesymistyczny występuje wtedy, gdy klucz osiowy za każdym razem znajdzie się na brzegu tabeli. Wówczas przy każdym kolejnym wywołaniu liczba elementów do posortowania jest tylko o 1 mniejsza, zatem funkcja zostanie wywołana n razy. Pesymistyczna złożoność czasowa wynosi zatem O(n2).

Przeanalizujmy złożoność pamięciową algorytmu. Sortowanie szybkie nie potrzebuje co prawda dodatkowej pamięci na przechowywanie sortowanych danych, wymaga jednak pamięci potrzebnej na obsługę rekurencyjnych wywołań funkcji. Rozmiar tej pamięci (podobnie jak czas wykonania) będzie zależał od głębokości drzewa wywołań. Złożoność pamięciowa wynosi więc O(logn) w przypadku średnim i O(n) w przypadku pesymistycznym. Nie jest to zatem algorytm sortujący w miejscu.

Zastanówmy się, kiedy może wystąpić przypadek pesymistyczny. Jeśli jako klucz osiowy obieramy pierwszy (lub ostatni) elementu z tablicy, to najgorszy przypadek wystąpi wtedy, gdy tablica jest już posortowana. Widzimy więc, że algorytm zachowuje się w sposób bardzo nienaturalny.

Możliwe usprawnienia

Niedoskonałości algorytmu można nieco poprawić stosując następujące techniki:

  • Jako klucz osiowy zamiast pierwszego elementu można obierać element losowy. Nie zmieni to złożoności algorytmu, jednak sprawi, że przypadek pesymistyczny nie będzie występował w przypadku tablicy posortowanej.
  • Przy wyborze klucza osiowego można zastosować nieco bardziej złożone techniki. Jednym z rozwiązań jest wybór mediany z kilku wybranych elementów (np. pierwszego, ostatniego i środkowego). Należy jednak uważać, żeby nie spowolniło to zbytnio całej procedury – siła omawianego algorytmu tkwi w tym, aby wybór klucza osiowego i przestawianie elementów było jak najszybsze.
  • W celu zmniejszenia liczby rekurencyjnych wywołań funkcji, dla małych tablic można zastosować jakiś prosty, nierekurencyjny algorytm sortowania, np. sortowanie przez wstawianie (wówczas mamy do czynienia z algorytmem hybrydowym).
  • Jeśli wartość klucza osiowego powtarza się w tablicy, można od razu ustawić te elementy obok klucza osiowego i nie włączać tego fragmentu tablicy do wywołań rekurencyjnych. Taka modyfikacja jest określana jako Quick Sort 3 Way Partition.

Kod źródłowy

Przykładowa implementacja algorytmu w języku C wygląda następująco:

void quicksort(int* tab, int poczatek, int n)
{
    if (n > 1) 
    {
        int liczba_mniejszych = 0;
        int liczba_wiekszych = 0;
        
        int koniec = poczatek + n;
        int t; // Zmienna tymczasowa
    
        // Przeniesienie elementow wokol klucza osiowego
        for (int i = (poczatek + 1); i < koniec; ++i)
        {
            
            if (tab[i] < tab[poczatek])
            {   
                if (liczba_wiekszych > 0)
                {
                    // Przeniesienie elementu mniejszego od klucza osiowego
                    // przed elementy wieksze od klucza osiowego
                    t = tab[poczatek+liczba_mniejszych+1];
                    tab[poczatek+liczba_mniejszych+1] = tab[i];
                    tab[i] = t;
                }                    
                ++liczba_mniejszych; 
            }
            else
            {
                ++liczba_wiekszych;
            }
            
        }
        
        // Wstawienie klucza osiowego na wlasciwe miejsce
        t = tab[poczatek+liczba_mniejszych];
        tab[poczatek+liczba_mniejszych] = tab[poczatek];
        tab[poczatek] = t;
        
        // Wywołanie rekurencyjne
        quicksort(tab, poczatek, liczba_mniejszych);
        quicksort(tab, koniec-liczba_wiekszych, liczba_wiekszych);
        
    }
}

// Przykladowe uzycie funkcji
int main()
{
    int tab[] = {3, 7, 5, 1, 5, 8, 4, 2};
    quicksort(tab, 0, 8);
    
    return 0; 
}

Bibliografia

Ocena: 0 Tak Nie
Liczba głosów: 0.

Dodano: 5 stycznia 2018 18:56, ostatnia edycja: 13 lutego 2019 16:40.

REKLAMA

Zobacz też

Programowanie dynamiczne – technika projektowania algorytmów polegająca na rozwiązywaniu podproblemów i zapamiętywaniu ich wyników. W technice tej, podobnie jak w metodzie dziel i zwyciężaj, problem dzielony jest na mniejsze podproblemy. Wyniki rozwiązywania podproblemów są jednak zapisywane w tabeli, dzięki czemu w przypadku natrafienia na ten sam podproblem nie trzeba go ponownie rozwiązywać.

Wykorzystując programowanie dynamiczne można zastosować metodę zstępującą z zapamiętywaniem lub metodę wstępującą.

  • Metoda zstępująca z zapamiętywaniem polega na rekurencyjnym wywoływaniu funkcji z zapamiętywaniem wyników. Metoda ta jest podobna do metody dziel i zwyciężaj – różni się od niej tym, że jeśli rozwiązanie danego problemu jest już w tabeli z wynikami, to należy je po prostu stamtąd odczytać.
  • Metoda wstępująca polega na rozwiązywaniu wszystkich możliwych podproblemów, zaczynając od tych o najmniejszym rozmiarze. Wówczas w momencie rozwiązywania podproblemu na pewno są już dostępne rozwiązania jego podproblemów. W tym podejściu nie zużywa się pamięci na rekurencyjne wywołania funkcji. Może się jednak okazać, że część podproblemów została rozwiązana nadmiarowo (nie były one potrzebne do rozwiązania głównego problemu).
→ Czytaj całość

Wyznaczanie maksymalnego przepływu – problem obliczeniowy polegający na wyznaczeniu maksymalnego przepływu w sieci przepływowej.

Sieć przepływowa jest skierowanym grafem prostym. Każdy łuk (krawędź skierowana w grafie) ma swoją nieujemną wagę, która oznacza maksymalny dopuszczalny przepływ w tym łuku. Na potrzeby tego artykułu nazwijmy rzeczy przepływające przez sieć danymi. Jeden z wierzchołków sieci jest źródłem, z którego wypływają przesyłane dane. Inny z wierzchołków to ujście, do którego te dane wpływają. Zakłada się ponadto, że dla każdego z pozostałych wierzchołków istnieje ścieżka ze źródła do ujścia przechodząca przez ten wierzchołek.

Przepływem w sieci nazywamy przyporządkowanie każdemu łukowi pewnej wartości, która oznacza liczbę danych aktualnie przesyłanych przez ten łuk. Wartości te muszą spełniać następujące warunki:

  • Wartość przyporządkowana krawędzi musi być mniejsza lub równa jej wadze (warunek przepustowości).
  • Do każdego wierzchołka (poza źródłem i ujściem) musi wpływać tyle samo danych, ile z niego wypływa (warunek zachowania przepływu).

Omawiany problem polega na dobraniu takiego przepływu, aby liczba danych wypływających ze źródła (i zarazem wpływających do ujścia) była jak największa.

→ Czytaj całość

Sortowanie – zagadnienie polegające na uporządkowaniu elementów zbioru rosnąco lub malejąco według pewnego klucza. Zagadnienie to, ze względu na częstość występowania, jest bardzo istotne dla informatyki. Istnieje wiele różnych algorytmów realizujących sortowanie.

→ Czytaj całość
Polityka prywatnościKontakt