Träd

Definition

Ett träd är en samling noder förbundna på så sätt att det finns exakt ett sätt att gå från en godtycklig nod till en annan godtycklig nod, se figur 5.1 på sid 87 i kompendiet. Till de termer som är definierade i figuren kan läggas följande: Exempel på trädstrukturer "i verkligheten" är släkträd, innehållsförteckningen i en bok (boken består av kapitel som består av avsnitt som i sin tur kan bestå av "underavsnitt") och filsystemet i ett operativsystem (varje katalog kan innehålla filer och andra kataloger).

Ordnat träd

Ett träd är ordnat om syskon ligger i en viss ordning i förhållande till varandra (mao, ett ordnat träd består av en rot och en sekvens av subträd som även de är ordnade träd). Exempel på detta är de parsträd kompilatorer bygger, se nedan.

Trädet i bilden ovan beskriver instruktionen var1 = X / Y; . Trädet måste vara ordnat eftersom det har betydelse i vilken ordning noderna läses (var1 = Y / X; betyder något helt annat).

Binärt träd

Ett binärt träd är ett träd där en nod kan ha högst två barn, ett vänsterbarn och ett högerbarn .

Binärt sökträd

Definition: I ett binärt sökträd är noderna sorterade på så sätt att  för alla noder X i trädet är alla värden i det vänstra subträdet mindre än värdet i X och alla värden i det högra subträdet större än värdet i X. Figur 5.2 på sid 88 är ett exempel på ett binärt sökträd och figur 5.4 på sid 89 är ett exempel på ett träd som inte är ett binärt sökträd. Anledningen till att ha en datastruktur med sorterade element kan vara att det ska gå snabbt att hitta ett visst element eller att vi vill läsa alla element i ordningsföljd.  Vanligtvis är värdet i en nod inte det sparade elementet utan en nyckel till elementet. Om varje nod innehåller till exempel data om en person kan personnumret vara nyckeln.

Notera att  definitionen ovan inte tillåter dubletter, dvs det får inte finnas två likadana nycklar. Definitionen skulle kunna utökas till att tillåta likadana nycklar men det är oftast bättre att lagra element med samma nyckel i samma nod (till exempel i en lista).

Operationer på binära sökträd

För att förenkla koden i vårt trädbibliotek hanterar det bara nycklar, det går inte att spara något element i en nod.Vi ska kunna söka efter största, minsta och en godtycklig nod samt ta bort en nod och lägga till en nod. Så här definierar vi dessa operationer: Dessutom skriver vi metoder för att traverserva hela trädet (dvs läsa alla noder). Dessa får samma definition som för en lista : Här kommer denna definition, BinarySearchTree.h . Notera metoderna returnerar en pekare till en nod i listan. Detta betyder att definitionen av en nod blir publik i motsats till i våra tidigare datastrukturer. Är inte detta dåligt? Jo, det har nackdelar: det går inte att ändra nodens definition och det går inte att skriva om implementationen till att använda en array (vilket i och för sig knappast blir aktuellt). Fördelen är att användaren får ut ett helt subträd eftersom noden även innehåller länkar till dess barn. Om vi inte returnerade en nod skulle det vara svårt att få tag i ett subträd. Dessutom blir koden lättare om vi returnerar hela noder.

Implementation av binärt sökträd

Här är hela implementationen, BinarySearchTree.c , och ett testprogram för den, TestBinarySearchTree.c . Låt oss nu titta på den bit för bit. Från headerfilen hämtar vi definitionen av en nod:
struct Node {
  KeyElement key;
  struct Node* parent;
  struct Node* leftChild;
  struct Node* rightChild;
};
Den innehåller en nyckel och en pekare till sina två barn. Dessutom innehåller den en pekare till sin förälder. Den behövs för att kunna implementera metoden next() effektivt. Hade vi inte metoder för iterering skulle pekaren till föräldern ha kunnat utlämnas.

