Sei sulla pagina 1di 30

1A)OPERATORI LOGICI E BITWISE

Ecco il numero di byte che occupano in memoria le variabili del tipo:


char 1 byte range[-128,+127]
short 2 byte range[-32768,+32767]
int 4 byte range[-2147483648,+2147483647]
long 4 byte(32 bit)/8 byte(64 bit) range[-2^64,2^64-1]
float 4 byte
double 8 byte
long double 8 byte(32 bit)
puntatore 4 byte in sistemi a 32 bit

Ad una sequenza di bit può essere associato il valore di :

 vero se nella sequenza di presenta un valore !=0 o se il risultato è 1


 falso se nella sequenza si presenta il valore 0

Gli operatori bitwise sono operatori che agiscono sui singoli bit di una variabile di tipo intero
(=char,short int e long int),rispettivamente signed e unsigned. Essi sono:
"~" [tilde] Complemento a 1 (0=>1, 1=>0)//per scriverlo si fa alt+126
"<<" shift a sinistra di n bit
">>" shift a destra di n bit
"&" AND [la e commerciale '&' è detta ampersand]
"^" XOR(or esclusivo)
"|" OR (or inclusivo)

*ESEMPIO DI SHIFT*
A---->01000001

shift a sinistra A<<2 *01*000001 Alla fine avremo 00000100 i bit 0 e 1 all'estrema sinistra
escono fuori e i 2 bit vuoti a causa dello shift a sinistra vengono
riempiti con due zeri

shift a destra A>>1 0100000*1* Alla fine avremo 00100000 il bit 1 all'estrema destra esce
fuori e il bit vuoto a causa dello shift a destra viene riempito
con uno zero

*ESEMPIO DI ROTAZIONE*
V------>01100110

rotazione a destra di due W=Vrot>2 011001*10* Alla fine avremo 10011001 i due bit persi con
lo shift in realtà li ritroviamo nella parte opposta cioè nei bit più a sinistra.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


1B)ESEMPI DI APPLICAZIONE DEGLI OPERATORI BITWISE
Ecco alcune proprietà dei bitwise:

Gli operatori di shift ci consentono di effettuare moltiplicazioni e divisioni a nostro piacimento e in


particolare lo shift a destra(>>) ci permette di dividere rispetto allo shift a sinistra(<<) che invece ci
consente di moltiplicare.

X&1=X
X&0=0

X|1=1
X|0=X

X^1=~X (ora X viene invertito)


X^0=X (ora X rimane invariato)
Attraverso questa proprietà dello xor è possibile scambiare i valori di due variabili senza usarne una
terza di appoggio.

In C, per visualizzare i bit di qualsiasi tipo si usa il costrutto Union che presenta la stessa
allocazione di memoria per ciascun tipo di dato e quindi bisogna fare attenzione alla giusta
allocazione dei byte… Ad esempio union word_32_bit
{
long la; //32 bit
short sa[2]; //2 short fanno 32 bit
char ca[4]; //4 char fanno 32 bit
}

2A)RAPPRESENTAZIONE DEI NUMERI E CAMBIAMENTO DI BASE


La rappresentazione Posizionale di un numero è questa:
1936.27|10= 1*10^3+9*10^2+3*10^1+6*10^0+2*10^-1+7*10^-2

Le basi principali conosciute son queste:


base 2 con sole 2 cifre (0 e 1)
base 10 con le cifre (0,1,2,3,4,5,6,7,8,9)
base 16 con le cifre (0,1,2,3,4,5,6,7,8,9,A(10),B(11),C(12),D(13),E(14),F(15))

2B)OPERAZIONI ARITMETICHE MEDIANTE BITWISE


Quando vengono sommati dei numeri in base 2 occorre ricordare questa regola principale:
1+1 genera risultato 0 ma riporto 1
In C l’operazione della somma di due numeri in base 2 si fa attraverso l’operatore bitwise XOR e il
riporto si fa con l’AND.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


2C)SISTEMA ARITMETICO INTERO
Nel sistema aritmetico intero due sono i tipi principali di numeri e infatti abbiamo:

 Il tipo intero (I) di cui fanno parte la categoria char, short int, long int o int
 Il tipo reale-floating point (F) di cui fanno parte la categoria float, double e long double

Ricordiamo che con n bit dati in input, è possibile rappresentare solo 2^n possibili combinazioni
diverse.

*ARITMETICA MODULO m*
Nell’aritmetica modulo m l’insieme dei numeri naturali viene posto in corrispondenza con un
insieme a cardinalità finita, cioè costituito da un numero finito di numeri, più precisamente i numeri
che vanno da 0 a m-1. In particolare questa corrispondenza chiamata ɸ|m, ad ogni numero naturale
k gli associamo un'immagine ɸ|m(k)=k|modm. K|modm indica il resto della divisione intera per m
cioè il numero k viene diviso per m ottenendo quindi quoziente e resto ma viene prelevato solo il
resto. Il codominio è costituito dai numeri che vanno da 0 a m-1. Tutti questi numeri sono i
rappresentanti di classi di equivalenza dove ogni classe di equivalenza contiene tutti i numeri
naturali che divisi per m danno luogo allo stesso resto.
Classi di equivalenza…
[0]= {0,4,8,12,16,20,…}contiene tutti e soli i numeri naturali che danno resto 0 nella divisione per 4
quindi tutti i multipli di 4.
[1]= {1,5,9,13,17,21,…}contiene tutti e soli i numeri naturali che danno resto 1 nella divisione per 4
[2]= {2,6,10,14,18,22,…}contiene tutti e soli i numeri naturali che danno resto 2 nella divisione per
4
[3]= {3,7,11,15,19,23,…}contiene tutti e soli i numeri naturali che danno resto 3 nella divisione per
4

I tipi di rappresentazione usati dai computer sono i seguenti:

 Rappresentazione per segno e modulo (più vicina alla mentalità umana)


Si usa per il campo mantissa(parte decimale di un logaritmo in base 10) di un numero reale
floating-point.
C’è un bit dedicato al segno e i rimanenti bit per rappresentare il valore del numero.
Intervallo di rappresentazione(range) è simmetrico: [-((2^(n-1))-1);((2^(n-1))-1)]
 Rappresentazione per complemento a 2
Si usa per memorizzare i numeri interi con segno.
Range non simmetrico:[-2^(n-1);(2^(n-1))-1]
 Rappresentazione Biased
Si usa per il campo esponente di un numero reale floating point.
Range non simmetrico:[-(bias=(2^(n-1))-1;2^(n-1)]

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


2D)RAPPRESENTAZIONI PER COMPLEMENTO A 2, PER ECCESSO B
La rappresentazione per complemento a 2 su n bit di un intero si ottiene facendo:
[(2^n)+k]%2^n

Ricordiamoci che poi dal punto di vista della rappresentazione, il complemento a 2 di un numero
positivo coincide con il numero stesso, mentre il complemento a 2 di un numero negativo coincide
con (2^n)-k dove n e’ il numero di bit su cui voglio rappresentare e k e’ il numero che voglio
prendere in considerazione.

Si può calcolare il tutto anche con gli operatori bitwise per fare il complemento a 2:
1)si prende il numero e si complementa a 1 bit a bit
2)si somma 1 al risultato e poi si trova il resto modulo 2^n cioè [%2^n]

La rappresentazione biased si ottiene con questa formula:


k+B=k+(2^(n-1))-1 B(bias)=[(2^(n-1))-1)]

3A)SISTEMA ARITMETICO REALE FLOATING-POINT


La notazione scientifica è quella notazione più compatta che ci consente di rappresentare i numeri
reali mettendo al primo posto le cifre significative. Ricordiamo ovviamente che tutti gli zeri prima
della virgola non vengono presi in considerazione. Per fare la notazione scientifica occorre
effettuare la normalizzazione della mantissa, cioè spostare la virgola vicino alle cifre significative.
Per esempio: 300000000->normalizzo->3*10^8 dove 3 è la mantissa, 10 è la base di numerazione e
8 è l’esponente.
In base 2 la prima cifra della mantissa può essere solo 1 perchè lo 0 non viene considerato come
anche in presenza della base 10. La considerazione che la prima cifra debba essere sempre 1 ci porta
a introdurre il concetto di bit implicito: visto che il valore della prima cifra è noto è possibile non
rappresentarla esplicitamente. Per esempio: 110110000->[1]1011*2^(+1000) su 8 bit.

Il sistema aritmetico reale floating-point denota l’insieme dei numeri reali rappresentati in un
computer e presenta questi formati:
BASIC single 32 bit e double 64 bit
EXTENDED double 80 bit

Un numero viene rappresentato attraverso 3 campi principali secondo lo standard IEEE 754:

 Segno, che ci fa capire se c’è un numero negativo o positivo


 Esponente, che viene rappresentato come intero biased
 Mantissa che viene generata con lo schema del round to nearest
e il valore del numero inserito x si calcola dalla formula x=(-1)^s*[l.m]*2^(e-BIAS).
Dove s è il segno, l è il bit implicito che assume un valore convenzionale e m è la mantissa.

Per la rappresentazione di un numero in singola precisione(32bit), si da 1 cifra per il segno, 8 cifre


per l’esponente e 23 cifre esplicitamente rappresentate (24 a causa del bit implicito che non si
considera) per la mantissa.
Quindi in singola precisione: | 1 cifra(segno) | 8 cifre(esponente) | 23 cifre(mantissa) |
------------------------------------------------------------------------
In doppia precisione(64bit), si da 1 cifra per il segno, 11 cifre per il campo esponente e 52 per il
campo mantissa.
APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI
Quindi in doppia precisione: | 1 cifra(segno) | 11 cifre(esponente) | 52 cifre(mantissa) |
------------------------------------------------------------------------
In doppia doppia precisione, 1 cifra per il segno, 15 cifre per il campo esponente e 64 cifre per la
mantissa che in questo caso non presenta il bit implicito.
Quindi in singola precisione: | 1 cifra(segno) | 15 cifre(esponente) | 64 cifre(mantissa) |
------------------------------------------------------------------------

Ecco gli oggetti del Sistema aritmetico floating-point

 Numeri normalizzati che hanno bit implicito uguale a 1, mantissa maggiore o uguale a zero
e esponente compreso tra e|min ed e|max. Il loro valore è dato da questa formula:
x=(-1)^s*[l.m]*2^(e-BIAS)
 Infinito con segno che ha campo esponente massimo e mantissa nulla quindi senza bit
implicito. L’infinito rappresenta il particolare caso di overflow di un numero reale.
 NaN(not a number) è caratterizzato dal campo esponente massimo e mantissa diversa da
zero. NaN si ha quando si presenta un numero non valido. C’è la forma indeterminata +∞/-∞
 Zero con segno che ha valore dell’esponente pari al minimo e mantissa pari a zero.
 Numeri denormalizzati che hanno esponente pari al minimo e mantissa diversa da zero con
