I PUNTATORI E LE STRUTTURE DATI DINAMICHE Cosimo Laneve/Ivan Lanese argomenti 1. dichiarazioni di puntatori 2. le operazione su puntatori (NULL, new, delete, &, *) 3. puntatori passati come parametri e ritornate come valori di funzioni 4. esempi/esercizi 5. le strutture dati dinamiche: liste 6. la funzione revert iterativa slide 2 prologo le strutture dati dinamiche sono strutture dati le cui dimensioni possono essere estese o ridotte durante l’esecuzione del programma – tutti i tipi di dati studiati finora hanno una dimensione che è nota staticamente – ci sono casi in cui sono necessari dati la cui dimensione è sconosciuta al programmatore esempi: – sequenza di caratteri da scrivere in modo invertito (sappiamo risolverlo solo con la ricorsione) – numeri naturali grandi a piacere e operazioni su di esse – pile, code, insiemi senza limitazioni sul numero di elementi – alberi genealogici di famiglie e programmi che stampano la lista di avi di date persone (questi alberi aumentano col tempo man mano che nascono nuovi discendenti) questi problemi possono essere risolti con gli array, ma in modo insoddisfacente slide 3 prologo/cont non è possibile pensare di avere le strutture dati dinamiche in C++ come primitive, visto che sono infinite piuttosto bisogna individuare una operazione di base per implementare i tipi di dato dinamici a programma esempio: una pila può essere implementata in diversi modi o p p i p o p p o p p o [ci sono degli archi] slide 4 i p p i p i p p prologo/cont un arco significa che un elemento è logicamente connesso con un altro elemento – occorre definire questa connessione logica per implementare i tipi di dato dinamici nella memoria ciò che accade è: progra mma 1BA progr amma p o p o p connessioni logiche i 5C p 5C 0FA i 1BA p 5C3 5C3 p 7E0 7E0 connessioni fisiche (in rosso sono rappresentati gli indirizzi di memoria) gli elementi sono strutture con due campi: uno di essi contiene il valore dell’elemento, l’altro contiene l’indirizzo di memoria in cui si trova l’elemento logicamente connesso slide 5 puntatori quando si scrive il programma non è possibile sapere in quale indirizzo di memoria il dato sarà caricato – possiamo solamente far riferimento agli indirizzi in maniera simbolica, utilizzando nomi che li rappresentano – al momento non abbiamo ancora nessun tipo di dato “indirizzo di memoria” – abbiamo utilizzato il concetto di “indirizzo di memoria” nel passaggio per riferimento -- vedi funzione scambia graficamente, anzichè scrivere indirizzi, scriveremo delle frecce, per indicare che un certo contenitore (una variabile) contiene l’indirizzo di memoria di un dato: 1BA progr amma no o 5C p 5C 0FA i 1BA p 5C3 p progra mma 5C3 p p 7E0 7E0 p o i slide 6 si puntatori/cont. definizione (informale): il tipo di dato “indirizzo di memoria” è detto puntatore – un puntatore non punta ad un indirizzo di memoria qualunque, ma solamente a indirizzi che contengono elementi di un certo tipo esempi: puntatori a interi num_p num 3 puntatori a caratteri car_p car a puntatori a strutture (liste/pile/code) struct_p struct a osservazione: il puntatore è un tipo di dato parametrico: dipende dal tipo di elementi a cui punta slide 7 dichiarazioni di puntatori per indicare che un identificatore è un puntatore a valori di un certo tipo, nella dichiarazione, si prefigge l’identificatore con “*” esempi: int *p ; // p è un puntatore a interi double *f_p ; // f_p è un puntatore a float struct lista { int val ; struct lista *next ; } // next è un puntatore // a strutture lista slide 8 operazioni su puntatori – NULL (puntatore vuoto) – new (allocazione blocco di memoria) – delete (deallocazione blocco di memoria) – & (indirizzo di) – * (dereferenziazione) slide 9 operazioni su puntatori/NULL la costante NULL rappresenta il puntatore vuoto – di solito si usa come il puntatore memorizzato nell’elemento finale di un struttura dati dinamica – un programma attraversa una lista, visitando ciascun elemento attraverso puntatori successivi; quando si ottiene il puntatore NULL significa che è stata raggiunta la fine della lista la costante NULL può essere assegnata a qualunque puntatore esempi: int *p char *q, p = NULL q = NULL r = NULL r ; ; ; if (p == NULL) . . . ; // ERRORE! r non è un puntatore la costante NULL ha valore 0 ed è definita in diverse librerie, incluso iostream slide 10 operazioni su puntatori/new problema: la dichiarazione di puntatore alloca solo lo spazio che può contenere un indirizzo di memoria – come fare a far puntare il puntatore ad una certa area? la funzione new permette di allocare dinamicamente la memoria per una variabile di tipo puntatore – new prende in input un tipo e restituisce un puntatore ad un nuovo blocco di memoria che contiene elementi di quel tipo nump nump int *nump ; nump = new int ; le celle di memoria create usando l’operatore new sono dette dinamiche – le celle di memoria dinamiche sono create (e distrutte) quando il programma è in esecuzione slide 11 operazioni su puntatori/delete delete libera l’area di memoria puntata dall’argomento che deve essere un puntatore – tali celle potranno così essere riutilizzate da successive chiamate a new – il valore del puntatore dopo l’invocazione di free è indefinito (dipende dal compilatore) -- dangling pointer – delete non fa nulla se il puntatore in input è NULL esempio: delete nump ; heap nump heap nump A delete nump ; regola di programmazione: è buona norma riassegnare il puntatore dopo l’invocazione di delete (riassegnare i dangling pointer) esempi: int *p ; while (true) { p = new int ; delete p ; } // non termina while (true) { p = new int ; } // termina con out-of memory slide 12 gestione della memoria durante l’esecuzione la memoria (ram) del programma è divisa in due parti: stack e heap – heap è l’area di memoria in cui sono allocati nuovi blocchi di memoria (ad esempio, dalla funzione new) – stack è l’area di memoria in cui vengono allocati i record di attivazione delle funzioni programma identificatori globali stack heap new alloca memoria dall’heap, delete libera la memoria dell’heap slide 13 operazioni su puntatori/& l’operatore &, applicato a una lhs-expression, restituisce il suo indirizzo di memoria (che può essere assegnato a un puntatore) esempio: int *p, n ; n = 5 ; p = &n ; p n = 5 ; n n p = &n ; p 5 o persino n [è errore fare p = &5 esempio: struct lista *p, q q.val = 3 ; q.next = NULL ; p = &q ; p q.val = 3 ; p q.next = NULL; q q p p = &(n*5) ] p = & q ; 3 slide 14 p q 3 5 operazioni su puntatori/* (dereferenziazione) se si esegue int *nump ; newp = new int ; come si fa ad accedere alla cella puntata da newp (che è stata allocata dinamicamente)? – serve un operazione che, preso un puntatore, restituisce l’indirizzo di memoria per accedere all’elemento puntato da esso – il risultato di questa operazione deve essere una lhs-expression è l’operazione di dereferenziazione “*” esempio: *p = 5 ; p newp = new int; *p = 5; p p 5 slide 15 operazioni su puntatori/esempi esempio (aliasing): int *nump, *numq, x ; x = 1 ; nump = &x ; *nump = 2 ; cout << *nump << x ; esempio (aliasing tra puntatori): numq = nump ; *numq = 1 ; cout << *nump << x ; esempio (differenza tra oggetti puntati e puntatori): numq = nump ; // // // // *numq = *nump ; slide 16 cambia la locazione a cui punta numq cambia il contenuto della locazione a cui punta numq esercizi base su puntatori • definire una variabile di tipo intero, incrementarla due volte attraverso due puntatori distinti e stampare il risultato • allocare una variabile di tipo intero, incrementarla, stampare il risultato e deallocarla slide 17 puntatori passati come argomento di funzioni il parametro formale è un oggetto di tipo puntatore il parametro attuale è l’indirizzo di una variabile esempio: funzione che prende in input dividendo e divisore, e restituisce quoziente e resto void division(int dvd, int dvs, int *q, int *r){ *q = dvd / dvs ; *r = dvd % dvs; } main(void){ int quot, rem; division(10, 3, &quot, &rem); cout << quot << rem ; } cosa stampa main? slide 18 puntatori passati come argomento di funzioni quot rem division(10, 3, &quot, &rem); *q = dvd/dvs; *r = dvd%dvs; quot rem dvd dvs q r 10 3 quot rem 3 1 10 3 dvd dvs q r il passaggio dei parametri è per valore, ma vengono copiati i puntatori che consentono di modificare le variabili del chiamante quot rem slide 19 3 1 esempio/la funzione scambia siano a e b di tipo int void scambia(int x, int y){ int tmp ; tmp = x ; x = y ; y = tmp ; } // invocata con scambia(a,b) void scambia(int *x, int *y){ int tmp ; tmp = *x ; *x = *y ; *y = tmp ; } // invocata con scambia(&a,&b) void scambia(int& x, int& y){ int tmp ; tmp = x ; x = y ; y = tmp ; } // invocata con scambia(a,b) void scambia(int *x, int *y){ int *tmp ; tmp = x ; x = y ; y = tmp ; } // invocata con scambia(&a,&b) slide 20 tipi di dato astratto/le liste una lista è una sequenza di nodi che contengono dei valori (di un determinato tipo) e in cui ogni nodo, eccetto l’ultimo, è collegato al nodo successivo mediante un puntatore head val a next b dichiarazione di lista in C++ struct lista { int val; lista *next; } ; slide 21 f tipi di dato astratto/le liste/operazioni dichiarazioni e inizializzazioni lista *p1, *p2, *p3 ; p1 = new lista ; (*p1).val = 1 ; p2 = new lista ; p1 1 ?? p2 2 ?? p3 (*p2).val = 2 ; p3 = p2 ; collegamento tra nodi (*p1).next = p2 ; p1 1 p2 2 ?? p3 tre modi per accedere al campo val del secondo nodo (*p2).val (*p3).val (*((*p1).next)).val abbreviazione: in alternativa a scrivere (*p1).next slide 22 si può scrivere p1->next tipi di dato astratto/le liste/operazioni aggiunta di un nodo in coda p2->next = new lista ; (p2->next)->val = 3 ; p1 1 p2 2 p3 3 ?? ultimo elemento della lista: si memorizza nel campo next il valore NULL (p2->next)->next = NULL; p1 1 p2 2 p3 3 accesso agli elementi della lista: la testa è il primo elemento della lista – la variabile p1 punta alla testa della lista – una funzione che conosce l’indirizzo contenuto in p1 ha accesso ad ogni elemento della lista slide 23 tipi di dato astratto/le liste/inserimento e rimoz. le liste (come tutte le strutture dinamiche) sono facilmente modificabili inserimento di un elemento in testa: p1 p2 p2 = new lista ; p2->val = 0 ; p2->next = p1 ; p1 = p2 ; 0 1 2 inserimento di un elemento tra il primo e il secondo p2 = new lista ; p2->val = 4 ; p2->next = (p1->next)->next ; p1->next = p2 ; p3 3 p1 0 p2 4 1 2 p3 3 rimozione dell’elemento di testa: p1 = p1->next ; rimozione del secondo elmento: slide 24 p1->next = (p1->next)->next ; tipi di dato astratto/le liste/inserimento e rimoz. inserimento di un elemento in coda: while (p1->next != NULL) // p1 punta a una lista non vuota p1 = p1->next ; p1->next = new lista ; p1 = p1->next ; p1->val = 10 ; p1->next = NULL ; // p1 non punta più all’inizio della lista! domanda: perche` non si è scritto while (p1 != NULL) p1 = p1->next ; rimozione di un elemento dalla coda while (p1->next != NULL) p1 = p1->next ; delete p1->next ; p1->next = NULL; // p1 punta a una lista non vuota // NON FUNZIONA! Perché? slide 25 esercizi sulle liste • scrivere un programma che prende in input interi dall'utente finchè l'utente non inserisce 0, li inserisce in una lista e poi – stampa la lista degli input – stampa il valore più vicino alla media • scrivere un programma che prende in input interi dall’utente finchè l’utente non inserisce 0, poi prende un’ altro intero n e rimuove dalla lista tutti gli interi multipli di n, quindi stampa la lista • scrivere un programma che consente all'utente di lavorare su una lista di interi con le seguenti operazioni – – – – – aggiungere un elemento in fondo vedere le informazioni dell'elemento corrente andare avanti di uno andare indietro di uno eliminare l'elemento corrente slide 26 tipi di dato astratto/le liste/stampa e ricerca stampa degli elementi memorizzati in una lista: void stampa_el(lista *p){ // versione ricorsiva if (p != NULL) { cout << p->val << eol; stampa_el(p->next) ; } } // è tail recursive void stampa_el_it(lista *p){ // versione iterativa while (p != NULL) { cout << p->val ; p = p->next ; } } ricerca di un elemento nella lista bool search_el(lista *p, const int e){ // versione ricorsiva if (p == NULL) return(false) ; else if (p->val == e) return(true) ; else return(search_el(p->next, e)) ; } // è tail recursive bool search_el_it(lista *p, const int e){ // versione iterativa bool f = false ; while ((p != NULL) && (!f)){ if (p->val == e) f = true ; else p = p->next ; } return(f) ; slide 27 } tipi di dato astratto/le liste/revert problema: scrivere una funzione una funzione iterativa che prende in input una sequenza di caratteri che termina con il “.” e la stampa invertita. void revert(){ char c ; lista *p , *q; p = NULL ; cin >> c ; while (c != '.') { q = new lista ; q->val = c ; q->next = p ; p = q ; cin >> c ; } cout << "\n la sequenza invertita e`: " ; while (p != NULL){ cout << p->val ; p = p->next ;} } slide 28 strutture dati dinamiche/esercizi 1. implementare il tipo di dato pila con le operazioni di (a) creazione della pila vuota, (b) push = inserimento di un elemento nella pila, (c) pop = eliminazione di un elemento dalla pila; (d) top = valore dell’ elemento in testa alla pila 2. implementare il tipo di dato coda con le operazioni di (a) creazione della coda vuota, (b) enqueue = inserimento di un elemento nella coda, (c) dequeue = eliminazione di un elemento dalla coda; (d) top_el = valore dell’ elemento in testa alla coda 3. implementare il tipo di dato insieme con le operazioni di (a) creazione dell’insieme vuoto, (b) is_in = appartenenza di un elemento all’insieme (c) union = unione di due insiemi; (d) intersection = intersezione di due insiemi slide 29 liste/varianti • alcune delle operazioni precedenti sono scomode da implementare su liste come quelle viste fin'ora • possiamo modificare leggermente la struttura dati lista per risolvere problemi quali – accesso all'ultimo elemento • nelle liste semplici è necessario scorrere tutta la lista • è sufficiente tenere in una variabile statica il puntatore all'ultimo elemento della lista – spostamento all'indietro • nelle liste semplici è necessario scorrere tutta la lista cercando l'elemento che punta a quello corrente • è sufficiente tenere in ogni elemento un puntatore al precedente • nel primo elemento questo puntatore avrà valore NULL • queste liste si chiamano liste bidirezionali o doppiamente linkate slide 30 lista/varianti/quando usarle • vantaggio: semplificano alcuni tipi di operazioni – ricerca di alcuni elementi • svantaggio: complicano altre operazioni – – le informazioni addizionali devono essere aggiornate si complicano inserimenti e cancellazioni • la ricerca del miglior compromesso – si cerca di rendere più efficienti le operazioni più frequenti • esempio: se ho molti inserimenti e cancellazioni cercherò di usare la struttura più semplice slide 31 liste bidirezionali: esercizi • prendere in input caratteri e creare una lista bidirezionale che li contenga. Stampare i caratteri inseriti in ordine inverso e in ordine normale • scrivere un programma che consente all'utente di lavorare su una lista bidirezionale di interi con le seguenti operazioni – – – – – aggiungere un elemento in fondo vedere le informazioni dell'elemento corrente andare avanti di uno andare indietro di uno eliminare l'elemento corrente slide 32