DD1320 Tillämpad Datalogi

Föreläsning 8: Sortering, prioritetskö, trappa (heap)

Sortering är en av dom vanligaste uppgifterna för ett program. Här följer en beskrivning av några sorteringsalgoritmer!

Urvalssortering (Selection sort)

Vi tänker oss att vi ska sortera en lista med N tal. Totalt krävs N(N-1)/2 jämförelser och N-1 byten, helt oberoende av hur pass osorterad listan är från början. Tiden är alltså i stort sett proportionell mot kvadraten på N. Man säger att komplexiteten är O(N2).
6 13 5 2 1 3 7 8 10 12 4 9 11

1 13 5 2 6 3 7 8 10 12 4 9 11

1 2 5 13 6 3 7 8 10 12 4 9 11
         
def urvalssortera(data):
    n = len(data)
    for i in range(n):
        minst = i
        for j in range(i+1,n):
            if data[j] < data[minst]:
                minst = j
        data[minst],data[i] = data[i], data[minst]

Urvalssorteringsanimation från Hong Kongs universitet.


Bubbelsortering (Bubble sort)

En något smartare metod än urvalssortering är denna: Totalt krävs i värsta fall N(N-1)/2 jämförelser och lika många byten. Men om listan är nästan sorterad från början räcker det med några få genomgångar och då blir bubbel snabbare än urval.

Bubbelsortanimation från universitetet i Oldenburg.




Insättningssortering (Insertion sort)

Denna metod känns särskilt naturlig om man får värden ett efter ett (t ex om dom läses in från en fil) och man vill sortera in dom i en lista. Här sorterar vi in ett värde i taget på följande vis: Om alla värden redan finns i listan sorterar vi in ett värde i taget, med början från det andra.

Komplexiteten är i allmänhet O(N2) men den är lite snabbare än urvalssortering i praktiken eftersom en flytt är snabbare än ett byte. För att sortera in ett nytt värde i en redan sorterad lista är insättning bäst.

13 6 5 2 1 3 7 8 10 12 4 9 11

6 13 5 2 1 3 7 8 10 12 4 9 11

5 6 13 2 1 3 7 8 10 12 4 9 11
         
def insattningssortera(data):
    n = len(data)
    for i in range(1, n):
        varde = data[i]
        plats = i
        while plats > 0 and data[plats-1] > varde:
            data[plats] = data[plats-1]
            plats = plats - 1
        data[plats] = varde

Animation av insättningssortering från Indian Institute of Technology i Kanpur.


Damerna först

Den enklaste sorteringsuppgiften är att sortera om en personlista med damerna först och den smarta algoritmen kallas damerna-först-sortering.
  1. Sätt ett pekfinger i var ände av listan!
  2. Rör fingrarna mot varandra tills vänstra fingret fastnat på en herre och högra fingret på en dam!
  3. Låt damen och herren byta plats!
  4. Upprepa från 2 tills fingrarna korsats!
Idén kan utvecklas till Quicksort, som är den snabbaste av alla sorteringsalgoritmer.



Quicksort

Damerna-först-algoritmen med två pekfingrar används i Quicksort. Först bestämmer man vilka (små) nyckelvärden som ska kallas damer. Resten kallas herrar och så utför man algoritmen. Listan delas alltså i två segment, det första med små värden, det andra med stora värden. Nu behöver man bara sortera segmenten var för sej. Det här är en rekursiv tanke!
def quicksort(data):
    sista = len(data) - 1
    qsort(data, 0, sista)

def qsort(data, i, j):
    pivotindex = (i+j)/2
    data[pivotindex], data[j] = data[j], data[pivotindex]
    k = partitionera(data, i-1, j, data[j])
    data[k], data[j] = data[j], data[k]
    if k-i > 1:
        qsort(data, i, k-1)
    if j-k > 1:
        qsort(data, k+1, j)

def partitionera(data, v, h, pivot):
    while True:
        v = v + 1
        while data[v] < pivot:
            v = v + 1
        h = h -1
        while h != 0 and data[h] > pivot:
            h = h -1
        data[v], data[h] = data[h], data[v]
        if v >= h: break
    data[v], data[h] = data[h], data[v]
    return v

Komplexiteten blir i allmänhet O(N log N). Det beror på att man kan dela listan på mitten log N gånger. Exakt hur snabb den är beror på hur man avgör vilka värden som ska vara damer. Om man tar det första värdet i listan och utnämner det och alla mindre värden till damer, så blir Quicksort mycket långsam för redan nästan sorterade listor! Det bästa är att ta ut tre värden - det första, det sista och något i mitten - och låta det mellersta värdet bestämma vad som är damer. Det kallas median-of-three. Man kan visa att komplexiteten då blir 1.4 N log N i genomsnitt.

Quicksortanimation från Aucklands universitet.

Merge Sort