bit implicito pari a zero. Il loro valore è dato da questa formula:
x=(-1)^s*[l.m]*2^e-(BIAS+1).

3B)SISTEMA ARITMETICO STANDARD IEEE 754


Ad ogni numero reale rappresentabile viene associato il suo rappresentante floating-point mediante
uno schema di rounding(approssimazione). Ci sono 4 metodi di arrotondamento principali:

 Round to nearest(RN usato per default dal compilatore)


 Round toward 0 (RZ) che corrisponde al troncamento
 Round toward -∞(RM)
 Round towar +∞(RP)

-∞ <-----||---|---------||------------||------------||----------|---||---> +∞
| -X | 0 | +X |
* * * *
RN(x) RZ(x) RZ(x) RN(x)
RM(x) RP(x) RM(x) RP(x)

Lo schema del Round to nearest è l’arrotondamento in base a cui il valore del primo bit della mantissa da
eliminare [(il t+2-simo)] influenza la mantissa del numero inserito: se questo bit è 1 allora si aggiunge 1 al
bit meno significativo(ulp=Unit in the Last Place) della mantissa del numero che vogliamo considerare. Per
evitare errori, nel caso in cui la mantissa di x sia equidistante dalle mantisse di due numeri Floating Point
consecutivi, il Round to nearest la approssima con la mantissa che tra le due è pari.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


3C)ERRORI DI ROUNDOFF
L’errore di Roundoff può essere di due tipi principali:

 Statico, quando si prende in considerazione la rappresentazione in memoria


 Dinamico, quando si prende in considerazione il risultato di operazioni aritmetiche

Due sono poi le misure di errore principali, considerando x il valore esatto del numero considerato e
ẋ la sua approssimazione.

ERRORE ASSOLUTO: |x- ẋ| ci permette di avere cifre decimali corrette

ERRORE RELATIVO: |(x- ẋ)/x|<=ɛ/2 ci permette di avere cifre significative corrette

I tipi di accuratezza, poi, sono di due tipi principali:

 Accuratezza statica: |x-fl(x)|/|x| dipende dalla precisione del Sistema Aritmetico Floating-
point e dallo schema di rounding utilizzato.
 Accuratezza dinamica per averla c’è bisogno di 1 bit per rappresentare il bit implicito, 2
guard-bit oltre la mantissa e 1 sticky-bit cioè un bit che vale 1 se per esso transita almeno un
bit=1.
3D)ESEMPI DI ROUNDOFF
Ulp(a)=unit in the last place di a=si definisce come il minimo numero floating-point positivo che
sommato ad a fornisce un risultato maggiore di a, cioè da contributo ad a. Un valore minore non
riesce a dare alcun contributo alla somma. Questa cosa vale nel sistema aritmetico Floating-point
ma non vale nella matematica in generale.

In particolare dando 1 ad a, questo minimo numero prende il nome di epsilon macchina.


EPSILON MACCHINA (sp:singola precisione)=1.192093*10^-7.
EPSILON MACCHINA (dp:doppia precisione)=2.220446*10^-16
EPSILON MACCHINA (precisione long double)=1.165331*10^-317

Criterio di arresto naturale: Tutti i linguaggi di programmazione forniscono queste variabili


predefinite FLT_EPSILON e DBL_EPSILON che sono utili a evitare addizioni che non servono e
che costituiscono una perdita di tempo.

Un'altra cosa fondamentale da ricordare che rispetto alla matematica in cui vale la proprietà
associativa, nel Sistema aritmetico reale Floating-point questa proprietà non vale. Infatti in certi casi
può succedere che, [(a+b)+c]!=[a+(b+c)]. La causa è da farsi risalire all'ulp della somma parziale.
Poichè in certi casi le somme da eseguire sono tante e i termini sono molto piccoli se confrontati
alla somma parziale, è successo che la somma è diventata così grande che i termini non danno più
contributo alla somma parziale. Il problema si può evitare sommando a gruppi, ovvero
applicando l'algoritmo di raddoppiamento ricorsivo. Questo algoritmo funziona perchè le
componenti adiacenti tra loro che si sommano sono sempre dello stesso ordine di grandezza, e
quindi si garantisce la non perdita di significatività nell'addizione.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


L’algoritmo di HORNER è un algoritmo efficiente ed accurato per la valutazione di un polinomio.
La differenza più importante tra Horner e il metodo di valutazione tradizionale riguarda
principalmente la complessità computazionale, cioè il numero di operazioni elementari che si
esegue in un algoritmo. Principalmente si deve controllare il numero di moltiplicazioni e divisioni,
perchè "pesano" di più delle addizioni e sottrazioni. Quindi meno moltiplicazioni fa un algoritmo
meglio è!
Prendiamo un polinomio di grado n, l'algoritmo tradizionale del calcolo del polinomio impiega 2n
moltiplicazioni e n addizioni.
Invece l'algoritmo di Horner impiega n moltiplicazioni e n addizioni.

Ricordiamo che la somma di molti addendi può essere fatta o:

 In ordine crescente dal più piccolo al più grande


 In ordine decrescente dal più grande al più piccolo
Si vede che quando noi sommiamo i termini in ordine decrescente l’errore è sempre più grande
dell’errore ottenuto sommando in ordine crescente. Inoltre risulta costante e siamo caduti
nell’errore di scendere oltre l’ulp.

Infine come algoritmi per sommare molti addendi, ricordiamo:


-l’algoritmo di raddoppiamento ricorsivo che funziona così: le componenti adiacenti tra loro che si
sommano sono sempre dello stesso ordine di grandezza, e quindi si garantisce la non perdita di
significatività nell'addizione.
-l’algoritmo della somma a blocchi che, invece di sommare le componenti adiacenti dell’array,
somma quelle diametralmente opposte (simmetriche rispetto al centro).

4A)TIPO CARATTERE E STRINGA IN C


In C si deve fare una distinzione tra ‘a’ e “a” visto che:

 ‘a’= costante carattere = è un intero contenente il valore del codice ASCII del carattere
corrispondente [97].
 “a”= costante stringa = è una sequenza di più caratteri memorizzata come array di caratteri
in cui l’ultima componente contiene il carattere null(‘\0’=0) per indicare la fine stringa.
Array frastagliato per evitare spreco di memoria e quindi una maggiore efficienza di tempo e
spazio: array di puntatori a stringhe costanti.
La miglior gestione delle stringhe avviene con puntatori e non con matrici di caratteri.

Il modo più semplice per leggere un carattere da tastiera è getchar()


Il modo più semplice per visualizzare un carattere è tramite putchar()
Il modo più semplice per leggere una stringa da tastiera è gets() /*scanf(“%s”,&stringa)*/
Il modo più semplice per visualizzare una stringa è puts()

Fflush(stdin) è utile per pulire il buffer della tastiera poiché certe volte questa contiene caratteri
sporchi a causa di alcuni errori del programma.
La gestione di stringhe tramite puntatori può avvenire solo mediante allocazione dinamica, con
malloc.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Ecco alcune funzioni sulle stringhe importanti:
-strlen(ps)restituisce la lunghezza (senza contare il carattere di fine stringa) di ps
-strcpy(pt,ps)copia *ps in *pt compreso il carattere ‘\0’
-strcat(s1,s2)concatena a *s1 la stringa *s2
-strcmp(s1,s2)confornta *s1 e *s2 : restituisce un valore<0 se *s1<*s2
-strchr(pt,pc)restituisce un puntatore alla prima occorrenza del carattere *pc in *pt
-strstr(pt,ps)restituisce un puntatore alla prima occorrenza della stringa *ps in *pt
Attraverso la funzione free possiamo liberare lo spazio di memoria quando non ci serve più.

--*ANNOTAZIONE SUI PUNTATORI*--


Il puntatore è una variabile che contiene un indirizzo di memoria. Viene solitamente designato con
* che è detto operatore di indirezione e se lo vogliamo fare nel programma avremo per esempio:
void main()
{
Int a=5;//variabile che contiene 5
int *p=&a;//puntatore contenente l’indirizzo di a cioè dove è stato collocato il valore 5
//il puntatore in questo caso punta alla variabile a contenendo il suo indirizzo
//di memoria
}

4B)APPLICAZIONE ALLO STRING MATCHING


L’algoritmo naive dello string matching è quello di ricerca diretta: questo consiste nel confrontare
ogni carattere del testo con il primo carattere del pattern fino a quando il testo finisce. Ogni volta
che questi due caratteri sono uguali si avanza, sul testo e sul pattern, a confrontare i caratteri
successivi.
La complessità computazionale relativa a questo algoritmo è N*M confronti nel caso peggiore,
dove N indica la lunghezza del testo e M quella del pattern.

Tuttavia nel caso in cui il testo è altamente ripetitivo come le molecole del DNA, la ricerca è
estremamente lenta: quindi conviene usare algoritmi più veloci di ricerca.
Ecco gli algoritmi principali di string matching:

 Naive(ricerca diretta)
 Smart(automa a stati finiti)
 Knuth-Morris-Pratt
 Boyer-Moore
Nell’eliminazione di un pattern in un testo in modo dinamico, ricordiamo che vengono prese queste
nuove funzioni in considerazione:
-memcpy(dest,src,num_byte)copia il blocco di num_byte dalla posizione src alla posizione dest.
-memmove(dest,src,num_byte) sposta un blocco di num_byte dalla posizione src alla posizione
dest.
-memset(dest,ch,num_byte)assegna al blocco di num_byte dalla posizione dest il valore del byte
ch.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


5A)FUNZIONI PER L’ALLOCAZIONE DINAMICA
L’allocazione statica della memoria consente l’allocazione di tutto lo spazio di memoria per i dati di
un programma prima della sua esecuzione. Quindi l’occupazione di memoria è fissa.

L’allocazione dinamica della memoria consente la possibilità di allocare/deallocare spazio di


memoria in fase di esecuzione del programma. Quindi l’occupazione può variare secondo necessità.

Funzioni C per allocare memoria si trovano nella libreria stdlib.h e sono qui elencate:
-malloc(…)Alloca blocchi di memoria per un oggetto
-calloc(…)Alloca blocchi di memoria per un array di oggetti inizializzando gli elementi a 0
-realloc(…)Rialloca blocchi di memoria allocati prima con malloc o calloc.
-free(…)Libera/Dealloca blocchi di memoria.

La differenza principale tra malloc e calloc è questa:


malloc alloca un blocco di byte e non inizializza il contenuto e lascia il blocco di byte inalterato
calloc alloca un blocco di byte ed inizializza il contenuto. Oltre ad allocare inizializza a
zero il blocco di memoria allocato.

Il massimo size di un array dipende dal PC.


