Datastrukturer med JavaScript Tree

Hva du skal skape

Trær er en av de mest brukte datastrukturene i webutvikling. Denne utsagnet gjelder både for utviklere og brukere. Hver webutvikler som har skrevet HTML og lastet den inn i en nettleser, har opprettet et tre, som refereres til som dokumentobjektmodellen (DOM). Hver bruker av Internett som i sin tur har konsumert informasjon på Internett, har mottatt den i form av et tre - DOM. 

Nå er det klimaks: Artikkelen du leser for øyeblikket, blir gjengitt i nettleseren din som et tre! Avsnittet du leser er representert som tekst i a

element; de

elementet er nestet inne i a element; og elementet er nestet inne i en element. 

Nestingen av data ligner et slektstre. De element er en forelder, den element er et barn, og

element er et barn av element. Hvis denne analogien til et tre virker nyttig for deg, vil du finne trøst i å vite at flere analogier vil bli brukt under implementeringen av et tre. 

I denne artikkelen vil vi opprette et tre med to forskjellige metoder for tre-traversal: Dybde-første søk (DFS) og bredde-første søk (BFS). (Hvis ordet traversal er ukjent for deg, anser det å bety å besøke hver knutepunkt.) Begge disse typer traverser markerer forskjellige måter å interagere med et tre på; begge reiser innbefatter også bruken av datastrukturer som vi har dekket i denne serien. DFS bruker en stabel og BFS bruker en kø for å besøke noder. Det er kult!

Tre (dybde-første søk og bredde-første søk)

I datavitenskap er et tre en datastruktur som simulerer hierarkiske data med noder. Hver node av et tre har sine egne data og pekere til andre noder.

Terminene til noder og pekere kan være nye for noen lesere, så la oss beskrive dem videre med en analogi. La oss sammenligne et tre til et organisasjonsskjema. Diagrammet har en toppnivåposisjon (root node), for eksempel konsernsjef. Rett under denne stillingen er andre stillinger, som visepresident (VP). 

For å representere dette forholdet bruker vi piler som peker fra konsernsjefen til en VP. En stilling, som administrerende direktør, er en knutepunkt; forholdet vi lager fra en administrerende direktør til en VP er en peker. For å skape flere relasjoner i vårt organisasjonsdiagram, gjentar vi bare denne prosessen - vi har et knutepunkt til en annen knutepunkt. 

På et konseptnivå, håper jeg at noder og pekere gir mening. På praktisk nivå kan vi dra nytte av å bruke et mer teknisk eksempel. La oss vurdere DOM. En DOM har en element som sin toppnivåposisjon (root node). Denne noden peker på a element og a element. Denne prosessen gjentas for alle noder i DOM. 

