Strutture dati in C
dispense del corso di
Laboratorio di Algoritmi e Strutture Dati
A.A. 2001/2002
prima parte
(versione molto ma molto draft)
Gianfranco Ciaschetti
1
27 maggio 2002
1Dipartimento
di Matematica Pura e Applicata, Universitµa degli Studi di
L'Aquila, via Vetoio, Coppito, I-67010 L'Aquila; e-mail: [email protected]
Indice
1 Insiemi e oggetti
2
2 Liste
6
2.1 Inserimento . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.2 Ricerca . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.3 Cancellazione . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
3 Code e Pile
3.1 Implementazione con array
3.1.1 Pile . . . . . . . .
3.1.2 Code . . . . . . . .
3.2 Implementazione con liste
3.2.1 Pile . . . . . . . .
3.2.2 Code . . . . . . . .
.
.
.
.
.
.
13
13
15
16
18
18
19
4 Alberi
4.1 Visita di un albero in profonditµa . . . . . . . . . . . . . . . .
4.2 Visita di un albero in ampiezza . . . . . . . . . . . . . . . . .
4.3 Inserimento e cancellazione . . . . . . . . . . . . . . . . . . . .
22
24
27
30
.
.
.
.
.
.
.
.
.
.
.
.
1
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Capitolo 1
Insiemi e oggetti
Nella progettazione di algoritmi spesso si ha bisogno di rappresentare oggetti
e insiemi di oggetti. Ogni oggetto µe descritto all'interno di un calcolatore
da un set di informazioni che ne rappresentano le proprietµa e/o le caratteristiche. Possiamo allora pensare a un oggetto come a una generica porzione
di memoria in cui le sue informazioni sono archiviate. A seconda del numero
e del tipo di informazioni che intendiamo associare a ogni oggetto, esso puµo
occupare piµ
u o meno spazio in memoria. L'oggetto x puµo essere indicato
tramite il contenuto o l'indirizzo delle locazioni di memoria in cui le sue informazioni sono memorizzate. In C, nel primo caso l'oggetto µe rappresentato
da una variabile, nel secondo caso da un puntatore.
Il linguaggio C mette a disposizione un certo numero di tipi prede¯niti
per la rappresentazione di oggetti con una singola informazione (interi, reali,
caratteri, ecc.), oltre a un tipo array per l'aggregazione di oggetti dello stesso
tipo e un tipo struct per l'aggregazione di oggetti di tipo diverso. Sia gli array
che le strutture hanno l'e®etto di de¯nire locazioni di memoria contigue nelle
quali gli oggetti sono memorizzati.
Ad esempio, se vogliamo de¯nire un oggetto di tipo intero basta dichiarare
una variabile di tipo intero o puntatore a intero, come
int o;
int *po;
oppure, se vogliamo de¯nire un insieme omogeneo di 10 interi, dichiariamo
una variabile
int A[10];
2
o ancora, per realizzare una struttura con un intero e un carattere,
struct ascii
{
int code;
char c;
}
I nomi o, po, A e ascii sono nomi di variabili, e come tali possono essere
de¯niti come stringhe arbitrarie.
Array e strutture sono collezioni statiche di oggetti, in quanto non permettono di inserire o cancellare altri oggetti oltre quelli speci¯cati all'atto della
dichiarazione (o dell'allocazione, se un array µe de¯nito come un puntatore).
Tuttavia molto spesso si richiede di disporre di una struttura dati che
rappresenti un insieme dinamico, il cui numero di elementi puµo variare nel
tempo. Per incrementare il numero n di elementi di un'array, una volta che
esso µe allocato in memoria, occorre allocare una nuova porzione di memoria
per n + 1 oggetti e copiare l'intero contenuto dell'array precedente nel nuovo. Ad esempio, per poter aggiungere un elemento all'array A dell'esempio
precedente, si puµo , de¯nita una funzione Reallocate che prende in ingresso
un array di interi (tramite due parametri, il nome dell'array e la sua dimensione) e il numero di elementi da aggiungere, e®ettuare la seguente chiamata
di funzione:
int* Reallocate(int *p, int cur_dim, int grow_factor)
{
int *A_primo = (int*)malloc((cur_dim + grow_factor)*sizeof(int));
for (int i=0; i<cur_dim; i++)
A_primo[i] = A[i];
return A_primo;
}
A = Reallocate(A, 10, 1);
Gli insiemi dinamici di oggetti hanno diverse caratteristiche a seconda del
tipo di operazioni che su di esso si intendono fare. Un tipo di dato astratto
(TDA) rappresenta un insieme dinamico e il set di operazioni che su di esso
si intendono eseguire. Le operazioni tipiche sono di interrogazione (ricerca
di un oggetto, numero di oggetti presenti, ecc.) o di modi¯ca dell'insieme
(inserimento, cancellazione, modi¯ca di un oggetto, ecc.). Il mantenimento in memoria di un TDA per la rappresentazione di insiemi dinamici puµo
richiedere che in un oggetto siano presenti, oltre alle informazioni che lo identi¯cano, un certo numero di informazioni aggiuntive che permettono la sua
aggregazione nell'insieme. Ad esempio, la struttura
struct nodo
{
int info;
struct nodo *next;
}
puµo essere usata per realizzare una lista lineare di oggetti, ognuno collegato
al proprio successore nella lista. Le liste saranno presentate nel x??.
La scelta del TDA da utilizzare per rappresentare insiemi dinamici dipende
principalmente dal tipo di operazione che si intende fare sull'insieme, e ha
in°uenza sull'e±cienza di un eventuale algoritmo che deve usare tale insieme di informazioni. Ad esempio, la ricerca di un elemento speci¯co in una
lista richiede che tutti gli elementi vengano esaminati linearmente, e perciµo
richiede un tempo computazionale pari a O(n). Se l'operazione di ricerca
di un elemento µe ripetuta molte volte in un algoritmo, si potrebbe scegliere
di utilizzare strutture (TDA) piµu e±cienti per questa operazione, come ad
esempio alberi bilanciati. In questo contesto, a meno che non sia esplicitamente detto il contrario, utilizziamo un'analisi asintotica del caso peggiore
per determinare l'e±cienza di un'operazione in una struttura dati.
Riprendendo l'esempio precedente, se anzich¶e un solo intero volessimo
memorizzare per ogni oggetto della lista tutte le informazioni relative a un
impiegato, potremmo dichiarare un oggetto del tipo:
struct object
{
int matricola;
char nome[20];
char cognome[20];
char sesso;
long stipendio;
struct object *next;
};
Alternativamente, per mantenere la struttura lista collegata piµ
u leggera,
possiamo memorizzare in essa solo il puntatore all'oggetto anzich¶e l'oggetto
stesso. In questo caso, dovremmo de¯nire l'oggetto impiegato con le sue
sole informazioni, cio¶e senza il campo next, e dichiarare un nuovo oggetto
per la nostra lista
struct my_object
{
struct object* o;
struct my_object *next;
};
Le liste che vengono realizzate con le due diverse dichiarazioni sono rappresentate in ¯gura 1.1.
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
nome
cognome
sesso
stipendio
Figura 1.1: Una lista di oggetti di tipo impiegato una di puntatori a oggetti di
tipo impiegato
Capitolo 2
Liste
Una lista (o lista collegata o lista lineare) µe una struttura dati in cui gli
oggetti sono organizzati in un ordine lineare. Ogni oggetto della lista contiene
informazioni proprie piµ
u un puntatore all'oggetto successivo.
A di®erenza degli array, la dimensione di una lista non µe nota a priori
(negli array, ricordiamo, la dimensione µe speci¯cata all'atto della dichiarazione
o dell'allocazione esplicita di memoria), ma varia nel tempo man mano che
gli oggetti sono inseriti o cancellati dalla lista.
Una lista µe univocamente determinata mediante una variabile di tipo
puntatore che contiene l'indirizzo del primo oggetto della lista. Gli altri
oggetti possono essere individuati scorrendo la lista mediante i puntatori agli
elementi successivi. In quanto segue, supponiamo senza perdita di generalitµa
che ogni oggetto contenga una sola informazione di tipo intero, come nella
dichiarazione che segue:
struct elem
{
int info;
struct elem *next;
}
Per il TDA lista lineare, de¯niamo le seguenti operazioni:
² inserimento
² cancellazione
² ricerca
6
Ognuna di queste operazioni µe implementata in modo diverso a seconda
che la lista sia mantenuta ordinata (rispetto al campo info oppure no.
2.1
Inserimento
Se la lista non µe ordinata, scegliamo di inserire un elemento in testa alla lista,
in modo da minimizzare il numero di operazioni elementari da compiere.
Siano de¯nite, oltre all'oggetto elem, le seguenti variabili:
elem *piniz;
elem *p;
/* puntatore al primo oggetto della lista */
/* puntatore a un oggetto generico */
p
12
piniz
15
10
7
21
10
7
21
12
piniz
15
Figura 2.1: Inserimento in testa a una lista lineare
Tutto quello che occorre fare, una volta creato il nuovo elemento da inserire, e allocata memoria per esso, µe aggiornare opportunamente i puntatori
come mostrato in ¯gura 2.2, cio¶e eseguire le seguenti istruzioni:
/* alloca memoria per il nuovo oggetto e inserisci dati di informazione */
p = (elem*) malloc (sizeof(elem));
p->info = 12;
/* aggiorna puntatori */
p->next = piniz;
piniz = p;
Se invece la lista µe ordinata, allora l'inserimento richiede che il nuovo
oggetto venga collocato nella posizione opportuna nella lista. Senza perdita di generalitµa , supponiamo che la lista non possa presentare ripetizioni
(elementi con stessa informazione).
p
12
piniz
3
10
13
21
13
21
12
piniz
3
10
r
q
Figura 2.2: Inserimento in una lista lineare ordinata
Bisogna prima trovare il punto di inserimento del nuovo oggetto (immediatamente prima dell'elemento che contiene l'informazione maggiore della
sua) e poi e®ettuare la modi¯ca dei puntatori.
/* alloca memoria per il nuovo oggetto e inserisci dati di informazione */
p = (elem*) malloc (sizeof(elem));
p->info = 12;
/* trova il punto di inserimento */
struct elem *q = piniz, *r = piniz;
while (q->info < p->info)
{
r = q;
q = q->next;
}
/* aggiorna puntatori */
/* r e q sono il predecessore e il successore del nuovo elemento p*/
r->next = p;
p->next = q;
Si noti che abbiamo dovuto utilizzare due variabili di tipo puntatore
perch¶e altrimenti, una volta identi¯cato l'elemento q, non abbiamo modo
di recuperare la locazione di memoria dove scrivere il puntatore all'elemento
da inserire. Questo problema non si presenta se al posto di liste lineari utilizziamo liste doppie, contenenti sia il puntatore all'oggetto successivo sia il
puntatore all'oggetto precedente. In questo caso, la struttura dell'oggetto
diventa la seguente:
struct elem
{
int info;
struct elem *next;
struct elem *prev;
}
e le istruzioni per l'inserimento in una lista ordinata diventano le seguenti (si
veda la ¯gura 2.3):
struct elem *q = piniz;
/* trova il punto di inserimento */
while (q->info < p->info)
q = q->next;
/* aggiorna puntatori */
q->prev->next = p;
p->prev = q->prev->next;
p->next = q;
q->prev = p;
A partire da una lista doppia, si possono de¯nire liste circolari facendo
puntare il predecessore del primo elemento all'ultimo elemento della lista, e
il successore dell'ultimo al primo.
2.2
Ricerca
La ricerca di un elemento in una lista viene fatta scandendo tutti gli elementi
della lista ¯no a trovare quello che contiene l'informazione interessata, se
esiste, o ¯no alla ¯ne della lista. Nel codice che segue, supponiamo ancora che
p sia il puntatore all'elemento da inserire, e piniz il puntatore all'elemento
iniziale della lista.
p
12
piniz
3
10
13
21
13
21
12
piniz
3
10
q->prev
q
Figura 2.3: Inserimento in una lista doppia ordinata
struct elem* Ricerca(int k)
{
struct elem *q = piniz;
while (q != NULL)
if (q->info != k)
q = q->next;
else
break;
return q;
La procedura di ricerca restituisce un puntatore all'elemento cercato, se esso µe presente nella lista, altrimenti il puntatore nullo NULL. Qui descriviamo la procedura generale nel caso delle liste lineari, lasciando per esercizio
l'implementazione dell'operazione di cancellazione nel caso delle liste doppie
o circolari.
2.3
Cancellazione
La cancellazione di un elemento, come per l'inserimento in una lista ordinata, prevede due fasi: prima si deve identi¯care l'elemento da cancellare,
e poi modi¯care opportunamente i puntatori (ed eventualmente cancellare
l'elemento dalla memoria, se non serve piµu ). Il procedimento µe illustrato in
Fugura 2.4
void Delete(int k)
{
/* trova l'elemento da cancellare */
struct elem *q = piniz, *r = piniz;
while (q->info != p->info)
{
r = q;
q = q->next;
}
/* aggiorna puntatori */
r->next = q;
/* cancella oggetto dalla memoria */
free p;
piniz
3
10
13
21
p
piniz
3
10
13
r
21
q
Figura 2.4: Cancellazione di un elemento da una lista lineare
La procedura di cancellazione descritta non considera il caso in cui l'elemento
da cancellare non µe presente nella lista. Si lascia allo studente per esercizio
il compito di descrivere la procedura completa.
Solitamente, si usa inserire all'inizio della lista un oggetto ¯ttizio (dummy) per evitare di eseguire i controlli necessari per i casi particolari in
cui l'elemento da cancellare si trovi all'inizio o alla ¯ne della lista. Tuttavia, la presenza di un oggetto dummy non riduce la complessitµa asintotiva
dell'operazione.
A titolo di esempio, presentiamo il codice per la costruzione di una lista
lineare di 10 elementi.
#include<stdio.h>
#include<stdlib.h>
struct elem {
int info;
struct elem *next;
}
void main()
{
struct elem *p, *piniz;
int k, i;
for(i=0; i<10; i++)
{
printf("\n%d -esimo elemento", i);
scanf("%d", &k);
p = (struct elem *)malloc(sizeof(elem));
p->info = k;
p->next = piniz;
piniz = p;
}
}
Capitolo 3
Code e Pile
Code e pile sono particolari struture dati astratte che permettono di inserire
ed estrarre elementi solo in determinate posizioni. In particolare, una coda
µe gestita in modo FIFO (¯rst-in-¯rst-out), mentre una pila µe getita in modo
LIFO (last-in-¯rst-out).
Le operazioni consentite in queste strutture sono le seguenti:
push inserimento
pop estrazione
Per le code l'inserimento avviene in coda alla struttura, e l'estrazione in testa,
mentre per le pile si inserisce e si estrae sempre in testa alla struttura.
Code e pile possono essere realizzate sia tramite array che tramite liste.
Discuteremo l'implementazione delle funzioni push e pop in entrambi i casi.
3.1
Implementazione con array
Se pile e code vengono implementate tramite array, occorre che il numero di
elementi presenti nella pila o nella coda sia sempre inferiore alla dimensione
dell'array, a meno di riallocazioni di memoria. Come mostrato in ¯gura 3.2,
occorre mantenere due indici head e tail per rappresentare la coda con un
array, mentre basta solo l'indice head per la pila.
13
push
pop
pop
push
LIFO
FIFO
Figura 3.1: Pile e Code
top
1
3
4
8
11
head
tail
pile
1
3
5
7
4
11
code
Figura 3.2: Implementazione di pile e code tramite array
3.1.1
Pile
Per descrivere le operazioni di inserimento e cancellazione in una pila, supponiamo di costruirire una pila utilizzando un array A con 100 posizioni, e di
chiamare top l'indice di testa della pila. Vediamo di seguito le istruzioni C
che creano l'array, allocano memoria per esso, inizializzano l'indice
tt top, e realizzano le funzioni push e pop.
int A[100];
int top;
for(int k=0; k<100; k++)
A[k] = 0;
top = -1;
void push(int i)
{
if (top < 99)
A[++top] = i;
else
printf("errore: pila piena");
}
int pop()
{
if (top > 0)
return A[top--];
else
printf("errore: pila vuota");
}
Si puµo osservare che sia l'operazione di inserimento che quella di cancellazione
aggiornano l'indice top al valore precedente o successivo. Se si vuole scandire
l'intera pila, basta e®etture un ciclo for partendo da 0 ¯no al valore di top.
Se la lista µe vuota, nessuna istruzione interna al ciclo viene eseguita.
for(int k=0; k<top; k++)
...
3.1.2
Code
Anche per una coda, l'inserimento di un elemento comporta l'aggiornamento
degli indici. In particolare, un inserimento aggiorna l'indice di testa, mentre l'estrazione aggiorna l'indice di coda. Per descrivere queste oeprazioni,
supponiamo di costruire una coda utilizzando un array A con 100 posizioni.
Vediamo di seguito le istruzioni C che creano l'array, allocano memoria per
esso, inizializzano gli indici head e tail, e realizzano le funzioni push e pop.
int A[100];
int tail, head;
for(int k=0; k<100; k++)
A[k] = 0;
head = -1;
tail = -1;
void push(int i)
{
if (tail < 99)
A[++tail] = i;
}
int pop()
{
if (head > 0)
return A[--head];
}
Se si vuole scandire una coda, basta partire da uno dei due indici (ad
esempio quello di testa) e raggiungere l'altro in un ciclo while, come nella
seguente istruzione:
while(head < tail)
{
int p = head;
...
p++;
}
Un modo pratico di realizzare liste µe tramite array circolari. In un array
circolare, la coda della struttura coda rappresenta il primo elemento libero
dell'array, secondo l'ordine circolare, come mostrato in ¯gura 3.3.
3
1
4
5
5
3
6
8
head = 3
2
11
7
1
4
8
tail = 9
9
0
11
10
Figura 3.3: Implementazione di code tramite array circolari
In questo caso, supponendo che l'array A ha n posizioni, le operazioni
push e pop possono essere codi¯cate come segue:
head = 0;
tail = 0;
void push(int i)
{
if (head == (tail+1)%n)
printf("errore: coda piena");
else
{
A[tail] = i;
tail = (tail+1)%n;
}
}
int pop()
{
if (head == tail)
printf("errore: coda vuota");
else
{
int k = A[head];
head = (head+1)%n;
return head;
}
}
3.2
Implementazione con liste
Se invece che interi volessimo realizzare una pila o una coda di oggetti generici
con piµu di un'informazione, ci potrebbe essere utile realizzare pile e code con
delle liste collegate. Ogni elemento della lista contiene tutte le informazioni
relative all'oggetto, e un puntatore all'oggetto successivo. Senza perdita di
generalitµa , supponiamo per ora che ci sia solo un'informazione di tipo intero
associata a ogni oggetto.
3.2.1
Pile
Una pila realizzata tramite una lista ha il puntatore top corrispondente al
puntatore iniziale della lista, e le operazioni di inserimento e cancellazione
vengono e®ettuate solo in testa alla lista.
struct elem {
int info;
struct elem *next;
}
struct elem *top;
/* come piniz */
void create()
{
top = NULL;
}
void push(struct elem *p)
{
p->next = top;
top = p;
}
struct elem *pop()
{
if (top != NULL)
{
struct elem *p = top;
top = top->next;
return p;
else
printf("errore: pila vuota");
}
Si noti che nella implementazione di pile tramite liste non occorre e®etuare
il controllo di pila piena, in quanto usiamo una struttura dati dinamica.
3.2.2
Code
Una coda realizzata tramite una lista ha il puntatore head corrispondente
al puntatore iniziale della lista, mentre il puntatore tail punta all'ultimo
elemento della lista.
struct elem {
int info;
struct elem *next;
}
struct elem *head, *tail;
void create()
{
head = NULL;
tail = NULL;
}
void push(struct elem *p)
{
if (head == NULL)
head = p;
else
tail->next = p;
tail = p;
}
struct elem *pop()
{
if (head == NULL)
printf("errore: coda vuota");
else
{
struct elem *p = head;
head = head->next;
return p;
}
}
Come nell'implementazione tramite array, µe possibile de¯nire liste circolari come quella rappresentata in ¯gura 3.4, dove si illustra l'inserimento di
un elemento.
In questo caso, le inizializzazioni sono le stesse che per le liste lineari,
mentre le operazioni push e pop vanno riscritte come segue:
void push(struct elem *p)
{
if (head == NULL)
head = p;
else
{
p->next = head;
tail->next = p;
tail = p;
}
}
15
10
7
tail
21
head
8
Figura 3.4: Inserimento di un elemento in una coda implementata con lista
circolare
struct elem *pop()
{
if (head == NULL)
printf("errore: coda vuota");
else
{
struct elem *p = head;
head = head->next;
tail->next = head;
return p;
}
}
Capitolo 4
Alberi
Un albero rappresenta la generalizzazione di una lista lineare, in cui un elemento puµo avere piµu di un successore. Ogni elemento si chiama nodo e
dispone dei puntatori ai suoi successori, ed eventualmente al predecessore.
Seguendo una terminologia genealocica, il predecessore del nodo in un albero
si chiama padre, i successori ¯gli, e cosµ³ via. Il nodo che non ha predecessori µe
detto la radice dell'albero, mentre quelli che non hanno successori sono detti
foglie. In un albero ogni elemento ha un solo predecessore.
Un albero µe detto n-ario se ogni elemento ha al piµ
u n ¯gli. In fugura 4.1
sono mostrati un esempio di albero binario (4.1-a) e uno n-ario generico (4.1b). Un albero µe identi¯cato per mezzo della propria radice, ossia il puntatore
all'oggetto che µe in tale posizione.
Se l'albero µe binario, allora basta usare due variabili di tipo puntatore per
memorizzare i ¯gli di ogni nodo. Supponendo ancora, senza perdita di
generalitµa , di memorizzare una sola informazione per nodo, descriviamo
l'implementazione del tipo nodo.
struct nodo {
int info;
struct nodo *left;
struct nodo *right;
struct nodo *parent;
}
Per ogni nodo, il puntatore left punta al suo ¯glio sinistro (eventualmente NULL se si tratta di una foglia), right al suo ¯glio destro e parent
al genitore. L'informazione sul genitore non µe sempre necessaria.
22
(a)
(b)
Figura 4.1: Alberi binari e n-ari
Se invece l'albero puµo avere piµu successori per ogni nodo, e il numero µe
variabile, dobbiamo memorizzare i puntatori ai ¯gli in un array e speci¯carne
le dimensioni. La dichiarazione del tipo nodo sarµa allora del tipo:
struct nodo {
int info;
struct nodo **sons;
int numsons;
}
Si potrµa in questo caso indicare l'i-esimo ¯glio del nodo puntato da p con
l'espressione p->sons[i-1].
Si de¯nisce altezza di un nodo x dell'albero il numero massimo di archi
(puntatori) che occorre percorrere, a partire da x, per raggiunge una sua
foglia. Si de¯nisce inoltre altezza dell'albero l'altezza della sua radice.
In generale, gli alberi sono usati perch¶e , a di®erenza delle liste, permettono di avere piµ
u successori per ogni elemento, ma anche perch¶e , se
organizzati opportunamente, danno luogo a operazioni piµ
u e±cienti. In particolare, un albero bilanciato di n elementi ha altezza log2 n e si vedrµa come
µe possibile in alberi bilanciati realizzare operazioni di ricerca, inserimento e
cancellazione in un tempo proporzionale all'altezza dell'albero, cio¶e in O(n).
Se l'albero non gode di particolari proprietµa , occorre in genere visitare
l'intero albero per cercare un dato elemento. Esistono due modi diversi per
visitare un albero:
² visita in profonditµa
² visita in ampiezza
4.1
Visita di un albero in profonditµ
a
La visita di un albero in profonditµa prevede che, dato un nodo corrente x,
si visiti successivamente il proprio sottoalbero sinistro (l'albero che ha come
radice il ¯glio sinistro di x, x->left) e poi il proprio sottoalbero destro
(l'albero che ha come radice il ¯glio destro di x, x->right). Nel caso di
alberi n-ari, si visitano nell ordine i ¯gli del nodo corrente (x), x->sons[0],
x->sons[1], ..., x->sons[numsons-1].
Questa strategia si puµo applicare ricorsivamente, a partire dalla radice,
per tutti i nodi dell'albero. Mostriamo di seguito il codice per ricerca di un
elemento di un albero binario e n-ario mediante visita in profonditµa . Per
completezza, presentiamo sia la versione ricorsiva che quella iterativa.
struct b_nodo {
int info;
struct b_nodo *left;
struct b_nodo *right;
}
struct n_nodo {
int info;
struct n_nodo *left;
struct n_nodo **sons;
int numsons;
}
/* creazione degli alberi */
...
struct b_nodo *b_root;
struct n_nodo *n_root;
...
struct b_nodo* BRecDepthFirstSearch(int key)
{
struct nodo *p = b_root;
if (p->info == key)
return p;
if (p->left != NULL)
DepthFirstSearch(p->left);
if (p->right != NULL)
DepthFirstSearch(p->right);
}
struct n_nodo* NRecDepthFirstSearch(int key)
{
int i;struct nodo *p = b_root;
if (p->left != NULL)
for (i=0; i<p->numsons; i++)
DepthFirstSearch(p->sons[i]);
if (p->info == key)
return p;
}
struct b_nodo* BDepthFirstSearch(int key)
{
char found = 0;
struct nodo *p = b_root;
if ((p->info == key) && (found != 0))
{
found = 1;
return p;
}
while ((p->left != NULL) && (found != 0))
{
p = p->left;
if (p->info == key)
{
found = 1;
return p;
}
}
while ((p->right != NULL) && (found != 0))
{
p = p->right;
if (p->info == key)
{
found = 1;
return p;
}
}
}
struct b_nodo* NDepthFirstSearch(int key)
{
char found = 0;
int i;
struct nodo *p = b_root;
if ((p->info == key) && (found != 0))
{
found = 1;
return p;
}
for (i=0; i<p->numsons; i++)
{
p = p->sons[i];
if ((p->info == key) && (found != 0))
{
found = 1;
return p;
}
}
}
A titolo di esempio, si riporta in ¯gura 4.2 la sequenza di visita in profonditµa prodotta con le due funzioni date. Come si puµo notare, la versione ricorsiva della visita in ampiezza µe piµu agevole, poich¶e la struttura
oggetto-puntatore lista successivi si presta naturalmente alla de¯nizione
di funzioni ricorsive.
1
3
9
1
4
5
1, 3, 9, 4, 5, 8
7
8
11
3
13
8
10
2
5
6
1, 7, 11, 3, 13, 6, 8, 2, 10, 5
Figura 4.2: Visita in profonditµa di un albero
4.2
Visita di un albero in ampiezza
La visita di un albero in ampiezza procede, a partire dalla radice (livello 0),
visitando in sequenza tutti i nodi di uno stesso livello, per poi passare a tutti
quelli del livello successivo. Nella ¯gura 4.3 si mostra la sequenza di visita
in ampiezza di un albero.
Per realizzare questa operazione abbiamo bisogno, ogni volta che ci troviamo a un nodo corrente x, di memorizzare gli elementi successivi da visitare.
Mentre si visita il nodo padre di x, possiamo memorizzare i puntatori ai
¯gli in una sequenza, che verrµa visitata solo dopo che tutti i nodi al livello
di x siano stati visitati. Mediante l'uso di una coda si realizza facilmente
questa operazione: l'elemento corrente x µe in testa alla coda, poi ci sono
tutti gli elementi dello stesso livello, e in¯ne vengono accodati i ¯gli di x. Il
procedimento µe illustrato in ¯gura 4.3 per alberi binari.
Di seguito presentiamo il codice per la visita in ampiezza di un albero
binario e un albero n-ario, nella sola versione iterativa.
struct b_nodo {
int info;
struct b_nodo *left;
struct b_nodo *right;
}
1
1
3 4
1
3
9
3 4
4
5
3 4
9
4
9
4
9 5
8
9 5
8
5
8
8
1, 3, 4, 9, 5, 8
8
Figura 4.3: Visita in ampiezza di un albero
struct n_nodo {
int info;
struct n_nodo *left;
struct n_nodo **sons;
int numsons;
}
/* creazione degli alberi */
...
struct b_nodo *b_root;
struct n_nodo *n_root;
...
/* costruzione delle code */
struct b_nodo *b_queue[100];
struct n_nodo *n_queue[100];
int b_head = -1;
int n_head = -1;
int b_tail = -1;
int n_head = -1;
/* inserisci la radice nella coda */
b_queue[0] = b_root;
n_queue[0] = n_root;
b_head = 0;
b_tail = 0;
n_head = 0;
n_tail = 0;
/* visita in ampiezza */
struct b_nodo* BBreadthFirstSearch(int key)
char found = 0;
while (b_head <= b_tail)
{
/* controlla elemento corrente */
if (b_queue[head]->info == key)
{
found = 1;
return b_queue[head];
}
/* inserisci figli */
if (b_queue[head]->left != NULL)
push(b_queue, b_queue[head]->left;
if (b_queue[head]->right != NULL)
push(b_queue, b_queue[head]->right;
/* elimina nodo corrente */
pop (b_queue);
}
struct n_nodo* BBreadthFirstSearch(int key)
char found = 0;
int i;
while (b_head <= b_tail)
{
/* controlla elemento corrente */
if (b_queue[head]->info == key)
{
found = 1;
return b_queue[head];
}
/* inserisci figli */
for (i=0; i<b_queue[head]->numsons; i++)
push(b_queue, b_queue[head]->sons[i];
/* elimina nodo corrente */
pop (b_queue);
}
Nel codice proposto mancano i controlli di coda vuota e coda piena. Inoltre,
si µe supposto di aver ride¯nito le operazioni push e pop in modo che esse
possano lavorare con entrambi i tipi di strutture (array di puntatori a b nodo
e array di puntatori a n nodo). Si µe supposto inoltre che gli aggiornamenti dei
puntatori alla testa e alla coda sono e®ettuati direttamente dalle operazioni
push e pop.
4.3
Inserimento e cancellazione
Se l'albero non richiede particolari organizzazioni dei dati, occorre speci¯care
il punto di inserimento di un nuovo elemento. La procedura di inserimento
richiede allora due parametri: il puntatore al nuovo nodo da inserire e il
puntatore al padre. Riprendendo parte delle de¯nizioni del listato precedente,
descriviamo l'operazione di inserimento per i due diversi alberi.
void BInsert(struct elem *p, struct elem *par)
{
if (par->left != NULL) && (par->right != NULL)
printf("genitore errato");
if (par->left == NULL)
par->left = p;
else
par->right = p;
}
void NInsert(struct elem *p, struct elem *par)
{
/* rialloca memoria per l'array dei figli */
p->sons = Reallocate(p->sons, numsons, 1);
par->sons[numsons] = p;
par->numsons++;
}
Nel codice per l'inserimento abbiamo supposto di aver de¯nito una funzione
Reallocate che rialloca array di puntatori a nodo, anzich¶e interi come quella
descritta nel x1.
Per quanto riguarda la cancellazione, il procedimento µe piµ
u complesso.
Innanzitutto, se µe nota la chiave key dell'elemento da cancellare, occorre
cercare il suo puntatore x nell'albero. A questo punto possono presentarsi
tre diverse situazioni:
1. x µe una foglia
2. x ha solo un ¯glio
3. x ha entrambi i ¯gli
In tutti i casi occorre mantenere un puntatore al padre di x. Ciµo µe facile
se si utilizza il campo parent, altrimenti occorre modi¯care l'operazione di
ricerca in modo che restituisca oltre al nodo cercato anche il proprio genitore.
void BDelete(int key)
{
struct elem *p = BSearch(key);
if (p == NULL)
printf("errore: elemento non presente");
/* caso 1 */
if (p->left == NULL) && (p->right == NULL)
{
/* p e' figlio destro o sinistro */
if (p == p->parent->left)
p->parent->left = NULL;
else
p->parent->right = NULL;
return;
}
/* caso 2 */
if (p->right == NULL)
/* p e' figlio destro o sinistro? */
if (p == p->parent->left)
p->parent->left = p->left;
else
p->parent->right = p->left;
if (p->left == NULL)
/* p e' figlio destro o sinistro? */
if (p == p->parent->left)
p->parent->left = p->right;
else
p->parent->right = p->right;
/* caso 3 */
struct elem *q = FindSucc(p);
/* copia il successore in p */
p->info = q->info;
/* elimina successore */
if (q == q->parent->left)
q->parent->left = NULL;
else
q->parent->right = NULL;
}
Abbiamo ipotizzato, nel terzo caso, di eliminare una foglia dell'albero, ed
esattamente la foglia piµ
u a sinistra del sottoalbero destro di p. Come vedremo negli alberi binari di ricerca (seconda parte delle dispense), quando
un ordinamento dei nodi dell'albero deve essere mantenuto, la funzione FindSucc trova l'elemento successivo di p nell'albero. In questo contesto, la scelta
del successivo µe assolutamente inin°uente.
struct nodo* FindSucc(struct nodo *p)
{
struct nodo *q = p->right;
while (q->left != NULL)
q = q->left;
return q;
}
Si lascia per esercizio al lettore de¯nire la procedura di cancellazione in
un albero n-ario.