Capitolo 3 Algoritmi e complessità. 3.1 Definizioni fondamentali La teoria della complessità è uno strumento per determinare la qualità di procedure di calcolo definite per la soluzione di generiche istanze di problemi. Tale teoria è basata sulla definizione di modelli formali di calcolo (ad es. le macchine di Turing): una dettagliata e completa trattazione va certamente oltre lo scopo di queste note. Tuttavia, alcuni concetti della teoria della complessità sono indispensabili per valutare l’efficienza degli algoritmi; in modo particolare il tempo di esecuzione. Questa esigenza può essere parzialmente soddisfatta introducendo in modo più informale i principali concetti della teoria della complessità, il concetto di problema e di algoritmo. Iniziamo con l’introdurre il concetto di problema. Definizione 3.1.1 Si definisce Problema una funzione F dall’insieme I delle istanze (INPUT) all’insieme S delle soluzioni (OUTPUT). Ciascuna istanza I è l’insieme delle informazioni necessarie affinché la funzione F possa determinare in modo univoco una soluzione appartenente all’insieme S. Data un’istanza i ∈ I, quindi, il problema associa all’istanza i la soluzione s = F (i) ∈ S. L’istanza può quindi essere definita come l’insieme dei dati d’ingresso, mentre la soluzione è una particolare ”proprietà” F (i) dell’istanza i. Per illustrare il concetto di problema consideriamo un classico esempio su grafi: il problema del cammino orientato di lunghezza minima fra due nodi dati. Tale problema viene così definito: Definizione 3.1.2 Dato un grafo orientato G(N, A), con delle lunghezze associate agli archi, e due nodi s e t di G, trovare il cammino orientato da s a t di lunghezza complessiva minima. Un’istanza del problema è data in Figura 3.1, ed è costituita dal grafo G (informazioni topologiche) e dalle informazioni metriche costituite dalle lunghezze associate agli archi del grafo. Il problema consiste nell’associare a ciascuna istanza (uno specifico grafo con delle specifiche lunghezze) il cammino di lunghezza totale minima (soluzione). Nella stessa figura è mostrato il cammino minimo da s a t per l’istanza data. Tale cammino è (una) soluzione del problema del cammino minimo per l’istanza data. Si osservi che in un grafo dato potrebbero esistere più cammini di lunghezza minima. In tal caso, la funzione assocerebbe all’istanza d’ingresso uno solo tra questi cammini. Una definizione più generale definisce il problema come una relazione piuttosto che una funzione, e cioè l’insieme di tutte le possibili coppie (i, s) ∈ I × S. Qui si è preferita una definizione semplificata del concetto di problema allo scopo di alleggerire la notazione. Possiamo ora introdurre il concetto di algoritmo. Definizione 3.1.3 Si definisce algoritmo una procedura in grado di trovare, in passi successivi, una soluzione per una generica istanza di un problema. 18 2 v2 3 s t 1 1 t s 1 1 3 v1 v1 Figura 3.1: Istanza e soluzione del problema di cammino minimo Si osservi che istanza e soluzione debbono essere opportunamente codificate, ad esempio univocamente associate a stringhe binarie (bit). Come vedremo in seguito, alcuni parametri degli algoritmi di soluzione dipendono dalla codifica scelta per l’input e per l’output. Un algoritmo deve godere delle seguenti proprietà: • Correttezza. Un algoritmo deve essere corretto: applicato a un’istanza i ∈ I, l’algoritmo produce sempre la soluzione associata F (i). • Efficienza: l’efficienza di un algoritmo viene misurata in base al tempo necessario per risolvere il problema e allo spazio di memoria occupato durante il calcolo. Tipicamente, il tempo di calcolo dipenderà dalla dimensione dell’istanza di input: più grande sarà, più tempo sarà richiesto dall’algoritmo. La dimensione dell’istanza è dunque un altro parametro rilevante e di norma viene misurato in numero di bit necessari alla codifica. Per rendere indipendente il tempo di calcolo dalla potenza di calcolo dello strumento sul quale viene eseguito l’algoritmo, esso viene calcolato in termini di passi, ovvero operazioni elementari. 3.2 Misure di efficienza di algoritmi. Per misurare l’efficienza di un algoritmo si fa uso di un modello di calcolo, ovvero di computer ideale in grado di effetturare alcune operazioni elementari su dati contenuti in una memoria organizzata in celle (o parole) che contengono un numero costante di bit (es. 32 bit). cella Figura 3.2: Cella di memoria Ogni operazione primitiva richiede 1 passo. Le operazioni considerate primitive sono, ad esempio: • Operazioni logico-aritimetiche (+,-, *, /, if, . . .) 19 • Acccesso a matrici e vettori (A[23], B[12, 23]) • Assegnazione di valori a variabili (A[i] := 34) • Chiamate di funzioni e sub-routine • Confronti tra numeri (A[i] > A[j]) • ... I loop (es. for, while, repeat) richiedono un numero di passi che è funzione della configurazione dei dati trattati. Vediamo con un esempio come si esegua il conteggio dei passi di un algoritmo. Consideriamo il problema del calcolo della componente di valore massimo di un vettore a componenti intere: Definizione 3.2.1 Dato un vettore a d componenti intere, trovare la (una) componente di valore massimo. L’insieme delle istanze di input sono tutti i vettori a d componenti intere. L’insieme delle soluzioni coincide con l’insieme degli interi. ALGORITMO ————————————– 1. M ax := A[1] 2. For i := 2 to d do begin 3. if A[i] > M ax 4. then M ax := A[i] end; ————————————– Figura 3.3: Si può adesso contare il numero di volte che ogni istruzione viene eseguita. 1. M ax := A[1]. Questa istruzione viene eseguita una sola volta. In termini di operazioni primitive, essa consiste in un’operazione di assegnazione e un’operazione di accesso a vettore. Quindi, complessivamente, 2 operazioni primitive (2 passi). 2. For i := 2 to d. Questa istruzione viene eseguita d−1 volte. Consiste in un’operazione di incremento e un’operazione di confronto. Quindi, complessivamente, 2 operazioni primitive (2 passi) eseguite d − 1 volte. 3. if A[i] > M ax. Questa istruzione viene eseguita d − 1 volte. Consiste in un’operazoione di accesso a vettore e un’operazione di confronto. Quindi, complessivamente, 2 operazioni primitive (2 passi) eseguite d − 1 volte. 4. then M ax := A[i]. Questa istruzione viene eseguita un numero variabile di volte (in dipendenza dal risultato del test), numero compreso fra 0 (se il test non è mai verificato, vettore ordinato per valori decrescenti) e d − 1 (test sempre verificato, vettore ordinato per valori crescenti). Consiste in un’operazione di assegnazione e un’operazione di accesso a vettore. Quindi, complessivamente, 2 operazioni primitive (2 passi). 20 Max := A[1] For i:=2 to d do begin if A[i]> Max then Max := A[i] end; [2 passi – accesso vettore+assegnazione] [2 passi – confronto (i con d)+incremento di i] [2 passi – accesso vettore+confronto] [2 passi – accesso vettore+assegnazione] d-1 volte ! Figura 3.4: Numero passi dell’algoritmo algoritmo per la componente più grande Quanto esposto viene riassunto in Figura 3.4. Quindi, per d > 0, il numero totale di passi (tempo) T sarà 2 + 4(d − 1) ≤ T ≤ 2 + 6(d − 1). Questo risultato evidenzia come il numero di passi dell’algoritmo dipenda dalla dimensione d dell’istanza. Si definisce tempo di esecuzione di un algoritmo il numero di passi necessario a determinare la soluzione associata a un’istanza, e dipende da: • dimensione size(i) dell’istanza i ∈ I e • specifica istanza: nel caso del vettore visto sopra dipende dall’ordinamento delle componenti del vettore. Si definisce complessità di un algoritmo il numero di passi necessario all’algoritmo per determinare la soluzione associata a una generica istanza i ∈ I avente dimensione size(i). La complessità è quindi una funzione c(size(i)) della sola dimensione dell’istanza. Si tratta ora di definire misure formali della dimensione di un’istanza size(i) è della complessità di un algoritmo c(size(i)). La dimensione size(i) di un’istanza i ∈ I è definita come il numero di celle necessarie a rappresentare i (i dati d’ingresso). Tale numero dipende a sua volta da alcuni parametri tipici dell’istanza. Ad esempio, nel caso del vettore di interi a d componenti, supponendo che il numero di celle richieste per rappresentare ciascun intero sia al più k, la dimensione della rappresentazione sarà kd: ovvero la dimensione dell’istanza è, in questo caso, una funzione lineare del numero di componenti del vettore di input. Da ora in poi faremo l’ipotesi che la cardinalità di celle necessarie per rappresentare un numero (intero, reale, ecc.) sia un valore costante predeterminato. Questo implica che la dimensione del massimo numero rappresentabile è fissata. Si consideri l’esempio di un generico problema di programmazione lineare: min cT x : {x : Ax = b, x ≥ 0}, con A ∈ Rm×n , b ∈ Rm , c ∈ Rn : in questo caso, il numero di celle per rappresentare l’istanza size(i), e cioè per rappresentare A, b e c, è proporzionale a mn + m + n. Ora, per definire la complessità di un algoritmo c(size(i)) per una generica istanza di dimensione size(i) dovremmo considerare il valor medio del tempo di esecuzione su tutte le istanze di dimensione size(i). Questo calcolo può essere effettivamente svolto in pochi casi, ed è genericamente molto complicato. Si preferisce quindi valutare una misura di complessità diversa, la complessità nel caso peggiore, che indicheremo con W (size(i)) e che è definita come il numero di passi necessari a determinare la soluzione della più difficile istanza di dimensione size(i). Analogamente, possiamo definire una complessità nel caso migliore, come il numero di passi B(size(i)) necessari ad un algoritmo per determinare la soluzione della più facile istanza di dimensione size(i). Gli andamenti qualitativi delle tre misure di complessità sono illustrati in Figura 3.5. Riconsideriamo l’esempio dell’algoritmo per il calcolo della componente di valore massimo di un vettore di interi. Si è visto che il numero di passi T dipendeva sia dalla dimensione d del vettore, che dal risultato del test all’istruzione 3. Specificamente, si è visto che 4d − 2 ≤ T ≤ 6d − 4: quindi, nel caso migliore sarà B(d) = 4d − 2, mentre nel caso peggiore sarà W (d) = 6d − 2. 21 W(size(I)) Valor medio #passi B(size(I)) size(I) Figura 3.5: Tre misure di complessità W(d) B(d) #passi 1 d Figura 3.6: Complessità nel caso migliore e nel caso peggiore per l’algoritmo della massima componente di un vettore La misura di complessità maggiormente utilizzata è quella nel caso peggiore, per una serie di motivi. In primo luogo, il caso migliore si ottiene per istanze poco significative. Al contrario, il caso peggiore si ottiene per istanze patologiche, e rappresenta quindi una misura ”prudenziale” della complessità. Inoltre, è in genere sufficientemente agevole determinare un’approssimazione superiore (in inglese: upper bound) g(size(i)) della funzione W (size(i)). g(size(I)) #passi W(size(I)) size(I) Figura 3.7: Upper bound g() del caso peggiore W () Date due funzioni f, g : Rn → R, diremo che f è ordine di g (indicato con O(g)) se e solo se esiste una costante c1 ≥ 0 e un punto x0 ∈ Rn tali che f (x) ≤ c1 g(x) per ogni x ≥ x0 , x ∈ Rn . In altri termini f (x) è ordine di g(x) se, per valori di x abbastanza grandi, il valore di f (x) è definitivamente inferiore a quello di g(x). Ad esempio, se f (x) = x2 + 100x + 10000 allora f (x) è O(x2 ). Infatti, è facile vedere che, se x ≥ 1000, f (x) < 2x2 (si osservi che in questo caso c1 = 2). Di converso, si osservi che se, per esempio, x ≤ 10, 22 allora f (x) > 2x2 . Una semplice regola per calcolare g(x) data f (x): • elimina coefficienti e costanti • conserva l’addendo di f (x) che cresce più rapidamente Esempi di applicazione: f (x) = 20x2 + log(x) + 10000 → O(x2 ) f (x) = 2x + 20x2 + log(x) + 10000 → O(2x ) Il prossimo teorema illustra alcune semplici relazioni conseguenza della definizione di ”O grande di”. Teorema 3.2.2 Siano d(n), e(n), f (n) e g(n) funzioni da N+ → IR. Allora valgono le seguenti relazioni: 1. Se d(n) è O(f (n)) allora ad(n) è O(f (n)) per ogni a > 0. 2. Se d(n) è O(f (n)) e e(n) è O(g(n)) allora d(n) + e(n) è O(f (n) + g(n)). 3. Se d(n) è O(f (n)) e f (n) è O(g(n)) allora d(n) è O(g(n)). 4. Se d(n) è O(f (n)k ) e f (n) è O(g(n)) allora d(n) è O(g(n)k ). 3.3 Complessità polinomiale Diremo che un algoritmo ha Complessità nel Caso Peggiore O(g(size(i))) se e solo se la funzione W (size(i)) è ordine di g(size(i)), ovvero: g(size(i)) è un upper bound di W (size(i)). Ad esempio, l’algoritmo per il calcolo della componente di valore massimo di un vettore di dimensione d ha complessità nel caso peggiore pari a W (size(i)) = W (d) = 6d − 4, e quindi la complessità è O(d). Un algoritmo si dice avere complessità polinomiale se e solo se la sua Complessità nel Caso Peggiore è O(size(i)k ) con k costante. Consideriamo di nuovo la programmazione lineare. Si è visto che size(i) = mn + n + m. Se esistesse un algoritmo A che risolvesse la programmazione lineare (nel caso peggiore) in W (size(i)) = m4 + n4 + 4mn + m3 allora, essendo W (size(i)) ≤ (mn + n + m)4 , la complessità di A sarebbe O(size(i)4 ), e quindi A avrebbe complessità polinomiale. Secondo la definizione di Jack Edmonds (uno dei padri della teoria) un algoritmo è efficiente (good) se e solo se ha complessità polinomiale. Naturalmente, il valore k in O(size(i)k ) è un indice di efficienza: un algoritmo di complessità O(size(I)2 ) è in genere migliore di un algoritmo di complessità O(size(i)3 ). Un algoritmo ha Complessità Esponenziale se la complessità nel caso peggiore cresce più velocemente di size(i)k per ogni possibile k (algoritmo inefficiente). In Figura 3.8 sono tabellati i valori di alcune funzioni di n per valori crescenti di n. Si osservi l’acceerazione della crescita della funzione quando gli esponenti crescono e, ancora più evidentemente, per la funzione esponenziale 2n . 3.4 Codifiche e trascodifiche Come introdotto nel Paragrafo 3.1, ogni istanza di input per uno specifico algoritmo deve essere opportunamente codificata. Ad esempio, un vettore d’interi può essere rappresentato in memoria con una struttura dati di tipo vettore oppure con una lista semplice (vedi Figura 3.9). Nella lista semplice vengono rappresentati unicamente gli elementi diversi da 0. Naturalmente, oltre al valore, bisogna memorizzare per ogni elemento non nullo anche la posizione dell’elemento stesso nel vettore. Quindi, per ogni elemento 23 lo g (n) n 1 2 3 4 5 6 7 8 9 10 2 4 8 16 32 64 12 8 2 56 512 10 2 4 n^2 n^5 4 16 64 2 56 10 2 4 4096 16 3 8 4 6 553 6 2 6 2 14 4 10 4 8 576 2 ^n 32 10 2 4 3 2 76 8 10 4 8 576 3 3 554 4 3 2 10 73 74 18 2 4 3 4 3 59 73 8 3 6 8 1.0 9 9 51E+12 3 .518 4 4 E+13 1.12 59 E+15 4 16 2 56 6 553 6 4 2 9 4 9 6 72 9 6 1.8 4 4 6 7E+19 3 .4 0 2 8 2 E+3 8 1.1579 2 E+77 1.3 4 0 8 E+154 *** Figura 3.8: Andamento di alcune funzioni di n non nullo, la rappresentazione a lista richiede un’occupazione di 3 celle di memoria, una per rappresentare il valore, un’altra per rappresentare l’indice e la terza per mantenere il puntatore al prossimo elemento nella lista. Quindi, se il vettore contiene nz elementi diversi da 0, avremo bisogno di 3nz celle di memoria. Se il vettore è di dimensione m, la rappresentazione ”diretta” tramite la struttura vettore richiede invece m celle di memoria. Dal punto di vista dell’occupazione di spazio, la rappresentazione a lista sarà conveniente quando 3nz < m, ovvero quando il vettore è un vettore sparso. 1 2 3 4 5 6 7 8 13 0 0 12 15 0 15 0 1 13 4 12 5 15 7 15 Figura 3.9: Codifiche diverse di un vettore di interi Un algoritmo è corretto se, data un’istanza codificata nel modo previsto dall’algoritmo stesso, fornisce in output la soluzione univocamente associata all’istanza. Quindi, codifiche diverse della stessa istanza corrispondono a diversi algoritmi. Ad esempio, se le istanze del problema sono vettori di interi, non possiamo usare lo stesso algoritmo per input forniti con la rappresentazione vettoriale ed input forniti con la rappresentazione a lista. Naturalmente, i due algoritmi possono utilizzare lo stesso metodo di soluzione, ma si tratta comunque di algoritmi distinti. 3.4.1 Trascodifiche Supponiamo ora di disporre di un algoritmo A che accetta istanze i ∈ I di un problema P secondo una codifica IA di dimensione size(IA ). Che succede se la mia istanza è rappresentata con un’altra codifica, diciamo IB ? Posso ancora utilizzare A per trovare soluzioni al problema P ? Per quanto detto sopra ciò non è possibile. Tuttavia, se disponessimo di un ”traduttore” che trasforma IB in IA , allora potremmo effettuare la traduzione e fornire il risultato di tale traduzione in input a A. Un traduttore è dunque un algoritmo C che risolve il problema di trascodifica, ovvero accetta in input un’istanza codificata come IB e fornisce in output un’istanza codificata come IA . Possiamo quindi definire un nuovo algoritmo per il problema P che accetta istanze codificate come IB ”concatenando” l’algoritmo C e l’algoritmo A (indicheremo tale concatenazione come C • A. L’algoritmo complessivo accetta un’istanza codificata come 24 IB , la trasforma nella stessa istanza codificata come IA , applica quindi A all’input IA e produce una soluzione all’istanza. IB C •A C IA A Figura 3.10: Un algoritmo per istanze rappresentate come IB Ha senso a questo punto chiedersi quale sia la complessità dell’algoritmo C • A. Il prossimo teorema risponde, sotto certe condizioni, a tale domanda. Teorema 3.4.1 (Di trascodifica) Se A ha complessità polinomiale rispetto a size(IA ) e C ha complessità polinomiale rispetto a size(IB ) allora C • A ha complessità polinomiale rispetto a size(IB ). Dim. Poniamo per semplicità size(IA ) = r e size(IB ) = d. Poichè C ha complessità polinomiale, esistono due costanti non negative, c1 e k1 , tali che, per d abbastanza grande, WC (d) ≤ c1 dk1 . Poichè A ha complessità polinomiale, esistono due costanti non negative, c2 e k2 , tali che, per r abbastanza grande, WA (r) ≤ c2 rk2 . Calcoliamo ora la dimensione r dell’istanza codificata come IA rispetto alla dimensione dell’istanza di input IB . Poichè l’algoritmo di trascodifica utilizza al massimo c1 dk1 passi elementari per tradurre e rappresentare l’istanza nel formato IA , la dimensione di quest’ultima istanza non può ecccedere il numero di passi elementari e quindi r ≤ c1 dk1 . Quindi la complessità nel caso peggiore WC•A (d) dell’algoritmo C • A (funzione della dimensione d dell’input) soddisfa: WC•A (d) = WC (d) + WA (r) ≤ c1 dk1 + c2 rk2 ≤ c1 dk1 + c1 c2 dk1 k2 ≤ cdk con k = max(k1 , k1 k2 ) e c = c1 + c1 c2 . 3.5 Rappresentare istanze di grafi. Sia dato un grafo orientato G(N, A), con associato un intero bv a ogni nodo v ∈ V e un intero cuv a ogni arco uv ∈ A. Si vuole rappresentare il grafo e i parametri associati e calcolare la quantità di memoria richiesta. Matrice d’adiacenza. Un primo modo di rappresentazione è la cosiddetta matrice di adiacenza (nodo-nodo): si tratta di una matrice binaria A di dimensione |N | × |N |, in cui l’elemento auv = 1 se / A. l’arco uv ∈ A, mentre auv = 0 se uv ∈ Per semplicità ipotizziamo che ogni parametro richieda esattamente una cella di memoria per essere rappresentato. Allora, occorreranno |N |2 celle per rappresentare la matrice A, |N |2 celle per rappresentare i parametri di arco [cuv ], e |N | celle per rappresentare i parametri di nodo [bv ]. In tutto, la rappresantazione del grafo e dei suoi parametri richiede 2|N |2 + |N | celle. 25 c23 b3 v c34 3 b2 v2 b4 v4 c14 c21 b1 c41 v4 Figura 3.11: Esempio di grafo con parametri associati ai nodi e agli archi v1 v2 v3 v4 v1 - 0 0 1 v2 1 - 1 v3 0 0 v4 1 0 v1 v2 v3 v4 v1 - - - c14 0 v2 c21 - c23 - - 1 v3 - - - c34 0 - v4 c41 - - - Figura 3.12: Matrice adiacenza nodi-nodi e parametri archi Liste d’adiacenza. La seconda rappresentazione è quella cosiddetta a lista di adiacenza. Tutte le informazioni relative agli archi (i parametri [cuv ] e le adiacenze) sono rappresentate da |N | liste (una per nodo). La lista L(u), per u ∈ N , rappresenta gli archi della stella uscente di u. Il grafo di Figura 3.11 viene rappresentato come in Figura 3.13. v1 b1 v2 b2 v3 b3 v4 b4 ° v4 c14 v1 c21 nil ° v3 c23 nil ° ° ° v4 v1 c34 c41 nil nil Figura 3.13: Liste di adiacenza In questa rappresentazione, ogni arco uv ∈ A appare rappresentato esattamente una volta (l’indice del nodo v appare nella lista L(u)) e quindi l’occupazione complessiva degli indici di nodo risulta pari a |A| celle. Analogamente, il parametro cuv , per ogni uv ∈ A, apparirà una sola volta, e quindi l’occupazione sarà di |A| celle. Inoltre, i puntatori agli archi occuperanno anch’essi esattamente |A| celle. Infine, i parametri bv associati ai nodi richiedono |N | celle per essere rappresentati: analogamente, i puntantori alle stelle uscenti occuperanno esattamente |N |. In tutto, l’occupazione sarà 3|A| + 2|N | celle. Vogliamo ora rispondere alla domanda: quando è preferibile usare l’una o l’altra rappresentazione 26 per un grafo G(N, A)? Per semplicità, poniamo |N | = n e |A| = m, e supponiamo n ≤ m. Chiaramente size(G(N, A)) = f (n, m), e in particolare, se usiamo la matrice di adiacenza avremo sizeA = 2n2 + n mentre se usiamo le liste di adiacenza avremo sizeL = 3m + n. Si osservi ora che il numero di archi di un grafo è limitato dal numero di possibili coppie di nodi e quindi m ≤ n2 . Di seguito, diremo che un grafo è denso se m ≈ n2 , mentre sarà sparso se m ≈ n. Figura 3.14: Grafo denso e grafo sparso Quale rappresentazione? La rappresentazione da preferire dipende in genere dalle caratteristiche del grafo e dal ”punto di vista”. Dal punto di vista dell’occupazione di memoria, si ha che sizeA = 2n2 + n = O(n2 ) mentre sizeL = 3m + 2n = O(m). Si ricordi che il numero massimo di archi di un grafo ). orientato è pari a n(n − 1) (mentre il numero massimo di archi di un grafo non orientato è pari a n(n−1) 2 Quindi m ≤ n2 − n e di conseguenza m = O(n2 ). Quindi, ambedue le rappresentazioni richiedono (nel caso peggiore) uno spazio O(n2 ) e si pone size(G) = O(n2 ). Di conseguenza, un algoritmo su grafi ha Complessità Polinomiale se e solo se: W (size(G)) = W (n2 ) ≤ c1 (n2 )r con r costante e quindi (ponendo k = 2r), si ha che un algoritmo su grafi ha Complessità Polinomiale se e solo se la sua Complessità nel Caso Peggiore è O(nk ) con k costante. Da un punto di vista pratico, la rappresentazione scelta può avere un effetto sull’efficienza nella soluzione di istanze specifiche. In particolare, in caso di grafo denso m ≤ n2 , si avrà che le due rappresentazioni occupano una quantità di memoria confrontabile. Infatti: sizeA = O(m) = O(n2 ) = sizeL . Di converso, nel caso di grafo sparso, le liste di adiacenza sono certamente convenienti. Ad esempio, se n = 104 e m = 105 (grafo sparso), si ha sizeA ≈ 108 = 100 milioni di celle, mentre sizeL ≈ 105 ovvero 100.000 celle. Analizziamo più in dettaglio qual è l’effetto di utilizzare l’una o l’altra delle due rappresentazioni dei grafi sulla complessità degli algoritmi di soluzione. Il lemma seguente fa uso di un importante risultato di trasformazione da una rappresentazione all’altra. La dimostrazione, che viene omessa, è basata sulla costruzione di un opportuno algoritmo di trasformazione. Lemma 3.5.1 Sia G(N, A) un grafo orientato. Se A è la sua matrice di adiacenza, allora esiste un algoritmo CAL che costruisce in tempo O(n2 ) la rappresentazione mediante liste di adiacenza. Di converso, se L è la sua rappresentazione mediante liste di adiacenza, allora esiste un algoritmo CLA che costruisce in tempo O(n2 ) la sua matrice di adiacenza. Il precedente lemma dice che, dato un grafo orientato, è possibile passare efficientemente da una sua rappresentazione all’altra. Questo risultato viene sfruttato nel prossimo teorema che spiega come, dal punto di vista della efficienza teorica, le due rappresentazioni siano equivalenti. 27 Teorema 3.5.2 (Teorema dell’equivalenza delle rappresentazioni di un grafo) Sia dato un problema P le cui istanze sono descritte mediante un grafo orientato. Un algoritmo A ha complessità polinomiale rispetto a sizeA se e solo se esiste un algoritmo L che ha complessità polinomiale rispetto a sizeL . Dim. La dimostrazione discende direttamente e banalmente dal Teorema 3.4.1 e dal Lemma 3.5.1. 3.6 Autovalutazione. 1. Dimostrare i vari enunciati del Teorema 3.2.2. 2. L’algoritmo A ha complessità pari 10n log n operazioni, mentre la complessità dell’algoritmo B è n2 . Si determini il valore n0 per cui A è meglio di B per n ≥ n0 . 28