Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
NET
INGEGNERIA INFORMATICA
anno 2016
g.maccanti@students.uninettunouniversity.net
Sommario
ALGORITMI E PROGRAMMAZIONE AVANZATA – PARTE 1 - RICORSIONE E ALGORITMI RICORSIVI................ 13
ALGORITMO RICORSIVO .............................................................................................................................. 13
ALGORITMO ITERATTIVO............................................................................................................................. 13
RICORSIONE DIRETTA .................................................................................................................................. 13
RICORSIONE INDIRETTA ............................................................................................................................... 13
PERCHE’ SCRIVIAMO FUNZIONI RICORSIVE? ............................................................................................... 13
ANALISI DI UNA FUNZIONE RICORSIVA: ...................................................................................................... 13
PUNTATORI E INDIRIZZI ................................................................................................................................... 14
PUNTATORI.................................................................................................................................................. 14
INDIRIZZI ...................................................................................................................................................... 14
OPERAZIONI SUI PUNTATORI ...................................................................................................................... 14
PROGRAMMA DI ESEMPIO PUNTATORI E INDIRIZZI ................................................................................... 15
ALLOCAZIONE DINAMICA DELLA MEMORIA ................................................................................................... 16
ISTRUZIONE MALLOC() ................................................................................................................................ 16
ISTRUZIONE FREE() ...................................................................................................................................... 16
PROGRAMMA DI ESEMPIO: MALLOC .......................................................................................................... 16
TYPEDEF .......................................................................................................................................................... 17
PROGRAMMA DI ESEMPIO: TYPEDEF .......................................................................................................... 17
LISTE LINEARI ................................................................................................................................................... 18
IMPLEMENTAZIONE .................................................................................................................................... 18
STATO DELLA LISTA...................................................................................................................................... 18
PRIMITIVE DI GESTIONE DELLE LISTE ........................................................................................................... 19
Inizializzazione di una lista: codice c........................................................................................................ 19
Ricerca in una lista: definizione ............................................................................................................... 19
Ricerca in una lista: codice c .................................................................................................................... 19
Visita di una lista: codice c ...................................................................................................................... 19
Inserimento in una lista: definizione........................................................................................................ 20
Inserimento in testa in una lista: codice c ............................................................................................... 20
Elimina elemento di una lista .................................................................................................................. 22
Elimina elemento di una lista: codice c.................................................................................................... 22
PROGRAMMA GESTIONE PRIMITIVE LISTE: CODICE C ................................................................................. 23
TIPI DI DATO ASTRATTI – PARTE 1: PILE E CODE ............................................................................................. 25
TIPI DI DATO ASTRATTI ................................................................................................................................ 25
N.B. All’interno del presente documento ci sono svariati collegamenti ipertestuali che sono
riconducibili alla forma ALGORITMI E PROGRAMMAZIONE AVANZATA – PARTE 1 - RICORSIONE E
ALGORITMI RICORSIVI. Cliccandovisi sopra (ctrl+click) potrete aprirli.
ALGORITMO ITERATTIVO
In informatica viene detto algoritmo iterativo un algoritmo che esprime un certo numero di volte, un certo
numero di operazioni. Un esempio è il ciclo for o il ciclo while, i quali eseguono un certo numero di volte le
operazioni contenute al loro interno.
RICORSIONE DIRETTA
Si verifica quando una funzione, all’interno del suo corpo, richiama sé stessa.
RICORSIONE INDIRETTA
Si verifica quando una funzione, all’interno del suo corpo, esegue una chiamata ad una funzione diversa, la
quale, direttamente o indirettamente, richiama il metodo principale.
N.B. quando scriviamo funzioni ricorsive è essenziale gestire il problema della terminazione.
PUNTATORI E INDIRIZZI
PUNTATORI
Il puntatore è un tipo, cioè una variabile, che contiene un indirizzo di memoria.
Come sappiamo bene, in C, è possibile utilizzare le funzioni (sottoprogrammi) solamente passando loro un
valore, quindi il contenuto di una variabile e non la variabile stessa. Per fare un esempio: facciamo finta che
io, nel metodo main() abbia un vettore V[n], sarebbe impossibile pensare di passare quel vettore ad una
funzione valorizzaVettore() affinché questa esegua la valorizzazione dello stesso per poi tornare il tutto al
metodo main chiamante; non perché non lo si voglia fare, ma perché il linguaggio stesso non lo permette.
Il C, quindi, per far si di lavorare dinamicamente su alcune strutture, ha messo a disposizione i puntatori
che, effettivamente, lavorano per contenuti e questi contenuti, molto furbescamente, non sono altro che
gli indirizzi di memoria di una data variabile. Io posso, quindi, passare ad una funzione, anziché il vettore, il
suo indirizzo di memoria, così che la mia funzione ci possa lavorare sopra tranquillamente senza incorrere
in problemi.
INDIRIZZI
Per accedere al contenuto di una variabile o, per meglio dire, per accedere alla locazione di memoria di una
specifica variabile, è necessario utilizzare l’operatore di indirizzo ‘&’.
Quindi, quando vogliamo associare ad un puntatore, l’indirizzo di una variabile, sarà necessario dichiarare
una variabile di tipo puntatore (*px) e, successivamente, associare alla variabile puntatore, l’indirizzo della
variabile da puntare (px = &x).
‘*’: operatore unario di indirezione; ritorna il valore contenuto dal puntatore (cioè dalla variabile puntata).
‘&’: operatore di indirizzo; opera su una variabile e ne ritorna l’indirizzo.
int main()
{
//DICHIARO UNA VARIABILE GENERICA A
int a;
//STAMPO IL COTENUTO DELLA VARIABILE A E IL CONTENUTO DELLA VARIABILE PA, CHE, IN QUESTO
CASO, È L'INDIRIZZO DI MEMORIA DI A
printf("\n a: %d --> pa: %p \n", a, pa);
return 0 ;
}
ISTRUZIONE MALLOC()
L’istruzione malloc() sta per MEMORY ALLOCATION ed è l’istruzione che ci permette di allocare
dinamicamente la memoria.
Strumenti: Void * malloc(<dimensione>); //LA <DIMENSIONE> è il numero di byte da allocare.
char *p;
p=(char*)malloc(10); //DIECI CARATTERI MEMORIZZATI A PARTIRE DALL’INDIRIZZO P.
Oppure:
int *px;
px(int*)malloc10*sizeof(int);
ISTRUZIONE FREE()
La memoria allocata, che non serve più, andrebbe liberata; almeno è buona pratica farlo. Per poter liberare
memoria precedentemente allocata, si utilizza l’istruzione free().
Void free(<puntatore>); //<puntatore> E’ IL PUNTATORE PRECEDENTEMENTE ALLOCATO CON MALLOC()
TYPEDEF
In C esiste il typedef che ha la proprietà di definire nuovi nomi per i tipi di dati; questo permette di rendere
il codice più portabile e più leggibile.
In tal modo non si crea un nuovo tipo, ma si definisce un nuovo tipo e un nome ad esso associato. La
sintassi è la seguente:
struct tm var;
time var;
struct tm *punt;
time *punt;
LISTE LINEARI
Una lista lineare è una collezione di oggetti omogenei (dello stesso tipo), allocata dinamicamente e
memorizzata in maniera non contigua. Occupa, in memoria, una posizione qualsiasi che, tra l’altro, può
essere cambiata dinamicamente durante l’utilizzo della stessa. La sua dimensione non è nota a priori e può
variare nel tempo (l’opposto dell’array, in cui la dimensione è ben nota e non modificabile. Una lista può
contenere uno o più campi contenenti informazioni e, necessariamente, deve contenere un puntatore per
mezzo del quale è legato all’elemento successivo.
IMPLEMENTAZIONE
Una particolarità delle liste (a differenza, ad esempio, degli array) è che sono costituite da due funzioni del
C (strutture e puntatori), quindi non sono un tipo di dato nuovo, basato su implementazioni particolari, ma
solo il risultato di un uso sapiente di tali costrutti.
La lista base ha solo un campo informazione ed un puntatore, come mostrato di seguito:
struct EL{
int Info; //CONTIENE I DATI, QUINDI E’ LA CHIAVE (KEY). VERRA’ UTILIZZATA PER ESEGUIRE
RICERCHE SUGLI ELEMENTI DELLA LISTA
struct EL *Prox; //E’ UN PUNTATORE A UN TIPO (struct EL). QUINDI NEL PUNTATORE E’
CONTENUTO L’INDIRIZZO DI UN ALTRO ELEMENTO DELLO STESSO TIPO. ECCO COME FACCIAMO A MANTENERE I
COLLEGAMENTI TRA I VARI ELEMENTI
};
Per semplicità e per evitare di scrivere troppo codice, definiamo due nuovi tipi, uno per l’elemento e uno
per il collegamento, come di seguito:
Quindi, ogni volta che definiamo una variabile di tipo ElemLista, in realtà è una struct con i campi Info, ecc.
La nostra forma definitiva, usando i nuovi tipi, quindi, sarà:
struct{
TipoElemento info;
PElemLista Prox;
};
Inizialmente, una lista sarà sempre vuota (NULL); la sua dichiarazione sarà:
PElemLista Head = NULL;
INVOCAZIONE: Visita(Head); //TESTA, POICHE’ SI PARTE DAL PRIMO ELEMENTO DELLA LISTA
Inserire un nuovo elemento, ovviamente, significa creare nuovo spazio nella lista e, per fare ciò, dovremmo
avvalerci dell’istruzione malloc.
Il primo inserimento che dobbiamo fare, ovviamente, è un inserimento “in testa”, poiché abbiamo una lista
vuota.
INVOCAZIONE: InserisciInTesta(&Head, 0); //TESTA, POICHE’ SI PARTE DAL PRIMO ELEMENTO DELLA
LISTA PIU’ L’ELEMENTO DA INSERIRE
Stato della lista, al momento della creazione dell’elemento q, valorizzato a 0, in questo caso:
q->Prox = *p;
Collego la testa a q:
*p = q;
Perché, dunque, due implementazioni? Perché, come abbiamo predetto, si potrebbe verificare il caso in cui
in una lista, l’elemento da cancellare, sia proprio la testa e, a quel punto, cancellandolo dobbiamo essere in
grado però di non perdere la concatenazione con gli altri elementi.
p = q = *h; //ASSOCIO AI DUE PUNTATORI, LA TESTA DELLA LISTA; QUINDI ENTRAMBI SONO INIZIALIZZATI
ALLA TESTA
if(p->Info == delElem) //SE IL PRIMO ELEMENTO (TESTA DELLA LISTA) E' L'ELEMENTO DA CANCELLARE...
{
*h = p->Prox; //ALLORA ALLA TESTA ASSOCIO SOLO IL PUNTATORE ALL'ELEMENTO SUCCESSIVO
free(p); //CANCELLO LA TESTA DELLA LISTA
return(1); //TORNO 1 PERCHE' HO ESEGUITO L'OPERAZIONE DI CANCELLAZIONE
}
INVOCAZIONE: eliminaElementoLista(&Head, 0); //TESTA, POICHE’ SI PARTE DAL PRIMO ELEMENTO DELLA
LISTA PIU’ L’ELEMENTO DA INSERIRE
Abbiamo bisogno, infine, di una quantità o per meglio dire, un valore speciale, che ci permetta di
rappresentare uno stack vuoto:
#define VUOTO -1
Passo lo stack per indirizzo (&s) quando ho bisogno di agire su di esso, quindi modificarne i valori
s = stack;
x= elemento dello stesso tipo di quelli presenti nella pila.
INVOCAZIONE: Inizializza(&Stack);
N.B. IL RETURN RITORNA IL VALORE DEL CONFRONTO SE s.top e VUOTO SONO UGUALI. IL TEST, NEL MAIN,
SARA’:
if(!PilaVuota(s)) //LEGGIAMO: SE LA PILA NON E’ PIENA
{
printf(“NON VUOTA!”);
}else{
printf(“VUOTA”);
}
INVOCAZIONE: PilaVuota(s);
INVOCAZIONE: PilaPiena(s);
INVOCAZIONE: Top(s);
INVOCAZIONE: Pop(&s);
typedef struct {
int top;
int Info[N];
}Stack;
s->top++;
s->Info[s->top]=x;
}else{
printf("PILA SATURA\n");}}
q = generica coda;
x= elemento dello stesso tipo di quelli presenti nella coda.
Ad un certo punto, poi, elimino uno degli elementi, il primo (ricordiamo la gestione FIFO), come nel caso in
figura:
Vediamo subito che sia il front che il rear, all’interno del nostro vettore, hanno creato un buco, poiché ho 6
posizioni, quattro elementi, e due posti disponibili.
Bene, ora aggiunto un ulteriore elemento:
Teoricamente ora ho riempito la coda, giusto? Sì, perché se pensiamo che la coda sia piena quando rear =
N-1, allora è vero. Visivamente, però, vediamo che ho un buco libero, buco che potrebbe essere sfruttato
per inserire un altro elemento.
Per fare questo (inserire a ruota gli elementi) abbiamo bisogno dell’operatore %, quello che ci ha permette
di raccogliere il resto di un’operazione. In questo modo, essendo il rear = 5 (poiché abbiamo riempito tutti i
buchi dal front alla fine del vettore), aggiungerò un nuovo elemento, quindi 5+1 = 6, essendo che il nostro
vettore è di N elementi (in questo caso 6), avrò che rear/N = 0 (6/6), quindi ricomincerò da capo con
l’inserimento di un nuovo elemento. Forzerò il rear all’elemento 0, ed inserirò lo stesso nell’apposito spazio
del vettore. Non ci facciamo ingannare, l’elemento in testa non è 2, ma è -1 poiché è indicato dal nostro
front.
Tutte le volte che faccio Enqueue() incrementerò il rear e tutte le volte che faccio Dequeue() incrementerò il
front; sempre utilizzando l'operatore modulo, per cui 5+1 = 0 (5+1 = 6 -> 6 mod 6 = 0).
Abbiamo bisogno, infine, di una quantità o per meglio dire, un valore speciale, che ci permetta di
rappresentare uno stack vuoto:
INVOCAZIONE: InizializzaCoda(&q);
INVOCAZIONE: CodaVuota(q);
INVOCAZIONE: CodaPiena(q);
Enqueue: codice c
void Enqueue(queue *q, int val)
{
if(!CodaPiena(*q)) //SE LA CODA NON E’ PIENA...
{
q->rear=(q->rear+1)%N; //IN PRATICA FACCIO AVVANZARE REAR DI UNA POSIZIONE
q->Info[q->rear]=val; //ASSOCIO IL VALORE AL VETTORE
INVOCAZIONE: Enqueu(&q, val); //VAL E’ IL VALORE CHE VOGLIO INSERIRE NELLA PILA, TIPO 5 O 6
Vengono utilizzate due variabili, front e rear, che indicano rispettivamente la posizione del primo elemento
della coda e dell'ultimo elemento della coda.
Inizialmente, quando la coda è vuota si assume che front=rear=-1.
Quando viene inserito il primo elemento, rear viene posto a 0 e l'elemento viene inserito nel vettore nella
componente di indice rear (ossia 0). Anche front viene assegnato a 0, in quanto è necessario rappresentare
la presenza dell’unico elemento in coda.
Quando un nuovo elemento deve essere inserito successivamente, rear viene incrementato di 1 e
l'elemento viene inserito nella nuova posizione fornita da rear. Si noti che l'incremento dell’indice rear è
circolare.
Dequeue: codice c
void Dequeue(queue *q){
if(!CodaPiena(*q)){
if(q->front==q->rear){
InizializzaCoda(q);
}else {
q->front=(q->front+1)%N; } } }
INVOCAZIONE: Dequeu(&q);
Per quanto riguarda la cancellazione dell'elemento di posizione front, essa viene semplicemente realizzata
incrementando la variabile front. Dopo aver effettuato la cancellazione, è ovviamente necessario verificare
che la coda non diventi vuota, perché in tal caso bisogna aggiornare la variabile rear. Se la coda diviene
vuota, sia front che rear divengono pari a -1. La condizione di coda vuota viene controllata prima della
cancellazione dell'elemento front. Se front e rear sono coincidenti, significa che la coda possiede un solo
elemento, cioè quello che deve essere eliminato. Ciò significa che dopo l'inserimento la coda diverrà vuota.
L’incremento della variabile front deve avvenire in modo circolare, analogamente a quanto visto per la
variabile rear. Ad esempio si consideri la seguente cancellazione di un elemento.
HEAP
Lo heap è una struttura dati in cui le chiavi sono organizzate come un “albero” binario (organizzazione degli
oggetti in ordine gerarchico o a seconda dei livelli; binario poiché ad ogni nodo ci sono poi due diversi rami
collegati). Sono rispettate alcune proprietà legate ai valori e alla struttura dell’albero:
• Proprietà sui valori (heap property);
• Proprietà sulla struttura (shape property).
Heap Property
Shape Property
La proprietà dice che lo heap è un albero completo ad ogni livello tranne (eventualmente) l’ultimo.
Come possiamo vedere, dalla figura, l’albero è effettivamente uno heap poiché rispecchia sia la heap
property che la shape property, infatti l’ultimo livello non è completo per il ramo di destra ma, comunque,
ai livelli superiori lo è.
Ricordiamo che l’albero serve solo per visualizzare le proprietà dello heap, ma fisicamente questa struttura
non esiste, nemmeno in memoria, poiché viene implementata tramite vettore.
q = generica coda;
x= elemento dello stesso tipo di quelli presenti nella coda.
L’implementazione di una coda può avvenire tramite vettore o tramite lista ma, nella pratica, si usa una
struttura dati detta heap che si presta ad una implementazione tramite vettori.
• Heapify: impone la heap property:
o Si applica ad un generico elemento i;
o Assume che i due sottoalberi siano, a loro volta, già degli heap;
• BuildHeap: dato un generico vettore A[1,…,n] lo converte in uno hea;
Heapify: codice c
Heapify(A,i) //A E’ IL VETTORE CHE HA LO HEAP E i IL NODO SU CUI APPLICHAIMO HEAPIFY: A[i]
{
Heapify: esempio
BuildHeap: codice c
Come abbiamo già detto, l’operazione BuildHeap permette di costruire uno heap partendo da un generico
vettore A[1,…,n]. Questo, avviene secondo le seguenti regole:
• Applica Heapify su tutti i nodi interni a partire dal basso;
• Utile nel contesto degli algoritmi di ordinamento.
ADT ALBERO
Abbiamo già capito come è formato un albero poiché lo abbiamo visto quando abbiamo parlato delle
ricorsioni e dello heap. Per ora, dobbiamo vederlo come una collezione di oggetti che ha le seguenti
proprietà:
• Molte informazioni sono strutturare in maniera gerarchie;
• Organizzate su più livello (organigrammi, alberi genealogici, ecc.).
L’elemento più caratterizzante di un albero, è il suo grado. Il grado di un albero ci dice quanti discendenti
può avere un dato nodo.
N.B. La scelta della struttura dati può influire sull’efficienza di un algoritmo; es: accedere ad un elemento.
PROGETTO DI UN ALGORITMO
Come facciamo ad “inventare” un algoritmo?
La struttura del problema da risolvere può essere usata per orientare il progetto dell’algoritmo.
Gli algoritmi sono tipicamente classificati secondo un paradigma metodologico, cioè una stratega
algoritmica generale.
PARADIGMI ALGORITMICI
Paradigmi generali
• Divide and conquer;
• Ricerca ed enumerazione.
RICERCA ED ENUMERAZIONE
La (o una) soluzione al problema è ottenuta tramite una ricerca all’interno di uno spazio delle soluzioni.
PROGRAMMAZIONE DINAMICA
E’ un paradigma molto potente ma non sempre applicabile; si utilizza in presenza di certe caratteristiche.
La soluzione ottima al problema è ottenuta dalla soluzione ottime ai vari SOTTOPROBLEMI.
Quello che faremo, in modo particolare, è studiare in maniera semi-formale (analisi asintotica) la
complessità degli algoritmi.
ASINTOTICO: Nel linguaggio scientifico, ciò che tende ad avvicinarsi sempre più a qualcosa senza mai
raggiungerla o coincidere con essa.
Vediamo, in questa tabella, il tempo di esecuzione ottenuto in secondi T(n) per un certo generico algoritmo.
Adesso assumiamo che, l’elaboratore su cui stiamo lavorando, impieghi per un problema di dimensione 1,
quindi T(1), un micro secondo – T(1) = 1µS.
Semplicemente, questa tabella, ci dice come scala il tempo di esecuzione all’aumentare della quantità dei
dati in INPUT.
Notiamo infatti, come un algoritmo poco efficiente, non solo è molto lento, ma non è nemmeno in grado di
beneficiare dell’evoluzione tecnologica.
ANALISI DI COMPLESSITA’
La notazione asintotica è lo strumento che noi utilizziamo per analizzare in maniera semi-formale la
complessità degli algoritmi.
N.B. Ο,Ω,Θ identificano classi di funzioni, quindi è corretto scrivere la seguente notazione:
f(n) = O(g(n))
L’obiettivo in generale, quindi, è quello di cercare il limite superiore più stretto possibile; altrimenti la
nostra analisi è troppo approssimata.
T(n) = O(g(n)) significa che il tempo di esecuzione, anche nel caso peggiore, è limitato superiormente da
g(n).
Quello che vediamo è che la nostra f(n) per valori di n maggiori di n0 (quindi dimensioni grandi) è limitata
inferiormente da c*g(n).
T(n) = Ω(g(n)) significa che il tempo di esecuzione, anche nel caso migliore, è limitato inferiormente da g(n).
T(n) = Θ(g(n)) significa che nel caso peggiore è O(g(n)) e nel caso migliore è Ω(g(n)) (in pratica non vi è
distinzione fra tempo di esecuzione nel caso peggiore e migliore).
Teta grande esplica il concetto di uguaglianza asintotica.
Transitività
f(n) = Θ(g(n)) e g(n) = Θ(h(n)) à f(n) = Θ(h(n))
Abbiamo detto che Θ equivale ad una uguaglianza quindi, se sappiamo che f(n) = Θ(g(n)) e sappiamo che
g(n) = Θ(h(n)), essendo che rappresentano una uguaglianza, possiamo tranquillamente dire che f(n) =
Θ(h(N)).
Simmetria
f(n) = Ο(g(n)) ßàg(n) = Ω(f(n))
Simmetria trasposta
f(n) = Ο(g(n)) à g(n) = Ω(f(n))
Riflessività
Tutte tre le notazioni godono della proprietà riflessiva:
f(n) = Θ(f(n))
f(n) = Ο(f(n))
f(n) = Ω(f(n))
Somma e massimo
In questo caso stiamo valutando il costo asintotico di una somma di funzioni.
Esempio:
3'& + ' + 7 = Θ 3& = Θ(n& )
COMPLESSITA’ DELL’INSERTIONSORT
Ora vedremo un esempio di calcolo di complessità asintotica di un algoritmo; lo faremo con un esempio.
Es: algoritmo di ordinamento per inserimento (InsertionSort).
Vedete che abbiamo un vettore e, ad ogni iterazione, l’algoritmo provvede a spostare i vari elementi
posizionandoli nel giusto ordine. Ovviamente, ad ogni ciclo, sarà necessario un numero indefinito di
confronti e di scambi.
Ora traduciamo l’algoritmo in pseudocodice; pseudocodice significa che abbiamo, a grandi linee, lo
scheletro del programma ma che, ancora, non è stato tradotto nella sintassi definitiva. Questo basta, ad
ogni modo, per farci un’idea generale e proseguire con la nostra analisi asintotica.
PSEUDOCODICE:
1 for j 2 to n do //for(j=2; j<n; j++)
2 key A[j] //METTO L’ELEMENTO A[j] NELLA VARIABILE
3 //inserisci A[j] nella sequenza A[1...j-1]
4 i j – 1 //i = j-1
5 while i > 0 and A[i] > key do //while(i> 0 && A[i] > Key){
6 A[i+1] A[i] //A[i+1] = A[i]
7 i (i – 1) //i = (i-1)
8 A[i+1] key //A[i+1] = key
9 [j (j + 1)] //j = (j+1)
2 ' =G+H∗'+%∗ 9:
;<&…>
La complessità dipende da tj
Dobbiamo chiederci, dopo i espansioni, come sarà scritta la nostra equazione? Vediamo che dopo ogni
espansione abbiamo un termine costante “c” in più e un termine ricorsivo che si divide per due “T(n/4),
T(n/8), ecc.”. Quindi, alla fine, come espressione generica dopo i passi, avremo c*i+T(n/2^i).
La seconda domanda che dobbiamo porci è, quanto andiamo avanti? Andiamo avanti finché non
raggiungiamo T1, ovvero finché il termine n/2i diventa 1 à log & '
METODO PRINCIPALE/MASTER
Utile perché applica un metodo matematico, sempre, ma non a tutte le ricorrenze.
Le ricorrenze a cui può essere applicato, sono quelle riconducibili al seguente tipo:
Dove a>=1, b>1 sono costanti. F(n) è una funzione asintoticamente positiva.
Abbiamo tre casi a seconda del confronto tra f(n) e '^ log W 4:
1. f(n)< '^ log W 4
2. f(n)= '^ log W 4
3. f(n)> '^ log W 4
Interpretazione asintotica:
1. f(n) = U('XYZ[ \]^ )
2. f(n) = Θ('XYZ[ \ )
3. f(n) = Ω('XYZ[ \]^ ) à T(n)= Θ('XYZ[ \ )
ALGORITMI DI ORDINAMENTO
PERCHE L’ORDINAMENTO?
L’ordinamento è un problema fondamentale ed il più studiato nell’informatica.
Una stima dice che il 25% degli elaboratori nel mondo, stiamo eseguendo programmi di ordinamento dei
dati.
ORDINAMENTO
Con linguaggio matematico è esprimibile come una relazione ℜ su un insieme S tale che, dati a,b,c, Є S.
PROPRIETA’ DELL’ORDINAMENTO
Simmetrica
aℜa
Transitiva
aℜb and bℜc <-> aℜc
Anti riflessiva
aℜb and bℜa <-> a=b
Es: la relazione <= sui numeri reali.
In pratica:
Data una sequenza di n valori A’=(a’1,…,a’n) tale che a’1<=a’2<=…<=a’n.
ASSUNZIONI
• Il tempo di accesso ad un elemento, deve avvenire in tempo costante O(1) – ovviamente
abbiamo un vettore e, sappiamo bene che, un vettore, può accedere direttamente ad un
elemento, tramite il suo indice, indifferentemente dal numero degli elementi;
• La dimensione del vettore è O(n) – ovvero la quantità di dati deve essere conosciuta a priori e,
una volta stanziato il vettore, non lo si può né ingrandire né rimpicciolire;
• Ordinamento senza duplicazione – significa che non possiamo ordinare i dati appoggiandoci su
un secondo vettore di comodo, ma l’ordinamento verrà eseguito, direttamente, sul vettore di
partenza.
N.B. gli algoritmi quadratici trovano applicazione solo a livello teorico; nella pratica non sono molto
utilizzati poiché non performanti. Degli algoritmi logaritmici, il quicksort è quello meglio implementabile e
funzionale.
HEAPSORT
E’ un algoritmo di ordinamento basato sullo ADT CODA A PRIORITA’ come struttura dati. Esso permette
un’efficiente estrazione del min/max da una sequenza.
L’efficienza degli heap sta proprio nel costo di queste due primitive.
Heap e ordinamento
Uno heap, per come è strutturato, rappresenta un ordinamento parziale (solo su alcuni insiemi di valori) su
un insieme.
Noi possiamo riassettarlo, con costo lineare, per ottenere un algoritmo che predisponga, in partenza, una
sequenza ordinata attraverso la Heapify.
Heap: implementazione
Il nostro algoritmo è composto in due fasi:
1. Trasforma il vettore in heap (BuildHeap);
2. Costruiamo la nostra sequenza ordinata partendo dal massimo – avviene con una eliminazione
ripetuta del massimo con mantenimento della proprietà dello heap (iterazione Heapify).
Heapsort: pseudocodice
N.B. SIAMO IN UNO MaxHeap
HeapSort(A) //A E’ UN GENERICO VETTORE DI DIMENSIONE N – A[n]
{
BuildHeap(A,n); //COSTRUISCO LO HEAP PASSANDO IL MIO VETTORE E LA SUA GRANDEZZA
for i ← n downto 2 //CICLO DI FOR ESEGUITO n-1 VOLTE à for(i=n; i>=1; i-2)
swap(A[1], A[i]) //SCAMBIA IL PRIMO ELEMENTO DELLO Heap (IL PIU’ GRANDE) CON ELEMENTO iESIMO
//LO SCAMBIO PROVOCA UNA PERTURBAZIONE DELLO Heap, quindi A NON E’ PIU’ UNO
//Heap
n ← n-1 //DECREMENTO LA QUANTITA’ DI n; IN PRATICA GLI DICO DI CONCENTRARSI SOLO
SULLA //SULLA RESTANTE PARTE DEGLI ELEMENTI, POICHE’ L’ULTIMO ELEMENTO ORA E’ IL
//MASSIMO
Heapify(A,1) //INVOCO Heapify, QUINDI RIPRISTINO LE PROPRIETA’ DELLO Heap
}
Heapsort: esempio
Sequenza non ordinata di 8 elementi; ovviamente non è uno heap.
PRIMO PASSO
Scambia il primo elemento, quindi 12, con l’ultimo, quindi 2 (swap A[1], A[i]).
Applico la Heapify e riottengo una riorganizzazione dei valori fino ad avere uno Heap.
N.B. vediamo che il numero due è stato riposizionato come ultimo elemento del
vettore. Ovviamene 12 lo vediamo ma non lo consideriamo.
SECONDO PASSO
La nostra struttura non era più uno heap, dopo l’applicazione di Heapify otteniamo
nuovamente uno heap.
TERZO PASSO
All’ultima iterazione, avremo un solo elemento che, per forza, sarà uno heap e, a
questo punto, abbiamo terminato il nostro algoritmo.
Tutti i casi hanno costo O(nlogn), non abbiamo un caso migliore e uno peggiore, anche se avessimo un
vettore ordinato al contrario.
QUICKSORT
È un algoritmo basato sul paradigma DIVIDE AND CONQUER (DIVIDE-ET-IMPERA) – DIVIDI E RISOLVI così
chiamato perché ha, in generale, prestazioni migliori tra quelli basati sul confronto.
Il funzionamento concettuale del QUICKSORT, consiste di tre fasi:
1. Scegliamo un elemento (pivot – è il nostro elemento di riferimento);
2. Sulla base del nostro pivot, riorganizziamo il vettore – riorganizzare il vettore significa eseguire una
sorta di ordinamento parziale che vede la suddivisione del vettore in due partizioni:
a. Tutti gli elementi <= del pivot spostati prima del pivot (partizione di sinistra);
b. Tutti gli elementi >= del pivot spostati dopo il pivot (partizione di destra).
c. per la Transitiva sappiamo che gli elementi della partizione di sinistra sono <= di quella
destra e gli elementi della partizione di destra sono >= di quella di sinistra.
3. Ordiniamo ricorsivamente le due partizioni.
Quicksort: pseudocodice
QuickSort(A,p,r) //A E’ IL VETTORE, p E r SONO GLI ESTREMI SINISTRA E DESTRA DEL
VETTORE
{
if (p < r) { //SE L’INDICE SINISTRO DELLA PARTIZIONE E’ MINORE DELL’INDICE DESTRO
q = Partition(A,p,r) //q SARA’ L’INDICE DI MEZZERIA DELLE DUE PARTIZIONI
QuickSort(A,p,q) //ORDINO GLI ELEMENTI PRIMA DEL PIVOT (DA p A q)
QuickSort(A, q +1 ,r) //ORDINO GLI ELEMENTO DOPO DEL PIVOT (DA q+1 A r)
}
}
}
q divide il vettore in due sotto vettori, tali che gli elementi di A[p,q] <= elementi di A[p+1,r]
Quicksort: partition
È chiaro come appaia subito che l’intelligenza del Quicksort() sta tutta nella funzione Partition().
La funzione Partition() esegue i seguenti passi:
1. Scelta del pivot;
2. Generazione dei due sotto vettori.
while A[i] x
if (i < j){ Scambio i valori
swap (A[i],A[j]);
} else {
return j; //j RAPPRESETA IL PUNTO DI MEZZERIA DELLA NOSTRA PARTIZIONE; SARA’ q
}
Endwhile //IL CICLO CONTINUA FIN QUANDO NON SI SONO SCAVALCATI GLI INDICI
}
Quicksort: esempio
Scelgo un generico vettore A di 8 elementi:
A[53264137]
Partition (livello 0)
Il nostro pivot (x) come abbiamo detto, assumerà il valore del primo elemento del vettore (5 in questo
caso).
Inoltre vediamo l’estremo sinistro (p) sia valorizzato a indice 0 e l’estremo destro (r) a indice 7.
Per ultimo notiamo che i due i due indici i e j vengono posizionati all’esterno del vettore; i = -1.
Dopo le prime due iterazioni del nostro ciclo, vediamo che i e j hanno rispettivamente assunto i valori 0 e 6,
questo significa che siamo in grado di confrontare il numero 5 (in questo caso il pivot) per la partizione
sinistra e il numero 3 per la partizione di destra; ovviamente entrambi sono fuori posto, poiché 3 è più
piccolo e andrebbe a sinistra e 5 è maggiore di tre, quindi andrebbe a destra. Li scambio.
In questo momento abbiamo che il vettore A ha una partizione di sinistra che va da A[p,q] e una partizione
di destra che va da A[q+,r].
Partition (livello 1)
Con l’esempio vediamo come, il vettore, viene, di volta in volta, suddiviso in sotto vettori che sono
analizzati e ordinati. Tutto questo viene fatto senza toccare le alte partizioni quindi senza rispostare o
modificare i dati già precedentemente ordinati.
Quicksort: analisi
E’ importante, ovviamente, valutare la complessità dell’algoritmo di Quicksort e, di conseguenza, il suo
costo. Possiamo avere, in particolare, tre casi:
1. Caso migliore;
2. Caso medio;
3. Caso peggiore.
Diciamo quindi, come commento generale, che per gli algoritmi di ordinamento è relativamente facile
svolgere il loro compito con una complessità buona O(nlogn) e i vincoli sono abbastanza significativi.
Il problema è che su un insieme di dati molto grandi potrebbe comunque essere una limitazione; per
questo motivo ci poniamo l’obbiettivo di ottenere una complessità lineare O(n), ricordandoci che per
andare oltre il limite teorico è necessario rilassare una o più di queste assunzioni.
for i=1 to n //PER OGNI ELEMENTO DEL VETTORE, DEVO FARE UN ISTOGRAMMA
C[A[i]] C[A[i]]+1; //IN PRATICA, PER OGNI ELEMENTO, MI SEGNO QUANTE VOLTE COMPARE;
CIOE’ IL NUMERO 1 COMPARE 2 VOLTE, IL NUMERO 5 COMPARE 1 VOLTA, ECC.
for i=2 to k
C[i] C[i] + C[i-1]; //SAPRO’ QUANTI ELEMENTI SONO <= DI UN CERTO ELEMENTO
T(n) S(n)
Fase I O(n) O(k)
L’insieme da cui le chiavi sono prese, può essere ordinato; in questo caso alcune operazioni non sono
significative.
OPERAZIONI
Interrogazioni
Forniscono informazioni sull’insieme.
Operazioni di modifica
Modificano l’insieme.
S = generico insieme.
*Se S è ordinato.
Ritorna l’elemento di S con valore di chiave più grande o NULL se S è vuoto.
DIZIONARI
Un insieme dinamico che fornisce solo le operazioni Search(), Insert(), Delete() è detto dizionario.
Quando l’insieme è totalmente ordinato, si parla di dizionario ordinato.
PRIMA DI DECIDERE, VEDIAMO QUAL E’ IL LORO COSTO ASINTOTICO (CI BASIAMO SOLO OP. BASILARI).
PROPRETA’ DI UN BST
Per ogni nodo con chiave x:
• I nodi nel sottoalbero di sinistra hanno chiavi <= di x;
• I nodi nel sottoalbero di destra hanno chiavi >= di x.
Per ovvie ragioni, essendo che abbiamo già parlato degli alberi, sappiamo che le primitive sarebbero
intrinsecamente ricorsive ma, per ragioni di efficienza, la maggior parte è iterativa.
Ricerca di un BST
Dato un generico elemento da ricercare “x”:
• Se x è < della chiave della radice, cerca nel sottoalbero di sinistra;
• Se x è > della chiave della radice, cerca nel sottoalbero di destra;
• Se x è = alla chiave della radice, trovato.
In realtà, quando si parla di ricerca di un BST, dobbiamo sapere che quasi sempre ci si riferisce a
quella iterativa; sebbene ne esista una versione ricorsiva, non è facile risalirne al costo poiché
sarebbero necessarie delle assunzioni. Noi le vedremo entrambe, ma è richiesta quella iterativa.
Visita di un BST
Le visite (il modo in cui elenchiamo gli elementi appartenenti ad un insieme dinamico implementato con un
BST) sono classificate in base all’ordine dei visita dei nodi:
• Inorder (infisso);
• Preorder (prefisso);
• Postorder (postfisso).
Tutte queste funzioni hanno complessità Θ(n) poiché, parlando di una visita, sappiamo dover “toccare” tutti
gli elementi.
if (key[u] < key[x]) then //SE LA CHIAVE DEL NODA DA INSERIRE E’ MINORE DI x
x left[x] //x SEGUE IL PUNTATORE A SINISTRA
else //ALTRIMENTI
x right[x] //x SEGUE IL PUNTATORE A DESTRA
endwhile //ESCO DAL CICLO QUANDO x E’ = A NULL
v y //v DIVENTERA’ IL GENITORE DI U
// GESTIONE DEI CASI PARTICOLARI
if (v = NULL) then //v E’ NULL SE L’ALBERO E’ VUOTO - CASO PARTICOLARE I
root[T] u //SE VUOTO AVRA’ COME VERTICE IL NUOVO ELEMENTO
else //ALBERO NON VUOTO – CASO PARTICOLARE II
if (key[u] < key[v]) //SE u E’ MINORE INSERISCO COME FIGLIO SINISTRO
left [v] u
else //SE u E’ MAGGIORE INSERISCO COME FIGLIO DESTRO
right[v] u
endif
}
Siamo nel caso particolare II, dove ho un albero già esistente (T != NULL)
e devo inserire un nuovo elemento (u=5).
Secondo lo pseudocodice avremo che x viene inizializzato alla radice e y
a NULL: x = root(T); y = NULL
Cominciamo a scendere muovendo x e y finché non è uguale a NULL
Arrivo fino al punto in cui u è maggiore di x (u=5 > x=4) e dovrei andare
a destra, mi accorgo che spostando x questo assume valore NULL
(posizione libera per l’inserimento) ed esco dal ciclo di while.
v è uguale a y (4): v y
come figlio di v, sul disegno vediamo l’ovvietà, ma su codice dobbiamo chiederci ancora se u < x o u > x: if
(key[u] < key[v])
Entreremo nel ramo else, poiché u > x e inserirò l’elemento: right[v] u
return y
}
TABELLE HASH
Le tabelle hash sono, in pratica, una implementazione di insieme dinamici non ordinati che permettono dei
tempi di accesso per inserimenti, ricerca e cancellazione molto veloci.
• Se la cardinalità è molto grande, quindi |U|, è impossibile riuscire ad allocare una tabella m=|U|;
• Se n << m (se il numero di elementi del sottoinsieme è molto più piccolo rispetto l’universo), avrò
che l’utilizzazione della tabella è basso (fattore di carico n/m) – per capire meglio, dobbiamo
pensare al fatto che avrò allocato uno spazio molto molto grande, per poi memorizzare,
effettivamente, un numero di dati molto basso.
ESEMPIO:
Facciamo finta di doverci occupare del sistema di immatricolazione della nostra università; sappiamo che
quasi tutte utilizzano, per le matricole, un numero di cifre pari a 6, quindi avremo un universo che va da 0 a
999999, di conseguenza la nostra tabella sarà pari a:
T[0,…,999999].
Supponiamo, poi, che il numero di studenti iscritti sia pari a 100:
TABELLA HASH
Abbiamo capito che le tabelle ad accesso diretto sono soltanto ipotizzabili teoricamente e non facilmente
applicabili nella pratica; la loro possibilità di applicazione è resa possibile dalle tabelle HASH.
La tabella HASH, infatti, è sempre una tabella basata su un vettore, ma, al contrario di quelle ad accesso
diretto, si basa sull’osservazione che soltanto una parte delle chiavi viene effettivamente utilizzato.
La tabella HASH, quindi memorizza in una tabella T di dimensione m, dove m, però, è molto più piccola del
nostro universo. T: m << |U|.
Come facciamo, però, a mappare un insieme di cardinalità di U su un insieme più piccolo di dimensione m?
Attraverso una funzione che definisce un indice h(k) (k è la mia chiave, h è la funzione) a partire da un
valore k, questa funzione è detta funzione di HASH.
ESEMPIO:
Come vediamo dal nostro esempio, una tabella HASH ci permette di risparmiare molto spazio ma, tuttavia,
è possibile che due chiavi (ad esempio 3 e 19) collidano nella stessa posizione.
FUNZIONI DI HASH
Vediamo le caratteristiche che identificano le funzioni di HASH:
Å K = 1/É
Ñ:Ü Ñ <;
J=0,1,…,m-1
Il problema è che tipicamente non è controllabile perché P è sconosciuto; In pratica si usano delle
approssimazioni.
• Concatenazione;
• Indirizzamento aperto.
Stiamo dicendo che si procede per tentativi: ho una chiave, ne calcolo il valore di HASH, se la posizione
corrispondente di una tabella è libera, la inserisco, se la posizione è occupata, ricalcolo un altro valore.
Giacomo Maccanti – g.maccanti@students.uninettunouniversity.net 75
ALGORITMI E PROGRAMMAZIONE AVANZATA - G.MACCANTI@STUDENTS.UNINETTUNOUNIVERSITY.NET
Questo è apparentemente banale poiché si potrebbe supporre che il ricalcolo potrebbe avvenire su base
“casuale”; dobbiamo però fare attenzione al fatto che questa funzione viene utilizzata anche per cercare un
valore, quindi la sequenza di tentativi che viene prodotta deve essere ripetibile e riconducibile.
• LINEAR PROBING;
• Quadratic probing;
• Double hashing.
h : U x {0,1,…,m-1} {0,1,…,m-1}
k = chiave
i = numero di tentativo (try)
A questo punto avremo che la funzione h() genererà una sequenza di probing (tentativi): permutazione
degli indici <0,1,2,…,m-1>
La HashInsert e la HashSearch seguono la sequenza di probing fino a che non si trova una posizione libera.
La HashDelete (cancellazione di un elemento) comporta la “rottura” della sequenza di probing, quindi
possiamo affermare che l’indirizzamento aperto non è adatto se è richiesta la cancellazione degli elementi”.
LINEAR PROBING
E’ forse il più facile tra tutti i meccanismi di probing; serve per valutare diverse posizioni generando una
certa sequenza di probing.
Paradigmi generali
• Divide and conquer;
• Ricerca ed enumerazione.
PROGRAMMAZIONE DINAMICA
La programmazione dinamica nasce proprio dal fatto che l’algoritmo divide-et-impera tende a generare un
numero esponenziale di sotto problemi quando, invece, il numero di sotto problemi è in genere molto
minore.
La programmazione dinamica evita di risolvere lo stesso sotto problema più volte attraverso la
memorizzazione delle soluzioni già compiute e che sono riutilizzabili.
Attenzione che questo tipo di algoritmo non sostituisce il divide-et-impera ma, semplicemente è un
paradigma che può essere implementato per problemi di ottimizzazione, quando si verificano:
• Più soluzioni;
• Soluzioni con “punteggio”;
• Cerca la soluzione con “punteggio” più alta.
• Sottoproblemi indipendenti;
• Risolve ogni sottoproblema ricorsivamente in modo top-down.
Programmazione dinamica
• Caratterizzazione della struttura di una soluzione ottima (come è definita una soluzione
ottima? Gode della proprietà della sottostruttura ottima);
• Definizione ricorsiva del valore di una soluzione ottima (la soluzione ottima in funzione ai
sottoproblemi);
• Calcolo bottom-up del valore della soluzione ottima (memorizza soluzioni in una tabella a
partire da quelle ai problemi elementari);
• Costruzione di una soluzione ottima (solo se ci serve anche la soluzione e non solo il valore).
IL CAMBIO DI MONETE
DATI:
• Sia dato un intero n > 0 (pensiamo di andare ad un banco di cambio monete in una certa valuta
portando una certa quantità di euro).
• In quella valuta esistono un insieme di banconote D e queste hanno valori {d1,…,dm} (immaginate i
vari tagli da 5€, 10€, ecc.);
• Noi vogliamo calcolare il minimo numero di banconote in cui la quantità può essere cambiata.
IN TOTALE 9 BANCONOTE
OVVIAMENTE QUESTA NON E’ UNA SOLUZIONE OTTIMA
Supponiamo che io abbia un certo n > 0 (quantitativo da cambiare) e che, come riportato in figura, le
banconote (5 banconote) siano esattamente il cambio migliore che mi può essere fornito:
Quindi abbiamo per b e per n-b due soluzioni ottime; possiamo risolvere la nostra ricorrenza dal basso.
ALGORITMO: FASE 2
Scriviamo la ricorrenza attraverso la definizione ricorsiva della soluzione ottima.
Leggiamo C(p):
• caso terminale – per p = 0, C(p) vale 0. Ovvio che se dobbiamo totalizzare una quantità pari a 0,
qual è il numero minimo di monete per totalizzare 0? Ovviamente 0;
• Per p != 0 – scegliamo la più piccola moneta tra tutte le monete che possono variare la mia
quantità.
ALGORITMO: FASE 3
Change (d[],n){ //d E’ IL VETTORE DELLE MONETE, N LA QUANTITA’ DA
CAMBIARE
C(0) 0 //IL VETTORE c VIENE IMPOSTATO A 0 CHE E’ IL VALORE
TERMINALE
for p 1 to n //PER p CHE VA DA 1 A n – QUINDI n ITERAZIONI
min //INIZIALIZZIAMO AD UN VALORE MOLTO GRANDE
for all d[i] <= p //PER TUTTI I VALORI DI MONETE CHE STANNO IN p
if 1 + C(p−d[i]) < min then //SE LA SOLUZIONE DEL MIO PROBLEMA E’ MINORE RISPETTO
AL MINIMO ATTUALE
min 1 + C(p − d[i]) //ALLORA AGGIORNA IL MINIMO
coin i
C(p) min
}
C vale zero:
C(0)=0
Parte il ciclo for che va da p = 1 a 31; attualmente abbiamo solo la soluzione per C che vale 0, quindi, qual è
l’unica monete che può cambiare 1? Ovviamente 1 perciò d[1]=1:
p=1 d[1]=1
C(1) è il minimo di tuti i valori possibili (ne ho solo uno possibile al momento) e applico quindi la ricorrenza;
scelto la moneta che ha costo 1 più la soluzione ottima del sottoproblema residuo:
C(1)=min(1+C(1-1)) = 1
…
Da 1 a 7 non cambierà molto, poiché 1 è l’unica moneta che, al momento, può fare la differenza:
p=7 d[1]=1
C(7) =min(1+C(7-1)) = 7
Le cose cambiano valutando C(8), perché applichiamo effettivamente la programmazione dinamica, che
valuta le opzioni e ci dà le varie possibilità:
p=8 d[1]=1
C(8) =min(1+C(8-1)) = 8
d[2]=8
C(8) =min(1+C(8-8)) = 1
OSSERVAZIONE
La versione del problema in cui un oggetto si prende interamente non è risolvibile in modo ottimo con una
scelta greedy è richiesta la risoluzione della programmazione dinamica.
Selezione di attività
E’ un problema che fa parte di scheduling, quindi assegnazione di attività nel tempo.
Il problema prevede n attività S={1,2,…,n} in competizione tra loro per l’utilizzo di una certa risorsa.
Noi dobbiamo determinare il massimo sottoinsieme di attività per l’utilizzo della risorsa senza conflitti.
In pratica stiamo dicendo: quante attività possiamo assegnare a questa macchina senza che si verifichino
dei conflitti, cioè non è possibile che due attività simultaneamente utilizzino questa macchina?
Due attività i e j sono compatibili se gli intervalli [si,fi] e [sj,fj] sono disgiunti, ovvero fj ≤ si (attività j finisce
prima che inizi l’attività i) oppure fi ≤ sj (attività i finisce prima che inizi l’attività j).
L’algoritmo fornisce la risposta dicendoci che il massimo di attività compatibili è tre e sono:2, 3 e 6.
I problemi risolubili in modo ottimo da una strategia greedy devono godere di due proprietà fondamentali:
• Sottostruttura ottima;
• Proprietà della scelta greedy.
Sottostruttura ottima
Già la conosciamo, una sottostruttura ottima prevede che tutti i sottoproblemi siano risolti in modo
ottimale con un approccio bottom-up.
• La scelta di un algoritmo greedy è un algoritmo che può dipendere dalle scelte fatte in precedenza;
prendiamo come esempio quello fatto sulla Selezione di attività dove notiamo che, una volta scelto
il due o il tre, quella scelta influenzerà le scelte future;
• Non può dipendere da tutte le soluzioni al sottoproblema;
• Dopo aver preso una decisione questa non viene più riconsiderata; pensiamo sempre al problema
sella selezione di attività, una volta scelto il due, questo non verrà più considerato.
ESEMPIO
Se io dovessi calcolare tutti i divisori di un numero n?
Soluzione
Enumera tutti gli interi da 1 a n e verifica che dividano esattamente n.
Ricerca implicita
Analizziamo con più cura lo spazio delle soluzioni e “potiamo” (pruning) quelle non necessarie. Implica
usare proprietà dello spazio delle soluzioni.
ESEMPIO
Calcolare tutti i divisori di un numero n.
SOLUZIONE
Enumera tutti gli interi da 1 a n (saltando i multipli di quelli che hai già verificato) e verifica che dividano
esattamente n.
ESEMPIO
Vogliamo enumerare tutte le parole di 3 bit con 2 o più “1” (000,001,010,…,111).
Al momento, non essendoci una soluzione immediata, dobbiamo enumerare tutte le possibilità.
Essendoci 3 bit e solo due valori, abbiamo 2^3 = 8 possibilità = spazio di ricerca.
Cosa osserviamo, a livello operativo, di questo albero? Che ad ogni livello abbiamo sempre due decisioni,
una decisione che associa sempre l’iesimo valore a 0 (sinistra) e a 1 (destra). Notiamo che ogni livello del
nostro albero specifica un bit successivo; primo livello bit 1, secondo livello bit 2 terzo livello bit 3. L’altezza
dell’albero è uguale per ogni ramo ed è pari a 3.
Ovviamente, per quanto riguarda la costruzione di un programma per risolvere questo problema, ci pare
anche troppo complicato un albero decisione; basterebbe, infatti, tre cicli for per scorrere tutte le varianti.
Pensiamo, però, a n elementi; cosa facciamo? Inseriamo n cicli for nel nostro algoritmo? Se avessimo 1000
elementi? 1000 cicli for?
ALGORITMO DI BACKTRACKING
Il backtracking è la parola inglese per “tornare indietro”. Pensate ad una serie di strade, diramate, le
percorriamo tutte per raggiungere la nostra meta, la nostra soluzione, ma arriviamo ad una strada a fondo
chiuso, dobbiamo allora tornare indietro (backtracking). Backtracking è il nome che diamo ad una variante
dell’approccio “brute force” in cui viene sistematicamente esplorato lo spazio delle soluzioni.
Sistematicamente significa visitare in preordine l’albero delle decisioni.
Pensiamo, prima di tutto, alla soluzione finale, ovvero ad un vettore di n oggetti composto da elementi a(1),
a(2),…,a(n).
• Tutte le soluzioni;
• Una soluzione ottima.
E’ sufficiente eliminare le condizione di successo dal ciclo.
Algoritmo di backtracking calcolo di tutte le soluzioni: pseudocodice
Backtrack (k)
for (i = 1 …m)
scegli il k-esimo candidato
if (accettabile)
memorizzalo
if (k < n)
Backtrack (k + 1)
else
stampa la soluzione
cancella dalla soluzione
endfor
I GRAFI – PARTE I
I GRAFI
In molte applicazioni non sono rilevanti gli oggetti di un insieme, ma anche le relazioni tra di esse.
Pensate ad una mappa, dove gli oggetti potrebbero essere i punti di riferimento di questa mappa (casa,
lavoro, supermercati, aeroporti, ecc..) chiaramente ci interessa la relazione che ci sono tra questi oggetti,
tra queste località come l’esistenza di connettività: esiste una strada, un percorso, ecc. Possiamo fare
numerosi esempi di questo tipo. Pensate a internet; ci sono innumerevoli server sparsi in tutto il mondo e
noi abbiamo bisogno di avere determinate metriche, come la banda, tra un punto e l’altro.
DEFINIZIONI E PROPRIETA’
Un grafo G può essere visto come una coppia (V,E) dove:
• V è l’insieme dei vertici – disegnati con i cerchi;
• E è l’insieme degli archi – disegnati con le rette.
V = {A,B,C,D,E,F}
E = {(A,B),(A,D),(B,C),(C,D),(C,E),(D,E)
Vediamo che gli archi sono indicati con i vertici che mettono
in relazione; non tutti gli oggetti sono in relazioni tramite gli
archi: il vertice F non è in relazione con nessun altro.
Un arco è una coppia di vertici (v,w) per cui v є V e w Є V;
stiamo appunto dicendo che un arco viene identificato con
la notazione tra parentesi e tramite i vertici che esso connettono separati da virgola.
Relazione binaria significa che è una relazione collega un singolo vertice ad un singolo altro vertice.
V = {A,B,C,D,E,F}
E = {(A,B),(A,D),(B,C,),(D,C),(E,C),(D,E),(D,A),(F,F)}
I grafi orientati possono rappresentare un certo tipo di
informazione e relazione tra oggetti, intesa come senso di
dipendenza: A dipende da B o viceversa.
Gli archi (A,D) e (D,A) sono archi distinti.
V = {A,B,C,D,E,F}
E = {(A,B),(A,D),(B,C,),(C,D),(C,E),(D,E)}
Gli archi (A,D) e (D,A) sono lo stesso arco.
ESEMPIO DI SOTTOGRAFO H:
Un cammino non è altro che un insieme di vertici e relativi archi che gli uniscono.
La lunghezza di un cammino <w1,w2,…,wn> è n–1 (numero di archi che compongono quel cammino).
<A,B,C,E>: semplice;
<A,B,C,E,D,C>: non semplice perché passo da c due volte;
<A,B,C,D,A>: ciclo, poiché ritorno al vertice di partenza.
Un grafo non orientato G è connesso se esiste un percorso tra ogni coppia di vertici (tutte le possibili coppie
di vertici sono connesse tramite un cammino).
5 vertici à 10 archi
RAPPRESENTAZIONE DI GRAFI
Per quanto riguarda i nostri grafi dobbiamo raffigurare i vertici e la loro connettività, noi abbiamo bisogno
di due rappresentazioni canoniche:
• Matrice di adiacenza;
• Lista di adiacenza.
ADT GRAFO
Vediamo come implementar il grafo come tipo di dato astratto.
Intanto, come prima cosa, dobbiamo definire il tipo di implementazione da utilizzare e poi le eventuali
strutture dati tipiche.
ARCHI
typedef struct edge *EdgePtr; //EdgePtr IDENTIFICA UN PUNTATORE ALLA STRUCT edge
typedef struct edge { //DICHIARAZIONE DELLA STRUCT edge (ARCHI)
int Weight; //PESO DELL’ARCO – NON ESISTE SE IL GRAFO NON E’ PESATO
// altri eventuali campi
NodePtr NextNode; //PUNTATORE CHE INDICA QUESTO ARCO QUALE VERTICI CONNETTE
EdgePtr NextEdge; //PUNTATORE CHE COLLEGA ALL’ARCO SUCCESSIVO
} Edge;
struct node
struct edge
Vertice
Lista adiacenza
Non è richiesta nessuna speciale struttura dati per le liste di adiacenza. Tutte le primitive richiedono ricerca,
inserimento, cancellazione da/in una lista bidimensionale.
VISITE DI UN GRAFO
Per visita di un grafo intendiamo l’attraversamento di tutti i suoi vertici.
Le applicazioni sono molto importanti, pensiamo ad esempio alla necessità di capire se un certo vertice
raggiunge un dato vertice, oppure verificare l’esistenza di cicli, o, ancora, verificare la connettività.
• Visita in ampiezza: scopre tutti i vertici a distanza n (n° di archi) dalla sorgente prima di scoprire
quelli a distanza n+1;
• Visita in profondità: scopre tutti i vertici adiacenti al vertice scoperto per ultimo.
Abbiamo detto che si parte scoprendo i vertici vicini alla sorgente e poi quelli a distanza n+1 (quindi quelli
adiacenti a quelli adiacenti):
Dopodiché scopro quelli adiacenti a B e D, quindi per B sarà C e per D sarà ancora C ed E; per ultimo
vediamo che C può visitare nuovamente E, già visitato da D, e F, non ancora visitato:
Partendo da A, visitiamo B:
Da B, possiamo visitare solo C, dopodiché da C possiamo andare o in D o in E; abbiamo detto che si procede
in ordine alfabetico, quindi andiamo verso E:
Con E non posso andare da altre parti, quindi torno a C, che va a F, che non può continuare quindi torno a
C, che non può continuare, quindi torno a B, che non ha altre vie, quindi torno ad A, che può andare verso
D. Ci fermiamo poiché abbiamo già visitato sia C che E:
Costo: O(V)
Può essere importante ricorda il vertice da cui sono arrivato nello scoprire un vertice. Associamo un
attributo P[u] (predecessore) ad ogni vertice; inizialmente a NULL.
Dopo la Visit(G,s), solo i vertici neri eccetto s hanno predecessore non nullo, con le seguenti proprietà:
• Vp – tutti i vertici neri raggiungibili da s;
• Gp – albero o foresta di alberi.
Per la gestione dei vertici grigi, usiamo una struttura dati D i cui elementi sono ordinati:
• create_empty – crea una struttura vuotoa;
• first(D) – preleva il primo elemento (no modifiche);
• add(D,x) – aggiunge x a D;
• remove_first(D) – elimina il primo elemento da D;
• not_Empty(D) – ritorna TRUE se D non è vuoto, FALSE altrimenti.
Perché i vertici grigi? Perché quelli bianchi non sono ancora stati toccati, quelli neri sono ormai inutili
poiché visitati, quelli grigi, invece, devono finire di essere visitati.
La visita in ampiezza calcola la “distanza” (n° di archi) di ogni vertice dalla sorgente. Questo genera un
albero di visita (insieme aciclico di vertici) che ha:
• S come radice;
• Include tutti i vertici raggiungibili dalla sorgente;
• Ogni cammino dalla sorgente ad un vertice è minimo (nel n° di archi) – in pratica stiamo costruendo
il cammino più breve.
ESEMPIO
ESEMPIO
Possiamo dire, in generale, che la visita in profondità somiglia molto alla visita infissa di un albero.
La visita in profondità (DFS) viene implementata in modo ricorsivo.
ETICHETTE TEMPORALI
La visita in profondità può essere arricchita attraverso l’informazione di tempo; ovviamente un tempo
virtuale.
In particolare noi ci memorizziamo due attributi:
• d[v]: tempo in cui v viene scoperto (grigio) – incrementato ogni volta che un vertice viene scoperto;
• f[v]: tempo in cui v è processato (nero) – incrementato ogni volta che un vertice viene chiuso.
Ovviamente, non connettendo ogni singola casa alla centrale; sebbene il problema sia risolto non abbiamo
minimizzato per niente il nostro costo.
• Modello: grafo;
• Vertici: case;
• Archi: possibili connessioni;
• Pesi: costi di posatura.
La soluzione è un MST.
(b,c) sicuro.
• Un taglio (S,V-S) di un grafo non orientato G=(V,E) è una partizione di V – stiamo dicendo che
l’insieme dei vertici è diviso in due parti, una chiamata S, l’altra chiamata V-S;
• Un arco attraversa il taglio se uno dei suoi vertici appartiene alla partizione S e l’altro alla partizione
V-S;
• Un taglio rispetta un insieme A di archi se nessun arco in A attraversa il taglio;
• Un arco è leggero se il suo peso è il minimo tra tutti quelli che attraversano il taglio.
Il concetto è quello di valutare il peso degli archi che attraversano questo taglio.
ESEMPIO
Definiamo un taglio (la linea verde a punti . . . ),
questo taglio è il risultato della partizione dei
vertici S che sono quelli che stanno graficamente
sopra il taglio S{a,b,d,e}; e di V-S che sono i
restanti, quindi quelli che sono sotto il taglio V-
S{c,f,g,h,i}.
OSSERVAZIONI
ESEMPIO
Inizialmente il mio insieme A ← è vuoto; quindi non ha
elementi ne vincoli iniziali.
Abbiamo 4 componenti connesse (a,b,c,d).
Ora devo aggiungere l’arco sicuro, cioè quell’arco con peso
inferiore e che connette due componenti connesse; quindi a,b.
Lo aggiunto.
ALGORITMO DI KRUSKAL
L’algoritmo di Kruskal è basato sulla costruzione del MST tramite la connessione delle varia componenti
connesse.
E’ un algoritmo greedy perché ad ogni passo aggiungiamo alla foresta, l’arco di peso minore.
Quello che noi vogliamo studiare è: dato un grafo orientato G=(V,E), con una funzione di peso w: E → R un
percorso minimo tra due vertici u e v è un cammino per cui: w(p) = δ(u,v).
Questa astrazione del problema, ci suggerisce due varianti possibili per risolvere il problema:
Sorgente singola: tra un vertice (sorgente) e tutti gli altri vertici;
Sorgente multipla: tra tutte le coppie di vertici.
SORGENTE SINGOLA
Ad esempio voglio sapere la distanza tra Torino (sorgente) e Bologna vediamo nella figura qual è la distanza
minima. Però, se prendo Torino come sorgente, posso anche calcolare la distanza minima tra tutte le altre
destinazioni.
Questo appare facilmente intuibile, poiché se noi immaginiamo un percorso minimo s ad un vertice v, come
segue:
p’
s-------------->u----->v
Immaginate il percorso minimo tra s e v, passando per u, possiamo quindi dire, intuitivamente che il percorso
s → u è a sua volta un percorso minimo ottimo chiamato p’.
Il peso minimo del cammino minimo da s a v è: δ ( s , v ) = δ ( s , u ) + w ( u , v )
RILASSAMENTO
L’operazione di rilassamento ha come obiettivo quella di voler valutare e trovare un percorso minimo tra s e
v attraverso u.
Alla base di questo, abbiamo la definizione della distanza minima d[v] che rappresenta il limite superiore per
la distanza minima da s a v (inizialmente ∞). P[v] è il predecessore di v nel percorso minimo attuale da s a v
(inizialmente NULL).
Il rilassamento tenta di decrementare la stima d[v] del percorso minimo v. Se avvenuta aggiorna anche il
predecessore di v.
Questo è alla base di tutti gli algoritmi.
ALGORITMO DI DIJKSTRA
Mantiene un insieme S di vertici, le cui distanze minime sono state calcolate; cioè questo algoritmo nel
momento in cui ad un vertice viene assegnato un valore d[u], questo non verrà più modificato.
Per cui ci sono vertici per cui la stima della distanza coincide con la distanza minima: d[u] = δ(s,u).
Questo algoritmo sceglie in modo greedy il vertice u in V-S (s sono quelli assegnati, quindi V – S sono quelli
non ancora assegnati) con la stima più bassa – aggiunge u a s.
Utilizza una coda a priorità Q per memorizzare i vertici e con d[] come chiave.
ALGORITMO DI BELLMAN-FORD
L’idea nasce dall’iterare |V|-1 volte e, ad ogni passo, applicare il rilassamento ad ogni arco del grafo. Questo
avviene perché se noi usiamo l’algoritmo di Dijkstra ad un grafo con dei pesi negati, questo, semplicemente
può tornare dei risultati sbagliati; proprio perché greedy. Ogni volta che estraiamo un vertice, questo avrà
una distanza minima che non cambierà più (questo vale solo se tutti gli archi sono positivi).
Problemi intrattabili
Che cosa intendiamo, allora, per problemi intrattabili? Intendiamo algoritmi il cui tempo di esecuzione è
maggiore di n^k. Più grandi di n^k non sarà ovviamente una funzione polinomiale ma sarà una funzione
diversa.
Perché quelli polinomiali sono quelli che preferiamo? Perché, matematicamente parlando, hanno un pregio
nei loro esponenti che sono, generalmente, piccoli. Se noi prendessimo un algoritmo di O(n^100) potrebbe
sicuramente considerarsi intrattabile, ma è improbabile che si verifichi.
L’altro aspetto del perché ci “piacciono” gli algoritmi polinomiale, sta nel fatto che esistono particolari
proprietà di chiusura.
PERCHE’ È RILEVANTE?
Perché la conoscenza dei problemi intrattabili è importante per il progetto di un algoritmo. Se intrattabile, è
inutile cercare un algoritmo efficiente esatto, meglio puntare ad una efficace approssimazione.
PROBLEMI DI DECISIONE
Tutta la teoria della NP-completezza si applica ai problemi di decisione, ovvero a problemi con un risultato
booleano (vero, falso).
Apparentemente sembra una restrizione, ma i problemi di ottimizzazione possono essere facilmente
ricondotti a problemi di decisione.
Ottimizzazione VS Decisione
Rivediamo il problema dei percorsi minimi.
Dato un grafo G=(V,E). Due vertici u,v ∈ V, quale è il percorso minimo in G tra u e v?
Come possiamo tradurlo in un problema di decisione? Possiamo provarlo a tradurlo in una problema
risolvibile con una funzione booleana:
Dato un grafo G=(V,E). Due vertici u,v ∈ V, e un intero k>=0. Esiste un percorso in G tra u e v di lunghezza al
più k?
Se possiamo risolvere il problema di ottimizzazione rapidamente, lo stesso vale per il problema di decisione
corrispondente; questo vale anche nel caso in cui non si possa risolvere. Se possiamo provare che un
problema di decisione è intrattabile, lo stesso si applica al problema di ottimizzazione.
NON DETERMINISMO
Deterministici sono, in generale, i sistemi che noi abitualmente utilizziamo, come i calcolatori. Il
determinismo potrebbe essere sintetizzato come l’univocità di una scelta in un processo. Un programma è
deterministico se, a fronte di certi INPUT, fornisce sempre lo stesso OUTPUT.
Gli algoritmi non deterministici sono algoritmi in cui il passo successivo è scelto in modo non deterministico,
ovvero per specificarli si introduce una funzione speciale e due istruzioni eseguite in O(1).
Choice (x 1 ,...,x n )
Ritorna arbitrariamente uno degli elementi (x 1 ,...,x n ).
failure()
terminazione senza successo.
success()
terminazione con successo.
verifica.
Molti problemi rilevanti hanno una soluzione in tempo polinomiale non deterministico, ma non esiste un
algoritmo deterministico polinomiale.
ESEMPIO
A: date n variabili Booleane con valori x1,…,xn, esiste almeno una variabile con valore VERO?
B: dati n interi i1,…,in, è max{i1,…,in}>0?
ALGORITMO PER B
foreach j =1…n
if (ij > 0)
return true
return false
Problemi NP-completi
Un problema A è NP-completo (NPC) se:
1. E’ un problema NP;
2. Per ogni altro problema A’ in NP, A’∝ A.
DEFINIZIONE INFORMALE
Se un qualunque problema nella classe NPC fosse risolubile in tempo polinomiale, allora tutti i problemi in
NP possono essere risolti in tempo polinomiale.
P: i problemi “facili”.
DIMOSTRAZIONE DI NP-completezza
Accediamo ora l’aspetto ingegneristico della questione, ovvero, dato un problema, capire se è NP-
completo; come si fa?
1. Si mostra che il problema è in NP (verificabile in tempo polinomiale);
2. Dimostriamo che è NP-hard, cioè un problema noto come NPC può essere ridotto a questo problema.
Serve un insieme di problemi NPC noti.
È SODDISFACIBILE?
SI: poiché se assegniamo (x1=F, x2=F, x3=T) e risolviamo la funzione, uscirà come risultato V(ero).
È SODDISFACIBILE? NO!
Ricordatevi che il problema non è risolvere il problema ma trovare il caso a noi necessario.
for i = 1 to n do
xi Choice (true, false )
/* verifica *
if E(x1, x2, … ,xn) = T then
success
else
failure
}
ALTRI PROBLEMI NP
Usando la riducibilità e partendo da SAT, è stato dimostrato che altri numerosi problemi classici sono NPC.
ALGORITMI PSEUDOPOLINOMIALI
Definizione: quando il tempo di esecuzione è polinomiale nel valore numerico dell’input (che però è
esponenziale nella lunghezza degli input).
ALGORITMI APPROSSIMATI
Cosa fare se il mio problema è intrattabile?
1. Approssimazioni;
2. Euristiche.
RINGRAZIAMENTI
Questo testo è stato reso possibile grazie alla documentazione fornita dall’Università Telematica
Internazionale UniNettuno, che desidero ringraziare per il supporto e la dedizione che adotta nei confronti
dei propri studenti.
Desidero, inoltre, ringraziare tutti i miei colleghi di studio che hanno, insieme, dato consigli, mosso critiche
(costruttive) e, soprattutto, hanno trovato utilità in questa guida. Se, anche tu, senti questa necessità, sappi
che accoglierò ogni commento a braccia aperte.