Appunti per il corso di Programmazione I AA 2005–06

annuncio pubblicitario
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
Scarica