Nu deklarerar vi själva trädet:
struct Node* root = NULL;
Det enda vi behöver hålla reda på är roten. Den innehåller pekare till sina barn som har pekare till sina barn osv.

Så över till operationerna. Vi börjar med min() som ska returnera noden med minst nyckel. Den hittar vi genom att hela tiden gå nedåt till vänster (vänster barn är alltid mindre än aktuell nod). Vi returnerar noden längst ner till vänster. min() anropar bara en privat metod _min(struct Node* n) som gör hela jobbet. Det är därför att _min() kommer att användas av andra publika metoder som inte ska söka från roten. Däremot ska användaren av vårat bibliotek inte behöva hålla reda på trädets rot, så vi kan inte låta min() ta en nod som inparemeter.
struct Node* min() {
  return _min(root);
}

struct Node* _min(struct Node* n) {
  struct Node* min = n;
  while (min->leftChild != NULL) {
    min = min->leftChild;
  }
  return min;
}
Sedan tar vi max(), den fungerar precis som min() men vi går år höger hela tiden.
struct Node* max() {
  return _max(root);
}

struct Node* _max(struct Node* n) {
  struct Node* max = n;
  while (max->rightChild  != NULL) {
    max = max->rightChild;
  }
  return max;
}
Notera hur lätt det är att hitta minsta respektive största elementet. Hade datastrukturen varit osorterad skulle det varit nödvändigt att läsa alla element för att veta vilket som var minst eller störst.

Så var det dags för traverseringen. Det finns tre olika sätt att traversera ett binärt träd:

  1. Inorder Besök först en nods vänstra barn, sedan noden själv och sist nodens högra barn.
  2. Preorder Besök först noden själv, sedan dess vänstra barn och sist dess högra barn.
  3. Postorder Besök först nodens vänstra barn, sedan dess högra barn och sist noden själv.
Dessa traverseringsordningar förklaras tydligare i figur 5.5 på sid 90 och tabellen överst på sid 91. Vi väljer inorder för vår traversering eftersom det innebär att nycklarna kommer ut sorterade (i stigande ordning). Först skriver vi beforeFirst() :
void beforeFirst() {
  cursor = min();
}
Det första elelementet är det minsta. Cursorn pekar nu i och för sig på det minsta elementet, inte före det. Detta spelar ingen som helst roll bara operationerna fungerar enligt definitionen: Om beforeFirst() anropas först kommer nästa anrop av next() att returnera det första elementet.

Nu tittar vi på next(). Den ska hitta noden som är närmast större än cursor. Eftersom det inorder traversering ska vi till höger barn efter att ha varit i aktuell nod. Detta görs med raderna
  if (cursor->rightChild != NULL) {
    next = cursor->rightChild;
Nu är vi nere i det högra barnet. Den minsta noden under det är dess nedersta vänstra avkomma, den hittar vi med raden
    next = _min(next);
Så var det klart. Det andra fallet är om aktuell nod inte har något högerbarn. Då måste vi upp till dess förälder i stället.
  } else {
    next = cursor->parent;
Om vi kom från ett vänsterbarn upp till föräldern har vi hittat nästa nod eftersom inorder betyder att efter vänstra barnet kommer föräldern. Om vi däremot kom från ett högerbarn hade vi redan varit i föräldern. Vi måste då vidare upp tills vi når en nivå där vi kom från ett vänsterbarn.
    struct Node* ch = cursor;
    while (next != NULL && ch == next->rightChild) {
      ch = next;
      next = next->parent;
    }
  }
Kontrollen next != NULL i while-loopen beror på att om vi redan besökt alla noder kommer vi att fortsätta hela vägen upp till roten vars parent är NULL. Nu har vi hittat nästa nod. Vi returnerar cursor och uppdaterar den till att peka på nästa nod.
  struct Node* current = cursor;
  cursor = next;
  return current;
Pust! Nu är vi klara med next().

Lyckligtvis är hasNext() betydligt enklare. Traverseringen är slut om cursor är NULL. Vi har då gått igenom hela trädet och kommit till rotens förälder, se next() .
bool hasNext() {
  return cursor != NULL;
}

