Capitolo 2 Algoritmi - Università degli Studi di Roma "Tor Vergata"

Capitolo 2
Algoritmi
I progressi ottenuti nel campo dell’elaborazione elettronica hanno permesso lo sviluppo e
l’applicazione dei metodi matematici per la modellizzazione e la risoluzione di una grande varietà di problemi decisionali, anche di dimensioni ragguardevoli. Tutto l’insieme di
metodologie che hanno in comune l’uso del metodo matematico, come per esempio l’ottimizzazione, la programmazione matematica, la teoria dei grafi, la teoria delle code, la
teoria delle decisioni, la simulazione, ecc, sono raccolte in una disciplina che prende il nome
di Ricerca Operativa. Data la natura applicativa della Ricerca Operativa, lo studio teorico
del problema matematico posto viene normalmente affiancato allo studio delle tecniche
necessarie per ottenere una soluzione in modo efficiente.
2.1
Breve tassonomia dei problemi decisionali
In generale, nella modellazione di un problema decisionale, ci dobbiamo preoccupare di
tre componenti fondamentali: il grado di incertezza, il numero di obiettivi ed il numero di
decisori.
Il grado di incertezza indica se ci si trova in condizioni di informazione completa, come
nei problemi deterministici, oppure in condizioni di conoscenza parziale, come nei problemi stocastici. Il numero di obiettivi è un’altra componente da conoscere e, in generale,
potremo riconoscere problemi a singolo obiettivo, oppure multiobiettivo. Analogamente,
17
18
CAPITOLO 2. ALGORITMI
per il numero dei decisori avremo una divisione in due classi, quella dei problemi a singolo
decisore e quella dei problemi multidecisore.
Continuando con questi accenni di tassonomia dei problemi decisionali, potremo considerare problemi lineari oppure problemi non lineari, a seconda della nostra funzione
obiettivo. Inoltre, sulla base dei valori che possono assumere le variabili, avremo problemi
continui se i valori apparterranno allo spazio dei numeri reali R, problemi discreti (o combinatori ) se i valori apparterranno allo spazio dei numeri interi Z ed infine, problemi misti
se le variabili possono assumere valori sia reali che interi.
Utilizzando lo schema appena descritto, nella trattazione della teoria dei grafi ci limiteremo in seguito al solo studio dei problemi deterministici, singolo obiettivo, singolo
decisore, lineari e discreti. Al lettore è lasciato immaginare la quantità di problemi decisionali che si possono osservare nella realtà al presentarsi ed al combinarsi delle diverse
ipotesi sopra dette e, di conseguenza, come la loro analisi sia fondamentale per individuare
le tecniche più adeguate per risolverli.
2.2
Algoritmi
Per la soluzione di un problema, occorre individuare un metodo generale (procedura) in
grado di risolvere ogni generica istanza, fornendoci la soluzione desiderata in un certo
numero di passi. Il termine generale usato per definire tali procedure è algoritmo 1 .
Definizione 2.2.1 Un algoritmo è una procedura definita usata per risolvere un problema
usando un numero finito di passi.
Gli algoritmi ricopriranno una grande importanza nel nostro studio della Teoria dei
Grafi e, quindi, dedicheremo le prossime pagine alla descrizione di alcuni strumenti per la
loro analisi.
Esempio 2.2.1 Descrivere un algoritmo per trovare l’elemento più grande in una sequenza
(lista) di interi.
1
Il termine algoritmo deriva dal nome del matematico persiano Abu Ja’far Mohammed ibn Musa alKhowarizmi, vissuto nel IX secolo d.C..
2.2. ALGORITMI
19
Per specificare la procedura di risoluzione di questo semplice problema possiamo utilizzare
molti metodi, ma uno dei più semplici è quello di utilizzare il linguaggio naturale per
descrivere i singoli passi della procedura. Per risolvere il problema devono essere eseguiti
i seguenti passi:
Step 1: Poni il massimo temporaneo uguale al primo intero della sequenza;
Step 2: Compara il prossimo intero nella sequenza.
Se è più grande, poni il massimo
temporaneo pari a tale valore;
Step 3: Ripetere il passo precedente per ogni altro elemento della lista;
Step 4: Stop se non ci sono altri interi. Il valore cercato è contenuto nel massimo temporaneo
(che diventa definitivo).
Per descrivere più efficacemente un algoritmo si può utilizzare una descrizione mediante pseudocodice, basata su una sintassi molto simile al linguaggio di programmazione PASCAL, di facile comprensione per chiunque abbia dei rudimenti di Fondamenti di Informatica. Essa inoltre ci permette di evitare le specificità di un linguaggio di
programmazione.
Utilizzando lo pseudocodice, il nostro algoritmo diventa:
procedure MAX(a1 , . . . , an ; integers)
max := a1
for i := 2 to n
if max < ai then max := ai
max contiene il massimo
Come è facile notare, l’algoritmo in pseudocodice segue fedelmente i passi sopra descritti.
Per poter fornire una soluzione significativa, gli algoritmi devono rispettare alcune
proprietà:
Proprietà 2.2.1 Un algoritmo deve soddisfare le seguenti proprietà:
20
CAPITOLO 2. ALGORITMI
• Input - L’algoritmo deve avere un input contenuto in un insieme definito I.
• Output - Da ogni insieme di valori in input, l’algoritmo produce un insieme di valori
in uscita che comprende la soluzione.
• Determinatezza - I passi dell’algoritmo devono essere definiti precisamente.
• Finitezza - Un algoritmo deve produrre la soluzione in un numero di passi finito
(eventualmente molto grande) per ogni possibile input definito su I.
• Efficacia - Deve essere possibile effettuare ogni passo dell’algoritmo esattamente ed
in un tempo finito.
• Generalità - L’algoritmo deve essere valido per ogni insieme di dati contenuti in I
e non solo per alcuni.
Oltre a queste proprietà, un algoritmo deve essere efficiente, ovvero, dato un input di
dimensione fissata, deve fornire una soluzione in un tempo ragionevole ed inoltre deve occupare una quantità limitata di memoria di un computer. Problematiche di questo tipo sono
trattate dall’analisi della complessità computazionale degli algoritmi; in particolare,
se l’oggetto dello studio è il tempo di elaborazione, parleremo di complessità temporale, mentre se l’oggetto è l’occupazione della memoria, allora parleremo di complessità
spaziale.
È chiaro quindi che nell’analisi di un algoritmo è di fondamentale importanza sapere se
risolverà il nostro problema in un microsecondo, in un ora o in un secolo. Analogamente,
è importante sapere se l’occupazione di memoria possa eccedere le capacità disponibili.
L’analisi della complessità spaziale coinvolge principalmente l’analisi delle strutture dati
e, quindi, esula dagli scopi di queste note. Viceversa, l’analisi della complessità temporale
è molto importante per gli algoritmi su grafo e sarà approfondita nella Sezione 2.4.
2.2.1
Algoritmi di ricerca
In questa sezione vedremo alcuni esempi di algoritmi ed in particolare ci concentriamo sugli
algoritmi di ricerca su stringa che abbiamo già introdotto nell’Esempio 2.2.1.
2.2. ALGORITMI
21
Gli algoritmi di ricerca rivestono un particolare interesse nella pratica; basti pensare
alla necessità di trovare una parola in un dizionario, un dato in un database o anche alla
ricerca di pagine web attraverso i motori di ricerca. In quest’ultimo caso, l’algoritmo di
ricerca presenta, ovviamente, complessità ben diverse.
Il primo algoritmo che introduciamo è l’algoritmo di ricerca lineare (o sequenziale): data una lista di elementi distinti a1 , a2 . . . , an , localizzare l’elemento x o affermare
che non c’è.
procedure LINEAR SEARCH (x, integer; a1 , . . . , an , distinct integers)
i := 1
while (i ≤ n AND x 6= ai )
i := i + 1
if i ≤ n then
posizione := i
else
posizione := 0
L’algoritmo inizia confrontando x con a1 . Se l’elemento non è stato individuato, si
incrementa il contatore i e quindi si continua fino a che una delle due condizioni risulta
falsa (cioè o sono arrivato alla fine della lista, o ho trovato l’elemento) ed il ciclo while
termina. L’istruzione condizionale if ha il compito di inserire nella variabile di output il
valore della posizione o il valore 0 se tale valore non è nella lista.
Il secondo algoritmo di ricerca che descriviamo è l’algoritmo di ricerca binaria:
data una lista di elementi distinti a1 , a2 . . . , an , ordinati in modo che a1 ≤ a2 ≤ . . . ≤ an ,
localizzare l’elemento x o affermare che non c’è.
La differenza in questo caso è che la sequenza è ordinata in modo crescente, come
per esempio può accadere in un vocabolario se il criterio adottato è quello lessicografico.
Supponiamo allora che sia assegnata la sequenza {1, 2, 4, 5, 6, 9, 10, 12, 15, 18, 20, 24} e che
si voglia trovare se il numero 18 appartiene a tale lista.
L’idea che sta alla base dell’algoritmo è quella di dividere ad ogni passo la lista in due
parti (nel nostro caso {1, 2, 4, 5, 6, 9} e {10, 12, 15, 18, 20, 24}) e confrontare l’elemento da
cercare rispettivamente con l’ultimo elemento della prima metà e con il primo elemento
della seconda metà. Nel nostro caso l’elemento è più grande del primo elemento della
22
CAPITOLO 2. ALGORITMI
seconda metà e quindi possiamo concentrarci solo in tale stringa per la ricerca. L’algoritmo
continua dividendo tale stringa ulteriormente, ottenendo pertanto {10, 12, 15} e {18, 20, 24}
ed eseguendo di nuovo i confronti. Cosı̀ facendo l’algoritmo genera ancora le stringhe {18}
e {20, 24} arrestandosi al valore cercato oppure affermando che non appartiene a tale lista.
Lo pseudocodice dell’algoritmo proposto è il seguente:
procedure BINARY SEARCH (x, integer; a1 , . . . , an , increasing integers)
i := 1
j := n
while (i < j )
begin
m := b i+j
c
2
if x > am then
i := m + 1
else
j := m
end
if x = ai then
posizione := i
else
posizione := 0
L’apparente complessità dell’algoritmo di ricerca binaria, rispetto a quello lineare,
nasconde dei benefici che saranno mostrati nel paragrafo 2.4.
2.3
Crescita di funzioni
Nell’analisi di un algoritmo è di particolare interesse comprendere la sua applicabilità pratica e, principalmente, capire il tempo necessario per ottenere un risultato utile, ovvero la
sua efficienza. Osservando gli algoritmi presentati nella sezione precedente, si può notare
come l’input sia sempre legato al numero n di oggetti in ingresso sui quali eseguire l’elaborazione. Ci potremmo quindi chiedere quanto cresce il tempo di elaborazione al crescere di
n e se è possibile trovare una funzione f (n) che sia in grado di trasferire questa informazione. Inoltre, sarebbe utile disporre di un criterio in grado di paragonare due algoritmi
confrontando la crescita delle rispettive funzioni dell’input.
2.3. CRESCITA DI FUNZIONI
2.3.1
23
Notazione Big-O
Per analizzare il comportamento degli algoritmi dobbiamo prima introdurre la notazione
Big-O, necessaria per lo studio della crescita di una funzione generica, di cui segue la
definizione:
Definizione 2.3.1 Siano f e g due funzioni tali che f, g : N → R (o anche f, g : R → R).
Diremo che f (n) = O(g(n)) se esistono due costanti, C e k, tali che ∀n > k si ha:
f (n) ≤ C|g(n)|
(2.1)
La 2.1 si legge come “f (n) è un big o di g(n)”.
È importante notare che basta trovare una sola coppia C, k tale che sia vera la 2.1. In
realtà, una coppia che soddisfa la definizione data non è mai unica, anzi, basta prendere
una qualunque coppia C 0 , k 0 tale che C < C 0 e k < k 0 per soddisfare la definizione e questo
ci porta a dire che se una coppia esiste, allora ne esistono infinite.
Esempio 2.3.1 Mostrare che f (n) = n2 + 2n + 1 è un O(n2 ).
Per risolvere questo esercizio basta osservare che 0 ≤ n2 + 2n + 1 ≤ n2 + 2n2 + n2 = 4n2 ,
avendo considerato C = 4 e k = 1. Notare che in questo caso si ha che f (n) ≤ C|g(n)| e
g(n) ≤ C|f (n)|. Quando ciò accade diremo che le funzioni sono dello stesso ordine.
Occorre notare che il segno di uguale nella 2.1 non è realmente un uguale, ma, piuttosto,
indica che in questa notazione, quando si hanno dei valori di n sufficientemente grandi nei
dominii di f e g, la disuguaglianza è verificata.
Se f (n) ≤ C|g(n)| e h(n) è una ulteriore funzione che assume valori assoluti maggiori
di g(n) a partire da valori di n sufficientemente grandi, allora si ha ovviamente che f (n) ≤
C|h(n)|. Di norma, però, si sceglie la funzione g più piccola possibile; quindi nell’esempio
precedente è corretto, ma privo di senso, dire che f (n) = O(n3 ).
Esempio 2.3.2 Dare una stima Big-O della somma dei primi numeri n interi positivi.
24
CAPITOLO 2. ALGORITMI
Dato che 1 + 2 + . . . + n ≤ n + n + . . . + n = n2 , allora 1 + 2 + . . . + n = O(n2 ), con C = 1
e k = 1.
Esempio 2.3.3 Dare una stima Big-O di f (n) = log n!.
Per quanto riguarda il fattoriale si ha che 1 · 2 · . . . · n ≤ n · n · . . . · n = nn . Quindi
log n! ≤ log nn = n log n, che implica log n! = O(n log n).
2.3.2
Crescita di combinazioni di funzioni
Gli algoritmi sono tipicamente composti da diverse operazioni concatenate ed annidate in
sottoprocedure, quindi la notazione introdotta nel paragrafo precedente deve essere estesa
in modo da tenere conto del peso delle singole sottoprocedure.
Supponiamo allora di avere assegnate due funzioni f1 (n) = O(g1 (n)) e f2 (n) = O(g2 (n)).
Per la definizione data nel paragrafo precedente sappiamo che esistono delle costanti C1 ,
C2 , k1 e k2 tali che f1 (n) ≤ C1 |g1 (n)|, ∀n > k1 e f2 (n) ≤ C2 |g2 (n)|, ∀n > k2 .
Teorema 2.3.1 Si supponga che f1 (n) = O(g1 (n)) e f2 (n) = O(g2 (n)). Allora la somma
delle due funzioni è:
(f1 + f2 )(n) = O(max{g1 (n), g2 (n)})
(2.2)
Dimostrazione: Si noti che |(f1 +f2 )(n)| = |f1 (n)+f2 (n)| ≤ |f1 (n)|+|f2 (n)| (quest’ultima
relazione è vera per la disuguaglianza triangolare, |x + y| ≤ |x| + |y|).
Se si considera g(n) = max{g1 (n), g2 (n)} e C = C1 + C2 , allora |f1 (n)| + |f2 (n)| ≤
C1 |g1 (n)| + C2 |g2 (n)| ≤ C|g(n)|.
Corollario 2.3.2 Se entrambe le funzioni f1 (n) e f2 (n) sono entrambe O(g(n)), allora
(f1 + f2 )(n) = O(g(n)).
Per quanto riguarda il prodotto di funzioni, vale il seguente teorema:
2.3. CRESCITA DI FUNZIONI
25
Teorema 2.3.3 Si supponga che f1 (n) = O(g1 (n)) e f2 (n) = O(g2 (n)). Allora il prodotto
delle due funzioni è:
(f1 · f2 )(n) = O(g1 (n) · g2 (n))
(2.3)
Dimostrazione: Considerando C = C1 · C2 , allora (f1 · f2 )(n) = |f1 (n)| · |f2 (n)| ≤
C1 |g1 (n)| · C2 |g2 (n)| ≤ C|(g1 · g2 )(n)|.
Esempio 2.3.4 Dare una stima Big-O della funzione f (n) = 3n log(n!) + (n2 + 3) log n.
Considerando ogni termine singolarmente abbiamo:
f (n) =
3n
→
n
log n!
→
n log n
+(n2 + 3) → n2 + 3 ≤ 4n2
log n
→
log n
Quindi f (n) = O(n2 log n).
L’ultimo risultato di questa sezione riguarda la crescita di funzioni polinomiali (in x,
per comodità di notazione):
Teorema 2.3.4 Sia f (x) = an xn + an−1 xn−1 + . . . + a0 , con an , an−1 . . . , a0 numeri reali.
Allora f (x) = O(xn ).
Dimostrazione: Utilizzando la disuguaglianza triangolare, e per x > 1,
|f (x)| = |an xn + an−1 xn−1 + . . . + a0 |
≤ |an |xn + |an−1 |xn−1 + . . . + |a0 |
= xn (|an | +
|an−1 |
|a0 |
+ ... + n )
x
x
≤ xn (|an | + |an−1 | + . . . + |a0 |)
Questo dimostra che |f (x)| < Cxn , dove C = |an | + |an−1 | + . . . + |a0 |, se x > 1. Quindi
f (x) = O(xn ).
26
CAPITOLO 2. ALGORITMI
Come abbiamo detto, la notazione Big-O viene usata per la stima del numero di ope-
razioni necessarie affinchè un algoritmo risolva un dato problema. Le funzioni che normalmente si usano sono 1, log n, n, n log n, n2 , 2n , n!. La sequenza presentata non è casuale, ma
rispetta l’ordinamento tale per cui la funzione successiva è sempre più grande di quella che
la precede. In Figura 2.1 sono riportati i grafici delle funzioni indicate, dove n sia l’ascisse
e in ordinata siano i valori della funzione, in scala esponenzialmente crescente.
2048
n!
1024
2n
512
256
128
n2
64
32
16
8
n log n
n
4
log n
2
1
1
2
3
4
5
6
7
8
Figura 2.1: Grafico delle funzioni più comunemente usate nella stima Big-O
2.4
Complessità degli algoritmi
L’analisi della complessità temporale degli algoritmi può essere espressa in termini di numero di operazioni eseguite dallo specifico algoritmo quando l’input ha un data dimensione.
Questo tipo di analisi risulta essere più efficiente della semplice misura del tempo impiegato da un computer per completare la sua elaborazione, perché, nel caso, la velocità di
elaborazione può variare molto da computer a computer ed è inoltre difficile da misurare
e valutare.
2.4. COMPLESSITÀ DEGLI ALGORITMI
27
Per illustrare come analizzare la complessità di un algoritmo, consideriamo il primo
esempio della Sezione 2.2 per trovare l’elemento più grande in una lista. Le operazioni
che sono eseguite sono i due confronti all’interno del ciclo for, uno per verificare se si è
giunti alla fine della lista, l’altro per aggiornare, eventualmente, il massimo temporaneo.
Dato che i due confronti vengono ripetuti dal passo due al passo n ed è poi eseguito
un’ulteriore confronto per uscire dal ciclo quando il contatore i = n + 1, si ha che sono
eseguiti esattamente 2(n − 1) + 1 = 2n − 1 confronti. Quindi, dato un input di lunghezza
n, se si misura la complessità in termini di confronti, si ha che l’algoritmo trova il massimo
in una lista di lunghezza n in O(n) passi.
Questo ragionamento ci ha portato ad usare la notazione Big-O introdotta nella sezione
precedente per dare una misura della complessità computazionale temporale dell’algoritmo.
Questa procedura può essere generalizzata allo studio dell’efficienza di qualunque algoritmo
e, negli esempi che seguono, mostreremo come usare tale misura e come sia possibile servirsi
della composizione delle funzioni per valutare la complessità generata da più procedure o
da procedure annidate.
Prendendo ad esempio l’algoritmo di ricerca lineare, all’interno del ciclo while vengono
effettuati due confronti: uno per verificare se si è arrivati alla fine della lista e l’altro per
confrontare x con un termine della lista. Successivamente viene eseguito un confronto fuori
dal ciclo. Considerando il caso peggiore, ovvero quello in cui l’elemento non è contenuto
nella lista, sono eseguiti 2n + 2 confronti e quindi la ricerca lineare richiede almeno O(n)
confronti.
Il tipo di analisi eseguita sull’algoritmo di ricerca lineare è del tipo worst case, ovvero
viene contato il massimo numero di operazioni necessarie per risolvere il nostro problema
dato un input fissato. Ovviamente, quest’analisi mostra quante operazioni sono necessarie
all’algoritmo, nel caso peggiore, per garantire che verrà prodotta una soluzione, ma nella
realtà possono esserne effettuate molte di meno. A titolo di esempio, si può notare che
l’algoritmo di ricerca lineare ha complessità proporzionale a n, ma se l’elemento da ricercare
è tra i primi nella lista, l’algoritmo termina con un numero di passi minore.
Analizziamo ora l’algoritmo di ricerca binaria e, per semplicità, supponiamo che la lista
28
CAPITOLO 2. ALGORITMI
Complessità
O(1)
O(log n)
O(n)
O(n log n)
O(na )
O(an ), con a > 1
O(n!)
Terminologia
Complessità costante
Complessità logaritmica
Complessità lineare
Complessità n log n
Complessità polinomiale
Complessità esponenziale
Complessità fattoriale
Tabella 2.1: Terminologia comunemente usata per indicare la complessità degli algoritmi
sia composta da n = 2k elementi (e, quindi, k = log n). Notare che con questa ipotesi non
c’è perdita di generalità, perchè potremmo considerare la nostra lista originale come parte
di una lista più grande di 2k+1 elementi, dove 2k ≤ n ≤ 2k+1 . Ad ogni passo dell’algoritmo,
le variabili i e j sono confrontate per vedere se la lista ristretta ha più di un termine e, se
i < j, viene eseguito un confronto per determinare se x è maggiore del termine mediano
della lista in considerazione. Al primo passo, la ricerca è limitata a 2k−1 termini e vengono
effettuati due confronti; ad ogni passo successivo vengono eseguiti due confronti su di una
lista che è grande la metà di quella del passo precedente. Alla fine del ciclo while vengono
eseguiti due ulteriori confronti e quindi complessivamente saranno stati eseguiti al più 2k+2
confronti, ovvero 2 blog nc + 2 confronti. Quindi, l’algoritmo di ricerca binaria richiede al
più O(log n) confronti, e da ciò segue che tale algoritmo, a parità di input, è molto più
efficiente dell’algoritmo di ricerca lineare.
In Tabella 2.1 è riportata la terminologia comunemente usata per indicare la complessità
temporale degli algoritmi. La stima Big-O permette di valutare come il tempo necessario
per risolvere un problema cambi in funzione della dimensione dell’input. Tale stima però
non fornisce indicazioni sul tempo realmente necessario ad un computer per completare
l’elaborazione perchè non possiamo individuare un valore limite senza aver ricavato le
costanti C e k nell’equazione 2.1 ed inoltre perchè è difficile stimare il tempo richiesto per
completare una singola operazione. Comunque, possiamo tentare di fornire una misura
riconducendoci a delle stime sui tempi di operazione sui bit2 . Cosı̀ facendo, possiamo
2
Nel nostro caso si è assunto che il tempo di elaborazione di una operazione base su bit, eseguita su un
computer ad alte prestazioni, sia di 10−9 secondi.
2.4. COMPLESSITÀ DEGLI ALGORITMI
Dimensione
del problema
n
10
102
103
104
105
106
29
Numero di operazioni su bit eseguite
log n
3 · 10−9 sec
7 · 10−9 sec
1 · 10−8 sec
1.3 · 10−8 sec
1.7 · 10−8 sec
7 · 10−8 sec
n
−8
10
10−7
10−6
10−5
10−4
10−3
sec
sec
sec
sec
sec
sec
n log n
3 · 10−8 sec
7 · 10−7 sec
1 · 10−5 sec
1 · 10−4 sec
2 · 10−2 sec
3 · 10−2 sec
n2
−7
10 sec
10−5 sec
10−3 sec
10−1 sec
10 sec
17 min
2n
−6
10 sec
4 · 1013 anni
∗
∗
∗
∗
n!
3 · 10−3 sec
∗
∗
∗
∗
∗
Tabella 2.2: Tempo di calcolo usato dagli algoritmi
ottenere la Tabella 2.2 che riporta i tempi computazionali necessari a problemi con diverse
dimensioni di input, fornendo inoltre una indicazione sul numero di operazioni su bit. Gli
asterischi indicano tempi maggiori di 10100 anni.
La tabella riporta tempi computazionali che possono risultare impraticabili anche per
istanze piccole. Ci si potrebbe chiedere quale vantaggio si avrebbe con l’aumento delle prestazioni degli elaboratori. Dall’esempio seguente è facile convincersi che questa possibilità
non ha riscontro nella realtà.
Supponiamo di considerare due algoritmi, uno di complessità polinomiale O(n2 ) e l’altro
di complessità esponenziale O(2n ). Consideriamo ora un elaboratore che abbia velocità v1
e che ci permetta di risolvere in una data unità di tempo una istanza di dimensione n1 .
Immaginiamo ora di poter disporre di un elaboratore 100 volte più veloce (v2 = 100 · v1 );
considerando la complessità dell’algoritmo, si può affermare che esiste una proporzionalità
pari a n22 /n21 = v2 /v1 = 100 e quindi, con un rapido conto, si ottiene che è possibile risolvere
nello stesso tempo istanze con n2 = 10 · n1 , cioè 10 volte più grandi.
Applicando lo stesso ragionamento per l’algoritmo di complessità O(2n ), si ottiene,
con un elaboratore 100 volte più veloce, che 2n2 /2n1 = v2 /v1 = 100, ovvero che n2 =
n1 + log 100 ≈ n1 + 7, cioè posso risolvere istanze con solo 7 nodi in più!
Questo esempio ci mostra come il miglioramento delle capacità di elaborazione ha
purtroppo solo un impatto marginale nella efficienza degli algoritmi con complessità
esponenziale.
30
CAPITOLO 2. ALGORITMI
2.5
Esercizi
Es. 2.5.1 Mostrare che l’algoritmo dell’Esempio 2.2.1 rispetta le Proprietà 2.2.1
Es. 2.5.2 Fornire una stima Big-O della seguente funzione: f (n) = n2 +
n
log n
log n!.
Es. 2.5.3 Fornire una stima Big-O della seguente funzione: f (n) = 13 + 23 + . . . + n3 .
Es. 2.5.4 Fornire una stima Big-O della seguente funzione: f (n) =
√
1+
√
2+...+
√
n.
Es. 2.5.5 Fornire una stima Big-O della seguente funzione: f (n) = log 2 + log 3 + . . . +
log n.
Es. 2.5.6 Dare una stima Big-O per f (n) = n2 (n log(n!) + n log n).
Es. 2.5.7 Dare una stima Big-O per f (n) = (log n)2 + log(n2 ).
Es. 2.5.8 Determinare la complessità computazionale associabile al seguente segmento di
codice (n << m):
while (j < m) do
begin
for i := 1 to m do
if a[i] < j then a[i] = j;
for i := 1 to n do
if a[i] < j then a[i] = j;
j = j + 1;
end
Es. 2.5.9 Determinare la complessità computazionale associabile al seguente segmento di
codice (m << n):
for i := 1 to m do
begin
2.5. ESERCIZI
if a[i] < j then a[i] = j;
for i := 1 to n do
if a[i] < j then a[i] = j;
j = j + 1;
end
Es. 2.5.10
31
32
CAPITOLO 2. ALGORITMI