Struttura dati Dizionario Un dizionario è : un insieme di coppie (elemento, chiave) Sul campo chiave è definita una relazione d'ordine totale Su cui definiamo le seguenti operazioni: insert(elem e, chiave k) aggiunge ad S una nuova coppia (e, k) delete(elem e, chiave k) cancella da S la coppia con chiave k search(chiave k) se la chiave k è presente in S restituisce l elemento e ad esso associato altrimenti restituisce null Le operazioni di inserimento e cancellazione rendono le struttura dati dinamica
Struttura dati Dizionario: Vettore non ordinato E' opportuno tenere traccia del numero n di elementi effettivamente presenti nel dizionario (dimensione logica dell array) Insert: O(1) (inserisco in fondo all array) Search: O(n) (devo scorrere l array) Delete: O(n) (cerco l'elemento da cancellare e copio l ultimo elemento nella posizione cancellata) E se usassimo una lista concatenata (ordinata o no)?
Albero binario Un albero binario radicato è una coppia T = (N, A) costituita da un insieme N di nodi ed un insieme A detti archi. In un albero: N N di coppie di nodi 1. Ogni nodo v (tranne la radice) ha un solo padre u tale che (u, v ) A.. Nodi con lo stesso padre sono detti fratelli 2. Un nodo u può avere zero o uno o due figli (nodi v tali che (u, v) A) 3. Un nodo senza figli è detto foglia, mentre i nodi che non sono né foglie né la radice sono detti nodi interni 4. La profondità (o livello) di un nodo è dato dal numero di archi che bisogna attraversare per raggiungerlo dalla radice 5. Altezza di un albero: massima profondità a cui si trova una foglia
Albero binario di ricerca Albero binario di ricerca (BST): albero binario in cui ogni nodo v contiene a) un valore chiave(v) del dizionario b) un campo padre parent(v) c) un campo figlio sinistro sin(v) d) un campo figlio destro des(v) Condizione: - Le chiavi nel sottoalbero radicato in sin(v) hanno valore chiave(v) - Le chiavi nel sottoalbero radicato in des(v) hanno valore > chiave(v) Tali proprietà inducono un ordinamento totale sulle chiavi del dizionario
Albero binario di ricerca! Albero binario di ricerca Albero binario non di ricerca: 47 49
Visita di un albero binario di ricerca Visita in ordine simmetrico dato un nodo v, elenco prima 1) il sotto-albero sinistro di v (in ordine simmetrico), poi 2) il nodo v, poi 3) il sotto-albero destro di v (in ordine simmetrico) algoritmo Visita_Inorder(node v) if (v null) then Visita_Inorder(sin(v)) stampa chiave(v) Visita_Inorder(des(v)) Visita_Inorder(root) visita tutti i nodi del BST una sola volta Complessità: T(n) = (n). Proprietà: Visita_Inorder visita i nodi del BST in ordine crescente rispetto alla chiave
Ricerca Search(chiave k): traccia un cammino nell albero partendo dalla radice: su ogni nodo, usa la proprietà del BST per decidere se proseguire nel sottoalbero sinistro o destro La complessità della procedura di ricerca considerata è T(n) = O(h), dove h è l altezza del BST.
Inserimento Insert(chiave k): inserisce nell'albero un nuovo nodo u con chiave k Cerca la chiave k nell albero, identificando così il nodo v che diventerà padre del nodo u; tale nodo v deve essere un nodo dal quale la ricerca di k non può proseguire (e quindi deve essere un nodo v che non ha sottoalbero sinistro e/o destro) Crea un nuovo nodo u con chiave = k Appendi u come figlio sinistro/destro di v in modo che sia mantenuta la proprietà di ordinamento totale La complessità della procedura considerata è T(n) = O(h), dove h è l altezza del BST
Predecessore Predecessor (chiave k): cerca il nodo v che ha la chiave che precede k nell'ordinamento delle chiavi (crescente) - Sia u il nodo che contiene la chiave k Caso 1. Esiste un sottoalbero sinistro di u. - cerchiamo il predecessore nel sottoalbero sinistro di radice u. Non può essere nel sottoalbero destro perché le chiavi sono > k - Cerchiamo il massimo nel sottoalbero del figlio sinistro si tratta del "nodo più a destra", individuabile scendendo sempre a destra in tale sottoalbero, fino a trovare un nodo senza figlio destro x u w v non qui
Predecessore: caso 2 Predecessor (chiave k): cerca il nodo v che ha la chiave che precede k nell'ordinamento delle chiavi (crescente) - Sia u il nodo che contiene la chiave k Caso 2. Non esiste un sottoalbero sinistro di u - cerchiamo il predecessore sul cammino verso la radice - è il primo nodo con chiave minore di k che si incontra sul cammino verso la radice - u deve essere nel sottoalbero destro di tale nodo v - Inoltre bisogna trovare quello più prossimo - Basta risalire da u verso i suoi antenati finché troviamo un nodo figlio destro di suo padre (u compreso): il padre è proprio il v cercato u v
Cancellazione Delete (chiave k): cerca la chiave k nell albero, identificando così il nodo u con chiave k 1. Se u è una foglia, essa viene rimossa aggiornando il parent(u) 2. Il nodo u da eliminare ha un unico figlio f Si elimina u e si attacca f a parent(u) 3. Il nodo u da eliminare ha due figli Si individua il predecessore (risp. successore) v di u Il predecessore (risp. successore) non ha figlio destro (risp. sinistro) Perché se avesse figlio destro non sarebbe predecessore, perché il figlio destro avrebbe chiave < k ma maggiore di quella di v Si copia il contenuto di v in u e si rimuove v
Albero binario di ricerca in C Definiamo in maniera astratta sia il contenuto di ciascun nodo che il corrispondente campo chiave: struct key { ; int value; typedef struct key key; struct item { ; key chiave; /* Qui eventuali altre informazioni */ typedef struct item item; Questo permette di cambiare chiave e informazioni ai nodi con poche modifiche
Operazioni sulle chiavi Nei file item.h e item.c inseriamo le definizioni appena viste e le funzione necessarie alle operazioni su item e key item new_item(key k){ item it; it.chiave = k; return it; La funzione new_item crea un nuovo item a partire da un oggetto key key new_key(int val){ key k; k.value = val; return k; La funzione new_key invece crea una variabile di tipo key a partire da un intero
Operazioni sulle chiavi - 2 Nel caso per creare nuove variabili di tipo item e key venga allocato spazio dinamicamente, esso va deallocato con opportune funzioni void destroy_item(item it){ destroy_key(it.chiave); /* in base a come è creato un item, potrebbe servire deallocare spazio*/ void destroy_key(key k){ /* non c'è nulla da deallocare in questa implementazione, ma in altre potrebbe servire*/ Serve anche ottenere la chiave di un item key get_key(item it){ return it.chiave;
Operazioni sulle chiavi - 3 Serve anche poter confrontare due chiavi: int cmp_key(key k1, key k2){ return k1.value - k2.value; void destroy_key(key k){ /* non c'è nulla da deallocare in questa implementazione, ma in altre potrebbe servire*/ Potrebbe servire anche stampare un item void print_key(key k){ printf("%d\n", k.value); void print_item(item it){ print_key(it.chiave); /* qui stampa di altre eventuali informazioni*/
Definiamo un albero binario di ricerca (BST) Definiamo ora la struttura nodo di un albero binario di ricerca: struct BST { ; item info; struct BST *sin, *des, *parent; typedef struct BST BST; Questa definizione rimane inalterata se cambiamo il contenuto di item, e lo stesso vale per le altre funzioni che implementeremo I tre campi puntatore, sin, des e parent puntano ai sottoalberi sinistro e destro, e al nodo padre rispettivamente
Creazione Creeremo un albero come puntatore nullo. Lo spazio necessario verrà allocato con l'inserimento. BST *createbst(void){ return NULL; Altre implementazioni potrebbero richiedere invece variazioni alla funzione createbst
Inserimento La funzione insertbst crea un nodo con la chiave k e lo introduce al posto giusto nell'albero. Viene restituita la nuova radice. BST *BSTinsert(BST *p, key k){ BST *q = malloc(sizeof(bst)); BST *r = p,*s = NULL; item it; it.chiave = k; if(!q) { fprintf(stderr,"errore di allocazione\n"); exit(-1); q->info = it; q->sin = q->des = NULL; while(r) { s = r; r=cmp_key(k,get_key(r->info))<0? r->sin:r->des; q->parent = s; if(!s) return q; /* q e' il primo nodo inserito */ if(cmp_key(k, get_key(s->info)) < 0) s->sin = q; else s->des = q; return p;
Inserimento: analisi BST *q = malloc(sizeof(bst));... it.chiave = k; if(!q){fprintf(stderr,"errore di allocazione\n");exit(- 1); q->info = it; q->sin = q->des = NULL; In primo luogo viene allocato il nuovo nodo puntato da q nella memoria dinamica. while(r){ s = r; r = cmp_key(k, get_key(r->info)) < 0? r->sin : r->des; r scende nell'albero scegliendo il ramo sinistro o destro a seconda dell'esito del confronto. s punta al padre di r. Si noti il confronto delle chiavi eseguito con la funzione cmp_key
Inserimento: analisi q->parent = s; if(!s) /* q e' il primo nodo inserito */ return q; if(cmp_key(k, get_key(s->info)) < 0) s->sin = q; else s->des = q; return p; Si collega il nodo puntato da q nel punto individuato da s. Se s è NULL, l'albero è vuoto e viene restituita la nuova radice q, altrimenti q viene agganciato come figlio sinistro o destro a seconda dell'esito del confronto fra le chiavi.
Ricerca /* funzione che cerca un nodo con chiave k nell'albero p*/ BST *BSTsearch(BST *p, key k) { if(!p cmp_key(k, get_key(p->info)) == 0) return p; return BSTsearch(cmp_key(k,get_key(p->info))<0? p->sin:p->des,k);
Predecessore BST *BSTmax(BST *p) { /* si assume p!= NULL */ for(;p->des; p = p->des); return p; ; BST *BSTpred(BST *q) { /* si assume q!= NULL */ BST *qq; if(q->sin) return BSTmax(q->sin); qq = q->parent; if(!qq) return q; /* q unico nodo*/ while(qq && q == qq->sin) { q = qq; qq = qq->parent; return qq;
Cancellazione BST * BSTdelete(BST *p, BST *q){ /* si assume q!= NULL */ BST *r, *s, *t = NULL; if(!q->sin!q->des) r = q; else r = BSTpred(q); /* Caso 3 :il predecessore non ha figlio destro*/ s= r->sin? r->sin: r->des;/* r->des usato per settare a NULL*/ if(s) /* se il predecessore ha figlio sinistro, si crea il ponte tra il figlio e il padre di s*/ s->parent = r->parent; if(!r->parent) t = s; /* se r è la radice*/ else /* si collega il figlio di r con il padre di r*/ if(r == r->parent->sin) r->parent->sin = s; else r->parent->des = s; if(r!= q)/* Caso 3: qui se se ci sono più informazioni nel nodo occorre copiarle*/ q->info.chiave = r->info.chiave; free(r); return t? t : (p!= r? p : NULL);
Cancellazione: analisi BST *r, *s, *t = NULL; r sarà uguale a q nei casi 1 e 2, sarà il successore di q nel caso 3. s sarà il figlio di r oppure NULL se r non ha figli. t sarà diverso da NULL solo se la radice dovrà essere modificata. if(!q->sin!q->des) r = q; else r = BSTpred(q); /* Caso 3 :il predecessore non ha figlio destro*/ s = r->sin? r->sin: r->des;/* r->right usato per settare a NULL*/ Viene determinato in quale dei tre casi siamo, e assegnati i valori corrispondenti a r e s if(s) s->parent = r->parent; if(!r->parent) t = s; /* se r è la radice*/ else /* si collega il figlio di r con il padre di r*/ if(r == r->parent->sin) r->parent->sin = s; else r->parent->des = s; Il nodo r viene estratto dall'albero, modi cando opportunamente il padre di r e s. Si gestisce il caso in cui r è la radice e il caso in cui s è NULL. return t? t : (p!= r? p : NULL); Ritorna t se la radice è da modificare, p se non è da modificare, NULL se q era l'unico nodo dell'albero.
Distruzione void destroybst(bst *p) /* si assume p!= NULL */ { while(p = BSTdelete(p,p));
Esercizi Esercizio 1. Scrivere un programma C che gestisca un albero binario di ricerca (BST). Il programma deve ciclare chiedendo all'utente quale tra 5 operazioni effettuare: 1) Creare un BST vuoto, 2) inserire una chiave (intero) nell'albero, 3) cancellare il nodo contenente una chiave, 4) cercare una chiave, 5) distruggere l'albero. Esercizio 2. Implementare un dizionario usando un vettore invece di un BST, e confrontare le prestazioni delle due implementazioni eseguendo n operazioni di inserimento, n operazioni di ricerca e n cancellazioni. Provare con n=500, 5000, 10000 e confrontare i tempi di esecuzione.
Esercizi Esercizio 3. Modificare il codice proposto per l'albero binario di ricerca in modo da avere delle stringhe come chiavi Esercizio 4. Scrivere una funzione che prenda in input un BST e ne esegua una stampa grafica rispettando la disposizione dei nodi dall'alto verso il basso e da sinistra verso destra