In generale, gli array dinamici possono raggiungere un size molto maggiore di quelli statici.

Ci sono principalmente 3 aree di memoria per allocare le variabili in C:

 Memoria automatica,detta “stack”


 Memoria dinamica, detta “heap”
 Memoria statica
Di queste 3 le memorie dinamica e statica possono occupare tutto lo spazio messo a disposizione
dal sistema operativo.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


D’altra parte l’ammontare delle memorie stack e heap varia durante l’esecuzione.
Considera questo schema:

La rappresentazione tipica della memoria di un processo in esecuzione (come un programma C)


consiste delle seguenti sezioni:
1.TEXT SEGMENT
2.INITIALIZED DATA SEGMENT
3.UNINITIALIZED DATA SEGMENT
4.HEAP
5.STACK
1Il text segment è una delle sezioni di un programma in un object file o in memoria. Contiene le
istruzioni eseguibili. E’ allocato in memoria al di sotto dell’area heap/stack per prevenire che un
overflow dell’heap/stack possa sporcarlo.
Solitamente un segmento text può essere condiviso in modo che solo una copia sia necessaria in
memoria per i programmi eseguiti frequentemente. Inoltre, spesso, esso è read-only, per prevenire
che un programma modifichi accidentalmente le sue istruzioni.
2Il segmento dati inizializzato è una porzione dello spazio degli indirizzi virtuali di un
programma. Contiene le variabili globali, le variabili static, const ed extern che sono inizializzate
nel programma. Il segmento dati non è tutto read-only: esso può essere ulteriormente suddiviso in
initialized read-only area e initialized read-write area.
3Il segmento dati non inizializzato contiene le variabili globali, static ed extern che sono
inizializzate dal kernel a 0 prima che il programma cominci l’esecuzione. In un object file questa
sezione non occupa spazio: l’object file distingue tra variabili inizializzate e non inizializzate per
efficienza di spazio; le variabili non inizializzate non occupano spazio su disco nell’object file. Il
segmento dati non inizializzato comincia alla fine del segmento dati inizializzato e contiene tutte le
variabili globali e quelle static che sono inizializzate a 0 o non hanno un’esplicita inizializzazione
nel codice sorgente.
4Il segmento heap è quello dove avviene l’allocazione dinamica della memoria. Esso comincia
alla fine del segmento dati non inizializzato e cresce verso la memoria alta. L’area heap è gestita
dalle funzioni malloc, calloc, realloc e free.
5L’area stack è adiacente all’heap e cresce nella direzione opposta; quando lo stack pointer
incontra l’heap pointer, la memoria libera si è esaurita.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Il segmento stack è usato per memorizzare tutte le variabili locali ed è usato per passare gli
argomenti alle funzioni e l’indirizzo di ritorno della istruzione che dev’essere eseguita dopo che la
funzione è finita. Le variabili locali hanno come portata il blocco in cui sono introdotte; esse sono
create quando il controllo entra nel blocco e sono cancellate quando il controllo esce dal blocco.
Anche tutte le chiamate di una funzione ricorsiva sono aggiunte allo stack. I dati sono allocati o
deallocati nello stack secondo la filosofia LIFO(Last input first output).

5B)ALLOCAZIONE DINAMICA DI MATRICI IN C


In C una matrice di due dimensioni del tipo A[m][n] è allocata in memoria per righe.
L’allocazione dinamica delle matrici cambia se si considerano righe o colonne.
Per righe: *(pa+i*n+j)
Per colonne:*(pa+j*m+i)
Dove pa indica il puntatore all’indirizzo base dell’array, cioè alla prima componente dell’array.
i*n= il numero di componenti che sicuramente precedono il valore che vogliamo trovare.
j*m=il numero totale degli elementi delle colonne che precedono l’elemento da ricercare.

Se io voglio aggiungere una riga ad una matrice m*n userò questa formula: (m+1)*n

Se invece voglio togliere una riga ad una matrice m*n userò questa formula: (m-1)*n

5C)LE MATRICI COME PARAMETRI DI SOTTOPROGRAMMI


Nel linguaggio C una matrice è memorizzata per righe e gli indici partono da 0.
In Fortran e Matlab una matrice è memorizzata invece per colonne e gli indici partono da 1.

Ecco come si fa il prodotto righexcolonne:


Ad esempio ho una matrice A[3][2] e una matrice B[3][4]. La matrice finale C avra’[2][4].

3 2 5 3 1 2 3
A= 5 2 7 B= 1 4 5 9
9 9 2 6

1riga-1colonna 1riga-2colonna 1riga-3colonna 1riga-4colonna


9+2+45=56 3+8+45=56 6+10+10=26 9+18+30=56
C=
2riga-1colonna 2riga-2colonna 2riga-3colonna 2riga-4colonna
15+2+63=80 5+8+63=76 10+10+14=34 15+18+42=75
Per il prodotto di 2 matrici si ricorre a questo metodo:

C|mxn=A|mxp*B|pxn dove il numero di colonne di A deve coincidere con il numero di righe di B.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Per calcolare il tempo di esecuzione di un blocco di istruzioni occorre usare la libreria del C time.h
Ci sono poi:
 Funzioni per il tempo poco accurate e sono:
-time(), difftime(), clock() e CLOCKS_PER_SEC [valide per tutti I sistemi operativi]
 Funzioni per il tempo accurate ai nanosecondi e sono:
-clock-gettime()--solo per Linux
-QueryPerformanceCounter()--solo per Windows

6)ALGORITMO KMP DI STRING MATCHING


Lo string matching è un particolare problema di ricerca che consiste nella determinazione
dell’eventuale presenza di un pattern p in un testo t. Il problema dello String Matching può essere di
vario tipo:
 Decisionale, se il pattern è presente o meno nel testo
 Quantitativo, se si vuole conoscere quante volte il pattern è presente nel testo
 Enumerativo, se si vuole sapere in quali posizioni si trova il pattern nel testo.
Ecco i principali tipi di algoritmi di string matching:
-Naive
-Smart(automa a stati finiti)
-Rabin-Karp
-Knuth-Morris-Pratt
-Boyer-Moore

Ricordiamo che la complessità computazionale dell’algoritmo Naive è nel caso peggiore


M(lunghezza pattern)*N(lunghezza testo) e nel caso medio N se il testo non si presenta di natura
ripetitiva e la distribuzione dei caratteri è uniforme.
Quest’algoritmo è comunque inefficiente di fronte a testi molto ripetitivi come la sequenza del
DNA.

Il KMP si comporta così: quando il confronto tra il carattere del testo e del pattern fallisce,
provocando un mismatch, invece che arretrare il puntatore sul pattern e sul testo, si possono
sfruttare le conoscenze sul pattern per evitare confronti il cui esito è già noto.

7A)GESTIONE DEI FILE SEQUENZIALI


La possibilità di avere accesso ai file è una funzionalità fondamentale in ogni piattaforma o
linguaggio di programmazione. In parole povere, un file può essere considerato una stringa scritta
su disco. Questo significa che le informazioni memorizzate in un file vengono mantenute nel
sistema anche quando il processo di esecuzione del programma termina o il computer viene spento.
Il file è dunque una “stringa scritta su disco”: questo non significa che è possibile scrivere solo
informazioni testuali. Un carattere è infatti rappresentato da un byte che contiene informazioni
binarie. In un file è quindi possibile scrivere qualsiasi cosa; in questa definizione, per stringa si
intende una qualsiasi sequenza di byte.
In C esistono due tipi di file:

 File binario che contiene delle informazioni così come appaiono in memoria, cioè senza
convertire in caratteri.
 File di testo che viene messo per default ed è un insieme di righe di caratteri: ogni riga
contiene 0 o più caratteri (al massimo 255) e termina con un carattere speciale, ad esempio
\n.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


In C un file viene aperto con l’istruzione fopen e viene chiuso con la funzione fclose
Le funzioni di lettura e scrittura di un file binario sono fread e fwrite.

Facciamo un’annotazione sull’input/output su file sia formattato che non formattato:


La differenza tra formattato e non formattato è che nel formattato bisogna specificare un formato
alla stessa maniera di ciò che facciamo con printf. Invece il non formattato prevede di leggere
automaticamente o un carattere o una stringa.
Getc e putc sono analoghe a getchar e putchar con la differenza che qui c’è un parametro che
individui il file oggetto del trasferimento. Analogamente il caso per le stringhe di caratteri.
Fgets inoltre prevede un valore che legge il numero di caratteri da leggere

INPUT OUTPUT
non formattato formattato non formattato formattato
getc() putc()
gets() fscanf() puts() fprintf()
fgets() fputs()

Modo di scrittura in C:
fscanf(FILE *, “formato”, variabili) fprintf(FILE *,”formato”,variabili)

singolo carattere stringa di carattere


int getc(FILE*) char *gets(char *)
int getchar(void) char *fgets(char *,int, FILE *)
int putc(intero,FILE *) int puts(char *)
int putchar(intero) int fputs(char *, FILE *)

Un file prima di essere usato va aperto specificando il modo in cui verrà usato. Ecco la sintassi del
C:
dichiarando precedentemente
FILE *fp ----->*fp è un puntatore ad una struttura contenente informazioni sul file. Serve per
indirizzare tutte le operazioni sul file.
fp=fopen(nome_file,modo) se modo=r //legge r+=legge e scrive
modo=w //scrive w+=legge e scrive
modo=a //apre un file in maniera a+=legge e scrive in coda
che sia possibile scrivere
in coda al contentuto attuale del file
Alla fine quando il file non ci serve più facciamo
fclose(fp), per chiuderlo.

Se vogliamo considerare un file binario nell’apertura fopen vicino al modo bisogna includere la
lettera b per indicare appunto il file binario. In C avremo:
fp=fopen(nome_file,”rb”)

Due funzioni importanti per l’input/output di un file binario sono fread e fwrite.

La funzione feof che mi serve per indicare la fine di un testo, ritorna 0(falso) quando il file non è
terminato e ritorna un valore !=0 quando il file è terminato.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


7B)STRING MATCHING SU FILE
In C è possibile creare in fase di esecuzione una stringa di cui non si conosce a priori la lunghezza,
gestendola tramite puntatore.
Esempio:
char *p_stringa=”Stringa costante”; che è uguale al dire char *p_stringa;
p_stringa=”Stringa costante”;

Attraverso l’allocazione dinamica, poi, si fa questo metodo:


-si stabilisce la lunghezza della stringa con len_str non valutando ‘\0’
-si alloca dinamicamente lo spazio per la stringa attraverso malloc
-si deve avere un puntatore per accedere alla stringa (p_str)
Ovviamente , se non c’è abbastanza memoria si ritornerà la scritta “memoria insufficiente” con
l’immediato arresto del programma.

8A)RICHIAMI SULLE PRINCIPALI STRUTTURE DINAMICHE


