Note per il corso di Complementi di Algoritmi e Strutture Dati∗ Elena Zucca 23 luglio 2012 Indice Introduzione 1 2 3 4 1 Tecniche per la progettazione e l’analisi degli algoritmi 1.1 Introduzione . . . . . . . . . . . . . . . . . . . . . . . 1.2 Correttezza di algoritmi ricorsivi . . . . . . . . . . . . 1.3 Correttezza di algoritmi iterativi . . . . . . . . . . . . 1.4 Notazioni asintotiche . . . . . . . . . . . . . . . . . . 1.5 Complessità di algoritmi e problemi . . . . . . . . . . 1.6 Esempi di progettazione e analisi di algoritmi ricorsivi 1.7 Teorema fondamentale delle ricorrenze . . . . . . . . . 1.8 Esempi di progettazione e analisi di algoritmi iterativi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2 3 5 6 7 7 7 7 Algoritmi di ordinamento 2.1 Algoritmi elementari . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.1 Selection sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.2 Insertion sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Mergesort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5 Delimitazione inferiore della complessità del problema dell’ordinamento per confronti 2.6 Algoritmi di ordinamento lineari . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 9 9 10 11 14 21 23 24 Strutture dati 3.1 Heap . . . . . . . . . 3.2 Alberi . . . . . . . . 3.3 Alberi di ricerca . . . 3.4 Alberi AVL . . . . . 3.5 2-3-alberi e B-alberi . 3.6 Strutture union-find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 28 30 32 35 38 40 Grafi 4.1 Introduzione e terminologia . . . . . . . . . . . . . . . . . 4.2 Visite . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3 Cammini minimi in un grafo pesato: algoritmo di Dijkstra 4.4 Algoritmo di Prim . . . . . . . . . . . . . . . . . . . . . . 4.5 Algoritmo di Kruskal . . . . . . . . . . . . . . . . . . . . 4.6 Ordinamento topologico . . . . . . . . . . . . . . . . . . 4.7 Componenti fortemente connesse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 43 45 47 49 51 52 54 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ∗ Queste note sono in larga parte elaborate a partire da materiale didattico del professor Elio Giovannetti, che ringrazio infinitamente. Gli esempi e gli esercizi servono ad acquisire familiarità con le tecniche descritte, ma non saranno oggetto di domande all’orale. 5 Teoria della NP-completezza 5.1 Problemi astratti e concreti e classe P 5.2 Classe NP . . . . . . . . . . . . . . . 5.3 Problemi NP-completi . . . . . . . . 5.4 Algoritmi di approssimazione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A Relazioni d’ordine e di equivalenza 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 56 57 59 62 63 Tecniche per la progettazione e l’analisi degli algoritmi 1.1 Introduzione Un problema algoritmico (o computazionale) consiste di: • precondizione: una specifica precisa dei dati di partenza (input) e delle condizioni che devono essere da essi soddisfatte • postcondizione: una specifica precisa delle condizioni che devono essere soddisfatte dai dati di uscita (output), e in particolare della relazione che vi deve essere fra l’input e l’output, ossia una specifica R(x, y) che definisca qual è, per ogni istanza x dell’input, la risposta o l’insieme delle possibili risposte y che si vogliono ottenere. Esempi di problemi algoritmici: Problema del massimo di una sequenza di interi Data una sequenza non vuota di numeri interi, trovare il massimo di tale sequenza e l’indice di un tale valore massimo nella sequenza. Problema dell’ordinamento di una sequenza Data una sequenza di elementi presi da un insieme su cui è definita una relazione d’ordine totale, trovare una permutazione di tale sequenza che sia ordinata. Problema della ricerca in una sequenza ordinata Data una sequenza ordinata di elementi e dato un elemento determinare se esso compare nella sequenza o no, e l’indice del posto in cui compare. Problema del cammino minimo Data una mappa stradale che riporti località, strade e lunghezze dei percorsi, e data una coppia di località (raggiungibili l’una dall’altra), trovare un percorso di lunghezza minima fra di esse. Problema della distanza di editing Date due stringhe, trovare la più corta sequenza di operazioni (di inserimento, cancellazione e modifica) che trasforma una stringa nell’altra. La soluzione di un problema algoritmico è un algoritmo, ossia (la descrizione precisa di) un procedimento “meccanico” che permetta, per ogni input soddisfacente alla relativa specifica di trovare un output che soddisfi alla relativa specifica. Un algoritmo è corretto (risolve il problema computazionale dato) se per ogni istanza di input termina con l’output corretto. Uno stesso problema può essere risolto da molti algoritmi, che possono richiedere un numero di operazioni elementari e uno spazio di memoria molto diversi l’uno dall’altro. Domande fondamentali: • un dato algoritmo quanto tempo ci mette, e di quanto spazio di memoria ha bisogno, per produrre la risposta per un input di una data dimensione? • qual è la massima dimensione dell’input per la quale l’algoritmo produce una risposta in un tempo accettabile e con un’occupazione di memoria accettabile? • dato un problema, si può trovare un algoritmo migliore di quelli esistenti per risolvere quel problema? Per rispondere a queste domande definiremo con precisione le nozioni di complessità temporale e spaziale di un algoritmo. L’efficienza o addirittura la definibilità di un algoritmo è spesso legata al modo in cui i dati sono rappresentati in memoria, cioè alla struttura dati scelta. Per esempio, una soluzione del problema della ricerca in una sequenza ordinata è data dall’algoritmo di ricerca binaria; tale soluzione è sensata, perché molto più efficiente della ricerca sequenziale, solo se la sequenza è realizzata per mezzo di un array (o di un albero di ricerca), non se la sequenza è realizzata come una lista. Lo studio degli algoritmi è quindi inseparabile dallo studio delle strutture dati. 2 1.2 Correttezza di algoritmi ricorsivi È basata sul principio di induzione, di cui casi particolari sono il principio di induzione aritmetica e quello di induzione aritmetica completa, ma che più in generale si applica a qualunque insieme definito induttivamente. Vediamo cosa significa. Def. 1.1 [Definizione induttiva] Una definizione induttiva R è un insieme di regole Pr c , dove Pr è un insieme detto delle premesse e c è un elemento, detto la conseguenza della regola. L’insieme definito induttivamente I(R) è il più piccolo tra gli insiemi X chiusi rispetto a R, ossia tali che, per ogni regola Pr c ∈ R, se Pr ⊆ X allora c ∈ X. In altre parole, I(R) è l’insieme di tutti e soli gli elementi che si ottengono applicando ripetutamente le regole, a partire da quelle con premessa vuota che costituiscono la base della definizione induttiva. Una definizione induttiva è in genere data attraverso una qualche descrizione finita “a parole”, per esempio: L’insieme dei numeri pari è il più piccolo insieme tale che (oppure: è l’insieme definito induttivamente da): • 0 è un numero pari, • se n è un numero pari, allora n + 2 è un numero pari. Questa descrizione corrisponde all’insieme (infinito) di regole: ∅ 0 n ∪ { n+2 | n ∈ N}. Prop. 1.2 [Principio di induzione (forma generale)] Sia R una definizione induttiva, I(R) ⊆ U , e P un predicato su U . Se, per ogni Pr c ∈ R, (P (d) vale per ogni d ∈ Pr ) implica che valga P (c) (?) allora P (d) vale per ogni d ∈ I(R). Dimostrazione Poniamo C = {d | P (d) vero} e osserviamo che la condizione (?) può essere scritta nella forma equivalente: Pr ⊆ C implica c ∈ C. Allora è chiaro che C risulta chiuso rispetto a R, quindi I(R) ⊆ C, quindi P (d) vale per ogni d ∈ I(R). Si noti che nel caso Pr = ∅ richiedere la condizione (?) equivale a richiedere che P (c) sia vero. Ciò suggerisce la seguente formulazione equivalente che è quella comunemente usata: Sia R una definizione induttiva, I(R) ⊆ U , e P un predicato su U . Se Base P (c) vale per ogni ∅ c ∈R Passo induttivo (P (d) vale per ogni d ∈ Pr ) implica che valga P (c) per ogni Pr c ∈ R, Pr 6= ∅ allora P (d) vale per ogni d ∈ I(R). Vediamo alcuni casi particolari del principio di induzione. Prop. 1.3 [Principio di induzione aritmetica] Sia P un predicato sui numeri naturali tale che Base vale P (0) Passo induttivo se vale P (n) allora vale P (n + 1), allora P (n) vale per ogni n ∈ N. Dimostrazione N può essere visto come l’insieme definito induttivamente da • 0∈N • se n ∈ N allora n + 1 ∈ N. Quindi la proposizione è un caso particolare del principio di induzione. Vediamo un esempio di applicazione. Esempio 1.4 Proviamo per induzione aritmetica che 20 + 21 + ... + 2n = 2n+1 − 1, per tutti gli n ∈ N. Base P (0): 20 = 1 = 21 − 1 3 Passo induttivo Assumiamo P (i) e dimostriamo P (i + 1): 20 + 21 + ... + 2i+1 = (20 + 21 + ... + 2i ) + 2i+1 = (2i+1 − 1) + 2i+1 = 2 · (2i+1 ) − 1 = 2i+2 − 1 (per ipotesi induttiva) Esercizio 1.5 1. Si dimostri che 1 + 2 + ... + n = n(n + 1)/2. 2. Si dimostri che 12 + 22 + 32 + ... + n2 = n(n + 1)(2n + 1)/6. Prop. 1.6 [Principio di induzione aritmetica completa] Sia P un predicato sui numeri naturali tale che Base vale P (0) Passo induttivo se vale P (m) per ogni m < n allora vale P (n), allora P (n) vale per ogni n ∈ N. Dimostrazione N può essere visto come l’insieme definito induttivamente da • 0∈N • se {m | m < n} ⊆ N allora n ∈ N. Quindi la proposizione è un caso particolare del principio di induzione. Esempio 1.7 Un altro esempio: possiamo dare una definizione induttiva degli alberi binari (con nodi in N ) : • l’albero vuoto è un albero binario, • se L e R sono alberi binari e x ∈ N , allora la tripla (x , L, R) è un albero binario. Di conseguenza, è possibile provare proprietà degli alberi binari con il principio di induzione, che prende la seguente forma: Sia P un predicato sugli alberi binari tale che • P vale sull’albero vuoto, • se P vale su L e R, allora vale su (x , L, R), allora P vale su tutti gli alberi binari. Esercizio 1.8 Dare una definizione induttiva delle liste e formulare il corrispondente principio di induzione. Se abbiamo un algoritmo ricorsivo il cui input varia in un insieme definito induttivamente, e definito a sua volta in modo induttivo (ossia ricalcando la definizione induttiva dell’insieme), è facile provare la correttezza dell’algoritmo con il principio di induzione. Per esempio nel caso di un algoritmo sui numeri naturali: • se l’algoritmo è corretto per 0 • e assumendo che sia corretto per n, è corretto per n + 1, allora è corretto su qualunque numero naturale. Analogamente si può applicare il principio di induzione arimetica completa o il principio di induzione per alberi, liste, etc. Inoltre, il principio di induzione non solo ci consente di provare a posteriori la correttezza di un algoritmo, ma fornisce anche una guida per il suo sviluppo. Per esempio, nel caso dell’induzione aritmetica: • si scrive l’algoritmo corretto per 0 • se l’argomento è n > 0, si assume che l’algoritmo sappia operare correttamente su n − 1, e fidandosi di tale assunzione si scrive l’algoritmo corretto per operare su n. L’esempio “classico” è l’algoritmo ricorsivo per il fattoriale. Analogamente si ha per altri tipi di induzione. 4 Esempio 1.9 [Algoritmo di Euclide] È nota la seguente proprietà: MCD(a, b) = MCD(b, a mod b) dove a mod b è il resto della divisione intera di a per b. Base Sappiamo calcolare direttamente MCD(a, 0) per qualunque a, poiché esso è semplicemente a. Quindi mcd(a,0)=a. Passo induttivo (b > 0). Ipotesi induttiva La funzione mcd calcola correttamente MCD(a, b0 ) per qualunque b0 < b (per qualunque a). Tesi induttiva mcd(a,b) = mcd(b, a mod b) calcola correttamente MCD(a, b). Dimostrazione: si applica la proprietà nota, e poiché a mod b < b, si può sfruttare l’ipotesi induttiva. Il principio di induzione è implicitamente alla base, come vedremo, anche della scrittura di algoritmi iterativi corretti. 1.3 Correttezza di algoritmi iterativi Come si scrive un algoritmo iterativo “in modo che sia corretto”? //Pre: precondizione = quello che sappiamo essere vero all’inizio while (B) C //Post: postcondizione = quello che vogliamo sia vero alla fine L’algoritmo è corretto se, assumendo che valga Pre all’inizio, possiamo concludere che vale Post alla fine. Cos’è un’invariante di ciclo? qualunque condizione Inv tale che: //Pre: Inv ∧ B C //Post: Inv cioè, se devo eseguire il corpo del ciclo e la condizione vale, allora vale anche dopo averlo eseguito. Quando un’invariante serve a provare la correttezza? • Inv vale all’inizio • se valgono insieme Inv e ¬ B e allora vale Post. Infatti, in questo caso è facile vedere per induzione aritmetica sul numero di iterazioni che, assumendo che il comando termini, alla fine vale Post. Come si prova la terminazione? trovando una quantità t (funzione di terminazione) tale che, se devo eseguire il corpo, quindi se valgono Inv e B: • viene decrementata eseguendo il corpo • è limitata inferiormente. Esempio 1.10 [Esempio “didattico”] //Pre: x = x0 ∧ y = y0 ∧ x ≥ 0 while (x != 0) x = x-1 y = y+1 //Post: y = x0 + y0 Ci sono molte invarianti, per esempio x ≥ 0, x ≤ x0 , y ≥ y0 , true, false, ma quella che mi serve è x + y = x0 + y0 ∧ x ≥ 0. //Pre: x = x0 ∧ y = y0 ∧ x ≥ 0 while (x!=0) //Inv: x + y = x0 + y0 ∧ x ≥ 0 x = x-1 y = y+1 //Post: y = x0 + y0 Infatti: • vale banalmente all’inizio • se vale insieme a x = 0, allora vale la postcondizione. 5 • se vale insieme a x 6= 0 prima di eseguire il corpo, allora vale dopo La funzione di terminazione che mi serve è il valore di x, infatti: • viene decrementato a ogni passo • assumendo che valga Inv (quindi x ≥ 0), è limitato inferiormente da 0. Metodologia: l’invariante si ottiene in genere “indebolendo” la postcondizione, in modo da modellare il “risultato parziale al passo generico”. L’invariante non serve solo per provare la correttezza a posteriori, ma soprattutto come guida allo sviluppo dell’algoritmo. In molti algoritmi iterativi, inclusi una buona parte di quelli che vedremo, l’idea iniziale è “una buona invariante”. Una volta trovata la proprietà invariante, si scrivono nell’ordine: il corpo del ciclo in modo che produca un avvicinamento alla soluzione, la condizione di controllo in modo che in uscita si abbia la postcondizione, l’inizializzazione in modo da garantire che l’invariante valga inizialmente. Vedremo esempi di questa metodologia nella sezione sezione 1.8. 1.4 Notazioni asintotiche Def. 1.11 Una funzione g(n) è O(f (n)) se esistono due costanti c > 0 e n0 ≥ 0 tali che g(n) ≤ cf (n) per ogni n ≥ n0 , ossia, da un certo punto in poi, g sta sotto una funzione “multipla” di f (“cresce al più come f ”). Una funzione g(n) è Ω(f (n)) se esistono due costanti c > 0 e n0 ≥ 0 tali che g(n) ≥ cf (n) per ogni n ≥ n0 , ossia, da un certo punto in poi, f sta sopra una funzione “sottomultipla” di f (“cresce almeno come f ”). Una funzione g(n) è Θ(f (n)) se esistono tre costanti c1 , c2 > 0 e n0 ≥ 0 tali che c1 f (n) ≤ g(n) ≤ c2 f (n) per ogni n ≥ n0 , ossia, da un certo punto in poi, f è compresa tra un multiplo di f e un sottomultiplo di f (“cresce come f ”). Le espressioni “multiplo” e “sottomultiplo” sono un abuso di linguaggio, giustificato dalla seguente osservazione: nella definizione di O è significativo il fatto che c possa essere maggiore di uno, mentre nella definizione di Ω è significativo che c possa essere minore di uno. Per comodità si usa scrivere f (n) = O(g(n)) invece di f (n) è O(g(n)), e analogamente per le altre notazioni. Occorre però ricordare che si tratta di un abuso di notazione, poiché il simbolo = in questo caso non denota un’uguaglianza, ma l’appartenenza a un insieme. Proprietà della notazione asintotica Transitiva f (n) = O(g(n)) e g(n) = O(h(n)) implica f (n) = O(h(n)) f (n) = Ω(g(n)) e g(n) = Ω(h(n)) implica f (n) = Ω(h(n)) f (n) = Θ(g(n)) e g(n) = Θ(h(n)) implica f (n) = Θ(h(n)) Riflessiva f (n) = O(f (n)) f (n) = Ω(f (n)) f (n) = Θ(f (n)) Simmetrica f (n) = Θ(g(n)) se e solo se g(n) = Θ(f (n)) Simmetrica trasposta f (n) = O(g(n)) se e solo se g(n) = Ω(f (n)) Comportamenti notevoli Trattabili Θ(1) costante Θ(log n) logaritmico Θ(log n)k poli-logaritmico radice quadrata Θ(n1/2 ) Θ(n) lineare Θ(n log n) pseudolineare Θ(n2 ) quadratico Θ(n3 ) cubico Intrattabili Θ(2n ) esponenziale Θ(n!) fattoriale Θ(nn ) esponenziale in base n 6 1.5 Complessità di algoritmi e problemi In generale sia il numero di passi elementari che lo spazio richiesto da un algoritmo dipendono dalla dimensione n dell’input. Tuttavia, in generale non sono funzioni di n, perché, a parità di dimensione, input diversi possono richiedere un tempo di calcolo e un’occupazione di memoria molto diversi (per esempio, l’algoritmo di ordinamento insertion sort effettua Θ(n) confronti nel caso di una sequenza ordinata, mentre in altri casi ne effettua Θ(n2 )). Scrivere T (n) ed S (n) sarebbe quindi ambiguo: occorre distinguere, per ogni n, caso migliore, caso peggiore e caso medio. Formalmente, se i è un’istanza di input, siano t(i ) e s(i ) il tempo di esecuzione e lo spazio di memoria necessario 1 per l’esecuzione dell’algoritmo per l’input i . Le seguenti funzioni risultano allora ben definite: complessità temporale del caso peggiore Tworst (n) = max{t(i ) | i ha dimensione n} complessità temporale del caso migliore Tbest (n) = min{t(i ) | i ha dimensione n} complessità temporale del caso medio Tavg (n) = avg{t(i ) | i ha dimensione n} Queste tre funzioni possono non avere forme matematiche elementari, ma hanno di solito andamenti asintotici ben definiti. Analoghe definizioni si possono dare per la complessità spaziale. Per il caso medio, per ogni n si considerano tutti i possibili input di dimensione n, siano i1 , . . . iN , e si fa la media aritmetica dei tempi: Tavg (n) = t(i1 )+...+t(iN ) N Tuttavia, se i possibili casi di input non sono equiprobabili, ma si presentano con probabilità p1 , . . . , pN con p1 + . . . + pN = 1, la media deve essere una media pesata: Tavg (n) = p1 ·t(i1 )+...+pN ·t(iN ) N Si noti che (anche nel caso equiprobabile) si fa la media sui diversi casi, non sui diversi tempi (analogia con media dei voti). [MANCA UNA PARTE: VEDI LUCIDI 2] 1.6 Esempi di progettazione e analisi di algoritmi ricorsivi Esempio 1.12 [Torri di Hanoi] [VEDI LUCIDI 2] Esempio 1.13 [Ricerca binaria] [VEDI LUCIDI 2] 1.7 Teorema fondamentale delle ricorrenze [VEDI LUCIDI 2] 1.8 Esempi di progettazione e analisi di algoritmi iterativi Esempio 1.14 [Ricerca binaria iterativa] Sia la sequenza rappresentata con un array a con indici 0..n − 1. Idea: si confronta l’elemento da cercare con l’elemento centrale dell’array, a seconda del risultato ci si restringe a considerare solo la prima o la seconda metà, e cosı̀ via. Idea per l’invariante: al passo generico il valore da cercare x, se presente nell’array, si trova nella porzione di array compresa fra due indici inf e sup, formalmente: x ∈ a[0..n − 1] ⇒ x ∈ a[inf..sup] Guidati da questa invariante: Passo confronto con x l’elemento centrale a[mid] della porzione a[inf..sup], sono possibili tre casi: • x < a[mid]: x, se c’è, si trova nella porzione a[inf..mid − 1], quindi sup = mid-1 • x > a[mid]: x, se c’è, si trova nella porzione a[mid + 1..sup], quindi inf = mid+1 • x = a[mid]: ho trovato x, fine! Condizione di controllo la porzione di array su cui effettuare la ricerca non deve essere vuota, quindi inf ≤ sup 1 In aggiunta allo spazio occupato dallinput stesso. 7 Inizializzazione inizialmente la porzione di array su cui effettuare la ricerca è l’intero array, quindi inf = 0 e sup = n − 1. Algoritmo completo: binary_search(x,a) inf = 0; sup = n-1 //Pre: inf = 0 ∧ sup = n − 1 while (inf <= sup) //Inv: x ∈ a[0..n − 1] ⇒ x ∈ a[inf..sup] mid = (inf + sup)/2 if (x < a[i]) sup = mid-1 else if (x > a[i]) inf = mid+1 else return true //Post: x 6∈ a[0..n − 1] return false Vediamo ora un altro esempio in cui il ruolo dell’invariante è ancora più significativo. Esempio 1.15 [Bandiera nazionale olandese] Si ha una sequenza di n elementi rossi, bianchi e blu. Vogliamo modificare la sequenza in modo da avere prima tutti gli elementi rossi, poi tutti i bianchi, poi tutti i blu. Le operazioni che possiamo effettuare sulla sequenza sono: • swap(i,j) scambia di posto due elementi, per 1 ≤ i, j ≤ n • colour(i) restituisce Red, White, Blue per 1 ≤ i ≤ n Vogliamo inoltre esaminare ogni elemento una volta sola. Indichiamo con Red (i, j) il predicato “gli elementi da i a j compresi sono tutti rossi” e analogamente White(i, j), Blue(i, j). Siano inoltre U, W e B gli indici del primo elemento ignoto, bianco e blu. La postcondizione sarà quindi: Red (1, W − 1) ∧ White(W, B − 1) ∧ Blue(B, n) e l’invariante: Red (1, U − 1) ∧ White(W, B − 1) ∧ Blue(B, n) ∧ U ≤ W (l’ultima condizione nell’invariante è aggiunta per chiarezza). Algoritmo completo: //Pre: U = 1 ∧ W = B = n + 1 while (U < W) //Inv: Red (1, U − 1) ∧ White(W, B − 1) ∧ Blue(B, n) ∧ U ≤ W switch (Colour(W-1) case Red : swap(U, W-1); U = U + 1 case White : W = W - 1 case Blue : swap(W-1, B-1); W = W-1; B = B-1 //Post: Red (1, W − 1) ∧ White(W, B − 1) ∧ Blue(B, n) Prova di correttezza: • Inv vale all’inizio banalmente • Inv ∧ U ≥ W implica Post perché is ha U = W • se vale Inv e U < W eseguendo il corpo del ciclo vale ancora Inv : si vede per casi • come funzione di terminazione possiamo prendere W − U, infatti decresce eseguendo il corpo del ciclo in ognuno dei casi, e se vale la condizione di controllo è limitata inferiormente. 2 Algoritmi di ordinamento Problema dell’ordinamento: data una sequenza di elementi, ordinarla rispetto a una relazione d’ordine totale definita sugli elementi, per esempio rispetto a una chiave. Gli algoritmi che risolvono tale problema, cioè gli algoritmi di ordinamento, sono classificabili secondo diversi criteri, quali la complessità asintotica, oppure il fatto che godano o no di certe proprietà interessanti. 8 Stabilità Un algoritmo di ordinamento si dice stabile se non altera l’ordine relativo di oggetti distinti che siano uguali rispetto alla relazione d’ordine. Ossia, se nella sequenza da ordinare vi sono due elementi x e y uguali rispetto alla relazione d’ordine, e x si trova prima di y, anche nella sequenza ordinata x deve trovarsi prima di y. Se una sequenza di elementi già ordinata secondo un certo criterio viene ordinata secondo un altro criterio per mezzo di un algoritmo stabile, elementi uguali secondo il nuovo criterio risultano ordinati secondo il precedente criterio. Per esempio, se una sequenza di persone è ordinata per nome, ordinandola per cognome con un algoritmo stabile si ottiene una sequenza in cui le persone con lo stesso cognome sono ordinate per nome. Naturalmente la stabilità non ha importanza se elementi uguali rispetto alla relazione d’ordine non sono distinguibili (per esempio una sequenza di interi). Algoritmi sul posto Un algoritmo di ordinamento si dice sul posto (o in loco, o in place) se la memoria aggiuntiva (oltre a quella necessaria per l’input ed eventualmente per la ricorsione) utilizzata è costante, ossia se l’algoritmo riordina la sequenza senza usare una sequenza ausiliaria o comunque uno spazio proporzionale al numero di elementi. 2.1 Algoritmi elementari 2.1.1 Selection sort Sia la sequenza rappresentata con un array a con indici 0..n − 1. Idea: si cerca il minimo dell’array e lo si mette al primo posto (mettendo il primo al posto del minimo), poi si cerca il minimo nella parte di array dal secondo elemento alla fine, e lo si mette al secondo posto, e cosı̀ via. Invariante, assumendo che i sia il primo elemento ancora non esaminato: • ordered (a, 0, i − 1) • ∀h ∈ 0..i − 1, l ∈ i..n − 1.a[h] ≤ a[l] (lo abbreviamo a[0..i − 1] ≤ a[i..n − 1]) Si noti che ogni elemento della parte ordinata è al suo posto definitivo (a differenza di insertion sort, vedi dopo). Passo Si scambia a[i] con l’elemento di valore minimo in a[i..n − 1], si ottiene: • ordered (a, 0, i) • a[0..i] ≤ a[i + 1..n − 1] quindi incrementando i l’invariante vale ancora. Condizione di uscita Non occorre controllare l’ultimo elemento, perché in base alla seconda condizione nell’invariante è già al posto giusto, quindi i < n − 1. Inizializzazione La parte da ordinare a[i..n − 1] è l’intero array, quindi i = 0. Algoritmo completo con asserzioni (l’ultima condizione nell’invariante è aggiunta per chiarezza): selectsort(a) i = 0 //Pre: i = 0 while (i<n-1) //Inv: ordered (a, 0, i − 1) ∧ a[0..i − 1] ≤ a[i..n − 1] ∧ i ≤ n − 1 swap(a,i,indexMinFrom(a, i)) i++ //Post: ordered (a, 0, n − 1) • Pre ⇒ Inv perché 0..i − 1 vuoto • Inv ∧ i ≥ n − 1 ⇒ Post perché si ha i = n − 1, quindi ordered (a, 0, n − 2) ∧ a[0..n − 2] ≤ a[n − 1] • Inv si mantiene • terminazione: n − 1 − i decresce a ogni passo e se vale la condizione di controllo è limitata inferiormente. L’algoritmo può ovviamente essere realizzato con un for: for(i = 0; i<n-1; i++) swap(a,i,indexMinFrom(a, i)) 9 Complessità temporale della procedura indexMinFrom: deve percorrere a[i..n − 1], quindi n − i passi. Complessità temporale dell’algoritmo di ordinamento: indexMinFrom viene invocata per i che varia da 0 a n − 2, il numero totale di passi è quindi: n + (n − 1) + (n − 2) + ... + 3 + 2 = n(n + 1)/2 − 1 = n2 + n 2 −1 L’algoritmo è quadratico. A differenza di insertion sort (vedi dopo) non esistono un caso peggiore e un caso migliore: il numero di passi non dipende dal modo in cui sono disposti i valori nell’array. Selection sort è quindi un algoritmo peggiore di insertion sort, che, pur essendo anch’esso in media quadratico, ha un caso migliore lineare. Complessità spaziale: l’algoritmo non usa array ausiliari, ma solo un numero fissato (e piccolo) di variabili. È pertanto un ordinamento sul posto. Inoltre, essendo un algoritmo iterativo, la dimensione dello stack è costante. La complessità spaziale è quindi Θ(1). Stabilità: come quasi tutti gli algoritmi di ordinamento basati su scambi, il selection sort non è stabile. Esercizio: trovare un esempio che lo mostri. 2.1.2 Insertion sort Sia la sequenza rappresentata con un array a con indici 0..n − 1. Idea: si prende il primo elemento non ancora esaminato e lo si inserisce al posto giusto nella parte già ordinata. Invariante, assumendo che i sia il primo elemento ancora non esaminato: ordered (a, 0, i − 1) Passo Inserisco a[i] al posto giusto in a[0..i − 1], si ottiene ordered (a, 0, i), quindi incrementando i l’invariante vale ancora. Condizione di controllo i < n. Inizializzazione Un array di un solo elemento è ordinato quindi i = 1. Algoritmo completo con asserzioni (l’ultima condizione nell’invariante è aggiunta per chiarezza): insertsort(a) i = 1 //Pre: i = 1 while (i < n) //Inv: ordered (a, 0, i − 1) ∧ i ≤ n insert(a,a[i],i)//inserisce a[i] al posto giusto in a[0..i − 1] //ordered (a, 0, i) i++ //Post: ordered (a, 0, n − 1) • Pre ⇒ Inv perché a[0..0] ordinato • Inv ∧ i ≥ n ⇒ Post perché si ha i = n • Inv si mantiene • terminazione: n − i decresce a ogni passo e se vale la condizione di controllo è limitata inferiormente. Inserimento di x in a[0..i − 1]. Idea: confrontiamo x con gli elementi in a[0..i − 1], a partire dall’ultimo, e se è minore li spostiamo di un posto a destra finché non troviamo l’indice in cui inserire x. j = i //Pre: j = i ∧ ordered (a, 0, i − 1) while(j > 0 && x < a[j-1])//Inv: ordered (a, 0, j − 1) ∧ ordered (a, j + 1, i) ∧ x < a[j + 1..i] ∧ j ≥ 0 a[j] = a[j-1] j--; //Post: Inv ∧ (j = 0 ∨ x ≥ a[j − 1]) a[j] = x //ordered (a, 0, i) • Pre ⇒ Inv perché j + 1..i risulta vuoto • Inv ∧ ¬(j > 0 ∧ x < a[j − 1]) è proprio Post • Inv si mantiene 10 • terminazione: j decresce a ogni passo e se vale la condizione di controllo è limitato inferiormente. Se si esce dal ciclo con j = 0, dopo l’assegnazione si ha a[0] < a[1..i] e ordered (a, 1, i), da cui la tesi. Se si esce dal ciclo con x ≥ a[j − 1], dopo l’assegnazione si ha a[j − 1] ≤ a[j] < a[j + 1..i], ordered (a, 0, j − 1) e ordered (a, j + 1, i), da cui la tesi. Complessità temporale dell’algoritmo di inserimento: • caso migliore: valore da inserire maggiore o uguale dell’ultimo elemento della parte ordinata, un solo confronto • caso peggiore: valore da inserire minore di tutti gli elementi della parte ordinata, i confronti • caso medio: numero medio di confronti = (1 + ... + i)/i = i(i + 1)/2i = (i + 1)/2 Complessità temporale dell’algoritmo di ordinamento: il ciclo viene eseguito per i che varia da 1 a n − 1 • caso migliore: array ordinato, ogni inserimento fa un solo confronto, in totale n − 1 confronti • caso peggiore: array ordinato inversamente, numero totale di confronti 1 + 2 + 3 + ... + n − 1 = n(n − 1)/2 = n2 /2 − n2 • caso medio: 1/2(2 + 3 + ... + n) = 1/2(n(n + 1)/2 − 1) = n2 /4 + n/4 − 1/2. L’algoritmo è O(n2 ), perché è Θ(n2 ) nel caso peggiore, è Ω(n), perché è Θ(n) nel caso migliore, ma non è Θ(n2 ) nè Θ(n): per ogni dimensione n vi sono sia input per cui il tempo è proporzionale a n, sia input per cui è proporzionale a n2 . Complessità spaziale: come il selection sort, è iterativo e non usa array ausiliari (algoritmo sul posto), quindi S(n) = Θ(1). Inoltre è stabile. Per esercizio: si spieghi perché. 2.2 Mergesort Problema preliminare: date due sequenze ordinate, (fonderle in modo da) ottenere una sequenza degli stessi elementi ordinata. Idea: si percorrono contemporaneamente le due sequenze inserendo ogni volta in fondo alla sequenza risultato il più piccolo dei due elementi di testa. Algoritmo con asserzioni, assumendo le due sequenze rappresentate con due array a e b con indici, rispettivamente, 0..n − 1 e 0..m − 1, e la sequenza risultato rappresentata con un array c con indici 0..n + m − 1, e le asserzioni ordered (a, 0, n − 1) e ordered (b, 0, m − 1) costantemente vere in quanto questi array non vengono modificati: merge(a,b) i = 0; j = 0 \\Pre: i = 0 ∧ j = 0 while (i < m && j < n) \\ Inv: c[0..i + j − 1] = merge(a[0..i − 1], b[0..j − 1]) ∧ i ≤ m ∧ j ≤ n if(a[i] <= b[j]) c[i+j] = a[i]; i++ else c[i+j] = b[j]; j++ \\Post: Inv∧(i = m ∨ j = n) while (i < m) c[i+j] = a[i]; i++ while(j < n) c[i+j] = b[j]; j++ return c • Pre ⇒ Inv vale banalmente perché 0..i − 1, 0..j − 1, e 0..i + j − 1 sono vuoti • Inv ∧ ¬(i < m ∧ j < n) è proprio Post • Inv si conserva, per l’invariante e il fatto che i due array sono ordinati • terminazione: la coppia (m − i, n − j) decresce e se vale la condizione di controllo è limitata inferiormente. Complessità dell’algoritmo di fusione: percorre interamente i due array, una volta sola, quindi Θ(n + m), assumendo che i due array abbiano approssimativamente la stessa lunghezza Θ(n). Non vi è distinzione fra caso migliore, caso peggiore e caso medio. Variante (utilizzata da mergesort) che fonde due porzioni adiacenti di un array usando un array ausiliario (usiamo espressioni ++ per compattezza): 11 merge(a,inf,mid,sup)\\fonde a[inf..mid] con a[mid+1..sup] aux = array con indici 0..sup − inf + 1 i = inf; j = mid+1; k = 0 while(i <= mid && j <= sup) if(a[i]<= a[j]) aux[k++] = a[i++] else aux[k++] = a[j++] while(i <= mid) aux[k++] = a[i++] while(j <= sup) aux[k++] = a[j++] for(h = 0; h < sup-inf; h++) a[inf + h] = aux[h] La complessità spaziale è anch’essa lineare in n lunghezza dell’array. Esistono varianti dell’algoritmo le quali, attraverso meccanismi complicati di scambi, non utilizzano un array ausiliario. Tali varianti, però, per la loro complicazione, sono in generale più lente. Vediamo ora l’algoritmo mergesort. Schema astratto dell’algoritmo (di tipo divide-et-impera, mentre i due precedenti erano di tipo incrementale): mergesort(s) if (|s|> 1) dividi s in due sequenze s1 ed s2 di lunghezza |s|/2 mergesort(s1 ) mergesort(s2 ) merge(s1 ,s2 ,s)//fonde ordinatamente s1 ed s2 in s Correttezza: si prova per induzione aritmetica completa sulla lunghezza di s. Base Una sequenza di lunghezza zero o uno è ordinata, e infatti l’algoritmo non fa nulla. Passo induttivo Poiché n2 < n, dopo l’esecuzione di mergesort(s1 ); mergesort(s2 ) per ipotesi induttiva si ha che s1 e s2 sono ordinate. Quindi, in base alla specifica di merge, la sequenza che si ottiene alla fine è ordinata e ha gli stessi elementi della lista di partenza. Complessità temporale: si ha la seguente relazione di ricorrenza (la fusione ha costo lineare, la divisione ha costo costante per un array e lineare per una lista) T (1) = 1 T (n) = 2T ( n2 ) + n per n > 1 Possiamo trovare la soluzione (assumendo per semplicità n = 2k ) espandendo l’albero di ricorsione (si hanno k+1 livelli ognuno di costo n), o per sostituzioni successive: T (n) = 2T ( n2 ) + n = 2(2T ( n2 2 ) + n2 ) + n = 22 T ( n2 2 ) + 2n = 22 (2T ( n2 3 ) + n2 2 ) + 2n = 23 T ( n2 3 ) + 3n = ... = 2k (T ( n2 k )) + kn = n(k + 1) = n(log n + 1) Possiamo a questo punto verificare per induzione aritmetica: Base T (20 ) = 1(0 + 1) Passo induttivo T (2k ) = 2T (2k−1 ) + 2k = (per ip. ind.) 2 · 2k−1 · k + 2k = 2k · k + 2k = 2k (k + 1) La soluzione poteva anche essere trovata direttamente applicando il teorema master. Poiché l’algoritmo di merge ha complessità Θ(n) in ogni caso, anche per il mergesort non c’è distinzione fra caso peggiore, migliore e medio, ma si ha sempre T (n) = Θ(n log n). Complessità spaziale: lo spazio di memoria necessario per la ricorsione è la massima profondità dello stack = altezza dell’albero di ricorsione, quindi Θ(log n). L’algoritmo di fusione fa uso di una sequenza ausiliaria per ogni chiamata, che può poi essere rimossa, quindi richiede uno spazio Θ(n). La complessità spaziale è perciò S (n) = Θ(log n) + Θ(n) = Θ(n). È anche possibile dare una versione “ecologica” che usa un’unica sequenza ausiliaria che viene passata come argomento aggiuntivo (vedi sotto). Stabilità: l’algoritmo è stabile se la fusione è realizzata in modo che quando nelle due sequenze si trovano due elementi uguali rispetto alla relazione d’ordine, venga inserito per primo l’elemento della prima sequenza (quindi è fondamentale il ≤ nel test). Diamo ora una versione concreta in cui la sequenza è rappresentata con un array con indici 0..n−1 ed utilizziamo l’ottimizzazione detta sopra: 12 mergesort(a) = aux = array con indici 0..n − 1 mergesort(a,0,n-1,aux) mergesort(a,inf,sup,aux) if (inf<sup) mid = (inf+sup)/2 mergesort(a,inf,mid,aux) mergesort(a,mid+1,sup,aux) merge(a,inf,mid,sup,aux)//fonde a[inf..mid] con a[mid+1..sup] usando aux merge(a,inf,mid,sup,aux)//fonde a[inf..mid] con a[mid+1..sup] i = inf; j = mid+1; k = inf while(i <= mid && j <= sup) if(a[i] <= a[j]) aux[k++] = a[i++] else aux[k++] = a[j++] while(i <= mid) aux[k++] = a[i++] while(j <= sup) aux[k++] = a[j++] for (h = inf; h <= sup; h++) a[h] = aux[h] usando aux Ottimizzazione ulteriore: si può evitare di copiare la parte non esaurita di array in aux per poi ricopiarla in a nel modo seguente: • se la parte non esaurita è la seconda, questa è già al suo posto, quindi non deve essere ricopiata • se la parte non esaurita è la prima, la si sposta direttamente al posto giusto in a, partendo dal fondo (perché?). L’algoritmo di fusione diventa quindi: merge(a,inf,mid,sup,aux) i = inf; j = mid+1; k = inf while(i <= mid && j <= sup) if(a[i]<= a[j]) aux[k++] = a[i++] else aux[k++] = a[j++] for (h = mid, l = sup; h >= i;) a[l--] = a[h--] // while(j <= sup) aux[k++] = a[j++] for (h = inf; h < k; h++) a[h] = aux[h] ], e poi: Per esempio, se a = [20 30 40 | 10], dopo il primo ciclo avrò aux = [10 a=[ 40] copio da a stesso a = [ 30 40] copio da a stesso a = [ 20 30 40] copio da a stesso a = [10 20 30 40] copio da aux Versione “a passi alterni”: si può evitare di ricopiare ogni volta l’array ausiliario nell’array da ordinare, scambiando a ogni livello di ricorsione i ruoli dei due array. merge(a,inf,mid,sup,b) i = inf; j = mid+1; k = inf while(i <= mid && j <= sup) if(a[i]<= a[j]) b[k++] = a[i++] else b[k++] = a[j++] while(i <= mid) b[k++] = a[i++] while(j <= sup) b[k++] = a[j++] //for(h = inf; h < sup-inf; h++) a[inf + h] = aux[h] //Post: b[inf..sup] = merge(a[inf..mid], a[mid + 1..sup]) mergesort(a,inf,sup,b) //Pre: a[inf..sup] = b[inf..sup] if(inf<sup) mid = (inf+sup)/2 mergesort(b,inf,mid,a) mergesort(b,mid+1,sup,a) 13 merge(b,inf,mid,sup,a) //Post: ordered (a, inf, sup) mergesort(a)//top-level aux = copia di a mergesort(a,0,n-1,aux) Prova di correttezza: Base inf ≤ sup: ok Passo Induttivo Si ha inf < sup. Dato che la lunghezza mid−inf è minore di sup−inf, e la precondizione b[inf..mid] = a[inf..mid] è verificata, la chiamata ricorsiva mergesort(b,inf,mid,a) ordina b[inf..mid], e analogamente mergesort(b,mid+1,sup,a) ordina b[mid+1..sup]. A questo punto merge(b,inf,mid,sup,a) produce una copia ordinata di b[inf..sup] in a[inf..sup]. Quindi, mergesort(a,inf,sup,b) ordina a[inf..sup]. Nota: questa versione del mergesort è in sostanza quella che si trova nella libreria di Java (nella classe Arrays). Gli elementi che alla fine si trovano in a provengono da a oppure da b a seconda del numero pari o dispari di livelli di ricorsione, anzi sono un misto se la lunghezza dell’array non è una potenza di due. Esempio: a = [60a 50a 40a ], b = [60b 50b 40b ] mergesort(a,0,2,b): mergesort(b,0,0,a) non fa nulla mergesort(b,1,2,a): mergesort(a,1,1,b) non fa nulla mergesort(a,2,2,b) non fa nulla merge(a,1,2,b) ordina a[1..2] in b[1..2] a = [60a 50a 40a ], b = [60b 40a 50a ] merge(b,0,2,a)ordina b[0..2] in a[0..2] a = [40a 50a 60b ], b = [60b 40a 50a ] Un’altra (importante) ottimizzazione: se quando si deve effettuare la fusione l’ultimo elemento del segmento sinistro è minore o uguale al primo elemento del segmento di destra, la sequenza dei due segmenti è già un segmento ordinato e quindi la fusione non è necessaria. Esercizio: con tale ottimizzazione la complessità asintotica in qualche caso cambia? Si considerino le diverse versioni viste sopra. 2.3 Quicksort È l’algoritmo di ordinamento che ha, in generale, prestazioni migliori tra quelli basati su confronti. È stato ideato da Charles Antony Richard Hoare nel 1961. È molto popolare dato che è facilmente implementabile, ha un buon comportamento in un’ampia varietà di situazioni, è sul posto. Gli svantaggi sono dati dal fatto che non è stabile, nel caso peggiore ha un comportamento quadratico ed è fragile, nel senso che un errore nella sua implementazione può passare inosservato ma causare in certe situazioni un drastico peggioramento nelle prestazioni. È stato sottoposto a un’analisi matematica approfondita ed estremamente precisa, i risultati ottenuti in fase di analisi sono stati verificati sperimentalmente in modo esteso e l’algoritmo di base è stato migliorato al punto da diventare il metodo ideale per un gran numero di applicazioni pratiche. Praticamente dal momento in cui Hoare pubblicò per la prima volta il suo lavoro, sono state provate e analizzate molte varianti, ma spesso un miglioramento in un senso porta a un peggioramento delle prestazioni in qualche altro punto. Schema astratto dell’algoritmo, anch’esso di tipo divide-et-impera: quicksort(s) if (|s| > 1) togli un elemento p da s (per esempio il primo) partiziona gli altri elementi di s in due parti: una sequenza s1 contenente tutti gli elementi < p 14 una sequenza s2 contenente tutti gli elementi ≥ p forma la sequenza s1 p s2 quicksort(s1 ) quicksort(s2 ) L’elemento p è detto pivot o perno della partizione. È facile provare la correttezza dell’algoritmo per induzione aritmetica completa sulla lunghezza n della sequenza s: Base Una sequenza di lunghezza zero o uno è ordinata, e infatti l’algoritmo non fa nulla. Passo Dopo la partizione si ha: elementi di s1 < p ≤ elementi di s2 Per ipotesi induttiva, l’algoritmo ordina correttamente sequenze di lunghezza < n. Le sequenze s1 ed s2 hanno certamente lunghezza < n perché è stato tolto p, quindi dopo le due chiamate ricorsive s1 ed s2 sono ordinate; allora la sequenza s1 p s2 è chiaramente ordinata. Variante: quicksort(s) if (|s| > 1) scegli un elemento p di s (per esempio il primo) partiziona gli elementi di s in due parti: una sequenza s1 contenente elementi ≤ p una sequenza s2 contenente elementi ≥ p forma la sequenza s1 s2 quicksort(s1 ) quicksort(s2 ) In questa variante, il pivot non viene tolto, quindi il procedimento con cui si effettua la partizione deve garantire che s1 ed s2 risultino sempre non vuote, quindi entrambe di lunghezza minore della lunghezza di s. Complessità temporale: assumiamo di poter effettuare la partizione in tempo lineare e senza usare strutture ausiliarie (vedremo poi alcuni algoritmi concreti). Il tempo T (n) necessario per ordinare una sequenza di lunghezza n è quindi (per esempio per la seconda versione): T (1) = Θ(1) T (n) = Θ(n) + T (r) + T (n − r) per n > 1 dove Θ(n) è il tempo necessario per effettuare la partizione, T (r) il tempo necessario per ordinare s1 di lunghezza r, T (n − r) il tempo necessario per ordinare s2 di lunghezza n − r, ed r > 0 è diverso per ogni chiamata ricorsiva. Nota: nel quicksort tutto il lavoro dell’algoritmo viene effettuato dal partizionamento, cosı̀ come nel mergesort veniva effettuato dalla fusione. Il caso migliore si avrà quando, a ogni chiamata ricorsiva, le due parti s1 ed s2 risultano di uguale lunghezza (partizione sempre bilanciata). In questo caso avremo T (1) = Θ(1) T (n) = Θ(n) + T ( n2 ) + T ( n2 ), per n > 1. Questa relazione di ricorrenza è la stessa del mergesort e, come abbiamo visto, ha soluzione T (n) = Θ(n log n). Il caso peggiore si avrà quando, a ogni chiamata ricorsiva, le due parti s1 ed s2 risultano una di lunghezza 1 e l’altra di lunghezza n − 1 (partizione sempre sbilanciata al massimo). In questo caso avremo: T (1) = Θ(1) T (n) = Θ(n) + T (n − 1) per n > 1. che ha soluzione T (n) = Θ(n2 ), infatti assumendo per semplicità T (1) = 1, T (n) = n + T (n − 1) ed espandendo si ha T (n) = n + (n − 1) + ... + 1 = n(n + 1)/2 come si può verificare facilmente per induzione aritmetica. Si può provare (vedi nel seguito) che si ha anche 15 Tavg (n) = Θ(n log n) Complessità in spazio: il quicksort opera la partizione sul posto, cioè senza bisogno di un array ausiliario. Tuttavia, essendo un algoritmo ricorsivo, ha bisogno dello spazio per lo stack. L’altezza dell’albero di ricorsione è la massima profondità di chiamate ricorsive annidate, quindi: Sbest (n) = Θ(log n) Sworst (n) = Θ(n) Savg (n) = Θ(log n) Partizionamento: prima versione “semplice” (ignoti in fondo) In questa variante si segue il primo schema ricorsivo. Se a[inf..sup] è la porzione di array da ordinare, si sceglie il pivot p, per esempio a[inf], e il resto dell’array viene partizionato in due parti: a[inf + 1..i] gli elementi minori di p, a[i + 1..sup] gli elementi maggiori o uguali a p. Si scambia quindi a[inf] con a[i] e si ordinano ricorsivamente a[inf..i − 1] e a[i + 1..sup]. L’array risulta in ogni momento intermedio diviso in tre parti, la prima formata da elementi minori del pivot, la seconda da elementi maggiori o uguali, la terza da elementi non ancora esaminati, come indicato in figura: j i inf inf+1 < >= sup unknown Usiamo l’indice i per l’ultimo degli elementi minori del pivot, l’indice j per il primo degli “ignoti”. Passo Si considera il primo elemento ignoto, se questo è minore del pivot lo si scambia con l’elemento di posto i + 1 e si incrementano i e j, altrimenti basta incrementare j. Condizione di controllo Finché ci sono elementi ignoti, quindi j ≤ sup. Inizializzazione Tutti gli elementi sono ignoti, quindi i = inf e j = inf + 1. Algoritmo completo con asserzioni (l’ultima condizione nell’invariante è aggiunta per chiarezza): i = inf; j = inf+1 //Pre: i = inf ∧ j = inf + 1 while (j <= sup) //Inv: a[inf + 1..i] < p ∧ a[i + 1..j − 1] ≥ p ∧ j ≤ sup + 1 if (a[j] < p) i++; swap(a,i,j) j++ //Post: a[inf + 1..i] < p ∧ a[i + 1..sup] ≥ p • Pre ⇒ Inv vale banalmente perché inf + 1..i e i + 1..j − 1 vuoti • Inv si conserva • Inv ∧ j > sup ⇒ Post ovviamente perché si ha j = sup + 1 • terminazione: sup − j decresce e se vale la condizione di controllo è limitata inferiormente. Inserimento del pivot: deve essere scambiato con a[i], e poi si ordinano ricorsivamente a[inf..i − 1] e a[i + 1..sup]. Più sinteticamente con un ciclo for: i = inf for(j = inf+1; j <= sup; j++) if (a[j] < p) i++; swap(a,i,j) swap(a,inf,i) Cosa succede in questa versione se l’array è ordinato: il pivot p è inizialmente il minimo, quindi alla fine della partizione la parte sinistra risulta vuota e la parte destra ha lunghezza n − 1. Inoltre la condizione a[j] < p è sempre falsa, quindi non si effettua nessuno scambio. Di conseguenza, dato che la parte destra non viene modificata, il suo primo elemento è di nuovo il minimo, e cosı̀ via. È evidentemente un caso peggiore: ad ogni partizione si ha sempre il massimo sbilanciamento fra le due parti: quindi, si ha complessità temporale quadratica e complessità spaziale lineare. 16 Partizionamento: seconda versione “semplice” (ignoti al centro) Anche in questa variante si segue il primo schema ricorsivo ma, come nel problema della bandiera olandese, si tengono gli elementi non esaminati al centro. Quindi, dopo aver tolto il pivot a[inf], l’array risulta in ogni momento intermedio diviso in tre parti, la prima formata da elementi minori del pivot, la seconda da elementi non ancora esaminati, la terza da elementi elementi maggiori o uguali del pivot, come indicato in figura: j i inf inf+1 unknown < sup >= Usiamo l’indice i per il primo degli “ignoti”, l’indice j per l’ultimo. Passo Si considera il primo elemento ignoto, se questo è minore del pivot basta incrementare i, altrimenti lo si scambia con l’ultimo e si decrementa j. Condizione di controllo Finché ci sono elementi ignoti, quindi i ≤ j. Inizializzazione Tutti gli elementi sono ignoti, quindi i = inf + 1 e j = sup. Algoritmo completo con asserzioni (l’ultima condizione nell’invariante è aggiunta per chiarezza): i = inf+1; j = sup //Pre: i = inf + 1 ∧ j = sup while (i <= j) //Inv: a[inf + 1..i − 1] < p ∧ a[j + 1..sup] ≥ p ∧ i ≤ j + 1 if (a[i] < p) i++ else swap(a,i,j); j-//Post: a[inf + 1..i − 1] < p ∧ a[i..sup] ≥ p • Pre ⇒ Inv vale banalmente perché inf + 1..i − 1 e j + 1..sup vuoti • Inv si conserva • Inv ∧ i > j ⇒ Post ovviamente perché si ha i = j + 1 • terminazione: j − i decresce e se vale la condizione di controllo è limitata inferiormente. Inserimento del pivot: deve essere scambiato con a[j = i − 1], e poi si ordinano ricorsivamente a[inf..j − 1] e a[j + 1..sup]. Il caso dell’array ordinato è ancora un caso peggiore? Come nella prima versione il pivot è inizialmente il minimo, e alla fine della partizione la parte sinistra è vuota e la destra ha lunghezza n − 1. Tuttavia, in questo caso la condizione a[i] < p risulta sempre falsa, e quindi si operano sempre degli scambi. La parte destra risulta modificata, e il primo elemento non è più il minimo, ma il secondo più piccolo. La partizione della parte destra risulterà quindi lievemente meno sbilanciata e, via via che si scende nella ricorsione, lo sbilanciamento diminuisce. Il caso dell’array ordinato non è più un caso peggiore, il tempo di calcolo è espresso da una relazione di ricorrenza complessa ed è comunque superiore a quello per un array con elementi in “ordine casuale”. Vediamo un esempio: [10 20 30 40 50] [ |20 30 40 50| ] [ |50 30 40|20] [ |40 30|50 20] [ |30|40 50 20] [ | |30 40 50 20] [ |10|30 40 50 20] pivot 10 20 ≥ 10, scambio e decremento j 50 ≥ 10, scambio e decremento j 40 ≥ 10, scambio e decremento j 30 ≥ 10, scambio e decremento j inserisco il pivot Osservazione: in entrambe le versioni viste finora, gli elementi possono essere spostati due volte. Per esempio, nella versione con gli ignoti in mezzo, se a[i] e a[j] sono ≥ p, l’elemento di posto j viene prima scambiato con quello di posto i, e poi al passo successivo riportato a destra. In altri termini, l’algoritmo mette al posto giusto solo un elemento alla volta, eventualmente spostandone un altro dalla parte sbagliata. Altra osservazione: in entrambe le versioni viste finora, se nel partizionamento vi sono molti elementi uguali al pivot (anche se scelto a caso) essi finiscono tutti da una stessa parte, producendo cosı̀ uno sbilanciamento. Un array di elementi tutti uguali, o quasi tutti uguali, costituisce quindi un caso peggiore, di complessità quadratica, anche con pivot scelto a caso. La versione originale di Hoare (vedi dopo) e la tripartizione (ossia un algoritmo di partizionamento in cui si distinguono elementi minori, uguali e maggiori) evitano questo problema. Nel caso della tripartizione, su un array di elementi tutti uguali non viene effettuata 17 nessuna chiamata ricorsiva, quindi questo costituisce un caso migliore molto particolare di complessità temporale lineare e spaziale costante. Svantaggi della tripartizione: il test è più complesso e quindi più dispendioso in tempo; tenere gli elementi uguali al pivot in mezzo costringe a effettuare uno scambio per ogni elemento diverso dal pivot, e questo si presume sia il caso più frequente. Un rimedio al difetto precedente può essere ottenuto posizionando la parte degli uguali al pivot a sinistra o a destra invece che in mezzo, spostandola poi in mezzo solo alla fine del partizionamento, e combinando la tripartizione con alcuni aspetti della versione alla Hoare, come nella versione di Bentley-McIllRoy (vedi dopo). Partizionamento: versione [simile a] originale di Hoare In questa variante si segue il secondo schema ricorsivo, quindi gli elementi uguali al pivot possono stare da entrambe le parti, e si deve garantire che alla fine le due parti su cui si effettuano le chiamate ricorsive siano non vuote. La parte da esaminare è in mezzo, e si effettua uno scambio solo quando entrambi gli elementi dopo lo scambio saranno in posizione giusta. Nessun elemento viene quindi scambiato più di una volta. Gli elementi uguali al pivot vengono sempre scambiati. Usiamo l’indice i per il primo degli “ignoti”, l’indice j per l’ultimo. j i inf ≤ unknown sup ≥ Invariante: a[inf..i − 1] ≤ p ∧ a[j + 1..sup] ≥ p. Condizione di controllo i ≤ j Inizializzazione i = inf, j = sup Passo Si incrementa i fino a trovare un elemento ≥ p, poi si decrementa j fino a trovare un elemento ≤ p. A questo punto si hanno tre casi possibili: • i < j: occorre scambiare i e j, e poi incrementare i e decrementare j • i = j: (quindi a[i] = p) scambiare i e j è inutile ma non dannoso, e poi occorre spostare gli indici per terminare il ciclo • i = j + 1: scambiare i e j sarebbe errato. Per semplificare possiamo ridurci a due casi: si fa lo scambio e si spostano i e j a meno che gli indici non siano “incrociati”. Algoritmo di partizionamento completo con asserzioni: i = inf; j = sup //Pre: i = inf ∧ j = sup while(i <= j) //Inv: a[inf..i − 1] ≤ p ∧ a[j + 1..sup] ≥ p ∧ ((i = inf ∧ j = sup) ∨ (inf < i ∧ j < sup)) //∧i ≤ j + 2 ∧ i = j + 2 ⇒ a[i − 1] = p while(a[i] < p) i++ //a[i] ≥ p while(a[j] > p) j-- //a[j] ≤ p if(i <= j) swap(a,i,j) i++; j-//Post: a[inf..i − 1] ≤ p ∧ a[j + 1..sup] ≥ p ∧ inf < i ∧ j < sup // ∧((i = j + 1) ∨ (i = j + 2 ∧ a[i − 1] = p)) Correttezza: anzitutto, i due cicli interni terminano senza uscire dai limiti dell’array. Infatti, nella prima iterazione gli indici si fermano alla peggio sul pivot, perché il segmento a[i..sup], essendo a[inf..sup], contiene sicuramente il pivot, e analogamente a[inf..j]. Si effettua quindi un primo scambio (alla peggio del pivot con se stesso) e si spostano gli indici, quindi dopo la prima iterazione si ha sicuramente inf < i e j < sup, ossia le due porzioni a[inf..i − 1] e a[j + 1..sup] sono sicuramente non vuote (eventualmente con intersezione non vuota). Quindi, a[j + 1] funge da guardia per i e viceversa. L’uscita dal ciclo può avvenire in tre modi: • alla fine dei due cicli interni ho i < j, effettuo lo scambio e incrementando gli indici ho i = j + 1 • alla fine dei due cicli interni ho i = j (quindi a[i] = p), effettuo lo scambio e incrementando gli indici ho i = j + 2 • alla fine dei due cicli interni ho i = j + 1, non faccio nulla. 18 Quindi i = j + 1 oppure i = j + 2. In entrambi i casi richiamo ricorsivamente su a[inf..j] e a[i..sup], e so che le due porzioni non sono vuote. Infatti: • nel primo caso richiamo su a[inf..i − 1] e a[j + 1..sup] che so essere non vuote (da dopo la prima iterazione) • nel secondo caso richiamo su a[inf..i − 2] e a[i..sup], entrambe di lunghezza minore di quella della sequenza iniziale perché ho escluso l’elemento a[i − 1] = p. Algoritmo completo (usiamo un ciclo do-while in quanto si effettua almeno un’iterazione): quicksort(a,inf,sup) if (inf < sup) p = a[inf]; i = inf; j = sup do while (a[i] < p) i++ while (a[j] > p) j-if (i <= j) swap(a,i,j); i++; j-while(i <= j) quicksort(a,inf,j) quicksort(a,i,sup) Come si vede, il corretto funzionamento di questa versione, che minimizza il numero di spostamenti e di confronti inutili, dipende da dettagli abbastanza sottili. Partizionamento: versione di Bentley-McIlroy (1993) Adottata dalla Sun nelle API di Java. Combina vantaggi della versione originale di Hoare (ogni elemento è spostato al più una volta) e della tripartizione (efficienza in caso di elementi ripetuti). L’array è diviso in cinque porzioni: inf j i h = < k sup = > unknown Invariante: a[inf..h − 1] = p ∧ a[h..i − 1] < p ∧ a[j + 1..k] > p ∧ a[k + 1.. sup] = p. Passo • a[i] = p: swap(a,i,h); h++; i++ • a[i] < p: i++ • a[j] = p: swap(a,j,k); k--; j-• a[j] > p: j-• else: swap(a,i,j); i++; j-Il ramo else (quindi la sequenza i++; j--) viene eseguito nel caso a[i] > p ∧ a[j] < p, quindi non può essere i = j, ma si ha i < j. Quindi nell’invariante si ha i ≤ j + 1, quindi in uscita i = j + 1. inf h j i = k < > sup = Bisogna ancora spostare le due porzioni di elementi uguali al pivot al centro, si procede nel modo seguente: • se la parte “uguali di destra” è più corta di quella dei minori, si scambiano gli “uguali” con una parte dei minori > = = > • se la parte “uguali di destra” è più lunga di quella dei minori, si scambiano i minori con una parte degli uguali 19 > = > = • analogamente a sinistra. Per esercizio: scrivere il codice completo di quicksort in questo caso e la prova di correttezza per il ciclo. Analisi rigorosa del caso medio Assumiamo che per ogni lunghezza n tutte le possibili coppie di lunghezze delle due parti siano ugualmente probabili. È più semplice considerare la prima versione, in cui il pivot viene tolto, e quindi le possibili coppie di lunghezze sono: (0, n − 1), (1, n − 2) , (2, n − 3), . . . , (k, n − 1 − k), . . . , (n − 1, 0) Si ha quindi T (n) = n + n1 (T (0) + T (n − 1) + T (1) + T (n − 2) + . . . + T (n − 1) + T (0)) n−1 P = n + n2 T (i) i=0 2 Per ottenere T (n) in funzione di T (n − 1), espandiamo analogamente T (n − 1), e otteniamo T (n − 1) = n − 1 + n−1 quindi n−2 P T (i) = i=0 n−1 2 (T (n − 1) − n + 1) e sostituendo nell’espressione di partenza: T (n) = n + n2 ( n−1 2 (T (n − 1) − n + 1) + T (n − 1)) Semplificando si ottiene T (n) = n+1 n T (n − 1) + 2n−1 n che è una relazione di ricorrenza di forma usuale. Per risolverla, maggioriamo in questo modo: T (n) ≤ n+1 n T (n − 1) + 2(n+1) n e poi usiamo il metodo delle sostituzioni successive: T (n) ≤ T (n) ≤ ... T (n) ≤ 2(n+1) n+1 n 2n n ( n−1 T (n − 2) + n−1 ) + n 2(n+1) 2(n+1) n+1 T (n − 2) + + n−1 n−1 n n+1 n−1 T (n T (n) ≤ 2(n + 1) − (n − 1)) + n P 1 i=2 2(n+1) 2 ... + 2(n+1) n−1 + 2(n+1) n (assumendo per semplicità T (1) = 0) i Questa sommatoria è la somma armonica che si prova essere Θ(log n). Si può infatti osservare che 1 2 + 1 3 + 1 22 + 1 5 + 1 6 + 1 7 ... + 1 23 + ... ≤ 1 + 1 2 + 1 2 + 1 22 20 + 1 22 + 1 22 + 1 22 + . . . = log n n−2 P i=0 T (i), Randomizzazione Il caso peggiore si verifica quando, a ogni chiamata ricorsiva, gli elementi della porzione di array considerata sono o tutti minori o tutti maggiori/uguali del pivot. Un buon modo per evitare questo consiste nello scegliere ogni volta il pivot a caso. Si scambia poi l’elemento-pivot con il primo in modo da riportarsi alla situazione vista precedentemente. In questo modo nessuna particolare istanza di input si comporta da caso peggiore. Un utilizzatore “maligno” può produrre un caso peggiore solo conoscendo l’algoritmo usato per la generazione dei numeri pseudo-casuali. Un’altra tecnica è basata sull’osservazione che la partizione è perfettamente bilanciata se il pivot è la mediana (valore appartenente all’insieme tale che metà degli elementi dell’insieme sono minori o uguali, e l’altra metà sono maggiori o uguali).2 Non conoscendo la mediana, si può scegliere come pivot il mediano di un certo numero di elementi, per esempio il mediano di primo, ultimo, ed elemento centrale, o di nove elementi presi ad intervalli regolari. In questo modo il caso peggiore non è più l’array ordinato, ma è comunque una fissata configurazione dell’array. Si possono anche combinare i due meccanismi, ossia scegliere il mediano di un certo numero di elementi di cui alcuni scelti a caso. Uso di algoritmi elementari Il quicksort è asintoticamente molto più efficiente di algoritmi di ordinamento quadratici come l’insertion sort, tuttavia la sequenza di operazioni che esso esegue per ciascun elemento dell’array è più complessa e quindi più dispendiosa in tempo, quindi per piccoli valori di n (alcune decine) un algoritmo quadratico ma semplice, come l’insertion sort, è più efficiente di quicksort. Si possono quindi confrontare sperimentalmente i tempi di calcolo di quicksort e insertion sort per determinare in modo approssimato il valore di n0 , e quando la lunghezza della porzione da ordinare è inferiore a n0 eseguire direttamente insertion sort. 2.4 Heapsort Idea: si parte dal selection sort (ordinamento per estrazione successiva del minimo), quadratico, ma, invece di cercare ogni volta il minimo, si inseriscono tutti gli elementi in una coda a priorità. Si operano quindi delle estrazioni successive del minimo. Se la coda a priorità è realizzata tramite heap, vedi sezione 3.1, l’estrazione del minimo costa O(log n) anziché O(n), e quindi il ciclo di n estrazioni costa O(n log n) invece di O(n2 ). Analogamente per il ciclo di inserimento nella coda. Sia la sequenza rappresentata con un array a con indici 0..n − 1. Prima versione dell’algoritmo (non sul posto): heapsort(a) Q = heap di lunghezza n for (i = 0; i < n; i++) Q.insert(a[i]) for (i = 0; i < n; i++) a[i] = Q.getMin() Versione sul posto: possiamo osservare che ogni elemento si trova sempre o nell’array o nello heap, quindi è possibile utilizzare l’array stesso come heap, nel modo seguente. Prima fase Si costruisce lo heap nella porzione iniziale dell’array da ordinare. heap parte ancora da esaminare i A ogni iterazione, la porzione a[0..i − 1] (inizialmente il solo elemento a[0]) è uno heap, l’elemento di posto i è già nella posizione di ultima foglia, basta quindi farlo risalire, rendendo cosı̀ uno heap la porzione a[0..i]. Alla fine della prima fase tutto l’array è uno heap. Seconda fase Si sostituisce la porzione finale dello heap con un array ordinato. heap ancora da svuotare parte già ordinata i A ogni iterazione, la porzione a[0..i] (inizialmente tutto l’array) è uno heap. Si scambia l’ultima foglia (elemento di posto i) con la radice, che quindi viene aggiunta a sinistra della porzione ordinata dell’array. Poi si fa scendere la radice, rendendo cosı̀ nuovamente uno heap la porzione a[0..i − 1]. Alla fine lo heap è vuoto e l’array ordinato. Attenzione: per ottenere l’ordine giusto e non quello inverso nella seconda fase, occorre utilizzare nella prima fase uno heap a massimo anzichè a minimo. In questo modo l’algoritmo è sul posto, ha complessità temporale O(n log n) ottimale in ogni caso come mergesort (non un caso peggiore quadratico come quicksort) e inoltre, a differenza di mergesort e quicksort, è un algoritmo iterativo, quindi con 2 La mediana è spesso una nozione statistica più significativa della media, per esempio considerando 11 persone, di cui 5 guadagnano al mese 2000 euro, 5 guadagnano 3000 euro, e uno guadagna 100000 euro, la media è circa 11364, la mediana 3000. 21 complessità spaziale (in ogni caso) Θ(1). Lo heapsort è quindi, dal punto di vista asintotico, l’algoritmo di ordinamento migliore fra quelli esaminati. Tuttavia nella maggior parte delle situazioni l’algoritmo di ordinamento più veloce risulta essere quicksort. Nella versione in place di heapsort, se si ordina un array con indici 0..n − 1, lo heap partirà dall’indice 0 invece che dall’indice 1, quindi i figli del nodo i saranno 2i + 1 e 2i + 2, e il genitore (i − 1)/2. Inoltre, dato che lo heap è a massimo, nella moveUp (qui chiamata addToHeap) e nella moveDown i test sono invertiti rispetto alla versione data nella sezione 3.1. Si ha quindi il codice seguente, dove scriviamo heap(a, 0, i) per “la porzione di array a[0..i] è uno heap (a massimo)”. heapsort(a) //a con indici 0..n − 1 for (i = 1;i < n;i++) addToHeap(a,i) //Inv: heap(a, 0, i − 1) for (i = n - 1; i > 0;i--) getMax(a,i) //Inv: heap(a, 0, i) ∧ ordered (a, i + 1, n − 1) ∧ a[0..i] ≤ a[i + 1] addToHeap(a,i) //Pre: heap(a, 0, i − 1) elem = a[i] //elemento da far salire while (i > 0 && elem > a[(i-1)/2]) //Inv: i posto vuoto a[i] = a[(i-1)/2] //scende il genitore i = (i-1)/2 //sale il posto vuoto a[i] = elem //Post: heap(a, 0, i) getMax(a,i) //Pre: heap(a, 0, i) swap(a,0,i) //scambia massimo con ultima foglia moveDown(a,0,i-1) //Post:heap(a, 0, i − 1) ∧ a[0..i − 1] ≤ a[i] moveDown(a,k,i) elem = a[k] //elemento da far scendere j = 2*k+1 //j figlio sinistro while (j < i && elem > a[j]) //Inv: i posto vuoto if (j+1 < i && a[j+1] < a[j]) j++ //j figlio destro a[k] = a[j] //sale il figlio k = j //scende il posto vuoto a[k] = elem //Post: heap(a, k, i) //invocata anche con k 6= 0 nella versione ottimizzata Ottimizzazione: l’array può essere trasformato in uno heap, invece che con n inserimenti successivi, con tempo totale Θ(n log n), applicando ripetutamente moveDown ai nodi a partire dal basso. Ciò richiede solo un tempo Θ(n) (vedi sotto). Infatti: • un albero costituito solo da una foglia è uno heap • un albero quasi completo i cui sottoalberi sinistro e destro sono degli heap diventa uno heap se si applica moveDown alla sua radice. Corrispondente algoritmo ricorsivo: heapify(a) //a con indici 0..n − 1 heapify(a,0) heapify(a,i) //a con indici 0..n − 1 j = 2*i+1 //figlio sinistro if(j < n) //i ha almeno un figlio heapify(a,j) heapify(a,j+1) moveDown(a,i,n-1) //Post: heap(a, i, n − 1) Nota: non occorre gestire a parte il caso in cui esista solo il figlio sinistro, perchè l’algoritmo invocato su un figlio destro inesistente non fa nulla, come nel caso di una foglia. 22 Con l’utilizzo dell’algoritmo heapify la complessità asintotica non cambia, perché il ciclo di estrazioni successive del massimo rimane Θ(n log n), tuttavia si ha un miglioramento rispetto a 2n log n. Trattandosi di un algoritmo ricorsivo cresce ovviamente la complessità in spazio, è facile tuttavia dare un algoritmo iterativo equivalente, che cioè esegue esattamente la stessa sequenza di chiamate di moveDown: heapify(a) //a con indici 0..n − 1 for (j = (n-2)/2; j >= 0; j--) moveDown(a,j,n-1); Si noti che (n − 2)/2, essendo il genitore dell’ultima foglia, è l’ultimo nodo interno. Versione finale compatta di heapsort: heapsort(a) for (j =(n-2)/2; j >= 0; j--) moveDown(a,j,n) for(i = n-1; i > 0; i--) swap(a,0,i) moveDown(a,0,i) Calcolo della complessità di heapify: se h è l’altezza dell’albero heap, 2h è il numero delle foglie e 2h+1 − 1 = n è il numero dei nodi, quindi, considerando la versione ricorsiva, otteniamo la seguente relazione di ricorrenza: T (20+1 − 1) = 0 T (2h+1 − 1) = h + 2T (2h − 1) (infatti 2h+1 − 1 − 1 = 2h+1 − 2 = 2(2h − 1) ) = h + 2(h − 1 + 2T (2h−1 − 1)) = 20 h + 21 (h − 1) + 22 (h − 2 + 2T (2h−2 − 1)) = ... = 20 h + 21 (h − 1) + . . . + 2h−1 1 + 2h T (2h−(h−1) − 1) = 2h−1 + 2h−2 2 + . . . + 21 (h − 1) + 20 h Possiamo giungere alla stessa conclusione dalla versione iterativa, osservando che il tempo di esecuzione è proporzionale (nel caso peggiore) alla somma delle altezze dei sottoalberi non foglie, che sono: uno di altezza h, due di altezza h − 1, . . . , 2h − 1 di altezza uno. Per calcolare questa sommatoria la scriviamo su h righe nel modo seguente: 2h−1 + 2h−2 + . . . + 2+ 1 = 2h − 1 2h−2 + . . . + 2+ 1 = 2h−1 − 1 ... = ... 2+ 1 = 22 − 1 1 = 21 − 1 Quindi si ha 2h − 1 + 2h−1 − 1 + . . . + 22 − 1 + 21 − 1 + 20 − 1 = 2h+1 − (h + 1). Ricordando che 2h+1 − 1 = n, si ha T (n) = O(n). 2.5 Delimitazione inferiore della complessità del problema dell’ordinamento per confronti Per ordinare una sequenza di n elementi bisogna almeno esaminare tutti gli elementi, quindi la complessità del problema ha delimitazione inferiore (in ogni caso) Ω(n). Si noti che, in casi particolari, l’ordinamento di una sequenza può essere effettuato senza ricorrere a confronti. Per esempio, se si ha un array di 50 elementi, ognuno con una chiave da 0 a 49, per ordinarlo basta disporre di un array ausiliario della stessa lunghezza e percorrere l’array di partenza, mettendo nell’array ausiliario ogni elemento al suo posto. Ovviamente ciò richiede un tempo Θ(n), e non viene effettuato nessun confronto. Questo algoritmo tuttavia usa informazione aggiuntiva, e non è utilizzabile in generale. Consideriamo quindi il problema dell’ordinamento assumendo che l’informazione possa essere ottenuta solo mediante confronti. Si prova che qualunque algoritmo di ordinamento per confronti deve eseguire nel caso peggiore almeno n log n passi, perché deve effettuare almeno n log n confronti. Prova semi-formale: le possibili permutazioni di n elementi sono n!, perciò un algoritmo di ordinamento deve poter produrre le n! permutazioni diverse di una sequenza. Eseguendo k confronti, possiamo ottenere 2k insiemi diversi di risultati booleani, quindi questi 2k insiemi distinti di booleani devono essere non meno delle n! possibili permutazioni. Si ha quindi: T (n) ≥ k ≥ log(n!) È facile dare una minorazione di log(n!), osservando che: 23 n! = n · (n − 1) · (n − 2) · . . . · 1 ≥ (prendendo solo metà dei fattori) n n · (n − 1) · (n − 2) · . . . · n2 ≥ ( n2 ) · ( n2 ) · ( n2 ) · . . . · ( n2 ) = ( n2 ) 2 n Quindi log(n!) ≥ log( n2 ) 2 = n 2 log( n2 ), quindi T (n) = Ω(n log n) Prova più rigorosa: un insieme di possibili sequenze di confronti può essere rappresentato come un albero binario (detto albero di decisione). In questo albero, ogni nodo interno rappresenta un confronto, per effetto del quale l’insieme delle possibili permutazioni risultato si restringe, e ogni foglia rappresenta una permutazione risultato. Fissata la lunghezza n della sequenza, a ogni algoritmo di ordinamento per confronti corrisponde un albero delle decisioni, e a ogni esecuzione dell’algoritmo (ossia, a ogni particolare input) corrisponde un particolare cammino dalla radice a una foglia. Ogni albero di decisione (corrispondente a un certo algoritmo) per una sequenza di lunghezza n avrà comunque almeno n! foglie (se l’algoritmo è inefficiente vi possono essere più foglie corrispondenti ad una stessa permutazione). Il caso peggiore per quell’algoritmo corrisponderà al cammino più lungo, la cui lunghezza è quindi l’altezza dell’albero. Tuttavia, dato che l’albero ha almeno n! foglie, avrà altezza (quindi cammino più lungo) non inferiore a log(n!). Osservazione: un algoritmo di ordinamento ottimale avrà un albero di decisione bilanciato, quindi l’altezza dell’albero sarà log(n!), quindi Θ(n log n). Un algoritmo di ordinamento non ottimale, invece, avrà un albero di decisione sbilanciato e/o con nodi corrispondenti a confronti inutili. Per esempio, il seguente è l’albero di decisione per insertion sort su una sequenza di tre elementi. a b c b?a ≥ < c?a c?b ≥ < ≥ < a b c b a c b?a ≥ a c b c?b ≥ < c a b b c a < c b a Il numero massimo di confronti è 3, e non potrebbe essere minore in quanto dlog(3!)e = 3. Infatti, per ordinare tre elementi a, b, c è certamente necessario: 1. effettuare un primo confronto, per esempio di a con b 2. effettuare un secondo confronto, di c con a oppure con b 3. in qualche caso “sfortunato”, per esempio se abbiamo confrontato c con a e risulta a > b e a > c, effettuare un terzo confronto, per esempio di b con c. Su tre elementi quindi l’albero di decisione di insertion sort risulta bilanciato. Per esercizio: dare l’albero di decisione per insertion sort su 4 elementi. È bilanciato? In caso di risposta negativa, si spieghi dove l’algoritmo non applica la migliore strategia possibile. Per esercizio: dare l’albero di decisione per selection sort su tre elementi. È bilanciato? Effettua confronti inutili? 2.6 Algoritmi di ordinamento lineari Come già osservato, se si ammettono algoritmi non per soli confronti, si possono dare ordinamenti in tempo lineare. Tali algoritmi però, a differenza di quelli per confronti, non sono applicabili in tutte le situazioni. Vediamo alcuni casi in cui è possibile dare un ordinamento lineare. • Ordinare un array di n elementi aventi chiavi intere tutte diverse in 0..n − 1 (già visto). • Ordinare un array di n elementi aventi chiavi intere tutte diverse in m..m + n − 1 (m intero), per esempio ordinare per numero di matricola 1000 studenti di matricola compresa tra 42000 e 42999, con matricole non duplicate (generalizzazione del precedente). • Ordinare una sequenza di n interi compresi nell’intervallo m..m + n − 1, senza elementi ripetuti. In questo caso non serve un array ausiliario, anzi non serve neppure l’array di input, basta for(i = 0; i < n; i++) a[i] = m + i. 24 • (Integer sort) Ordinare un array di n interi compresi nell’intervallo 0..k−1, con possibili ripetizioni (sicuramente se n > k) Idea: utilizzare un array ausiliario c di contatori con indici 0..k − 1. Si percorre l’array da ordinare a, incrementando, per ogni elemento a[i], il contatore c[a[i]] corrispondente. Poi, si percorre l’array dei contatori, inserendo in a, per ognuno dei k valori, tanti elementi uguali a k quanti contati in precedenza. integersort(k,a) //a con indici 0..n − 1, ∀i ∈ 0..n − 1.0 ≤ a[i] < k c = array con indici 0..k − 1 for (v = 0;v < k;v++) c[v] = 0 //prima fase: se a[i] = v incremento il v-esimo contatore for (i = 0; i < n; i++) c[a[i]]++ //seconda fase i = 0 //indice su a for(v = 0;v < k;v++) for(j = 0; j < c[v]; j++) a[i++] = v //tanti valori v quanti contati nella prima fase vengono messi nell’array Complessità: k passi per inizializzare i contatori (se inizializzazione non di default), n passi per contare i valori (prima fase), n passi per riempire l’array (seconda fase), in totale 2n oppure 2n + k iterazioni. Riassumendo: • nel caso di elementi con chiavi tutte presenti e non ripetute, la chiave fornisce direttamente il posto in cui mettere l’elemento, ma serve array ausiliario per non sovrascrivere gli elementi • nel caso di elementi interi “puri”, ripetuti e mancanti: bisogna contare il numero di presenze di ciascun valore, ma non serve array ausiliario, perché i valori si scrivono direttamente senza copiarli (si sa quali sono). Counting sort Consideriamo ora il caso di elementi (non interi puri) con chiavi ripetute e mancanti. Ossia il problema è: ordinare un array di n elementi aventi chiavi intere nell’intervallo 0..k − 1. Come nel caso di integer sort, per ogni possibile valore v della chiave bisogna contare gli elementi di chiave uguale a v. Tuttavia, ora elementi con chiavi uguali contengono anche altri dati, quindi serve un array ausiliario e non si può semplicemente inserire il giusto numero di copie di ogni valore. Idea: quale è la posizione in cui deve andare ciascun elemento nell’array ausiliario b? Il primo degli elementi di chiave v deve andare nel posto successivo a tutti gli elementi di chiave < v. Quindi dobbiamo calcolare, per ciascuna chiave v, il numero degli elementi di chiave < v. Si ottiene l’algoritmo counting sort. Assumiamo che il metodo key restituisca la chiave intera di un elemento. countsort(k,a) for (v = 0; v < k; v++) c[v] = 0 //prima fase for (i = 0; i < n; i++) c[a[i].key()]++ //seconda fase tot = 0 for (v = 0; v< k; v++) //Inv: ∀j ∈ 0..v − 1.c[j] = numero elementi di chiave < j //∧ tot = numero di elementi di chiave < v temp = c[v] //temp = numero elementi di chiave = v c[v] = tot tot += temp //terza fase for (i = 0; i < n; i++) b[c[a[i].key()]++] = a[i] Esempio: a = [A1 |B2 |C1 |D1 |E1 |F0 |G0 |H0 ] Prima fase: c[0] = 3, c[1] = 4, c[2] = 1 Seconda fase: c[0] = 0, c[1] = 3, c[2] = 7 25 Terza fase: c[1] = 4 b = [ | | |A| | | | ] b = [ | | |A| | | |B] c[2] = 8 b = [ | | |A|C| | |B] c[1] = 5 b = [ | | |A|C|D| |B] c[1] = 6 ... Variante: l’ultimo degli elementi di chiave v deve andare nell’ultimo posto di tutti gli elementi di chiave ≤ v. Quindi, poiché gli array sono indiciati a partire da 0, l’indice dell’ultimo elemento di chiave v è il numero di elementi di chiave ≤ v, decrementato di 1. countsort(k,a) for (v = 0; v < k; v++) c[v] = 0 //prima fase uguale for (i = 0; i < n; i++) c[a[i].key()]++ //seconda fase c[0]-for(j = 1; v < k; v++) c[j] += c[j-1] //terza fase: dalla fine, per ottenere un ordinamento stabile for(i = n-1; i >= 0; i--) b[c[a[i].key()]--] = a[i] Esempio: a = [A1 |B2 |C1 |D1 |E1 |F0 |G0 |H0 ] Prima fase: c[0] = 3, c[1] = 4, c[2] = 1 Seconda fase: c[0] = 2, c[1] = 6, c[2] = 7 Terza fase: c[0] = 1 b = [ | |H| | | | | ] b = [ |G|H| | | | | ] c[0] = 0 b = [F |G|H| | | | | ] c[0] = −1 b = [F |G|H| | | |E| ] c[1] = 5 ... Osservazioni: • alla fine si può ricopiare l’array ausiliario b nell’array di partenza a, in modo che l’algoritmo abbia la funzionalità usuale degli algoritmi di ordinamento • è un algoritmo di ordinamento stabile, perché il primo degli elementi di chiave v nell’array a viene messo al primo posto fra gli elementi di chiave v nell’array b, il secondo nel secondo, e cosı̀ via • l’algoritmo può facilmente essere adattato al caso in cui le chiavi sono in un intervallo che non inizia da zero, cioè da m a m + k − 1, con m 6= 0. Complessità temporale: • eventuale ciclo for che inizializza i contatori: k passi • prima fase, ciclo che conta gli elementi: n passi • seconda fase, ciclo che somma i contatori: k passi • terza fase, ciclo che mette in b in ordine: n passi • ciclo che ricopia b in a: n passi quindi: T (n, k) ∼ = 3n + 2k oppure 3n + k = Θ(n + k) Se k è dello stesso ordine di grandezza di n, si ha: T (n) = Θ(n) 26 (complessità lineare) Complessità spaziale: l’algoritmo utilizza due array ausiliari, b di lunghezza n e c di lunghezza k. Quindi anche lo spazio è lineare: S (n, k) = Θ(n + k) Nota: se l’insieme delle possibili chiavi è molto grande l’algoritmo non è usabile: troppo dispendioso in spazio e in tempo. Per esempio, non si può usare il counting sort per ordinare una sequenza di stringhe. Assumendo una lunghezza massima di 20 caratteri, il numero k di chiavi sarebbe 2620 . Bucket sort Risolve stesso problema del counting sort, ossia ordinare un array di n elementi con chiavi intere fra 0 e k − 1 (o fra m ed m + k − 1). Opera come integer sort e counting sort, mantenendo però un array di liste (bucket) anziché di contatori. La v-esima lista conterrà gli elementi con chiave uguale a v. Alla fine si concatenano le liste in una lista risultato, o si ricopiano in un array. Complessità: Θ(n + k). Conveniente se la sequenza è già rappresentata da una lista, nel caso di array, invece, sono in genere migliori counting sort o integer sort. Per avere un algoritmo stabile, in ogni bucket gli elementi devono essere nell’ordine in cui sono nell’array originale, quindi vanno inseriti in fondo. Inoltre, se si usa il bucket sort per ordinare un array, quando si estraggono gli elementi per metterli nell’array risultato, essi devono essere estratti dall’inizio. Quindi i bucket sono code. Radix sort Ordinamento in base a una tupla di chiavi: per esempio, si supponga di voler ordinare in base a cognome, nome e anno di nascita (nell’ordine) una sequenza di persone, si può operare in vari modi: • definire una funzione di confronto che confronta prima i cognomi, se questi sono uguali i nomi, e cosı̀ via • ordinare prima per cognome mettendo gli elementi con cognomi uguali in liste, poi ordinare per nome ogni lista di cognomi uguali, e cosı̀ via • ordinare prima la sequenza per anno di nascita, poi riordinarla per nome con un algoritmo stabile, infine riordinarla per cognome con un algoritmo stabile; poiché un algoritmo stabile preserva l’ordine precedente, alla fine gli elementi risulteranno ordinati nel modo voluto. Radix sort ordina una sequenza di elementi con chiavi d-tuple di valori nell’intervallo 0..k − 1 per mezzo del terzo metodo illustrato, usando il counting sort o il bucket sort come algoritmo di ordinamento stabile. Il numero totale di iterazioni è quindi circa d(3n + k), con d e k costanti. Si ha quindi ancora complessità temporale lineare: T (n, k) = Θ(n + k) Se d non è molto piccolo (poche unità), la costante moltiplicativa influenza pesantemente l’efficienza. Esempio: vogliamo ordinare array di elementi di tipo intero a 32 bit (come int di Java), possiamo: • scomporre il campo di tipo int in due campi di 16 bit • ordinare due volte l’array con il counting sort: • prima rispetto ai 16 bit meno significativi • poi rispetto ai 16 bit più significativi oppure: • scomporre il campo di tipo int in 4 campi di 8 bit (byte) • ordinare quattro volte l’array con il counting sort: • prima rispetto al byte più basso • poi rispetto al secondo byte dal basso • e cosı̀ via quale delle due scomposizioni è più efficiente? Si ha • 2 counting sort su 16 bit: T (n) ∼ = 2(3n + 216 ) 27 • 4 counting sort su 8 bit: T (n) ∼ = 4(3n + 28 ) Il tempo del primo metodo cresce più lentamente, ma ha costante iniziale molto più grande. Al di sotto di una soglia il secondo metodo sarà quindi più efficiente del primo, approssimativamente il valore di n tale che: 2(3n + 216 ) = 4(3n + 28 ) ; n ∼ = 20000 Il secondo metodo, inoltre, occupa meno spazio del primo (i contatori sono 28 invece di 216 ). In generale, radix sort su interi di b bit si effettua mediante rb esecuzioni del counting sort su r bit (con r sottomultiplo di b), quindi: T (n) = Θ( rb (n + 2r )) Come scegliere r, cioè la dimensione dei “pezzi”? In rb vogliamo r il più grande possibile , ma in n + 2r non vogliamo 2r molto maggiore di n, cioè r molto maggiore di log n, quindi: r il più grande possibile ma che non superi (troppo) log n. Si noti che 2r cresce esponenzialmente al crescere di r, mentre r troppo grande. Riprendiamo l’esempio di un array di int (32 bit): b r si riduce proporzionalmente, quindi non conviene prendere un • array di dimensione n ∼ = 1000: log 1000 ∼ = 10 ; r = 8 è una scelta ottimale (4 counting sort) • array di dimensione n ∼ = 100000: log 100000 ∼ = 17 ; r = 16 è ottimale (2 counting sort) I risultati sono in accordo con quelli della valutazione più precisa precedente. Nota: rispetto ai migliori algoritmi di ordinamento per confronti, il radix sort su interi è più efficiente per array di lunghezza molto grande, altrimenti una buona implementazione di quicksort è di solito più veloce. 3 Strutture dati 3.1 Heap Una coda a priorità è una collezione di elementi ognuno dei quali possiede una certa priorità, rappresentata per esempio da un numero naturale (in genere per consuetudine 1 indica la priorità più alta possibile), e si estrae ogni volta l’elemento (o uno degli elementi) con priorità più alta. Le operazioni fondamentali sono quindi: • inserimento di un elemento con una data priorità • estrazione (o solo lettura) dell’elemento con il valore minimo di priorità • test se la coda è vuota o no Si può considerare la coda costituita di coppie (elemento, priorità) oppure considerare la priorità come una proprietà degli elementi. Si possono inoltre definire altre operazioni, come: variazione della priorità di un elemento, cancellazione di un elemento, fusione di due code a priorità, etc. Nelle realizzazioni “ingenue” si ha: • liste ordinate: estrazione del minimo costa Θ(1), ma inserimento costa O(n) • liste non ordinate: inserimento costa Θ(1), ma estrazione del minimo costa O(n). Idea migliore: usare uno heap (a minimo), ossia un albero binario quasi completo, cioè completo fino al penultimo livello, in cui la chiave di ciascun nodo è minore o uguale delle chiavi dei figli. Il fatto che l’albero sia quasi completo garantisce che sia bilanciato, vedi sezione 3.2, quindi che la lunghezza di un cammino sia O(log n) con n numero dei nodi. Per fare in modo che l’albero rimanga sempre quasi completo, si può procedere nel modo seguente: • nell’inserimento, aggiungere il nuovo elemento come foglia all’ultimo livello e poi farlo risalire al posto giusto mediante scambi successivi col genitore 28 • nell’estrazione del minimo, spostare al posto della radice una foglia dell’ultimo livello, e poi farla scendere al posto giusto mediante scambi successivi col minore dei figli. I due algoritmi ausiliari di base sono quindi: • moveUp: data una foglia, o più in generale un nodo che sia al posto giusto rispetto ai figli ma non necessariamente rispetto al genitore, lo fa risalire fino al posto giusto, scambiandolo via via con il genitore • moveDown: data la radice, o più in generale un nodo che sia al posto giusto rispetto al genitore ma non necessariamente rispetto ai figli, lo fa scendere fino al posto giusto, scambiandolo via via con il minore dei figli. Quindi: inserimento: crea una foglia + moveUp, estrazione del minimo: sposta da foglia a radice + moveDown. Per poter realizzare concretamente con complessità logaritmica i due algoritmi astratti precedenti, bisogna: • trovare in tempo costante (o al più logaritmico) quale foglia tagliare oppure creare • essere in grado, nell’algoritmo di inserimento, di risalire dal figlio al padre in tempo costante. Una soluzione semplice consiste nell’assumere che lo heap sia quasi completo da sinistra, e rappresentarlo con un array in cui i nodi sono memorizzati consecutivamente per livelli. Se i nodi vengono memorizzati nell’array a partire dall’indice 1 (lasciando inutilizzato l’elemento di indice 0), i figli del nodo memorizzato nell’elemento di indice i risultano memorizzati rispettivamente negli elementi di indice 2i e 2i + 1, e il genitore in quello di indice i/2. Come in altre situazioni dello stesso genere, in cui un elemento deve venire scambiato più volte successivamente (per esempio insertion sort), conviene memorizzare tale elemento in una variabile temporanea e inserirlo solo alla fine nella posizione giusta. È quindi come se ogni volta si scambiasse un elemento con un “posto vuoto”, il quale alla fine raggiunge la posizione corretta in cui inserire il valore. Supponiamo che lo heap Q sia rappresentato con un oggetto3 con due attributi a array degli elementi e sup indice dell’ultimo elemento utilizzato (0 quando lo heap è vuoto), e scriviamo gli algoritmi come metodi. moveUp e inserimento Se i è l’indice di un elemento eventualmente fuori posto rispetto al genitore, cioè minore del genitore, l’algoritmo moveUp lo fa risalire al posto giusto. Q.moveUp(i) //1 ≤ i ≤ sup elem = a[i] //elemento da far salire while(i > 1 && elem < a[i/2]) //i posto vuoto a[i] = a[i/2] //scende il genitore i = i/2 //sale il posto in cui mettere l’elemento a[i] = elem Q.insert(elem) if (sup == a.length-1) errore o riallocazione a[++sup] = elem //mette l’elemento in una nuova foglia Q.moveUp(sup) //lo fa risalire al posto giusto moveDown e rimozione del minimo Se i è l’indice di un elemento eventualmente fuori posto rispetto ai figli, cioè maggiore di almeno un figlio, lo fa scendere al posto giusto, scambiandolo ogni volta con il minore dei figli. È anche chiamato fixHeap. Q.moveDown(i) //1 ≤ i ≤ sup elem = a[i] //elemento da far scendere j = 2* i while (j <= sup && elem > a[j]) //i posto vuoto, j figlio sinistro if (j+1 <= sup && a[j+1] < a[j]) j++ //j figlio destro a[i] = a[j] //sale il figlio i = j //scende il posto in cui mettere l’elemento a[i] = elem Q.getMin() if (sup == 0) errore 3 Utilizzeremo per le strutture dati il paradigma di programmazione a oggetti. 29 min = a[1] if (sup == 1) sup = 0 //lo heap diventa vuoto else a[1] = a[sup--] Q.moveDown(1) return min Ulteriore operazione derivata che cambia la priorità di un elemento: Q.changePriority(i,delta) { if (i > sup || i < 1) errore a[i] += delta if (delta > 0) Q.moveDown(i) else if (delta < 0) Q.moveUp(i) Analsi della complessità: gli algoritmi moveUp e moveDown sono entrambi nel caso peggiore logaritmici nel numero degli elementi, poiché percorrono al massimo un cammino (fra radice e foglia) in un albero binario quasi completo impiegando un tempo costante per ogni passo. Tali sono quindi anche tutte le operazioni considerate, in particolare l’estrazione del minimo e l’inserimento. Per esse si ha dunque: Tworst (n) = Θ(log n). 3.2 Alberi Assumiamo nel seguito un universo N di elementi detti nodi. Def. 3.1 [Albero] Definiamo in modo mutuamente induttivo le nozioni di albero (radicato) e di foresta: • una foresta è un insieme di alberi, • un albero è una coppia (x , F ) dove x è un nodo detto radice dell’albero, ed F è una foresta i cui elementi sono detti (sottoalberi) figli del nodo x . Sostituendo nella definizione precedente “insieme” con “sequenza” si ottiene la definizione di albero (radicato) ordinato e foresta di alberi ordinati. Si noti che, in base alle definizioni precedenti, una foresta può essere vuota ma un albero contiene sempre almeno un nodo. Con il termine figlio si intende anche il nodo radice di un sottoalbero figlio. Un ramo o arco di un albero è una coppia di nodi genitore-figlio o figlio-genitore. Il numero di figli di un nodo è detto grado del nodo. Un cammino (path) in un albero è una sequenza di nodi dell’albero in cui ogni nodo è figlio del precedente. A volte con la parola cammino si intende un cammino dalla radice a un nodo. La lunghezza di un cammino è il numero di rami costituente il cammino, cioè il numero dei nodi meno uno. La profondità di un nodo è la lunghezza del cammino dalla radice a tale nodo, quindi la radice ha profondità zero. Un livello di un albero è l’insieme dei nodi aventi una certa profondità. Un nodo è una foglia se ha grado zero, altrimenti è un nodo interno. L’altezza di un albero è la lunghezza del più lungo cammino dalla radice a un nodo (foglia). Def. 3.2 [Albero binario] Gli alberi binari sono definiti induttivamente nel modo seguente: • l’albero vuoto è un albero binario, • la tripla (L x R), dove x è un nodo e L, R sono alberi binari, è un albero binario. Attenzione: un albero binario non è un albero. La nozione di albero binario può essere generalizzata a quella di albero k-ario. Un albero binario di altezza h è pieno se tutti i nodi tranne le foglie hanno figlio sinistro e destro non vuoti. Un albero binario può essere rappresentato dai nodi interni di un albero binario pieno sostituendo ogni nodo “mancante” con una foglia. Un albero ordinato in cui ogni nodo ha grado 0 oppure 2 è equivalente a un albero binario pieno. Un albero binario è completo se tutte le foglie hanno la stessa profondità e tutti i nodi interni hanno grado 2, quindi, se l’albero ha profondità h, i nodi sono 2h+1 − 1, le foglie sono 2h , come si vede facilmente per induzione aritmetica: profondità 0: 1 nodo, 1 foglia profondità h + 1: un nodo interno + due sottoalberi completi di profondità h, quindi, per ipotesi induttiva, 1 + 2 · (2h+1 − 1) = 1 + 2h+2 − 2 = 2h+2 − 1 nodi e 0 + 2 · 2h = 2h+1 foglie. Quindi, si ha che h = log n se n è il numero delle foglie. Un albero binario è quasi completo se è completo fino al penultimo livello. Si ha 2h − 1 ≤ n ≤ 2h+1 − 1, quindi l’altezza è ancora logaritmica nel numero dei nodi. Gli alberi binari completi e quasi-completi godono quindi entrambi dell’importante proprietà che l’altezza sia logaritmica nel numero dei nodi. Tale proprietà è detta bilanciamento, ossia: 30 Def. 3.3 [Alberi bilanciati] Una classe di alberi binari è di alberi binari bilanciati se l’altezza, al crescere del numero n dei nodi, cresce come O(log n)4 . Vedremo più avanti altre classi di alberi bilanciati, in particolare gli alberi bilanciati in altezza. Gli alberi binari sono un insieme definito induttivamente, quindi possiamo usare il principio di induzione per provarne delle proprietà, vedi sezione 1.2). La programmazione ricorsiva risulta quindi naturale, in particolare per problemi che richiedono una visita in profondità. Le soluzioni iterative utilizzano uno stack esplicito. Visita in profondità (depth-first) di un albero: un percorso che, per ogni nodo, visita ognuno dei sottoalberi figli del nodo in modo completo separatamente dagli altri; la visita della radice può essere effettuata prima e/o dopo le visite dei figli, e/o fra di esse (quindi anche più volte). Le visite preorder, inorder e postorder sono esempi di visite in profondità di un albero binario. preorder radice, figlio sinistro, figlio destro inorder figlio sinistro, radice, figlio destro (tipica dei BST, vedi sezione 3.3) postorder figlio sinistro, figlio destro, radice Una visita in profondità si realizza in modo naturale con l’ovvio algoritmo ricorsivo corrispondente alla definizione induttiva. Per ottenere un algoritmo iterativo occorre idearlo in modo diverso, a partire da un’opportuna invariante. Visita preorder iterativa: Invariante T1 , . . . , Tn sequenza di alberi binari da visitare in preorder. Passo Si hanno due casi: • T1 è vuoto: lo si elimina • T1 non è vuoto: allora è della forma (L x R), si visita la radice x e la sequenza diventa L, R, T2 , . . . , Tn . Condizione di controllo sequenza non vuota Inizializzazione sequenza formata da un solo albero. Quindi la sequenza è uno stack. Algoritmo completo, assumendo che un albero non vuoto sia rappresentato con un oggetto con tre attributi root nodo radice, left e right figli sinistro e destro: T.preorder() S = stack vuoto S.push(T) do T1 = S.pop() if (T1 non vuoto) visita(T1 .root) S.push(T1 .right) S.push(T1 .left) while (S non vuoto) Ottimizzazione: non metto alberi vuoti nello stack. T.preorder() S = stack vuoto if (T non vuoto) S.push(T) do T1 = S.pop() //sicuramente non vuoto visita(T1 .root) if (T1 .right non vuoto) S.push(T1 .right) if (T1 .left non vuoto) S.push(T1 .left) while(S non vuoto) Visita inorder iterativa, idea: fra la visita del figlio sinistro e quella del destro occorre visitare la radice, quindi al passo generico si avrà una sequenza mista di nodi e di alberi. Con l’ottimizzazione vista sopra: 4 Quindi come θ(log n), dato che non può crescere meno di log n. 31 Invariante P1 , . . . , Pn sequenza di alberi binari non vuoti da visitare in inorder oppure nodi da visitare. Passo Si hanno due casi: • P1 è un nodo singolo, lo si visita ed elimina • P1 è un albero non vuoto: allora è della forma (L x R), la sequenza diventa L (se non vuoto), x , R (se non vuoto), P2 , . . . , P n . Condizione di controllo Sequenza non vuota Inizializzazione Sequenza formata da un solo albero. Visita breadth-first o per livelli: non è definibile in modo induttivo, ma si ha un algoritmo inerentemente iterativo. Senza ottimizzazione: Invariante T1 , . . . , Tn sequenza di alberi binari da visitare per livelli. Passo Si hanno due casi: • T1 è un albero vuoto, lo si elimina • T1 è un albero non vuoto: allora è della forma (L x R), si visita x e la sequenza diventa T2 , . . . , Tn , L, R. La sequenza è quindi una coda. Condizione di controllo Sequenza non vuota Inizializzazione Sequenza formata da un solo albero. Algoritmo: T.breadthfirst() Q = coda vuota Q.add(root) while (Q non vuota) T1 = Q.remove() if (T1 non vuoto) visita(T1 .root) Q.add(T1 .left); Q.add(T1 .right) 3.3 Alberi di ricerca Gli alberi di ricerca possono essere utilizzati per implementare il tipo di dato “dizionario”, ossia una collezione di elementi sulla quale si possono operare ricerca, inserimento e cancellazione. Utilizzando un albero di ricerca si combinano i vantaggi dell’implementazione come lista e quella come array ordinato: inserimento e cancellazione si possano fare per semplice modifica di puntatori, ed è possibile la ricerca binaria. Se l’albero è bilanciato, le tre operazioni principali sono tutte logaritmiche. Sequenze di inserimenti e cancellazioni su un albero possono sbilanciarlo riducendo quindi l’efficienza delle operazioni successive, fino a farla diventare lineare. Studieremo più avanti alcune strutture di albero di ricerca più complesse che garantiscono il bilanciamento. Assumiamo che gli alberi binari siano etichettati con elementi di un insieme totalmente ordinato, ossia che a ogni nodo sia associato un elemento di tale insieme detto chiave del nodo. Def. 3.4 [Albero di ricerca binario] Un albero di ricerca binario (BST per binary search tree) è un albero binario in cui, per ogni nodo x : • tutte le chiavi (dei nodi) del sottoalbero sinistro sono minori della chiave di x • tutte le chiavi (dei nodi) del sottoalbero destro sono maggiori della chiave di x . Variante: definizione con minore o uguale, quindi elementi duplicati. Definizione induttiva equivalente alla precedente: • l’albero vuoto è un albero di ricerca binario 32 • se L e R sono alberi di ricerca binari, e x è un nodo tale che: – tutte le chiavi (dei nodi) di L sono minori della chiave di x – tutte le chiavi (dei nodi) di R sono maggiori della chiave di x allora l’albero (L x R) è un albero di ricerca binario. Prop. 3.5 In un albero di ricerca binario la sequenza delle chiavi nella visita inorder è una sequenza ordinata. Dimostrazione Per induzione sulla definizione. Base Per l’albero vuoto la tesi vale ovviamente. Passo induttivo Sia (L x R) un albero di ricerca binario e sia k la chiave di x . Allora per ipotesi induttiva la sequenza delle chiavi di L nella visita inorder è una sequenza ordinata l, e i suoi elementi sono tutti minori di k . Analogamente per ipotesi induttiva la sequenza delle chiavi di R è una sequenza ordinata r, e i suoi elementi sono tutti maggiori di k . La sequenza delle chiavi dell’albero nella visita inorder è l k r che risulta quindi ordinata. Per descrivere le operazioni assumiamo che un albero sia rappresentato con un oggetto con un attributo root che restituisce il nodo radice o null se l’albero è vuoto, e che un nodo sia rappresentato con un oggetto con attributi left, right, parent e key per il figlio sinistro, il figlio destro, il genitore e la chiave. Ricerca La ricerca di un elemento restituisce il nodo che lo contiene oppure null se non trovato. T.search(k) return search(root,k) search(x,k) //x nodo o null if (x = null ∨ k = x.key) return x if (k < x.key) return search(x.left,k) return search(x.right,k) La correttezza è ovvia per la proprietà dei BST, il tempo di esecuzione è O(h) con h altezza albero. Versione iterativa: T.search(k) x = root while (x 6= null ∧ k 6= x.key) if (k < x.key) x = x.left else x = x.right return x Minimo e massimo T.min() if (root = null) return null min(root) min(x) //x nodo, ricorsiva if (x.left = null) return x return min(x.left) min(x) //x nodo, iterativa while (x.left 6= null) x = x.left return x Si noti che non serve confrontare chiavi. Analogamente per il massimo. La correttezza è ovvia per la proprietà dei BST, il tempo di esecuzione è O(h) con h altezza albero. 33 Successore e predecessore succ(x) //x nodo if (x.right 6= null) return min(x.right) return first_right_anc(x) first_right_anc(x) //x nodo, ricorsiva if (x.parent = null) return null if (x.parent.right = x) return first_right_anc(x.parent) return x.parent //x.parent.left = x first_right_anc(x) //x nodo, iterativa while (x.parent 6= null ∧ x = x.parent.right) x = x.parent return x.parent Anche qui non serve confrontare chiavi. Correttezza: diciamo che ar è un antenato destro di x se x è un nodo del sottoalbero sinistro di ar , sinistro analogamente. Gli antenati sinistri di x e i loro discendenti sinistri hanno chiavi minori di qualla di x . Sia ar il primo antenato destro di x (che può non esistere). Gli eventuali antenati destri e discendenti destri di ar hanno chiavi maggiori di ar . I discendenti destri di x e il nodo ar hanno chiavi maggiori della chiave di x . Se il sottoalbero destro di x non è vuoto, ossia x ha discendenti destri, il successore è il minimo di questi, infatti essi si trovano in un sottoalbero sinistro di ar . Altrimenti, se esiste ar , questo è il successore. Se ar non esiste, x non ha un successore. Tempo di esecuzione è O(h) con h altezza albero. Proprietà: se un nodo ha figlio destro, allora il suo successore non ha figlio sinistro (e simmetricamente se ha figlio sinistro il suo predecessore non ha figlio destro). Prova: se x ha figlio destro r il successore di x è il nodo y con chiave minima del sottoalbero radicato in r. Se y avesse un figlio sinistro, questo avrebbe una chiave minore. Inserimento Sia z il nodo da inserire. Si cerca il nodo che diventerà il genitore del nuovo nodo e poi lo si inserisce come foglia. Quindi anche il costo dell’inserimento è O(h). T.insert(z) y = null //padre del nodo corrente x = root while (x 6= null) y = x if (z.key < x.key) x = x.left else if (z.key = x.key) return //se non vogliamo duplicati else x = x.right z.parent = y if (y = null) T.root = z //T era vuoto else if (z.key < y.key) y.left = z //inserisco z come figlio sinistro o destro di y else y.right = z Cancellazione Sia z il nodo da cancellare, si hanno i seguenti casi: 1. se z non ha figlio sinistro lo si sostituisce con il suo figlio destro, che può anche essere null. 2. se z ha figlio sinistro ma non destro, lo si sostituisce con il figlio sinistro 3. se z ha due figli, il suo successore y si trova sicuramente nel sottoalbero destro, e per la proprietà precedente non ha figlio sinistro, allora: • se il successore y di x è proprio il suo figlio destro, si sostituisce x con y, e poi si rende z.left figlio sinistro di y • altrimenti, preliminarmente si sostituisce y con il suo figlio destro, e si rende z.right figlio destro di y, poi si opera come nel caso precedente. 34 T.delete(z) if (z.left = null) //solo figlio destro subst(z,z.right) else if (z.right = null) //solo figlio sinistro subst(z,z.left) else y = min(z.right) if (y.parent 6= z) //successore non figlio destro subst(y,y.right) y.right = z.right //rende z.right figlio destro di y y.right.parent = y subst(z,y) y.left = z.left //rende z.left figlio sinistro di y y.left.parent = y Il metodo T.subst(x,y) sostituisce dentro T il sottoalbero di radice x con il sottoalbero di radice y. T.subst(x,y) //x nodo, y nodo o null if (root = x) root = y// x era la radice else if (x = x.parent.left) x.parent.left = y else x.parent.right = y if (y 6= null) y.parent = x.parent Complessità: ogni operazione, incluse le chiamate di subst, richiede tempo costante, tranne la chiamata a min, quindi O(h). In generale, tutte le operazioni possono essere eseguite in O(h). 3.4 Alberi AVL Def. 3.6 [Albero bilanciato in altezza] Un albero (binario) è k-bilanciato in altezza se per ogni nodo le altezze dei figli differiscono al più di k. Equivalentemente in modo induttivo: Base l’albero binario vuoto è k-bilanciato in altezza Passo induttivo se L, R sono k-bilanciati in altezza, e le altezze di L e R differiscono al più di k, allora (L x R) è k-bilanciato in altezza. La denominazione è appropriata, poiché dimostreremo che gli alberi bilanciati in altezza sono bilanciati nel senso della definizione 3.3. Considereremo nel seguito in particolare alberi 1-bilanciati in altezza, che chiameremo semplicemente bilanciati in altezza. Def. 3.7 [Fattore di bilanciamento] Il fattore di bilanciamento β(x ) di un nodo x in un albero binario è la differenza tra l’altezza del sottoalbero sinistro e quella del sottoalbero destro. Quindi, in un albero binario completo il fattore di bilanciamento è 0 per ogni nodo, mentre in un albero bilanciato in altezza è 0, 1 o −1. Per provare che l’altezza di un albero bilanciato in altezza cresce come O(log n), consideriamo tra tutti gli alberi bilanciati in altezza quelli “più sbilanciati”, ossia in cui ogni nodo ha fattore di bilanciamento 1 o −1, ossia con il minor numero di nodi. Questi alberi si chiamano alberi di Fibonacci. È facile vedere che, per ogni altezza h, essi possono essere costruiti nel modo seguente (per semplicità descriviamo solo gli alberi in cui il fattore di bilanciamento è sempre 1): h = 0 F0 = albero formato da un solo nodo, ossia della forma (• x •) h = 1 F1 = albero formato da radice e figlio sinistro, ossia della forma (y x •) h > 1 Fh = albero della forma (Fh−1 x Fh−2 ) Quindi il numero di nodi nh di un albero di Fibonacci di altezza h soddisfa la seguente relazione di ricorrenza: n0 = 1 n1 = 2 nh = nh−1 + nh−2 + 1 per h > 1 35 Proviamo per induzione aritmetica che si ha, per ogni i ≥ 0: n2i ≥ 2i , quindi anche n2i+1 ≥ 2i . Base per i = 0 si ha n0 = 1 ≥ 20 = 1 Passo induttivo n2(i+1) = n2i+2−1 + n2i+2−2 + 1 = n2i+1 + n2i + 1 ≥ (per ipotesi induttiva) 2i + 2i + 1 ≥ 2i+1 quindi nh ≥ 2bh/2c , quindi log nh ≥ bh/2c, quindi h = O(log n). Dato che il numero di nodi minimo di un albero bilanciato in altezza cresce esponenzialmente rispetto all’altezza, anche il numero di nodi di un albero bilanciato qualsiasi cresce esponenzialmente rispetto all’altezza. Gli alberi bilanciati in altezza sono quindi bilanciati (il risultato si può generalizzare agli alberi k-bilanciati in altezza). Gli alberi AVL (inventati nel 1962 dai due matematici sovietici Adelson-Velskii e Landis) sono alberi di ricerca binari che vengono mantenuti (1-)bilanciati in altezza. Idea: le operazioni di inserimento ed eliminazione di un elemento effettuano ogni volta, se necessario, un ribilanciamento, per mezzo di opportune trasformazioni dell’albero, dette rotazioni, che lo rendono bilanciato in altezza mantenendone la proprietà di essere un albero di ricerca. Esempio in notazione testuale: (5 7 •) con l’inserimento di 3 diventa ((3 5 •) 7 •), con una rotazione diventa (3 5 (• 7 •)). Un altro esempio: (5 7 •) con l’inserimento di 6 diventa ((• 5 (• 6 •)) 7 •), con una rotazione diventa ((• 5 •) 6 (• 7 •)). Vediamo ora nei dettagli come avviene il ribilanciamento. Se T è un albero binario di ricerca bilanciato in altezza, l’inserimento o eliminazione di un nodo può causare in qualche nodo di T uno sbilanciamento di al più ±2. Consideriamo allora un albero binario di ricerca T in cui il bilanciamento della radice sia ±2 e per tutti gli altri nodi sia 0, 1 o −1, ossia T è un albero 2-bilanciato in altezza i cui sottoalberi propri sono tutti 1-bilanciati. Per semplicità assumiamo che lo sbilanciamento sia a sinistra (il caso di sbilanciamento a destra è ovviamente simmetrico), quindi la situazione sia come in figura. x +2 h R h+2 L Assumiamo convenzionalmente altezza −1 per l’albero vuoto. Il sottoalbero destro può anche essere vuoto, ma il sottoalbero sinistro ha altezza almeno uno, quindi ha figlio sinistro LL e figlio destro LR (uno dei due può essere vuoto). Distinguiamo due casi: sbilanciamento causato da LL e sbilanciamento causato da LR. Nel primo caso si hanno due sottocasi. Caso LL1 +2 +1 y y x LL h h+3 h x 0 h+2 => R LR LL 0 LL h+1 h h LR R h+1 Si noti che LR e R possono anche essere vuoti, mentre LL è sicuramente non vuoto. La rappresentazione lineare infissa dell’albero è: ((LL y LR) x R) Quindi, parentesizzando diversamente si può far salire LL e scendere R: (LL y (LR x R)) 36 Caso LL2 +2 +1 y y x LL h h+3 -1 x 1 h+3 => R LL h+1 h h h+1 LL R LR LR h+1 h+1 Anche in questo caso la rotazione LL risolve il problema: LL ((LL y LR) x R) ⇒ (LL y (LR x R)) Si osservi che questo caso si può verificare solo in seguito a eliminazione di un nodo che faccia diminuire l’altezza di R da h + 1 ad h, ma non in seguito a un inserimento. Infatti l’inserimento di un nodo non può far aumentare l’altezza di entrambi i sottoalberi LL e LR, e se uno dei due avesse altezza h + 2 lo sbilanciamento si sarebbe già verificato. Caso LR In questo caso la rotazione precedente non è una soluzione: +2 -1 y y x h h+3 x 1 h+3 => h R h -2 LL h R LL LR h+1 LR h+1 Occorre considerare i due sottoalberi di LR, siano T1 e T2 . Caso LR1 I due sottoalberi hanno altezze diverse, per esempio: -1 y 2 z x 0 y z 1 h h+3 T1 T2 h T2 LL x -1 h+2 => R h 0 T1 LL h h R h Infatti nella rappresentazione lineare infissa si ha: ((LL y (T1 z T2 )) x R) e parentesizzando diversamente si ottiene: ((LL y T1 ) z (T2 x R)) Il caso in cui le altezze di T1 e T2 sono invertite si tratta con la stessa rotazione. Caso LR2 I due sottoalberi hanno la stessa altezza. z x -1 y 2 0 y z 0 h h+3 LR => h LL LL T1 h T2 x 0 h+2 R h 0 h 37 T1 h T2 h h R Anche in questo caso la rotazione LR risolve il problema: LR ((LL y (T1 z T2 )) x R) ⇒ ((LL y T1 ) z (T2 x R)) Si noti che T1 e T2 possono essere entrambi vuoti, nel qual caso LL e R sono anch’essi vuoti. Si noti inoltre che, a differenza del sottocaso 2 del caso LL, l’altezza dell’albero diminuisce di uno, come nei casi LL1 e LR1. Questo caso si può verificare sia in seguito all’eliminazione di un nodo che faccia diminuire l’altezza di R da h ad h − 1, che in seguito all’inserimento di z con T1 e T2 entrambi vuoti. Lo sbilanciamento a destra è ovviamente speculare di quello a sinistra, quindi si tratta con due rotazioni RR e RL che sono speculari rispetto a LL e LR. Per esempio la rotazione RR sarà: RR (L x (RL y RR)) ⇒ ((L x RL) y RR) Le rotazioni LL e RR sono una l’inversa dell’altra. LL ((T1 y T2 ) x T3 ) ⇒ RR ⇐ (T1 y (T2 x T3 )) Le rotazioni LL e RR sono dette semplici, mentre le rotazioni LR e RL sono dette doppie perchè possono essere ottenute con una sequenza di due rotazioni semplici, per esempio: RR LL ( (LL y (T1 z T2 )) x R) ⇒ ( ((LL y T1 ) z T2 ) x R) ⇒ ((LL y T1 ) z (T2 x R)) Comunque, per numero di rotazioni eseguite da un’operazione sugli alberi AVL si intende il numero di ribilanciamenti eseguiti, quindi una rotazione doppia conta per uno. La radice del sottoalbero a cui si applica una rotazione viene detta perno della rotazione. Inserimento e cancellazione Per inserire o cancellare un elemento in un albero AVL: • si inserisce o elimina un nodo come in un BST ordinario • se in seguito a ciò qualche sottoalbero si è sbilanciato, lo si ribilancia tramite l’opportuna rotazione • in particolare, l’albero da ribilanciare è il più piccolo albero che si è sbilanciato. Per poter effettivamente eseguire inserimento e cancellazione in tempo logaritmico, occorre che, per ogni nodo x , il fattore di bilanciamento possa essere (ri-)calcolato in tempo costante, ossia senza dover visitare tutto il sottoalbero di radice x . A tale scopo, si mantiene per ogni nodo l’informazione sull’altezza del sottoalbero di cui è radice, e la si aggiorna a ogni modifica. Ovviamente l’inserimento o cancellazione di un nodo può causare una variazione di altezza solo nei nodi posti sul cammino dalla radice al nodo inserito o cancellato. Nell’inserimento, l’incremento di altezza di un sottoalbero T può portare lo sbilanciamento del genitore x di T da 1 a 2 o da −1 a −2, e incrementare di uno l’altezza dell’albero di radice x . Lo sbilanciamento provocato da inserimento non può essere del tipo LL2 o RR2 (vedi sopra), e in tutti gli altri casi la rotazione di ribilanciamento fa diminuire di uno l’altezza dell’albero di radice x , che quindi ritorna all’altezza che aveva prima dell’inserimento. Di conseguenza, dopo aver effettuato la rotazione di ribilanciamento sicuramente non è necessario eseguire altre rotazioni. Teorema 3.8 Un inserimento in un albero AVL richiede al massimo una rotazione (semplice o doppia). Nella cancellazione, la diminuzione di altezza di un sottoalbero può portare lo sbilanciamento del genitore x da da 1 a 2 o da −1 a −2, lasciando invariata l’altezza del sottoalbero di radice x , perché si “accorcia” il figlio che era già meno alto. In tal caso, la rotazione di ribilanciamento può fare diminuire di uno l’altezza dell’albero radice (nei casi LL1, RR1, LR e RL). Questo può provocare uno sbilanciamento in un nodo antenato, quindi un’ulteriore rotazione che può diminuire l’altezza di un sottoalbero, e cosı̀ via. Si possono cioè avere sbilanciamenti a cascata, al più uno per ogni nodo del cammino dalla radice al nodo eliminato. Nel caso peggiore si hanno quindi Θ(log n) rotazioni: la complessità dell’operazione è ancora O(log n). 3.5 2-3-alberi e B-alberi Una tecnica alternativa alle rotazioni consiste nell’utilizzare alberi con grado dei nodi variabile e mantenere il bilanciamento attraverso scissioni e fusioni. Un albero due-tre è un albero di ricerca con nodi a due figli e nodi a tre figli, in cui tutte le foglie sono allo stesso livello, e quindi tutti i cammini dalla radice a una foglia hanno la stessa lunghezza. 38 Inserimento L’inserimento di un nuovo nodo deve preservare l’ordine caratteristico degli alberi di ricerca e il fatto che tutte le foglie siano allo stesso livello. Ciò si ottiene nel modo seguente: come in un albero di ricerca ordinario, si scende nell’albero (a sinistra, destra o al centro a seconda del valore della chiave da inserire) fino a una foglia, e si inserisce il nuovo elemento in ordine nella foglia. Se cosı̀ si ottiene una foglia con tre elementi, si opera una scissione in due della foglia, facendo salire il suo elemento mediano nel genitore. Se cosı̀ il genitore diventa un nodo con tre elementi, lo si scinde a sua volta in due, facendo salire il suo elemento mediano, e cosı̀ via. Se in tal modo si risale fino alla radice, ed essa diventa un nodo con tre elementi, la radice viene scissa in due come un nodo ordinario, e l’elemento mediano sale diventando la nuova radice. L’altezza dell’albero aumenta quindi di uno. Cancellazione Se l’elemento da cancellare è in una foglia con due elementi, semplicemente lo si elimina. Se l’elemento da cancellare è in una foglia con un solo elemento si considerano due casi. • Se c’è almeno un fratello adiacente con due elementi, si sposta nel genitore un elemento di tale fratello, e si fa scendere un elemento del genitore nel nodo deficitario. • Se non esiste un fratello adiacente che abbia due elementi, si opera una fusione con uno dei fratelli, facendo scendere un elemento del genitore. Se cosı̀ facendo il genitore diventa un nodo vuoto, si ripete il procedimento esaminando i fratelli del genitore, e cosı̀ via fino eventualmente alla radice. Se l’elemento da cancellare non è una foglia, si sostituisce l’elemento da cancellare con il massimo del sottoalbero alla sua sinistra, cioè con il suo predecessore, oppure con il minimo del sottoalbero alla sua destra, cioè con il suo successore, poi si elimina tale predecessore o successore. Ci si riduce quindi al caso precedente (cancellazione di una foglia). B-alberi I B-alberi (B-tree) sono alberi di ricerca comunemente utilizzati nell’ambito di basi di dati e dispositivi di memoria secondaria ad accesso diretto. Vantaggio: la dimensione del nodo è uguale a quella di un blocco di lettura/scrittura, cioè al numero di byte che viene trasferito fra memoria secondaria e memoria principale in una singola operazione di lettura/scrittura (per esempio la dimensione di una pagina su disco). Il grado minimo di un B-albero è il minimo numero di figli dei nodi. In un albero di grado minimo t si ha quindi: • a regime per ogni nodo, esclusa la radice: t ≤ numero figli ≤ 2t − 1 quindi t − 1 ≤ numero chiavi ≤ 2t − 2 • ogni nodo deve avere spazio per 2t figli e 2t−1 chiavi per permettere durante l’inserimento la creazione di nodi “iper-pieni” che vengono poi scissi. Per esempio: un B-albero di grado minimo 2 è un albero due-tre: • 2 ≤ numero figli ≤ 3 • 1 ≤ numero chiavi ≤ 2 L’algoritmo di inserimento è l’ovvia generalizzazione di quello per gli alberi due-tre: • come in un albero di ricerca ordinario, si scende nell’albero (nel ramo opportuno a seconda del valore della chiave da inserire) fino a una foglia, e si inserisce il nuovo elemento in ordine nella foglia • se cosı̀ si ottiene una foglia con un numero di elementi maggiore del numero massimo, la si scinde in due facendo salire il suo elemento mediano nel genitore • se cosı̀ il genitore diventa un nodo con un numero di elementi maggiore del numero massimo, lo si scinde a sua volta in due, facendo salire il suo elemento mediano, e cosı̀ via • se in tal modo si risale fino alla radice, ed essa viene scissa, l’elemento mediano sale diventando la nuova radice, e l’altezza dell’albero aumenta di uno. Esercizio 3.9 Inserire le seguenti chiavi in un B-albero con massimo numero di chiavi per nodo 4, cioè grado massimo 5: 3, 7, 9, 23, 45, 1, 5, 14, 25, 24, 13, 11, 8, 19, 4, 31, 35, 56 39 Cancellazione Se l’elemento da cancellare è in una foglia: • si elimina l’elemento, poi: • se il numero di chiavi risultante nel nodo (foglia) è ancora maggiore o uguale del numero minimo, si termina • se il numero di chiavi del nodo è diventato minore del numero minimo, si considerano i fratelli adiacenti al nodo (sono al più due) • se almeno uno di essi contiene un numero di chiavi maggiore del numero minimo, si fa salire nel genitore un elemento di tale fratello e si fa scendere un elemento del genitore nel nodo deficitario • se nessun fratello adiacente ha un numero di chiavi maggiore del numero minimo, si opera una fusione con uno dei fratelli, facendo scendere un elemento del genitore • se cosı̀ facendo il numero di chiavi del genitore è diventato minore del numero minimo, si ripete il procedimento sul genitore. Se l’elemento da cancellare non è una foglia, si sostituisce l’elemento da cancellare con il massimo del sottoalbero alla sua sinistra, cioè con il suo predecessore, oppure con il minimo del sottoalbero alla sua destra, cioè con il suo successore, poi si elimina tale predecessore o successore. Ci si riduce quindi al caso precedente (cancellazione di una foglia). Esercizio 3.10 Dato il B-albero con massimo numero di chiavi per nodo 4 ottenuto nell’esercizio precedente: • si inseriscano una dopo l’altra le ulteriori chiavi 2, 6, 12 • si cancellino, una dopo l’altra, le seguenti chiavi: 1, 4, 5, 7, 3, 14. 3.6 Strutture union-find Problema (versione astratta): rappresentare una collezione di insiemi disgiunti sulla quale siano possibili le seguenti operazioni: • makeSet(a) aggiunge l’insieme costituito dal solo elemento a (singleton) • union(A,B) sostituisce gli insiemi A e B con la loro unione • find(a) restituisce l’insieme che contiene l’elemento a. Se ho n makeSet posso eseguire al più n − 1 union. Supponiamo di identificare ogni insieme con un suo elemento (se pensiamo gli insiemi come classi di equivalenza, un suo rappresentante). Si ha quindi: • makeSet(a) aggiunge l’insieme costituito dal solo elemento a (singleton) • union(a,b) sostituisce gli insiemi (rappresentati da) a e b con (un rappresentante del)la loro unione • find(a) restituisce (il rappresentante del)l’insieme che contiene l’elemento a. Idea generale per l’implementazione: ogni insieme è un albero la cui radice è il rappresentante. Si usano alberi con numero arbitrario di figli, in genere difficili da implementare, ma in questo caso interessa solo risalire al padre. La collezione di insiemi è quindi una foresta. Se gli elementi sono i numeri naturali da 0 a n − 1, è possibile implementare gli alberi con array in cui gli indici corrispondono ai nodi, e ogni elemento dell’array (nodo) contiene l’indice del padre. Basandosi su questa idea generale, è possibile dare diverse rappresentazioni con diverse caratteristiche di efficienza. Vediamo prima due rappresentazioni elementari, assumendo che n sia il numero di makeSet. Alberi QuickFind Permettono di eseguire rapidamente la find. Sono alberi di altezza uno. • makeSet(a) aggiunge un nuovo albero con due nodi, radice a e figlio a: O(1) • union(a,b) rende la radice dell’albero che contiene a padre di tutti i nodi dell’albero che contiene b: O(n) • find(a) restituisce il padre di a: O(1) • occupazione di spazio: a ogni istante il numero totale di nodi è O(n) (massimo 2n, minimo n + 1), il numero totale di archi è O(n). 40 Alberi QuickUnion Permettono di eseguire rapidamente la union. Sono alberi di altezza arbitraria. • makeSet(a) aggiunge un nuovo albero con un unico nodo a: O(1) • union(a,b) tra rappresentanti rende a padre di b: O(1). • union(a,b) generica, richiede prima una find: O(n) • find(a) risale la catena dei padri di a: O(n) • occupazione di spazio: a ogni istante il numero totale di nodi è n, il numero totale di archi è O(n) (massimo n − 1, minimo 0). QuickUnion con union-by-size oppure union-by-rank crescere senza controllo. Si consideri per esempio: L’operazione più costosa è la find, perché l’altezza degli alberi può makeSet(1) ... makeSet(n) union(n-1,n) ... union(1,2) Per mantenere gli alberi bilanciati, l’unione viene effettuata scegliendo sempre come radice del nuovo insieme quella dell’insieme di cardinalità maggiore, cioè la radice dell’albero con meno nodi diventa figlio della radice dell’altro. Teorema 3.11 Con la union-by-size, l’altezza di ogni albero della struttura union-find è al più logaritmica nel numero di nodi dell’albero. Dimostrazione Proviamo che h ≤ log |T |, ossia 2h ≤ |T |, per ogni albero T di altezza h, è una proprietà invariante della struttura union-find. • vale all’inizio: la proprietà vale banalmente per la struttura vuota • vale dopo una makeSet: si ha un nuovo albero T con un solo nodo, per esso si ha quindi banalmente 2h = |T | • vale dopo una union, infatti, siano T e T 0 i due alberi che vengono fusi, di altezza h e h0 rispettivamente, con 2h ≤ |T |, 0 2h ≤ |T 0 | e, per esempio, |T 0 | ≤ |T |: – se h0 + 1 ≤ h, si ottiene un nuovo albero di altezza h e numero di nodi |T | + |T 0 |, e 2h ≤ |T | < |T | + |T 0 | 0 – se h0 +1 > h, si ottiene un nuovo albero di altezza h0 +1 e numero di nodi |T |+|T 0 |, e 2h +1 ≤ 2|T 0 | ≤ |T |+|T 0 |. Si ha quindi: • union tra rappresentanti: O(1). • union generica: O(log n) • find: O(log n) Una tecnica alternativa a quella dell’unione per dimensione, detta union-by-rank5 , è la seguente: l’unione viene effettuata scegliendo come radice del nuovo albero quella dell’albero di altezza maggiore, cioè la radice dell’albero meno alto diventa figlio della radice di quello più alto. Teorema 3.12 Con la union-by-rank, l’altezza di ogni albero della struttura union-find è al più logaritmica nel numero di nodi dell’albero. Dimostrazione Proviamo che h ≤ log |T |, ossia 2h ≤ |T |, per ogni albero T di altezza h, è una proprietà invariante della struttura union-find. • vale all’inizio e dopo una makeSet: banalmente, analogamente a prima • vale dopo una union, infatti, siano T e T 0 i due alberi che vengono fusi, di altezza h ed h0 rispettivamente, con 2h ≤ |T |, 0 2h ≤ |T 0 | e, per esempio, h0 ≤ h: 5 Chiamiamo questa tecnica union-by-rank piuttosto che union-by-height perchè, come vedremo più avanti, può essere generalizzata scegliendo in base non all’altezza effettiva ma a un suo maggiorante. 41 – se h0 < h, si ottiene un nuovo albero di altezza h e numero di nodi |T | + |T 0 |, e 2h ≤ |T | < |T | + |T 0 | 0 – se h0 = h, si ottiene un nuovo albero di altezza h + 1 e numero di nodi |T | + |T 0 |, e 2h+1 = 2h + 2h ≤ |T | + |T 0 |. Si noti che il campo altezza deve essere aggiornato solo in questo caso. Nota implementativa: ovviamente, perché le operazioni abbiano la complessità indicata sopra, per ogni albero della foresta unionfind sarà necessario memorizzare l’informazione sul suo numero di nodi o altezza, in modo che quando si effettua la union il numero di nodi o l’altezza dell’albero unione possano essere calcolati in tempo costante a partire da quelli dei due argomenti. In un’implementazione in cui si aggiunga un campo size a ogni nodo, tale campo sarà rilevante solo per i nodi radice. Questo rende possibile, nel caso in cui l’universo degli elementi sia l’insieme degli interi da 0 a n − 1, un’implementazione con array in cui gli indici corrispondono ai nodi, e , se l’indice corrisponde a un nodo non radice, il suo contenuto è l’indice corrispondente al genitore, se a un nodo radice, l’opposto della dimensione dell’albero. In questo modo non si hanno ambiguità, essendo gli indici numeri non negativi. Path compression Possiamo fare in modo che la find durante la sua esecuzione tenda a diminuire l’altezza dell’albero, cosı̀ da rendere più veloci le operazioni successive. Ci sono varie tecniche possibili, consideriamone una: la find rende figli della radice tutti i nodi che incontra nel suo percorso di risalita dal nodo alla radice. La realizzazione della path compression può essere iterativa: dopo aver trovato la radice, si ripercorre il cammino dal nodo alla radice aggiornando in ogni nodo del cammino il puntatore al genitore, oppure ricorsiva: l’aggiornamento del genitore viene effettuato dopo la chiamata ricorsiva. In entrambi i casi il numero di passi della find modificata è uguale a quello della find originale moltiplicato per un fattore costante, quindi la complessità rimane logaritmica. Versione iterativa: UF.find(x) root = x while (root.parent 6= null) root = root.parent while (x.parent 6= root) oldParent = x.parent x.parent = root x = oldParent return root Versione ricorsiva: UF.find(x) if (x.parent = null) return x x.parent = find(x.parent) return x.parent Si noti che, dato che la union tra elementi generici consiste di due operazioni di find seguite dall’unione vera e propria, introducendo la compressione dei cammini nella find la introduciamo automaticamente anche nella union. Nota: possiamo utilizzare insieme senza problemi la union-by-size e la compressione dei cammini, in quanto quest’ultima non modifica il numero di nodi degli alberi che costituiscono la foresta union-find. In altri termini, in un’implementazione in cui si aggiunga un campo size a ogni nodo, durante la compressione non è necessario aggiornare i campi size dei nodi coinvolti. Nel caso della union-by-rank, invece, la compressione dei cammini può modificare l’altezza dell’albero cui appartiene il nodo argomento della find. Tuttavia, anche in questo caso è possibile evitare di aggiornare l’informazione, interpretandola non più come altezza dell’albero, ma come una quantità, chiamata rank, che sappiamo essere maggiore o uguale dell’altezza dell’albero. L’unione viene effettuata in base al rank piuttosto che all’altezza, e, come nel caso dell’altezza, se i due argomenti dell’unione hanno lo stesso rank r l’albero unione deve avere rank r + 1, per garantire che il rank sia un maggiorante dell’altezza dell’albero. Si ha che: h ≤ r ≤ log |T |, ossia 2h ≤ 2r ≤ |T |, per ogni albero T di altezza h e rank r, è una proprietà invariante della struttura union-find. Infatti quando si esegue una union vale esattamente il ragionamento della prova precedente considerando il rank invece dell’altezza, e quando si esegue una find il rank resta uguale mentre l’altezza può diminuire. Il vantaggio della compressione dei cammini si ha quando si effettua una sequenza di operazioni, non può quindi venire misurato dalla complessità di una singola operazione. Si considera quindi la nozione di complessità ammortizzata. Si tratta di una nozione utile in tutti i casi in cui il tempo di calcolo impiegato da un algoritmo che realizzi un’operazione su una struttura dati può dipendere, oltre che dalla dimensione n della struttura, dalle operazioni (dello stesso o di altro genere) effettuate prima. Per una sequenza di m operazioni dello stesso tipo si definisce: 42 Tam (n) = Tworst (n) m Più in generale si possono anche considerare sequenze di operazioni di diverso tipo (come nel caso delle strutture union-find). Non si confonda la complessità del caso medio (media, eventualmente pesata con una certa distribuzione di probabilità, del tempo di esecuzione su tutti i possibili input) con la complessità ammortizzata (media del tempo di esecuzione su una sequenza di operazioni, non interviene nessuna nozione probabilistica). Si può dimostrare (non lo vediamo) che la complessità per una sequenza di n makeSet, m find e n − 1 union è O((n + m) log∗ n), dove log∗ è una funzione dalla crescita lentissima, in pratica costante da un certo punto in poi (per esempio, log∗ 265536 = 5). Quindi, la complessità ammortizzata per questa sequenza di operazioni è “quasi” O(1). 4 Grafi 4.1 Introduzione e terminologia Def. 4.1 [Grafo] Un grafo (orientato) è una coppia G = (V , E ) dove: • V è un insieme i cui elementi sono detti nodi o vertici • E è un insieme di archi (edges), dove un arco è una coppia di nodi detti estremi dell’arco. In un grafo non orientato gli archi sono coppie non ordinate, ossia (u, v ) e (v , u) denotano lo stesso arco. Un arco da un nodo in se stesso è detto cappio, e un grafo senza cappi è detto semplice. La definizione di grafo si può generalizzare a quella di multigrafo, in cui gli archi sono un multiinsieme. Nel caso di grafi orientati, il primo elemento della coppia è detto nodo uscente o coda, il secondo nodo entrante o testa. Dato il seguente grafo non orientato: A B F C D E • l’arco (A, B) è incidente sui nodi A e B, • i nodi A e B sono adiacenti, A è adiacente a B, B è adiacente ad A, • i nodi adiacenti a un nodo A si chiamano anche i vicini di A, • il grado δ(u) di un nodo u è il numero di archi incidenti sul nodo, per esempio δ(B) = 4. Dato il seguente grafo orientato: A B F C D E • l’arco (A, B) è incidente sui nodi A e B, uscente da A, entrante in B, • il nodo B è adiacente ad A, ma A non è adiacente a B, • i nodi adiacenti a un nodo A si chiamano anche i vicini di A, • il grado δ(u) di un nodo u è il numero di archi incidenti sul nodo, per esempio δ(B) = 4, • il grado uscente (outdegree) δout (u) di un nodo u è il numero di archi uscenti dal nodo, per esempio δout (B) = 2, • il grado entrante (indegree) δin (u) di un nodo u è il numero di archi entranti nel nodo, per esempio δin (B) = 2. Dato un grafo G = (V , E ), con n nodi ed m archi, si hanno le seguenti ovvie proprietà: 43 • se G è non orientato – la somma dei gradi dei nodi è il doppio del numero degli archi: P u∈V δ(u) = 2m, – m è al massimo il numero di tutte le possibili coppie non ordinate di nodi, ossia n(n + 1)/2, quindi m = O(n 2 ). • se G è orientato: – m è al massimo il numero di tutte le possibili coppie ordinate di nodi, ossia n 2 , quindi m = O(n 2 ). Naturalmente un grafo dove vi sia un arco per ogni coppia di nodi è un caso limite molto particolare e poco interessante, in genere si avrà m minore o molto minore del limite superiore. Quindi la complessità degli algoritmi sui grafi viene di solito espressa in funzione dei due parametri n e m ossia come T (n, m), anche se m è in ogni caso O(n 2 ). Un grafo in cui il numero degli archi sia dell’ordine di n 2 si dice grafo denso. In un grafo G: • un cammino (path) è una sequenza di nodi u1 , . . . , un con n ≥ 0 e, per ogni i ∈ 0..n − 1, ui+1 adiacente a ui , ossia (ui , ui+1 ) è un arco, • gli archi (ui , ui+1 ) si dicono contenuti nel cammino, • n − 1, ossia il numero degli archi, è la lunghezza del cammino, • un cammino è degenere o nullo se è costituito da un solo nodo, ossia ha lunghezza 0, • è semplice se i nodi sono distinti, tranne al più il primo e l’ultimo, • in un grafo orientato, un cammino non nullo forma un ciclo se il primo nodo coincide con l’ultimo, • in un grafo non orientato, un cammino di lunghezza ≥ 3 forma un ciclo (semplice) se il primo nodo coincide con l’ultimo e tutti gli altri nodi sono distinti, • v è raggiungibile da u se esiste un cammino da u a v . Un grafo G è aciclico se non vi sono cicli in G. Un grafo orientato aciclico è anche detto DAG (directed acyclic graph). Un grafo non orientato si dice connesso se ogni nodo è raggiungibile da ogni altro. Un grafo orientato si dice fortemente connesso se ogni nodo è raggiungibile da ogni altro, debolmente connesso se il grafo non orientato corrispondente è connesso. Un grafo connesso avente n nodi deve avere almeno n − 1 archi. Un sottografo di G = (V , E ) è un grafo ottenuto da G non considerando alcuni archi o alcuni nodi insieme agli archi incidenti su di essi. Il sottografo indotto da un sottoinsieme V 0 di nodi è il sottografo di G costituito dai nodi di V 0 e dagli archi di G che connettono tali nodi. Un albero libero è un grafo non orientato connesso aciclico. Se in un albero libero si fissa un nodo u come radice, si ottiene un albero nel senso usuale (ossia, radicato), avente u come radice. Graficamente si può pensare di “appendere” il grafo a un qualunque nodo, e si ottiene sempre un albero. Un albero radicato può equivalentemente essere definito come un grafo orientato aciclico (DAG) in cui uno e un solo nodo, detto radice, ha grado entrante 0, e tutti gli altri nodi hanno grado entrante 1. Ogni albero radicato secondo la prima definizione può infatti essere visto, attribuendo agli archi l’ovvio orientamento, come un albero radicato nel secondo senso. Dato un grafo non orientato e connesso G, un albero ricoprente (spanning tree) di G è un sottografo di G che contiene tutti i nodi ed è un albero libero. Ossia, è un albero che connette tutti i nodi del grafo usando archi del grafo, ha quindi n nodi ed n − 1 archi. Analogamente, dato un grafo non orientato (eventualmente non connesso) G, si chiama foresta ricoprente di G un sottografo di G che contiene tutti i nodi ed è una foresta libera. Si vede facilmente che, dato un grafo non orientato, esiste sempre una foresta ricoprente di G, un albero ricoprente se G è connesso. Le nozioni di albero e foresta ricoprenti possono essere date anche sui grafi orientati: • un albero ricoprente di un grafo orientato G è un sottografo di G che contiene tutti i nodi ed è un albero radicato (esiste sempre se connesso), • foresta ricoprente di un grafo orientato G è un sottografo di G che contiene tutti i nodi ed è una foresta (esiste sempre). 44 Rappresentazione di grafi Lista di archi Si memorizza l’insieme dei nodi e la lista degli archi, spazio totale O(n + m). Molte operazioni richiedono di scorrere l’intera lista. operazione tempo di esecuzione grado di un nodo O(m) nodi adiacenti O(m) esiste arco O(m) aggiungi nodo O(1) aggiungi arco O(1) elimina nodo O(m) elimina arco O(m) Liste di adiacenza Per ogni nodo si memorizza la lista dei nodi adiacenti. Si hanno n liste, di lunghezza totale 2m per grafo non orientato, m per grafo orientato, quindi complessità spaziale O(n + m). Diventa più semplice trovare gli adiacenti di un nodo, operazione cruciale in molte applicazioni, mentre il test di verifica dell’esistenza di un arco non è molto efficiente. Inoltre nel caso di grafo non orientato ogni arco è rappresentato due volte, quindi si ha ridondanza e quindi il problema di mantenere la coerenza dell’informazione. operazione tempo di esecuzione grado di u O(δ(u)) nodi adiacenti a u O(δ(u)) esiste arco (u, v ) min(δ(u), δ(v )) aggiungi nodo O(1) aggiungi arco O(1) elimina nodo O(m) elimina arco (u, v ) O(δ(u) + δ(v )) Liste di incidenza (variante della precedente) Combina vantaggi delle due precedenti, si mantiene la lista degli archi e, per ogni nodo, una lista di puntatori agli archi incidenti. Richiede un pò più spazio ma sempre O(n + m). Matrice di adiacenza assumendo corrispondenza tra nodi e 1..n, matrice quadrata M di dimensione n a valori booleani (oppure 0, 1), dove Mi,j vero se e solo se esiste l’arco (ui , uj ). Richiede più spazio, O(n 2 ), ma la verifica della presenza di un arco è in tempo costante. Per contro, trovare gli adiacenti diventa più costoso (intera riga), e nel caso di grafi non orientati si ha ridondanza (matrice simmetrica). Il tempo per l’aggiunta ed eliminazione di un nodo tiene conto della riallocazione. operazione grado di un nodo nodi adiacenti esiste arco aggiungi nodo aggiungi arco elimina nodo elimina arco 4.2 tempo di esecuzione O(n) O(n) O(1) O(n 2 ) O(1) O(n 2 ) O(1) Visite Per visitare un grafo si possono usare algoritmi simili a quelli visti per gli alberi. Tuttavia, poiché un nodo è raggiungibile in più modi, bisogna “marcare” i nodi, in modo che se si raggiunge un nodo già visitato non lo si visiti di nuovo. Le operazioni eseguite al passo di visita dipendono, come sempre, dal particolare algoritmo. L’insieme dei nodi viene partizionato in tre insiemi, tradizionalmente indicati con i colori bianco, grigio, e nero: bianco inesplorato, ossia non ancora toccato dall’algoritmo grigio aperto, ossia toccato dall’algoritmo (visita iniziata) nero chiuso (visita conclusa) Le visite iterative usano un insieme F detto frangia da cui vengono via via estratti i nodi da visitare. A seconda della struttura dati impiegata per la frangia si ottengono diversi tipi di visita: analogamente a quanto visto per gli alberi, la visita in ampiezza 45 utilizza una coda, la visita in profondità una pila, mentre gli algoritmi di Dijkstra e Prim che vedremo successivamente utilizzano una coda a priorità. Consideriamo per semplicità la visita del sottografo connesso a partire da un nodo. Ossia, dati un grafo G e un nodo di partenza s, la visita di tutti i nodi raggiungibili da s. Costruisce implicitamente l’albero di visita T , cioè l’albero di radice s in cui il genitore di un nodo u è l’altro estremo v dell’arco (v , u) percorrendo il quale si è arrivati a u. La costruzione dell’albero di visita può essere resa esplicita nell’algoritmo stesso, per esempio memorizzando per ogni nodo il suo genitore. I nodi nell’albero di visita corrente sono tutti grigi o neri. Si tratta di un albero ricoprente. Si generalizza a visita completa che costruisce una foresta ricoprente dell’intero grafo. Visita in ampiezza (breadth-first) La frangia è una coda Q. BFS(G,s) //visita nodi di G raggiungibili a partire da s for each (u nodo in G) marca u come bianco Q = coda vuota Q.add(s); marca s come grigio; parent[s] = null while (Q non vuota) u = Q.dequeue() //u non nero visita u for each ((u,v) arco in G) if (v bianco) marca v come grigio; Q.add(v); parent[v]=u marca u come nero Osservazioni: nell’albero BFS ogni nodo è il più vicino possibile alla radice, ossia, la visita calcola la distanza (lunghezza minima di un cammino) dalla radice a ogni nodo (vedi dopo). La coda mantiene l’ordine in cui i nodi sono trovati dall’algoritmo, ogni nodo entra in coda una volta sola. Il padre di un nodo viene deciso nel momento in cui il nodo viene incontrato. Invariante del ciclo: nodi grigi = nodi in F = nodi i cui archi uscenti non sono ancora stati esaminati; nodi in T = nodi neri e grigi. Visita in profondità (depth-first) La frangia è una pila S. DFS(G,s) //visita nodi di G raggiungibili a partire da s for each (u nodo in G) marca u come bianco S = pila vuota S.push(s); marca s come grigio; parent[s] = null while (S non vuota) u = S.pop() if (u non nero) visita u for each ((u,v) arco in G) if (v bianco) marca v come grigio; S.push(v); parent[v]= u else if (v grigio) S.push(v); parent[v]= u //modifica il padre marca u come nero Osservazioni: si segue un cammino nel grafo finché possibile. Un nodo può essere inserito nella pila anche se grigio, quindi più volte, nel caso peggiore tante volte quanti sono i suoi archi entranti. Il padre viene modificato ogni volta. Può essere estratto dalla pila un nodo nero. Complessità della visita completa: assumiamo n = numero nodi, m = numero archi, e che la marcatura e le operazioni di inserimento, eliminazione e modifica delle strutture dati siano fattibili in tempo costante. Nota: assunzioni valide per pila e coda, potrebbero non esserlo con strutture dati diverse, per esempio coda a priorità per algoritmi di Dijkstra e Prim. • marcature dei nodi: O(n) • inserimenti in F e T , modifiche di F e T , estrazioni da F : ogni nodo viene inserito una prima volta in F e T , poi eventualmente F e T vengono aggiornati al più m volte: O(n + m) • esplorazione archi incidenti eseguita n volte, quindi: – lista di archi: O(n · m) – liste di adiacenza: O(n + m) perché ogni lista di adiacenza viene scandita una volta sola – matrice di adiacenza: O(n2 ) 46 Visita in profondità ricorsiva La visita in profondità si può anche realizzare molto semplicemente in modo ricorsivo, analogo all’algoritmo ricorsivo per la visita preorder sugli alberi. Occorre tuttavia marcare i nodi come visitati. DFS(G,s) for each (u nodo in G) marca u come visitato T = albero di radice s DFS(G,s,T) DFS(G,u,T) visita u; marca u come visitato for each ((u,v) arco in G) if (v non visitato) aggiungi l’arco (u, v) a T DFS(G,v,T) Visita in ampiezza con livello esplicito BFS(G,s) for each (u nodo in G) marca u come bianco Q = coda vuota Q.add(s); marca s come grigio; parent[s]=null; level[s]=0 while(Q non vuota) u = Q.remove() visita u for each ((u,v) arco in G) if (v bianco) marca v come grigio; Q.add(v); parent[v]=u; level[v]=level[u]+1 marca u come nero Proprietà invariante (1) della visita in ampiezza: ogni cammino da s a un nodo u bianco passa per almeno un nodo appartenente a Q. Dimostrazione: l’invariante vale banalmente all’inizio in quanto s è in Q. Si mantiene, in quanto a ogni passo tolgo un nodo u da Q, ma per ogni cammino da s a un nodo bianco v che passava per u, o lo stesso v è adiacente a u e quindi diventa grigio, oppure il nodo adiacente a u nel cammino tra u e v va in Q. Proprietà invariante (2): i nodi appartenenti a Q sono ordinati per livello e si trovano su non più di due livelli contigui, ossia, se Q = u1 . . . un : • level(ui ) ≤ level(ui+1 ) per i ∈ 1..n − 1. • level(un ) ≤ level(u1 ) + 1 L’invariante vale banalmente all’inizio, in quanto inizialmente Q contiene solo s, a livello 0. Si mantiene, in quanto, chiamati i due livelli l1 ed l2 ≤ l1 + 1, si toglie da Q il primo nodo, di livello l1 si inseriscono in fondo a Q nodi adiacenti al nodo tolto, che quindi sono di livello l1 + 1, quindi Q rimane ordinata e al più su due livelli. Visita in ampiezza e cammini minimi: definiamo d (u) la distanza di u da s, ossia la lunghezza minima di un cammino da s a u. Proprietà invariante (3): il livello di un nodo u nell’albero di visita è uguale alla lunghezza minima di un cammino da s a u, cioè level(u) = d (u). L’invariante vale banalmente all’inizio, in quanto l’albero di visita contiene solo s. Si mantiene: infatti, la proprietà vale per tutti i nodi già nell’albero (neri e grigi), quindi per tutti quelli in Q (grigi). Si estrae dalla coda un nodo u, quindi si ha level(u) = d (u). Ogni nodo bianco v adiacente a u viene inserito in coda, e messo al livello level(v ) = level(u) + 1 nell’albero. C’è quindi un cammino di lunghezza level(u) + 1 da s a v , vediamo che questa lunghezza è minima. Per la Proprietà (1) ogni cammino da s a v deve passare per un nodo w di Q, quindi ha lunghezza l ≥ d (w ) + 1 = level(w ) + 1. Per la Proprietà (2) level(w ) ≥ level(u), quindi l ≥ level(u) + 1 = level(v ). 4.3 Cammini minimi in un grafo pesato: algoritmo di Dijkstra Def. 4.2 [Grafo pesato] Un grafo pesato è un grafo G = (V , E ) dove ad ogni arco è associato un peso o costo attraverso una funzione c : E → R. Il costo di un cammino da s a t è la somma dei pesi o costi degli archi che compongono il cammino. Un cammino da s a t si dice minimo se ha peso minimo (detto distanza) fra tutti i cammini da s a t. 47 Naturalmente può esistere più di un cammino minimo. Se non esistono archi con peso negativo, un cammino minimo fra due nodi connessi esiste sempre, altrimenti può non esistere. Problema: dato un grafo orientato pesato G, con pesi non negativi (che a seconda dell’applicazione possono rappresentare distanze, tempi di percorrenza, costi di attività, etc.), e dati un nodo di partenza s e un nodo di arrivo t, trovare un cammino di peso minimo da s a t. Generalizzazione: dati un grafo orientato pesato G con pesi non negativi e un nodo di partenza s, trovare per ogni nodo u del grafo il cammino minimo da s a u. Esempi di applicazione: navigatore satellitare (trovare l’itinerario più corto, o più veloce, da un luogo a un altro su una mappa stradale); trovare il percorso di durata minima fra due stazioni di una rete ferroviaria o di una rete di trasporto pubblico urbano; protocollo di routing OSPF (Open Shortest Path First) usato in Internet per trovare la migliore connessione da ogni nodo della rete a tutte le possibili destinazioni. Algoritmo di Dijkstra (1959), idea: è una visita in ampiezza in cui a ogni passo: • per i nodi già visitati si ha la distanza minima e l’albero T di cammini minimi • per ogni nodo non ancora visitato si ha distanza provvisoria minima = distanza minima ottenibile con cammino in T più un arco • si estrae dalla coda un nodo a distanza provvisoria minima (che risulta distanza minima) • si aggiornano le distanze provvisorie dei nodi adiacenti al nodo estratto tenendo conto del nuovo arco in T Dato che ogni volta si estrae un minimo, conviene rappresentare l’insieme dei nodi ancora da visitare come coda a priorità. L’uso di una coda a priorità realizzata come heap invece che come semplice array o lista fu introdotto da Johnson nel 1977; tale versione dell’algoritmo è quindi talvolta chiamata algoritmo di Johnson. Per non dover trattare a parte il caso di nodi non in coda, conviene inserire all’inizio in coda tutti i nodi assegnando loro come distanza provvisoria “infinito”. Djikstra(G,s) for each (u nodo in G) dist[u] = ∞ parent[s] = null; dist[s] = 0 Q = heap vuoto for each (u nodo in G) Q.add(u,dist[u]) while (Q non vuota) u = Q.getMin() //estraggo nodo a distanza provvisoria minima for each ((u,v) arco in G) if (dist[u] + cu,v < dist[v]) // se v nero falso parent[v] = u; dist[v] = dist[u] + cu,v Q.changePriority(v, dist[v]) //moveUp Nota implementativa: nell’operazione changePriority, l’argomento non è l’indice dell’elemento nello heap, ma l’elemento stesso. Occrre quindi poter trovare l’indice in tempo costante. Correttezza Sia d [u] la distanza (lunghezza di un cammino minimo) da s a u. L’invariante è composta di due parti: 1. per ogni nodo u non in Q, ossia “nero” dist[u] = d [u] 2. per ogni nodo u in Q dist[u] = d\Q [u] = lunghezza di un cammino minimo da s a u i cui nodi, tranne u, non sono in Q, ossia sono nodi “neri”. Convenzione: se questo cammino non esiste, ossia u non è raggiungibile da s solo attraverso nodi neri (u è “bianco”), d\Q [u] = ∞. L’invariante vale all’inizio: 1. vale banalmente perché non ci sono nodi neri (tutti i nodi sono in coda) 2. vale banalmente perché la distanza è per tutti infinito, tranne che per s per cui vale 0. L’invariante si mantiene. 48 1. Viene estratto dalla coda il nodo u con dist[u] minima, quindi, per l’invariante (2), tale che esiste un cammino π da s a u minimo tra quelli costituiti da tutti nodi neri tranne l’ultimo. Tale cammino è allora anche il minimo in assoluto fra tutti i cammini da s a u. Infatti, supponiamo per assurdo che esista un cammino da s a u di costo minore di quello di π. Questo cammino deve necessariamente contenere nodi non neri, altrimenti avrebbe costo minore o uguale a quello di π. Sia w il primo di tali nodi non neri. Il cammino quindi è della forma π1 π2 con π1 cammino da s a w e π2 cammino da w a u. Ma il cammino π1 , essendo formato solo da nodi neri tranne l’ultimo, ha costo maggiore o uguale a quello di π, e quindi a maggior ragione π1 π2 ha costo maggiore o uguale a quello di π. Quindi, aggiungendo il nodo u estratto dalla coda all’insieme dei nodi neri, l’invariante (1) vale ancora, ossia, è ancora vero che per tutti i nodi neri, compreso il nuovo nodo nero u, è stato trovato il cammino minimo. 2. Poiché l’insieme dei nodi neri risulta modificato per l’aggiunta di u, occorre però ripristinare l’invariante (2): per ogni nodo v in Q, dist[v ] deve essere la lunghezza del minimo fra i cammini da s a v i cui nodi sono tutti, eccetto v neri; ma adesso fra i nodi neri c’è anche u, quindi bisogna controllare se per qualche nodo v adiacente a u il cammino da s a v passante per u è più corto del cammino trovato precedentemente, e in tal caso aggiornare dist[v ] e parent[v ]. Postcondizione: al termine dell’esecuzione dell’algoritmo la coda è vuota, quindi tutti i nodi sono neri. Poiché vale l’invariante (1), per ogni nodo è stato trovato il cammino minimo da s. Nota: per dimostrare che il ciclo mantiene l’invariante (1), che è quello che interessa, è necessario dimostrare che il ciclo mantiene anche l’invariante (2). Complessità Se la coda a priorità è realizzata come sequenza non ordinata, ogni inserimento in coda o modifica della priorità ha complessità O(1), ma l’estrazione del minimo ha complessità O(n), quindi la complessità dell’algoritmo è O(n 2 ). Analogamente se la coda a priorità è realizzata come sequenza ordinata. Nella versione di Johnson, se n e m sono il numero rispettivamente dei nodi e degli archi, si ha: • inizializzazione di tutti i nodi come bianchi: O(n) • n estrazioni dallo heap: O(n log n) • ciclo interno: ogni arco viene percorso una volta, e per ogni nodo adiacente si ha eventuale moveUp nello heap, quindi O(m log n) Complessivamente si ha quindi O((m + n) log n). Si noti che se il grafo è denso, cioè m = O(n 2 ), la complessità diventa O(n 2 log n), quindi peggiore della versione originale quadratica. 4.4 Algoritmo di Prim Def. 4.3 [Minimo albero ricoprente] Dato un grafo G connesso, non orientato e pesato, un minimo albero ricoprente6 di G è un albero ricoprente di G in cui la somma dei pesi degli archi è minima. Un minimo albero ricoprente di G è quindi un sottografo di G tale che: • sia un albero libero, ossia connesso e aciclico • contenga tutti i nodi di G • la somma dei pesi degli archi sia minima. Esempi di applicazione: costruzione di reti di computer, linee telefoniche, rete elettrica, etc. Idea: simile a Dijkstra, ma si prende ogni volta, fra tutti i nodi adiacenti a quelli per cui si è già trovato il minimo (neri), quello connesso a un nodo nero dall’arco di costo minimo, cioè si cerca il nodo “più vicino” all’albero già costruito (poi, come in Dijkstra, si aggiornano gli altri nodi). Prim(G,s) for each (u nodo in G) marca u come non visitato //necessario for each (u nodo in G) dist[u] = ∞ parent[s] = null; dist[s] = 0 Q = heap vuoto for each (u nodo in G) Q.add(u, dist[u]) 6 In inglese minimum spanning tree. 49 while(Q non vuota) u = Q.getMin() //estraggo nodo a minima distanza dai neri marca u come visitato (nero) for each ((u,v) arco in G) if (v non visitato && cu,v < dist[v] ) parent[v] = u; dist[v] = cu,v Q.changePriority(v,dist[v]) //moveUp Nota: a differenza di quanto accade in Dijkstra, occorre un controllo esplicito che i nodi adiacenti al nodo u estratto dalla coda non siano già stati visitati, perché non è detto che per un nodo v già visitato il test cu,v < dist[v] sia falso. Correttezza Sia T l’albero di nodi neri (non in Q) corrente. L’invariante è composta di due parti: 1. T ⊆ MST per qualche MST minimo albero ricoprente di G 2. per ogni nodo u 6= s in Q dist[u] = costo minimo di un arco che collega u a un nodo nero. (se questo arco non esiste consideriamo il costo minimo = ∞). L’invariante vale all’inizio: 1. vale banalmente perché T è vuoto (non ci sono nodi neri) 2. vale banalmente perché non ci sono nodi neri e la distanza è per tutti infinito, tranne che per s. L’invariante si mantiene. 1. Viene estratto dalla coda un nodo u con dist[u] minima, cioè connesso a un nodo nero y da un arco di costo minimo tra tutti quelli che “attraversano la frontiera”, cioè uniscono un nodo nero a un nodo non nero. L’arco (y, u) diventa quindi parte dell’albero di nodi neri corrente. Dimostriamo che aggiungendo tale arco si ha ancora un sottoalbero di un minimo albero ricoprente di G. Supponiamo per assurdo che aggiungendo (y, u) a T l’albero ottenuto non appartenga a nessun minimo albero ricoprente di G. Per l’invariante (1), T ⊆ MST per un certo MST minimo albero ricoprente di G. MST , essendo un albero ricoprente, per definizione è un sottografo connesso che connette tutti i nodi di G: quindi in MST , se non c’è l’arco (y, u), ci deve essere un altro cammino da y a u. Questo cammino dovrà contenere un (primo) arco (x, z) che “attraversa la frontiera”, ossia connette un nodo di T a un nodo non di T . Poiché MST è un albero, quindi ogni coppia di nodi è connessa da un unico cammino, se eliminiamo l’arco (x, z) otteniamo un grafo non connesso, costituito da due alberi (sottografi connessi aciclici), siano T1 e T2 . Quindi se consideriamo il sottografo formato da T1 , T2 e l’arco (y, u) otteniamo: • nuovamente un albero • che contiene tutti i nodi di G, quindi è un albero ricoprente • in cui la somma dei pesi degli archi è minore o uguale di quella di MST , in quanto differisce da quest’ultimo solo per l’arco (y, u) al posto dell’arco (x, z), e cy,u ≤ cx,z dato che cy,u è il minimo costo di un arco da un nodo nero a un nodo non nero. Quindi è un minimo albero ricoprente che contiene l’arco (y, u), contro l’ipotesi per assurdo. 2. L’insieme dei nodi neri risulta modificato per l’aggiunta di u, quindi occorre ripristinare l’invariante (2), controllando se per qualche nodo v non nero e adiacente a u l’arco (u, v ) ha costo minore del precedente arco che univa v a un nodo nero, e in tal caso aggiornare l’arco e la distanza. Postcondizione: all’uscita dal ciclo tutti i nodi sono neri, quindi T connette tutti i nodi, cioè è un albero ricoprente di G. Per l’invariante (1), T ⊆ MST per qualche MST minimo albero ricoprente, quindi T = MST . Come in Dijkstra, si noti che per dimostrare che il ciclo mantiene l’invariante (1) è necessario dimostrare che il ciclo mantiene anche l’invariante (2). Nota: L’algoritmo di Prim genera un albero radicato di radice s, ma il minimo albero ricoprente è per definizione un albero non radicato, quindi è possibile ottenere lo stesso a partire da nodi diversi. In particolare si può dimostrare che se i pesi degli archi sono tutti distinti il minimo albero ricoprente è unico. L’analisi della complessità è esattamente la stessa dell’algoritmo di Dikstra, quindi O((n + m) log n). 50 4.5 Algoritmo di Kruskal Anche questo algoritmo risolve il problema del minimo albero ricoprente. In particolare, il minimo albero ricoprente viene costruito mantenendo una foresta alla quale si aggiunge a ogni iterazione un nuovo arco che unisce due sottoalberi distinti. Quindi l’algoritmo di Kruskal, a differenza di quello di Prim, non costruisce l’albero a partire da un nodo scelto come radice, ma come un insieme di archi che alla fine risulta essere un grafo connesso aciclico, quindi un albero libero. Algoritmo astratto: Kruskal(G) s = sequenza archi di G in ordine di costo crescente T = foresta formata dai nodi di G e nessun arco while (s non vuota) estrai da s il primo elemento (u,v) if (u,v non connessi in T) T = T + (u,v) return T Nella sequenza non si fa nessun reinserimento o variazione di ordine quindi non è una coda ma una semplice sequenza ordinata, realizzabile per esempio con un array. Ottimizzazione: un albero di n nodi ha n − 1 archi, quindi si può interrompere il ciclo dopo aver aggiunto all’albero n − 1 archi. Kruskal(G) s = sequenza archi di G in ordine di costo crescente T = foresta formata dai nodi di G e nessun arco counter = 0 while (counter < n-1) estrai da s il primo elemento (u,v) if (u,v non connessi in T) T = T + (u,v) counter++ return T Correttezza Simile a Prim. Invariante: T ⊆ MST per qualche MST minimo albero ricoprente di G. All’inizio vale banalmente perché in T non ci sono archi. Si mantiene, infatti: si sceglie un arco (u, v ) di costo minimo tra quelli non ancora inseriti in T . • Se u e v appartengono a uno stesso albero nella foresta T , aggiungendo l’arco (u, v ) si avrebbe un ciclo, quindi esso viene correttamente scartato, T rimane uguale e l’invariante vale ancora. • Se u e v appartengono a due alberi distinti nella foresta T , dimostriamo che aggiungendo l’arco (u, v ) a T si ha ancora una foresta contenuta in un minimo albero ricoprente. Per assurdo supponiamo che non sia cosı̀. Per l’invariante, tutti gli archi di T appartengono a un minimo albero ricoprente di G, sia MST . Se in MST non c’è l’arco (u, v ), ci deve essere un altro cammino che connette u a v . Tale cammino deve contenere un arco (x, y) che connette un nodo x dell’albero Tu che contiene u con un nodo y che non appartiene a tale albero, dato che v non vi appartiene. Tale arco non può appartenere alla foresta corrente T , perché in tal caso sarebbe un arco di Tu . Quindi tale arco è ancora in s, quindi cu,v ≤ cx,y . Se eliminiamo l’arco (x, y) da MST , otteniamo due alberi non connessi fra loro, e quindi se aggiungiamo a questi due alberi l’arco (u, v ) otteniamo, analogamente a Prim: – nuovamente un albero – che contiene tutti i nodi di G, quindi è un albero ricoprente – in cui la somma dei pesi degli archi è minore o uguale di quella di MST , in quanto differisce da quest’ultimo solo per l’arco (u, v ) al posto dell’arco (x, y), e cu,v ≤ cx,y . Quindi è un minimo albero ricoprente che contiene l’arco (u, v ), contro l’ipotesi per assurdo. Postcondizione: all’uscita dal ciclo si ha necessariamente un unico albero, perché l’algoritmo ha esaminato tutti gli archi di G unendo ogni volta due alberi di T se non ancora connessi (ossia, ha inserito n − 1 archi, come si vede direttamente nella versione ottimizzata). Quindi alla fine T è un unico albero, sottoalbero di un minimo albero ricoprente di G contenente tutti i nodi di G, quindi è un minimo albero ricoprente di G. Complessità algoritmo astratto: il problema è controllare se due nodi sono già connessi. Farlo in modo banale con una visita dei due alberi richiede tempo O(n) nel caso peggiore, quindi si ha O(m · n). Per migliorare l’efficienza possiamo implementare la foresta con una struttura union-find, vedi sezione 3.6. 51 Kruskal(G) s = sequenza archi di G in ordine di costo crescente T = foresta formata dai nodi di G e nessun arco UF = struttura union-find vuota for each (u nodo in G) UF.makeSet(u) while (s non vuota) estrai da s il primo elemento (u,v) if (UF.find(u) 6= UF.find(v)) T = T + (u,v) UF.union(u,v) return T Si noti che si ha una corrispondenza biunivoca tra gli alberi della foresta corrente T e gli insiemi della struttura union-find: due nodi appartengono a uno stesso albero in T se e solo se appartengono a uno stesso insieme nella struttura union-find. Ottimizzazione: dopo due find che danno lo stesso risultato, si farebbe una union che riesegue le due find. È convienente definire un’operazione specializzata per l’algoritmo di Kruskal: union_by_need(x,y) che restituisce un booleano, con la seguente specifica: • se x e y appartengono allo stesso insieme restituisce falso • se x e y appartengono a due insiemi diversi, effettua l’unione e restituisce vero. In questo modo il corpo del ciclo nell’algoritmo di Kruskal diventa: estrai da s il primo elemento (u,v) if (UF.union_by_need(u,v)) T = T + (u,v) Complessità dell’algoritmo di Kruskal nella versione con union-find: • ordinamento degli archi: O(m log m) • n makeSet , 2m find e n − 1 union: “quasi” O(n + m). Complessivamente, poiché m = O(n 2 ), e log n 2 = 2 log n, possiamo anche dire che la complessità è O(m log n). 4.6 Ordinamento topologico È facile vedere che in un grafo orientato aciclico (DAG) la relazione sull’insieme dei nodi “v è raggiungibile da u” è un ordine parziale, vedi definizione A.1. Un ordine totale, vedi definizione A.2, che “raffina” questo ordine parziale si chiama ordine topologico. Def. 4.4 [Ordine topologico] Un ordine topologico su un grafo orientato aciclico G = (V , E ) è un ordine totale (stretto) < su V tale che, per ogni u, v ∈ V : (u, v ) ∈ E ⇒ u < v Problema: dato un DAG trovare un ordine topologico. È un problema rilevante per scheduling di job, realizzazione di diagramma PERT delle attività, ordine di valutazione delle caselle in un foglio di calcolo, ordine delle attività specificate da un makefile, etc. Si vede facilmente che possono esistere diversi ordini topologici per lo stesso grafo. Def. 4.5 In un grafo orientato definiamo un nodo sorgente (source) se non ha archi entranti, pozzo (sink) se non ha archi uscenti. È facile vedere che in un grafo orientato aciclico esistono sempre almeno un nodo pozzo e un nodo sorgente: infatti, se per assurdo cosı̀ non fosse a partire da un nodo qualunque si potrebbe sempre costruire un ciclo. Quindi, esiste sempre un ordine topologico: infatti esiste sempre un nodo sorgente, eliminando questo dal grafo con tutti i suoi archi uscenti esiste sempre un nodo sorgente nel grafo restante, e cosı̀ via. Questa osservazione porta direttamente ad un ovvio algoritmo astratto. Per evitare di modificare il grafo e trovare in tempo costante, a ogni passo, un nodo sorgente, possiamo memorizzare per ogni nodo il suo indegree. Invece di rimuovere un arco (u, v ) si decrementa il valore di indegree per il nodo v . Quando il valore di indegree per un nodo diventa zero, lo si inserisce in un insieme di nodi sorgente da cui si estrae ogni volta il nodo successivo da inserire nell’ordine topologico. Si ottiene quindi il seguente algoritmo (n numero nodi, m numero archi): 52 topologicalsort(G) S = insieme vuoto Ord = sequenza vuota for each (u nodo in G) indegree[u] = indegree di u //m passi for each (u nodo in G) if (indegree[u] = 0) S.add(u) //n passi while (S non vuoto) // in tutto m passi u = S.remove() Ord.add(u) //aggiunge in fondo for each ((u,v) arco in G) indegree[v]-if (indegree[v]=0) S.add(v) return Ord La complessità è quindi O(n + m). Vediamo ora un algoritmo alternativo che consiste essenzialmente in una visita in profondità. Riprendiamo l’algoritmo per la visita in profondità ricorsiva visto precedentemente, modificato leggermente in modo da avere una visita completa (ossia, anche di un grafo non connesso) e una marcatura “nero” inutile ai fini dell’algoritmo che evidenzia la fine della visita. DFS(G) for each (u nodo in G) marca u come bianco T = foresta formata dai nodi di G e nessun arco for each (u nodo in G) if (u bianco) DFS(G,u,T) DFS(G,u,T) //inizio visita visita u; marca u come grigio for each ((u,v) arco in G) if (v bianco) T = T + (u,v) DFS(G,v,T) //marca u come nero //fine visita Indichiamo ora esplicitamente nell’algoritmo il tempo di inizio e fine visita utilizzando dei timestamp (per semplicità assumiamo un contatore time globale). DFS(G) for each (u nodo in G) marca u come bianco T = foresta formata dai nodi di G e nessun arco time = 0 for each (u nodo in G) if (u bianco) DFS(G,u,T) DFS(G,u,T) time++; start[u] = time //inizio visita visita u; marca u come grigio for each ((u,v) arco in G) if (v bianco) T = T + (u,v) DFS(G,v,T) time++; end[u] = time //marca u come nero //fine visita Proprietà della visita: se G è aciclico, per ogni (u, v ), in qualunque visita in profondità di G si ha: end[v ] < end[u] Prova (semi-formale): l’arco (u, v ) viene esaminato durante la visita del nodo u, quindi: • v non può essere grigio, perché questo significherebbe che c’è un cammino da v a u, mentre G è aciclico • se v è bianco, viene effettuata la chiamata DFS(G,v,T), ossia inizia la visita di v , quindi dovrà essere necessariamente end[v ] < end[u] 53 • se v è nero, la sua visita è già terminata, quindi si ha end[v ] < end[u]. Quindi, l’ordine dei nodi di G secondo il tempo di fine visita in una visita in profondità è l’inverso di un ordine topologico. Di conseguenza, per ottenere un ordine topologico dei nodi di un DAG è sufficiente effettuare una visita in profondità, inserendo a ogni fine visita il nodo in una sequenza ordinata. La complessità è semplicemente quella della visita, quindi O(n + m). 4.7 Componenti fortemente connesse Def. 4.6 In un grafo orientato G, due nodi u e v si dicono mutuamente raggiungibili, o fortemente connessi, se ognuno dei due è raggiungibile dall’altro, ossia se esistono un cammino da u a v e un cammino da v a u, ossia se u e v appartengono allo stesso ciclo. È immediato vedere che la relazione di mutua raggiungibilità o connessione forte, che indichiamo con ↔, è una relazione di equivalenza, vedi definizione A.3. Def. 4.7 [Componente fortemente connessa] In un grafo orientato G, le componenti fortemente connesse sono le classi di equivalenza della relazione ↔, ossia i sottografi massimali di G i cui nodi sono tutti fortemente connessi tra loro. Passando al quoziente rispetto all’equivalenza ↔, si ottiene un grafo G ↔ detto grafo quoziente in cui i nodi sono le componenti fortemente connesse di G ed esiste un arco (C, C 0 ) se e solo se esiste un arco da un nodo in C a un nodo in C 0 . È chiaro che il grafo quoziente risulta essere aciclico, quindi su di esso esiste un ordine topologico. Def. 4.8 Una componente fortemente connessa di un grafo orientato è una sorgente o un pozzo se è, rispettivamente, un nodo sorgente o pozzo nel grafo quoziente. In una visita in profondità di un grafo G, il tempo di fine visita end (C) di una componente fortemente connessa C è il massimo dei tempi di fine visita dei nodi appartenenti a C. Lemma 4.9 Se C e C 0 sono due componenti fortemente connesse del grafo G, ed esiste un arco da (un nodo di) C a (un nodo di) C 0 , allora in qualunque visita in profondità di G si ha: end (C 0 ) < end (C) Dimostrazione (semi-formale) Si hanno due casi. • Il primo nodo visitato di C e C 0 è in C, sia u. Allora devono essere visitati tutti i nodi di C e C 0 prima di terminare la visita di u, quindi: end (C 0 ) < end (u) = end (C) • Il primo nodo visitato di C e C 0 è in C 0 . Allora, dato che non può esistere un cammino da C 0 a C, la visita attraversa tutti i nodi di C 0 prima di qualunque nodo di C, ossia la visita dei nodi di C inizierà dopo, e terminerà anche dopo, la visita dei nodi di C 0 , quindi: end (C 0 ) < end (C). Teorema 4.10 In una visita in profondità di un grafo orientato il nodo avente il massimo tempo di fine visita appartiene a una componente fortemente connessa sorgente. Dimostrazione Sia u il nodo avente il massimo tempo di fine visita. Se la componente fortemente connessa C cui appartiene u non fosse una sorgente, ci sarebbe un arco da un altra componente fortemente connessa C 0 a C. Allora, per il lemma precedente, avremmo end (C 0 ) < end (C), e quindi end (u) non sarebbe il massimo tempo di fine visita. Teorema 4.11 In una visita in profondità di un grafo orientato la visita di un nodo appartenente a una componente fortemente connessa pozzo C visita tutti e soli i nodi di C. Dimostrazione Ovvio, perché tutti i nodi raggiungibili vengono visitati e non c’è nessun arco uscente da una componente fortemente connessa pozzo. Si noti che non vale la proprietà simmetrica di quella del Teorema 4.10, ossia il nodo avente il minimo tempo di fine visita non appartiene necessariamente a una componente fortemente connessa pozzo. Non è quindi possibile con una visita in profondità di un grafo trovare le componenti fortemente connesse pozzo. Osservazione: una componente fortemente connessa sorgente in un grafo orientato G è una componente fortemente connessa pozzo nel grafo trasposto G T , cioè nel grafo che si ottiene da G invertendo l’orientamento degli archi. Dalle precedenti proprietà è facilmente ricavabile un algoritmo per trovare le componenti fortemente connesse di un grafo G: 54 • si effettua una visita in profondità inserendo i nodi in una sequenza Ord in ordine di fine visita • si estrae via via da Ord l’ultimo nodo u, per il Teorema 4.10 u si trova in una componente fortemente connessa sorgente nel grafo ottenuto da G non considerando le componenti fortemente connesse precedenti • per trovare tutti i nodi di tale componente fortemente connessa, che per l’osservazione sopra è una componente fortemente connessa pozzo nel grafo trasposto, per il Teorema 4.11 basta effettuare una visita dei nodi (non ancora visitati) raggiungibili da u nel grafo trasposto G T • non è necessario durante le visite modificare i grafi G e G T perché basta, come al solito, marcare i nodi visitati. Più concretamente: SCC(G) DFS(G,Ord) //aggiunge i nodo visitati a Ord in ordine di fine visita GT = grafo trasposto di G Ord↔ = sequenza vuota //ordine topologico delle c.f.c. while (Ord non vuota) u = ultimo nodo non visitato in Ord //si trova in una c.f.c. sorgente C = insieme di nodi vuoto DFS(GT ,u,C) //aggiunge nodi visitati in C Ord↔ .add(C) //aggiunge in fondo return Ord↔ La sequenza delle componenti fortemente connesse ottenute è in ordine topologico, perché ogni volta si individua una componente fortemente connessa a cui arriva un arco da una delle precedenti. Se il grafo è aciclico le componenti fortemente connesse sono i singoli nodi, quindi l’algoritmo restituisce semplicemente un ordine topologico dei nodi. Complessità: • visita in profondità del grafo: O(n + m) • generazione del grafo trasposto: O(n + m) • successive visite del grafo trasposto: O(n + m) quindi complessivamente O(n + m). 5 Teoria della NP-completezza Significa “Non deterministic Polinomial time” perché la classe NP fu originariamente studiata nel contesto degli algoritmi non deterministici. Noi utilizzeremo una nozione equivalente più semplice, quella di verifica. I problemi vengono suddivisi in termini di complessità computazionale in due classi principali: • quelli risolvibili con un algoritmo polinomiale (T (n) = O(nk )) sono considerati trattabili (facili) • quelli per cui non esiste un algoritmo polinomiale (T (n) = Ω(k n )) non trattabili (difficili). Questo per considerazioni di ordine “pratico”: • solitamente le complessità O(nk ) hanno k piccoli, e possono essere ulteriormente migliorate • per diversi modelli di calcolo, un problema che può essere risolto in tempo polinomiale in un modello, può esserlo anche in un altro • la classe dei problemi risolvibili in tempo polinomiale ha interessanti proprietà di chiusura, in quanto i polinomi sono chiusi per addizione, moltiplicazione e composizione. Quindi per esempio se l’output di un algoritmo polinomiale è utilizzato come input per un altro l’algoritmo composto è polinomiale. Ci sono anche algoritmi dei quali non si conosce la complessità. Tipicamente, non si è ancora riusciti a dimostrare che T (n) = O(nk ) oppure T (n) = Ω(k n ). Informalmente, la classe P è quella dei problemi risolvibili in tempo polinomiale, la classe NP è quella dei problemi verificabili in tempo polinomiale. Risolvere un problema significa che data una sua istanza ne fornisco una soluzione. Verificare un problema significa che data una sua istanza e una sua soluzione (certificato), controllo se la soluzione risolve effettivamente l’istanza del 55 problema. Ne consegue che P ⊆ NP, perché posso usare un qualsiasi algoritmo di P per generare la soluzione e confrontarla con un certificato. Quello che non si sa è se P = NP, oppure P ⊂ NP, ossia se ci sono problemi verificabili in tempo polinomiale che non sono risolvibili in tempo polinomiale. Sappiamo risolvere i problemi NP solo in tempo esponenziale, ma non possiamo escludere che esistano algoritmi in tempo polinomiale per risolverli. All’interno della classe NP vi è una classe di problemi ritenuti “più difficili” di tutti gli altri. Questa è la classe dei problemi NP-completi (NP-C). I problemi NP-C godono di un’importante proprietà: se si scoprisse un algoritmo che risolve uno di questi problemi in tempo polinomiale, tutti i problemi di NP-C (e come conseguenza tutti quelli di NP) sarebbero risolvibili in tempo polinomiale. Si dimostrerebbe quindi che P = NP. Nel seguito: • formalizziamo la nozione di problema • mostriamo come astrarre dal particolare linguaggio usato per descrivere il problema • formalizziamo le classi P, NP, NP-C • troviamo un (primo) problema in NP-C • descriviamo altri problemi in NP-C. 5.1 Problemi astratti e concreti e classe P Def. 5.1 Un problema (astratto) è una relazione P ⊆ I × S , dove I è l’insieme degli input (o istanze del problema) e S è l’insieme delle soluzioni. In generale per ogni istanza la soluzione può non essere unica (per esempio può esserci più di un cammino minimo). Def. 5.2 Un problema (astratto) di decisione P è un problema (astratto) in cui ogni input ha come soluzione vero oppure falso, ossia P : I → {T , F }. Problemi di ricerca sono quelli in cui si cerca una soluzione. Problemi di ottimizzazione sono quelli in cui ci sono diverse soluzioni e se ne cerca una che sia “ottima” (per esempio, il minimo albero ricoprente, oppure il cammino minimo). La teoria della complessità computazionale si concentra sui problemi di decisione, in quanto in genere, per ogni problema di altro tipo P, è possibile dare un problema di decisione Pd tale che risolvendo il primo si sappia risolvere il secondo. Nel caso di un problema di ricerca si restituisce vero se e solo se una soluzione esiste, nel caso di un problema di ottimizzazione si pone una limitazione al valore da ottimizzare. Per esempio: trovare il cammino minimo tra una coppia di nodi in un grafo è un problema di ottimizzazione. Un problema di decisione collegato è il seguente: dati due nodi determinare se esiste un cammino lungo al più k. Infatti se abbiamo un algoritmo per risolvere il primo problema, un algoritmo che risolve il secondo si ottiene eseguendo il primo e poi confrontando il valore minimo ottenuto con k. In altri termini il problema di decisione Pd è “più facile” del problema originale P, quindi se proviamo che Pd è “difficile” indirettamente proviamo che lo è anche P. Da ora in poi quindi parleremo semplicemente di “problema” intendendo problema di decisione. Dato un problema P : I → {T , F }, diciamo che un algoritmo A risolve P se, per ogni input i ∈ I , A(i ) = P(i ). Un problema P è nella classe Time(f (n)) se e solo se esiste un algoritmo di complessità temporale O(f (n)) che lo risolve, dove n è la dimensione dell’input. Analogamente, P è nella classe Space(f di complessità spaziale S S (n)) se e solo se esiste un algoritmo S c O(f (n)) che lo risolve. Sia P = c≥0 Time(nc ), PSpace = c≥0 Space(nc ), ExpTime = c≥0 Time(2n ). È facile vedere che P ⊆ PSpace in quanto un algoritmo che impiega un tempo polinomiale può accedere al più a un numero polinomiale di locazioni di memoria. Si può provare inoltre che PSpace ⊆ ExpTime (intuitivamente, assumendo per semplicità c le locazioni di memoria binarie, nc locazioni di memoria possono trovarsi in al più 2n stati diversi). Non si sa se queste inclusioni siano strette (sono problemi aperti). Sappiamo invece che P ⊂ ExpTime, ossia che esiste (almeno) un problema che può essere risolto in tempo esponenziale ma non polinomiale. Notiamo che la definizione precedente è semi-formale, perché non essendoci un formato preciso per l’input parliamo genericamente di “dimensione”. Per essere più precisi, possiamo uniformare tutti i possibili input codificandoli come stringhe binarie. Def. 5.3 Un problema concreto P è un problema il cui insieme di istanze è l’insieme delle stringhe binarie, ossia P : {0, 1}? → {T , F }. È equivalente considerare qualunque alfabeto con cardinalità almeno 2. Un problema astratto P può essere rappresentato in modo concreto tramite una codifica, ossia una funzione iniettiva: 56 c : I → {0, 1}? Il problema concreto c(P) è definito da c(P)(x ) = T se e solo se x = c(i ) e P(i ) = T , ossia assumiamo convenzionalmente che la soluzione sia falso sulle stringhe che non sono codifica di nessun input. Tuttavia, codifiche diverse possono portare a tempi computazionali diversi. Supponiamo per esempio che l’input i di P sia un numero naturale e che il tempo di esecuzione dell’algoritmo sia Θ(i ), per esempio un algoritmo che controlla se un numero è primo dividendolo per tutti i precedenti. Allora, se il numero è codificato nell’usuale formato binario si ha |c(i )| = blog i c + 1 e quindi la complessità dell’algoritmo in funzione della stringa è esponenziale, mentre se è codificato in formato unario la complessità è polinomiale. Possiamo raggruppare tutte le codifiche che producono la stessa complessità computazionale. Se esistono due funzioni f12 e f21 calcolabili in tempo polinomiale tali che: • se f12 (x ) = y, x = c1 (i ) se e solo se y = c2 (i ) • se f21 (y) = x , x = c1 (i ) se e solo se y = c2 (i ) allora c1 e c2 si dicono correlate polinomialmente. Se c1 e c2 sono correlate polinomialmente, allora usare una o l’altra è equivalente. Lemma 5.4 Sia P un problema astratto e siano c1 , c2 due codifiche di P correlate polinomialmente. Allora c1 (P) è risolvibile in tempo polinomiale se e solo se c2 (P) è risolvibile in tempo polinomiale. Dimostrazione Dimostriamo un’implicazione, l’altra è simmetrica. Dato un algoritmo in tempo polinomiale A1 che risolve c1 (P), un algoritmo in tempo polinomiale A2 che risolve c2 (P) può essere ottenuto eseguendo, a partire da y = c2 (i ), prima l’algoritmo in tempo polinomiale che calcola x = f21 (y) = c1 (i ), poi A1 (x ). Si noti che |x | = O(|y|) perché essendo l’algoritmo polinomiale l’output è anch’esso polinomiale, quindi A2 è polinomiale anche rispetto a |y|. L’algoritmo A2 risolve c2 (P) in quanto per y = c2 (i ) si ha A2 (y) = A1 (c1 (i )) = P(i ). Se non esiste un input i tale che y = c2 (i ), non esiste neanche per x e quindi si ha A2 (y) = A1 (x ) = F . Come visto sopra, una descrizione artificiosamente lunga può far apparire la complessità di un algoritmo più bassa. Al limite, la codifica potrebbe contenere al suo interno (parte della) soluzione, per esempio nella codifica di un grafo potrei includere tutti i suoi cicli semplici. Diremo che una codifica è ragionevole se questo non succede, quindi non sono introdotti dati irrilevanti e non vi è una generazione esponenziale di dati per la rappresentazione di un’istanza del problema. Assumeremo d’ora in poi che le codifiche siano ragionevoli, in particolare che la codifica di un intero sia correlata polinomialmente alla sua rappresentazione binaria, la codifica di un insieme sia correlata polinomialmente alla lista delle codifiche degli elementi con opportuni separatori, e da queste si derivino codifiche ragionevoli per altri oggetti matematici come n-uple, grafi e formule. Un problema concreto P è nella classe Time(f (n)) se e solo se esiste un algoritmo di complessità temporale O(f (n)) che lo risolve, dove n = |x | è la lunghezza dell’input. Analogamente, P è nella classe Space(f (n)) se e solo se esiste un algoritmo di complessità spaziale f (n) che lo risolve. Un problema di decisione concreto può anche essere visto come un linguaggio sull’alfabeto {0, 1}, ossia il sottoinsieme formato da tutte le stringhe con soluzione vero. Def. 5.5 Data una stringa x ∈ {0, 1}? , un algoritmo A accetta x se A(x ) = T , rifiuta x se A(x ) = F . Si noti che stringa non accettata non significa stringa rifiutata perché l’algoritmo potrebbe non terminare. Def. 5.6 Un algoritmo A accetta un linguaggio P se accetta ogni stringa del linguaggio, ossia x ∈ P implica A(x ) = T . Un algoritmo A decide un linguaggio P se accetta ogni stringa del linguaggio e rifiuta ogni altra stringa, ossia x ∈ P implica A(x ) = T , x 6∈ P implica A(x ) = F . Possiamo quindi definire equivalentemente P come la classe dei linguaggi P per cui esiste un algoritmo polinomiale che decide P. Si prova che P è anche la classe dei linguaggi per cui esiste un algoritmo polinomiale che accetta P. Infatti, dato un algoritmo A che accetta P in tempo limitato da p polinomio fissato, un algoritmo che risolve P può essere costruito nel modo seguente: data un’istanza x di P di lunghezza n, simuliamo l’algoritmo A per il tempo p(n). Trascorso questo tempo, se A ha accettato x allora la soluzione è vero, altrimenti falso. 5.2 Classe NP Introduciamo ora il significato della classe NP con degli esempi. Consideriamo le formule su un insieme di variabili definite dalla seguente sintassi: 57 x φ :: = . . . :: = x | φ | φ1 ∧ φ2 | φ1 ∨ φ2 | ∃x .φ | ∀x .φ In particolare, le formule in forma normale congiuntiva (CNF) e le formule quantificate sono definite nel modo seguente: l c φCNF φQ :: = :: = :: = :: = x |x l1 ∨ . . . ∨ ln c1 ∧ . . . ∧ cn φCNF | ∃x .φQ | ∀x .φQ letterale clausola formula in CNF formula quantificata Problema della soddisfacibilità SAT : data una formula in forma normale congiuntiva, determinare se esiste un’assegnazione di valori di verità alle variabili che la renda vera. Problema delle formule quantificate: data una formula quantificata, determinare se è vera. Entrambi i problemi possono essere facilmente risolti in tempo esponenziale (possiamo prendere come dimensione dell’istanza del problema il numero di variabili). eval(phi) return eval(phi,empty) eval(exists x.phi,env) return eval(phi,env.add(x,true)) or eval(phi,env.add(x,false)) eval(forall x.phi,env) = return eval(phi,env.add(x,true) and eval(phi,env.add(x,false)) ... sat(phi) //con variabili x_1 ... x_n return eval(exists x_1. ... exist x_n.phi) Anzi sono in PSpace in quanto usano un numero lineare di locazioni di memoria. Questo esempio mostra come spesso un algoritmo di decisione generi, in caso positivo, una “prova”, detta certificato, che dimostra la verità della proprietà da verificare, per esempio, nel caso della soddisfacibilità, l’assegnazione di valori alle variabili che rende vera la formula. Mentre nel caso della soddisfacibilità verificare la validità di un certificato è facile, nel caso delle formule quantificate il “certificato” stesso consta di un numero esponenziale di assegnazioni di valori di verità a variabili. Questo suggerisce l’idea di usare come classificazione la difficoltà di verificare se un certificato è valido per un problema. Informalmente, definiamo NP la classe dei problemi che ammettono certificati verificabili in tempo polinomiale. Per esempio, dato che possiamo verificare in tempo polinomiale se un’assegnazione di valori alle variabili rende vera una formula in forma normale congiuntiva, il problema della soddisfacibilità è nella classe NP. Non possiamo dire altrettanto per il problema delle formule quantificate: non è noto se sia in NP, ma si congettura che non lo sia. Un altro esempio: problema del ciclo hamiltoniano in un grafo non orientato. È un ciclo semplice che contiene ogni nodo (quindi passa esattamente una volta per ogni nodo). È facile vedere che questo problema è in NP (un certificato è un ciclo), mentre non è noto un algoritmo polinomiale che lo risolva. Def. 5.7 Un algoritmo di verifica per un problema (astratto) P ⊆ I è un algoritmo A : I × C → {T , F }, dove C è un insieme di certificati, ed esiste un certificato y tale che A(x , y) = T se e solo se x ∈ P. Nel caso dei problemi concreti, si ha I = C = {0, 1}? . Il linguaggio verificato da A è l’insieme delle stringhe verificate da A, ossia {x | ∃y.A(x , y) = T }. Quindi un algoritmo verifica un linguaggio se per ogni stringa del linguaggio esiste un certificato valido, e non esiste per stringhe che non vi appartengono. Per esempio nel caso del grafo hamiltoniano se non lo è non è possibile esibire un certificato. La classe NP è quindi la classe dei linguaggi che possono essere verificati da un algoritmo polinomiale. Più precisamente: Def. 5.8 Un linguaggio P è nella classe NP se e solo se esistono un algoritmo di verifica polinomiale A e una costante c > 0 tali che P = {x | ∃y.A(x , y) = T , |y| = O(|x|c )} sat_ver(phi,env) return eval(phi,env) 58 Equivalentemente, si può anche definire NP come la classe dei problemi per cui esiste un algorimo polinomiale non deterministico che li risolve, tale cioè che se la risposta è positiva ci sia almeno una possibile computazione che dia risposta positiva. sat_non_det(phi) //con variabili x_1 ... x_n b_1 = true or b_1 = false ... b_n = true or b_n = false return eval(phi, x_1 -> b_1, ..., x_n -> b_n) Legame con la verifica in tempo polinomiale (informalmente): un algoritmo non deterministico può sempre essere visto come composto di due fasi (vedi esempio sopra): • una prima fase non deterministica che costruisce un certificato • una seconda fase deterministica che controlla se questo è un certificato valido. È facile verificare che P ⊆ NP, perché un algoritmo deterministico è un caso particolare di algoritmo non deterministico, oppure equivalentemente è un caso particolare di algoritmo di verifica in cui si ignora il certificato. Inoltre, la fase deterministica di verifica può essere eseguita in tempo polinomiale solo se il certificato ha dimensione polinomiale, quindi NP ⊆ PSpace. Si congettura che entrambe le inclusioni siano proprie, ossia P ⊂ NP ⊂ PSpace ⊂ ExpTime, ma questo non è stato dimostrato. 5.3 Problemi NP-completi I problemi NP-completi sono i “più difficili” tra i problemi in NP. Ossia, se per qualcuno di questi problemi si trovasse un algoritmo polinomiale, se ne troverebbe uno per tutti quelli in NP, e quindi si dimostrerebbe che P = NP. Infatti ognuno dei problemi in NP è riducibile a un problema NP-completo, nel senso formalizzato sotto. Def. 5.9 Dati due problemi concreti P1 e P2 , diciamo che P1 è riducibile polinomialmente a P2 , e scriviamo P1 ≤P P2 , se esiste una funzione f : {0, 1}? → {0, 1}? , detta funzione di riduzione, calcolabile in tempo polinomiale, tale che, per ogni x ∈ {0, 1}? , x ∈ P1 se e solo se f (x) ∈ P2 . Per esempio, il problema della soddisfacibilità è polinomialmente riducibile a quello delle formule quantificate. Lemma 5.10 Dati due problemi concreti P1 e P2 tali che P1 ≤P P2 , se P2 ∈ P allora anche P1 ∈ P. Dimostrazione Dato un algoritmo in tempo polinomiale A2 che risolve P2 , un algoritmo in tempo polinomiale A1 che risolve P1 può essere ottenuto eseguendo, a partire da x , prima l’algoritmo in tempo polinomiale che calcola f (x ), poi A2 (f (x )). Analogamente a quanto visto per il Lemma 5.4 l’algoritmo A1 risulta polinomiale. L’algoritmo A1 risolve P1 in quanto A1 (x ) = A2 (f (x )) = P2 (f (x )) = P1 (x ). La definizione può essere generalizzata al caso in cui per risolvere P1 si usa un “piccolo” numero di chiamate a un algoritmo che risolve P2 . Questa generalizzazione semplifica molte riduzioni. Per esempio, possiamo far vedere che il problema di trovare se in un grafo esiste un ciclo hamiltoniano, ossia un ciclo che visita ogni vertice esattamente una volta, può essere ridotto al problema di trovare se esiste un cammino semplice di lunghezza k tra due nodi di un grafo. Infatti, per verificare se in un grafo esiste un ciclo hamiltoniano, basta verificare che esista un arco u, v tale che u e v siano connessi da un cammino semplice di lunghezza n − 1. In questo caso l’algoritmo per trovare se esiste il cammino viene richiamato al più tante volte quanto il numero degli archi, ma la riduzione rimane polinomiale. Def. 5.11 Un problema concreto P si dice NP-arduo o NP-difficile (in inglese NP-hard) se per ogni problema Q ∈ NP si ha Q ≤P P , si dice NP-completo se appartiene alla classe NP ed è NP-arduo. Indichiamo con NP-C la classe dei problemi NP-completi. Nota: esistono problemi NP-ardui ma non appartenenti alla classe NP. Teorema 5.12 Se un qualunque problema NP-completo appartiene alla classe P, allora si ha P = NP, o, equivalentemente, se è P 6= NP, quindi esiste almeno un problema in NP non risolvibile in tempo polinomiale, allora nessun problema NP-completo è risolvibile in tempo polinomiale. 59 Dimostrazione Ovvio in base al precedente lemma. ? Quindi, per risolvere il problema aperto P = NP, basterebbe, per un qualunque problema in NP-C, trovare un algoritmo polinomiale o provare che non ne esiste uno. Per dimostrare che un problema è in NP basta mostrare un algoritmo polinomiale non deterministico che lo risolve, o un algoritmo polinomiale che lo verifica. Come possiamo invece dimostrare che un problema è NP-completo? Una volta dimostrato che almeno un problema, sia P, è NP-completo, possiamo provare che un altro lo è mostrando la riducibilità di P a questo problema, infatti in questo modo anche il nuovo problema risulta NP-completo per la transitività della relazione di riducibilità. Bisogna quindi trovare un “primo” problema di cui dimostrare la NP-completezza. Nota metodologica: provare che un problema è NP-completo è utile, perché in tal caso è opportuno cercare un algoritmo approssimato o ridursi a un sottoproblema per cui si possa trovare un algoritmo polinomiale. Storicamente, il primo problema di cui è stata provata la NP-completezza (da Stephen Cook nel 1971) è il problema della soddisfacibilità. Teorema 5.13 [Teorema di Cook] SAT è NP-completo. Non diamo la dimostrazione del teorema di Cook. L’idea è la seguente: si definisce un algoritmo che, dato un problema P ∈ NP e un input x per P, costruisce una formula in forma normale congiuntiva che descrive un algoritmo non deterministico per P, ossia la formula è soddisfacibile se e solo se l’algoritmo restituisce T . Dimostriamo invece la NP-completezza di un altro problema per cui la prova è più semplice. Def. 5.14 Dati (la descrizione di) un algoritmo (di verifica) hAv i, una stringa x e una stringa 1t , il problema dell’arresto limitato (bounded halting problem) BH richiede di decidere se Av verifica x in al più t passi. Teorema 5.15 BH è NP-completo. Dimostrazione È facile vedere che BH appartiene a NP, in quanto si può costruire un algoritmo di verifica AvBH che, sull’input ((hAv i, x , 1t ), y), simula Av su (x , y) per t passi. Nota: occorre assumere che t sia rappresentato in notazione unaria in modo che il tempo sia proporzionale alla dimensione dell’input. Dobbiamo ora dimostrare che ogni problema P ∈ NP è riducibile polinomialmente a BH . Se P ∈ NP, allora esiste un algoritmo polinomiale di verifica AvP per P, ossia tale che, per ogni input x , esiste un certificato y con |y| = O(|x |) e AvP (x , y) = T se e solo se x ∈ P. Siano p e q i polinomi fissati che esprimono, rispettivamente, il tempo di esecuzione di AvP in funzione della lunghezza dell’input e la lunghezza di y in funzione di quella di x . Possiamo ridurre P al problema dell’arresto limitato attraverso la seguente funzione di riduzione: fP (x ) = (hAvP i, x , 1p(|x|+q(|x |)) ) Questa funzione è calcolabile con un algoritmo polinomiale in quanto hAvP i è una stringa costante. L’algoritmo si limita a costruire una tripla formata da questa stringa costante, l’input originale x e un numero polinomiale di 1. Si ha inoltre che: x ∈P⇔ AvP verifica x in t = p(|x| + q(|x |)) passi ⇔ (hAP i, x , 1t ) ∈ BH . Quindi, se avessi un algoritmo polinomiale ABH che risolve BH , otterrei un algoritmo polinomiale AP per qualunque problema P in NP nel modo seguente: dato un input x , si richiama l’algoritmo ABH fornendogli in input la descrizione di un algoritmo di verifica hAvP i per P, l’input x e 1t con t = p(|x| + q(|x |)) dove p e q sono i polinomi che esprimono la lunghezza di un certificato e il tempo di esecuzione per hAvP i. Come anticipato, in genere le dimostrazioni di NP-completezza non si fanno in questo modo diretto, ma facendo vedere che il problema si riduce a un altro problema NP-completo. Vediamo degli esempi. Def. 5.16 Data una formula in 3CNF, ossia in CNF e tale che ogni clausola sia la disgiunzione di esattamente tre letterali, il problema della 3-soddisfacibilità 3SAT richiede di verificare se esiste un’assegnazione di valori di verità alle variabili che rende la formula vera. È un caso particolare del problema di soddisfacibilità, in cui il numero di letterali in ogni clausola è esattamente tre. Nonostante questa limitazione si può provare che il problema rimane NP-completo. Questo problema è interessante perché è più facile da ridurre in un altro rispetto alla forma generale. Teorema 5.17 3SAT è NP-completo. 60 Dimostrazione L’appartenenza a NP è ovvia essendo un caso particolare di SAT . Per dimostrare che è NP-arduo, diamo una riduzione di SAT in 3SAT , ossia mostriamo che ogni formula CNF può essere trasformata in una formula 3CNF. Anzitutto, scegliamo tre variabili nuove y1 , y2 , y3 e aggiungiamo alla formula CNF sette nuove clausole che sono tutte le possibili combinazioni dei corrispodenti letterali tranne y1 ∨ y2 ∨ y3 , in modo tale che l’unica assegnazione di valori a queste variabili che rende vera la loro congiunzione sia y1 = y2 = y3 = T , ossia: y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 , y1 ∨ y2 ∨ y3 . Poi trasformiamo ogni clausola c nella formula in una congiunzione di clausole ognuna di esattamente tre letterali. • Se c = l , si sostituisce c con l ∨ y1 ∨ y2 . • Se c = l1 ∨ l2 , si sostituisce c con l1 ∨ l2 ∨ y1 . • Se c = l1 ∨ l2 ∨ c 0 con c 0 disgiunzione di almeno un letterale: – se c 0 è un solo letterale non si fa nulla – altrimenti, si introduce una nuova variabile yc , e si sostituisce c con yc ∨ c 0 , l1 ∨ l2 ∨ yc , l1 ∨ yc ∨ y1 , l2 ∨ yc ∨ y1 . Le ultime tre clausole implicano che l1 ∨ l2 è equivalente a yc , quindi c è equivalente a yc ∨ c 0 . A questo punto, si riapplica induttivamente il procedimento a yc ∨ c 0 . Per costruzione la congiunzione di tutte le nuove clausole è equivalente alla formula di partenza, e la trasformazione può essere effettuata in tempo polinomiale. Quindi SAT ≤P 3SAT . Si può provare che numerosi altri problemi sono NP-completi usando il problema della 3-soddisfacibilità. Vediamo due esempi: il problema della clique e il problema dell’insieme indipendente. Def. 5.18 Una clique o cricca in un grafo orientato G = (V , E ) è un insieme V 0 ⊆ V di nodi tale che per ogni coppia di essi esiste l’arco che li collega, ossia il sottografo indotto da V 0 è completo. La dimensione di una clique è il numero dei suoi nodi. Il problema della clique richiede di trovare una clique di dimensione massima in un grafo. Il corrispondente problema di decisione richiede di determinare se nel grafo esiste una clique di dimensione k. L’algoritmo banale consiste nell’esaminare tutti i possibili sottoinsiemi di nodi di dimensione k, e ha complessità k 2 nk se n è il numero di nodi, che risulta esponenziale se k è funzione di n. Teorema 5.19 Il problema della clique è NP-completo. Dimostrazione Un certificato per il problema della clique è una clique di dimensione k. È facile verificare polinomialmente questo certificato, e questo prova che il problema è in NP. Per dimostrare che il problema è NP-arduo, diamo una riduzione di 3SAT in questo problema nel modo seguente. Data una formula 3CNF φ formata di k clausole, costruiamo il grafo Gφ nel modo seguente: • un nodo per ogni occorrenza di letterale in una clausola, quindi 3k nodi • un arco tra due occorrenze di letterali se sono in due clausole diverse e non sono uno la negazione dell’altro. Mostriamo che φ è soddisfacibile se e solo se Gφ contiene una clique di k nodi. ⇒ Se φ è soddisfacibile, significa che esiste un’assegnazione di valori alle variabili tale che almeno un letterale per ogni clausola risulta vero. Consideriamo i nodi di Gφ corrispondenti ai letterali scelti. Esiste un arco che collega ogni coppia di questi nodi, perché sono in clausole diverse e non sono uno la negazione dell’altro, altrimenti non potrebbero essere contemporaneamente veri. Abbiamo quindi trovato una clique di dimensione k. ⇐ Se Gφ contiene una clique di k nodi, dato che non vi sono archi tra nodi che rappresentano letterali nella stessa clausola, sappiamo che la clique contiene esattamente un nodo per ogni clausola, siano l1 , . . . , lk i corrispondenti letterali. Scegliamo un’assegnazione di valori alle variabili tale che l1 , . . . , lk risultino veri, ciò è possibile in quanto sappiamo che tra di essi non vi sono due letterali che sono uno la negazione dell’altro, altrimenti non vi sarebbe un arco. Con questa assegnazione ogni clausola è soddisfatta, e quindi l’intera formula. 61 Def. 5.20 Un insieme indipendente in un grafo G = (V , E ) è un insieme V 0 ⊆ V di nodi tale che per ogni coppia di essi non esiste l’arco che li collega, ossia tale che il sottografo indotto da V 0 non ha archi. La dimensione di un insieme indipendente è il numero dei suoi nodi. Il problema dell’insieme indipendente richiede di trovare un insieme indipendente di dimensione massima in un grafo. Il corrispondente problema di decisione richiede di determinare se nel grafo esiste un insieme indipendente di dimensione k. Teorema 5.21 Il problema dell’insieme indipendente è NP-completo. Dimostrazione Un certificato per il problema dell’insieme indipendente è un insieme indipendente di k nodi. È facile verificare polinomialmente questo certificato, e questo prova che il problema è in NP. Per dimostrare che il problema è NP-arduo, definiamo una riduzione dal problema della clique nel modo seguente. Dato un grafo G consideriamo il grafo G che ha lo stesso insieme di nodi e tale che, per ogni coppia di nodi (u, v ), in G esiste l’arco (u, v ) se e solo se in G non esiste. È immediato vedere che un insieme di nodi definisce una clique in G se e solo se definisce un insieme indipendente in G 0 . Si conoscono ormai moltissimi problemi NP-completi, tra questi citiamo: • commesso viaggiatore: dato un grafo non orientato completo pesato, trovare un ciclo hamiltoniano di costo minimo (nella versione di decisione stabilire se esiste un ciclo hamiltoniano di costo al più k) • zaino: dato uno “zaino” con una certa capacità ed n oggetti a cui sono associati dei pesi, trovare il sottoinsieme di peso massimo che sia possibile mettere nello zaino (nella versione di decisione trovare se esiste un sottoinsieme di peso ≥ k) • copertura di vertici: dato un grafo non orientato G = (V , E ) trovare una copertura di vertici (vertex cover) di dimensione minima per G, ossia un un insieme V 0 ⊆ V di nodi tale che per ogni arco (u, v ) almeno un estremo appartenga a V 0 (nella versione di decisione stabilire se esiste una copertura di vertici di dimensione k) • colorazione: dato un grafo G = (V , E ) trovare il minimo k per cui una k-colorazione dei nodi di G, ossia una funzione c : V → 1..k tale che c(u) 6= c(v ) se (u, v ) ∈ E (nella versione di decisione stabilire se esiste una k-colorazione). 5.4 Algoritmi di approssimazione Nel caso di problemi di ottimizzazione la cui versione decisionale sia NP-completa, può essere non strettamente necessario trovare una soluzione ottima. Vediamo come misurare la distanza tra una soluzione “quasi ottima” e una soluzione ottima. Def. 5.22 Sia P un problema di ottimizzazione. Diciamo che un algoritmo di approssimazione per P ha fattore di approssimazione ρ(n) (≥ 1) se: • in un problema di minimizzazione (C ∗ ≤ C) si ha C ≤ C ∗ ρ(n) • in un problema di massimizzazione (C ≤ C ∗ ) si ha C ≤ C ∗ ρ(n). Un modo per riassumere i due requisiti è dire che ∗ max{ CC∗ , CC } ≤ ρ(n) ossia il costo C della soluzione prodotta dall’algoritmo su un input di dimensione n differisce di un fattore moltiplicativo ≤ ρ(n) dal costo C ∗ di una soluzione ottima. Gli algoritmi esatti sono quelli per i quali è esattamente ρ(n) = 1. Il fattore di approssimazone ρ(n) è in genere una funzione della dimensione n dell’input, ma a volte è possibile dare algoritmi di approssimazione con fattore di approssimazione costante, ed esistono persino problemi che possono essere approssimati con qualunque fattore di approssimazione > 0, al prezzo di un tempo di esecuzione più alto (tipicamente, inversamente proporzionale a ). Altri problemi invece sono difficili anche da approssimare, per esempio la colorazione: si è dimostrato che se esistesse un algoritmo di approssimazione per il problema della colorazione con fattore di approssimazione ≤ 43 , allora risulterebbe P = NP. Diamo un esempio di algoritmo di approssimazione che risolve un problemi su grafi con fattore di approssimazione costante. Copertura di vertici Abbiamo visto che si tratta di un problema NP-completo. È possibile tuttavia dare un algoritmo approssimato molto semplice: vertex_cover(G) C = insieme di nodi vuoto //copertura E = insieme archi di G while (E non vuoto) //Inv: C copertura per archi non in E estrai da E un arco (u,v) 62 C.add(u); C.add(v) for each ((u,w) arco in G) E = E - (u,w) for each ((v,w) arco in G) E = E - (v,w) return C Infatti, ogni volta che scegliamo i due estremi u e v di un arco, con essi copriamo non solo l’arco (u, v ) ma anche tutti gli altri archi che hanno u o v come estremi, quindi questi possono essere eliminati. Questo algoritmo ha complessità O(n + m) se n e m sono rispettivamente il numero dei nodi e degli archi. Possiamo provare che l’algoritmo ha fattore di approssimazione 2 nel modo seguente: sia A l’insieme degli archi che vengono estratti dall’algoritmo. Una qualsiasi copertura di vertici, inclusa la copertura ottima C ∗ , deve contenere almeno un estremo di ognuno di questi archi. Due archi in A non hanno mai un estremo comune, perché ogni volta che si estrae un arco si eliminano tutti quelli con un estremo comune. Quindi non è possibile coprire due archi in A con lo stesso nodo in C ∗ , quindi si ha: |A| ≤ |C ∗ |. D’altra parte, si ha |C| = 2|A| perché ogni volta che si estrae un arco si inseriscono in C i suoi due estremi, che sicuramente non erano già in C. Quindi |C| ≤ 2|C ∗ |. Si noti la metodologia: si riesce a dimostrare che il fattore di approssimazione è 2 anche se non si conosce la soluzione ottima, in quanto si utilizza la conoscenza di un limite inferiore per la soluzione ottima. A Relazioni d’ordine e di equivalenza Def. A.1 [Ordine parziale] Un ordine parziale su un insieme X è una relazione ≤ su X che sia: riflessiva x ≤ x antisimmetrica se x ≤ y e y ≤ x allora x = y transitiva se x ≤ y e y ≤ z allora x ≤ z. Un ordine parziale stretto su X è una relazione < su X che sia: antiriflessiva x 6< x, asimmetrica se x < y, allora y 6< x, transitiva se x < y e y < z allora x < z. Dato un ordine parziale ≤ a partire da questo possiamo definire il corrispondente ordine parziale stretto: x < y se e solo se x ≤ y e x 6= y, e viceversa dato un ordine parziale stretto < possiamo definire il corrispondente ordine parziale: x ≤ y se e solo se x < y oppure x = y. Quindi è indifferente dare l’uno o l’altro. Def. A.2 [Ordine totale] Un ordine totale su un insieme X è una relazione ≤ su X che sia un ordine parziale e inoltre sia: totale x ≤ y oppure y ≤ x. Analogamente è possibile definire un ordine totale stretto. Un esempio tipico di ordine totale è l’ordinamento usuale sui numeri naturali, interi o reali, mentre un esempio tipico di ordine parziale è la relazione tra nodo padre e nodo figlio in un albero (ci sono nodi non confrontabili). Def. A.3 [Equivalenza] Un’equivalenza su un insieme X è una relazione ∼ su X che sia: riflessiva x ∼ x simmetrica se x ∼ y allora y ∼ x transitiva se x ∼ y e y ∼ z allora x ∼ z. La classe di equivalenza di x ∈ X è l’insieme di tutti gli elementi equivalenti a x. Un elemento che appartiene a una certa classe di equivalenza si chiama anche rappresentante di tale classe. L’insieme delle classi di equivalenza è detto insieme quoziente di X rispetto a ∼. 63