Om man har flera sorterade småfiler är det lätt att samsortera dom till en fil. Det här kan man också göra med en lista om man har extrautrymme för att kopiera den till två andra hälften så långa listor. Det här ger en rekursiv tanke! Komplexiteten blir O(N log N), lika snabb som quicksort men kräver extra minnesutrymme. Om första halvan av listan a ska samsorteras med andra halvan kopierar man först över allting till hjälplistan b och sorterar sedan tillbaka från b till a. Detta förfarande kallas merge och programmeras lämpligen som en egen metod.
def mergesort(data):
    if len(data) > 1:
        mitten = len(data)/2
        vensterHalva = data[:mitten]
        hogerHalva = data[mitten:]

        mergesort(vensterHalva)
        mergesort(hogerHalva)

        i, j, k = 0, 0, 0
    
        while i < len(vensterHalva) and j < len(hogerHalva):
            if vensterHalva[i] < hogerHalva[j]:
                data[k] = vensterHalva[i]
                i = i + 1
            else:
                data[k] = hogerHalva[j]
                j = j + 1
            k = k + 1
                
        while i < len(vensterHalva):
            data[k] = vensterHalva[i]
            i = i + 1
            k = k + 1
                    
        while j < len(hogerHalva):
            data[k] = hogerHalva[j]
            j = j + 1
            k = k + 1

Mergesortanimation från North Carolina State University.


Divide and conquer

En kanske inte så sympatisk härskarteknik som går ut på att så split mellan sina undersåtar för att rikta deras misstro mot varandra istället för mot härskaren kallas på engelska divide and conquer. (På svenska säger vi söndra och härska, men det passar inte lika bra här.)

Inom datalogi används detta uttryck för att beskriva en lösningsmetod där man delar upp ett problem i två eller flera delproblem och löser dessa var för sig. Delproblemen löses enklast med rekursion!

Quicksort och mergesort är två exempel på divide-and-conquer-principen.




Räknesortering (Distribution count)

Om man vet att det bara finns ett litet antal nyckelvärden, till exempel 100 olika, är distributionsräkning oslagbart snabbt. Det kräver att talen som sorteras in i listan hämtas från en annan lista eller fil. Komplexiteten blir O(N), men fungerar alltså bara om man har få nyckelvärden. Om man upprepar förfarandet för varje position (siffra eller bokstav) i de data som ska sorteras får man radixsortering.
3 4 3 3 5 3 5 3 5 4 5 3 4

3:6
4:3
5:4

3 3 3             4                        



Sortering av större mängder data

Alla metoderna ovan förutsätter att de data som ska sorteras kan lagras i primärminnet. Om så inte är fallet får man ta till extern sortering men det ingår inte i den här kursen.

Prioritetskö

När man poppar en stack får man ut det senast inpushade. När man tar ut något ur en vanlig kö får man tvärtom ut det som legat längst tid i kön. Man skulle kunna se det som att det som stoppas in tidsstämplas och att det påstämplade talet ger prioriteten för uthämtning.

I en prioritetskö stämplas en prioritet på varje objekt som stoppas in och vid uthämtning får man objektet med högst prioritet.

En abstrakt prioritetskö kan ha föjande anrop.

put(p,x)
Stoppa in x med påstämplad prioritet p (oftast ett heltal).
x = get()
Hämta det som har högst prioritet.
isEmpty()
Undersök om prioritetskön är tom.
Om det man vill stoppa in i prioritetskön är ett tal kan man använda talet självt som prioritet och bara skriva put(x). Hur den då skiljer sej från en stack och från en vanlig kö ser man av följande exempel.
      pq.put(1)
      pq.put(3)
      pq.put(2)
      x = pq.get() # x blir 3 
En kö hade skickat tillbaka det först instoppade talet 1; en stack hade skickat tillbaka det senast instoppade talet, 2; prioritetskön skickar tillbaka det bästa talet, 3. I denna prioritetskö betraktar vi största talet som bäst - vi har en så kallad maxprioritetskö. Det finns förstås också minprioritetsköer, där det minsta talet betraktas som bäst.

Prioritetsköer har många användningar. Man kan tänka sej en auktion där budgivarna stoppar in sina bud i en maxprioritetskö och auktionsförrättaren efter "första, andra, tredje" gör pq.get() för att få reda på det vinnande budet. För att han ska veta vem som lagt detta bud behövs förstås båda parametrarna i pq.put(p,x).

      pq.put(bud,person)   #person är ett objekt med budgivarens namn och bud
      winner = pq.get()    #budgivaren  med högst bud 

Trappa