Un dato strutturato in C è un’insieme di istruzioni logicamente collegate e identificate da un unico
nome. Esempi di dati strutturati possono essere le informazioni relative ad :
-un utente Enel(nome-contratto-consumo-pagamenti)
-un mazzo di carte(seme-colore-valore).

Ogni tipo di dato strutturato stabilisce:


-se il numero di componenti è fisso, di tipo statico, o variabile, di tipo dinamico.
-il tipo di componenti
-le modalità di accesso alle componenti
-l’eventuale possibilità di inserimento/eliminazione di informazioni
-l’eventuale ordinamento delle componenti.

In C poi ci sono tipi di dati primitivi e derivati:


quelli primitivi sono gestiti dal linguaggio stesso e quelli derivati sono gestiti a carico del
programmatore.

Il tipo di dato primitivo più comune è l’array che presenta un numero di componenti fisso,
componenti dello stesso tipo e l’accesso alle componenti tramite indici.
Per aggiungere informazioni in un array ordinato, non è facilmente eseguibile: infatti, si deve avere
un array sovradimensionato per mettere la nuova informazione, e la nuova informazione deve essere
inserita in modo da mantenere l’array ordinato: ciò è possibile facendo slittare le altre componenti
per permettere lo spazio di inserire la nuova informazione. Le operazione da effettuare sono lunghe
e inefficienti!

Un altro tipo di dato primitivo è la struct il cui numero di componenti è fisso, le componenti dette
campi possono essere di tipo diverso e l’accesso alle componenti è diretto tramite nome.

8B)GENERALITA’ SULLE STRUTTURE DATI DINAMICHE


Le strutture dinamiche sono classificate in base al tipo di collegamento logico:
ci sono quelle lineari, gerarchiche e reticolari.
Quelle lineari, sono una struttura dati monodimensionale assimilabili alla forma dell’array.
Quelle gerarchiche sono una struttura dati bidimensionale con livelli gerarchiche e si accede ai vari
livelli solo attraverso il livello superiore.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Quelle reticolari sono strutture bidimensionali in cui non esiste una gerarchia e possiamo passare da
un’informazione ad un’altra purchè ci sia possibile accedere attraverso un ramo di collegamento.

 Le strutture dinamiche lineari, presentano un predecessore, un elemento corrente a cui segue


il successore. Da un punto di vista grafico avremo questa situazione:
___ ___ ___ ___ ___ ____ ___ ___
|___| --|___|-|___| --|___|-|___| --|___|- |___| --|___|
inizio fine struttura lineare aperta, le
informazioni si susseguono linearmente una dietro l’altra.

------------------------------------------------------------------------------------
| ___ ___ ___ ___ ___ ____ ___ ___ |
--|___| --|___|-|___| --|___|-|___| --|___|- |___| --|___|--
inizio
struttura lineare chiusa,
detta anche circolare in cui manca la fine.

Per eliminare l’elemento corrente, bisogna modificare il link del predecessore affinchè punti al
successore.
Per eliminare l’elemento in testa, bisogna modificare il link che punta all’inizio della struttura.
Per eliminare l’elemento in coda, bisogna eliminare il link che punta alla fine della struttura.

Per inserire un elemento dopo quello corrente, bisogna inerire il link che va dall’elemento nuovo al
successore e modificare il link dell’elemento corrente affinchè punti al nuovo elemento.
Per inserire un elemento in testa alla struttura bisogna inserire il link che va dall’elemento nuovo
alla testa della struttura e modificare il link alla testa affinchè punti al nuovo elemento.
Per inserire un elemento in coda, bisogna aggiungere il link che punta dalla fine della struttura al
nuovo elemento.

8C)PRINCIPALI STRUTTURE DINAMICHE LINEARI-PILA E CODA


La PILA detta anche stack, è una struttura lineare aperta in cui l’accesso alle componenti per
l’inserimento e l’eliminazione avvengono solo ad un estremo della struttura(detta testa della pila).
La pila è una struttura LIFO (LAST INPUT FIRST OUTPUT) perché l’ultimo elemento inserito
è il primo ad essere eliminato. Esempio classico la pila di piatti sporchi da lavare: arriva un piatto
sporco e lo si mette sopra l’ultimo e quindi sarà il primo piatto ad essere lavato.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


La casella E è la testa dello stack dove c’è l’ultima informazione inserita. La fase di inserimento si
chiama push e la fase di eliminazione si chiama pop. L’inserimento e l’eliminazione possono
avvenire solo in questa parte della struttura.
Da un punto di vista dinamico, quando si inserisce un elemento, il valore del puntatore allo stack si
incrementa mentre quando si elimina un elemento, il valore del puntatore allo stack decresce.
La realizzazione della pila si può fare in due modi,
-statico, con un’array e si stabilisce la dimensione massima di riempimento (MAX_STACK_SIZE)
-dinamico, con una lista lineare.
La pila si presta ad essere rappresentata come un array convenendo di immaginarlo rovesciato:
l’inserimento e l’eliminazione che avvengono alla testa dello stack, in realtà si verificano
sull’ultima componente dell’array.
Array=[] [] [] [] [] [] [] [] [] [] []
1 2 3 456 7 8 9 n
Stack-
n

n-1

n-2

La CODA, detta anche queue, è una struttura lineare aperta in cui l’accesso alle componenti
avviene solo ai due estremi:
l’eliminazione avviene solo all’inizio della struttura(testa).
l’inserimento avviene solo alla fine(fondo).
La coda si presenta come una struttura FIFO( FIRST INPUT FIRST OUTPUT) perché il primo
elemento inserito è il primo ad essere eliminato. Esempio classico è la fila ordinata allo sportello di
un ufficio.
La fase di eliminazione si chiama dequeue e la fase di inserimento enqueue. A differenza dello
stack, la coda tende a slittare verso la fine dell’array in quanto l’inserimento e l’eliminazione
avvengono in due estremi opposti: l’inserimento alla fine dell’array e l’eliminazione all’inizio.
Fondo(inserisco)
E

Testa(elimino)

La casella con la componente A di indice 1 è la testa, che contiene quindi l’indice nell’array
dell’informazione di testa della coda(la prossima da eliminare).

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


La casella con la componente E di indice n è il fondo, che contiene quindi l’indice nell’array della
pria componente libera, cioè dove inserire la prossima informazione.

Nel caso della coda, tutte le componenti dell’array che si liberano in seguito ad eliminazione di
informazioni, diventano spazio inutilizzabile e quindi la coda più facilmente della pila tende a
raggiungere il massimo riempimento.
Tuttavia questa pecca della coda può essere risolta attraverso un compattamento verso l’inizio
dell’array. Ad ogni eliminazione le informazioni sono spostate di un posto verso l’inizio dell’array.
Questo naturalmente comporta uno spostamento di informazioni, risultando inefficiente. Allora
come evitare lo slittamento della coda verso la fine dell’array con un algoritmo che sia anche
efficiente?
Si può fare considerando l’array circolare, considerando cioè l’array disposto su una circonferenza e
quindi senza fare la differenza tra prima componente e ultima componente nel senso che dopo
l’ultima componente noi immaginiamo che ci sia la prima. E’ come se incollassimo il lato esterno
dell’ottava componente con il lato esterno della prima componente. Ciò si realizza facendo uso
dell’aritmetica modulare, cioè l’aritmetica dei resti modulo n dove n rappresenta la lunghezza
dell’array che contiene la coda. Ogni qualvolta l’indice sull’array viene aggiornato, subito dopo
quest’indice viene calcolato modulo n in modo da riportarlo entro i limiti del suo range[0;n-1].
Ciò significa che se si va oltre il valore dell’indice i=n-1, incrementando ulteriormente, il
corrispondente valore n viene riportato al valore 0 perché 0 e n sono congrui modulo n.

8D)PRINCIPALI STRUTTURE DINAMICHE LINEARI- LISTA


La lista è una struttura dati lineare che a differenza della pila e della coda ci consente di
inserire/eliminare informazioni da qualsiasi parte della struttura. La lista definisce un ordinamento
che, a differenza della pila e della coda dipende dall’ordine di arrivo delle informazioni, è capace di
mantenere l’ordine logico delle informazioni ed e’ quindi una struttura dati ordinata.
Nelle liste si presentano i nodi che costituiscono il campo informazione, che può essere a sua volta
suddiviso in sottocampi, e il campo puntatore(link) che mi consente di passare da un nodo al nodo
successivo in senso logico e non fisico perchè i nodi possono essere allocati in memoria in una
posizione qualsiasi. La lista prevede la VISITA cioè accedere ordinatamente a tutti i nodi della
struttura, eventualmente stampando le informazioni o elaborando le informazioni.
Per eliminare un’informazione in testa alla lista cioe’ eliminare il primo nodo, bisogna
semplicemente modificare il link che punta all’inizio della struttura: la lista partirà dal nodo
successore al nodo eliminato.
Per eliminare un’informazione in coda alla lista cioè eliminare l’ultimo nodo, bisogna eliminare il
link che punta alla fine della struttura.

Per l’inserimento di un’informazione, bisogna seguire in ordine queste informazioni:


1)inserire il link che va dall’elemento nuovo al successore.
2)modificare il link dell’elemento corrente affinchè punti al nuovo elemento.
Per inserire un’informazione in testa alla lista, cioè all’inizio della struttura si deve:
1)inserire il link che va dall’elemento nuovo alla testa della struttura.
2)modificare il link alla testa affinchè punti al nuovo elemento.
Per inserire un’informazione in coda alla lista, cioè alla fine della struttura, bisogna aggiungere il
link che punta alla fine della struttura al nuovo elemento.

8E)IMPLEMENTAZIONE C DI UNA LISTA LINEARE- FONDAMENTI


Il linguaggio C mette a disposizione i tipi di dati primitivi struct e array, assieme al tipo puntatore,
per realizzare le strutture dati dinamiche lineari e cioè la pila, la lista e la coda. La lista lineare

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


utilizza una struct per la descrizione del singolo nodo e fa uso di un puntatore per la gestione dei
link tra le informazioni.
Per costruire il singolo nodo di una lista, occorre seguire questo esempio:
struct PERSONA
{
Char nome[20];
struct PERSONA *p_next; //puntatore autoriferente perché all’interno
//della struttura struct PERSONA c’è un
//puntatore allo stesso tipo della struttura
}

Un puntatore autoriferente indica che non ci sono più nodi.


Gli operatori di struttura “.”per il nome di una struttura e “->”per il puntatore ad una struttura,
assieme alle parentesi tonde ( ) per le chiamate alle funzioni e alle parentesi quadre [ ] per gli indici
di array hanno priorità massima su tutti gli altri operatori.

8F)IMPLEMENTAZIONE C DI UNA LISTA LINEARE- ORGANIZZAZIONE DEI DATI E


