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