Guida di Programmazione Avanzata
SEZIONE I - Analisi di Algoritmi e Complessità
Lezione 1 - Tecniche di Progettazione di Algoritmi
Secondo una concezione che può essere fatta risalire ad Euclide e che è stata ampiamente formalizzata in una delle opere di Cartesio il concetto di problema rispecchia una struttura ben definita.
Un problema consta dei seguenti elementi:
- Ciò che è noto, i DATI, l'istanza del problema che si deve affrontare
(oggi potremmo dire l'INPUT).
- Ciò che si deve determinare, gli elementi incogniti la cui determinazione
fornisce il RISULTATO (ovvero l'OUTPUT).
- La CONDIZIONE che deve essere soddisfatta dal risultato.
Possiamo così individuare, ad esempio, fra i vari tipi di problemi, le seguenti classi:
- PROBLEMI DI DECISIONE; ad esempio verificare se un dato programma
Java è sintatticamente corretto.
- PROBLEMI DI RICERCA; in cui, nello spazio delle soluzioni possibili,
si vuole determinare una Soluzione Ammissibile (il problema delle OTTO
REGINE).
- PROBLEMI DI OTTIMIZZAZIONE; in cui, alle soluzioni ammissibili, è
associata una MISURA e si deve determinare una soluzione OTTIMA.
Lezione 2 - Introduzione alla Complessità di Calcolo
Si può definire un'ALGORITMO una procedura computazionale ben definita che prende un valore, o un insieme di valori, come INPUT e produce un valore, o un insieme di valore, come OUTPUT.
Generalmente l'input necessario per fornire un risultato al problema computazionale viene chiamato istanza di un problema.
Il problema più comune da affrontare è il "Problema di Ordinamento" (detto anche SORTING PROBLEM) che consiste nell'ordinare in ordine crescente un insieme di valori. L'importanza di questo problema è che risulta un'operazione fondamentale nel campo dei computer.
Se si pensa che l'algoritmica (intesa come studio della complessita e dell'ottimizzazione di algoritmi) abbia un'applicazione limitata conviene dare un'occhiata alla carrellata dei maggiori campi di applicazione di questa materia.
- Il progetto del Genoma Umano, che deve determinare i tre bilioni di coppie
chimiche base che compongono il DNA umano.
- In internet gli algoritmi sono utilizzati per gestire e manipolare una grande
quantità di dati.
- Nel commercio elettronico sono usati algoritmi numerici per la crittografia
a chiave pubblica e le firme digitali.
- Nella soluzione di problemi di programmazione lineare, inerenti la scelta o
l'ottimizzazione.
- Nella risoluzione di una procedura di visita dei grafi in maniera efficiente.
- Nel calcolo del prodotto di matrici di dimensioni differenti.
- Data un'equazione aX "congruo" b (mod n) riuscire a trovare tutte le
X che soddisfano l'equazione.
- Ci sono delle soluzioni possibili, molte delle quali non sono corrette.
- Hanno delle applicazioni pratiche di risoluzione od ottimizzazione.
Generalmente per ottimizzare un problema di ordinamento bisogno ricorrere a particolari Strutture Dati (o DATA STRUCTURES) che servono per organizzare i dati in maniera da facilitare (e velocizzare) l'accesso e la modifica.
Lezione 3 - Ordine di Grandezza delle funzioni
Dimensione dell'input
Per rendere significativo il concetto di complessita computazionale è necessario determinare in funzione di cosa viene espresso il consumo di risorse da parte di un determinato programma. Ci conviene adottare come parametro la quantità di memoria utilizzata per rappresentare i dati di ingresso.
Molto spesso, tuttavia, anzichè considerare la dimensione complessiva della rappresentazione dei dati si può assumere, per semplicità, un parametro caratterizzante la lunghezza dell'input.
Se W è la rappresentazione del polinomio
Operazioni Dominanti
Diciamo che un'operazione in un algoritmo è dominante quando
(n), cioè il numero di volte che essa viene
ripetuta durante l'esecuzione dell'algoritmo in funzione della dimensione
dell'input, verificac •
(n) ≥ T(n)
(n) ≥ T(n)dove "c" è una opportuna costante e "T" è il costo di esecuzione.
L'analisi Asintotica
- f(n)
O(g(n)) se e solo se esistono due
costanti positive "c" ed "n0" tali che:
f(n) ≤ c • g(n) per ogni n ≥ n0
f(n) CRESCE AL PIU' COME g(n) - f(n)
Ω(g(n)) se e solo se esistono due
costanti positive "c" ed "n0" tali che:
f(n) ≥ c • g(n) per ogni n ≥ n0
f(n) CRESCE ALMENO QUANTO g(n) - f(n)
(g(n))
se e solo se esistono tre costanti positive "c1", "c2"
ed "n0" tali che:
c1 • g(n) ≤ f(n) ≤ c2 • g(n) per ogni n ≥ n0
f(n) e g(n) CRESCONO ALLO STESSO MODO - f(n) = O(g(n)) se e solo se:
lim n —› ∞ f(n) / g(n) = 0
f(n) CRESCE PIU' LENTAMENTE DI g(n) - f(n) ~ O(g(n)) se e solo se:
lim n —› ∞ f(n) / g(n) = 1
f(n) e g(n) SONO UGUALI A MENO DI TERMINI DI ORDINE INFERIORE
Regole di Semplificazione
| - Dei fattori costanti: | k • O(f(n)) = O(k • f(n)) |
| - Della somma: | Se f(n) O(g(n)),
allora f(n) + g(n) O(g(n)) |
| - Del prodotto: | Se f(n) è O(f1(n)) e g(n) è O(g1(n)), allora f(n)g(n) è O(f1(n)g1(n)) |
| - Dei polinomi: | Un polinomio amnm + ... + a1n + a0 è O(nm) |
| - Transitiva: | Se f(n) è O(g(n)) e g(n) è O(h(n)), allora f(n) è O(h(n)) |
Confronto tra Classi di Complessità
Posto n=60 ed esprimendo il tempo in secondi,
| O(1) | COSTANTE | k (~ 0,000001) |
| O(logn) | LOGARITMICA | 0,000018 |
| O(n) | LINEARE | 0,00006 |
| O(n • logn) | N-LOGN | 0,001 |
| O(n2) | QUADRATICA | 0,0036 |
| O(n3) | CUBICA | 0,216 |
| O(n5) | ALLA QUINTA | 13 mesi |
| O(2n) | ESPONENZIALE | 366 secoli |
Lezione 4 - Complessità dei programmi iterativi
In questa lezione daremo una regola per calcolare la complessità del tempo di esecuzione dei programmi non ricorsivi e in cui non vi siano cicli nelle chiamate di funzione (ricorsione nascosta). La regola è definita per induzione sulla sintassi dei costrutti del linguaggio relativi alle espressioni ed ai comandi: la complessità viene definita direttamente sui costrutti elementari, mentre la complessità dei costrutti composti è definita in base alla complessità dei costrutti componenti (definizione composizionale).
Nel seguito viene definita una grammatica che definisce la sintassi delle espressioni e dei comandi di un linguaggio di programmazione, che è un sottoinsieme del C++ (e molto simile al Java). I simboli nonterminali della grammatica sono le lettere maiuscole: E genera le espressioni e C i comandi; inoltre assumiamo che V generi un qualsiasi valore elementare, I un qualsiasi identificatore e O un qualsiasi operatore. Indichiamo con I(E,...,E) una chiamata di funzione, mentre I[E] è l'operatore di selezione di un array. Per semplicità, la notazione non è del tutto esatta nelle produzioni che riguardano le chiamate di funzione. Ç è il simbolo utilizzato per rappresentare la complessità.
E ::= V | E O E | O E | I(E,...,E) | I | I[E]
C ::= I = E | I[E] = E | If(E) C | If(E) C else C | for(I=E;E;I=E) C
|while(E) C | do C while(E) | I(E,...,E) | {C...C} | return E;
Complessità delle espressioni
1) Ç[V] = Ç[I] = O(1)
2) Ç[E1 O E2] = Ç[E1] + Ç[E2]
3) Ç[I[E]] = Ç[E]
4) Ç[I(E1,...,En)] = Ç[E1] + Ç[E2] + ... Ç[En] + Ç[{C...C}]
Complessità dei comandi
1) Ç[I=E] = Ç[return E] = Ç[E]
2) Ç[I[E1]=E2] = Ç[E1] + Ç[E2]
3) Ç[If(E) C] = Ç[E] + Ç[C]
4) Ç[If(E) C1 else C2] = Ç[E] + O(max(f(n),g(n)))
5) Ç[while(E) C] = Ç[E] + (Ç[E] + Ç[C]) • O(g(n))
6) Ç[do C while(E)] = (Ç[E]+Ç[C]) • O(g(n))
7) Ç[{C1...Cn}] = Ç[C1] + Ç[C
8) Ç[for(I=E1; E2; I=E3) C] = Ç[E1] + Ç[E2] + (Ç[C] + Ç[E2] + Ç[E3]) • O(g(n))
NB: Quando si studia la complessità di una funzione (g) che ne chiama un'altra (f) bisogna porre attenzione al risultato di f (istruzione return) e non tanto alla sua complessità.
Analisi del Caso Medio
Il principale motivo per lo studio del comportamento medio di un algoritmo è legato al fatto che in molte circostanze non è necessario che un algoritmo operi con una quantità limitata di risorsa su ogni possibile input, ma è importnate che sia "generalmente efficiente", anche se in alcuni casi la sua esecuzione richiede costi elevati. In alcuni casi è ragionevole fare la seguente ipotesi di equi-probabilità sull'input del problema: per ogni fissato N assumiamo che tutte le possibili istanze aventi dimensione n abbiano la stessa probabilità di essere fornite come input. Comunque a volte è necessario fare altre ipotesi sulla distribuzione di probabilità dell'input basate sulle caratteristiche del problema.
Lezione 5 - Insertion Sort (iterativo)
Uno dei metodi più semplici per ordinare un array è l'insertion sort [ordinamento per inserimento]. Nella vita quotidiana si ha un esempio di insertion sort giocando a carte. Per ordinare le carte che tenete in mano, ne estraete una, scorrete le restanti, e inserite la carta estratta nella posizione corretta. Questo processo viene ripetuto finché tutte le carte saranno nella sequenza corretta. La complessità sia del caso medio che del caso peggiore è O(n2).
Se l'array contiene n elementi, dobbiamo indicizzarne n - 1. Per ogni elemento, dobbiamo esaminarne e spostarne, al più, altri n - 1. Questo porta ad un algoritmo di complessità O(n2). Insertion sort è un algoritmo di ordinamento di tipo in-place. Questo significa che l'array viene ordinato "sul posto", senza utilizzare memoria ulteriore. Insertion sort è anche un ordinamento stabile. Gli ordinamenti stabili mantengono l'ordinamento originale quando vi sono elementi uguali nei dati in input.
|
void InsertionSort(int a[], int n) { for(j = 2; j < n+1; j++) { int k = a[j]; for(int i = j-1; (i>O) && (a[i]>k); i--) { a[i+1] = a[i]; } a[i+1] = k; } } |
COMPLESSITA' NEL CASO MIGLIORE: O(n)
COMPLESSITA' NEL CASO PEGGIORE: O(n2)
COMPLESSITA' NEL CASO MEDIO: O(n2)
|
Ecco qui di seguito un applet java per testare la funzionalità dell'algoritmo, sono disponibili anche i sorgenti dell'applet.
L'applet :: Algoritmo di ordinamento generico :: Algoritmo Insertion Sort :: Algoritmo Shell Sort |