FUNZIONI PER GESTIRLI
Riguardo alla lista si eseguono varie operazioni come quelle di inserimento in testa e in mezzo(in
coda) oppure eliminazione in testa e in mezzo(in coda). Dal punto di vista del C occorre utilizzare i
puntatori doppi poiché stiamo andando a modificare il contenuto di una variabile che già di per sé è
un puntatore. D’altra parte è possibile costruire una lista lineare a partire da un array contenente le
informazioni da inserire nei nodi.

8G)PARTICOLARI ORGANIZZAZIONI DEI DATI PER UNA LISTA LINEARE


Abbiamo visto che è necessario avere due funzioni di inserimento e due funzioni di eliminazione
per distinguere la situazione dell’operazione eseguita alla testa della lista oppure nel mezzo della
lista. Infatti quando l’operazione di inserimento/eliminazione avviene in testa è necessario
modificare il puntatore esterno per puntare al nuovo nodo che si trova all’inizio della struttura.
L’eliminazione in testa è differente dall’eliminazione in mezzo della struttura: Una possibile
soluzione per ridurre la duplicazione di queste funzioni è l’uso di un nodo fittizio all’inizio della
struttura, detto nodo sentinella e il puntatore esterno “head” punterà a questo nodo anziché puntare
al vero primo nodo: una volta introdotto questo nuovo nodo, se noi vogliamo eliminare
l’informazione del primo nodo, il puntatore head rimarrà fisso perché punterà sempre al nodo
fittizio che non contiene alcuna informazione.

E’ possibile scrivere delle funzioni in C che implementino operazioni sulla lista senza far
riferimento alla particolare struttura(struct PERSONA) definita per il nodo della lista: questo
comporta dei vantaggi rilevanti, possiamo usare queste funzioni per qualsiasi problema senza
mettere mano al loro codice e quindi teoricamente andranno a formare una sorta di libreria.
E’ necessario usare il cast e i puntatori a void.

8H)APPLICAZIONE DELLE LISTE LINEARI AD ALTRE STRUTTURE DATI


La pila e la coda possono essere memorizzate mediante liste lineari:
-per la pila, le operazioni di inserimento/eliminazione avvengono ad un unico estremo e allora se
rappresentiamo la pila con una lista lineare, l’estremo più comodo è la testa della lista(top)
-per la coda, le operazioni di inserimento/eliminazione prevedono due puntatori: un puntatore alla
testa della lista(top) che inserisce e un puntatore alla coda della lista(bottom) che serve per
eliminare.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Ricordiamo due importanti tipi di liste:
 Lista circolare, in cui non c’è un puntatore che indica fine lista ma il campo puntatore
dell’ultimo nodo torna indietro a puntare al nodo di testa. Circolare perché si intende una
struttura dalla forma della circonferenza.
 Lista bidirezionale, in cui ci sono due campi puntatori: quindi è come se avessimo due
liste: una che parte dal primo nodo all’ultimo nodo e un’altra che parte dall’ultimo per
arrivare al primo. Per questa lista sono necessari due puntatori di testa, che puntano
rispettivamente agli estremi opposti della struttura.

9A)GENERALITA’ SULLA STRUTTURA DATI ALBERO


L’albero è una struttura gerarchica perché noi possiamo accedere alle informazioni(ad esempio
delle lettere dell’alfabeto) solo partendo dal nodo radice, cioè la radice dell’albero: dal nodo radice
possiamo discendere ai livelli inferiori. I nodi finali del livello più basso sono dette nodi foglie.
Nell’albero i nodi si raggruppano in livelli: tipo nel livello 1 troviamo solo la radice. Il nodo che
stiamo prendendo in considerazione si chiama nodo corrente e i nodi che si possono raggiungere da
esso sono i nodi figli. Il nodo padre del nodo corrente è il nodo grazie al quale siamo potuti arrivare
al nodo corrente. Al livello più basso troviamo certamente nodi foglie. La radice, quindi, è l’unico
che non ha padre e le foglie sono gli unici nodi che non hanno figli. Per ogni nodo di un albero si
definisce grado come il numero dei figli: quindi le foglie hanno tutte grado 0 perché non hanno
figli. Si dice sottoalbero un albero che ha come radice il nodo corrente che vogliamo considerare.
Gli algoritmi di visita di un albero sono descritti in maniera ricorsiva. Ricordiamo che in un albero
non è possibile attraversare l’albero da un nodo al fratello o risalire da un nodo al nodo padre, per
poter attraversare i nodi dello stesso livello ci dobbiamo conservare il loro padre in modo da
riprendere l’informazione del padre per riscendere da un suo figlio ad un altro figlio.

9B) ALBERI BINARI-DEFINIZIONI ED ALGORITMI DI VISTA


Gli alberi binari possono essere descritti in questo modo: noi abbiamo il nodo radice e due
sottoalberi, rispetto agli alberi potevamo avere un numero imprecisato di figli per ogni nodo. Invece
qui la possibilità è di avere al massimo 2 figli per ogni nodo, in particolare un figlio individua un
sottoalbero. Ma visto che ce ne sono solo due si dice che abbiamo un sottoalbero sinistro e un
sottoalbero destro, potendo essere tutti e due vuoti, avendo così le foglie. Ci sono due tipi principali
di alberi binari:
 ALBERO BINARIO COMPLETO: Un albero binario si dice completo quando ogni nodo ha
esattamente 2 figli, non ci possono essere nodi che non abbiano figli. Qui c’è un legame tra i
livelli, il numero di foglie e il numero di nodi complessivi: ogni livello prevede una potenza
di due. Abbiamo queste due formule principali:
numero foglie:(2^ numero di livelli complessivi -1)
numero nodi:(2^numero di livelli complessivi)-1
 ALBERO BINARIO QUASI COMPLETO: Un albero binario si dice quasi completo si
quando solo il livello delle foglie non è completo. Quelle che mancano devono trovarsi
verso destra. Tuttavia quando non è completo, nell’albero si introducono dei nodi fittizi,
detti nodi esterni che non contengono alcuna informazione.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Ricordiamo adesso che sono tre gli algoritmi fondamentali, che si differenziano principalmente per
la visita della radice rispetto alla visita dei due sottoalberi, nella visita dell’albero:
-Visita preorder(ordine anticipato) che visita in ordine :
1)la radice
2)il sottoalbero sinistro
3)il sottoalbero destro
-Visita inorder(ordine simmetrico) che visita in ordine:
1)il sottoalbero sinistro
2)la radice
3)il sottoalbero destro
-Visita postorder(ordine differito) che visita in ordine:
1)il sottoalbero sinistro
2)il sottoalbero destro
3)la radice

9C) STRUTTURE DATI PER LA RAPPRESENTAZIONE DI UN ALBERO BINARIO


Perché l’albero binario non è un tipo di dato primitivo dobbiamo sfruttare i dati primitivi array
oppure ad una lista multipla che viene memorizzata con una struct mediante puntatori.
Ci sono tre funzioni fondamentali:
 padre,
se i=1, a[i]è radice ma non ha padre
se i!=1 per trovare il padre a[k] di questo nodo si calcola k dove k=(i/2), prendendo sempre
la parte intera;
 figlio_sinistro,
se 2*i<=n dove n è la cardinalità dell’albero(cioè il numero di nodi contenuti nell’albero),
allora k=2*i, prendendo sempre la parte intera: ad esempio se ho 2,5 prenderò 2;
se 2*i>n allora a[i] non ha figlio sinistro;
 figlio_destro,
se 2*i+1<=n allora k=2*i+1,
se 2*i+1>n allora a[i] non ha figlio destro;

In C gli indici su array partono da 0, ma se si usa il valore i=0, non si può trovare il figlio sinistro
perché 2i=0(e nemmeno il destro).
Ci sono due soluzioni principali per risolvere questa cosa:
-se l’indice parte da 0, somma i+1 ad ogni operazione
-oppure si parte ponendo l’indice i=1 e non uguale a zero

La rappresentazione di un albero binario mediante array va bene solo se l’albero è bilanciato,


altrimenti c’è spreco di spazio. Quindi occorre usare la lista!

Indipendentemente dalla rappresentazione, l’algoritmo di visita dell’albero deve fare uso di uno
stack che va gestito esplicitamente nell’algoritmo iterativo di visita mentre è gestito implicitamente
nel linguaggio di programmazione se si ricorre ad un algoritmo ricorsivo.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


9D)[FACOLTATIVO]ALBERI TRAMATI-RAPPRESENTAZIONE ED ALGORITMI DI
VISITA
Il programmatore può decidere di stravolgere l’organizzazione strutturale dell’albero, privilegiando
il passaggio da un nodo a un altro che non sia necessariamente di tipo gerarchico. Ciò è possibile
con gli alberi tramati che sono alberi in cui è possibile, da ogni nodo, risalire al nodo padre.

9E)ALBERI BINARI DI RICERCA


Gli alberi binari di ricerca o alberi binari ordinati sono alberi binari che vengono costruiti e
mantenuti in modo tale che tra le informazioni sia soddisfatta una certa proprietà: la proprietà
dell’ordinamento. Se y è un qualsiasi nodo dell’albero si ha che:
-per ogni nodo x del sottoalbero sinistro di y, la chiave[x] del sottoalbero sinistro di y è minore della
sua chiave, cioèchiave[x]<=chiave[y]
-per ogni nodo z del sottoalbero destro di y, la chiave[z] del sottoalbero destro di y è maggiore della
sua chiave, cioèchiave[y]<=chiave[z]
In questo modo se si visita l’albero inorder, si ottengono informazioni ordiante.

9F)STRUTTURA DATI HEAP


Un heap è un albero binario quasi completo in cui i nodi sono etichettati tramite chiavi e c’è un ben
preciso ordinamento tra chiavi. Se x è un nodo qualsiasi dell’heap, ad esclusione della radice perché
la radice non ha padre, si ha che : chiave[x]<=chiave[padre di x]. Questo ci fa capire che in un heap,
l’elemento massimo è memorizzato nella radice.
Un heap può essere memorizzato mediante array con le solite regole:
Avendo i
-il padre di i=i/2
-il figlio sinistro di i=2*i
-il figlio destro di i=2*i+1

E’ possibile trasformare un array binario quasi completo in heap ma in molteplici modi e


soluzioni. L’operazione base che consente di ripristinare la proprietà heap sui nodi di un albero
binario quasi completo è la procedura Heapify e coinvolge un nodo e i suoi figli. Tra i suoi figli si
determina il nodo con chiave massima. Poi si confronta il figlio con chiave massima con il padre e
si scambiano i due nodi se non verificano la proprietà dell’heap. Adesso viene verificata la proprietà
dell’heap.

E’ possibile trasformare un albero binario in heap attraverso la procedura Heapify in modo