Sökning efter ett visst element sker genom att alltid jämföra värdet i aktuell nod med det sökta elementet. Om de är lika har vi hittat den sökta noden, om det sökta elementet är mindre än det i aktuell nod går vi ned till det vänstra barnet och om det sökta elementet är större går vi ned åt höger. Bilden nedan visar den väg search() tar för att hitta noden med nyckeln 9.


insert() fungerar på nästan samma sätt som search(). Först söker den efter en nod med den nyckeln som ska läggas in. Hittas en sådan nod kan den nya noden inte läggas in eftersom det inte får finnas dubletter. Om det inte finns någon nod med den nya nyckeln läggs den nya noden in där sökningen returnerade NULL. Detta innebär att trädet fortfarande är ett binärt sökträd eftersom vi sökt ut positionen enligt reglerna för att bonärt sökträd. Notera att detta innebär att nya noder alltid blir löv. I bilden nedan visas en sökning efter en nod med nyckeln 12. En sådan finns inte vilket innebär att en ny nod med nyckeln 12 kan läggas in där sökningen returnerar NULL.

Lägg märke till hur mycket mer kod det är i operationen insert() än i search(). Det beror till stor del på att insert() är iterativ medans search() är rekursiv.

Slutligen återstår operationen remove() som dessvärre är den krångligast av alla. Först söker vi efter en nod med den nyckel som ska tas bort (på samma sätt som i search() ). Därefter sönderfaller algoritmen i tre olika fall:

  1. Noden som ska tas bort är ett löv

  2. Det är bara att ta bort noden och sätta förälderns pekare som tidigare pekade ut noden till NULL.
  3. Noden som ska tas bort har ett barn

  4. Förälderns pekare som tidigare pekade ut noden ändras till att peka på barnet varefter noden tas bort. Bilderna nedan visar hur noden 10 tas bort ur trädet.

     
  5. Noden som ska tas bort har två barn

Prestanda för implementationen

  1. Vad gäller traverseringen finns det flera alternativa implementationer. Olika datastrukturer, till exempel en länkad lista, där elementen är sorterade kan byggas redan vid beforeFirst(). Sedan hämtas elementen vid traversering ur den. Detta gör traverseringen klart långsammare medans många andra operationer blir lite snabbare på grund av att vi slipper pekaren parent.
  2. Vi kan konstatera att exekveringshastigheten av så gott som alla operationer är proportionell mot djupet av den nod som hanteras. Detta innebär att worst case är proportionell mot det nedersta lövets djup. Kan vi då veta vilken höjd ett visst träd har? Svaret är att höjden är helt beroende av i vilken ordning noderna läggs in. Bilderna nedan visar två träd med exakt samma noder men noderna är inlagda i olika ordning.


  3. Trädet till vänster kallas balancerat . Trädet till höger har degenererat till en länkad lista. Detta är de två extremfallen, det är lätt att inse att de har ganska olika prestanda. Den nedersta noden i det högra trädet har djupet 6 medans den i det vänstra har djupet 2. Finns det då något genomsnitt? Mer generellt har ett balancerat träd ett djup som är största heltalet mindre än log2 n, där n är antalet noder i trädet. Ett träd av typen i den högra bilden ovan har djupet n-1. Detta innebär att trädet till vänster är O(log n) medans trädet till höger är O(n), en väsentlig skillnad. Det går att visa att ett träd i genomsnitt har djupet 1,38log n, dvs det är O(log n).

    Det finns olika strategier att stuva om noderna i träd när nya noder läggs in så att träden ska vara så nära balancerade som möjligt. Några exempel är AVL-träd, röd-svarta träd och AA-träd. Gemensamt för dem alla är att insert() och remove() blir lite långsammare eftersom noderna måste stuvas om. Eftersom de är väldigt nära balancerade blir djupet ungefär log n i stället för 1,38log n, dvs ungefär 25% snabbare.