FONDAMENTI DI INFORMATICA I INTRODUZIONE ALL’ANALISI DI ALGORITMI 1. INTRODUZIONE Quando realizziamo un programma per risolvere un determinato problema mediante il calcolatore, dobbiamo tenere presente che, per essere realmente utilizzabile, il nostro programma deve soddisfare diverse proprietà; in particolare deve essere, innanzitutto, corretto ed efficiente. In questo corso la nostra attenzione sarà principalmente rivolta ai metodi di analisi dell'efficienza degli algoritmi e alle tecniche di progettazione di algoritmi efficienti. Per iniziare a trattare tali argomenti, introdurremo i concetti di efficienza di algoritmi e programmi e i metodi con cui tale efficienza può essere analizzata; infine chiariremo anche che relazione c'è tra l'efficienza degli algoritmi (o dei programmi) e la complessità dei problemi che con tali algoritmi intendiamo risolvere. Obiettivo di questo parte del corso è l'acquisizione della capacità di valutare il costo di esecuzione di un algoritmo o di un programma in modo da poter confrontare vari algoritmi per la risoluzione di un problema e scegliere quello le cui caratteristiche di efficienza corrispondono alle esigenze dell'utente. Per la risoluzione di un problema sono in genere disponibili più algoritmi dotati di diverse caratteristiche di efficienza. Nella Sezione 2 vedremo un semplice esempio che chiarisce questo fatto. I diversi algoritmi per la risoluzione di un problema, in genere, utilizzano, durante l'esecuzione, quantità di tempo e quantità di memoria diverse. Per poter scegliere l'algoritmo o il programma più adatto alle esigenze dell'utente è necessario imparare a valutarne il costo di esecuzione in modo formale. Innanzitutto è necessario esprimere l'algoritmo facendo riferimento ad un modello di calcolo astratto (come la macchina a registri) o un linguaggio di programmazione reale (come il Pascal o il C++ o Java). Poi dobbiamo individuare quale misura di complessità considerare (tempo, memoria, numero di operazioni di tipo particolare Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 2 ecc.) e quale parametro scegliere per esprimere la variabilità dell'input, in funzione della quale misuriamo il costo di esecuzione (ad esempio possiamo assumere il numero di caratteri di cui è composto l'input o, secondo i problemi trattati, altri tipi di grandezze). Infine dobbiamo decidere se ci interessa valutare il comportamento dell'algoritmo nel caso peggiore o nel caso medio . Questi concetti sono introdotti nella Sezione 3. Nella Sezione 4 vedremo come i concetti introdotti si applicano al caso dell'analisi di un algoritmo scritto in linguaggio ad alto livello e poi, più in particolare, come si può semplificare l'analisi limitandosi a valutare il numero di volte in cui una particolare operazione del nostro programma, detta operazione dominante, viene eseguita. Nella Sezione 5, introdurremo i concetti di limite superiore (upper bound ) e limite inferiore (lower bound ) alla complessità di un problema. Nella Sezione 6, applicheremo le tecniche di analisi di complessità per alcuni semplici algoritmi per due problemi classici: l’ordinamento di un vettore e la ricerca di un elemento in un vettore. Nella Sezione 7, infine, introdurremo due strutture dati classiche, pile e code, e valuteremo la complessità dei metodi per la loro manipolazione. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 3 1. COMPLESSITA' DI PROGRAMMI E COMPLESSITA' DI PROBLEMI 1.1. Introduzione In questa prima sezione cercheremo di chiarire, innanzitutto in modo intuitivo, cosa si intende per complessità di un programma e per complessità di un problema ma prima di fornire esempi e definizioni più formali, facciamo alcune premesse. Iniziamo dal concetto di problema. In termini molto generali possiamo definire un problema come segue. Dato un alfabeto Σ e l’insieme Σ* delle stringhe su di esso, un problema P è una funzione da Σ* a 2 * tale che per ogni stringa x (istanza del problema), P(x) è un insieme di stringhe (soluzioni dell’istanza del problema), eventualmente vuoto (se l’istanza del problema non ha soluzioni). Il dominio del problema P, indicato come dom(P), è l’insieme delle stringhe x, per le quali P(x) non è vuoto. Il problema P può essere definito in maniera analoga come una funzione parziale a multi-valori da Σ* a Σ*, cioè una relazione su Σ×Σ; la funzione è definita solo su dom(P). Σ Un problema P è detto: − di ricerca se per ogni x in dom(P), P(x) è finito; − deterministico se per ogni x in dom(P), P(x) è un singoletto; − di decisione se per ogni x in dom(P), P(x) vale {si} oppure {no}. Dato un problema di ricerca P, un problema O di ottimizzazione associato a P è un problema tale che dom(P) = dom(O) e per ogni x in dom(P), O(x) ⊆ P(x), cioè le soluzioni di O sono alcune delle soluzioni di P che soddisfano particolari condizioni di ottimizzazione (di minimo o massimo). Un algoritmo A di risoluzione di un problema P è una sequenza finita di istruzioni che calcola P tale che ogni istruzione sia eseguibile da un particolare esecutore (essere umano o automa) in un tempo finito, anche se può accadere che la esecuzione complessiva non termini poiché potrebbe essere richiesto di ripetere le stesse istruzioni un numero illimitato di volte. Un programma in un linguaggo qualsiasi di programmazione è un algoritmo; in tal caso l’esecutore è il calcolatore. I problemi risolvibili (o calcolabili) sono quelli per i quali esiste un programma di risoluzione. Non tutti i problemi sono risolvibili. Ad esempio, il seguente problema delle corrispondenze non è risolvibile: dati due insiemi di stringhe S1 e S2, trovare una stringa x tale che essa sia contemporaneamente la concatenazione di stringhe in S1 e la concatenazione di stringhe in S2. Una istanza del problema è S1 = {01, 00, 110} e S2 = {001, 1}; una soluzione è 0011001, ottenibile sia come 00 ⋅ 110 ⋅ 01 sia come 001 ⋅ 1 ⋅ 001, Introduciamo ora il concetto di complessità di un programma. Con questo termine intendiamo riferirci ad una valutazione quantitativa dell'efficienza del programma, cioè, intuitivamente, del tempo che il programma impiega per risolvere il problema che dobbiamo affrontare e per il quale esso è stato realizzato. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 4 A questo proposito, facciamo un'ulteriore osservazione. Nel trattare il problema dell'efficienza parleremo in generale indifferentemente sia di algoritmi sia di programmi poiché il fatto che un algoritmo sia espresso mediante un vero e proprio linguaggio di programmazione, pur essendo utile all'analisi formale dell'efficienza stessa, non influenza sostanzialmente il risultato. L'efficienza di un programma, infatti, dipende fondamentalmente dai passi previsti dall'algoritmo, dalle operazioni che esso esegue, dai cicli che devono essere percorsi e non dal modo in cui passi di calcolo elementari, operazioni, cicli si esprimono nel particolare linguaggio di programmazione usato. Un secondo aspetto della complessità che prenderemo in esame in questa sezione (e poi, più approfonditamente, nella Sezione 4) è quello relativo alla complessità di problemi . In questo caso il termine complessità viene utilizzato per dare una valutazione quantitativa della effettiva difficoltà di risoluzione del problema, cioè di quelle intrinseche caratteristiche che rendono la soluzione di un problema più costosa della soluzione di un altro. 2.2 Complessità di algoritmi Per comprendere, con un semplice esempio, come le stesse operazioni possono essere svolte da due algoritmi in modo molto diverso dal punto di vista dell'efficienza consideriamo il seguente gioco. Esempio 1 Supponiamo di dover indovinare un numero tra 1 e 99 e supponiamo che ad ogni tentativo ci venga semplicemente detto se il numero che abbiamo proposto è "troppo grande", "troppo piccolo", "corretto". Come possiamo procedere per individuare il numero facendo il minimo numero di errori? Il modo più ingenuo consiste nel tentare di indovinare il numero senza una precisa strategia. In questo caso, se siamo molto fortunati possiamo anche indovinare il numero al primo o secondo tentativo ma se siamo molto sfortunati rischiamo di dover fare un numero molto alto di tentativi, anche 50 o, al limite, 99! Una scelta preferibile è certamente quella di procedere in modo più razionale, secondo una ben determinata strategia. Ad esempio posssiamo effettuare i seguenti tentativi:10, 20, 30, ..., 90. Se,ad esempio a 70, la risposta è " troppo grande" vuol dire che abbiamo superato il numero. A questo punto possiamo variare il numero di uno in uno: 61, 62, 63 ecc. Prima di arrivare a 70 avremo certamente terminato. In definitiva , in questo modo , anche se siamo molto sfortunati (il numero da indovinare è 99) non serviranno mai più di 18 tentativi. Esiste però un metodo ancora più efficiente, che richiede ancora meno tentativi. Proponiamo il numero 50 come primo tentativo. Se esso è "troppo grande" proponiamo la metà di 50, cioè 25; se è "troppo piccolo" proponiamo 75, cioè il numero che sta a metà tra 50 e 100, e così via, ogni volta dimezzando l'intervallo di ricerca. Chiaramente questo non è altro che il metodo di ricerca binaria che si è appreso in corsi precedenti per effettuare efficientemente la ricerca di una informazione in una tabella. Se il numero da indovinare è ad esempio 34 la sequenza di tentativi che dovremo fare è: 50, 25, 37, 31, 34. Con questo metodo, si può facilmente verificare che per indovinare un numero nell'intervallo 1,.., n - 1 sono sempre sufficienti al più ⎡log2 n⎤ tentativi (dove con il simbolo ⎡log2 n⎤ intendiamo il minimo intero maggiore o uguale a log2 n), e quindi, nel nostro gioco, anche nel caso più sfortunato, non serviranno mai più di ⎡log2 100⎤ =7 tentativi. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Domanda 1. Pag. 5 Utilizzando il metodo basato sulla ricerca binaria per indovinare un numero tra 1 e 99, conta quanti tentativi sono necessari per indovinare i numeri 6, 18, 20, 24. Quanti tentativi servono, in generale se dobbiamo indovinare un numero tra 0 ed n-1? Come abbiamo visto nell'esempio lo stesso problema può essere risolto in diversi modi, con caratteristiche di efficienza molto diverse. Essenzialmente l'efficienza di un algoritmo è legata al suo costo di esecuzione, cioè alla quantità di risorsa che esso impiega durante l'esecuzione. Nell'esempio precedente tale costo è stato misurato in termini di "numero di tentativi" fatti per indovinare. Nei casi più frequenti il costo corrisponderà (o sarà direttamente proporzionale) alla quantità di tempo o alla quantità di memoria utilizzata per l'esecuzione del programma. A volte per indicare questo costo di esecuzione parliamo anche di complessità dell'algoritmo e chiamiamo misura di complessità la risorsa (tempo o memoria) di cui abbiamo valutato il consumo da parte dell'algoritmo. La questione della valutazione dell'efficienza degli algoritmi e della scelta degli algoritmi più efficienti atti a risolvere un dato problema è al centro del nostro interesse in tutto questo corso. Tuttavia, prima di procedere a dare le basi e i metodi formali che consentono di analizzare l'efficienza di un algoritmo e di confrontarla con quella di algoritmi alternativi, vogliamo fare ancora qualche considerazione preliminare. Prima di tutto osserviamo che la necessità di disporre di una soluzione efficiente dipende dalla natura del problema dato. Ad esempio se dobbiamo controllare se un impianto nucleare sta funzionando regolarmente il tempo di risposta che richiediamo è dell'ordine dei decimi di secondo, se dobbiamo effettuare una prenotazione per un volo internazionale abbiamo bisogno di ottenere una risposta nell'arco di qualche decina di secondi, ma se dobbiamo fornire un rapporto mensile per l'ufficio del personale di un'azienda il vincolo dell'efficienza è molto meno stringente. Inoltre dobbiamo tenere presente che, a volte, la produzione di un programma molto efficiente può comportare una diminuzione della sua leggibilità e quindi un aumento dei costi di redazione e di manutenzione del programma stesso. Infine esistono problemi intrinsecamente difficili per i quali è impossibile trovare algoritmi molto efficienti e, se si vuole ottenere una risposta in tempi rapidi, ci si deve accontentare di una risposta approssimata. La questione della intrinseca difficoltà dei problemi sarà discussa nel prossimo paragrafo e poi, più diffusamente, nella Sezione 4. Prima di procedere, tuttavia, per fissare le idee sul confronto di efficienza tra diversi algoritmi, risolvi il seguente esercizio. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Esercizio 1. Pag. 6 Facendo riferimento a quanto appreso nei corsi di base di informatica, esamina due diversi algoritmi per l'ordinamento di un vettore, l'algoritmo di ordinamento per selezione e l'algoritmo di ordinamento a bolle. e: i) considera il seguente vettore: <5 , 34 , 22 , 42 , 9 , 8 , 6 , 3 , 7> e determina quanti confronti tra singoli elementi sono richiesti dai due algoritmi esaminati per ordinare il vettore in senso decrescente; ii) considera il comportamento dei due algoritmi in presenza di un vettore di n elementi, già ordinato in senso decrescente; quanti confronti eseguiranno? iii) Cosa accade se i due vettori sono ordinati in senso crescente? 2.3. Complessità di problemi La possibilità di ottenere algoritmi efficienti per i problemi che si devono risolvere con l'elaboratore è legata certamente alla conoscenza, da parte del progettista software, delle tecniche di programmazione più avanzate e delle strutture di dati più idonee, ma, come abbiamo già detto, essa dipende soprattutto dalla effettiva complessità intrinseca del problema dato. La questione dell'esistenza di un algoritmo efficiente per la risoluzione di un dato problema è una questione estremamente importante. In molti casi, infatti, l'esistenza di un algoritmo efficiente è una condizione indispensabile perché un problema sia realmente risolubile. Problemi che richiedono, ad esempio, un numero esponenziale di operazioni per essere risolti possono essere considerati non risolubili a tutti i fini pratici, anche se si presentano semplici ed innocui. Domanda 2 Supponi che un ladro un pò ingenuo, volendo scassinare una cassaforte di cui non conosce la combinazione, decida di provare tutte le possibili combinazioni; supponi che ogni tentativo gli costi dieci secondi; tenendo conto del fatto che la combinazione della cassaforte è costituita da una sequenza di cinque caratteri alfabetici (tratti dall'alfabeto inglese), quanto tempo impiegherà il ladro per il suo tentativo? Supponi che un potente calcolatore sia collegato alla cassaforte e possa provare ogni combinazione in un milionesimo di secondo quanto tempo impiegherebbe per provare tutte le combinazioni? E cosa accadrebbe se la combinazione fosse costituita da dieci caratteri? D'altra parte, come abbiamo già osservato in precedenza, la possibilità di individuare algoritmi sempre più efficienti dipende dalla complessità del problema considerato. Può infatti accadere che, data la intrinseca difficoltà del problema, non possa esistere alcun algoritmo sostanzialmente più efficiente di quelli già noti. Aiutiamoci ancora con il gioco visto nell'Esempio 1. Supponiamo che per migliorare le possibilità di vittoria si cerchi un metodo di formulazione di tentativi ancora più efficiente, un metodo, cioè, che consenta di indovinare un numero da 1 a 99 con ancora meno tentativi di quelli richiesti dal metodo basato sulla ricerca binaria. Ebbene, purtroppo ci dobbiamo convincere che a questo punto non è più solo questione di astuzia nel fare i tentativi; a questo punto siamo giunti a un limite legato alla intrinseca difficoltà del problema. Infatti si può Pag. 7 Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi dimostrare che non è possibile individuare un numero da 1 a n - 1 con meno di ⎡log2 n⎤ tentativi. Nota bene: se siamo fortunati possiamo anche indovinare il numero con uno o due tentativi; quanto detto significa però che nessun metodo sistematico consente di individuare sempre il numero con meno di ⎡log2 n⎤ tentativi. Una dimostrazione formale di questo risultato è alquanto complessa, possiamo tuttavia cercare di spiegarlo con un ragionamento intuitivo, rinviando ad un momento successivo un approfondimento di questa problematica (vedi Sezione 5). L'impossibilità di trovare un metodo che richieda meno di ⎡log2 n⎤ tentativi è dovuta al fatto che la minima quantità di informazione necessaria per individuare un numero dato tra 1 ed n - 1 è costituita da un numero di bit pari a ⎡log2 n⎤ che, come si può facilmente verificare, è il numero di bit necessario per rappresentare in binario ogni numero dell'intervallo dato Poiché ogni tentativo può fornirci al più una quantità di informazione pari ad un bit un numero di confronti inferiore a ⎡log2 n⎤ non può essere comunque sufficiente per risolvere, in generale, il nostro problema. In tutti i problemi che vogliamo risolvere c'è un limite oltre il quale non si può andare nel tentativo di realizzare algoritmi di sempre maggiore efficienza. Con il termine di complessità di un problema intendiamo proprio tale soglia. Ad esempio, la complessità del problema di indovinare un numero tra 0 ed n-1 è data da ⎡log2 n⎤ tentativi. Sfortunatamente una grande quantità di problemi di rilevante interesse pratico hanno un'intrinseca complessità che li rende molto difficili da risolvere anche con un potente elaboratore. Per molti problemi di ottimizzazione, ad esempio, come problemi di gestione ottima di risorse, di distribuzione ottima di prodotti in una rete di magazzini, di sequenziamento ottimo di attività in un sistema di calcolo distribuito ecc. la complessità intrinseca è tale che questi problemi sono stati definiti "intrattabili" e se ne può ottenere soltanto una soluzione approssimata. Per avere un'idea del tempo richiesto dalla soluzione di problemi di diverso grado di complessita' riportiamo nella Tabella 1 i tempi necessari per risolvere problemi di varia taglia, quando la funzione di costo è quella indicata nella prima colonna. Per fissare le idee possiamo immaginare che il primo dei problemi di ogni riga sia risolubile in un microsecondo. Le diverse colonne mostrano come cresce il tempo di risoluzione se la taglia diviene 10 volte, 20 volte, 50 volte più grande. Come si vede il concetto di intrattabilità per problemi di costo esponenziale è quanto mai giustificato. Dimensione del problema 1 x10 x20 x30 x40 X50 n 0.000001 0.00001 0.00002 0.00003 0.00004 0.00005 n2 0.000001 0.0001 0.0004 0.0009 0.0016 0.0025 n3 0.000001 0.001 0.008 0.027 0.064 0.125 2n 0.000001 0.001 1.0 17.9 min 12.7 giorni 35.7 anni Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi 3n 0.000001 0.059 58 min 6.5 anni Pag. 8 3855 secoli 2x10 8secoli Tabella 1. Tempi di risoluzione (in secondi, se non diversamente indicato) di vari problemi per diverse funzioni di costo, al crescere della taglia dei problemi. Prima di concludere consideriamo ancora un esempio per fissare le idee sul concetto di complessità intrinseca di un problema. Siano dati quattro numeri: n1, n2, n3, n4. Per individuare il numero più grande sono necessari almeno 3 confronti: ad esempio vanno prima confrontati (i) n1 con n2 (e supponiamo che sia n2 più grande) e n3 con n4 (e supponiamo che sia n3 più grande) e poi (ii) n2 con n3 (e supponiamo che sia n3 più grande). E’ facile convincersi che due soli confronti non sono sufficienti in quanto qualsiasi numero escluso dal confronto potrebbe inficiare il risultato. Per trovare il secondo maggiore, non basta prendere il numero n2 “perdente” nel passo (ii) ma va fatto un ulteriore confronto tra n2 e n4 che nel passo (i) era già risultato superato da n3 ma che tuttavia potrebbe essere maggiore di n2. Si consideri ad esempio il caso n1 = 4, n2 = 8, n3 = 20, n4 = 10. Domanda 3 Consideriamo un torneo di tennis a eliminatorie con 16 giocatori; perchè se vogliamo determinare il vincitore servono 15 incontri? Perché se vogliamo determinare oltre al primo anche il secondo miglior giocatore 15 incontri non sono sufficienti e ne servono 18? Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 9 3. METODI DI ANALISI DI COMPLESSITA' DEGLI ALGORITMI 3.1. Introduzione Nella sezione precedente abbiamo visto che uno stesso problema può essere affrontato con metodi di risoluzione diversi che possono presentare un diverso grado di efficienza. Abbiamo anche visto che l'efficienza di un algoritmo può essere decisiva rispetto alla possibilità di risolvere il problema dato nei limiti di tempo richiesti dall'applicazione. Infine abbiamo osservato che nella ricerca di algoritmi sempre più efficienti per la risoluzione di un problema ci dobbiamo confrontare con un limite determinato dalla intrinseca complessità del problema stesso. In questa Sezione inizieremo a porre questa problematica in termini più formali. Come abbiamo già detto, con il termine complessità di un algoritmo intendiamo riferirci ad una valutazione quantitativa del costo di esecuzione dell'algoritmo stesso espresso con una opportuna unità di misura (nell'Esempio 1 della sezione precedente l'unità di misura era costituita dal numero di tentativi che effettuavamo per indovinare il numero dato) in funzione di qualche parametro caratteristico del problema (sempre nell'esempio citato questo parametro era dato dall'ampiezza dell'intervallo dei possibili valori del numero da indovinare). Spesso anziché di complessità parliamo di efficienza di un algoritmo o, più esplicitamente e più propriamente, di costo di esecuzione. La relazione tra questi termini è abbastanza chiara: un algoritmo è tanto più complesso e tanto meno efficiente quanto più elevato è il suo costo di esecuzione. Per poter utilizzare correttamente questi termini, tuttavia, è necessario tenere conto di tutta una serie di fattori che devono essere precisati perché il concetto di complessità di un algoritmo non rimanga un concetto ambiguo. Il primo fattore da considerare è il modello di macchina utilizzato e, in relazione ad esso, l'unità di misura della complessità che viene adottata; è poi necessario definire il tipo di analisi che vogliamo effettuare e, infine, il modo in cui esprimiamo la complessità emersa dalle nostre valutazioni. Esaminiamo uno ad uno questi diversi aspetti. 3.2. Modelli di macchina e misure di complessità. Il primo fattore che dobbiamo tenere presente per analizzare in modo rigoroso il comportamento di un algoritmo è il tipo di macchina, di sistema di calcolo su cui si suppone che l'algoritmo venga eseguito. Come abbiamo osservato nella sezione precedente, l'efficienza di un programma dipende essenzialmente dalla struttura dell'algoritmo utilizzato e non dalle caratteristiche del linguaggio di programmazione in cui l'algoritmo è stato codificato. Tuttavia per effettuare un'analisi accurata del costo di esecuzione dobbiamo definire formalmente come il calcolo sarà eseguito e quali grandezze utilizzare come misura della complessità. La prima possibilità che ci si offre è, chiaramente, quella di fare riferimento ad un elaboratore reale su cui il programma può essere eseguito e sul quale possiamo misurare il tempo richiesto per l'esecuzione. Questo procedimento, tuttavia, presenta molti inconvenienti. Il primo è che tale metodo è troppo influenzato dalle caratteristiche tecnologiche della macchina utilizzata e cambiando macchina si otterrebbero dei risultati diversi. Il secondo e più grave inconveniente è che, in genere, noi non siamo tanto interessati a stabilire il tempo di Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 10 risoluzione di una particolare istanza di un problema quanto vogliamo determinare con che legge il costo di esecuzione che ci interessa (sia esso il tempo o la quantità di memoria o qualche altra grandezza) varia al variare dell'istanza. Ad esempio, nel caso del problema della determinazione del primo e secondo miglior giocatore di un torneo di tennis, ci interessa sapere quante partite occorrono in funzione del numero di giocatori. A tale scopo è necessario fare un passo di astrazione e considerare modelli di calcolo concettualmente basati sugli stessi principi dei normali calcolatori ma molto più elementari di essi. Modelli di questo tipo ne sono stati introdotti diversi. Alcuni risalgono ai primi studi di logica volti a determinare quale fosse la classe di problemi risolubili con metodi algoritmici. E' il caso, ad esempio, delle macchine di Turing introdotte dal logico Alan Turing nel 1936 e ancora oggi utilizzate in Informatica Teorica proprio per studi relativi alla complessità di calcolo. Tali macchine sono dotate di un numero finito di stati interni ed operano leggendo e scrivendo caratteri di un particolare alfabeto (ad esempio quello binario o quello alfanumerico) mediante una testina di lettura e scrittura su un nastro potenzialmente illimitato. Il carattere letto correntemente dalla testina e lo stato interno in cui la macchina si trova determinano la transizione eseguita dalla macchina, cioè il nuovo stato interno, il carattere eventualmente scritto sul nastro e lo spostamento della testina sul nastro. Se come modello di macchina utilizziamo la macchina di Turing possiamo misurare il numero di passi che la macchina compie (numero di transizioni) e assimilare questa grandezza al tempo di calcolo. Oppure possiamo misurare il numero di celle del nastro di lavoro utilizzate e considerarlo come indicativo della quantità di memoria richiesta dall'algoritmo. Un altro modello di macchina che può essere utilizzato sono le macchine a registri. Una macchina a registri, detta anche RAM (Random Access Machine) è chiamata così perchè la sua memoria consiste di un numero finito, ma arbitrario, di registri, ognuno dei quali può contenere un intero grande a piacere. I registri sono accessibili direttamente, in base al loro numero d'ordine. Un registro particolare, chiamato accumulatore, è destinato a contenere via via, uno degli operandi su cui agiscono le istruzioni della macchina. La macchina scambia informazioni con il mondo esterno mediante due nastri di ingresso e uscita consistenti di sequenze di celle, ciascuna delle quali ancora capace di contenere un intero grande a piacere. La macchina, infine, è dotata di una unità centrale capace di eseguire le istruzioni del linguaggio di programmazione. La Figura 1 fornisce una rappresentazione di una macchina a registri. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 11 Figura 1.1 Rappresentazione fisica di una macchina a registri. Il linguaggio di programmazione delle RAM è simile al linguaggio Assembler di un calcolatore reale. Un programma consiste in una sequenza di istruzioni di vario tipo (di trasferimento, aritmetiche, di controllo, di I/O). Le istruzioni possono essere eventualmente dotate di etichetta. In genere un'istruzione opera su operandi contenuti nei registri, che in tal caso vengono indicati semplicemente con il numero d'ordine del registro (ad esempio 3 indica il contenuto del registro 3). Quando un operando viene indirizzato in modo indiretto tramite il contenuto di un altro registro esso viene indicato con il numero del registro preceduto da * (ad esempio *3 indica il contenuto del registro indirizzato dal registro 3). Infine un operando può essere direttamente costituito dal dato su cui si vuole operare. in tal caso il dato viene indicato con = (ad esempio =3 indica che l'operando è l'intero 3). Le istruzioni di trasferimento (LOAD e STORE) permettono di trasferire il contenuto di un registro nell'accumulatore e viceversa. Le istruzioni aritmetiche (ADD, somma, SUB, sottrazione, MULT, moltiplicazione, DIV, parte intera della divisione, REM, resto della divisione) permettono di eseguire le quattro operazioni tra il contenuto dell'accumulatore ed il contenuto di un registro. Il risultato rimane nell'accumulatore. Nota che l'istruzione SUB dà risultato 0 se si tenta di sottrarre un numero maggiore da un numero minore. Le istruzioni vengono sempre eseguite sequenzialmente tranne nei casi in cui la sequenza di esecuzione non venga alterata da una istruzione di controllo e cioè un'istruzione di HALT o Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 12 una istruzione di salto. Quando si incontra l'istruzione HALT l'esecuzione del programma si arresta. Il programma termina anche se si tenta di eseguire un'istruzione inesistente. Le istruzioni di salto sono salti incondizionati (JUMP) o condizionati in base al contenuto dell'accumulatore (JGTZ, JEQZ cioè salta se l'accumulatore contiene un intero maggiore di zero, o rispettivamente, uguale a zero); l'operando di un'istruzione di salto è l'etichetta (o il numero d'ordine) dell'istruzione alla quale eventualmente si effettua il salto. Infine due istruzioni di I/O (READ e WRITE) consentono di leggere un numero intero sul nastro di ingresso e trasferirlo in un registro e, rispettivamente, di scrivere il contenuto di un registro sul nastro di uscita. Esempio 2 Supponiamo che gli interi x (> 0) e y siano forniti sul nastro di ingresso. Il seguente programma dà in uscita il valore xy. LOOP STOP READ READ LOAD STORE LOAD JEQZ LOAD MULT STORE LOAD SUB STORE JUMP WRITE HALT 1 2 =1 3 2 STOP 1 3 3 2 =1 2 LOOP 3 Dal punto di vista del loro uso nell'analisi del costo di esecuzione di algoritmi si può dire che le RAM sono un modello abbastanza realistico, sostanzialmente equivalente ad un elaboratore reale programmato in Assembler, e per il quale il tempo è rappresentato dal numero di istruzioni eseguite, eventualmente pesate in modo da tenere conto del fatto che le varie istruzioni elementari possono avere un costo diverso (ad esempio la moltiplicazione è in genere più costosa del semplice trasferimento di dati) e che, a differenza che negli elaboratori reali, in queste macchine i registri possono contenere un intero arbitrariamente grande. Un metodo semplice ma già sufficientemente dettagliato per analizzare il costo di un programma per RAM è di attribuire un costo unitario a tutte le istruzioni e limitarsi a contare quante volte ogni istruzione viene eseguita in funzione dell'input del programma. Riprendiamo in esame l'esempio precedente; possiamo vederlo diviso in tre parti; lettura dell'input e inizializzazione, esecuzione del ciclo, terminazione. La prima e l'ultima parte hanno rispettivamente un costo pari a 4 e a 2, indipendentemente dal valore dell'input. Il corpo del programma, cioè il ciclo, consta di 8 istruzioni che vengono eseguite y volte. Inoltre le prime due istruzioni del ciclo vengono eseguite una volta in più, quando il valore y diventa uguale a zero e si effettua il salto all'etichetta STOP. In definitiva il costo di esecuzione del programma è pari a 4+8y+2+2 = 8y+8. Esercizio 2 Realizza un programma per macchine a registri che leggendo in ingresso l'intero n e, successivamente, n numeri interi, ne determina il massimo e lo Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 13 stampa sul nastro di uscita. Quale è il suo costo di esecuzione misurato in termini di istruzioni eseguite? Per quanto le macchine di Turing e, soprattutto, le RAM siano dei modelli di calcolo astratti, sufficientemente elementari per determinare facilmente il costo di esecuzione di un algoritmo, non dobbiamo pensare che sia sempre necessario fare riferimento a tali modelli. In molti casi possiamo utilizzare dei modelli più semplici, di potenza limitata ma adatti a descrivere il particolare tipo di algoritmi che vogliamo analizzare. Un esempio tipico sono gli alberi di decisione, nei quali ogni nodo dell'albero rappresenta un'operazione di confronto tra interi. Essi possono essere utilizzati per analizzare e confrontare algoritmi di ricerca (come quello dell' Esempio 1). Lo stesso concetto di torneo, utilizzato nella sezione precedente, può essere visto come un modello di esecuzione di algoritmi di ordinamento parziale, in cui la misura di complessità adottata è il numero di incontri effettuati. Infine possiamo considerare un algoritmo scritto in un linguaggio ad alto livello (Java, Pascal, C++ ecc.), o anche in uno pseudo-linguaggio (più simile al linguaggio naturale che ad un vero e proprio linguaggio di programmazione) e limitarci a valutare il numero di operazioni fondamentali che vengono compiute dall'algoritmo stesso (cioè le cosiddette operazioni dominanti), adottando questo valore come misura del tempo di calcolo. Questo approccio sarà quello utilizzato prevalentemente in tutto questo corso. Esercizio 3 Dato un insieme di 2n interi, utilizzando il modello di calcolo del torneo, determina quanti confronti sono sufficienti i) per individuare il massimo e il minimo, ii) per individuare il massimo e il secondo elemento più grande. 3.3. Dimensione dell'input Quando vogliamo esprimere il costo di esecuzione di un algoritmo dobbiamo precisare in funzione di quale parametro del problema forniamo questa rappresentazione. Ad esempio nei casi visti precedentemente, l'individuazione di un numero in un dato intervallo o la determinazione del vincitore di un torneo, abbiamo fatto riferimento rispettivamente all'ampiezza dell'intervallo e al numero di giocatori impegnati nel torneo. Al fine di standardizzare il concetto di "dimensione" o "taglia" dell'input rispetto alla quale esprimere la valutazione dell'efficienza di un algoritmo, è stato convenzionalmente adottato il concetto di lunghezza dell'input, corrispondente al numero di bit (o, più in generale, di caratteri) che costituiscono l'input di un algoritmo. Ricordando che data una stringa w si esprime con |w| la sua lunghezza, in generale con |I| indicheremo la lunghezza dell'input I. Più in generale, in modo meno rigoroso, si tende a fare riferimento a caratteristiche dell'input più informali ma più intuitive. Ad esempio se si analizzano algoritmi di ordinamento di un vettore A si fa riferimento al numero n di elementi che lo costituiscono e così pure se si considerano algoritmi di ricerca su una tabella T di n elementi. Ciò non deve stupire perchè, se si assume che gli elementi del vettore o della tabella abbiano una lunghezza massima prefissata c, il valore n è direttamente proporzionale ai valori |A| e |T| poichè abbiamo |A| = |T| = c⋅ n. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 14 A volte per ottenere una rappresentazione efficace del costo di esecuzione di un algoritmo si sacrifica il rigore e si fa ricorso a parametri non direttamente proporzionali alla lunghezza dell'input Esempio 3 Consideriamo il caso di algoritmi che operano su matrici. Quando diciamo che un algoritmo che opera su matrici di dimensione n⋅n richiede un numero di operazioni quadratico, ovvero dell'ordine di n2 , vuol dire che esprimiamo la complessità dell'algoritmo in funzione della dimensione della matrice (cioè del numero delle sue righe o delle sue colonne). In generale la scelta del parametro di riferimento può incidere sulla valutazione dell'efficienza di un algoritmo. Se avessimo adottato come parametro di riferimento il numero di elementi della matrice, la valutazione avrebbe dato un esito diverso. Chiamiamo e tale numero; poiché abbiamo e = n2 una valutazione dell'efficienza dell'algoritmo in funzione di questo nuovo parametro ci porterebbe a dire che l'algoritmo è lineare nella dimensione dell'input, cioè richiede un numero di operazioni dell'ordine di e. Naturalmente entrambe le valutazioni sono corrette, tuttavia la prima formulazione è indubbiamente più suggestiva e, in effetti, è la più usata. Per renderci conto dell'importanza di adottare come dimensione dell'input una grandezza che varia linearmente o al più polinomialmente con la sua lunghezza, riflettiamo un attimo sul valore che assume |n| quando n è un numero intero espresso in base 2. Ebbene, in tal caso abbiamo che tra il valore |n| e il valore n c'è un salto esponenziale; infatti abbiamo che |n| = ⎣log n⎦ + 1, dove ⎣log n⎦ rappresenta il massimo intero minore o uguale di log n. Vediamo un esempio che mette in evidenza in modo palese quali diverse valutazioni si ottengono se si analizza il costo di esecuzione di un algoritmo numerico utilizzando due diverse misure dell'input corrispondenti alla lunghezza o al valore dell'input stesso. Esempio 4 Consideriamo il cosiddetto test di primalità di un numero, cioè il problema di decidere se un dato numero è, o meno, un numero primo. Un banale algoritmo, basato sulla proprietà che se un numero n non è primo esso deve avere un divisore minore o uguale di , consiste nel provare tutti i numeri da 2 a e verificare se qualcuno di essi è un divisore di n. Quante operazioni di divisione richiede questo procedimento? Chiaramente - 1. Se valutiamo questo numero in funzione dell'input n abbiamo l'impressione di trovarci di fronte a un compito relativamente facile, visto che il suo costo di risoluzione cresce addirittura meno rapidamente dell' input. Se però effettuiamo una analisi più accurata, ricordandoci che il costo di risoluzione deve essere valutato in funzione della lunghezza dell'input, ecco che la difficoltà del problema ci si rivela in modo molto più chiaro. In tal caso, infatti, poiché abbiamo che, sostanzialmente, |n| = log n, otteniamo che il numero di operazioni di divisione, valutato in funzione di |n| è 2|n|/2 e quindi esponenziale. Vediamo un caso concreto. Supponiamo di dover decidere se il numero 2100 - 1 è un numero primo. Usando l'algoritmo introdotto precedentemente dobbiamo eseguire circa 250 operazioni di divisione Questo numero è in realtà talmente grande da rendere praticamente insolubile il problema; se anche utilizzassimo una macchina capace di eseguire un milione di divisioni al secondo il tempo necessario per verificare se 2100 - 1 è un numero primo con questo metodo sarebbe di 250 microsecondi: circa 10 anni ! La differenza tra un algoritmo che, prendendo in ingresso un numero n richiede un numero di operazioni lineare in n e uno che richiede un numero di operazioni lineare in funzione della Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 15 lunghezza di n può essere dunque abissale ed è pertanto necessario fare la massima attenzione a definire esattamente in funzione di quale parametro stiamo esprimendo la complessità dell'algoritmo. Domanda 4 Considera i seguenti problemi: − determinare il massimo di un insieme di interi di valore compreso tra O e MAXINT, dove MAXINT è il massimo intero rapprsentabile con 4 byte − moltiplicare due polinomi a coefficienti interi di valore compreso tra MAXINT e +MAXINT, − calcolare il fattoriale di un numero intero. In funzione di quali parametri dell'input ritieni efficace e corretto esprimere la complessità di algoritmi per la loro soluzione? 3.4. Tipi di analisi Per effettuare l'analisi della complessità di algoritmi e programmi dobbiamo individuare una funzione che esprima il costo di esecuzione dell'algoritmo stesso, al variare della dimensione dell'input. Naturalmente se volessimo esprimere con una funzione l'esatta quantità di risorsa utilizzata per la risoluzione di un problema incontreremmo delle notevoli difficoltà dovute alla grande quantità di dettagli implementativi di cui dovremmo tenere conto e difficilmente riusciremmo ad esprimere in modo analitico, cioè con un'espressione matematica, la grandezza in questione. Pensiamo, a titolo di esempio, ancora al problema della primalità di un numero. Ricordiamo quale è il valore dei numeri primi minori di 20: 2,3,5,7,11,13,17,19. Il metodo alquanto banale che abbiamo supposto di utilizzare richiede, come abbiamo già visto, al più 2 |n|/2 operazioni, tuttavia nel caso che il numero fornito in input sia un numero pari è chiaro che una sola divisione, la divisione per 2, sarà sufficiente per verificare che il numero non è primo. Se il numero è multiplo di tre basteranno due divisioni, se è multiplo di cinque ne basteranno tre e così via. Sfortunatamente un'espressione matematica che in forma esplicita rappresenti esattamente il numero di divisioni necessarie per verificare se un dato numero n è un numero primo o no non è nota e sarebbe comunque talmente complessa essa stessa da risultare poco comprensibile. E' per tale motivo che si usa la limitazione meno stringente ma |n|/2 piu' chiara 2 . In generale, dunque, è preferibile esprimere la complessità di un algoritmo mediante una funzione che: - delimiti superiormente il valore esatto - esprima in modo chiaro come il costo di soluzione del problema varia al crescere della dimensione dell'input. Questo tipo di analisi viene detto analisi asintotica del caso peggiore. Chiariamo separatamente cosa intendiamo per analisi asintotica e cosa intendiamo per analisi del caso peggiore. Parliamo di analisi asintotica poichè la valutazione del costo di risoluzione mette essenzialmente in evidenza come tale costo va all'infinito al tendere all'infinito della dimensione dell'input. E' chiaro che, da tale punto di vista, eventuali fattori costanti o termini di ordine inferiore vengono ignorati. Ad esempio, noi diciamo che un algoritmo ha una Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 16 complessità dell'ordine di n2 anche se una valutazione più precisa del costo di esecuzione dell'algoritmo stesso darebbe il risultato c n2 + b n Per esprimere una valutazione asintotica di complessità è stata introdotta una notazione derivata essenzialmentente dall'analisi classica: la notazione Θ. Definizione 1 Una funzione f(n) è Θ(g(n)) se limn ∞ g(n)/f(n) = c > 0 cioè converge verso un valore finito maggiore di zero La complessità di un algoritmo, misurata in termini di una data risorsa, è Θ (g) se la quantità di risorsa richiesta per l'esecuzione dell'algoritmo è Θ (g). → Utilizzando la notazione "teta" possiamo dire, ad esempio, che l'algoritmo per il test di primalità di un intero n, visto nell'Esempio 4, ha una complessità Θ(2|n|/2). Domanda 5 Data la funzione f(n) = c n2 + b n quali delle seguenti affermazioni sono corrette? a) f(n) è Θ (n) b) f(n) è Θ(n2) c) f(n) è Θ(n3). L'uso dell'analisi asintotica nella valutazione del costo degli algoritmi dà un'indicazione molto utile dell'andamento di tale costo al crescere della dimensione del problema affrontato. Esso, tuttavia, ha dei limiti dovuti al fatto che le costanti e i termini di ordine inferiore vengono ignorati. Di conseguenza, due programmi di costo n log n, l'uno, e 1000 n log n, l'altro, vengono considerati della stessa complessità Θ(n log n). Più grave ancora è che se un programma ha costo 1000 n logn e un altro ha costo n2 il primo è considerato (asintoticamente) migliore del secondo anche se il costo di esecuzione del secondo risulta, in realtà, inferiore fino ad n=10000. Nonostante questi inconvenienti l'analisi asintotica viene ritenuta utile per determinare una prima informazione di carattere globale sul costo di esecuzione di un programma. Naturalmente se si deve stabilire come si comporta il programma per particolari valori dell'input (come quando si devono confrontare, a fini pratici, due programmi destinati ad operare su valori di n relativamente "piccoli" oppure quando si devono rispettare vincoli temporali molto stringenti) le costanti e i termini di ordine inferiore non possono più essere ignorati e l'analisi deve essere maggiormente raffinata. Veniamo ora al concetto di analisi del caso peggiore. Con questo termine intendiamo dire che per valutare il costo dell'algoritmo teniamo conto, per ogni valore n della dimensione dell'input, del costo richiesto dai casi più complessi tra quelli che hanno dimensione n. Sempre con riferimento al problema dei numeri primi, ad esempio, teniamo conto del fatto che, tra i numeri di lunghezza n, sono proprio i numeri primi che richiedono 2n/2 operazioni per essere riconosciuti. Analogamente, nel caso dell'ordinamento in senso crescente di un vettore di n elementi con il metodo "a bolle" diciamo che il costo di esecuzione è dell'ordine di n2 perchè questo è ciò che accade nel caso peggiore, cioè se il vettore è ordinato in senso decrescente, mentre noi sappiamo che se il vettore è già ordinato in senso crescente il metodo "a bolle" esegue soltanto n confronti. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 17 Naturalmente, come abbiamo già messo in evidenza in precedenza, in alcune applicazioni, anziché tenere conto del caso peggiore è più utile tenere conto di ciò che accade nella generalità dei casi. Più precisamente, se abbiamo informazioni sufficienti, possiamo calcolare il costo di esecuzione per i diversi possibili input e determinare la media dei costi ottenuti. In questo caso parliamo di analisi del caso medio. Anche nell'analisi del caso medio in genere si utilizza un approccio asintotico, cioè si mette in evidenza solo l'andamento all'infinito del costo di esecuzione, al crescere della dimensione dell'input. Un esempio significativo di algoritmo che risulta sensibilmente più efficiente nel caso medio di quanto non sia nel caso peggiore è il metodo di ordinamento "quicksort" (ordinamento rapido). Infatti tale algoritmo (che sarà studiato nel capitolo 3 di questo Corso) esegue un numero di confronti O(n2) nel caso peggiore ma solo O(n log n) nel caso medio. Vediamo ancora un semplice esempio di applicazione dei concetti esposti fin qui. Esempio 5 Consideriamo il problema di calcolare la potenza k-esima di un intero a cioè ak, e cerchiamo di individuare un algoritmo per il calcolo di questo valore. Il metodo più banale cui possiamo pensare consiste nel moltiplicare a per se stesso k-1 volte. Come possiamo valutare la complessità di questo metodo? Chiaramente la misura di complessità che possiamo usare per questo semplice calcolo è proprio il numero di moltiplicazioni utilizzate; questo numero dovrà essere espresso in funzione della dimensione dell'input, cioè in funzione di |a| e |k|. Definiamo m = |a| ed n = |k|. Il costo di esecuzione dell'algoritmo è dunque pari a k - 1, cioè Θ(2n) moltiplicazioni. Come si vede il costo è indipendente dal parametro m ma, asintoticamente, varia con n in modo addirittura esponenziale. Potremmo trovare un metodo più efficiente?. Vediamo il seguente metodo. Quando dobbiamo calcolare 2k se k è una potenza di 2 il problema è abbastanza semplice: ad esempio se k = 8 possiamo calcolare prima 22 = 4, poi 2 4 come 22 . 22 e infine 28 come 24 . 24. Il tutto richiede quindi non k - 1 moltiplicazioni (cioè 7), come nel caso dell'algoritmo visto precedentemente, ma semplicemente 3. Supponiamo ora che k non sia una potenza di 2. In tal caso possiamo determinare lo sviluppo di k in potenze di 2, ovvero la sua rappresentazione binaria, calcolare i contributi delle varie potenze di due e poi ottenere il risultato richiesto moltiplicando tra di loro i vari contributi. Sia ad esempio k = 13. La rappresentazione binaria è 1101. Ciò significa che 2k = 28 . 24 . 2 e che, quindi, dobbiamo calcolare il contributo della potenza ottava, della quarta e della prima mentre la potenza seconda non dà alcun contributo. Una volta calcolati i vari contributi dobbiamo semplicemente moltiplicarli tra di loro. Naturalmente il metodo può essere applicato esattamente nello stesso modo se anziché calcolare le potenze di 2 calcoliamo le potenze di una qualunque base. Adesso possiamo calcolare quante moltiplicazioni richiede questo metodo: se n = |k| ne occorrono semplicemente n - 1 per determinare le potenze di a e successivamente altre n - 1 per moltiplicare i contributi necessari nel caso peggiore, cioè se tutti i contributi sono presenti (nell'esempio visto questo secondo passo richiedeva meno moltiplicazioni perché il contributo 22 era assente). Assumendo che le moltiplicazioni comportino un costo unitario, abbiamo quindi un costo complessivo 2n-2 che, asintoticamente, corrisponde ad un costo Θ(n), cioè lineare nella lunghezza dell'input. Esercizio 4 Determina il numero di confronti ed il numero di scambi richiesti, nei casi peggiori, dall'algoritmo di ordinamento "quicksort"; esprimi tali valori con Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 18 la notazione Θ e determina in quali situazioni vantaggiose l'algoritmo può richiedere un numero sensibilmente inferiore di confronti e di scambi. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 19 4. ANALISI DI EFFICIENZA DI PROGRAMMI SCRITTI IN LINGUAGGIO AD ALTO LIVELLO 4.1. Introduzione Per poter calare i concetti di analisi degli algoritmi visti fin qui nella pratica della programmazione è necessario comprendere come si effettua l'analisi di complessità di algoritmi formulati in linguaggi di programmazione ad alto livello. L'uso di modelli calcolo formali, come le macchine di Turing e le RAM, infatti, come si è già detto, risulterebbe eccessivamente gravoso Nella sezione precedente abbiamo già valutato in vari casi il costo di esecuzione di algoritmi descritti informalmente e, inoltre, abbiamo fatto riferimento a programmi scritti in Java incontrati nei corsi di informatica del primo anno. In tali casi abbiamo adottato come misura di complessità le operazioni più significative che venivano eseguite da tali programmi. Ad esempio la complessità di algoritmi di ordinamento è stata misurata in termini del numero di confronti e scambi eseguiti, algoritmi di elevamento a potenza sono stati valutati utilizzando il numero di moltiplicazioni ecc. In realtà, anche se questo approccio rappresenta un modo semplificato per misurare la complessità di un programma in linguaggio ad alto livello, se esso viene applicato correttamente può dare risultati sufficientemente significativi. A tal fine è necessario che sia ben chiaro quali sono tutte le ipotesi semplificative che vengono fatte. In questa sezione vedremo come un programma in linguaggio ad alto livello possa nascondere molti elementi che possono alterare la valutazione della complessità e vedremo quindi in quali condizioni è possibile fare ricorso ad un'analisi basata semplicemente sul conteggio delle operazioni dominanti. 4.2. Analisi dettagliata di programmi in linguaggio ad alto livello Se vogliamo valutare in modo dettagliato il costo di un programma in linguaggio ad alto livello possiamo procedere in modo molto simile a quanto abbiamo visto nel caso delle macchine a registri, utilizzando, cioè un modello a costi uniformi. Assumendo che le operazioni elementari abbiano tutte un costo unitario dobbiamo moltiplicare tale costo per il numero di volte in cui l'operazione viene ripetuta (nel caso che essa si trovi ad esempio dentro un ciclo). Un programma in linguaggio ad alto livello, però, può rendere difficile un'accurata analisi della complessità. Quanto abbiamo visto nella Sezione 2 di questo Capitolo (e cioè il modo di procedere formale che viene utilizzato nel caso di macchine a registri e che tiene conto di tutti i costi in gioco) ci sarà utile per considerare tutti i dettagli implementativi che possono essere nascosti dall'uso di un linguaggio ad alto livello. Esempio 6 Supponiamo di avere il seguente frammento di programma: for (int n=1; n<=m; i++) x=x+n;. Se immaginiamo di implementare questo frammento di programma su una macchina a registri (o su un reale elaboratore), ci rendiamo conto che il suo costo è dovuto non solo alla Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 20 ripetizione dell'istruzione x := x + n, che appare esplicitamente, ma anche alla ripetizione di un'incremento della variabile n, il cui valore deve variare da 1 a m e di un test sulla variabile stessa, che regola la ripetizione del ciclo fino a che n non ha assunto il valore m. Poiché tali istruzioni vengono ripetute m volte, al variare di n da 1 a m, il costo complessivo sarà 3m. Ma in un programma scritto in linguaggio ad alto livello ci possono essere altri costi occulti. Ad esempio dobbiamo tener conto della quantità di tempo necessaria per effettuare operazioni che pur essendo primitive del linguaggio non possono realisticamente essere considerate di costo unitario (come le funzioni matematiche standard sqrt , exp ecc. o come l'assegnazione tra matrici). Inoltre, se analizziamo il consumo di memoria da parte di un programma ricorsivo dovremo calcolare esplicitamente la quantità di memoria richiesta dalla pila per la gestione di chiamate ricorsive di procedura Per chiarire questi aspetti vediamo l'analisi dettagliata di due programmi scritti in linguaggio ad alto livello, un programma iterativo per il calcolo del fattoriale e un programma ricorsivo per lo stesso problema. Esempio 7 Si considerino i seguenti metodi Java che, dato un intero n forniscono il valore del fattoriale di n: public static int fact1 (int n) { int fattoriale=1; for(int i=1; i<=n; i++) fattoriale*=i; return fattoriale; } public static int fact2 (int n) { if (n = 0) return 1 else return n*fact2(n-1); } Come possiamo constatare, ad un'analisi sommaria i due programmi non appaiono molto diversi dal punto di vista del costo di esecuzione. Entrambi i programmi devono eseguire n moltiplicazioni per calcolare n! e, complessivamente, è facile verificare che per entrambi il tempo di esecuzione è Θ(n) Se però passiamo ad un'analisi accurata dello spazio utilizzato ci accorgiamo che il costo di esecuzione dei due programmi è diverso. Infatti il programma FACT1 utilizza solo quattro celle di memoria per contenere le variabili i, k, n, fattoriale. In questo caso diciamo che lo spazio è Θ(1). Nel caso del programma FACT2, invece, lo spazio utilizzato asintoticamente è sensibilmente maggiore di quello utilizzato dal programma FACT1 poiché è necessario tenere conto dello spazio richiesto per allocare nella pila, durante le chiamate ricorsive, i valori via via assunti dalla variabile FACT2. Poiché abbiamo n chiamate ricorsive otteniamo che lo spazio richiesto è Θ(n). 4.3. Costo di un programma in termini di operazioni dominanti Nonostante le cautele messe in evidenza nel paragrafo precedente l'analisi del tempo di esecuzione di un programma può essere molto semplificata se si introduce il concetto di istruzione dominante. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 21 Definizione 2 Dato un programma il cui costo di esecuzione è Θ(t(n)) definiamo un'istruzione istruzione dominante (e l'operazione da essa eseguita operazione dominante ) quando, per ogni intero n, il suo contributo al costo di esecuzione, nel caso peggiore di input di dimensione n, è d(n) con d(n) = Θ(t(n)). L' istruzione (o operazione) dominante è dunque quell'istruzione il cui costo di esecuzione è tale, appunto, da dominare il costo di esecuzione dell'intero programma. E' chiaro che, per una valutazione asintotica del costo di esecuzione di un programma, è del tutto sufficiente determinare il costo dovuto all' operazione dominante. In tal modo si evita di quantificare tutte le altre operazioni (trasferimenti, operazioni aritmetiche, salti condizionati e non) i cui costi sono comunque dominati da essa. Esempio 8 Consideriamo il frammento di programma già visto precedentemente: for (int n=1;n<m;n++) x += n; i costi di esecuzione di questo frammento valutati tenendo conto non solo della ripetizione della istruzione x+ = n ma anche dell'aggiornamento della variabile n e del test sul suo valore, in realtà sono dominati dal solo costo dell'operazione x += n. Domanda 6 Quali sono le operazioni dominanti dei programmi FACT1 e FACT2? Esercizio 5 Considera il seguente programma noto come "ordinamento mediante inserimento" (insertion sort). public static void insertionSort ( int[] A) { int b,j; for (int i=1; i<A.length(); i++) { b=A[i]; j= i-1; while ((j >= 1 ) && (b < A[j]) { A[j+1]= A[j];j--; } A [j+1] = b; } } Determina il costo di esecuzione nel caso peggiore, valutando dettagliatamente il costo di tutte le istruzioni, e individua l'istruzione dominante. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 22 5. COMPLESSITA' INTRINSECA DI PROBLEMI 5.1. Limiti inferiori e superiori alla complessità di un problema. Quando abbiamo mostrato i diversi modi di procedere per giungere ad indovinare un numero compreso tra 1 ed n con il minimo numero di tentativi, abbiamo mostrato che un metodo basato sulla ricerca binaria consente di risolvere tale problema sistematicamente con un numero di tentativi Θ(log2 n). In tale occasione abbiamo anche osservato che il numero di ⎡log2 n⎤ tentativi rappresenta una soglia al di sotto della quale non è possibile scendere. Tale soglia è dovuta al fatto che il valore ⎡log2 n⎤ corrisponde alla quantità di informazione necessaria per individuare un oggetto in un insieme di n oggetti. Esso corrisponde infatti al numero di bit necessari a rappresentare un qualunque numero compreso tra 0 ed n-1. Qualunque metodo generale per determinare un oggetto tra n oggetti, e che sia basato non sulla sorte ma su una sequenza sistematica di domande a risposta binaria, deve quindi necessariamente produrre, almeno nel caso peggiore, ⎡log2 n⎤ bit e comporta quindi ⎡log2 n⎤ tentativi. La soglia di complessità che abbiamo individuato è, in un certo senso, una misura della complessità intrinseca del problema dato. E' stato questo, dunque, il primo esempio di analisi della complessità intrinseca di un problema che abbiamo incontrato. Per semplice che esso sia possiamo utilizzarlo per fare alcune considerazioni. Per caratterizzare la complessità di un problema abbiamo bisogno, fondamentalmente, di due riferimenti. Prima di tutto dobbiamo sapere quale è, nel caso peggiore, la quantità di tempo (o di memoria) sufficiente per la risoluzione del problema dato. Questa quantità rappresenta un limite superiore (un upper bound ) della complessità del problema. A tal fine è sufficiente conoscere qualche algoritmo (non necessariamente il migliore) per la risoluzione del problema dato e valutarne la complessità con uno dei metodi visti nelle sezioni precedenti. La conoscenza di un upper bound, tuttavia, non ci dà un'informazione completa sulla complessità del problema, infatti potrebbero esistere metodi di risoluzione del problema dato diversi da quelli noti che ne consentono la soluzione in modo molto più rapido. Sapere che un problema è risolubile con un algoritmo di costo esponenziale, ad esempio, non ci dice molto sulla reale complessità del problema che, al limite, potrebbe essere anche lineare. L'informazione data dall'upper bound deve dunque essere confrontata con una limitazione inferiore (un lower bound ) cioè con un'indicazione della quantità di tempo (o di memoria) che, sempre nel caso peggiore, è sicuramente necessaria (anche se non sufficiente) per la risoluzione del problema. E' qui che interviene il concetto di complessità intrinseca del problema. Per determinare un lower bound di complessità di un problema è necessario, infatti, dimostrare che nessun algoritmo per la risoluzione del problema stesso può fare a meno di utilizzare una certa quantità di risorsa (tempo, memoria) o di eseguire un certo numero di operazioni (ad esempio quadratico) almeno in una serie di casi particolarmente difficili. Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 23 Per rappresentare un upper bound e un lower bound di complessità di un problema si usano notazioni simili a quella adottata per la complessità di un algoritmo. Sia data una qualunque misura di complessità per un dato modello di macchina (numero di operazioni dominanti, quantità di tempo, quantità di memoria ecc.); sia n la dimensione del problema dato e sia tA(n) il costo di esecuzione di un algoritmo A nel caso peggiore, cioè in corrispondenza della più difficile tra le istanze di dimensione n. Definizione 3 Un algoritmo A ha un limite limite superiore (upper bound ) di complessità Ο(g(n)) se limn ∞ tA(n)/g(n) = c ≥ 0 cioè, asintoticamente, il costo tA(n) di esecuzione dell'algoritmo, nel caso peggiore, è sempre minore o uguale di c g(n). Un problema ha un limite superiore (upper bound ) di complessità O(g(n)) se esiste un algoritmo A per la sua risoluzione che ha un costo di esecuzione O(g(n)). → Definizione 4 Un algoritmo A ha un limite inferiore (lower bound ) di complessità Ω(g'(n)) se limn ∞ g(n)/tA(n) = c ≥ 0 esistono cioè, asintoticamente, il costo di esecuzione dell'algoritmo, nel caso peggiore, è sempre maggiore o uguale di c g'(n). Un problema ha un limite inferiore (lower bound ) di complessità Ω(g'(n)) se, dato un qualunque algoritmo per la sua risoluzione, esso ha una complessità Ω(g'(n)). → Chiaramente, tanto più vicine sono le funzioni g e g', tanto più precisa sarà la caratterizzazione della complesssità del problema dato. Ad esempio per il caso della moltiplicazione di matrici n⋅n è stato dimostrato che la complessità del problema (misurata in termini di moltiplicazioni tra elementi) è O(n2.34) ed Ω(n2). Se si vuole pervenire ad una più precisa caratterizzazione è necessario o individuare un algoritmo che richieda meno di n2.34 operazioni oppure dimostrare che la complessità intrinseca del prodotto di matrici è maggiore di quanto osservato finora e che ogni algoritmo per questo problema richiede necessariamente più di n2 operazioni (ne richiede ad esempio n2log n oppure n2.1). Quando si riesce a mostrare che un problema ha una complessità O(g) e Ω(g') con g e g' asintoticamente uguali a meno di costanti moltiplicative (cioè lim g/g' = c > 0) possiamo dire che la complessità del problema è determinata con precisione. In questo caso usiamo la notazione “teta”. Definizione 5 Dato un problema, se il suo upper bound è O(g(n)) e il suo lower bound è Ω(g(n)), diciamo che la sua complessità è Θ(g(n)). Il problema dell'individuazione di un numero compreso tra 1 ed n è un esempio di problema la cui complessità può essere nettamente caratterizzata. Infatti, in base a quanto abbiamo visto, esso ha un upper bound O(log n) e un lower bound Ω(log n) e quindi possiamo dire che la sua complessità è Θ(log n). Nel seguito di questa sezione prenderemo in esame diversi aspetti riguardanti la complessità intrinseca di problemi. In particolare dopo aver discusso il concetto di algoritmo ottimale presenteremo alcune tecniche elementari per la determinazione di lower bounds che ci consentono di mostrare algoritmi ottimali per semplici problemi come alcuni casi di ordinamento parziale e la fusione di due vettori ordinati. Infine tratteremo una tecnica più complessa che consente di caratterizzare la complessità di problemi come la ricerca di Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 24 un'informazione in un archivio e l'ordinamento di vettori mostrando che anche per essi disponiamo di algoritmi ottimali. In realtà per un gran numero di problemi di interesse pratico una caratterizzazione precisa della complessità non è (ancora) stata determinata. Per tali problemi (ad esempio problemi di scheduling, di partizionamento di grafi, di soddisfacimento di formule logiche ecc.) i migliori algoritmi noti hanno comunque un costo esponenziale mentre le delimitazioni inferiori di complessità individuate sono di tipo polinomiale (in genere quadratico!). Il divario tra upper bound e lower bound in questi casi lascia dunque una grande incertezza sull'effettiva intrinseca difficoltà di questi problemi. In questi casi si può procedere a stabilire, almeno, valutazioni di complessità relativa mediante tecniche più sofisticate che permettono di arrivare comunque ad un'utile classificazione della complessità di questi problemi in base alla quale essi sono stati definiti "intrattabili". 5.2. Algoritmi ottimali E' interessante osservare che quando abbiamo un problema per il quale upper bound e lower bound coincidono vuol dire che per tale problema disponiamo di un algoritmo ottimale. In questo caso, infatti, se l'algoritmo di cui disponiamo, e che ci ha consentito di determinare l'upper bound, ha un costo O(g(n)) e se il lower bound è Ω(g(n)) vuol dire che non sarà possibile trovare alcun algoritmo asintoticamente migliore; gli unici miglioramenti che si potranno apportare potranno incidere solo sulla costante moltiplicativa. Definizione 6 Sia dato un problema P con un lower bound di complessità Ω(g) per una opportuna funzione g; se un algoritmo A per P ha un costo di esecuzione O(g) allora diciamo che A è un algoritmo ottimale per P. Supponiamo di avere per un dato problema un algoritmo A che richiede tempo O(n2) e che il lower bound del problema sia proprio Ω(n2); potremo dire che A è un algoritmo ottimale per il problema dato. Ciò non significa che non ci siano algoritmi migliori; significa semplicemente che tali algoritmi potranno aver un migliore comportamento su alcune istanze particolari o, al massimo potranno richiedere un tempo di esecuzione che è minore di quello richiesto da A solo per un fattore costante. Se il costo di esecuzione di A è O(n2) vuol dire che il tempo che esso richiede (o il numero di operazioni che esso compie) è ad esempio tA(n) = c n2 + d n Un algoritmo A' migliore di A potrà avere un costo di esecuzione tA'(n) = c/2 n2 + d n che asintoticamente è sempre O(n2). Comunque non sarà possibile trovare algoritmi che siano asintoticamente migliori, ad esempio nessun algoritmo per il problema dato potrà avere un costo di esecuzione O(n log n) e neanche O(n2-ε) per piccolo che sia ε. 5.3. Metodi elementari per determinare lower bounds I primi esempi di lower bound che vogliamo mostrare possono essere ottenuti con tecniche abbastanza elementari. Una prima considerazione, banale, è quella che ci dice che, se la soluzione di un problema di taglia n richiede che ogni dato in ingresso sia preso in esame, il costo di ogni algoritmo non può che essere lineare in n perchè totto l'input deve essere ispezionato almeno una volta e, Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 25 quindi, la complessità del problema è almeno Ω(n). Questo metodo di individuazione del lower bound di un problema è detto metodo di ispezione dell'input Esempio 12 Un esempio in cui è applicabile il metodo suddetto è la determinazione del massimo di un vettore. In questo caso, infatti, poichè ogni elemento deve essere stato confrontato (direttamente o indirettamente) con il massimo possiamo asserire che il lower bound, in termini di confronti, è esattamente n-1. Poichè n-1 confronti sono anche sufficienti per determinare il massimo del vettore, in questo caso possiamo dire di avere una esatta caratterizzazione della complessità del problema. Domanda 8 Perchè nel caso della ricerca di un'informazione in una tabella il metodo non è applicabile? Una seconda tecnica elementare, ma che può essere utilizzata in generale per determinare il lower bound di un problema (detta tecnica dell'avversario) consiste nel supporre che, dato un ipotetico algoritmo che risolve il problema assegnato con un certo costo, un invisibile avversario possa alterare i dati in ingresso in modo da mettere in crisi l'algoritmo e mostrare che il costo deve necessariamente essere maggiore. Esempio 13 Supponiamo che siano date due liste ordinate A=(a1, a2, ..., an) e B=(b1, b2, ..., bn). Per ottenere la loro fusione, cioè la lista C contenente in modo ordinato gli elementi di entrambe, si rendono necessari 2n-1 confronti. Per dimostrarlo procediamo come segue. Supponiamo che un algoritmo effettui soltanto n-2 confronti. In tal caso l'avversario può costruire due liste A e B tali che, mediante n-2 confronti si verifichi: a1 ≤ b1 ≤ a2 ≤ ... ai-1 ≤bi-1≤ ai e bi ≤ ai+1 ≤ bi+1≤ ... ≤ an ≤ bn A questo punto resta ancora da determinare, mediante un ulteriore confronto tra ai e bi, se il risultato corretto sia la lista C=(a1, b1, a2, ... , ai-1, bi-1, ai, bi, ai+1, bi+1, ..., an, bn) o la lista C=(a1, b1, a2, ... , ai-1, bi-1, bi, ai, ai+1, bi+1, ..., an, bn). Esercizio 7 Dimostrare, con il metodo dell'avversario che il lower bound del problema di determinare il primo e l'ultimo giocatore di un torneo è costituito da ⎡3/2 n⎤ - 2 incontri. 6. ORDINAMENTO E RICERCA IN UN VETTORE 6.1 Ordinamento in un Vettore Un algoritmo di ordinamento di un vettore con n elementi abbastanza naturale e stremamente da realizzare è l'ordinamento a bolle (bubble sort). In esso il primo elemento è confrontato con il secondo ed eventualmente scambiato; il secondo con il terzo e così via. Durante questa prima iterazione gli elementi maggiori sono risaliti come bolle verso le posizioni di indice più Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi Pag. 26 alto del vettore e in particolare l'elemento massimo si è portato nell'ultima posizione. Alla seconda iterazione sarà il secondo maggiore a portarsi nella penultima posizione. Dopo n-1 iterazioni, ogni elemento avrà raggiunto la sua posizione nell'ordinamento. In effetti, può accadere che durante una iterazione più elementi contemporaneamente raggiungano la posizione definitiva, in particolare quelli successivi all'indice in cui è avvenuto l'ultimo scambio; pertanto l'ordinamento può essere costruito in meno di n-1 iterazioni. Scriviamo di seguito l'algoritmo in un formato generico sia rispetto ai tipi degli elementi sia rispetto al tipo di ordinamento. Il metodo è definito all’interno della classe Vettore. public static boolean ordBolla( int [] V, int n ) { int limite = n-1; while ( limite > 0 ) { int indiceUltimoScambio = 0; for ( int i = 0; i < limite; i++ ) if ( V[i]> V[i+1] ) { int k = V[i]; V[i] = V[i+1]; V[i+1] = k; indiceUltimoScambio = i; } limite = indiceUltimoScambio; } } La complessità è quadratica nel caso peggiore e medio mentre diventa lineare nel caso migliore quando il vettore è parzialmente ordinato. Volendo misurare le operazioni fondamentali di un algoritmo di ordinamento, cioè confronto tra due elementi e loro scambio, abbiamo che sia il numero di confronti che quello di scambi è Θ(n2) nel caso peggiore e medio mentre nel caso migliore il numero di confronti è Θ(n) e il numero di scambi è Θ(1). Un altro noto algoritmo di ordinamento è quello per inserzione (insertion sort). Vengono effettuate n-1 iterazioni e a ogni iterazione i abbiamo che i primi i elementi sono ordinati per cui, allo scopo di estendere l'ordinamento anche all'elemento i+1, è sufficiente determinare la posizione in cui inserire tale elemento e effettuare tale inserimento attraverso opportuni spostamenti. public static boolean ordInserzione ( int [] V, int n ) { for ( int i = 1; i < n; i++ ) { int tmp = V[i]; int iPos=i; for ( int j = i-1; j >= 0 && iPos==i; j-- ) if ( tmp < V[j] ) V[j+1] = V[j]; else iPos=j; if ( iPos != i ) V[iPos] = tmp; } } Come per l'ordinamento a bolle la complessità è quadratica nel caso peggiore e medio mentre diventa lineare nel caso migliore quando il vettore è parzialmente ordinato. Sia il numero di scambi che quello di confronti è Θ(n2) nel caso peggiore e medio mentre nel caso migliore il numero di confronti è Θ(n) e il numero di scambi è Θ(1). L'algoritmo di selezione (selection sort) effettua l'ordinamento selezionando alla prima iterazione il primo minore che finisce in prima posizione, alla seconda il secondo minore Pag. 27 Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi viene spostato alla seconda posizione, e così via; alla iterazione n-1 il vettore risulterà ordinato. public static void ordSelezione (int [] V, int n ) { for ( int i = 0; i < n-1; i++ ) { int iMinimo = i; for ( int j = i+1; j < n; j++ ) if ( V[j]> V[iMinimo] ) iMinimo = j; if ( i != iMinimo ) { int k = V[iMinimo]; V[iMinimo] = V[i]; V[i] = k; } } } Questa volta la complessità è quadratica non solo nel caso peggiore e medio ma anche nel caso migliore. Il numero di confronti è Θ(n2) in ogni caso; il numero di scambi è nel caso migliore è Θ(n) nei casi peggiori e medio mentrefor_each_node 0) nel caso migliore. Ricapitoliamo in Tabella 2 i numeri di confronti e scambi per i tre algoritmi di ordinamento. Più avanti nel libro avremo modo di studiare algoritmi di ordinamento più efficienti con complessità Θ(n logn). Bolla Inserzione Selezione MIN Θ(n) Θ(n) Θ(n2) Confronti MEDIO MAX 2 Θ(n ) Θ(n2) Θ(n2) Θ(n2) Θ(n2) Θ(n2) MIN Θ(1) Θ(1) Θ(1) Scambi MEDIO Θ(n2) Θ(n2) Θ(n) MAX Θ(n2) Θ(n2) Θ(n) Tabella 2. Numero di confronti e scambi in vari tipi di ordinamento Esercizio 9 L’ordinamento a contatore (counting sort) si applica a un vettore V di interi e consiste nell’allocare un vettore T di indici (Vmin,Vmax), dove Vmin e Vmax sono rispettivamente il minimo e il massimo valore in V, inizializzare tutti gli elementi di T a 0, scandire ciascun elemento i di V incrementando ogni volta T[V[i]] di 1 e, infine, ricopiare in V gli indici j da Vmin a Vmax tante volte quanto vale il contatore T[j]. Scrivere l’algoritmo e valutarne la complessità. 6.2 Ricerca in un Vettore Affrontiamo ora il problema di ricerca di un elemento in un vettore di n elementi. Scriviamo un semplice algoritmo di scansione del vettore che restituisce l’indice dell’elemento cercato oppure -1 se l’elemento non è presente: public static int ricerca( int [] V, int n, int o ) { bool trovato = false; int io = 0; while ( io < n && !trovato ) if ( V[i] == 0 ) trovato = true; else io++; Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi } Pag. 28 return (io<n)? io: -1; La complessità è ovviamente lineare in n nel caso peggiore e medio mentre è costante nel caso migliore. E' interessante valutare il comportamento dell'algoritmo nei due sotto-casi: ricerca con successo e ricerca senza successo. E' facile verificare che l'algoritmo ha complessità lineare in tutti i casi tranne che per il caso migliore della ricerca con successo in cui la complessità è costante. L'algoritmo è ottimale in quanto il problema ha complessità (caso peggiore) Ω(n) per i due tipi di ricerca: infatti non si può evitare di scandire tutti gli n elementi per essere sicuri che vi sia o non vi sia quello cercato. Nel caso la ricerca avvenga su un vettore ordinato l'algoritmo può essere migliorato in quanto è possibile riconoscere anticipatamente se l'elemento non è presente — infatti in tale caso il problema ha complessità Ω(logn) nel caso di ricerca con successo o insuccesso. Un semplice algoritmo di ricerca in un vettore ordinato è il seguente: public static int ricercaOrd ( int [] V, int n, int o ) { bool trovato = false; bool introvabile = false; int io; for ( io = 0; io < n && !trovato && !introvabile; ) if ( V[i] == o ) trovato = true; else if ( V[i] < o ) {introvabile = true; io=-1;} else io++; return io; } Il miglioramento ottenuto è però molto ridotto: esso riguarda solo il caso migliore di ricerca con insuccesso in cui la complessità passa da lineare a costante. Un algoritmo ottimale si ha con la ricerca binaria in cui la ricerca parte dall'elemento centrale e si sposta su una delle meta del vettore a secondo del risultato del confronto; ad ogni passo la dimensione del vettore si riduce della metà. public static int ricercaBin( int [] V, int n, int o ) { bool trovato = false; int in=0, medio, fin=n-1; while ( in <= fin && !trovato ) { medio = (in+fin)/2; if ( V[i] < o ) in = medio+1; else if ( V[i] > o ) fin = medio-1; else trovato = true; } return ( trovato ) ? medio : -1; } Supponendo che inizialmente n = 2p, si ha n = 2p-1 alla seconda iterazione e così via; nel caso peggiore l'algoritmo termina quando n diventa uguale a 20, cioè dopo p iterazioni. Pertanto, essendo p = logn, la complessità è Θ(log n); questa è la complessità anche nel caso medio mentre nel caso migliore essa è costante. Per come è stata definita la notazione Θ (cioè le costanti possono essere trascurate), tali misure valgono anche quando n non è una potenza di Pag. 29 Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi 2. Infatti, detto p = ⎡logn⎤, abbiamo che 2p < log n < 2p+1 per cui il numero di iterazione k vale: p < k < p+1. Pertanto k = Ω(p) e k = Ο(p+1) = Ο(p); quindi k = Θ(p) = Θ(⎡logn⎤) e, poichè ⎡logn⎤ < logn+1, k = Θ(⎡logn⎤). Notiamo che nel caso di ricerca con insuccesso la complessità Θ(log n) vale anche per il caso migliore. Ricapitoliamo in Tabella 3 le complessità delle tre ricerche nei vari casi. Ricerca Ricerca Ordinata Ricerca Binaria MIN Θ(1) Θ(1) Θ(1) Con Successo MEDIO MAX Θ(n) Θ(n) Θ(n) Θ(n) Θ(logn) Θ(logn) MIN Θ(n) Θ(1) Θ(logn) Senza Successo MEDIO MAX Θ(n) Θ(n) Θ(n) Θ(n) Θ(logn) Θ(logn) Tabella 3. Complessità temporale per vari tipi di ricerca Fondamenti di Informatica I Introduzione all’Analisi di Algoritmi 7 Pag. 30