bottom-up, dai livelli più bassi dell’albero in su, per convertire l’array in un heap.

10A)GENERALITA’ SULLA STRUTTURA DATI GRAFO


I grafi rappresentano la struttura dati più sofisticata e utilizzata. Eulero ha inventato la teoria dei
grafi intorno al 1736, dovendo risolvere il cosiddetto problema dei ponti di Konisberg, questa città
che, attraversata da un fiume, presentava dei ponti. Allora gli abitanti di questa città si chiedevano
se era possibile determinare un percorso che parta da un punto della terra ferma e attraversi tutti i
ponti 1 volta per tornare al punto di partenza(generando un percorso chiuso). Eulero rispose dando
inizio alla teoria dei grafi.
Il grafo è una struttura reticolare che, come nel caso dell’albero, è un insieme di nodi che
contengono le informazioni(dette vertici) e un insieme di archi che costituiscono le connessioni tra
due nodi adiacenti(dette anche lati).
Dicesi grafo orientato quando è assegnato un verso di percorrenza, di conseguenza grafo non
orientato dicasi quando non è stato assegnato alcun verso di percorrenza, quindi la percorrenza è in
entrambe le direzioni.
APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI
Quindi i grafi orientati presentato strade di percorrenza a senso unico, i grafi non orientati
presentano invece strade di percorrenza a doppio senso.
Da un punto di vista matematico, gli archi si presentano come coppie ordinate nel caso del grafo
orientato e coppie non ordinate nel caso del grafo non orientato.
Anche nel caso del grafo si parla di gradi ma, a differenza degli alberi in cui i grafi sono il numero
dei figli di un nodo, qui i gradi si definiscono come il numero di archi incidenti il nodo.
Tipiche rappresentazioni molecolari riconducibili ai grafi sono le strutture molecolari e i circuiti
elettrici.
Una carta stradale può essere presentata come un grafo i cui nodi sono le città e i cui archi sono le
strade fra una città e l’altra.

La soluzione di Eulero è che il problema non è risolubile perché esiste un percorso che parte da un
nodo qualsiasi, attraversa una volta sola ciascuno arco e termina nel vertice iniziale se e solo se il
grado di ogni vertice è pari.

Un grafo è un insieme di vertici e di archi e ricordiamo che il cammino è una sequenza di nodi dove
si parte da un’origine e si arriva a una destinazione: ci sono 3 tipi di cammino principali:
 cammino, che passa più volte per uno stesso nodo già attraversato, che si intreccia che passa
più volte per uno stesso punto.
 cammino semplice, che non passa mai per uno stesso nodo.
 cammino minimo, perché attraversa il minimo nodo di archi per arrivare alla destinazione.
Dicesi poi cammino chiuso, quando il punto di partenza coincide con il punto di arrivo.

Un grafo connesso è un grafo in cui due coppie di punti possono essere comunque collegate da un
cammino. Un ciclo è un cammino semplice chiuso. Un ciclo di lunghezza 1 si chiama cappio o
loop.

I due cicli che prendono il nome di importanti uomini di ingegno del passato sono:
-Il ciclo Euleriano, che attraversa ogni arco esattamente 1 volta.
-Il ciclo Hamiltoniano, che visita ogni nodo, tranne il primo, esattamente 1 volta.

10B)RAPPRESENTAZIONE IN MEMORIA DI UN GRAFO


Per rappresentare i grafi si usano o le matrice di adiacenze o le liste di adiacenza.
In una matrice si presenta il cappio quando sulla diagonale principale ci sono tutti 1.
Nella costruzione della matrice di adiacenze(che è di tipo booleano cioè presenta 1 e 0 in base l
valore di VERO/FALSO) di un grafo non orientato di viene a creare una matrice simmetrica: una
matrice simmetrica significa che se noi potessimo tagliarla sulla diagonale principale, il triangolo
superiore è simmetrico rispetto al triangolo inferiore. A un punto di vista di memoria visto che c’è
una matrice simmetrica, si può dimezzarne l’occupazione di memoria rappresentandone soltanto il
triangolo superiore o solo il triangolo inferiore.
Ricordiamo che i grafi connessi presentano autovalori tutti diversi e i grafi non connessi presentano
autovalori non tutti diversi: questa proprietà accomuna i grafi alla matematica.
Nella costruzione della matrice di adiacenze di un grafo orientato si presenta una matrice non
simmetrica .
Ricordiamo che nelle matrici sparse vengono memorizzati solo gli 1 e gli 0 no.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


10C)ALGORITMI DI VISITA DI UNA STRUTTURA RETICOLARE *ATTO 1*
Nel caso di albero generico, l’algoritmo di visita in ordine anticipato prevede che un nodo sia
visitato prima di visitare un figlio, perché adesso il numero dei figli non è fissato. Per realizzare
questo algoritmo si usa una pila per visitare i nodi non ancora visitati.
L’algoritmo per la visita in profondità di un grafo si chiama DFS(Depth First Search): questo
algoritmo si basa sull’idea di visitare un nodo V e poi ricorsivamente visitare ogni nodo U adiacente
a V e non ancora visitato. Questo algoritmo, applicato a un grafo connesso, consente di visitare tutti
i nodi del grafo: se il grafo di partenza non è connesso, allora sarà visitato soltanto il sottografo
connesso che ha come nodo il nodo da cui parte la visita.
L’algoritmo iterativo del DFS ha bisogno di una struttura dati aggiuntiva, la pila, per ricordare i
nodi da elaborare ancora.
Ogni volta che un nodo viene visitato nella pila vanno inseriti i nodi a esso adiacenti: a ogni
ripetizione si estrae un vertice dalla pila e, se non è già stato visitato, lo si visita. Dopodichè
l’algoritmo continua con questa iterazione. Per stabilire se un nodo sia stato già visitato oppure no,
c’è bisogno di un flag:(usiamo la colorazione del vertice).
Quando un vertice è bianco, si intende che non è stato mai visitato e un vertice diventa grigio non
appena è stato visitato. Il DFS coincide con la visita in ordine anticipato di un albero
qualsiasi(VISITA PREORDER), quando si intenda per radice dell’albero il vertice del grafo da cui
si parte. L’algoritmo DFS trasforma il grafo in un albero che si chiama Albero di ricerca depth-
first . Applicando la visita in ordine anticipato sull’Albero di ricerca depth-first , si ottiene la stessa
sequenza di visita dei nodi di quella ottenuta applicando l’algoritmo DFS al grafo di partenza.
Nell’Albero di ricerca depth-first non sono presenti tutti gli archi del grafo ma quelli presenti sono
attraversati esattamente 1 volta. Invece nell’algoritmo DFS tutti gli archi del grafo sono attraversati
per ben 2 volte.
L’algoritmo DFS era stato scoperto, in realtà, già qualche secolo fa e veniva utilizzato per
l’attraversamento di un labirinto. Confrontando il grafo con il labirinto, i vertici del grafo sono gli
incroci del labirinto mentre gli archi del grafo sono i percorsi del labirinto.

10D) ALGORITMI DI VISITA DI UNA STRUTTURA RETICOLARE *ATTO 2*


L’algoritmo di visita in ampiezza è chiamato BFS(Breadth first-search). Questo algoritmo, come il
DFS ha bisogno di una struttura dati di appoggio: la coda. Un’applicazione tipica dei grafi è nella
robotica: un grafo potrebbe essere la pianta che il robot ha memorizzato nello spazio dove si muove.
Lui può andare in stanze collegate tra corridoi. Il BFS prevede di partire da un nodo, chiamato nodo
sorgente e avrò in uscita tutti i cammini che partono da un nodo e automaticamente io saprò tutti i
nodi che sono raggiungibili partendo da quel nodo e di conseguenza saprò tutti quei nodi che non
sono raggiungibili, se ci sono. Questo algoritmo di visita prevede una colorazione dei nodi, perché il
colore di un nodo indica se già è stato visitato oppure situazioni diverse. Inizialmente tutti i vertici
sono bianchi poiché non ancora visitati: un vertice viene colorato in grigio quando viene raggiunto
per la prima volta e invece viene colorato in nero quando tutti i vertici ad esso adiacenti non ancora
visitati sono stati inseriti nella coda. Inoltre in ogni vertice viene indicato il suo livello che sarà pari
a quello del vertice del livello precedente+1.
Bianco->grigio->nero.
La visita in ampiezza produce un albero BFS tale che:
1)la radice è il nodo sorgente
2)sono toccati tutti i nodi del grafo, mentre gli archi sono un sottoinsieme di quelli del grafo
3)è possibile calcolare le lunghezze di tutti i cammini che partono dalla radice dell’albero(mediante
il livello)
4)è possibile stabilire se un dato nodo sia connesso con la radice dell’albero.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


11A)PROGRAMMAZIONE ITERATIVA VS RICORSIVA
Spesso la matematica utilizza la ricorsione, che è una modalità sintetica, per definire un oggetto,
qualcosa. Un algoritmo o un programma si dicono ricorsivi se richiamano se stessi direttamente o
indirettamente.
Qualsiasi algoritmo può essere descritto in forma ricorsiva.
Esempio di funzione matematica definita ricorsivamente è il MASSIMO COMUN DIVISORE che
si calcola in questo modo. Se voglio trovare il massimo comun divisore tra i numeri 14 e 8
(m=14 e n=8), farò:

MCD(14,8)= MCD(n,m[modulo]n), ricordando che m[modulo]n è il resto della divisione intera di


m per n
Quindi…
MCD(8,6)=MCD(6,2)=MCD(2,0) quindi il massimo comun divisore è 2
In una funzione ricorsiva si presenta sempre una scelta (if-else) che alterna il caso banale alla
soluzione ricorsiva.
Alla ricorsione è facilmente riconducibile il fattoriale.
Adesso è doveroso fare una classificazione degli algoritmi ricorsivi. Ci sono due tipi di ricorsione:
 ricorsione diretta che all’interno di una procedura richiama se stessa. Si distinguono 3
sottocasi principali:
-ricorsione lineare, in cui si prevede che nel corpo della procedura compaia 1 sola chiamata
ricorsiva.
-ricorsione binaria, in cui si prevedono due chiamate ricorsive all’interno del corpo della
procedura: ciò vuole dire che il problema principale viene decomposto in sottoproblemi e
poi la chiamata ricorsiva si applica a uno dei due sottoproblemi.
-ricorsione non lineare, significa che compare la chiamata ricorsiva all’interno della nostra
procedura che si trova all’interno di un ciclo ripetitivo e non sappiamo il numero di volte in
cui questa chiamata ricorsiva verrà attivata ecco perché non lineare.
Esempio di ricorsione non lineare è il calcolo delle disposizioni con ripetizioni dei primi n
numeri naturali. Ad esempio ho 3 numeri , 1 2 e 3. Il numero di coppie possibile è 3^2
perché: 11 12 13 21 22 23 31 32 33.
 ricorsione indiretta in cui una procedura_1 può chiamare una procedura_2 in cui c’è il