En av skjønnhetene i dette designet er evnen til å neste noder: a

    element, for eksempel, kan ha mange 
  • elementer nestet inne i det; dessuten hver
  • elementet kan ha søsken
  • noder. Det er rart, men likevel morsomt og sant!

    Operasjoner av et tre

    Siden hvert tre inneholder noder, som kan være en separat konstruktør fra et tre, vil vi skissere operasjonene til begge konstruktørene: node og Tre.

    node

    • data lagrer en verdi.
    • forelder peker til en nodes foreldre.
    • barn peker til neste knutepunkt i listen.

    Tre

    • _rot peker på rotknuten til et tre.
    • traverseDF (tilbakeringing) traverser noder av et tre med DFS.
    • traverseBF (tilbakeringing) traverser noder av et tre med BFS.
    • inneholder (data, traversal) søker etter en node i et tre.
    • legg til (data, toData, krysse) legger til en node til et tre.
    • fjern (barn, foreldre) fjerner en knutepunkt i et tre. 

    Implementering av et tre

    La oss nå skrive koden for et tre!

    Egenskaper av en knutepunkt 

    For implementeringen skal vi først definere en funksjon som heter node og deretter en konstruktør oppkalt Tre.

    funksjonsnode (data) this.data = data; this.parent = null; this.children = []; 

    Hver forekomst av Node inneholder tre egenskaper: data, forelder, og barn. Den første egenskapen inneholder data knyttet til en node. Den andre egenskapen peker på en knute. Den tredje eiendommen peker på mange barn noder.

    Egenskaper av et tre

    La oss nå definere vår konstruktør for Tre, som inkluderer node konstruktør i sin definisjon: 

    funksjon Tree (data) var node = ny Node (data); this._root = node; 

    Tre inneholder to linjer med kode. Den første linjen oppretter en ny forekomst av node; den andre linjen tildeler node som roten til et tre. 

    Definisjonene av Tre og node krever bare noen få linjer med kode. Disse linjene er imidlertid nok til å hjelpe oss med å simulere hierarkiske data. For å bevise dette punktet, la oss bruke noen eksempler på data for å lage en forekomst av Tre (og indirekte, node).

    var tree = nytt tre ('CEO'); // data: 'CEO', foreldre: null, barn: [] tree._root;

    Takket være eksistensen av forelder og barn, Vi kan legge til noder som barn av _rot og tilordne også _rot som foreldrene til disse noderne. Med andre ord kan vi simulere opprettelsen av hierarkiske data. 

    Metoder for et tre

    Deretter skal vi opprette følgende fem metoder: 

    Tre

    1. traverseDF (tilbakeringing)
    2. traverseBF (tilbakeringing)
    3. inneholder (data, traversal)
    4. legg til (barn, foreldre)
    5. fjern (node, foreldre)

    Siden hver metode av et tre krever at vi krysser et tre, vil vi først implementere metoder som definerer ulike typer treetransversal. (Å krysse et tre er en formell måte å si på å besøke hver knutepunkt.) 

    1 av 5: traverseDF (tilbakeringing)

    Denne metoden går gjennom et tre med dybde-første søk.  

    Tree.prototype.traverseDF = funksjon (tilbakeringing) // dette er en tilbakekallingsfunksjon og umiddelbart innkallingsfunksjon (funksjonsfornyer (currentNode) // trinn 2 for (var i = 0, lengde = nåværende Node.children.length; i < length; i++)  // step 3 recurse(currentNode.children[i]);  // step 4 callback(currentNode); // step 1 )(this._root); ;

    traverseDF (tilbakeringing) har en parameter som heter Ring tilbake. Hvis det er uklart fra navnet, Ring tilbake antas å være en funksjon, som vil bli kalt senere inn traverseDF (tilbakeringing)

    Kroppen av traverseDF (tilbakeringing) inkluderer en annen funksjon som heter recurse. Denne funksjonen er en rekursiv funksjon! Med andre ord er det selvoppkalt og selvoppsigende. Bruk trinnene nevnt i kommentarene til recurse, Jeg skal beskrive den generelle prosessen som recurse bruker til å krysse hele treet. 

    Her er trinnene: 

    1. Påkall straks recurse med rotenoden til et tre som argument. I dette øyeblikk, currentNode peker til gjeldende knutepunkt. 
    2. Skriv inn en til loop og iterate en gang for hvert barn av currentNode, begynner med det første barnet. 
    3. Inne i kroppen av til loop, påkalle rekryter med et barn av currentNode. Det eksakte barnet avhenger av gjeldende iterasjon av til sløyfe. 
    4. Når currentNode Vi har ikke lenger barn, vi forlater til loop og påkalle Ring tilbake vi passerte under påkallingen av traverseDF (tilbakeringing)

    Trinn 2 (selvoppsigende), 3 (selvoppringing) og 4 (tilbakeringing) gjenta til vi krysser hvert knutepunkt. 

    Rekursjon er et veldig vanskelig tema å undervise og krever en hel artikkel for å tilstrekkelig forklare det. Siden forklaringen på rekursjon ikke er fokus i denne artikkelen, er fokuset på å implementere et tre. Jeg foreslår at noen lesere mangler en god forståelse av rekursjon gjør følgende to ting.

    Forsøk først med vår nåværende implementering av traverseDF (tilbakeringing) og prøv å forstå i en grad hvordan det fungerer. For det andre, hvis du vil at jeg skal skrive en artikkel om rekursjon, vennligst be om det i kommentarene i denne artikkelen.

    Følgende eksempel viser hvordan et tre ville bli krysset med traverseDF (tilbakeringing). Å krysse et tre, jeg lager en i eksempelet nedenfor. Tilnærmingen som jeg vil bruke i øyeblikket er ikke ideell, men det fungerer. En bedre tilnærming ville være å bruke Legg til verdi), som vi vil implementere i trinn 4 av 5. 

    var tree = nytt tre ('en'); tree._root.children.push (ny node ('two')); tree._root.children [0] .parent = tree; tree._root.children.push (ny knutepunkt ('tre')); tree._root.children [1] .parent = tree; tree._root.children.push (ny knutepunkt ('fire')); tree._root.children [2] .parent = tree; tree._root.children [0] .children.push (ny knutepunkt ('fem')); tree._root.children [0] .children [0] .parent = tree._root.children [0]; tree._root.children [0] .children.push (ny node ('six')); tree._root.children [0] .children [1] .parent = tree._root.children [0]; tree._root.children [2] .children.push (ny node ('seven')); tree._root.children [2] .children [0] .parent = tree._root.children [2]; / * lager dette treet en ├── two │ ├── five │ └─ - six ├── three └── four └── seven * /

    Nå, la oss påkalle traverseDF (tilbakeringing).

    tree.traverseDF (funksjon (node) console.log (node.data)); / * logger følgende strenger til konsollen "fem" seks "to" tre "sju" fire "en" * /

    2 av 5: traverseBF (tilbakeringing)

    Denne metoden bruker bredde-første søk for å krysse et tre. 

    Forskjellen mellom dybde-første søk og bredde-første søk innebærer sekvensen som nodene til et trebesøk. For å illustrere dette punktet, la oss bruke treet vi opprettet fra traverseDF (tilbakeringing).

    / * tre en (dybde: 0) ├── to (dybde: 1) │ ├── fem (dybde: 2) │ └─ - seks (dybde: 2) ├── tre (dybde: 1) └── fire (dybde: 1) └─ - syv (dybde: 2) * /

    La oss nå passere traverseBF (tilbakeringing) samme tilbakeringingen vi brukte til traverseDF (tilbakeringing)

    tree.traverseBF (funksjon (node) console.log (node.data)); / * logger følgende strenger til konsollen "en" to "tre" fire "fem" seks "sju" * /

    Loggene fra konsollen og diagrammet til vårt tre viser et mønster om bredde-første søk. Start med rotnoden; Reis deretter en dybde og besøk hver knutepunkt i det dybde fra venstre til høyre. Gjenta denne prosessen til det ikke er flere dybder å reise. 

    Siden vi har en konseptuell modell for bredde-første søk, la oss nå implementere koden som ville gjøre vårt eksempel arbeid. 

    Tree.prototype.traverseBF = funksjon (tilbakeringing) var kø = ny kø (); queue.enqueue (this._root); currentTree = queue.dequeue (); mens (currentTree) for (var i = 0, lengde = currentTree.children.length; i < length; i++)  queue.enqueue(currentTree.children[i]);  callback(currentTree); currentTree = queue.dequeue();  ;

    Vår definisjon av traverseBF (tilbakeringing) inneholder mye logikk. Av denne grunn vil jeg forklare logikken i håndterbare trinn: 

    1. Opprett en forekomst av .
    2. Legg til noden som påkalte traverseBF (tilbakeringing) til forekomsten av
    3. Erklære en variabel som heter currentNode og initialiser den til node vi har nettopp lagt til i køen vår. 
    4. Samtidig som currentNode peker på en knutepunkt, kjør koden inni samtidig som sløyfe. 
    5. Bruk en til loop for å iterere på barna av currentNode.
    6. Inne i kroppen av til sløyfe, legg til hvert barn i køen. 
    7. Ta currentNode og send det som et argument av Ring tilbake
    8. tilordne currentNode til noden blir fjernet fra køen. 
    9. Før currentNode peker ikke på en knute - hver knutepunkt i treet har blitt besøkt - gjenta trinn 4 til 8.

    3 av 5: inneholder (tilbakekalling, traversal)

    La oss definere en metode som lar oss søke etter en bestemt verdi i treet vårt. For å bruke noen av våre metoder for trekryp, har jeg definert inneholder (tilbakekalling, traversal) å akseptere to argumenter: dataene som skal søkes og typen av traversal. 

    Tree.prototype.contains = funksjon (tilbakering, traversal) traversal.call (dette, tilbakeringing); ;

    I kroppen av inneholder (tilbakekalling, traversal), Vi bruker en metode som heter anrop å passere dette og Ring tilbake. Det første argumentet binder traversering til treet som påkalte inneholder (tilbakekalling, traversal); Det andre argumentet er en funksjon som påberopes på hver knutepunkt i vårt tre. 

    Tenk deg at vi ønsker å logge inn på konsollen noen noder som inneholder data med et oddetall og krysse hver knutepunkt i vårt tre med BFS. Dette er koden vi vil skrive:

    // tree er et eksempel på en root node tree.contains (funksjon (node) if (node.data === 'two') console.log (node);, tree.traverseBF);

    4 av 5: legg til (data, toData, traversal)

    Vi har nå en metode for å søke etter en bestemt node i vårt tre. La oss nå definere en metode som gjør det mulig for oss å legge til en node til en bestemt node.  

    Tree.prototype.add = funksjon (data, toData, traversal) var barn = ny Node (data), foreldre = null, tilbakeringing = funksjon (node) if (node.data === toData) parent = node; ; this.contains (tilbakekalling, traversal); hvis (foreldre) parent.children.push (barn); child.parent = foreldre;  ellers Kast ny feil ('Kan ikke legge til nod til en ikke-eksisterende foreldre.'); ;

    legg til (data, toData, traversal) definerer tre parametere. Den første parameteren, data, brukes til å opprette en ny forekomst av node. Den andre parameteren, toData, brukes til å sammenligne mot hver knutepunkt i et tre. Den tredje parameteren, traversering, er typen av tre-traversal brukt i denne metoden. 

    I kroppen av legg til (data, toData, traversal), Vi erklærer tre variabler. Den første variabelen, barn, er initialisert som en ny forekomst av node. Den andre variabelen, forelder, er initialisert som null; men det vil senere peke til hvilken som helst node i et tre som samsvarer med verdien av toData. Omplasseringen av forelder skjer i den tredje variabelen vi erklærer, som er Ring tilbake

    Ring tilbake er en funksjon som sammenligner toData til hver knute data eiendom. Hvis hvis setningen evaluerer til ekte, deretter forelder er tilordnet noden som matchet sammenligningen i hvis uttalelse. 

    Den faktiske sammenligningen av hver knute til toData forekommer i inneholder (tilbakekalling, traversal). Typen av traversal og Ring tilbake må bestås som argumenter for inneholder (tilbakekalling, traversal)

    Til slutt, hvis forelder eksisterer i treet, vi presser barn inn i parent.children; Vi tildeler også forelder til foreldrene til barn. Ellers kaster vi en feil. 

    La oss bruke legg til (data, toData, traversal) i et eksempel: 

    var tree = nytt tre ('CEO'); tree.add ('VP of Happiness', 'CEO', tree.traverseBF); / * Vårt tre 'CEO' └── 'VP of Happiness' * /

    Her er et mer komplekst eksempel på legg til (addData, toData, traversal)

    var tree = nytt tre ('CEO'); tree.add ('VP of Happiness', 'CEO', tree.traverseBF); tree.add ('VP of Finance', 'CEO', tree.traverseBF); tree.add ('Varsel av sorg', 'CEO', tree.traverseBF); tree.add ('Valpedirektør', 'VP of Finance', tree.traverseBF); tree.add ('Puppy Manager', 'Director of Puuppies', tree.traverseBF); / * Tree 'CEO' ─ - 'VP of Happiness' ├── 'VP of Finance' │ ├── 'Direktør for valper' │ └── 'Manager of Puppy's' └── 'VP of Sadness' * /

    5 av 5: fjern (data, fromData, traversal)

    For å fullføre vår implementering av Tre, Vi vil legge til en metode som heter fjern (data, fromData, traversal). På samme måte som å fjerne en node fra DOM, fjerner denne metoden en knute og alle sine barn.

    Tree.prototype.remove = funksjon (data, fraData, traversal) var tree = dette, foreldre = null, childToRemove = null, index; var tilbakering = funksjon (node) if (node.data === fraData) parent = node; ; this.contains (tilbakekalling, traversal); hvis (foreldre) index = findIndex (foreldre.barn, data); hvis (indeks === undefined) kaste ny feil ('Node å fjerne eksisterer ikke.');  ellers childToRemove = parent.children.splice (indeks, 1);  ellers kaste ny feil ('Foreldre eksisterer ikke.');  returner childToRemove; ;

    Lik legg til (data, toData, traversal), fjern traverses et tre for å finne en node som inneholder det andre argumentet, som er nå fromData. Hvis denne noden er funnet, så forelder peker på det. 

    For øyeblikket når vi vår første hvis uttalelse. Hvis forelder eksisterer ikke, vi kaster en feil. Hvis forelder eksisterer, påberoper vi oss findIndex () med parent.children og dataene vi ønsker å fjerne fra barnens noder av forelder. (findIndex () er en hjelpemetode som jeg definerte nedenfor.)

    funksjon findIndex (arr, data) var index; for (var i = 0; i < arr.length; i++)  if (arr[i].data === data)  index = i;   return index; 

    Innsiden findIndex (), Følgende logikk oppstår. Hvis noen av noder i parent.children inneholder data som samsvarer data, variabelen index er tilordnet et heltall. Hvis ingen av barnas dataegenskaper samsvarer data, deretter index beholder standardverdien av udefinert. På siste linje av findIndex (), vi returnerer index

    Vi kommer nå tilbake til fjern (data, fromData, traversal). Hvis index er udefinert, en feil blir kastet. Hvis index er definert, bruker vi den til å splitte noden vi vil fjerne fra barna av forelder; Vi tildeler også det fjernede barnet til childToRemove

    Til slutt kommer vi tilbake childToRemove

    Fullstendig implementering av et tre

    Vår implementering av Tre er ferdig. Ta en titt - vi har oppnådd mye: 

    funksjonsnode (data) this.data = data; this.parent = null; this.children = [];  funksjon Tree (data) var node = ny Node (data); this._root = node;  Tree.prototype.traverseDF = funksjon (tilbakeringing) // dette er en resurse og umiddelbart innkallingsfunksjon (funksjonsfornyer (currentNode) // trinn 2 for (var i = 0, lengde = currentNode.children.length; i < length; i++)  // step 3 recurse(currentNode.children[i]);  // step 4 callback(currentNode); // step 1 )(this._root); ; Tree.prototype.traverseBF = function(callback)  var queue = new Queue(); queue.enqueue(this._root); currentTree = queue.dequeue(); while(currentTree) for (var i = 0, length = currentTree.children.length; i < length; i++)  queue.enqueue(currentTree.children[i]);  callback(currentTree); currentTree = queue.dequeue();  ; Tree.prototype.contains = function(callback, traversal)  traversal.call(this, callback); ; Tree.prototype.add = function(data, toData, traversal)  var child = new Node(data), parent = null, callback = function(node)  if (node.data === toData)  parent = node;  ; this.contains(callback, traversal); if (parent)  parent.children.push(child); child.parent = parent;  else  throw new Error('Cannot add node to a non-existent parent.');  ; Tree.prototype.remove = function(data, fromData, traversal)  var tree = this, parent = null, childToRemove = null, index; var callback = function(node)  if (node.data === fromData)  parent = node;  ; this.contains(callback, traversal); if (parent)  index = findIndex(parent.children, data); if (index === undefined)  throw new Error('Node to remove does not exist.');  else  childToRemove = parent.children.splice(index, 1);   else  throw new Error('Parent does not exist.');  return childToRemove; ; function findIndex(arr, data)  var index; for (var i = 0; i < arr.length; i++)  if (arr[i].data === data)  index = i;   return index; 

    Konklusjon 

    Trær simulerer hierarkiske data. Mye av verden rundt oss ligner denne typen hierarki, som nettsider og våre familier. Hver gang du befinner deg i behov av å strukturere data med hierarki, bør du vurdere å bruke et tre.