En lista är en struktur som är enkel att hantera men som inte är så

Träd
En lista är en struktur som är enkel att hantera men som
inte är så effektiv ur söksynpunkt. Att leta efter en viss
nod i en lista med n noder kommer i genomsnitt att
kräva n/2 jämförelser. Detta är inte så effektivt.
En mer avancerad struktur som blir mer komplex att
hantera men som blir mer effektiv är ett träd. Ett träd är
en struktur som har grenar. Detta leder till att vi får fler
korta sökvägar istället för en lång.
Ett träd kan se ut på detta sätt:
Ett träd är uppbyggt med en mängd noder sammanbundna genom pekare på ett hierarkiskt sätt. Varje nod
har en ingående pekare och ett antal utgående pekare.
1
2
Ett binärt träd är ett träd där varje nod har en ingående
pekare och högst två utgående pekare. Kan alltså se ut på
följande sätt:
Man talar också i termer av släkträd så att underliggande
noder kallas för barn, i ett binärt träd talar man om vänsterbarn och högerbarn. Vi talar om förälder eller fader
och förfader.
Vi talar också om olika nivåer i trädet, rotnoden utgör
nivå noll, sedan ökar nivån nedåt.
rot
nod
En nod i ett binärt träd kan se ut på följande sätt:
inre nod
Nyckeldel
Infodel
Vänsterpek
löv
3
4
Högerpek
Ett binärt sökträd är ett binärt träd där nodernas placering baseras på deras nyckel så att en nods vänsterpekare
pekar ut en nod med en lägre nyckel och en nods högerpekare pekar ut en nod med en högre nyckel. Det kan se
ut på detta sätt:
2
8
4
6
1
Om vi har ett träd med 8 nivåer kan vi i det ideala fallet
lägga in femhundraelva noder och hitta en godtycklig av
dessa noder med max 8 jämförelser.
9
7
Vad ska man ha ett träd till? Det finns ju flera goda skäl,
t.ex utgör de en stor bit av denna kurs. Det finns ju en del
andra skäl också.
Ett sökträd är en effektiv struktur att söka i. För att finna
talet 4 i vårt binära träd så behövs det 3 jämförelser. Vi
ser att vi kan hitta vilket tal som helst i trädet med högst
4 jämförelser trots att vi har tio noder. Ett träd blir en logproportionelig struktur till skillnad mot en lista som blir
linjär.
5
3
Vi antar här att nycklarna är unika. Om de inte är det kan
man införa ett räknarfält i noderna eller låta nodernas
infodel vara en länkad lista med alla dubletter.
10
5
Ett träd verkar vara komplicerat att underhålla och att
lägga in noder i. Detta är inte fallet. I själva verket är det
tämligen trivialt att lägga in noder i ett binärt sökträd.
Däremot är det lite knepigare att ta bort något ur ett träd.
Trädets effektitivet avgörs av dess utseende. Det ska vara
balanserat för att vara effektivt. Sämsta möjliga är om vi
bara har en lång pinne åt ett håll. Då har vi ju en länkad
lista.
6
Hur kan vi då implementera träd?
Datastrukturen blir ju mycket lik en lista, det enda som
skiljer i själva noden är att vi har två pekare. Vi kan alltså
göra en post eller en klass som innehåller tillämpliga
delar. Nämligen:
Nyckeldel
Infodel
Vänsterpekare
Högerpekare
Det visar sig att det kan vara fördelaktigt att i varje nod
ha en referens till sin “förälder” också. Det ingår egentligen inte i trädet, men förenklar vissa saker.
Vad ska då ingå i ADT‘n?
7
8
Blir ungefär som i listfallet. Vi börjar med en lågnivådel ,
trädnoden, som mest innehåller konstruktorer
Sökning i ett binärt sökträd.
Vi kan sedan utan större problem bygga upp högre nivårutiner som lite grand kan beror på vad vi avser att göra.
Sådana kan vara:
Börja i roten.
search
insert
delete
print
leta efter en nod i ett träd
lägger in en ny nod i ett träd
tar bort en ny nod ur ett träd
skriver ut hela trädets infodelar
Oväntat nog blir dessa rätt triviala trots att trädet verkar
så komplext. Undantaget är delete.
Vi antar att alla nycklar har en unik förekomst.
Repetera
Jämför nycklarna
Om lika klara
Annars om söknyckeln för stor,
gå ett steg till höger
Annars gå ett steg till vänster
tills vi hittat rätt eller tills vi inte kan gå längre
Om vi hittat en nollpekare så fanns inte sökt nod.
Vi kan naturligtvis formulera detta annorlunda:
Om trädet är tomt så fanns nyckeln inte
Annars jämför rotnoden med sökt data
Om lika så är vi klara
Annars finns den ev. till vänster eller till höger
.
9
En rekursiv formulering
10
Hur lägger vi in noder i ett binärt träd då? Verkar ju
svårt.
Resonemang;
Har vi en tom pekare så fanns inte noden.
Om vi har en eller flera noder så måste letar vi först i
roten. Finns den inte där så letar vi i de båda subträden
och hittar den kanske då. Om vi har ett binärt sökträd
kan algoritmen förenklas en del. Vi kan ju då utesluta ett
av subträden i den rekursiva sökningen.
Antag att vi vill lägga in talen
12 4 15 9 1 8 20
Det blir på detta viset:
Blir då så här:
a)
Om trädet är tomt så fanns nyckeln inte
Annars Jämför rotnoden med sökt data
Om lika så är vi klara
Annars om för liten leta till höger
annars leta till vänster
b)
12
12
4
4
11
c)
12
12
15
12
12
d)
4
4
15
1
9
f)
15
9
8
12
4
1
15
g)
12
e)
4
9
15
1
9
20
8
13
14
Vi kan notera en viktig sak. All inläggning av noder sker
längst ned i trädet. Vi lägger aldrig in en nod mitt i trädet. Det behövs inte. Det betyder å andra sidan att trädets utseende kommer att bero på i vilken ordning talen
läggs in.
Om vi tar samma tal som nyss men i ordningen
1 20 4 8 9 12 15
får vi följande träd
Man kan formulera en inläggningsalgoritm på följande
sätt:
Om trädet är tomt så lägg in noden direkt.
Annars
Repetera
Jämför vår nyckel med nodens nyckel
om vår nyckel större försök gå till höger
annars försök gå till vänster
tills du inte kan gå längre
lägg in noden där
Man kan naturligtvis formulera samma sak rekursivt
Trivialfallet:
roten är en nollpekare, d. v. s. trädet är tomt
sätt roten att peka på vår nya nod
Generella fallet:
Om nyckeln mindre än rotens nyckel lägg in den
nya noden i vänster subträd
Annars lägg in i höger subträd.
Test:
Eftersom subträdet till slut blir en noll-pekare så
konvergerar alltid det generella fallet mot trivialfallet.
1
20
4
8
9
12
15
15
16
Hur kan det fungera?
Vi kommer först att anropa rutinen nånting så här:
Om vi går vidare med att skriva ut alla noder i ett träd så
kommer vi in på något som kallas för traversering av ett
träd. Att traversera ett träd innebär att man går igenom
hela trädet så att man besöker alla noder en gång.
root = insert(root, nynod)
Man brukar tala om tre standardmetoder att göra detta:
Sedan kommer insert att anropa sig själv ungefär så här:
inorder
postorder
preorder
root.left = insert(root.left, nynod)
Så småningom blir det första argumentet en nollpekare
och den kommer då att ändras till att peka på min nya
nod. Eftersom root.left är en pekare som ingår i trädet så
kommer min nya nod att noggrannt hängas upp i julgranen på lämplig gren.
in betyder emellan något och betyder i detta fallet att
roten är i mitten. Alltså, vänster-roten-höger.
post betyder ju efter och då får vi, vänster-höger-roten
eftersom pre betyder före så får vi i det sista fallet rotenvänster-höger.
Detta appliceras rekursivt på trädet.
17
18
Man kan exemplifiera detta med följande träd:
Man kan formulera en sekventiell inorder algoritm på
följande sätt:
Skaffa en stack av noder tillräckligt stor.
Sätt pek till roten.
Repetera
så längre inte pek är nollpekare
pusha pek
pek = pek.left
om stacken inte är tom
poppa pek
gör något med utpekad nod
pek = pek.höger
tills pek är nollpekare och stacken är tom.
5
3
2
8
4
6
9
7
1
Inorder : 1 2 3 4 5 6 7 8 9 10
Preorder: 5 3 2 1 4 8 6 7 9 10
Postorder: 1 2 4 3 7 6 10 9 8 5
19
10
Det blir ju så här ungefär:
pusha 5
pusha 3
pusha 2
pusha 1
poppa 1
skriv ut 1
poppa 2
skriv ut 2
poppa 3
skriv ut 3
pusha 4
poppa 4
skriv ut 4
20
poppa 5
skriv ut 5
pusha 8
pusha 6
poppa 6
skriv ut 6
pusha 7
poppa 7
skriv ut 7
poppa 8
skriv ut 8
pusha 9
poppa 9
skriv ut 9
pusha 10
poppa 10
skriv ut 10
klart
Hur blir en rekursiv formulering då?
Trivialfallet:
Tomt träd, gör inget
Generella fallet:
Ta först hand om vänster subträd
Skriv sedan ut roten
Ta sedan hand om höger subträd
Konvergens? Ja subträden blir nollpekare så småningom.
blir såhär ungefär
Notera att detta kan förenklas genom vår föräldrapekare!
Då behövs inte stacken.
inorder_print( root : trädpekare)
om rot inte nollpekare
inorder_print(root.left)
skriv(root.info)
inorder_print(root.right)
end
Ingen stack ingen repetition bara några få enkla rader.
Medge att det blir snyggt!
21
Vi ser att om vi har ett binärt sökträd så får vi stigande
nyckelordning om vi gör en inorder traversering.
Om vi inte bryr oss om ordningen utan bara vill besöka
alla noder en gång och utföra något, spelar det då någon
roll vilken traverseringsordning jag väljer.
22
Vi kan implementera ett binärt träd på olika sätt. Som i
listfallet börjar vi med att definera en nod. Den innehåller
en datadel och två pekare samt ett antal enkla funktioner
på dessa.
Det kan bli något åt det här hållet:
package trad;
//
//
//
//
En trädnodklass med heltal som datadel
använd paketåtkomst för att TreeNode och Tree ska
kunna komma åt varandra direkt, stäng ute de som är
utanför paketet
class TreeNode {
int val;
TreeNode parent;
TreeNode left;
TreeNode right;
//
//
//
//
datadel
förälder
vänsterpekare
högerpekare
TreeNode() {
val = 0;
parent = null;
right = null;
left = null;
}
// std konstruktor
// nollställ
TreeNode(int val) {
this.val = val;
parent = null;
right = null;
left = null;
}
// konstruktor
// sätt värde
//
//
TreeNode(int val, TreeNode parent) {
this.val = val;
// sätt värde
this.parent = parent;
23
24
En Iterator för vårt träd kan se ut som
right = null;
left = null;
}
package trad;
// kopiera ett träd rekursivt
public TreeNode copy() {
TreeNode t = new TreeNode(); // kopia
t.val = val; // kopiera datadelen
public interface Iterator {
public boolean hasNext();
public int next();
public void remove();
// klona barnen
if(left != null) {
t.left = left.copy(); // vänsterträdet
t.left.parent = t; // t är förälder
}
if(right != null) {
t.right = right.copy();
t.right.parent = t;
}
return t; // kopian
}
}
};
25
26
// hämta nästa nod, flytta referenserna
Vi kan om vi är mindre renläriga direkt arbeta med
TreeNode klassen och låta ett träd vara en pekare till en
TreeNode.
public int next() {
}
//
//
//
//
Det är dock snyggare att explicit skapa en trädtyp som vi
kan deklarera och använda. Ett binärt sökträd kan bli
ungefär så här
ta bort aktuell nod. Om den har två
barn så kommer den att ersättas av sin
efterföljare, därför backar vi ett
steg i det fallet.
public void remove() {
}
package trad;
// En klass för binära sökträd, trädnodklassen
// finns i samma paket med paketåtkomst för
// att underlätta arbetet
};
// Standardkonstruktor
public Tree() { root = null; }
public class Tree {
private TreeNode root;
// trädets rot
// en iterator
private class TreeIterator implements Iterator {
// aktuell nod och nästa nod
// Kopiera trädet
public Object clone() {
Tree t = new Tree();
t.root = (TreeNode) root.copy();
return t;
}
private TreeNode lastReturned = null,next;
// ny iterator
// konstruktor
public Iterator iterator() {
return new TreeIterator();
}
public TreeIterator() {
}
// Inläggning, bara en "wrapper"
// kolla om det finns fler noder
public boolean hasNext() {
}
public void insert(int val) {
root = insert(root, val);
}
// Rekursiv inläggning. Eftersom vi inte kan ha
// referensparametrar i Java så måste vi returnera
27
28
// resultatet istället och lägga in på rätt ställe
}
TreeNode insert(TreeNode root, int val) {
}
// Leta upp angiven nod, returnera en referens
// till den
// Kolla om tomt träd
private TreeNode nodeSearch(int val) {
}
public boolean empty() {
return root == null;
}
// ta bort nod med angiven datadel
public void remove(int val) {
}
// Töm trädet, bara en wrapper
public void clear() {
}
// den som gör själva jobbet
private void deleteEntry(TreeNode p) {
}
// Ta bort alla noder ur trädet, behövs kanske inte?
};
private void clear(TreeNode root) {
}
// Leta efter angivet värde i trädet, icke rekursivt
public boolean search(int val) {
}
// Traversera trädet, bara en wrapper
public void traverse() {
traverse(root);
}
// inorder traversering
void traverse(TreeNode root) {
}
// ta fram efterföljande nod i inorder mening.
// En hjälpfunktion
private TreeNode successor(TreeNode e) {
29
Innan vi implementerar detta ska vi se hur man kan ta
bort saker ur ett träd.
När vi ska ta bort något ur ett träd så blir det vissa
svårigheter. Det inses lätt att det inte är trivialt att såga
bort en bit mitt i ett träd och samtidigt behålla trädet
intakt. Med lite datoriserad ympningsteknik kan man
dock fixa till det också.
30
Antag att vi har trädet på sid 19.
Man inser att det är lätt att ta bort ett löv t. ex. noden med
värdet 1, men också överkomligt att ta bort en nod med
bara ett barn, t. ex. 2’an. I detta fall får vi limma fast 1’an
direkt under 3’an.
Om vi vill ta bort noden med värdet 8 blir det svårare, vi
kan inte hänga upp båda barnen under roten eftersom
den då får 3 barn vilket inte är tillåtet.
Istället erätter vi noden med en annan nod som är
enklare att ta bort, utan att rubba trädets grundstruktur.
Finns det en sådan nod. Ja två stycken, de som har
värden närmast aktuell nod. Av konvention brukar man
ta den som är närmast större än aktuell nod, i det här
fallet 9’an. Vi vet säkert att denna nod inte kan ha något
vänsterbarn.
31
32
Varför? Jo om den hade det så skulle denna nod ha ett
lägre värde än vår nod men samtidigt högre än 8’an.
(Följer av dess läge). Men då är den ju närmare 8’an än
vårt tal vilket strider mot grundantagandet.
Vi ersätter alltså 8’an med 9’an och tar bort den gamla
noden med talet 9.
Hur kan vi nu implementera detta?
sökning ganska enkelt enligt tidigare beskrivning
// Leta efter angivet värde i trädet,
// rekursivt
public boolean search(int val) {
Notera att resonemanget lika gärna kan appliceras på
noden närmast mindre än aktuell nod.
// kolla om sökt tal finns i roten
if (root.val == val) return true;
Det är inte speciellt tidskrävande att ta bort noder ur ett
träd, det är bara det att metodiken blir lite strulig.
// Nej, leta vidare
Hur hittar vi rätt nod? Ta ett steg nedåt till höger, gå
sedan så långt åt vänster som det går. Då har vi hittat rätt
nod.
TreeNode l = root;
// så länge vi kan fortsätta och
// så länge vi inte hittat rätt
Notera att vi har en successor metod i vår klass, den är
till för att hitta efterföljaren. Använs kanske också av iteratorn.
while (l != null && l.val != val) {
// gå till vänster eller höger
if (val < l.val) l = l.left;
else l = l.right;
}
return (l != null); // svaret
}
33
34
}
Ta bort ett träd, enkelt med hjälp av destruktorerna
// Töm trädet, bara en wrapper
public void clear() {
// tomt inget att göra
if (root == null) return;
else {
clear(root);
root = null;
}
empty, trivial funktion
public boolean empty() {
return root == null;
}
}
// Ta bort alla noder ur trädet,
// behövs kanske inte?
private void clear(TreeNode root) {
// ta bort subträden rekursivt
if (root.left != null) {
clear(root.left);
root.left.parent = null;
root.left = null;
}
if (root.right != null) {
clear(root.right);
root.right.parent = null;
root.right = null;
}
35
36
Utskrift av alla noder i inorder ordning
nodeSearch och successor används bl. a. av remove.
public void traverse() {
traverse(root);
}
// Leta upp angiven nod,
// returnera en referens till den
// inorder traversering
void traverse(TreeNode root) {
if (root != null) { // tomt?
traverse(root.left);
System.out.println(root.val);
traverse(root.right);
}
}
private TreeNode nodeSearch(int val) {
TreeNode t = root;
while (t != null) {
if (t.val == val) return t;
else if (t.val > val) t = t.left;
else t = t.right;
}
return null;
}
// ta fram efterföljande nod i inorder
// mening. En hjälpfunktion
private TreeNode successor(TreeNode e) {
// tomt träd, ingen efterföljare
if (e == null) return null;
// ta ett steg åt höger om det går
else if (e.right != null) {
// sedan åt vänster så långt det går
TreeNode p = e.right;
37
while (p.left != null) p = p.left;
return p;
}
38
Borttagning görs på detta sätt:
// ta bort nod med angiven datadel
// inget högerbarn
public void remove(int val) {
TreeNode p = nodeSearch(val);
else {
// kolla om den fannse
// saknar högerbarn
// vandra uppåt så länge du
// bara är högerbarn. Stanna när
// du hittar ett barn som är
//vänsterbarn, returnera dess
// förälder.
TreeNode p = e.parent;
TreeNode ch = e;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
if (p == null) throw new
NoSuchElementException();
deleteEntry(p);
}
// själva arbetshästen
private void deleteEntry(TreeNode p) {
// kolla om p har två barn, i så fall
// byt ut innehållet och
// ta bort en annan nod
if (p.left != null && p.right != null) {
TreeNode s = successor(p);
p.val = s.val;
// flytta s data till p
p = s;
// ta bort s istället
}
// Nu vet vi att p har högst ett barn
TreeNode replace;
// om vi har ett vänsterbarn, spara referens
// annars spara referens till högerbarn
if(p.left != null)
replace = p.left;
else
39
40
replace = p.right;
// om p har ett barn, länka
//utbytesnoden till föräldern
if (replace != null) {
// finns ett barn
replace.parent = p.parent; // farfar blir pappa
// se till att farfar adopterar barnbarnet
// när vi slår ihjäl pappa. Kolla dock att
// farfar existerar först
if (p.parent == null) root = replace;
// ingen farfar
// om p är vänsterbarn, lägg in utbytesnod
// där istället
Vanliga binära sökträd lider av de är så känsliga för
inläggningsordningen.
Det leder till att effektiviteten är svår att förutse och den
kan bli allt från log(n) till n-proportionell i ett träd.
För att avhjälpa detta kan vi använda oss av olika
metoder att balansera trädet. Obalans uppstår vid
inläggning och borttagning av noder. Vi kan modifiera
dessa operationer så att balans uppnås.
En metod att göra detta är s. k. AVL-träd, uppkallade
efter de ryska kamraterna Adelson-Velskij och Landis.
else if (p == p.parent.left)
p.parent.left = replace;
// annars högerbarn
else p.parent.right = replace;
}
// inga barn, kolla om jag är
// den enda i hela världen
else if (p.parent == null) root = null;
//
//
//
//
ja, nu utrotad!
nej finns fler än jag, kolla om jag är
vänster eller högerbarn till min förälder.
Nollställ aktuellt ställe.
else {
if (p == p.parent.left) p.parent.left = null;
else p.parent.right = null;
}
}
41
42
Principen för ett AVL-träd är ganska enkel. Vi inför en
balansräknare i varje nod som anger skillnaden i längd
mellan nodens vänster och högergren. I ett balanserat
träd skall denna ha något av värdena -1, 0 eller +1.
I ett AVL-träd modifierar vi sedan insert och remove så
att balans alltid bibehålls oavsett inläggningsordning.
43
44