processo ricorsivo, in modo indiretto.
 Mutua ricorsione, cioè quando in una procedura avviene una chiamata ricorsiva indiretta
tramite un’altra procedura.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


11B)ANALISI DI FUNZIONI RICORSIVE
Un parametro importante per poter valutare l’occupazione di spazio e la complessità di tempo di un
algoritmo ricorsivo è la profondità di ricorsione che si definisce come il massimo numero di
chiamate ricorsive realmente eseguite. Ad esempio la profondità di ricorsione del fattoriale di n è n:
questo comporta ad avere n copie delle variabili/parametri locali. La profondità di ricorsione
moltiplicata per il numero di variabili usate dalla procedura, dove intendiamo sia le variabili interne
quindi locali che i parametri formali quantifica l’occupazione di memoria. Quindi il parametro che
ci consente di capire se un algoritmo sia meglio descritto con la ricorsione oppure no è la profondità
di ricorsione.
La complessità computazionale è sia di tempo che di spazio. Analizziamo la complessità dei vari
tipi di ricorsione:
-Nella ricorsione lineare troviamo una complessità pari a O(n) perché ad 1 chiamata ricorsiva 1
attivazione, n nel caso in cui ci siano n chiamate.
-Nella ricorsione binaria troviamo una complessità pari a O(2^n) [profondità esponenziale, la
peggiore] nel caso del calcoli dei numeri di Fibonacci a causa delle duplicazioni perché ad 1
chiamata ricorsiva abbiamo 2 attivazioni, con la presenza di chiamate ridondanti con un relativo
spreco di tempo. Nel caso invece di trovare il massimo elemento all’interno di un array si presenta
una complessità dell’ordine O(n), in cui le chiamate sono n e il vettore si accorcia nelle continue
divisioni in porzioni, visto che non ci sono duplicazioni perché le porzioni di array, a parità di
livelli, non hanno nulla in comune l’uno con l’altro.
Quindi facciamo questa scaletta:
|O(2^n) Fibonacci (ordine di crescita esponenziale)
PROFONDITA’: |O(n) Calcolo massimo in array (ordine di crescita lineare)
|
Entrambi gli algoritmi suddividono il problema iniziale in due sottoproblemi:
-nel caso di Fibonacci i due sottoproblemi non sono indipendenti ma sono legati da questa formula
di ricorrenza: F(n)=F(n-1)+F(n-2)
-nel caso del massimo i due sottoproblemi sono tra loro indipendenti.

Quindi Fibonacci non conviene risolverlo ricorsivamente!


La ricerca binaria utilizza il metodo del Divide et impera scindendo il problema in due
sottoproblemi ed ad ogni livello di ricorsione si risolve sempre uno dei due sottoproblemi. La
profondità è dell’ordine di crescita logaritmica O(log2(n)) ed è molto conveniente.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


11C)ALTRI ESEMPI DI ALGORITMI RICORSIVI
L’algoritmo di Horner è un algoritmo che ci consente di valutare un polinomio.
Questo algoritmo può essere trattato in due modi principali, ma il tipo di operazione è sempre
quella. Cioè il sommare ad un valore costante la x moltiplicata per qualcosa che o è l’attivazione o è
qualcosa che ho calcolato nell’iterazione precedente:
-in maniera iterativa valutando le parentesi dalle più interne alle più esterne.
-in maniera ricorsiva valutando le parentesi dalle più esterne alle più interne, dove ogni parentesi è
una chiamata.
Altri algoritmi che possono essere descritti in maniera ricorsiva sono la costruzione di una lista, a
partire da un array contenente le informazioni da inserire nei nodi.
Abbiamo anche la visita di un albero binario, nei tre modi principali di visita: visita preorder,
inorder e postorder.
C’è anche la ricerca binaria in un albero binario ordinato: l’albero deve essere costruito in
modo che l’ordine naturale dei nodi si trovi mediante la visita inorder. La complessità di questo
algoritmo presenta un confronto per ogni livello: ci sono log2(n) livelli in un albero bilanciato.

12A)ALGORITMI IN PLACE A COMPLESSITA’ QUADRATICA


I problemi di ricerca di un elemento detto chiave e ordinamento di dati sono molto importanti.
Ci sono molti algoritmi, sia di ricerca che di ordinamento che offrono caratteristiche diverse in base
al parametro di efficienza, cioè la complessità di spazio e di tempo. Di ogni algoritmo è utile
valutare i casi limiti detti casi peggiori, quando si presentano dati disordinati, o i casi migliori,
quando si presentano dati già ordinati da considerare.
Abbiamo due metodi di ordinamento principali:
-ci sono quelli interni, in cui i dati risiedono in memoria centrale.
-ci sono quelli esterni, in cui i dati risiedono parzialmente in memoria di massa.
Negli algoritmi di ordinamento l’operazione principale è lo scambio(scambio di informazioni per
ordinarle) che può essere di due tipi:
-scambi reali, quando i dati sono fisicamente scambiati di posto. Per scambiare l’informazione vera
e propria si richiede di scambiare non solo la chiave ma tutte le informazioni collegate a quella
chiave.
-scambi virtuali, quando i dati non sono fisicamente scambiati perché si opera tramite puntatori:
questo si fa per evitare di perdere tempo se abbiamo dati cospicui. Noi abbiamo le informazioni ma
per accedere a queste informazioni c’è bisogno di un array ausiliario di puntatori e quindi gli scambi
avvengono su questo array di puntatori e non sull’array informazioni vero e proprio. Accedendo
all’array di puntatori è come se accedessimo all’array informazioni secondo il criterio richiesto.

L’algoritmo di ordinamento ha la proprietà di essere stabile o instabile.


Si parla di algoritmo stabile quando l’ordinamento delle chiavi uguali viene preservato, cioè viene
mantenuto l’ordinamento relativo delle informazioni con chiavi uguali( tipo prima R 2 e poi R 4
vedi slide).
Si parla di algoritmo instabile quando il processo relativo all’algoritmo stabile non viene effettuato
(quindi si presenta prima R 4 e poi R 2).

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Ecco i principali tipi di ordinamento:
 Selection Sort[complessità quadratica e ordinamento per selezione di minimo]. Io cerco
il minimo valore del vettore disordinato, tipo la lettera dell’alfabeto più piccola: una volta
trovata la porto nella sua posizione definitiva nel vettore ordinato, effettuando gli scambi
che servono. Ad ogni passo si verifica che il vettore risulta suddiviso in due porzioni: una
che è la porzione ordinata del vettore che, ad ogni iterazione, cresce e l’altra è la porzione
relativa al vettore disordinato che, ad ogni iterazione, si riduce. L’algoritmo terminerà
quindi quando il vettore disordinato si è svuotato, arrivando a n passi, n-1: è ovvio che se il
mio sottovettore disordinato si è ridotto ad una sola componente, quella componente è
ordinata. Quindi l’n-simo passo è superfluo. Nella ricerca del minimo abbiamo n-1 scambi e
n*(n-1)/2 confronti. Quindi O(n^2) confronti e al più O(n) scambi. Quindi nel complesso,
abbiamo una complessità quadratica dell’ordine di O(n^2).
Selection Sort[complessità quadratica e ordinamento per selezione di massimo]. Poiché
si cerca il massimo dell’array disordinato, le posizioni della sottoporzione ordinata si
troveranno alla fine delle ultime componenti del vettore: quindi l’ultima componente del
vettore sarà occupata dall’elemento massimo. Il procedimento di confronti/scambi continua
finchè non abbiamo che tutto il vettore è ordinato, cioè che la porzione disordinata si è
completamente svuotata.
Abbiamo il numero di confronti e il numero di scambi realmente eseguiti: essi vanno
interpretati come delle matrici che collegano gli elementi dell’array che potrebbero essere
numerati sulle colonne con i passi dell’algoritmo che potrebbero essere numerati sulle righe.
Guardando al numero degli scambi, si osserva che per ciascuna riga, viene eseguito
esattamente 1 scambio e che, quindi, alla fine dell’algoritmo i puntini blu che appariranno
all’interno del quadrato, occupano la diagonale principale della matrice, a conferma del fatto
che questo algoritmo ha una complessità computazionale, in termini del numero di scambi,
dell’ordine O(n) quindi lineare. Invece se guardiamo al numero di confronti, vediamo che al
primo passo, il numero di confronti è n-1 e ad ogni passo successivo il numero di confronti
diminuisce di 1: alla fine dell’algoritmo il numero di confronti eseguiti riempirà una matrice
triangolare il cui numero di elementi è dell’ordine di (1/2)*n^2, così come la complessità
computazionale dell’algoritmo Selection Sort riporta.
Abbiamo una matrice triangolare superiore il cui numero di elementi rappresenta quanti
confronti l’algoritmo ha eseguito. Una matrice triangolare superiore di n righe x n colonne
presenta un numero di elementi dell’ordine O(n^2) mentre la complessità lineare del numero
di scambi viene rappresentata con una matrice diagonale perché gli elementi principali della
matrice diagonale sono n. La complessità di spazio dell’algoritmo Selection Sort è
dell’ordine O(n) perché l’algoritmo è in place.
Un algoritmo si dice che ordina “in place” solo se in ogni istante c'è al più un numero
costante di elementi memorizzati al di fuori dello spazio assegnato per l'input, cioè, ad
esempio, se l'algoritmo riordina l'array senza usare un array ausiliario.
 Bubble Sort(detto anche Exchange Sort, algoritmo peggiore per numero di scambi non per
numero di confronti): confronta due componenti adiacenti del vettore, se queste non sono in
ordine, le scambia e passa a confrontare le successive 2. Se le due componenti confrontate
sono in ordine, non vengono scambiate. Il 1 passo termina fino a che non siamo arrivati
all’ultima coppia del vettore terminando tutto il vettore. Alla fine del 1 passo, il vettore non
è sicuramente ordinato ma mette il massimo al suo posto, cioè nell’ultima componente del
vettore. Arrivati al 2 passo l’algoritmo incomincia dall’inizio con gli scambi, però adesso il
vettore si è accorciato divenendo n-1 perché il massimo già si trova al suo posto.
Continuando a confrontare e a scambiare, l’algoritmo termina finquando il vettore
disordinato, accorciatosi ad ogni passo è scomparso.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Considerando una matrice, sulle colonne possiamo considerare gli elementi dell’array e sulle
righe il numero dei passi. Si osserva che sia il numero di confronti che il numero di scambi
realmente effettuati tende ad occupare una metà dei due quadrati e quindi non va oltre una
specie di matrice triangolare superiore anche se è riferita alla diagonale secondaria invece
che alla diagonale principale. Abbiamo alcuni di valori di complessità in dipendenza dei
casi:
Il numero di confronti è dello stesso ordine di quelli del Selection Sort, cioè O(n) nel caso
migliore; invece nel caso peggiore il numero di confronti è O((1/2)*n^2).

