Quick sort

Inledning

Uppdaterad 2011-02-10 p.g.a. slarv i implementation som gjorde att resultatet inte alltid blev sorterat.

Quick sort börjar med att välja ett värde från vektorn. Detta värde brukar kallas pivot-värde. Pivot-värdet kan väljas slumpvis som i videoexemplet. Resterande värden jämförs sedan mot pivot-värdet och sorteras i två högar. En hög för mindre värden än pivot-värdet och en hög för större värden. När detta är gjort så har pivot-värdet hittat sin rätta plats i vektorn. Därefter upprepas sorteringen genom att välja ett nytt pivot-värde i någon hög av osorterade värden. På så vis delas vektorn ned i mindre och mindre delar.

Implementation

Quick sort anses vara en av de mer avancerade algoritmer att implementera. Därför är det extra viktigt att försöka visualisera algoritmen såsom vi försökt i videon ovan. Ett tips är att verkligen ta fram en kortlek och öva några gånger innan du ger dig på att förstå koden nedan. Visst kanske det låter dumt men det underlättar faktiskt!

Strategin i Quick Sort är att dela in datan i allt mindre delar. Denna teknik kallas ofta för divide and conquer eller "söndra och erövra" på svenska. Som videon visar så behandlas först hela datan genom att delas in i två delar; en lägre del och en högre del beroende på vilket pivot-värde som valts. Dessa två delar som uppstår bearbetas sedan efter samma metod. Detta upprepas om och om igen; välj pivot-värde, dela upp datan, upprepa.

Implementationen av quick sort är ett typexempel på en rekursiv funktion. Detta betyder att funktionen anropar sig själv. Det låter kanske konstigt men tänk nu på att vi skall upprepa samma metod på ett sätt som vi inte i förväg kan räkna ut.

Metoden nedan är deklarerad static då exemplet är taget från ett Console Application

Quick Sort - Algoritm

static void QuickSort(int[] data, int left, int right)
{
	//Välj det tal som avgör indelningen i "högre" och "lägre"
	int pivot = data[(left + right) / 2];
	//Välj det område som skall bearbetas
	int leftHold = left;
	int rightHold = right;

	//Så länge vi har ett område kvar
	while (leftHold < rightHold)
	{
		//Hitta ett tal på vänster sida som skall ligga i den "högre" delen
		while ((data[leftHold] < pivot) && (leftHold <= rightHold)) leftHold++;
		//Hitta ett tal på höger sida som skall ligga i den "lägre" delen
		while ((data[rightHold] > pivot) && (rightHold >= leftHold)) rightHold--;

		//Om vi nu har ett område kvar så skall talen på 
		//vänster kant och höger kant byta plats
		if (leftHold < rightHold)
		{
			//Byta plats
			int tmp = data[leftHold];
			data[leftHold] = data[rightHold];
			data[rightHold] = tmp;
			//Minska området om vi flyttat två pivot-tal
			if (data[leftHold] == pivot && data[rightHold] == pivot)
				leftHold++;
		}
	}
	//Nu när området är bearbetat så skall "lägre" delen bearbetas
	//om sådan finns därefter detsamma med en eventuell "högre" del
	if (left < leftHold -1) QuickSort(data, left, leftHold - 1);
	if (right > rightHold + 1) QuickSort(data, rightHold + 1, right);
}

Vi har valt att låta metoden QuickSort() anropas med två extra parametrar utöver datan som skall sorteras. left är början på vektorn dvs. 0 när vi använder metoden och right sista positionen i vektorn dvs. data.Length - 1. Anledningen att parametrarna måste finnas är att metoden anropas sig själv rekursivt i koden, då med andra värden på left och right.

Vi skapar inga nya "lägre" och "högre" vektorer av tal så som kanske videon ser ut att göra. Vi behöver bara byta plats på tal från vänster och höger sida på det område som bearbetas så att tal under pivot-värdet ligger i början (vänster/left) och tal över pivot-värdet ligger i slutet (höger/right) i området. Tal som har samma värde som pivot-värdet kommer automatiskt att hamna mellan dessa delar i vektorn.

Läs kommentarerna noga i koden och testa gärna med att stega igenom algoritmen i Visual Studio.

Prestanda

För att mäta prestandan på algoritmen så har vi konstruerat ett Console Application som slumpar fram 1000, 10000 och sist 100000 heltal. Talen skickas först osorterade till algoritmen för att mäta generell prestanda. Därefter skickas den sorterade datan en gång till genom algoritmen för att mäta prestandan under andra förutsättningar.

Testerna som gjorts har körts på en något äldre maskin så bli inte förvånad om du får mycket bättre tider vid en jämförelse.

Testprogram

