Appunti per il corso di Programmazione I A.A. 2005–06 Marco Baioletti Dipartimento di Matematica ed Informatica Facoltà di Scienze MM.FF.NN. Università degli Studi di Perugia 28 gennaio 2006 Capitolo 1 Problemi e algoritmi La programmazione di un calcolatore si svolge essenzialmente in una prima fase, in cui si delinea un algoritmo che risolve il problema di interesse, ed in una seconda fase, in cui l’algoritmo è tradotto in un programma scritto in un linguaggio di programmazione. In questo e nel prossimo capitolo si vedranno tutti questi concetti e tutti gli strumenti da utilizzare nella programmazione. 1.1 Problemi e istanze Un problema da risolvere mediante un agente di calcolo (dalla soluzione manuale con carta e penna, a quella semimanuale con la calcolatrice, fino a quella completamente automatica con un calcolatore) può essere descritto in termini di dati di ingresso e dati in uscita. Una serie di esempi di problemi, essenzialmente matematici, è la seguente 1. dato un numero reale x ed un numero intero positivo n, calcolare xn 2. dati i coefficienti di un’equazione di secondo grado, trovare le soluzioni o indicare che non ci sono soluzioni reali 3. dati n numeri interi, trovare il maggiore 4. dato un numero intero n, stabilire se è primo 5. dato un sistema di n equazioni lineari in n incognite, trovare la soluzione o determinare che il sistema ha zero o infinite soluzioni 6. dato un grafo di n nodi, cioè un insieme di elementi collegati tra di loro attraverso delle connessioni, dette archi, trovare il percorso di minima lunghezza che porta da un dato vertice ad un altro 7. dato un insieme di n stringhe, ordinarle alfabeticamente 8. dato un numero intero n, scomporlo in fattori primi In maniera più formale un problema P può essere descritto tramite un insieme I, contenente tutti i possibili valori dei dati di ingresso, un insieme R, contenenti tutti i possibili risultati del problema, e una funzione sol : I → R, che ad ogni combinazione dei dati di ingresso associa il risultato corrispondente 1 . 1 in generale un’istanza potrebbe anche avere più soluzioni o nessuna e quindi non sol non sarebbe una funzione, ma una relazione matematica 1 Un’istanza di un problema è una possibile combinazione dei dati di ingresso, cioè un elemento di I. Un’istanza si ottiene sostituendo dei valori ammissibili alle variabili che compaiono nell’enunciato del problema. Ad esempio, nel problema 1 se si sostituiscono ai due dati x e n due valori numerici ammissibili si ottengono tutte le possibili istanze: tre possibili istanze sono x = 1.5 e n = 4, x = 7.1 e n = 10 e x = −81.5 e n = 2. Le istanze del problema 6 sono tutti i possibili grafi di n nodi con tutte le possibili lunghezze per gli archi. In generale il numero delle istanze di un problema è quasi sempre molto elevato. Ad esempio nel problema 4, I è l’insieme di tutti i numeri positivi, R è l’insieme {sı̀,no} e ½ sı̀ se i è primo sol(i) = no se i non è primo Quando R è l’insieme {sı̀,no} (o forme equivalenti) il problema si dice decisionale. 1.2 Gli algoritmi Per risolvere un problema con un agente di calcolo è necessaria una descrizione precisa del procedimento da eseguire. Il procedimento, detto algoritmo, deve essere descritto in termini di operazioni elementari (o passi) che l’agente di calcolo conosce bene, è in grado di eseguire senza nessun tipo di abilità (in altre parole meccanicamente) e che può portare a termine soltanto con le risorse di calcolo ad esso disponibili. Uno dei primi algoritmi, se non il primo algoritmo che la storia ricordi, è l’algoritmo di Euclide (detto antenaresis) per calcolare il massimo comun divisore di due numeri interi: Dati due numeri diversi, si sottragga ripetutamente il minore dei due dall’altro fino a che i due numeri non diventino uguali: il numero cosı̀ ottenuto è il massimo comun divisore dei due numeri iniziali Ciò dimostra che l’idea di algoritmo è ben distinta dall’idea di programma e inoltre il concetto di algoritmo non é necessariamente connesso con l’informatica. In generale gli algoritmi possono essere descritti a parole, purchè si dia una descrizione dettagliata e rigorosa dei passi da utilizzare. Ad esempio due algoritmi per la soluzione del problema dell’elevamento a potenza sono Esempio 1 (Algoritmo A1) Calcolo della potenza con moltiplicazioni successive 1. memorizza il valore 1 nella variabile P 2. ripeti N volte (a) moltiplica P per X 3. il risultato è in P Esempio 2 (Algoritmo A2) Calcolo della potenza con elevamenti al quadrato e dimezzamento dell’esponente 1. memorizza il valore 1 nella variabile P 2. ripeti finché N non diventa 0 (a) se N è dispari moltiplica P per X (b) moltiplica X per sé stesso 2 (c) dimezza N (tralasciando il resto) 3. il risultato è in P Gli algoritmi possono anche essere con un meccanismo abbastanza semplice e molto diffuso: i diagrammi di flusso. Essi sono basati su un sistema grafico in cui ogni passo è rappresentato da un rettangolo (istruzioni di calcolo) o un parallelogramma (istruzioni di ingresso/uscita), i punti di scelta dell’algoritmo sono rappresentati con dei rombi contenenti la condizione da controllare e i passi sono collegati tra di loro con degli archi orientati. Nella figura seguente viene presentato il diagramma di flusso per l’algoritmo A2. Inizio X, N (INPUT) sì N=0 ? no sì N dispari ? no P←P⋅X X ← X2 N ← N/2 P (OUTPUT) Fine Figura 1.1: Il diagramma di flusso E’ opinabile l’uso dei diagrammi di flusso perché induce il programmatore ad utilizzare l’istruzione di salto GOTO che già dalla fine degli anni ’60 è ritenuta un’istruzione da evitare in quanto rende illeggibili i programmi. Un altro sistema abbastanza diffuso in ambito scientifico è quello basato sulla pseudo–codifica in cui l’algoritmo è descritto attraverso un linguaggio di programmazione semplificato, con le istruzioni tratte dal linguaggio naturale (del tipo se. . . allora . . . altrimenti . . . fine–se, mentre. . . ripeti . . . fine–ripeti) o basate su istruzioni di linguaggi di programmazione esistenti, come l’Algol. 1.3 Proprietà degli algoritmi Le tre proprietà fondamentali che un algoritmo deve possedere sono la finitezza, la correttezza e l’efficienza. 3 1.3.1 Finitezza Per finitezza si intende che l’algoritmo deve usare sempre una quantità finita di risorse per risolvere una qualunque istanza del problema. Dato che ciò vale anche per il tempo di calcolo, si richiede che l’algoritmo termini sempre, dopo un numero finito, anche se non limitato, di passi. Detto in altri termini, un procedimento che in alcuni casi non termina entro un tempo finito, ossia richiede un tempo infinito o comunque un numero infinito di risorse, non è considerato valido come algoritmo. 1.3.2 Correttezza Per correttezza di un algoritmo si intende che l’algoritmo deve essere in grado di risolvere il problema in tutte le sue possibili istanze, ovvero, secondo il formalismo precedente, che ricevendo come input l’istanza i produca come output sol(i). Dimostrare che un algoritmo è corretto non sempre è semplice. Per algoritmi semplici si può fornire una dimostrazione matematica, basata solitamente sul metodo di induzione. Nella maggior parte dei casi si può tentare di capire se l’implementazione dell’algoritmo in programma è corretta utilizzando un insieme di test che riescano a coprire in qualche modo l’insieme, solitamente estremamente grande, delle possibili istanze del problema. Un modo molto usato è quello di trovare degli esempi per diverse classi di istanze. Ad esempio i due algoritmi utilizzati per risolvere il problema dell’elevamento a potenza sono entrambi corretti. Infatti A1 calcola X N moltiplicando X per sè stesso N volte e ciò corrisponde alla definizione di potenza con esponente positivo. Per vedere che A2 é corretto bisogna considerare che ad ogni iterazione il valore di P · X N , detto invariante di ciclo, resta uguale a X̄ N̄ , ove X̄ è il valore iniziale di X e N̄ è il valore iniziale di N . All’inizio del ciclo P vale 1, X e N hanno i valori di X̄ e N̄ e quindi l’invariante è uguale a X̄ N̄ . Supponiamo ora che ad ogni passo 3 dell’algoritmo l’invariante di ciclo sia uguale a X̄ N̄ . Se N è dispari, allora N diventa N 2−1 , P diventa P X e X diventa X 2 , perciò l’invariante calcolato con i nuovi valori resta uguale a X̄ N̄ . Se invece N è pari, allora N diventa N2 , P rimane inalterato e X diventa X 2 , e perciò l’invariante continua a rimanere uguale a X̄ N̄ . Alla fine del ciclo N sarà pari a 0 e perciò P varrà X̄ N̄ . Una possibile variazione sul concetto di correttezza è data dal concetto di algoritmo probabilistico. Un algoritmo probabilistico è un algoritmo con alta probabililità produce la soluzione corretta dell’istanza data del problema da risolvere. Se non ci riesce può dare una soluzione errata o ammettere di non essere stato in grado di risolvere il problema. Spesso un algoritmo probabilistico può dare risposte diverse sulla stessa istanza. Un algoritmo probabilistico, benchè non sia corretto al 100%, come richiederebbe la teoria, è però utile in situazioni in cui non si conoscono algoritmi corretti efficienti, come ad esempio, per il problema di controllare se un numero intero è primo (anche se esistono algoritmi esatti che rispondono in tempo polinomiale non sono per ora competitivi con quelli probabilistici). 1.3.3 Efficienza Per efficienza si intende che l’algoritmo per risolvere il problema deve utilizzare al meglio le risorse di calcolo. In teoria sarebbe auspicabile utilizzare solo algoritmi ottimi, ossia quelli che utilizzano la minor quantità possibile di risorse rispetto a tutti gli altri algoritmi in grado di risolvere il problema in questione, ma non sempre questo è possibile. Infatti per alcuni problemi non si conoscono algoritmi ottimali: in tal caso si usano i migliori algoritmi 4 noti, che potrebbero essere non ottimali. Per valutare l’efficienza di un algoritmo bisogna calcolare la quantità di risorse di calcolo necessarie ad un algoritmo. 1.4 Introduzione alla complessità computazionale Per calcolo del costo, o complessità computazionale, di un algoritmo si intende la quantificazione delle risorse utilizzate durante l’esecuzione di un algoritmo. Due misure comuni di complessità sono la complessità temporale, ossia quanti passi impiega l’algoritmo, e la complessità spaziale, ossia di quanta memoria ha bisogno l’algoritmo. La risorsa su cui ci concentreremo sarà il tempo di calcolo (complessità temporale), benchè le considerazioni seguenti vanno bene anche per gli altri tipi di risorse. Come tA (i), misura del tempo utilizzato dall’algoritmo A per risolvere l’istanza i, non si può utilizzare il tempo effettivo in secondi perchè questo presuppone di utilizzare una macchina reale, con certe caratteristiche tecniche, e di avere un’implementazione ben precisa dell’algoritmo scritta in un linguaggio di programmazione ben determinato: tutti questi fattori possono influenzare anche parecchio i risultati, ma non sono assolutamente legati all’algoritmo stesso. Quindi la complessità temporale è usualmente valutata in termini del numero di operazioni svolte, presupponendo che il tempo di calcolo effettivo (cioè quello finale in secondi) sarà all’incirca proporzionale ad esso. Una prima ipotesi semplificativa è quella del costo uniforme, cioè che ogni operazione abbia lo stesso costo, e quindi conti per una. Ma ciò è irrealistico: ci possono essere operazioni più veloci e operazioni più lente. In generale si suddividono le operazioni in tipi omogenei per tempo di esecuzione (cioè le istruzioni all’interno di un tipo hanno più o meno lo stesso tempo di calcolo) e si contano le operazioni di ogni tipo. In alcuni casi la differenza esistente tra i tempi dei vari tipi di istruzione potrebbe essere cosı̀ netta che le operazioni più veloci possono essere trascurate rispetto a quelle più lente. Ad esempio per gli algoritmi dell’elevamento a potenza le operazioni su interi sono trascurabili rispetto alle operazioni a virgola mobile e quindi ha senso contare solo queste ultime. Per poter confrontare due algoritmi che risolvono lo stesso problema o per sintetizzare l’andamento del costo dell’algoritmo non si può usare direttamente la funzione tA (i) in quanto è un oggetto difficilmente utilizzabile per fare confronti o ragionamenti. Un principio quasi sempre riscontrato è che la quantità di tempo (o di qualunque risorsa) utilizzata dall’algoritmo dipenderà in generale dalla grandezza dell’istanza del problema: tanto più grande (e quindi più difficile) sarà l’istanza da risolvere e quanto maggiore sarà il tempo necessario a risolverla. Un modo ragionevole per “misurare” la grandezza delle istanze di un problema è quello di contare i bit che servono a rappresentare l’istanza. Non c’è quasi mai bisogno di scendere a questo livello e spesso una misura grossolana è sufficiente. Ad esempio la dimensione di un’istanza per il problema dell’elevamento a potenza sarà il numero di cifre della base e dell’esponente, mentre nel problema dell’ordinamento delle stringhe sarà proprio il numero di stringhe. Perciò lo scopo dell’analisi computazionale di un algoritmo è quello di valutare una funzione di costo C(n), che ad ogni valore n come possibile valore della dimensione di un’istanza del problema, associa la quantità di risorse utilizzata per risolvere le istanze di quella dimensione n. Poichè in generale la quantità di risorse non dipende solo dalla grandezza dell’istanza, ma anche da altri fattori, tra i quali l’istanza stessa, bisogna in qualche modo trovare una sintesi tra tutte le istanze di dimensione n. La metodologia più utilizzata in assoluta è la cosiddetta complessità nel caso peggiore che verrà spiegata in seguito. 5 1.4.1 Complessità nel caso peggiore Per formalizzare meglio il calcolo della complessità temporale nel caso peggiore, definiamo TA (n), per un fissato n, come il massimo tempo impiegato da A a risolvere un’istanza di dimensione n. Detto in altri termini, TA (n) rappresenta il tempo necessario a risolvere la peggiore istanza possibile di dimensione n. Quest’ultima considerazione spiega il termine di complessità nel caso peggiore. La funzione TA ha svariate caratteristiche positive. Innanzitutto non è difficile da calcolare, in quanto ci si deve sempre mettere nell’ottica di cercare il peggior caso possibile, che di solito è un caso estremo e regolare. Ad esempio in alcuni algoritmi di ordinamento il caso peggiore è quello di ordinare un insieme già ordinato ma in senso inverso. Inoltre vale l’ovvia relazione che per ogni istanza i tA (i) ≤ TA (dim(i)) ossia TA fornisce un limite superiore al tempo necessario ad una qualunque istanza, in quanto è il massimo tempo possibile e ogni altra istanza si deve risolvere con minor o ugual tempo. Uno svantaggio è che in certi casi la complessità nel caso peggiore restituisce una stima troppo pessimistica del tempo di calcolo. Un esempio lampante è il celeberrimo algoritmo del simplesso per la programmazione lineare2 . Nonostante si sappia che nel caso peggiore si comporta veramente male (con tempi di calcolo che crescono in modo esponenziale rispetto alle dimensioni del problema), questo algoritmo è molto utilizzato nella pratica perchè la stragrande maggioranza delle istanze vengono risolte molto velocemente. Il motivo sembra essere che il caso peggiore di quel problema è un caso patologico, che non si presenterà mai (o quasi) in pratica. 1.4.2 Esempi di calcolo della complessità Diamo ora alcuni esempi di calcolo della complessità nel caso peggiore di acluni algoritmi. I due algoritmi A1 e A2 presentati per risolvere il problema dell’elevamento a potenza hanno comportamenti diversi. Per calcolare la complessità computazionale calcoliamo il numero delle moltiplicazioni al variare dell’esponente N , poichè il loro numero non varia se invece cambiasse X. Per calcolare X N A1 esegue sempre N moltiplicazioni, quindi il costo è T (N ) = N . Si noti che questo vuol dire che l’algoritmo A1, nonostante le apparenze, ha una complessità esponenziale nella grandezza dell’esponente ! Infatti per rappresentare l’esponente N in base 2 si ha bisogno di dlog2 N e bit e quindi è questa la dimensione dell’istanza. Per cui T (N ) = N andrebbe letta come T (n) = 2n − 1, ove n rappresenta la dimensione in bit di N , e 2n − 1 è proprio il più grande numero intero positivo rappresentabile con n bit. Per capire quante operazioni sono svolte da A2 si deve innanzitutto notare che ad ogni passo vengono svolte una moltiplicazione, talvolta un’altra moltiplicazione, (punto 3a), ed una divisione intera. Quest’ultima è un’operazione più veloce delle altre e può essere trascurata dal computo totale delle operazioni svolte. Il numero di iterazioni che vengono effettuate è la parte intera di log2 N , poichè questo numero equivale al numero di volte che si può dimezzare un numero fino a farlo diventare 1, e quindi T (N ) ≤ 2blog2 N c. Per cui, riprendendo la considerazione fatta per A1, A2 ha complessità lineare nella dimensione dell’istanza: T (n) = 2n. In generale poi non interessa nemmeno una valutazione precisa della complessità computazionale, ma è sufficiente conoscerne il comportamento asintotico. Ad esempio viene riportato che T (n) è dell’ordine di n2 , in simboli T (n) = O(n2 ), per diversi algoritmi di ordinamento di array di n elementi, anzichè indicare espressamente la formula precisa di T (n). 2 calcolare il massimo (o il minimo) di una funzione lineare a più variabili non negative sotto il vincolo di disequazioni lineari 6 Un altro esempio si ha nel confronto dei seguenti due algoritmi per il calcolo del massimo di n numeri. Il primo algoritmo, chiamato M1, è basato sulla definizione di massimo, cioè di quel numero che è maggiore o uguale di tutti gli altri. Per trovarlo basta prendere a turno ciascuno degli n numeri e controllare se essi soddisfano la condizione di massimo, l’algoritmo si ferma quando un siffatto numero viene trovato. In pseudo–codifica l’algoritmo M1 è 1. prendi a turno ciascuno degli n elementi, sia Ak l’elemento preso 2. confronta ogni altro numero Ai con Ak contando quanti elementi Ai sono ≤ Ak 3. se il conteggio dà n − 1, tutti gli altri sono minori o uguali a Ak e quindi il numero più grande è proprio Ak , altrimenti continua con il numero successivo Il secondo algoritmo, chiamato M2, consiste nel calcolare un massimo parziale, aggiornandolo ad ogni numero preso in considerazione. Si parte ponendo come massimo parziale il primo elemento dell’insieme. Per ogni altro elemento dell’insieme, si confronta l’elemento in questione con il massimo parziale e se l’elemento corrente risulta essere maggiore del massimo parziale, il massimo parziale diventa proprio l’elemento corrente. E’ facile vedere che dopo aver passato in rassegna tutti gli elementi dell’insieme si trova come massimo parziale proprio il massimo elemento dell’insieme In pseudo–codifica l’algoritmo è 1. memorizza il primo numero in MAX 2. confronta ogni altro numero Ai con MAX: se Ai > M AX allora poni MAX pari a Ai 3. Alla fine MAX il numero pi grande In termini di efficienza il secondo algoritmo è molto più efficiente del primo, valutandone la complessità nel caso peggiore. Il caso peggiore per l’algoritmo M1 è quando il massimo è l’ultimo elemento: ad ogni ciclo l’algoritmo fa n − 1 confronti per contare gli elementi ≤ Ak e deve arrivare all’ultimo elemento per trovare il massimo, in totale perciò fa n · (n − 1) confronti. Quindi TA1 (n) = O(n2 ) Invece M2 fa sempre N − 1 confronti, quindi TA2 (n) = O(n). Perciò essendo M1 è quadratico in n, mentre M2 è lineare in n, M2 è nettamente superiore a M1 in fatto di efficienza. Per inciso si può vedere che M2 usa il minor numero possibile di confronti e quindi è anche un algoritmo ottimale. 7 Capitolo 2 Programmi e linguaggi di programmazione 2.1 Introduzione In generale un programma è una serie di istruzioni di un determinato linguaggio di programmazione in grado di svolgere un determinato compito. Un programma solitamente è ottenuto implementando un algoritmo ed è perciò il risultato finale della risoluzione di un problema: il problema viene inizialmente risolto mediante la progettazione di un algoritmo che verrà successivamente implementato mediante un programma. L’implementazione di un algoritmo con un programma introduce una serie di dettagli che nella stesura dell’algoritmo non appaiono, quindi un programma è molto più espressivo dell’algoritmo da cui deriva, anche se le linee principali sono le stesse. Si noti inoltre che lo stesso algoritmo può essere scritto in linguaggi di programmazione diversi e anche usando lo stesso linguaggio, implementato in modi diversi. Quindi da un algoritmo possono nascere molti programmi che, anche se all’apparenza molto diversi, sono tutti equivalenti e condividono la correttezza e la complessità. La programmazione di un calcolatore nel senso più preciso della parola non può che avvenire in linguaggio macchina. Infatti il calcolatore può eseguire solo programmi scritti in tale linguaggio. La programmazione direttamente in linguaggio macchina può essere però estremamente complessa e difficile a causa della mancanza di astrazione delle istruzioni del linguaggio macchina (le istruzioni operano direttamente sui registri o sulle celle della memoria, non è possibile definire espressioni matematiche, non esistono strutture di controllo, non si possono trattare dati strutturati, ecc.), sull’estrema povertà nella scelta delle istruzioni e anche su alcune “stranezze” proprie del linguaggio macchina (ad esempio in alcuni linguaggi macchina alcune istruzioni possono essere eseguite solo su determinati registri, ma non su tutti). Un altro grave ed evidente difetto del linguaggio macchina è che ogni programma scritto in un linguaggio macchina per un certo calcolatore può essere eseguito solo in quel tipo di calcolatore. Quindi se si vuole eseguire tale programma in un altro calcolatore, c’è bisogno di riscriverlo completamente. Inoltre l’implementazione di un algoritmo in linguaggio macchina può richiedere l’utilizzo di tecniche e di trucchi propri della macchina in cui si lavora e che risultano inutilizzabili quando l’algoritmo viene implementato in un’altra macchina. 8 2.2 I principali linguaggi di programmazione I linguaggi di programmazione ad alto livello nascono per evitare al programmatore di dover lavorare in linguaggio macchina o in linguaggio assembler. Essi hanno reso possibile la diffusione della programmazione e la creazione di programmi molto complessi e molto lunghi. Come si è visto, la programmazione in linguaggio macchina è molto difficile perché si deve lavorare direttamente con la risorse della macchina, perdendosi dietro ad una miriade di dettagli tecnici che nulla o ben poco hanno a che fare con gli algoritmi che si vorrebbe implementare. Utilizzare un linguaggio di programmazione consente al programmatore di lavorare con una macchina astratta in cui ci si può concentrare sui dettagli realmente significativi. Inoltre scrivere un programma con un linguaggio di programmazione consente di poter facilmente utilizzare il programma su macchine diverse. 2.2.1 I paradigmi di programmazione Esistono svariati linguaggi di programmazione, sia general–purpose (adatti a qualsiasi tipo di applicazione), sia orientati ad un particolare tipo di applicazione. I linguaggi di programmazione sono classificati in base al paradigma di programmazione che seguono. Esistono quattro importanti paradigmi di programmazione • Nella programmazione imperativa un programma è visto come sequenza di comandi che la macchina deve svolgere. Il programma viene eseguito in un certo ordine, che può essere modificato con istruzioni condizionali, istruzioni di iterazione e salti. La memoria viene suddivisa in variabili a cui è possibile assegnare dei valori e di cui è possibile riutilizzare in seguito il valore. Molti linguaggi, soprattutto quelli più diffusi (C, Pascal, Fortran, Cobol, Basic, ecc.), sono imperativi. Sarà dedicata una piccola sezione alla descrizione delle caratteristiche più importanti dei linguaggi imperativi. Sono i linguaggi in qualche modo più vicini al funzionamento della macchina. • Nella programmazione funzionale un programma è visto come una serie di espressioni da valutare in cui compaiono funzioni e funzionali (cioè funzioni che restituiscono come risultato altre funzioni) predefinite e definite dal programmatore, spesso in maniera ricorsiva. Non esistono, almeno nei linguaggi funzionali puri, né variabili né istruzioni vere e proprie. I linguaggi più famosi sono Lisp e APL. • Nella programmazione logica un programma è visto come il controllo di relazioni tra dati. Le relazioni possono essere date esplicitamente (fatti) o possono essere dedotte attraverso delle regole, anch’esse spesso ricorsive, a partire da relazioni già note e fatti. Non esistono anche in questo caso né variabili né istruzioni vere e proprie. E’ il paradigma più astratto e più lontano dal funzionamento della macchina. Il linguaggio più famoso è il Prolog. • Nella programmazione orientata agli oggetti, che spesso compare insieme alla programmazione imperativa, un programma è visto come una serie di messaggi inviati ad oggetti. Ogni oggetto risponde ad un messaggio eseguendo un metodo di risposta. I metodi sono scritti essenzialmente come sequenze di istruzioni e invio di messaggi ad altri oggetti. E’ una tecnica recente e molti linguaggi moderni sono ad oggetti. I linguaggi ad oggetti più famosi sono C++, Java, Eiffel, Smalltalk. Inoltre molti linguaggi già esistenti si stanno convertendo alla programmazione ad oggetti (Ada, Fortran, Pascal, ecc.). 2.2.2 I principali linguaggi di programmazione In generale i linguaggi di programmazione più famosi sono: 9 FORTRAN (1954) E’ stato il primo linguaggio di programmazione ad alto livello indipendente dall’architettura (prima di allora si programmava nel linguaggio macchina). E’ nato ed è sempre stato utilizzato per scopi scientifici, prima tra tutte le applicazioni numeriche (FORTRAN sta infatti per FORmula TRANslation). Ha subito con il passare del tempo molte modifiche ed esistono diverse versioni : FORTRAN IV (1966), FORTRAN 77, Fortran 90, Fortran 95. COBOL (1959) E’ un linguaggio ideato per applicazioni aziendali ed ampiamente utilizzato per la gestione degli archivi. E’ il linguaggio di programmazione più vicino all’inglese. ALGOL (1960) E’ stato il primo linguaggio definito in modo formale (sintassi BNF) ed ha introdotto molti dei concetti utilizzati in altri linguaggi di programmazione. E’ utilizzato per la descrizione degli algoritmi. Non ha mai avuto una diffusione apprezzabile. PL/1 (1963) Linguaggio ideato dall’IBM come sintesi di FORTRAN, COBOL e ALGOL. Poco diffuso al di fuori degli ambienti IBM poiché è un linguaggio molto complesso. Basic (1965) Linguaggio elementare di uso generico, diffuso e utilizzato nei primi home computer e personal computer. Molto primitivo e poco standardizzato, con decine di dialetti diversi e incompatibili. Attualmente la Microsoft ne distribuisce una versione avanzata, il Visual Basic, per la programmazione rapida di applicazioni per il sistema Windows. Pascal (1971) Linguaggio ideato e molto utilizzato per l’insegnamento della programmazione. Ha avuto una certa diffusione sui PC grazie al Turbo Pascal. C (1975) Linguaggio ideato per scrivere il sistema operativo UNIX ed utilizzato in seguito per scrivere applicazioni di base (sistemi operativi, compilatori, programmi di utilità). E’ abbastanza diffuso in ambito aziendale. ADA (1979) Linguaggio estremamente potente e complesso ideato per scrivere applicazioni sicure. E’ poco diffuso a causa della difficoltà nella progettazione di compilatori efficienti per questo linguaggio. E’ uno degli strumenti più usati nell’ingegneria del software. C++ (1985) Versione orientata agli oggetti del linguaggio C. E’ uno dei linguaggi più utilizzati sia in ambito aziendale, grazie al Visual C++, sia in ambito scientifico, ove è in competizione con il FORTRAN. Java (1995) Linguaggio fortemente ispirato al C++ e ideato per applicazioni sulle pagine Web. Ha la peculiarità di essere compilato per un processore virtuale (JVM) che poi viene emulato dai processori reali, che consente di poter eseguire un programma Java già compilato in una serie molto ampia di piattaforme. La caratteristica di essere perfettamente portabile lo rende uno dei mezzi più semplici per la diffusione di programmi, anche tramite Internet. 2.3 I concetti fondamentali della programmazione imperativa Un programma nella programmazione imperativa è un insieme di istruzioni che operano su dati. Vediamo ora in dettaglio ciascuno di questi due concetti. 10 2.3.1 Dati I dati trattati in un programma possono essere dati costanti e dati variabili. La differenza ovviamente consiste nel fatto che i primi non cambiano durante il corso del programma, mentre i secondi possono essere modificati dal programma. I dati normalmente vengono classificati in tipi di dato. Tipi di dato Un tipo di dato è definito da un insieme D di possibili valori, detto dominio ed un insieme O di operazioni possibili. Ad esempio il tipo di dato intero è definito dall’insieme dei numeri interi utilizzabili, per esempio tutti i numeri compresi tra -32768 e +32767, e un insieme di operazioni , ad esempio somma, prodotto, differenza, quoziente, resto. I tipi di dati che si trovano negli usuali linguaggi di programmazione si suddividono in dati elementari e dati strutturati. I dati elementari sono costituiti da un unico valore e si suddividono in dati numerici (numeri interi e numeri reali) e dati non numerici (stringhe e valori booleani). I dati strutturati sono costituiti attraverso dei costrutti a partire da più valori, sia elementari, sia strutturati a loro volta. Il costrutto array consente di memorizzare n dati, tutti dello stesso tipo T . Un esempio di array è un vettore di 10 componenti intere. Ogni componente dell’array è numerata e si può accedere alle singole componenti specificando un indice, cioè un numero che rappresenta la posizione della componente voluta nell’array (la prima, la seconda, . . . ). Il costrutto record consente di memorizzare un insieme di dati di tipi diversi T1 , T2 , . . . , Tn detti campi ai quali, solitamente, è attribuito un nome. Un esempio di record è fornito dai dati di una persona: nome, cognome, data di nascita, indirizzo, numero di telefono, stipendio. Per accedere ad una delle componenti di un record si indica il nome del campo desiderato. Variabili Una variabile è una parte della memoria su cui è possibile memorizzare un dato appartenente ad un certo tipo di dato. Le variabili utilizzate normalmente sono associate ad un nome che permette di accedere al dato memorizzato, sia per leggere il valore, sia per modificare il valore. Molti linguaggi associano ad una variabile un tipo di dato fisso che deve essere dichiarato dal programmatore. Inoltre in molti linguaggi ci sono regole che stabiliscono la durata di una variabile (cioè quando la variabile sarà presente in memoria) e l’area di visibilità (cioè la zona del programma in cui è possibile usare la variabile). 2.3.2 Istruzioni Le istruzioni di un linguaggio imperativo si suddividono in istruzioni elementari e istruzioni strutturate. Istruzioni elementari La principale istruzione elementare è l’assegnamento, che consente di memorizzare un valore, calcolato come risultato di un’espressione, all’interno di una variabile. Altre istruzioni elementari sono le istruzioni di I/O, lettura e scrittura di dati attraverso le usuali periferiche quali tastiera, video e memorie di massa. Un’altra istruzione è la chiamata di un sottoprogramma, di cui accenneremo in seguito. 11 Istruzioni strutturate Le istruzioni strutturate sono istruzioni composte, costituite da più istruzioni, elementari o a loro volta strutturate, utilizzando tre costrutti fondamentali: sequenza : è una successione di istruzioni I1, I2, . . . , In che sono eseguite in modo sequenziale, cioè una dopo l’altra secondo l’ordine indicato; si usa anche per raggruppare le istruzioni in modo da considerarle come se fossero una sola istruzione; scelta : è un punto di scelta del programma, in cui si valuta una condizione 1 e a seconda del risultato si esegue un insieme di istruzioni S1 (se la condizione è vera) o un insieme di istruzioni S2 (se la condizione è falsa). E’ possibile anche avere forme di scelta a più valori; iterazione è un modo per ripetere un insieme di istruzioni, si distinguono iterazioni a conteggio, in cui il numero di ripetizioni è fissato a priori (ad esempio ripeti S per 10 volte), e iterazioni a condizione, in cui la ripetizione continua fintantochè una data condizione è vera (ad esempio ripeti S mentre X è diverso da 0) oppure è falsa (ad esempio ripeti S fintantochè A non diventa positivo). Sottoprogrammi Infine in molti linguaggi di programmazione è possibile definire delle parti di programma, dette sottoprogrammi, che possono avere tutte le caratteristiche dei programmi interi (ad esempio variabili proprie) e che possono essere mandati in esecuzione, anche in più punti del programma o da altri sottoprogrammi, attraverso opportune istruzioni di chiamata a sottoprogrammi. La chiamata di un sottoprogramma sospende temporaneamente l’esecuzione del programma o del sottoprogramma in corso, attivando l’esecuzione del sottoprogramma chiamato. Quando l’esecuzione di quest’ultimo finirà, il programma o il sottoprogramma sospeso riprenderà ad essere eseguito a partire dal punto in cui era stato sospeso, cioè l’istruzione immediatamente successiva alla chiamata. Questo meccanismo è simile al concetto di sub–routine visto nella parte di programmazione in linguaggio macchina, ma molto più raffinato, potendosi distinguere le procedure dalle funzioni (quest’ultime producono un risultato esplicito) e potendo utilizzare dei parametri mediante i quali il programma chiamante e il sottoprogramma chiamato possono scambiarsi dei dati. 2.4 Strumenti per la programmazione Il programmatore ha a disposizione diversi strumenti per la scrittura e l’esecuzione di un programma: editor sono programmi che permettono di scrivere il codice del programma; alcuni possono controllare parzialmente la correttezza del programma (almeno dal punto di vista lessicale) e aiutare il programmatore nella digitazione del codice, completando le istruzioni; traduttori sono programmi che traducono un programma da un linguaggio ad un altro; se ne parlerà in seguito; debuggger sono programmi che consentono di far eseguire un programma un passo per volta e consentendo di “entrare dentro al programma”: vedere il contenuto delle variabili, modificarlo, ecc. Sono ottimi strumenti per scoprire eventuali errori di programmazione. 1 un’espressione il cui risultato è vero o falso 12 2.4.1 Tecniche di traduzione Poichè è estremamente complicato programmare in linguaggio macchina e inoltre è impossibile far eseguire da una macchina un programma scritto nel linguaggio macchina di una macchina diversa, sono stati ideati i linguaggi ad alto livello, tra i quali i più famosi sono stati descritti nel paragrafo precedente. Nonostante l’introduzione di questi linguaggi, le CPU tradizionali sono in grado solo di eseguire programmi scritti in linguaggio macchina e quindi è necessario tradurre i programmi dal linguaggio ad alto livello al linguaggio macchina. Esistono due tecniche distinte di traduzione di un programma: l’interpretazione e la compilazione. Si dice che un programma P è interpretato se la fase di traduzione è eseguita durante l’esecuzione stessa. In pratica, tramite un particolare programma, chiamato interprete, ogni singola istruzione di P viene tradotta in linguaggio macchina e poi eseguita. Se un’istruzione deve essere eseguita più volte verrà di conseguenza tradotta più volte. Si dice che un programma P è compilato se la fase di traduzione è eseguita interamente prima dell’esecuzione. In pratica tutto il programma P, detto sorgente viene tradotto da un programma, detto compilatore, nel programma P’, detto eseguibile, equivalente a P ma scritto in linguaggio macchina. I programmi interpretati sono eseguiti in modo molto più lento perchè ogni istruzione deve essere tradotta prima di essere eseguita, mentre nella compilazione il programma tradotto P’ è eseguito direttamente dalla macchina. Un secondo vantaggio offerta dalla compilazione è che la traduzione in linguaggio macchina può essere svolta in modo da ottenere un codice molto efficiente (fase di ottimizzazione) a scapito della velocità della fase di traduzione. Un terzo vantaggio è che la traduzione viene svolta solo una volta, cioè prima che il programma deve essere eseguito per la prima volta. Infatti una volta ottenuto l’eseguibile, questo può essere eseguito tutte le volte che se ne ha bisogno. Anche l’interpretazione offre dei vantaggi. Innanzitutto per eseguire un programma tramite interpretazione non si ha bisogno di una fase precedente di traduzione: il sorgente viene eseguito direttamente, senza il ritardo di una traduzione completa, come se la macchina “capisse” veramente il linguaggio ad alto livello in cui è scritto. Inoltre, mentre ogni volta che si modifica un programma in un linguaggio compilato questo va ritradotto nuovamente, un programma interpretato potrebbe addirittura essere modificato anche durante l’esecuzione stessa. Con l’esecuzione interpretata di un programma è possibile effettuare un’esecuzione passo–passo, per vedere cosa succede dopo ogni istruzione eseguita, o inserire dei punti di arresto. La caratteristica dell’immediatezza dell’interpretazione si utilizza nei programmi interattivi (ad esempio pacchetti matematici o statistici) in cui l’utente “dialoga” in modo diretto con il programma dando dei comandi che devono essere eseguiti subito, senza dover essere tradotti completamente. In generale, però, il confronto tra la velocità di esecuzione di un programma interpretato e quella di un programma compilato è talmente sfavorevole per i programmi interpretati, che per molti linguaggi ad alto livello (Pascal, C, Fortran, C++, Cobol, ecc.), utilizzati per applicazioni che devono essere veloci, si usa solo la modalità di esecuzione compilata. 2.4.2 I compilatori Diamo ora uno sguardo più dettagliato ai compilatori. La fase di traduzione di un programma ad alto livello in un programma in linguaggio macchina è svolta in più fasi. Nella prima fase, detta analisi lessicale, il programma viene sottoposto ad una verifica di correttezza lessicale, trovando errori di “ortografia” (essenzialmente parole chiave scritte in modo errato) e scomposto in parti elementari, dette token: parole chiave, numeri, identificatori, ecc. 13 Nella seconda fase, detta analisi sintattica o parsing, il programma viene sottoposto ad una verifica di correttezza sintattica, trovando errori di sintassi (cioè di istruzioni “grammaticatamente” scorrette). L’analisi sintattica decompone il programma nelle sue componenti principali (istruzioni, costrutti, espressioni, dichiarazioni, ecc.). Può esserci a questo punto una fase in cui avviene una prima traduzione del programma in un formato intermedio (non ancora in linguaggio macchina), in cui ad esempio le espressioni aritmetiche sono trascritte in modo semplificato, che può facilitare il passaggio seguente. Nella fase successiva il programma viene tradotto in linguaggio macchina. Può essere poi eseguita una fase di ottimizzazione del codice macchina, in cui si cerca di velocizzare il programma spostando e riscrivendo le istruzioni attraverso l’uso di regole che producono un codice equivalente ma più efficiente. Il codice cosı̀ si chiama oggetto. Nell’ultima fase il programma in linguaggio macchina è collegato attraverso una programma detto linker, che può essere esterno rispetto al compilatore stesso, alle librerie di procedure e funzioni predefinite, quali le procedure di input/output, le funzioni matematiche, ecc. Il programma risultato di quest’ultima fase si chiama eseguibile. 14 Capitolo 3 Gli algoritmi elementari In questo capitolo vengono descritti brevemente gli algoritmi elementari sviluppati durante il corso. Per maggiori delucidazioni si rimanda al libro di testo. 3.1 Somma di un array La somma di un array X di n elementi numerici si calcola utilizzando una variabile accumulatore, chiamata nel codice sottostante somma, su cui si sommano successivamente tutti gli elementi dell’array. somma=0; for(i=0;i<n;i++) somma +=x[i]; 3.2 Conteggio degli elementi di un array che verificano una data proprietà Il conteggio degli elementi di un array X che verificano una data proprietà (nell’esempio contiamo gli elementi pari) si calcola utilizzando una variabile “contatore” conta=0; for(i=0;i<n;i++) if(x[i]%2==0) conta++; 3.3 Fattoriale e coefficiente binomiale Il fattoriale n! è la produttoria di tutti i numeri da 1 a n e perciò si calcola in modo simile alla sommatoria. fatt=1; for(i=1;i<=n;i++) fatt *= i; Esiste una versione ricorsiva del fattoriale (che è uno degli esempi più famosi di funzione ricorsiva) 15 int fattoriale(int n) { if (n==0) return 1; else return n*fattoriale(n-1); } ¡ ¢ Il coefficiente binomiale nk si può calcolare attraverso il calcolo di tre fattoriali secondo la formula µ ¶ n n! = k k!(n − k)! ma si può calcolare in modo più efficiente come µ ¶ n n(n − 1) · · · (n − k + 1) = k k! coeff_bin=1; for(i=1;i<=k;i++) coeff_bin *= (n-i+1)/i; 3.4 Massimo e minimo di un array Per calcolare il massimo (o il minimo) di un array di n elementi si usa un meccanismo già descritto nella parte dedicata agli algoritmi ed alla complessità. Si usa una variabile di nome MAX che contiene il valore massimo corrente (cioè il più alto valore fino a quel momento trovato). MAX è inizializzata con il valore del primo elemento del vettore e poi, passando in rassegna tutti gli altri elementi del vettore, è aggiornata con il valore dell’elemento corrente se questo supera l’attuale valore di MAX. Il codice è il seguente max=a[0]; for(i=1;i<n;i++) if (a[i]>max) max=a[i]; Si può anche utilizzare un ciclo FOR che comprende anche il primo elemento, inizializzando MAX con un valore molto basso (ad esempio un numero negativo grande in valore assoluto, tipo −1038 per numeri reali, o -32768 per numeri interi a 16 bit, ecc.) max=-1.0e38; for(i=0;i<n;i++) if (a[i]>max) max=a[i]; Alla prima iterazione sicuramente a[0] > M AX e quindi MAX prenderà il valore di a[0]. Questo può essere utile se non si vuole trattare a sè il primo elemento (si pensi al caso in cui gli elementi anzichè risiedere in un vettore sono generati da programma). In modo simile si può calcolare il minimo elemento min=a[0]; for(i=1;i<n;i++) if (a[i]<min) min=a[i]; 16 Anche per il minimo si può anche utilizzare un ciclo for che comprende anche il primo elemento ma allora bisogna inizializzare MIN ad un valore molto alto (ad esempio un numero positivo grande in valore assoluto, tipo 1038 per numeri reali o 32767 per numeri interi a 16 bit) min=1.0e38; for(i=0;i<n;i++) if (a[i]<min) min=a[i]; 3.5 Gli algoritmi di ricerca in un array Gli algoritmi di ricerca risolvono il problema di cercare un elemento x all’interno di un vettore a di n elementi. 3.5.1 Ricerca lineare Il metodo più elementare è quello della ricerca lineare. Questo algoritmo restituisce il più piccolo valore di i per cui a[i] = x oppure, qualora l’elemento cercato non sia presente, il valore di i restituito è pari a n . int i=0; bool trovato=false; while(i<n && !trovato) { if(a[i]==x) trovato=true; else i++; } In C e in C++ questo algoritmo potrebbe anche essere espresso in modo molto più compatto tramite un unico ciclo for. for(i=0;i<n && a[i]!=x;i++) ; In questo caso alla fine del ciclo for la variabile i assume un valore minore di n se l’elemento da cercare è stato trovato. La ricerca lineare ha complessità nel caso peggiore, che si verifica quando l’elemento non c’è oppure si trova all’ultimo posto, pari a O(n), infatti vengono effettuati al più n confronti. 3.5.2 Ricerca binaria Se il vettore a è ordinato in senso crescente si può usare un algoritmo più efficiente, chiamato ricerca binaria (o dicotomica). int s=0,d=n-1,m; bool trovato=false; do { m=(s+d)/2; if(a[m]==x) trovato=true; else if(a[m]>x) { d=m-1; } else { s=m+1; } } while(s<=d && !trovato); 17 Nella ricerca binaria la ricerca viene effettuata su una parte del vettore, detta parte valida, delimitata dai due indici s e d. All’inizio la parte valida corrisponde all’intero vettore. Ad ogni passo la parte valida del vettore viene suddivisa in due parti uguali. Se l’elemento cercato è minore dell’elemento centrale, la ricerca continua nella parte di sinistra, se è maggiore dell’elemento centrale, la ricerca continua nella parte di destra, infine se è uguale, la ricerca termina con successo. La ricerca termina con insuccesso quando la parte valida non può essere più ulteriormente suddivisa, cioè quando è composta da un solo elemento. La complessità nel caso peggiore è O(log2 n), in quanto log2 n è il numero massimo di volte in cui un vettore di n elementi può essere dimezzato fino ad arrivare ad un solo elemento. Questo è ovviamente il caso peggiore, sia per la ricerca con successo, che per la ricerca con insuccesso. 3.6 Gli algoritmi di ordinamento Gli algoritmi di ordinamento cercano una permutazione degli elementi di un vettore di n elementi ordinabili1 in modo che gli elementi cosı̀ permutati siano in ordine crescente (o descrescente). 3.6.1 Ordinamento a bolle (Bubblesort) L’ordinamento a bolle è molto semplice, ma non molto efficiente. Si basa sul seguente principio: se tra due elementi consecutivi, il primo è maggiore del secondo, allora questi due elementi sono sicuramente fuori posto e, come minimo, bisogna scambiarli fra di loro. Questo controllo deve essere ripetuto più volte, infatti l’effetto di una singola passata che controlla ogni elemento con il precedente e in caso sfavorevole li scambia è di garantire che l’elemento più grande sia messo al posto giusto, cioè all’ultimo posto dell’array. Una seconda passata sarà in grado di portare il secondo elemento più grande al penultimo posto. Si noti che il confronto tra il penultimo e l’ultimo è inutile in quanto l’ultimo è il più grande. In generale occorreranno n − 1 passate, ognuna delle quali controllerà sempre meno elementi: for(i=0;i<n-2;i++) { for(j=0;j<n-i;j++) { if(a[j]>a[j+1]) { temp=a[j]; a[j]=a[j+1]; a[j+1]=temp; } } } Il numero di scambi è fisso e pari a (n − 1) + (n − 2) + . . . + 1, cioè quadratico in n. Il numero di scambi nel caso peggiore è anch’esso quadratico in n. L’algoritmo può essere migliorato osservando che, se dopo un intero ciclo for interno non si sono fatti scambi, allora il vettore è già ordinato e si può uscire dal ciclo esterno (che diventa un ciclo do–while). i=0; bool scambia; do { scambia=false; 1 cioè appartenenti ad un insieme in cui è definita una relazione d’ordine 18 for(j=0;j<n-i;j++) { if(a[j]>a[j+1]) { temp=a[j]; a[j]=a[j+1]; a[j+1]=temp; scambia=true; } } i++; }while(scambia); 3.6.2 Ordinamento per selezione Questo secondo algoritmo di ordinamento si basa su questo semplice principio: l’elemento più piccolo in un vettore ordinato sta al primo posto, il secondo elemento più piccolo sta al secondo posto, ecc. Da questa idea nasce il seguente algoritmo. Si trova l’elemento più piccolo del vettore e lo si mette al primo posto, mettendo l’elemento che si trovava al primo posto nella posizione originaria in cui si trovava l’elemento più piccolo, in pratica li si scambia di posto. Poi si trova il secondo elemento più piccolo e lo si scambia con il secondo elemento del vettore. E’ facile trovare il secondo elemento più piccolo, in quanto sarà l’elemento minore del vettore tranne il primo elemento (che contiene l’elemento più piccolo). Generalizzando si ottiene il seguente codice for(i=0;i<n-1;i++) { jmin=i; for(j=i+1;j<n;j++) if(a[j]<a[jmin]) temp=a[i]; a[i]=a[jmin]; a[jmin]=temp; } jmin=j; E’ uno degli algoritmi più efficienti, perchè fa solo n − 1 scambi. Purtroppo anche questo algoritmo ha bisogno di un numero quadratico di confronti. 3.6.3 Quicksort Nel quick–sort si divide il vettore da ordinare in due parti scegliendo un elemento discriminatore (detto pivot) mettendo in una parte gli elementi minori del pivot e dall’altra quelli maggiori. Poi si ordinano ricorsivamente le due parti e tutto il vettore risulta ordinato, dato che ogni elemento della prima parte è minore di ogni elemento della seconda. Anche in questo caso la parte di vettore in cui l’algoritmo opera è delimitata dai due indice s e d. void quicksort(int a[], int s, int d) { if(s<=d) { int i,j; int x; i=s; j=d; x=... // elemento pivot scelto in qualche modo 19 do { while (a[i]<x) { i++; } while (a[j]>x) { j--; } if(i<=j) { int temp=a[i]; a[i]=a[j]; a[j]=temp; i++; j--; } } while(i<=j); quicksort(a,s,j); quicksort(a,i,d); } } La scelta dell’elemento pivot è cruciale. Una scelta pessima è quella del minimo o del massimo elemento del vettore, perchè si dimostra che in questo caso l’algoritmo usa un numero quadratico di operazioni. Una scelta ottima sarebbe la mediana del vettore, ma poichè trovare la mediana è difficile non è possibile usarla. Un’alternativa è quella di prendere tre elementi a caso e calcolare la mediana solo di questi tre. Di solito si prende un elemento fisso (ad esempio il primo o l’ultimo o quello centrale), o un elemento scelto a caso o, se i dati sono numerici, la media del vettore o di alcuni elementi, ad esempio tra il primo e l’ultimo. Tante altre scelte sono possibili. Si dimostra comunque che, scegliendo un pivot non in modo pessimo, il quick–sort è veloce in media quanto il miglior algoritmo teorica, il merge–sort, avendo una complessità di O(n log2 n). Ciò spiega il nome e la notevole velocità dimostrata nelle prove empiriche. 20 Capitolo 4 I puntatori e le variabili dinamiche 4.1 Introduzione In questo capitolo saranno introdotti i concetti di puntatore e di variabile dinamica. Questi concetti saranno di cruciale importanza per l’implementazione delle strutture dati dinamiche, introdotte nel capitolo successivo. 4.2 4.2.1 I puntatori Definizione di variabili puntatori e indirizzi Una variabile usualmente contiene un dato, sia esso un numero, una stringa, un valore di verità, ecc. Una variabile V , essendo una zona di memoria, ha un proprio indirizzo, corrispondente all’indirizzo della prima cella di RAM assegnata a V . Per estrarre l’indirizzo di una variabile si usa l’operatore prefisso &, ad esempio per trovare quello di V si usa &v. Un indirizzo è essenzialmente un numero intero senza segno, la cui grandezza dipende dal sistema operativo, dal processore e dalla quantità di RAM; normalmente è a 32 bit. E’ possibile creare delle variabili che contengono gli indirizzi di altre variabili o, in generale, di dati presenti in memoria. Questo nuovo tipo di variabili, detto puntatore, consente di raggiungere in modo indiretto un dato all’interno della memoria. Non esistono in C e in C++ variabili puntatore generiche, è possibile creare solo puntatori specializzati a contenere indirizzi di variabili o di dati di un determinato tipo di dati T e non di altri tipi di dati. Per dichiarare che la variabile p è un puntatore di tipo T si usa la sintassi T *p, ad esempio per dichiarare che p1 è un puntatore a int, p2 e p3 sono puntatori a double e p4 è un puntatore a bool si usa int *p1; double *p2,*p3; bool *p4;. La variabile p1, per esempio, può contenere l’indirizzo di un int ed è possibile assegnargli l’indirizzo di una variabile int: p1=&v; Con questo comando p1 contiene l’indirizzo di v e si dice che p1 “punta” a v. Se successivamente si dichiara un secondo puntatore a int di nome p2, posso assegnare a p2 l’indirizzo contenuto in p1 con 21 int *p2; p2=p1; Ora p2 e p1 contengono lo stesso indirizzo, cioè quello della variabile v. E’ possibile assegnare ad una variabile puntatore di qualsiasi tipo un valore particolare, indicato in C e C++ con zero (0), che non corrisponde ad alcun indirizzo valido. Il valore 0 indica che la variabile puntatore non “punta” a niente. 4.2.2 Accesso indiretto Dato un puntatore p di tipo T che punta ad una variabile v, è possibile accedere in modo indiretto al dato contenuto in v mediante la notazione *p. Detto in altri termini, se p punta ad una variabile v, cioè contiene l’indirizzo di v, allora nelle espressioni e negli assegnamenti *p è equivalente a v. *p è come se fosse una specie di ”secondo nome” di v. Ovviamente l’equivalenza tra *p w v cade nel momento in cui in p viene memorizzato un altro indirizzo. Il modo più corretto per interpretare *p è perciò ”il dato il cui indirizzo è memorizzato in p”. Alcuni esempi possono chiarire meglio la situazione. int a,b; double x,y; a=1; b=2; int *p1,*p2; p1=&a; // p1 punta ad a cout << *p1 << endl; // scrive 1 p2=p1; // p2 punta ad a cout << *p2 << endl; // scrive 1 *p2=5; cout << a << " " << *p1 << " " << *p2 << endl; // scrive 5 5 5 p2=&b; cout << *p1 << " " << *p2 << endl; // scrive 5 2 *p1=*p2+1; cout << *p1 << " " << *p2 << endl; // scrive 3 2 cout << a << " " << b << endl; // scrive 3 2 In conclusione con un puntatore si può operare a due livelli diversi: direttamente sull’indirizzo (la modalità che si ottiene usando il puntatore senza asterisco) e indirettamente sul dato puntato (che si ottiene mettendo l’asterisco davanti al puntatore). 4.2.3 Puntatori a strutture Supponiamo di dichiarare un puntatore di tipo struct struct punto { int x,y}; punto p; punto *pp; pp=&p; 22 per accedere ad un campo di p tramite il puntatore pp bisognerebbe scrivere (*pp).x e (*pp).y. E’ possibile usare una sintassi semplificata per accedere ai campi di una struttura tramite puntatore mediante l’operatore ->, ad esempio (*pp).x si abbrevia con pp->x e (*pp).y con pp->y. 4.3 Variabili dinamiche In molti linguaggi di programmazione è possibile creare delle variabili dinamiche le quali, a differenza di quelle usuali, possono essere create e distrutte a discrezione del programmatore, mediante comandi propri del linguaggio. Le variabili usuali sono infatti create e distrutte in modo automatico con tempi decisi implicitamente dal programmatore: le variabili locali sono create nel momento in cui il blocco in cui sono dichiarate entra in esecuzione e sono distrutte quando l’esecuzione del blocco termina. Con le variabili dinamiche è quindi possibile creare a proprio piacimento un numero illimitato di variabili e poi distruggerle una volta che non servono più. L’altra grande differenza è che le variabili dinamiche non hanno un nome, come invece hanno le variabili usuali. Infatti i comandi per creare e distruggere una variabile dinamica funzionano mediante puntatori. Per il resto le variabili dinamiche si comportano come le variabili usuali, in quanto sono in grado di memorizzare un valore di un determinato tipo, che poi può essere letto e utilizzato in un’espressione e che può essere cambiato mediante un’istruzione di assegnamento. Per creare una variabile dinamica di tipo T si ha bisogno di un puntatore di tipo T a cui si assegna il risultato dell’operazione new T. Ad esempio per creare e gestire delle variabile dinamiche di tipo int int *p1,*p2,*p3; p1=new int; // crea una variabile dinamica p2=new int; // crea una variabile dinamica p3=new int; // crea una variabile dinamica *p1=3; *p2=4; *p3=5; cout << *p1 << " " << *p2 << " " << *p3 << endl; // scrive 3 4 5 *p1=*p2+3; cout << *p1 << endl; // scrive 7 L’accesso ad una variabile dinamica può essere quindi effettuato solo tramite un puntatore che contiene il suo indirizzo. E’ ovvio che è possibile far sı̀ che due o più puntatori contengano l’indirizzo della stessa variabile dinamica. E’ da notare inoltre che se l’indirizzo di una variabile dinamica viene perso, ossia non ci sono più puntatori che lo contengano, quella variabile non è più raggiungibile e quindi utilizzabile. Nel linguaggio Java in questa situazione la variabile viene distrutta automaticamente dal sistema con una modalità chiamata garbage collection. In C++ tale situazione è irrimediabile: le variabili dinamiche non più utilizzate possono essere distrutte solo mediante l’istruzione delete applicata ad un puntatore che ne contiene l’indirizzo. Si noti che con l’uso delle variabili dinamiche è possibile creare una variabile in un sottoprogramma e utilizzarla in un altro sottoprogramma, a patto di restituire l’indirizzo, ad esempio mediante un parametro di tipo puntatore. Nell’esempio seguente void crea_variabile(int* &p) { p=new int; *p=5; } 23 int main() { int *punt; crea_variabile(punt); cout << *punt << endl; delete punt; } viene creata nel sottoprogramma una variabile dinamica che poi viene utilizzata e distrutta in main. Una differenza ulteriore tra variabili dinamiche e variabili usuali è che le prime sono localizzata in una zona della memoria centrale, detta Heap, mentre le seconde si trovano in un’altra zona, detta Stack. La differenza consiste principalmente nel modo in cui le variabili sono create e distrutte: nello stack si utilizza una politica molto semplice (Last In–First Out) in cui le ultime variabili create sono le prime ad essere distrutte. Infatti in virtù delle regole indotte dal linguaggio C++ (simili a molti altri linguaggi), quando si entra in un blocco le variabili locali al blocco vengono create e, appena termina, vengono distrutte. La gestione dello heap è più complessa in quanto viene a mancare la corrispondenza temporale tra ordine di creazione e di distruzione. 4.4 Array dinamici Tramite i puntatori ed il comando new è possibile creare variabili dinamiche di tipo array, dette array dinamici. La particolarità degli array dinamici è che è possibile definire la dimensione dell’array a tempo di esecuzione, anzichè solo a tempo di compilazione come si è costretti a fare con gli array tradizionali. La sintassi del comando new per creare un array dinamico è new T[dim] in cui T è il tipo degli elementi e dim è il numero degli elementi dell’array. L’array cosı̀ creato può essere gestito memorizzando in una variabile di tipo puntatore a T l’indirizzo restituito da new e utilizzando il puntatore come se fosse il nome di un array usuale, cioè utilizzando la solita notazione con l’indice. Nell’esempio seguente si crea un array dinamico di interi, la cui dimensione viene letta da tastiera, che poi viene utilizzato e infine distrutto. int *vett,n; cout << "Inserisci n "; cin >> n; vett=new int[n]; for(i=0;i<n;i++) { vett[i]=i; } for(i=0;i<n;i++) { cout << vett[i] << " "; } delete vett; 24 Capitolo 5 Le strutture dati dinamiche 5.1 Introduzione In questo capitolo verrà introdotto il concetto di struttura dati dinamica e saranno descritte due strutture dati dinamiche elementari: le liste e gli alberi. Le strutture dati dinamiche intendono superare i difetti delle strutture dati usuali, definite mediante i costrutti di array e di struct, utilizzando le variabili dinamiche. In particolare le strutture dati dinamiche sono strumenti che consentono di memorizzare una collezione di dati, normalmente omogenei, in modo da rendere il più efficiente possibile sia l’occupazione di memoria, sia il tempo necessario per svolgere le operazioni di inserimento, di cancellazione, e di ricerca di un elemento e di scansione dell’intera struttura. L’uso di un array per memorizzare una collezione di elementi diventa particolarmente restrittivo quando il numero di elementi varia considerevolmente con il tempo, in quanto l’array può essere dimensionato solo a tempo di compilazione (per gli array tradizionali) o a tempo di esecuzione al momento della creazione (per gli array dinamici), ma non è possibile aumentare o ridurre successivamente il numero di elementi. L’unica cosa possibile è quella di utilizzare un array dimensionato con il massimo numero possibile di elementi in modo che sia sempre possibile trovare spazio per nuovi inserimenti, con l’enorme svantaggio che le posizioni non occupate occupano comunque spazio in memoria. Un altro problema degli array è dovuto alla loro struttura rigida: gli elementi di un array sono memorizzati in modo consecutivo in memoria. Per inserire un elemento in mezzo ad un array bisogna spostare fisicamente ogni elemento successivo al punto di inserimento verso il fondo dell’array per far posto al nuovo elemento. Mentre per cancellare un elemento, senza che sia lasciata vuota la posizione che occupa, bisogna spostare ogni elemento successivo verso l’inizio dell’array. D’altro canto, se l’array è mantenuto ordinato, allora la ricerca è molto veloce, in quanto è possibile usare la ricerca binaria. In ogni caso la scansione lineare è molto semplice in quanto, per passare da un elemento al successivo, basta incrementare di uno l’indice con cui si scorre l’array. 5.2 Le liste Le liste costituiscono una delle soluzioni più semplici ai problemi elencati nella sezione precedente. In particolare le liste hanno i vantaggi di richiedere un’occupazione di memoria proporzionale al numero di elementi effettivamente presenti nella struttura e di consentire inserimenti e cancellazioni di elementi in posizioni arbitrarie senza la necessità di spostare fisicamente gli elementi. 25 Una tale struttura dati è ottenuta creando un insieme di nodi, ognuno dei quali ha una parte per memorizzare un elemento e una parte di collegamento con gli altri nodi. I nodi sono collegati tra di loro in modo da poter essere memorizzati in punti diversi della memoria centrale (quindi in modo da facilitare inserimenti e cancellazioni) e in modo che sia possibile percorrere, almeno in un certo ordine, tutto l’insieme. I nodi sono variabili dinamiche, di modo che sia possibile crearne e distruggerne a piacimento senza essere vincolati, come negli array, ad un numero massimo di elementi. La realizzazione pratica del collegamento è tramite puntatore: un nodo n è collegato ad un nodo n0 se in n è memorizzato l’indirizzo di n0 . Il modo più semplice per organizzare una lista è quella della lista lineare unidirezionale e, poichè parleremo solo di questo tipo di liste, d’ora in avanti useremo il termine lista per indicare questo tipo particolare. In tali liste ogni nodo è collegato solo al nodo successivo, tranne l’ultimo elemento della lista, per il quale non si può parlare ovviamente di successivo. Ogni nodo sarà quindi una struct del tipo struct nodo { TIPO key; nodo* next; }; typedef nodo* lista; in cui TIPO è un qualunque tipo di dato utilizzabile in C++: int, double, string, . . . . Si dirà rispettivamente lista di int, di double . . . una lista i cui nodi hanno come TIPO int, double, . . . . Il campo next di un nodo contiene quindi l’indirizzo del nodo successivo a n (quindi next è di tipo puntatore a nodo); per l’ultimo elemento si memorizzerà 0 nel campo next. Per gestire una lista siffatta è necessario memorizzare solo l’indirizzo del primo nodo, che si chiama testa della lista, in un puntatore opportuno. L’ultimo elemento della lista verrà chiamato coda della lista. Per indicare che una lista è vuota, si memorizzerà 0 come indirizzo della testa della lista. La lista di interi (7, 4, 9, 2, 6) può essere rappresentata in forma grafica nel seguente modo 7 s - 4 s - 9 s - 2 s - 6 / in cui il collegamento è disegnato con un arco che lega il nodo al successivo e l’ultimo elemento ha una barra al posto dell’arco. In memoria la lista sarà rappresentata da 5 nodi. Supponendo che ogni nodo occupi 8 byte (4 per il campo key e 4 per il campo next) e che la memoria abbia celle di 4 byte l’una, una possibile situazione potrebbe essere la seguente 26 indirizzo valore 1000 2 1004 1024 1008 1012 1016 7 1020 1032 1024 6 1028 0 1032 4 1036 1044 1040 1044 9 1048 1000 La testa della lista è all’indirizzo 1016, il secondo elemento è all’indirizzo 1032, . . . , l’ultimo elemento si trova all’indirizzo 1024. Le celle vuote sono inutilizzate. 5.3 Algoritmi di gestione delle liste In questa sezione vedremo come svolgere le principali operazioni di gestione di una lista. Si farà riferimento a liste di interi, cioè TIPO è int. Con cambiamenti si possono adattare tutte le procedure al caso di liste di altri tipi di elementi. In ogni operazione sarà utilizzato un parametro di tipo lista, cioè puntatore a nodo, contenente l’indirizzo della testa della lista. 5.3.1 Inserimento all’inizio di una lista L’inserimento in testa significa inserire un nuovo elemento alla testa della lista. E’ un’operazione molto semplice. Questo sottoprogramma deve modificare la testa della lista e quindi la testa deve essere passata come parametro per riferimento. void ins_testa(lista &testa, int x) { nodo *nuovo; nuovo=new nodo; nuovo->next=testa; nuovo->key=x; testa=nuovo; } Un modo alternativo di implementare questa operazione è quello di usare della funzione, che restituisce la testa come risultato. nodo* ins_testa(lista testa, int x) { nodo* nuovo; nuovo=new nodo; nuovo->key=x; nuovo->next=testa; 27 return nuovo; } 5.3.2 Scansione Un modo per poter accedere in successione a tutti gli elementi di una lista (si dice che gli elementi vengono “visitati”) è quello di utilizzare un puntatore di sccorimento che, a turno, conterrà l’indirizzo dei vari elementi. L’indirizzo di partenza sarà quello della testa, mentre per passare da un elemento al suo successivo si dovrà aggiornare il puntatore di scorrimento assegnandogli il contenuto del campo next dell’elemento corrente. La scansione avrà termine quando il puntatore di scorrimento conterrà 0, infatti questa possibilità si verifica quando si tenterà di passare all’elemento successivo quando l’elemento corrente è la coda della lista. nodo *q; q=testa; while(q!=0) { // visita il nodo q q=q->next; } Un modo più compatto è quello di usare un ciclo for al posto del ciclo while. nodo* q; for(q=testa;q!=0;q=q->next) { // visita il nodo q } Ad esempio nel caso in cui ogni nodo visitato deve essere visualizzato a video si ottiene void stampa(lista testa) { nodo* q; for(q=testa;q!=0;q=q->next) cout << q->key << " "; } 5.3.3 Inserimento in fondo L’inserimento in coda significa inserire un nuovo elemento all’ultimo posto della lista. Per inserire in fondo bisogna arrivare all’ultimo elemento e attaccarci il nuovo elemento. Per far funzionare il sottoprogramma nel caso che la lista sia vuota, basta semplicemente richiamare ins inizio qualora si verifichi questo caso. void ins_coda(lista &testa, int x) { if(testa==0) ins_testa(testa,x); else { nodo *nuovo, *p; for(p=testa;p->next!=0;p=p->next) ; // p punta all’ultimo elemento nuovo=new nodo; 28 nuovo->key=x; nuovo->next=0; p->next=nuovo; } } Il numero di operazioni necessarie ad inserire in coda ad una lista che ha già N elementi è proporzionale a N , al contrario di quanto avviene con l’inserimento in testa, che usa sempre solo quattro operazioni. 5.3.4 Inserimento dopo un dato nodo Dato un nodo p, l’inserimento di un nuovo elemento dopo p è molto facile: void ins_dopo(nodo *p, int x) { nodo *nuovo; nuovo=new nodo; nuovo->key=x; nuovo->next=p; p->next=nuovo; } Può essere utile farsi restituire il nuovo nodo inserito, trasformando la procedura in funzione nodo* ins_dopo(nodo *p, int x) { nodo *nuovo; nuovo=new nodo; nuovo->key=x; nuovo->next=p->next; p->next=nuovo; return nuovo; } 5.3.5 Inserimento prima di un dato nodo Invece l’inserimento prima di un dato nodo p non è cosı̀ facile in quanto ci vorrebbe l’indirizzo dell’elemento precedente a p. Però è possibile usare il trucco di inserire un nuovo nodo dopo p contenente la chiave di p e di utilizzare il nodo p per memorizzarci l’elemento da inserire. void ins_prima(nodo *p, int x) { int t=p->key; p->key=x; ins_dopo(p,t); } 5.3.6 Lunghezza Per calcolare la lunghezza di una lista si deve sempre usare un ciclo for che scorre la lista contando il numero di elementi presenti. 29 int lunghezza(lista testa) { nodo* q; int conta=0; for(q=testa;q!=0;q=q->next) conta++; return conta; } 5.3.7 Ricerca Per cercare un elemento x in una lista bisogna necessariamente usare la ricerca di tipo lineare. La funzione restituisce il puntatore al nodo che contiene l’elemento, oppure 0 se l’elemento non è presente. nodo* cerca(lista testa,int x) { nodo* q; for(q=testa; q!=0 && q->key!=x;q=q->next) ; return q; } 5.3.8 Cancellazione Per cancellare un elemento da una lista bisogna trovarlo all’interno della lista e “scavalcarlo”. Se n è il modo da cancellare, bisognerà connettere il nodo precedente a n al nodo successivo a n. Supponiamo per semplicità che l’elemento sia presente nella lista, altimenti non avrebbe senso cancellarlo (del resto basta solo aggiungere alcuni controlli per gestire correttamente questa eventualità). Poichè se si cancella il primo elemento la testa viene modificata, la testa deve essere passata come parametro per riferimento. void elimina(lista &testa, int x) { nodo *p,*q; if(testa->key==x) { // e’ il primo elemento q=testa->next; delete testa testa=q; } else { for(q=testa;q->key!=x;q=q->next) p=q; p->next=q->next; delete q; } } 5.3.9 Lettura da tastiera Per leggere una lista da tastiera si devono inserire nella lista gli elementi nell’ordine in cui sono digitati da tastiera e ciò comporta che si dovrebbe utilizzare l’inserimento in coda. Supponiamo che l’utente inserisca il numero 99999, rappresentato con la costante FINE, per la fine dei numeri. void leggi(lista &testa) { 30 testa=0; const int FINE=99999; int x; do { cin >> x; if(x!=FINE) ins_coda(testa,x); } while(x!=FINE); } Per evitare di usare ins coda si può mantenere un puntatore all’ultimo elemento della lista void leggi(lista &testa) { testa=0; nodo* ultimo=0; const int FINE=99999; int x; do { cin >> x; if(x!=FINE) { if(ultimo==0) { ins_testa(testa,x); ultimo=testa; } else ultimo=ins_dopo(ultimo,x); } } while(x!=FINE); } 5.3.10 Copia Per ottenere una copia di una lista si può usare un procedimento simile a quello visto per la lettura da tastiera lista copia(lista testa) { nodo *p,*nuova_testa=0, *ultimo=0; for(p=testa;p!=0;p=p->next) if(ultimo==0) { ins_testa(nuova_testa,p->key); ultimo=nuova_testa; } else ultimo=ins_dopo(ultimo,p->key); return nuova_testa; } 5.3.11 Inserimento in una lista ordinata Una lista ordinata è una lista in cui ogni elemento è minore o uguale del successivo. L’inserimento in una lista ordinata non può essere fatta nè in testa nè in coda, ma nel punto corretto in modo da mantenere ordinata la lista. Ad esempio se si vuole inserire 8 nella lista 1 s - 5 s - 11 31 s - 24 s - 31 / lo si deve inserire tra il 5 e l’11. Una procedura per l’inserimento in una lista ordinata è la seguente void ins_ord(lista &testa,int x) { if(testa==0 || testa->key>x) ins_testa(testa,x); else { nodo *p,*q; for(q=testa;q!=0 && q->key<x;q=q->next) p=q; ins_dopo(p,x); } } La ricerca in una lista ordinata può essere più veloce quando l’esito è negativo: se si trova un elemento maggiore di quello cercato, la ricerca può essere terminata senza esaminare gli altri elementi. 5.4 5.4.1 Algoritmi di gestione degli alberi binari Definizioni Diamo ora alcune utili definizioni. Definizione 1 Un albero binario è l’insieme vuoto oppure è un nodo collegato a due alberi binari disgiunti, detti sottoalbero di sinistra e sottoalbero di destra. Ogni nodo è etichettato con un valore, ad esempio un numero intero. Nella figura 5.4.1 si può vedere un esempio di albero binario. 10 4 7 3 11 2 8 1 9 Figura 5.1: Albero binario 32 Definizione 2 Una foglia di un albero binario è un nodo in cui il sottoalbero di sinistra e il sottoalbero di destra sono vuoti. Nell’esempio 9, 1 e 8 sono foglie. Definizione 3 Dato un nodo n, i discendenti sinistri di n sono i nodi appartenenti al sottoalbero di sinistra di n. Analogamente si definiscono i discendenti destri. Nell’esempio i discendenti sinistri di 4 sono 3, 2 e 9; i discendenti destri di 3 sono 2 e 9. Definizione 4 La radice di un albero è quel nodo che non è discendente di nessun nodo. La radice dell’albero nell’esempio è 10. Definizione 5 Dato un nodo n, si chiama figlio sinistro di n la radice del sottoalbero di sinistra di n. Analogamente si definisce il figlio destro. Il figlio sinistro di 7 è 11, il figlio destro di 3 è 2. Per ogni nodo n, esiste un unico percorso che partendo dalla radice e passando da un nodo ad uno dei due figli arriva a n. Il percorso da 10 a 2 è 10–4–3–2. Definizione 6 Dato un nodo n, si chiama livello del nodo il numero di nodi che esistono nel percorso dalla radice a n La radice 10 è di livello 1, i suoi due figli 4 e 7 sono di livello 2, i figli dei suoi figli 3, 11 e 8 sono di livello 3, ecc. Definizione 7 L’altezza di un albero è il livello più alto di ogni suo nodo (di ogni sua foglia) L’albero dell’esempio ha altezza 5, perchè il nodo di massimo livello è il 9, il cui livello è 5. Un risultato semplice da dimostrare è che un albero di altezza h ha al più 2h − 1 nodi. Alternativamente un albero con n nodi ha altezza almeno pari a log2 (n + 1). 5.4.2 Definizione della struttura dati Un albero binario può essere implementato in modo del tutto simile a come viene implementata una lista, con l’unica differenza che un nodo avrà due puntatori (chiamati left e right) che conterranno, rispettivamente, l’indirizzo del suo figlio sinistro e del suo figlio destro. struct nodo { TIPO key; nodo *left,*right; }; typedef nodo* albero; I nodi saranno sempre variabili dinamiche e sarà indispensabile memorizzare l’indirizzo della radice dell’albero. In tutte le prossime sezioni si farà riferimento ad alberi di interi, cioè il campo key è di tipo int. 33 5.4.3 Metodi di visita degli alberi Esistono tre principali metodi di visita di un albero binario: pre–order, in–order e post–order. Questi tre metodi differiscono nell’ordine in cui gli elementi dell’albero sono visitati. Nel metodo pre–order ogni nodo è visitato prima di ogni suo discendente. Nell’esempio sarebbero visitati nell’ordine i nodi 10,4,3,2,9,7,11,1,8. void pre_order(albero r) { if(r!=0) { cout << "visito " << r->chiave << endl; pre_order(r->left); pre_order(r->right); } } Nel metodo in–order ogni nodo è visitato dopo ogni suo discendente sinistro e prima di ogni suo discendente destro. Nell’esempio sarebbero visitati i nodi nell’ordine 3,9,2,4,10,11,1,7,8. void in_order(albero r) { if(r!=0) { in_order(r->left); cout << "visito " << r->chiave << endl; in_order(r->right); } } Nel metodo post–order ogni nodo è visitato dopo ogni suo discendente. Nell’esempio sarebbero visitati i nodi nell’ordine 9,2,3,4,1,11,8,7,10. void post_order(albero r) { if(r!=0) { post_order(r->left); post_order(r->right); cout << "visito " << r->chiave << endl; } } 5.4.4 Alberi binari di ricerca Un albero binario di ricerca è un albero binario in cui ogni elemento è maggiore o uguale di ogni suo discendente sinistro e minore o uguale di ogni suo discendente destro. Nella figura 5.4.4 si può vedere un esempio di albero binario di ricerca. Se si visita in forma in–order gli elementi di un albero binario di ricerca si ottengono gli elementi in ordine crescente. 5.4.5 Ricerca in un albero binario di ricerca La ricerca di un elemento x in un albero binario di ricerca è molto simile alla ricerca binaria in un vettore: partendo dalla radice, se l’elemento da cercare è più grande del nodo corrente si scende a destra, se è piccolo 34 8 4 10 1 7 9 11 3 2 Figura 5.2: Esempio di albero binario di ricerca a sinistra. La ricerca si ferma quando si trova l’elemento o si arriva allo zero. La funzione restituisce il nodo contenente l’elemento da cercare, o 0 se l’elemento non c’è. La versione iterativa è nodo* cerca_abr(albero r,int x) { nodo* q; bool trovato; q=r; trovato=false; while(q!=0 && !trovato) { if(q->key==x) trovato=true; else if(q->key>x) q=q->left; else q=q->right; } return q; } Si noti che il numero di operazioni necessarie a ricercare un elemento è nel caso pessimo pari all’altezza dell’albero. Se si riesce a mantenere bilanciato l’albero, o almeno a non sbilanciarlo troppo si ottiene che la complessità è logaritmica nel numero dei nodi dell’albero, come in un array ordinato. 5.4.6 Inserimento in un albero binario di ricerca Per inserire un elemento in una albero binario di ricerca bisogna cercare il “posto giusto” ove inserirlo, in modo che dopo l’inserimento l’albero sia un albero binario di ricerca. Questa procedura non funziona se l’albero è vuoto. La versione iterativa è void ins_abr(albero radice,int x) { nodo *p,*q,*nuovo; 35 nuovo=new nodo; nuovo->key=x; nuovo->left=0; nuovo->right=0; q=radice; while(q!=0) { p=q; if(q->key>x) q=q->left; else q=q->right; } if(p->key>x) p->left=nuovo; else p->right=nuovo; } La complessità è identica a quella della ricerca, cioè dipendente dall’altezza dell’albero. 36