Il numero di scambi, invece, non supera la metà degli elementi della matrice intera e quindi
al più sarà O((1/2)*n^2) nel caso peggiore; nel caso migliore il numero di scambi è
dell’ordine O(n) quando il vettore considerato si presenta parzialmente ordianto. Al più
perché nella matrice triangolare che si viene a formare, ci sono degli elementi mancanti e
quindi è evidente che il numero di scambi, l’algoritmo dipende dalla proprietà di essere più
o meno ordinato dell’array inizialmente. Quindi abbiamo, nel caso medio, sia per il numero
di confronti che per il numero di scambi una complessità quadratica: quindi l’algoritmo
Exchange Sort, rispetto al Selection Sort sul numero di confronti è paragonabile, mentre per
il numero di scambi ce ne sono molti di più, per il fatto che il Bubble Sort scambia sempre a
coppie, anche quando potrebbe evitarselo. Anche in questo caso l’algoritmo è in place.
Infine la complessità di tempo del Bubble Sort è O(n) perché l’algoritmo è in place.
Però se il vettore di partenza, nel caso migliore fosse già ordinato, questo algoritmo di
ordinamento si baserebbe su un solo passo, facendo soltanto confronti senza scambiare mai,
perché appunto il vettore di partenza è già ordinato. Invece nel caso peggiore, vengono
effettuati tutti gli scambi, viene quadratico per gli scambi e quadratico per i confronti che
vengono tutti effettuati.
 Insertion Sort(detto talvolta Bubble Sort). C’è una modifica rispetto al precedente algoritmo
che ne migliora l’efficienza. Come nel caso precedente vengono confrontati elementi
adiacenti, però, a differenza del caso precedente, i confronti riguardano la sottoporzione
dell’array che va sempre più aumentando e quando la sottoporzione aumenta e quindi io
devo ricominciare i confronti, invece che cominciare dalle prime componenti, comincia
dalle ultime della sottoporzione. La prima sottoporzione al primo passo, è costituita da due
componenti perché per confrontare ce ne servono almeno due: quindi le prime due
componenti vengono confrontate e se è il caso scambiate. Aumentiamo di 1 la lunghezza
della sottoporzione sulla quale operare dei confronti. Ogni volta che si incrementa di una
unità la sottoporzione, i confronti partono dalla fine ma non dalla fine del vettore ma alla
fine della sottoporzione e questi confronti, seguiti eventualmente da scambi, si arrestano non
appena troviamo due componenti che si trovano nell’ordine. Quindi al passo successivo la
sottoporzione aumenta di lunghezza. Non confronto le componenti precedenti perché si
trovano già nell’ordine. La sottoporzione aumenta di 1 e i confronti partono dalla coda e si
fermano quando non ci sono scambi. In questo modo si risparmiano molti scambi rispetto
all’algoritmo Bubble Sort e in questo sta la modifica rispetto all’algoritmo precedente.
Continuando, alla fine sarà ordinato.
E’ interessante notare il numero di confronti e di scambi, confrontandoli rispetto a quelli
relativi al Selection Sort e al Bubble Sort.
In generale l’Insertion Sort presenta una complessità di spazio O(n) perché è in place.
Poi come numero di confronti, abbiamo una complessità O(n) nel caso migliore e una
complessità O((1/2)*n^2) nel caso peggiore.
Nel caso del numero degli scambi, abbiamo una complessità O(n) nel caso migliore e una
complessità O((1/2)*n^2) nel caso peggiore.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Poiché abbiamo detto che l’Insertion Sort è un miglioramento del Bubble Sort, guardando il
numero di confronti e il numero di scambi e se noi guardiamo le complessità asintotiche nel
caso migliore e nel caso peggiore, non possiamo fare altro che dire sono le stesse rispetto
all’algoritmo precedente perché si riferiscono a un caso migliore e a un caso peggiore. Ma
allora perché abbiamo detto che nell’algoritmo Insertion Sort c’è una modifica di
miglioramento rispetto al Bubble Sort?
L’Insertion Sort risulta mediamente 2 volte più veloce del Bubble Sort e il 40% più veloce
del Selection Sort.
Nell’Insertion Sort si può evitare l’operazione di scambio riconducendola ad
assegnazioni(uno scambio corrisponde a 3 assegnazioni, 1 variabile, 2 variabile e variabile
d’appoggio). Se noi utilizziamo questa variabile di lavoro aggiuntiva per memorizzare
temporaneamente le informazioni, l’algoritmo esegue 1 assegnazione invece che 1 scambio
e i tempi si riducono ancora di più.

12B)ALGORITMI DELLA CLASSE DIVIDE ET IMPERA


Il primo algoritmo della classe Divide et Impera, alla quale fanno parte tutti che hanno una
complessità asintotica O(n*log2(n)) e quindi sono più efficienti rispetto agli algoritmi dell’ordine
O(n^2) a complessità quadratica, che introduciamo è il Merge Sort. Prima di parlare del Merge Sort,
facciamo un breve richiamo al merge ordinato di array.
Abbiamo 2 array di input e un terzo array di output dato dalla fusione dei due: nei 2 array di input
vengono confrontate di volta in volta le chiavi e quella minima viene inserita nell’array di output. Il
puntatore avanza sull’array che ha fornito la chiave da inserire. Quando si verifica che uno dei 2
array è finito, i nodi rimanenti dell’altro array vengono direttamente copiati. L’array finale di output
è un array che prevede come size la somma delle lunghezze dei 2 array di input. Questa
caratteristica comporta che il Merge di array ordinati non è in place è ciò verrà mantenuto anche dal
Merge Sort che è un algoritmo non in place.
Il Merge Sort è un algoritmo che presenta una complessità logaritmica(O(n*log2(n)) sia nel numero
di confronti che nel numero di scambi ma ha anche la caratteristica di non essere in place.
Il Merge Sort confronta coppie disgiunte di elementi del vettore, cioè è come se vedesse il vettore
suddiviso in tante coppie: la prima-seconda, terza-quarta e così via, che vanno confrontate ed
eventualmente scambiate. Le porzioni ordinate che ne risultano a due a due, sono unite attraverso
merge con le coppie adiacenti, formando sottoporzioni di 4 elementi ordinate, e così via. Questo
algoritmo Merge Sort riapplica l’algoritmo di fusione(Merge) su porzioni dell’array e alla fine ci
troveremo con l’intero vettore ordinato.
Non viene sfruttata l’idea del raddoppiamento ricorsivo nel caso in cui il numero degli elementi del
vettore non è potenza di 2.
Un altro algoritmo della classe Divide et Impera è il Quick Sort. Questo algoritmo esiste in varie
versioni: l’idea dell’algoritmo si basa sulla scelta di un elemento detto pivot o anche elemento
partizionatore. Dopo aver scelto il pivot, bisogna trovare la posizione che deve essere occupata nel
vettore ordinato da questo elemento. Si scorre il vettore disordinato in modo da spostare da una
parte tutte le chiavi minori del partizionatore e dall’altra parte tutte quelle che sono maggiori. Una
volta fatta questa separazione, l’elemento partizionatore deve stare in mezzo. Questo significa che si
scorrono le due sottoporzioni del vettore confrontando le chiavi con il pivot e facendo scambi se
queste chiavi non soddisfano l’ordinamento. Il partizionatore si chiama così perché poi partizionerà
il vettore in 2 sottovettori disordinati caratterizzati dal fatto che la porzione di sinistra contiene le
chiavi minori e la porzione di destra le chiavi maggiori. Il Divide et Impera sta nel fatto che il
problema iniziale è stato scisso in 2 sottoproblemi di ordinamento.
Se non si presentano due sottoporzioni bilanciate vuol dire che non è stato scelto nel modo giusto il
partizionatore.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI


Il Quick Sort è in place e quindi la sua complessità di spazio è O(n) cioè lineare, invece la
complessità di tempo a riguardo del numero dei confronti è O(n*log2(n)) nel caso migliore, cioè
quando il vettore ha una cardinalità che è potenza di 2 anche in funzione della scelta del
partizionatore, e O(n^2) nel caso peggiore.
Il Quick Sort va utilizzato preferibilmente quando i dati sono potenza di 2 e quando il vettore sia
mediamente disordinato perché quando abbiamo dati quasi già ordinati il Quick Sort fornisce la
peggiore prestazione.
Quindi esistono tanti algoritmi di ordinamento perché non ne esiste uno migliore in assoluto, ma
esiste in funzione al tipo di dato da ordinare e alle chiavi, della memoria che utilizziamo, se
abbiamo spazio aggiuntivo.
La scelta migliore del partizionatore è:
-random, quando si presenta una distribuzione uniforme dei dati
- sfruttando la distribuzione di probabilità dei dati se è nota
L’ultimo algoritmo della classe Divide et Impera che consideriamo è l’Heap Sort : esso è in place
quindi non usa aree di memoria aggiuntive per l’ordinamento ma lavora sull’array che contiene i
dati iniziali. Quindi è efficiente come il Merge Sort e il Quick Sort ma a differenza del Merge Sort
ha una complessità minore perché è in place. L’Heap Sort usa la struttura dati Heap, un albero
binario quasi completo(perché il livello delle foglie non è completo: le foglie mancanti per chi
guarda si devono trovare verso destra) in cui ogni nodo ha un valore non inferiore a quello di tutti i
nodi nei suoi sottoalberi.
La proprietà che deve essere soddisfatta da tutti i nodi dell’heap è questa : se x è un qualsiasi nodo
dell’heap, ad esclusione della radice perché non ha padre,
la chiave del nodo x cioè del nodo corrente deve essere minore o uguale della chiave del padre.
key(x)<=key(padre(x)).
Ne consegue che l’elemento con valore massimo è memorizzato nella radice.
L’idea dell’Heap Sort è questa: i dati disordinati si trovano in un array e l’algoritmo viene suddiviso
in 2 sottoalgoritmi:
 Il primo sottoalgoritmo costruisce la struttura dati Heap.
 Il secondo sottoalgoritmo ordina l’array riorganizzando le informazioni nell’array affinchè
l’albero che ne viene fuori restituisca una struttura dati heap.
Nell’Heap Sort ha una complessità di spazio dell’ordine O(n) lineare e una complessità di tempo
O(n*log2(n)) perché c’è un confronto e uno scambio 1 per livello(cioè log2(n)), al più( cioè n)
ripetuto per tutti i nodi.

APPUNTI PROGRAMMAZIONE II PIERLUIGI LIPARDI

Potrebbero piacerti anche