Quick Sort - Prestandatest

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace QuickSort
{
    class Program
    {
        static void QuickSort(int[] data, int left, int right)
        {
            //Välj det tal som avgör indelningen i "högre" och "lägre"
            int pivot = data[(left + right) / 2];
            //Välj det område som skall bearbetas
            int leftHold = left;
            int rightHold = right;

            //Så länge vi har ett område kvar
            while (leftHold < rightHold)
            {
                //Hitta ett tal på vänster sida som skall ligga i den "högre" delen
                while ((data[leftHold] < pivot) && (leftHold <= rightHold)) leftHold++;
                //Hitta ett tal på höger sida som skall ligga i den "lägre" delen
                while ((data[rightHold] > pivot) && (rightHold >= leftHold)) rightHold--;

                //Om vi nu har ett område kvar så skall talen på 
                //vänster kant och höger kant byta plats
                if (leftHold < rightHold)
                {
                    //Byta plats
                    int tmp = data[leftHold];
                    data[leftHold] = data[rightHold];
                    data[rightHold] = tmp;
                    //Minska området om vi flyttat två pivot-tal
                    if (data[leftHold] == pivot && data[rightHold] == pivot)
                        leftHold++;
                }
            }
            //Nu när området är bearbetat så skall "lägre" delen bearbetas
            //om sådan finns därefter detsamma med en eventuell "högre" del
            if (left < leftHold -1) QuickSort(data, left, leftHold - 1);
            if (right > rightHold + 1) QuickSort(data, rightHold + 1, right);
        }

        static int[] GenerateData(int size)
        {
            Random rnd = new Random();
            int[] data = new int[size];

            for (int i = 0; i < data.Length; i++)
                data[i] = rnd.Next(data.Length);

            return data;
        }

        static void Main(string[] args)
        {
            int[] sizes = new int[] { 1000, 10000, 100000 };

            for (int i = 0; i < sizes.Length; i++)
            {
                Console.WriteLine("Skapar slumpad data av längd " + sizes[i]);
                int[] data = GenerateData(sizes[i]);

                Console.WriteLine("Sorterar slumpad data");
                DateTime startTid = DateTime.Now;
                QuickSort(data, 0, data.Length - 1);
                TimeSpan deltaTid = DateTime.Now - startTid;
                Console.WriteLine("Det tog {0:0.00} ms att sortera.\n", deltaTid.TotalMilliseconds);

                Console.WriteLine("Sorterar redan sorterad data");
                startTid = DateTime.Now;
                QuickSort(data, 0, data.Length - 1);
                deltaTid = DateTime.Now - startTid;
                Console.WriteLine("Det tog {0:0.00} ms att sortera.\n", deltaTid.TotalMilliseconds);
            }
        }
    }
}

Resultat

bild

Då testprogrammet använder DateTime för att mäta tiden blir resultatet av tider under 5 ms opålitliga. Alla resultat < 5 ms får anses som snabba.

Vi kommer att göra en del jämförelser med resultat från Bubble Sort och Insertion Sort .

Analys

Bästa fall och medelfall

Quick sort har samma prestanda i bästa fallet som i medelfallet vilket är O(n log n). log n delen i ordo-funktionen kommer sig av att algoritmen delar in områden i mindre delar hela tiden. Vi kan ta ett räkneexempel för att visa på sambandet.

Ta siffran 64 och dela den på 2. Hur många gånger kan du göra det?

Vi testar; 32, 16, 8, 4, 2, 1 ... alltså 6 gånger. Detta kan beräknas som log2 64 = 6. För varje nivå dvs. 1 vektor med 64 tal, 2 vektorer med 32 tal, 4 vektorer med 16 tal, etc. etc så måste vi göra jämförelser. Därför skulle vi kunna beskriva antalet beräkningar i en Quick Sort med 64 * log2 64 i detta exempel.

I verkligheten har vi ett annat problem, nämligen att välja ett bra pivot-värde för varje nivå. Det mest optimala är att försöka dela området i två lika delar. På så vis redurcerar vi antalet nivåer/delningar som behöver göras. Eftersom vi inte vet något om datan så väljs pivot-värdet slumpvis.

I bästa fallet så kan både Bubble Sort och Insertion Sort vara snabbare än Quick Sort. Men det räcker med att ett litet tal ligger fel för att Bubble Sort direkt skall hamna i sitt sämsta fall. Insertion Sort tåler en viss grad av "mindre osortering" och ändå prestera bra. Quick Sort har bättre förutsättningar om datan redan är sorterad (med det sätt vi väljer pivot-värde) genom att inga tal behöver byta plats. Detta gör Quick Sort någon faktor bättre i prestanda gentemot medelfallet.

Det intressanta är att titta på just medelfallet. Här ser vi att det endast tar 11 ms att sortera 100000 i slumpad data!!

Quick sort är som generell sorteringsalgoritm oslagbar! Den är i fallet med 100000 data ca 577 gånger snabbare än Insertion Sort och 2313 gånger snabbare än Bubble Sort! Ökar vi mängden data så sticker både Bubble Sort och Insertion Sort iväg kvadratiskt i tid! Quick Sort ökar endast logaritmiskt i tid.

Sämsta fall

Quick Sort har faktiskt ett sämsta fall som liknar Bubble Sort och Insertion Sort, dvs. O(n2). För att locka fram det sämsta fallet i Quick Sort så måste vi konsekvent välja det absolut sämsta pivot-värdet varje gång! Chansen att detta inträffar är astronimisk om vi jobbar med slumpvis data. Om vi jobbar med redan sorterad data så kan vi ändra i algoritmen för att locka fram ett sämsta fall.

Genom att ängra på rad 4 (valet av pivot-värde) i algoritmen till


			//Välj det tal som avgör indelningen i "högre" och "lägre"
            int pivot = data[left];
			...

Så blir nu valet av pivot-värde riktigt dåligt för redan sorterad data. Detta skapar n nivåer i logaritmen då varje område endast delas i 1 tal + resten. Det optimala hade varit att dela i lika delar. Prestandan blir nu O(n2).

Vi körde ett test med sämsta fallet och fick ett resultat på 4328 ms för 100000 data.

Jämförelser med andra algoritmer

Slumpad data 1000 10000 100000
Bubble Sort 0 259 25440
Insertion Sort 0 64 6354
Quick Sort 0 0 11
Sorterad data 1000 10000 100000
Bubble Sort 0 0 0
Insertion Sort 0 0 0
Quick Sort 0 0 4

Tid är angiven i millisekunder (1s = 1000ms). Ett resultat på 0 indikerar inte att tiden verkligen var 0 ms utan snarare att det gick för snabbt för att mätas med vår metod.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *

Scroll to top