Binære søketrær Et notat for INF Stein Michael Storleer 6. mai 3 Dette notatet er nyskrevet og inneholder sikkert feil. Disse vil bli fortløpende rettet og datoen over blir oppdatert samtidig. Hvis du vil ha en kopi på papir, kan det derfor være lurt å vente litt, til denne kommentaren er fjernet. Nest etter lister er trær den viktigste datastrukturen i programmering. Trær lager vi ved å la listeelementnodene ha mer enn en «nestepeker». Grunnen til å velge et tre istedet for en lenkeliste, er at veien til et bestemt objekt blir kortere. Hvis vi tenker oss at vi skal finne ett av mange tusen blader på et vanlig forgrenet tre, blir veien langs stamme, grener og kvister kortere til bladet enn om alle bladene lå etter hverandre langs en lang stengel. Poenget med å bruke et binærtre istedet for en lenkeliste når man skal lage en beholder f.eks., er altså at man raskere skal kunne legge inn/hente objektene. Dvs. færre if-tester og færre runder i løkker eller færre metodekall. Trær lager vi på samme måte som lenkelister av noder med pekere som igjen kan peke på noder. Eneste forskjell er at vi kan ha mer enn en. I INF skal vi bare se på den typen trær som har to forgreninger, to «nestepekere». Slike trær kaller vi binære. (Jf. binært tallsystem, et tallsystem som har to sifre). Denne tegningen viser strukturen i et binærtre. Legg merke til pilenes retning. Øverste node er starten på hele treet og kalles treets rot. Mens naturlige trær vokser oppover, vokser nodetrær nedover: r i slike trær kan lages som instanser av en klasse som har to pekere «til seg selv», slik: class { venstre, høyre ; I tillegg til de to pekerne inneholder noden dataene som skal lagres i treet, akkurat som listeelementobjektene i en lenkeliste. I programmer kan dette typisk være en peker til f.eks. personobjekter, en peker av typen Object, eller av en generisk type. I dette notatet bruker vi for enkelhets skyld, en heltallsverdi som representerer nøkkelen, eller attributtet som gjør at objektene er sammenlignbare:
class { venstre, høyre ; int v ; // verdien som nodene i treet sammenlignes på venstre int 3 v høyre rot venstre int v høyre venstre int 8 v høyre Dette tegnes vanligvis slik uten navn og type på variablene: 3 8 og uten pekere som peker til : 3 8 Et binært tre med noder med heltallsverdier: 3 Treet ovenfor er et binærtre, men tallene (verdiene i nodene) er ikke satt inn på en ordnet måte. (Det er da heller ikke muig å lage en traverseringsalgoritme som «besøker» nodene i stigende rekkefølge etter verdi). Invariant tilstandspåstand alle noder har to og bare to pekere som kan peke på andre noder. Denne påstanden sikrer at treet vårt er binært.
Invariant tilstandspåstand Alle noder i treet bortsett fra rotnoden blir pekt på av én og bare én annen nodes peker. Denne påstanden sikrer at det bare er én måte (en «sti») fram til en node, og at det ikke finnes sykler eller «blindtarmer». Kort sagt at strukturen er en trestruktur. Den sikrer også at vi har én bestemt node (sammenlign med lenkelistas førsteelement) hvor alle operasjoner starter. Av tilstandspåstand følger at alle nodene kan nås fra rota på en og bare en måte. Nedenfor ser vi tegninger av grafer som bryter den siste tilstandspåstanden og som dermed ikke er binærtrær: 3 63 3 3 3 3 Binære trær er en rekursiv struktur siden venstre- og høyrepekerne peker på binærtrær (eller ). Da kan vi definere et binærtre rekursivt slik: Et binærtre er en node med to pekere hvor disse peker til eller til et binærtre. Da vil en node hvor begge pekene peker til være et binærtre: () Da kan vi ta utgangspunkt i treet () og la en eller flere av pekerne peke på et eller to nye binærtrær av type (). Vi kan da få tre forskjellige trær: () (3) (4) 3
Slik kan vi fortsette. Vi har nå et utvalg av 4 forskjellige trær. Vi kan ta utgangspunkt i et av disse og erstatte en eller flere pekere med trærne () til (4). Her et eksempel hvor det er tatt utgangspunkt i treet (3) og hvor vi har erstattet alle dets pekere med instanser av binærtreet (4): Binærtrær som blir pekt på av en venstre- eller høyrepeker kaller vi et subtre. Et binærtre består dermed av en node som peker på et (mulig tomt) venstre subtre og et (mulig tomt) høyre subtre. Alle noder i treet er derfor røtter i subtrær. Oppgave. Hvor mange ikke-tomme trær er det i figuren nærmest over? Binære søketrær Dataene/objektene som skal lagres i treet må være sammenlignbare for at treet skal være søkbart og bli et søketre. Sammenlig med en sortert lenkeliste. For å ordne nodene etter verdi (den sammenlignbare egenskapen) innfører vi to tilstandspåstander til: Invariant tilstandspåstand 3 Alle verdier i nodene i det venstre subtreet er mindre enn verdien i noden selv. Invariant tilstandspåstand 4 Alle verdier i nodene i det høyre subtreet er større enn verdien i noden selv. Hvis vi vil lagre noder med like verdier forandrer vi en (men bare en!) av tilstandspåstandene, f.eks. Invariant tilstandspåstand 3 Alle verdier i nodene i det venstre subtreet er mindre enn eller lik verdien i noden selv. Når vi skal sette en ny node inn i et søketre, må vi sørge for at hvis tilstandspåstand 3 og 4 holder før innsetting, bevares de etter at noden er satt inn: 3 4
Oppgave. Hvor mange pekere har treet over? La én av dem peke på en ny node med verdien 8. Finn alle steder den nye noden kan settes inn slik at påstand 3 og 4 over fortsatt holder. Vi får også et søketre om vi snur ulikhetene i tilstandspåstandene (men speilvendt): 3 Nivåer (treets dybde) På nivå har vi plass til = node på nivå har vi plass til = noder på nivå har vi plass til = 4noder på nivå 3 har vi plass til 3 = 8 noder på nivå 4 har vi plass til 4 = 6 noder på nivå har vi plass til = 3 noder... på nivå har vi plass til = 4 noder på nivå 4 har vi plass til 4 = 6384 noder på nivå har vi plass til = 3 noder på nivå har vi plass til = 486 noder på nivå 4 har vi plass til 4 = 66 noder på nivå har vi plass til = 348 noder... på nivå 6 har vi plass til 6 = 86346433343336 noder på nivå n har vi plass til n noder For hvert nytt nivå får vi altså plass til dobbelt så mange noder som på forrige nivå. Det et plass til like mange noder på et nytt nivå som summen av alle noder på alle nivåene over pluss en. Hvis treet har 6 nivåer ( - ) har treet plass til 63 noder. 63 = 6. Med et nivå til () får vi plass til 64 til. Generelt har et tre med n nivåer plass til maks n noder. Oppgave 3. Hvor mange nivåer må et binært søketre med noder som peker på en person ha, dersom vi skal lagre Norges befolkning på.. objekter? I figuren nedenfor er denne doblingseffekten illustrert ved hjelp av kuber
der neste kube får dobbelt så stort volum som forrige kube: Sammenligning med lenkelister/arrayer Hvis man setter inn objekter i et søketre i sortert orden, blir resultatet et tre som ser ut som og som er en lenkeliste (bare en av pekerne brukes, sammenlign med nestepeker; alle venstre- eller alle høyrepekere peker til ). Tegning av et binærtre som også er en lenkeliste: Fra en array med objekter, kan vi verste fall få nivåer i treet. På den annen side vil vi kunne få plass til alle i et binærtre med nivåer ( = ). Dette treet ville vært perfekt balansert. For å bygge opp et balansert tre med vanlig innsettingsmetode, kan ikke nodene komme i en vilkårlig orden. Som rotnode må vi velge det midterste objektet (medianen) fra den sorterte arrayen. (Dette er ikke helt enkelt hvis vi har en lenkeliste istedet for array). Hvis ikke vil en av subtrærne få mer enn 63 noder, og dermed en dybde på minst. (Hele treet får dermed minst 8 nivåer!) Når vi velger medianen som rot, blir de to delarrayene som blir igjen akkurat like lange, hver med 6/ = 3 noder. Her igjen må vi for hvert av 6
subtrærne velge det midterste objektet som rot. Slik fortsetter vi nedover til vi tilslutt får delarrayer som har akkurat 3 noder. Også her velger vi det midterste av de tre som rot, elementet først som venstre node (subtre) og siste element som høyre. Dette er en framgansmåte som er nokså lett å programmere rekursivt. Oppgave 4. Skriv en slik metode. (Dette er nok den vanskeligste oppgaven i dette notatet). Når elementene kommer i vilkårlig orden, blir treet vanligvis passe skjeivt. Legg merke til at det er bare to rekkefølger som gir maksimal skjeivhet, mens det er flere rekkefølger for å lage et perfekt balansert tre. I et tre med objekter, vil rekkefølgen for de 64 siste være helt vilkårlig hvis de 63 første danner et perfekt balansert tre! Siden 33 = 8834 kan vi lagre objekter som representerer alle menneskene på jorda i et (velbalansert) tre med 33 nivåer. Lagret vi de omtrent,8 milliarder (jf. http://www.census.gov/popclock/) personobjektene i en sortert lenkeliste, måtte vi i gjennomsnitt lette oss gjennom halve lista, eller ca. 3, milliarder, før vi fant et objekt som representerer ett bestemt menneske. Når vi sammnligner 33 med 3, forstår vi hvor mye mer effektivt søking i et tre er i forhold til lineæar søking. Ikke rart et binærtre hvor objektene er ordnet kalles et søketre! Binære søketrær brukes som datastruktur i beholdere for å lagre objekter og til å sortere objekter. To hovedalgoritmer for trær Operasjoner på binære søketrær faller naturlig i to hovedtyper: søking eller innsetting og traversering (gjøre noe med alle nodene). Søking og innsetting Søking/innsetting starter i rota og man leter seg nedover treet (i retning fra rota) til man har funnet objektet det letes etter, eller plassen der et nytt objekt skal inn. Algoritmen søker seg nedover i treet kan programmeres som en while-løkke som maksimalt får så mange runder som antall nivåer i treet. I INF lager vi disse algoritmene som rekursive metoder inne i nodeobjektene. Her er klassen med en metode for å sette inn et nodeobjekt: class { venstre, høyre ; int v ; // verdien som nodene i treet sammenlignes på public void settinn ( ny ) { i f ( ny. v > v ) i f ( høyre == ) høyre = ny ; else høyre. settinn ( ny ) ; else i f ( v e n s t r e == ) v e n s t r e = ny ; else v e n s t r e. settinn ( ny ) ;
Før vi programmerer settinn, må vi bestemme to ting, to tilstandspåstander, nærmere bestemt nr. 3 og 4 ovenfor: Skal små verdier mot venstre eller mot høyre? Hva skal vi gjøre med noder med like verdier? I metoden over, har vi svart på det første spørsmålet ved å la små verdier (verdier mindre enn eller lik nodens verdi) legges til mot venstre. (Dette er det vanlige). Dette bestemmes av testen if (ny.v > v). Ved å snu ulikhetstegnet blir det omvendt. På det andre spørsmålet gir metoden ovenfor svaret at like verdier settes inn mot venstre, de havner med andre ord i samme subtre som mindre verdier. Hvis vi ønsker at de skal inn mot høyre, erstatter vi > med >= i den første if-testen. Hvis vi ikke vil ha noder med like verdier i treet vil følgende endring sørge for det ved at ingenting blir satt inn hvis ny.v == v: public void settinn ( ny ) { i f ( ny. v > v ) i f ( høyre == ) høyre = ny ; else høyre. settinn ( ny ) ; else i f ( ny. v < v ) i f ( v e n s t r e == ) v e n s t r e = ny ; else v e n s t r e. settinn ( ny ) ; Vil vi gjøre noe i noden som har lik verdi legger vi til en egen else-grein for det: public void settinn ( ny ) { i f ( ny. v > v ) i f ( høyre == ) høyre = ny ; else høyre. settinn ( ny ) ; else i f ( ny. v < v ) i f ( v e n s t r e == ) v e n s t r e = ny ; else v e n s t r e. settinn ( ny ) ; else {... Gjøre noe, kan f.eks. være å oppdatere en teller som holder rede på hvor mange like objekter som er satt inn, eller kaste et unntak hvis dette ikke skulle kunne skje. Når vi skal sette inn første node, må vi særbehandle den, siden vi ikke har noen node som vi kan kalle metoden i. Dette gjør vi gjerne i en egen metode i objektet som holder hele treet: class BStre { private r o t ; public s e t t I n n i T r e ( ny ) { i f ( r o t == ) r o t = ny ; else r o t. settinn ( ny ) ; 8
Algoritmen for å finne en node med en gitt verdi v, blir tilsvarende, bortsett fra at letingen (rekursjonen) kan stoppe når objektet er funnet. Denne metoden må derfor ha en else-gren for tilfellet der verdien vi leter etter er lik nodens verdi. Oppgave. Skriv en metode i nodeklassen som tar et heltall som parameter og som returnerer (en peker til) en node i treet med samme verdi som parameteren. Metoden returnerer hvis verdien ikke finnes i treet. Oppgave 6. Utvid nodeklassen med en teller frekvens. Skriv settinn slik at noder med like verdier settes inn møt høyre og slik at hvis det i et subtre finnes k noder med samme verdi som subtreets rot, skal rotas frekvens være k +. r med verdier som det bare finnes en av har frekvens lik. Traversering «Å besøke» alle nodene i et tre, løpe over treet, kalles å traversere treet. Det må vi gjøre hvis det søkes etter noe annet enn verdien treet er ordnet etter. F.eks. hvis vi har et søketre med personer sortert etter navn og ønsker å finne ut om det finnes personer i treet med en bestemt fødselsdato, må vi sørge for å gå systematisk gjennom hele treet. Eller hvis vi ønsker å gjøre noe med alle nodene, er også algoritmer som traverserer treet det vi må ty til. Utskrift av alle nodene er et enkelt eksempel på traversering. Å skrive ut et helt binærtre, kan enkelt deles opp i tre operasjoner: skrive ut info om rotnoden (s;) skrive ut hele venstre subtre (v;) skrive ut hele høyre subtre (h;) La oss først utvide nodeklassen med to metoder: class { venstre, høyre ; int v ; // verdien som nodene i treet sammenlignes på public void s k r i v ( ) { // skriver ut info om denne noden public skrivutogsubtrær ( ) { s k r i v ( ) ; // dene linja forkorter vi til: s; // hvis subtre til venstre skriv ut dette v; // hvis subtre til høyre skriv ut dette h; Siden nodene som er rotnoder i subtrærne også har denne metoden, kan subtrærne skrives ut ved å kalle på samme metode i nodene som venstre og høyre peker på: v e n s t r e. skrivutogsubtrær ( ) ; høyre. skrivutogsubtrær ( ) ;
Vi må bare sørge for at vi kun gjør dette for ikke-tomme subtrær: public skrivutogsubtrær ( ) { s k r i v ( ) ; // s; i f ( v e n s t r e!= ) v e n s t r e. skrivutogsubtrær ( ) ; // v; i f ( høyre!= ) høyre. skrivutogsubtrær ( ) ; // h; Metoden har tre setninger. For å få skrevet ut alle nodene spiller rekkefølgen av de tre setningene ingen rolle. De kan ordnes på 6 måter: s; v; h; s; h; v; v; s; h; v; h; s; h; s; v; h; v; s; Bare to a disse vil skrive ut verdiene i nodene sortert, dvs. med stigende eller synkende med hensyn til ordningskriteriet (verdien). public skrivutogsubtrær ( ) { i f ( v e n s t r e!= ) v e n s t r e. skrivutogsubtrær ( ) ; s k r i v ( ) ; i f ( høyre!= ) høyre. skrivutogsubtrær ( ) ; public skrivutogsubtrær ( ) { i f ( høyre!= ) høyre. skrivutogsubtrær ( ) ; s k r i v ( ) ; i f ( v e n s t r e!= ) v e n s t r e. skrivutogsubtrær ( ) ; Dette kan vi bruke for å sortere en mengde objekter: Sett et og et objekt inn i et binært søketre og ta/skriv dem ut med en av de to metodene ovenfor. For å legge objekter fra en itererbar datastruktur (lenkeliste, array, Hash- Map) inn i et binært søketre kan vi bruke innsettingsmetoden ovenfor. For å legge objekter fra et tre til en listestruktur bruker vi en traverseringsalgoritme. Ønsker vi at objektene skal ligge i sortert orden i lista/arrayen, lager vi en metode som følger mønsteret til en av de to metodene ovenfor, f.eks. den første: public leggogsubtrærtilliste ( ) { i f ( v e n s t r e!= ) v e n s t r e. leggogsubtrærtilliste ( ) ; l i s t e. l e g g I n n ( this ) ; i f ( høyre!= ) høyre. leggogsubtrærtilliste ( ) ; Her forutsetter vi at vi har et listeobjekt pekt på av liste. Listeobjektet har en metode legginn( n) som legger noden parmeteren n peker på inn i en lenkeliste. Oppgave. Hvis vi har et binært søketre med små verdier mot venstre og lista adminisreres LIFO (en stabel, stack), hvilken rekkefølge på setningene (s;, v;, h;) må vi da bruke under traverseringen (der nodene legges i lista) for at elementene skal komme i stigende rekkefølge når vi tar dem ut av lista?