Den bästa implementationen av en prioritetskö är en trappa, (eng heap), som är en lista tab tolkad som binärträd. HEAP Roten är tab[1] (vi använder inte tab[0], dess båda barn är tab[2] och tab[3] osv. Allmänt gäller att tab[i] har barnen tab[2*i] och tab[2*i+1]. Trappvillkoret är att föräldern är bäst, dvs varje tal ligger på två sämre tal.

Ett nytt tal läggs alltid in sist i trappan. Om trappvillkoret inte blir uppfyllt, dvs om det är större än sin förälder, byter förälder och barn plats och så fortgår det tills villkoret uppfyllts. Det här kallas upptrappning och kan i värsta fall föra det nya talet hela vägen upp till toppen, alltså tab[1].

Man plockar alltid ut det översta talet ur trappan och fyller igen tomrummet med det sista talet i trappan. Då är inte trappvillkoret uppfyllt, så man får byta talet och dess största barn. Denna nedtrappning upprepas tills villkoret åter gäller.

Både put och get har komplexitet log N om trappan har N element. Nackdelen med trappan är man måste bestämma listans storlek från början.

Här följer en rudimentär implementation av en max-heap:


class HeapNode:

    def __init__(self, data, priority):
        """data är det objekt vi vill lagra
           priority är nyckelvärdet som används för att jämföra objekten"""
        self.data = data
        self.priority = priority

class Heap:
    # En max-heap

    def __init__(self, maxsize):
        """Skapar en lista där vi använder element 1..maxsize"""
        self.maxsize = maxsize
        self.tab = (maxsize+1)*[None]
        self.size = 0

    def isEmpty(self):
        """Returnerar True om heapen är tom, False annars"""
        return self.size  == 0

    def isFull(self):
        """Returnerar True om heapen är full, False annars"""        
        return self.size == self.maxsize

    def put(self, data, priority):
        """Lägger in nya data med nyckeln priority i heapen"""
        if not self.isFull():
            self.size += 1
            self.tab[self.size] = HeapNode(data, priority)
            i = self.size
            while i > 1 and self.tab[i//2].priority < self.tab[i].priority:
                self.tab[i//2], self.tab[i] = self.tab[i], self.tab[i//2]
                i = i//2

    def get(self):
        """Hämtar det största (översta) objektet ur heapen"""
        if not self.isEmpty():
            data = self.tab[1]
            self.tab[1] = self.tab[self.size]
            self.size -= 1
            i = 1
            while i <= self.size//2:
                maxi = self.maxindex(i)
                if self.tab[i].priority < self.tab[maxi].priority:
                    self.tab[i],self.tab[maxi] = self.tab[maxi], self.tab[i]
                i = maxi
            return data.data
        else:
            return None

    def maxindex(self, i):
        """Returnerar index för det största barnet"""
        if 2*i+1 > self.size:
            return 2*i
        if self.tab[2*i].priority > self.tab[2*i+1].priority:
            return 2*i
        else:
            return 2*i+1



Heapsort

Om man stoppar in N tal i en trappa och sedan hämtar ut dom ett efter ett får man dom sorterade. Komplexiteten för denna heapsort blir O(N log N), alltså av lika god storleksordning som quicksort. Visserligen är quicksort lite snabbare, men heapsort har inte quicksorts dåliga värstafallsbeteende. och så kan ju en heap användas till andra saker än sortering också.
    heap = Heap(16)               
    for word in open("folksagor.txt").read().split():
        heap.put(word, word)
    while not heap.isempty():
        print heap.get()   

Heapsortanimation från Aucklands universitet.

Bästaförstsökning

Labb 4 behandlar problemet att finna kortaste vägen från FAN till GUD. Man har då ett problemträd med FAN som stamfar/urmoder, på nivån därunder barnen MAN, FIN, FAT osv, på nästa nivå fans barnbarn osv. Om man lägger barnen i en kö kommer man att gå igenom problemträdet nivå för nivå, alltså breddenförst. Om man byter kön mot en stack blir sökningen djupetförst. Med en prioritetskö får man bästaförstsökning, dvs det mest lovande barnet prioriteras och får föda barn.

Det här är exempel på en girig algoritm, där man i varje steg väljer den väg som verkar bäst för stunden, utan att reflektera över vad konsekvenserna blir i längden. Ofta ger giriga algoritmer inte den bästa lösningen, men i just det här fallet fungerar det faktiskt! Algoritmen kallas Dijkstra's algoritm (efter den holländske datalogen Edsger W. Dijkstra) och att den fungerar bevisas i fortsättningskursen DD1352 Algoritmer, datastrukturer och komplexitet.

Exempel 1: Sök billigaste transport från Teknis till Honolulu. All världens resprislistor finns tillgängliga.

Problemträdets objekt innehåller en plats, ett pris och en förälderpekare. Överst i trädet står Teknis med priset noll. Barnen är alla platser man kan komma till med en transport och priset, till exempel T-centralen, 20.00. Man söker en Honoluluobjekt i problemträdet. Med breddenförstsökning får man den resa som har så få transportsteg som möjligt.

Med bästaförstsökning får man den billigaste resan.



Exempel 2: Sök effektivaste processen för att framställa en önskad substans från en given substans. All världens kemiska reaktioner finns tillgängliga med uppgift om utbytet i procent.

Problemträdets objekt innehåller substansnamn och procenttal. Överst i trädet står utgångssubstansen med procenttalet 100. Barnen är alla substanser man kan framställa med en reaktion och utbytet, till exempel C2H5OH, 96%.

Med en max-prioritetskö får man fram den effektivaste process som leder till målet.