Quando si impara a codificare per la prima volta, è comune imparare gli array come “struttura dati principale”
Eventualmente, si impara anche hash tables
. Se state perseguendo una laurea in informatica, dovete seguire un corso sulla struttura dei dati. Imparerete anche linked lists
queues
, e stacks
. Queste strutture di dati sono chiamate strutture di dati “lineari” perché hanno tutte un inizio logico e una fine logica.
Quando iniziamo ad imparare su trees
e graphs
, può diventare molto confuso. Non memorizziamo i dati in modo lineare. Entrambe le strutture dati memorizzano i dati in un modo specifico.
Questo post è per aiutarvi a capire meglio la struttura di dati ad albero e per chiarire qualsiasi confusione possiate avere su di essa.
In questo articolo, impareremo:
- Cos’è un albero
- Esempi di alberi
- La sua terminologia e come funziona
- Come implementare strutture ad albero nel codice.
Iniziamo questo viaggio di apprendimento. 🙂
Definizione
Quando si inizia a programmare, è comune capire meglio le strutture dati lineari rispetto a strutture dati come alberi e grafi.
Gli alberi sono ben noti come una struttura dati non lineare. Non memorizzano i dati in modo lineare. Organizzano i dati in modo gerarchico.
Tuffiamoci in esempi di vita reale!
Cosa intendo quando dico in modo gerarchico?
Immaginate un albero genealogico con relazioni di tutte le generazioni: nonni, genitori, figli, fratelli, ecc. Organizziamo comunemente gli alberi genealogici in modo gerarchico.
Il disegno sopra è il mio albero genealogico. Tossico, Akikazu, Hitomi,
e Takemi
sono i miei nonni.
Toshiaki
e Juliana
sono i miei genitori.
TK, Yuji, Bruno
, e Kaio
sono i figli dei miei genitori (io e i miei fratelli).
In HTML, il Document Object Model (DOM) funziona come un albero.
Il tag HTML
contiene altri tag. Abbiamo un tag head
e un tag body
. Questi tag contengono elementi specifici. Il tag head
ha i tag meta
e title
. Il tag body
ha elementi che si mostrano nell’interfaccia utente, per esempio, h1
a
li
, ecc.
Una definizione tecnica
Un tree
è un insieme di entità chiamate nodes
. I nodi sono collegati da edges
. Ogni node
contiene un value
o data
, e può avere o meno un child node
.
Il first node
del tree
è chiamato root
. Se questo root node
è collegato da un altro node
, il root
è allora un parent node
e il node
collegato è un child
.
Tutti i Tree nodes
sono collegati da link chiamati edges
. E’ una parte importante di trees
, perché gestisce la relazione tra nodes
.
Leaves
sono gli ultimi nodes
su un tree.
Sono nodi senza figli. Come i veri alberi, abbiamo il root
branches
, e infine il leaves
.
Altri concetti importanti da capire sono height
e depth
.
Il height
di un tree
è la lunghezza del percorso più lungo di un leaf
.
Il depth
di un node
è la lunghezza del percorso verso il suo root
.
Riassunto terminologico
- Radice è il
node
più alto deltree
- Bordo è il collegamento tra due
nodes
- Bambino è un
node
che ha unparent node
- Genitore è un
node
che ha unedge
a unchild node
- Foglia è un
node
che non ha unchild node
neltree
- Altezza è la lunghezza del percorso più lungo verso un
leaf
- La profondità è la lunghezza del percorso verso il suo
root
Alberi binari
Ora discuteremo un tipo specifico di tree
. Lo chiamiamo ilbinary tree
.
“In informatica, un albero binario è una struttura dati ad albero in cui ogni nodo ha al massimo due figli, che sono indicati come il figlio sinistro e il figlio destro.” – Wikipedia
Guardiamo quindi un esempio di un binary tree
.
Codifichiamo un albero binario
La prima cosa che dobbiamo tenere a mente quando implementiamo un binary tree
è che è un insieme di nodes
. Ogni node
ha tre attributi: value
left_child
, e right_child
.
Come implementiamo un semplice binary tree
che inizializza con queste tre proprietà?
Diamo un’occhiata.
class BinaryTree: def __init__(self, value): self.value = value self.left_child = None self.right_child = None
Ecco qui. Il nostro binary tree
classe.
Quando istanziamo un oggetto, passiamo il value
(i dati del nodo) come parametro. Guardate il left_child
e il right_child
. Entrambi sono impostati su None
.
Perché?
Perché quando creiamo il nostro node
, non ha nessun figlio. Abbiamo solo il node data
.
Testiamolo:
tree = BinaryTree('a')print(tree.value) # aprint(tree.left_child) # Noneprint(tree.right_child) # None
Ecco.
Possiamo passare il string
a
‘ come value
al nostro Binary Tree node
. Se stampiamo il value
left_child
, e right_child
, possiamo vedere i valori. Cosa dobbiamo fare qui?
Implementeremo un metodo per inserire un nuovo node
al right
e al left
.
Ecco le regole:
- Se l’attuale
node
non ha unleft child
, creiamo semplicemente un nuovonode
e lo impostiamo sulleft_child
del nodo attuale. - Se ha il
left child
, creiamo un nuovo nodo e lo mettiamo al posto dell’attualeleft child
. Allocate questoleft child node
alleft child
del nuovo nodo. 🙂
Ecco il codice:
Ancora una volta, se il nodo corrente non ha un
left child
, creiamo semplicemente un nuovo nodo e lo impostiamo sulleft_child
del nodo corrente. Oppure creiamo un nuovo nodo e lo mettiamo al posto dell’attualeleft child
. Assegniamo questoleft child node
alleft child
del nuovo nodo.E facciamo la stessa cosa per inserire un
right child node
.Fatto. 🙂
Ma non del tutto. Dobbiamo ancora testarlo.
Costruiamo il seguente
tree
:Per riassumere l’illustrazione di questo albero:
-
a
node
sarà ilroot
del nostrobinary Tree
-
a
left child
b
node
-
a
right child
c
node
-
b
right child
d
node
b
node
non ha unleft child
) -
c
left child
e
node
-
c
right child
f
node
- sia
e
chef
nodes
non hanno figli
Quindi ecco il codice per il
tree
:L’inserimento è fatto.
Ora dobbiamo pensare al
tree
traversal.Abbiamo due opzioni: Depth-First Search (DFS) e Breadth-First Search (BFS).
- DFS “è un algoritmo per attraversare o cercare una struttura dati ad albero. Si parte dalla radice e si esplora il più possibile lungo ogni ramo prima di fare il backtracking”. – Wikipedia
- BFS “è un algoritmo per attraversare o cercare la struttura ad albero dei dati. Inizia alla radice dell’albero ed esplora prima i nodi vicini, prima di passare ai vicini di livello successivo”. – Wikipedia
Perciò immergiamoci in ogni tipo di attraversamento dell’albero.
Ricerca in profondità (DFS)
DFS esplora un percorso fino ad una foglia prima di tornare indietro ed esplorare un altro percorso. Vediamo un esempio con questo tipo di attraversamento.
Il risultato per questo algoritmo sarà 1-2-3-4-5-6-7.
Perché?
Ripartiamo.
- Partiamo dal
root
(1). Stampalo.
2. Vai al
left child
(2). Stampalo.3. Poi vai al
left child
(3). Stampatelo. (Questonode
non ha figli)4. Tornate indietro e andate al
right child
(4). Stampatelo. (Questonode
non ha figli)5. Ritornate al
root
node
e andate alright child
(5). Stampalo.6. Vai al
left child
(6). Stampatelo. (Questonode
non ha figli)7. Tornate indietro e andate al
right child
(7). Stampatelo. (Questonode
non ha figli)8. Fatto.
Quando andiamo in profondità alla foglia e facciamo backtrack, questo si chiama algoritmo DFS.
Ora che abbiamo familiarità con questo algoritmo di attraversamento, discuteremo i tipi di DFS:
pre-order
in-order
, epost-order
.Pre-ordine
Questo è esattamente quello che abbiamo fatto nell’esempio precedente.
- Stampa il valore del
node
. - Vai al
left child
e stampalo. Questo se, e solo se, ha unleft child
. - Vai al
right child
e stampalo. Questo se, e solo se, ha unright child
.
In-order
Il risultato dell’algoritmo in-order per questo
tree
esempio è 3-2-4-1-6-5-7.La sinistra per prima, la metà per seconda e la destra per ultima.
Ora codifichiamo.
def in_order(self): if self.left_child: self.left_child.in_order() print(self.value) if self.right_child: self.right_child.in_order()
- Vai al
left child
e stampalo. Questo se, e solo se, ha unleft child
. - Stampa il valore del
node
- Vai al
right child
e stampalo. Questo se, e solo se, ha unright child
.
Post-order
Il risultato dell’
post order
algoritmo per questotree
esempio è 3-4-2-6-7-5-1.La sinistra per prima, la destra per seconda, e la metà per ultima.
Codifichiamo questo.
- Vai al
left child
e stampalo. Questo se, e solo se, ha unleft child
. - Vai al
right child
e stampalo. Questo se, e solo se, ha unright child
. - Stampa il valore del
node
Ricerca Breadth-First (BFS)
L’algoritmo BFS attraversa il
tree
livello per livello e profondità per profondità.Ecco un esempio che aiuta a spiegare meglio questo algoritmo:
Si attraversa livello per livello. In questo esempio, il risultato è 1-2-5-3-4-6-7.
- Livello/Profondità 0: solo
node
con valore 1 - Livello/Profondità 1:
nodes
con valori 2 e 5 - Livello/Profondità 2:
nodes
con valori 3, 4, 6 e 7
Ora codifichiamo.
Per implementare un algoritmo BFS, usiamo la struttura dati
queue
come aiuto.Come funziona?
Ecco la spiegazione.
- Prima aggiungi il
root
node
nelqueue
con il metodoput
. - Iterate mentre il
queue
non è vuoto. - Prendi il primo
node
nelqueue
, e poi stampa il suo valore. - Aggiungere sia
left
cheright
children
in ilqueue
(se ilnode
corrente hachildren
). - Fatto. Stamperemo il valore di ogni
node,
livello per livello, con il nostroqueue
helper.
Albero di ricerca binaria
“Un albero di ricerca binaria è talvolta chiamato albero binario ordinato o ordinato, e mantiene i suoi valori in ordine, in modo che la ricerca e altre operazioni possano utilizzare il principio della ricerca binaria” – Wikipedia
Un’importante proprietà di un
Binary Search Tree
è che il valore di unBinary Search Tree
node
è maggiore del valore della prole del suoleft child
, ma più piccolo del valore della prole del suoright child.
“Ecco una ripartizione dell’illustrazione precedente:
- A è invertito. Il
subtree
7-5-8-6 deve essere a destra, e ilsubtree
2-1-3 deve essere a sinistra. - B è l’unica opzione corretta. Soddisfa la proprietà
Binary Search Tree
. - C ha un problema: il
node
con il valore 4. Deve stare a sinistra delroot
perché è più piccolo di 5.
Codifichiamo un albero di ricerca binario!
Ora è il momento di codificare!
Cosa vedremo qui? Inseriremo nuovi nodi, cercheremo un valore, cancelleremo nodi, e il saldo del
tree
.Iniziamo.
Inserimento: aggiungere nuovi nodi al nostro albero
Immaginiamo di avere un
tree
vuoto e vogliamo aggiungere nuovinodes
con i seguenti valori in questo ordine: 50, 76, 21, 4, 32, 100, 64, 52.La prima cosa che dobbiamo sapere è se 50 è il
root
del nostro albero.Possiamo ora iniziare a inserire
node
danode
.- 76 è maggiore di 50, quindi inserire 76 sul lato destro.
- 21 è minore di 50, quindi inserire 21 sul lato sinistro.
- 4 è minore di 50.
Node
con valore 50 ha unleft child
21. Dato che 4 è più piccolo di 21, inseriscilo a sinistra di questonode
. - 32 è più piccolo di 50.
Node
con valore 50 ha unleft child
21. Poiché 32 è maggiore di 21, inseriamo 32 sul lato destro di questonode
. - 100 è maggiore di 50.
Node
con valore 50 ha unright child
76. Poiché 100 è più grande di 76, inseriamo 100 sul lato destro di questonode
. - 64 è più grande di 50.
Node
con valore 50 ha unright child
76. Poiché 64 è più piccolo di 76, inseriamo 64 sul lato sinistro di questonode
. - 52 è maggiore di 50.
Node
con valore 50 ha unright child
76. Poiché 52 è più piccolo di 76,node
con valore 76 ha unleft child
64. 52 è più piccolo di 64, quindi inserisci 54 sul lato sinistro di questonode
.
Nota uno schema qui?
- Il nuovo
node
valore è maggiore o minore dell’attualenode
? - Se il valore del nuovo
node
è maggiore dell’attualenode,
vai a destrasubtree
. Se l’attualenode
non ha unright child
, inseritelo lì, altrimenti tornate al passo #1. - Se il valore del nuovo
node
è più piccolo dell’attualenode
, andate a sinistrasubtree
. Se l’attualenode
non ha unleft child
, inseriscilo lì, altrimenti torna al passo #1. - Non abbiamo gestito casi speciali qui. Quando il valore di un nuovo
node
è uguale al valore corrente delnode,
usa la regola numero 3. Considera di inserire valori uguali al lato sinistro delsubtree
.
Ora codifichiamo.
Sembra molto semplice.
La parte potente di questo algoritmo è la parte di ricorsione, che è sulla linea 9 e sulla linea 13. Entrambe le linee di codice chiamano il metodo
insert_node
, e lo usano rispettivamente per i suoileft
eright
children
. Le linee11
e15
sono quelle che fanno l’inserimento per ognichild
.Cerchiamo il valore del nodo… Oppure no…
L’algoritmo che costruiremo ora riguarda il fare ricerche. Per un dato valore (numero intero), diremo se il nostro
Binary Search Tree
ha o non ha quel valore.Un elemento importante da notare è come abbiamo definito l’algoritmo di inserimento dell’albero. Prima abbiamo il nostro
root
node
. Tutti isubtree
di sinistranodes
avranno valori più piccoli delroot
node
. E tutti isubtree
nodes
di destra avranno valori maggiori delroot
node
.Guardiamo un esempio.
Immaginiamo di avere questo
tree
.Ora vogliamo sapere se abbiamo un nodo basato sul valore 52.
Facciamo un esempio.
- Iniziamo con il
root
node
come nostronode
attuale. Il valore dato è più piccolo del valore attualenode
? Se sì, allora lo cercheremo sulla sinistrasubtree
. - Il valore dato è maggiore del valore attuale
node
? Se sì, allora lo cercheremo sulla destrasubtree
. - Se le regole #1 e #2 sono entrambe false, possiamo confrontare il valore corrente
node
e il valore dato se sono uguali. Se il confronto restituiscetrue
, allora possiamo dire: “Sì! Il nostrotree
ha il valore dato,” altrimenti, diciamo, “Nooo, non l’ha fatto.”
Ora codifichiamo.
Facciamo il codice:
- Le linee 8 e 9 rientrano nella regola #1.
- Le linee 10 e 11 rientrano nella regola #2.
- La linea 13 rientra nella regola #3.
Come lo testiamo?
Creiamo il nostro
Binary Search Tree
inizializzando ilroot
node
con il valore 15.bst = BinarySearchTree(15)
E ora inseriremo molti nuovi
nodes
.Per ogni
node
inserito, testeremo se il nostro metodofind_node
funziona davvero.Sì, funziona per questi valori dati! Testiamo un valore che non esiste nel nostro
Binary Search Tree
.print(bst.find_node(0)) # False
Oh sì.
La nostra ricerca è fatta.
Cancellazione: rimuovere e organizzare
La cancellazione è un algoritmo più complesso perché dobbiamo gestire diversi casi. Per un dato valore, dobbiamo rimuovere il
node
con questo valore. Immaginate i seguenti scenari per questonode
: non ha nessunchildren
, ha un solochild
, o ha duechildren
.- Scenario #1: Un
node
senzachildren
leaf
node
).
# |50| |50|# / \ / \# |30| |70| (DELETE 20) ---> |30| |70|# / \ \# |20| |40| |40|
Se il
node
che vogliamo cancellare non ha figli, lo cancelliamo semplicemente. L’algoritmo non ha bisogno di riorganizzare iltree
.- Scenario #2: Un
node
con un solo figlio (left
oright
figlio).
# |50| |50|# / \ / \# |30| |70| (DELETE 30) ---> |20| |70|# / # |20|
In questo caso, il nostro algoritmo deve far sì che il genitore del
node
punti al nodochild
. Se ilnode
è illeft child
, facciamo puntare il genitore delleft child
alchild
. Se ilnode
è ilright child
del suo genitore, facciamo puntare il genitore delright child
alchild
.- Scenario #3: Un
node
con due bambini.
# |50| |50|# / \ / \# |30| |70| (DELETE 30) ---> |40| |70|# / \ /# |20| |40| |20|
Quando il
node
ha 2 figli, dobbiamo trovare ilnode
con il valore minimo, partendo dalnode
‘sright child
. Metteremo questonode
con valore minimo al posto delnode
che vogliamo rimuovere.E’ il momento di codificare.
- Prima: Notate i parametri
value
eparent
. Vogliamo trovare ilnode
che ha questovalue
, e il genitore delnode
è importante per la rimozione delnode
. Il nostro algoritmo restituirà un valore booleano. RestituisceTrue
se trova ilnode
e lo rimuove. Altrimenti restituisceFalse
. - Dalla linea 2 alla linea 9: Iniziamo a cercare il
node
che ha ilvalue
che stiamo cercando. Se ilvalue
è più piccolo delcurrent nodevalue
, andiamo alleft subtree
, ricorsivamente (se, e solo se, ilcurrent node
ha unleft child
). Se ilvalue
è maggiore, vai alright subtree
, ricorsivamente. - Linea 10: Cominciamo a pensare all’algoritmo
remove
. - Dalla linea 11 alla linea 13: Copriamo il
node
senzachildren
, ed è illeft child
dal suoparent
. Rimuoviamo ilnode
impostando ilparent
delleft child
aNone
. - Linee 14 e 15: Copriamo il
node
senzachildren
, ed è ilright child
dal suoparent
. Rimuoviamo ilnode
impostando ilparent
delright child
aNone
. - Metodo Clear node: Mostrerò il codice
clear_node
qui sotto. Imposta i nodileft child , right child
, e il suovalue
aNone
. - Dalla linea 16 alla linea 18: Copriamo il
node
con un solochild
left child
), ed è illeft child
dal suoparent
. Impostiamo ilparent
delleft child
alnode
delleft child
(l’unico figlio che ha). - Dalla linea 19 alla linea 21: Copriamo il
node
con un solochild
left child
), ed è ilright child
del suoparent
. Impostiamo ilparent
delright child
alnode
delleft child
(l’unico figlio che ha). - Dalla linea 22 alla linea 24: Copriamo il
node
con un solochild
right child
), ed è illeft child
dal suoparent
. Impostiamo ilparent
delleft child
alnode
delright child
(l’unico figlio che ha). - Dalla linea 25 alla linea 27: Copriamo il
node
con un solochild
right child
) , ed è ilright child
del suoparent
. Impostiamo ilparent
delright child
alnode
delright child
(l’unico figlio che ha). - Dalla linea 28 alla linea 30: Copriamo il
node
con entrambi ileft
eright
figli. Prendiamo ilnode
con il più piccolovalue
(il codice è mostrato sotto) e lo impostiamo sulvalue
delcurrent node
. Terminate rimuovendo il più piccolonode
. - Linea 32: Se troviamo il
node
che stiamo cercando, deve restituireTrue
. Dalla linea 11 alla linea 31, gestiamo questo caso. Quindi basta restituireTrue
e questo è tutto.
- Per usare il metodo
clear_node
: impostare il valoreNone
su tutti e tre gli attributi – (value
left_child
, eright_child
)
def clear_node(self): self.value = None self.left_child = None self.right_child = None
- Per usare il metodo
find_minimum_value
: andare molto in basso a sinistra. Se non troviamo più nodi, abbiamo trovato il più piccolo.
def find_minimum_value(self): if self.left_child: return self.left_child.find_minimum_value() else: return self.value
Ora testiamolo.
Useremo questo
tree
per testare il nostroremove_node
algoritmo.# |15|# / \# |10| |20|# / \ / \# |8| |12| |17| |25|# \# |19|
Togliamo il
node
con ilvalue
8. E’ unnode
senza figli.print(bst.remove_node(8, None)) # Truebst.pre_order_traversal()# |15|# / \# |10| |20|# \ / \# |12| |17| |25|# \# |19|
Ora rimuoviamo il
node
con ilvalue
17. E’ unnode
con un solo figlio.print(bst.remove_node(17, None)) # Truebst.pre_order_traversal()# |15|# / \# |10| |20|# \ / \# |12| |19| |25|
Infine, rimuoveremo un
node
con due figli. Questo è ilroot
del nostrotree
.print(bst.remove_node(15, None)) # Truebst.pre_order_traversal()# |19|# / \# |10| |20|# \ \# |12| |25|
I test sono ora fatti. 🙂
E’ tutto per ora!
Abbiamo imparato molto qui.
Congratulazioni per aver finito questo denso contenuto. È davvero difficile capire un concetto che non conosciamo. Ma tu ce l’hai fatta 🙂
Questo è un ulteriore passo avanti nel mio viaggio verso l’apprendimento e la padronanza di algoritmi e strutture dati. Puoi vedere la documentazione del mio viaggio completo qui sulla mia pubblicazione Renaissance Developer.
Divertiti, continua a imparare e a codificare.
Il mio Twitter & Github. ☺
Risorse aggiuntive
- Introduzione alla struttura di dati ad albero da mycodeschool
- Albero da Wikipedia
- Come non rimanere bloccati dagli alberi dal talentuoso Vaidehi Joshi
- Introduzione agli alberi, lezione del Professor Jonathan Cohen
- Introduzione agli alberi, Lecture by Professor David Schmidt
- Intro to Trees, Lecture by Professor Victor Adamchik
- Trees with Gayle Laakmann McDowell
- Binary Tree Implementation and Tests by TK
- Coursera Course: Data Structures dell’Università della California, San Diego
- Corso Coursera: Data Structures and Performance by University of California, San Diego
- Concetti e implementazione dell’albero di ricerca binaria da Paul Programming
- Implementazione dell’albero di ricerca binaria e test da TK
- Tree Traversal da Wikipedia
- Albero di Ricerca Binario Rimuovi Nodo Algoritmo da GeeksforGeeks
- Albero di Ricerca Binario Rimuovi Nodo Algoritmo da Algolist
- Imparare Python Da Zero a Eroe
-