Strutture Di Dati e Algoritmi

March 30, 2017 | Author: Manuel Alessio | Category: N/A
Share Embed Donate


Short Description

Download Strutture Di Dati e Algoritmi...

Description

Pierluigi Crescenzi Giorgio Gambosi Roberto Grossi

Strutture di dati e algoritmi Progettazione, analisi e visualizzazione

Ad Antonella, Paola e Susanna A Benedetta, Federica, Giorgia, Martina e Nicole

A Roberta

Sommario

Prefazione

XIII

1

Problemi computazionali 1.1 Indecidibilità di problemi computazionali 1.2 Trattabilità di problemi computazionali 1.2.1 Rappresentazione e dimensione dei dati 1.2.2 Algoritmi polinomiali ed esponenziali 1.3 Problemi NP-completi 1.4 Modello RAM e complessità computazionale

1 3 6 10 11 14 18

2

Sequenze: array 2.1 Sequenze lineari 2.1.1 Modalità di accesso 2.1.2 Allocazione della memoria 2.1.3 Array di dimensione variabile 2.2 Opus libri: scheduling della CPU 2.2.1 Ordinamento per selezione 2.2.2 Ordinamento per inserimento 2.3 Complessità di problemi computazionali 2.3.1 Limiti superiori e inferiori 2.4 Ricerca di una chiave 2.4.1 Ricerca binaria 2.4.2 Complessità della ricerca per confronti 2.5 Ricorsione e paradigma del divide et impera 2.5.1 Equazioni di ricorrenza e teorema fondamentale 2.5.2 Moltiplicazione veloce di due numeri interi 2.5.3 Ordinamento per fusione 2.5.4 Ordinamento e selezione per distribuzione 2.5.5 Alternativa al teorema fondamentale delle ricorrenze 2.6 Opus libri: grafica e moltiplicazione di matrici

23 23 24 25 27 28 30 31 32 36 37 37 40 40 42 43 46 49 54 55

2.7

3

Moltiplicazione veloce di due matrici

60

2.6.2

Sequenza ottima di moltiplicazioni e paradigma della programmazione dinamica

62

Paradigma della programmazione dinamica

69

2.7.1

Sicurezza dei sistemi e sotto-sequenza comune più lunga . . . .

71

2.7.2

Sistemi di backup e partizione di un insieme di interi

75

2.7.3

Problema della bisaccia

77

2.7.4

Pseudo-polinomialità e programmazione dinamica

80

Sequenze: liste

83

3.1

83

3.2

3.3 3.4

4

2.6.1

Liste 3.1.1

Ricerca, inserimento e cancellazione

3.1.2

Liste doppie e liste circolari

86

Opus libri: problema dei matrimoni stabili

89

3.2.1

Strutture di dati utilizzate

91

3.2.2

Implementazione dell'algoritmo

92

Liste randomizzate

4.2

4.3

Opus libri: gestione di liste ammortizzate e ad auto-organizzazione . . .

99

Unione e appartenenza a liste disgiunte

99

3.4.2

Liste ad auto-organizzazione

102

3.4.3

Tecniche di analisi ammortizzata

108 113

Alberi binari

113

4.1.1

Algoritmi ricorsivi su alberi binari

116

4.1.2

Inserimento e cancellazione

123

Opus libri: minimo antenato comune

125

4.2.1

Trasformazione da antenati comuni a minimi in intervalli . . . 1 2 7

4.2.2

Soluzione efficiente in spazio

Visita per ampiezza e rappresentazione di alberi •4.3.1

129 131

Rappresentazione implicita di alberi binari

133

Rappresentazione succinta per ampiezza

136

4.3.3

Implementazione di rank e select

138

4.3.4

Limite inferiore allo spazio delle rappresentazioni succinte . . . 142

4.3.2

4.4

94

3.4.1

Alberi 4.1

84

Alberi cardinali e ordinali, e parentesi bilanciate 4.4.1

143

Rappresentazione succinta mediante parentesi bilanciate . . . . 1 4 6

5

Dizionari 5.1 Dizionari 5.2 Liste e dizionari 5.3 Opus libri: funzioni hash e peer-to-peer 5.3.1 Tabelle hash: liste di trabocco 5.3.2 Tabelle hash: indirizzamento aperto 5.4 Opus libri: kernel Linux e alberi binari di ricerca 5.4.1 Alberi binari di ricerca 5.4.2 AVL: alberi binari di ricerca bilanciati 5.5 Opus libri: basi dati e B-alberi 5.6 Opus libri: liste invertite e trie 5.6.1 Trie o alberi digitali di ricerca 5.6.2 Trie compatti e alberi dei suffissi

6

Grafi 6.1 Grafi 6.1.1 Alcuni problemi su grafi 6.1.2 Rappresentazione di grafi 6.1.3 Cammini minimi, chiusura transitiva e prodotto di matrici 6.2 Opus libri: colorazione di grafi e algoritmi golosi 6.2.1 II problema dell'assegnazione delle lunghezze d'onda 6.2.2 Grafi a intervalli 6.2.3 Colorazione di grafi a intervalli 6.2.4 Massimo insieme indipendente in un grafo a intervalli 6.2.5 Paradigma dell'algoritmo goloso 6.3 Grafi casuali e modelli di reti complesse 6.3.1 Grafi casuali alla Erdòs-Rényi 6.3.2 Grafi casuali con effetto di piccolo mondo 6.3.3 Grafi casuali invarianti di scala 6.4 Opus libri: motori di ricerca e classificazione 6.4.1 Significatività delle pagine con PageRank 6.4.2 Significatività delle pagine con HITS 6.4.3 Convergenza del calcolo iterativo di PageRank e HITS

7

Pile e code 7.1 Pile 7.1.1 Implementazione di una pila mediante un array 7.1.2 Implementazione di una pila mediante una lista 7.2 Opus libri: Postscript e notazione postfissa

151 151 152 154 157 158 161 162 165 170 177 183 190 199 199 205 208 . .213 215 216 217 219 221 224 225 228 230 235 239 241 246 249

253 253 254 255 256

7.3

7.4

7.5

Code 7.3.1 Implementazione di una coda mediante un array 7.3.2 Implementazione di una coda mediante una lista Opus libri: Web crawler e visite di grafi 7.4.1 Visita in ampiezza di un grafo 7.4.2 Visita in profondità di un grafo Applicazioni delle visite di grafi 7.5.1 Grafi diretti aciclici e ordinamento topologico 7.5.2 Componenti (fortemente) connesse

259 260 260 261 262 267 270 270 273

8

Code con priorità 281 8.1 Code con priorità 281 8.2 Heap 283 8.2.1 Implementazione di uno heap implicito 285 8.2.2 Insolito caso di DecreaseKey 288 8.2.3 Costruzione di heap e ordinamento 289 8.3 Opus libri: routing su Internet e cammini minimi 293 8.3.1 Problema della ricerca di cammini minimi su grafi 295 8.3.2 Cammini minimi in grafi con pesi positivi 297 8.3.3 Cammini minimi in grafi pesati generali 302 8.4 Opus libri: data mining e minimi alberi ricoprenti 306 8.4.1 Problema della ricerca del minimo albero di ricoprimento . . . 3 0 8 8.4.2 Algoritmo di Kruskal 310 8.4.3 Algoritmo di Jarnik-Prim 312

9

NP-completezza 9.1 Problemi NP-completi 9.1.1 Classi P e NP 9.1.2 Riducibilità polinomiale 9.1.3 Problemi NP-completi 9.1.4 Teorema di Cook-Levin 9.1.5 Problemi di ottimizzazione 9.2 Esempi e tecniche di NP-completezza 9.2.1 Tecnica di sostituzione locale 9.2.2 Tecnica di progettazione di componenti 9.2.3 Tecnica di similitudine 9.2.4 Tecnica di restrizione 9.2.5 Come dimostrare risultati di NP-completezza 9.3 Algoritmi di approssimazione 9.4 Opus libri: il problema del commesso viaggiatore

317 317 318 322 324 326 328 329 329 331 334 334 335 337 338

9.4.1

Problema del commesso viaggiatore su istanze metriche

9.4.2

Paradigma della ricerca locale

. . . .341 344

A

Notazioni

351

B

Teorema delle ricorrenze

353

Indice analitico

355

Prefazione

Ottimi testi su algoritmi presumono che il lettore abbia già sviluppato una capacità di astrazione tale da poter recepire la teoria degli algoritmi con un taglio squisitamente matematico. Altri ottimi testi, ritenendo che il lettore abbia la capacità di intravedere quali sono gli schemi programmativi adatti alla risoluzione dei problemi, danno più spazio agli aspetti implementativi con un taglio pragmatico e orientato alla programmazione. Il nostro libro cerca di combinare questi due approcci, ritenendo che lo studente abbia già imparato i rudimenti della programmazione, ma non sia ancora in grado di astrarre i concetti e di riconoscere gli schemi programmativi da utilizzare. Mirato ai corsi dei primi due anni nelle lauree triennali, il testo segue un approccio costruttivistico che agisce a tre livelli, tutti strettamente necessari per un uso corretto del libro: • partendo da problemi reali, lo studente viene guidato a individuare gli schemi programmativi più adatti: gli algoritmi presentati sono descritti anche in uno pseudocodice molto vicino al codice reale (ma comunque di facile comprensione); • sviluppato il codice, ne vengono analizzate le proprietà con un taglio più astratto e matematico, al fine di distillare l'algoritmo corrispondente e studiarne la complessità computazionale; • utilizzando l'ambiente di visualizzazione ALVIE, viene mostrato l'algoritmo in azione e viene reso possibile sia eseguirlo su qualunque insieme di dati di esempio sia modificarne il comportamento, se necessario. Gli argomenti classici dei corsi introduttivi di algoritmi e strutture di dati (come array, liste, alberi e grafi, ricorsione, divide et impera, programmazione dinamica e algoritmi golosi) sono integrati con argomenti e applicazioni collegate alle tecnologie più recenti. Uno degli obiettivi del libro è infatti quello di integrare teoria e pratica in modo proficuo per l'apprendimento, fornendo al contempo agli studenti una chiara percezione della significatività dei concetti e delle tecniche introdotte nella risoluzione di problemi attuali e ai docenti un insieme di motivazioni, nell'introduzione di tali concetti e tecniche, le quali possono essere di ausilio nelle attività di didattica frontale.

Quest'integrazione tra tecniche algoritmiche e applicazioni dà luogo nel testo a momenti di vera e propria opera di progettazione di strutture di dati e di algoritmi, denominata opus libri, in particolare, il libro esplora i seguenti domini applicativi, in ordine di trattazione: complessità dei giochi • scheduling della CPU • grafica computerizzata • sicurezza dei sistemi • matrimoni stabili e abbinamento di risorse • politica LRU e ad auto-organizzazione • minimo antenato comune e flussi informativi • rappresentazione succinta di documenti XML • crittografia • sistemi peer-to-peer • kernel di Linux e gestione della memoria virtuale • sistemi di gestione delle basi dati • sistemi di recupero delle informazioni • assegnazione delle lunghezze d'onda nelle reti • reti complesse di piccolo mondo e invarianti di scala • motori di ricerca nel Web • link analysis e classificazione dei documenti • Postscript e notazione polacca • Web crawling ed esplorazione di grafi • logistica e pianificazione di attività • routing di pacchetti su Internet • data mining e cluster analysis • ottimizzazione dei trasporti I tre livelli di apprendimento costruttivistico, a cui abbiamo fatto riferimento in precedenza, vengono applicati a tali argomenti, descrivendoli in modo semplice e mostrandone l'impatto nella progettazione efficiente ai fini delle prestazioni ottenute, le quali sono misurate in relazione a un modello di calcolo di riferimento (nel nostro caso, la Random Access Machine). La classificazione degli argomenti segue l'approccio moderno alla programmazione, in cui l'organizzazione delle strutture di dati è centrale (C+ + Standard Template Library, Java Collections e Library of Efficient Data types and Algorithms, nota come LEDA). La trattazione non è però vincolata a un linguaggio di programmazione specifico, ma risulta comprensibile sia agli studenti con maggiore familiarità per i linguaggi strutturati di tipo procedurale, sia a quelli che posseggono una buona conoscenza della programmazione a oggetti. Inoltre, l'apertura e l'estendibilità dell'ambiente di visualizzazione ALVIE, rendendo possibile l'introduzione al suo interno di nuove visualizzazioni, consentono al docente interessato di introdurre le strutture di dati e gli algoritmi su esse operanti usando l'approccio tipico della programmazione a oggetti. In ogni caso, il docente può decidere o meno se utilizzare tale paradigma programmativo, senza pregiudicare la fruibilità degli argomenti trattati nel testo. Infine, siamo pienamente coscienti che non esiste il libro perfetto in grado di soddisfare tutti i docenti: il sapere odierno è sempre più dinamico, variegato e distribuito e un semplice libro non può catturare le mille sfaccettature di una disciplina scientifica in continua evoluzione. Per questo al libro è associato il sito Web h t t p : / / a l g o r i t m i c a . o r g in cui i docenti possono trovare ulteriore materiale didattico (come integrazioni al testo

e lucidi in PowerPoint) e a cui possono contribuire, in modo collaborativo e verificato, sullo stile di iniziative quali Wikipedia per ampliare e approfondire i contenuti del libro, mettendo a disposizione estensioni di ALVIE per quanto riguarda sia nuove funzionalità offerte da tale sistema che nuove visualizzazioni. Il sito Web non è quindi un semplice archivio in cui trovare lucidi o esempi, ma un'estensione a tutto campo del libro come mostrato dalla decisione di pubblicare, in forma elettronica e aperta a tutti, ALVIE e il relativo fascicolo didattico piuttosto che allegarlo al libro. Guida per il docente. Versioni preliminari del libro e di ALVIE sono state sperimentate con successo in corsi e laboratori di algoritmi e strutture di dati della laurea triennale in Informatica e in Matematica e della laurea specialistica in Fisica (ma riteniamo che il testo sia perfettamente adatto anche alla laurea triennale in Ingegneria). "Scorrevole ma impegnativo" è stato il commento più diffuso tra i colleghi che cortesemente hanno letto una versione preliminare dei capitoli. Per quanto riguarda gli studenti, questi ultimi non hanno segnalato particolari difficoltà degli argomenti principali e hanno apprezzato l'attualità delle applicazioni, presentate attraverso ciascun opus libri. Per questo motivo, auspichiamo che i docenti valutino l'approccio del nostro libro "sul campo", con i propri studenti, in aggiunta all'usuale valutazione "a priori" del testo. Forniamo di seguito alcuni percorsi didattici che permettono al docente di organizzare corsi da 4, 6, 9 e 12 crediti formativi universitari (CFU). Ognuna delle ultime quattro righe rappresenta uno di tali percorsi, mentre le colonne rappresentano i paragrafi del libro raggruppati per capitoli (ricordiamo di affiancare tali percorsi con l'uso del software ALVIE e la consultazione del sito Web).

CFU

Cap.l

Cap. 2

Cap. 3

Cap. 4

Cap. 5

Cap. 6

Cap. 7

Cap. 8

Cap. 9

1.1-1.4'2.1^.5i2.6 2.7 3.1:3.2 3.313.4 4.1 4.2Ì4.3 4.4 5.1-5.4 5.5 5.6 6.1 6.2 6.3 6.47.1,7.2'7.3 7.47.5 8.1 8.2 8.3 8.49.1 9.2 9.3 9.4

4 6 9 12

In generale, il nostro libro non segue l'approccio consolidato (seguito anche da noi stessi per anni) di introdurre prima un problema in termini formali e teorici, per poi presentarne la soluzione algoritmica, la dimostrazione di correttezza e l'analisi di complessità, mediante opportuni teoremi e lemmi. Abbiamo infatti preferito non presumere che i lettori siano esclusivamente motivati dalla pura speculazione teorica nello studio degli algoritmi: invece, abbiamo cercato di compensare un divario tra le applicazioni e i problemi, stimolando anche le abilità programmative degli studenti. Speriamo in tal modo di catalizzare l'attenzione di tutti gli studenti dei primi anni, inclusi coloro che appaiono meno interessati per qualcosa che risulta loro essere astratto rispetto alla tecnologia odierna, rendendoli curiosi e interessati a esplorare gli aspetti variegati e affascinanti dell'algoritmica teorica che hanno reso possibile il progresso di molte tecnologie

informatiche. Per facilitare comunque la ricerca di definizioni, proprietà e dimostrazioni contenute nel libro, il nostro sito Web include un glossario delle definizioni e un indice delle proprietà e delle dimostrazioni. Legenda. Nel libro impieghiamo tre icone a margine del testo per segnalare particolari situazioni al lettore:

L'icona a sinistra indica che l'argomento trattato è ulteriormente dettagliato sul sito Web, mediante note bibliografiche, riferimenti a pagine Web ed eventuale materiale didattico aggiuntivo. L'icona al centro indica che un esercizio a fine capitolo completa l'argomento trattato, per cui ne consigliamo lo svolgimento. Infine, l'icona a destra segnala un'argomentazione più teorica del solito. Ringraziamenti. Siamo profondamente grati a tutti gli studenti, che con il loro entusiasmo e con le loro osservazioni ci hanno permesso di migliorare il testo finale e l'ambiente di visualizzazione. Quest'ultimo non avrebbe potuto essere fruibile senza il fondamentale lavoro di tesi di Carlo Nocentini, a cui va il nostro caloroso ringraziamento. Ringraziamo i seguenti colleghi e amici per aver letto, con spirito critico, versioni preliminari di alcuni capitoli: Anna Bernasconi, Paolo Cignoni, Valentina Ciriani, Andrea Clementi, Miriam Di Ianni, Paolo Ferragina, Gianni Franceschini, Antonio Gullì, Michele Loreti, Fabrizio Luccio, Alessio Malizia, Donatella Merlini, Linda Pagli, Paolo Penna, Nadia Pisanti, Guido Proietti, Geppino Pucci, Romeo Rizzi, Gianluca Rossi e Cecilia Verri. Ringraziamo, inoltre, Lorenzo Davitti, per avere interpretato così bene le nozioni di albero, grafo, pila, coda e così via, riuscendo a illustrarle nell'immagine di copertina, e per aver dato vita al personaggio di ALVIE, e Bruna Parra per averci assistito nella definizione della veste tipografica del libro. Ringraziamo infine il team della Pearson Education Italia e, in particolare, Micaela Guerra e Alessandra Piccardo, per il loro aiuto e la pazienza mostrata durante la stesura del libro e per averci sempre perdonato le innumerevoli scadenze mancate. Pierluigi Crescenzi Università degli Studi di Firenze Giorgio Gambosi Università degli Studi di Roma "Tor Vergata" Roberto Grossi Università di Pisa Giugno 2006

Capitolo 1

Problemi computazionali

SOMMARIO Iproblemi computazionali possono essere classificati in base alla complessità dei relativi algoritmi di risoluzione. Questo capitolo offre una visione d'insieme dei temi che riguardano lo studio degli algoritmi e la difficoltà computazionale intrinseca dei problemi computazionali. DIFFICOLTÀ 0,5 CFU Questo libro è rivolto al lettore che ha acquisito i princìpi di base della programmazione e, forte di questa nuova abilità, ha iniziato a esplorare le possibilità offerte dal calcolatore. Tale lettore può oramai trovare naturale il fatto di numerare gli elementi a partire da 0 invece che da 1, perché molti linguaggi moderni di programmazione adottano tale stile di enumerazione; inoltre può aver acquisito dimestichezza con le potenze del 2, per cui riesce a contare con le dieci dita da 0 fino a 1023 e considera un migliaio di elementi pari a 2 1 0 = 1024 piuttosto che a 1000; o infine può essersi convinto che il termine settato (spesso affiancato nella sua opera di dissoluzione linguistica da termini come inizializzare) sia sempre stato il participio passato del verbo settare (una variabile) e non invece un aggettivo che indichi qualcosa "provvisto di setti". Ebbene, a un tale lettore indirizziamo in questo libro alcune sfide su problemi computazionali, ovvero problemi risolvibili al calcolatore mediante algoritmi:1 l'algoritmo è l'essenza computazionale di un programma ma non deve essere identificato con quest'ultimo, in quanto un programma si limita a codificare in uno specifico linguaggio (di 1 II rermine "algoritmo" deriva dalla traslitterazione latina Algorismus del nome del matematico persiano del IX secolo, Muhammad al-Khwarizmi, che descrisse delle procedure per i calcoli aritmetici. Da notare che un algoritmo non necessariamente richiede la sua esecuzione in un calcolatore, ma può essere implementato, per esempio, mediante un dispositivo meccanico, un circuito elettronico o un sistema biologico.

programmazione) i passi descritti da un algoritmo, e programmi diversi possono realizzare lo stesso algoritmo. Avendo cura di distillare gli aspetti rilevanti ai fini computazionali (trascendendo quindi dal particolare linguaggio, ambiente di sviluppo o sistema operativo adottato), possiamo discutere di algoritmi senza addentrarci nel gergo degli hacker e dei geek, rendendo così alla portata di molti, concetti utili a programmare in situazioni reali complesse. La progettazione di algoritmi a partire da problemi provenienti dal mondo reale (il cosiddetto problem solving) è un processo creativo e gratificante, la cui essenza cercheremo di trasmettere al lettore attraverso un apprendistato basato su esempi ragionati che saranno presentati nei vari capitoli e che chiameremo ciascuno opus libri, intesa come opera di progettazione algoritmica (considerata la miriade di anglicismi presenti nell'informatica, speriamo che il lettore ci perdonerà questo latinismo). Gli ingredienti alla base di queste opere algoritmiche saranno semplici, ovvero array, liste, alberi e grafi come illustrato nella Figura 1.1, ma essi ci permetteranno di strutturare i dati elementari (caratteri, interi, reali e stringhe) in forme più complesse, in modo da rappresentare le istanze di problemi computazionali reali e concreti nel mondo virtuale del calcolatore. Pur sembrando sorprendente che problemi computazionali complessi, come il calcolo delle previsioni metereologiche o la programmazione di un satellite, possano essere ricondotti all'elaborazione di soli quattro ingredienti di base, ricordiamo che l'informatica, al pari delle altre scienze quali la chimica, la fisica e la matematica, cerca di ricondurre i fenomeni (computazionali) a pochi elementi fondamentali. Nel caso dell'informatica, come molti sanno, l'elemento costituente dell'informazione è il bit, componente reale del nostro mondo fisico quanto l'atomo o il quark: il bit rappresenta l'informazione minima che può assumere due soli valori (per esempio, 0/1, acceso/spento, destra/sinistra o testa/croce). Negli anni '50, lavorando presso i prestigiosi laboratori di ricerca della compagnia telefonica statunitense AT&T Bell Labs,2 il padre della teoria dell'informazione, Claude Shannon, definì il bit come la quantità di informazione necessaria a rappresentare un evento con due possibilità equiprobabili, e introdusse l'entropia come una misura della quantità minima di bit necessaria a rappresentare un contenuto informativo senza perdita di informazione (se possiamo memorizzare un film in un supporto digitale, o se possiamo ridurre il costo per bit di certi servizi di telefonia cellulare, lo dobbiamo a questo tipo di studi che hanno permesso l'avanzamento della tecnologia). L'informazione può essere quindi misurata come le altre entità fisiche e, come quest'ultime, è sempre esistita: la fondamentale scoperta nel 1953 della "doppia elica" del DNA (l'acido desossiribonucleico presente nel nucleo di tutte le celle), da parte di James Watson e Francis Crick, ha infatti posto le basi biologiche per la comprensione della 2

Presso gli stessi laboratori furono sviluppati, tra gli altri, il sistema operativo UNIX e i linguaggi di programmazione C e C++, in tempi successivi.

ao 3

Figura 1.1

8

1

ai

12

13

in-l ZM

Ingredienti di base dì un algoritmo: array, liste, alberi e grafi.

struttura degli esseri viventi da un punto di vista "informativo". Il D N A rappresenta in effetti l'informazione necessaria alle funzionalità degli esseri viventi e può essere rappresentato all'interno del calcolatore con le strutture di dati elencate sopra. In particolare, la doppia elica del D N A è costituita da due filamenti accoppiati e avvolti su se stessi, a formare una struttura elicoidale tridimensionale. Ciascun filamento può essere ricondotto a una sequenza (e, quindi, a un array oppure a una lista) di acidi nucleici (adenina, citosina, guanina e timina) chiamata struttura primaria: per rappresentare tale sequenza, usiamo un alfabeto finito come nei calcolatori, quaternario invece che binario, dove le lettere sono scelte tra le iniziali delle quattro componenti fondamentali: {A, C, G, T}. La sequenza di acidi nucleici si ripiega su se stessa a formare una struttura secondaria che possiamo rappresentare come un albero. Infine, la struttura secondaria si dispone nello spazio in modo da formare una struttura terziaria elicoidale, che possiamo modellare come un grafo. Nel seguito, citeremo varie fonti per illustrare alcuni concetti fondamentali alla comprensione del resto del libro, invitando il lettore a visitare il sito web per eventuali approfondimenti e a iniziare da subito a usare ALVIE (Algorithmic Visualization Environment), il nostro strumento software, per visualizzare il comportamento degli algoritmi e delle strutture di dati in discussione.

1.1

Indecidibilità di problemi computazionali

Nel libro Algorithmics: The Spirit of Computing, l'autore David Harel riporta un estratto di un articolo della rivista Time Magazine di diversi anni fa in cui il redattore di un periodico specializzato in informatica dichiarava che il calcolatore può fare qualunque cosa: basta scrivere il programma adatto a tale scopo, in quanto le eventuali limitazioni sono dovute all'architettura del calcolatore (per esempio, la memoria disponibile), e non certo al programma eseguito. Probabilmente al redattore sfuggiva l'esistenza del prò-

blema della fermata, pubblicato nel 1937 dal matematico inglese Alan Turing, uno dei padri dell'informatica che, con la sua idea di macchina universale, è stato il precursore del moderno concetto di "software". 3 Espresso in termini odierni, il problema della fermata consiste nel capire se un generico programma termina (ovvero, finisce la sua esecuzione) oppure "va in ciclo" (ovvero, continua a ripetere sempre la stessa sequenza di istruzioni all'infinito), supponendo di non avere limiti di tempo e di memoria per il calcolatore impiegato a tal proposito. Per esempio, consideriamo il problema di stabilire se un dato intero p > 1 è un numero primo, ovvero è divisibile soltanto per 1 e per se stesso: 2, 3, 5, 7, 11, 13 e 17 sono alcuni numeri primi (tra l'altro, trovare grandi numeri primi è alla base di diversi protocolli crittografici). Il seguente programma codifica un possibile algoritmo di risoluzione per tale problema. Primo ( numero ): fattore = 2;

{pre: numero > 1)

WHILE (numero °/0 fattore != 0 )

fattore = fattore + 1; RETURN (fattore == numero);

Tale codice non è particolarmente efficiente: per esempio, potremmo evitare di verificare che n u m e r o sia divisibile per f a t t o r e quando quest'ultimo è pari. Tuttavia, siamo sicuri che esso termina perché la variabile f a t t o r e viene incrementata a ogni iterazione e la guardia del ciclo nella riga 3 viene sicuramente verificata se f a t t o r e è uguale a numero. ALVIE: n u m e r i primi

Osserva, s p e r i m e n t a e verifica

PrimeNumber

Nel semplice caso appena discusso, decidere se il programma termina è quindi immediato. Purtroppo, non è sempre così, come mostrato dal seguente programma il cui scopo è quello di trovare il più piccolo numero intero pari che non sia la somma di due numeri primi. 3

Per quanto riguarda il problema della fermata, lo stesso Turing nel suo lavoro del 1937 afferma di essersi ispirato al primo teorema di incompletezza di Kurt Godei, il quale asserisce che esistono teoremi veri ma indimostrabili in qualunque sistema formale che possa descrivere l'aritmetica degli interi.

CongetturaGoldbach( ): n = 2; DO {

n = n + 2; controesempio = TRUE;

(p = 2; p > WHILE (¡controesempio); RETURN n;

Se fossimo in grado di decidere se la funzione C o n g e t t u r a G o l d b a c h termina o meno, allora avremmo risolto la congettura di Goldbach, formulata nel XVIII secolo, la quale afferma che ogni numero intero n > 4 pari è la somma di due numeri primi p e q. In effetti, il programma tenta di trovare un valore di n per cui la congettura non sia vera: se la congettura di Goldbach è però vera, allora il programma non termina mai (ipotizzando di avere tutto lo spazio di memoria necessario). Nonostante il premio milionario offerto nel 2000 dalla casa editrice britannica Faber&Faber a chi risolvesse la congettura, nessuno è stato in grado ancora di provarla o di trovarne un controesempio. Riuscire a capire se un programma arbitrario termina non è soltanto un'impresa ardua (come nel caso del precedente programma) ma, in generale, è impossibile per i calcolatori, come Turing ha dimostrato facendo riferimento al problema della fermata e usando le macchine di Turing (un formalismo alternativo a quello adottato in questo libro). Ricordiamo che, nei calcolatori, un programma è codificato mediante una sequenza di simboli che viene data in ingresso a un altro programma (tipicamente un compilatore): non deve quindi stupirci il fatto che una stessa sequenza di simboli possa essere interpretata sia come un programma che come un dato d'ingresso di un altro programma. Quest'osservazione è alla base del risultato di Turing, la cui dimostrazione procede per assurdo. Supponiamo che esista un programma T e r m i n a ( A , D), il quale, preso un programma A e i suoi dati in ingresso D, restituisce (in tempo finito) un valore di verità per indicare che A termina o meno quando viene eseguito sui dati d'ingresso D. Notiamo che sia A che D sono sequenze di simboli, e siamo noi a stabilire che A debba essere intesa come un programma mentre D come i suoi dati d'ingresso: è quindi perfettamente legittimo invocare T e r m i n a ( A , A), come accade all'interno del seguente programma. Paradosso( A ) : WHILE (Terminai A , A ))

Poiché il corpo del ciclo WHILE è vuoto, per ogni programma A, osserviamo che P a r a d o s s o ( A) termina se e solo se la guardia T e r m i n a i A, A) restituisce il valore FALSE, ovvero se e solo se il programma A non termina quando viene eseguito sui dati d'ingresso A. Possiamo quindi concludere che P a r a d o s s o ( P a r a d o s s o ) termina se e solo se la guardia T e r m i n a ( P a r a d o s s o , P a r a d o s s o ) restituisce il valore FALSE, ovvero se e solo se il programma P a r a d o s s o non termina quando viene eseguito sui dati d'ingresso P a r a d o s s o . In breve, P a r a d o s s o ( P a r a d o s s o ) termina se e solo se P a r a d o s s o ( P a r a d o s s o ) non termina! Questa contraddizione deriva dall'aver assunto l'esistenza di Termina, l'unico anello debole del filo logico tessuto nell'argomentazione precedente. Quindi, un tale programma non può esistere e, pertanto, diciamo che il problema della fermata è indecidibile. Purtroppo esso non è l'unico: per esempio, stabilire se due programmi A e B sono equivalenti, ovvero producono sempre i medesimi risultati a parità di dati in ingresso, è anch'esso un problema indecidibile. Notiamo che l'uso di uno specifico linguaggio non influisce su tali risultati di indecidibilità, i quali valgono per qualunque modello di calcolo che possa formalizzare il comportamento di un calcolatore (più precisamente di una macchina di Turing).

1.2 Trattabilità di problemi computazionali L'esistenza di problemi indecidibili restringe la possibilità di progettare algoritmi e programmi ai soli problemi decidibili. In questo ambito, non tutti i problemi risultano risolvibili in tempo ragionevole, come testimoniato dal noto problema delle Torri di Hanoi, un gioco del XIX secolo inventato da un matematico francese, Edouard Lucas, legandolo alla seguente leggenda indiana (probabilmente falsa) sulla divinità Brahma e sulla fine del mondo. In un tempio induista dedicato alla divinità, vi sono tre pioli di cui il primo contiene n = 64 dischi d'oro impilati in ordine di diametro decrescente, con il disco più ampio in basso e quello più stretto in alto (gli altri due pioli sono vuoti). Dei monaci sannyasin spostano i dischi dal primo al terzo piolo usando il secondo come appoggio, con la regola di non poter spostare più di un disco alla volta e con quella di non porre mai un disco di diametro maggiore sopra un disco di diametro inferiore. Quando i monaci avranno terminato di spostare tutti i dischi nel terzo piolo, avverrà la fine del mondo. La soluzione di questo gioco è semplice da descrivere usando la ricorsione. Supponiamo di avere spostato ricorsivamente i primi n — 1 dischi sul secondo piolo, usando il terzo come appoggio. Possiamo ora spostare il disco più grande dal primo al terzo piolo, e quindi ricorsivamente spostare gli n — 1 dischi dal secondo al terzo piolo usando il primo come appoggio.

TorriHanoi( n, primo, secondo, terzo ): IF (n = 1) { PRINT primo i—»terzo; > ELSE {

TorriHanoi( n - 1, primo, terzo, secondo ); PRINT primo i—»terzo;

TorriHanoi( n - 1, secondo, primo, terzo );

> Dimostriamo, per induzione sul numero n di dischi, che il numero di mosse effettuate eseguendo tale programma e stampate come "origine 0 sia un multiplo di k — 2). TorriHanoiGen( n, k ) : FOR (i = 1; i = 1; TorriHanoiCn/(k-2),

(pre: n > 0 multiplo di k - 2) i = i+1) 0, k-1, i); i = i-1) i, 0, k-1);

Intuitivamente, il codice precedente divide gli n dischi in k - 2 gruppi di ^ dischi ciascuno. Il primo ciclo sposta, per ogni i, l'v-esimo gruppo dal disco 0 al disco i, usando il disco k — 1 come appoggio e invocando T o r r i H a n o i , mentre il secondo ciclo sposta tale gruppo dal disco i al disco k - 1 usando il disco 0 come appoggio: notiamo che, per rispettare la regola di sovrapposizione dei dischi, il secondo ciclo scorre i gruppi in ordine inverso rispetto al primo. Il numero di mosse è dunque pari a 2 x (k — 2) volte il numero di mosse richiesto per spostare ^ ^ dischi usando tre pioli. Non è difficile estendere il suddetto codice in modo che funzioni per tutti i valori di n (anche quando

n non è un multiplo di k — 2), osservando che il numero totale di mosse è pari a M(n, k) = 2 x (k - 2) x ( 2 ^ - l ) Ponendo k* = Lnr^J P e r n ^ 5, possiamo verificare che M(n, k*) ^ n 2 . In tal caso, il problema delle Torri di Hanoi può quindi essere risolto con un numero di mosse quadratico nel numero dei dischi (notiamo che è in generale un problema aperto stabilire il numero minimo di mosse per ogni n e per ogni k > 3). Per esempio, volendo spostare gli n = 64 dischi del problema originale usando k* = 10 pioli, sono sufficienti soltanto 64 2 = 4096 secondi contro i 2 6 4 — 1 = 18 446 744 073 709 551 615 secondi necessari nel caso di tre pioli (supponendo di poter effettuare una mossa al secondo). Il problema delle Torri di Hanoi con tre o più pioli illustra come una soluzione che richiede un numero esponenziale di passi risulti irragionevole se confrontata con una che ne richiede un numero polinomiale. In generale, il passaggio da un andamento esponenziale a uno polinomiale ha due importanti conseguenze. In primo luogo, un polinomio cresce molto più lentamente di una qualunque funzione esponenziale, come mostrato nella seguente tabella relativa al polinomio n 2 e analoga a quella vista nel caso della funzione 2 n . n tempo

5 25 s

10 100 s

15 225 s

20 7 m

25 11 m

30 15 m

35 21 m

40 27 m

45 34 m

In secondo luogo, la polinomialità del tempo di esecuzione rende molto più efficaci gli eventuali miglioramenti nella velocità di esecuzione dei singoli passi. Ad esempio, nel caso del problema generalizzato delle Torri di Hanoi con n dischi e k* pioli, potendo eseguire m operazioni in un secondo, invece di una singola operazione al secondo, occorrerebbero ^ = ( n / i / m ) 2 secondi per spostare gli n dischi. L'effetto di tale miglioramento permane a lungo in quanto è necessario portare il numero di dischi a n x y/rn per ottenere lo stesso tempo complessivo di esecuzione. In altre parole, un miglioramento di un fattore moltiplicativo nelle prestazioni si traduce in un aumento anch'esso moltiplicativo del numero di dischi trattabili. La tabella seguente (analoga a quella vista nel caso della funzione 2 n ) esemplifica questo comportamento nel caso n = 64, mostrando il numero di dischi gestibili (con k* pioli) in un tempo pari a 4096 secondi, al variare della velocità di esecuzione: come possiamo vedere, miglioramenti di quest'ultima si traducono in incrementi significativi del numero di dischi che il programma è in grado di gestire. operazioni/sec numero dischi

1 64

10 202

100 640

IO3 2023

IO4 6400

IO5 20238

IO6 64000

IO9 2023857

1.2.1

Rappresentazione e dimensione dei dati

Volendo generalizzare la discussione fatta nel caso delle Torri di Hanoi a un qualunque problema computazionale, è anzitutto necessaria una breve escursione nella rappresentazione e nella codifica dei dati elementari utilizzati dal calcolatore. Secondo quanto detto in riferimento alla teoria dell'informazione di Claude Shannon, il bit (binary digit) segnala la presenza (1) oppure l'assenza (0) di un segnale o di un evento con due possibilità equiprobabili. 4 La stessa sequenza di bit può essere interpretata in molti modi, a seconda del significato che le vogliamo assegnare nel contesto in cui la usiamo: può essere del codice da eseguire oppure dei dati da elaborare, come abbiamo visto nel problema della fermata. In particolare, gli interi nell'insieme { 0 , 1 , . . . , 2 k — 1} possono essere codificati con k bit b]C_]b)C_2 • • • bibo- La regola per trasformare tali bit in un numero intero è semplice: basta moltiplicare ciascuno dei bit per potenze crescenti di 2, a partire dal bit meno significativo bo, ottenendo bi x 2V. Per esempio, la sequenza 0101 codifica il numero 3 2 intero 5 = 0 x 2 + 1 x 2 + 0 x 2 1 + 1 x 2°. La regola inversa può essere data in vari modi, e l'idea è quella di sottrarre ogni volta la massima potenza del 2 fino a ottenere 0. Per rappresentare sia numeri positivi che negativi è sufficiente aggiungere un bit di segno. I caratteri sono codificati come interi su k = 8 bit (ASCII) oppure su k = 16 bit {Unicode¡UTF8). La codifica riflette l'ordine alfabetico, per cui la lettera 'A' viene codificata con un intero più piccolo della lettera 'Z' (bisogna porre attenzione al fatto che il carattere '7' non è la stessa cosa del numero 7). Le stringhe sono sequenze di caratteri alfanumerici che vengono perciò rappresentate come sequenze di numeri terminate da un carattere speciale oppure a cui vengono associate le rispettive lunghezze. I numeri reali sono codificati con un numero limitato di bit a precisione finita di 32 o 64 bit nello standard IEEE754 (quindi sono piuttosto dei numeri razionali). Il primo bit è utilizzato per il segno; un certo numero dei bit successivi codifica l'esponente, mentre il resto dei bit serve per la mantissa. Per esempio, la codifica di —0,275 x 2 18 è ottenuta codificando il segno meno, l'esponente 18, e quindi la mantissa 0,275 (ciascuno con il numero assegnato di bit). Infine, in generale, un insieme finito è codificato come una sequenza di elementi separati da un carattere speciale per quell'insieme: questa codifica ci permetterà, se necessario, di codificare anche insiemi di insiemi, usando gli opportuni caratteri speciali di separazione. Le regole di codifica discusse finora, ci consentono, per ogni dato, di ricavarne una rappresentazione binaria: nel definire la dimensione del dato, faremo riferimento alla lunghezza di tale rappresentazione o a una misura equivalente. II bit viene usato come unità di misura: 1 byte = 8 bit, 1 kilobyte (KB) = 2 10 byte = 1024 byte, 1 megabyte (MB) = 2 10 KB = 1 048 576 byte, 1 gigabyte (GB) = 2 1 0 MB = 1 073 741 824 byte, 1 terabyte (TB) = 2 10 GB, 1 petabyte (PB) = 2 10 TB e cosi via. 4

Figura 1.2

1.2.2

Una prima classificazione dei problemi computazionali decidibili.

Algoritmi polinomiali ed esponenziali

Abbiamo già osservato che, tranne che per piccole quantità di dati, un algoritmo che impiega un numero di passi esponenziale è impossibile da usare quanto un algoritmo che non termina! Nel seguito useremo il termine algoritmo polinomiale per indicare un algoritmo, per il quale esiste una costante c > 0, il cui numero di passi elementari sia al massimo pari a n c per ogni dato in ingresso di dimensione n . Questa definizione ci porta a una prima classificazione dei problemi computazionali come riportato nella Figura 1.2 dove, oltre alla divisione in problemi indecidibili e decidibili, abbiamo l'ulteriore suddivisione di quest'ultimi in problemi trattabili (per i quali esiste un algoritmo risolutivo polinomiale) e problemi intrattabili (per i quali un tale algoritmo non esiste): facendo riferimento alla figura, tali classi di problemi corrispondono rispettivamente a P e EXP — P, dove EXP rappresenta la classe di problemi risolubili mediante un algoritmo esponenziale, ovvero un algoritmo il cui numero di passi è al più esponenziale nella dimensione del dato in ingresso.5 Talvolta gli algoritmi esponenziali sono utili per esaminare le caratteristiche di alcuni problemi combinatori sulla base della generazione esaustiva di tutte le istanze di piccola taglia. Discutiamo un paio di casi, che rappresentano anche un ottimo esempio di uso della ricorsione nella risoluzione dei problemi computazionali. Nel primo esempio, vogliamo 'Volendo essere più precisi, i problemi intrattabili sono tutti i problemi decidibili che non sono inclusi in P: tra di essi, quindi, vi sono anche problemi che non sono contenuti in EXP. Nel resto di questo libro, tuttavia, non considereremo mai problemi che non ammettano un algoritmo esponenziale.

generare tutte le 2 n sequenze binarie di lunghezza n , che possiamo equivalentemente interpretare come tutti i possibili sottoinsiemi di un insieme di n elementi. Per illustrare questa corrispondenza, numeriamo gli elementi da 0 a n— 1 e associamo il bit in posizione b della sequenza binaria all'elemento b dell'insieme fornito (dove 0 ^ b ^ n — 1): se tale bit è pari a 1, l'elemento b è nel sottoinsieme così rappresentato; altrimenti, il bit è pari a 0 e l'elemento non appartiene a tale sottoinsieme. Durante la generazione delle 2 n sequenze binarie, memorizziamo ciascuna sequenza binaria A e utilizziamo la procedura E l a b o r a per stampare A o per elaborare il corrispondente sottoinsieme. Notiamo che A viene riutilizzata ogni volta sovrascrivendone il contenuto ricorsivamente: il bit in posizione b, indicato con A[b — 1], deve valere prima 0 e, dopo aver generato tutte le sequenze con tale bit, deve valere 1, ripetendo la generazione. Il seguente codice ricorsivo permette di ottenere tutte le 2 n sequenze binarie di lunghezza n : inizialmente, dobbiamo invocare la funzione G e n e r a B i n a r i e con input b = n. GeneraBinarie ( A, b ): IF (b == 0) { Elaborai A );

{pre: i primi b bit in A sono da generare)

> ELSE {

A [b-1] = 0; GeneraBinarie( A, b-1 ); A [b-1] = 1; GeneraBinarie( A, b-1 );

> ALVIE: g e n e r a z i o n e ricorsiva delle s e q u e n z e binarie

Osserva, sperimenta e verifica BinaryStringGeneration

Il secortdo esempio riguarda la generazione delle permutazioni degli n elementi contenuti in una sequenza A. Ciascuno degli n elementi occupa, a turno, l'ultima posizione in A e i rimanenti n — 1 elementi sono ricorsivamente permutati. Per esempio, volendo generare tutte le permutazioni di n = 4 elementi a, b, c, d in modo sistematico, possiamo generare prima quelle aventi d in ultima posizione (elencate nella prima colonna), poi quelle aventi c in ultima posizione (elencate nella seconda colonna) e così via:

a b a c c b

b a c a b c

c c b b a a

d d d d d d

a b a d d b

b d c a d e d b c a b c b a c d a c

a d a c c d

d a c a d c

c c d d a a

b b b b b b

d b d c c b

b d c d b c

c a c a b a b. a d a d a

Restringendoci alle permutazioni aventi d in ultima posizione (prima colonna), possiamo permutare i rimanenti elementi a, b, c in modo analogo usando la ricorsione su questi tre elementi. A tal fine, notiamo che le permutazioni generate per i primi n — 1 = 3 elementi, sono identiche a quelle delle altre tre colonne mostrate sopra. Per esempio, se ridenominiamo l'elemento c (nella prima colonna) con d (nella seconda colonna), otteniamo le medesime permutazioni di n — 1 = 3 elementi; analogamente, possiamo ridenominare gli elementi b e d (nella seconda colonna) con d e e (nella terza colonna), rispettivamente. In generale, le permutazioni di n — 1 elementi nelle colonne sopra possono essere messe in corrispondenza biunivoca e, pertanto, ciò che conta sono il numero di elementi da permutare come riportato nel codice seguente. Invocando tale codice con parametro d'ingresso p = n , possiamo ottenere tutte le n! permutazioni degli elementi in A: GeneraPermutazioni ( A, p ): IF (p == 0) { Elaborai A );

{pre: iprimi p elementi di A sono dapermutare)

> ELSE { FOR (i = p-1; i >= 0; i = i-1) {

Scambiai i, p-1 ); GeneraPermutazioni( A, p-1 ); Scambiai i, p-1 );

> > Notiamo l'utilizzo di una procedura S c a m b i a prima e dopo la ricorsione così da mantenere l'invariante che gli elementi, dopo esser stati permutati, vengono riportati al loro ordine di partenza, come può essere verificato simulando l'algoritmo suddetto. ALVIE: g e n e r a z i o n e ricorsiva delle p e r m u t a z i o n i

Osserva, sperimenta e verifica PermutaiionGeneration

1.3

Problemi NP-completi

La classificazione dei problemi decidibili nella Figura 1.2 ha in realtà una zona grigia localizzata tra i problemi trattabili e quelli intrattabili (le definizioni rigorose saranno date nell'ultimo capitolo del libro). Esistono decine di migliaia di esempi interessanti di problemi che giacciono in tale zona grigia: di questi ne riportiamo uno tratto dal campo dei solitari e relativo al noto gioco del Sudoku. In tale solitario, il giocatore è posto di fronte a una tabella di nove righe e nove colonne parzialmente riempita con numeri compresi tra 1 e 9, come nell'istanza mostrata nella parte sinistra della Figura 1.3. Come possiamo vedere, la tabella è suddivisa in nove sotto-tabelle, ciascuna di tre righe e tre colonne. Il compito del giocatore è quello di riempire le caselle vuote della tabella con numeri compresi tra 1 e 9, rispettando i seguenti vincoli: 1. ogni riga contiene tutti i numeri compresi tra 1 e 9; 2. ogni colonna contiene tutti i numeri compresi tra 1 e 9; 3. ogni sotto-tabella contiene tutti i numeri compresi tra 1 e 9. Nella parte destra della Figura 1.3 mostriamo una soluzione ottenuta abbastanza facilmente sulla base di implicazioni logiche del tipo: "visto che la sotto-tabella in alto a destra deve contenere un 3, che la prima riga e la seconda riga contengono un 3 e che la nona colonna contiene un 3, allora nella casella in terza riga e settima colonna ci deve essere un 3". Tali implicazioni consentono al giocatore di determinare inequivocabilmente il contenuto di una casella: notiamo che, nel caso mostrato in figura, in ogni passo del processo risolutivo, vi è sempre almeno una casella il cui contenuto può essere determinato sulla base di siffatte implicazioni. Tuttavia, le configurazioni iniziali che vengono proposte al giocatore non sono sempre di tale livello di difficoltà: le configurazioni più difficili raramente consentono di

3

9 7

1 6

8 1 8

4 2

5

9 Figura 1.3

1 9

6

7 3

4'

3 9

5

9 3 4

6 9 8

3 4 2

9 7 5

6 1 8

5 6 7

1 8 4

2

4

3 9

5 3

1 6

3 8 2

4 7 9

2 4 8

7 9 3

5 1 6

6 2

4 1 6

2

1

3 5

9 3

5 6 2

7 4 8

5 8

2 4

7

7 9

7 9 6

8 2 1

7

8 5 1

9 3 4

9 8 1

3 2 4

6

Un esempio di istanza del gioco del Sudoku e la corrispondente soluzione.

5 7

procedere in modo univoco fino a raggiungere la soluzione finale, costringendo pertanto il giocatore a operare delle scelte che possono talvolta rivelarsi sbagliate. Con riferimento alla Figura 1.4, possiamo procedere inizialmente in modo univoco partendo dalla configurazione nella parte sinistra fino a ottenere la configurazione nella parte destra: a questo punto, non esiste alcuna casella il cui contenuto possa essere determinato in modo univoco. Per esempio, la casella in basso a destra può contenere sia un 1 che un 3 e non abbiamo modo di scegliere quale valore includere, se non procedendo per tentativi e annullando le scelte parziali che conducano a un vicolo cieco. In questi casi, il giocatore è dunque costretto a eseguire un algoritmo di backtrack, in base al quale la scelta operata più recentemente (se non conduce a una soluzione del problema) viene annullata e sostituita con un'altra scelta possibile (che non sia già stata analizzata). Questo modo di procedere è formalizzato nella seguente funzione ricorsiva Sudoku, la quale esamina tutte le caselle inizialmente vuote, nell'ordine implicitamente specificato dalle funzioni P r i m a V u o t a , S u c c V u o t a e U l t i m a V u o t a (ad esempio, scorrendo la tabella per righe o per colonne): supponendo che la configurazione iniziale contenga almeno una casella vuota, la funzione deve inizialmente essere invocata con argomento la casella restituita da P r i m a V u o t a . Sudoku( casella ): {pre: casella vuota) elenco = insieme delle cifre ammissibili per casella; FOR (i = 0; i < I elenco I; i = i+1) { Assegnai casella, elenco[i] ); IF (!UltimaVuota(casella) && !Sudoku(SuccVuota(casella))) { Svuotai casella ); > ELSE

{

RETURN

TRUE;

> > RETURN

FALSE;

Per ogni casella vuota, il codice calcola l'elenco delle cifre (comprese tra 1 e 9) che in essa possono essere contenute (riga 2): prova dunque ad assegnare a tale casella una dopo l'altra tali cifre (riga 4). Se giunge all'ultima casella della tabella (riga 5), il codice restituisce il valore TRUE (riga 8): 6 in questo caso, una soluzione al problema è stata trovata. Altrimenti, invoca ricorsivamente la funzione S u d o k u con argomento la prossima casella vuota e, nel caso in cui l'invocazione ricorsiva non abbia prodotto una soluzione accettabile, annulla la scelta appena fatta (riga 6) e ne prova un'altra. L'intero procedi6 N o t i a m o che k congiunzione di due o più operandi booleani è valutata in m o d o pigro: gli operandi sono valutati da sinistra verso destra e la valutazione ha termine non appena viene incontrato un operando il cui valore sia FALSE. Inoltre, RETURN termina la chiamata di funzione restituendo il valore specificato.

2

6

4

9 3 8 1

5

7 3

5

3

2

1

7 3

7

9 5 6 1 9

6 8 5 2

4 6 5

2 7 1

9 3 8 1

6

2

7 6

Figura 1.4

2

9

4

6 8 7 3 2 4 5 1 9

2 3 1

1 8

6 2 8 7

7 3 6 4

9 5 6 1 9

7 8 3

6 8 5 4 2

Un esempio di istanza difficile del Sudoku e una successiva configurazione senza proseguimento univoco.

mento ha termine nel momento in cui il codice trova una soluzione oppure esaurisce le scelte possibili (riga 11). Osserviamo che l'algoritmo sopra esposto esegue, nel caso pessimo, un numero di operazioni proporzionale a 9 m , dove m ^ 9 x 9 indica il numero di caselle inizialmente vuote: infatti, per ogni casella vuota della tabella vi sono al più 9 possibili cifre con cui tentare di riempire tale casella. In generale, usando n cifre (con n numero quadrato arbitrariamente grande), il gioco necessita di una tabella di dimensione n x n , e quindi l'algoritmo suddetto ha complessità esponenziale, in quanto richiede circa n m ^ n n X R = 2 n 2 | o 6 n operazioni. A differenza del problema delle Torri di Hanoi, non possiamo però concludere che il problema del gioco del Sudoku sia intrattabile: nonostante si conoscano solo algoritmi esponenziali per il Sudoku, nessuno finora è riuscito a dimostrare che tale problema possa ammettere o meno una risoluzione mediante algoritmi polinomiali. Un'evidenza della diversa natura dei due problemi dal punto di vista della complessità computazionale, deriva dal fatto che, quando il secondo problema ammette una soluzione, esiste sempre una prova dell'esistenza di una tale soluzione che possa essere verificata in tempo polinomiale (al contrario, non esiste alcun algoritmo polinomiale di verifica per il problema delle Torri di Hanoi). Supponiamo infatti che, stanchi di tentare di riempire una tabella di dimensione n x n pubblicata su una rivista di enigmistica, incominciamo a nutrire dei seri dubbi sul fatto che tale tabella ammetta una soluzione. Per tale motivo, decidiamo di rivolgerci direttamente all'editore chiedendo di convincerci che è possibile riempire la tabella. Ebbene, l'editore ha un modo molto semplice di fare ciò, inviandoci la sequenza delle cifre da inserire nelle caselle vuote. Tale sequenza ha chiaramente lunghezza m ed è quindi polinomiale in n: inoltre, possiamo facilmente verificare la correttezza del problema pro-

posto dall'editore, riempiendo le caselle vuote con le cifre della sequenza come riportato nel codice seguente. Verif icaSudoku( sequenza ): (pre: sequenza di m. cifre, con 0 < m ^ n 2 ) casella = PrimaVuotaC ); FOR (i = 0; i < m; i = i+1) { cifra = sequenza [i]; IF (cifra appare in casella.riga) RETURN FALSE; IF (cifra appare in casella.colonna) RETURN FALSE; IF (cifra appare in casella.sotto-tabella) RETURN FALSE;

Assegnai casella, cifra ); casella = SuccVuota(casella);

> RETURN TRUE;

Notiamo che le tre verifiche alle righe 5 - 7 possono essere eseguite in circa n passi, per cui l'intero algoritmo di verifica richiede circa m x n ^ n 3 passi, ed è quindi polinomiale. In conclusione, verificare che una sequenza di m cifre sia una soluzione di un'istanza del Sudoku può essere fatto in tempo polinomiale mentre, ad oggi, nessuno conosce un algoritmo polinomiale per trovare una tale sequenza. Insomma, il problema del Sudoku si trova in uno stato di limbo computazionale nella nostra classificazione della Figura 1.2. ALVIE: il p r o b l e m a del S u d o k u

Osserva, s p e r i m e n t a e verifica

Sudoku

Tale problema non è un esempio isolato ma esistono decine di migliaia di problemi simili che ricorrono in situazioni reali, che vanno dall'organizzazione del trasporto a problemi di allocazione ottima di risorse. Questi problemi formano la classe N P e sono caratterizzati dall'ammettere particolari sequenze binarie chiamate certificati polinomiali: chi ha la soluzione per un'istanza di un problema in NP, può convincerci di ciò fornendo un'opportuno certificato che ci permette di verificare, in tempo polinomiale, l'esistenza di una qualche soluzione. Notiamo che chi non ha tale soluzione, può comunque procedere per tentativi in tempo esponenziale, provando a generare (più o meno esplicitamente) tutti i certificati possibili. Come mostrato nella Figura 1.2, la classe NP include (non sappiamo se in senso stretto o meno) la classe P in quanto, per ogni problema che ammette un algoritmo polinomiale, possiamo usare tale algoritmo per produrre una soluzione e, quindi, un certificato polinomiale.

Il problema del Sudoku in realtà appartiene alla classe dei problemi NP-completi che sono stati introdotti indipendentemente all'inizio degli anni '70 da due informatici, lo statunitense/canadese Stephen Cook e il russo Leonid Levin. Tali problemi sono i più difficili da risolvere algoritmicamente all'interno della classe NP, nel senso che se scopriamo un algoritmo polinomiale per un qualsiasi problema NP-completo, allora tutti i problemi in NP sono risolubili in tempo polinomiale (ovvero la classe NP coincide con la classe P). Se invece dimostriamo che uno dei problemi NP-completi è intrattabile (e quindi che la classe NP è diversa dalla classe P), allora risultano intrattabili tutti i problemi in NPC. (NPC),

I problemi studiati in questo libro si collocano principalmente nella classe NP, di cui forniremo una trattazione rigorosa nell'ultimo capitolo. Per il momento anticipiamo che, in effetti, il concetto di NP-completezza fa riferimento ai soli problemi decisionali (ovvero, problemi per i quali la soluzione è binaria — sì o no): con un piccolo abuso di terminologia, indicheremo nel seguito come NP-completi anche problemi che richiedono la ricerca di una soluzione non binaria e che sono computazionalmente equivalenti a problemi decisionali NP-completi. I problemi in NP (e quindi quelli NP-completi) influenzano la vita quotidiana più di quanto possa sembrare: come detto, se qualcuno mostrasse che i problemi NP-completi ammettono algoritmi polinomiali, ovvero che P = NP, allora ci sarebbero conseguenze in molte applicazioni di uso comune. Per esempio, diventerebbe possibile indovinare in tempo polinomiale una parola chiave di n simboli scelti in modo casuale, per cui diversi metodi di autenticazione degli utenti basati su parole d'ordine e di crittografia basata su chiave pubblica non sarebbero più sicuri (come il protocollo secure sockets layer adoperato dalle banche e dal commercio elettronico per le connessioni sicure nel Web). Non a caso, nel 2000 è stato messo in palio dal Clay Mathematics Institute un premio milionario per chi riuscirà a dimostrare che l'uguaglianza P = NP sia vera o meno (la maggioranza degli esperti congettura che sia P ^ NP per cui possiamo parlare di apparente intrattabilità): risolvendo uno dei due problemi aperti menzionati finora (Goldbach e NP) è quindi possibile diventare milionari.

1.4

Modello RAM e complessità computazionale

La classificazione dei problemi discussa finora e rappresentata graficamente nella Figura 1.2, fa riferimento al concetto intuitivo di passo elementare: concludiamo questo capitolo con una specifica più formale di tale concetto, attraverso una breve escursione nella struttura logica di un calcolatore. L'idea di memorizzare sia i dati che i programmi come sequenze binarie nella memoria del calcolatore, è dovuta principalmente al grande e controverso scienziato ungherese

John von Neumann 7 negli anni '50, il quale si ispirò alla macchina universale di Turing. I moderni calcolatori mantengono una struttura logica simile a quella introdotta da von Neumann, di cui il modello RAM (Random Access Machine o macchina ad accesso diretto) rappresenta un'astrazione: tale modello consiste in un processore di calcolo a cui viene associata una memoria di dimensione illimitata, in grado di contenere sia i dati che il programma da eseguire. Il processore dispone di un'unità centrale di elaborazione e di due registri, ovvero il contatore di programma che indica la prossima istruzione da eseguire e l'accumulatore che consente di eseguire le seguenti istruzioni elementari: 8 • operazioni aritmetiche: somma, sottrazione, moltiplicazione, divisione; • operazioni di confronto: minore, maggiore, uguale e così via; • operazioni logiche: and, or, not e così via; • operazioni di trasferimento: lettura e scrittura da accumulatore a memoria; • operazioni di controllo: salti condizionati e non condizionati. Allo scopo di analizzare le prestazioni delle strutture di dati e degli algoritmi presentati nel libro, seguiamo la convenzione comunemente adottata di assegnare un costo uniforme alle suddette operazioni. In particolare, supponiamo che ciascuna di esse richieda un tempo costante di esecuzione, indipendente dal numero dei dati memorizzati nel calcolatore. Il costo computazionale dell'esecuzione di un algoritmo, su una specifica istanza, è quindi espresso in termini di tempo, ovvero il numero di istruzioni elementari eseguite, e in termini di spazio, ovvero il massimo numero di celle di memoria utilizzate durante l'esecuzione (oltre a quelle occupate dai dati in ingresso). Per un dato problema, è noto che esistono infiniti algoritmi che lo risolvono, per cui il progettista si pone la questione di selezionarne il migliore in termini di complessità in tempo e/o di complessità in spazio. Entrambe le complessità sono espresse in notazione asintotica in funzione della dimensione n dei dati in ingresso, ignorando così le costanti moltiplicative e gli ordini inferiori. 9 Solitamente, si cerca prima di minimizzare la complessità asintotica in tempo e, a parità di costo temporale, la complessità in spazio: la motivazione è che lo spazio può essere riusato mentre il tempo è irreversibile. 10 Nella complessità al caso pessimo o peggiore consideriamo il costo massimo su tutte le possibili istanze di dimensione n , mentre nella complessità al caso medio consideriamo il costo mediato tra tali istanze. La maggior parte degli algoritmi presentati in questo 7

I1 saggio L'apprendista stregone di Piergiorgio Odifreddi descrive la personalità di von Neumann. Notiamo che le istruzioni di un linguaggio ad alto livello come C, C++ e JAVA, possono essere facilmente tradotte in una serie di tali operazioni elementari. 9 Gad Landau usa la seguente metafora: un miliardario rimane tale sia che possegga un miliardo di euro che ne possegga nove, o che possegga anche diversi milioni (le costanti moltiplicative negli ordini di grandezza e gli ordini inferiori scompaiono con la notazione asintotica O, CI e 0 ) . 10 In alcune applicazioni, come vedremo, lo spazio è importante quanto il tempo, per cui cercheremo di minimizzare entrambe le complessità con algoritmi più sofisticati. 8

libro saranno analizzati facendo riferimento al caso pessimo, ma saranno mostrati anche alcuni esempi di valutazione del costo al caso medio. Diamo ora una piccola guida per valutare al caso pessimo la complessità in tempo di alcuni dei costrutti di programmazione più frequentemente usati nel libro (come ogni buona catalogazione, vi sono le dovute eccezioni che saranno illustrate di volta in volta). • Le singole operazioni logico-aritmetiche e di assegnamento hanno un costo costante. • Nel costrutto condizionale IF (guardia) { bloccol } ELSE { blocco2 } uno solo tra i rami viene eseguito, in base al valore di g u a r d i a . Non potendo prevedere in generale tale valore e, quindi, quale dei due blocchi sarà eseguito, il costo di tale costrutto è pari a costo ( g u a r d i a) + max{costo(bloccol), c o s t o ( b l o c c o 2 ) } • Nel costrutto iterativo FOR ( i = 0 ; i < m ; i = i + l ) { corpo } sia ti il costo dell'esecuzione di c o r p o all'iterazione i del ciclo (come vedremo nel libro, non è detto che c o r p o debba avere sempre lo stesso costo a ogni iterazione). Il costo risultante è dato da m— 1 i=0

• Nei costrutti iterativi WHILE (guardia) { corpo } DO { corpo } WHILE (guardia); sia m il numero di volte in cui g u a r d i a è soddisfatta. Sia t( il costo della sua valutazione all'iterazione i del ciclo, e tt il costo di c o r p o all'iterazione i. Poiché g u a r d i a viene valutata una volta in più rispetto a c o r p o , abbiamo il seguente costo totale: m

m— 1

i=0

i=0

(notiamo che, di solito, la parte difficile rispetto alla valutazione del costo per il ciclo FOR, è fornire una stima del valore di m).

• Il costo della chiamata a funzione è dato da quello del corpo della funzione stessa più quello dovuto al calcolo degli argomenti passati al momento dell'invocazione (come vedremo, nel caso di funzioni ricorsive, la valutazione del costo sarà effettuata nel libro mediante la risoluzione delle relative equazioni di ricorrenza). • Infine, il costo di un blocco di istruzioni e costrutti visti sopra è pari alla somma dei costi delle singole istruzioni e dei costrutti, secondo quanto appena discusso. Per concludere, osserviamo che la valutazione asintotica del costo di un algoritmo serve a identificare algoritmi chiaramente inefficienti senza il bisogno di implementarli e sperimentarli. Per gli algoritmi che risultano invece efficienti (da un punto di vista di analisi della loro complessità), occorre tener conto del particolare sistema che intendiamo usare (piattaforma hardware e livelli di memoria, sistema operativo, linguaggio adottato, compilatore e così via). Questi aspetti sono volutamente ignorati nel modello RAM per permettere una prima fase di selezione ad alto livello degli algoritmi promettenti, che però necessitano di un'ulteriore indagine sperimentale che dipende anche dall'applicazione che intendiamo realizzare: come ogni modello, anche la RAM non riesce a catturare le mille sfaccettature della realtà. RIEPILOGO In questo capitolo abbiamo definito i concetti di decidibilità e di trattabilità dei problemi computazionali, fornendo una prima classificazione dei problemi stessi in base alla complessità dei relativi algoritmi di risoluzione. In particolare, abbiamo visto come la trattabilità venga fatta coincidere con l'esistenza di algoritmi risolutivi con complessità polinomiale, fornendo una prima definizione dei problemi risolvibili efficientemente, e quindi delle classi NP f NPC. Abbiamo infine introdotto un modello di calcolo che utilizzeremo nel seguito per la loro valutazione. ESERCIZI 1. Dimostrate che non esiste un programma T e r m i n a Z e r o , il quale, preso un programma A, restituisce (in tempo finito) un valore di verità per indicare che A termina o meno quando viene eseguito con input 0. 2. Per ogni i ^ 1, l'i-esimo numero di Fibonacci F(i) è definito nel modo seguente:

llJ

i l se i = 1,2 ~ \ F(i — 1) + F(i — 2) altrimenti

Sulla base di tale definizione, scrivete una funzione ricorsiva F i b o n a c c i che calcoli il valore F(i). Valutate il numero di passi che tale funzione esegue al variare del

numero i e, usando il fatto che F(i) = + ^J ^ — 1 dove (f) = è il rapporto aureo, dimostrate che tale numero cresce esponenzialmente in i. Descrivete poi un algoritmo iterativo polinomiale in i. 3. Progettate un algoritmo ricorsivo per generare tutti i sottoinsiemi di taglia n ottenibili da un insieme di m elementi, in cui il numero di chiamate ricorsive effettuate è proporzionale al numero di sottoinsiemi generati. 4. Descrivete un algoritmo di backtrack per la risoluzione del problema delle n regine che può essere descritto nel modo seguente: n regine devono essere poste su una scacchiera di dimensione n x n in modo tale che nessuna regina possa mangiarne un'altra (ricordiamo che una regina può mangiare un'altra regina se si trova sulla stessa riga, sulla stessa colonna o sulla stessa diagonale). 5. In alcuni casi, per l'analisi di algoritmi operanti su numeri, è necessario fare a meno dell'ipotesi che il costo di un'operazione sia costante: in particolare ciò risulta necessario per rendere il costo di esecuzione di un'operazione aritmetica dipendente dal valore dei relativi argomenti. Una tipica ipotesi, in tal caso, è quella di considerare il costo di un'addizione n j + ri2 tra due interi proporzionale alla lunghezza della codifica del più grande tra i due, e quindi a log max{n 1,1x2}. Valutate, sotto tale ipotesi, il conseguente costo di una moltiplicazione n j x n2 effettuata mediante il normale procedimento imparato alla scuola elementare.

Capitolo 2

Sequenze: array

SOMMARIO Il modo più semplice per aggregare dei dati elementari consiste nel disporli uno di seguito all'altro a formare una sequenza lineare, identificando ciascun dato con la posizione occupata. In questo capitolo studieremo tale disposizione descrivendo due diversi modi di realizzarla, l'accesso diretto e l'accesso sequenziale, che riflettono l'allocazione della sequenza nella memoria del calcolatore. Successivamente, analizzeremo le sequenze lineari ad accesso diretto (dette anche array), mostrando diverse loro applicazioni e operazioni fornite. Tra l'altro, studieremo il problema della ricerca e dell'ordinamento. Inoltre, mostreremo come risolvere ricorsivamente i problemi utilizzando il paradigma del divide et impera e la tecnica della programmazione dinamica, introducendo l'analisi degli algoritmi ricorsivi mediante le equazioni di ricorrenza. DIFFICOLTÀ 2 CFU

2.1

Sequenze lineari

Una sequenza lineare è un insieme finito di elementi disposti consecutivamente in cui ognuno ha associato un indice di posizione in modo univoco. Seguendo la convenzione di enumerare gli elementi a partire da 0, indichiamo una sequenza lineare di n elementi con la notazione Qo, a i , . . . , a n _ i , dove la posizione j contiene il (j + l)-esimo elemento rappresentato da a¡ (per 0 ^ j ^ n — 1). Nel disporre gli elementi in una sequenza, viene ritenuto importante il loro ordine relativo: quindi, la sequenza ottenuta invertendo l'ordine di due qualunque elementi è generalmente diversa da quella originale. Per esempio, consideriamo la parola a l g o r i t m o , vista come sequenza di n = 9 caratteri ao, a j , . . . , as = a, 1, g, o, r , i ,

transazione deposita 1000 deposita 1000 preleva 1500 preleva 500 Figura 2.1

saldo 0 1000 2000 500

0

transazione deposita 1000 deposita 1000 preleva 500 preleva 1500

saldo 0

transazione

1000

deposita 1000 preleva 1500 deposita 1000 preleva 500

2000 1500

0

Sequenze di transazioni bancarie.

t , m, o. Se invertiamo gli elementi ao e a i , otteniamo la parola l a g o r i t m o che nel corrente dizionario italiano non ha alcun significato. Se a partire da quest'ultima parola invertiamo gli elementi a j e 03, otteniamo la parola l o g a r i t m o , che indica una nota funzione matematica. Un altro esempio significativo sono le sequenze di transazioni bancarie. Supponiamo che queste siano solo di due tipi, ovvero prelievi e depositi, e che non sia possibile prelevare una somma maggiore del saldo. A partire da un saldo nullo, consideriamo la seguente sequenza di transazioni (colonna a sinistra nella Figura 2.1): deposita 1000, deposita 1000, preleva 1500, preleva 500. Se invertiamo le ultime due transazioni, la nuova sequenza non genera errori in quanto il saldo è sempre maggiore oppure uguale alla quantità che viene prelevata (colonna centrale nella Figura 2.1). Al contrario, se invertiamo la seconda e la terza transazione, otteniamo una sequenza che genera un errore in quanto cerca di prelevare 1500, quando il saldo è pari a 1000 (colonna a destra nella Figura 2.1).

2.1.1

Modalità di accesso

L'operazione più elementare su una sequenza lineare consiste certamente nell'accesso ai suoi singoli elementi, specificandone l'indice di posizione. Per esempio, nella sequenza ao, a i , . . . , ag = a, 1, g, o, r , i , t , m, o, tale operazione restituisce 07 = m nel momento in cui viene richiesto l'elemento in posizione 7. L'accesso agli elementi di una sequenza lineare a viene generalmente eseguito in due modalità. In quella ad accesso diretto, dato un indice 1, accediamo direttamente all'elemento ai della sequenza senza doverla attraversare. In altre parole, l'accesso diretto ha un costo computazionale uniforme, indipendente dall'indice di posizione i. Nel seguito chiameremo array le sequenze lineari ad accesso diretto e, coerentemente con la sintassi dei più diffusi linguaggi di programmazione, indicheremo con a[i] il valore dell'(i + 1)-esimo elemento di un array a. L'altra modalità consiste nel raggiungere l'elemento desiderato attraversando la sequenza a partire da un suo estremo, solitamente il primo elemento. Tale modalità, detta

Figura 2.2

Allocazione della memoria nel caso di un array.

ad accesso sequenziale, ha un costo O(i) proporzionale alla posizione i dell'elemento a cui si desidera accedere: d'ora in poi chiameremo liste le sequenze lineari ad accesso sequenziale. Notiamo però che, una volta raggiunto l'elemento ai, il costo di accesso ad Oi + i è 0(1). Generalizzando, il costo è 0(k) per accedere ad at+k partendo da ai. I due modi di realizzare l'accesso agli elementi di una sequenza lineare non devono assolutamente essere considerati equivalenti, vista la differenza di costo computazionale. Entrambe le modalità presentano prò e contro per cui non è possibile dire in generale che una sia preferibile all'altra: tale scelta dipende dall'applicazione che vogliamo realizzare o dal problema che dobbiamo risolvere.

2.1.2

Allocazione della memoria

La descrizione degli algoritmi che fanno uso di array e di liste dovrebbe prescindere dalla specifica allocazione dei dati nella memoria fisica del calcolatore. Tuttavia, una breve digressione su questo argomento permette di comprendere meglio la differenza di costo quando accediamo a un elemento di un array rispetto a un elemento di una lista. In questo paragrafo supponiamo che una parola di memoria e un indirizzo di memoria siano composti da 4 byte ciascuno. Gli array e le liste corrispondono a due modi diversi di allocare la memoria di un calcolatore. Nel caso degli array, le locazioni di memoria associate a elementi consecutivi sono contigue. Il nome dell'array corrisponde a un indirizzo che specifica dove si trova la locazione di memoria contenente l'inizio dell'array (tale inizio viene identificato con il primo elemento dell'array a[0]). Per accedere all'elemento a[i] è dunque sufficiente sommare a tale indirizzo i volte il numero di byte necessari a memorizzare un singolo elemento. Per esempio, consideriamo l'array a mostrato nella Figura 2.2, il quale contiene 5 elementi di 4 byte ciascuno. Se x è l'indirizzo contenuto nella variabile a, il primo elemento dell'array si trova nella locazione di memoria con indirizzo x, il secondo elemento si trova nella locazione con indirizzo x + 4, il terzo elemento si trova nella locazione con indirizzo x + 8, e così via. In altre parole, conoscendo il valore x, l'indirizzo

a

0

n

a3

4

» di

*

Figura 2.3

£12

Allocazione di memoria nel caso di una lista.

della locazione di memoria contenente a[i] può essere calcolato in tempo costante, con la formula x + i x 4. Ciò giustifica l'affermazione fatta in precedenza che, nel caso di accesso diretto, il costo dell'operazione di accesso è 0(1), in quanto è indipendente dall'indice di posizione dell'elemento desiderato. Differentemente dagli array, gli elementi delle liste sono allocati in locazioni di memoria non necessariamente contigue. Quest'allocazione deriva dal fatto che la memoria per le liste viene gestita dinamicamente durante la computazione, quando i vari elementi sono inseriti e cancellati (in un modo che non è possibile prevedere prima della computazione stessa). Per questo motivo, ogni elemento deve memorizzare, oltre al proprio valore, anche l'indirizzo dell'elemento successivo. Il nome della lista corrisponde a un indirizzo che specifica dove si trova la locazione di memoria contenente il primo elemento della lista. Per accedere ad Q^ è dunque necessario partire dal primo elemento e scandire uno dopo l'altro tutti quelli chc precedono ai nella lista. Per esempio, consideriamo la lista a mostrata nella Figura 2.3: in questo caso, a contiene 4 elementi di 4 byte ciascuno e, per ciascun elemento, l'indirizzo di 4 byte necessario a individuare l'elemento successivo. Per accedere al terzo elemento, ovvero ad Q2, è necessario partire dall'inizio della lista, ovvero da a, per accedere ad ciò, P°i a d Q i e quindi ad aj- In altre parole, l'accesso a un elemento di una lista richiede la scansione di tutti gli elementi che lo precedono e, per questo motivo, ha un costo proporzionale all'indice della sua posizione: in generale, se partiamo da Qì, l'accesso ad ai+k richiede 0 ( k ) passi. D'altra parte, le liste ben si

Verif icaRaddoppio ( ) : {pre: a. è un array di lunghezza d con n elementi) IF (N == d) { b = NuovoArrayC 2 x d ); FOR (i = 0; i < n; i = i+1) b[i] = a[i] ; a = b;

> VerificaDimezzamento ( ) : {pre: a. è un array di lunghezza d con n elementi) IF ((d > 1) tk (n == d/4)) { b = NuovoArrayC d/2 ); FOR (i = 0; i < n; i = i+1) b[i] = a[i] ; a = b;

Codice 2.1

Operazioni di ridimensionamento di un array dinamico.

prestano a implementare sequenze dinamiche, a differenza degli array per i quali, come vedremo nel prossimo paragrafo, è necessario adottare particolari accorgimenti.

2.1.3

Array di dimensione variabile

Volendo utilizzare un array per realizzare una sequenza lineare dinamica, è necessario apportare diverse modifiche che consentano di effettuare il suo ridimensionamento: diversi linguaggi moderni, come C + + , C # e JAVA forniscono array la cui dimensione può variare dinamicamente con il tempo. Prenderemo in considerazione l'inserimento e la cancellazione in fondo a un array a di n elementi per illustrarne la gestione del ridimensionamento. Allocare un nuovo array (più grande o più piccolo) per copiarvi gli elementi di a a ogni variazione della sua dimensione può richiedere O(n) tempo per ciascuna operazione, risultando particolarmente oneroso in termini computazionali, sebbene sia ottimale in termini di memoria allocata. Con qualche piccolo accorgimento, tuttavia, possiamo fare meglio pagando tempo O(n) cumulativamente per ciascun gruppo di O ( n ) operazioni consecutive, ovvero un costo distribuito di 0 ( 1 ) per operazione. Sia d il numero di elementi dell'array a correntemente allocati in memoria e n ^ d il numero di elementi effettivamente contenuti nella sequenza memorizzata in a. Ogni qualvolta un'operazione di inserimento viene eseguita, se vi è spazio sufficiente (n + 1 ^ d), aumentiamo n di un'unità. Altrimenti, se n = d, allochiamo un array b di taglia 2d, raddoppiamo d, copiamo gli n elementi di a in b e poniamo a = b. Analogamente, ogni qualvolta un'operazione di cancellazione viene eseguita, diminuiamo n di un'unità.

Quando n = d/4, dimezziamo l'array a: allochiamo un array b di taglia d/2, dimezziamo d, e copiamo gli n elementi di a in b (ponendo a = b). Queste operazioni di verifica ed eventuale ridimensionamento dell'array sono mostrate nel Codice 2.1. Contrariamente a prima, osserviamo che non è più possibile causare un ridimensionamento di a (raddoppio o dimezzamento) al costo di O(n) tempo per ciascuna operazione. Dopo un raddoppio, ci sono n = d + 1 elementi nel nuovo array di 2d elementi. Occorrono almeno n— 1 richieste di inserimento per un nuovo raddoppio e almeno n / 2 richieste di cancellazione per un dimezzamento. In modo simile, dopo un dimezzamento, ci sono n = d / 4 elementi nel nuovo array di d / 2 elementi, per cui occorrono almeno n + 1 richieste di inserimento per un raddoppio e almeno n / 2 richieste di cancellazione per un nuovo dimezzamento. In tutti i casi, il costo di O(n) tempo richiesto dal ridimensionamento può essere virtualmente distribuito tra le O(n) operazioni che lo hanno causato (a partire dal precedente ridimensionamento). Tale costo può essere concettualmente ripartito tra le operazioni, incrementando la loro complessità di un costo costante così distribuito per ciascuna operazione. ALVI E: array di dimensione variabile

Osserva, sperimenta e verifica DynamieArray

2.2

Opus libri: scheduling della CPU

I moderni sistemi operativi sono ambienti di multi-programmazione, ovvero consentono che più programmi possano essere in esecuzione simultaneamente. Questo non vuol dire che una singola unità centrale di elaborazione (CPU, da Central Processing Unit) esegua contemporaneamente più programmi, ma semplicemente che nei periodi in cui un programma non ne fa uso, la CPU può dedicarsi ad altri programmi. A tal fine, la CPU ha a disposizione una sequenza di porzioni di programma da dover eseguire, ciascuna delle quali è caratterizzata da un tempo di utilizzo della CPU in millisecondi (ms): per semplicità identifichiamo nel seguito le porzioni con i loro programmi. Supponiamo che vi siano quattro programmi o task PQ, P|, P2 e P3 in esecuzione sulla stessa CPU e che in un certo istante di tempo la sequenza dei tempi previsti di utilizzo della CPU da parte loro sia la seguente: 21ms per Po, 3ms per Pi, lms per P2 e 2ms per P3 (ipotizziamo che ciascun task vada completato prima di passare a elaborarne un altro come accade, per esempio, nella coda di stampa). La decisione di eseguire i

Po

Pi

0

21 P.

0

P3 3

p 2 P3 0 1 3 Figura 2.4

Po

P2 2627

5 Pi

P2 P 3 2425 27

Po 6

27

Scheduling della CPU e tempi di attesa.

programmi in una determinata sequenza si chiama scheduling. La CPU può decidere di eseguire i programmi nell'ordine in cui essi appaiono nella sequenza, che di solito coincide con l'ordine di arrivo. Per questo motivo, tale politica viene chiamata First Come First Served (FCFS). L'occupazione della CPU da parte dei programmi è quella mostrata nella parte superiore della Figura 2.4. I loro tempi di attesa sono pari a 0, 21, 24 e 25, rispettivamente, ottenendo un tempo medio di attesa uguale a 0 + 2 1 = 17,5ms. Se la sequenza dei programmi da eseguire fosse giunta in un ordine diverso, applicando la strategia FCFS il tempo medio di attesa risulterebbe diverso. Ad esempio, se la sequenza fosse Pi, P3, Po e P2, l'occupazione della CPU sarebbe quella mostrata nella parte centrale della Figura 2.4. I tempi di attesa sarebbero pari a 0, 3, 5 e 26, rispettivamente, ottenendo un tempo medio di attesa di °+ 3 +5+ 26 = 8,5ms, che è significativamente più basso. Dall'esempio risulta che il tempo di attesa medio diminuisce se i programmi con tempi di utilizzo minore vengono eseguiti per primi. Per questo motivo, quando viene sempre eseguita per prima la porzione di programma con tempo di esecuzione più breve, la politica viene chiamata Shortest Job First (SJF). Per esempio, facendo riferimento alla sequenza precedente, la CPU dispone i quattro programmi nell'ordine P2, P3, Pi e Po- Come mostrato nella parte inferiore della Figura 2.4, il tempo medio di attesa si riduce in tal caso a 0 + 1 + 3 + 6 = 2,5ms (che tra l'altro è il minimo tempo di attesa medio possibile). La realizzazione della strategia SJF richiede di poter eseguire l'ordinamento dei tempi previsti di utilizzo della CPU da parte dei diversi programmi. L'operazione di ordinamento di una sequenza lineare di elementi è una delle operazioni più frequenti in diverse applicazioni informatiche, e nel seguito ne discuteremo ulteriormente. In questo paragrafo, mostriamo come ottenere un ordinamento mediante

SelectionSort ( a ) : FOR (i = 0; i < n; i = i+1) { minimo = a[i]; indiceMinimo = i; FOR (j = i+1; j < n; j = j+1) { IF (a[j] < minimo) { minimo = a[j]; indiceMinimo = j;

(pre: la lunghezza di a è n)

}

> a[indiceMinimo] = a[i]; a[i] = minimo;

Codice 2.2

Ordinamento per selezione di un array a.

due semplici algoritmi, di cui valuteremo la complessità rispetto al numero n di elementi della sequenza. Entrambi gli algoritmi richiedono un tempo proporzionale a 0 ( n 2 ) e risultano utili per la loro semplicità quando il valore di n è piccolo. Vedremo più avanti come sia possibile progettare algoritmi di ordinamento più efficienti, la cui complessità temporale è O ( n l o g n ) .

2.2.1

Ordinamento per selezione

L'algoritmo di ordinamento per selezione, detto selection sort, consiste nell'eseguire n passi: al generico passo i = 0 , 1 , . . . , n — 1, viene selezionato l'elemento che occuperà la posizione i della sequenza ordinata. In altre parole, al termine del passo i, gli i + 1 elementi selezionati fino a quel momento coincidono con i primi i + 1 elementi della sequenza ordinata. Per realizzare il passo i, l'algoritmo deve selezionare il minimo tra gli elementi che si trovano dalla posizione i in avanti, per poi sistemarlo nella posizione corretta i. L'algoritmo di ordinamento per selezione è mostrato nel Codice 2.2. Come possiamo vedere, l'algoritmo esegue due cicli annidati: il ciclo esterno (che va dalla riga 2 alla 13) corrisponde agli n passi dell'algoritmo, mentre il ciclo interno (che va dalla riga 5 alla 10) corrisponde alla ricerca del minimo. Il posizionamento viene poi effettuato dalle righe 11 e 12, in cui il minimo da spostare viene scambiato con l'attuale elemento nella posizione i. Analizziamo la complessità dell'algoritmo di ordinamento per selezione utilizzando lo schema di analisi illustrato nel Paragrafo 1.4. Abbiamo m = n iterazioni nel ciclo esterno del Codice 2.2 (righe 2-13) e il costo ti dell'iterazione i è dato da un numero costante di operazioni di costo 0 ( 1 ) più il costo del ciclo interno (righe 5-10). Quindi,

al passo i, l'algoritmo esegue un numero di operazioni proporzionale a t i = n — i: il numero totale di operazioni al caso pessimo è pertanto proporzionale a n Í . -a .\ V-. nn+1 1 2 _ (n. - 1 ) = 2 _ = 2

TI— 1 i=0

\ =

2

i=l

In altre parole, il selection sort è un algoritmo di ordinamento con complessità quadratica rispetto al numero di elementi da ordinare. Tale complessità in tempo è sempre raggiunta, per qualunque sequenza iniziale di n elementi: possiamo dunque dire che il costo computazionale dell'algoritmo è 0 ( n 2 ) . Per questo motivo, nel prossimo paragrafo descriveremo un algoritmo di ordinamento altrettanto semplice, le cui prestazioni possono essere in alcuni casi significativamente migliori. ALVIE: ordinamento per selezione

Osserva, sperimenta e verifica

SelectionSort a t e : :•

2.2.2

Ordinamento per inserimento

L'algoritmo di ordinamento per inserimento, detto insertion sort, consiste anch'esso nell'eseguire n passi: al passo i = 0 , 1 , . . . , n — 1, l'elemento in posizione i viene inserito al posto giusto tra i primi i elementi. In altre parole, al termine del passo i, gli i + 1 elementi sistemati fino a quel momento sono tra di loro ordinati ma non coincidono necessariamente con i primi i + 1 elementi della sequenza ordinata. Sia p r o s s i m o l'elemento in posizione i: per realizzare il passo i, l'algoritmo confronta p r o s s i m o con i primi v elementi fino a trovare la posizione corretta in cui inserirlo: procedendo dalla posizione i— 1 verso l'inizio della sequenza, sposta ciascuno degli elementi di una posizione in avanti per far posto a quello da inserire. L'algoritmo di ordinamento per inserimento è mostrato nel Codice 2.3, il quale esegue un doppio ciclo di cui quello più esterno (dalla riga 2 alla 10) corrisponde agli n passi dell'algoritmo. Il ciclo w h i l e interno (dalla riga 5 alla 8), esamina le posizioni i — 1, i — 2 , . . . , fino a trovare il punto in cui p r o s s i m o deve essere inserito. Man mano che esamina gli elementi in tali posizioni, questi vengono spostati di una posizione in avanti. Al termine del ciclo interno (riga 9), avviene l'effettiva operazione di inserimento di p r o s s i m o nella posizione corretta.

(pre: la lunghezza di a è n)

InsertionSort ( a ): FOR (i = 0; i < n; i = i+1) { prossimo = a[i]; j

=

i;

WHILE ((j > 0 ) tt (a[j-l] > prossimo)) {

a[j] = a[j-l] ; j

=

j-i;

> a[j] = prossimo;

Codice 2.3

Ordinamento per inserimento di un array a.

Al passo i-esimo l'algoritmo di ordinamento per inserimento esegue un numero di operazioni proporzionale a i al caso pessimo, e il numero totale di operazioni eseguite è proporzionale a

n

¿

i =

^il

2 = 0(n )

i=0

In altre parole, anche l'insertion sort è un algoritmo di ordinamento con complessità quadratica rispetto al numero di elementi da ordinare. Tuttavia, a differenza del selection sort, può richiedere un tempo significativamente inferiore per certe sequenze di elementi: se la sequenza iniziale è già in ordine o soltanto un numero trascurabile di elementi è fuori ordine, l'insertion sort richiede O(n) tempo mentre la complessità del selection sort rimane comunque 0 ( n 2 ) . ALVIE: o r d i n a m e n t o per i n s e r i m e n t o

K Jh^fcì

^LBp^

2.3

•»

>• •





Osserva, s p e r i m e n t a e verifica

InsertionSort

Complessità di problemi computazionali

Per illustrare i principi di base che guidano la metodologia di progettazione degli algoritmi e la loro analisi di complessità in termini di tempo e spazio, consideriamo un problema "giocattolo", ovvero la ricerca del segmento di somma massima. Data una sequenza

SommaMassimal( a ): {pre: a contiene n elementi di cui almeno uno positivo) max = 0; FOR (i = 0; i < n; i = i+1) { FOR (j = i; j < n; j = j+1) { somma = 0; FOR (k = i; k max) max = somma;

>

> RETURN max; Codice 2.4

Prima soluzione per il segmento di somma massima.

di n interi memorizzata in un array a, un segmento è una qualunque sotto-sequenza di elementi consecutivi, di, O i + i , . . . , a j , dalla posizione i fino alla j. Tale segmento viene indicato con la notazione a[i, j], dove 0 ^ i ^ j ^ n — 1; in tal modo, l'intero array corrisponde ad a[0, n — 1]. La somma di un segmento a[i, j] è data dalla somma dei suoi componenti, somma[a[i, j]) = a[k]. Il problema consiste nell'individuare in a un segmento di somma massima, dove a parità di somma viene scelto il segmento più corto. Notiamo che se a contiene solo elementi positivi, allora il segmento di somma massima coincide necessariamente con l'intero array: il problema diventa interessante se l'array include almeno un elemento negativo. D'altra parte, se a contiene solo elementi negativi, allora il problema si riduce a trovare l'elemento dell'array il cui valore assoluto è minimo: per questo motivo, possiamo supporre che a contenga almeno un elemento positivo (è chiaro che, in questo caso, un segmento di somma massima deve avere gli estremi positivi, in quanto altrimenti potremmo ottenere un segmento avente somma maggiore escludendo un estremo negativo). Nella prima soluzione proposta, generiamo direttamente tutti i segmenti calcolando la somma massima. Un segmento a[i, )] è univocamente identificato dalla coppia di posizioni, i e }, dei suoi estremi a[i] e a[j]. Generiamo quindi tutte le coppie i e j in cui O ^ i ^ j ^ n — 1 e calcoliamo le somme dei relativi segmenti, ottenendo l'algoritmo mostrato nel Codice 2.4. Il costo di tale algoritmo è 0 ( n 3 ) tempo: infatti, il corpo dei primi due cicli f o r (dalla riga 5 alla 8) viene eseguito meno di n 2 volte e, al suo interno, il terzo ciclo f o r calcola somma{a[i, j]) eseguendo j — i + 1 iterazioni, ciascuna in tempo 0 ( 1 ) . D'altra parte l'algoritmo richiede H ( n 3 ) tempo, in quanto la riga 7 è eseguita L i = o L U () " i •+ 1 ) volte, ovvero Z i = o L U ì > L i = o (n ~ > L^o = Q ( n 3 ) volte. La complessità in spazio è 0 ( 1 ) in quanto usiamo soltanto un numero costante di variabili di appoggio oltre ai dati in ingresso.

SommaMassima2( a ): {pre: a contiene n elementi di cui almeno uno positivo) max = 0; FOR (i = 0; i < N; i = i+1) { somma = 0; FOR (j = i; j < n; j = j+1) { somma = somma + a[j] ; IF (somma > max) max = somma;

> > RETURN max; Codice 2.5

Seconda soluzione per il segmento di somma massima.

Nella seconda soluzione proposta, una volta calcolata somma{a[i, j — 1]), evitiamo di ripartire da capo per il calcolo di somma{ a[i, j]). Utilizziamo il fatto che somma{ a[i, j]) = somma[a[i, j — 1]) + a[j], ottenendo il Codice 2.5. Mantenendo l'invariante che, all'inizio di ogni iterazione del ciclo f o r più interno (dalla riga 5 alla 8), la variabile somma corrisponde asomma{a[\,) — 1]), è sufficiente aggiungere a[j] per ottenere somma{a[\,)]). Il costo in tempo è ora 0 ( n 2 ) in quanto dettato dai due cicli f o r , mentre la complessità in spazio rimane 0 ( 1 ) . Nella terza e ultima soluzione proposta, sfruttiamo meglio la struttura combinatoria del problema in quanto, a fronte di 0 ( n 2 ) possibili segmenti, ne possono esistere soltanto O(n) di somma massima, e questi sono disgiunti a seguito della seguente proprietà invariante. Esaminando un segmento di somma massima, a[i, j], notiamo che deve avere lunghezza minima tra i segmenti di pari somma, e deve soddisfare le seguenti due condizioni. (a) Ogni prefisso di a[i, }] ha somma positiva: somma{a[\, k]) > 0 per ogni i ^ k < j. Se così non fosse, esisterebbe un valore di k tale che somma{a[\, j]) = somma{a[i, k]) + somma{a[k + 1, j]) ^ somma(a[k + 1, j]) ottenendo una contraddizione in quanto il segmento a[k + 1, j] avrebbe somma maggiore o, a parità di somma, sarebbe più corto. (b) Il segmento a[i, j] non può essere esteso a sinistra: sommaia[k, i— 1]) < 0 per ogni 0 ^ k ^ i — 1. Se così non fosse, esisterebbe una posizione k ^ i — 1 per cui sommai a[k,j]) = sommaia[ k,i— 1]) + sommaia[x, j]) > sommaia[ i, j]) ottenendo una contraddizione in quanto il segmento a[k, j] avrebbe somma maggiore di quello con somma massima.

SommaMassima3( a ): (pre: a contiene n elementi di cui almeno uno positivo) max = 0; somma = max; FOR (j = 0; j < n; j = j+1) { IF (somma > 0) { somma = somma + a[j]; > ELSE {

somma = a[j] ;

> IF (somma > max) max = somma;

> RETURN max; Codice 2.6

Terza soluzione per il segmento di somma massima.

Sfruttiamo le proprietà (a) e (b) durante la scansione dell'array a, come mostrato nel Codice 2.6. In particolare, la riga 6 corrisponde all'applicazione della proprietà (a), che ci assicura che possiamo estendere a destra il segmento corrente. La riga 8, invece, corrisponde all'applicazione della proprietà (b), in base alla quale possiamo scartare il segmento corrente e iniziare a considerare un nuovo segmento disgiunto da quelli esaminati fino a quel punto. Il costo di quest'ultima soluzione è O(n) in quanto c'è un solo ciclo f o r , il cui corpo richiede 0 ( 1 ) passi a ogni iterazione. Lo spazio aggiuntivo richiesto è rimasto di 0 ( 1 ) locazioni di memoria. In conclusione, partendo dalla prima soluzione, abbiamo ridotto la complessità in tempo da 0 ( n 3 ) a 0 ( n 2 ) con la seconda soluzione, per poi passare a O(n) con la terza. Invitiamo il lettore a eseguire sul calcolatore le tre soluzioni proposte per rendersi conto che la differenza di complessità non è confinata a uno studio puramente teorico ma molto spesso incide sulle prestazioni reali. Notiamo che l'algoritmo per la terza soluzione ha una complessità asintotica ottima sia in termini di spazio che di tempo. Nel caso dello spazio, ogni algoritmo deve usare almeno un numero costante di locazioni per le variabili di appoggio. Per il tempo, ogni • algoritmo per il problema deve leggere tutti gli n elementi dell'array a perché, se così non fosse, avremmo almeno un elemento, a[r], non letto: in tal caso, potremmo invalidare la soluzione trovata assegnando un valore opportuno ad a[r]. Quindi ogni algoritmo risolutore per il problema del segmento di somma massima deve leggere tutti gli elementi e quindi richiede un tempo pari a n ( n ) . Ne consegue che la terza soluzione è ottima asintoticamente. In generale, pur essendoci un numero infinito di algoritmi risolutori per un dato problema, possiamo derivare degli argomenti formali per dimostrare che l'algoritmo proposto è tra i migliori dal punto di vista della complessità computazionale.

ALVIE: segmento dì somma massima

Osserva, sperimenta e verifica SegmentSum

2.3.1

Limiti superiori e inferiori

Il problema del segmento di somma massima esemplifica l'approccio concettuale adottato nello studio degli algoritmi. Per un dato problema computazionale FI, consideriamo un qualunque algoritmo A di risoluzione. Se A richiede t(n) tempo per risolvere una generica istanza di IT di dimensione n, diremo che 0 ( t ( n ) ) è un limite superiore alla complessità in tempo del problema Fi. Lo scopo del progettista è quello di riuscire a trovare l'algoritmo A con il migliore tempo t(n) possibile. A tal fine, quando riusciamo a dimostrare con argomentazioni combinatorie che qualunque algoritmo A' richiede almeno tempo f (n) per risolvere FI su un'istanza generica di dimensione n, asintoticamente per infiniti valori di n , diremo che 0 ( f ( n ) ) è un limite inferiore alla complessità in tempo del problema Fi. In tal caso, nessun algoritmo può richiedere asintoticamente meno di 0 ( f ( n ) ) tempo per risolvere FI. Ne deriva che l'algoritmo A è ottimo se t(n) = 0 ( f ( n ) ) , ovvero se la complessità in tempo di A corrisponde dal punto di vista asintotico al limite inferiore di Fi. Ciò ci permette di stabilire che la complessità computazionale del problema è 0 ( f ( n ) ) . Notiamo che spesso la complessità computazionale di un problema combinatorio FI viene confusa con quella di un suo algoritmo risolutore A: in realtà ciò è corretto se e solo se A è ottimo. Nel problema del segmento di somma massima, abbiamo mostrato che la terza soluzione richiede tempo O(n). Quindi il limite superiore del problema è O(n). Inoltre, abbiamo mostrato che ogni algoritmo di risoluzione richiede O(n) tempo, fornendo un limite inferiore. Ne deriva che la complessità del problema è 0 ( n ) e che la terza soluzione è un algoritmo ottimo. Per quanto riguarda la complessità in spazio, s(n), possiamo procedere analogamente al tempo nella definizione di limite superiore e inferiore, nonché di ottimalità. Da ricordare che lo spazio s(u) misura il numero di locazioni di memoria necessarie a risolvere il problema FI, oltre a quelle richieste dai dati in ingresso. Per esempio, gli algoritmi in loco sono caratterizzati dall'usare soltanto spazio s(n) = 0(1), come accade per il problema del segmento di somma massima.

RicercaSequenziale ( a, k ):

(pre: la lunghezza di a è n)

trovato = FALSE;

indice = -1; FOR (i = 0; (i

>

RETURN Codice 2.7

2.4

indice;

Ricerca sequenziale di una chiave k in un array a.

Ricerca di una chiave

Uno degli usi più frequenti del calcolatore è quello di cercare una chiave, ovvero un valore specificato, tra la mole di dati disponibili in forma elettronica. Data una sequenza lineare di n elementi, la ricerca di una chiave k consiste nel verificare se la sequenza contiene un elemento il cui valore è uguale a k (nel seguito identificheremo gli elementi con il loro valore). Se la sequenza non rispetta alcun ordine, occorre esaminare tutti gli elementi con il metodo della ricerca sequenziale, descritto nel Codice 2.7. L'algoritmo non fa altro che scandire, uno dopo l'altro, i valori degli elementi contenuti nell'array a: al termine del ciclo f o r (dalla riga 4 alla 9), se la chiave k è stata trovata, la variabile i n d i c e contiene la posizione della sua prima occorrenza. Nel caso pessimo, quando la chiave cercata non è tra quelle nella sequenza ( i n d i c e = —1), l'algoritmo richiede un numero di operazioni proporzionale al numero di elementi presenti nella sequenza, e quindi tempo O(n).

2.4.1

Ricerca binaria

E possibile fare di meglio quando la sequenza è ordinata? Usando la ricerca binaria (o dicotomica), il costo si riduce a O(logn) al caso pessimo: invece di scandire miliardi di dati durante la ricerca, ne esaminiamo solo poche decine! Un modo folkloristico di introdurre il metodo si ispira alla ricerca di una parola in un dizionario (o di un numero in un elenco telefonico). Prendiamo la pagina centrale del dizionario: se la parola cercata è alfabeticamente precedente a tale pagina, strappiamo in due il dizionario e ne buttiamo via la metà destra; se la parola è alfabeticamente successiva alla pagina centrale, buttiamo via la metà sinistra. Altrimenti, l'unica possibilità è che la parola sia nella pagina centrale. Con un numero limitato di operazioni possiamo diminuire

RicercaBinarialterativaC a, k ) : sinistra = 0; destra = n-1;

{pre: la lunghezza di a è n)

trovato = FALSE;

indice = -1; WHILE ((sinistra k) { destra = centro-1; > ELSE IF (a[centro] < k) {

sinistra = centro+1; > ELSE {

indice = centro; trovato = TRUE;

}

> RETURN Codice 2.8

indice;

Ricerca binaria di una chiave k in un array a.

il numero di pagine da cercare di circa la metà. Basta ripetere il metodo per ridurre esponenzialmente tale numero fino a giungere alla pagina cercata o concludere che la parola non appare in alcuna pagina. Analogamente possiamo cercare una chiave k quando l'array a è ordinato in modo non decrescente, basandoci su operazioni di semplice confronto tra due elementi. Confrontiamo la chiave k con l'elemento che si trova in posizione centrale nell'array, a[n/2], e se k è minore di tale elemento, ripetiamo il procedimento nel segmento costituito dagli elementi che precedono a[n/2]. Altrimenti, lo ripetiamo in quello costituito dagli elementi che lo seguono. Il procedimento termina nel momento in cui k coincide con l'elemento centrale del segmento corrente (nel qual caso, abbiamo trovato la posizione corrispondente) oppure il segmento diventa vuoto (nel qual caso, k non è presente nell'array). Il Codice 2.8 mantiene implicitamente l'invariante, per le due variabili s i n i s t r a e d e s t r a che delimitano il segmento in cui effettuare la ricerca, secondo la quale vale a [ s i n i s t r a ] ^ k ^ a [ d e s t r a ] . Inizialmente, queste due variabili sono poste a 0 e n— 1, rispettivamente (righe 2 e 3). Possiamo escludere i casi limite in cui k < a[0] oppure a[n — 1] < k, per cui presumiamo senza perdita di generalità che a[0] ^ k ^ a[n — 1]. A ogni iterazione del ciclo w h i l e (dalla riga 6 alla 16), se la chiave k è minore dell'elemento centrale (il cui indice di posizione è dato dalla semisomma delle due variabili s i n i s t r a e d e s t r a ) , la ricerca prosegue nella parte sinistra del segmento: per questo

motivo, la variabile d e s t r a viene modificata in modo da indicare l'elemento immediatamente precedente a quello centrale (riga 9). Se, invece, la chiave k è maggiore del valore dell'elemento centrale, la ricerca prosegue nella parte destra del segmento (riga 11). Se, infine, la chiave è stata trovata, l'indice di posizione della sua occorrenza viene memorizzato nella variabile i n d i c e e il ciclo ha termine in quanto la variabile t r o v a t o assume il valore vero (righe 13 e 14). Quando la chiave non appare, il segmento diventa vuoto poiché s i n i s t r a > d e s t r a . Osserviamo che a ogni iterazione del ciclo w h i l e , la lunghezza del segmento in cui deve proseguire la ricerca viene all'incirca dimezzato. Pertanto, la prima iterazione riduce la ricerca da n a circa n / 2 elementi; la seconda iterazione la riduce a circa n / 4 elementi; la terza la riduce a circa n / 8 , e così via. In generale, l'iterazione i-esima riduce la ricerca a circa n / 2 l elementi. Al caso pessimo, la chiave viene trovata all'ultima iterazione i*, per cui n / 2 l = 1. Se la chiave non è inclusa nella sequenza, occorre un'ulteriore iterazione. Quindi con un numero di iterazioni non superiore a i* + 1 = O(logn), la chiave specificata viene trovata oppure l'algoritmo conclude che tale chiave non appare. La ricerca in una sequenza ordinata richiede quindi O(logn) tempo. ALVIE: ricerca binaria

Osserva, sperimenta e verifica BinarySearch

La strategia dicotomica rappresentata dalla ricerca binaria è utile in tutti quei problemi in cui vale una qualche proprietà monotona boolena P(x) al variare di un parametro , intero x: in altre parole, esiste un intero Xo tale che P(x) è vera per x ^ Xo e falsa per x > xo (o, viceversa, P(x) è falsa per x ^ xo e vera per x > xo). Usando uno schema simile alla ricerca binaria possiamo trovare il valore di xo- Per esempio, ipotizziamo di dover indovinare il valore di un numero n > 0 pensato segretamente da un nostro amico. E dispendioso chiedere se n = 1, n = 2, n = 3, e così via per tutta la sequenza di numeri da 1 fino a n (che rimane sconosciuto fino all'ultima domanda), in quanto richiede di effettuare n domande. Usando il nostro schema, possiamo porre concettualmente XQ = n e, come prima cosa, effettuare domande del tipo "x ^ n?" per potenze crescenti x = 1 , 2 , 4 , 8 , . . . , 2 H e così via. Pur non conoscendo il valore di n, ci fermiamo non appena 2 h _ 1 < n ^ 2 h (a causa della riposta affermativa del nostro amico), effettuando in tal modo h. = O(logn) domande poiché h. < 1 + l o g n . A questo punto, sappiamo che n appartiene all'intervallo [ 2 h _ 1 + 1 . . . 2 h ] e applichiamo una variante del Codice 2.8 in cui la chiave k è il valore sconosciuto di n (in altre parole, i due confronti alle righe 8 e 10 diventano domande

da porre al nostro amico che ha pensato il numero k = n). Con ulteriori 0 ( l o g 2 h _ 1 ) = O(logn) domande, riusciamo a indovinare il numero n . In totale, abbiamo ridotto il numero di domande da effettuare al nostro amico da n a O(logn) per indovinare il numero n da lui segretamente pensato.

2.4.2

Complessità della ricerca per confronti

Il costo della ricerca binaria rappresenta un limite superiore di O(logn) per il problema della ricerca di una chiave usando confronti tra gli elementi di una sequenza ordinata. Seguendo le argomentazioni del Paragrafo 2.3.1, vogliamo ora stabilire un limite inferiore al problema per dimostrare che l'algoritmo di ricerca binaria è asintoticamente ottimo, stabilendo così anche la complessità del problema della ricerca per confronti in una sequenza ordinata: ogni algoritmo di ricerca per confronti (non soltanto la ricerca binaria) ne richiede Q(logn) al caso pessimo. Usiamo un semplice approccio combinatorio basato sulla teoria dell'informazione. Sia A un qualunque algoritmo di ricerca che usa confronti tra coppie di elementi. L'algoritmo A deve discernere tra n + 1 situazioni: la chiave cercata non appare nella sequenza ( i n d i c e = —1), oppure appare in una delle n posizioni della sequenza (0 ^ i n d i c e < n — 1). Durante l'esecuzione, A esegue dei confronti, ognuno dei quali dà luogo a tre possibili riposte in []: quindi il primo confronto permette di discernere tra al più tre situazioni, il secondo amplia il numero di situazioni di al più un fattore tre, e così via. Procedendo in questo modo, possiamo concludere che dopo t confronti di chiavi (mai esaminate prima), l'algoritmo A può discernere al più 3 l situazioni. Poiché viene richiesto di discernerne n + 1, deve valere 3 l ^ n + 1. Ne deriva che occorrono t ^ log 3 (n + 1) = n ( l o g n ) confronti: ciò rappresenta un limite inferiore per il problema della ricerca per confronti. Poiché limite superiore e inferiore coincidono asintoticamente, abbiamo che la complessità del problema è 0 ( l o g n ) e che la ricerca binaria è un algoritmo ottimo.

2.5

Ricorsione e paradigma del divide et impera

Il paradigma del divide et impera è uno dei più utilizzati nella progettazione di algoritmi ricorsivi e si basa su un principio ben noto a chiunque abbia dovuto scrivere programmi di complessità non elementare. Tale principio consiste nel suddividere un problema in due o più sotto-problemi, nel risolvere tali sotto-problemi, eventualmente applicando nuovamente il principio stesso, fino a giungere a problemi "elementari" che possano essere risolti in maniera diretta, e nel combinare le soluzioni dei sotto-problemi in modo opportuno così da ottenere una soluzione al problema di partenza.

RicercaBinariaRicorsiva( a, k, sinistra, destra ): {pre: 0 ^ sinistra ^ destra ^ n - 1) IF (sinistra == destra) { IF (k == a[sinistra]) { RETURN sinistra; > ELSE { RETURN -1;

> > centro = (sinistra+destra)/2; IF (k Codice 2.9

Ricerca binaria di una chiave k in un array a con ¡1 paradigma del divide et impera.

Quello che contraddistingue il paradigma del divide et impera è il fatto che i sottoproblemi sono istanze dello stesso problema originale ma di dimensioni ridotte: il fatto che un sotto-problema sia elementare o meno dipende, essenzialmente, dal fatto che la dimensione dell'istanza sia sufficientemente piccola da poter risolvere il sotto-problema in maniera diretta. Il paradigma del divide et impera può, dunque, essere strutturato nelle seguenti tre fasi che lo caratterizzano. Decomposizione: identifica un numero piccolo di sotto-problemi dello stesso tipo, ciascuno definito su un insieme dei dati di dimensione inferiore a quello di partenza. Ricorsione: risolvi ricorsivamente ciascun sotto-problema fino a ottenere insiemi di dati di dimensioni tali che i sotto-problemi possano essere risolti direttamente. Ricombinazione: combina le soluzioni dei sotto-problemi per fornire una soluzione al problema di partenza. Osserviamo che l'algoritmo di ricerca binaria può essere interpretato come un'applicazione del paradigma del divide et impera. In effetti, la ricerca di una chiave all'interno di un array di n elementi ordinati, viene realizzata risolvendo il problema della ricerca della chiave in un array di dimensione pari a circa la metà di quella dell'array originale, fino a giungere a un array con un solo elemento, nel qual caso il problema diviene chiaramente elementare. Più formalmente, tale descrizione della ricerca binaria è mostrata nel Codice 2.9, in cui le istruzioni alle righe 3-9 risolvono in maniera diretta il caso elementare, mentre le istruzioni alle righe 12 e 14 riducono il problema della ricerca all'interno

del segmento attuale a quello della ricerca nella metà sinistra e destra, rispettivamente, del segmento stesso. Nella chiamata iniziale, il parametro s i n i s t r a assume il valore 0 e il parametro d e s t r a assume il valore n — 1. Notiamo che, modificando semplicemente le righe 4-8 in modo che restituiscano la posizione s i n i s t r a se k < a [ s i n i s t r a ] e la posizione s i n i s t r a + 1 altrimenti (invece che il valore —1), la ricerca binaria così modificata restituisce il rango r di k (dove 0 ^ r < n), definito come il numero di elementi in a che sono minori o uguali a k. Inoltre, nel caso che l'array contenga un multi-insieme ordinato, dove sono ammesse le occorrenze multiple delle chiavi, il Codice 2.9 individua la posizione dell'occorrenza più a sinistra della chiave k (non è difficile modificarlo per individuare quella più a destra).

2.5.1

Equazioni di ricorrenza e teorema fondamentale

La formulazione ricorsiva della ricerca binaria necessita di un nuovo strumento analitico per valutarne la complessità temporale, facendo uso di equazioni di ricorrenza, ovvero di espressioni matematiche che esprimono una funzione T(n) sugli interi come una combinazione dei valori T(i), con 0 ^ i < n. A tale scopo, osserviamo che se il segmento all'interno del quale stiamo cercando una chiave è costituito da un solo elemento, allora il Codice 2.9 esegue un numero costante c di operazioni. Altrimenti, il numero di operazioni eseguite è pari a una costante c' più il numero di passi richiesto dalla ricerca della chiave in un segmento di dimensione pari alla metà di quello attuale. Pertanto, il numero totale T(n) di passi eseguiti su un array di n elementi verifica la seguente equazione di ricorrenza: T(n) =

{ T(n/2) + c'

altrimenti

(2-1)

Al fine di derivare una formulazione in forma chiusa di T(n) utilizzeremo il teorema fondamentale delle ricorrenze (master theorem), che consente di risolvere equazioni di ricorrenza di questo tipo (e di cui diamo dimostrazione nell'appendice). Il teorema fondamentale delle ricorrenze afferma che, se f(n) è una funzione e a, |3 e TLQ sono tre costanti tali che a ^ l , | 3 > l e n o > 0 , allora l'equazione di ricorrenza -j-r ^ _ f 0 ( 1 ) 1 ' \ a T ( n / P ) + f(n)

se n i : n 0 altrimenti

2) V

(dove n/(3 va interpretato come | n / P J o [n/|3]) ha le seguenti soluzioni per ogni n: 1. T(n) = 0 ( f ( n ) ) se esiste una costante y < 1 tale che a f ( n / | 3 ) = y f ( n ) ; 2. T(n) = 0 ( f ( n ) log p n) se a f ( n / ( 3 ) = f(n); 3. T(n) = Otn' 06 ! 5 a ) se esiste una costante y' > 1 tale a f ( n / | 3 ) = y' f(n).

' '

L'interpretazione dell'equazione (2.2) in termini del paradigma del divide et impera è che applichiamo il caso base quando ci sono al più no elementi; altrimenti, dividiamo gli n elementi in gruppi da n/(3 elementi ciascuno, effettuiamo a chiamate ricorsive (ciascuna su n/|3 elementi) e impieghiamo tempo f (n) per eseguire le fasi di decomposizione e di ricombinazione (ossia f (n) conteggia tutti i costi tranne quelli dovuti alle chiamate ricorsive). Nel caso dell'algoritmo di ricerca binaria, abbiamo che a = 1, (3 = 2 e f(n) = c' = 0(1) per ogni n, per cui rientriamo nel secondo caso del teorema fondamentale delle ricorrenze. Come già dimostrato nel paragrafo precedente, possiamo quindi concludere che la ricerca binaria in un array ordinato richiede O(logn) passi. La ricerca binaria è un esempio molto particolare di applicazione del paradigma del divide et impera, in quanto il problema originale viene decomposto in un solo sottoproblema e la fase di ricombinazione consiste semplicemente nel restituire esattamente la soluzione prodotta per il sotto-problema. Nel resto di questo paragrafo, forniamo degli esempi più articolati di applicazione del divide et impera per mostrarne l'uso generale.

2.5.2

Moltiplicazione veloce di due numeri interi

Un numero intero di n cifre decimali, con n arbitrariamente grande, può essere rappresentato mediante un array x di n + 1 elementi, in cui x[0] è un intero che rappresenta il segno (ossia +1 o —1) e x[i] è un intero che rappresenta l'i-esima cifra più significativa, dove 0 < x[i] ^ 9 e 1 ^ i ^ n . Ovviamente, tale rappresentazione è più dispendiosa dal punto di vista della memoria utilizzata, ma consente di operare su numeri arbitrariamente grandi. 1 Vediamo ora come sia possibile eseguire le due operazioni di somma e di prodotto facendo riferimento a tale rappresentazione. Nel seguito, senza perdita di generalità, supponiamo che i due interi da sommare o moltiplicare siano rappresentati entrambi mediante n cifre decimali dove n è una potenza di due: in caso contrario, infatti, tale condizione può essere ottenuta aggiungendo alla loro rappresentazione una quantità opportuna di 0 nelle posizioni più significative. Per quanto riguarda la somma, il familiare algoritmo che consiste nell'addizionare le singole cifre propagando l'eventuale riporto, richiede 0 ( n ) passi ed è quindi ottimo. Non possiamo fare lo stesso discorso per l'algoritmo di moltiplicazione che viene insegnato nelle scuole, in base al quale viene eseguito il prodotto del moltiplicando per ogni cifra del moltiplicatore, eseguendo poi n addizioni di numeri di 0 ( n ) cifre per ottenere il risultato desiderato: pertanto, la complessità di tale algoritmo per la moltiplicazione è 0 ( n 2 ) tempo. 'Il problema di gestire numeri interi arbitrariamente grandi ha diverse applicazioni tra cui i protocolli crittografici: esistono apposite implementazioni per molti linguaggi di programmazione, come, ad esempio, la classe B i g l n t e g e r del pacchetto j a v a . m a t h .

Facendo uso del paradigma del divide et impera e di semplici uguaglianze algebriche possiamo mostrare ora come ridurre significativamente tale complessità. Osserviamo anzitutto che possiamo scrivere ogni numero intero w di n cifre come 10 n ^ 2 x w s + w j , dove w s denota il numero formato dalle n / 2 cifre più significative di w e w j denota il numero formato dalle n / 2 cifre meno significative. Per moltiplicare due numeri x e y, vale quindi l'uguaglianza xy = ( 1 0 n / 2 x s + x d ) ( 1 0 n / 2 y s + y d ) = 1 0 n x s y s + 1 0 n / 2 ( x s y d + x d y s ) + x d y d che ci conduce al seguente algoritmo basato sul paradigma del divide et impera. Decomposizione: se x e y hanno almeno due cifre, dividili come numeri x s , x d , y s e y d aventi ciascuno la metà delle cifre. Ricorsione: calcola ricorsivamente le moltiplicazioni x s y s , x s y d , x d y s e x d y d . Ricombinazione: combina i numeri risultanti usando l'uguaglianza suddetta. Quindi, indicato con T(n) il numero totale di passi eseguiti per la moltiplicazione di due numeri di n cifre, eseguiamo quattro moltiplicazioni di due numeri di n / 2 cifre, dove ciascuna moltiplicazione richiede un costo di T(n/2), e tre somme di due numeri di n cifre. Osserviamo che per ogni k > 0, la moltiplicazione per il valore 10 k può essere realizzata spostando le cifre di k posizioni verso sinistra e riempiendo di 0 la parte destra. Pertanto, il costo della decomposizione e della ricombinazione è O(n) tempo e, riscrivendo tale termine come c ' n per una costante c' > 0 e indicando il costo per il caso base con la costante c > 0, possiamo esprimere T(n) mediante l'equazione di ricorrenza

T(n) =

f c { 4T(n/2)+c'n

se n = 1 altrimenti

(2 3)

'

Applicando il teorema fondamentale delle ricorrenze all'equazione (2.3), otteniamo a = 4, (3 = 2 e f(n) = c ' n nell'equazione (2.2). Poiché af(n/|3) = 4 c ' ( n / 2 ) = 2 c ' n = 2f(n), rientriamo nel terzo caso del teorema con y' = 2. Tale caso consente di affermare che il numero di passi richiesti è 0(n'°6 4 ) = 0 ( n 2 ) , non migliorando quindi le prestazioni del familiare algoritmo di moltiplicazione precedentemente descritto. Tuttavia, facendo uso di un'altra semplice uguaglianza algebrica possiamo migliorare il costo computazionale dell'algoritmo basato sul paradigma del divide et impera. Osserviamo infatti che il valore x s y d + x d y s presente nella precedente uguaglianza, può essere calcolato.facendo uso degli altri due valori x s y s e x d y d nel modo seguente: xsy ELSE {

xs[0] = xd[0] = ys [0] = yd[0] = 1; FOR (i = 1; i , osserviamo che la nuova posizione [x',y', 1] soddisfa la relazione trigonometrica x' = x c o s O — y sin 1) che rappresentano via via i contributi dei vari "rimbalzi" della luce sulle

ProdottoMatrici ( A, B ) : (pre: A Ì B sono di taglia rxsisxt) FOR (i = 0; i < r; i = i+1) FOR (j = 0; j < t; j = j+1) { C[i] [j] = 0; FOR (k = 0; k < s; k = k+1) C[i][j] = C[i][j] + A[i] [k] x B[k] [j] ;

> RETURN

Codice 2 . 1 6

C;

{post: C è di taglia

r x t)

Algoritmo per la moltiplicazione di due matrici in 0 ( r x s x t) tempo.

varie superfici, riuscendo così a determinare i contributi dell'illuminazione indiretta per ogni primitiva. Nel resto del paragrafo ipotizziamo di avere un numero arbitrariamente grande di righe e di colonne e studiamo la complessità dell'algoritmo di moltiplicazione di due matrici e di quello per determinare la sequenza ottima che minimizza il numero di operazioni necessarie a calcolare il prodotto di n matrici.

2.6.1

Moltiplicazione veloce di due matrici

L'algoritmo immediato per calcolare la somma A + B di due matrici A e B, effettua r x s operazioni di somma (una per ogni elemento di C): poiché dobbiamo sempre esaminare H ( r x s) elementi nelle matrici, abbiamo che la somma di due matrici può essere quindi effettuata in tempo ottimo 0 ( r x s). Invece, l'algoritmo immediato per il prodotto di due matrici A r x s e B s x t , mostrato nel Codice 2.16, non è ottimo. Esso richiede di effettuare O(s) operazioni per ognuno degli r x t elementi di C, richiedendo così un totale di 0 ( r x s x t) operazioni. A differenza della somma di matrici, in questo caso esiste una differenza, o gap di complessità, con il limite inferiore pari a O ( r x s + s x t ) . Restringendoci al caso di matrici quadrate, le quali hanno n = r = s = t righe e colonne, osserviamo che il limite superiore è 0 ( n 3 ) mentre quello inferiore è f l ( n 2 ) : ciò lascia aperta la possibilità di trovare algoritmi più efficienti per il prodotto di matrici. Analogamente alla moltiplicazione veloce tra numeri arbitrariamente grandi (Paragrafo 2.5.2), l'applicazione del paradigma del divide et impera permette di definire algoritmi per il prodotto di matrici con complessità temporale nettamente inferiore a quella 0 ( n 3 ) dell'algoritmo descritto nel Codice 2.16. L'algoritmo di Strassen, che descriviamo per semplicità nel caso della moltiplicazione di matrici quadrate in cui n è una potenza di due, rappresenta il primo e più diffuso esempio dell'applicazione del divide et impera: esso è basato sull'osservazione che una moltiplicazione di due matrici 2 x 2 può essere

effettuata, nel modo seguente, per mezzo di 7 moltiplicazioni (e 14 addizioni), invece delle 8 moltiplicazioni (e 4 addizioni) del metodo standard. Consideriamo la seguente moltiplicazione da effettuare: a c

b' d

X

e

f' h.

.9

ae + bg ce + dg

af + bh' cf + d h

Se introduciamo i seguenti valori v0 vi v2 v3

= ( b - d ) ( g + H) = (a + d)(e + h.) = (a — c)(e + f) = h ( a + b)

v 4 = a(f - h) v 5 = d(g - e) v 6 = e(c + d)

possiamo osservare che la moltiplicazione precedente può essere espressa come "a C

b' e x d .9

f" h.

V0

+Vi - v 3 v5 + v6

+V5

v 3 + V4 +v4 - v 6

VI - V2

Questa considerazione, che non pare introdurre alcuna convenienza nel caso di matrici 2 x 2 , risulta invece interessante se consideriamo la moltiplicazione di matrici n x n per n > 2. In tal caso, ciascuna matrice di dimensione n x n può essere considerata come una matrice 2 x 2, in cui ciascuno degli elementi a, b , . . . , h. è una matrice di dimensione n / 2 x n / 2 : le relative operazioni di somma e moltiplicazione su di essi sono quindi somme e moltiplicazioni tra matrici. Indicando con T(n) il costo temporale della moltiplicazione di due matrici n x n e applicando la considerazione precedente, osserviamo che T(n) è pari al costo di esecuzione di 7 moltiplicazioni tra matrici j x j , che esprimiamo come 7 T ( ^ ) , più il costo di esecuzione di 14 somme di matrici anch'esse 7 X 7 , che possiamo stimare come 0 ( n 2 ) . Da quanto detto deriva l'equazione di ricorrenza seguente, dove c e c' sono opportune costanti positive: T(n

7T(f) +c'n

2

se n ^ 2 altrimenti

(2.10)

Applicando il teorema fondamentale delle ricorrenze all'equazione (2.10), con a = 7, (3 = 2 e f(n) = c ' n 2 nell'equazione (2.2), osserviamo che ci troviamo nel terzo caso considerato dal teorema in quanto 7 c ' ( y ) 2 = | c ' n 2 (quindi, y' = | > 1): pertanto, T(n) = 0(n'°8 7 ) = 0 ( n 2 , 8 0 7 - ) - E tuttora ignota la complessità della moltiplicazione di due matrici quadrate e la congettura più diffusa è che sia 0 ( n e ) per una costante 2 < e < 3.

ALVIE: moltiplicazione veloce di matrici

Osserva, sperimenta e verifica

fi!

StrassenMatrixProduct

2.6.2

Sequenza ottima di moltiplicazioni e paradigma della programmazione dinamica

Il problema di trovare il modo più efficiente di moltiplicare una sequenza di matrici consente di introdurre un altro importante paradigma algoritmico, la programmazione dinamica. Volendo illustrare tale paradigma, possiamo vederlo come un modo efficace per tabulare un algoritmo ricorsivo: per esempio, nel calcolo dei numeri di Fibonacci, definiti ricorsivamente come Fo = 0. Fi = 1 e F n = + Fn 2 P e r n ^ 2 (quindi 0,1, 1, 2,3, 5 , 7 , 1 2 e così via), l'algoritmo immediato ricorsivo per calcolare F n richiede un tempo esponenziale in n perché ricalcola molte volte gli stessi valori F^ (k < n) già calcolati in precedenza, mentre un algoritmo iterativo impiega tempo lineare in n usando un array di appoggio f i b in cui scrive f i b [ 0 ] = Fo. f i"b[l] = Fi e i valori intermedi f ib[k] = f ib[k—l]+f ib[k—2] ottenuti attraverso un ciclo per k = 2 , 3 , . . . , n (in realtà è possibile far di meglio, calcolando F n in O(logn) tempo). Implicitamente, abbiamo applicato il paradigma della programmazione dinamica, che tratteremo più specificatamente nel paragrafo successivo, implementando una regola di calcolo ricorsiva (di F n ) con un algoritmo iterativo che riempe gli elementi di un'opportuna tabella ( f i b ) di cui restituiamo il valore nell'ultimo elemento (corrispondente a F n ). ALVIE: numeri di Fibonacci

Osserva, sperimenta e verifica

Fibonacci

Ripercorriamo il paradigma di programmazione dinamica brevemente illustrato sopra, applicandolo al calcolo della sequenza ottima di moltiplicazioni di n > 2 matrici A* = Ao x A j x • • • x A n _ j . Ai fini della nostra discussione, utilizziamo l'algoritmo immediato (Codice 2.16) che moltiplica due matrici di taglia r x s e s x t con l'ipotesi semplificativa che tale algoritmo richieda un numero di operazioni esattamente pari a r x s x t, notando che quanto descritto si applica anche agli algoritmi più veloci come quello di Strassen.

Dovendo eseguire n — 1 moltiplicazioni per ottenere A*, osserviamo che l'ordine con cui le eseguiamo può cambiare il costo totale quando le matrici hanno taglia differente: nel seguito indichiamo con di x d^+i la taglia della matrice Ai, dove 0 ^ i < n— 1. Date ad esempio n = 4 matrici tali che do = 100, di = 20, d 2 = 1000, d3 = 2 e d 4 = 50, nella seguente tabella mostriamo con le parentesi tutti i possibili ordini di valutazione del loro prodotto A* = Ao x Ai x A2 x A3 (di taglia do x d 4 ), riportando il corrispettivo costo totale di esecuzione per ottenere A* : (A 0 x (AI x (A 2 x A 3 )) (A 0 x ((A, x A 2 ) x A 3 ) ((A 0 x A I ) x (A 2 x A 3 ) ) (((A0 X A , ) X A

2

) X

A3)

((AQ x (AI x A 2 )) x A 3 )

d2d3d4 did2d3 d0did2 d0did2 did2d3

+ + + + +

did2d4 did3d4 d2d3d4 d0d2d3 d0did3

+ + + + +

dodid 4 d0did4 d0d2d4 d0d3d4 d0d3d4

= = = = =

1.200.000 142.000 7.100.000 2.210.000 54.000

Per esempio, la quarta riga corrisponde all'ordine naturale di moltiplicazione da sinistra verso destra: la moltiplicazione Ao x Ai richiede do x d] x d 2 operazioni e restituisce una matrice di taglia do x d 2 ; la successiva moltiplicazione per A 2 richiede do x d 2 x d 3 operazioni e restituisce una matrice di taglia do x d 3 ); l'ultima moltiplicazione per A 3 richiede do x d 3 x d 4 operazioni. Sommando tali costi e sostituendo i valori di d o , . . . , d 4 , otteniamo un totale di 2.210.000 operazioni. Le altre righe della tabella mostrano che il costo complessivo del prodotto può variare in dipendenza dell'ordine in cui sono effettuate le singole moltiplicazioni per ottenere lo stesso risultato: in questo caso, appare molto più conveniente effettuare le moltiplicazioni nell'ordine indicato nella quinta riga, che fornisce un costo di sole 54.000 operazioni. Il problema che ci poniamo è quello di trovare la sequenza di moltiplicazioni che minimizzi il costo complessivo del prodotto A* = Ao x A j x • • • x A n _ j per una data sequenza di n + 1 interi positivi do, d ] , . . . , d n (ricordiamo la nostra ipotesi che il costo della moltiplicazione di due matrici di taglia r x s e s x t sia pari a r x s x t). E possibile dimostrare che esiste un numero esponenziale di modi diversi di moltiplicare n matrici,^ il che rende un esame esaustivo delle varie possibilità rapidamente impraticabile al crescere di n. Possiamo ottenere un modo più efficiente di affrontare il problema considerando il sotto-problema di trovare il costo per effettuare il prodotto di un gruppo consecutivo di matrici, Ai x A i + ) x • • • x Aj, dove 1, indicando con M(i, j) il corrispondente costo minimo-, chiaramente, in tal modo siamo anche in grado di risolvere il problema iniziale calcolando M ( 0 , n — 1). Possiamo immediatamente notare che M(i, i) = 0 per ogni i, in quanto ha costo nullo calcolare il prodotto della sequenza composta dalla sola matrice Ai. Se passiamo al 'Tale numero è il numero di Catalan C„ ] = ^ ( ¡ „ l i • ''

cu

' andamento esponenziale sarà discusso

successivamente: anticipiamo c o m u n q u e che C n _ i è il numero di alberi binari distinti con n — 1 nodi interni, dove ogni nodo interno ha due figli e corrisponde a una coppia di parentesi.

CostoMinimoRicorsivo( i, j ):

(pre: 0 ^ i, j ^ n - 1)

IF (i >= J) RETURN 0;

minimo = +oo; FOR (r = i; r < j ; r = r+1) { costo = CostoMinimoRicorsivo( i, r ); costo = costo + CostoMinimoRicorsivoC r+1, j ); costo = costo + d[i] x d[r+l] x d[j+l]; IF (costo < minimo) minimo = costo; } RETURN minimo; Codice 2.17

Algoritmo ricorsivo per il calcolo del costo minimo di moltiplicazione M(i, j).

caso di M ( v, ) ) con i < j, osserviamo che possiamo ottenere la matrice Ai x Ai + \ x • • • x Aj (di taglia di x dj + i) fattorizzandola come una moltiplicazione tra At x • • • x A r (di taglia di x d T+ 1) e A r + i x • • • x Aj (di taglia d r + i x dj + i), per un qualunque intero r tale che i ^ r < j. Il costo di tale moltiplicazione è pari a di x d r + i x dj + i, a cui vanno aggiunti i costi minimi M(i, r) e M ( r + 1, j) per calcolare rispettivamente A; x • • • x A r e A r + i x • • • x Aj. Supponiamo di aver già calcolato induttivamente quest'ultimi costi per tutti i possibili valori r con i ^ r < j: allora il costo minimo M(i, j) sarà dato dal minimo, al variare di r, tra i valori M.(i, r) + M(r 4- 1, j) + d i d r + i dj + i. Da ciò deriva la seguente regola ricorsiva: ') - / 0 sei > j \ mini^T. 0 per le istruzioni alle righe 2, 3 e 10, a cui va aggiunta la complessità del ciclo alle righe 4-9. Per la generica iterazione r di quest'ultimo, abbiamo due chiamate ricorsive s u r + l e s u n — r + 1 matrici, rispettivamente, totalizzando una complessità di T(r + 1) + T(n — r — 1) passi, a cui va aggiunta la complessità costante c" > 0 per il resto delle istruzioni contenute nell'iterazione. Pertanto

T(n)-

, .

,

1

,

+ T ( n

_r_

1 ) + C„)

S

J^j,

t i

(2.12)

, \ J o

Figura 2.8

1

2

3

0

0

2000000

44000

54000

1

-

0

40000

42000

2

-

-

0

100000

3

-

-

-

0

Tabella dei costi minimi per il prodotto di n = 4 matrici in cui d 0 = 100, d, = 20, d 2 = 1000, d 3 = 2,d 4 = 50.

Ai fini della nostra discussione, possiamo porre c = c ' = c " complessità nel caso n > 1 dell'equazione (2.12) come

= 1, esprimendo la

n-2

T(tv) = 1 + ^ ( T ( r + 1 ) + T ( T I - T - 1) + 1) r=0

= n + (T(l) + T ( n - 1) + T(2) + T ( n - 2) + • • • + T ( n - 1 ) + T ( 1 ) ) n— 1 = n + 2^T(k) k=l

A questo punto, possiamo mostrare che T(n) ^ 2 n _ 1 , per induzione su n > 0. Nel caso base, T(l) ^ 1 = 2 ° per definizione. Al passo induttivo, supponiamo che T(k) ^ 2 k _ 1 per ogni k < n: allora n —1 n—1 T(n) = n + 2 ^ T ( k ) ^ n + 2 ^ 2k~ì k= 1

k=l

= n + 2

n —2 2T = n + 2 ( 2 n " ' - 1) > r=0

per n > 0, dove la prima disuguaglianza deriva dall'ipotesi induttiva. Di conseguenza, il numero di passi e di chiamate ricorsive eseguite dal Codice 2.17 è esponenziale nel numero n di matrici considerate. Questo spreco di tempo di calcolo può essere eliminato ottenendo un algoritmo polinomiale: calcoliamo una sola volta i valori M(i, j) memorizzandoli in una tabella dei costi, realizzata mediante un array bidimensionale c o s t i di taglia n x n, in modo tale che costi[v][j] = M(i, j) per 0 ^ i ^ j ^ n— 1 (gli elementi della tabella corrispondenti a valori di i e j tali che i > j non sono utilizzati dall'algoritmo). Illustriamo tale approccio

CostoMinimoIterativoC ): FOR (i = 0; i < n; i = i+1) { costi[i] [i] = 0 ;

> FOR (diagonale = 1; diagonale < n; diagonale = diagonale+ì) { FOR (i = 0; i < n-diagonale; i = i+1) { j = i + diagonale; costi [i] [j] = +oo; FOR ( R = i ; r < j ; r = r+1) { costo = costiti] [r] + costi[r+1][j]; costo = costo + d[i] x d[r+l] x d[j+l]; IF (costo < costiti][j]) { costi[i][j] = costo; indice[i] [j] = r;

> > > > RETURN Codice 2.18

costi[0][n-1];

Algoritmo iterativo per il calcolo dei costi minimi di moltiplicazione M(i, j).

nella Figura 2.8 con il nostro esempio in cui n = 4 e do = 100, d] = 20, d 2 = 1000, d 3 = 2 e d 4 = 50. Riempiamo la tabella dei costi per diagonale, dal basso in alto e da sinistra a destra, utilizzando la regola in (2.11). La diagonale 0 (ovvero quella per cui i = j) contiene chiaramente tutti 0. Consideriamo ora la diagonale 1 (ovvero quella per cui j = i + 1), che deve contenere i valori M ( 0 , 1 ) , M ( l , 2 ) e M ( 2 , 3 ) : possiamo calcolare tali valori usando elementi della tabella che sono già stati riempiti (in effetti, sono sufficienti quelli che si trovano sulla diagonale precedente). Possiamo quindi passare alla diagonale 2 (ovvero quella per cui j = i + 2) che deve contenere i valori M ( 0 , 2 ) e M ( l , 3 ) : gli elementi della tabella di cui abbiamo bisogno per calcolare tali valori sono tutti già stati riempiti e si trovano nelle due diagonali già esaminate. Nella diagonale 3, infine, dobbiamo inserire il valore M ( 0 , 3 ) , che può essere calcolato usando solo elementi delle diagonali precedenti. Come si può notare, abbiamo evitato di effettuare chiamate ricorsive, semplicemente memorizzando i valori secondo un ordine opportuno che dipende dalla regola in (2.11). L'algoritmo descritto nel Codice 2.18 effettua il calcolo dei valori M(i, j) secondo quanto appena illustrato e, basandosi sulla regola in (2.11), opera in senso inverso rispetto al Codice 2.17, in quanto riempie l'array c o s t i dal basso in alto, a partire dai valori costi[i][i], per 0 ^ i < n , fino a ottenere il valore c o s t i [ 0 ] [ n — 1] da restituire.

A partire dai valori noti c o s t i [ i ] [ i ] = 0 sulla diagonale 0 (righe 2-3), l'algoritmo determina tutti i valori c o s t i [ i ] [ j ] sulla diagonale 1 (con 0 < i < n — l e j = i + l ) , poi quelli sulla diagonale 2 (con 0 ^ i < n — 2 e j = i + 2), e così via, fino al valore c o s t i [ 0 ] [ n — 1] sulla diagonale n — 1 (righe 5—18): come possiamo notare, gli elementi su una data d i a g o n a l e sono identificati nel codice dai due indici i e j tali che 0 ^ i < n — d i a g o n a l e e j = i + d i a g o n a l e . A ogni iterazione, il ciclo alle righe 9 - 1 6 calcola il minimo costo analogamente a quanto viene fatto nel Codice 2.17. Il Codice 2.18 non solo calcola la tabella c o s t i , ma consente anche di costruire effettivamente la sequenza di prodotti di costo minimo. A tale scopo utilizza un altro array bidimensionale i n d i c e delle stesse dimensioni di c o s t i , che viene inizializzato con i n d i c e l i ] [ j ] = r (riga 14) per il valore di r che conduce alla scelta ottima nel ciclo più interno (righe 9-16): infatti, la sequenza di prodotti di costo minimo per Ai,..., Aj ha inizio con la moltiplicazione tra due matrici ricorsivamente definite, per tale valore di r, dalle parentesi in (Ai x • • • x A r ) x e(A r+ i x • • • x Aj). Una volta costruito l'array i n d i c e dal Codice 2.18, possiamo usare tale array per ricavare la sequenza di prodotti di costo minimo. Il codice seguente esegue tale operazione e deve essere invocato con input i = 0 e j = n — 1 : in esso, il simbolo x denota il prodotto tra matrici, implementato ad esempio mediante l'algoritmo di Strassen. ProdottoMinimoC i , j ) : IF (i == j) { RETURN

{pre: O ^ i ^ j ^ n — 1)

AI;

> r = indice[i] [j] ; RETURN (ProdottoMinimoC i, r )) x (ProdottoMinimoC r+1, j ));

Osserviamo che la complessità dell'algoritmo nel Codice 2.18 è polinomiale, in quanto esegue tre cicli annidati, ciascuno di n iterazioni al più, per un totale di 0 ( n 3 ) operazioni (chiaramente, le istruzioni all'interno di tali cicli possono essere eseguite in tempo costante rispetto al numero n di matrici da moltiplicare). Nel corso della sua esecuzione, inoltre, l'algoritmo usa le matrici c o s t i e i n d i c e , aventi ciascuna n righe e n colonne, e una quantità costante di altre locazioni di memoria. Da ciò possiamo concludere che la complessità temporale dell'algoritmo è 0 ( n 3 ) , mentre la sua complessità spaziale è 0 ( n 2 ) : la diversa implementazione (da ricorsiva a iterativa) della soluzione della relazione di ricorrenza che descrive M(i, j) ha evitato di ripetere più volte il calcolo di uno stesso valore, permettendo di ottenere un notevole risparmio nel tempo di esecuzione (da esponenziale a polinomiale). Tale guadagno è parzialmente compensato dalla necessità di utilizzare una maggiore quantità di memoria per "ricordare" i valori già calcolati, necessità che si traduce nel passaggio dalla complessità spaziale 0 ( 1 ) a 0 ( n 2 ) .

ALVIE: sequenza di moltiplicazioni di matrici

Osserva, sperimenta e verifica MatrixProductOrdering

2.7

Paradigma della programmazione dinamica

La costruzione della tabella dei costi discussa nel paragrafo precedente è un esempio di applicazione del paradigma di programmazione dinamica, dove tale termine indica il modo dinamico con cui riempiamo la tabella mediante una programmazione basata su relazioni ricorsive. Il paradigma della programmazione dinamica è basato sullo stesso principio utilizzato per il paradigma del divide et impera, in quanto il problema viene decomposto in sotto-problemi e la soluzione del problema è derivata da quelle di tali sotto-problemi. In effetti, entrambi i paradigmi vengono applicati a partire da una definizione ricorsiva che correla la soluzione di un problema a quella di un insieme di sottoproblemi: ricordiamo a tale proposito la definizione ricorsiva alla base dell'algoritmo di ordinamento di fusione (Paragrafo 2.5.3) che correla l'ordinamento di una sequenza a quello di due sotto-sequenze disgiunte in cui la sequenza viene decomposta, così come la definizione ricorsiva del costo ottimo del prodotto di una sequenza di matrici (equazione (2.11)) che correla il costo minimo a quello per un insieme di sotto-sequenze delle matrici. La differenza fondamentale tra i due paradigmi è dovuta al fatto che il paradigma della programmazione dinamica viene utilizzato per la soluzione di problemi di ottimizzazione, come il prodotto ottimo di una sequenza di matrici, ossia ogni soluzione ammissibile per un dato problema ha un costo associato e vogliamo trovare quella di costo ottimo (sia esso il minimo o il massimo a seconda della natura del problema), mentre il paradigma del divide et impera può essere applicato anche a problemi non necessariamente di ottimizzazione, come l'ordinamento. Un'altra differenza è che, mentre la formulazione ricorsiva del divide et impera comporta che un qualunque sotto-problema venga considerato una sola volta e, quindi, non si ponga il problema di evitare calcoli ripetuti di una medesima soluzione, la formulazione ricorsiva della programmazione dinamica comporta che uno stesso sotto-problema rientri come componente nella definizione di più problemi. Tale differenza può essere schematizzata dalla Figura 2.9, dove vengono mostrati degli schemi di decomposizione ricorsiva di un problema in sotto-problemi mediante il paradigma del divide et impera e quello della programmazione dinamica.

Divide et impera Figura 2.9

Programmazione dinamica

Decomposizione in sotto-problemi mediante il paradigma del divide et impera e della programmazione dinamica.

Come possiamo vedere, nel caso del paradigma del divide et impera il problema P viene decomposto in tre sotto-problemi Pi, P 2 e P3, ognuno dei quali a sua volta è decomposto in sotto-problemi elementari (risolubili in modo immediato senza decomposizioni ulteriori) in modo tale che uno stesso sotto-problema compare soltanto in una decomposizione: ad esempio, Pg compare soltanto nella decomposizione di P 2 , e la sua soluzione dovrà quindi essere calcolata una sola volta, nell'ambito del calcolo della soluzione di P 2 (cui contribuisce insieme al calcolo della soluzione di P7 e di Ps). Al contrario, nel caso del paradigma della programmazione dinamica possiamo vedere che uno stesso sotto-problema compare in più decomposizioni di problemi diversi, e quindi il calcolo della sua soluzione viene a costituire parte del calcolo delle soluzioni di più problemi. Ad esempio, Pg compare ora nella decomposizione sia di Pi che in quella di P 2 , con l'effetto che, se i calcoli delle soluzioni di Pi e di P 2 vengono effettuati senza tener conto di tale situazione, Pg deve essere risolto due volte, una per contribuire alla soluzione di Pi e l'altra per contribuire alla soluzione di P 2 . In generale, la risoluzione mediante programmazione dinamica di un problema è caratterizzata dalle seguenti due proprietà della decomposizione, la prima delle quali è condivisa con il divide et impera. Ottimalità dei sotto-problemi. La soluzione ottima di un problema deriva dalle soluzioni ottime dei sotto-problemi in cui esso è stato decomposto. Ad esempio, come abbiamo visto nella regola in (2.11), il problema della ricerca del costo minimo di moltiplicazione per la sequenza di matrici A ^ , . . . , Aj viene decomposto nella ricerca dei costi ottimi per tutte le sotto-sequenze A ^ , . . . , A r e A r + i , . . . , Aj, con j.

Sovrapposizione dei sotto-problemi. Uno stesso sotto-problema può venire usato nella soluzione di due (o più) sotto-problemi diversi. Ad esempio, nel caso della regola in (2.11), la soluzione del sotto-problema relativo alla sequenza Ai,..., Aj, con O ^ i ^ j ^ n — 1 viene utilizzata nella risoluzione di tutti i sotto-problemi relativi a sequenze A i ' , . . . , Aj' tali che i ' ^ i. e j ^ j'. La definizione di un algoritmo di programmazione dinamica è quindi basata su quattro aspetti: 1. caratterizzazione della struttura generale di cui sono istanze sia il problema in questione che tutti i sotto-problemi introdotti in seguito; 2. identificazione dei sotto-problemi elementari e individuazione della relativa modalità di determinazione della soluzione; 3. definizione di una regola ricorsiva per la soluzione in termini di composizione delle soluzioni di un insieme di sotto-problemi; 4. derivazione di un ordinamento di tutti i sotto-problemi cosi definiti, per il calcolo efficiente e la memorizzazione delle loro soluzioni in una tabella. Il numero di passi richiesto dall'algoritmo è quindi limitato superiormente dal prodotto tra il numero di sotto-problemi e il costo di ricombinazione delle loro soluzioni ottime. Nel caso della ricerca della sequenza ottima di prodotti di matrici, tali aspetti corrispondono, rispettivamente, all'introduzione del sotto-problema per il costo ottimo del prodotto di Ai,..., Aj, all'identificazione del caso i = j come sotto-problema elementare avente costo ottimo pari a 0, alla definizione della relazione ricorsiva (2.11) e al riempimento della tabella dei costi in ordine crescente di diagonale come indicato nel Codice 2.18. In questo caso, il numero di sotto-problemi è 0 ( n 2 ) , corrispondenti a tutte le coppie i e j tali che O ^ i ^ j ^ n — 1, e il costo di derivazione della soluzione di un sotto-problema è O(n) per determinare la scelta ottima di r nella regola in (2.11). Da ciò consegue, come già mostrato esaminando il Codice 2.18, che la risoluzione mediante programmazione dinamica del problema in questione richiede tempo 0 ( n 3 ) .

2.7.1

Sicurezza dei sistemi e sotto-sequenza comune più lunga

I sistemi operativi mantengono traccia dei comandi invocati dagli utenti, memorizzandoli in opportune sequenze chiamate log (i file nella directory / v a r / l o g dei sistemi Unix/Linux sono un esempio di tali sequenze). I sistemisti usano i log per verificare eventuali intrusioni (intrusion detection) che possano minare la sicurezza e l'integrità del sistema: quest'esigenza è molto diffusa a causa del collegamento dei calcolatori a Internet, che può permettere l'accesso remoto da qualunque parte del mondo. Uno dei metodi usati consiste nell'individuare particolari sotto-sequenze che appaiono nelle sequenze di log e che sono caratteristiche degli attacchi alla sicurezza del sistema. Sia F la

sequenza dei comandi di cui il log tiene traccia: quando avviene un attacco, i comandi lanciati durante l'intrusione formano una sequenza S ma, purtroppo, appaiono in F mescolati ai comandi legalmente invocati sul sistema. Per poter distinguere S all'interno di F, i comandi vengono etichettati in base alla loro tipologia (useremo semplici etichette come A, B, C e così via), per cui S e F sono entrambe rappresentate come sequenze di etichette: i singoli comandi di S sono probabilmente legali se presi individualmente mentre è la loro sequenza a essere dannosa. Dobbiamo quindi individuare S quando appare come sotto-sequenza di F: in altre parole, indicata con k la lunghezza di S e con n quella di F, dove k ^ n , vogliamo verificare se esistono k posizioni crescenti in F che contengono ordinatamente gli elementi di S (ossia se esistono k interi io, i i , . . . , i ^ - i tali che 0 ^ io < ii < • • • < i k - i ^ n — 1 e S[j] = F[ij] per 0 ^ j ^ k — 1). Per esempio, S = A,D, C, A, A,B appare come sottosequenza di F = B, A, A, B, D, C, D, C, A, A, C, A, C, B, A (dove le lettere sottolineate contrassegnano una delle possibili occorrenze, in quanto S può essere alternativamente vista come il risultato della cancellazione di zero o più caratteri da F). Il problema che in realtà intendiamo risolvere con la programmazione dinamica è quello di individuare la lunghezza delle sotto-sequenze comuni più lunghe per due sequenze date a e b. Diciamo che x è una sotto-sequenza comune ad a e b se appare come sotto-sequenza in entrambe: x è una sotto-sequenza comune più lunga (LCS o longest common subsequence) se non ne esistono altre di lunghezza maggiore che siano comuni alle due sequenze a e b (ne possono ovviamente esistere altre di lunghezza pari a quella di x ma non più lunghe). Indicando con LCS(a, b) la lunghezza delle sotto-sequenze comuni più lunghe di a e b, notiamo che questa formulazione generale del problema permette di scoprire se una sequenza di comandi S appare come sotto-sequenza di un log F: basta infatti verificare che sia k = LCS(S, F) (ponendo quindi a = S e b = F). Le possibili sotto-sequenze comuni di due sequenze a e b, di lunghezza m e n rispettivamente, possono essere in numero esponenziale in m e n ed è quindi inefficiente generarle tutte per poi scegliere quella di lunghezza massima. Applichiamo quindi il paradigma della programmazione dinamica a tale problema per risolverlo in tempo polinomiale O ( m n ) , seguendo la falsariga delineata dai quattro aspetti del paradigma discussi in precedenza. In prima instanza, definiamo L(i, j) = LCS(a[0,i — l],b[0, j — 1]) come la lunghezza massima delle sotto-sequenze comuni alle due sequenze rispettivamente formate dai primi i elementi di a e dai primi j elementi di b, dove O ^ i ^ m e 0 ^ j < n (adottiamo la convenzione che a[0, — 1] sia vuota e che b[0, —1] sia vuota). In seconda istanza, osserviamo che L ( m , n ) fornisce la soluzione LCS(a,b) al nostro problema e definiamo i sotto-problemi elementari come L(i, 0) = L(0, j) = 0, in quanto se (almeno) una delle sequenze è vuota, allora l'unica sotto-sequenza comune è necessariamente la sequenza vuota (quindi di lunghezza pari a 0).

In terza istanza, forniamo la definizione ricorsiva in termini dei sotto-problemi L(i, j) per i > 0 e j > 0, prendendo in considerazione le sotto-sequenze comuni di lunghezza massima per a[0, i — 1] e b[0, j — 1] secondo la seguente regola: [ 0

set^Ooj^O

L(i,j) = < L ( i - l , j - l ) + l se i, j > 0 e a[i — 1] = b[j — 1] { max { L(i, j — 1 ), L(i — 1, j) } se i, j > 0 e a[i - 1] ± b[j - 1]

(2.13)

La prima riga della regola in (2.13) riporta i valori per i sotto-problemi elementari (i ^ 0 o ) ^ 0). Le successive due righe in (2.13) descrivono come ricombinare le soluzioni dei sotto-problemi (i, j > 0): • a[i — 1] = b[j — 1]: se k = L(i — 1,j — 1) è la lunghezza massima delle sottosequenze comuni ad a [ 0 , i — 2] e b[0, j — 2], allora k + 1 lo è per a [ 0 , i — 1] e b[0,j — 1], in quanto il loro ultimo elemento è uguale (altrimenti ne esisterebbe una di lunghezza maggiore di k in a[0, i — 2] e b[0, j — 2], che è assurdo); • a[i — 1] ^ b[j — 1]: se k = L(i, j — 1 ) è la lunghezza massima delle sotto-sequenze comuni ad a[0,i — 1] e b[0,j — 2] e k ' = L(i — 1, j) è la lunghezza massima delle sotto-sequenze comuni ad a [ 0 , i — 2] e b[0, ) — 1], allora tali sotto-sequenze appariranno inalterate come sotto-sequenze comuni in a[0,i — 1] e b[0, j — 1], poiché non possono essere estese ulteriormente: pertanto, L(i, j) è pari a max{k, k'}. In quarta istanza, utilizziamo una tabella l u n g h e z z a di taglia ( m + 1 ) x ( n + 1 ), tale che l u n g h e z z a [ i , j] = L(i, j). Dopo aver inizializzato la prima colonna e la prima riga con i valori pari a 0, riempiamo tale tabella in ordine di riga secondo quanto riportato nel Codice 2.19. Essendoci O ( m n ) sotto-problemi, ciascuno risolvibile in tempo costante (righe 8 - 1 4 ) in base alla regola in (2.13), otteniamo una complessità di O ( m n ) tempo e spazio. Come nel caso del problema della sequenza di moltiplicazioni di matrici, possiamo individuare una delle sotto-sequenze comuni più lunghe, utilizzando un ulteriore array i n d i c e di taglia (m + 1) x (n + 1) nel Codice 2.19, per memorizzare quale delle sue istruzioni determina il valore dell'elemento corrente di l u n g h e z z a . Realizziamo ciò assegnando a i n d i c e [ i ] [ j ] una delle seguenti tre coppie di indici: (i — 1, j — 1) nella riga 9, (i, j — 1) nella riga 11 e (i — l , j ) nella riga 13. Il seguente algoritmo ricorsivo (che deve essere invocato con input i = m e j = n) usa i n d i c e per ricavare una sottosequenza comune più lunga (righe 3 e 5): StampaLCS ( i, j ) : IF ((i > 0) && (j > 0)) { < i \ j'> = indiceli] [j]; StampaLCS( i', j' );

(pre:

IF ((i' == i-1) && (j' == j-1))) PRINT a[i-l];

>

LCS ( a, b ) : (pre: a e b sono di lunghezza FOR ( i = 0; i > RETURN

lunghezza[M,N];

Codice 2.19 Algoritmo per il calcolo della lunghezza della sotto-sequenza comune più lunga.

Notiamo che possiamo ridurre a O(n) spazio la complessità dell'algoritmo descritto nel Codice 2.19, in quanto utilizza soltanto due righe consecutive di l u n g h e z z a alla volta (in alternativa, possiamo modificare il codice in modo che riempia la tabella l u n g h e z z a per colonne e ne utilizzi solamente due colonne alla volta). Tuttavia, tale riduzione in spazio non permette di eseguire l'algoritmo StampaLCS perché non abbiamo più a disposizione l'array i n d i c e . Esiste un modo più sofisticato, implementato in alcuni sistemi operativi, che richiede spazio lineare 0 ( n + m) e tempo 0 ( ( r + m + n) log(n + m)), dove r è il numero di possibili coppie di elementi uguali nelle sequenze a e b. Pur essendo r = O ( m n ) al caso pessimo, in molte situazioni pratiche vale r = 0 ( m + n): in tal caso, trovare una sotto-sequenza comune più lunga in spazio lineare richiede 0 ( ( n + m) logn) tempo.

ALVIE: sotto-sequenza c o m u n e più l u n g a

Osserva, sperimenta e verifica LongestCommonSubsequence

2.7.2

Sistemi di backup e partizione di un insieme di interi

Consideriamo la situazione in cui abbiamo due supporti esterni, ciascuno avente capacità di s byte, sui quali vogliamo memorizzare n file (di backup) che occupano 2s byte in totale, seguendo la regola che ciascun file può andare in uno qualunque dei ducsupporti purché il file non venga spezzato in due o più parti. Il paradigma della programmazione dinamica può aiutarci in tale situazione, permettendo di verificare se è possibile dividere i file in due gruppi in modo che ciascun gruppo occupi esattamente s byte. Tale problema viene detto della partizione (partition) ed è definito nel modo seguente. Supponiamo di avere un insieme di interi positivi A = {do, Q i , . . . , a n - i l aventi somma totale (pari) X ^ J q 1 a ì = 2s: vogliamo determinare se esiste un suo sottoinsieme A ' = (qì 0 , Qì, , . . . , a t k } C A tale che X!f=o Q í¡ = s> v a ^ e a dire t a ^ e c h e somma degli interi in A ' è pari alla metà della somma di tutti gli interi in A. È chiaro che vogliamo evitare di generare esplicitamente tutti i sottoinsiemi perché un tale metodo richiede tempo esponenziale (come discusso nel Paragrafo 1.2.2). Definiamo il sotto-problema generale consistente nel determinare il valore booleano T(i, j), per 0 ^ i ^ n e 0 ^ j ^ s, che poniamo pari a t r u e se e solo se esiste un sottoinsieme di Qo, a i , . . . , a t _ i avente somma pari a j: chiaramente, T(n, s) fornisce la soluzione del problema originario. Se consideriamo ad esempio l'istanza rappresentata dall'insieme A = {9,7, 5 , 4 , 1 , 2 , 3 , 8 , 4 , 3 } abbiamo che T(i, j) sarà definito per 0 < i < 10 e 0 ^ j ^ 23, e che T(3,12) = t r u e in quanto l'insieme A = {9,7, 5} contiene il sottoinsieme {7, 5} la cui somma è pari a 12. I valori T(0,j), per 0 ^ j ^ s, costituiscono l'insieme degli s + 1 sotto-problemi elementari, la cui soluzione deriva immediatamente osservando che T(0,0) = t r u e e T(0,j) = f a l s e per j > 0 poiché il sottoinsieme in questione è l'insieme vuoto con somma pari a 0. Nel caso generale, T(i, j) soddisfa la seguente regola ricorsiva: true

se i = 0 e ) = 0

t r U e

sei > 0 e T ( i - l , j ) = t r u e se i > 0, j ^ a t _ i e T(i - l , j - a t _ i ) = t r u e altrimenti

T(i j) = ^ ' true false

Per quanto riguarda la definizione ricorsiva dei sotto-problemi T(i, j) con 1 < i ^ n nella regola in (2.14), osserviamo che se T(i, j) = t r u e , allora ci sono due soli casi possibili: • il sottoinsieme di {ÜQ, . . . , AT_I} la cui somma è pari a J non comprende ÜÍ_I : tale insieme è dunque sottoinsieme anche di { a o , . . . , a ^ } e vale T(i — 1, j) = t r u e ; • il sottoinsieme di {a.o,..., at_ i} la cui somma è pari a j include at_ i : esiste quindi in { a o , . . . , ai_2) un sottoinsieme di somma pari a j — a i _ i per cui, se j —ai_i ^ 0, vale T(i — 1, j — ) = true.

Partizione ( a ) : (pre: a. è un array di n interi positivi la cui somma è 2s) FOR ( i = 0; i > RETURN

Codice 2.20

parti[n]ts];

Algoritmo iterativo per il problema della partizione.

Combinando i due casi sopra descritti (i soli possibili), abbiamo che T(i, j) = t r u e se e solo se T(i— 1, j) = t r u e oppure (j—QÌ_I ^ 0 eT(i— 1, j — ) = t r u e ) , ottenendo così il corrispondente Codice 2.20, in cui la tabella booleana p a r t i di taglia (n + 1) x (s + 1 ) viene riempita per righe in modo da soddisfare la relazione p a r t i [ i ] [ j ] = T(i, j) secondo la regola in (2.14). Osserviamo che abbiamo introdotto (n + 1) x (s + 1) sotto-problemi e che la soluzione di un sotto-problema richiede tempo costante per la regola in (2.14): pertanto, abbiamo che l'algoritmo iterativo descritto nel Codice 2.20 richiede tempo O(ns), il quale dipende dal valore di s piuttosto che dalla sua dimensione, come discuteremo nel Paragrafo 2.7.4. ALVIE: p r o b l e m a della partizione

Osserva, s p e r i m e n t a e verifica Partition

Come abbiamo discusso in precedenza, usare un algoritmo puramente ricorsivo per un problema di programmazione dinamica è inefficiente. In effetti, esiste un modo di evitare tale perdita di efficienza mantenendo la struttura ricorsiva dell'algoritmo, pren-

dendo nota (tale accorgimento viene denominato memoization in inglese) delle soluzioni già calcolate e prevedendo, in corrispondenza a ogni chiamata ricorsiva, di verificare anzitutto se la soluzione del sotto-problema in questione è già stata calcolata e annotata, così da poterla restituire immediatamente. Ciò comporta l'uso di un array di dimensione opportuna per mantenere le soluzioni dei vari sotto-problemi considerati, prevedendo inoltre che gli elementi di tale array possano assumere un valore "indefinito", che indichiamo con il simbolo 0, per segnalare il caso in cui il corrispondente sotto-problema non sia ancora stato risolto. A titolo di esempio, possiamo sviluppare un algoritmo ricorsivo per il problema della partizione che utilizza il suddetto meccanismo (ricordando comunque che è consigliabile usare la programmazione dinamica). Tale algoritmo inizializza la prima riga dell'array p a r t i come nel Codice 2.20 e riempie le righe successive con il valore indefinito 0. Successivamente, l'algoritmo invoca la funzione ricorsiva che segue la regola in (2.14). PartizioneMemoization( ): FOR (j = 0; j RETURN partiti]tj];

2.7.3

Problema della bisaccia

Mostriamo ora una generalizzazione del problema della partizione a un famoso problema di ottimizzazione: tale problema, denominato problema della bisaccia (zaino o knapsack), può essere definito, in modo pittoresco, come segue. Supponiamo che un ladro riesca a introdursi, nottetempo, in un museo dove sono esposti una quantità di oggetti preziosi, più o meno di valore e più o meno pesanti. Tenuto conto che il ladro può trasportare una quantità massima di peso, il suo problema è selezionare un insieme di oggetti da trafugare il cui peso complessivo non superi la

possanza che il ladro è in grado di sopportare, massimizzando al tempo stesso il valore complessivo degli oggetti rubati. Abbiamo quindi un insieme di elementi A = {ao, a i , . . . , a n _ i ) su cui sono definite le due funzioni v a l o r e e p e s o , le quali associano il valore e il peso a ogni elemento di A (supponiamo che sia il valore che il peso siano numeri interi positivi). Conosciamo inoltre un intero positivo p o s s a n z a , che indica il massimo peso totale che il ladro può portare. Vogliamo determinare un sottoinsieme A ' = {at 0 , a ^ , . . . , ai k _,} C A tale che il peso totale dei suoi elementi rientri nella possanza, ovvero X.jC=o P e s o ( a i j ) ^ p o s s a n z a , e tale che il valore degli oggetti selezionati, ovvero ^ j ^ o ' v a l o r e f a ^ ), sia il massimo possibile. Per applicare il paradigma della programmazione dinamica possiamo definire, come sotto-problema generico, la ricerca della soluzione ottima supponendo una minore possanza e un più ristretto insieme di elementi. In altri termini, per 0 ^ i ^ n e 0 ^ j ^ p o s s a n z a , denotiamo con P i j il sotto-problema relativo al caso in cui possiamo utilizzare i soli elementi A = {ao, a i , . . . , a ^ i } con il vincolo di non superare un peso pari a j, e indichiamo con V(i, j) il massimo valore ottenibile in tale situazione. Per caratterizzare i sotto-problemi elementari, osserviamo che V(i, 0) = 0, per 0 ^ 1 ^ n , in quanto il peso trasportabile è nullo e, quindi, il valore complessivo deve essere pari a 0, mentre V(0, j) = 0, per 0 ^ j ^ p o s s a n z a , poiché non ci sono elementi disponibili e il valore complessivo è necessariamente 0. Possiamo definire la decomposizione ricorsiva osservando che la soluzione di valore massimo V(i, j) per il sotto-problema Pi,j può essere ottenuta a partire da tutte le soluzioni ottime che utilizzano i soli elementi ao, a i , . . . , ai 2. in due soli possibili modi: • il primo modo è che la soluzione ottima di Pi,j non includa ai_i e che, in tal caso, abbia lo stesso valore della soluzione ottima del sotto-problema Pi-i,j, ossia V(i,j) = V(i— l , j ) ; • il secondo modo è che la soluzione ottima di P t j includa a^-i e che, pertanto, il suo valore sia dato dalla somma di v a l o r e ( a i _ i ) con il valore della soluzione ottima di P i _ i , m , dove m = j - p e s o ( a i _ i ) : in tal caso, quindi, V(i, j) = V(i l , j - p e s o ( a i _ i ) ) + v a l o r e ( a i _ i ) (se j ^ p e s o ( a i _ i ) ) . La soluzione ottima di P y sarà quella corrispondente alla migliore delle due (sole) possibilità, ovvero V(i,j) = max{V(i - l , j ) , V ( i - l , j - p e s o ( a i _ i ) ) + v a l o r e f a ^ i ) } . Per tornare al nostro esempio figurato, supponiamo che il ladro abbia a disposizione gli elementi ao, a j , . . . , a t _ i e la possibilità di trasportare un peso massimo j: se egli decide di non prendere l'elemento a^ 1, allora il meglio che può ottenere è la scelta ottima tra ao, a j , . . . , at_2, sempre con peso massimo j; se invece decide di prendere a i _ i , allora il meglio lo ottiene trovando la scelta migliore tra ao, a i , . . . , a t ^ 2 tenendo presente che,

Bisaccia( peso, valore, possanza ): (pre: peso e valore sono array di n interi positivi, possanza è un valore intero positivo) FOR (i = 0; i (i = 1; i V[i] [j]) VCi] [j] = M;

FOR

> >

> RETURN Codice 2.21

V[n][possanza];

Algoritmo iterativo per il problema della partizione.

dato che dovrà trasportare anche a t - i in aggiunta agli elementi scelti, si deve limitare a un peso massimo pari a j decrementato del peso di cii_i. La scelta migliore sarà quella che massimizza il valore. L'algoritmo iterativo descritto nel Codice 2.21 realizza tale strategia facendo uso di un array bidimensionale V di taglia (n + 1 ) x ( p o s s a n z a + 1 ), in cui l'elemento V[i][j] contiene il costo V(i, j) della soluzione ottima di P y . Dato che il numero di sottoproblemi è 0 ( n x p o s s a n z a ) e che derivare il costo di soluzione di un sotto-problema comporta il calcolo del massimo tra i costi di due altri sotto-problemi in tempo costante, ne consegue che il problema della bisaccia può essere risolto mediante il paradigma della programmazione dinamica in tempo 0 ( n x p o s s a n z a ) .

ALVIE: p r o b l e m a della bisaccia

Osserva, s p e r i m e n t a e verifica

Knapsack

2.1 A

Pseudo-polinomialità e programmazione dinamica

Concludiamo il paragrafo sulla programmazione dinamica commentando la complessità computazionale in tempo derivata con tale paradigma. Ricordiamo che la sequenza ottima per la moltiplicazione di n matrici richiede 0 ( n 3 ) tempo e la determinazione di una sotto-sequenza comune più lunga tra due sequenze di lunghezza n e m richiede O(nm) tempo. Gli algoritmi risultanti sono polinomiali nella dimensione dei dati in ingresso avendo, rispettivamente, un'istanza di n matrici e di n + m elementi nelle due sequenze. Per il problema della partizione di n interi di somma totale 2s, abbiamo un costo pari a O(ns), il quale è polinomiale in n e s ma non lo è necessariamente nella dimensione dei dati di ingresso, secondo quanto discusso nel Capitolo 1. Pur avendo n interi da partizionare, ciascuno di essi richiede k = O(logs) bit di rappresentazione. Quindi la dimensione dei dati è n k mentre il costo dell'algoritmo è O(ns) = 0 ( n 2 k ) : tale costo non è polinomiale rispetto alla dimensione dei dati e, per questo motivo, l'algoritmo viene detto pseudo-polinomiale, in quanto il suo costo è polinomiale solo se si usano interi piccoli rispetto a n (per esempio, quando s = 0 ( n c ) per una costante c > 0). Anche l'algoritmo discusso per il problema della bisaccia è pseudo-polinomiale in quanto richiede 0 ( n x p o s s a n z a ) = 0 ( n 2 k ) tempo mentre la dimensione dei dati in ingresso richiede O(nk) bit dove k = O ( l o g p o s s a n z a ) . Anche in questo caso, il costo dell'algoritmo non è polinomiale, ma lo diviene nel momento in cui il valore della possanza è polinomiale rispetto al numero di oggetti. Da ciò consegue che, mentre i primi due algoritmi sono polinomiali a tutti gli effetti, gli ultimi due algoritmi sono solo apparentemente polinomiali perché hanno complessità polinomiale rispetto al numero n di elementi, ma esponenziale rispetto alla lunghezza k della rappresentazione dei valori numerici contenuti nell'istanza del problema. In altri termini, i due algoritmi dati per i problemi della partizione e della bisaccia avrebbero complessità polinomiale se le istanze considerate dei due problemi avessero il vincolo aggiuntivo di non contenere valori numerici "eccessivamente grandi" rispetto a n. La ragione di tale anomalia è che, per valori numerici sufficientemente grandi, il problema della partizione e quello della bisaccia sono NP-completi (da notare, però, che non è vero che ogni problema NP-completo ammetta un algoritmo pseudo-polinomiale, come vedremo nel seguito del libro). RIEPILOGO In questo capitolo abbiamo discusso la gestione di una sequenza di elementi, distinguendo tra accesso diretto e accesso sequenziale. Abbiamo trattato approfonditamente la realizzazione dell'accesso diretto mediante array, abbiamo studiato il problema della ricerca e dell'ordinamento mediante confronti e abbiamo mostrato come risolvere ricorsivamente i problemi utilizzando il paradigma del divide et impera e la tecnica della programmazione dinamica, introducendo l'analisi degli algoritmi ricorsivi mediante le equazioni di ricorrenza.

ESERCIZI 1. Motivate perché lo schema dinamico illustrato nel Paragrafo 2.1.3 ha un costo maggiore se dimezziamo l'array quando il numero di elementi soddisfa la condizione n = d/2, dove d è il numero di elementi allocati in memoria. 2. Dimostrate che la complessità in tempo dell'algoritmo selection sort è @(n 2 ), per ogni sequenza di n elementi. Fornite una sequenza di n elementi per cui l'algoritmo insertion sort esegua 0 ( n 2 ) operazioni e una per cui l'algoritmo esegua O(n) operazioni. 3. Un algoritmo di ordinamento è stabile se, in presenza di elementi uguali, ne mantiene la posizione relativa nella sequenza d'uscita (quindi gli elementi uguali appaiono contigui ma non sono permutati tra di loro): valutate quali algoritmi di ordinamento discussi nel capitolo sono stabili. 4. Mostrate che, se un algoritmo per la risoluzione del problema del segmento di somma massima non legge un elemento a[r], è sempre possibile assegnare ad a[r] un valore tale da invalidare la soluzione calcolata dall'algoritmo. 5. Hannibal vi ha catturati insieme ad altre persone, per un totale di k persone. Dopo aver pensato a un numero n segreto, vuole che indoviniate il valore di n sapendo soltanto che è n > 0: le uniche domande ammesse sono i confronti con un valore x di vostra scelta, come x = n?, x < n?, x < n?, e così via. Una sola persona alla volta tra di voi può fare una domanda a Hannibal e, se per caso essa sceglie un intero x > n, esce dal gioco perché viene condotta a cena da Hannibal. Dovete decidere quali confronti porre all'attenzione di Hannibal, in modo da effettuare soltanto 0 ( n 1 / k ) domande in totale. 6. Il gioco di Rényi-Ulam consiste nell'indovinate il numero segreto n pensato da Hannibal attraverso domande che coinvolgono confronti con un valore x di vostra scelta, come x = n?, x ^ n?, x < n?, e così via. L'unica anomalia è che Hannibal potrebbe mentire sulla risposta e, se lo fa, può farlo una volta soltanto. Mostrate come indovinare comunque il valore di n effettuando soltanto logn + 0 ( 1 ) domande. 7. Dato un intero positivo k e un array a di n interi positivi in ordine crescente, descrivete un algoritmo per trovare una coppia di posizioni distinte i e j, tali che 0 ^ i < j ^ n - 1 e a[i] + a[j] = k. Il costo dell'algoritmo deve essere O(n) al caso pessimo.

8. Descrivete un algoritmo, basato sul paradigma del divide et impera, per trovare i due elementi più piccoli di un insieme di n elementi, fornendone un'analisi della complessità temporale che faccia uso del teorema fondamentale delle ricorrenze. 9. Descrivete un algoritmo, basato sul paradigma del divide et impera, per risolvere il problema del segmento di somma massima in tempo O ( n l o g n ) . 10. Descrivete un algoritmo, basato sul paradigma del divide et impera, per calcolare a n con O(logn) operazioni aritmetiche, dove a e n sono dati in ingresso, motivandone la complessità temporale (notate che a n = ( a n / 2 ) 2 se n è pari e che a n = ( a n / 2 ) 2 x a se n è dispari). 11. Per le seguenti equazioni di ricorrenza e una costante c' > 0, mostrate che • T(n) = 2T(n/2) 4- c' ha soluzione T(n) = O(n); • T(n) = 2T(n/2) + c ' n l o g n ha soluzione T(n) = O(nlog 2 n); • T(n) = T f v ' n ) + c' ha soluzione T(n) = O(loglogn). 12. Date due matrici di dimensione n x n, dove n è una potenza di due, scomponete ciascuna di esse come una matrice 2 x 2 in cui ciascun elemento è una matrice di dimensione n / 2 x n / 2 : indicando con a, b, c, d, e, f, g e h. tali matrici, mostrate la seguente uguaglianza, dove le operazioni di somma e prodotto sono quelle definite su matrici: e f "a b' ae + bg Q f + bh. X c d .9 h ce -I- dg cf + dh. 13. Mostrate un controesempio per il quale la scelta di r che massimizza il valore d T + i nell'equazione (2.11) non conduce alla sequenza ottima di moltiplicazioni tra matrici. 14. Progettate gli algoritmi per stampare uno dei due insiemi ottenuti nel problema della partizione e il contenuto ottimo della bisaccia degli elementi, analogamente a quanto visto per il prodotto di costo minimo per una sequenza di matrici e per le sotto-sequenze comuni più lunghe. 15. Formulate il problema della partizione come un'istanza del problema della bisaccia. Progettate un algoritmo ricorsivo con presa di nota per il problema della bisaccia.

Capitolo 3

Sequenze: liste

SOMMARIO In questo capitolo analizzeremo il secondo modo di realizzare una sequenza lineare facendo riferimento alla tipologia di accesso sequenziale: le sequenze così ottenute sono anche dette liste. In particolare, mostreremo i vantaggi e gli svantaggi di una tale realizzazione rispetto a quella ad accesso casuale descritta nel capitolo precedente. Successivamente, mostreremo un'applicazione di array e di liste alla risoluzione efficiente di un importante problema, ovvero ilproblema dei matrimoni stabili. Mostreremo inoltre un ulteriore uso della casualità descrivendo la gestione efficiente di un particolare tipo di liste randomizzate, ovvero le liste a salti. Infine, presenteremo come, sotto determinate condizioni, le prestazioni di una lista possono essere migliorate facendo in modo che si adatti alla sequenza di operazioni su di essa eseguita: a tale scopo, faremo per la prima volta un uso esplicito del concetto di complessità ammortizzata. DIFFICOLTÀ 1 CFU.

3.1

Liste

Abbiamo visto nel Capitolo 2 come l'organizzazione sequenziale dei dati può, in generale, essere realizzata in due diverse modalità, quella ad accesso diretto e quella ad accesso sequenziale. Nel primo caso, il tipo di dati utilizzato è l'array, di cui pregi e difetti sono già stati analizzati. In questo capitolo, invece, ci concentriamo sul tipo di dati lista, che realizza l'organizzazione dei dati con la modalità di accesso sequenziale. Ricordiamo che la caratteristica essenziale di questa realizzazione consiste nel fatto che i dati non risiedono in locazioni di memoria contigue e, pertanto, ciascun dato deve includere, oltre all'informazione vera e propria, un riferimento al dato successivo.

Qo

Figura 3.1

a2

ai

ûn-2

Qn-1

Esempio di lista di dimensione n.

Dato un elemento x di una lista in posizione i, nel seguito indicheremo con x . d a t o l'informazione associata a tale elemento e con x . s u c c il riferimento che lo collega all'elemento in posizione ( i + l)-esima. A tale proposito, presumiamo l'esistenza di un valore n u l i , utilizzato per indicare un riferimento "nullo", vale a dire un riferimento a nessuna locazione di memoria, e che per l'ultimo elemento x della lista sia x . s u c c = n u l i . Nel seguito, inoltre, denoteremo con a il riferimento al primo elemento della lista, ovvero alla locazione di memoria che lo contiene: evidentemente, nel caso in cui la lista sia vuota, tale riferimento avrà valore n u l i . Nella Figura 3.1 viene rappresentata una lista di n elementi: i riferimenti sono rappresentati in modo sintetico, senza evidenziare le relative locazioni di memoria, e il valore n u l i è indicato con il simbolo 4>. Le istruzioni seguenti mostrano come accedere all'elemento in posizione i di una lista a in tempo O(i), dove nella variabile p viene memorizzato, al termine del ciclo w h i l e , il riferimento a tale elemento oppure n u l i se tale elemento non esiste: p

=

a;

j

=

0;

WHILE p

>

3.1.1

=

((p

!=

nuli)

&&

(j

<

i))

{

p.succ;

j = j+i;

Ricerca, inserimento e cancellazione

Dato che una lista rappresenta una diversa realizzazione, rispetto a un array, di una sequenza lineare di elementi, possiamo definire su di essa le operazioni di ricerca, inserimento e'cancellazione già considerate per gli array. Per quanto riguarda la ricerca di una chiave k, il relativo algoritmo (Codice 3.1) , presenta la stessa struttura di quello considerato per gli array (Paragrafo 2.4) con la differenza che l'accesso all'elemento successivo avviene utilizzando il riferimento p . s u c c dell'elemento p corrente, invece che incrementando un indice di scorrimento. Inoltre, la terminazione della scansione della sequenza viene determinata, oltre che dall'aver trova-

RicercaSequenzialeC a, k ): p = a; WHILE p

=

RETURN

Codice 3.1

(Cp

!=

nuli)

kk

(p.dato

!=

k))

p.succ; P;

Ricerca sequenziale di una chiave k in una lista a.

to la chiave desiderata ( p . d a t o uguale a k), dalla verifica del raggiungimento dell'ultimo elemento della lista, avente riferimento n u l i al successore ( p . s u c c uguale a n u l i ) . A differenza degli array (Paragrafo 2.1.3), le liste si prestano molto bene a gestire sequenze lineari dinamiche, in cui il numero degli elementi presenti può variare nel tempo a causa di operazioni di inserimento e di cancellazione. In effetti, l'inserimento di un nuovo elemento all'interno di una lista può consistere semplicemente nel porlo in cima alla lista stessa, eseguendo le seguenti istruzioni, in cui ipotizziamo che x indichi il riferimento all'elemento da inserire (Figura 3.2): x.succ a

=

=

a;

x;

L'inserimento dopo l'elemento indicato da un riferimento p ^ n u l i è una semplice variazione dell'operazione precedente, in cui p . s u c c sostituisce la variabile a, come mostrato nelle seguenti istruzioni: x.succ

=

p.succ;

p.succ

=

x;

Leggermente più complicata è la cancellazione di un elemento, operazione che dovrà rendere l'elemento e la lista mutuamente non raggiungibili attraverso i relativi riferimenti. Avendo a disposizione il riferimento x all'elemento da cancellare, un caso particolare è rappresentato dalla situazione in cui x coincida con a, vale a dire in cui l'elemento da cancellare sia il primo della lista. In tal caso la cancellazione viene effettuata modificando il riferimento iniziale alla lista in modo da andare a "puntare" al secondo elemento, come mostrato nelle istruzioni seguenti: a

=

x.succ;

x.succ

=

nuli;

Per la cancellazione di un elemento diverso dal primo è necessario non solo avere a disposizione il suo riferimento x, ma anche un riferimento p all'elemento che lo precede.

l

x è inserito in cima alla lista

4>

Figura 3.2

Inserimento in testa a una lista.

In questo modo, possiamo cancellare l'elemento desiderato creando un "ponte" tra il suo predecessore e il suo successore (Figura 3.3). Questo ponte può essere realizzato mediante le seguenti istruzioni: p.succ = x.succ; x.succ = nuli; Ignorando il costo di allocazione e deallocazione e quello per determinare i riferimenti x e p nella lista a, in quanto esso dipende dall'applicazione scelta, le operazioni di inserimento e cancellazione appena descritte presentano un costo computazionale di 0(1) tempo e spazio, indipendente cioè dalla dimensione della lista. Ricordiamo a tale proposito come le medesime operazioni su un array richiedano tempo lineare 0 ( n ) .

3.1.2

Liste doppie e liste circolari

La struttura di una lista può essere modificata in modo tale da effettuare più efficientemente-determinate operazioni. In questo paragrafo introdurremo le due più diffuse variazioni di questo tipo: le liste doppie e le liste circolari. In una lista doppia l'elemento x in posizione i ha, oltre al riferimento x . s u c c all'elemento in posizione i + 1, un riferimento x . p r e d all'elemento in posizione i — 1, con x . p r e d uguale a n u l i se i = 0. Tale estensione consente di spostare in tempo 0(1) un riferimento x agli elementi della lista sia in avanti (con l'istruzione x = x . s u c c )

P

x

x

Figura 3.3

I

Cancellazione di un elemento da una lista.

che all'indietro (con l'istruzione x = x . p r e d ) . La Figura 3.4 fornisce un esempio di lista doppia: in questa figura, come in quelle successive, non saranno evidenziati, per semplicità, i campi relativi ai riferimenti. L'aggiunta del riferimento "all'indietro", sebbene complichi leggermente l'operazione di inserimento di un nuovo elemento, semplifica in modo sostanziale quella di cancellazione, in quanto consente di accedere, a partire dall'elemento da cancellare, ai due elementi circostanti, il cui contenuto va modificato nell'operazione di cancellazione. Al contrario, in una lista semplice la cancellazione di un elemento richiede un riferimento all'elemento precedente. Nello specifico, detto x il riferimento all'elemento da cancellare, possiamo considerare i seguenti quattro casi che determinano l'insieme delle istruzioni necessarie per eseguire la cancellazione. Caso 1. x fa riferimento al primo elemento della lista, cioè x è uguale a a. In questo caso, non avendo un predecessore, la cancellazione determina la modifica del riferimento p r e d del successore x . s u c c ; inoltre, è necessario aggiornare il riferimento iniziale a, come mostrato nelle seguenti istruzioni: x.succ.pred = nuli; a = x.succ; x.succ = nuli;

Figura 3.4

Lista doppia.

Caso 2. x fa riferimento all'ultimo elemento della lista, cioè x . s u c c è uguale a n u l i . In questo caso, non avendo un successore, la cancellazione richiede la modifica del riferimento s u c c del predecessore x . p r e d , come mostrato nelle seguenti istruzioni: x.pred.succ = nuli; x.pred = nuli;

Caso 3. x è l'unico elemento nella lista, cioè x è uguale ad a e x . s u c c è uguale a n u l i . In questo caso, l'effetto della cancellazione è quello di rendere la lista vuota, e quindi di assegnare al riferimento iniziale il valore n u l i , mediante la seguente istruzione: a = nuli;

Caso 4. x fa riferimento a un elemento "interno" della lista. In questo caso, vanno aggiornati sia il riferimento s u c c del predecessore che il riferimento p r e d del successore, come mostrato nelle seguenti istruzioni: x.succ.pred = x.pred; x.pred.succ = x.succ; x.succ = nuli; x.pred = nuli ;

Notiamo che tutti i casi appena discussi garantiscono correttamente che x . s u c c = x . p r e c = n u l i dopo una cancellazione, evitando così di lasciare un riferimento pendente {dangling pointer). In una lista circolare, l'ultimo elemento fa riferimento, come suo successore, al primo, creando così una struttura appunto circolare (Figura 3.5). Tale proprietà permette

Figura 3.5

Lista circolare.

di rappresentare, mediante la lista, un insieme di elementi da scandire ripetutamente. Inserimenti e cancellazioni in una lista circolare possono essere effettuati come in una lista semplice, facendo attenzione a mantenere l'invariante del riferimento dall'ultimo elemento al primo. Una classica applicazione di tale tipo di struttura è rappresentata dall'algoritmo round robin di assegnazione della CPU in un sistema operativo, algoritmo alternativo a quelli visti nel Paragrafo 2.2. In accordo a tale metodo, la CPU viene assegnata a turno a ogni programma, per un tempo massimo pari a un intervallo di tempo predefinito (detto quanto)-, se al termine del quanto il programma non ha finito la sua esecuzione, dovrà attendere il suo prossimo turno. La realizzazione di un algoritmo round robin fa uso di una lista circolare i cui elementi corrispondono ai programmi che necessitano della CPU, lista scandita da un riferimento al programma attualmente in esecuzione. L'assegnazione della CPU a un altro programma, in corrispondenza allo scadere del quanto di tempo, comporterà quindi l'individuazione del programma successivo, cui spetta ora la CPU: esso viene identificato dal successivo elemento nella lista circolare. Dato che l'insieme dei programmi varia nel tempo, è inoltre necessario gestire l'inserimento e la cancellazione di elementi dalla lista circolare. Mentre all'attivazione di un nuovo programma, un semplice inserimento in testa alla lista sarà sufficiente, per quanto riguarda la terminazione di un programma la corrispondente cancellazione del relativo elemento sarà più semplice ed efficiente, per quanto illustrato sopra, se si tratta di una lista circolare doppia, percorribile quindi sia in senso orario che antiorario in quanto il campo p r e d del primo elemento è un riferimento all'ultimo (Figura 3.6). In tal modo, ciascuna operazione richiederà tempo costante: la realizzazione dell'algoritmo round robin utilizzando array o liste semplici avrebbe delle prestazioni nettamente inferiori.

3.2

Opus libri: problema dei matrimoni stabili

Un'agenzia matrimoniale ha n clienti di sesso maschile, trio,..., m n _ i , e altrettante clienti di sesso femminile, fo, . . . , f n _ i , che desiderano essere abbinati al meglio delle loro preferenze. Ognuno (maschio e femmina) esprime le proprie preferenze specifican-

Figura 3.6

Lista circolare doppia.

do un ordinamento dei clienti del sesso opposto. In ciascun ordinamento, il cliente che appare in una posizione, o rango, è da preferire a quelli che si trovano in posizioni successive. L'abbinamento {match) tra le n coppie deve essere perfetto, ovvero deve abbinare tutti i clienti, e stabile rispetto agli ordinamenti delle preferenze. Un abbinamento è stabile se non esistono due coppie, (a, b) e (c, d), le cui preferenze si incrociano come indicato nella Figura 3.7: il cliente a preferisce la cliente d a b (in quanto a assegna a d rango minore rispetto a quello di b) mentre la cliente d preferisce il cliente a a c. E molto probabile che a e d si separino da b e d, rispettivamente, per abbinarsi tra di loro. Il problema appena descritto è detto problema dei matrimoni stabili e ha in realtà applicazioni molto più serie di quella appena descritta (come, ad esempio, l'assegnazione di tirocini esterni agli studenti diffusa in ambito scientifico, medico e giuridico). L'approccio che esamina esaustivamente tutti gli n! abbinamenti possibili tra m o , . . . , m. n _i e f o , . . . , f n - i richiede un tempo esponenziale nel numero di clienti. Nel nostro caso, vediamo come risolvere il problema in tempo 0 ( n 2 ) utilizzando array e liste: considerato che la semplice lettura delle preferenze richiede Q ( n 2 ) tempo, ne possiamo dedurre che l'algoritmo è ottimo in questo contesto. L'idea dell'algoritmo che ora svilupperemo è abbastanza semplice. Innanzitutto, occorre rompere la simmetria dei due insiemi di clienti. Assegniamo ai clienti di sesso maschile il ruolo di proponenti mentre le clienti di sesso femminile accettano o rifiutano le proposte in base al rango determinato dalle loro preferenze (alternativamente, i ruoli possono essere invertiti e l'abbinamento perfetto trovato può differire). I clienti si propongono alle clienti seguendo il proprio ordine di preferenza (che non è detto che corrisponda a quello delle clienti). Fintanto che vi sono clienti non abbinati, che chiameremo celibi, permettiamo a uno di essi, m, di proporsi alla cliente f che lui preferisce tra quelle a cui non si è già proposto. Se f non è abbinata, la proposta viene accettata, almeno temporaneamente. Altrimenti, se f preferisce m al cliente m ' a cui è attualmente abbinata, allora accetta la proposta di m, e m ' ritorna celibe. Se nessuno dei casi precedenti si verifica, e quindi f rifiuta la proposta di m, quest'ultimo passa alla successiva cliente. Il procedimento ha termine nel momento in cui tutti i clienti sono abbinati.

preferenze di a



Figura 3.7

3.2.1

Un esempio di

preferenze di d

0 0

• E È





non stabile.

Strutture di dati utilizzate

Per realizzare l'algoritmo, decidiamo quali strutture di dati adottare: a tale scopo osserviamo che le preferenze espresse da ciascun cliente sono utilizzate dall'algoritmo in maniera differente a seconda del ruolo svolto dal cliente stesso. Per i clienti m y , . . . , m-n-i usiamo un array di liste, p r e f e r i t a , ovvero un array i cui elementi sono dei riferimenti a delle liste. In particolare, p r e f e r i t a ! ) ] è il riferimento a un elemento della lista delle preferenze espresse dal cliente m j , per 0 ^ j ^ n — 1. Man mano che m¡ si propone alle clienti, scorrendo la propria lista, p r e f e r i t a t i ] viene aggiornato in modo da far riferimento alla prossima cliente cui eventualmente proporsi. Per le clienti f o , . . . , f i, adottiamo una diversa rappresentazione delle relative preferenze: la motivazione risiede nel fatto che una cliente deve accettare o rifiutare una proposta verificando nelle proprie preferenze il rango del cliente proponente e di quello cui è attualmente abbinata. In generale, dato un cliente m j vogliamo sapere in modo efficiente qual è il suo rango nelle preferenze di fi, per 0 i, j ^ n — 1: necessitiamo quindi di un accesso diretto. In particolare, usiamo un array bidimensionale, r a n g o , tale che rango[i][j) contiene il rango di m.j tra le preferenze di fi. Non è difficile costruire r a n g o in tempo lineare: per ogni fi, leggiamo le relative preferenze incrementando, per ogni elemento letto m.j, un contatore inizialmente nullo, e ponendo il valore di tale contatore in rango[i][j]. Per implementare l'algoritmo sopra descritto, necessitiamo di due ulteriori strutture ausiliarie. La lista c e l i b e contiene i celibi in attesa di abbinamento: una lista è adatta a tale scopo, in quanto l'insieme dei celibi può variare durante lo svolgimento della procedura, anche se non può aumentare di dimensione in quanto un nuovo celibe viene aggiunto alla lista solo se un altro cliente, precedentemente celibe, è stato abbinato.

MatrimoniStabiliWHILE (celibe

(pre: vedi §3.2.1 per celibe, preferita, abbinato, rango) != nuli) {

m = celibe.dato; f = preferita[m]; preferita[m] = preferita[m].succ; IF (abbinatoti] < 0) { abbinatoti] = m; celibe = celibe.succ; } ELSE IF (rangotf][m] < rangotf][abbinato[f]])

{

celibe.dato = abbinatoti]; abbinatoti] = m;

> > Codice 3.2

Algoritmo per la risoluzione del problema dei matrimoni stabili.

Inizialmente, la lista c e l i b e contiene tutti i clienti di sesso maschile. Infine, per poter tenere traccia degli abbinamenti attivi in un certo istante, utilizzeremo l'array a b b i n a t o , tale che a b b i n a t o ! ) ] = i se e solo se la coppia ( m i , f j ) fa attualmente parte dell'abbinamento: supponiamo che i valori di a b b i n a t o siano inizializzati a un valore "nullo", per convenzione pari a —1. Anche in questo caso necessitiamo di un accesso diretto in quanto, per ogni f j , vogliamo sapere se f j non è ancora stata abbinata ( a b b i n a t o ! ) ] < 0), oppure qual è il cliente m i attualmente abbinato a f j ( a b b i n a t o ! ) ] = i). Alla fine della computazione, a b b i n a t o specificherà un abbinamento, che mostreremo essere perfetto e stabile.

3.2.2

Implementazione dell'algoritmo

Avendo introdotto e inizializzato le liste e gli array necessari all'esecuzione dell'algoritmo, siamo ora in grado di illustrare la sua implementazione nel Codice 3.2. A ogni iterazione del ciclo w h i l e (righe 2-13), il cliente m in testa alla lista dei celibi si propone alla cliente f da lui preferita e a cui non si è ancora proposto (righe 3—4): successivamente, alla riga 5 viene aggiornato il riferimento all'eventuale futura cliente preferita da m. Se f non è abbinata, allora la proposta viene accettata e m viene rimosso dalla lista dei celibi (righe 6^8). Altrimenti viene verificato se m è preferito all'attuale cliente abbinato a f , confrontandone il rango: in tal caso, la proposta viene accettata e avviene lo scambio con m nella lista dei celibi (righe 9 - 1 2 ) . Se nessuno dei casi sopra si presenta, l'iterazione successiva del ciclo esamina la prossima cliente preferita da m. Non è difficile analizzare il numero di passi eseguiti dall'algoritmo, per una qualunque istanza del problema dei matrimoni stabili. A tale scopo, possiamo anzitutto

osservare che, poiché la rimozione e l'inserimento nella lista dei celibi avviene sempre in testa alla lista stessa, ogni iterazione del ciclo w h i l e richiede l'esecuzione di un numero costante di passi. Pertanto, per valutare la complessità temporale dell'algoritmo è sufficiente stimare il numero di volte che viene eseguito il corpo del ciclo w h i l e . Notiamo che, a ogni iterazione di tale ciclo, l'istruzione della riga 5 fa scorrere di una posizione in avanti un riferimento all'interno di una delle liste memorizzate in p r e f e r i t a . Questi riferimenti sono spostati esclusivamente in avanti per cui il numero totale di iterazioni del ciclo w h i l e non può superare la lunghezza totale di tali liste, ovvero n 2 . Ne deriva che l'algoritmo per il problema dei matrimoni stabili ha un costo pari a 0 ( n 2 ) tempo. Rimane ora da dimostrare che l'algoritmo termina avendo calcolato un abbinamento perfetto e stabile. A tale scopo, osserviamo anzitutto che, per ogni cliente f, dal momento in cui riceve la prima proposta, f sarà sempre abbinata a qualcuno (anche se quel qualcuno può cambiare durante l'esecuzione dell'algoritmo). In base a tale osservazione, possiamo concludere che se, a un certo istante, un cliente m è incluso nella lista dei celibi, allora deve esistere una cliente a cui m non si è ancora proposto. Se così non fosse, infatti, tutte le clienti sarebbero abbinate, contraddicendo il fatto che m sia ancora celibe. Quindi, al momento in cui il ciclo w h i l e termina la sua esecuzione, tutti i clienti sono abbinati: in altre parole, abbiamo appena dimostrato che la soluzione calcolata dall'algoritmo è un abbinamento perfetto. Per dimostrare che tale abbinamento è anche stabile, supponiamo per assurdo che non lo sia, ovvero che esistano due coppie (a, b) e (c, d) tali che a preferisce d a b mentre d preferisce a a c (Figura 3.7). In base al Codice 3.2, l'ultima proposta effettuata da a deve essere quella fatta a b. Due soli casi sono allora possibili. Caso 1: a non ha mai fatto una proposta a d. Questo implica che d segue b nella lista delle preferenze di a, contraddicendo il fatto che a preferisce d a b. Caso 2: a ha fatto una proposta a d ma, in quel momento oppure in uno successivo, d ha preferito ad a un altro cliente u. Quindi a segue u nelle preferenze di d. Se u = c, ciò contraddice il fatto che d preferisce a a c. Altrimenti (u ^ c), u deve necessariamente seguire c nelle preferenze di d, in quanto c è nella coppia finale per d. Per la proprietà transitiva su u, anche a deve seguire c nelle preferenze di d, contraddicendo il fatto che d preferisce a a c. In entrambi i casi, abbiamo raggiunto un assurdo: in conclusione, abbiamo che la soluzione calcolata dall'algoritmo è un abbinamento perfetto e stabile. Osserviamo, infine, che l'abbinamento perfetto non è necessariamente unico: per esempio, è sufficiente che i clienti abbiano tutti la stessa lista di preferenze e, in tal caso, l'ordine con cui vengono esaminati i clienti determina uno dei differenti abbinamenti. Inoltre, i clienti possono accordarsi per non lasciare alcuna scelta alle clienti in quanto

basta che ogni cliente specifichi come prima scelta nelle preferenze una cliente diversa dalla prima scelta degli altri: in effetti, il problema dei matrimoni stabili presenta innumerevoli varianti che sono state studiate per garantire ulteriori proprietà oltre a quella di avere un abbinamento perfetto. ALVIE: problema dei matrimoni stabili

Osserva, sperimenta e verifica

StableMarriage

3.3

Liste randomizzate

Una configurazione sfavorevole dei dati o della sequenza di operazioni che agisce su di essi può rendere inefficiente diversi algoritmi se analizziamo la loro complessità nel caso pessimo. In generale, la strategia che consente di individuare le configurazioni che peggiorano le prestazioni di un algoritmo è chiamata avversariale in quanto suppone che un avversario malizioso generi tali configurazioni sfavorevoli in modo continuo. In tale contesto, la casualità riveste un ruolo rilevante per la sua caratteristica imprevedibilità: vogliamo sfruttare quest'ultima a nostro vantaggio, impedendo a un tale avversario di prevedere le configurazioni sfavorevoli (in senso algoritmico). Abbiamo già discusso nel Paragrafo 2.5.4 come la casualità possa essere impiegata in tal senso, applicandola all'algoritmo di ordinamento per distribuzione (quicksort) nella scelta del pivot. Ricordiamo che un algoritmo random o casuale (di cui l'algoritmo quicksort costituisce un esempio) fa uso di sequenze di scelte casuali. Nel seguito descriviamo un algoritmo random per il problema dell'inserimento e della ricerca di una chiave k in una lista e dimostriamo che la strategia da esso adottata è vincente, sotto opportune condizioni. In particolare, usando una lista randomizzata di n elementi ordinati, i tempi medi o attesi delle operazioni di ricerca e inserimento sono ridotti a O(logn): anche se, al caso pessimo, tali operazioni possono richiedere tempo O(n), è altamente improbabile che ciò accada. Descriviamo una particolare realizzazione di liste randomizzate, chiamate liste a salti (skip list), la cui idea base (non random) può essere riassunta nel modo seguente (secondo quanto illustrato nella Figura 3.8). Partiamo da una lista ordinata di n + 2 elementi, Lo = eo, e i , . . . , e n + i , la quale costituisce il livello 0 della lista a salti: poniamo che il primo e l'ultimo elemento della lista siano i due valori speciali, —oo e +oo, per cui vale sempre —oo < et < +oo, per 1 ^ i ^ n . Per ogni elemento et della lista LQ

L [2]

[^OO

L[L]

' —oo ' •

L[0]

— 00

Figura 3.8

+ 0O

18 10 5

'r 10

18 16

18

+00

-41 30

+oo

80

41

Un esempio di lista a salti.

(1 ^ i < n) creiamo r^ copie di e^ se r^ > 0, dove 2 Ti è la massima potenza di 2 che divide i (nel nostro esempio, per i = 1 , 2 , 3 , 4 , 5 , 6 , 7 , abbiamo ti = 0 , 1 , 0 , 2 , 0 , 1 , 0 ) . Ciascuna copia ha livello crescente t — 1 , 2 , . . . , r^ e punta alla copia di livello inferiore l — 1: supponiamo inoltre che —oo e +oo abbiano sempre una copia per ogni livello creato. Chiaramente, il massimo livello o altezza h. della lista a salti è dato dal massimo valore di r^ incrementato di 1 e, quindi, h = O(logn). Passando a una visione orizzontale, tutte le copie dello stesso livello l (0 ^ t ^ H) sono collegate e formano una sottolista L^, tale che L^ C C • • • C LQ:1 come possiamo vedere nell'esempio mostrato nella Figura 3.8, le liste dei livelli superiori "saltellano" (skip in inglese) su quelle dei livelli inferiori. Osserviamo che, se la lista di partenza, LQ, contiene n + 2 elementi ordinati, allora Lj ne contiene al più 2 + n / 2 , L2 ne contiene al più 2 + n / 4 e, in generale, L^ contiene al più 2 + n / 2 ^ elementi ordinati. Pertanto, il numero totale di copie presenti nella lista a salti è limitato superiormente dal seguente valore: H (2 + n ) + (2 + n / 2 ) + • • • + (2 + n / 2 h )

=

=

2(h+l) +

^ n / 2

£

e=o h 2 ( h + 1) + n ^ l/2e

< 2 ( h + 1) + 2 n

e=o In altre parole, il numero totale di copie è 0 ( n ) . Per descrivere le operazioni di ricerca e inserimento, necessitiamo della nozione di predecessore. Data una lista L^ = e^, e j , . . . , l di elementi ordinati e un elemento x, diciamo che e' € L^ (con O ^ j < m — 1) è il predecessore di x (in L^) se e' è il massimo tra i minoranti di x, ovvero e- ^ x < e j + [ : osserviamo che il precedessore è sempre ben definito perché il primo elemento di L^ è —oo e l'ultimo elemento è +oo. 'Con un piccolo abuso di notazione, scriviamo L C [_' se l'insieme degli elementi di L è un sottoinsieme di quello degli elementi di L'.

(pre: gli elementi —oo e +00 fungono da sentinelle)

ScansioneSkipList ( k ) : p = L[h] ; WHILE

(p

WHILE p

=

!= n u l i )

=

in maniera analoga a quanto descritto per l'operazione di ricerca, eseguiamo una sequenza di lanci di moneta finché non otteniamo 1. Sia r ^ 1 il numero di bit casuali (lanci) così generati per k, per cui i primi r— 1 bit sono 0 e l'ultimo è 1. Se r ^ h + 1 , creiamo r copie di k e le inseriamo nelle liste Lo, L], l_2,..., L r : ciascuna inserzione richiede tempo costante in quanto va creato un nodo immediatamente dopo ciascuno dei predecessori P o . P i , • • •, Pr- Altrimenti, creiamo h-l-1 copie della chiave k, aggiorniamo tutte le liste già esistenti secondo quanto detto prima e creiamo una nuova lista L h + i composta dalle chiavi —oo, k e +oo. Come vedremo, il costo dell'operazione è, in media, O(Iogn). 2

La nozione di sequenza random R è stata formalizzata nella teoria di Kolmogorov in termini di incompressibilità, per cui qualunque programma che generi R non può richiedere significativamente meno bit per la sua descrizione di quanti ne contenga R. Per esempio, R = 0 1 0 1 0 1 • • • 0 1 non è casuale in quanto un programma che scrive per b / 2 volte 0 1 può generarla richiedendo solo O(logb) bit per la sua descrizione. Purtroppo è indecidibile stabilire se una sequenza è random anche se la stragrande maggioranza delle sequenze binarie lo sono.

ALVIE:

liste a salti Osserva, sperimenta e verifica

SkipList

Per stabilire la complessità media delle operazioni di ricerca e inserimento su una lista a salti (random), valutiamo un limite superiore per l'altezza media e per il numero medio di copie create con il procedimento appena descritto. La lista di livello più basso, Lo, contiene n + 2 elementi ordinati. Per ciascun inserimento di una chiave k, indipendentemente dagli altri inserimenti abbiamo lanciato una moneta equiprobabile per decidere se Li debba contenere una copia di k (bit 0) o meno (bit 1): quindi Li contiene circa n / 2 + 2 elementi (una frazione costante di n in generale), perché i lanci sono equiprobabili e all'incirca metà degli elementi in LQ ha ottenuto 0, creando una copia in Li, e il resto ha ottenuto 1. Ripetendo tale argomento ai livelli successivi, risulta che L2 contiene circa n / 4 + 2 elementi, L3 ne contiene circa n / 8 + 2 e così via: in generale, contiene circa n/2e + 2 elementi ordinati e, quando t = h, l'ultimo livello ne contiene un numero costante c > 0, ovvero n / 2 h + 2 = c. Ne deriviamo che l'altezza h. è in media O(logn) e, in modo analogo a quanto mostrato in precedenza, che il numero totale medio di copie è O(n) (la dimostrazione formale di tali proprietà sull'altezza e sul numero di copie richiede in realtà strumenti più sofisticati di analisi probabilistica). Mostriamo ora che la ricerca descritta nel Codice 3.3 richiede tempo medio 0 ( l o g n ) . Per un generico livello t nella lista a salti, indichiamo con T(^) il numero medio di elementi esaminati dall'algoritmo di scansione, a partire dalla posizione corrente nella lista L( fino a giungere al predecessore po di k nella lista Lo: il costo della ricerca è quindi proporzionale a 0 ( T ( h ) ) . Per valutare T(h), osserviamo che il cammino di attraversamento della lista a salti segue un profilo a gradino, in cui i tratti orizzontali corrispondono a porzioni della stessa lista mentre quelli verticali al passaggio alla lista del livello inferiore. Percorriamo a ritroso tale cammino attraverso i predecessori Po.Pi >P2> • • • >Ph> a l fine di stabilire induttivamente i valori di T(0),T( 1 ) , T ( 2 ) , . . . ,T(h.) (dove T(0) = 0 ( 1 ) , essendo già posizionati su po): notiamo che per T(£) con i ^ 1, lungo il percorso (inverso) nel tratto interno a L^, abbiamo solo due possibilità rispetto all'elemento corrente e € 1. Il percorso inverso proviene dalla copia di e nel livello inferiore (riga 7 del Codice 3.3), nella lista a cui siamo giunti con un costo medio pari a — 1). Tale evento ha probabilità j in quanto la copia è stata creata a seguito di un lancio della moneta che ha fornito 0.

2. Il percorso inverso proviene dall'elemento è a destra di e in L^ (riga 5 del Codice 3.3), a cui siamo giunti con un costo medio pari a 1{£)\ in tal caso, è non può avere una corrispettiva copia al livello superiore (in L^ +1 ). Tale evento ha probabilità j a seguito di un lancio della moneta che ha fornito 1. Possiamo quindi esprimere il valore medio di T{£) attraverso la media pesata (come per il quicksort) dei costi espressi nei casi 1 e 2, ottenendo la seguente equazione di ricorrenza per un'opportuna costante c' > 0: +

+

(3.1)

Otteniamo una limitazione superiore per il termine T(^) sostituendo la disuguaglianza con l'uguaglianza nell'equazione (3.1), analogamente a quanto discusso nel Paragrafo 2.5.4. Moltiplicando i termini dell'equazione così trasformata per 2 e risolvendo rispetto a T(^) otteniamo 1{£) =T(l1) + 2 c ' (3.2) Espandendo l'equazione (3.2), abbiamo T{£) = 1{1 - 1 ) + 2c' = T(£ - 2) + (2 + 2)c' = ••• = T(0) + (2£)c' = 0(£). Quindi T(H) = O(H) = O(logn) è il costo medio della ricerca. In conclusione, le liste randomizzate sono un esempio concreto di come l'uso accorto della casualità possa portare ad algoritmi semplici che hanno in media (o con alta probabilità) ottime prestazioni in tempo e in spazio.

3.4

Opus libri: gestione di liste ammortizzate e di liste ad auto-organizzazione

L'efficacia dell'auto-organizzazione nella gestione delle liste, da sempre accertata a livello euristico e sperimentale, può essere mostrata in modo rigoroso facendo uso dell'analisi ammortizzata, permettendo di ottenere un'implementazione efficiente delle operazioni di ricerca, inserimento e cancellazione. Prima di descrivere e analizzare in dettaglio una tale organizzazione delle liste, discutiamo un semplice caso di liste ammortizzate.

3.4.1

Unione e appartenenza a liste disgiunte

Le liste possono essere impiegate per operazioni di tipo insiemistico: avendo già visto come inserire e cancellare un elemento, siamo interessati a gestire una sequenza arbitraria S di operazioni di unione e appartenenza su un insieme di liste contenenti un totale di m elementi. In ogni istante le liste sono disgiunte, ossia l'intersezione di due qualunque liste

è vuota. Inizialmente, abbiamo m liste, ciascuna formata da un solo elemento. Un'operazione di unione in S prende due delle liste attualmente disponibili e le concatena (non importa l'ordine di concatenazione). Un'operazione di appartenenza in S stabilisce se due elementi appartengono alla stessa lista. Tale problema viene chiamato di union-find e trova applicazione, per esempio, in alcuni algoritmi su grafi che discuteremo in seguito. Mantenendo i riferimenti al primo e all'ultimo elemento di ogni lista, possiamo realizzare l'operazione di unione in tempo costante. Tuttavia, ciascuna operazione di appartenenza può richiedere tempo O(m) al caso pessimo (pari alla lunghezza di una delle liste dopo una serie di unioni), totalizzando O ( n m ) tempo per una sequenza di n operazioni. Presentiamo un modo alternativo di implementare tali liste per eseguire un'arbitraria sequenza S di n operazioni di unione e appartenenza in O ( n l o g n ) tempo totale, migliorando notevolmente il limite di O ( n m ) in quanto n < m. Rappresentiamo ciascuna lista con un riferimento all'inizio e alla fine della lista stessa nonché con la sua lunghezza. Inoltre, corrediamo ogni elemento z di un riferimento z . l i s t a alla propria lista di appartenenza: la regola intuitiva per mantenere tali riferimenti, quando effettuiamo un'unione tra due liste, consiste nel cambiare il riferimento z . l i s t a negli elementi z della lista più corta. Il Codice 3.4 realizza tale semplice strategia per risolvere il problema di union-find, specificando l'operazione C r e a per generare una lista di un solo elemento x, oltre alle funzioni A p p a r t i e n i e U n i s c i per eseguire le operazioni di appartenenza e unione per due elementi x e y. In particolare, l'appartenenza è realizzata in tempo costante attraverso la verifica che il riferimento alla propria lista sia il medesimo. L'operazione di unione tra le due liste disgiunte degli elementi x e y determina anzitutto la lista più corta e quella più lunga (righe 2—8): cambia quindi i riferimenti z . l i s t a agli elementi z della lista più corta (righe 9-13), concatena la lista lunga con quella corta (righe 14—15) e aggiorna la dimensione della lista risultante (riga 16). L'efficacia della modalità di unione può essere mostrata in modo rigoroso facendo uso di un'analisi più approfondita, che prende il nome di analisi ammortizzata e che illustremo in generale nel Paragrafo 3.4.3. Invece di valutare il costo al caso pessimo di una singola operazione, quest'analisi fornisce il costo al caso pessimo di una sequenza di operazioni. La giustificazione di tale approccio è fornita dal fatto che, in tal modo, non ignoriamo gli effetti correlati delle operazioni sulla medesima struttura di dati. In generale, data una sequenza S di operazioni, diremo che il costo ammortizzato di un'operazione in S è un limite superiore al costo effettivo (spesso difficile da valutare) totalizzato dalla sequenza S diviso il numero di operazioni contenute in S. Naturalmente, più aderente al costo effettivo è tale limite, migliore è l'analisi ammortizzata fornita. Nel caso delle operazioni U n i s c i e A p p a r t i e n i , siamo interessati a valutare le prime in quanto le seconde richiedono tempo costante. Partendo da m elementi, ciascuno

Crea( x ) : lista.inizio = lista.fine = x; lista.lunghezza = 1; x.lista = lista; x.succ = nuli; Appartieni( x, y ): RETURN (x.lista == y.lista);

(pre: x non vuoto)

{pre: x, y non vuoti)

UnisciC x, y ): (pre: x,y non vuoti e x.. lista ^ y. lista) IF (X.lista.lunghezza ELSE

{

corta = y.lista; lunga = x.lista;

> z = corta.inizio ; WHILE

(z != n u l i )

{

z.lista = lunga; z = z.succ;

> lunga.fine.succ = corta.inizio ; lunga.fine = corta.fine; lunga.lunghezza = corta.lunghezza + lunga.lunghezza; Codice 3.4

Operazioni di creazione, appartenenza e unione nelle liste disgiunte.

dei quali diventa inizialmente una lista di un singolo elemento attraverso l'operazione C r e a , possiamo concentrarci su un'arbitraria sequenza S di n operazioni U n i s c i . Osserviamo che, al caso pessimo, la complessità in tempo dell'unione è proporzionale direttamente al numero di riferimenti z . l i s t a che vengono modificati alla riga 11 del Codice 3.4: per calcolare il costo totale delle operazioni in S, è quindi sufficiente valutare un limite superiore al numero totale di riferimenti z . l i s t a cambiati. Possiamo conteggiare il numero di volte che la sequenza S può cambiare z . l i s t a per un qualunque elemento z nel seguente modo. Inizialmente, l'elemento z appartiene alla lista di un solo elemento (se stesso). In un'operazione di unione, se z . l i s t a cambia, vuol dire che z va a confluire in una lista che ha una dimensione almeno doppia rispetto a quella di partenza. In altre parole, la prima volta che z . l i s t a cambia, la dimensione della nuova lista contenente z è almeno 2, la seconda volta è almeno 4 e così via: in generale, l'i-esima volta che z . l i s t a cambia, la dimensione della nuova

lista contenente z è almeno 2 l . D'altra parte, al termine delle n operazioni di unione, la lunghezza di una qualunque lista è minore oppure uguale a n + 1: ne deriva che la lista contenente z ha lunghezza compresa tra 2 l e n + 1 (ovvero, 2 l ^ n + 1) e che vale sempre i = O(logn). Quindi, ogni elemento z vede cambiare il riferimento z . l i s t a al più O(logn) volte. Sommando tale quantità per gli n + 1 elementi coinvolti nelle n operazioni di unione, otteniamo un limite superiore di O ( n l o g n ) al numero di volte che la riga 11 viene globalmente eseguita: pertanto, la complessità in tempo delle n operazioni U n i s c i è O ( n l o g n ) e, quindi, il costo ammortizzato di tale operazione è O(logn). Al costo di queste operazioni, va aggiunto il costo 0(1) per ciascuna delle operazioni C r e a e Appartieni. Lo schema adottato per cambiare i riferimenti z . l i s t a è piuttosto generale: ipotizzando di avere insiemi disgiunti i cui elementi hanno ciascuno un'etichetta (sia essa z . l i s t a o qualunque altra informazione) e applicando la regola che, quando due insiemi vengono uniti, si cambiano solo le etichette agli elementi dell'insieme di cardinalià minore, siamo sicuri che un'etichetta non possa venire cambiata più di O(logn) volte. L'intuizione di cambiare le etichette agli elementi del più piccolo dei due insiemi da unire viene rigorosamente esplicitata dall'analisi ammortizzata: notiamo che, invece, cambiando le etichette agli elementi del più grande dei due insiemi da unire, un'etichetta potrebbe venire cambiata n ( n ) volte, invalidando l'argomentazione finora svolta. ALVIE: unione e appartenenza a liste disgiunte

Osserva, sperimenta e verifica UnionFind

3.4.2

o a?

CD

œ

o

Liste ad auto-organizzazione

L'auto-organizzazione delle liste è utile quando, per svariati motivi, la lista non è necessariamente ordinata in base alle chiavi di ricerca (contrariamente al caso delle liste randomizzate del Paragrafo 3.3). Per semplificare la discussione, consideriamo il solo caso della ricerca di una chiave k in una lista e adottiamo uno schema di scansione sequenziale, illustrato nel Codice 3.1: percorriamo la lista a partire dall'inizio verificando iterativamente se l'elemento attuale è uguale alla chiave cercata. Estendiamo tale schema per eseguire eventuali operazioni di auto-organizzazione al termine della scansione sequenziale (le operazioni di inserimento e cancellazione possono essere ottenute semplicemente, secondo quanto discusso nel Paragrafo 3.1).

MoveToFrontC a , k ) : p = a; IF (p == n u l l I I p . d a t o == k) RETURN p; WHILE ( ( p . s u c c != n u l i ) && ( p . s u c c . d a t o != k ) ) p = p.succ; IF ( p . s u c c == n u l l ) RETURN n u l i ; tmp = a; a = p.succ; p.succ = p.succ.succ; a . s u c c = tmp; r e t u r n a; Codice 3.5

Ricerca di una chiave k in una lista ad auto-organizzazione.

Tale organizzazione sequenziale può trarre beneficio dal principio di località temporale, per il quale, se accediamo a un elemento in un dato istante, è molto probabile che accederemo a questo stesso elemento in istanti immediatamente (o quasi) successivi. Seguendo tale principio, sembra naturale che possiamo riorganizzare proficuamente gli elementi della lista dopo aver eseguito la loro scansione. Per questo motivo, una lista così gestita viene riferita come struttura di dati ad auto-organizzazione (self-organizing o self-adjusting). Tra le varie strategie di auto-organizzazione, la più diffusa ed efficace viene detta move-to-front ( M T F ) , che consideriamo in questo paragrafo: essa consiste nello spostare l'elemento acceduto dalla sua posizione attuale alla cima della lista, senza cambiare l'ordine relativo dei rimanenti elementi, come mostrato nel Codice 3.5. Osserviamo che MTF effettua ogni ricerca senza conoscere le ricerche che dovrà effettuare in seguito: un algoritmo operante in tali condizioni, che deve quindi servire un insieme di richieste man mano che esse pervengono, viene detto in linea {online). Un esempio quotidiano di lista ad auto-organizzazione che utilizza la strategia MTF è costituito dall'elenco delle chiamate effettuate da un telefono cellulare: in effetti, è probabile che un numero di telefono appena chiamato, venga usato nuovamente nel prossimo futuro. Un altro esempio, più informatico, è proprio dei sistemi operativi, dove la strategia MTF viene comunemente denominata least recently used (LRU). In questo caso, gli elementi della lista corrispondono alle pagine di memoria, di cui solo le prime r possono essere tenute in una memoria ad accesso veloce. Quando una pagina è richiesta, quest'ultima viene aggiunta alle prime r, mentre quella a cui si è fatto accesso meno recentemente viene rimossa. Quest'operazione equivale a porre la nuova pagina in cima alla lista, per cui quella originariamente in posizione r (acceduta meno recentemente) va in posizione successiva, r + 1, uscendo di fatto dall'insieme delle pagine mantenute nella memoria veloce.

Per valutare le prestazioni della strategia MTF, il termine di paragone utilizzato sarà un algoritmo fuori linea (offline), denominato OPT, che ipotizziamo essere a conoscenza di tutte le richieste che perverranno. Le prestazioni dei due algoritmi verranno confrontate rispetto al loro costo, definito come la somma dei costi delle singole operazioni, in accordo a quanto discusso sopra: in particolare, contiamo il numero di elementi scanditi a partire dall'inizio della lista, per cui accedere all'elemento in posizione i ha costo i in quanto dobbiamo scandire gli i elementi che lo precedono. Lo spostamento in cima alla lista, operato da MTF, non viene conteggiato in quanto richiede un costo costante. Tale paradigma è ben esemplificato dalla gestione delle chiamate in uscita di un telefono cellulare: l'ultimo numero chiamato è già disponibile in cima alla lista per la prossima chiamata e il costo indica il numero di clic sulla tastierina per accedere a ulteriori numeri chiamati precedentemente (occorrono un numero di clic pari a i per scandire gli elementi che precedono l'elemento in posizione i nell'ordine inverso di chiamata). E di fondamentale importanza stabilire le regole di azione di OPT, perché questo può dare luogo a risultati completamente differenti. Nel nostro caso, OPT parte dalla stessa lista iniziale di MTF. Esaminate tutte le richieste in anticipo, OPT permuta gli elementi della lista solo una volta all'inizio, prima di servire le richieste. A questo punto, quando arriva una richiesta per l'elemento k in posizione i, restituisce l'elemento scandendo i primi i elementi della lista, senza però muovere k dalla sua posizione. Notiamo che OPT permuta gli elementi in un ordine (per noi imprevedibile) che rende minimo il suo costo futuro. Inoltre, ai fini dell'analisi, presumiamo che le liste non cambino di lunghezza durante l'elaborazione delle richieste. A titolo esemplificativo, è utile riportare i costi in termini concreti del numero di clic effettuati sui cellulari. Immaginiamo di essere in possesso, oltre al cellulare di marca MTF, di un futuristico cellulare OPT che conosce in anticipo le n chiamate che saranno effettuate nell'arco di un anno su di esso (l'organizzazione della lista delle chiamate in uscita è mediante le omonime politiche di gestione). Potendo usare entrambi i cellulari con gli stessi m numeri in essi memorizzati, effettuiamo alcune chiamate su tali numeri per un anno: quando effettuiamo una chiamata su di un cellulare, la ripetiamo anche sull'altro (essendo futuristico, OPT si aspetta già la chiamata che intendiamo effettuare). Per la chiamata j, dove j = 0 , l , . . . , n — 1 , contiamo il numero di clic che siamo costretti a fare per accedere al numero di interesse in MTF e, analogamente, annotiamo il numero di clic per OPT (ricordiamo che MTF pone il numero chiamato in cima alla sua lista mentre OPT non cambia più l'ordine inizialmente adottato in base alle chiamate future). Allo scadere dell'anno, siamo interessati a stabilire il costo, ovvero il numero totale di clic effettuati su ciascuno dei due cellulari. Mostriamo che, sotto opportune condizioni, il costo di MTF non supera il doppio del costo di OPT. In un certo senso, MTF offre una forma limitata di chiaroveggenza delle richieste rispetto a OPT, motivando il suo impiego in vari contesti con successo. In

Lista MTF Lista PRM Figura 3.9

== ==

e

22 ei

4 e0

26

eo

Ci e

4

23 25

27 26

25 27

Un'istantanea delle liste manipolate da MTF e PRM.

realtà quella adottata da OPT è una delle possibili permutazioni degli elementi della lista: mostriamo quindi una proprietà più generale. Presa una qualunque permutazione della lista iniziale, definiamo PRM come l'algoritmo che opera sulla lista permutata in analogia a quanto descritto per OPT (ovvero un elemento non viene cambiato di posizione dopo ogni accesso). I costi di PRM sono definiti analogamente a quelli di OPT per cui, quando la permutazione è quella fissata da OPT, i comportamenti di PRM e OPT coincidono. Mostrando in generale che il costo di MTF non supera il doppio del costo di PRM, otteniamo la dimostrazione anche per il caso specifico di OPT. Formalmente, consideriamo una sequenza arbitraria di n operazioni di ricerca su una lista di m elementi, dove le operazioni sono enumerate da 0 a n — 1 in base al loro ordine di esecuzione. Per 0 ^ j ^ n - 1, l'operazione j accede a un elemento k nelle lista come nel Codice 3.5: sia Cj la posizione di k nella lista di MTF e cj la posizione di k nella lista di PRM. Poiché vengono scanditi Cj elementi prima di k nella lista di MTF, e cj elementi prima di k nella lista di PRM, definiamo il costo delle n operazioni, rispettivamente, n—1

n-1 C

costo(MTF) =

Ì

e

c

costo(PRM) =

j=0

j

(3-3)

j=0

Vogliamo mostrare che costo(MTF) ^ 2 x costo(PRM) + 0 ( m 2 ) per ogni permutazione iniziale della lista di m elementi, ovvero che n-l

n-1

Y_ Cj Si 2 Y_ c j + j=0

°(™2)

(3-4)

j=0

Da tale diseguaglianza segue che MTF scandisce asintoticamente non più del doppio degli , elementi scanditi da OPT quando n m 2 (scegliendo nell'analisi la specifica permutazione, per noi imprevedibile, realizzata da OPT). Nel seguito proviamo una condizione più forte di quella espressa nella diseguaglianza (3.4) da cui possiamo facilmente derivare quest'ultima: a tal fine, introduciamo la nozione di inversione. Supponiamo di aver appena eseguito l'operazione j che accede all'elemento k, e consideriamo le risultanti liste di MTF e PRM: un esempio di configurazione delle due liste in un certo istante è quello riportato nella Figura 3.9.

Presi due elementi distinti x e y in una delle due liste, questi devono occorrere anche nell'altra: diciamo che l'insieme {x, y} è un'inversione quando l'ordine relativo di occorrenza è diverso nelle due liste, ovvero quando x occorre prima di y (non necessariamente in posizioni adiacenti) in una lista mentre y occorre prima di x nell'altra lista. Nel nostro esempio, {eo,e2} è un'inversione, mentre {e], e-?} non lo è. Definiamo con il numero di inversioni tra le due liste dopo che è stata eseguita l'operazione j: vale 0 ^ cDj ^ , per 0 ^ j ^ n — 1, in quanto = 0 se le due liste sono uguali mentre, se sono una in ordine inverso rispetto all'altra, ognuno degli (™) insiemi di due elementi è un'inversione. Per dimostrare la (3.4), non possiamo utilizzare direttamente la proprietà che Cj ^ 2cJ + 0 ( 1 ) , in quanto questa proprietà in generale non è vera. Invece, ammortizziamo il costo usando il numero di inversioni j — — ì = —£-{-\. Utilizzando la formula (3.7), otteniamo un costo ammortizzato pari a Cj = ( £ + 1) + (—£+ 1) = 2. Poiché ® n _ i ^ 0 e = 0, in base all'equazione (3.8) abbiamo che ^ ^ J Q 1 Cj ^ ^ 2n. Osserviamo che abbiamo utilizzato il metodo del potenziale per l'analisi della strategia MTF scegliendo come potenziale ®j il numero di inversioni rispetto alla lista gestita da PRM. ALVIE: p r o b l e m a del c o n t a t o r e b i n a r i o

Osserva, sperimenta e verifica BinaryCounter

RIEPILOGO In questo capitolo abbiamo descritto come realizzare le operazioni di ricerca, inserimento e cancellazione all'interno di una lista, considerando anche le varianti relative a liste doppie e circolari. Abbiamo poi mostrato un'applicazione di array e liste per la risoluzione del problema dei matrimoni stabili. Infine, abbiamo ripreso il concetto di algoritmo random mostrando una realizzazione efficiente di una lista a salti e abbiamo introdotto il concetto di analisi ammortizzata, considerando la gestione dell'unione di liste disgiunte e quella di liste ad auto-organizzazione. ESERCIZI 1. Mostrate come modificare il Codice 3.1, utilizzando un ulteriore riferimento q in modo tale che valga la seguente invariante, necessaria a implementare l'operazione di cancellazione in una lista semplice: se entrambi p e q puntano allo stesso elemento, questo è il primo della lista; altrimenti, p e q . s u c c puntano allo stesso elemento nella lista, e tale elemento è diverso dal primo elemento della lista. 2. Mostrate le istruzioni necessarie a inserire un nuovo elemento in cima a una lista doppia e quelle necessarie per le operazioni di inserimento e cancellazione in una lista circolare. 3. Dimostrate che utilizzando una lista semplice per implementare l'algoritmo round robin, esistono sequenze di operazioni che richiedono tempo O(n) per operazione, invece che tempo 0 ( 1 ) . 4. Descrivete un'implementazione dell'algoritmo insertion sort che utilizzi liste anziché array, identificando il tipo di lista adatto a ottenere, per ogni sequenza di n dati in ingresso, un costo computazionale uguale a quello dell'implementazione basata su array (ricordiamo che per alcune sequenze quest'ultima ha complessità temporale 0 ( n ) ) . 5. E possibile implementare l'algoritmo di risoluzione del problema dei matrimoni stabili facendo uso esclusivamente di array e mantenendo la complessità temporale 0 ( n 2 ) ? Giustificate la risposta. 6. Mostrate che, nonostante sia costo(MTF) ^ 2 x costo(OPT), alcune configurazioni hanno costo(MTF) < costo(OPT) (prendete una lista di m = 2 elementi e accedete a ciascuno n / 2 volte). 7. Consideriamo un algoritmo fuori linea REV, il quale applica la seguente strategia ad auto-organizzazione per la gestione di una lista. Quando REV accede all'elemento in posizione i, va avanti fino alla prima posizione i ' ^ i che è una potenza

del 2, prende quindi i primi i ' elementi e li dispone in ordine di accesso futuro (ovvero il successivo elemento a cui accedere va in prima posizione, l'ulteriore successivo va in seconda posizione, e così via). Ipotizziamo che n = m = 2 k + 1 per qualche k > 0, che inizialmente la lista contenga gli elementi eo, ..., em-i e che la sequenza di richieste sia eo, e \ , . . . , e m _ i , in questo ordine (vengono cioè richiesti gli elementi nell'ordine in cui appaiono nella lista iniziale). Dimostrate che il costo di MTF risulta essere 0 ( n 2 ) mentre quello di REV è O ( n l o g n ) . Estendete la dimostrazione al caso n > m. 8. Calcolate un valore della costante c adoperata nell'analisi ammortizzata con i crediti per il ridimensionamento di un array di lunghezza variabile, dettagliando come gestire i crediti.

Capitolo 4

Alberi

SOMMARIO In questo capitolo descriviamo come organizzare i dati in strutture gerarchiche introducendo le nozioni di albero binario, albero cardinale e albero ordinale. Discutiamo inoltre una metodologia generale di progettazione degli algoritmi ricorsivi su alberi e le varie modalità di visita (anticipata, simmetrica, posticipata e per ampiezza). L'opus libri del capitolo consiste in una soluzione efficiente del problema del minimo antenato comune. Infine, descriviamo come rappresentare in modo implicito e succinto gli alberi al fine di ottenere un risparmio della memoria occupata. DIFFICOLTÀ 1,5 CFU.

4.1

Alberi binari

Gli alberi rappresentano una generalizzazione delle liste nel senso che, mentre ogni elemento delle liste ha al più un successore, ogni elemento degli alberi può avere più di un successore. Come vedremo, gli alberi sono solitamente utilizzati per rappresentare partizioni ricorsive di insiemi e strutture gerarchiche: un tipico utilizzo di alberi per rappresentare gerarchie è fornito dagli alberi genealogici, in cui ciascun nodo dell'albero rappresenta una persona della famiglia i cui figli sono ad esso collegati da un arco ciascuno. Ad esempio, nella Figura 4.1, è mostrata una parte dell'albero genealogico della famiglia Baggins di Hobbiville, quella relativa ai discendenti di Largo (corrispondente alla radice dell'albero), il cui unico figlio è Fosco, i cui nipoti sono Dora, Drogo, e Dudo, e i cui pronipoti sono Frodo e Daisy. Come possiamo osservare, ogni nodo dell'albero ha associata la lista (eventualmente vuota) dei figli: utilizzando una terminologia che è un misto di genealogia e botanica,

Figura 4.1

I discendenti di Largo Baggins di Hobbiville.

chiamiamo foglie i nodi senza figli, e nodi interni i rimanenti nodi. Allo stesso tempo, l'albero associa a tutti i nodi, eccetto la radice, un unico genitore, detto padre: i nodi figli dello stesso padre sono detti (rateili. Osserviamo che, ad ogni nodo interno, è anche associato il sottoalbero di cui tale nodo è radice. Se un nodo u è la radice di un sottoalbero contenente un nodo v, diciamo che u è un antenato di v e che v è un discendente di u. Ad esempio, nella Figura 4.1 consideriamo il nodo Drogo il cui sottoalbero contiene, oltre a se stesso, il suo unico figlio Frodo. Il padre di Drogo è Fosco e i suoi fratelli sono Dora e Dudo. Infine, Drogo è discendente di Largo, che è suo antenato. Gli alberi genealogici sono spesso utilizzati anche per rappresentare l'insieme degli antenati di una persona, anziché quello dei suoi discendenti: in tali alberi i figli di un nodo rappresentano, in modo apparentemente contraddittorio, i suoi genitori. Nella Figura 4.2 sono, ad esempio, rappresentati gli antenati (noti agli autori) di Frodo Baggins. Questo tipo di albero, rispetto a quello visto in precedenza, presenta due principali caratteristiche: ogni nodo ha al più due figli e ogni figlio ha un ruolo ben determinato che dipende dall'essere il figlio sinistro oppure il figlio destro (in particolare, il figlio sinistro indica il padre mentre il figlio destro rappresenta la madre). Chiamiamo albero binario un albero siffatto, che può essere definito ricorsivamente nel modo seguente: un albero vuoto è un albero binario che non contiene alcuna chiave

Figura 4.2

L'albero degli antenati di Frodo Baggins.

e che viene indicato con n u l i , analogamente a quanto fatto con la lista vuota. Un albero binario (non vuoto) contenente n elementi è costituito dalla radice r, che memorizza uno di questi elementi mettendolo "a capo" degli altri; i rimanenti n — 1 elementi sono divisi in due gruppi disgiunti, ricorsivamente organizzati in due sottoalberi binari distinti, etichettati come sinistro e destro e radicati nei due figli ts e r o della radice. Notiamo che uno o entrambi i nodi Ts e TD possono essere n u l i , a rappresentare sottoalberi vuoti, e che i figli di una foglia sono entrambi uguali a n u l i , così come il padre della radice dell'albero. Un albero binario viene generalmente rappresentato nella memoria del calcolatore facendo uso di tre campi. In particolare, dato un nodo u, indichiamo con u . d a t o il contenuto del nodo, con u . s x il riferimento al figlio sinistro e con u . dx il riferimento al figlio destro UD (talvolta ipotizzeremo che sia anche presente un riferimento u . p a d r e al padre del nodo). Ad esempio, nella Figura 4.3 (in cui tre sottoalberi sono solo tratteggiati e non esplicitamente disegnati) mostriamo come viene rappresentata la parte superiore dell'albero

dato

sx

Frodo Baggins

/

dx

V

px

/

Ti Primula Brandibuck

V

Ruby Bolgeri

Figura 4.3

/

\

/

J

La rappresentazione della parte superiore dell'albero genealogico nella Figura 4.2.

genealogico illustrato nella Figura 4.2: osserviamo, tuttavia, che nel seguito preferiremo sempre fare riferimento alla rappresentazione grafica semplificata di quest'ultima figura.

4.1.1

Algoritmi ricorsivi su alberi binari

Gli alberi binari, essendo definiti in modo ricorsivo, permettono di progettare algoritmi ricorsivi seguendo una metodologia generale di risoluzione: nel discuterne alcuni esempi, introdurremo anche della terminologia aggiuntiva che, sebbene fornita per semplicità con riferimento agli alberi binari, è in generale applicabile anche ad alberi di tipo diverso, quali quelli considerati nel Paragrafo 4.3. Un parametro che caratterizza un albero è la sua dimensione n, data dal numero di nodi in esso contenuti: chiaramente, un albero di dimensione n ha esattamente n — 1 archi (che collegano un qualunque nodo diverso dalla radice al padre), come possiamo notare nell'esempio di Figura 4.2. Osserviamo che la dimensione di un albero binario può essere definita ricorsivamente nel modo seguente: un albero vuoto ha dimensione 0, mentre la dimensione di un albero non vuoto è pari alla somma delle dimensioni dei suoi sottoalberi, incrementata di 1, per includere la radice. Il Codice 4.1 utilizza tale osservazione per realizzare un algoritmo che determina la dimensione di un albero binario. Se l'albero è vuoto, la sua dimensione è pari a 0 (riga 3). Se non lo è, le due chiamate ricorsive calcolano la dimensione dei sottoalberi radicati nei

Dimensione( u ): IF (u == nuli) { RETURN 0; > ELSE

{

dimensioneSX = Dimensione( u.sx ); dimensioneDX = Dimensione( u.dx ); RETURN dimensioneSX + dimensioneDX + 1;

C o d i c e 4.1

Algoritmo ricorsivo per il calcolo della dimensione di un albero binario.

figli (righe 5-6): di tali dimensioni viene restituita come risultato la somma incrementata di 1 (riga 7). Un altro parametro caratteristico di un albero è la sua altezza: per definire tale parametro, notiamo che l'ordine gerarchico esistente tra i nodi di un albero permette di classificarli in base alla loro profondità. La radice r ha profondità 0, i suoi figli r$ e TD hanno profondità 1 (se diversi da n u l i ) , i nipoti hanno profondità 2 e così via. In generale, se la profondità di un nodo è pari a p, allora i suoi figli non vuoti (ovvero diversi da n u l i ) hanno profondità p + 1. Il seguente frammento (iterativo) di codice calcola la profondità di un nodo, fermandosi alla radice quando il riferimento al padre è n u l i . p = 0; WHILE (U.padre != nuli) {

p = p + 1; u = u.padre ;

> L'altezza h. di un albero è data dalla massima profondità raggiunta dalle sue foglie. Quindi, l'altezza misura la massima distanza di una foglia dalla radice dell'albero, in termini del numero di archi attraversati. E inefficiente calcolare esplicitamente tutte le profondità iterando il suddetto frammento di codice per ogni foglia dell'albero, prendendone poi la massima per trovare l'altezza. Infatti, uno stesso nodo u può essere attraversato molte volte per calcolare tali profondità, ovvero tante quante sono le foglie discendenti di u. Poiché la definizione di altezza si applica anche ai sottoalberi, è più efficiente e semplice trovare l'altezza di un albero binario osservando che l'albero composto da un solo nodo ha altezza pari a 0, mentre un albero con almeno due nodi ha altezza pari all'altezza del suo sottoalbero più alto, incrementata di 1 in quanto la radice introduce un ulteriore livello (da cui deriviamo che l'albero vuoto ha altezza pari a —1). Il Codice 4.2 utilizza tale osservazione per realizzare un algoritmo che determina l'altezza di un albero. Come possiamo osservare, se usiamo l'accorgimento di considerare

Altezza( u ): IF (u == nuli) { RETURN -1; } ELSE {

altezzaSX = Altezza( u.sx ); altezzaDX = Altezza( u.dx ); RETURN max( altezzaSX, altezzaDX ) + 1;

}• Codice 4.2

(post: restituisce — 1 se e solo se u è nuli) Algoritmo ricorsivo per il calcolo dell'altezza di un albero binario.

come caso base l'albero vuoto, otteniamo che il codice segue lo stesso schema del Codice 4.1 e, inoltre, che l'altezza calcolata per le foglie risulta correttamente pari a 0 (in quanto sottoalberi composti da un solo nodo): di conseguenza, per induzione è corretta anche l'altezza calcolata per tutti i sottoalberi. Nello specifico, il Codice 4.2 opera nel modo seguente: se l'albero è vuoto, la sua altezza è pari a —1 (riga 3). Se non lo è, le due chiamate ricorsive calcolano l'altezza dei sottoalberi radicati nei figli (righe 5-6): di tali altezze viene restituita come risultato la massima incrementata di 1 (riga 7). Sia il Codice 4.1 che il Codice 4.2 hanno quindi un caso base (albero vuoto) e un passo induttivo (albero non vuoto) in cui avvengono le chiamate ricorsive. A parte le differenze sintattiche dovute al fatto che i due codici calcolano quantità differenti, la struttura computazionale è identica: ciascuna invocazione restituisce un valore (l'altezza o la dimensione), che possiamo facilmente dedurre nel caso base di un albero vuoto. Nel passo induttivo, deleghiamo il calcolo delle rispettive quantità alla ricorsione sui due figli (sottoalberi): una successiva fase di combinazione di tali quantità, restituite dalle chiamate ricorsive sui figli, contribuisce a ottenere il risultato per il nodo corrente. Tale risultato va a sua volta restituito mediante l'istruzione r e t u r n , per far sì che l'induzione si propaghi attraverso la ricorsione: infatti, chi invoca le chiamate ricorsive deve a sua volta trasmettere il risultato così ottenuto. ALVIE:

a l t e z z a di un albero binario

Osserva, sperimenta e verifica BinaryTreeHeight

o

Non è difficile applicare lo schema ricorsivo appena delineato al calcolo del numero di foglie discendenti: in realtà, molti problemi su alberi possono essere risol-

Decomponibile(u): IF (u == nuli) { RETURN Decomponibile(nuli); > ELSE {

risultatoSX = Decomponibile(u.sx); risultatoDx = Decomponibile(u.dx); RETURN Ricombina(risultatoSX, risultatoDx);

Codice 4.3

Algoritmo ricorsivo per risolvere un problema decomponibile su alberi binari.

ti analogamente, con varianti più o meno sofisticate. Tali problemi sono detti decomponibili, in quanto caratterizzabili secondo il paradigma del divide et impera: sia D e c o m p o n i b i l e ( u ) il valore da calcolare relativamente al sottoalbero radicato nel nodo u (ad esempio la sua dimensione o il numero di foglie in esso contenute). Per sfruttare il paradigma del divide et impera, dobbiamo individuare i seguenti punti focali nella definizione di D e c o m p o n i b i l e ( u ) . Caso base: stabilire il valore di D e c o m p o n i b i l e ( u ) quando u = n u l i (anche se alcune volte è più semplice definire tale valore quando u è una foglia). Decomposizione: riformulare il problema per il sottoalbero radicato in un nodo u in termini di quelli radicati nei suoi figli Us e u q . Ricombinazione: trovare la regola R i c o m b i n a che permette di ricombinare i valori D e c o m p o n i b i l e ( u s ) e D e c o m p o n i b i l e f u o ) in modo da ottenere il valore Decomponibile(u). Nel caso del Codice 4.2, D e c o m p o n i b i l e ( u ) è l'altezza del sottoalbero radicato in u: il caso base è rappresentato dalla riga 3 mentre il passo induttivo ha come regola di combinazione quella riportata nella riga 7. Nel Codice 4.1, D e c o m p o n i b i l e ( u ) è la dimensione del sottoalbero radicato in u: il caso base è rappresentato dalla riga 3 mentre il passo induttivo ha come regola di combinazione quella riportata nella riga 7 di tale codice. Fatte le dovute premesse, possiamo fornire un codice generale per risolvere problemi decomponibili su alberi (Codice 4.3), che ricalca lo schema ricorsivo finora usato. Notiamo che ogni nodo viene attraversato un numero costante di volte, per cui se il caso base e la regola R i c o m b i n a richiedono tempo costante, l'esecuzione richiede un tempo totale O(n). Lo schema del Codice 4.3 permette di effettuare una visita di un albero binario a partire dalla sua radice. La visita equivale a esaminare tutti i nodi in modo sistematico, una e una sola volta, analogamente alla scansione di sequenze lineari, dove procediamo dall'inizio alla fine o viceversa. Per semplicità, durante la visita facciamo corrispondere

Anticipata( u ): IF (u != nuli) { PRINT u.dato;

Anticipata( u.sx ); Anticipata( u.dx );

> Codice 4 . 4

Visita anticipata di un albero binario. Le altre due visite, simmetrica e posticipata, sono ottenute spostando l'istruzione di stampa dalla riga 3 a una delle due righe successive.

l'esame di un nodo all'operazione di stampa del suo contenuto. Tale visita permette di operare varie scelte che dipendono dall'ordine con cui viene esaminato l'elemento memorizzato nel nodo corrente e vengono invocate le chiamate ricorsive nei suoi figli. Visita anticipata (preorder): stampa l'elemento contenuto nel nodo; visita ricorsivamente il sottoalbero sinistro; visita ricorsivamente il sottoalbero destro. Visita simmetrica (inorder): visita ricorsivamente il sottoalbero sinistro; stampa l'elemento contenuto nel nodo-, visita ricorsivamente il sottoalbero destro. Visita posticipata (postorder): visita ricorsivamente il sottoalbero sinistro; visita ricorsivamente il sottoalbero destro; stampa l'elemento contenuto nel nodo. Il costo di ciascuna delle tre visite è O(n) per un albero di dimensione n (cambia soltanto l'ordine con cui l'elemento nel nodo corrente viene stampato). Il codice per tali visite è una semplice variazione del Codice 4.3 (che rappresenta una visita posticipata in cui non viene restituito alcun valore). Per esempio, il Codice 4.4 realizza la visita anticipata: osserviamo che non restituisce alcun valore in questa forma e che può essere trasformato nel codice di una visita simmetrica o posticipata molto semplicemente, spostando l'istruzione di stampa (riga 3). Per apprezzare la differenza delle tre visite, consideriamo l'esempio mostrato nella Figura 4.4, in cui oltre alle tre visite suddette viene illustrato anche il risultato di una quarta visita che illustreremo più avanti.

ALVIE:

visita posticipata di un albero binario

Osserva, sperimenta e verifica BinaryTreePostorder

Tornando allo schema del Codice 4.3, possiamo notare che esso rappresenta un modo di effettuare una visita posticipata in cui viene raccolta l'informazione necessaria alla

anticipata: FB

D

B

RB

PB

GB

M

B

FB

M

B

G

B

A

B

A

B

G

B

G

RB

G

B

M

A

B

M

T

G

T

P

B

MT

G

T

T

M

T

PB

FB

T

M

B

A

G

simmetrica: D

B

RB

posticipata: R

B

D

B

M

B

ampiezza: F

Figura 4.4

B

D

B

PB

B

T

Risultato delle visite di un albero binario.

computazione di D e c o m p o n i b i l e ( u ) , a partire dal basso verso l'alto. Per risolvere alcuni problemi decomponibili, è necessario raccogliere più informazione di quanta ne serva apparentemente: studiamo, ad esempio, il caso degli alberi completamente bilanciati. Un albero binario è completo se ogni nodo interno ha esattamente due figli non vuoti. L'albero è completamente bilanciato se, oltre a essere completo, tutte le foglie hanno la stessa profondità. Un albero completamente bilanciato di altezza h. ha quindi 2 h — 1 nodi interni e 2h foglie: ne deriva che la relazione tra altezza h. e numero di nodi n = 2 h + 1 — 1 è h = log(n + 1) — 1. Possiamo introdurre la definizione di albero binario bilanciato: in tale albero vale la relazione h. = O(logn), che risulta essere interessante per la complessità delle operazioni fornite da diverse strutture di dati. Notiamo che un albero completamente bilanciato è bilanciato, mentre il viceversa non sempre vale. Volendo usare lo schema del Codice 4.3 per stabilire se un albero binario è completamente bilanciato, possiamo valutare cosa succede ipotizzando che D e c o m p o n i b i l e ( u ) sia un valore booleano, che risulta T R U E se e solo se T(u) è completamente bilanciato, dove T(u) indica l'albero radicato in u . Indicati come al solito con e con u p i due figli di u , il fatto che T ( u s ) e T(UQ ) siano completamente bilanciati, non comporta purtroppo che anche T(u) lo sia, in quanto i due sottoalberi potrebbero avere altezze diverse: in altre parole, T(u) è completamente bilanciato se e solo se T ( u s ) e T(U.D ), oltre a essere completamente bilanciati, hanno anche la stessa altezza. Nel Codice 4.5 richiediamo che D e c o m p o n i b i l e ( u ) sia una coppia di valori, in cui il primo è T R U E se e solo se T(u) è completamente bilanciato, mentre il secondo è l'altezza di T(u) (calcolata come nel Codice 4.2). La regola R i c o m b i n a diventa quindi quella riportata di seguito.

CompletamenteBilanciatoC u ): IF (u == nuli) { RETURN ; > ELSE {

= CompletamenteBilanciatoC u.sx ); = CompletamenteBilanciatoC u.dx ); completamenteBil = bilSX && bilDX && CaltSX == altDX); altezza = maxCaltSX, altDX) + 1; RETURN

ELSE {

altezzaSX = Cardine( u.sx, p+1 ); altezzaDX = Cardine( u.dx, p+1 ); altezza = max( altezzaSX, altezzaDX ) + 1; IF (p == altezza) PRINT u.dato; RETURN altezza;

}• Codice 4.6

(post: stampa i nodi cardine di T(u)) Algoritmo ricorsivo per individuare i nodi cardine in un albero binario. La chiamata iniziale ha come parametri la radice e la sua profondità pari a 0.

Inizialmente, questi parametri sono la radice r dell'albero e la sua profondità p r = 0. Le successive chiamate ricorsive provvedono a passare i parametri richiesti (righe 5 e 6): ovvero, se il nodo corrente ha profondità p, i figli avranno profondità p + 1. La verifica che la profondità sia uguale all'altezza nella riga 8 stabilisce infine se il nodo corrente è un nodo cardine: in tal caso, la sua informazione viene stampata. Da notare che la complessità temporale dell'algoritmo rimane O(n) in quanto si tratta di una semplice variazione della visita posticipata implicitamente adottata nel Codice 4.3. ALVIE:

nodi cardine di un albero binario

Osserva, s p e r i m e n t a e verifica HingeNode

4.1.2

Inserimento e cancellazione

Analogamente alle liste, gli alberi binari si prestano a essere mantenuti dinamicamente in modo efficiente. Osserviamo che una lista può essere rappresentata come un albero degenere in cui i nodi u hanno uno dei due campi, u . s x oppure u . d x , sempre uguale a n u l i . La testa della lista coincide quindi con la radice dell'albero degenere. Ne deriva che l'altezza di un albero binario può essere h. = n — 1 in tali casi degeneri (confrontiamo questo con il valore di h. = O(logn) nel caso di alberi bilanciati). Nel seguito, ipotizziamo che ogni nodo contenga il riferimento al padre (in tal caso, l'albero degenere è una lista doppia). L'operazione dinamica più semplice è quella di

p

=

u.padre

z.sx

=

u;

z.dx

=

nuli;

u.padre

=

z;

z.padre

=

p;

IF r >

>

(p

==

=

z;

ELSE

IF

p.sx

=

ELSE

{

p.dx

=

nuli)

(u

==

{

p.sx)

{

z;

z;

> Codice 4.7

Inserimento di un nuovo padre z (con il campo z . d a t o già inizializzato) del nodo u, che ne diventa figlio sinistro. Gli assegnamenti nelle righe 2-3 vanno invertiti per rendere u figlio destro di z.

inserire o cancellare un figlio. Sostituiamo un figlio di u, per esempio quello sinistro, con il nuovo figlio v a cui va aggiornato il riferimento al padre. Il seguente frammento di codice descrive un inserimento se u . s x è vuoto prima delle operazioni descritte, mentre descrive una cancellazione se u . s x non è vuoto (supponiamo che i campi v . d a t o , v . s x e v . d x siano stati già correttamente inizializzati dall'applicazione di riferimento). u . s x = v; IF (v != n u l i ) v . p a d r e = u; Un'operazione analoga è quella di inserire un nuovo padre z per un nodo u, ipotizzando di avere anche il riferimento r alla radice dell'albero binario. Ai fini della discussione, supponiamo che il nuovo padre abbia u come figlio sinistro (non è difficile modificare le righe 2 - 3 del Codice 4.7 per rendere u un figlio destro), e che il campo z . d a t o sia già stato correttamente inizializzato dall'applicazione di riferimento. Nel Codice 4.7, stabiliamo prima il vecchio padre di u, indicato con p nella riga 1 (p è n u l i quando u è la radice r dell'albero binario). Nelle righe 2—4, u diventa figlio sinistro di z, il nuovo padre. Le righe rimanenti rendono p (o il riferimento r se p è n u l i ) padre di z. In particolare, il confronto nella riga 6 permette di stabilire se r è il padre di z, mentre quello nella riga 8, permette a z di prendere il posto di u come figlio di p. In ogni caso, il costo totale dell'inserimento è 0 ( 1) tempo. Infine, mostriamo la cancellazione del padre di u quando u è figlio unico. In tal caso, le operazioni da eseguire sono riportate nel Codice 4.8, dove presumiamo che il padre di u non sia n u l i (altrimenti non procediamo con la cancellazione). Dopo aver

p = u.padre; pp = p . p a d r e ; u . p a d r e = pp; IF (pp == n u l i ) { r = u; } ELSE IF (p == p p . s x ) { p p . s x = u; } ELSE { pp.dx = u;

> Codice 4.8

Cancellazione del padre p del nodo u (dove p è diverso da n u l i e il fratello di u è uguale a n u l i ) .

individuato il nonno di u nelle prime due righe ed aver aggiornato in u il riferimento al nuovo padre, nella terza riga, le righe successive stabiliscono se il padre di u sia la radice 0 meno. Se lo è, allora u diviene la nuova radice, altrimenti u prende il posto del padre come figlio del nonno: osserviamo come le righe 3 - 1 0 siano analoghe alle righe 5 - 1 2 del Codice 4.7. Anche in questo caso, il costo è O( 1 ) tempo. Per concludere, osserviamo che altre operazioni dinamiche sono possibili, ma queste vanno discusse contestualmente all'applicazione di riferimento.

4.2

Opus libri: minimo antenato comune

In molte situazioni reali, i dati che devono essere elaborati sono statici (non subiscono modifiche nel corso del tempo) e possono quindi essere organizzati in un'opportuna struttura di dati in modo tale che le successive richieste siano efficientemente eseguite. In altre parole, siamo disposti a pagare un prezzo, in termini di tempo, per pre-elaborare 1 dati a disposizione (preprocessing), per poi rispondere molto velocemente a future richieste relative ai dati stessi (query). In questo paragrafo vediamo un esempio di tale approccio per la risoluzione di un noto problema su alberi. Dato un albero degli antenati, una domanda naturale consiste nel determinare chi sia il primo erede comune di due qualunque persone presenti nell'albero: facendo riferimento alla Figura 4.2, ad esempio, il primo erede comune di Marmadoc Brandibuck e di Adamanta Paffuti è Primula Brandibuck, mentre il primo erede comune di Drogo Baggins e Mirabella Tue è Frodo Baggins. Rispondere a tale domanda è equivalente a saper calcolare, dati due nodi u. e v di un albero, l'antenato comune di u e v che si trova più lontano dalla radice dell'albero: tale antenato è comunemente denominato minimo antenato comune. Il Codice 4.9 riporta le istruzioni necessarie a identificare tale an-

MinimoAntenatoComune ( u, v ) :

{pre: Il campo prof contiene la profondità)

WHILE (u.prof != v.prof) {

IF (u.prof > v.prof) { u = u.padre; > ELSE {

v = v.padre;

>

>

IF (u == v ) RETURN U; WHILE (u.padre != v.padre) {

u = u.padre; v = v.padre;

> RETURN

Codice 4.9

u.padre;

Ricerca del minimo antenato comune tra due nodi u e v.

fenato. A tal fine, ipotizziamo che ogni nodo sia dotato di un ulteriore campo p r o f contenente la sua profondità nell'albero (abbiamo visto nel paragrafo precedente come calcolare, per ogni nodo dell'albero, il valore di tale campo). Dopo aver risalito parte del cammino dal nodo di profondità maggiore verso la radice, i due nodi correnti si trovano alla stessa profondità (righe 2—8). A questo punto, abbiamo due possibilità: 1. abbiamo già raggiunto il minimo antenato comune, in quanto un nodo era discendente dell'altro (riga 9); 2. dobbiamo risalire in parallelo i due cammini verso la radice fino a che il minimo antenato comune risulti essere il padre di entrambi i nodi correnti (righe 10-13). Il costo temporale di tale algoritmo è proporzionale alla massima profondità tra u e v, per cui il costo al caso pessimo è O(h) tempo per un albero di altezza h. Uno scenario più interessante si presenta quando vogliamo rispondere a molte richieste di ricerca del minimo antenato comune. Ad esempio, un tale scenario si presenta nelle scienze economiche e manageriali per ottimizzare il flusso di informazioni nell'organizzazione delle reti gerarchiche (di comunicazione, sociali ed economiche). Tali reti sono rappresentate come alberi nella loro struttura principale e i nodi hanno importanza diversa in base alla quantità di informazione che devono scambiare con il resto dei nodi (tale importanza non è necessariamente collegata all'ordine gerarchico indotto dall'albero). Alle coppie di nodi critici, attivamente impiegate in flussi di vaste dimensioni, vengono aggiunti degli ulteriori collegamenti per creare canali diretti di comunicazione preferenziale, identificandone i minimi antenati comuni che sono i loro colli di bottiglia.

Eulero ( u, p ) : IF (u != nuli) {

{pre: p è la profondità di u)

PRINT p;

IF (u.sx != nuli) { Eulero( u.sx, p+1 ); PRINT p;

> IF (u.dx != nuli) { Eulero( u.dx, p+1 ); PRINT p;

>

> Codice 4.10

Stampa ricorsiva delle profondità dei nodi di un albero secondo il suo ciclo Euleriano. La chiamata iniziale ha come argomenti la radice r e la sua profondità p r = 0.

Nel caso di molte richieste, è pertanto preferibile dedicare tempo polinomiale di calcolo per effettuare un preprocessing dell'albero, così da poter rispondere successivamente alla sequenza di query sul minimo antenato comune in modo molto efficiente. Questo problema è generalmente indicato con l'acronimo LCA (dall'inglese Least Common Ancestor) e risulta essere uno strumento fondamentale per risolvere altri problemi, come vedremo in alcuni dei capitoli successivi. Il problema LCA è introdotto nel modo seguente: pre-elaborare un albero binario in tempo polinomiale così che sia possibile, dati due nodi qualunque u e v dell'albero, determinare in tempo costante il minimo antenato comune di u e v. Una soluzione immediata a tale problema per un albero di dimensione n e altezza h. deriva dalla costruzione di un array bidimensionale t di n righe e n colonne, tale che t[u][v] memorizza il risultato restituito da M i n i m o A n t e n a t o C o m u n e f u , v). Tale fase di preprocessing richiede 0(n 2 h.) tempo. Il vantaggio è che ora ogni successiva query richiede 0 ( 1 ) tempo. Tuttavia, lo spazio occupato è 0 ( n 2 ) celle di memoria a causa della dimensione dell'array t prodotto dal preprocessing. Per ridurre tale spazio a O ( n l o g n ) celle, mantenendo un tempo costante di query, operiamo una trasformazione del problema LCA nel problema della ricerca del minimo valore all'interno di un segmento di un array di numeri interi.

4.2.1

Trasformazione da antenati comuni a minimi in intervalli

La tecnica di trasformare un problema computazionale TT i in un altro problema computazionale n 2 è probabilmente una delle più comunemente utilizzate per progettare algoritmi di risoluzione. Intuitivamente, tale tecnica consiste nel mostrare come le soluzioni

Marmadoc Brandibuck

Figura 4 . 5

\ 1 Adaldrida Bolgeri



/

Gerontius Tue

Cammino Eleuriano di una porzione dell'albero degli antenati della Figura 4.2.

per Fi2 possano essere efficientemente impiegate per ottenere le soluzioni di FI) (vedremo nell'ultimo capitolo del libro una trattazione più formale del concetto di riduzione). Nel caso in questione, mostriamo come trasformare LCA nel problema RMQ (dall'inglese Range-Min Query) così definito: pre-elaborare in tempo polinomiale un array a di n numeri interi, così che sia possibile, dati due indici i e j, determinare in tempo costante l'indice del minimo elemento contenuto nel segmento a[i, j] (lo spazio totale deve essere di O ( n l o g n ) celle di memoria). Dato un albero (ovvero un'istanza del problema LCA), definiamo il ciclo Euleriano dell'albero attraverso una visita ricorsiva dei suoi nodi come illustrato nel Codice 4.10. In tale codice, le profondità dei nodi sono stampate secondo l'ordine indicato dalle frecce nella Figura 4.5 (in cui l'albero utilizzato è una porzione dell'albero degli antenati mostrato nella Figura 4.2): a partire dalla radice, i nodi sono visitati la prima volta quando ci muoviamo dai padri ai figli (frecce verso il basso) e sono poi visitati nuovamente quando ci muoviamo dai figli ai padri (frecce verso l'alto). Modificando in modo opportuno il Codice 4.10, ogni qualvolta un nodo viene visitato, possiamo aggiungere la sua profondità a un array di numeri interi, mantenendo al contempo un collegamento bidirezionale tra il nodo e il nuovo elemento dell'array (osserviamo che a ogni nodo dell'albero corrispondono al più tre elementi dell'array). Ad esempio, l'array risultante dalla visita dell'albero mostrato nella Figura 4.5 è illustra-

.S •= -e •= .5 DO « oa

0 a,

1 £

Figura 4.6

2

a> o 2

^

Q

B!

Q

0

1

2

1

0

1

2

3

2

3

2

0

1

2

3

4

5

6

7

8

9

10 11 12 13 14 15 16

UH

1

2

3

2

1

0

L'array corrispondente al cammino Euleriano della Figura 4.5.

to nella Figura 4.6. Osserviamo che la costruzione dell'array richiede tempo lineare nel numero di nodi dell'albero. Una volta costruito l'array, possiamo notare che il minimo antenato comune di due nodi u e v dell'albero è semplicemente il nodo con la profondità minima che si trova all'interno del segmento delimitato dalle prime due occorrenze dei due nodi nell'array. Ad esempio, supponiamo di voler trovare il minimo antenato comune di Marmadoc Brandibuck e di Mirabella Tue: la prima occorrenza nell'array di Marmadoc Brandibuck si ha in corrispondenza dell'indice 7, mentre la prima occorrenza di Mirabella Tue appare in corrispondenza dell'indice 12. Tra queste due occorrenze il nodo con profondità minima è quello corrispondente all'elemento di indice 11: in effetti, il minimo antenato comune di Marmadoc Brandibuck e di Mirabella Tue è Primula Brandibuck. In modo analogo, possiamo verificare che il minimo antenato comune di Ruby Bolgeri (indice 2) e Marmadoc Brandibuck (indice 7) è Frodo Baggins (indice 4). L'osservazione è valida in generale, in quanto supponendo che u venga visitato per la prima volta prima di v, il tratto di ciclo Euleriano che va da u a v attraversa una serie di nodi di cui il loro antenato comume minimo è quello con profondità minima. Abbiamo così dimostrato che il problema di rispondere in tempo costante a una qualunque query di tipo LCA, è riducibile al problema di rispondere in tempo costante a un'opportuna query di tipo RMQ. Possiamo quindi concentrarci su quest'ultimo problema, ponendoci come obiettivo di mantenere lo spazio a O ( n l o g n ) celle.

4.2.2

Soluzione efficiente in spazio

Dato un array a di n numeri interi, la soluzione quasi lineare del problema RMQ consiste nel considerare solamente la famiglia dei segmenti di a aventi una potenza del 2 come

RMQ( i , j ) : p* = p [ j - i + l ] ; q* = q C p * ] ;

mi = b[p*] [ i ] ; m2 = b[p*] [ j - q * + l ] ; IF (a[ml] < A[m2] ) { RETURN MI; > ELSE { RETURN M2;

> Codice 4.11

2° 21 22 23 24

0 0 0 0 0 0

Figura 4 . 7

Query per il problema RMQ.

1 1 1 4 4 4

2 2 3 4 4

3 3 4 4 4

4 4 4 4 4

5 5 5 5 5

6 6 6 6 11

7 7 8 8 11

8 8 8 11 11

9 9 10 11 16

10 10 11 11

11 11 11 11

12 12 12 15

13 13 14 16

14 14 15

15 15 16

16 16

La tabella b per la soluzione del problema RMQ in spazio O(nlogn).

lunghezza, e nel memorizzare il minimo elemento di ciascun segmento. Osserviamo che, per ogni indice i compreso tra 0 e n — 1, vi sono al più logn segmenti di tale famiglia, per cui lo spazio richiesto risulta essere O ( n l o g n ) celle di memoria. A tal fine il preprocessing richiede la costruzione di un array bidimensionale b di logn righe e n colonne, in cui b[l][i] contiene la posizione del minimo elemento nel segmento a[i,i + 2 l — 1], dove ' ° g n 0 a tabella mostrata nella Figura 4.7, costruita a partire dall'array mostrato nella Figura 4.6, mostra gli elementi minimi in corrispondenza a segmenti che rientrano interamente nei limiti dell'array). La costruzione di b richiede tempo O ( n l o g n ) utilizzando, per ogni l con logn, la seguente regola di programmazione dinamica chiamata del raddoppio. Caso base: poiché il minimo elemento all'interno di un segmento di lunghezza 2° = 1 coincide con l'unico elemento del segmento stesso, b[0][v] = i per 0 ^ i ^ n — 1. Passo induttivo: osserviamo che un segmento a[i, i + 2 l + 1 — 1] con l ^ 0, è interamente contenuto nell'array a solo se i + 2 l + 1 - 1 ^ n - 1, ovvero se i ^ n - 2 l + 1 . In tal caso, il minimo elemento in a[i, i + 2 l + 1 - 1] può essere calcolato confrontando

il minimo elemento all'interno della metà destra del segmento con il minimo elemento all'interno della metà sinistra. Quindi, b[l+l][i] = min{b[l][i], b[l][i+2 l ] per 0 ^ i ^ n — 2 l + 1 . Il preprocessing richiede la costruzione di due ulteriori array: l'array p di n numeri interi, tale che p[x] = l se e solo se 2 l è la più grande potenza del 2 minore o uguale a x (in altre parole, p[x] = [logxj), e l'array q di logn numeri interi, tale che q[l] = 2 l . Il tempo totale e lo spazio per il preprocessing sono quindi O ( n l o g n ) (ma è possibile ridurre lo spazio a O(n) celle di memoria). Per gestire una query di RMQ relativa a un segmento a[i, }] di lunghezza x = j — i + 1, calcoliamo p* = p[x] e q* = q[p*] = 2P* in tempo costante, e troviamo i due indici ttl] = b[p*][i] e m.2 = b[p*][j — q* + 1] che indicano la posizione degli elementi minimi nei due corrispondenti segmenti di lunghezza q* che ricoprono a[i, j] (un segmento è a[i, i + q * — 1] e l'altro è a[j — q* + 1,j]). Come già notato, il minimo in a[i, j] è il minimo tra i due elementi a[mi] e a[m2l: il Codice 4.11 si basa su tale sequenza di operazioni. Ad esempio, supponiamo di voler determinare il valore minimo contenuto all'interno del segmento a[7,12] dell'array mostrato nella Figura 4.6: in tal caso, x = 12 — 7 + 1 = 6, per cui p* = 2 e q* = 4 . Pertanto, possiamo consultare la tabella mostrata nella Figura 4.7 per determinare l'indice del valore minimo contenuto all'interno del segmento a[7,10]: l'elemento della tabella che ci interessa si trova in terza riga (in quanto il segmento ha lunghezza 4) e in ottava colonna (in quanto il segmento ha inizio dall'elemento di indice 7) ed è uguale a 8. Analogamente, utilizzando la tabella possiamo dedurre che l'indice del valore minimo contenuto all'interno del segmento a[9,12] è pari a l i . Poiché a[8] = 2 > 1 = Q[1 1], concludiamo che la risposta alla nostra interrogazione è l i . ALVIE: minimo antenato comune

Osserva, sperimenta e verifica LeastCommonAncestor

4.3

®

^¡^

_

Visita per ampiezza e rappresentazione di alberi

Abbiamo già osservato che, in molte applicazioni, le strutture di dati utilizzate risultano essere statiche, non subendo modifiche nel corso del tempo. Nel caso degli alberi, un esempio concreto è dato dal formato di scambio denominato XML (Extensible Markup Lartguage), il cui vasto utilizzo in molte applicazioni permette di memorizzare dati strutturati. I documenti in formato XML descrivono un albero i cui nodi sono etichettati

Figura 4.8

Una rappresentazione XML dell'albero genealogico mostrato nella Figura 4.3.

con attributi e valori: per esempio, uno dei modi per esprimere la porzione di albero mostrata nella Figura 4.3 in formato XML è quello riportato nella Figura 4.8. Questo utile formato occupa più spazio di quanto richiesto a causa della sua verbosità: poiché viene usato per rappresentare grandi quantità di dati, è necessario codificarlo in forma compressa mantenendo la sua struttura originale (in tal modo, il risparmio di spazio si traduce in una compressione dei dati memorizzati). Le etichette (nel nostro esempio, i nomi delle persone) sono memorizzate in una zona di memoria contigua usando opportune tecniche di compressione testuale (ad esempio, sfruttando la ridondanza di un qualunque linguaggio naturale). Per quanto riguarda, invece, la struttura ad albero, ovvero i riferimenti ai figli, è utile comprimerla a patto di simulare tali riferimenti in tempo costante: la caratteristica di questa metodologia è di permettere l'attraversamento dell'albero compresso senza decodificarne l'intera struttura ogni volta.

In questo paragrafo consideriamo due diverse metodologie (quella implicita e quella succinta) di compressione per gli alberi binari statici, in cui ipotizziamo che il campo u . d a t o (l'etichetta del nodo) occupi uno spazio prefissato di memoria indipendentemente dal nodo u di appartenenza e che il contenuto di u . d a t o sia già fornito compresso. In tal modo, possiamo concentrarci sul risparmio di spazio per memorizzare i riferimenti u . p a d r e , u . s x e u . dx in forma implicita o succinta. L'idea è di allocare un albero binario in un array secondo l'ordine derivante da una visita in ampiezza dei suoi nodi (un esempio di tale visita è riportato nella Figura 4.4). Ogni nodo u viene così associato concettualmente alla sua posizione nell'array (occupata dal campo chiave u . d a t o ) . In particolare, identificheremo il nodo u con il corrispondente elemento dell'array e, per attraversare l'albero dai padri ai figli e viceversa, utilizzeremo una rappresentazione indiretta dei riferimenti u . p a d r e , u . s x e u . dx, che saranno simulati calcolando le posizioni nell'array del padre, del figlio sinistro e del figlio destro di u, rispettivamente. Anticipiamo sin da ora, che mentre nella rappresentazione implicita (utilizzabile solo in alcune classi di alberi binari) usiamo soltanto l'array dei nodi e un numero costante di celle di memoria aggiuntive, nella rappresentazione succinta tale array è affiancato da ulteriori strutture di dati che richiedono 2n + o(n) bit aggiuntivi: in compenso, quest'ultima rappresentazione è applicabile ad alberi binari qualunque. Abbiamo addotto il notevole risparmio di spazio per motivare tali rappresentazioni, per cui cerchiamo di quantificarne il vantaggio. Ipotizzando che ciascun riferimento ( u . p a d r e , u . s x , u . d x ) sia allocato in una cella di memoria (di 32 o 64 bit), la rappresentazione succinta permette di sostituire i circa 100 bit (o più) occupati da tali campi con all'incirca due bit per nodo: ne deriva un risparmio in spazio che è quasi un cinquantesimo (o più) della rappresentazione esplicita dei riferimenti! In generale, se ciascun riferimento in un albero binario di dimensione n richiede logn bit (il minimo necessario per indicare uno qualunque dei nodi), la rappresentazione succinta sostituisce i 3 n l o g n bit usati dalla rappresentazione esplicita della struttura dell'albero, con soli 2n + o(n) bit (ricordiamo che la rappresentazione implicita è superiore in prestazioni ma meno generale e che i campi u . d a t o vengono compressi con altre tecniche).

4.3.1

Rappresentazione implicita di alberi binari

La relazione tra i nodi u di un albero binario in una rappresentazione implicita è codificata completamente tramite una semplice regola matematica senza alcun uso di memoria aggiuntiva, a parte quella necessaria alle chiavi u . d a t o e a un numero costante di variabili locali. L'albero binario completo a sinistra è l'esempio principe di albero rappresentabile in modo implicito. Un albero binario di altezza h. è completo a sinistra se i nodi di profondità minore di h. formano un albero completamente bilanciato, e se i nodi di ( profondità h. sono tutti accumulati a sinistra (parte sinistra della Figura 4.9). Possiamo verificare facilmente che h. = [lognj = O(logn), dove n è il numero di nodi.

padre

i 0

1

3

PB

FB

4

Rappresentazione implicita di un

6

RB G B 1

Figura 4.9

5

7



FB D B

2

8 LB

9 Ts X Y

sx dx

completo a sinistra.

Dato un albero binario completo a sinistra possiamo memorizzare in un array i suoi nodi, effettuando una visita per ampiezza operata a partire dalla radice, come mostrato nel Codice 4.12: la caratteristica di tale visita è che essa memorizza i nodi in ordine crescente di profondità p nell'array n o d o (la cui lunghezza presumiamo che sia uguale alla dimensione dell'albero binario), a partire dalla radice: inoltre, i nodi di profondità p sono memorizzati a partire dalla posizione u l t i m o + 1, procedendo da sinistra verso destra. Dopo aver memorizzato la radice dell'albero (righe 3—4), iteriamo fintanto che non vi sono più nodi da visitare (riga 5). Preso il nodo nella posizione a t t u a l e (riga 6), aggiungiamo nelle posizioni immediatamente successive a quella indicata da u l t i m o i suoi figli non vuoti (righe 7 - 1 4 ) . Per dimostrare che tutti i nodi sono visitati, osserviamo che ogni qualvolta un nodo viene visitato, i suoi figli sono memorizzati in n o d o e, da quel momento in poi, u l t i m o è maggiore oppure uguale alla loro posizione nell'array. Quindi, se la condizione della riga 5 non è verificata, allora non vi sono più nodi da visitare. Notando inoltre che, a ogni iterazione del ciclo w h i l e , il valore di a t t u a l e aumenta di 1, abbiamo che il costo è O(n) tempo, dove n indica la dimensione dell'albero. La Figura 4.9 riporta un esempio di tale memorizzazione dei nodi di un albero binario completo a sinistra in un array: osserviamo che i soli campi u . d a t o sono effettivamente memorizzati. Pur avendo ignorato i campi u . p a d r e , u . s x e u . d x , la relazione gerarchica viene comunque preservata: è infatti sufficiente esaminare, nell'array, la posizione corrispondente a ciascun nodo. La radice occupa la posizione i = 0. In generale, se l'albero ha dimensione n e un suo generico nodo u occupa la posizione i, possiamo associare le posizioni dell'array ai tre riferimenti u . s x , u . dx e u . p a d r e con la regola: • il figlio sinistro occupa la posizione 2i + 1 (se 2v + 1 ^ n , allora u . s x = n u l i ) ; • il figlio destro occupa la posizione 2i + 2 (se 2i + 2 ^ n , allora u.dx = n u l i ) ; • il padre occupa la posizione |_(i — 1 )/2J (se i = 0, allora u . p a d r e = n u l i ) .

Implicita( u, nodo ): (pre: \iè la radice dell'albero e la lunghezza di nodo è uguale al numero dei suoi nodi) ultimo = attuale = 0; nodo[attuale] = u; WHILE (attuale IF (u.dx ! = nuli) -C nodo[ultimo+1] = u.dx; ultimo = ultimo +1;

} attuale = attuale+1;

> Codice 4.12

Memorizzazione in un array dei nodi di un albero binario completo a sinistra mediante una visita per ampiezza.

La navigazione nell'albero da un nodo ai suoi figli, e viceversa, richiede quindi tempo costante come nella rappresentazione esplicita. Nell'ottica del risparmio di memoria, la rappresentazione implicita è preferibile perché usa soltanto 0 ( 1 ) celle di memoria aggiuntive, quindi O(logn) bit, oltre allo spazio necessariamente richiesto per la memorizzazione dei campi u . d a t o . Osserviamo che tale rappresentazione non può essere usata per alberi binari qualunque (a meno di non sfruttare una qualche relazione tra le chiavi dei campi u . d a t o ) , come possiamo dedurre dall'albero mostrato nella parte sinistra della Figura 4.10. In base alla regola matematica appena esposta, l'unico figlio del nodo in posizione 1 dovrebbe occupare la posizione 4, mentre esso viene memorizzato in posizione 3 dalla visita in ampiezza realizzata dal Codice 4.12: in questo caso, la regola non vale cosi come è formulata, ma è possibile raffinare l'idea mediante l'introduzione della rappresentazione succinta. ALVIE:

rappresentazione implicita ®

Osserva, sperimenta e verifica ImplicitRepresentation

®®® ®®

®

"

0 1 2 3 4 5 6 7 8 9 10 11 12 1 1 1 0 1 0 0 1 1 0 0 0 0 (PB)

F

B

D

B

P

B

-

RB

X

Y

Z

Y

1 F b D b PbRBXYZY ,1 1 1 0 1 0 0 1 nodo[0,n-l].dato Figura 4.10

4.3.2

10000

pieno[0,2n]

Rappresentazione succinta per ampiezza di un albero binario con n nodi.

Rappresentazione succinta per ampiezza

Nell'ottica del risparmio di memoria per un albero binario qualunque, utilizziamo una rappresentazione succinta, il cui scopo è quello di rappresentare la struttura dell'albero (senza sfruttare proprietà particolari delle chiavi) utilizzando 2n + o(n) bit in aggiunta allo spazio occupato dai campi u . d a t o . In particolare, riprendendo l'esempio mostrato nella Figura 4.10, modifichiamo il Codice 4.12 in modo che produca un ulteriore array binario p i e n o contenente i bit pari a 1 in corrispondenza dei nodi dell'albero e i bit pari a 0 in corrispondenza dei riferimenti n u l i (tale modifica è illustrata nel Codice 4.13). Come possiamo osservare nell'esempio nella Figura 4.10, esiste una corrispondenza biunivoca tra i nodi dell'albero e i bit pari a 1 in p i e n o : ricordando che nodo[i] memorizza l'(i + 1)-esimo nodo u visitato per ampiezza (0 ^ i < n — 1), abbiamo che esso corrisponde all'fi + 1 )-esimo 1 nell'array p i e n o e viceversa. In base a tale corrispondenza, l'array p i e n o permette di stabilire la seguente regola, analoga a quella introdotta per la rappresentazione implicita: 1. se un nodo occupa la posizione i nell'array nodo, allora i bit corrispondenti ai suoi due figli occupano le posizioni 2i + 1 e 2i + 2 nell'array p i e n o ; 2. se un nodo occupa la posizione i nell'array nodo, allora p i e n o [ 2 i + 1] = 1 se solo se il riferimento al suo figlio sinistro non è n u l i e p i e n o [ 2 i + 2] = 1 se solo se il riferimento al suo figlio destro non è n u l i . Tale regola può essere verificata per ispezione diretta nella Figura 4.10. Per convincerci della sua correttezza in generale, osserviamo che l'invariante mantenuta dal ciclo w h i l e nel Codice 4.13 è che il valore di u l t i m o P i e n o è pari a due volte il valore di a t t u a l e : chiaramente ciò è vero prima dell'inizio del ciclo (poiché entrambi i valori sono 0); inoltre, l'invariante è mantenuta a ogni iterazione, in quanto il valore di a t t u a l e

SuccintaAmpiezza( u, nodo, pieno ): (pre: u radice di albero di n nodi; lunghezze di nodo e pieno sono n e 2n + 1) ultimoNodo = ultimoPieno = 0; nodo[ultimoNodo] = u; pieno[ultimoPieno] = 1; attuale = 0; WHILE (attuale ELSE {

pieno[ultimoPieno + 1 ] = 0 ;

> IF (u.dx != nuli) { nodo[ultimoNodo+1] = u.dx; ultimoNodo = ultimoNodo + 1; pieno[ultimoPieno + 2 ] = 1; > ELSE {

pieno[ultimoPieno + 2 ] = 0 ;

> attuale = attuale + 1; ultimoPieno = ultimoPieno + 2;

Codice 4.13

Rappresentazione succinta per ampiezza di un albero binario.

è incrementato di 1 e quello di u l t i m o P i e n o è incrementato di 2. Da ciò deriva che se un nodo occupa la posizione a t t u a l e in nodo, allora i bit corrispondenti ai suoi due figli occupano nell'array p i e n o le posizioni u l t i m o P i e n o + 1 = 2 x a t t u a l e + 1 (riga 12 o 14) e u l t i m o P i e n o + 2 = 2 x a t t u a l e + 2 (riga 19 o 21). Abbiamo così dimostrato il punto 1 della suddetta regola. Per dimostrare il punto 2, osserviamo che, quando un nodo u. viene esaminato dalla visita in ampiezza (riga 8), se un figlio di u esiste, allora il bit a esso corrispondente è posto a 1 (riga 12 o 19), altrimenti tale bit è posto a 0 (riga 14 o 21). La regola suddetta permette di navigare da un nodo u ai suoi figli e viceversa, mantenendo i soli campi u . d a t o in n o d o e associandoli ai corrispettivi bit pari a 1 in p i e n o . Per poter passare in tempo costante dalle posizioni di n o d o a quelle dei corrispondenti 1 in p i e n o e viceversa, utilizziamo due funzioni basilari per le strutture di dati succinte, una inversa dell'altra, definite su un array b di m bit (nel nostro caso, b è l'array p i e n o

Select

1 L F B D B P B RB XY Z Y , 1 1 1 0 1 0 0 1 1 0 0 0 0 pieno nodo Rank Figura 4.11

Uso di Rank e S e l e c t per mettere in corrispondenza gli elementi di nodo con i corrispettivi bit in pieno.

e m = 2 n + 1 come mostrato nella Figura 4.11): • Rank(b,i) = numero di 1 presenti nel segmento b[0,i], per 0 ^ i ^ m — 1; • S e l e c t ( b , i) = posizione dell'(i + 1 )-esimo 1 in b, per 0 ^ i < Rank(b, m — 1 ). Il Codice 4.14 mostra come navigare nell'albero binario utilizzando l'array nodo, le funzioni Rank e S e l e c t applicate all'array p i e n o e la regola discussa prima. Per esempio, per identificare la posizione nell'array n o d o del figlio sinistro di nodo[i], prima identifichiamo la sua posizione f = 2 i + 1 nell'array p i e n o ; poi, usiamo R a n k ( p i e n o , f) per vedere quanti 1 sono presenti nel segmento p i e n o [ 0 , f], deducendo che il figlio sinistro si trova in n o d o [ R a n k ( p i e n o , f) — 1], Per trovare il padre di nodo[i], è sufficiente identificare la posizione p = S e l e c t f p i e n o , i) del bit 1 corrispondente a nodo[i] nell'array p i e n o ; invertendo la regola, otteniamo che n o d o [ [ ( p — 1)/2J] contiene il padre di nodo[i]. Per calcolare lo spazio aggiuntivo rispetto a quello occupato dall'array nodo, dobbiamo conteggiare i 2 n + 1 bit necessari per memorizzare l'array p i e n o , e lo spazio necessario per realizzare le funzioni Rank e S e l e c t , che ora mostreremo essere O ( n l o g l o g n / l o g n ) = o(n) bit. Quindi, la rappresentazione succinta utilizza un totale di 2 n + o(n) bit aggiuntivi per la rappresentazione di un qualunque albero binario.

4.3.3

Implementazione di rank e select

La funzione Rank per un array b di m bit può essere implementata usando o(m) bit aggiuntivi: per illustrare il metodo adottato, consideriamo ad esempio un array di m = 256 bit, i cui primi 13 bit corrispondano a quelli dell'array p i e n o riportato nella Figura 4.10. Partiamo dalla rappresentazione esplicita di Rank, tabulando i suoi valori in un array: b[i] i Rank

1 0 1

1 1 2

1 2 3

0 3 3

1 4 4

0 5 4

0 6 4

1 7 5

1 8 6

0 9 6

0 10 6

0 11 6

0 12 6

0 13 6

0 14 6

0 15 6

••• •••

IndiceFiglioSinistro( i ): f = 2 x i + 1; IF (pieno [f] == 0) { RETURN nuli; > ELSE {

RETURN Rank( pieno, f ) - 1;

} IndiceFiglioDestro( i ): f = 2 x i + 2; IF (pieno[f] == 0) { RETURN nuli; > ELSE {

RETURN Rank( pieno, f ) - 1;

> IndicePadre( i ): IF (i == 0) { RETURN nuli; > ELSE {

p = Select( pieno, i ); RETURN

(p - 1) / 2;

> Codice 4.14

Simulazione dei riferimenti sx, dx e padre per nodo[i]. Tali riferimenti, se diversi da n u l i , restituiscono la posizione del corrispondente elemento nell'array nodo.

Tale rappresentazione permette un tempo costante di calcolo, ma richiede un'occupazione di memoria eccessiva, in quanto contiene m interi di l o g m bit ciascuno. Per ridurre lo spazio, partizioniamo b in segmenti elementari di k = j l o g m bit ciascuno (k = 4 nell'esempio). Inoltre, manteniamo un solo valore di Rank per ogni segmento elementare, cosi che dobbiamo memorizzare solo m / k = 2 m / log m interi di log m bit ciascuno in un array r ' , per un totale di 2 m bit: r'[j] j b[i] i Rank

1 0 1

1 1 2

1 2 3

3 1 0 3 3

1 4 4

0 5 4

0 6 4

5 2 1 7 5

1 8 6

0 9 6

0 10 6

6 3 0 11 6

0 12 6

0 13 6

0 14 6

6 4 0 15 6

... ••• •••

Se dobbiamo restituire uno dei valori in r ' , possiamo chiaramente farlo in tempo costante. Ma cosa succede se dobbiamo restituire un valore Rank(b,i) non "campionato"? Usiamo la proprietà che ogni valore non campionato può essere espresso come la som-

ma del valore campionato più vicino a partire da sinistra (ovvero, r ' [i/k] supponendo che r'[0] = 0) e del numero di bit pari a 1 nella porzione di segmento elementare che contiene b[i]. Per esempio, Rank(b,6) = r'[l] + (numero di 1 nei primi tre bit di b[4,7]) = 3 + 1 = 4 Per calcolare il numero di 1 nei primi bit di ciascun segmento elementare, costruiamo una volta per tutte un array bidimensionale c, le cui colonne corrispondono a tutti i possibili 2 k segmenti elementari di k bit e le cui righe corrispondono a tutte le possibili k posizioni all'interno di un segmento elementare. Questo array contiene tutte le risposte alle richieste che possono essere eseguite sui primi bit di un segmento elementare, come mostrato nella Figura 4.12: usando c, possiamo dunque calcolare Rank(b,6) = r'[l] + c[2][9] = 3 + 1 = 4, in quanto il segmento b[4,7] = 1 , 0 , 0 , 1 corrisponde alla colonna 9 e i suoi primi tre bit terminano nella posizione j = 2. L'osservazione fondamentale, nota come trucco dei quattro russi, è che non ci possono essere troppi segmenti elementari distinti: precisamente, il loro numero è 2 k = x/rrT- Ne deriva che l'array c contiene soltanto k x 2 k = CH^TU logm) elementi di O(logk) bit, e quindi utilizza o(m) bit. A questo punto, per calcolare Rank(b,i) in tempo costante usando solo r ' , c e i segmenti elementari di b, è sufficiente restituire r'[q] + c[r][s], dove q e r sono il quoziente e il resto della divisione tra i e k, e s rappresenta il (q + 1)esimo segmento elementare di b. Mentre è ovvio che q = [_i/k.J e r = i — kq possono essere calcolati in tempo costante, non altrettanto facile è calcolare s in tempo costante (in effetti, se eseguissimo un semplice algoritmo di conversione da binario a decimale, questo richiederebbe tempo O(k) = O(logm)). Possiamo a tale scopo sostituire b con un vettore b ' contenente m / k interi tale che b'[j] è uguale al numero intero rappresentato dalla sequenza di bit del (j + l)-esimo segmento di b (notiamo che 0 ^ b'[j] < V/TTI): r'[j] j b'[j] i Rank

0 1

1 2

2 3

3 1 14 3 3

4 4

5 4

6 4

5 2 9 7 5

8 6

9 6

10 6

6 3 8 11 6

12 6

13 6

14 6

6 4 0 15 ••• 6

Pertanto, Rank(b,i) = r'[q] + c[r][b'[q + 1]]: ad esempio, Rank(b,6) = r'[l] + c[2][b'[2]] = r ' [ l ] + c[2][9] = 4 . In realtà abbiamo ancora troppi valori tabulati in r', in quanto questi richiedono 2m bit in totale. Introduciamo allora un ulteriore livello di campionamento, tabulando solo un valore di Rank ogni j log2 m bit consecutivi, memorizzando tali valori in un array r " di soli 8 m / l o g 2 m x logm = 0 ( m / l o g m ) bit (la scelta di g è semplicemente dovuta al fatto di poter proseguire in questo modo l'esempio).

— O ~ o O —. o O O O — ' — O O — 'O — — oO O— — « O O—' O ———' O O OO O O —O. O O—. O O O— O I O ——' — O — ' O

H= 0 h= 1 h = 2 h= 3 Figura 4.12

0 0 0 0

0 0 0 1

0 0 1 1

0 0 1 2

0 1 1 1

0 1 1 2

0 1 2 2

0 1 2 3

1 1 1 1

1 1 1 2

1 1 2 2

1 1 2 3

1 2 2 2

1 2 2 3

1 2 3 3

1 2 3 4

L'array c per il numero di 1 contenuti nelle prime h. posizioni di tutti i possibili segmenti elementari.

A q u e s t o p u n t o , p a r t i z i o n i a m o b in b l o c c h i d a g log 2 m bit e d i v i d i a m o o g n i b l o c c o in s e g m e n t i e l e m e n t a r i , c o s t r u e n d o l'array r ' a esso relativo (in altre parole, è c o m e se avessimo u n array T ' p e r o g n i blocco): r" r' Rank b i

1 1 0

2 1 1

3 1 2

3 3 0 3

4 1 4

4 0 5

4 0 6

5 5 5 1 7

6 1 8

6 0 9

6 0 10

1 6 0 11

6 0 12

6 0 13

6 0 14

6 1 6 0 15

•••

N e deriva c h e le s o m m e parziali in r ' h a n n o valore al p i ù g l o g 2 m e, q u i n d i , ciascun e l e m e n t o di r ' richiede o r a s o l t a n t o 0 ( l o g l o g m ) bit: p e r t a n t o , lo spazio o c c u p a t o d a r ' è O ( m l o g l o g m / l o g m ) bit. S o m m a n d o a n c h e lo spazio di r " e c, o t t e n i a m o o ( m ) bit in a g g i u n t a agli m bit richiesti da b (in realtà d a b ' ) .

Per calcolare R a n k ( b , i ) in

t e m p o c o s t a n t e u s a n d o b ' , r ' , r " e c, o s s e r v i a m o c h e i identifica u n u n i c o b l o c c o e u n u n i c o s e g m e n t o e l e m e n t a r e al s u o i n t e r n o : q u i n d i , R a n k ( b , i ) è, c o m e p r i m a , pari alla s o m m a r ' [ q ] + c[r][b'[q + 1]] a cui p e r ò a g g i u n g i a m o il valore c a m p i o n a t o di R a n k per il blocco c o r r e n t e , ovvero r " [ t ] dove t = 8 i / l o g m ( n e l l ' e s e m p i o , R a n k ( b , 12) = r " [ l ] + r ' [ 3 ] + c[1][0] = 5 + 1 + 0 = 6). Tali o p e r a z i o n i r i c h i e d o n o u n t e m p o c o s t a n t e ciascuna e d e t e r m i n a n o il c o s t o finale di u n ' i n v o c a z i o n e a R a n k . L'operazione S e l e c t ha u n a realizzazione a livelli simile a quella di R a n k , a n c h e se l e g g e r m e n t e p i ù sofisticata in q u a n t o la p a r t i z i o n e di b in s e g m e n t i e l e m e n t a r i è adattiva rispetto al n u m e r o di 1 in essi c o n t e n u t i . ALVIE: rappresentazione succinta per a m p i e z z a

Osserva, sperimenta e verifica BreadthRepresentation



®

cE>

4.3.4

Limite inferiore allo spazio delle rappresentazioni succinte

Nei paragrafi precedenti abbiamo mostrato come costruire una rappresentazione succinta che richiede 2n + o(n) bit aggiuntivi: in questo paragrafo, dimostriamo che questa quantità non è significativamente migliorabile per un qualunque albero binario, secondo la seguente argomentazione tratta dalla teoria dell'informazione. Prendiamo un insieme di C oggetti distinti. Per indicarne uno qualunque, non possiamo usare meno di k = flogC] bit (per esempio, abbiamo bisogno di almeno k = 2 bit per distinguere C = 3 oggetti). Se così non fosse, basterebbero k' < k bit ma confonderemmo due oggetti distinti poiché 2 k ' ^ < C, generando una contraddizione: in generale, occorrono k = [log C] bit per rappresentare un qualunque oggetto appartenente all'insieme di C oggetti. Nel nostro caso, gli oggetti da considerare sono gli alberi binari di dimensione n e l'obiettivo è quello di valutare il minimo numero di bit necessari per rappresentare in modo distinto alberi diversi. Sia C n il numero di alberi binari distinti con n nodi (equivalentemente, gli alberi binari con n nodi interni, dove ciascuno ha due figli). Tale quantità è nota come numero di Catalan, C n = ( 2 T [ l )/(n + 1 ) : quindi, occorrono [logC n ] bit per rappresentare un qualunque albero binario con n nodi. La regola ricorsiva con cui otteniamo C n è intuitiva: togliendo la radice da un albero binario di n nodi, i rimanenti nodi costituiscono il sottoalbero sinistro di dimensione s (dove O ^ s ^ n — 1 ) e il sottoalbero destro di dimensione n — s — 1. Ne deriva che il numero C n di alberi binari distinti con n nodi soddisfa l'equazione ricorsiva n— 1 Cn = ^ ( C s=0

s

x Cn_s_,)

(dove Co = Ci = 1)

(4.1)

in quanto possiamo combinare tutti i possibili C s sottoalberi sinistri con tutti i possibili C n _ s _ i sottoalberi destri, per ogni valore di s. La soluzione dell'equazione di ricorrenza (4.1) è il numero di Catalan C n = ( 2 ^ ) / ( n + 1): la dimostrazione di ciò richiede tecniche di analisi combinatoria che possono essere facilmente trovate nella letteratura specializzata. Per valutare quindi k = [~logCn~|, osserviamo che 2n^ n /

_



(2n)! 2n 2 n x (2n - 1 ) x (2n — 2 ) x - - - x 3 x 2 x l n!n! 2n n x n x ( n — l)x(n— 1 ) x • • •x 1 x 1 1 2nx2n (2n - 1) x (2n - 2) 3x2 X X - X • • •x 2n nxn (n-l)x(n—1) lxl 1 ^ 2 n x 2n ^ (2n — 2) x (2n — 2) ^ ^2x2 2n nxn (n — l ) x ( n — 1) lxl

=

1 22n — x 2s x 2 x 2 x 2 x - - - 2 x 2 = —— 2n v ' 2n 2 n volte

AnticipataC u ): IF (u != nuli) { PRINT u.dato; FOR (i = 0; i < k; i = i+1)

AnticipataC u.figlio[i] );

> Codice 4.15

Visita anticipata di un albero k-ario, derivata dal Codice 4.4.

dove la diseguaglianza deriva dal fatto che 2 n — i > 2 n — i — 1 per ogni i > 0. Concludiamo che k = [ l o g C a l = l o g f ( 2 ^ ) / ( n + l ) ] > l o g [ 2 2 r v / ( 2 n ( n + 1))] = 2 n - 0 ( l o g n ) bit sono necessari. Abbiamo quindi stabilito un limite inferiore all'occupazione in spazio di una qualunque rappresentazione succinta di un albero binario: quella che abbiamo proposto nei paragrafi precedenti risulta dunque essere ottima a meno di un termine o(n) di ordine inferiore. Ricordiamo comunque che, per insiemi particolari di alberi binari, possono esistere rappresentazioni che richiedono meno di 2 u — O(logn) bit (come osservato, ad esempio, nel caso di alberi completi a sinistra).

4.4

Alberi cardinali e ordinali, e parentesi bilanciate

Gli alberi binari finora discussi sono un caso particolare degli alberi cardinali o k-ari, caratterizzati dal fatto che ogni nodo ha k riferimenti ai figli, i quali sono enumerati da 0 a k — 1. Precisamente, un nodo u di un albero k-ario ha i campi u . d a t o e u . p a d r e come negli alberi binari, mentre i riferimenti ai suoi figli sono memorizzati in un array u . f i g l i o di dimensione k, dove u . f i g l i o l i ] è il riferimento (eventualmente uguale a n u l i ) al figlio i (0 ^ i < k - 1): per k = 2, abbiamo che u . f i g l i o [0] corrisponde a u . s x mentre u . f i g l i o [ 1 ] corrisponde a u . d x . Per k = 3 , 4 , . . . , si ottengono alberi ternari, quaternari e così via, che possono essere definiti ricorsivamente come gli alberi binari: la maggior parte delle definizioni relative agli alberi binari si adattano facilmente agli alberi cardinali. Per esempio, un albero k-ario è completo se ogni nodo interno ha tutti i k figli non vuoti: inoltre, è completamente bilanciato se tutte le foglie sono alla stessa profondità. L'altezza h. di un albero k-ario completamente bilanciato soddisfa la relazione 1 + k + k 2 + k 3 + • • • + k h = n sul numero n di nodi: quindi, risulta h. = O(log k n) in quanto _

kh+l — 1

2-1=0^ k-i • Gli algoritmi ricorsivi per gli alberi binari si estendono facilmente agli alberi k-ari. Per esempio, la visita anticipata del Codice 4.4 dà luogo al Codice 4.15 in cui, attraverso un ciclo aggiuntivo, visitiamo ricorsivamente tutti i figli del nodo corrente.

Figura 4 . 1 3

Due alberi binari distinti che risultano indistinguibili come alberi ordinali.

Gli alberi ordinali si differenziano da quelli cardinali, in quanto ogni nodo memorizza soltanto la lista ordinata dei riferimenti non vuoti ai suoi figli. Il numero di tali figli è variabile da nodo a nodo e viene chiamato grado: il grado è un intero compreso tra 0 (nel caso di una foglia) e n — 1 (nel caso di una radice che ha i rimanenti nodi come figli), e un nodo di grado d > 0 ha d figli che sono numerati consecutivamente da 0 a d — 1. Un esempio è mostrato nella Figura 4.1 dove il grado massimo (denominato grado dell'albero) è d = 3. Osserviamo che gli alberi cardinali e gli alberi ordinali sono due strutture di dati differenti, nonostante l'apparente somiglianza. Gli alberi nella Figura 4.13 sono distinti se considerati come alberi cardinali, in quanto il nodo D è il figlio destro del nodo B nel primo caso ed è il figlio sinistro nel secondo caso, mentre tali alberi sono indistinguibili come alberi ordinali in quanto D è il primo (e unico) figlio di B. Nel caso degli alberi ordinali, non conviene preallocare un nodo in modo da poter ospitare il massimo numero di figli, essendo il suo grado variabile. Usiamo quindi la memorizzazione binarizzata dell'albero, introducendo semplici nodi in cui, oltre al campo u . d a t o , sono presenti anche i campi u . p a d r e per il riferimento al padre, u . p r i m o per il riferimento al primo figlio (quello con numero 0) e u . f r a t e l l o per il riferimeno al successivo fratello nell'ordine filiare. Un esempio di memorizzazione binarizzata è mostrato nella Figura 4.14. Osserviamo che, a differenza degli alberi cardinali in cui l'accesso a un qualunque figlio richiede sempre tempo costante, negli alberi ordinali per raggiungere il figlio i (con i > 0) è necessario O(i) tempo per scandire la lista dei figli con il seguente frammento di codice: p = u.primo; j = 0; WHILE ((p != nuli) && (j < i)) { p = p.fratello; j = j+i;

>

Figura 4.14

Memorizzazione binarizzata dell'albero ordinale mostrato nella Figura 4.1. I riferimenti u . f r a t e l l o sono rappresentati come frecce tratteggiate per distinguerli dai riferimenti u . primo.

Quindi la memorizzazione binarizzata dei nodi in un albero ordinale è analoga alla rappresentazione degli alberi binari, ma la semantica delle due rappresentazioni è ben diversa! Allo stesso tempo, la memorizzazione binarizzata permette di stabilire l'esistenza di una corrispondenza biunivoca tra gli alberi binari e gli alberi ordinali: ogni albero binario di n nodi con radice r è la memorizzazione binarizzata di un distinto albero ordinale di n + 1 nodi in cui viene introdotta una nuova radice fittizia il cui primo figlio è r. Tale corrispondenza identifica il campo u . s x degli alberi binari con il campo u . p r i m o della memorizzazione binarizzata degli alberi ordinali e il il campo u . dx con il campo u . f r a t e l l o , e viene esemplificata nella parte superiore della Figura 4.15, dove la radice fittizia è mostrata come un pallino (la radice fittizia è introdotta ai soli fini della presente discussione). Un altro esempio deriva dai due alberi binari mostrati nella Figura 4.13, quando questi sono interpretati come la memorizzazione binarizzata di due distinti alberi ordinali: nel primo caso i nodi B e D sono fratelli, mentre nel secondo caso D è il primo (e unico) figlio di B. E possibile stabilire anche una corrispondenza biunivoca tra alberi ordinali e sequenze di parentesi bilanciate, come mostrato nella parte inferiore della Figura 4.15, utilizzando il Codice 4.16. La regola utilizzata è ricorsiva: ogni nodo u (di grado d) è associato a una coppia di parentesi bilanciate (• • • ) e i suoi figli sono ricorsivamente codificati ciascuno con una sequenza bilanciata di parentesi. Quindi, la codifica del sottoalbero radicato in u risulta nella sequenza di parentesi annidate ( V( • • • ) ( • • • ) • • • ( • • • ) / ) v d sottoalberi

dove la prima coppia di parentesi annidata corrisponde al primo figlio (e relativo sottoal-

ParentesiOrdinali( u ): PRINT '(';

p = u.primo; WHILE (p != nuli) {

ParentesiOrdinali( p ); p = p.fratello;

> PRINT ')'; Codice 4.16

Visita anticipata per la rappresentazione di un albero ordinale (non vuoto) mediante le parentesi bilanciate.

bero), la seconda coppia annidata corrisponde al secondo figlio (e relativo sottoalbero) e così via (Figura 4.15). Osserviamo che la sequenza di parentesi bilanciate così ottenuta codifica univocamente la struttura di un albero ordinale. Di conseguenza, vale la seguente (non ovvia) proprietà: esiste una corrispondenza biunivoca tra gli alberi binari di n nodi, gli alberi ordinali di n + 1 nodi e le sequenze bilanciate di 2 n parentesi! Quindi la cardinalità di ciascuno di questi tre insiemi è data dal numero di Catalan discusso nel Paragrafo 4.3.4.

4.4.1

Rappresentazione succinta mediante parentesi bilanciate

La corrispondenza tra alberi ordinali e parentesi bilanciate permette di utilizzare quest'ultime per una rappresentazione succinta ottima usando 2 n + o(n) bit (il limite inferiore basato sul numero di Catalan si applica anche a questa rappresentazione). Utilizzando una variante del Codice 4.16, i nodi dell'albero sono memorizzati in un array n o d o in ordine di visita anticipata e le correspondenti coppie di parentesi vengono codificate in un array binario p a r e n t e s i , dove i bit pari a 1 codificano le parentesi aperte e i bit pari a 0 codificano le parentesi chiuse. L'elemento nodo[i] è associato alla (i + l)-esima parentesi aperta. Nell'esempio della Figura 4.15, otteniamo la seguente configurazione degli array n o d o e p a r e n t e s i (in cui viene messo in evidenza l'accoppiamento delle parentesi): match

I n rr^i ni n |

• F b D B RB XY Z y PB, 1 1 1 0 1 l o o nodo

1 oo

parentesi

1 oo ,

X n 0

1 2

3 4

5 6 7

rn n

8 9 10 11 12 13

( ( ( ) (J ( ) ) ( ) ) 1 ( 1 ) 1 )

• FBDB - RbXy Figura 4.15

Zy

PB

Corrispondenza biunivoca tra un albero binario di n nodi, un albero ordinale di n + 1 nodi e una sequenza di 2n parentesi bilanciate (ignorando la coppia per la radice fittizia, rappresentata con un pallino).

Oltre alle funzioni Rank e S e l e c t necessarie a passare dagli elementi dell'array nodo ai corrispondenti bit in p a r e n t e s i e viceversa, utilizziamo la seguente funzione: • Match(b,i) = intero j tale che i e ) sono le posizioni di una coppia di parentesi bilanciate, per 0 ^ i, j ^ 2 n — 1. Se p a r e n t e s i [ l ] = 1 e p a r e n t e s i [ r ] = 0 codificano la coppia di parentesi bilanciate che rappresenta nodo [il, allora 1 = M a t c h ( p a r e n t e s i , r) e r = M a t c h ( p a r e n t e s i , l); inoltre, i = R a n k ( p a r e n t e s i , l) e l = S e l e c t ( p a r e n t e s i , i ) . L'implementazione di Match segue la falsariga di quella di Rank e S e l e c t descritta nel Paragrafo 4.3.3, utilizzando però tecniche più sofisticate. Lo spazio totale utilizzato da p a r e n t e s i e dalle implementazioni di Rank, S e l e c t e Match è pari a 2 n + o(n) bit. Tale rappresentazione succinta è quindi adatta per la struttura dei documenti XML, che possono essere propriamente modellati come alberi ordinali con nodi etichettati e che costituivano la motivazione iniziale per la progettazione di rappresentazioni succinte. Per navigare all'interno della rappresentazione succinta, il Codice 4.17 illustra come simulare i riferimenti per u = nodo[i]. Il riferimento u . p r i m o è simulato prendendo la parentesi in posizione 1 + 1 , successiva a quella aperta corrispondente a nodo[i] in posizione 1 (riga 2). Se tale parentesi è chiusa (riga 3), vuol dire che il riferimento è n u l i ; altrimenti, la parentesi aperta in posizione 1 + 1 corrisponde a n o d o [ i + 1], ovvero il primo figlio di nodo[i] (riga 6). Il riferimento u . f r a t e l l o è simulato calcolando la posizione r che contiene la parentesi chiusa della coppia corrispondente a nodo[i]

IndicePrimoFiglio( i ): 1 = Select( parentesi, i ); IF (parentesi[1 + 1] == 0) { RETURN nuli; } ELSE { RETURN I + 1;

> IndiceFratelloSuccessivo( i ): 1 = Select( parentesi, i ); r = Match( parentesi, 1 ); IF (parentesi[r + 1] == 0) { RETURN nuli; > ELSE {

RETURN Rank( parentesi, r + 1 ) - 1;

> Codice 4.17

Simulazione dei riferimenti primo e fratello per nodo[i]. Tali riferimenti, se diversi da nuli, restituiscono la posizione del corrispondente elemento nell'array nodo.

(riga 3): se la parentesi in posizione r + 1 è chiusa, vuol dire che il riferimento è n u l i ; altrimenti, essa corrisponde al fratello di nodo[i] e il numero di parentesi aperte che la precedono indica la sua posizione nell'array n o d o (riga 7). ALVIE: rappresentazione succinta con parentesi bilanciate

Osserva, sperimenta e verifica BracketRepresentation

La corrispondenza biunivoca, illustrata nella Figura 4.15, ci permette di rappresentare anche gli alberi binari, in alternativa alla rappresentazione succinta per ampiezza descritta nel Paragrafo 4.3.2. Notiamo che non possiamo rappresentare direttamente un albero binario mediante parentesi perché quest'ultime non ci permettono di distinguere se un unico figlio sia sinistro o destro (come accade nella Figura 4.13). Conviene invece utilizzare la corrispondenza con gli alberi ordinali, secondo quanto illustrato nella Figura 4.15. Notiamo che la rappresentazione succinta mediante parentesi bilanciate permette di fornire, in tempo costante, più funzionalità rispetto a quella per ampiezza, come ad esempio il calcolo della dimensione di un sottoalbero o la risposta alle query

per il problema LCA discusso nel Paragrafo 4.2. Queste funzionalità sono rese operative utilizzando sempre 2 n + o(n) bit in totale. RIEPILOGO In questo capitolo abbiamo descritto come effettuare computazioni e visite ricorsive negli alberi. Abbiamo poi mostrato una soluzione efficiente al problema del minimo antenato comune. Infine, abbiamo considerato le classi degli alberi binari, cardinali e ordinali, mostrando come rappresentarli in modo efficiente dal punto di vista dello spazio. ESERCIZI 1. Dimostrate per induzione che un albero binario completamente bilanciato di altezza h. ha 2 H — 1 nodi interni e 2 h foglie. 2. Dato un vamente, l)-esimo algoritmi

array a di n elementi, progettate un algoritmo che costruisca ricorsie in tempo 0 ( n ) , un albero binario bilanciato tale che a[i] sia l'(i + campo u . d a t o in ordine di visita anticipata. Considerate anche gli per le altre visite: simmetrica, posticipata e per ampiezza.

3. Dato un albero binario, costruite un array bidimensionale m tale che, per ogni coppia di nodi u e v, l'elemento m[u] [v] contenga il minimo antenato comune di u e v. La costruzione deve seguire lo schema ricorsivo delineato nel Codice 4.3 e deve richiedere tempo ottimo 0 ( n 2 ) (senza usare la query che richiede tempo costante, descritta nel Paragrafo 4.2.1). Osservate che durante la ricorsione nel nodo corrente u, potete individuare quali coppie di nodi hanno u come minimo antenato comune. 4. Ipotizzando che un nodo u appaia per la prima volta prima di un nodo v lungo il ciclo Euleriano di un albero binario, dimostrate che il loro antenato comune minimo è quello con profondità minima nel tratto di ciclo Euleriano che va da u a v (inclusi). 5. Dato un qualunque albero con n foglie, tale che ciascuno dei nodi interni ha due o più figli, provate per induzione che il numero di nodi interni è strettamente inferiore a n . 6. Progettate un algoritmo ricorsivo per i seguenti problemi: • stabilire se un albero binario è completo a sinistra • stabilire se un albero k-ario è completamente bilanciato • calcolare il numero di foglie discendenti dalla radice di un albero ordinale.

7. Dimostrate che l'altezza di un albero completo a sinistra di n nodi è pari a h. = O(logn), utilizzando la relazione tra l'altezza e la posizione (nell'array) dei nodi incontrati partendo dalla foglia più a sinistra e risalendo gli antenati fino alla radice. 8. Dimostrate per induzione la regola adottata nella rappresentazione implicita per associare le posizioni dell'array ai riferimenti al padre e ai due figli e descritta nel Paragrafo 4.3.1. 9. Analogamente al Codice 4.14, descrivete come simulare i riferimenti al padre e ai figli usando la rappresentazione degli alberi binari mediante parentesi bilanciate (come nella corrispondenza biunivoca nella Figura 4.15). 10. Utilizzando un array simile a quello mostrato nella Figura 4.12, mostrate come implementare l'algoritmo per il minimo antenato comune utilizzando soltanto O(n) celle di memoria invece che O ( n l o g n ) (osservate che le profondità dei nodi memorizzate differiscono di uno).

Capitolo 5

Dizionari

SOMMARIO In questo capitolo descriviamo la struttura di dati denominata dizionario e le operazioni da essa fornite. Mostriamo quindi come realizzare un dizionario utilizzando le liste doppie, le tabelle hash, gli alberi di ricerca, gli alberi AVL, i B-alberi e, infine, i trie o alberi digitali di ricerca, inclusi gli alberi dei suffissi e le liste invertite. DIFFICOLTÀ 2 CFU.

5.1

Dizionari

Un dizionario memorizza una collezione di elementi e ne fornisce le operazioni di ricerca, inserimento e cancellazione. I dizionari trovano impiego in moltissime applicazioni: insieme agli algoritmi di ordinamento, costituiscono le componenti fondamentali per la progettazione di algoritmi efficienti. Ai fini della discussione, ipotizziamo che ciascuno degli elementi e contenga una chiave di ricerca, indicata con e . c h i a v e , e che il resto delle informazioni in e siano considerate dei dati satellite, indicati con e . s a t : come al solito, indicheremo l'elemento vuoto con n u l i . Definito il dominio o universo U delle chiavi di ricerca contenute negli elementi, un dizionario memorizza un insieme S = { e o , e i , . . . , e n _ i ) di elementi, dove n è la dimensione di S, e fornisce le seguenti operazioni per una qualunque chiave k e U: • R i c e r c a ( S , k): restituisce l'elemento e se k = e . c h i a v e , oppure il valore n u l i se nessun elemento in S ha k come chiave; • I n s e r i s c i ( S , e ) : estende l'insieme degli elementi ponendo S = S U {e}, con l'ipotesi che e . c h i a v e sia una chiave distinta da quelle degli altri elementi in S (se e appartiene già a S, l'insieme non cambia);

• C a n c e l l a ( S , k): elimina dall'insieme l'elemento e tale che k = e . c h i a v e e pone S = S — {e} (se nessun elemento di S ha chiave k, l'insieme non cambia). Poiché in diverse applicazioni il campo satellite e . s a t degli elementi è sempre vuoto e il dizionario memorizza soltanto l'insieme delle chiavi di ricerca distinte, l'operazione di ricerca diventa semplicemente quella di appartenenza all'insieme: • A p p a r t i e n e ( S , k): restituisce t r u e se e solo se k appartiene all'insieme S, ovvero R i c e r c a ( S , k) / n u l i . Il dizionario è detto statico se fornisce soltanto l'operazione di ricerca ( R i c e r c a ) e viene detto dinamico se fornisce anche le operazioni di inserimento ( I n s e r i s c i ) e di cancellazione ( C a n c e l l a ) . Quando una relazione di ordine totale è definita sulle chiavi dell'universo U (ovvero, per ogni coppia di chiavi k e k' G U è sempre possibile verificare se vale k < k'), il dizionario è detto ordinato: con un piccolo abuso di notazione, estendiamo gli operatori di confronto tra chiavi di ricerca ai rispettivi elementi, per cui possiamo scrivere che gli elementi di S soddisfano la relazione eo < e\ < • < en_i (intendendo che le loro chiavi la soddisfano) e che, per esempio, vale k ^ et (intendendo k ^ e^.chiave). II dizionario ordinato per S fornisce le seguenti ulteriori operazioni: • S u c c e s s o r e ( S , k): restituisce l'elemento et tale che i è il minimo intero per cui k ^ e i e 0 ^ i < n , oppure il valore n u l i se k è maggiore di tutte le chiavi in S; • P r e d e c e s s o r e ( S , k): restituisce l'elemento ei tale che i è il massimo intero per cui e i ^ k e 0 ^ i < n , oppure n u l i se k è minore di tutte le chiavi in S; • I n t e r v a l l o f S , k, k'): restituisce tutti gli elementi e € S tali che k ^ e ^ k', dove supponiamo k ^ k', oppure n u l i se tali elementi non esistono in S; • Rango(S, k): restituisce l'intero r che rappresenta il numero di chiavi in S che sono minori oppure uguali a k, dove 0 < r < n . Da notare che le prime due operazioni restituiscono lo stesso valore di R i c e r c a ( S , k ) quando k e S, mentre la terza lo ottiene come caso speciale quando k = k'. Infine, la quarta operazione può simulare le prime tre se, dato un rango r, possiamo accedere all'elemento e r G S in modo efficiente.

5.2

Liste e dizionari

Le liste doppie (Paragrafo 3.1.2) sono un ottimo punto di partenza per l'implementazione efficiente dei dizionari. Nel seguito presumiamo che una lista doppia L abbia tre campi, ovvero due riferimenti L. i n i z i o e L . f i n e all'inizio e alla fine della lista e, inoltre, un intero L. l u n g h e z z a contenente il numero di nodi nella lista. Utilizziamo una funzione N u o v a L i s t a per inizializzare i primi due campi a n u l i e il terzo a 0. Ricordiamo che ogni nodo p della lista L è composto da tre campi p . p r e d , p . s u c c e p . d a t o , e faremo uso della funzione NuovoNodo per creare un nuovo nodo quando sia necessario farlo. Il campo p . d a t o contiene un elemento e G S: quindi possiamo

InserisciCima(lista, e): p = NuovoNodo( ); p.dato = e; lun = lista.lunghezza; IF (lun == 0) { p.succ = p.pred = nuli; lista.inizio = p; lista.fine = p; > ELSE {

InserisciFondo(lista, e): p = NuovoNodo( ); p.dato = e; lun = lista.lunghezza; IF (lun == 0) { p.succ = p.pred = nuli; lista.inizio = p; lista.fine = p; > ELSE {

p.succ = lista.inizio ; p.pred = nuli; lista.inizio.pred = p; lista.inizio = p;

>

p.succ = nuli; p.pred = lista.fine; lista.fine.succ = p; lista.fine = p;

>

lista.lunghezza = lun + 1;

lista.lunghezza = lun + 1;

RETURN lista;

RETURN lista;

Codice 5.1

Inserimento in cima e in fondo a

lista doppia, componente dì un dizionario.

indicare con p . d a t o . c h i a v e e p . d a t o . s a t i campi dell'elemento memorizzato nel nodo corrente. L'uso diretto delle liste doppie per implementare i dizionari non è consigliabile per insiemi di grandi dimensioni, in quanto le operazioni richiederebbero O(n) tempo: le liste sono però una componente fondamentale di molte strutture di dati efficienti per i dizionari, per cui riportiamo il codice delle funzioni definite su di esse che saranno utilizzate nel seguito. In particolare, nel Codice 5.1, il ruolo dell'operazione I n s e r i s c i del dizionario è svolto dalle due operazioni di inserimento in cima e in fondo alla lista, per poterne sfruttare le potenzialità nei dizionari che discuteremo più avanti. Per lo stesso motivo, nel Codice 5.2, l'operazione R i c e r c a restituisce il puntatore p al nodo contenente l'elemento trovato (basta prendere p . d a t o per soddisfare le specifiche dei dizionari date nel Paragrafo 5.1). Osserviamo che le operazioni suddette implementano la gestione delle liste doppie discussa nel Paragrafo 3.1.2. Quindi, la complessità delle operazioni di inserimento riportate nel Codice 5.1 è costante indipendentemente dalla lunghezza della lista, mentre le operazioni di ricerca e cancellazione riportate nel Codice 5.2 richiedono O(n) tempo dove n è la lunghezza della lista (anche se la cancellazione effettiva richiede 0 ( 1 ) avendo il riferimento al nodo, non utilizzeremo mai questa possibilità). Un esempio di dizionario dinamico e ordinato, che abbiamo già incontrato e che si basa sulle liste, è dato dalle liste randomizzate descritte nel Paragrafo 3.3, le cui operazioni richiedono un tempo atteso di O(logn). Nel seguito descriviamo altri dizionari

Ricercai lista, k ): p = lista.inizio; WHILE ((p != nuli) && (p.dato.chiave != k)) p = p.succ; RETURN P;

Cancellai lista, k ): p = Ricercai lista, k ); IF (p != nuli) { IF (lista.lunghezza == 1) { lista.inizio = lista.fine = nuli; > ELSE IF (p.pred == nuli) { p.succ.pred = nuli; lista.inizio = p.succ; )• ELSE IF (p.succ == nuli) { p.pred.succ = nuli; lista.fine = p.pred; > ELSE {

p.succ.pred = p.pred; p.pred.succ = p.succ;

> lista.lunghezza = lista.lunghezza - 1;

> RETURN lista; Codice 5.2

Ricerca e cancellazione in una lista doppia, componente di un dizionario.

di uso comune in applicazioni informatiche: notiamo che le operazioni che gestiscono tali dizionari possono essere estese per permettere la memorizzazione di chiavi multiple, ossia la gestione di un multi-insieme di chiavi.

5.3

Opus libri: funzioni hash e peer-to-peer

Il termine inglese hash indica un polpettone ottenuto tritando della carne insieme a della vetdura, dando luogo a un composto di volume ridotto i cui ingredienti iniziali sono mescolati e amalgamati. Tale descrizione ben illustra quanto succede nelle funzioni Hash : U > [0,m — 1], aventi l'universo li delle chiavi come dominio e l'intervallo di interi da 0 a m — 1 come codominio (di solito, m è molto piccolo se confrontato con la dimensione di U): una funzione Hash(k) = h. trita la chiave k € li restituendo come risultato un intero 0 ^

h. ^ m — 1. Notiamo che tale funzione non necessariamente preserva l'ordine delle chiavi appartenenti all'universo LI. Alcune funzioni hash sono semplici e utilizzano la codifica binaria delle chiavi (per cui, nel seguito, identifichiamo una chiave k con la sua codifica in binario): • Hash(k) = k % m calcola il modulo di k utilizzando un numero primo m; • Hash(k) = ko © k] © • • •ffik s _ i spezza la codifica binaria di k nei blocchi ko, k j , . . . , k s „ i di pari lunghezza, dove 0 < ki < m — l e l'operazione © indica l'OR esclusivo.1 La seconda funzione hash è chiamata iterativa in quanto divide la chiave k in blocchi di sequenze binarie ko, k[, . . . , k s i e lavora su tali blocchi, di fatto ripiegando la chiave su se stessa (i blocchi hanno la stessa lunghezza e alla chiave vengono aggiunti dei bit in fondo se necessario). Altre funzioni hash sono più sofisticate, per esempio quelle nate in ambito crittografico come M D 5 {Message-Digest Algorithm versione 5), inventata dal crittografo Ronald Rivest ideatore del metodo RSA, e SHA-1 (Secure Hash Algorithm versione 1), introdotta dalla National Security Agency del governo statunitense. Queste funzioni sono iterative e lavorano su blocchi di 512 bit applicandovi un'opportuna sequenza di operazioni logiche per manipolare i bit (per esempio, l'OR esclusivo o la rotazione dei bit) restituendo cosi un intero a 128 bit (MD5) O a 160 bit (SHA-1). Ad esempio, la valutazione di M D 5 ( a l g o r i t m o ) con la chiave " a l g o r i t m o " restituisce la sequenza esadecimale 2 446cead90f929el03816ff4eb92da6c2 mentre SHA-1 (algoritmo) restituisce 6f77f39f5ea82a55df8aaf4f094e2ff0e26d2adb La caratteristica di queste funzioni hash è che, cambiando anche leggermente la chiave, l'intero risultante è completamente diverso. Ad esempio, M D 5 ( a l g o r i t m i ) restituisce 6a8af95d7f185bla223c5b20cc71eb4a mentre SHA-1 (algoritmi) restituisce 147d401a6ale3c20e7d6796bcac50a993726d4fa Volendo ottenere un valore hash nell'intervallo [0,m — 1] (dove m è molto minore di 2 1 2 8 ), possiamo utilizzare tali funzioni nel modo seguente. •

Hash(k) = MD5(k) % m

• Hash(k) = SHA-1 ( k ) % m Osserviamo che, essendo la dimensione dell'universo U molto vasta, esistono sempre due chiavi ko e k] in U tali che ko ^ k j e Hash(ko) = Hash(k] ): una tale situazione è 'L'OR esclusivo tra due bit vale 1 se e solo se i due bit sono diversi (0 e 1, oppure 1 e 0): la notazione Q © b = c indica l'OR esclusivo bitwise, in cui il bit i-esimo di c è l'OR esclusivo del bit i-esimo di a e b. 2 La codifica esadecimale usa sedici cifre 0, 1, ..., 9, a, b f per codificare 24 possibili configurazioni di 4 bit.

chiamata collisione. Tuttavia, la natura deterministica del calcolo delle suddette funzioni hash, fa si che se Hash(ko) ^ Hash(ki) allora ko ^ k ( . Questa proprietà tipica delle funzioni in generale, coniugata con la robustezza di M D 5 e SHA-1 in ambito crittografico (soprattutto la versione più recente SHA-2 che restituisce un intero a 512 bit), trova applicazione anche nei sistemi distribuiti di condivisione dei file (peer-to-peer). In tali sistemi distribuiti (come BitTorrent, FreeNet, Gnutella, E-Mule, Napster e così via), l'informazione è condivisa e distribuita tra tutti i client o peer piuttosto che concentrata in pochi server, con un enorme vantaggio in termini di banda passante e tolleranza ai guasti della rete: la partecipazione è su base volontaria e, al momento di scaricare un determinato file, i suoi blocchi sono recuperati da vari punti della rete. Un numero sempre crescente di servizi operanti in ambiente distribuito usufruisce dei protocolli creati per tali sistemi (ad esempio, la telefonia via Internet). In tale scenario, lo stesso file può apparire con nomi diversi o file diversi possono apparire con lo stesso nome. Essendo la dimensione di ciascun file dell'ordine di svariati megabyte, quando i peer devono verificare quali file hanno in comune, è impensabile che questi si scambino direttamente il contenuto dei file: in tal modo, tutti i peer riceverebbero molti file da diversi altri peer e questo è impraticabile per la grande quantità di dati coinvolti. Prendiamo per esempio il caso di due peer P e P' che vogliano capire quali file hanno in comune. Non potendo affidarsi ai nomi dei file devono verificarne il contenuto e l'uso delle funzioni hash in tale contesto è formidabile: per ogni file f memorizzato, P calcola il valore SHA-1 (f ), chiamato impronta digitale {digitaifingerprint), spedendolo a P' (solo 160 bit) al posto di f (svariati megabyte). Da parte sua, P' riceve tali impronte digitali da P e calcola quelle dei propri file. Dal confronto delle impronte può dedurre con certezza quali file sono diversi e, con altissima probabilità, quali file sono uguali. Un altro uso dell'hash in tale scenario è quando P decide di scaricare un file f. Tale file è distribuito nella rete e, a tale scopo, è stato diviso in blocchi f o , f i , . . . , f s - i : oltre all'impronta h. = SHA-l(f) dell'intero file, sono disponibili anche le impronte hi = SHA-1 (fi) dei singoli blocchi (per 0 ^ i ^ s — 1). A questo punto, dopo aver recuperato le sole impronte digitali h, ho, h i , . . . , h s ^ i attraverso un'opportuna interrogazione, P lancia le richieste agli altri peer diffondendo tali impronte. Appena ha terminato di ricevere i rispettivi blocchi fi in modo distribuito, P ricostruisce f da tali blocchi e verifica che le impronte digitali corrispondano: la probabilità di commettere un errore con tali funzioni hash è estremamente basso. Viste le loro proprietà, è naturale utilizzare le funzioni hash per realizzare un dizionario che memorizzi un insieme S = {eo, e i , . . . , e n _ | } C U di elementi. I dizionari basati sull'hash sono noti come tabelle hash (hash map) e sono utili per implementare una struttura di dati chiamata array associativo, i cui elementi sono indirizzati utilizzando le chiavi in S piuttosto che gli indici in [0, n — 1].

La situazione ideale si presenta quando, fissando m = O(n), la funzione Hash. è perfetta su S, ovvero nessuna coppia di chiavi in S genera una collisione: in tal caso, il dizionario è realizzato mediante un semplice array binario t a b e l l a di m bit inizialmente uguali a 0, in cui ne vengono posti n pari a 1 con la regola che t a b e l l a [ h ] = 1 se e solo se h. = Hash(ei. c h i a v e ) per ciascun elemento e^ dove 0 ^ i < n — 1. Essendo una funzione perfetta, Hash non necessita di ulteriori controlli: tuttavia, inserendo o cancellando elementi in S, può accadere che Hash facilmente perda la proprietà di essere perfetta su S. Da notare che esistono dizionari dinamici basati su famiglie di hash che richiedono 0(1) tempo al caso pessimo per la ricerca e O ( 1 ) tempo medio ammortizzato per l'inserimento e la cancellazione. Il limite di 0 ( 1) tempo per la ricerca non è in contrasto con quello di O(logn) confronti per la ricerca (Paragrafo 2.4.2): infatti, nel primo caso i bit della chiave k vengono manipolati da una o più funzioni hash per ottenere un indice dell'array t a b e l l a mentre nel secondo caso k viene soltanto confrontata dalla ricerca binaria con le altre chiavi. In altre parole, il limite di n ( l o g n ) confronti vale supponendo che l'unica operazione permessa sulle chiavi sia il loro confronto diretto con altre (oltre alla loro memorizzazione), mentre tale limite non vale se i bit delle chiavi possono essere usati per calcolare una funzione diversa dal confronto di chiavi. La costruzione dei suddetti dizionari dinamici basati sull'hash perfetto è piuttosto macchinosa e le loro prestazioni in pratica non sono sempre migliori dei dizionari che fanno uso di funzioni hash non necessariamente perfette. Questi ultimi, pur richiedendo per la ricerca 0(1) tempo in media invece che al caso pessimo, sono ampiamente diffusi per la loro efficienza in pratica e per la semplicità della loro gestione, che si riduce a risolvere le collisioni prodotte dalle chiavi. Nel seguito descriveremo due semplici modi per fare ciò: mediante liste di trabocco (che oltretutto garantiscono 0(1) tempo al caso pessimo per inserire una chiave non presente in S) oppure con l'indirizzamento aperto.

5.3.1

Tabelle hash: liste di trabocco

Nelle tabelle hash con liste di trabocco (chaining), t a b e l l a è un array di m liste doppie, gestite secondo quanto descritto nel Paragrafo 5.2, e t a b e l l a [ h ] contiene le chiavi e dell'insieme S tali che h. = H a s h ( e . c h i a v e ) : in altre parole, le chiavi che collidono fornendo lo stesso valore h. di Hash sono memorizzate nella medesima lista, etichettata con h. (ovviamente tale lista è vuota se nessuna chiave dà luogo a un valore hash h). L'operazione di ricerca scandisce la lista associata al valore hash della chiave, mentre l'operazione d'inserimento, dopo aver verificato che la chiave dell'elemento non appare nella lista, inserisce un nuovo nodo con tale elemento in fondo alla lista. La cancellazione verifica che la chiave sia presente e rimuove il corrispondente elemento, e quindi il nodo, dalla lista doppia corrispondente.

Ricerca( tabella, k ) : h = Hash(k); p = tabella[h].Ricerca( k ); IF (p != nuli) RETURN p.dato ELSE RETURN nuli;

' Inserisci( tabella, e ): IF (Ricercai tabella, e.chiave ) == nuli) { h = Hash( e.chiave ); tabella[h].InserisciFondo( e );

> Cancellai tabella, k ): IF (Ricercai tabella, k ) != nuli) { h = Hash(k); tabella[h].Cancella( k );

> Codice 5.3

Dizionario realizzato mediante tabelle hash con liste di trabocco.

Pur avendo un caso pessimo di 0 ( n ) tempo (tutte le chiavi danno luogo allo stesso valore hash), ciascuna operazione è molto veloce in pratica se la funzione hash scelta è buona: in effetti, il costo medio è 0 ( 1 ) tempo con l'ipotesi che la funzione Hash distribuisca in modo uniformemente casuale gli n elementi di S nelle m liste di trabocco. In questo modo, la lunghezza media di una qualunque delle liste è 0 ( ^ ), dove ^ = a è chiamato fattore di caricamento: quindi, le operazioni sulla tabella richiedono in media un tempo costante 0 ( 1 + a) perché il loro costo è proporzionale alla lunghezza della lista acceduta. Mantenendo l'invariante che m è circa il doppio di n (Paragrafo 2.1.3), abbiamo che a = 0 ( 1 ) e il costo delle operazioni diventa costante. ALVIE: gestione di tabelle hash con liste di trabocco

Jh^j^

5.3.2

Osserva, sperimenta e verifica ChainingHash

Tabelle hash: indirizzamento aperto

Nelle tabelle hash a indirizzamento aperto (open addressing), t a b e l l a è un array di m celle in cui porre gli n elementi, dove m > n (quindi a < 1), in cui usiamo n u l i

Ricerca( tabella, k ): FOR (i = 0; i < M; i = i+1) { h = Hash[i](k); IF (tabella[H] == null) RETURN -1;

IF (tabella[h].chiave == k) RETURN tabella[h];

> Inserisci ( tabella, e ): {pre: tabella contiene ri < m chiavi) IF (Ricercai tabella, e.chiave ) == nuli) { i = -1; DO {

i = i+1; h = Hash[i]( e.chiave ); IF (tabella[h] == nuli) tabella[h] = e; > WHILE (tabella[h]

Codice 5.4

!= e);

Dizionario realizzato mediante tabelle hash con indirizzamento aperto.

per segnalare che la posizione corrente è vuota. Poiché le chiavi sono tutte collocate direttamente nell'array, usiamo una sequenza di funzioni Hash[i] per 0 ^ i < m — 1, chiamata sequenza di scansione {probing), tali che i valori Hash[0](k), Hash[l](k), . . . , Hash[m — l](k) formano sempre una permutazione delle posizioni 0 , 1 , . . . , m — 1, per ogni chiave k € U. Tale permutazione rappresenta l'ordine con cui esaminiamo le posizioni di t a b e l l a durante le operazioni del dizionario. Per comprendere l'organizzazione delle chiavi nella tabella hash, descriviamo prima l'operazione di inserimento di un elemento. Il Codice 5.4 mostra tale operazione, in cui iniziamo a esaminare le posizioni Hash[0](k), Hash[l](k), . . . , Hash[m — l](k) fino a trovare la prima posizione Hash[i](k) libera (poiché n < m, siamo sicuri che i < m): tale procedimento è analogo a quando cerchiamo posto in treno, in cui andiamo avanti fino a trovare un posto libero (ovviamente esaminiamo i posti in ordine lineare piuttosto che permutato). La ricerca di una chiave k segue questa falsariga: esaminiamo le suddette posizioni fino a trovare l'elemento con chiave k e, nel caso giungiamo in una posizione libera, siamo certi che la chiave non compare nella t a b e l l a (altrimenti l'inserimento avrebbe posto in tale posizione libera un elemento con chiave k). La cancellazione di una chiave è solitamente realizzata in modo virtuale, sostituendo l'elemento con una marca speciale, che indica che la posizione è libera durante l'operazione d'inserimento di successive chiavi e che la posizione è occupata ma con una chiave diversa da quella cercata durante le successive operazioni di ricerca: quest'ultima condizione è necessaria poiché una cancellazione che svuotasse la posizione della chiave rimossa, potrebbe ingannare

una successiva ricerca (non necessariamente della stessa chiave) inducendola a fermarsi erroneamente in quella posizione e dichiarare che la chiave cercata non è nel dizionario. Se prevediamo di effettuare molte cancellazioni, è quindi più conveniente usare una tabella con liste di trabocco perché si adatta meglio a realizzare dizionari molto dinamici. Per la complessità temporale, osserviamo che il caso pessimo rimane O(n) tempo e che ciascuna operazione è molto veloce in pratica se la funzione hash scelta è buona: in effetti, il costo medio è 0 ( 1 ) tempo con l'ipotesi che, per ogni chiave k, le posizioni in Hash[0](k), Hash[l](k), . . . , Hash[m — l](k) formino una delle m! permutazioni possibili in modo uniformemente casuale. Poiché il costo è direttamente proporzionale al valore di i tale che Hash[i](k) è la posizione individuata per la chiave, indichiamo con T(n, m) il valore medio di i: in altre parole, T(n, m) indica il numero medio di posizioni che troviamo occupate quando inseriamo un'ulteriore chiave in una tabella di m posizioni, contenente n elementi, dove n < m. Utilizziamo un'equazione di ricorrenza per definire T ( n , m ) , dove T(0,m) = 1 in quanto ogni posizione esaminata è sempre libera in una tabella vuota. Per n > 0, osserviamo che, essendo occupate n posizioni su m della tabella, la posizione esaminata risulta occupata n volte su m (poiché tutte le permutazioni di posizioni sono equiprobabili): in tal caso, effettuiamo un solo accesso e ci fermiano su una posizione libera con probabilità -n3^I1> mentre, con probabilità troviamo la posizione occupata per cui effettuiamo ulteriori T(n — l , m — 1) accessi alle rimanenti posizioni (oltre all'accesso alla posizione occupata). Facendo la media pesata di tali costi (abbiamo già incontrato un'analisi al caso medio di questo tipo nei Paragrafi 2.5.4 e 3.3), otteniamo x l + ^ x (1 + T ( n - l , m - 1)), dando luogo alla seguente equazione di ricorrenza:

T(n,m) = |

| ^ i j ( n - l , m — 1)

linimenti

(5>1)

Proviamo per induzione che la soluzione della (5.1) soddisfa la relazione T(n, m) ^ ™ • Il caso base per n = 0 e m > O è immediato, mentre, per n > 0 e m > n, abbiamo che T ( n , m = 1 -I

m

T u - l,m- 1 < 1H

x m m

= — n m - n

Ne consegue che T(n, m) ^ = (1 — a ) - 1 = 0 ( 1 ) mantenendo l'invariante che m sia circa il doppio di n (Paragrafo 2.1.3). Da notare che i tempi medi calcolati per le tabelle hash con liste di trabocco e a indirizzamento aperto non sono direttamente confrontabili in quanto l'ipotesi di uniformità della funzione hash nel secondo caso è più forte di quella adottata nel primo caso. Nella pratica, non possiamo generare una permutazione veramente casuale delle posizioni scandite con Hash[i] per 0 ^ i < m — 1. Per questo motivo, adottiamo alcune

semplificazioni usando una o due funzioni Hash(k) e H a s h ' ( k ) (come quelle descritte nel Paragrafo 5.3), impiegandole come base di partenza per le scansioni della tabella, la cui dimensione m è un numero primo, in uno dei modi seguenti: • Hash[i](k) = (Hash(k) + i) % m (scansione lineare); • Hash[i](k) = (Hash(k) + ai2 + bi + c) % m (scansione quadratica); • Hash[i](k) = (Hash(k) + i x (1 + Hash'(k))) % m (scansione basata su hash doppio). Nella scansione lineare, dopo aver calcolato il valore h = Hash(k), esaminiamo le posizioni H, h.+1, h + 2 e così via in modo circolare. Tale scansione crea delle aggregazioni (,cluster) di elementi, che occupano un segmento di posizioni contigue in t a b e l l a . Tali elementi sono stati originati da valori hash h. differenti, ma condividono lo stesso cluster: la ricerca che ricade all'interno di tale cluster deve percorrerlo tutto nel caso non trovi la chiave. Un più attento esame delle chiavi contenute nel cluster mostra che quelle che andrebbero a finire in una stessa lista di trabocco (Paragrafo 5.3.1) sono tutte presenti nello stesso cluster (e tale proprietà può valere per più liste, le cui chiavi possono condividere lo stesso cluster). Pertanto, quando tali cluster sono di dimensione rilevante (seppure costante), conviene adottare le tabelle hash con liste di trabocco che hanno prestazioni migliori, quando sono associate a una buona funzione hash. La scansione quadratica non migliora molto la situazione, in quanto i cluster appaiono in altra forma, seguendo l'ordine specificato da Hash[i](k). La situazione cambia usando il doppio hash, in quanto l'incremento della posizione esaminata in t a b e l l a dipende da una seconda funzione H a s h ' : se due chiavi hanno una collisione sulla prima funzione hash, c'è ancora un'ulteriore possibilità di evitare collisioni con la seconda. ALVIE:

gestione di tabelle hash con indirizzamento aperto

Osserva, sperimenta e verifica OpenHash

5.4

Opus libri: kernel Linux e alberi binari di ricerca

Nel sistema operativo GNU/Linux, la gestione dei processi generati dai vari programmi in esecuzione è prevista nel suo nucleo {kernel). In particolare, ogni processo ha a disposizione uno spazio virtuale degli indirizzi di memoria, detta memoria virtuale, in cui le celle sono numerate a partire da 0. La memoria virtuale fa sì che il calcolatore (utilizzando la memoria secondaria) sembri avere più memoria principale di quella fisicamente

presente, condividendo quest'ultima tra tutti i processi che competono per il suo uso. La memoria virtuale di un processo è suddivisa in aree di memoria (VMA) di dimensione limitata, ma solo un sottoinsieme di tutte le VMA associate a un processo risultano presenti fisicamente nella memoria principale. Quando un processo accede a un indirizzo di memoria virtuale, il kernel deve verificare che la VMA contenente quell'indirizzo sia presente in memoria principale: se è così, usa le informazioni associate alla VMA per effettuare la traduzione dell'indirizzo virtuale nel corrispondente indirizzo fisico. In caso contrario, si verifica un page fault che costringe il kernel a caricare in memoria principale la VMA richiesta, eventualmente scaricando in memoria secondaria un'altra VMA (scelta, ad esempio, applicando la politica LRU discussa nel Paragrafo 3.4.2). La ricerca della VMA deve essere eseguita in modo efficiente: a tale scopo, il kernel usa una strategia mista (tipica di Linux e applicata anche in altri contesti del sistema operativo), che è basata sull'uso di dizionari. Fintanto che il numero di VMA presenti in memoria è limitato (circa una decina), le VMA assegnate a un processo sono mantenute in una lista e la ricerca di una specifica VMA viene eseguita attraverso di essa. Quando il numero di VMA supera un limite prefissato, la lista viene affiancata da una struttura di dati più efficiente dal punto di vista della ricerca: tale struttura di dati è chiamata albero binario di ricerca (fino alla versione 2.2 del kernel, venivano usati gli alberi AVL, mentre nelle versioni successive questi sono stati sostituiti dagli alberi rosso-neri). In effetti, gli alberi binari di ricerca costituiscono uno degli strumenti fondamentali per il recupero efficiente di informazioni e sono pertanto applicati in moltissimi altri contesti, oltre a quello appena discusso.

5.4.1

Alberi binari di ricerca

Un albero binario viene generalmente rappresentato nella memoria del calcolatore facendo uso di tre campi, come mostrato nel Capitolo 4: dato un nodo u, indichiamo con u . s x il riferimento al figlio sinistro, con u . dx il riferimento al figlio destro e con u . d a t o il contenuto del nodo, ovvero un elemento e e S nel caso di dizionari (precisamente, faremo riferimento a u . d a t o . c h i a v e e u . d a t o . s a t per indicare la chiave e i dati satellite di tale elemento). Volendo impiegare gli alberi per realizzare un dizionario ordinato per un insieme S = (eo, e j , . . . , e n _ i } di elementi, memorizziamo gli elementi nei loro nodi in modo da soddisfare la seguente proprietà di ricerca per ogni nodo u: • tutti gli elementi nel sottoalbero sinistro di u (riferito da u . sx) sono minori dell'elemento u . d a t o contenuto in u; • tutti gli elementi nel sottoalbero destro di u (riferito da u . dx) sono maggiori di u.dato. Un albero binario di ricerca è un albero binario che soddisfa la suddetta proprietà di ricerca: una conseguenza della proprietà è che una visita simmetrica dell'albero fornisce la sequenza ordinata degli elementi in S, in tempo O(n).

Ricercai u, k ): IF (u == null) RETURN nuli;

IF (k == u.dato.chiave) { RETURN u.dato; > ELSE IF (k < u.dato.chiave) { RETURN Ricercai u.sx, k ); > ELSE { RETURN Ricercai u.dx, k );

> Inserisci( u, e ) : IF iu == nuli) { u = NuovoNodoi); u.dato = e; u.sx = u.dx = nuli ; } ELSE IF ie.chiave < u.dato.chiave) {

u.sx = Inserisci( u.sx, e ); > ELSE IF (e.chiave > u.dato.chiave) {

u.dx = Inserisci( u.dx, e );

> RETURN U; C o d i c e 5.5

{post: se k appare già in u, non viene memorizzata)

Algoritmi ricorsivi per la ricerca dell'elemento con chiave k e l'inserimento di un elemento e in un albero di ricerca con radice u.

La ricerca di una chiave k in tale albero segue la falsariga della versione ricorsiva della ricerca binaria (Paragrafo 2.5). Il Codice 5.5 ricalca tale schema ricorsivo: partiamo dalla radice dell'albero e confrontiamo il suo contenuto con k e, nel caso sia diverso, se k è minore proseguiamo la ricerca a sinistra, altrimenti la proseguiamo a destra. Per l'inserimento osserviamo che, quando raggiungiamo un riferimento vuoto, lo sostituiamo con un riferimento a un nuovo nodo (righe 3—5), che restituiamo per farlo propagare verso l'alto (riga 11) attraverso le chiamate ricorsive (abbiamo discusso tali schemi ricorsivi nel Paragrafo 4.1.1): tale propagazione avviene notando che le chiamate ricorsive alle righe 7 e 9 sovrascrivono il campo relativo al riferimento (figlio sinistro o destro) su cui sono state invocate. Infatti se k è minore della chiave nel nodo corrente, l'inseriamo ricorsivamente nel figlio sinistro; se k è maggiore, l'inseriamo ricorsivamente nel figlio destro; altrimenti, abbiamo un duplicato e non l'inseriamo affatto. Il costo della ricerca e dell'inserimento è pari all'altezza h dell'albero, ovvero 0(h.) tempo. La cancellazione dell'elemento con chiave k presenta più casi da esaminare, in quanto essa può disconnettere l'albero che va, in tal caso, opportunamente riconnesso per mantenere la proprietà di ricerca, come descritto nel Codice 5.6, il cui schema ricorsivo

Cancellai u, k ): IF (u != nuli) { IF (u.dato.chiave == k) { IF (u.sx == nuli) { u = u.dx; } ELSE IF (u.dx == nuli) {

u = u.sx; > ELSE {

w = MinimoSottoAlbero( u.dx ); u.dato = w.dato; u.dx = Cancella( u.dx, w.dato.chiave );

> > ELSE IF (k < u.dato.chiave) {

u.sx = Cancellai u.sx, k ); > ELSE IF (k > u.dato.chiave) {

u.dx = Cancellai u.dx, k );

> > RETURN u;

MinimoSottoAlbero( u ):

{pre: u / nuli)

WHILE (u.sx != nuli)

u = u.sx; RETURN u; Codice 5.6

Algoritmo ricorsivo per la cancellazione dell'elemento con chiave k da un albero di ricerca con radice u.

è simile a quello dell'inserimento. I casi più semplici sono quando il nodo u è una foglia oppure ha un solo figlio (righe 5 e 7): eliminiamo la foglia mettendo a n u l i il riferimento a essa oppure, se il nodo ha un solo figlio, creiamo un "ponte" tra il padre di u e il figlio di liQuando u. ha due figli non possiamo cancellarlo fisicamente (righe 9-11), ma dobbiamo individuare il nodo w che contiene il successore di k nell'albero (riga 9), che risulta essere il minimo del sottoalbero destro ( u . d x non è n u l i ) . Sostituiamo quindi l'elemento in u. con quello in w per mantenere la proprietà di ricerca dell'albero (riga 10) e, con una chiamata ricorsiva, cancelliamo fisicamente w in quanto contiene la chiave copiata in u. (riga 11). E importante osservare che quest'ultima s'imbatterà in un caso semplice con la cancellazione di w, in quanto w non può avere il figlio sinistro (altrimenti non conterrebbe il minimo del sottoalbero destro). C o m e osservato in precedenza, propaghiamo l'effetto

della cancellazione verso l'alto (riga 19) attraverso le chiamate ricorsive. Per la complessità temporale, osserviamo che il codice percorre il cammino dalla radice al nodo u in cui si trova l'elemento con chiave k e poi percorre due volte il cammino da u al suo discendente w. In totale, il costo rimane 0(h.) tempo anche in questo caso. Purtroppo h = Q ( n ) al caso pessimo e un albero non sembra essere vantaggioso rispetto a una lista ordinata. Tuttavia, con elementi inseriti in maniera casuale, l'altezza media è O(logn) e i metodi discussi finora hanno una complessità O(logn) al caso medio. Vediamo come ottenere degli alberi binari di ricerca bilanciati, che hanno sempre altezza H = O(logn) dopo qualunque inserimento o cancellazione. Questo fa sì che la complessità delle operazioni diventi O(logn) anche al caso pessimo. A L V I E:

ricerca, inserimento e cancellazione in alberi di ricerca

^ R y ' fi W jSSB^ Osserva, sperimenta e verifica BinarySearchTree

5.4.2

©

©

©

©

© ©© © % ©

AVL: alberi binari di ricerca bilanciati

L'albero AVL (acronimo derivato dalle iniziali degli autori russi Adel'son-Velsky e Landis che lo inventarono negli anni '60) è un albero binario di ricerca che garantisce avere un'altezza h. = O(logn) per n elementi memorizzati nei suoi nodi. Oltre alla proprietà di ricerca menzionata nel Paragrafo 5.4.1, l'albero AVL soddisfa la proprietà di essere 1-bilanciato al fine di garantire l'altezza logaritmica. Dato un nodo u, indichiamo con h(u) la sua altezza, che indentifichiamo con l'altezza del sottoalbero radicato in u, dove h . ( n u l l ) = — 1 (Paragrafo 4.1.1). Un nodo u è 1-bilanciato se le altezze dei suoi due figli differiscono per al più di un'unità |h.(u.sx) - h(u.dx)| s? 1

(5.2)

Un albero binario è 1-bilanciato se ogni suo nodo è 1-bilanciato. La connessione tra l'essere 1 -bilanciato e avere altezza logaritmica non è immediata e passa attraverso gli alberi di Fibonacci, che sono un sottoinsieme degli alberi 1-bilanciati con il minor numero di nodi a parità di altezza. In altre parole, indicato con Fibh un albero di Fibonacci di altezza h e con n ^ il suo numero di nodi, eliminando un solo nodo da Fib^ otteniamo che l'altezza diminuisce o che l'albero risultante non è più 1-bilanciato: nessun albero 1-bilanciato con n nodi e altezza h può dunque avere meno di rih nodi, ossia n ^ n.h. Mostrando che n ^ ^ c h per un'opportuna costante c > 1, deriviamo che n ^ c H e, quindi, che h. = O(logn).

Fibo

Fib\

7

' Figura 5.1

Fibì

Fib-i

FibH

A

Alberi di Fibonacci.

Gli alberi di Fibonacci FibH di altezza h. sono definiti ricorsivamente (Figura 5.1): per h. = 0, abbiamo un solo nodo, per cui no = 1, e, per h. = 1, abbiamo un albero con n i = 2 nodi (la radice e un solo figlio, che nella figura è quello sinistro). Per h. > 1, l'albero Fibh è costruito prendendo un albero Fib^-i e un albero /7'£H-2> le c u i radici diventano i figli di una nuova radice (quella di Fibh). In tal caso, abbiamo che rih = rih-1 + tvh-2 + 1) relazione ricorsiva che ricorda quella dei numeri di Fibonacci (Paragrafo 2.6.2) motivando così il nome di tali alberi. Possiamo osservare nella Figura 5.1 che Fibo e Fib\ sono alberi 1-bilanciati di altezza 0 e 1, rispettivamente, con il minimo numero di nodi possibile. Ipotizzando che, per induzione, tale proprietà valga per ogni i < h. con h. > 1, mostriamo come ciò sia vero anche per FibH ragionando per assurdo. Supponiamo di poter rimuovere un nodo da Fibh mantenendo la sua altezza h. e garantendo che rimanga 1-bilanciato: non potendo rimuovere la radice, tale nodo deve appartenere a Fib ^ ^ FH > c

H

dovecJ> = ! ± ^ l , 2 e

6180339...

che, quindi, esiste una costante c > 1 tale che

per h. > 2. Pertanto, n n = F h + 3 — 1 ^ c h e, poiché n ^

possiamo

InserisciC u, e ) : IF (u == nuli) { RETURN f = NuovaFoglia( e ); } ELSE IF (e.chiave < u.dato.chiave) { u.sx = InserisciC u.sx, e ); IF (Altezza(u.sx) - Altezza(u.dx) == 2) { IF (e.chiave>u.sx.dato.chiave) u.sx=RuotaAntiOraria(u.sx); u = RuotaOrariaC u );

> } ELSE IF (e.chiave > u.dato.chiave) { u.dx = InserisciC u.dx, e ); IF (Altezza(u.dx) - AltezzaCu.sx) == 2) { IF (e.chiave < u.dx.dato.chiave) u.dx = RuotaOraria(u.dx); u = RuotaAntiOrariaC u );

> u.altezza = max( Altezza(u.sx), Altezza(u.dx) ) + 1; RETURN U;

AltezzaC u ): IF (u == nuli) { RETURN -1; > ELSE {

RETURN u.altezza;

NuovaFogliaC e ): u = NuovoNodoO; u.dato = e; u.altezza = 0; u.sx = u.dx = nuli; RETURN u;

Codice 5.7

Algoritmo per l'inserimento di un elemento e in un albero AVL con radice u.

concludere che n ^ c h : in altre parole, ogni albero 1-bilanciato di n nodi e altezza h. verifica h. = O(logn). Per implementare gli alberi AVL, introduciamo un ulteriore campo u . a l t e z z a nei suoi nodi u, tale che u . a l t e z z a = h(u). Notiamo che l'operazione di ricerca negli alberi AVL rimane identica a quella descritta nel Codice 5.5. Mostriamo quindi nel Codice 5.7 come estendere l'inserimento per garantire che l'albero AVL rimanga 1-bilanciato. Dopo la creazione della foglia f contenente l'elemento e (riga 3), aggiorniamo le altezze ricorsivamente nei campi u . a l t e z z a degli antenati u di f, essendo questi ultimi i soli nodi che possono cambiare altezza (riga 17). Allo stesso tempo, controlliamo se il nodo corrente è 1-bilanciato: definiamo nodo critico il minimo antenato di f che viola tale proprietà. A tal fine, percorriamo in ordine inverso le chiamate ricorsive sugli antenati di f (righe 5 e 11), fino a individuare il nodo critico u, se esiste: in tal caso, se f discende da

RuotaOraria( z ): v = z.sx; z.sx = v.dx; v.dx = z; z.altezza = max( Altezza(z.sx), Altezza(z.dx) v.altezza = max( Altezza(v.sx), Altezza(v.dx)

i; l;

RETURN V;

RuotaAntiOraria( v ): z = v.dx; v.dx = z.sx; z.sx = v; v.altezza = max( Altezza(v.sx), Altezza(v.dx) z.altezza = max( Altezza(z.sx), Altezza(z.dx)

1;

RETURN Z; Codice 5.8

Rotazioni oraria e antioraria.

u . sx, l'altezza del sottoalbero sinistro di u differisce di due rispetto a quella del destro (riga 6), mentre se f discende da u . dx, abbiamo che è l'altezza del sottoalbero destro di u a differire di due rispetto a quella del sinitro (riga 12). Comunque vada, aggiorniamo l'altezza del nodo prima di terminare la chiamata attuale (riga 17). Discutiamo ora come ristrutturare l'albero in corrispondenza del nodo critico u, utilizzando le rotazioni orarie e antiorarie per rendere nuovamente u un nodo 1-bilanciato (righe 7 - 8 e 13-14): tali rotazioni sono illustrate e descritte nel Codice 5.8. Notiamo che esse richiedono 0 ( 1 ) tempo e preservano la proprietà di ricerca: le chiavi in a sono minori di v . d a t o . c h i a v e ; quest'ultima è minore delle chiavi in (3, le quali sono minori di z . d a t o . c h i a v e ; infine, quest'ultima è minore delle chiavi in y. Utilizziamo le rotazioni per trattare i quattro casi che si possono presentare (individuati con un semplice codice mnemonico), in base alla posizione di f rispetto ai nipoti del nodo critico u (Figura 5.2): 1. caso SS: la foglia f appartiene al sottoalbero a radicato in u . s x . sx; 2. caso SD: la foglia f appartiene al sottoalbero (3 radicato in u . s x . dx;

Figura 5.2

I quattro casi possibili di sbilanciamento del nodo crìtico u a causa della creazione della foglia f (contenente la chiave k).

3. caso DS: la foglia f appartiene al sottoalbero y radicato in u . d x . sx; 4. caso D D : la foglia f appartiene al sottoalbero 6 radicato in u . d x . dx. In tutti e quattro i casi, lo sbilanciamento conduce l'albero AVL in una configurazione in cui due sottoalberi hanno un dislivello pari a due. Con riferimento all'inserimento descritto nel Codice 5.7, trattiamo il caso SS con una rotazione oraria effettuata sul nodo critico u, riportando l'altezza del sottoalbero a quella immediatamente prima che la foglia f venisse creata (riga 8). Se invece incontriamo il caso SD, prima effettuiamo una rotazione antioraria sul figlio sinistro di u (riga 7) e poi una oraria su u stesso (riga 8): anche in tal caso, l'altezza del sottoalbero torna a essere quella immediatamente prima che la foglia f venisse creata (Figura 5.3). I casi D D e DS sono speculari: effettuiamo una rotazione antioraria su u (riga 14) oppure una rotazione oraria sul figlio destro di u seguita da una oraria su u (righe 13 e 14). Siccome ogni caso richiede una o due rotazioni, il costo è 0(1) tempo per eseguire le rotazioni (al più due), a cui va aggiunto il tempo di inserimento O(logn).

ALVIE:

i n s e r i m e n t o in alberi A V L cEHD>

Osserva, sperimenta e verifica Avllnsert

cwiTì CjTTTp (TT7TT)

Ctna>CIIi&CE3)

dmr>

crnrn OUI!> OTTTT)t cTTTTTtcrnT»

Per la cancellazione, possiamo trattare dei casi simili a quelli dell'inserimento, solo che possono esserci più nodi critici tra gli antenati del nodo cancellato: preferiamo quindi marcare logicamente i nodi cancellati, che vengono ignorati ai fini della ricerca. Quando una frazione costante dei nodi sono marcati come cancellati, ricostruiamo l'albero con le sole chiavi valide ottenendo un costo ammortizzato di O(logn). Possiamo trasformare il costo ammortizzato in un costo al caso pessimo interlacciando la ricostruzione dell'albero, che produce una copia dell'albero attuale, con le operazioni normalmente eseguite su quest'ultimo. Nonostante siano stati concepiti diverso tempo fa, gli alberi AVL sono tuttora molto competitivi rispetto a strutture di dati più recenti: la maggior parte delle rotazioni avvengono negli ultimi due livelli di nodi, per cui gli alberi AVL risultano molto efficienti se implementati opportunamente (all'atto pratico, la loro forma è molto vicina a quella di un albero completo e quasi perfettamente bilanciato).

5.5

Opus libri: basi dati e B-alberi

Le basi di dati (database) sono al centro dei sistemi informativi di aziende, enti pubblici e privati, in quanto permettono di organizzare, in forma permanente, una grande quantità di dati strutturati in un formato predefinito (record) mettendoli in relazione tra di loro e rendendoli disponibili, in modo uniforme e controllato, ad applicazioni eterogenee (attraverso le transazioni concorrenti che mantengono l'integrità dei dati e la loro specifica in un opportuno linguaggio di interrogazione come, per esempio, SQL ovvero Structured Query Languagé). I sistemi per la gestione di basi di dati (DBMS o Data Base Management System) utilizzano diverse strutture di dati, chiamate indici, per garantire un accesso veloce ai dati richiesti dalle transazioni in corso. Possiamo modellare, semplificandolo, il problema di costruire un indice per un DBMS in termini di un insieme S = {eo, e¡,..., e n _ i } di n record, dove ciascun record è caratterizzato da una chiave di ricerca primaria che lo identifica univocamente (per esempio, il codice fiscale se il record è riferito alle persone). I record possono contenere ulteriori chiavi di ricerca, dette secondarie, che esprimono i loro attributi aggiuntivi, non necessariamente in modo univoco (per esempio, la nazionalità o la città di residenza). Possiamo realizzare un tale indice usando la struttura di

dati del dizionario: oltre a liste e tabelle hash, la struttura di dati principale per realizzare l'indice nei DBMS è il B-albero (B-tree). Prima di descrivere in dettaglio il B-albero, presentiamo lo scenario entro cui valutare il suo costo computazionale estendendo il modello RAM (Paragrafo 1.4). Nel seguito supponiamo che la RAM abbia accesso a due livelli di memoria: al primo livello, detto memoria principale, aggiungiamo un secondo livello, detto memoria secondaria o disco e diviso in blocchi di memoria, ciascun blocco contenente un numero di celle di memoria consecutive pari a d i m e n s i o n e B l o c c o . L'accesso alla memoria di secondo livello non è diretto, contrariamente al primo livello, ma avviene esclusivamente attraverso delle primitive che permettono di leggere o scrivere un blocco di memoria, consentendo il suo trasferimento da e verso la memoria principale. Tale blocco può essere quindi elaborato con le operazioni della RAM nella memoria principale: ai nostri fini, trattiamo un blocco b, una volta caricato nella memoria principale, come un normale array b di d i m e n s i o n e B l o c c o elementi. Nella memoria secondaria, i blocchi sono univocamente identificati da interi positivi distinti che consideriamo essere i loro indirizzi logici a tutti gli effetti. Il blocco b è quindi identificato dal suo indirizzo i > 0 quando decidiamo di trasferirlo tra i due livelli di memoria: notiamo che l'indirizzo logico 0 è riservato e rappresenta l'indirizzo vuoto (ovvero, nessun blocco corrisponderà mai a tale indirizzo). Le seguenti primitive gestiscono il trasferimento di blocchi, dove b è un array di d i m e n s i o n e B l o c c o elementi nella memoria principale e i > 0 è l'indirizzo logico di un blocco nella memoria secondaria: • b = L e g g i D a D i s c o ( i ) trasferisce il blocco con indirizzo i dalla memoria secondaria alla memoria principale, dove diventa accessibile come array b; • i = C r e a S u D i s c o ( b ) trasferisce l'array b dalla memoria principale alla memoria secondaria, creando lo spazio opportuno per ospitarlo come blocco e restituendo l'indirizzo i assegnato a tale blocco nella memoria secondaria; • A g g i o r n a S u D i s c o f b , i) trasferisce l'array b dalla memoria principale alla memoria secondaria, sovrascrivendo lo spazio di memoria secondaria allocato in precedenza al blocco con indirizzo i. Al costo computazionale in termini di tempo e di spazio, aggiungiamo un ulteriore costo, chiamato numero di trasferimenti, che conteggia il numero di blocchi trasferiti tra i due livelli di memoria, ovvero il numero di volte che le suddette primitive sono invocate all'interno degli algoritmi: il motivo è che l'accesso al disco è di diversi ordini di grandezza più lento di quello alla memoria principale. Al termine dell'esecuzione di un algoritmo, i dati persistenti sono esclusivamente quelli che risiedono in memoria secondaria, mentre quelli presenti nella memoria principale sono considerati persi (come avviene in molti calcolatori). Il numero di trasferimenti richiesti dalle operazioni sugli alberi di ricerca come gli AVL è elevato nel modello a due livelli di memoria: infatti, ogni accesso a un nodo

c E 29 18 36

-OO

Figura 5.4

,

»131

-

C D

23 57

-

cuCD Kj

-oo A B 0 97 43

o 13! E F

41 64

o 12

Esempio di B-albero con B = 4 (primi due livelli di blocchi), visto come indice per n = 7 record di cui uno fittizio (ultimo livello), le cui chiavi primarie sono A, B F (oltre alla sentinella -oo). I numeri in corsivo rappresentano il grado dei nodi del B-albero, gli altri numeri sono gli indirizzi logici dei blocchi e le frecce segnalano il loro uso come riferimenti. Ciascun blocco è rappresentato ripiegato su se stesso a fini illustrativi.

può causare uno o più trasferimenti e, pertanto, le operazioni sul dizionario richiedono O(logn) trasferimenti ciascuna. Inoltre, non sfruttiamo il fatto che una volta trasferito un blocco, abbiamo d i m e n s i o n e B l o c c o elementi a disposizione. Sia B ^ 4 il più grande intero pari tale che B ^ ( d i m e n s i o n e B l o c c o — 2 ) / 2 (in altre parole, un blocco di memoria secondaria può contenere fino a 2 x B + 2 elementi). Per semplificare la discussione, ipotizziamo che ciascun record occupi esattemente un blocco di memoria secondaria e abbia una chiave primaria su cui costruire l'indice di ricerca. Mostriamo come ottenere un dizionario che richiede 0(log B n) trasferimenti, fornendo un costo ottimo nella ricerca per confronti in quanto possiamo mostrare, attraverso una generalizzazione del limite inferiore discusso per la ricerca binaria (Paragrafo 2.4.2), che occorrono 0 ( l o g B n) trasferimenti. Un B-albero (precisamente, un B + -albero) per n record ha 0 ( n / B ) nodi, dove ciascun nodo è di grado (numero di figli) variabile compreso tra B/2 e B — 1 (esclusa la radice, che può avere grado compreso tra 2 e B — 1) e contiene un sottoinsieme delle chiavi primarie contenute nei suoi figli, in numero pari al proprio grado: in particolare, le chiavi nei padri sono una replica della minima chiave contenuta in ciascuno dei figli (e tale proprietà vale ricorsivamente nei figli). Inoltre, i nodi allo stesso livello (cioè alla stessa profondità) sono collegati in una lista ordinata: questo è utile in particolare per accedere' alla sequenza ordinata di tutte le chiavi distinte del B-albero, che sono quelle memorizzate nelle foglie (queste ultime hanno tutte lo stesso livello). Mostriamo un esempio di B-albero con B = 4 nella Figura 5.4, dove utilizziamo un record fittizio con la chiave speciale —oo < k, per ogni k e U, che funge da sentinella negli algoritmi che mantengono tale struttura di dati. Possiamo riassumere le caratteristiche di un B-albero come segue (dove B ^ 4 pari è un parametro della definizione del B-albero, che dipende

dalla dimensione del blocco di memoria e da cui dipende la complessità delle operazioni definite sul B-albero): 1. ciascun nodo u del B-albero è implementato come un array u di 2 x B + 2 elementi (di cui almeno la metà sono utilizzati) e, risiedendo in un blocco di memoria, è univocamente individuato dal suo indirizzo logico; 2. i primi B elementi dell'array u contengono un numero variabile g di chiavi primarie dei record, dove B/2 ^ g < B tranne che per la radice, in cui 2 < g < B (la cardinalità g delle chiavi può variare da nodo a nodo e, per questo motivo, è memorizzata nell'elemento u[2 x B + 1]); 3. i successivi B elementi dell'array contengono g indirizzi logici associati alle chiavi (tali indirizzi conducono ai figli dei nodi interni oppure ai record collegati alle foglie): u[r] contiene la minima chiave primaria che appare nel nodo o record indicato da u[B + r], per 0 ^ r ^ g; 4. le foglie sono tutte allo stesso livello, denominato 0, e se un nodo u ha livello i, allora suo padre ha livello i + 1 (inoltre, l'elemento u[2 x B] contiene l'indirizzo del nodo successivo a u nel livello i): in tal modo, l'altezza h. del B-albero è data dal livello della radice; 5. le chiavi in ogni nodo u soddisfano la proprietà di ricerca: per ogni 0 < r < g, la chiave in u[r] è maggiore delle chiavi in u[0, r— 1] e di quelle raggiungibili nei nodi discendenti, attraverso i riferimenti in u[B, B + r — 1]; inoltre, essa è minore delle chiavi in u [ r + 1, g] e minore o uguale di quelle raggiungibili nei nodi discendenti, attraverso i riferimenti in u[B + r, B + g]. Oltre ai blocchi di un B-albero, dobbiamo memorizzare la sua altezza e l'indirizzo logico della radice. Come si può osservare dalla Figura 5.4, il numero di nodi (blocchi) in un B-albero per n record è pari a 0 ( n / B ) e la sua altezza è h = 0(log B n) poiché, a parte la radice, ogni nodo ha grado minimo pari a B/2: quindi la radice ha almeno due figli, almeno 2 x B/2 nipoti, almeno 2 x (B/2) 2 pronipoti, e così via, fino a trovare almeno 2 ( B / 2 ) h _ 1 foglie. Essendo queste ultime in numero pari a 0 ( n / B ) , abbiamo che 2 ( B / 2 ) h _ 1 = 0 ( n / B ) , per cui H = O( ) = 0(log B TI). Nei moderni sistemi di calcolo, il valore di d i m e n s i o n e B l o c c o è tale che B è dell'ordine delle migliaia: pertanto, con un B-albero di soli due livelli (h. = 1) possiamo indicizzare milioni di record, mentre con un ulteriore livello arriviamo a indicizzarne miliardi. Usando le proprietà esposte sopra, possiamo vedere come effettuare un'operazione R i c e r c a di una chiave k in un B-albero di cui conosciamo l'altezza e l'indirizzo logico della radice, come mostrato nel Codice 5.9. Partendo dalla radice (di cui conosciamo l'indirizzo) e dall'altezza dell'albero, che sono i parametri d'ingresso di R i c e r c a , carichiamo dalla memoria secondaria un nodo per ogni livello (riga 3): su tale nodo, ora presente in memoria principale, effettuiamo la ricerca binaria ricorsiva (riga 5) descritta nel Paragrafo 2.5, la quale permette di calcolare il rango di k nell'array ordinato formato

Ricercai indirizzo, altezza, k ): FOR (livello = altezza; livello >= 0; livello = livello - 1) { nodo = LeggiDaDisco( indirizzo ); card = nodo[2 x B + 1]; rango = RicercaBinariaRicorsivaC nodo[0, card-I] , k) ; indirizzo = nodo [B + rango - 1];

> RETURN

indirizzo;

Inserisci( indirizzoRadice, altezza, e ): = InsRicC indirizzoRadice, altezza, e ); IF (indirizzoN != 0) { buffer[0] = - o o ; buffer[B] = indirizzoRadice; buffer[1] = chiave; buffer[B + 1] = indirizzoN; buffer[2 x B] = 0 ; buffer[2 x B + 1] = 2; indirizzoRadice = CreaSuDiscoC buffer ); altezza = altezza+1;

> RETURN rango; i = i - 1) { nodo[i] = nodo[i-l]; nodo[B + i] = nodo[B + (i - 1)];

> nodo[rango] = chiave; nodo[B + rango] = indirizzoN; nodo[2 x B + 1] = card + 1; AggiornaSuDiscoC nodo, indirizzo ); IF (nodo [2 x B + 1] == B) { FOR (i = 0; i < B/2; i = i + 1) { buffer [i] = nodo[B/2+i]; buffer[B + i] = nodo[B + (B/2+i)];

> buffer[2 x B + 1] = nodo[2 x B + 1] = B/2; buffer[2 x B] = nodo[2 x B]; nodo[2 x B] = CreaSuDiscoC buffer ); AggiornaSuDiscoC nodo, indirizzo ); RETURN ;

> > RETURN

Codice 5.10

Inserimento ricorsivo in un B-albero.

chiave salita dal basso (righe 8 - 1 1 ) ponendola nella posizione corrispondente al rango precedentemente calcolato con la ricerca binaria ricorsiva (riga 12), con l'indirizzo associato a essa nella posizione corrispondente (riga 13), e incrementando il numero di chiavi presenti nel nodo (riga 14). Dopo aver salvato il nodo sulla memoria secondaria (riga 15), controlliamo se va diviso (riga 16): in tal caso prendiamo le B / 2 chiavi più grandi e i loro indirizzi associati e le poniamo in un array di appoggio b u f f e r (righe 17-20), che diventerà il fratello destro del nodo corrente. Aggiustiamo la loro cardinalità (riga 21) e colleghiamo il nuovo nodo mediante un riferimento (riga 22) per agganciarlo al resto dei nodi nella lista ordinata per quel livel-

lo. Possiamo quindi salvare nella memoria secondaria b u f f e r come fratello destro del nodo corrente (riga 23), che a sua volta va salvato con la sua nuova configurazione delle chiavi (riga 24). Facciamo quindi salire al padre del nodo corrente la chiave minima che appare nella posizione zero del suo nuovo fratello, associandogli l'indirizzo logico di esso (riga 25). L'inserimento in un B-albero richiede 0 ( 1 ) trasferimenti per livello e, quindi, ha un costo totale di 0 ( l o g B n ) trasferimenti, come la ricerca. A differenza della ricerca, il tempo totale di calcolo, a parte i trasferimenti, è 0 ( B l o g B n ) perché ciascun livello richiede 0(B) tempo: tuttavia, tale tempo è trascurabile rispetto a quello richiesto per trasferire i blocchi da e verso la memoria secondaria. Per quanto riguarda l'operazione di cancellazione, anch'essa può essere realizzata con lo stesso costo dell'inserimento, solo che invece dell'operazione di divisione utilizziamo l'operazione di fusione. Analogamente a quanto detto per gli alberi AVL, consigliamo però di cancellare logicamente le chiavi e di ricostruire periodicamente il B-albero con le sole chiavi non marcate come cancellate. Un'ultima osservazione riguarda l'uso dei B-alberi in memoria principale: fissando B = 4 otteniamo un albero di ricerca bilanciato alternativo agli alberi AVL, chiamato 2-3-albero, che ha lo stesso costo O(logn) per operazione. Inoltre, il modo con cui ricopiamo le chiavi nei livelli superiori è reminiscente di quanto succede nell'organizzazione delle skip list (Paragrafo 3.3). Infine, modificando leggermente il B-albero (B = 4) e colorando alternativamente i livelli dei nodi con il rosso e il nero, è possibile ottenere una forma equivalente a un'altra famiglia di alberi bilanciati, detti alberi rosso-neri (noti come red-black tree e usati per gestire le VMA del kernel Linux). ALVIE:

ricerca e inserimento in B-alberi

Osserva, sperimenta e verifica BTree

5.6

Opus libri: liste invertite e trie

Nei sistemi di recupero dei documenti (information retrieval), i dati sono documenti testuali e il loro contenuto è relativamente stabile: pertanto, in tali sistemi lo scopo principale è quello di fornire una risposta veloce alle numerose interrogazioni degli utenti (contrariamente alle basi di dati che sono progettate per garantire un alto flusso di transazioni che ne modificano i contenuti). In tal caso, la scelta adottata è quella di elaborare preliminarmente l'archivio dei documenti per ottenere un indice in cui le ricerche siano

molto veloci, di fatto evitando di scandire l'intera collezione dei documenti a ogni interrogazione (come vedremo, i motori di ricerca sul Web rappresentano un noto esempio di questa strategia). Infatti, il tempo di calcolo aggiuntivo che è richiesto nella costruzione degli indici, viene ampiamente ripagato dal guadagno in velocità di risposta alle innumerevoli richieste che pervengono a tali sistemi. Le liste invertite (chiamate anche file invertiti, file dei posting o concordanze) costituiscono uno dei cardini nell'organizzazione di documenti in tale scenario. Le consideriamo un'organizzazione logica dei dati piuttosto che una specifica struttura di dati, in quanto le componenti possono essere realizzate utilizzando strutture di dati differenti. La nozione di liste invertite si basa sulla possibilità di definire in modo preciso la nozione di termine (inteso come una parola o una sequenza massimale alfanumerica), in modo da partizionare ciascun documento o testo T della collezione di documenti D in segmenti disgiunti corrispondenti alle occorrenze dei vari termini (quindi le occorrenze di due termini non possono sovrapporsi in T). La struttura delle liste invertite è infatti riconducibile alle seguenti due componenti (ipotizzando che il testo sia visto come una sequenza di caratteri): • il vocabolario o lessico V, contenente l'insieme dei termini distinti che appaiono nei testi di D; • la lista invertita Lp (detta dei posting) per ciascun termine P G V, contenente un riferimento alla posizione iniziale di ciascuna occorrenza di P nei testi T E D: in altre parole, la lista per P contiene la coppia (T, i) se il segmento T[i, i + |P| — 1] del testo è proprio uguale a P (ogni documento T € D ha un suo identificatore numerico che lo contraddistingue dagli altri documenti in D). Notiamo che le liste invertite sono spesso mantenute in forma compressa per ridurre lo spazio a circa il 10-25% del testo e, inoltre, le occorrenze sono spesso riferite al numero di linea piuttosto che alla posizione precisa nel testo. Solitamente, la lista invertita Lp memorizza anche la sua lunghezza in quanto rappresenta la frequenza del termine P nei documenti in D. Per completezza, osserviamo che esistono metodi alternativi alle liste invertite come le bitmap e i signature file, ma osserviamo anche che tali metodi non risultano superiori alle liste invertite come prestazioni. La realizzazione delle liste invertite prevede l'utilizzo di un dizionario (tabella hash o albero di ricerca) per memorizzare i termini P € V: nello specifico, ciascun elemento e memorizzato nel dizionario ha un distinto termine P G V contenuto nel campo e . c h i a v e e ha un'implementazione della corrispondente lista Lp nel campo e . s a t . Nel seguito, ipotizziamo quindi che e . s a t sia una lista doppia che fornisce le operazioni descritte nel Paragrafo 5.2; inoltre, ipotizziamo che un termine sia una sequenza massimale alfanumerica nel testo dato in ingresso. Il Codice 5.11 descrive come costruire le liste invertite usando un dizionario per memorizzare i termini distinti, secondo quanto abbiamo osservato sopra. Lo scopo è

CostruzioneListelnvertiteC T ) : (pre: T e D è un testo di lunghezza n) i = 0; WHILE ( i < n ) { WHILE (i < n kk ¡AlfaNumericoC T[i] )) i = i+1; 3 = ì; WHILE (j < n ft& AlfaNumericoC T[j] )) j = j+i; e = dizionarioListelnvertite.Ricercai T[i,j-1] ); IF (e != nuli) { e.sat.InserisciFondoC ); > ELSE

{

elemento.chiave = T[i,j-1]; elemento.sat = NuovaListaC ); elemento.sat.InserisciFondoC ); dizionarioListelnvertite.Inserisci( elemento )

>

>

i = j;

AlfaNumericoC c ): RETURN

('a' }

> Codice 5.12

Algoritmo per la risoluzione dell'interrogazione (P NEAR Q), in cui specifichiamo anche il massimo numero di posizioni in cui P e Q possono distare.

da V e r i f i c a N e a r , considerando nell'ordinamento delle triple prima il valore di T, poi il minimo tra i e j e poi il loro massimo: tale ordine lessicografico permette di esaminare ogni testo in ordine di identificatore e di scorrere le occorrenze al suo interno riportate nell'ordine simulato di scansione del testo, fornendo quindi un utile formato di uscita all'utente dell'interrogazione (in quanto non deve saltare da una parte all'altra del testo). La funzione I n t e r r o g a z i o n e N e a r nel Codice 5.12 provvede quindi a cercare P e Q nel vocabolario utilizzando il dizionario e supponendo che i testi T nella collezione sia identificati da interi (righe 2 e 3). Nel caso che le liste invertite Lp e Lq non siano entrambe vuote, il codice provvede a scandirle come se volesse eseguire una fusione di tali liste (righe 4—22): ricordiamo che ciascuna lista è composta da una coppia che memorizza l'identificatore del testo e la posizione all'interno del testo dell'occorrenza del termine corrispettivo (P o Q). Di conseguenza, se i testi sono diversi nel nodo corrente delle due liste, la funzione avanza di una posizione sulla lista che contiene il testo con identificatore minore (righe 10—13). Altrimenti, i testi sono i medesimi per cui essa deve produrre tutte le

VerificaNear( listaX, listaY, M ): {pre: posX ^ posY) = listaX.dato; ctestoY, posY> = listaY.dato; WHILE (listaY != null && testoX == testoY && posY-posX ELSE

{

RETURN nuli;

> > RETURN

Codice 5.14

u.dato;

Algoritmo di ricerca in un trie.

Ogni stringa in S corrisponde a un nodo u del trie ed è quindi memorizzata nel campo u . d a t o . c h i a v e (e gli eventuali dati satellite sono in u . d a t o . s a t ) : per esempio, la foglia 5 corrisponde a Vercelli come mostrato in Figura 5.5, dove le foglie del trie sono numerate da 0 a 6, e la foglia numero i memorizza il nome della ( i + 1 )-esima provincia (0 ^ i ^ 6). In generale, estendendo tutte le stringhe con un simbolo speciale, da appendere in fondo a esse come terminatore di stringa, e memorizzando le stringhe così estese nel trie, otteniamo la proprietà che queste stringhe sono associate in modo univoco alle foglie del trie (quest'estensione non è necessaria se nessuna stringa in S è a sua volta prefisso di un'altra stringa in S). Notiamo che l'altezza di un trie è data dalla lunghezza massima tra le stringhe. Nel nostro esempio, l'altezza del trie è 9 in quanto la stringa più lunga nell'insieme è Catanzaro. Potando i sottoalberi che portano a una sola foglia, come mostrato nella parte destra della Figura 5.5, l'altezza media non dipende dalla lunghezza delle stringhe, ma è limitata superiormente da 2 l o g a n + 0 ( 1 ) , dove n è il numero di stringhe nell'insieme S. Tale potatura presume che le stringhe siano memorizzate altrove, per cui è possibile ricostruire il sottoalbero potato in quanto è un filamento di nodi contenente la sequenza di caratteri finali di una delle stringhe (alternativamente, tali caratteri possono essere memorizzati nella foglia ottenuta dalla potatura). Per quanto riguarda la dimensione del trie, indicando con N la lunghezza totale delle n stringhe memorizzate in S, ovvero la somma delle loro lunghezze, abbiamo che la dimensione di un trie è al più N + 1. Tale valore viene raggiunto quando ciascuna stringa in S ha il primo carattere differente da quello delle altre, per cui il trie è composto da una radice e poi da |S| filamenti di nodi, ciascun filamento corrispondente a una stringa. Tuttavia, se si adotta la versione potata del trie, la dimensione al caso medio non dipende dalla lunghezza delle stringhe e vale all'inora l , 4 4 n .

RicercaPref issi ( radiceTrie, P ): (pre: P è una stringa di lunghezza m ) u = radiceTrie; fine = false; FOR (i = 0; Ifine kk i < M; i = i+1) { IF (u.figlioC P[i] ] != nuli) { u = u.figlio[ P[i] ]; > ELSE {

fine = true;

> > numStringhe = 0; Recuperai u, elenco ); RETURN elenco;

Recuperai u, elenco ): IF (u != nuli) { IF (u.dato != nuli) { elenco[numStringhe]= u.dato; numStringhe = numStringhe + 1 ;

> FOR (c = 0; c < sigma; c = c + 1) Recuperai u.figlio[c], elenco );

> Codice 5.15 Algoritmo di ricerca per prefissi in un trie (numStringlie è una variabile globale).

Come possiamo notare dall'esempio nella Figura 5.5, i nodi del trie rappresentato prefissi delle stringhe memorizzate, e le stringhe che sono memorizzate nei nodi discendenti da un nodo u hanno in comune il prefisso associato a u . Nel nostro esempio, le foglie discendenti dal nodo che memorizza il prefisso p i hanno associate le stringhe pisa e pistoia. Sfruttiamo questa proprietà per implementare in modo semplice la ricerca di una stringa di lunghezza m in un trie utilizzando la sua definizione ricorsiva, come mostrato nel Codice 5.14: partiamo dalla radice per decidere quale figlio scegliere a livello i = 0 e, al generico passo in cui dobbiamo scegliere un figlio a livello i del nodo corrente u, esaminiamo il carattere P[i] della stringa da cercare. Osserviamo che, se il corrispondente figlio non è vuoto, continuiamo la ricerca portando il livello a i + 1. Se invece tale figlio è vuoto, possiamo concludere che P non è memorizzata nel dizionario. Quando i = m, abbiamo esaminato tutta la stringa P con successo pervenendo al nodo u di cui restituiamo l'elemento contenuto nel campo u . d a t o : in tal caso, osserviamo che P è memorizzato nel dizionario se e solo se tale campo è diverso da n u l i .

La parte interessante della ricerca mostrata nel Codice 5.14 è che, a differenza della ricerca mostrata per le tabelle hash e per gli alberi di ricerca, essa può facilmente identificare il nodo u che corrisponde al più lungo prefisso di P che appare nel trie: a tal fine, possiamo modificare la riga 7 del codice in modo che restituisca il nodo u (al posto di nuli). Di conseguenza, tutte le stringhe in S, che hanno tale prefisso di P come loro prefisso, possono essere recuperate nei nodi che discendono da u (incluso) attraverso una semplice visita, ottenendo il Codice 5.15: notiamo che le ricerche di stringhe lunghe, quando queste ultime non compaiono nel dizionario, sono generalmente più veloci delle corrispondenti ricerche nei dizionari implementati con le tabelle hash, poiché la ricerca nei trie si ferma non appena trova un prefisso di P che non occorre nel trie mentre la funzione hash è comunque calcolata sull'intera stringa prima di effettuare la ricerca. Non è difficile analizzare il costo della ricerca, in quanto vengono visitati O(m) nodi: poiché ogni nodo richiede tempo costante, abbiamo O(m) tempo di ricerca. Diamo un piccolo esempio quantitativo sulla velocità di ricerca dei trie, supponendo di volere memorizzare i codici fiscali in un trie: ricordiamo che un codice fiscale contiene 9 lettere prese dall'alfabeto A . . . Z di 26 caratteri, e 7 cifre prese dall'alfabeto 0 . . . 9 di 10 caratteri, per un totale di 26 9 x IO7 possibili codici fiscali. Cercare un codice fiscale in un trie richiede di attraversare al più 16 nodi, indipendentemente dal numero di codici fiscali memorizzati nel trie, in quanto l'altezza del trie è 16. In contrasto, la ricerca binaria o quella in un albero AVL, per esempio, avrebbe una dipendenza dal numero di chiavi: nel caso estremo, memorizzando metà dei possibili codici fiscali in un array ordinato, tale ricerca richiederebbe circa log(26 9 x IO7) — 1 ^ 6 4 confronti tra chiavi. Il trie è quindi una struttura di dati molto efficiente per la ricerca di sequenze. L'inserimento di un nuovo elemento nel dizionario rappresentato con un trie segue nuovamente la sua definizione ricorsiva, come mostrato nel Codice 5.16: se il trie è vuoto viene creata una radice (righe 2-7) e, quindi, dopo aver verificato che la chiave dell'elemento non appaia nel dizionario (riga 8), il trie viene attraversato fino a trovare un figlio vuoto in cui inserire ricorsivamente il trie per il resto dei caratteri (righe 14—18) oppure il nodo esiste già ma l'elemento da inserire deve essergli associato (riga 21). In altre parole, l'inserimento della stringa P cerca prima il suo più lungo prefisso x che occorre in un nodo u del trie, analogamente alla procedura R i c e r c a , e scompone P come xy: se y non è vuoto sostituisce il sottoalbero vuoto raggiunto con la ricerca di x, mettendo al suo posto il trie per y; altrimenti, semplicemente associa P al nodo u identificato. Il costo dell'inserimento è O(m) tempo in accordo a quanto discusso per la ricerca (e la cancellazione può essere trattata in modo analogo): da notare che non occorrono operazioni di ribilanciamento (rotazioni o divisioni) come succede negli alberi di ricerca. Infatti, un'importante proprietà è che la forma del trie è determinata univocamente dalle

Inserisci ( radiceTrie, e ): IF (radiceTrie == nuli) { radiceTrie = NuovoNodo( ); radiceTrie.dato = nuli; FOR (c < 0; c < sigma; c = c + 1) radiceTrie.figlio[c] = nuli;

(pre: e. chiave ha lunghezza m)

> IF (Ricercai radiceTrie, e.chiave ) == nuli) { u = radiceTrie; FOR (i = 0; i < m; i = i+1) { IF (u.figlio[ e.chiave[i] ] != nuli) { u = u.figlio[ e.chiave[i] ]; > ELSE {

u.figlioli e.chiave[i] ] = NuovoNodo( ); u = u.figlio[ e.chiave[i] ]; u.dato = nuli; FOR (C < 0; C < sigma; C = C + 1) u.figlio[c] = nuli;

}

> u.dato = e;

> RETURN

radiceTrie;

Codice 5.16 Algoritmo di inserimento di un elemento in un trie.

stringhe in esso contenute e non dal loro ordine di inserimento (contrariamente agli alberi di ricerca). ALVIE:

ricerca e i n s e r i m e n t o in u n trie

Osserva, s p e r i m e n t a e verifica

Trie

•O-O'O-O-O'O' • O •O 'o• • -o • o • •

Inoltre, poiché i trie preservano l'ordine lessicografico delle stringhe in esso contenute, possiamo fornire un semplice metodo per ordinare un array S di stringhe come mostrato nel Codice 5.17, in cui adoperiamo la funzione R e c u p e r a del Codice 5.15 per effettuare una visita simmetrica del trie costruito attraverso l'inserimento iterativo delle stringhe contenute nell'array S.

OrdinaLessicograf icamente( S ) : (pre: S è un array din stringhe) radiceTrie = nuli; elemento.sat = nuli; FOR (i = 0; i < n; i = i+1) { elemento.chiave = S[i]; radiceTrie = Inserisci( radiceTrie, elemento );

> numStringhe = 0; Recuperai radiceTrie, S ); RETURN

Codice 5.17

S;

Algoritmo di ordinamento di stringhe (fa uso di una variabile globale numStringhe).

La complessità dell'ordinamento proposto nel Codice 5.17 è O(N) tempo se la somma delle lunghezze delle n stringhe in S è pari a N. Un algoritmo ottimo per confronti, come il mergesort, impiegherebbe O ( n l o g n ) confronti, dove però ciascun confronto richiederebbe di accedere mediamente a N / n caratteri di una stringa, per un totale di O ( ^ - n l o g n ) = 0 ( N logn) tempo: l'ordinamento di stringhe con un trie è quindi più efficiente in tal caso (radixsort). Analogamente a quanto discusso per la ricerca in tabelle hash, il limite così ottenuto non contraddice il limite inferiore (in questo caso sull'ordinameno) poiché i caratteri negli elementi da ordinare sono utilizzati per altre operazioni oltre ai confronti. ALVIE:

ordinamento in un trie

_Osserva, . „ sperimenta e verifica

TrieSort

»-o o - •

Nati per ricerche come quelle discusse finora, i trie sono talmente flessibili da risultare utili per altri tipi di ricerche più complesse. Inoltre, sono utilizzati nella compressione dei dati, nei compilatori e negli analizzatori sintattici. Servono a completare termini specificati solo in parte; per esempio, i comandi nella shell, le parole nella composizione dei testi, i numeri telefonici e i messaggi SMS nei telefoni cellulari, gli indirizzi del Web o della posta elettronica. Permettono la realizzazione efficiente di correttori ortografici, di analizzatori di linguaggio naturale e di sistemi per il recupero di informazioni mediante basi di conoscenza. Forniscono operazioni di ricerca più complesse di quella per prefissi, come la ricerca con espressioni regolari e con errori. Permettono di individuare ripetizioni nelle stringhe (utili, per esempio, nell'analisi degli stili di scrittura di vari autori) e

di recuperare le stringhe comprese in un certo intervallo. Le loro prestazioni ne hanno favorito l'impiego anche nel trattamento di dati multidimensionali, nell'elaborazione dei segnali e nelle telecomunicazioni. Per esempio sono utilmente impiegati nella codifica e decodifica dei messaggi, nella risoluzione dei conflitti nell'accesso ai canali e nell'istradamento veloce dei router in Internet. A fronte della loro duttilità, i trie hanno una struttura sorprendemente semplice. Purtroppo essi presentano alcuni svantaggi dal punto di vista dello spazio occupato per alfabeti grandi, in quanto ciascuno dei loro nodi richiede l'allocazione di un array di ff elementi: inoltre, le loro prestazioni possono peggiorare se il linguaggio di programmazione adottato non rende disponibile un accesso efficiente ai singoli caratteri delle stringhe. Esistono diverse alternative per l'effettiva rappresentazione in memoria di un trie che sono basate sulla rappresentazioni dei suoi nodi mediante strutture di dati alternative agli array come le liste, le tabelle hash e gli alberi binari.

5.6.2

Trie compatti e alberi dei suffissi

I trie discussi finora hanno una certa ridondanza nel numero dei nodi, in quanto quelli con un solo figlio non vuoto rappresentano una scelta obbligata e non raffinano ulteriormente la ricerca nei trie, al contrario dei nodi che hanno due o più figli non vuoti. Tale ridondanza è rimossa nel trie compatto, mostrato nella parte sinistra della Figura 5.6, dove i nodi con un solo figlio non vuoto sono altrimenti rappresentati per preservare le funzionalità del trie: a tal fine, gli archi sono etichettati utilizzando le sottostringhe delle chiavi appartenenti agli elementi dell'insieme S, invece che i loro singoli caratteri. Formalmente, dato il trie per le chiavi contenute negli elementi dell'insieme S, classifichiamo un nodo del trie come unario se ha esattamente un figlio non vuoto. Una catena di nodi unari è una sequenza massimale u o , u i , . . . , u r _ i di r ^ 2 nodi nel trie tale che ciascun nodo Ut è unario per 1 ^ i ^ r — 2 (notiamo che Uo potrebbe essere la radice oppure u r _ i potrebbe essere una foglia). Sia Ci il carattere per cui Ui = Ui_i.f i g l i o [ c i ] e (3 = C] • • • c r _ i la sottostringa ottenuta dalla concatenazione dei caratteri incontrati percorrendo la catena da ito a u r _ i : definiamo l'operazione di compattazione della catena sostituendo l'intera catena con la coppia di nodi uo e u r _ j collegati da un singolo arco (uo,u T _i ), che è concettualmente etichettato con |3. Notiamo infatti che l'esplicita memorizzazione di (3 non è necessaria se associamo, a ciascun nodo u, il prefisso ottenuto percorrendo il cammino dalla radice fino a u (la radice ha quindi un prefisso vuoto): in tal modo, indicando con a il prefisso nel padre di u e con y quello in u, possiamo ricavare 3 per differenza in quanto a(3 = y e la sottostringa |3 è data dagli ultimi r — 1 = ||3| caratteri di y. Il trie compatto per l'insieme S è ottenuto dal trie costruito su S applicando l'operazione di compattazione a tutte le catene presenti nel trie stesso. Ne risulta che i nodi del trie compatto sono foglie oppure hanno almeno due figli non vuoti. Per implementare

Figura 5.6

A sinistra, il trie compatto per memorizzare i nomi di alcune province; a destra, la versione con le sottostringhe rappresentate mediante triple.

un trie compatto, estendiamo l'approccio adottato per i trie, utilizzando la rappresentazione degli alberi cardinali cr-ari (Paragrafo 4.4): ciascun nodo u di un trie compatto è dotato di un campo u . d a t o in cui memorizzare un elemento e s S (quindi i campi di e sono indicati come u . d a t o . c h i a v e e u . d a t o . s a t ) a cui aggiungiamo un campo u . p r e f i s s o per memorizzare il prefisso associato a u . Tale prefisso è memorizzato mediante una coppia (e, j) per indicare che esso è dato dai primi j caratteri della stringa contenuta nel campo e . c h i a v e : il vantaggio è che rappresentiamo ciascun prefisso con soli due interi indipendentemente dalla lunghezza del prefisso stesso, perché lo spazio richiesto da ciascun nodo rimane O(a) (purché i campi chiave degli elementi siano memorizzati a parte). La parte destra della Figura 5.6 mostra un esempio di tale rappresentazione, dove possiamo osservare che un nodo interno u appare nel trie compatto se e solo se, prendendo il prefisso a associato a u, esistono almeno due caratteri c / c' dell'alfabeto L tali che entrambi ac e a c ' sono prefissi delle chiavi di alcuni elementi in S. La presenza di due chiavi, una prefisso dell'altra, potrebbe introdurre nodi unari che non possiamo rimuovere. Per tale ragione, estendiamo tutte le chiavi degli elementi in S con un ulteriore carattere $, che è un simbolo speciale da usare come terminatore di stringa (analogamente al carattere ' \ 0 ' nel linguaggio C). In tal modo, poiché $ appare solo in fondo alle stringhe, nessuna può essere prefisso dell'altra e, come osservato in precedenza, esiste una corrispondenza biunivoca tra le n chiavi e le n foglie del trie compatto costruito su di esse: quindi, presumiamo che ciascuna delle n foglie contenga un distinto elemento e € S (in particolare, illustriamo questa corrispondenza nella Figura 5.6 etichettando con i la foglia contenente ti). Utilizzando il simbolo speciale e la rappresentazione dei prefissi mediante coppie, il trie compatto ha al più n nodi interni e

RicercaPref issi( radiceTrieCompatto, P ): u = radiceTrieCompatto; fine = false; i = 0; WHILE (¡fine kk

{pre: P contiene m caratteri)

i < M) {

IF (u.figlioli P[i] ] != nuli) { u = u.figliot P[i] ]; = u.prefisso; WHILE (Ci < m ) kk

(i < j) kk

(P[i] == e.chiave[i]))

i = i + 1; fine = (i < m) kk (i < j); } ELSE {

fine = true;

> > numStringhe = 0; Recuperai u, elenco ); Codice 5.18

Algoritmo di ricerca per prefissi in un trie compatto (fa uso di una variabile globale numStringhe e della funzione Recupera del Codice 5.15).

n foglie, e quindi richiede O ( n a ) spazio, dove a = 0 ( 1 ) per le nostre ipotesi: lo spazio dipende quindi solo dal numero n delle stringhe nel caso pessimo e non dalla somma N delle loro lunghezze, contrariamente al trie. La rappresentazione compatta di un trie non ne pregiudica le caratteristiche discusse finora. Per esempio la ricerca per prefissi in un trie compatto simula quella per prefissi in un trie (Codice 5.15) ed è mostrata nel Codice 5.18: la differenza risiede nelle righe 8 11 dove, dopo aver raggiunto il nodo u , ne prendiamo il prefisso a esso associato e ne confrontiamo i caratteri con P, dalla posizione i in poi, fino alla fine di una delle due stringhe oppure quando troviamo due caratteri differenti (riga 9). Analogamente alla ricerca nei trie, terminiamo di effettuare confronti quando tutti caratteri di P sono stati esaminati con successo oppure troviamo il suo più lungo prefisso che occorre nel trie compatto, e il costo del Codice 5.18 rimane pari a 0 ( m ) tempo più il numero di occorrenze riportate. L'operazione R i c e r c a è realizzata in modo analogo a quella per prefissi e mantiene la complessità di 0 ( m ) tempo. Analogamente alla ricerca, l'inserimento di un nuovo elemento e nel trie compatto richiede 0 ( m ) tempo come mostrato nel Codice 5.19. Dopo aver creato la radice (righe 2-8), se necessario, a cui associamo il prefisso vuoto (di lunghezza 0), verifichiamo che l'elemento non sia già nel dizionario. A questo punto, procediamo come nel caso della ricerca per prefissi per identificare il più lungo prefisso x della chiave di e che oc-

Inserisci( radiceTrieCompatto, e ): (pire: e. chiave ha lunghezza m ) IF (radiceCompattoTrie == nuli) { radiceTrieCompatto = NuovoNodo( ); radiceTrieCompatto.prefisso = ; radiceTrieCompatto.dato = nuli; FOR (c < 0; c < sigma; c = c + 1) radiceTrieCompatto.figlio[c] = nuli;

> IF (Ricercai radiceTrieCompatto, e.chiave ) == nuli) { u = radiceTrieCompatto; fine = false; i = 0; WHILE (Ifine Sete i < m) { v = u; indice = i; IF (u.figlio[ e.chiave[i] ] != nuli) { u = u.figlio[ e.chiave[i] ]; = u.prefisso; WHILE (i < M & & i < j && p.chiave[i] == e.chiave[i])

i = i + 1; fine = (i < m) && (i < j) ; > ELSE {

fine = true;

> > IF (fine) CreaFoglia( v, u, indice, i );

> RETURN

radiceTrieCompatto;

Codice 5.19 Algoritmo per l'inserimento di un elemento in trie compatto.

corre nel trie compatto, dove P = xy. Sia u il nodo raggiunto e v il nodo calcolato nelle righe 11-23 e sia i = |x|: se x coincide con il prefisso associato a u, allora v = u; se invece, x è più breve del prefisso associato a u , allora v è il padre di u . Invochiamo ora C r e a F o g l i a , descritta nel Codice 5.20, che fa la seguente cosa: se u ^ v, spezza l'arco (u,v) in due creando un nodo intermedio a cui associa x come prefisso (corrispondente ai primi i caratteri di e . c h i a v e ) e a cui aggancia la nuova foglia che memorizza l'elemento e, la cui chiave ne diventa il prefisso di lunghezza m (in quanto prendiamo tutti i caratteri di e . c h i a v e ) ; se invece u = v, crea soltanto la nuova foglia come descritto sopra, agganciandola però a u. Ricordiamo che, non essendoci una chiave prefisso di un'altra, ogni inserimento di un nuovo elemento crea sicuramente una foglia. Infine, la cancellazione dell'elemento avente chiave uguale a P, specificata in ingresso, richiede la rimozione della foglia raggiunta con la ricerca di P, nonché dell'arco che

CreaFoglia ( v, u, indice, i ) : (pre: e.chiave ha lunghezza m) IF (v != u) { v.figlio[ e.chiave[indice] ] = NuovoNodo( ); v = v.figlio[ e.chiave[indice] ]; v.prefisso = ; v.dato = nuli; FOR (C < 0; C < sigma; C = C + 1) v.figlio[c] = nuli; = u.prefisso; v.figlio[ p.chiave[i] ] = u; u = v;

> u.figlio[ e.chiave[i] ] = NuovoNodo( ); u = u.figliof e.chiavefi] ]; u.prefisso = ; u.dato = e; FOR (c < 0; c < sigma; c = c + 1) u.figlio[c] = nuli; Codice 5.20

Algoritmo per la creazione di una foglia e di un eventuale nodo (suo padre).

collega la foglia a suo padre u . Se u diventa unario, allora dobbiamo rimuovere u , il suo arco entrante e il suo arco uscente, sostituendoli con un unico arco la cui etichetta è la concatenazione della sottostringa nell'arco entrante con quella nell'arco uscente. Tuttavia dobbiamo stare attenti a non usare elementi cancellati per i prefissi associati ai nodi u del trie compatto: se e viene cancellato e un antenato u della corrispondente foglia contiene il prefisso (e, j), allora è sufficiente individuare un altro elemento e' contenuto in una foglia che discende da u e sostituire quel prefisso con (e', j). Il costo della cancellazione è O(m) poiché a = 0 ( 1 ) . ALVIE:

trie compatto Osserva, sperimenta e verifica CompactTrie

Uno dei motivi per introdurre le complicazioni della gestione dei trie compatti, rispetto a quella più semplice dei trie, è il loro utilizzo per una struttura di dati con un numero sempre crescente di applicazioni. Dato un testo T di n caratteri in cui l'ultimo

Figura 5.7

Albero dei suffissi per T = banana$.

carattere è il simbolo speciale $, definiamo il suo suffisso i come il segmento composto dagli ultimi caratteri T[i, n — 1], per 0 ^ i ^ n — 1. Un albero dei suffissi ( s u f f i x tree) per T è il trie compatto costruito su n chiavi, che sono tutti i suffissi i di T per 0 ^ i ^ n — 1. Un esempio dell'albero dei suffissi per il testo T = b a n a n a $ è mostrato nella Figura 5.7, come risulta dalla costruzione del trie compatto sulle seguenti chiavi (che sono i suffissi di T numerati da 0 a 6): 0. b a n a n a $

1. anaiia$

2. n a n a $

3. a n a $

4. n a $

5. a$

6.$

Gli alberi dei suffissi vengono studiati per la loro importanza nella costruzione di indici testuali che risultano più potenti delle liste invertite: infatti, l'utilizzo degli alberi dei suffissi non necessita di dividere il testo in segmenti che rappresentano i termini ma, piuttosto, ognuno dei (2) = 0 ( n 2 ) segmenti del testo è un potenziale termine di ricerca, come succede nelle sequenze biologiche. Il vantaggio di impiegare un albero dei suffissi, essendo un trie compatto con n foglie, è che lo spazio occupato è lineare a fronte di un numero 0 ( n 2 ) di potenziali termini: è chiaro che memorizzando tali termini nel vocabolario V delle liste invertite avremmo spazio quadratico o superiore, mentre con l'albero dei suffissi tale spazio è soltanto di O(n) celle di memoria incluse quelle necessarie a memorizzare T. Notiamo che, applicando l'inserimento descritto nel Codice 5.19 ai vari suffissi, possiamo costruire l'albero dei suffissi per T in tempo Lr=o |T[i,n - 1]| = Lr=O' (T-L — i) = 0 ( n 2 ) (otteniamo tale costo quando, ad esempio, i primi n — 1 caratteri di T sono tutti uguali): esistono vari algoritmi che permettono la costruzione di tale struttura di dati richiedendo soltanto O(n) tempo. L'impiego degli alberi dei suffissi nella costruzione degli indici per documenti, si basa sulla seguente proprietà fondamentale delle occorrenze: un termine P di m caratteri

occorre in posizione i del testo T (ovvero P = T[i, i — m + 1]) se e solo se P è prefisso del suffisso T[i, n — 1]. In altre parole, il problema della ricerca in un testo {pattern matching) può essere formulato in termini della ricerca per prefissi descritta nel Codice 5.18. Tale ricerca non è applicata a stringhe indipendenti, bensì ai suffissi del testo come abbiamo mostrato sopra: una volta individuato il nodo u che corrisponde a P, le foglie i discendenti da u corrispondono esattamente alle posizioni i del testo in cui P occorre. Per esempio, nella Figura 5.7, la ricerca di P = a n a conduce al nodo le cui foglie discendenti sono i = 1 e i = 3, che sono anche le occorrenze di P. La proprietà fondamentale riduce quindi il problema di cercare P in T al problema di cercare P nel trie compatto e, quindi, il costo della ricerca è indipendente dalla lunghezza del testo, ovvero è O(m) più il numero di occorrenze trovate (osserviamo che la proprietà fondamentale vale non solo per la ricerca esatta, ma anche per altri tipi di ricerca che utilizzano l'albero dei suffissi). Volendo utilizzare l'albero dei suffissi per una collezione di documenti D = {To,Ti,..., T s _i}, basta che lo costruiamo sul testo T = To$Ti$ • • • $T s _i$ ottenuto concatenando i testi in D separati dal simbolo speciale $: poiché P non contiene tale simbolo, le sue occorrenze sono esclusivamente all'interno di ciascun documento. Concludiamo il paragrafo discutendo alcune tra le molteplici applicazioni dell'albero dei suffissi che dimostra avere "una miriade di virtù" (alcuni studi recenti mostrano come ridurre ulteriormente lo spazio richiesto dagli alberi dei suffissi per renderlo paragonabile a quello richiesto dalle liste invertite). Una prima applicazione è quella di memorizzare, in modo compatto, la statistica sulle sottostringhe. Prima dell'invenzione dell'albero dei suffissi, alcuni problemi erano ritenuti difficili: un esempio è dato dal trovare, in tempo O(n), la più lunga sottostringa che appare in T almeno due volte. La soluzione diventa semplice con l'utilizzo del suddetto albero, in quanto tale sottostringa corrisponde a uno dei prefissi più lunghi memorizzati in un nodo interno dell'albero. Una seconda applicazione è nella compressione dei dati con i metodi basati su dizionario, come l'algoritmo di sostituzione testuale Lempel-Ziv su cui si basa il compressore g z i p , oppure con i metodi basati sull'ordinamento, come l'algoritmo della trasformata di Burrows-Wheeler su cui si basa il compressore b z i p . In quest'ultimo caso, dobbiamo ordinare lessicograficamente i suffissi del testo e questo ordinamento è facilmente reperibile eseguendo una visita simmetrica nell'albero dei suffissi (analoga al Codice 5.17). Un'ulteriore applicazione è nella ricerca approssimata di stringhe, in cui è rilevante l'uso di una primitiva l c p ( i , j) che restituisce il massimo numero di caratteri iniziali uguali tra i due suffissi T[i,n — 1] e T[j,n — 1], per 0 ^ i,) < n , in tempo costante. Dopo aver applicato l'algoritmo del minimo antenato comune (Paragrafo 4.2) all'albero dei suffissi, ogni volta che perviene un'interrogazione l c p ( i , j), possiamo restituire il valore richiesto in tempo costante prendendo le foglie i e j nell'albero dei suffissi, indi-

viduando il loro minimo antenato comune u. e restituendo la seconda componente di u . p r e f i s s o , che rappresenta la lunghezza del prefisso corrispondente a u. RIEPILOGO In questo capitolo abbiamo descritto come realizzare un dizionario utilizzando le tabelle hash, gli alberi di ricerca, gli alberi AVL, i B-alberi e, infine, i trie o alberi digitali di ricerca, inclusi gli alberi dei suffissi e le liste invertite, discutendo la complessità di ciascuna realizzazione. ESERCIZI 1. Mostrate come estendere i dizionari discussi nel capitolo in modo che possano gestire multi-insiemi con chiavi eventualmente ripetute. 2. Valutate il costo della rappresentazione dei grafi con liste di adiacenza realizzate mediante dizionari. 3. Descrivete la cancellazione fisica da tabelle hash a indirizzamento aperto. 4. Dimostrate per induzione su h. che rin = Fk+3 — 1 negli alberi di Fibonacci. 5. Mostrate che la complessità dell'inserimento in un AVL cambia significativamente se sostituiamo la funzione A l t e z z a del Codice 5.7 con quella ricorsiva definita nel Capitolo 4. 6. Mostrate che, dopo aver ribilanciato tramite le rotazioni un nodo critico a seguito di un inserimento, non ci sono altri nodi critici. 7. Mostrate che, analogamente a quanto fatto per la ricerca binaria, D(Iog B n) è il limite inferiore per il numero di trasferimenti necessari nella ricerca di chiavi per confronti (ogni nuovo blocco caricato in memoria principale aumenta le possibili scelte di una fattore almeno pari a B/2). 8. Supponendo che la lista di nodi su ciascun livello del B-albero sia bidirezionale e utilizzando il B-albero in memoria principale con B = 4 e i puntatori ai padri, descrivete un algoritmo di ricerca chiamato finger search, il cui costo è O(logd) tempo dove d < n è il numero di chiavi che intercorrono nella lista delle foglie tra la chiave attualmente cercata k e l'ultima chiave cercata k' (mantenete un riferimento chiamato finger a k', aggiornandolo con ogni ricerca). 9. Mostrate come gestire il campo u . p a d r e negli alberi binari di ricerca, negli alberi AVL e nei B-alberi (in questi ultimi, l'inserimento può richiedere 0(B log B n) trasferimenti ma il costo ammortizzato diventa 0(log B n) utilizzando il riferimento alla lista dei nodi sullo stesso livello).

10. Discutete come realizzare, per i dizionari ordinati, le operazioni S u c c e s s o r e , P r e d e c e s s o r e , I n t e r v a l l o e Rango descritte nel Paragrafo 5.1, utilizzando gli alberi AVL, i B-alberi e i trie, analizzandone la complessità. 11. Un'occorrenza della stringa P nella posizione i del testo T ha al più k mismatch se il numero di posizioni in cui i caratteri di P e T[i, i + m— 1] differiscono è al più k, dove m è la lunghezza di P e k ^ m. Utilizzando la primitiva l c p , mostrate come trovare tutte le occorrenze di P in T con al più k mismatch in tempo O(nk), dove n è la lunghezza di T.

Capitolo 6

Grafi

SOMMARIO In questo capitolo esaminiamo le caratteristiche principali dei grafi, fornendo le definizioni relative a tali strutture. Mostriamo come attraverso di essi sia possibile modellare una quantità di situazioni e come molti problemi possano essere interpretati come problemi su grafi. Introduciamo poi il paradigma dell'algoritmo goloso applicandolo alla risoluzione del problema della colorazione e di quello del massimo insieme indipendente nel caso di grafi a intervalli. Infine, studiamo il concetto di rete complessa e mostriamo come sia possibile sfruttare la struttura a grafo del World Wide Web per rendere più efficaci le ricerche di documenti in esso contenuti. DIFFICOLTÀ I CFU

6.1

Grafi

II collegamento tra due nodi nelle liste rappresenta la relazione tra predecessore e successore, mentre il collegamento negli alberi rappresenta la relazione tra figlio e padre. I collegamenti nei grafi rappresentano una generalizzazione di tali relazioni e includono, come caso particolare, la relazione espressa da liste e alberi: il collegamento tra due nodi in un grafo rappresenta una relazione di adiacenza o di vicinanza tra tali nodi. L'importanza dei grafi deriva dal fatto che una grande quantità di situazioni può essere modellata e rappresentata mediante essi, e quindi una grande quantità di problemi può essere espressa per mezzo di problemi su grafi: gli algoritmi efficienti su grafi rappresentano alcuni strumenti generali per la risoluzione di numerosi problemi di rilevanza pratica e teorica. Un grafo G è definito come una coppia di insiemi finiti G = (V, E), dove V rappresenta l'insieme dei nodi o vertici e le coppie di nodi in E C V x V sono chiamate archi o

Figura 6.1

Rotte areee di collegamento tra alcune capitali europee.

lati. Il numero n = |V| di nodi è detto ordine del grafo, mentre m = |E| indica il numero di archi (i due numeri possono essere estremamente variabili, l'uno rispetto all'altro). La dimensione del grafo è data dal numero n + m totale di nodi e di archi, per cui la dimensione dei dati in ingresso è espressa usando due parametri nel caso dei grafi, contrariamente al singolo parametro adottato per la dimensione di array, liste e alberi. Infatti, n e m vengono considerati parametri indipendenti, per cui nel caso di grafi la complessità lineare viene riferita al costo 0 ( n + m) di entrambi i parametri. In generale, vale 0 m ^ (2) poiché il numero massimo di archi è dato dal numero (2) = 0 ( n 2 ) di tutte le possibili coppie di nodi: il grafo è sparso se m = O(n) e denso se m = @(n 2 ). Nella trattazione di grafi un arco viene inteso come un collegamento tra due nodi u e v e viene rappresentato con la notazione (u, v) (che, come vedremo, è un piccolo abuso per semplificare la notazione del libro). Ciò è motivato dalla descrizione grafica utilizzata per rappresentare grafi, in cui i nodi sono elementi grafici (punti o cerchi) e

gli archi sono linee colleganti le relative coppie di nodi, questi ultimi detti terminali o estremi degli archi. Consideriamo l'esempio mostrato nella Figura 6.1, che riporta le rotte di una nota compagnia aerea relativamente all'insieme V degli aeroporti dislocati presso alcune capitali europee, identificate mediante il codice internazionale del corrispondente aeroporto: BVA (Parigi), CIA (Roma), CRL (Bruxelles), DUB (Dublino), MAD (Madrid), NYO (Stoccolma), STN (Londra), SXF (Berlino), TRF (Oslo). Possiamo rappresentare le rotte usando una forma tabellare come quella riportata in basso nella Figura 6.2, in cui la casella all'incrocio tra la riga x e la colonna y contiene il tempo di volo (in minuti) per la rotta che collega gli aeroporti x e y. La casella è vuota se non esiste una rotta aerea. Tale rappresentazione è mostrata graficamente mediante uno dei due grafi in alto nella Figura 6.2, dove ciascun arco (x,y) € E rappresenta la rotta tra x e y etichettata con il tempo di volo corrispondente (osserviamo che una lista lineare o un albero non riescono a modellare l'insieme delle rotte). Un grafo G = ( V, E) è detto pesato o etichettato sugli archi se è definita una funzione W : E i-> 1 che assegna un valore (reale) a ogni arco del grafo. Nell'esempio della Figura 6.2, i pesi sono dati dai tempi di volo. Nel seguito, con il termine grafo pesato G = (V, E, W) indicheremo un grafo pesato sugli archi. L'esempio mostrato nelle Figure 6.1 e 6.2 illustra una serie di nozioni sulla percorribilità e raggiungibilità dei nodi di un grafo. Dato un arco (u,v), diremo che i nodi u e v sono adiacenti e che l'arco (u, v) è incidente a ciascuno di essi: in altri termini, un arco (u,v) è incidente al nodo x se e solo se x = u oppure x = v. Il numero di archi incidenti a un nodo è detto grado del nodo e un nodo di grado 0 è detto isolato. Facendo riferimento al grafo nell'esempio, il nodo CIA ha grado pari a 5 mentre il nodo MAD è isolato. Una proprietà che viene spesso utilizzata è che la somma dei gradi dei nodi è pari a 2m (il doppio del numero di archi). Per mostrare ciò, dobbiamo vedere ciascun arco come incidente a due nodi, per cui la presenza di un arco fa aumentare di 1 il grado di entrambi i suoi due estremi: il contributo di ogni arco alla somma dei gradi è pari a 2. Invece di sommare i gradi di tutti i nodi, possiamo calcolare tale valore moltiplicando per 2 il numero m di archi. Nell'esempio, m = 12 e la somma dei gradi è 24. E naturale chiederci se, a partire da un nodo, è possibile raggiungere altri nodi attraversando gli archi: relativamente al nostro esempio, vogliamo sapere se è possibile andare da una città a un'altra prendendo uno o più voli. Tale percorso viene modellato nei grafi attraverso un cammino da un nodo u a un nodo z, definito come una sequenza di nodi x 0 , x i , x 2 , . . . , x ^ tale che xo = u, x^ = z e (xi,xi + i ) e E per ogni 0 ^ i < k: l'intero k ^ 0 è detto lunghezza del cammino. Un ciclo è un cammino per cui vale x 0 = x k , ovvero un cammino che ritorna nel nodo di partenza. Un cammino (o un

BVA 90 " 120

DUB-

120 130

CRLMAD

SXF

Figura 6.2

CRL -

MAD

-

-

-

110

95 -

>NYO

STN HO

SXF.

DUB -

STN 110

MAD

115

TRF

TRF

BVA

-

-

-

-

-

-

-

-

-

-

-

-

-

95

160

135

115

-

130

'CRL-

I35

TRF

SXF

140

95

STN 110

CIA

120

DUB-

* 175 -NY0 160

190 BVA 90 "

175 140

'90

95

SXF CRL DUB STN MAD TRF BVA CIA NY0

120

-

115

90 -

CIA -

120 190 160

NY0 -

130 -

135

-

-

-

-

-

120 130

-

90 190 -

115 -

160 135

-

-

-

-

-

-

-

-

-

-

120 140

-

120 -

175

-

140 175 -

Rappresentazione a grafo (con n = 9 nodi e m = 12 archi) e tabellare delle rotte mostrate nella Figura 6.1.

ciclo) è semplice se non attraversa alcun nodo più di una volta, ossia se non esiste alcun ciclo annidato al suo interno. Nella Figura 6.2 esiste un cammino semplice di lunghezza k = 3 da u = BVA a z = SXF, dato da BVA, NY0, STN, SXF. Il cammino BVA, CIA, DUB, CRL, CIA, NY0, STN, SXF non è semplice a causa del ciclo CIA, DUB, CRL, CIA. Invece, STN, TRF, SXF non è un cammino in quanto TRF e SXF non sono collegati da un arco. Un cammino minimo da u a z è caratterizzato dall'avere lunghezza minima tra tutti i possibili cammini da u. a z: in altre parole, vogliamo sapere qual è il modo di andare dalla città u alla città z usando il minor numero di voli. Nell'esempio, sia BVA, CIA, STN, SXF che BVA, NY0, STN, SXF sono cammini minimi. La distanza tra due nodi u e z è pari alla lunghezza di un cammino minimo che li congiunge e, se tale cammino non esiste, è pari a +oo: la distanza tra BVA e SXF è 3 mentre tra BVA e MAD è +oo.

190

CIA

190

BVA

BVA

90

' 90 . ' '' \

175 DUB

DUB

140

NYO

MAD

TRF

Figura 6.3

175 \

NfO

MAD

CIA 120

;

TRF

Un sottografo e un sottografo indotto del grafo a destra nella Figura 6.2.

Nel caso di grafi pesati ha senso definire il peso di un cammino come la somma dei pesi degli archi attraversati (che sono quindi intesi come "lunghezze" degli archi), ovvero come ^ J q W ( x i , x i + i ) : nel nostro esempio, ipotizzando che il tempo di commutazione tra un volo e il successivo sia nullo, vogliamo sapere qual è il modo più veloce per andare da una città a un'altra (a differenza del cammino minimo). Il cammino minimo pesato è il cammino di peso minimo tra due nodi e la distanza pesata è il suo peso (oppure +oo se non esiste alcun cammino tra i due nodi). Nel nostro esempio, il cammino minimo pesato è BVA, NYO, STN, SXF (e quindi la distanza pesata è 385) perché il cammino BVA, CIA, STN, SXF ha peso pari a 390 (non è detto che un cammino minimo pesato debba essere anche un cammino minimo). I cammini permettono di stabilire se i nodi del grafo sono raggiungibili: due nodi u e z sono detti connessi se esiste un cammino tra di essi. Nell'esempio i nodi BVA e SXF sono connessi, in quanto esiste il cammino BVA, NYO, STN, SXF che li congiunge, mentre i nodi BVA e MAD non lo sono. Un grafo in cui ogni coppia di nodi è connessa è detto a sua volta connesso. Dato un grafo G = (V, E), un sottografo di G è un grafo G' = (V', E') composto da un sottoinsieme dei nodi e degli archi presenti in G: ossia, V' C V ed E' C V' x V' e, inoltre, vale E' C E. Nella Figura 6.3 è mostrato a sinistra un sottografo del grafo presentato nella Figura 6.2. Se vale la condizione aggiuntiva che in E' appaiono tutti gli archi di E che connettono nodi di V', allora G' viene denominato sottografo indotto da V'. E sufficiente specificare solo V' in tal caso: il grafo mostrato a destra nella Figura 6.3 è il sottografo indotto dall'insieme di nodi V' = {BVA, CIA, DUB, NYO, MAD, TRF}. Possiamo quindi definire una componente connessa di un grafo G come un sottografo G' connesso e massimale di G, vale a dire un sottografo di G avente tutti nodi

Lungarno Fibonacci

V. Bruno Figura 6.4

Parte della rete stradale della città di Pisa, dove le strade a doppio senso di circolazione sono rappresentate mediante una coppia di archi aventi etichetta comune.

connessi tra loro e che non può essere esteso, in quanto non esistono ulteriori nodi in G che siano connessi ai nodi di G'. All'interno di una componente connessa possiamo raggiungere qualunque nodo della componente stessa, mentre non possiamo passare da una componente all'altra percorrendo gli archi del grafo: la richiesta di massimalità nelle componenti connesse è motivata dall'esigenza di determinare con precisione tutti i nodi raggiungibili. Facendo riferimento al grafo nella Figura 6.2, il sottografo indotto dai nodi STN, SXF, TRF è connesso, ma non è una componente connessa del grafo, in quanto può essere esteso, ad esempio, aggiungendo il nodo CIA. In effetti, il grafo in questione risulta composto da due componenti connesse: la prima indotta dal nodo isolato MAD e la seconda indotta dai restanti nodi. Come possiamo osservare, un grafo connesso è composto da una sola componente connessa.1 Un grafo completo o cricca (clique) è caratterizzato dall'avere tutti i suoi nodi a due a due adiacenti. Facendo riferimento al grafo nella Figura 6.2, il sottografo indotto dai nodi CIA, CRL, DUB è una cricca (anche se non è una componente connessa in quanto esistono altri nodi, come BVA, che sono collegati alla cricca). La notazione K r è usata per indicare una cricca di r vertici e, nel nostro grafo, compaiono diversi sottografi che sono K3 (ma nessun K4 vi appare). I grafi discussi finora sono detti non orientati in quanto un arco (u, v) non è distinguibile da un arco (v,u), per cui (u,v) = (v,u): entrambi rappresentanto simmetricamente un collegamento tra u e v. In diverse situazioni, tale simmetria è volutamente evitata, come nel grafo illustrato nella Figura 6.4, che rappresenta la viabilità stradale di alcuni punti nella città di Pisa, con i sensi unici indicati da singoli archi orientati e le strade a doppio senso di circolazione indicate da coppie di archi. Gli archi hanno un ' È interessante notare che un albero può essere equivalentemente visto come un grafo che è connesso e non contiene cicli, in cui un nodo viene designato come radice.

senso di percorrenza e la notazione (u, v) indica che l'arco va percorso dal nodo u. verso il nodo v, e quindi (u,v) ^ (v, u), in quanto il grafo è orientato o diretto. 2 L'arco (u, v) viene detto diretto da u a v, quindi uscente da u ed entrante in v. Il nodo u è denominato nodo iniziale o di partenza mentre v è denominato nodo finale, di arrivo o di destinazione. Il grado in uscita di un nodo è pari al numero di archi uscenti da esso, mentre il grado in ingresso è dato dal numero di archi entranti. Facendo riferimento al grafo nella Figura 6.4, il nodo B ha grado in ingresso pari a 4 e grado in uscita pari a 1, mentre il nodo C ha grado in ingresso pari a l e grado in uscita pari a 3. Il grado è la somma del grado d'ingresso e di quello d'uscita: in un grafo orientato la somma dei gradi dei nodi in uscita è uguale a m, poiché ciascun arco fornisce un contributo pari a 1 nella somma dei gradi in uscita. Inoltre, il numero di archi è 0 ^ m ^ 2 x (") poiché otteniamo il massimo numero di archi quando ci sono due archi diretti per ciascuna coppia di nodi. Nel seguito, sarà sempre chiaro dal contesto se il grafo sarà orientato o meno. Le definizioni viste finora per i grafi non orientati si adattano ai grafi orientati. Un cammino (orientato) da un nodo u a un nodo z soddisfa la condizione che tutti gli archi percorsi nella sequenza di nodi sono orientati da u a z. In questo caso, diciamo che il nodo u è connesso al nodo z (e questo non implica che z sia connesso a u perché la direzione è opposta). Allo stesso modo, possiamo definire un ciclo orientato come un cammino orientato da un nodo verso se stesso. Usando i pesi degli archi, la definizione di cammino minimo (pesato o non) e la nozione di distanza rimangono inalterate. La stessa cosa avviene per la definizione di sottografo. E importante evidenziare il concetto di grafo fortemente connesso, quando ogni coppia di nodi è connessa, e di componente fortemente connessa: quest'ultima va intesa come un sottografo massimale tale che, per ogni coppia di nodi u e z, in esso esistono due cammini orientati all'interno del sottografo, uno da u a z e l'altro da z a u. Nel grafo nella Figura 6.4, le due componenti fortemente connesse risultano dai sottografi indotti rispettivamente dai nodi A, B e da C, D, E, F, G. Nel presente e nei seguenti capitoli, discuteremo alcuni algoritmi che usano le nozioni introdotte finora, ipotizzando che i nodi siano numerati V = { 0 , 1 , . . . , n — 1}.

6.1.1

Alcuni problemi su grafi

La versatilità dei grafi nel modellare molte situazioni, e i relativi problemi computazionali che ne derivano, sono ben illustrati da un esempio "giocattolo" in cui vogliamo organizzare una gita in montagna per n persone. 2 Nella teoria dei grafi, un arco che collega due nodi u e v di un grafo non orientato viene rappresentato come un insieme di due nodi {u, v), spesso abbreviato come uv, mentre se il grafo è orientato l'arco viene rappresentato con la coppia (u, v) (infatti, {u, v) = {v,u} mentre (u, v) ^ (v,u)). Con un piccolo abuso di notazione, nel libro useremo (u, v) anche per gli archi non orientati, e in tal caso varrà (u, v) = (u, v), in quanto sarà sempre chiaro dal contesto se il grafo è orientato o meno.

Figura 6.5

Esempio di grafo delle conoscenze.

Per il viaggio, le poltrone nel pullman sono disposte a coppie e si vogliono assegnare le poltrone ai partecipanti in modo tale che due persone siano assegnate a una coppia di poltrone soltanto se si conoscono già (supponiamo che n sia un numero pari). Una tale situazione può essere modellata mediante un grafo G = (V, E) delle "conoscenze", in cui V corrisponde all'insieme degli n partecipanti ed E contiene l'arco (x,y) se e solo se le persone x e y si conoscono: nella Figura 6.5 è fornito un esempio di grafo di tale tipo. In questo modello, un assegnamento dei posti che soddisfi le condizioni richieste corrisponde a un sottoinsieme di archi E ' C E tale che tutti i nodi in V siano incidenti agli archi di E' (quindi tutti i partecipanti abbiano un compagno di viaggio) e ogni nodo in V compaia soltanto in un arco di E' (quindi ciascun partecipante abbia esattamente un compagno): tale sottoinsieme viene denominato abbinamento o accoppiamento perfetto {perfect matching) dei nodi di G. Nel caso del grafo mostrato nella Figura 6.5 esistono due abbinamenti diversi: il primo è {(v 0 ,vi), (v 2 ,v 4 ), (v 3 ,v 5 )}, mentre il secondo è {(v 0 ,v 4 ), (vi,v 2 ), (v 3 ,v 5 )}. Un problema simile lo abbiamo già incontrato nel caso dei matrimoni stabili discussi nel Capitolo 3: quest'ultimo può essere ora modellato come un abbinamento su un grafo bipartito G = (Vo, Vi,E), caratterizzato dal fatto di avere due insiemi di vertici Vo e Vj (i clienti e le clienti nel nostro caso) tali che ogni arco (x,y) e E ha gli estremi in insiemi diversi, quindi x 6 Vo,y G Vi oppure x € Vi,y e VoTornando alla gita, supponiamo che sia prevista un'escursione in quota per cui i partecipanti devono procedere in fila indiana lungo vari tratti del percorso. Ancora una volta, i partecipanti preferiscono che ognuno conosca sia chi lo precede che chi lo segue. In tal caso, si cerca un cammino hamiltoniano (dal nome del matematico del XIX secolo William Rowan Hamilton) ovvero un cammino che passi attraverso tutti i nodi una e una sola volta: si tratta quindi di trovare una permutazione ( 7 t o , 7 t i , . . . , 7 t _ i ) dei nodi che sia un cammino, ovvero (7ii,7ti + i) € E per ogni 0 ^ i ^ n — 2. Nel n

Figura 6.6

La città di Königsberg (immagine proveniente da w w v . w i k i p e d i a . o r g ) con evidenziati i ponti rappresentati da Euler come archi di un multigrafo che viene trasformato in un grafo non orientato.

grafo considerato esistono quattro cammini hamiltoniani diversi, dati dalle sequenze (V3,V5,V0,V1,V2,V4), (V3,V5,Vo,Vi,V4,V2), (v3,V5,Vo,V4,V2,Vi) e (V3,V5,V0,V4,V1,V2).3

Consideriamo quindi il caso in cui, giunti al ristorante del rifugio montano, vogliamo disporre i partecipanti intorno a un tavolo in modo tale che ognuno conosca i suoi vicini, di destra e di sinistra. Quel che vogliamo, ora, è un cammino hamiltoniano nel grafo delle conoscenze in cui valga l'ulteriore condizione (7t n _i,7to) € E: tale ordinamento prende il nome di ciclo hamiltoniano. A differenza del caso precedente, possiamo notare come una permutazione di tale tipo non esista per il grafo considerato nell'esempio. Infine, tornati a valle, i partecipanti visitano un parco naturale ricco di torrenti che formano una serie di isole collegate da ponti di legno. I partecipanti vogliono sapere se è possibile effettuare un giro del parco attraversando tutti i ponti una e una sola volta, tornando al punto di partenza della gita. Il problema non è nuovo e, infatti, il principale matematico del XVIII secolo, Leonhard Euler, lo studiò relativamente ai ponti della città di Königsberg mostrati nella Figura 6.6, per cui l'origine della teoria dei grafi viene fatta risalire a Euler. Le zone delimitate dai fiumi sono i vertici di un grafo e gli archi sono i ponti da attraversare: nel caso che più ponti colleghino due stesse zone, ne risulta un multigrafo ovvero un grafo in cui la stessa coppia di vertici è collegata da archi multipli, come nel caso della Figura 6.6. In tal caso, sostituiamo ciascun arco multiplo (x,y) da una coppia di archi (x,w) e (w, z), dove w è un nuovo vertice usato soltanto per (x, y). In termini moderni, ne risulta un grafo G in cui vogliamo trovare un 3 In realtà, i cammini sarebbero otto, considerando quelli risultanti da un percorso "al contrario" dei quattro elencati.

ciclo euleriano, ovvero un ciclo (non necessariamente semplice) che attraversa tutti gli archi una e una sola volta (mentre un ciclo hamiltoniano attraversa tutti i nodi una e una sola volta). E possibile attraversare tutti i ponti come.richiesto se e solo se G ammette un ciclo euleriano: Euler dimostrò che la condizione necessaria e sufficiente perché ciò avvenga è che G sia connesso e i suoi nodi abbiano tutti grado pari, pertanto il grafo nella parte destra della Figura 6.6 non contiene un ciclo euleriano, in quanto presenta 4 nodi di grado dispari, mentre è fàcile verificare che K5 ammette un ciclo euleriano in quanto tutti i suoi nodi hanno grado 4. Euler dimostrò anche che se esattamente due nodi hanno grado dispari allora il grafo contiene un cammino euleriano, che comprende quindi ogni arco una e una sola volta: il grafo nella Figura 6.6 non contiene neanche un cammino euleriano. Come vedremo nei prossimi capitoli, la verifica che G sia connesso richiede tempo lineare, quindi il problema di Euler richiede 0 ( n + m) tempo e spazio. I problemi esaminati sinora derivano dalla modellazione di numerosi problemi reali, e sono stati studiati nell'ambito della teoria degli algoritmi. È interessante a tale proposito osservare che, pur avendo tali problemi una descrizione molto semplice, l'efficienza della loro soluzione è molto diversa: mentre per il problema dell'abbinamento e del ciclo euleriano sono noti algoritmi operanti in tempo polinomiale nella dimensione del grafo, per i problemi del cammino e del ciclo hamiltoniano non sono noti algoritmi polinomiali. Dato che è possibile verificare in tempo polinomiale se un cammino o un ciclo passano una e una sola volta per tutti i nodi, e quindi se sono hamiltoniani, ne deriva che tali problemi appartengono alla classe NP, e in effetti è anche possibile dimostrarne la NP-completezza.

6.1.2

Rappresentazione di grafi

La rappresentazione utilizzata per un grafo è un aspetto rilevante per la gestione efficiente del grafo stesso, e viene realizzata secondo due modalità principali (di cui esistono varianti): le matrici di adiacenza e le liste di adiacenza. Dato un grafo G = (V, E), la matrice di adiacenza A di G è un array bidimensionale di n x n elementi in {0,1} tale che A[i][j] = 1 se e solo se (i, j) e E per 0 ^ i, j ^ n — 1. In altre parole, A[i][j] = 1 se esiste un arco tra il nodo i e il nodo j, mentre A[i][j] = 0 altrimenti. Nella Figura 6.7 è fornita, a titolo di esempio, la matrice di adiacenza del grafo non orientato mostrato nella Figura 6.2 in cui il nodo i è associato alla riga i e alla colonna i, dove 0 ^ i < n, così fornendo, in corrispondenza degli elementi con valore 1; l'elenco dei nodi adiacenti a tale nodo. Se consideriamo grafi non orientati, vale A[i][j] = A[j][i] per ogni O ^ i , j ^ n — 1, e quindi A è una matrice simmetrica. Volendo rappresentare un grafo pesato, possiamo associare alla matrice di adiacenza una matrice P dei pesi che rappresenta in forma tabellare la funzione W, come mostrato nella tabella in basso nella Figura 6.2 (talvolta le matrici A e P vengono combinate in un'unica matrice per occupare meno spazio).

SXF CRL DUB STN MAD TRF BVA CIA NYO Figura 6.7

SXF 0 0 0 1 0 0 0 0 0

CRL 0 0 1 0 0 0 0 1 1

DUB 0 1 0 0 0 0 1 1 0

STN 1 0 0 0 0 1 0 1 1

MAD TRF 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0

BVA 0 0 1 0 0 0 0 1 1

CIA 0 1 1 1 0 0 1 0 1

NYO 0 1 0 1 0 0 1 1 0

Matrice di adiacenza per il grafo nella Figura 6.2.

La rappresentazione di un grafo mediante matrice di adiacenza consente di verificare in tempo 0(1) l'esistenza di un arco tra due nodi ma, dato un nodo i, richiede tempo O(n) per scandire l'insieme dei nodi adiacenti, anche se tale insieme include un numero di nodi molto inferiore a n, come mostrato dalle seguenti istruzioni: FOR ( j = 0; j < n; j = IF (A[i] [ j ] ! = 0) { PRINT arco ( i , j ) ; PRINT peso P [ i ] [ j ]

j+1) {

d e l l ' a r c o , se p r e v i s t o ;

> > Per scandire efficientemente i vertici adiacenti, conviene utilizzare un array contenente n liste di adiacenza. In questa rappresentazione, a ogni nodo I del grafo è associata la lista dei nodi adiacenti, detta lista di adiacenza e indicata con l i s t a A d i a c e n z a [ i ] , che implementiamo come un dizionario a lista (Paragrafo 5.2) di lunghezza pari al grado del nodo. Se il nodo ha grado zero, la lista è vuota. Altrimenti, l i s t a A d i a c e n z a [ i ] è una lista doppia con un riferimento sia all'elemento iniziale che a quello finale della lista di adiacenza per il nodo i: ogni elemento x di tale lista corrisponde a un arco (i, j) incidente a i e il corrispettivo campo x . d a t o contiene l'altro estremo j. Per esempio, il grafo mostrato nella Figura 6.2 viene rappresentato mediante liste di adiacenza come illustrato nella Figura 6.8. Volendo rappresentare un grafo pesato, è sufficiente aggiungere un campo x . p e s o contenente il peso W(i, j) dell'arco (i, j). La scansione degli archi incidenti a un dato nodo i può essere effettuata mediante la scansione della corrispondente lista di adiacenza, in tempo pari al grado di i, come illustrato nelle istruzioni del seguente codice.

SXF

STO

CRL

DUB |

1 CIA |

1 NYO

DUB

CRL |

1 BVA |

1 CIA |

STO

SXF |

1 TRF |

1 CIA |

1 CIA |

1 NYO |

[ NYO

MAD TRF

STO

BVA

DUB |

CIA

CRL

DUB |

1 STN |

1 BVA |

NYO

CRL

STN |

1 BVA

CIA

Figura 6.8

1 NYO

Lista di adiacenza per il grafo nella Figura 6.2, in cui SXF, CRL, DUB, STN, MAD, TRF, BVA, CIA, NYO sono implicitamente enumerati 0,1,2 8, in quest'ordine.

x = listaAdiacenza[i].inizio; WHILE (x != null) {

j = x.dato; PRINT

(i,j);

PRINT x.peso (se previsto);

x = x.succ;

Tuttavia, la verifica della presenza di un arco tra una generica coppia di nodi i e j richiede la scansione della lista di adiacenza di i oppure di j, mentre tale verifica richiede tempo costante nelle matrici di adiacenza. Non esiste una rappresentazione preferibile all'altra, in quanto ciascuna delle due rappresentazioni presenta quindi vantaggi e svantaggi che vanno ponderati al momento della loro applicazione, valutandone la convenienza in termini di complessità in tempo e spazio che ne derivano. Per quanto riguarda lo spazio utilizzato, la rappresentazione del grafo mediante matrice di adiacenza richiede spazio 0 ( n 2 ) , indipendentemente dal numero m di archi presenti: ciò risulta ottimo per grafi densi ma poco conveniente nel caso in cui trattiamo grafi sparsi in quanto hanno m = O(m) oppure, in generale, per grafi in cui il numero di archi sia m = o ( n 2 ) . Per tali grafi, la rappresentazione mediante matrice di adiacenza risulta poco efficiente dal punto di vista dello spazio utilizzato. Invece, lo spazio è pari

Figura 6.9

I grafi completi

e K 5 e il grafo bipartito completo K33.

a 0 ( n + m) celle di memoria nella rappresentazione mediante liste di adiacenza: infatti la lista per ciascun nodo è di lunghezza pari al grado del nodo stesso e, come abbiamo visto, la somma dei gradi di tutti i nodi risulta essere O(m); inoltre, usiamo spazio O(n) per l'array dei riferimenti. La rappresentazione con liste di adiacenza può essere vista anche come una rappresentazione compatta della matrice di adiacenza, in cui ogni lista corrisponde a una riga della matrice e include i soli indici delle colonne corrispondenti a valori pari a 1. Per avere un metro di paragone per la complessità in spazio, occorre confrontare lo spazio per memorizzare 0 ( n + m) interi e riferimenti, come richiesto dalle liste di adiacenza, con lo spazio per memorizzare un array bidimensionale di 0 ( n 2 ) bit, come richiesto dalla matrice di adiacenza. Per grafi particolari, possiamo usare rappresentazioni succinte nel caso statico come quelle discusse per gli alberi statici (Capitolo 4). Appartengono a questo tipo di grafi sia gli alberi (che hanno n — 1 archi) che i grafi planari, che possono essere sempre disegnati sul piano senza intersezioni degli archi: Euler dimostrò infatti che un grafo planare di n vertici contiene m = O(n) archi e quindi è sparso. Un esempio di grafo planare è mostrato nella Figura 6.2, dove è riportata a destra una sua disposizione nel piano senza intersezioni degli archi {embedding planare). Mentre il grafo completo K4 è planare, come mostrato dal suo embedding planare nella Figura 6.9, non lo sono K5 e il grafo bipartito completo K33 in quanto è possibile dimostrare che non hanno un embedding planare. Da quanto detto deriva che un grafo planare non può contenere né K5 né K3 3 come sottografo: sorprendentemente, questi due grafi completi consentono di caratterizzare i grafi planari. Preso un grafo G, definiamo la sua contrazione G' come il grafo ottenuto collassando i vertici w di grado 2, per cui le coppie di archi (u, w) e (w,v) a essi incidenti diventano un unico arco (u, v): nella Figura 6.6, il grafo G a destra ha il grafo G' al centro come contrazione. Il teorema di Kuratowski-Pontryagin-Wagner afferma che G non è

planare se e solo se esiste un suo sottografo G' la cui contrazione fornisce K5 oppure 1^3. Tale proprietà può essere impiegata per certificare che un grafo non è planare, esibendo G ' come prova che non è possibile trovare un embedding planare di G. Tornando alla rappresentazione nel calcolatore dei grafi, consideriamo il caso di quella per grafi orientati: notiamo che essa non presenta differenze sostanziali rispetto alla rappresentazione discussa finora per i grafi non orientati. La matrice di adiacenza non è più simmetrica come nel caso di grafi non orientati e, inoltre, vengono solitamente rappresentati gli archi uscenti nelle liste di adiacenza. L'arco orientato (i, j) viene memorizzato solo nella lista l i s t a A d i a c e n z a [ i ] (come elemento x contenente j nel campo x . d a t o ) in quanto è differente dall'arco orientato (j,i) (che, se esiste, va memorizzato nella lista l i s t a A d i a c e n z a [ j ] come elemento contenente i). Osserviamo che nei grafi non orientati l'arco (i, j) è invece memorizzato sia in l i s t a A d i a c e n z a f t ] che in l i s t a A d i a c e n z a [ j ] . Nel seguito, ciascuna lista di adiacenza è ordinata in ordine crescente di numerazione dei vertici in essa contenuti, se non specificato diversamente, e fornisce tutte le operazioni su liste doppie indicate nel Paragrafo 5.2. Infine notiamo che, mentre la rappresentazione di un grafo lo identifica in modo univoco, non è vero il contrario. Infatti, esistono n! modi per enumerare i vertici con valori distinti in V = { 0 , 1 , . . . , n — 1} e, quindi, altrettanti modi per rappresentare lo stesso grafo. Per esempio, i due grafi di seguito sono apparentemente distinti:

La distinzione nasce dall'artificio di enumerare arbitrariamente gli stessi vertici ma è chiaro che la relazione tra i vertici è la medesima se ignoriamo la numerazione (ebbene sì, il cubo a destra è un grafo bipartito). Tali grafi sono detti isomorfi in quanto una semplice rinumerazione dei vertici li rende uguali e, nel nostro esempio, i vertici del grafo a sinistra vanno rinumerati come 0-4,

1-1,

2-6,

3-3,

4-5,

5-0,

6-7,

7-2,

per ottenere il grafo a destra: il problema di decidere in tempo polinomiale se due grafi arbitrari di n vertici sono isomorfi equivale a trovare tale rinumerazione, se esiste, in tempo polinomiale in n ed è uno dei problemi algoritmici fondamentali tuttora irrisolti, con molte implicazioni (per esempio, stabilire se due grafi arbitrari non sono isomorfi ha delle importanti implicazioni nella crittografia).

0 1 2 3 4 5 6 7 8 9 10 Figura 6.10

6.1.3

0 1 1 0 0 1 1 0 0 0 0 0

1 1 1 1 1 1 0 0 1 0 0 0

2 0 1 1 0 1 0 0 0 0 0 0

3 0 1 0 1 0 1 1 1 0 0 0

4 1 1 1 0 1 0 0 0 0 0 0

5 1 0 0 1 0 1 1 1 0 0 0

6 0 0 0 1 0 1 1 1 0 0 0

7 0 1 0 1 0 1 1 1 0 0 0

8 0 0 0 0 0 0 0 0 1 1 1

9 0 0 0 0 0 0 0 0 1 1 1

10 0 0 0 0 0 0 0 0 1 1 1

Matrice di adiacenza modificata per il calcolo della chiusura transitiva.

Cammini minimi, chiusura transitiva e prodotto di matrici

La rappresentazione di un grafo G = (V, E) mediante matrice di adiacenza fornisce un semplice metodo per il calcolo della chiusura transitiva del grafo stesso: un grafo G* = (V, E* ) è la chiusura transitiva di G se, per ogni coppia di vertici i e j in V, vale (i, j ) € E* se e solo se esiste un cammino in G da i a j. Sia A la matrice di adiacenza del grafo G, modificata in modo che gli elementi della diagonale principale hanno tutti valori pari a 1 (come mostrato nella Figura 6.10). Calcolando il prodotto booleano A 2 = A x A dove l'operazione di somma tra elementi è l'OR e la moltiplicazione è l'AND, possiamo notare che un elemento A2[i][j] = A[i][k] • A[k][j] di tale matrice è pari a 1 se e solo se esiste almeno un indice 0 ^ t ^ n - 1 tale che A[i][t] = A[t][j] = 1 (nella Figura 6.11 a sinistra è mostrata la matrice di adiacenza A 2 corrispondente a quella della Figura 6.10). Tenendo conto dell'interpretazione dei valori degli elementi della matrice A, ne deriva che, dati due nodi i e j, vale A2[i][j] = 1 se e solo se esiste un nodo t, dove 0 ^ t ^ n — 1, adiacente sia a i che a j, e quindi se e solo se esiste un cammino di lunghezza al più 2 tra i e j. L'eventualità che il cammino abbia lunghezza inferiore a 2 deriva dal caso in cui t = i oppure t = j (motivando così la nostra scelta di porre a 1 tutti gli elementi della diagonale principale di A). Moltiplicando la matrice A 2 per A otteniamo, in base alle considerazioni precedenti, la matrice A 3 tale che A3[i][j] = 1 se e solo se i nodi i e j sono collegati da un cammino di lunghezza al più 3: nella Figura 6.11 a destra è mostrata la matrice di adiacenza A 3 corrispondente alla matrice di adiacenza della Figura 6.10. Possiamo verificare che, moltiplicando A 3 per A, la matrice risultante è uguale ad A : da ciò deriva che A l =A 3 per ogni i ^ 3, e che quindi A 3 rappresenta la relazione di connessione tra i nodi per cammini di lunghezza qualunque. Indicheremo in generale 3

0 1 2 3 4 5 6 7 8 9 10 Figura 6.11

0 1 1 1 1 1 1 1 1 0 0 0

1 1 1 1 1 1 1 1 1 0 0 0

2 1 1 1 1 1 0 0 1 0 0 0

3 1 1 1 1 1 1 1 1 0 0 0

4 1 1 1 1 1 1 0 1 0 0 0

5 1 1 0 1 1 1 1 1 0 0 0

6 1 1 0 1 0 1 1 1 0 0 0

7 1 1 1 1 1 1 1 1 0 0 0

8 0 0 0 0 0 0 0 0 1 1 1

9 0 0 0 0 0 0 0 0 1 1 1

10 0 0 0 0 0 0 0 0 1 1 1

0 1 2 3 4 5 6 7 8 9 10

0 1 1 1 1 1 1 1 1 0 0 0

1 1 1 1 1 1 1 1 1 0 0 0

2 1 1 1 1 1 1 1 1 0 0 0

3 1 1 1 1 1 1 1 1 0 0 0

4 1 1 1 1 1 1 1 1 0 0 0

5 1 1 1 1 1 1 1 1 0 0 0

6 1 1 1 1 1 1 1 1 0 0 0

7 1 1 1 1 1 1 1 1 0 0 0

8 0 0 0 0 0 0 0 0 1 1 1

9 0 0 0 0 0 0 0 0 1 1 1

10 0 0 0 0 0 0 0 0 1 1 1

Matrici di adiacenza A 2 (a sinistra) e A 3 = A* (a destra).

tale matrice come A*, osservando che essa rappresenta il grafo G* di chiusura transitiva del grafo G. Per la corrispondenza tra nodi adiacenti in G* e nodi connessi in G, la matrice A* consente di verificare in tempo costante la presenza di un cammino in G tra due nodi (non consente però di ottenere il cammino, se esiste), oltre che di ottenere in tempo O(n), dato un nodo, l'insieme dei nodi nella stessa componente connessa in G. Poniamoci ora il problema del calcolo efficiente di A* a partire da A, esemplificato dal codice seguente. A' = A; DO { B = A*; A* = B x B ; > WHILE (A* ! = B ) ; RETURN A * ;

Il codice, a partire da A, moltiplica la matrice per se stessa, ottenendo in questo modo la sequenza A 2 , A 4 , A 8 , . . . , fino a quando la matrice risultante non viene più modificata da tale moltiplicazione, ottenendo così A*. Valutiamo il numero di passi eseguiti da tale codice: l'istruzione a riga 1 viene eseguita una sola volta e ha costo 0 ( n 2 ) , richiedendo la copia degli n 2 elementi di A, mentre l'istruzione alla riga 3 e il controllo alla riga 5 sono eseguiti un numero di volte pari al numero di iterazioni del ciclo, richiedendo un costo 0 ( n 2 ) a ogni iterazione. Per quanto riguarda l'istruzione alla riga 4, anch'essa viene eseguita a ogni iterazione e ha un costo pari a quello della moltiplicazione di una matrice n x n per se stessa, costo che indichiamo per ora con Cvi(n). Per contare il numero massimo di iterazioni, consideriamo che la matrice A 1 rappresenta come adiacenti elementi a distanza al più i e che il diametro (la massima distanza tra

Figura 6.12

Grafo rappresentante i 25 stati confinanti nell'Unione Europea in cui la numerazione dei vertici da 0 a 24 è sostituita dalla rispettiva sigla internazionale.

due nodi) di un grafo con n vertici è al più n— 1. Dato che a ogni iterazione la potenza i della matrice A 1 calcolata raddoppia, saranno necessarie al più log(n— 1 ) = 0 ( l o g n ) iterazioni per ottenere la matrice A*. Da ciò consegue che il costo computazionale del codice precedente sarà pari a 0((TI 2 +CM (n.)) logn). Come discusso nel Capitolo 2, abbiamo che CM(TX) = n ( n 2 ) e che CM(TI) = 0 ( n 3 ) con il metodo classico di moltiplicazione di matrici: quindi il costo totale di calcolo di A* è 0 ( n 3 logn). Una riduzione del costo CM(TI) può essere ottenuta utilizzando algoritmi più efficienti per la moltiplicazione di matrici, come ad esempio l'algoritmo di Strassen introdotto nel Paragrafo 2.6.1.

6.2

Opus libri: colorazione di grafi e algoritmi golosi

I grafi permetteno di esprimere i problemi che riguardano lo scheduling di risorse e di attività (come ad esempio l'allocazione di registri nei compilatori e l'assegnamento di frequenze nei cellulari) come un problema di colorazione, in cui vogliamo colorare i vertici con il minimo numero possibile di colori in modo tale che i vertici adiacenti siano colorati diversamente. Prima di discutere un'applicazione reale di tale problema, illustriamo un esempio basato sulla colorazione della cartina dei paesi dell'Unione Europea (in realtà i cartografi usano altri criteri come il bilanciamento dei colori piuttosto che il minimo numero di colori usati). In questo caso, il grafo non orientato G = (V, E) nella Figura 6.12 ha V uguale all'insieme degli n = 25 paesi dell'Unione: inoltre (i, j) € E se e solo se i paesi i e j sono confinanti, per 0 ^ i, j ^ n — 1. Dato un qualunque intero k > 0, una k-colorazione è una funzione x : V >—> { 0 , 1 , . . . , k — 1}, definita sui vertici e indicata con la lettera greca x (chi), che assegna

colori diversi a vertici adiacenti, ovvero x(i-) x(j) P e r ° g n i a r c o (i> j) £ E: il minimo numero k per il quale esiste una k-colorazione viene denominato numero cromatico xo del grafo in questione. Per il grafo considerato, una possibile 4-colorazione è quella che assegna colore 0 ai nodi IRL, E, D, I, SK, S, LIT e EST, colore 1 ai nodi UK, F, NL, DK, PL, A, SF, LTV, GR, CYP e MLT, colore 2 ai nodi B, C Z e SL, e colore 3 ai nodi P, L e H. È possibile d'altra parte verificare che 4 colori sono necessari perché B, F, D, L formano una cricca e, essendo l'uno adiacente agli altri, richiedono colori tutti diversi: pertanto, nel caso del grafo nella Figura 6.12 abbiamo che xo = 4. Per la colorazione vale quanto detto per il cammino e per il ciclo hamiltoniano: dato un intero k ^ 3 non è noto nessun algoritmo che in tempo polinomiale determini se un grafo arbitrario ha numero cromatico k. D'altra parte, osserviamo che, dato un valore k < 0 e un'assegnazione di colori ai nodi, è facile verificare in tempo polinomiale se essa è una k-colorazione: pertanto il problema della k-colorazione è in NP. Come vedremo successivamente, tale problema è in effetti NP-completo. Per alcune famiglie importanti di grafi, tuttavia, la colorazione può essere trovata in tempo lineare. Per esempio, abbiamo appena visto che la cricca K r ha xo = r in quanto ogni coppia di vertici è adiacente. Nel caso di grafi planari, come quello nella Figura 6.12, è sempre possibile trovare una 4-colorazione in tempo lineare, per cui xo ^ 4: questo risultato non è affatto ovvio e discende da una famosa congettura del XIX secolo risolta quasi un secolo dopo da due matematici statunitensi, Kenneth Appel e Wolfgang Haken, con l'ausilio di tecniche algoritmiche e del calcolatore. Pur esistendo dei grafi planari che richiedono Xo = 4 colori, come quello mostrato nella Figura 6.12, altri grafi planari potrebbero richiedere Xo < 4 colori: stabilire se un grafo planare ammette una 3-colorazione è un problema NP-completo. Il problema del calcolo del numero cromatico xo n e ' c a s o di grafi planari è quindi , altrettanto difficile quanto quello per grafi arbitrari. Ciò è sorprendente perché si tratta di stabilire "soltanto" se xo è pari a 3 o 4: infatti, per Xo = L il grafo è composto da un insieme di nodi isolati mentre per xo = 2 è facile progettare un algoritmo che risolve il problema in tempo polinomiale su grafi arbitrari. Vediamo in questo paragrafo che il problema diventa facilmente risolvibile per un'altra classe speciale di grafi, chiamati grafi a intervalli, per cui sviluppiamo algoritmi appositi che ne sfruttano le particolari proprietà.

6.2.1

II problema dell'assegnazione delle lunghezze d'onda

In una rete ottica, i segnali luminosi viaggiano simultaneamente su lunghezze d'onda differenti con una tecnologia che si chiama multiplazione a divisione di lunghezza d'onda. La banda di trasmissione di una fibra ottica viene partizionata in canali multipli, ciascuno dei quali opera a una diversa lunghezza d'onda: ad esempio, una fibra ottica standard a modo singolo può ospitare 128 lunghezze d'onda con una banda pari a 10

gigabit al secondo per ciascuna lunghezza d'onda. Quando un segnale cambia lunghezza d'onda è necessario trasformarlo in un segnale elettrico, instradarlo e riconvertirlo in un segnale ottico con l'opportuna lunghezza d'onda: questo genera un ritardo significativo in quanto il segnale elettrico viaggia molto più lentamente del segnale ottico. 'E quindi più efficiente evitare, per quanto possibile, il cambiamento di lunghezza d'onda del segnale trasmesso: sfruttando la tecnologia ottica, in una rete trasparente i segnali viaggiano da un collegamento all'altro senza cambiare lunghezza d'onda. Un sistema lineare ottico è una rete trasparente composta da un certo numero di collegamenti o linee interconnessi tra loro a formare una sequenza lineare lo, l i , . . . , l s - 1 . Una richiesta di trasmissione del segnale ottico viene rappresentata con l'intervallo chiuso [a,b] se necessita della sequenza di collegamenti l a , l Q + i . . . , l b , dove 0 ^ a ^ b ^ s — 1. Tale richiesta prevede che il segnale viaggi sulla stessa lunghezza d'onda in l Q , l a + i • • •, lb- Tuttavia, bisogna prestare attenzione ad assegnare le lunghezze d'onda alle richieste in modo che non vi siano due segnali che viaggino sulla stessa lunghezza d'onda e interessino uno stesso collegamento. Questo problema, spesso combinato con quello dell'instradamento delle richieste, è stato molto studiato e risulta essere molto difficile in reti ottiche generali: per la sua semplicità, il sistema lineare ottico viene impiegato come base per la progettazione di reti ottiche. Sia R l'insieme di n richieste [ao,bo], [ a i . b i ] , . . . , [ a n _ i , b n _ i ] da soddisfare. Sia inoltre fi la lunghezza d'onda assegnata alla richiesta [ai.bi], per cui l'insieme R è soddisfatto se vale f i ^ f j per ogni coppia di richieste distinte [Qi,bi] e [aj,bj] che condividono almeno un collegamento (ovvero, gli intervalli hanno intersezione non vuota). Il problema dell'assegnazione delle lunghezze d'onda consiste nel soddisfare l'insieme R utilizzando il minimo numero di lunghezze d'onda distinte. Ad esempio, supponiamo che s = 2, n = 3 e R = {[0,1], [0,2], [1,2]}. In questo caso sono sufficienti due lunghezze d'onda: la prima è assegnata alle richieste [0,1] e [1,2], mentre la seconda è assegnata a [0,2], D'altra parte, due lunghezze d'onda sono necessarie in quanto esistono due intervalli che si intersecano.

6.2.2

Grafi a intervalli

Vediamo ora come sia possibile modellare il problema dell'assegnazione delle lunghezze d'onda mediante un problema di colorazione di grafi. In particolare, dato l'insieme R di n richieste [do,boi, [ a i , b ] ] , . . . , [ a n _ i , b n _ i ] , definiamo il grafo GR = (VR, ER) ottenuto da R come segue e come illustrato nella Figura 6.13: l'insieme VR = { 0 , 1 , . . . ,n—1} dei vertici rappresenta gli intervalli in R, dove i e VR corrisponde all'intervallo [at, bi]. L'insieme degli archi ER è definito in base all'intersezione degli intervalli, ovvero (i, j) S ER se e solo se gli intervalli [ai, bi] e [cij, bj] hanno intersezione non vuota (incluso il caso in cui si intersecano in uno degli estremi). Ogni grafo così ottenuto, ovvero che possa essere rappresentato come grafo di intersezione di intervalli, viene detto grafo a intervalli.

Figura 6.13

Un insieme di intervalli e il grafo a intervalli corrispondente.

Usando la formulazione mediante grafo a intervalli, osserviamo che il problema dell'assegnazione delle lunghezze d'onda si riduce al problema della colorazione minima del grafo GR, in cui i colori sono in corrispondenza biunivoca con le lunghezze d'onda. In altre parole, dobbiamo calcolare il numero cromatico xo di GR, e tale numero corrisponde al numero minimo p. = xo di lunghezze d'onda che soddisfano le richieste. Prima di descrivere un algoritmo polinomiale per la risoluzione di quest'ultimo problema, osserviamo che i grafi a intervalli rappresentano gli archi in forma implicita: per esempio, la cricca K n è un grafo a intervalli, in quanto generato da n intervalli, l'uno annidato dentro l'altro. In tal modo, ogni coppia di intervalli ha intersezione non vuota, dando luogo a m = (") archi. Notiamo che i grafi a intervalli sono un sottoinsieme proprio dei grafi: per esempio, il grafo a forma di quadrato (ossia 4 vertici e altrettanti archi) non è un grafo a intervalli. I grafi a intervalli consentono di modellare diverse altre situazioni in cui dobbiamo allocare risorse ad attività da svolgere in un certo intervallo di tempo. Ad esempio, supponiamo di avere un insieme di lezioni da dover pianificare, e che a ognuna sia associato un intervallo temporale all'interno del quale essa debba essere svolta. Se due lezioni si sovrappongono, a esse non può essere assegnata lo stessa aula. Il problema di calcolare il minor numero possibile di aule sufficienti a svolgere tutte le lezioni è ancora una volta equivalente a quello di dover calcolare il numero cromatico del grafo a intervalli corrispondente all'insieme delle lezioni.

I grafi a intervalli hanno anche altre applicazioni, nel campo dell'archeologia (per determinare un ordinamento dell'età di un insieme di artefatti) oppure nel campo della biologia (per la costruzione di mappe del DNA o per la ricerca di geni), per citare alcuni esempi in altre discipline.

6.2.3

Colorazione di grafi a intervalli

L'algoritmo di colorazione del grafo a intervalli sfrutta alcune proprietà geometriche degli intervalli, che supponiamo di avere ordinato in ordine non decrescente in base al loro estremo sinistro, disponendo la sequenza ordinata parallelamente all'asse delle ascisse nel piano cartesiano. Utilizziamo una linea immaginaria di scansione (sweeping line), parallela all'asse delle ordinate, che procede da sinistra verso destra. In ogni istante, la linea interseca un certo numero di intervalli: il numero cromatico xo è almeno pari al massimo numero di intervalli intersecati dalla linea durante la sua scansione. Se consideriamo gli eventi rilevanti che occorrono durante la scansione della linea immaginaria, possiamo notare che il numero di intervalli intersecati cambia soltanto quando la linea incontra un estremo di un intervallo: • se l'estremo è quello sinistro, e quindi incontriamo un nuovo intervallo, a quest'ultimo viene assegnato uno qualunque dei colori non utilizzati dagli intervalli intersecati dalla linea; • se l'estremo è quello destro, e quindi riscontriamo la fine di un intervallo, quest'ultimo rilascia il colore assegnatogli affinché qualcun altro possa usarlo. Per poter essere riutilizzati in seguito, i colori temporaneamente rilasciati vengono mantenuti in un s e c c h i e l l o (bucket), realizzato come un array non ordinato i cui elementi sono inseriti ed estratti in fondo, richiedendo tempo costante per operazione. L'algoritmo garantisce che i colori appartengano all'insieme { 0 , 1 , . . . ,xo — 1} e che due intervalli che si intersecano ricevano colori diversi. L'algoritmo di colorazione di un grafo a intervalli GR è mostrato nel Codice 6.1, dove i parametri d'ingresso sono gli intervalli [do, boi, [aj, bj], . . . , [ a n _ i , b n _ ] ] . Dopo aver azzerato il numero di colori disponibili nel secchiello (che inizialmente è vuoto) e il contatore per il numero di colori usati, procediamo a ordinare gli intervalli in base al loro estremo sinistro (righe 3—7). In particolare, utilizziamo un array di 2 n elementi in cui poniamo le triple (Qv,0,i) e (bi, l , i ) in corrispondenza dell'intervallo [di, b*], per 0 ^ i < n. — 1: la prima componente della tripla contiene un estremo dell'intervallo, la seconda specifica se è un estremo destro (vale 1) o sinistro (vale 0), e la terza contiene l'indice i dell'intervallo. Non possiamo generare due triple uguali, e il successivo ordinamento lessicografico (riga 7) fa sì

ColoraGraf olntervalli ( [a[0], b[0]], [a[l], b[l]],..., [a[n - 1], b[n - 1]] ) : coloriLiberi = numeroColori = 0; FOR (i = 0; i < N; i = i + 1) { estremo[2 x i] = ; estremo[2 x i + 1] = ;

> OrdinaCrescente( estremo ); FÜR (j = 0; j < 2 x n; j = j + 1) { = estremo[j] ; IF (estremoDestro == 1) { secchiello[coloriLiberi] = colore [i]; coloriLiberi = coloriLiberi + 1; > ELSE IF (coloriLiberi > 0 ) {

colore[i] = secchiello[coloriLiberi-1]; coloriLiberi = coloriLiberi - 1; > ELSE

{

colore[i] = numeroColori; numeroColori = numeroColori + 1 ;

> Codice 6.1

Algoritmo per la colorazione di grafi a intervalli, dove s e c c h i e l l o è una semplice sequenza non ordinata.

che gli intervalli che condividono lo stesso estremo siano ordinati in modo che vengano prima le triple associate a intervalli che iniziano in tale estremo di quelle associate a intervalli che terminano in esso. Il successivo ciclo (righe 8 - 2 0 ) scandisce gli intervalli in ordine, per simulare la linea immaginaria, e assegna i colori agli intervalli memorizzandoli nell'array c o l o r e . Preso l'estremo dell'intervallo corrente, non importa il valore di tale estremo ma piuttosto se è destro o sinistro: la seconda componente della tripla fornisce quest'informazione, mentre la terza componente ci indica che siamo nell'intervallo [ai, b j (riga 9). Utilizziamo ora il fatto che quando incontriamo un estremo destro, rilasciamo il colore riponendolo nel secchiello (righe 10—12). Nel caso in cui incontriamo un estremo sinistro, dobbiamo o prelevare uno dei colori nel secchiello (se non è vuoto, come nelle righe 13-15) oppure utilizzare un nuovo colore mai usato prima (righe 16—18). Questo algoritmo produce una k-colorazione del grafo, dove il numero k di colori è pari a n u m e r o C o l o r i : dimostriamo ora che l'algoritmo utilizza il minor numero possibile di colori, e quindi che n u m e r o C o l o r i = xo al termine dell'algoritmo. A tale scopo, per ogni nodo i, indichiamo con P(t) i nodi adiacenti a i ai quali, nel momento

in cui considera di, l'algoritmo ha già assegnato un colore. Tali nodi corrispondono a intervalli che iniziano prima di, o con, a^ e che terminano dopo, o con, cu: pertanto, tutti questi nodi, incluso i, corrispondono a intervalli che si intersecano in cu, e quindi l'insieme P(i) U {i} forma una cricca. In particolare, se i è un nodo in corrispondenza del quale la variabile n u m e r o C o l o r i deve essere incrementata, questo implica che | P(i) |= n u m e r o C o l o r i , e il grafo contiene una cricca di | n u m e r o C o l o r i | +1. Se consideriamo il valore di n u m e r o C o l o r i al termine dell'algoritmo, abbiamo che il grafo contiene una cricca di tale dimensione: da ciò deriva che xo ^ n u m e r o C o l o r i . Dato che l'algoritmo ha effettuato una k-colorazione dove k = n u m e r o C o l o r i , ne deriva che tale colorazione è ottima. Per quanto riguarda la complessità dell'algoritmo, notiamo che il costo dominante è quello dell'ordinamento (riga 7) in quanto il resto del codice richiede tempo O(n). Infatti, il primo ciclo richiede n iterazioni di tempo costante mentre il secondo ciclo ne richiede 2n, in cui ciascuna operazione sul secchiello richiede tempo costante. Ne risulta, in generale, una complessità totale di O ( n l o g n ) tempo. ALVIE: colorazione di grafi a intervalli

Osserva, sperimenta e verifica IntervalGraphColoring

6.2.4

Massimo insieme indipendente in un grafo a intervalli

Il problema della colorazione dei nodi di un grafo è solo uno dei tanti problemi su grafi che, in generale, sono NP-completi, ma che divengono risolvibili in tempo polinomiale nel momento in cui li restringiamo a grafi a intervalli: un altro esempio di tali problemi è quello della ricerca del massimo insieme indipendente. Definiamo un insieme indipendente (independent set) come un sottoinsieme I r di r vertici, il cui sottografo indotto non possiede archi: IT è, quindi, complementare alla cricca K r che invece possiede tutti gli archi possibili. In generale, dato un grafo G, trovare un suo insieme indipendente di cardinalità massima è un problema NP-completo: notiamo come tale problema possa essere visto come un problema di colorazione, consistente nell'individuare il massimo numero di nodi che possono essere correttamente colorati facendo uso di un solo colore. In questo paragrafo, mostriamo che ciò è risolvibile in tempo polinomiale nel caso di grafi a intervalli.

Il problema del massimo insieme indipendente ha diverse applicazioni in molti contesti reali: l'esempio principe, in tal senso, è quello dell'assegnazione (scheduling) a un singolo elaboratore (sia esso umano, meccanico o elettronico) del massimo numero di compiti da eseguire. Ipotizzando, infatti, che i compiti non possano essere frazionati e che sussista tra i compiti una relazione di compatibilità, possiamo modellare tale problema nel modo seguente. Definiamo un grafo G i cui nodi corrispondono ai compiti da eseguire e i cui archi rappresentano la relazione di compatibilità tra di essi (ovvero, esiste un arco tra il compito t e il compito t ' se e solo se t e t ' possono essere entrambi eseguiti dall'elaboratore): un massimo insieme indipendente di G rappresenta una soluzione ottimale per il problema dell'assegnazione di compiti. Nel caso in cui i compiti siano specificati come intervalli di esecuzione, attraverso un tempo di inizio e un tempo di fine, e che due compiti siano compatibili se e solo se i loro corrispondenti intervalli non si intersecano, il problema dello scheduling si riduce a quello della ricerca del massimo insieme indipendente all'interno di un grafo a intervalli. L'algoritmo polinomiale per la risoluzione di tale problema è abbastanza simile a quello visto in precedenza per il problema della colorazione. Anche in questo caso, scandiamo gli intervalli da sinistra verso destra: ogni qualvolta incontriamo la fine di un intervallo che non ne interseca un altro precedentemente assegnato all'elaboratore, decidiamo di assegnare tale intervallo all'elaboratore stesso. In altre parole, questo approccio cerca di rendere l'elaboratore nuovamente disponibile il prima possibile, preferendo assegnare a esso i compiti che terminano prima. Il Codice 6.2 realizza questa strategia, ipotizzando per semplicità che gli intervalli abbiano gli estremi destri distinti e che quindi possano essere ordinati in modo crescente in base ai loro estremi destri (righe 3—6). Una volta eseguito l'ordinamento, l'algoritmo esamina uno dopo l'altro gli intervalli in base a tale ordine (righe 8-16) e, per ciascuno di essi, verifica (riga 10) se è il primo esaminato ( u l t i m o < 0) e, quindi, il primo a terminare, oppure se il suo tempo di inizio è successivo al tempo di conclusione dell'ultimo intervallo precedentemente assegnato (QÌ > b u i t imo)- Se è così, assegna l'intervallo esaminato e aggiorna il riferimento all'ultimo intervallo assegnato (righe 11 e 12), mentre in caso contrario decide di non assegnare l'intervallo esaminato (riga 14). Osserviamo anzitutto che la soluzione prodotta dal Codice 6.2 è un insieme indipendente. In effetti, in base alla guardia della riga 10 e al fatto che gli intervalli sono esaminati-in ordine crescente rispetto al loro tempo di conclusione, un intervallo che viene incluso nella soluzione non interseca nessuno di quelli precedentemente inclusi. Tale soluzione è anche ottimale, ovvero non può esistere un insieme indipendente di cardinalità maggiore. Supponiamo per assurdo che esista un insieme indipendente M tale che |M| > |S|, dove S è l'insieme indipendente calcolato dall'algoritmo. Supponiamo, inoltre, che i due

InsiemelndipendenteGraf olntervalli ( [a[0], b[0]],..., [a[n — 1], b[n — 1]] ) : (pre: i valori b[i] sono tutti distinti per 0 ij i SJ n — 1) FOR (i = 0; i < n; i = i + 1) { estremoDestro[i] = ;

> OrdinaCrescente( estremoDestro ); ultimo = -1; FOR (j = 0; j < n; j = j + 1) { = estremo[j]; IF ((ultimo < 0) II (a[i] > b[ultimo])) { assegnati] = TRUE;

ultimo = i; > ELSE { assegnati] = FALSE;

> > RETURN assegna; Codice 6.2

Algoritmo per la ricerca del massimo insieme indipendente in un grafo a intervalli.

insiemi siano in ordine crescente rispetto al tempo di conclusione degli intervalli in essi contenuti: pertanto possiamo considerare M come un insieme di indici { m o , . . . , m.h} tale che b m i < b m i + 1 per 0 ^ i < h, e S come un insieme di indici { s o , . . . , s j J tale che b S i < b Si + 1 per 0 ^ i < k (con h > k). Notiamo che M deve includere un intervallo T i cui tempi di inizio e di conclusione sono entrambi maggiori di b m k . Se dimostriamo che b S k < b m k , allora abbiamo che T viene esaminato dal Codice 6.2 successivamente all'intervallo [ a s k , b s j : in tal caso, T deve essere incluso in S in quanto il suo tempo di inizio è maggiore di b m k > b S k , contraddicendo il fatto che S contiene k intervalli. Per verificare che b Sk ^ b m k , mostriamo per induzione che b S i ^ b m i per 0 ^ i ^ k. Chiaramente, bSQ ^ b m o , in quanto il nostro algoritmo seleziona sempre l'intervallo che termina per primo. Per i > 0, abbiamo per ipotesi induttiva che b S i < b m i _ , . Inoltre, poiché M è un insieme indipendente, deve valere bTTli_1 < a m ( ^ b m i (due intervalli in M non si possono intersecare). Quindi l'intervallo [ a m i , b m J viene esaminato dal Codice 6.2 successivamente all'intervallo [a Si , , b S i ,] d'intervallo [ a S l , b S l ] non può avere un tempo di fine superiore a b m i , ovvero b S i ^ b m i . Abbiamo dunque dimostrato che l'insieme indipendente prodotto dal Codice 6.2 ha la cardinalità massima possibile. Per quanto riguarda la complessità dell'algoritmo, il costo dominante è quello dell'ordinamento, per cui la complessità totale è O ( n l o g n ) tempo.

ALVIE:

massimo insieme indipendente in grafi a intervalli

Osserva, sperimenta e verifica IndependentSet

6.2.5

Paradigma dell'algoritmo goloso

I due algoritmi polinomiali per la risoluzione dei problemi della colorazione di nodi e del massimo insieme indipendente nel caso di grafi a intervalli, seguono uno schema molto simile (non a caso i Codici 6.1 e 6.2 non sono molto diversi tra di loro). In effetti, una volta stabilito un ordine con cui esaminare gli intervalli, entrambi gli algoritmi decidono come comportarsi sulla base di scelte che appaiono in quel momento le migliori possibili. 4 Nel caso del problema della colorazione, ogni qualvolta esaminiamo un nuovo intervallo, decidiamo di assegnargli, se possibile, un qualunque colore tra quelli già utilizzati: in questo caso, il criterio adottato è quello di non utilizzare nuovi colori se non è necessario. Nel caso del problema del massimo insieme indipendente, invece, ogni qualvolta esaminiamo un nuovo intervallo decidiamo di includerlo nella soluzione se ciò è compatibile con quanto fatto fino a quel momento: in questo caso, quindi, il criterio adottato è quello di assegnare un compito se è possibile farlo. I due criteri sopra esposti conducono l'algoritmo di risoluzione a comportarsi in modo goloso, nel senso che spingono l'algoritmo a operare delle scelte solamente sulla base della situazione attuale, senza cercare di prevedere le conseguenze di tali scelte nelfuturo: per questo motivo, diciamo che i Codici 6.1 e 6.2 si basano sul paradigma dell'algoritmo goloso (greedy algorithm). Tale paradigma (che difficilmente può essere formalizzato in modo più preciso) risulta talvolta, ma non così spesso, vincente: il suo successo, in verità, dipende quasi sempre da proprietà strutturali del problema che non sempre sono evidenti. Nel caso dei due problemi su grafi a intervalli, abbiamo sfruttato proprietà geometriche del problema intrinseche alla sua restrizione al caso di grafi a intervalli. Osserviamo che, nonostante la sua semplicità, il paradigma dell'algoritmo goloso non risulta essere quasi mai una strategia facile da applicare. Una delle difficoltà principali da affrontare consiste nel decidere in che ordine dobbiamo esaminare i componenti di un'istanza del problema. Nel caso del problema del massimo insieme indipendente in grafi a intervalli, avremmo potuto decidere di esaminare gli intervalli ordinati in 4 N o n è in realtà la prima volta che incontriamo un algoritmo che si comporta in questo modo: la strategia SJF descritta nel Paragrafo 2.2, infatti, ordina i programmi da eseguire sulla C P U in base al loro tempo di esecuzione e, quindi, li assegna a essa in base a tale ordinamento.

al loro tempo di inizio, piuttosto che in base al loro tempo di fine: in tal caso, è facile verificare che l'algoritmo goloso corrispondente non calcola un insieme indipen- r dente di cardinalità massima. Supponiamo, infatti, che gli intervalli siano [1,10], [2, 5] e [6,9]: in tal caso, l'algoritmo goloso restituisce come soluzione l'insieme formato dal solo intervallo [1, 10], mentre l'insieme di cardinalità massima è costituito dagli altri due intervalli. L'algoritmo goloso può essere riformulato in termini di programmazione dinamica in cui, una volta ordinati in modo opportuno gli elementi di un'istanza, decomponiamo un problema in un unico sotto-problema, definito eliminando l'ultimo elemento nell'ordine specificato: la fase di ricombinazione consiste nel decidere in modo goloso se e in che modo aggiungere tale elemento alla soluzione del sotto-problema. In conclusione, il paradigma dell'algoritmo goloso non è, in generale, più semplice da utilizzare di quello della programmazione dinamica e raramente consente di risolvere in modo esatto un problema computazionale (anche se esso è stato applicato con un certo successo nel campo delle euristiche di ottimizzazione combinatoria e degli algoritmi di approssimazione). base

6.3

Grafi casuali e modelli di reti complesse

In molti contesti una struttura modellabile mediante un grafo deriva come conseguenza di una serie di attività svolte in modo indipendente, senza alcun coordinamento comune. I risultanti grafi non sono ottenuti da processi guidati da un qualche tipo di controllo centrale, ma piuttosto si accrescono per mezzo di processi evolutivi, in cui nuovi nodi e nuovi archi sono aggiunti al grafo sulla base di informazioni e caratteristiche locali, ignorando la struttura generale del grafo stesso. Tali strutture, denominate reti complesse, presentano l'ulteriore e non secondaria caratteristica di avere una dimensione molto elevata, sia in termini di archi che di nodi. Il più noto esempio di tale situazione è fornito dal World Wide Web, che può essere modellato mediante un grafo orientato in cui i nodi rappresentano pagine e gli archi rappresentano riferimenti {link) tra le pagine stesse. Tale grafo si è formato in modo "casuale", intendendo con tale termine il fatto che la creazione (o l'eliminazione) di nodi o archi avviene al di fuori di controlli centralizzati: ogni utente decide individualmente e indipendentemente i contenuti delle proprie pagine e i riferimenti ad altre pagine. Altri tipi di situazioni modellate mediante reti complesse compaiono in contesti molto diversi tra loro, di cui mostriamo ora alcuni esempi significativi. Le reti sociali sono usate per rappresentare persone, o gruppi di persone, e un qualche tipo di relazione tra loro, come l'amicizia e la conoscenza, ma anche relazioni di collaborazione nella produzione scientifica (ad esempio Erdos number) o compresenza quali interpreti in uno stesso ¿ film (ad esempio Six Degrees of Kevin Bacon, in breve ÓDKB). A tale proposito, il grafo

non orientato di 6DKB è un esempio frequentemente citato di tali reti, i cui nodi sono gli attori cinematografici e dove due nodi sono collegati mediante un arco se e solo se i due attori corrispondenti hanno recitato in uno stesso film. Dato un attore, il gioco consiste nel trovare un cammino fino all'attore Kevin Bacon; in alternativa, dati due attori, bisogna trovare un cammino che li collega. Alla data del 1997, il grafo di 6DKB conteneva più di 200.000 nodi collegati da circa 13 milioni di archi (nel 2005, il numero di nodi è diventato circa 800.000, ma per motivi di completezza faremo riferimento ai dati del 1997). Un'ulteriore tipologia di reti sociali, infine, è rappresentata da grafi in cui gli archi rappresentano comunicazioni di un qualche tipo, quali ad esempio scambio di messaggi (di posta ordinaria o elettronica) o chiamate telefoniche (ad esempio quello noto come AT&T caligraph). Le reti di informazioni sono utilizzate per rappresentare relazioni di rimando tra documenti di una qualche natura. Il grafo del Web è un esempio di rete di questo tipo, come anche i grafi costruiti per rappresentare l'insieme delle citazioni tra articoli di ricerca scientifica che appaiono nella loro bibliografia. Le reti tecnologiche rappresentano la struttura di reti di tipo tecnologico, quali ad esempio reti di distribuzione (elettrica, telefonica, ma anche Internet) o reti di trasporto (strade, ferrovie, collegamenti aerei e marittimi). Le reti biologiche infine modellano relazioni che sorgono in campo biologico, sia a livello di biologia molecolare e genetica che di etologia (relazioni predatore-preda) e di medicina (reti neurali, reti vascolari). L'obiettivo dello studio di grafi di questo tipo è quello di ottenere una caratterizzazione di parametri ritenuti significativi: ad esempio, cerchiamo le proprietà che caratterizzano la rete come il diametro del grafo corrispondente (la distanza tra i due nodi più lontani), o la distribuzione del grado dei nodi. Inoltre, visto che oggetto dello studio non sono in realtà i singoli grafi, ma le famiglie di grafi che modellano una stessa tipologia di relazione o sistema (ad esempio tutti i grafi che rappresentano la relazione di conoscenza tra persone, per insiemi diversi di persone), tale caratterizzazione è di tipo statistico. In definitiva, cerchiamo di ottenere una caratterizzazione, e quindi un modello matematico, della struttura generale di un qualunque grafo di grandi dimensioni che rappresenti una rete del tipo di quelle citate sopra. Tale caratterizzazione potrà essere utilizzata per comprendere le caratteristiche fondamentali del processo che ha portato alla costruzione del grafo risultante e per costruire algoritmi che generino istanze di grafi statisticamente simili a quelli considerati. Ciò permetterà di simulare e prevedere il risultato futuro del processo alla base della costruzione del grafo esaminato, e quindi il comportamento futuro della rete. Usando tale conoscenza possiamo inoltre costruire algoritmi che operano efficientemente, almeno in senso statistico, su tali grafi, utilizzando a tal fine le caratteristiche dei grafi stessi.

Esaminando le reti complesse finora studiate dai ricercatori, emergono alcune caratteristiche comuni. In primo luogo, il numero di archi è limitato rispetto al loro numero massimo, vale a dire m n ( n — 1 )/2, dove n è il numero di nodi e m è il numero di archi. È stato cioè osservato che le reti complesse esaminate tendono a essere sparse. In secondo luogo, le reti complesse tendono a presentare raggruppamenti di nodi o aggregazioni (cluster): intuitivamente, un insieme di nodi adiacenti a uno stesso nodo tende a formare una cricca. Come vedremo, l'abbondanza di aggregazioni in una rete viene misurata a partire da un parametro associato ai singoli nodi, denominato coefficiente di aggregazione: dato un nodo v, il relativo coefficiente di aggregazione C v è il rapporto tra il numero di archi presenti nel sottografo indotto dai nodi adiacenti a v, e il massimo numero possibile di archi tra tali nodi, corrispondente al caso in cui tali nodi formino una cricca. Formalmente,

= ^ d v ( d v - 1)

(6-1)

dove d v è il grado di v, m v è il numero di archi nel sottografo indotto dai nodi ad esso adiacenti e d v ( d v — 1 ) / 2 è il numero di archi nella cricca Kd v . Il coefficiente C di aggregazione di un grafo, cui faremo riferimento nel seguito, è allora definito come la media, sull'insieme dei nodi del grafo, di C v , vale a dire C = n Y-v C v E stato quindi osservato che, se consideriamo il coefficiente di aggregazione di una rete complessa, tale coefficiente tende ad assumere valori elevati. In terzo luogo, tali reti presentano un diametro relativamente piccolo in considerazione delle due caratteristiche precedenti: il diametro, vale a dire la distanza tra i due nodi più lontani, tende a essere più elevato per grafi sparsi (bisogna percorrere più archi per andare da un nodo a un altro) e per grafi aventi un maggior numero di aggregazioni (gli archi tendono a collegare solo nodi vicini, e archi che realizzano collegamenti "lunghi" sono rari). Invece, nonostante siano sparse e contengano un numero elevato di aggregazioni, molte reti complesse con n nodi hanno un diametro significativamente inferiore al suo valore massimo n . In quarto luogo, le reti complesse presentano una grande varietà nella distribuzione dei gradi dei nodi: vale a dire, esse contengono un numero significativo di nodi per ogni possibile valore del grado, all'interno di un intervallo ampio di tali valori. Descriviamo ora tre modelli classici di grafi casuali utilizzati per descrivere e generare grafi aventi caratteristiche quanto più possibile in accordo, da un punto di vista statistico, con le proprietà fondamentali delle reti complesse illustrate sopra, presentando al contempo dei semplici algoritmi per generare tali grafi in modo efficiente. I tre modelli verrano in particolare analizzati dal punto di vista della distribuzione statistica del coefficiente di aggregazione, del diametro e dei gradi dei nodi.

{pre: 0 ^ p ^ 1)

GeneraErdòsRényi (n, p) : FOR (u = 0; u < n; u = u + 1) { A[u] [u] = 0; FOR (V = u + 1; v < n; v = v + 1) { IF (random() < p) { A [u] [v] = A [v] [u] = 1; > ELSE {

A[u] [v] = A[v] [u] = 0;

> >

> RETURN A; Codice 6.3 Algoritmo per la generazione di un grafo casuale G„, p alla Erdòs-Rènyi.

6.3.1

Grafi casuali alla Erdòs-Rényi

Il modello classico di grafi non orientati costruiti in base a un processo casuale è il cosiddetto modello di Erdòs-Rényi, dal nome dei due matematici ungheresi che lo hanno introdotto alla fine degli anni '50. In tale modello, ipotizziamo di avere un insieme di n nodi e un valore prefissato di probabilità p, con 0 ^ p ^ 1. Nel modello supponiamo inoltre che, data una qualunque coppia di nodi u e v, l'arco (u, v) esista con probabilità p, indipendentemente dalle caratteristiche strutturali del grafo, come ad esempio dalla presenza di altri archi incidenti su u o v. La generazione di un grafo casuale di questo tipo, indicato con la notazione G n , p , può essere effettuata in tempo 0 ( n 2 ) mediante il Codice 6.3 che utilizza una primitiva random() per generare un valore reale r pseudocasuale appartenente all'intervallo 0 ^ r < 1, in modo uniforme ed equiprobabile. ALVIE: generazione di grafi casuali alla Erdòs-Rènyi

Osserva, sperimenta e verifica ErdosRenyiGraph

La distribuzione dei gradi dei nodi in un grafo casuale alla Erdòs-Rényi è descritta dalla probabilità pd che un nodo abbia grado d, probabilità caratterizzata dalla nota distribuzione di Bernoulli pd = (

n

^

1

)p

d

d-p)

n

-

d

-

1

(6.2)

La formula (6.2) deriva dall'osservazione che il grado di un nodo v è pari a d se esiste un sottoinsieme di d nodi, tra gli altri n — 1 nel grafo, a esso adiacenti, tale che nessuno dei rimanenti n — d — 1 nodi è adiacente a v. Ricordiamo che il numero di sottoinsiemi di cardinalità d in un insieme di n — 1 elementi è dato dal coefficiente binomiale ')• Inoltre, per ciascun sottoinsieme, la probabilità che tutti i suoi d nodi siano adiacenti a v è pari a p d , mentre la probabilità che nessuno degli altri n — d — 1 lo sia è data da ( 1 — p ) n _ d _ 1 : da ciò deriviamo la formula (6.2). Una nota proprietà della distribuzione di Bernoulli in (6.2) è la sua approssimabilità, per valori di n sufficientemente grandi, per mezzo della distribuzione di Poisson

pa =

z

-ir

(6.3)

dove z = p ( n — 1) è il grado medio di un nodo. I grafi casuali G n , p presentano ulteriori caratteristiche significative elencate di seguito. Come possiamo vedere nella formula (6.3), la probabilità che un nodo abbia grado d decresce esponenzialmente (ovvero, potremmo dire, precipitevolissimevolmente) al crescere di d. Possiamo mostrare tale proprietà facendo uso della seguente formula di Stirling per l'approssimazione del fattoriale: d! ss d d e~ d v / 27td

(6.4)

Applicando tale approssimazione alla formula 6.3 otteniamo chep^« (f)de-zV2^d, dal che possiamo concludere che se d > ez allora p^ decresce esponenzialmente al crescere di d. Da questo fatto consegue che in un grafo G n , p i gradi dei nodi tendono a essere addensati intorno al valore medio p(n— 1 ), con frazioni di nodi aventi grado maggiore o minore di tale valore che tendono rapidamente a svanire man mano che ci allontaniamo da esso (la nota curva a forma di "campana" centrata attorno al valore medio). A proposito del coefficiente di aggregazione di un grafo di questo tipo, osserviamo che tale valore risulta il più basso possibile, a parità di numero di archi nel grafo stesso: questo deriva dalla considerazione che in un grafo di questo tipo gli archi tendono a essere distribuiti in modo uniforme (addensamenti di archi su un sottoinsieme di nodi risultano poco probabili). Detto altrimenti, se un nodo ha z nodi adiacenti, allora il numero massimo di archi possibili tra i suoi nodi adiacenti è pari a z(z — l ) / 2 e il numero medio di archi presenti tra tali nodi è pari a pz(z — 1 )/2: da ciò consegue che il coefficiente C di aggregazione del grafo è pari a p. Se notiamo però che p è in media la frazione di archi presenti in un qualunque insieme di nodi, possiamo convincerci che non c'è nessun particolare addensamento di archi tra nodi vicini di uno stesso nodo, e quindi nessun effetto significativo di aggregazione.

Il diametro del grafo è con alta probabilità logaritmico nel numero di nodi: tale caratteristica è in realtà dovuta al fatto che, con alta probabilità, il diametro non differisce significativamente dalla distanza media tra due nodi scelti in modo casuale e che quest'ultima è logaritmica nel numero di nodi. Per giustificare informalmente quest'affermazione osserviamo che, indicando con z il grado medio, un nodo avrà all'incirca z nodi adiacenti (a distanza 1), z 2 nodi a distanza 2, z 3 nodi a distanza 3 e, in generale, all'incirca z s nodi a distanza s. Attraverso questo processo di ramificazione è possibile dimostrare che, con alta probabilità, per la distanza media s abbiamo che z s = 0(n), da cui otteniamo che s = 0 ( l o g n / logz). Nonostante le caratteristiche appena enunciate siano di grande utilità in diversi contesti, i grafi casuali di tipo Erdòs-Rényi non sembrano però adatti a rappresentare reti complesse come quelle discusse in precedenza. Tale difformità deriva da due fattori: come osservato sopra, da un lato la distribuzione dei gradi in G n , p risulta troppo accentrata intorno al valore medio rispetto a quanto non avvenga in una rete complessa; dall'altro non si presenta in G n , p alcun fenomeno di aggregazione, al contrario di quanto avviene nelle reti complesse. Un'ulteriore caratteristica significativa dei grafi casuali di tipo Erdos-Rényi riguarda l'andamento della dimensione delle varie componenti connesse al crescere della probabilità p, il quale presenta un effetto cosiddetto di soglia: vale a dire che la dimensione della componente connessa più grande nel grafo cambia al di sotto e al di sopra di un determinato valore di p, denominato appunto soglia. È interessante concludere con l'osservazione che in qualunque grafo, generato casualmente come sopra o meno, esiste sempre un sottografo regolare, come conseguenza della teoria di Ramsey (introdotta dal matematico inglese Frank Ramsey nei primi del Novecento). Ricordiamo che un insieme indipendente è un sottoinsieme I r di r vertici, il cui sottografo indotto non possiede archi e che I r è complementare alla cricca K r che invece possiede tutti gli archi possibili. Il numero di Ramsey R(k, l) è il più piccolo intero tale che ogni grafo con n > R(k, l) nodi contiene K^ oppure li come sottografo. Anche se, in generale, è difficile calcolarlo, Ramsey ha dimostrato che tale numero esiste sempre. In altre parole, il disordine totale è impossibile.

6.3.2

Grafi casuali con effetto di piccolo mondo

A un più attento esame, le reti complesse si collocano in una situazione intermedia tra i grafi casuali di tipo Erdòs-Rényi e i grafi regolari, caratterizzati dal fatto che i nodi hanno tutti lo stesso grado. In particolare, consideriamo i grafi regolari R ni d con n nodi, ciascuno di grado d pari, in cui ogni nodo u è collegato a d nodi in modo circolare (d/2 nodi che lo precedono e altrettanti che lo succedono), come illustrato nella Figura 6.14, dove ciascun nodo u è collegato ai nodi v = u + 1 e v = u + 2 modulo n in quanto d = 4. Il diametro della rete

Figura 6.14

Esempio di rete Rl2,4.

nella figura è pari a 3, mentre il coefficiente di aggregazione risulta pari a 1/2, poiché ogni nodo ha d = 4 vicini collegati da 3 archi (a fronte di 6 archi possibili). In generale, il diametro di R n ,d è 0 ( n / d ) , mentre il coefficiente di aggregazione rimane 1/2. Osserviamo quindi che, da un lato, le reti R n ,d hanno diametro e coefficiente di aggregazione elevati mentre, dall'altro, i grafi di tipo Erdòs-Rényi hanno diametro e coefficiente di aggregazione limitati. In tale scenario, le reti complesse sono ibride poiché combinano un diametro limitato con un elevato coefficiente di aggregazione pur essendo sparse: questa caratteristica viene indicata con il termine piccolo mondo (small world) ed è stata ampiamente divulgata nella letteratura e nella pubblicistica. L'origine del concetto di piccolo mondo deriva in effetti dallo studio di reti sociali, e in particolare della rete delle conoscenze tra persone: un famoso esperimento effettuato dal sociologo Stanley Milgram negli anni '60, cercò di valutare la distanza media tra una qualunque coppia di persone su un grafo di tale tipo, mediante un'operazione di instradamento "cooperativo" di messaggi. Nell'esperimento, una lettera doveva viaggiare da un mittente nel Nebraska a un destinatario nel Massachusetts. La lettera doveva essere inviata dal mittente a un suo conoscente a scelta, in modo tale da avvicinare, a suo avviso, il messaggio al destinatario: il conoscente in questione, e tutti gli intermediari successivi, ricevevano la richiesta di operare nello stesso modo, fino alla consegna della lettera al destinatario effettivo. L'esperimento appurò, sulla base di molti tentativi e contrariamente all'intuizione, che il numero medio di intermediari per ogni messaggio ricevuto dal destinatario era in effetti inferiore a 6: tale risultato dette luogo alla locuzione "6 gradi di separazione" ed è anche la ragione del numero 6 nel nome del grafo 6DKB.

Figura 6.15

Creazione di una rete di piccolo mondo a partire dalla rete regolare nella Figura 6.14.

Un esempio di rete sociale che presenta caratteristiche di piccolo mondo è, infatti, dato proprio dal grafo di 6DKB (dati riferiti all'anno 1997, in cui il numero n di nodi era circa uguale a 225000). Questa rete, con grado medio z pari a 61, ha una distanza media tra due nodi pari a 3,65 e un coefficiente di aggregazione C pari a 0,79. Volendo modellare tale rete come un grafo casuale di Erdòs-Rényi, dobbiamo fissare il valore di p pari a z / ( n — 1 ) ~ 0,00027: in tal caso, la distanza media è circa pari a l o g n / logz ~ 2,99 e il coefficiente di aggregazione è uguale a p ~ 0,00027. La rete 6DKB presenta quindi caratteristiche simili a un grafo casuale per quanto riguarda la distanza media tra nodi, per la quale abbiamo un incremento di circa il 22%, ma diverse rispetto al coefficiente di aggregazione, che risulta superiore per un fattore di circa 3000. La stessa situazione si presenta per il grafo orientato del World Wide Web, avendo anche quest'ultimo caratteristiche di piccolo mondo. Uno studio effettuato sul sottoinsieme del World Wide Web composto dai nodi nel dominio . edu ha ottenuto valori pari a 4,062 per la distanza media tra due nodi (4,048 per il corrispondente grafo casuale) e a 0,156 per il coefficiente di aggregazione (0,0012 per il corrispondente casuale). Per la loro caratteristica intermedia, le reti di piccolo mondo sono solitamente generate partendo da una rete regolare di grado d che viene poi modificata attraverso un uso limitato di casualità, come illustrato nel Codice 6.4, partendo da una rappresentazione mèdiante liste di adiacenza vuote, create attraverso la funzione N u o v a L i s t a , che vengono opportunamente riempite con il metodo A g g i u n g i L i s t a A d i a c e n z a . Il codice produce delle pertubazioni casuali a partire dalle reti R n ,a: infatti, sostituendo le righe 7 - 1 7 con la sola riga 16, otteniamo R n ,d- Tali righe simulano l'eliminazione di un arco (u,v) dalla rete regolare e il contemporaneo inserimento di un arco (u,w) verso un nodo w / v, determinato in modo casuale. In particolare, fissata una probabilità p ' di

GeneraPiccoloMondoCn, p', d): FOR (u = 0; u < N; u = u + 1) listaAdiacenza[u] = NuovaListaC ); FOR (u = 0; u < n; U = u + 1) FOR (j = 1; j ELSE

{

AggiungiListaAdiacenzaC u, v );

>

> AggiungiListaAdiacenzaC x, y ): e.chiave = y; listaAdiacenza[x].InserisciFondoC e ); e.chiave = x; listaAdiacenza[y].InserisciFondoC e ); Codice 6.4

Generazione di un grafo casuale con effetto di piccolo mondo ottenuta perturbando la costruzione di un grafo non orientato regolare di grado d pari.

"ridirezionamento", il codice considera l'uno dopo l'altro tutti gli archi della rete: ogni arco (u,v) considerato viene eliminato con probabilità p ' e sostituito da un arco (u, w), dove w è un nodo determinato in modo casuale e uniforme tra i rimanenti n — 1 nodi (righe 7-12), come illustrato nella Figura 6.15. ALVIE:

generazione di grafi casuali con effetto di piccolo mondo

Osserva, sperimenta e verifica SmallWorldGraph

• _



Poiché il numero di archi è m = O ( d n ) , l'algoritmo richiede tempo lineare nella dimensione del grafo. I nuovi archi introdotti vengono a fungere da "scorciatoie" tra regioni diverse del grafo e, come vedremo, sono sufficienti a limitare la distanza media

Figura 6.16

Andamento di L(p')/L(0) (curva continua) e C(p')/C(0) (curva tratteggiata) al crescere della probabilità p'.

tra due qualunque nodi. Al tempo stesso, il coefficiente di aggregazione della rete non viene modificato in modo sostanziale dai nuovi archi introdotti, se la probabilità p ' è sufficientemente piccola. Data la probabilità p ' , indichiamo con L(p') la distanza media tra due nodi nella rete risultante dal Codice 6.4 e con C(p') il suo coefficiente medio di aggregazione. Possiamo confrontare queste due quantità con quelle della rete R n ,d di partenza, che sono indicate con L(0) e C(0) in quanto nessun arco viene cambiato: la Figura 6.16 mostra l'andamento, in funzione della probabilità p ' riportata in ascissa in scala logaritmica, dei rapporti L(p')/L(0) (curva continua) e C(p')/C(0) (curva tratteggiata). Esiste un intervallo all'interno del quale la distanza media è diminuita significativamente mentre il coefficiente di aggregazione è rimasto immutato: in questo intervallo la rete presenta quindi l'effetto desiderato di piccolo mondo. In particolare, potremmo scegliere 0,01 come valore di p ' , ottenendo una trascurabile diminuzione del coefficiente di aggregazione a fronte di una riduzione di oltre l'80% della distanza media. Per quanto riguarda il grado medio dei nodi nella rete così costruita, esso risulta indipendente dal valore p ' in quanto il numero di archi rimane costante al variare di tale probabilità. Osserviamo inoltre che, al variare di p ' , passiamo da una rete in cui tutti i nodi hanno lo stesso grado, per p ' = 0, a una rete simile ai grafi casuali di Erdòs-Rényi, p e r p ' = 1. In ogni caso, la distribuzione del grado dei nodi risulta concentrata intorno al valor medio, cosa che non si verifica in generale per le reti complesse esaminate. Per superare questa limitazione, vediamo un modello alternativo che consente di rappresentare e ottenere grafi casuali con le medesime distribuzioni dei gradi delle reti complesse osservate "in natura", pur non assicurando di generare reti di piccolo mondo.

6.3.3

Grafi casuali invarianti di scala

Una delle caratteristiche rilevanti nelle reti complesse è quella relativa alla distribuzione dei gradi dei nodi. Differentemente dai grafi casuali, questa distribuzione segue una legge di potenza (power law) del tipo Pd ~ d Y in funzione del grado d, dove y > 0 è una costante che dipende dalla rete considerata e che risulta, per i casi osservati, compresa tra 2,1 e 4: per esempio, y ~ 2 , 3 per il grafo di ÓDKB, mentre y ~ 3 per il grafo che modella le citazioni tra articoli di ricerca. La Figura 6.17 riporta un diagramma a scala logaritmica su entrambi gli assi, che mostra l'andamento di una distribuzione con legge di potenza e di una con legge esponenziale, come quella di un grafo casuale. La distribuzione esponenziale presenta un intervallo ristretto in cui assume valori elevati seguito da un punto di caduta ( c u t - o f f ) sulle ascisse, oltre il quale il numero di nodi è praticamente nullo: in sostanza, la rete non presenta nodi con grado significativamente superiore al punto di caduta, che definisce, quindi, un limite superiore sul possibile grado di un nodo. E possibile verificare che una distribuzione a legge di potenza risulta invariante di scala, vale a dire che essa appare uguale a se stessa, indipendentemente dalla scala a cui viene esaminata. Diciamo che una funzione f(x) è invariante rispetto alla scala se vale la proprietà f(ax) = g(a)f(x) per ogni a, dove g( ) è una funzione dipendente da f( ). L'idea di fondo è che un incremento di un fattore a nella scala (e quindi nell'unità di misura di x) non determina variazioni di f(x), eccetto che per un fattore moltiplicativo di scala. Anche se, in linea di principio, tale proprietà risulta soddisfatta soltanto da funzioni a legge di potenza, del tipo f(x) = c x a , per le quali vale f(ax) = c ( a x ) a = ( c a a ) x a , è uso comune considerare invarianti di scala anche funzioni che all'infinito tendono a coincidere con funzioni del tipo suddetto. In generale, un grafo è detto invariante di scala se la distribuzione dei gradi dei suoi nodi è una funzione invariante di scala e quindi, in particolare, se segue una legge di potenza. Le reti complesse osservate presentano la caratteristica di essere invarianti di scala, almeno in linea di principio: infatti, esse presentano una distribuzione dei gradi dei nodi che segue una legge a potenza fino a un certo punto, oltre il quale il numero dei nodi decresce rapidamente, anche in considerazione del fatto che la rete ha comunque una dimensione finita. In una rete complessa, quindi, la probabilità che un nodo abbia grado d decresce polinomialmente al crescere di d: pertanto, in una tale rete il grado dei nodi è molto più differenziato che non in un grafo casuale alla Erdòs-Rényi, dove decresce esponenzialmente all'allontanarsi dal valore medio. In una rete complessa compaiono nodi aventi grado più elevato del grado medio, fenomeno assente nei modelli finora discussi. Se consideriamo la struttura del grafo del World Wide Web, ad esempio, possiamo osservare che al suo interno compare una quantità significativa di portali, corrispondenti a nodi aventi grado molto elevato. In effetti, è possibile verificare empiricamente che la

Figura 6.17

Distribuzione con legge a potenza (linea tratteggiata) e distribuzione esponenziale (curva continua) su scala logaritmica.

distribuzione dei gradi in ingresso nel World Wide Web rispetta una legge a potenza con y ~ 2,1, mentre la distribuzione dei gradi in uscita ne rispetta una con y ^ 2,45. L'invarianza di scala delle reti complesse sembra derivare da due fattori. Da un lato, le reti non sono costruite inserendo archi su un insieme di nodi inizialmente privo di archi; al contrario, esse si espandono anche per mezzo di un inserimento continuo di nuovi nodi. Ad esempio, il World Wide Web cresce nel tempo mediante la creazione di nuove pagine, che vengono collegate a quelle già esistenti, così come la rete di collaborazioni tra attori si estende con l'aggiunta di nuovi debuttanti. Inoltre, i nuovi nodi tendono a essere collegati ai nodi aventi grado più elevato: ad esempio, una nuova pagina del World Wide Web tenderà ad avere collegamenti verso i siti più noti, come molte altre pagine. La probabilità con cui un nuovo nodo si collega ai nodi esistenti non è quindi uniforme: al contrario, i nodi aventi grado più elevato hanno maggiore probabilità di essere riferiti dalle nuove pagine secondo il principio per cui "il ricco diventa sempre più ricco". Tra i vari approcci introdotti per costruire grafi non orientati aventi distribuzione dei gradi che rispettino una legge a potenza, il più semplice procede come mostrato nel Codice 6.5, utilizzando liste di adiacenza vuote, create con N u o v a L i s t a ed estese con

GeneraScalabile(n): arrayArchi = {(0,1), (1,2), (2,0)}; F O R (t = 0; t < 3; t = t + 1 ) listaAdiacenza[t] = NuovaLista( ); AggiungiListaAdiacenza( 0, 1 ); AggiungiListaAdiacenza( 1, 2 ); AggiungiListaAdiacenza( 2, 0 ); F O R (t = 2; t < n-1; t = t + 1) { i = (2 x t - 1) x randomO; (u, w) = arrayArchi [i]; arrayArchi[2t-l] = (t+1, u); arrayArchi[2t]= (t+1, w); listaAdiacenza[t+l] = NuovaLista( ); AggiungiListaAdiacenza( t+1, u ); AggiungiListaAdiacenza( t+1, w );

} Codice 6.5

Algoritmo per la generazione di un grafo casuale scalabile.

• Il procedimento inizia all'istante t = 1, in una configurazione in cui il grafo è composto da una cricca K3 con 2t + 1 = 3 archi (e i cui nodi sono 0 , 1 , 2 come mostrato nella righe 2—7). • A ogni istante t ^ 2, il nodo t + 1 viene creato e due nuovi archi incidenti su di esso vengono aggiunti al grafo. A tal fine, un arco ( u , w ) viene determinato in modo casuale (e con distribuzione uniforme) tra i 2t — 1 archi già presenti nel grafo e memorizzati in a r r a y A r c h i (righe 9-10): i due nuovi archi introdotti sono allora (t + l , u ) e (t + l , w ) , dando luogo a un grafo con 2t + 1 archi che collegano t + 2 nodi (righe 11-15). Nella Figura 6.18 mostriamo un esempio di costruzione di un grafo con distribuzione dei gradi dei nodi secondo la legge a potenza, limitatamente ai primi otto nodi. Per ogni passo, riportiamo i valori di t, u , w al fine di determinare quali sono gli archi che sono stati inseriti nel passo stesso.

ALVIE:

generazione di grafi casuali con invariante di scala

Osserva, sperimenta e verifica ScaleFreeGraph

Figura 6.18

Costruzione di grafo scalabile.

Nella costruzione precedente, il grado di un nodo aumenta, a ogni passo, con probabilità tanto maggiore quanto maggiore è il grado del nodo stesso: ciò deriva dal fatto che la probabilità di scegliere un arco incidente su un nodo aumenta al crescere del suo grado (in quanto gli archi vengono scelti in modo uniforme). Pertanto, nodi "ricchi" tendono a diventare sempre più ricchi. Il grafo risultante rispetta una legge a potenza. Consideriamo l'evento che un qualunque nodo v abbia grado d all'istante t e indichiamo con p(d, t) la sua probabilità. Tale evento si verifica: 1. se v aveva grado d — 1 all'istante t — 1 e uno degli archi incidenti su v è stato scelto tra i 2t — 1 archi del grafo, oppure 2. se v aveva grado d all'istante t — 1 ed è stato scelto uno dei 2t — 1 — d archi non incidenti su v. Dato che la probabilità che il nuovo arco scelto sia incidente a v è pari al rapporto tra il grado di v e il numero complessivo di archi nel grafo, ne consegue che, che p(d, t) può essere espressa mediante la seguente relazione di ricorrenza: p(d, t) = ^ - ^ p ( d - l , t - l ) +

2 t

~^~dp(d,t-l).

(6.5)

È possibile dimostrare, a partire dall'equazione (6.5), che la probabilità che un nodo abbia grado d tende, al crescere del numero di nodi, al valore

P(J)=

d ( d + l ' ) 2 ( d + 2) = e ' d ~ 3 > -

verificando così che il grafo ottenuto modella una rete invariante di scala.

6.4

Opus libri: motori di ricerca e classificazione

La disponibilità di grandi quantità di informazioni resa possibile, in particolare, da Internet e il World Wide Web, se da un lato fa sì che abbiamo accesso a una grande quantità di documenti di interesse, dall'altro introduce la necessità di avere informazioni sulla maggiore o minore significatività, ai propri fini, dei documenti accessibili. Una tipica operazione effettuata nell'accesso al Web è quella di richiedere tutti i documenti che soddisfano una certa interrogazione (query), generalmente definita usando i termini presenti nei documenti (per esempio, trovare tutti i documenti contenenti i termini "web" e "searching"). Quest'operazione viene resa possibile dall'utilizzo di motori di ricerca, ovvero di sistemi disponibili in linea che effettuano una raccolta e una catalogazione dei documenti accessibili sul Web e che consentono di interrogare velocemente il catalogo così costruito utilizzando, ad esempio, le liste invertite descritte nel Paragrafo 5.6 (una nota regola empirica stabilisce che la risposta debba essere fornita entro 200 millisecondi affinché un essere umano la percepisca come rapida). Data però la dimensione del Web, la semplice restituzione di un elenco, che può essere molto lungo, di tutti i documenti che soddisfano l'interrogazione non fornisce una soluzione pratica al problema di estrarre le informazioni più rilevanti (per esempio, la ricerca della parola "algoritmo" restituisce oltre due milioni di documenti mentre quella di "web searching" ne restituisce oltre 150 milioni). Vorremmo estrarre dall'elenco di tali documenti un sottoinsieme S con le seguenti caratteristiche: • S contiene relativamente pochi documenti (un centinaio al massimo); • i documenti in S sono rilevanti per l'utente che ha formulato l'interrogazione; • molti dei documenti in S provengono da fonti autorevoli. E quindi necessario che i documenti che soddisfano una certa interrogazione vengano ordinati in modo tale che i più significativi siano presentati prima degli altri: a tal fine, un motore di ricerca effettua, oltre alla ricerca dei documenti, la loro classificazione in base a un valore di significatività o rango (rank) assegnato a ciascun documento, per restituire i documenti stessi ordinati per rango decrescente.

Data una collezione di documenti D, intendiamo definire una funzione r a n g o : D — i > R tale che, per ogni coppia di documenti di e d2 in D, consideriamo di più rilevante di d.2 se r a n g o ( d i ) > rango(d2). Nel caso di collezioni di documenti convenzionali la funzione r a n g o veniva tradizionalmente calcolata sulla base del loro solo contenuto. Con l'avvento delle tecnologie ipertestuali, in cui i documenti possono essere arricchiti con riferimenti ad altri documenti (come nel caso del Web in cui i documenti sono pagine collegate tra di loro), l'insieme dei collegamenti tra i documenti stessi viene utilizzato per estrarre ulteriori informazioni sulla loro significatività: in altre parole, la funzione di rango usa, oltre al contenuto dei documenti, anche la struttura a grafo orientato dell'intera collezione (in cui i vertici sono i documenti e gli archi orientati sono i collegamenti tra di loro). L'intuizione, in questo caso, è che quanto più un documento è riferito da altri documenti, tanto più esso è significativo all'interno della collezione D: infatti, tali riferimenti sono ritenuti essere l'espressione di una forma latente di giudizio umano. In un certo senso, la significatività di un documento viene determinata in modo democratico, attraverso un meccanismo di votazione in cui l'introduzione di un riferimento dal documento di al documento d2 assume la valenza di un voto espresso da di a favore di d2- Se consideriamo quindi il grafo del Web definito sopra, l'intuizione è che una pagina è tanto più significativa quanto maggiore è il grado di ingresso del nodo corrispondente, cioè il numero di archi entranti in tale nodo. Questo approccio alla misurazione del rango di un documento rende il risultato di tale processo particolarmente robusto rispetto a tentativi di modificarne in modo artificioso il risultato, quando una grande quantità di "elettori" ovvero di documenti sono coinvolti. In particolare, nelle situazioni in cui il rango di un documento è calcolato a partire solo dal suo contenuto, quest'ultimo può essere manipolato dal suo produttore (ad esempio, introducendo termini appositi) al fine di aumentare in modo indebito il rango del documento: tale attività è nota, nel caso del Web, come search spam e consiste, ad esempio, nel manipolare elementi di una pagina quali il titolo, le parole chiave e i testi associati ai riferimenti (snippet). Al contrario, una funzione di rango basata anche sui riferimenti tra i documenti risulta di difficile manipolazione da parte di un singolo produttore, e rende quindi il rango stesso più resistente a questo tipo di search spam. Nel processo di votazione appena descritto (basato esclusivamente sul grado di ingresso dei nodi), dobbiamo però tenere presente anche un altro aspetto, cioè che un riferimento proveniente da un documento che ne ha molti in uscita deve avere una rilevanza minore rispetto a quello di un documento che ne ha pochi, perché presupponiamo che nel secondo sia stata effettuata una scelta più accurata dei documenti da riferire. Inoltre, dobbiamo tenere conto anche della significatività dei documenti, in quanto un riferimento proveniente da un documento "autorevole" è più influente rispetto a quello proveniente da uno meno rilevante.

Attualmente, i più importanti motori di ricerca sul Web utilizzano una propria funzione di rango basata sull'analisi dei collegamenti (link analysis) e, in linea di principio, operano secondo il seguente schema comune: 1. impiegano specifici programmi, detti crawler o spider, che visitano e raccolgono le pagine del web seguendo i link che le collegano; 2. analizzano il testo in ogni pagina raccolta e costruiscono un indice di ricerca dei termini che compaiono nelle pagine stesse; 3. tengono traccia dei link tra le pagine, costruendo quindi la matrice di adiacenza del grafo del Web (o meglio, della sua porzione visitata); 4. calcolano, a partire dalla matrice di adiacenza, il rango delle pagine raccolte (o di un loro sottoinsieme) secondo modalità che possono variare da motore a motore. Nel resto di questo paragrafo, discutiamo due approcci che hanno ispirato gli attuali metodi di realizzazione della funzione di rango: osserviamo sin d'ora che tali approcci non sono tra di loro alternativi, ma sono piuttosto complementari. Il primo approccio, detto PageRank e utilizzato da Google insieme ad altri metodi, calcola (e ricalcola periodicamente) il rango di tutte le pagine raccolte: le pagine restituite in seguito a un'interrogazione vengono poi mostrate all'utente in ordine decrescente di rango. Il secondo approccio, detto HITS (Hypertext Induced Topic Selection o selezione degli argomenti indotta dagli ipertesti) e utilizzato inizialmente da Clever dell'IBM, ha in parte ispirato successivi motori di ricerca come Ask, il quale usa ExpertRank: l'approccio HITS identifica prima un opportuno insieme D di pagine che, in qualche modo, sono collegate all'interrogazione e di queste solamente ne calcola il rango che viene utilizzato per decidere l'ordine con cui mostrare le pagine in D. Osserviamo che la collezione D (dominio della funzione r a n g o ) è costituita da tutte le pagine raccolte dai crawler nel caso di PageRank, mentre tale collezione è formata dalle sole pagine collegate all'interrogazione nel caso di HITS: in quest'ultimo caso, la stessa pagina può avere valori di rango diversi a seconda dell'interrogazione. Da quanto appena esposto, è chiaro che i due approcci possono essere combinati utilizzando HITS per raffinare ulteriormente i risultati forniti da PageRank: osserviamo inoltre che i due approcci possono essere usati in generale per calcolare il rango dei nodi di un qualunque grafo orientato o di un suo sottografo indotto, utilizzando i loro archi come meccanismo di votazione.

6.4.1

Significatività delle pagine con PageRank

Come osservato in precedenza, una funzione di rango basata sul sistema di votazione e da applicare al grafo del Web rispetta due principi fondamentali: il rango di una pagina è direttamente proporzionale al rango delle pagine che la riferiscono e inversamente proporzionale al grado in uscita di tali pagine. Il sistema di votazione prevede che ogni pagina esprima almeno una preferenza, ovvero esso interpreta l'astensione di una pagina come una distribuzione uniforme di preferenze a tutte le pagine del Web (per quella

pagina tutte le pagine del Web sono ugualmente importanti). In termini della matrice di adiacenza del Web, ciò vuol dire che le righe con tutti 0 (corrispondenti alle pagine senza link in uscita) diventano righe con tutti 1. A queste osservazioni, dobbiamo aggiungere quella che il tipico navigatore (surfer) non sempre si muove all'interno del Web seguendo un link, ma talvolta utilizza la barra dei comandi di un browser per spostarsi direttamente su una pagina non necessariamente collegata a quella attuale: supponiamo che questo avvenga con probabilità 1 — a con 0 < a < l. 5 Per ogni pagina i, indichiamo nel seguito con E(i) l'insieme delle pagine che riferiscono i e con u s c i t a ( i ) il grado in uscita di i. Il PageRank modella le suddette osservazioni definendo matematicamente la seguente funzione r a n g o per ogni pagina j:

r a n g o ( j ) = (1 - a) + a

Y_ ieE(j)

rango(t uscita(i)

(6.6)

Tale formula intuitivamente descrive il fatto che con probabilità oc la pagina j viene raggiunta seguendo un link da una delle pagine che la riferiscono, mentre con probabilità 1 — a essa viene raggiunta digitando direttamente il suo indirizzo nella barra dei comandi (quando PageRank è stato introdotto, a = 0,85). Sia A la matrice di adiacenza del grafo del Web, ovvero A[i][j] = 1 se e solo se esiste un link dalla pagina i alla pagina j per 0 ^ i, j ^ n — 1, dove n indica il numero di pagine (notiamo che, da quanto osservato in precedenza, per ogni i deve esistere almeno un valore di j tale A[i][j] = 1): quindi, i € E(j) se e solo se A[i][j] = 1. Il calcolo della funzione r a n g o secondo l'equazione (6.6) è esplicitato nel Codice 6.6, che per prima cosa calcola il grado in uscita di ogni nodo e inizializza un array mono-dimensionale di n elementi, detto vettore di rango, per il quale al termine dell'esecuzione vale X[j] = r a n g o ( j ) per 0 ^ j ^ n — 1 (righe 3—8). Il ciclo successivo (righe 9-18) calcola il rango delle pagine usando l'equazione (6.6): poiché tale equazione è ricorsiva, non è sufficiente una sua sola applicazione, ma il calcolo deve essere effettuato in maniera iterativa, in modo che 1 valori calcolati a un passo divengano i valori in ingresso del passo successivo (righe 10 e l i ) , fino a raggiungere il risultato finale (riga 18). In particolare, le righe 12-17 eseguono un passo di tale calcolo iterativo: notiamo che, poiché A[i][)] = 1 se i € E(j) e A[i][j] = 0 altrimenti, il ciclo f o r più interno (righe 14—15) corrisponde al calcolo del valore X!igE(j) usTit°a(V) P r e s e n t e nell'equazione (6.6). Quando il ciclo d o - w h i l e termina (ovvero, il nuovo valore coincide con quello calcolato al passo precedente), il valore di X che viene restituito alla riga 19 soddisfa l'equazione (6.6): pertanto, il Codice 6.6, se termina, calcola il valore di PageRank. 5 In realtà, non stiamo considerando in questo contesto la possibilità che il navigatore utilizzi i pulsanti di navigazione generalmente disponibili nei browser.

PageRank( A ): (pre: A è una matrice binaria n x n, tale che FOR (j = 0; j < n; j = j+1) { uscita[j] = 0; FOR (i = 0; i < n; i = i+1) uscita[j] = uscita[j] + A[j][i]; X[j] = 1;

^Jo' A[i] [j] > 0 per 0 ^ i < n)

> DO { FOR (j = 0; j < N; j = j+1)

Y[j] = X[j] ; FOR (j = 0; j < n; j = j+1) {

X[j] = FOR (i X[j] X[j] =

0; = 0; i < n; i = i+1) = X[j] + A[i] [j] x Y[i] / uscitati] ; ( 1 - a ) + a x X[j] ;

} > WHILE (X != Y); RETURN X; Codice 6.6 Algoritmo per il calcolo di PageRank.

ALVIE:

calcolo di PageRank

Osserva, sperimenta e verifica PageRank

Per quanto riguarda la complessità del codice, ogni iterazione del ciclo d o - w h i l e richiede 0 ( n 2 ) tempo: quindi, la complessità temporale è 0 ( b n 2 ) dove b indica il numero di iterazioni che sono eseguite. In linea di principio, b potrebbe essere un valore infinito, in quanto i valori X e Y calcolati a ogni iterazione potrebbero oscillare senza mai convergere allo stesso valore: inoltre, la convergenza potrebbe dipendere dai valori con cui inizializziamo X (riga 7). Vedremo nel Paragrafo 6.4.3 che ciò non può accadere, facendo uso di strumenti di algebra lineare. Una naturale interpretazione del Codice 6.6 consiste nell'immaginare ogni pagina fornita inizialmente di un "credito" di significatività di default (pari a 1 nel codice): successivamente, ogni pagina decide di distribuire in parti uguali il suo credito alle pagine da essa riferite, ricevendo in cambio un contributo da ciascuna pagina che la riferisce.

Figura 6.19

Effetti dell'apertura verso l'esterno sul valore di PageRank.

Anche se da un Iato aprire all'esterno una pagina, includendo in essa dei link in uscita, causa una perdita di rango, dall'altro una tale apertura può incrementare il numero di riferimenti alla pagina stessa e, quindi, aumentare il flusso di rango in entrata che, in linea di principio, potrebbe compensare e superare quello in uscita. Consideriamo l'esempio mostrato nella parte sinistra della Figura 6.19, in cui il sito Web formato dalle due pagine 0 e 1 è isolato rispetto al resto del mondo (in questo caso rappresentato dalle tre pagine 2, 3 e 4): notiamo che in questa situazione il rango totale del sito formato dalle due pagine (che è pari al numero delle pagine stesse), viene equamente distribuito tra di esse, in quanto le due pagine si riferiscono vicendevolmente. Volendo aprire tale sito all'esterno, possiamo decidere di farlo in diversi modi. Ad esempio, possiamo decidere di collegare la pagina 0 alla pagina 2 e viceversa, come mostrato nella parte centrale della figura (ovviamente, un'apertura verso l'esterno deve essere contraccambiata da un riferimento in entrata): in tal caso, il sito globalmente perde del rango, passando da 2 a poco più di 1. Se, però, decidiamo di collegare la pagina 0 alla pagina 3 e viceversa, come mostrato nella parte destra della figura, la situazione cambia drasticamente: la pagina 0 arriva a un rango superiore a 2 e anche la 1 aumenta leggermente il suo rango, così che il rango totale passa da 2 a 3,3. In altre parole, gestendo l'apertura verso l'esterno in modo opportuno, il rango delle proprie pagine può migliorare significativamente. Un'altra interessante osservazione consiste nel fatto che aprire un sito verso l'esterno può essere accompagnato da una ristrutturazione del sito stesso, in modo da diminuire la quantità di rango che viene perso a causa dell'apertura. Consideriamo la situazione mostrata nella parte sinistra della Figura 6.20, in cui la pagina 3 potrebbe essere una tipica pagina contenente un elenco di riferimenti a pagine esterne al sito formato dalle pagine 0, 1, 2 e 3. Come possiamo vedere, il rango iniziale (pari a 4) del sito si riduce significativamente a causa della pagina 3: il rango totale, dopo

0,42

Figura 6.20

0,8

Effetti della ristrutturazione di un sito web sul valore di PageRank.

l'apertura verso l'esterno, è divenuto pari a circa 2 , 2 1 3 con una perdita del 45%. In questo caso, possiamo progettare una ristrutturazione del sito, aggiungendo delle pagine di recensione delle pagine esterne a cui la pagina 3 fa riferimento e che a loro volta fanno riferimento alla pagina 0 (che funge da home page del sito), come mostrato nella parte destra della figura: cosi facendo il rango che fuoriesce dal sito si riduce significativamente e il rango totale passa da un potenziale di 7 a un valore attuale pari a circa 5,485, con una perdita del 22%. Queste considerazioni mostrano che diverse strategie in grado di manipolare il valore di PageRank sono state progettate allo scopo (generalmente concertato) di migliorare la posizione delle proprie pagine nell'ordine di apparizione e, quindi, di "monetizzare" la creazione di riferimenti. Queste strategie hanno avuto un certo impatto sull'affidabilità del concetto di rango di una pagina Web. Sappiamo che il motore di ricerca Google penalizza esplicitamente le cosiddette fabbriche di link e altre manipolazioni progettate per aumentare artificialmente il rango di una pagina: non conosciamo, però, i meccanismi con cui Google riconosce tali fabbriche e tali manipolazioni. Un altro (non indifferente) svantaggio di PageRank è che esso tende a favorire pagine più vecchie: in effetti, una pagina nuova, anche se molto interessante, non avrà all'inizio molti riferimenti in entrata, a meno che non sia parte di un sito già esistente e con un insieme di pagine fortemente connesse tra di loro. Per tutti questi motivi, Google non usa solo PageRank per calcolare il rango: si vocifera che usi oltre 100 fattori moltiplicativi, tra i quali PageRank è uno dei tanti (e forse nemmeno troppo importante). Google suggerisce, quasi banalmente, che il miglior modo per acquisire un alto rango è quello di creare pagine con contenuti di qualità.

6.4.2

Significatività delle pagine con HITS

La funzione di rango realizzata da HITS è motivata dall'osservazione che le pagine significative per certi termini di ricerca non sempre contengono quei termini stessi: per esempio, né la pagina principale di www. f e r r a r i . i t né quella di www . f i a t . i t contengono esplicitamente il termine "automobile". Inoltre tali pagine non si riferiscono vicendevolmente in modo diretto, mentre lo fanno indirettamente attraverso qualche loro pagina secondaria oppure attraverso siti specializzati per gli appassionati di automobili. Nella terminologia di HITS, le pagine w w w . f e r r a r i . i t e w w w . f i a t . i t sono delle autorità (authority) nel campo automobilistico (mentre non lo sono in quello enologico o di letteratura latina), e un sito specializzato che contiene dei riferimenti a entrambe viene denominato concentratore di collegamenti (hub). Il fenomeno osservato è quindi quello del mutuo rafforzamento in termini di significatività, secondo i seguenti principi: • un concentratore è tanto più significativo quanto lo sono le autorità a cui si riferisce e, quindi, il peso del primo è direttamente proporzionale al peso delle ultime; • un'autorità è tanto più significativa quanto lo sono i concentratori che la riferiscono e, quindi, il peso della prima è direttamente proporzionale al peso degli ultimi. HITS classifica implicitamente le pagine in base ai principi sopra esposti: ogni pagina è simultaneamente un'autorità e un concentratore, chiaramente con peso molto variabile. A tal fine, le seguenti due funzioni di rango sono utilizzate per una data collezione D di documenti: • rangoA(j) misura il peso in D della pagina j intesa come autorità; • rangoC(j) misura il peso in D della pagina j intesa come concentratore. Da quanto osservato sopra, possiamo derivare le equazioni ricorsive che definiscono le suddette funzioni per la pagina j nella collezione D, indicando con E(j) C D l'insieme delle pagine che riferiscono j e con U(j) C D quelle riferite da j, all'interno della collezione stessa: rangoA(j) = ^ rangoC(i) (6.7) iGE(j)

rangoC(j) =

rangoA(k)

(6.8)

k€U(j)

Analogamente al PageRank, possiamo effettuare il calcolo di tali funzioni mediante un procedimento iterativo, assegnando un valore iniziale pari a ^ a ciascuna pagina j e normalizzando di volta in volta il valore di rango, per mantenere l'invariante che £ j € D rangoA(j) = £ j 6 D rangoC(j) = 1.

HITS ( A ) : {pre: A è La matrice di adiacenza di un grafo G con n nodi) FOR (j = 0; j < n; j = j + 1) X[j] = W[j] = 1/n; DO {

sommaX = FOR (j = { Y[j] FOR (j = X[j] = FOR (i X[j] FOR (k W[j] sommaX sommaW

sommaW = 0; 0; j < N; j = j+1) = X[j] ; Z[j] = W[j] ; > 0; j < n; j = j+1) { W[j] = 0; = 0; i < n; i = i+1) = X[j] + A[i] [j] x Z[i] ; = 0; K < N; k = k + 1 ) = W[j] + A[j] [k] x Y[k] ; = sommaX + X[j]; = sommaW + W[j];

} FOR (j = 0; j < n; j = j+1)

{ X[j] = X[j] / sommaX; W[j] = W[j] / sommaW; > > WHILE ((X != Y) Il (W != Z)); RETURN ; Codice 6.7

Algoritmo per il calcolo di HITS.

Per semplificare la notazione, il Codice 6.7 suppone che il sottoinsieme delle pagine del grafo del Web che compongono la collezione D siano state nuovamente numerate da 0 a n — 1 e che A sia la matrice di adiacenza del sottografo del Web indotto dalle pagine in D: quindi, i € E(j) se e solo se A[i][j] = 1 e k G U(j) se e solo se A[j][k] = 1. Il Codice 6.7 inizializza due vettori di rango di n elementi (righe 2 e 3) tali che, al termine dell'esecuzione, vale X[j] = rangoA(j) e W[j] = r a n g o C ( j ) per 0 ^ j ^ n — 1. Il ciclo successivo (righe 4 - 1 9 ) calcola le due funzioni di rango usando le equazioni (6.7) e (6.8): poiché tali equazioni sono ricorsive, i valori calcolati a un passo diventano i valori in ingresso del passo successivo (righe 6 e 7), fino a raggiungere il risultato finale (riga 19). In particolare, le righe 8 - 1 8 eseguono un passo di tale calcolo iterativo: notiamo che il primo ciclo f o r interno (righe 10 e 11) corrisponde al calcolo del valore Z i 6 E ( j ) rangoC(i) (poiché A[i][j] = 1 se i e E(j) e A[i][j] = 0 altrimenti) e che il secondo ciclo f o r (righe 12 e 13) corrisponde al calcolo di ^ L k € U ( j ) rangoA(k) (poiché A[j][k] = 1 se k e E(j) e A[j][k] = 0 altrimenti). Le rimanenti righe del ciclo d o - w h i l e calcolano i valori sommaX e sommaW necessari a normalizzare i valori così calcolati (righe 17 e 18): quando il ciclo d o - w h i l e termina

(ovvero, i nuovi valori coincidono con quelli calcolati al passo precedente), i valori di X e W che vengono restituiti alla riga 20 soddisfano necessariamente l'equazioni (6.7) e (6.8). Pertanto, se termina, il Codice 6.7 calcola correttamente le funzioni di rango definite per HITS. ALVIE:

calcolo di HITS

Osserva, sperimenta e verifica HITS

Analogamente a PageRank, ogni iterazione del ciclo d o - w h i l e richiede 0 ( n 2 ) tempo: quindi, la complessità temporale è 0 ( b n 2 ) dove b indica il numero di iterazioni che sono eseguite (vedremo nel Paragrafo 6.4.3 che b è un valore finito, ovvero il Codice 6.7 termina). A questo punto, è interessante discutere come viene costruita la collezione D di documenti su cui applicare HITS, mostrando anche come esso può integrarsi con altri sistemi di rango. Ipotizziamo di eseguire un'interrogazione utilizzando un motore di ricerca con la propria funzione di rango (come, ad esempio, PageRank), ottenendo così un elenco di risultati: prendiamo i primi t in ordine di rango formando un insieme S di partenza (t = 200 nella proposta originale di HITS). Per ottenere D, estendiamo S con le pagine che appartengono al vicinato di quelle in S: HITS aggiunge a S tutte le pagine in U(j ) per j G S e un opportuno sottoinsieme delle pagine in E(j) per j S S (quest'ultimo potrebbe essere molto vasto). Altre scelte sono possibili: per esempio, possiamo aggiungere anche le pagine con i riferimenti in uscita di secondo livello, ovvero U(k) per k e U(j) e j € S. In tal modo, l'insieme S delle prime t pagine fornite da un motore di ricerca è esteso e raffinato con l'algoritmo di HITS. Il metodo HITS, come quello PageRank, presenta alcuni svantaggi che in qualche modo limitano la sua affidabilità come implementazione del concetto di rango di una pagina Web. Anzitutto, poiché il valore di HITS dipende dall'interrogazione, la costruzione dell'insieme D e il calcolo descritto nel Codice 6.7 devono essere eseguiti ogni volta al momento dell'interrogazione (con ovvie conseguenze dal punto di vista delle prestazióni). Inoltre, HITS è soggetto a meccanismi di search spam al pari di PageRank. Dal punto di vista del produttore di una pagina Web, è infatti facile aggiungere link in uscita e quindi aumentare in tal modo il punteggio di concentratore della pagina stessa: poiché i due punteggi di HITS sono tra di loro dipendenti, questo può portare a un aumento del punteggio di autorità della pagina. Inoltre, cambiamenti locali alla struttura dei

collegamenti possono risultare in punteggi drasticamente diversi, in quanto la collezione D è piccola in confronto all'intero Web. Infine, HITS presenta un cosiddetto problema di "deriva del soggetto" (topic drift), in quanto è possibile che costruendo l'insieme D includiamo una pagina non precisamente focalizzata sull'argomento dell'interrogazione ma con un alto punteggio di autorità: il rischio è che questa pagina e quelle a essa vicine possano dominare la lista ordinata che viene restituita all'utente, spostando così l'attenzione su documenti non proprio inerenti all'interrogazione.

6.4.3

Convergenza del calcolo iterativo di PageRank e HITS

Per dimostrare la convergenza del Codice 6.6 facciamo uso di un famoso risultato di algebra lineare, detto teorema di Perron-Frobenius. Ciò è dovuto al fatto che, come mostriamo in questo paragrafo, possiamo interpretare il codice come un procedimento iterativo per il calcolo della soluzione di un sistema di equazioni lineari: il teorema di Perron-Frobenius ci consente di dimostrare che tale soluzione esiste ed è unica e che viene calcolata da tale procedimento iterativo. Indichiamo con X , k ' il valore del vettore di rango X al termine della k-esima iterazione del ciclo d o - w h i l e del Codice 6.6, per k ^ 1, e con X ( 0 ' il suo valore iniziale (ovvero, formato da tutti 1). In base alle istruzioni eseguite dal Codice 6.6 all'interno del ciclo, possiamo riformulare l'equazione (6.6) come X | k | [j] = (1 - a) + a V i=0

( A[Ì]p]nX > Codice 7.4

Visita in ampiezza di un grafo con n vertici a partire dal vertice s, utilizzando una coda Q inizialmente vuota e un array r a g g i u n t o per marcare i vertici visitati.

schema segue quello visto per gli alberi, in cui la lista di adiacenza del vertice corrente fornisce, come al solito, i riferimenti ai suoi vicini. Il Codice 7.4 riporta lo schema di visita a partire da un vertice prescelto s, dove l i s t a A d i a c e n z a [ u ] . i n i z i o indica il riferimento all'inizio della lista di adiacenza per il vertice u . Dopo aver inizializzato r a g g i u n t o e la coda Q (righe 2-4), inizia il ciclo di visita. Il vertice u in testa alla coda viene estratto (riga 6) e, se non è stato ancora raggiunto, viene marcato come tale e la sua lista di adiacenza viene scandita a partire dal primo elemento (righe 7 - 1 3 ) . Poiché ogni vertice viene inserito nella coda soltanto se non ancora marcato, e quindi una sola volta, ciascuna delle liste di adiacenza esaminate viene anch'essa considerata una sola volta: in conseguenza di ciò, il costo totale della visita è dato dalla somma delle lunghezze delle liste di adiacenza esaminate, ovvero al più dalla somma dei gradi di tutti i vertici del grafo, ottenendo un totale di 0 ( n + m) tempo e O(n) celle di memoria aggiuntive. Un esempio di visita in ampiezza è descritto nella Figura 7.2, dove l'ordine dei vertici in ciascuna lista di adiacenza determina l'ordine di visita dei vertici stessi nel grafo: supponiamo che i vertici in ciascuna lista di adiacenza siano mantenuti in ordine crescente, se non specificato altrimenti. L'ordine con cui i vertici vengono raggiunti dalla visita del grafo illustrato nella parte sinistra della Figura 7.2 è dato da 0 , 1 , 2 , 3 , 5 , 4 , 6 , 7 (l'ordine del loro inserimento nella coda). In particolare, dal vertice 0 raggiungiamo i vertici 1 , 2 , 3 e 5, dal vertice 3 raggiungiamo 4 e 6 e dal vertice 5 raggiungiamo 7 (mentre i rimanenti vertici non permettono di raggiungerne altri). L'esempio illustra alcune proprietà interessanti della visita. Gli archi che conducono a vertici ancora non visitati, permettendone la scoperta, formano un albero detto albero

destra, annotato con gli archi all'indietro tratteggiati e la profondità dei nodi.

BFS, la cui struttura dipende dall'ordine di visita (parte destra della Figura 7.2). Per poter costruire tale albero, modifichiamo lo schema di visita illustrato nel Codice 7.4: invece di usare una coda Q in cui i vertici sono inseriti ed estratti una sola volta, usiamo Q come coda in cui gli archi sono inseriti ed estratti una sola volta. Il Codice 7.5 riporta tale modifica della visita in ampiezza: dopo aver estratto l'arco (u', u) dalla coda (riga 6), scandiamo la lista di adiacenza di u solo se quest'ultimo non è stato scoperto (riga 7). La visita richiede 0 ( n + m) tempo, in quanto ogni arco è inserito ed estratto una sola volta e le liste di adiacenza sono scandite solo quando i corrispondenti vertici sono visitati la prima volta. Utilizzando il Codice 7.5, non è difficile individuare gli archi dell'albero BFS: basta memorizzare l'arco ( u ' , u ) quando il nodo u viene marcato come raggiunto nella riga 8 e, inoltre, u ' diventa il padre di u nell'albero BFS. In generale, gli archi individuati in tal modo formano un sottografo aciclico e, quando gli archi di tale sottografo sono incidenti a tutti i vertici, l'albero BFS ottenuto è un albero di ricoprimento (spanning tree) del grafo, vale a dire un albero i cui nodi coincidono con quelli del grafo. Cambiando l'ordine relativo dei vertici all'interno delle liste di adiacenza, possiamo ottenere alberi diversi. Notiamo infine che tali alberi, avendo grado variabile, possono essere rappresentati come alberi ordinali (Paragrafo 4.4). L'albero BFS è utile per rappresentare i cammini minimi dal vertice di partenza s verso tutti gli altri vertici: tale proprietà è vera in quanto gli archi non sono pesati (altrimenti non è detto che valga, e vedremo successivamente come gestire il caso in cui gli archi sono pesati). Per verificare tale proprietà, basta osservare che l'algoritmo visita prima i vertici a distanza 1 (ovvero i vertici adiacenti a s), poi quelli a distanza 2 e così via, come un semplice ragionamento per induzione può stabilire. In altre parole, la prò-

BreadthFirstSearch( s ) : FOR (u = 0; u < n; u = u + 1) raggiunto[u] = FALSE;

Q.Enqueue( (null, s) ); WHILE (!Q.Empty( )) {

(u', u) = Q.Dequeue( ); IF (!raggiunto[u]) { raggiunto[u] = TRUE;

FOR (x = listaAdiacenza[u].inizio ; x != nuli; x=x.succ) { v = x.dato; Q.Enqueue( (u, v) );

}

> Codice 7.5

Visita in ampiezza di un grafo in cui la coda Q contiene archi anziché vertici.

fondità p di un vertice v nell'albero BFS indica che la sua distanza minima da s nel grafo è proprio p; inoltre, i nodi irraggiungibili da s non vengono inclusi nell'albero BFS, e possiamo considerare che risultino a distanza infinita da s. Per verificare quanto affermato sopra ragioniamo in modo induttivo rispetto alla distanza dei nodi da s: il caso base dell'induzione è banalmente verificato in quanto l'unico nodo a profondità 0 è s che evidentemente ha distanza 0 da sé stesso. Per mostrare il passo induttivo, supponiamo che per ogni nodo u a distanza p ' < p da s la profondità di u. sia pari a p ' e ipotizziamo che esista, per assurdo, un nodo v a distanza 6 da s la cui profondità nell'albero BFS sia p / 6, e quindi tale che il cammino minimo da s a v sia di lunghezza 6. Consideriamo allora sia il vertice v' (a distanza 6—1 da s) che precede v in tale cammino, che il padre u di v a profondità p — 1 nell'albero BFS. Evidentemente, non può essere p < 6 in quanto, in tal caso, il cammino da s a v che attraversa u avrebbe lunghezza minore di 6, il che contraddice l'ipotesi che 6 sia la distanza tra s e v. Al tempo stesso, se 6 < p l'algoritmo di visita avrebbe raggiunto v da v' e quindi v avrebbe profondità 6 (infatti v ' sarebbe stato visitato prima di u). La proprietà appena discussa ha due conseguenze rilevanti. 1. Gli archi del grafo che non sono nell'albero BFS sono chiamati all'indietro (back): possono collegare solo due vertici alla stessa profondità nell'albero BFS oppure a profondità consecutive p e p + 1. 2. Il diametro del grafo può essere calcolato come la massima tra le altezze degli alberi BFS radicati nei diversi vertici del grafo (in generale, ne possono esistere n diversi). Il tempo richiesto per calcolare il diametro è 0 ( n ( n + m)).

BreadthFirstSearchExploreC s ) : Q.Enqueue( s ) ; WHILE ( ! Q . E m p t y (

))

{

u = Q.Dequeue( ) ; IF ( ! D . A p p a r t i e n e ( u ) ) { D.Inserisci(u); FOR (X = l i s t a A d i a c e n z a [ u ] . i n i z i o ; v = x.dato; Q.Enqueue( v ) ;

x != n u l i ; x=x.succ) {

> > Codice 7.6

Esplorazione mediante visita in ampiezza di un grafo, utilizzando una coda Q e un dizionario D per memorizzare i vertici visitati.

Un'altra applicazione interessante è che la visita BFS ci permette di stabilire se un grafo non orientato G è connesso. Infatti, G è connesso se e solo se r a g g i u n t o c i . ] vale T R U E per ogni 0 ^ u. ^ n — 1, ovvero tutti i vertici sono stati raggiunti e quindi abbiamo che l'albero BFS è un albero di ricoprimento. Il costo computazionale è lo stesso della visita BFS, quindi 0 ( n + m) tempo e O(n) celle di memoria. Nel caso in cui l'insieme dei nodi del grafo non sia preventivamente noto, e quindi il grafo debba essere esplorato per determinarne la struttura, non è possibile evidentemente utilizzare un array per distinguere i nodi raggiunti da quelli non ancora trovati. Per ottenere tale funzionalità è necessario fare uso di una struttura di dati che consenta di rappresentare un insieme, nello specifico l'insieme dei vertici raggiunti, dando la possibilità di aggiungere nuovi elementi all'insieme e di verificare l'appartenenza di un elemento all'insieme stesso. Tali funzionalità sono offerte da un dizionario (Capitolo 5), che quindi impieghiamo nel Codice 7.6, che è una semplice riscrittura del Codice 7.4. Nel codice in questione, il dizionario D, inizialmente vuoto, viene in effetti utilizzato in sostituzione dell'array r a g g i u n t o per rappresentare, a ogni istante, l'insieme dei nodi già raggiunti dalla visita. Dal punto di vista del costo computazionale, la sostituzione dell'array con un dizionario fa si che tale costo dipenda dal costo delle operazioni definite sul dizionario, dipendente a sua volta dall'implementazione adottata per tale struttura di dati. In particolare, esaminando il Codice 7.6 risulta che l'operazione I n s e r i s c i è eseguita O(n) volte, mentre l'operazione A p p a r t i e n e è invocata O(m) volte: ad esempio, utilizzando una tabella hash, per la quale le due operazioni richiedono tempo medio 0(1), il costo complessivo della visita è O ( n + m) nel caso medio. Utilizzando invece un

DepthFirstSearchC s ) : FOR (u = 0; u < n ; u = u + 1) raggiunto[u]

= FALSE;

P.PushC s ) ; WHILE ( ! P . E m p t y ( ) )

{

u = P.PopC ) ; IF ( ! r a g g i u n t o [ u ] ) { raggiunto[u]

= TRUE;

FOR (x = l i s t a A d i a c e n z a [ u ] . f i n e ; x != n u l i ; x = x . p r e d ) { v = x.dato; P.PushC v ) ;

> > Codice 7.7

Visita in profondità di un grafo utilizzando una pila P di archi, inizialmente vuota. Ciascuna lista di adiacenza viene scandita all'indietro.

albero di ricerca bilanciato, i costi delle due operazioni sono O(logn) nel caso peggiore, e quindi il costo conseguente dell'algoritmo è 0 ( ( n + m.) logn). ALVIE:

visita in a m p i e z z a di un g r a f o

Osserva, s p e r i m e n t a e verifica BreadthFirstSearch

7.4.2

M ™





Visita in profondità di un grafo

Se nello schema di visita illustrato nei Codici 7.4 e 7.5 sostituiamo la coda Q con una pila P, otteniamo un altro algoritmo di visita di grafi, noto come algoritmo di visita in profondità, o DFS {Depth-First Search), realizzato dai Codici 7.7 e 7.8. Dato che in una pila un insieme di elementi viene estratto in ordine opposto a quello di inserimento, volendo estrarre dalla pila gli archi incidenti a un nodo u nello stesso ordine con cui li incontriamo scandendo la lista di adiacenza di u , dobbiamo inserire nella pila tali archi in ordine inverso rispetto a quello della lista. Analogamente alla visita in ampiezza, anche nella visita in profondità viene costruito un albero, detto albero DFS, i cui archi vengono individuati in corrispondenza alla scoperta di nuovi vertici.

DepthFirstSearchC s ): FOR (u = 0; u < n; u = u + 1) raggiunto[u] = FALSE;

P.Push( (nuli, s) ); WHILE (!P.Empty( )) {

(u\ u) = P.Pop( ); IF (!raggiunto[u]) { raggiunto[u] = TRUE;

FOR (x = listaAdiacenza[u].fine; x != nuli; x = x.pred) { v = x.dato; P.Push( (u, v) );

> > Codice 7.8

Visita in profondità di un grafo in cui la pila P contiene archi anziché vertici.

La visita in profondità, per la natura stessa della politica LIFO che adotta la pila, si presta in modo naturale a un'implementazione ricorsiva, riportata nel Codice 7.9. Tale implementazione è ampiamente usata in varie applicazioni discusse in seguito, in alternativa a quella iterativa; notate che in questo caso il fatto che l'utilizzo della ricorsione non richieda una gestione esplicita di una pila fa si che non sia più necessario effettuare una scansione al contrario delle liste di adiacenza. Unitamente alla visita ricorsiva, il codice mostra la funzione S c a n s i o n e , che esamina tutti i vertici del grafo alla ricerca di quelli non ancora scoperti, invocando la ricorsione su ciascun nodo s di questo tipo, utilizzandolo come vertice di partenza di una nuova visita. Osserviamo che l'esame di tutti i vertici del grafo in effetti non richiede necessariamente l'adozione di una visita in profondità per ogni nodo non ancora raggiunto: in linea di principio, anzi, potremmo utilizzare tipi di visita diversi. Il costo computazionale delle visite in profondità discusse sopra è analogo a quello della visita in ampiezza, ovvero 0 ( n + m) tempo sia per grafi orientati che per grafi non orientati. Il numero di celle di memoria richieste per la visita iterativa è O(m) mentre per quella ricorsiva è 0 ( n ) . Un esempio di visita di un grafo orientato, a partire dal vertice s = 0, è riportato nella Figura 7.3, dove sono mostrati sia l'albero BFS che l'albero DFS risultanti. Durante la visita in profondità, in particolare, i vertici vengono scoperti nell'ordine 0 , 1 , 2 , 4 , 5 , 3 (quest'ultimo a partire dal vertice 1). Una caratteristica importante della visita D e p t h F i r s t S e a r c h R i c o r s i v a è che la pila implicitamente mantentuta dalla chiamata ricorsiva su un vertice u contiene i nodi, in ordine inverso, lungo il cammino n

Scansione( G ) : FOR (s = 0; s < n ; s = s + 1) raggiunto[s] FOR (S = 0; s

= FALSE;

< n; s = s + 1) { IF ( ¡ r a g g i u n t o [ s ] ) D e p t h F i r s t S e a r c h R i c o r s i v a ( s ) ;

> DepthFirstSearchRicorsiva( u ): raggiunto[u]

= TRUE;

FOR (x = l i s t a A d i a c e n z a [ u ] . i n i z i o ; x != n u l i ; x = x . s u c c ) { v = x.dato; IF ( ¡ r a g g i u n t o [ v ] ) D e p t h F i r s t S e a r c h R i c o r s i v a ( v ) ;

> Codice 7.9

Visita in profondità di un grafo utilizzando la ricorsione.

dell'albero DFS che va dalla radice s al nodo u. In altre parole, esaminando il contenuto della pila implicita per ogni vertice scoperto, otteniamo la visita anticipata dell'albero DFS. Per esempio, quando la chiamata di D e p t h F i r s t S e a r c h R i c o r s i v a esamina il vertice u = 3 nella Figura 7.3, la pila implicita contiene i vertici corrispondenti al cammino 7t = 0 , 1 , 3 nell'albero DFS (il vertice u = 3 è in cima alla pila). Per quanto riguarda invece gli archi non appartenenti all'albero DFS, questi, nel caso di grafi orientati, possono essere classificati ulteriormente. In particolare, un arco (u, v) non appartenente all'albero DFS, può essere catalogato come segue: « all'indietro (back)-, se v è antenato di u nell'albero DFS; • in avanti (forward): se v è discendente di u nell'albero DFS (nipote, pronipote e così via, ma non figlio perché altrimenti l'arco apparterrebbe all'albero); • trasversale (cross): se v e u non sono uno antenato dell'altro. Nei grafi non orientati possono esserci solo archi all'indietro, che sono gli unici a condurre a vertici già visitati durante la visita in profondità. Dall'esempio nella Figura 7.3 emerge anche la differenza tra le due visite. Nella visita in ampiezza, i vertici sono esaminati in ordine crescente di distanza dal nodo di partenza s, per cui la visita risulta adatta in problemi che richiedono la conoscenza della distanza e dei cammini minimi (non pesati): successivamente, vedremo come, in effetti, un limitato adattamento della visita in ampiezza consenta di individuare i cammini minimi anche in grafi con pesi sugli archi. Nella visita in profondità, l'algoritmo raggiunge rapidamente vertici lontani dal vertice di partenza s, e quindi la visita è adatta per problemi collegati alla percorribilità, alla connessione e alla ciclicità dei cammini.

© ©A © © ©

©

avanti

©

V

F

?

(T)

; avanti 'trasversale'

indietro \

© trasversale

XD-'" Figura 7.3

Un grafo orientato, il relativo albero BFS a partire dal vertice s, dove s = 0, e l'albero DFS a partire da s con la classificazione degli archi in avanti, all'indietro e trasversali.

Anche per la visita in profondità, l'esplorazione di un grafo di cui non sono preventivamente noti i vertici richiede la sostituzione dell'array r a g g i u n t o con un dizionario: valgono rispetto a ciò le considerazioni effettuate per la visita in ampiezza nel Paragrafo 7.4.1. Nel caso specifico del grafo del Web, possiamo sostituire la coda o la pila con una coda che estrae gli elementi in base a un loro valore di rilevanza (il rank delle pagine Web), garantendo in questo modo la priorità di caricamento, durante la visita, alle pagine classificate come più interessanti. ALVIE:

visita in profondità di un grafo Osserva, s p e r i m e n t a e v e r i f i c a DepthFirstSearch

7.5 7.5.1

Applicazioni delle visite di grafi Grafi diretti aciclici e ordinamento topologico

La visita in profondità trova applicazione, tra l'altro, nell'identificazione dei cicli in un grafo, che in tal caso viene detto ciclico, mentre è aciclico se non contiene cicli: vale infatti la proprietà che un grafo G è ciclico se e solo se contiene almeno un arco all'indietro

(definito per le visite BFS e DFS). Infatti, consideriamo un grafo con un arco all'indietro (u, v) che, ricordiamo, non appartiene all'albero di ricoprimento (BFS o DFS) di G: esiste un cammino da v a u nell'albero in quanto, per definizione di arco all'indietro, v è antenato di u e da ciò deriva che, estendendo questo cammino con (u,v), ritorniamo in v, ottenendo quindi un ciclo. Viceversa, consideriamo un grafo contenente un ciclo: la visita costruisce un albero DFS che non può contenere tutti gli archi del ciclo, in quanto un albero è aciclico, e quindi almeno un arco è all'indietro. L'argomentazione suddetta vale per grafi orientati e non; infatti, nel caso di grafi orientati, se un ciclo contiene un arco in avanti (u,v) allora possiamo sostituire tale arco con il cammino da u a v nell'albero e ottenere comunque un ciclo (ricordiamo che, nel caso di grafi orientati, gli archi nell'albero sono diretti dai padri ai figli). Quindi, se il grafo è ciclico, esiste necessariamente un ciclo che non include archi in avanti. Infine, un arco trasversale (u,v) non può appartenere a un ciclo. Se così fosse, nell'albero il nodo u sarebbe antenato di v o viceversa, pervenendo a una contraddizione. L'algoritmo per verificare se un grafo è aciclico richiede 0 ( n + m) tempo: per ottenerlo, è infatti sufficiente modificare la visita in profondità (aggiungendo un ramo ELSE al controllo nella riga 7 del Codice 7.8 o nella riga 5 del Codice 7.9) in modo che, se essa trova che un vertice è già stato scoperto, determina che l'arco (u, v) è all'indietro e quindi che il grafo contiene un ciclo. Un grafo orientato aciciclo è chiamato DAG {Directed Acyclic Graph) e viene utilizzato in quei contesti in cui esiste una dipendenza tra oggetti espressa da una relazione d'ordine: per esempio gli esami propedeutici in un corso di studi, la relazione di ereditarietà tra classi nella programmazione a oggetti, la relazione tra ingressi e uscite delle porte in un circuito logico, oppure l'ordine di valutazione delle formule in un foglio elettronico. In generale, supponiamo di avere decomposto un'attività complessa in un insieme di attività elementari e di avere individuato le relativa dipendenze. Un esempio concreto potrebbe essere la costruzione di un'automobile: volendo eseguire sequenzialmente le attività elementari (per esempio, in una catena di montaggio), bisogna soddisfare il vincolo che, se l'attività B dipende dall'attività A, allora A va eseguita prima di B. Usando i DAG per modellare tale situazione, poniamo i vertici in corrispondenza biunivoca con le attività elementari, e introduciamo un arco (A,B) per indicare che l'attività A va eseguita prima dell'attività B. Un'esecuzione in sequenza delle attività che soddisfi i vincoli di precedenza tra esse, corrisponde a un ordinamento topologico del DAG costruito su tali attività, come illustrato nella Figura 7.4. Dato un DAG G = (V, E), un ordinamento topologico di G è una numerazione r| : Vi-» { 0 , 1 , . . . , n — 1} dei suoi vertici tale che per ogni arco (u,v) e E vale r|(u) < r|(v). In altre parole, se disponiamo i vertici lungo una linea orizzontale in base alla loro numerazione T|, in ordine crescente, otteniamo che gli archi risultano tutti orientati da sinistra verso destra. L'ordinamento topologico può anche essere visto come

© ® ©

©

©

( è " " © " ® ti

0

1

2

3

4

5

® Figura 7.4

Un esempio di DAG e di suo ordinamento topologico.

un ordinamento totale compatibile con l'ordinamento parziale rappresentato dal DAG. Osserviamo che i grafi ciclici non hanno un ordinamento topologico. Concettualmente, l'ordinamento topologico di un DAG G può essere trovato come segue: prendiamo un vertice z avente grado di uscita nullo, ovvero tale che la sua lista di adiacenza è vuota (tale vertice deve necessariamente esistere, altrimenti G sarebbe ciclico). Assegniamo il valore r|(z) = n — 1 a tale vertice, rimuovendolo quindi da G insieme a tutti i suoi archi entranti, ottenendo così un grafo residuo G ' che sarà ancora un DAG. Identifichiamo ora un vertice z' di grado di uscita nullo in G', a cui assegniamo q(z') = n — 2, rimuovendolo come descritto sopra. Iterando questo procedimento, otteniamo alla fine un grafo residuo con un solo vertice s, a cui assegniamo numerazione ri(s) = 0. Ogni arco (u,v) è evidentemente orientato da sinistra a destra nell'ordinamento indotto da rj, semplicemente perché v viene rimosso prima di u per cui q(v) > T|(u). Da tale procedimento deduciamo, inoltre, che non è detto che esista un unico ordinamento topologico per un dato DAG, in quanto, in generale, in un dato istante possono esistere più nodi aventi grado di uscita nullo, e quindi più possibilità (tutte corrette) di scelta del nodo da rimuovere. L'algoritmo per trovare un ordinamento topologico, in realtà, può essere realizzato in modo semplice, come mostrato nel Codice 7.10. Esso si basa sulla visita ricorsiva in profondità discussa precedentemente nel Codice 7.9: tale visita viene estesa realizzando la funzione TI(u) mediante un array e t à [u] e un contatore globale, inizializzato a n — 1 (riga 4 dell'ordinamento topologico). Dopo che le visite lungo gli archi uscenti da u sono terminate, il contacore viene assegnato a e t a [ u ] e decrementato di uno (righe 7 - 8 della visita ricorsiva). Intuitivamente, tutti i vertici raggiungibili da u in uscita sono stati esaminati e hanno ricevuto una numerazione strettamente maggiore del valore corrente del contatore, per cui assegnando tale valore a e t a [ u ] garantiamo che valga eta[u] < eta[u] per ogni arco uscente (u, v). Per garantire di assegnare una numerazione a tutti i vertici, scandiamo l'insieme dei vertici stessi, invocando la visita ricorsiva su quelli non ancora raggiunti dalle visite precedenti.

Ordinamento-topologico ( ): FOR (s = 0; s < n; s = s + 1) raggiunto[s] = FALSE; contatore = n - 1 ; FOR (S = 0; s < n; s = s + 1) { IF (!raggiunto[s]) DepthFirstSearchRicorsivaOrdinaC s );

> DepthFirstSearchRicorsivaOrdinaC u ): raggiunto[u] = TRUE; FOR (X = listaAdiacenza[u].inizio; x != nuli; x = x.succ) { v = x.dato; IF (!raggiunto[v]) DepthFirstSearchRicorsivaOrdina( v );

> eta[u] = contatore; contatore = contatore - 1; Codice 7.10 Ordinamento topologico di un grafo diretto aciclico. Per ogni vertice, l'array età indica l'ordine inverso di terminazione della visita in profondità ricorsiva.

Il costo computazionale rimane 0 ( n + m) tempo e lo spazio occupato è O(n) celle di memoria, richieste dalla pila implicitamente gestita dalla ricorsione. ALVIE:

ordinamento topologico di un grafo Osserva, sperimenta e verifica TopologicalSort

7.5.2

Componenti (fortemente) connesse

Le visite di grafi sono utili anche per individuare le componenti connesse di un grafo non orientato. Riconsiderando la scansione effettuata nel Codice 7.9 da questo punto di vista, notiamo che tutti i vertici sono connessi se e solo se, al termine della visita, tutti i vertici risultano raggiunti. Ne deriva che, in tal caso, tutti i valori dell'array r a g g i u n t o sono TRUE dopo la terminazione di S c a n s i o n e . Se, al contrario, qualche vertice non risulta raggiunto, esso verrà esaminato successivamente nel corso della visita ricorsiva invocata su un qualche nodo s / 0: ogni qualvolta abbiamo che r a g g i u n t o [ s ] vale FALSE, viene individuata una nuova componente connessa, che include il nodo s. L'individuazione

d

a

1 Figura 7.5

Un esempio di grafo orientato con le sue componenti fortemente connesse.

delle componenti connesse in un grafo orientato richiede pertanto 0 ( n + m) tempo e O(n) celle di memoria. Nel caso di un grafo orientato, come il grafo del Web, le componenti fortemente connesse sono collegate alla nozione di navigatore casuale (Paragrafo 6.4): osserviamo però che, se il grafo ha più di una componente fortemente connessa, il navigatore casuale rischia di non poter più raggiungere tutte le pagine perché resta intrappolato in una delle componenti. Tale eventualità viene scongiurata introducendo la possibilità per il navigatore di scegliere la prossima pagina in modo casuale. Per individuare le componenti fortemente connesse in un grafo orientato G = (V, E) applichiamo a G una visita in profondità ottenendo, come vedremo, una partizione dell'insieme dei vertici V in sottoinsiemi Vo, V i , . . . , V s ^i massimali e disgiunti, e tale che ciascun sottoinsieme Vi soddisfa la proprietà che due qualunque vertici u , v G V; sono collegati da un cammino orientato sia da u a v che da v a u (mentre questa proprietà non vale se u G Vi e v e Vj per i ^ j). Un semplice esempio di grafo composto da una singola componente fortemente connessa è dato da un ciclo orientato di vertici, oppure da un grafo contenente un ciclo Euleriano (che, ricordiamo, attraversa tutti gli archi una e una sola volta). Un esempio di grafo composto da più componenti è invece mostrato nella Figura 7.5, dove le componenti (massimali) sono racchiuse in cerchi per scopo illustrativo. Le componenti possono essere anche viste come macro-vertici, collegati da archi multipli.

S = T0

U

Figura 7.6

Passo generico dell'algoritmo per l'individuazione delle componenti fortemente connesse: quelle complete sono indicate in grigio chiaro mentre quelle parziali, attraversate dal cammino n dal vertice s al vertice u, sono rappresentate in neretto (quelle isolate non sono mostrate). I vertici nelle componenti parziali sono di tre tipi: quelli lungo 7i (in neretto), quelli con la visita completata (in grigio scuro) e quelli ancora da scoprire (in girgio chiaro). I rappresentanti sono indicati con r 0 =

Un'importante proprietà è che, non considerando la molteplicità di tali archi, i macrovertici formano un DAG: se così non fosse, infatti, un ciclo di macro-vertici formerebbe una componente fortemente connessa più grande, in quanto due vertici arbitrari all'interno di due macro-vertici distinti sarebbero comunque collegati da cammini in entrambe le direzioni, ma questo non è possibile per la massimalità delle componenti. Il DAG ottenuto in questo modo a partire dall'esempio mostrato nella Figura 7.5 è rappresentato nella Figura 7.4. La strutturazione di un grafo orientato in un DAG di macro-vertici (corrispondenti a componenti fortemente connesse) è fondamentale per la comprensione dell'algoritmo che stiamo per discutere. Un'altra utile osservazione riguarda la visita ricorsiva in profondità mostrata nel Codice 7.9. Ricordiamo che la visita mantiene implicitamente, e in ordine inverso, il cammino ti nell'albero DFS dal nodo di partenza s al vertice u attualmente considerato nella visita: i vertici lungo 7t sono quelli in cui la visita ricorsiva è iniziata ma non ancora terminata. I rimanenti vertici ricadono in due tipologie, ovvero quelli la cui visita è stata già completata (quindi la chiamata ricorsiva per loro è terminata) oppure quelli che non sono stati ancora raggiunti.

Per individuare le componenti fortemente connesse, applichiamo un algoritmo basato sulla visita ricorsiva in profondità, che si avvale anche di due ulteriori pile di vertici. Consideriamo un istante qualunque nell'esecuzione dell'algoritmo, e sia u il vertice attualmente raggiunto dalla visita. Adottiamo la seguente terminologia per classificare le componenti fortemente connesse del grafo in base allo stato dei vertici in esse contenuti rispetto alla visita in corso, oltre che al cammino implicito n dal vertice di partenza s al nodo u: • una componente è completa se la visita in tutti i suoi vertici è stata completata (tali vertici vengono detti completi e la visita ricorsiva in essi è terminata); • una componente è parziale se contiene alcuni vertici del cammino 71 (oltre a questi può contenere gli altri due tipi di vertici, ossia quelli la cui visita è stata già completata e quelli che non sono stati ancora raggiunti): sono chiamati parziali tutti i vertici della componente tranne quelli non ancora raggiunti; • una componente è ignota altrimenti, ovvero contiene esclusivamente vertici che non sono stati ancora raggiunti. L'algoritmo identifica anche dei rappresentanti durante la visita. Precisamente, un vertice parziale u è un rappresentante della propria componente (parziale) se è il primo vertice della componente a essere visitato. Quando la componente diventa completa, il rappresentante diventa completo come il resto dei vertici in essa e perde il suo status di rappresentante. Di conseguenza, i rappresentanti sono nel cammino n e separano una componente parziale dalla successiva. Se numeriamo tutti i vertici nell'ordine di scoperta da parte della visita (usando un array df sNumero a tal fine), i rappresentanti compaiono in ordine crescente di numerazione lungo il cammino 7t, come mostrato nella Figura 7.6. Durante la visita, l'algoritmo mantiene due pile: • p a r z i a l i : contiene tutti i vertici parziali inseriti in ordine di visita; • r a p p r e s e n t a n t i : contiene i vertici rappresentanti inseriti in ordine di visita. In generale, presi i vertici contenuti nel cammino 7t, osserviamo che i vertici parziali ne sono un sovrainsieme mentre i rappresentanti ne sono un sottoinsieme. Inoltre, i vertici appartenenti a una stessa componente parziale occupano posizioni contigue nella pila p a r z i a l i e il primo di tali vertici a essere impilato è il loro rappresentante, che viene anche inserito in r a p p r e s e n t a n t i a tale scopo. Queste sono le proprietà che il Codice 7.11 intende mantenere e utilizzare per l'individuazione delle componenti fortememente connesse. Inizialmente, nessun vertice è ancora esaminato (e quindi neanche c o m p l e t o ) e le pile sono vuote. Inoltre, usiamo l'array df sNumero sia per numerare i vertici in ordine di scoperta (attraverso c o n t a t o r e )

ComponentiFortementeConnesse( ): FOR (s = 0; s < n; s = s + 1) { dfsNumero[s] = -1; completo[s] = FALSE;

> contatore = 0; FOR (s = 0; s < n; s = s + 1) { IF (dfsNumero [s] == -1) DepthFirstSearchRicorsivaEstesa( s );

> DepthFirstSearchRicorsivaEstesa( u ): df sNumero[u] = contatore; contatore = contatore + 1; parziali.Push( u ); rappresentanti.Push( u ); FOR (X = listaAdiacenza[u].inizio; x != nuli; x = x.succ) { v = x.dato; IF (dfsNumero [v] == -1) { DepthFirstSearchRicorsivaEstesa( v ); > ELSE IF (!completo[v]) {

WHILE (dfsNumero[rappresentanti.Top()] > dfsNumero[v]) rappresentanti .PopO ;

> > IF (u == rappresentanti.TopO) { PRINT 'Nuova componente fortemente connessa:' DO {

PRINT z = parziali .PopO ; completo[z] = TRUE; > WHILE (z != u);

rappresentanti .PopO ;

> Codice 7.11

Stampa delle componenti fortemente connesse in un grafo orientato.

s = r0

Figura 7.7

s = r0

Situazione trattata dalle righe 10-12 di D e p t h F i r s t S e a r c h R i c o r s i v a E s t e s a ( u ) , prima (a sinistra nella figura) e dopo (a destra) la loro esecuzione.

sia per stabilire se un vertice è stato visitato o meno: inizializzando gli elementi di tale array al valore — 1, il successivo assegnamento di un valore maggiore oppure uguale a 0 (a seguito della visita) ci permette di stabilire se il corrispondente vertice sia stato raggiunto o meno. Osserviamo che, a differenza della visita in profondità, non occorre utilizzare esplicitamente l'array r a g g i u n t o . Le righe 2 - 3 assegnano la numerazione di visita a u, e le successive righe 4 - 5 inseriscono u in cima a entrambe le pile (in quanto potrebbe iniziare una nuova componente parziale che prima era ignota). A questo punto, esaminiamo la lista dei vertici adiacenti di u e invochiamo ricorsivamente la visita su quei vertici non ancora raggiunti (righe 8 9). Al contrario delle visite discusse in precedenza, se un vertice v adiacente a u è stato raggiunto precedentemente (righe 10-12), occorre verificare se l'arco (u, v) contribuisce alla creazione di un ciclo. Questo è possibile solo se v non è completo (riga 10): altrimenti (u, v) è un arco del DAG di macro-vertici di cui abbiamo discusso prima e non può creare un ciclo. Ipotizzando quindi che v non sia completo, siamo nella situazione mostrata nella Figura 7.7, dove l'arco (u,v) chiude un ciclo di componenti parziali, che devono essere unite in una singola componente parziale. Poiché i rispettivi vertici parziali occupano posizioni contigue nella pila p a r z i a l i , è sufficiente rimuovere i soli rappresentanti di tali componenti dalla pila r a p p r e s e n t a n t i : ne sopravvive solo uno, ovvero quello con numerazione di visita minore, che diventa il rappresentante della nuova componente

s = r0



Figura 7.8

s = T0

• "

Situazione trattata dalle righe 17-20 di D e p t h F i r s t S e a r c h R i c o r s i v a E s t e s a ( u ) , prima (a sinistra nella figura) e dopo (a destra) la loro esecuzione.

parziale così creata implicitamente (righe 10-12, dove la numerazione di v è usata per eliminare i rappresentanti che non sopravvivono). Terminata la scansione della lista di adiacenza di u, e le relative chiamate ricorsive, abbiamo che u diventa completo perché non possiamo scoprire ulteriori vertici tramite esso. Se u è anche rappresentante della propria componente (riga 15), vuol dire che u e tutti i vertici parziali che si trovano sopra di esso in p a r z i a l i formano una nuova componente completa, come illustrato nella Figura 7.8. E sufficiente, quindi, estrarre ciascuno di tali vertici dalla pila p a r z i a l i , marcarlo come c o m p l e t o (righe 18—19) ed eliminare u dalla pila r a p p r e s e n t a n t i (riga 21): notiamo che il corpo del ciclo alle righe 17-20 viene eseguito prima della guardia che, se non verificata, fa uscire dal ciclo. Inoltre, ogni vertice viene inserito ed estratto una sola volta al più in ciascuna pila, per cui il costo asintotico rimane quello della visita ricorsiva, ovvero 0 ( n + m) tempo e O(n) celle di memoria. ALVIE:

componenti (fortemente) connesse di un grafo

Osserva, sperimenta e verifica

Conne etedComponent

RIEPILOGO In questo capitolo abbiamo esaminato le pile e le code, mostrando come applicarle alla valutazione di notazioni polacche e a diversi problemi su grafi: visite in ampiezza e profondità, verifica della ciclicità o meno di un grafo, ordinamento topologico di un grafo aciclico, calcolo delle componenti (fortemente) connesse. ESERCIZI 1. Scrivete il codice per realizzare la conversione e l'interprete per le espressioni polacche. Usate un spazio bianco per separare variabili e costanti. 2. Scrivete il codice per implementare una coda mediante una lista, secondo quanto descritto nel Paragrafo 7.3.2. 3. Modificate la tabella di conversione da un'espressione infissa a una postfissa (Figura 7.1) in modo da riconoscere quando l'espressione infissa fornita in ingresso è sintatticamente incorretta (ad esempio, A + xB). 4. Modificate il codice per costruire l'albero BFS come albero ordinale rappresentato con memorizzazione binarizzata. 5. Usate la visita DFS per mostrare che un grafo non orientato con grado minimo d ammette un cammino di lunghezza maggiore di d. 6. Ricordiamo che il grafo a torneo è un grafo orientato G in cui per ogni coppia di vertici x e y esiste un solo arco che li collega, (x,y) oppure (y,x), ma non entrambi. L'interpretazione è che nella partita del torneo tra x e y uno dei due ha vinto. Prendete il DAG risultante dalle componenti fortemente connesse (che sono i macro-vertici) e mostrate che l'ordinamento topologico del DAG induce una classifica dei partecipanti al torneo.

Capitolo 8

Code con priorità

SOMMARIO In questo capitolo illustriamo e analizziamo la struttura di dati denominata coda con priorità, che gestisce sequenze di inserimenti ed estrazioni di elementi da un insieme, in presenza di pesi associati a tali elementi, pesi che ne determinano l'ordine di estrazione dall'insieme stesso. Di tale struttura di dati presentiamo l'implementazione più diffusa, lo heap, mostrandone poi l'utilizzo in contesti informatici diversi, quali l'ordinamento, la ricerca di cammini minimi su grafi pesati e l'individuazione di alberi ricoprenti di peso minimo (sempre su grafi pesati). DIFFICOLTÀ 1,5 CFU

8.1

Code con priorità

La struttura di dati coda con priorità memorizza una collezione di elementi in cui a ogni elemento è associato un valore, detto peso, appartenente a un insieme totalmente ordinato (solitamente l'insieme degli interi positivi). La coda con priorità può essere vista come un'estensione della coda (Paragrafo 7.3) e, infatti, le operazioni disponibili sono le stesse della coda: Empty, Enqueue, F i r s t e Dequeue. L'unica e sostanziale differenza rispetto a tale tipo di dati è che le ultime due operazioni devono restituire (ed estrarre, nel caso della Dequeue) l'elemento di peso minimo nella coda con priorità. 1 Ai fini della discussione, ipotizziamo che ciascun elemento e memorizzato nella coda con priorità contenga due campi, ossia un campo e . p e s o per indicarne il peso e un campo e . d a t o per indicare i dati a cui associare quel peso. 'Nel seguito, considereremo il caso in cui la coda con priorità restituisce sempre il minimo, ma le stesse considerazioni possono essere applicate mutatis mutandis al caso in cui dobbiamo restituire il massimo.

La necessità di estrarre gli elementi dalla coda in funzione del loro peso, ne rende l'implementazione più complessa rispetto a quella della semplice coda. Per convincerci di ciò consideriamo la semplice implementazione di una coda con priorità mediante una lista dei suoi elementi. Nel caso in cui decidiamo di implementare in tempo costante l'operazione Enqueue, inserendo i nuovi elementi in corrispondenza a un estremo della lista, ne deriva che la lista non è ordinata: per l'implementazione di Dequeue e di F i r s t è necessario individuare l'elemento di peso minimo all'interno della lista e, quindi, tale operazione richiede tempo O(n), dove n è la lunghezza della lista. Ad esempio, facendo riferimento alla parte alta della Figura 8.1, l'inserimento nella lista di un nuovo elemento avviene ponendolo subito prima del primo (con peso pari a 17), mentre la Dequeue richiede la scansione dell'intera lista, per determinare che l'elemento minimo è quello con peso pari a 3 e per poi estrarlo. Se decidiamo, invece, di mantenere gli elementi della lista ordinati rispetto al loro peso (ad esempio, in ordine non decrescente), ne deriva che Dequeue e F i r s t richiedono 0(1) tempo, in quanto l'elemento di peso minimo è sempre il primo della lista. Al tempo stesso, però, in corrispondenza a ogni Enqueue dobbiamo utilizzare tempo 0 ( n ) per inserire il nuovo elemento nella giusta posizione della lista, corrispondente all'ordinamento degli elementi nella lista stessa. Ad esempio, facendo riferimento alla parte bassa della Figura 8.1, osserviamo che l'operazione Dequeue richiede soltanto di eliminare il primo elemento della lista (ovvero, quello con pes^ pari a 3). L'esecuzione della Enqueue, ad esempio di un elemento con peso pari a 16, richiede però la scansione di una parte della lista per determinare che l'elemento va inserito tra gli elementi con pesi pari a 15 e 17: nel caso peggiore, la scansione avviene sull'intera lista. Facendo uso di soluzioni più sofisticate è possibile implementare una coda con priorità in modo più efficiente, in tempo O(logn), attraverso un bilanciamento del costo di esecuzione delle operazioni Dequeue e Enqueue, ottenuto per mezzo di un'organizzazione dell'insieme degli elementi in cui questi siano ordinati solo in parte.

Figura 8.2

8.2

Heaptree (a sinistra) e albero che non soddisfa la proprietà di heap (a destra).

Heap

Uno heap è una struttura di dati che, attraverso una rappresentazione solo parzialmente ordinata dei suoi elementi, permette di ottenere un tempo logaritmico per le operazioni E n q u e u e e Dequeue e un tempo costante per le operazioni Empty e F i r s t . Lo heap presenta la caratteristica di essere un albero binario completo a sinistra, di cui possiamo quindi dare una rappresentazione implicita (Paragrafo 4.3.1). Almeno inizialmente, comunque, descriveremo la sua struttura facendo riferimento a un'organizzazione esplicita ad albero, detta heaptree, degli elementi stessi: mostreremo poi come tale organizzazione possa essere riportata in termini della rappresentazione implicita. Uno heaptree è un albero H che soddisfa la seguente proprietà di heap (minimo): 1. il peso dell'elemento contenuto nella radice di H è minore o uguale di quelli contenuti nei figli della radice; più precisamente, se r è la radice di H e Vo, V i , . . . , v ^ - j sono i suoi figli, allora r . p e s o ^ v t . p e s o , per 0 ^ i < k.; 2. l'albero radicato in Vi è uno heaptree per 0 ^ i < k (gli alberi vuoti sono heaptree). L'albero nella parte sinistra della Figura 8.2 è uno heaptree, a differenza di quello nella parte destra in cui, ad esempio, il nodo con peso 18 è sopra al nodo con peso 14. Come corollario immediato, abbiamo che la radice di uno heaptree contiene l'elemento di peso minimo dell'insieme. Di conseguenza, l'effettuazione dell'operazione F i r s t nel caso di una coda con priorità implementata con uno heaptree richiede 0 ( 1 ) tempo. Uno heap è uno heaptree con i vincoli aggiuntivi di essere binario e completo a sinistra: ovvero, se h. è la sua altezza, i nodi di profondità minore di h. formano un albero completamente bilanciato e quelli di profondità h. sono tutti accumulati a sinistra. L'albero nella parte sinistra della Figura 8.3 è uno heap, mentre quello nella parte destra,

Figura 8.3

Heap (a sinistra) e heaptree che non e uno heap (a destra).

pur essendo uno heaptree, non è uno heap in quanto non è completo a sinistra. Nel Paragrafo 4.3.1 abbiamo visto che un albero completo a sinistra con n nodi ha altezza pari a h. = O(logn): ciò ci consente di effettuare in tempo O(logn) le operazioni Enqueue e Dequeue, di cui forniamo uno schema algoritmico generale mostrando poi come realizzare tali algoritmi nel contesto specifico della rappresentazione implicita. L'effettuazione dell'operazione Enqueue di un elemento e in uno heap H prevede, ad alto livello, l'esecuzione dei seguenti passi. 1. Inseriamo un nuovo nodo v contenente e come foglia di H in modo da mantenere H completo a sinistra. 2. Iterativamente, confrontiamo il peso di e con quello dell'elemento f contenuto nel padre di v e, se e.peso < f . p e s o , i due nodi vengono scambiati: l'iterazione termina quando v diventa la radice oppure quando e.peso ^ f . p e s o (notiamo che in questo modo manteniamo la proprietà di uno heaptree). Come possiamo vedere, l'operazione Enqueue opera inserendo un elemento nella sola posizione che consente di preservare la completezza a sinistra dello heap, facendo poi risalire l'elemento nello heap, di figlio in padre, fino a trovare una posizione che soddisfi la proprietà di uno heaptree. L'operazione Enqueue implementata su uno heap richiede tempo O(logn): a tal fine, ci basta osservare che il numero di passi effettuati è proporzionale al numero di elementi con i quali e viene confrontato e che tale numero è al massimo pari all'altezza h. = O(logn) dello heap (in quanto e viene confrontato con al più un elemento per ogni livello dello heap). Passiamo a considerare ora l'operazione Dequeue, che può essere effettuata, sempre ad alto livello, su uno heap H nel modo seguente.

1. Estraiamo la radice di H in modo da restituire l'elemento in essa contenuto alla fine dell'operazione. 2. Rimuoviamo l'ultima foglia di H (quella più a destra nell'ultimo livello), per inserirla come radice v (al posto di quella estratta), al fine di mantenere H completo a sinistra. 3. Iterativamente, confrontiamo il peso dell'elemento in v con quelli degli elementi nei suoi figli e, se il minimo fra i tre pesi non è quello di v, il nodo v viene scambiato con il figlio contenente l'elemento di peso minimo: l'iterazione termina se v diventa una foglia o se contiene un elemento il cui peso è minore di quelli degli elementi contenuti nei suoi figli. Al contrario dell'operazione Enqueue, l'operazione Dequeue opera facendo scendere un elemento, impropriamente posto come radice, all'interno dello heap, fino a soddisfare la proprietà di uno heaptree. Applicando le stesse considerazioni effettuate nel caso dell'operazione Enqueue, possiamo verificare che l'operazione Dequeue implementata su uno h^ap richiede anch'essa tempo O(logn). Notiamo che invertendo l'ordine dei confronti effettuati possiamo gestire uno heap H che soddisfa la proprietà di massimo nei suoi nodi (anziché di minimo), in cui il massimo è nella radice.

8.2.1

Implementazione di uno heap implicito

Come dichiarato sopra, uno heap è un albero completo a sinistra e, per quanto osservato nel Paragrafo 4.3.1, gode dell'interessante proprietà di poter essere rappresentato in modo implicito per mezzo di un array, che indichiamo con h e a p A r r a y , senza fare uso di riferimenti espliciti tra i nodi. Ricordiamo che i nodi dello heap H corrispondono agli elementi di h e a p A r r a y come segue: 1. la radice di H corrisponde all'elemento heapArray[0]; 2. se un nodo v di H corrisponde all'elemento heapArray[i], allora il figlio sinistro corrisponde all'elemento h e a p A r r a y [ 2 i + 1], il figlio destro corrisponde a h e a p A r r a y [ 2 i + 2] e il padre corrisponde a h e a p A r r a y [ ( i — 1 )/2]. Come effetto di questa rappresentazione, se H ha n nodi, i corrispondenti elementi sono memorizzati in ordine di ampiezza nelle prime n posizioni di h e a p A r r a y ed è possibile attraversare l'albero da padre a figlio o viceversa, in tempo costante. Dato che gli elementi dello heap occupano la parte iniziale di h e a p A r r a y , nell'implementazione è necessario prevedere anche una variabile intera h e a p S i z e , che indichi

Empty( ): RETURN heapSize == 0;

First( ): IF (!Empty( )) RETURN heapArray[0]; Enqueue( e ): VerificaRaddoppio( ); heapArray[heapSize] = e; heapSize = heapSize + 1 ; RiorganizzaHeapC heapSize - 1 ); Dequeue( ): IF (!Empty( )) { minimo = heapArray[0]; heapArray[0] = heapArray[heapSize - 1]; heapSize = heapSize - 1; RiorganizzaHeapC 0 ); VerificaDimezzamento( ); RETURN minimo;

} Codice 8.1

Implementazione di uno heap mediante un array: le funzioni Verif icaRaddoppio e VerificaDimezzamento seguono l'approccio del Paragrafo 2.1.3.

il numero di elementi attualmente presenti nello heap: in altre parole, h e a p S i z e è l'indice della prima posizione libera di h e a p A r r a y . Consideriamo ora l'implementazione, riportata nel Codice 8.1, delle quattro operazioni fornite da una coda con priorità. Possiamo osservare, per prima cosa, che la funzione Empty restituisce il valore t r u e se e solo se h e a p S i z e è uguale a 0 mentre, se lo heap non è vuoto, la funzione F i r s t restituisce il primo elemento di h e a p A r r a y che, per la rappresentazione implicita sopra illustrata, corrisponde alla radice dello heap. L'operazione E n q u e u e verifica se l'array debba essere raddoppiato (riga 2): successivamente, inserisce il nuovo elemento nella prima posizione libera dell'array e, quindi, come foglia dello heap per mantenerne la completezza a sinistra (riga 3). Dopo aver aggiornato il valore di h e a p S i z e (riga 4), per mantenere la proprietà di uno heaptree viene invocata la funzione R i o r g a n i z z a H e a p (riga 5), di cui posponiamo la discussione. Se lo heap contiene almeno un elemento, l'operazione D e q u e u e determina quello con il minimo peso, ovvero quello che si trova nella posizione iniziale dell'array (riga 3): quindi, per mantenere la completezza a sinistra, copia nella prima posizione l'elemento

RiorganizzaHeap ( i ): {pre: heapArray è uno heap tranne che nella posizione i) WHILE (i>0 kk heapArray[i].peso < heapArray[Padre(i)].peso) { Scambiai i, Padre( i ) ); i = Padre( i );

> WHILE (Sinistro(i) < heapSize kk i != MinimoPadreFigii(i)) { figlio = MinimoPadreFigli( i ); Scambia( i, figlio ); i = figlio;

> MinimoPadreFigli ( i ) : {pre: il nodo in posizione i ha almeno un figlio) j = k = Sinistro(i); IF (k+1 < heapSize) k = k+1; IF (heapArray[k].peso < heapArray[j].peso) j = k; IF (heapArray[i].peso < heapArraytj].peso) j = i; RETURN J; Padre(

1 ):

RETURN (i-l)/2; Sinistro( i ): RETURN 2 x i + 1;

Codice 8.2

Scambiai i, j ): tmp=heapArray[i]; heapArray[i]=heapArray[j]; heapArray[j]=tmp;

Riorganizzazione di uno heap per mantenere la proprietà di uno heaptree.

che si trova nella posizione finale e che corrisponde all'ultima foglia dello heap, e aggiorna il valore di h e a p S i z e (righe 4 e 5). Infine, per mantenere la proprietà di uno heaptree, invoca R i o r g a n i z z a H e a p e verifica se l'array debba essere dimezzato (righe 6 e 7). La funzione R i o r g a n i z z a H e a p , riportata nel Codice 8.2, ripristina la proprietà di uno heaptree in un albero completo a sinistra e rappresentato in modo implicito, con l'ipotesi che l'elemento e = h e a p A r r a y [ i ] sia eventualmente l'unico a violare la proprietà di heaptree. Il primo ciclo viene eseguito quando e deve risalire lo heap perché e . p e s o è minore del peso di suo padre: dobbiamo quindi scambiare e con il padre e iterare (righe 2-5). Il secondo ciclo viene eseguito quando e deve scendere perché e . p e s o è maggiore del peso di almeno uno dei suoi figli: dobbiamo quindi scambiare e con il figlio avente peso minimo, in modo da far risalire tale figlio e preservare la proprietà di uno heaptree (righe 6—10). Tale eventualità è segnalata nella riga 6 dal fatto che M i n i m o P a d r e F i g l i non restituisce i come posizione dell'elemento di peso minimo tra e e i suoi figli (que-

sto vuol dire che un figlio ha peso strettamente minore poiché, a parità di peso, viene restituito i). Il codice relativo a M i n i m o P a d r e F i g l i tiene conto dei vari casi al contorno che si possono presentare (per esempio, che e abbia un solo figlio, il quale deve essere sinistro e deve essere l'ultima foglia). Notiamo che, per ogni posizione i nello heap e ogni elemento e = h e a p A r r a y [ i ] , non può mai accadere che vengano eseguiti entrambi i cicli di R i o r g a n i z z a H e a p : in altre parole, e sale con il primo ciclo o scende con il secondo, oppure è già al posto giusto e, quindi, nessuno dei cicli viene eseguito. Ne deriva che il costo di R i o r g a n i z z a H e a p è proporzionale all'altezza dello heap e, quindi, richiede O(logn) tempo. ALVIE:

operazioni su uno heap implicito

Osserva, sperimenta e verifica

CD

HeapArray

La complessità di O(logn) tempo delle operazioni sulla coda con priorità heap sono determinate dal costo logaritmico di R i o r g a n i z z a H e a p e dal costo ammortizzato costante per operazione di V e r i f i c a R a d d o p p i o e V e r i f i c a D i m e z z a m e n t o : come sempre, nel caso in cui utilizziamo un array per rappresentare un insieme di elementi varianti nel tempo, prevediamo che la sua dimensione possa variare quando necessario, e in particolare possa essere raddoppiata o dimezzata secondo quanto già discusso nel Paragrafo 2.1.3 (se l'array ha dimensione prefissata, i costi finali sono al caso pessimo).

8.2.2

Insolito caso di DecreaseKey

Un'operazione molto utile negli heap è quella denominata D e c r e a s e K e y , che permette di diminuire il peso di un elemento memorizzato nello heap. Tuttavia, localizzare tale elemento all'interno di h e a p A r r a y richiede O(n) tempo senza una struttura di dati ausiliare. A tal fine, ipotizziamo che, presi gli n elementi in h e a p A r r a y , i loro campi d a t o siano distinti e formino una permutazione degli interi ( 0 , 1 , . . . , n — 1} (tale situazione occorre nel Paragrafo 8.3.2 dove utilizziamo D e c r e a s e K e y ) . Possiamo quindi introdurre un array p o s i z i o n e H e a p A r r a y di n interi che rappresentano le posizioni occupate in h e a p A r r a y dagli elementi memorizzati nello heap, ossia vale p o s i z i o n e H e a p A r r a y [ h e a p A r r a y [ i ] . d a t o ] = i per 0 ^ i < h e a p S i z e . L'adozione di questo array richiede ulteriori O(n) celle di memorie e quindi Io heap risultante non è più implicito (e non è conosciuta una semplice soluzione per renderlo

DecreaseKeyC dato, peso ): i = posizioneHeapArray[dato]; heapArray[i].peso = peso; RiorganizzaHeap( i ); Enqueue( e ) : VerificaRaddoppioC ); heapArray[heapSize] = e; posizioneHeapArray[ e.dato ] = heapSize; heapSize = heapSize + 1 ; RiorganizzaHeapC heapSize - 1 ); Scambiai i, j ): tmp=heapArray[i]; heapArray[i]=heapArray[j]; heapArray[j]=tmp; posizioneHeapArray[ heapArray[i].dato ] = i; posizioneHeapArray[ heapArray[j].dato ] = j; Codice 8.3

Riorganizzazione di uno heap per l'operazione DecreaseKey, che richiede l'introduzione dell'array posizioneHeapArray, della riga 4 in Enqueue e delle righe 5 e 6 in Scambia.

implicito), ma comunque tale heap rimane una struttura di dati semplice ed efficiente, in quanto fornisce l'operazione D e c r e a s e K e y in tempo O(logn). Per mantenere le informazioni aggiornate in p o s i z i o n e H e a p A r r a y , occorre modificare le operazioni E n q u e u e e S c a m b i a come riportato nel Codice 8.3. In particolare, registriamo la posizione di ogni nuovo elemento messo in coda (riga 4 in Enqueue) e scambiamo le posizioni analogamente a quanto succede per h e a p A r r a y (righe 5 e 6 in S c a m b i a ) : notiamo che la complessità di tali operazioni non cambia asintoticamente. Infine, l'operazione D e c r e a s e K e y accede all'elemento nella posizione indicata da p o s i z i o n e H e a p A r r a y , diminuisce il peso di tale elemento e applica R i o r g a n i z z a H e a p per preservare la proprietà di heap, in costo O(logn) utilizzando l'analisi discussa in precedenza.

8.2.3

Costruzione di heap e ordinamento

Proviamo ora a considerare il costo di costruzione di uno heap da un insieme dato di n elementi, inizialmente posti in h e a p A r r a y stesso: la soluzione immediata in tal caso comporta l'esecuzione di n operazioni E n q u e u e su uno heap inizialmente vuoto, come mostrato nel Codice 8.4. Notiamo che tale algoritmo di costruzione opera in loco e,

CreaHeapO : heapSize = 0; FOR (i = 0; i < n; i = i+1) { Enqueue( heapArray[i] );

> Codice 8.4

Costruzione di uno heap mediante Enqueue.

inoltre, quando E n q u e u e deve inserire l'elemento h e a p A r r a y [ i ] , le precedenti posizioni h e a p A r r a y [ 0 , i — 1] formano uno heap con h e a p S i z e = i, per i > 0. In conseguenza di ciò, al termine dell'ultima iterazione, l'intero array rappresenta uno heap. Quale sarà il costo complessivo della costruzione? Dato il costo logaritmico di E n q u e u e , avremo che il costo di costruzione sarà O ( n l o g n ) nel caso peggiore. Tale costo non è migliorabile asintoticamente a causa dell'operazione E n q u e u e , dato che il numero di confronti effettuati da essa su uno heap di k elementi è almeno pari a logk nel caso peggiore. In tal caso, il Codice 8.4 totalizza un numero di confronti limitato inferiormente da logk = l°g n !> s u c u ' possiamo applicare la doppia disuguaglianza dell'approssimazione di Stirling, in base alla quale abbiamo che V^n7tn n e" n + TiiiTT < n ! < \ / 2 n n n n e ~

n +

^

(8.1)

Di conseguenza, il numero di confronti eseguiti da n operazioni di E n q u e u e nel caso peggiore sarà almeno pari a log(n!) > ^ log2n7i + n l o g n — ^ n - y ^ + l " )

lo

8e

=

n

(nlogn)

Il motivo intuitivo di tale fenomeno è che, poiché tutti gli elementi salgono lungo lo heap, eventualmente fino alla radice, e poiché il numero di elementi su un livello cresce (in particolare raddoppia) a ogni livello, ne deriva che, eseguendo il Codice 8.4, molti elementi percorrono, nel caso peggiore, un cammino "lungo" fino alla radice. Sarebbe più conveniente, se la costruzione dello heap potesse avvenire spostando gli elementi verso il basso, non verso la radice ma verso le foglie: in questo caso, infatti, la maggior parte degli elementi, che tende già a trovarsi vicino alle foglie, percorrerebbe un cammino "breve". Mostriamo come tale intuizione conduca a un algoritmo di costruzione dello heap che richiede soltanto O(n) tempo. Il Codice 8.5 opera in tal modo, utilizzando R i o r g a n i z z a H e a p , in una versione ristretta al solo secondo ciclo (righe 6—10), indicata con R i o r g a n i z z a H e a p F i g l i perché procede esclusivamente verso i figli. Le iterazioni del Codice 8.5 scandiscono

CreaHeapMigliorato( ): heapSize = n; FOR (i = N-1; i >=0; i = i - 1) { RiorganizzaHeapFigli(i);

> Codice 8.5

Costruzione di uno heap mediante R i o r g a n i z z a H e a p F i g l i .

h e a p A r r a y all'indietro e, in effetti, la prima metà circa di tali iterazioni sono inutili perché non modificano tale array in quanto sono invocate sulle foglie dello heap. Le rimanenti operano sui nodi interni e, per ciascun nodo h e a p A r r a y [ i ] , tale nodo e i suoi discendenti formano uno heap tranne che nella posizione i, per cui vengono riposizionati da R i o r g a n i z z a H e a p F i g l i : a ogni istante, la parte finale dell'array rappresenta un insieme di heap che, man mano, vengono uniti insieme fino a ottenere un unico heap nell'intero array. Notiamo che anche questo algoritmo di costruzione dello heap è in loco. ALVIE:

costruzione di uno heap

Jhfej^ ^¡Ip^

Osserva, sperimenta e verifica

®

®

o

©

MakeHeap

Proviamo ora a convincerci che, in effetti, l'array risultante dall'applicazione del Codice 8.5 rappresenta uno heap. A tal fine, notiamo che al termine dell'iterazione i dell'algoritmo gli elementi in h e a p A r r a y [ i , n — 1] verificano la proprietà di heaptree, ossia h e a p A r r a y [ ( j — l ) / 2 ] < h e a p A r r a y [ j ] per 2i + 1 < j < TI: in conseguenza di ciò, per le posizioni i dei nodi interni (dove i < (n — 2)/2), possiamo vedere che h e a p A r r a y [ i , n— 1] codifica un insieme di i + 1 heap, ciascuno radicato in una posizione distinta di h e a p A r r a y [ i , 2i]. Supponiamo induttivamente che la proprietà suddetta sia verificata per l'iterazion e i + 1 e che, quindi, le posizioni delle radici siano quelle nell'intervallo [ i + l , 2 ( i + 1)]: durante l'iterazione i, R i o r g a n i z z a H e a p considera l'elemento h e a p A r r a y [ i ] e i due heap aventi radici in h e a p A r r a y [ 2 i + 1] e h e a p A r r a y [ 2 i + 2], al fine di ottenere un unico heap con radice in h e a p A r r a y [i]. Quindi, le radici nelle posizioni 2i + 1 e 2i + 2 sono assorbite nel nuovo heap che soddisfa la proprietà di heaptree, e la posizione i diventa una nuova radice, trasformando così l'intervallo delle radici da [Ì+ 1 , 2 ( i + 1)] a [i, 2i]. Al termine dell'algoritmo, quando

HeapSort ( a ) : {pre: la lunghezza di a è n) CreaHeapMigliore( ); FOR (heapSize = n-1; heapSize > 0; heapSize = heapSize - 1) { Scambiai heapSize, 0 ); RiorganizzaHeap( 0 );

> Codice 8.6

Ordinamento mediante heap di un array a.

i = 0, la proprietà di heaptree è verificata a partire dalla radice h e a p A r r a y [ 0 ] e l'intero array forma uno heap. Mostriamo ora che l'intera riorganizzazione dell'albero richiede un numero di confronti lineare nel numero di elementi. A tal fine, osserviamo che il numero di confronti complessivamente eseguiti è, nel caso peggiore, proporzionale alla somma delle altezze di tutti i sottoalberi. Dato l'albero completo a sinistra corrispondente allo heap di altezza h., consideriamo il suo completamento nell'ultimo livello di foglie in modo da ottenere concettualmente un albero completo di altezza h (se non lo è già): possiamo constatare che il Codice 8.5 non può impiegare minor tempo al caso pessimo su quest'ultimo albero. Precisamente, esso effettua n / 2 iterazioni sulle foglie, n / 4 iterazioni sui padri delle foglie, n / 8 iterazioni sui nonni e così via: in generale, effettua n / 2 ' + 1 iterazioni sui nodi di altezza pari a j, per 0 ^ j ^ h., e ciascuna iterazione richiede tempo proporzionale all'altezza stessa j. Ne deriva che il costo totale dell'algoritmo di costruzione è x pari t i / 2 ' + 1 ), che è limitata superiormente da 2 I j > i ( j x n / 2 ' ) = O(n), dove La costruzione dello heap può essere impiegata per ordinare un array di elementi. Infatti, l'utilizzo di una coda con priorità fornisce un semplice algoritmo di ordinamento: per ordinare un array di n elementi avendo a disposizione una coda con priorità PQ è sufficiente dapprima inserire gli elementi in PQ, effettuando n operazioni E n q u e u e , e quindi estrarli uno dopo l'altro, mediante n operazioni D e q u e u e . In tal modo, l'ordinamento richiede tempo O ( n l o g n ) . Tuttavia, questo costo può essere ridotto, anche se non in termini asintotici, usando uno heap con la proprietà di heaptree massimo (invece che minimo) e applicando il metodo di costruzione dello heap illustrato nel Codice 8.5 che, come visto, richiede tempo O(n) per creare lo heap a partire dall'array: ciò fornisce un costo complessivo di 0 ( n + n l o g n ) per l'algoritmo, che comunque asintoticamente è ancora O ( n l o g n ) . Il vantaggio dell'utilizzo di uno heap è che l'ordinamento viene effettuato in loco. Come possiamo infatti vedere nel Codice 8.6, l'algoritmo cosiddetto di Heapsort opera, dato

h e a p A r r a y da ordinare, prima riposizionando gli elementi di h e a p A r r a y in modo tale da costruire uno heap, e poi eliminando iterativamente il massimo elemento nello heap (in posizione 0), la cui dimensione è decrementata di 1, costruendo al tempo stesso la sequenza ordinata degli elementi di h e a p A r r a y a partire dal fondo. ALVIE:

ordinamento mediante heap

Osserva, sperimenta e verifica

© ® ®

© °

©

HeapSort

In particolare, all'iterazione i, l'elemento massimo estratto dallo heap (che a quel punto ha dimensione i ed è rappresentato dagli elementi in h e a p A r r a y [ 0 , i — 1]) viene inserito nella posizione i di h e a p A r r a y . AI termine di tale iterazione, abbiamo che gli elementi in h e a p A r r a y [ 0 , i— 1] formano uno heap e sono tutti di peso minore o uguale a quelli ordinati in h e a p A r r a y [ i , n — 1]: quando i = 0, risultano tutti in ordine.

8.3

Opus libri: routing su Internet e cammini minimi

Le reti di computer, di cui Internet è il più importante esempio, forniscono canali di comunicazione tra i singoli nodi, che rendono possibile lo scambio di informazioni tra i nodi stessi, e con esso l'interazione e la cooperazione tra computer situati a grande distanza l'uno dall'altro. Il Web e la posta elettronica sono, in questo senso, due applicazioni di grandissima diffusione che si avvalgono proprio di questa possibilità di comunicazione tra computer diversi. L'interazione di computer attraverso Internet avviene mediante scambio di informazioni suddivise in pacchetti: anche se la quantità d'informazione da passare è molto elevata, come ad esempio nel caso di trasmissione di documenti video, tale informazione viene suddivisa in pacchetti di dimensione fissa, che sono poi inviati in sequenza dal computer mittente al destinatario. Dato che i computer mittente e destinatario non sono direttamente collegati, tale trasmissione non coinvolge soltanto loro, ma anche un ulteriore insieme di nodi della rete, che vengono attraversati dai pacchetti nel loro percorso verso la destinazione. Il protocollo IP (Internet Protocol), che è il responsabile del recapito dei pacchetti al destinatario, opera secondo un meccanismo di packet switching, in accordo al quale ogni pacchetto viene trasmesso in modo indipendente da tutti gli altri nella sequenza. In questo senso, lo stesso pacchetto può attraversare percorsi diversi in dipendenza di mutate condizioni della rete, quali ad esempio sopravvenuti malfunzionamenti o sovraccarico di traffico in determinati nodi.

In quest'ambito, è necessario che nella rete siano presenti alcuni meccanismi che consentano, a ogni nodo a cui è pervenuto un pacchetto diretto a qualche altro nodo, di individuare una "direzione" verso cui indirizzare il pacchetto per avvicinarlo al relativo destinatario: nello specifico, se un nodo ha d altri nodi a esso collegati direttamente, il nodo invia il pacchetto a quello adiacente più vicino al destinatario. Questo problema, noto come instradamento (routing) dei messaggi viene risolto su Internet nel modo seguente: 2 nella rete è presente un'infrastruttura composta da una quantità di computer (nodi) specializzati per svolgere la funzione di instradamento; ognuno di tali nodi, detti router, è collegato direttamente a un insieme di altri, oltre che a numerosi nodi "semplici" che fanno riferimento a esso e che svolgono il ruolo di mittenti e destinatari finali delle comunicazioni. Ogni router mantiene in memoria una struttura di dati, che di solito è una tabella, detta tabella di routing, rappresentata in una forma compressa per limitarne la dimensione, che permette di associare a ogni nodo sulla rete, identificato in modo univoco dal corrispondente indirizzo IP, a 32 o 128 bit (a seconda che sia utilizzato IPv4 o IPv6), uno dei router a esso direttamente collegati. L'instradamento di un pacchetto p da un nodo u a un nodo v di Internet viene allora eseguito nel modo seguente: p contiene, oltre all'informazione da inviare a v (il carico del pacchetto), un'intestazione (header) che contiene informazioni utili per il suo recapito; la più importante di tali informazioni è l'indirizzo IP di v. Il mittente u invia p al proprio router di riferimento ri, il quale, esaminando lo header di p, determina se v sia un nodo con cui ha un collegamento diretto: se è cosi, t] recapita il pacchetto al destinatario, mentre, in caso contrario, determina, esaminando la propria tabella di routing, quale sia il router t2 a esso collegato cui passare il pacchetto. Questa medesima operazione viene svolta da T2, e così via, fino a quando non viene raggiunto il router r t direttamente connesso a v, che trasmette il pacchetto al destinatario. Il percorso seguito da un pacchetto è quindi determinato dalle tabelle di routing dei router nella rete, e in particolare dai router attraversati dal pacchetto stesso. Per rendere più efficiente la trasmissione del messaggio, e in generale, l'utilizzo complessivo della rete, tali tabelle devono instradare il messaggio lungo il percorso più efficiente (o, in altri termini, meno costoso) che collega u a v. Le caratteristiche di un collegamento diretto tra due router (come la velocità di trasmissione), così come la quantità di traffico (ad esempio, pacchetti per secondo) che viaggia su di esso, consentono, a ogni istante, di assegnare un costo alla trasmissione di un pacchetto sul collegamento. Un'assegnazione di costi a tutti i collegamenti tra router consente di modellare l'infrastruttura dei router come un grafo orientato pesato sugli archi, i cui nodi rappresentano i router, gli archi i collegamenti diretti tra router e il peso associato a un arco il 2

A1 fine di rendere più agevole la comprensione degli aspetti rilevanti per le finalità di questo libro, stiamo volutamente semplificando l'esposizione dei meccanismi di routing su Internet.

costo di trasmissione di un pacchetto sul relativo collegamento. In tal modo, l'obiettivo di effettuare il routing di un pacchetto nel modo più efficiente si riduce nel cercare, dato il grafo pesato che modella la rete, il cammino di costo minimo dal router associato a u a quello associato a v. I router utilizzano dei protocolli appositi per raccogliere le informazioni sui costi di tutti i collegamenti, e per costruire quindi le tabelle di routing che instradano i messaggi lungo i percosi più efficienti. Tali protocolli rientrano in due tipologie: link state e distance vector. I protocolli link state, come ad esempio OSPF (Open Shortest Path First), operano in modo tale che tutti i router, scambiandosi opportuni messaggi, acquisiscono l'intero stato della rete, e quindi tutto il grafo pesato che modella la rete stessa. A questo punto ogni router Ti applica su tale grafo un algoritmo di ricerca, come l'algoritmo di Dijkstra che esamineremo di seguito, per determinare l'insieme dei cammini minimi da r a qualunque altro router: se ti, t j , . . . , r s è il cammino minimo individuato da r^ che passa attraverso il router Tj a lui vicino, la tabella di routing di r^ associa il router Tj a tutti gli indirizzi verso r s . I protocolli distance vector, come ad esempio RIP (Routing Information Protocol) effettuano la determinazione dei cammini minimi senza scambiare l'intero grafo tra i router. Essi invece applicano un diverso algoritmo per la ricerca dei cammini minimi da un nodo a tutti gli altri, l'algoritmo di Bellman-Ford, che esamineremo anch'esso nel seguito: tale algoritmo, come vedremo, ha la peculiarità di operare mediante aggiornamenti continui operati in corrispondenza agli archi del grafo e, per tale caratteristica, si presta a un'elaborazione "collettiva" dei cammini minimi da parte di tutti i router nella rete.

8.3.1

Problema della ricerca di cammini minimi su grafi

La ricerca del cammino più corto {shortest path) tra due nodi in un grafo rappresenta un'operazione fondamentale su questo tipo di struttura, con importanti applicazioni. In generale, come osservato sopra, la conoscenza dei cammini minimi tra i nodi è un importante elemento in tutti i metodi di routing su rete, vale a dire in tutti i metodi che, dati una origine e una destinazione di un messaggio, determinano il percorso più conveniente da seguire per il messaggio stesso. Tra questi, particolare importanza riveste l'instradamento di pacchetti su Internet, come visto sopra, ma anche altre applicazioni di larga diffusione, quali ad esempio la ricerca del miglior percorso stradale verso una destinazione effettuata da un navigatore satellitare o da sistemi disponibili su Internet e largamente utilizzati quali MapQuest, GoogleMaps, YahooMaps o MSNMaps operano sulla base di algoritmi per la ricerca di cammini minimi. Come già visto nel Paragrafo 7.4, in un grafo non pesato il cammino minimo tra due nodi u e v può essere trovato mediante una visita in ampiezza a partire da u, utilizzando

20

Figura 8.4

Esempio di grafo orientato con pesi sugli archi.

una coda in cui memorizzare i nodi man mano che vengono raggiunti e da cui estrarli, secondo una politica FIFO, per procedere nella visita. Consideriamo ora il caso generale in cui il grafo G = (V, E) sia pesato con valori reali sugli archi attraverso una funzione W : E >—» IR: come già notato nel Paragrafo 6.1, un cammino v 0 , v i , v 2 , . . . .v^ ha associato un peso (che interpretiamo come lunghezza) pari alla somma W ( V Q , V ] ) + W ( V I , V 2 ) + ... + W F V K - i . V I J dei pesi degli archi che lo compongono. Nel seguito, la lunghezza sarà intesa come peso totale del cammino. Dati due nodi u, v e V, esistono in generale più cammini che collegano u a v, ognuno con una propria lunghezza: ricordiamo che la distanza pesata 6(u, v) da u a v è definita come la lunghezza del cammino di peso minore da u a v. Notiamo che, se il grafo è non orientato, vale 6(u, v) = 6(v, u), in quanto a ogni cammino da u a v corrisponde un cammino (il medesimo percorso al contrario) da v a u della stessa lunghezza: ciò non è vero, in generale, se il grafo è orientato. Per il grafo nella Figura 8.4, possiamo osservare ad esempio che 6(vi, v R, richiede di individuare i cammini di lunghezza minima tra i nodi del grafo stesso. Tale problema assume caratteristiche diverse, in termini di miglior modo di risolverlo, in dipendenza del numero di cammini minimi che ci interessa individuare nel grafo: in particolare, se siamo interessati a individuare gli n ( n — 1 ) cammini minimi tra tutte le coppie di nodi (alipairs shortestpath),

avremo che i metodi più efficienti di soluzione possono essere diversi rispetto al caso in cui ci interessano soltanto gli n — 1 cammini minimi da un nodo a tutti gli altri (single source shortest path). Come vedremo, la complessità di risoluzione di questo problema dipende, tra l'altro, dalle caratteristiche della funzione W: in particolare, considereremo dapprima il caso in cui W : E i—» R + , in cui cioè i pesi degli archi sono positivi. Sotto questa ipotesi introdurremo il classico algoritmo di Dijkstra (definito per il caso single source, ma utilizzabile anche per quello alipairs), e vedremo che questo algoritmo è una riproposizione degli algoritmi di visita discussi nel Paragrafo 7.4, in cui viene utilizzata però una coda con priorità come struttura di dati, al posto della coda e della pila. Nel caso generale in cui i pesi degli archi possono essere anche nulli o negativi, non possiamo usare l'algoritmo di Dijkstra. Vedremo allora come risolvere il problema diversamente, per quanto riguarda sia la ricerca ali pairs che quella single source, anche se meno efficientemente del caso in cui i pesi sono positivi.

8.3.2

Cammini minimi in grafi con pesi positivi

Consideriamo inizialmente il problema di tipo single source-. in tal caso, dato un grafo G = (V, E, W) con W : E >—> R + e dato un nodo s € V, vogliamo derivare la distanza ó(s,v) da s a v, per ogni nodo v € V. Se ad esempio consideriamo ancora il grafo nella Figura 8.4, allora per s = v j vogliamo ottenere l'insieme di coppie (vj, 0), (v2, 57), ( v 3 , l l ) , ( V 4 , 9 ) , (V 5 ,20), (V 6 ,35), (V 7 ,18), (v 8 ,14), (v 9 ,+oo), (v, 0 ,+oo), ( v n , + o o ) , (vi2, +oo), associando a ogni nodo la relativa distanza da v j . Inoltre, vogliamo ottenere anche, per ogni nodo, il cammino minimo stesso: a tal fine, ci è sufficiente ottenere, per ogni nodo v, l'indicazione del nodo u che precede v nel cammino minimo da s a v stesso. Ciò e sufficiente in quanto vale la proprietà che se il cammino minimo da s a v è da- , to dalla sequenza di nodi s, uo, U i , . . . , u T , u , v, allora la sequenza s, u < ) , u j , . . . , u ^ , u è il cammino minimo da s a u: se infatti così non fosse ed esistesse un altro cammino s , w o , w i , . . . , w t , u più corto, allora anche il cammino s , w o , w i W t , u , v avrebbe lunghezza inferiore a s , u o , u . i , . . . , u r , u , v, contraddicendo l'ipotesi fatta che tale cammino sia minimo. Come vedremo ora, questo problema può essere risolto mediante un algoritmo di visita del grafo che fa uso di una coda con priorità per determinare l'ordine di visita dei nodi e che viene indicato come algoritmo di Dijkstra. Gli elementi nella coda con priorità sono coppie (v, p), con v € V e p £ IR + , ordinate rispetto ai rispettivi pesi p: come vedremo, l'algoritmo mantiene l'invariante che, per ogni coppia (v, p) nella coda con priorità, abbiamo p ^ ó(s,v) e, nel momento in cui (v,p) viene estratta dalla coda con priorità, abbiamo p = 6(s,v).

Dijkstra( s ): FOR (u = 0; u < n; u = u + 1) { pred[u] = -1; dist[u] = +oo;

> pred[s] = s; dist [s] = 0; FOR (u = 0; u < n; u = u + 1) { elemento.peso = dist[u]; elemento.dato = u; PQ.Enqueue( elemento );

} WHILE (!PQ.Empty( )) {

e = PQ.Dequeue( ); v = e.dato; FOR (x = listaAdiacenza[v].inizio; x != nuli; x = x.succ) -t u = x.dato; IF (dist[u] > dist[v] + x.peso) { dist[u] = dist[v] + x.peso; pred[u] = v; PQ.DecreaseKeyC u, dist[u] );

> > > Codice 8.7

Algoritmo di Dijkstra per la ricerca dei cammini minimi single source, dove la variabile elemento indica un nuovo elemento allocato.

In particolare, a ogni istante il peso p associato al nodo v nella coda con priorità indica la lunghezza del cammino più corto trovato finora nel grafo: tale peso viene aggiornato ogni qual volta viene individuato un cammino più breve da s a v di quello considerato finora. L'algoritmo, mostrato nel Codice 8.7, determina la distanza di ogni nodo in V da s, oltre al corrispondente cammino minimo, utilizzando due array: d i s t associa a ogni nodo v la lunghezza del più breve cammino da s a v individuato finora e p r e d rappresenta il nodo che precede v in tale cammino. Per effetto dell'esecuzione del ciclo iniziale (righe 2—12), per ogni nodo v e V - {s}, d i s t [ v ] viene posto pari a +00 in quanto al momento non conosciamo alcun cammino da s a v; inoltre viene posto d i s t [ s ] pari a 0 in quanto s dista 0 da se stesso. I valori pred[v] vengono posti, per tutti i nodi eccetto s, pari a —1, valore utilizzato per codificare il fatto che non è noto alcun cammino da s a v, mentre per s adottiamo

la convenzione che pred[s] = s. Tutti i nodi vengono inoltre inseriti nella coda con priorità, con associati i rispettivi pesi. Nel ciclo successivo (righe 13—24), l'algoritmo procede a estrarre dalla coda con priorità il nodo v con peso minimo (riga 14): come vedremo sotto, il peso associato al nodo è pari alla distanza ó(s,v). In corrispondenza a questa estrazione, vengono esaminati tutti gli archi uscenti da v (righe 16-23): per ogni arco x = (v, u) considerato, ci chiediamo se il cammino s , . . . , v , u , di lunghezza 6(s,v) + W ( v , u ) = d i s t [ v ] + x . p e s o , è più corto della distanza del cammino minimo da s a u trovato fino a ora, dove tale distanza è memorizzata in d i s t [ u ] . In tal caso (o nel caso in cui s , . . . , v , u sia il primo cammino trovato da s a u) il peso associato a u viene decrementato al valore 6(s, v) + W(v, u) (riga 21) e v diventa il predecessore di u nel cammino minimo. ALVIE:

algoritmo di Djikstra Osserva, sperimenta e verifica

Dijkstra

Il decremento del peso di un elemento in una coda con priorità viene eseguito mediante l'operazione DecreaseKey descritta nel Paragrafo 8.2.2. Possiamo notare che il peso associato a un nodo v e V — {s} non è mai inferiore alla distanza 6(s, v), in quanto all'inizio tale peso è pari a +00 e ogni volta che viene aggiornato viene posto pari alla lunghezza di un qualche cammino esistente da s a v, di lunghezza quindi non inferiore a ó(s,v). Per quanto riguarda s, il suo peso è posto pari alla distanza da se stesso e mai aggiornato, in quanto s è necessariamente il primo nodo estratto dalla coda con priorità. Inoltre, per la struttura dell'algoritmo, la sequenza dei pesi associati nel tempo a ogni nodo v è monotona decrescente. Ciò che è necessario mostrare, per convincerci che l'algoritmo restituisce le distanze da s a tutti i nodi, è che il peso d i s t [ v ] di un nodo v al momento della sua estrazione dalla coda con priorità è pari a 6(s, v). A tal fine, dato un intero i > 0 e un nodo v e V, indichiamo con Si l'insieme dei primi i nodi estratti dalla coda e con 6t(s, v) la lunghezza del cammino minimo da s a v passante per soli nodi in Si. Mostriamo ora che, dopo che l'algoritmo ha considerato i primi i nodi estratti, se un nodo v è ancora nella coda il peso a esso associato è pari alla lunghezza del cammino minimo da s a v passante per i soli nodi in Si, e quindi che d i s t [ v ] = 6i(s, v). Questo è vero all'inizio dell'algoritmo quando, dopo la prima iterazione nella corso della quale è stato estratto s, per ogni nodo v adiacente a s abbiamo d i s t [ v ] = W(s, v).

Figura 8.5

Dimostrazione di correttezza dell'algoritmo di Dijkstra.

Dato che W(s,v) è anche la lunghezza dell'unico cammino che collega s a v passando per soli nodi in Si = {s}, e quindi abbiamo in effetti d i s t [ v ] = ói(s,v). Ragionando per induzione, facciamo ora l'ipotesi che la proprietà sia vera dopo k— 1 nodi estratti dalla coda con priorità, e mostriamo che rimane vera anche dopo k estrazioni. Supponiamo che u sia il k-esimo nodo estratto: per l'ipotesi induttiva, il peso di u nella coda al momento dell'estrazione è pari alla lunghezza del più corto cammino da s a u passante per i soli nodi in S ^ - i , e quindi d i s t [ u ] = ók_i (s,u). Consideriamo ora un qualunque nodo v adiacente a u e non ancora estratto, e consideriamo il cammino minimo da s a v passante per i soli nodi in Sk = Sk_i U {u}. Possiamo avere due possibilità: 1. tale cammino non passa per u, nel qual caso corrisponde al cammino minimo passante soltanto per nodi in S ^ - i , e quindi ó i j s . v ) = ó^-i (s,v); 2. tale cammino passa anche per u, nel qual caso corrisponde al cammino minimo da s a u seguito dall'arco (u, v), e ó^fs, v) = ó ^ - i (s,u) + W(u, v). In entrambi i casi possiamo verificare come l'algoritmo del Codice 8.7 operi in modo da assegnare a d i s t [ v ] il valore &k(s,v), e quindi da mantenere vera la proprietà che stiamo mostrando. Mostriamo ora che, se v g V è l'i-esimo nodo estratto, il cammino minimo da s a v passa necessariamente per i soli nodi in Si. Infatti, supponiamo per assurdo che tale cammino passi per alcuni nodi che si trovano ancora nella coda al momento in cui v viene estratto, e indichiamo con w il primo di tali nodi che compare nel cammino minimo (Figura 8.5).

Dato che w precede v in tale cammino minimo, avremmo che 6(s,w) < ó(s,v); inoltre, dato che stiamo ipotizzando che il cammino minimo non passi tutto per nodi in Si, abbiamo che ó(s,v) < ói(s,v); infine, dato che v viene estratto dalla coda con priorità prima di w, abbiamo che 6i(s,v) < 6t(s,w). Ma ciò porta a una contraddizione, se consideriamo che, dato che w è per ipotesi il primo nodo non appartenente a St nel cammino minimo, abbiamo anche 6(s,w) = ói(s,w): infatti, otteniamo allora che 6(s, w) < 6(s,v) < ói(s,v) < ói(s,w) = 6(s,w), il che è impossibile. Qual è il costo computazionale dell'algoritmo del Codice 8.7? Dato che l'algoritmo si limita a gestire in modo opportuno una coda con priorità, il suo costo dipenderà dal costo delle operazioni eseguite su tale struttura. Indichiamo allora con t c il costo di costruzione della coda, con t e il costo di estrazione del minimo con l'operazione Dequeue e con td il costo dell'operazione D e c r e a s e K e y . Come possiamo osservare, su un grafo di n nodi e m archi, l'algoritmo esegue n estrazioni del minimo e al più m D e c r e a s e K e y , da cui consegue che il suo tempo di esecuzione è 0 ( t c + n t e + m t d ) . Se utilizziamo l'implementazione della coda con priorità mediante uno heap, descritta nel Paragrafo 8.2, abbiamo che t c = O(n), t e = O(logn) e td = O(logn), per cui l'algoritmo ha costo complessivo pari a 0 ( ( n + m) logn) tempo, che risulta O ( m l o g n ) se supponiamo che il grafo sia connesso, e quindi m = n ( n ) . Possiamo ottenere un miglioramento del costo dell'algoritmo utilizzando la semplice implementazione a lista della coda con priorità: infatti, se tale implementazione viene effettuata per mezzo di una lista non ordinata, avremo che t c = O(n), t e = O(n) e td = 0(1), in quanto il decremento del peso di un nodo non comporta alcuna riorganizzazione della struttura. In questo caso, il costo dell'algoritmo risulta 0 ( n 2 + m), che è migliore del caso precedente per grafi densi, in cui in particolare il numero di archi è m = Q(n2/logn). In generale, possiamo pensare di ridurre il costo complessivo dell'algoritmo, almeno su grafi non sparsi, diminuendo il costo dell'operazione D e c r e a s e K e y a fronte di un aumento del costo dell'operazione Dequeue. Un modo, più bilanciato, di ottenere ciò è quello di utilizzare heap non binari, ma di grado d > 2: in questo caso, la profondità dello heap, e quindi il costo della f D e c r e a s e K e y diviene log d n , mentre la Dequeue richiede tempo 0 ( d l o g d n) in quanto deve esaminare, a ogni livello, tutti i d figli del nodo attuale. Da ciò deriva che il tempo di esecuzione dell'algoritmo diviene 0 ( n d l o g d n + m l o g d n), che è minimo, al variare di d, quando n d = m, e quindi quando d = m / n . Per tale valore di d ottieniamo allora un costo dell'algoritmo pari a O ( m l o g m / n n), che risulta 0 ( m ) se m = B(n 2 ) e O ( n l o g n ) se m = 0 ( n ) . Osserviamo infine che esiste un'implementazione della coda con priorità, lo heap di Fibonacci, che consente di effettuare le operazioni Enqueue, Dequeue e D e c r e a s e K e y con un costo ammortizzato rispettivamente pari a 0 ( 1 ) , O(logn) e 0(1). Ciò, in con-

seguenza anche di quanto visto nel Paragrafo 3.4.1, comporta che ogni sequenza di p operazioni Enqueue, di q ^ p operazioni Dequeue e di r operazioni DecreaseKey ha un costo complessivo di 0 ( p -I- r + q logn) tempo. L'utilizzo di questa implementazione permette allora di eseguire l'algoritmo del Codice 8.7 in tempo 0 ( n l o g n + m), che rappresenta il miglior costo possibile nel caso di grafi con m = O ( n l o g n ) . La ricerca dei cammini minimi tra tutte le coppie di nodi (vale a dire nel caso ali pairs) può essere effettuata applicando n volte, una per ogni possibile nodo sorgente, l'algoritmo di Dijkstra illustrato sopra: ciò fornisce un metodo di soluzione di tale problema avente costo O ( n l o g n + n m ) .

8.3.3

Cammini minimi in grafi pesati generali

L'algoritmo di Dijkstra non è applicabile al problema dei cammini minimi quando gli archi del grafo possono avere pesi negativi o nulli, perché un arco potrebbe diminuire la lunghezza del cammino di un nodo già estratto dalla coda con priorità. Per trattare la versione generale del problema dobbiamo comunque ipotizzare che nel grafo, anche in presenza di archi a peso negativo, non esistano cicli aventi peso complessivo negativo (vale a dire che è negativa la somma dei pesi degli archi corrispondenti): se v ^ , v i 2 , . . . , v¿ k , v i ( fosse un tale ciclo, avente peso —D, la distanza tra due qualunque di tali nodi sarebbe —oo. Notiamo infatti che attraversare il ciclo comporta che la distanza complessivamente percorsa viene decrementata di D, e quindi, dato che esso può essere percorso un numero arbitrariamente grande di volte, la distanza può divenire arbitrariamente grande in valore assoluto e negativa. Notiamo inoltre che ciò avviene non solo per quanto riguarda la distanza tra due nodi del ciclo, ma in realtà per tutte le coppie di nodi nella stessa componente connessa che include il ciclo stesso e quindi, se il grafo è connesso per tutte le coppie di nodi, la cui distanza risulta quindi pari a —oo. Il metodo più diffuso per la ricerca single source dei cammini minimi è il cosiddetto algoritmo di Bellman-Ford, che opera come mostrato nel Codice 8.8. L'algoritmo ha una struttura molto semplice e simile a quello di Dijkstra. Come possiamo vedere, non necessita di strutture di dati particolari: anche in questo caso per ogni nodo v viene mantenuta in d i s t [ v ] la lunghezza del cammino più breve da s a v finora trovato dall'algoritmo e in pred[v] il nodo che precede immediatamente v lungo tale cammino. Facciamo l'ipotesi che il valore - 1 per pred[v] stia a rappresentare il fatto che non viene

rappresentato alcun cammino da s a v; inoltre, poniamo anche predts] = s. Dopo l'inizializzazione (righe 2 - 7 ) come nell'algoritmo di Dijkstra, l'algoritmo opera per n = |V| iterazioni la scansione di tutti gli archi di G (righe 8-17): per ogni arco (u,v) esaminato, verifichiamo se i valori d i s t [ u ] e d i s t [ v ] soddisfano la condizione d i s t [ u ] > d i s t [ v ] 4- W ( u , v) (riga 12). Se è così, questo vuol dire che esiste un cam-

Bellman-FordC FOR (u = 0; pred[u] = dist[u] =

s ) : u < N; u = u + 1) { -1; +00;

> pred[s] = s; dist [s] = 0; FOR (i = 0; i < N; i = i + 1) FOR (v = 0; v < N; v = v + 1) { FOR (X = listaAdiacenza[v].inizio; x != nuli; x=x.succ) { u = x.dato; IF (dist[u] > dist[v] + x.peso) { dist[u] = dist[v] + x.peso; pred[u] = v;

> > > Codice 8.8

Algoritmo di Bellman-Ford per la ricerca dei cammini minimi single source.

mino s , . . . , v , u più breve del miglior cammino da s a u trovato finora: le informazioni d i s t [ u ] e d i s t [ v ] vengono allora aggiornate per rappresentare questa nuova situazione. ALVIE:

algoritmo di Bellman-Ford C5> • Osserva, sperimenta e verifica BellmanFord

-JOD CH

: vi».

Per mostrare che l'algoritmo opera correttamente anche con pesi negativi e nulli e quindi, al momento della sua terminazione, abbiamo d i s t [ v ] = 6(s, v) per ogni nodo v, ragioniamo per induzione: verifichiamo che, all'inizio dell'iterazione i nel ciclo più esterno (righe 8—17), abbiamo che d i s t [ v ] = ó(s, v) per ogni nodo v per il quale il cammino minimo da s a v è composto da al più i archi. Questa proprietà è vera per i = 0, in quanto s è il solo nodo tale che il cammino minimo da s a s è composto da 0 archi, e in effetti d i s t [ s ] = 0 = ó(s, s). Per il passo induttivo, ipotizziamo ora che la proprietà sia vera all'inizio dell'iterazione i: se consideriamo un qualunque nodo v tale che il cammino minimo s , . . . , u , v comprende i + 1 archi, ne consegue che per il nodo u il cammino minimo s , . . . , u com-

prende i primi i archi del cammino precedente, e quindi abbiamo d i s t [ u ] = ó(s,u). Ma allora, nel corso dell'iterazione i, abbiamo che d i s t [ v ] è aggiornato in modo che d i s t [ v ] = d i s t [ u ] -I-W(u,v) = ó(s,v) + W(u,v), che è uguale a 6(s,v) per l'ipotesi che il cammino s , . . . , u , v sia minimo. Quindi la proprietà risulta soddisfatta quando inizia l'iterazione i + 1. Se osserviamo che, dato che non esistono per ipotesi cicli di lunghezza negativa in G, un cammino minimo comprende al più n — 1 archi, possiamo concludere che quando i = n tutti i cammini minimi sono stati individuati. Per quanto riguarda il costo di esecuzione, possiamo notare che l'algoritmo esegue O(n) iterazioni, in ognuna delle quali esamina ogni arco due volte, e quindi esegue O(m) operazioni: ne consegue che il tempo di esecuzione è O(nm). Abbiamo quindi che la più vasta applicabilità (anche a grafi con pesi negativi, se non abbiamo cicli negativi) rispetto all'algoritmo di Dijkstra viene pagata da una minore efficienza. Osserviamo infine che, per quanto detto, se l'algoritmo effettuasse più di n iterazioni, l'ultima di esse non vedrebbe alcun aggiornamento dei valori nell'array d i s t , in quanto i cammini minimi sono stati già tutti trovati. Invece, se il grafo avesse cicli di lunghezza negativa, ci sarebbero valori in d i s t [ v ] che continuerebbero a decrementarsi indefinitamente. Da questa osservazione deriva che l'algoritmo di Bellman-Ford può essere utilizzato anche per verificare se un grafo G presenta cicli di lunghezza negativa. A tal fine, se n è il numero di nodi, basta eseguire n + 1 iterazioni e verificare se nel corso dell'ultima vengono modificati valori nell'array d i s t : se così è, possiamo concludere che il grafo presenta in effetti dei cicli negativi. Se consideriamo ora il problema della ricerca dei cammini minimi tra tutti i nodi, possiamo dire che una semplice soluzione è data, come nel caso dei grafi a pesi positivi, dall'iterazione su tutti i nodi dell'algoritmo per la ricerca single source: nel nostro caso, questo risulterebbe in un tempo di esecuzione 0 ( n 2 m ) . Possiamo tuttavia ottenere di meglio utilizzando il paradigma della programmazione dinamica, introdotto nel Paragrafo 2.7. Per seguire questo approccio, utilizziamo la rappresentazione dei grafi pesati mediante la matrice di adiacenza A a cui è associata quella dei pesi P, tale che P[u, v] = W(u, v) se e solo se A[u,v] = 1 (vale a dire (u,v) £ E), dove richiediamo che P[u, u] = 0 per 0 ^ u, v < n: gli altri valori di P sono considerati pari a +00 in quanto non esiste un arco di collegamento. Dato un cammino da u a v, definiamo come nodo interno del cammino un nodo w, diverso sia da u che da v, che compare nel cammino stesso. Per ogni k < n, indichiamo con V^ l'insieme dei primi k nodi di V, abbiamo cioè per definizione che Vk={0,l,...,k-1}. Possiamo allora considerare, per ogni coppia di nodi u e v e per ogni k, il cammino minimo ^ ( u , v) da u a v passante soltanto per nodi in V^ (eccetto per quanto riguarda

u e v stessi): se k = n tale cammino è il cammino minimo 7t(u,v) da u a v nell'intero grafo. Inoltre, dato che Vo = 0, il cammino minimo 7to(u,v) da u a v in Vo è dato dall'arco (u, v), se tale arco esiste. Se indichiamo con 6|c(u, v) la lunghezza del cammino 7t k (u,v), avremo allora che 6o(u,v) = W ( u , v ) se (u,v) G E, mentre óo(u,v) = +00 altrimenti. Se soffermiamo la nostra attenzione sul cammino minimo Tt^fu.v) per 0 < k ^ n , possiamo notare che possono verificarsi due eventualità: 1. il nodo k— 1 non appare in ^ ( u , v), ma allora ^ ( u , v) = 7tic_ 1 (u, v) (il cammino minimo è lo stesso che nel caso in cui utilizzavamo soltanto i nodi {0,1 k — 2} come nodi interni); 2. al contrario, il nodo k—1 appare in ^ ( u , v), ma allora, dato che tale cammino non può contenere cicli in quanto ogni ciclo (che non può avere lunghezza negativa) lo renderebbe più lungo, 7tk(u, v) può essere suddiviso in due parti: un primo cammino da u a k — 1 passante soltanto per nodi in Vk_i e un secondo cammino da k — 1 a v passante anch'esso per soli nodi in V k _ 1. Possiamo quindi derivare la seguente regola di programmazione dinamica per calcolare la lunghezza ó^fu, v) del cammino minimo da u a v: Si

{

ì= i

k|U VJ

'

S

p u,v

t l m i n { 6 k _ I ( u , v ) , 6 l c _ 1 ( u , k - 1) + S k _ , (k - l,v)}

se k = 0 se k > 1

Seguendo l'approccio della programmazione dinamica, abbiamo quindi un meccanismo di decomposizione del problema in sottoproblemi più semplici, con una soluzione definita per i sottoproblemi elementari, corrispondenti al caso k = 0. A questo punto, l'algoritmo risultante, detto algoritmo di Floyd-Warshall, presenta l'usuale struttura di un algoritmo di programmazione dinamica. In particolare, esso opera (almeno concettualmente) su una coppia di tabelle tridimensionali d i s t e p r e d , ciascuna di n "strati", n righe e n colonne: per il cammino 7t k (u, v), l'algoritmo utilizza queste tabelle per memorizzare 6k(u, v) in d i s t [ k , u, v] secondo la relazione (8.2) e il predecessore di v in tale cammino in p r e d [ k , u , v]. In realtà, un più attento esame dell'equazione (8.2) mostra che possiamo ignorare l'indice k — 1: infatti 6 k - i (u, k — 1) = 6k(u, k — 1) e ó ^ i i k — 1, v) = ók(k — 1, v) in quanto il nodo di arrivo (nel primo caso) e di partenza (nel secondo caso) è k — 1, il quale non può essere un nodo interno in tali cammini quando passiamo da V k _i a Vy. Tale osservazione ci permette di utilizzare le tabelle bidimensionali n x n per rappresentare d i s t e p r e d , come possiamo vedere nel Codice 8.9. L'algoritmo, dopo una prima fase di inizializzazione degli elementi d i s t [ u , v] (righe 2-5), passa a derivare iterativamente tutti i cammini minimi in al crescere di k

Floyd-Warshall( FOR (u = 0; u FOR (v = 0; dist[u,v]

): < n; u = u + 1) v < n; v = v + 1) { = P[u,v];

> FOR (k = 1; k dist[u,k-l] + dist[k-l,v]) { dist[u,v] = dist[u,k-l] + dist[k-l,v]; pred[u,v] = pred[k-l,v];

>

>

> Codice 8.9 Algoritmo di Floyd-Warshall per la ricerca dei cammini minimi alipairs.

(righe 6—14). A tal fine, utilizza la relazione di programmazione dinamica riportata in (8.2) per inferire, alla riga 9, quale sia il cammino minimo 7tk(u,v) e quindi quali valori memorizzare per rappresentare tale cammino e la relativa lunghezza (righe 10—12). Il costo dell'algoritmo è determinato dai tre cicli nidificati, da cui deriva un tempo di computazione 0 ( n 3 ) ; per quanto riguarda lo spazio utilizzato, il Codice 8.9 fa uso, come detto sopra, di due array di n x n elementi, totalizzando 0 ( n 2 ) spazio. ALVIE:

algoritmo di Floyd-Warshall

Osserva, sperimenta e verifica FloydWarshall

8.4

Opus libri: data mining e minimi alberi ricoprenti

In applicazioni di data mining è necessario operare su insiemi di dati di grandi dimensioni, ad esempio di carattere sperimentale, per individuare delle regolarità nei dati trattati o similitudini tra loro sottoinsiemi: il tutto nel tentativo di derivare un'ipotesi di legge, un qualche tipo di conoscenza induttivo della realtà soggiacente ai dati. Tipici casi di tali applicazioni sono ad esempio l'analisi di una rete sociale con l'obiettivo di individuare

le comunità al suo interno, o il partizionamento di una collezione di documenti su un insieme di tematiche. In questo contesto, un metodo applicato in modo estensivo è l'analisi di cluster (cluster analysis), vale a dire il partizionamento di dati osservati in sottoinsiemi, detti cluster, in modo tale che i dati in ciascun cluster condividano qualche proprietà comune, non posseduta da dati esterni al cluster. In genere, l'individuazione dei cluster viene effettuata in termini di prossimità dei dati rispetto a una qualche metrica definita su di essi che ne misura la distanza: a tal fine, i dati possono essere proiettati, eventualmente per mezzo di una qualche funzione predefinita, su uno spazio k-dimensionale, utilizzando la normale distanza euclidea come metrica di riferimento. Sono stati definiti numerosi metodi per l'individuazione di una partizione in cluster: la scelta del più efficace da utilizzare rispetto a un determinato insieme di dati è spesso molto ardua. La maggior parte dei metodi richiede inoltre l'assegnazione di valori a una serie di parametri, o addirittura la predeterminazione del numero di cluster da ottenere. In tale contesto, una tecnica piuttosto semplice, diffusa ed efficace in molte situazioni, derivata dalla teoria dei grafi, è basata sull'uso di alberi minimi di ricoprimento. Il problema del minimo albero di ricoprimento o minimo albero ricoprente (minimum spanning tree) è uno dei problemi su grafi più semplici da definire e più studiati. Ricordiamo che, dato un grafo non orientato e connesso G = (V, E), un albero di ricoprimento di G è un albero T i cui archi sono anche archi del grafo e collegano tutti i nodi in V (Paragrafo 7.4), ossia un albero T = (V, E'), dove E' C E. Un tale albero è minimo se la somma dei pesi nei suoi archi è la minima tra quelle di tutti i possibili alberi di ricoprimento (notiamo come nel caso in cui G non sia connesso non esiste alcun albero che lo ricopre: possiamo però definire una foresta di ricoprimento di G, vale a dire un insieme di alberi, ciascuno ricoprente la propria componente connessa). Nel seguito faremo sempre l'ipotesi che G sia connesso. Nel data mining basato sull'analisi dei cluster, i dati sono rappresentati da punti in uno spazio k-dimensionale. Un esempio è nell'analisi del genoma, in cui è possibile monitorare simultaneamente k parametri numerici per decine di migliaia di geni (che reagiscono a cambiamenti imposti al loro ambiente) attraverso dei dispositivi speciali chiamati microarray, i quali forniscono un insieme di punti k-dimensionali in uscita. In tal caso, il clustering basato sull'albero minimo di ricoprimento opera a partire dal grafo G = (V, E, W) in cui V è l'insieme dei punti, E è l'insieme di tutte le possibili coppie di punti (quindi G è un grafo completo di tutti gli archi) e W è la distanza euclidea tra i punti: in tale contesto l'albero minimo di ricoprimento viene detto euclideo. L'albero risultante può essere utilizzato come base per il partizionamento in cluster: come vedremo, infatti, ogni arco e dell'albero è l'arco di peso inferiore tra quelli in grado j3® di collegare le due porzioni di albero ottenute dall'eventuale rimozione di e.

In termini di data mining, la rimozione di e dà luogo a una separazione tra due cluster in cui la distanza tra due qualsiasi punti, uno per cluster, non può essere inferiore al peso di e. Scegliendo di rimuovere gli archi più lunghi (di peso maggiore) presenti nell'albero minimo di ricoprimento, abbiamo che iniziamo a separare i cluster più distanti tra di loro: osserviamo infatti che punti appartenenti alla stessa porzione di albero formano un cluster e che cluster diversi tendono a essere collegati da archi più lunghi. La rimozione di ogni successivo arco lungo, separa ulteriormente i cluster. In generale, dopo k rimozioni di archi dall'albero, otteniamo k + 1 cluster, tendendo così a separare insiemi di nodi "lontani" tra loro, al fine di ottenere cluster il più possibile significativi. Comunque, non è detto che la sola eliminazione degli archi più lunghi nell'albero fornisca una partizione in cluster accettabile: ad esempio, alcuni cluster ottenuti possono essere eccessivamente piccoli per le finalità dell'analisi dei dati effettuata. Spesso, può essere necessario aggiungere ulteriori criteri di scelta degli archi da eliminare, in modo da migliorare la significatività della partizione ottenuta.

8.4.1

Problema della ricerca del minimo albero di ricoprimento

Come già visto nel Paragrafo 7.4, un qualunque albero di ricoprimento di un grafo può essere trovato in tempo 0 ( n + m) mediante una visita (sia in ampiezza che in profondità) del grafo stesso: gli alberi BFS e DFS risultanti da tali visite non sono altro che particolari alberi di ricoprimento. Consideriamo ora, come nel paragrafo precedente, il caso in cui l'insieme degli archi sia pesato per mezzo di una funzione W : E — R + e che il grafo sia connesso e non orientato e connesso. In questa ipotesi ogni albero ricoprente T = (V, E') di G ha associato un peso Y - e e f W(e) pari cioè alla somma dei pesi dei suoi archi. Quel che vogliamo è allora trovare, fra tutti gli alberi ricoprenti di G, quello avente peso minimo (laddove gli alberi BFS e DFS non sono necessariamente di peso minimo). Introduciamo ora due proprietà strutturali del minimo albero ricoprente, che saranno utilizzate negli algoritmi che considereremo per questo problema: queste proprietà fanno riferimento al concetto di ciclo, già introdotto nel Paragrafo 6.1, e alla nuova nozione di taglio in un grafo connesso. Dato un grafo G = (V, E), un taglio {cut) su G è un qualunque sottoinsieme C C E di archi la cui rimozione disconnette il grafo, nel senso che il grafo G' = (V, E — C) è tale che esistono almeno due nodi u , v € V tra i quali non esiste un cammino. Ad esempio, nella Figura 8.6 viene mostrato un taglio (riportato graficamente come una linea più spessa) che corrisponde all'insieme di archi C = {(v^vs), (vi,vs), (v4,vy), v v ( v 4 » v 2 ) . ( v 3 > v 7 ) > ( v 3 > v 2 ) > ( i > 5 ) l - Come possiamo verificare, la rimozione degli archi nel taglio disconnette il grafo in due componenti disgiunte {vi, V3, V4} e (V2, V5, vg, V7, vg}. Enunciamo ora le due proprietà di un minimo albero ricoprente che utilizzeremo per mostrare la correttezza degli algoritmi che considereremo nel seguito. Dato un grafo

Figura 8.6

Esempio di taglio in un grafo.

G = (V, E) pesato sugli archi e un suo minimo albero ricoprente T = (V, E'), per ogni arco e G E abbiamo che: Condizione di taglio e £ E' se e solo se esiste un taglio in G che comprende e, per il quale e è l'arco di peso minimo; Condizione di ciclo e 0 E' se e solo se esiste un ciclo in G che comprende e, per il quale e è l'arco di peso massimo. Per convincerci della prima proprietà, osserviamo che se e e E' allora la sua rimozione disconnette i nodi su T in due componenti V', V" (ricordiamo che la rimozione di un qualunque arco di un albero disconnette l'albero stesso). L'insieme che comprende sia e che tutti gli archi (v',v") e E - E' tali che v' e V' e v " G V" è un taglio in G. Per la minimalità dell'albero T, per qualunque arco e' ^ e in tale insieme vale che YV(e') ^ W(e): se così non fosse, infatti, l'insieme di archi E' — {e} U {e'} indurrebbe un diverso albero ricoprente di peso W(T) — W(e) + W(e') < W(T). Al tempo stesso, con la stessa motivazione, dato un qualunque taglio in G, l'arco di peso minimo in tale taglio deve essere incluso nel minimo albero ricoprente. Per quanto riguarda la seconda proprietà, per ogni arco e 0 E', l'insieme E' U {e} induce un ciclo che include tale arco: se in tale ciclo esistesse un arco e' € E' tale che W(e') > W(e) allora l'insieme di archi E' — {e'} U {e} indurrebbe un diverso albero ricoprente di peso W(T) — W(e') + W(e) < W(T), pervenendo a una contraddizione. Al tempo stesso, se e è l'arco di peso massimo in un ciclo, non può appartenere a E': se così fosse, potremmo sostuirlo con uno del ciclo meno pesante, ottenendo per assurdo un albero di ricoprimento di costo inferiore al minimo. Nel seguito introduciamo due algoritmi classici risalenti a metà degli anni '50, l'algoritmo di Kruskal e quello di Jarnik-Prim, per la ricerca del minimo albero ricoprente di

un grafo. Prima di esaminarli, preannunciamo però che, in effetti, essi sono due varianti di un medesimo approccio generale alla risoluzione del problema. In questo approccio, un algoritmo opera in modo goloso, inizializzando E' all'insieme vuoto, e aggiungendo poi archi a tale insieme fino a che il grafo T = (V, E') non diventa connesso. Un arco viene aggiunto a E' se è quello più leggero uscente da una qualche componente connessa di T, vale a dire se è l'arco più leggero che collega un nodo della componente a un nodo non appartenente a essa. Per quanto detto sopra, quindi, un arco è incluso nel minimo albero ricoprente se è più leggero di un qualunque taglio che separa la componente dal resto del grafo. L'effetto di tutto ciò è che, a ogni passo, gli archi in E' formano un sottoinsieme (una foresta, in effetti) del minimo albero di ricoprimento. Dato che l'algoritmo termina quando tutti gli archi sono stati esaminati, ne deriva che per ogni taglio esiste almeno un arco (il più leggero, in particolare) che è stato inserito in E' e quindi il grafo T = (V, E') è connesso. Inoltre, dato che per ogni arco inserito in E' due componenti connesse disgiunte di T vengono riunite e che, a partire da n componenti disgiunte (i singoli nodi) per giungere ad avere un singola componente di n nodi bisogna effettuare n — 1 tali operazioni di riunione, ne deriva che il numero di archi in E' al termine dell'algoritmo è pari a n — 1 e che, quindi, T è un albero (essendo connesso e aciclico per costruzione).

8.4.2

Algoritmo di Kruskal

L'algoritmo di Kruskal per la ricerca del minimo albero di ricoprimento opera considerando gli archi l'uno dopo l'altro, in ordine crescente di peso, valutando se inserire ogni arco nell'insieme E' degli archi dell'albero. Nel considerare l'arco (u, v), possiamo avere due possibilità: 1. i due nodi u e v sono già collegati in G = (V, E'), e quindi l'arco (u,v) chiude un ciclo: in tal caso esso è l'arco più pesante nel ciclo, e quindi non appartiene al minimo albero di ricoprimento; 2. i due nodi u e v non sono già collegati in G = (V, E'), e quindi esiste almeno un taglio che separa u da v: di tale taglio (u., v), essendo il primo arco considerato, e il più leggero, e quindi esso appartiene all'albero e va messo in E'. L'algoritmo di Kruskal opera a partire da una situazione in cui esistono n componenti connesse distinte (gli n nodi isolati), ognuna con un proprio minimo albero di ricoprimento (l'insieme vuoto degli archi). L'algoritmo unisce man mano coppie di componenti disgiunte, mantenendo al tempo stesso traccia del minimo albero di ricoprimento della componente risultante. AI termine dell'esecuzione, tutte le componenti sono state riunificate in una sola, il cui minimo albero ricoprente è quindi il minimo albero ricoprente dell'intero grafo.

Kruskal( ) : FOR (u = 0; u < n; u = u + 1) { FOR (x = listaAdiacenza[U].inizio ; x != null; x = x.succ) { v = x.dato; elemento.dato = ; elemento.peso = x.peso; PQ.Enqueue( elemento );

> set[u] = NuovoNodo( ); Crea( set[u] );

> WHILE (!PQ.Empty( )) {

elemento = PQ.Dequeue( ); = elemento.dato ; IF (¡Appartieni( set[u], set[v] )) { Unisci( set[u], set [v] ); mst.InserisciFondo( );

> Codice 8.10

Algoritmo di Kruskal per la ricerca del minimo albero di ricoprimento.

Il Codice 8.10 presenta un'implementazione dettagliata dell'algoritmo delineato sopra e codice vengono utilizzate diverse strutture di dati: 1. una coda con priorità PQ contenente l'insieme degli archi del grafo e i loro pesi; 2. una struttura di dati che rappresenta una partizione dell'insieme dei nodi in modo tale dà consentire di verificare se due nodi appartengono allo stesso sottoinsieme e da effettuare l'unione dei sottoinsiemi di appartenenza di due elementi: a tal fine, utilizziamo un insieme di liste, così come illustrato nel Paragrafo 3.4.1; 3. un array s e t che associa a ogni nodo del grafo un riferimento al corrispondente elemento nella struttura di dati precedente; 4. una lista doppia m s t (come definita nel Paragrafo 5.2) utilizzata per memorizzare gli archi nel minimo albero ricoprente, quando sono individuati dall'algoritmo. Come possiamo vedere, l'algoritmo utilizza la coda con priorità per ottenere un ordinamento degli archi crescente rispetto al loro peso. In particolare, viene inizialmente creata una coda con priorità contenente tutti gli archi, oltre a una rappresentazione

di componenti composte da liste disgiunte, ciascuna inizialmente contenente un solo nodo u (righe 2-11). Gli archi vengono poi considerati l'uno dopo l'altro, in ordine crescente di peso (righe 12-19): per ogni arco, ci chiediamo se esso collega due nodi posti in componenti diverse o, equivalentemente, se chiude un ciclo. Se ciò non avviene e, quindi, l'arco ha peso minimo su un qualunque taglio che divide le due componenti, tali componenti sono unificate e l'arco viene inserito nel minimo albero di ricoprimento (righe 15—18). Notiamo che, essendo il grafo non orientato, ogni arco viene inserito due volte nella coda con priorità senza pregiudicare l'esito dell'algoritmo quando viene estratto due volte (la prima estrazione soltanto può sortire un effetto in quanto la seconda non supera la condizione alla riga 15). Per valutare il tempo di esecuzione dell'algoritmo di Kruskal, possiamo vedere che, su un grafo di n nodi e m archi, esso effettua al più m operazioni E n q u e u e , Empty e D e q u e u e , oltre a n operazioni C r e a , U n i s c i e m operazioni A p p a r t i e n i : il costo dell'algoritmo dipende quindi dai costi di esecuzione di tali operazioni sulle strutture di dati utilizzate. Se ad esempio utilizziamo uno heap come implementazione della coda con priorità e l'insieme di liste del Paragrafo 3.4.1 per rappresentare partizioni di nodi, abbiamo che E n q u e u e e D e q u e u e richiedono tempo O(logn), Empty, A p p a r t i e n i e C r e a in tempo 0 ( 1 ) , e A p p a r t i e n i in tempo O(logn) ammortizzato. Da ciò deriva che il costo complessivo dell'algoritmo in tal caso è O f m l o g m + n l o g n ) = 0 ( ( m + n) logn), e quindi O ( m l o g n ) se il grafo è connesso, per cui abbiamo m ^ n . ALVIE:

algoritmo di Kruskal

Osserva, sperimenta e verifica

Kruskal

8.4.3

Algoritmo di Jarnik-Prim

Come abbiamo visto, l'algoritmo di Kruskal costruisce un minimo albero ricoprente facendo crescere un insieme di minimi alberi ricoprenti relativi a sottoinsiemi dei nodi: gli alberi sono man mano unificati fino a ottenere quello relativo all'intero grafo.

L'algoritmo di Jarnik-Prim opera in modo più "centralizzato": esso parte da un qualunque nodo s e fa crescere un minimo albero ricoprente a partire da tale nodo, aggiungendo man mano nuovi nodi e archi all'albero stesso: se T indica la porzione di minimo albero ricoprente attualmente costruita, l'algoritmo sceglie l'arco (u,v) tale che esso è di

8.4 Opus libri: data mining e minimi alberi ricoprenti

peso minimo nel taglio tra u e T e v € V — T, aggiungendo v a T e (u,v) all'insieme E', fino a coprire tutti i nodi del grafo. Non è difficile renderci conto che l'insieme E' ottenuto al termine dell'algoritmo è l'insieme degli archi nel minimo albero ricoprente. Infatti, ogni arco aggiunto a E' è il più leggero nel taglio che separa T da V — T e quindi, per quanto detto sopra, deve far parte del minimo albero ricoprente del grafo. Dato che, inoltre, al termine dell'algoritmo abbiamo che | E' |= n — 1, tutti gli archi dell'albero compaiono in E'. Come implementare in modo efficiente l'algoritmo presentato sopra? Il punto critico di un'implementazione è rappresentato dal come realizzare efficientemente la selezione dell'arco di peso minimo tra T a V — T. La soluzione banale, consistente nell'effettuare tale selezione scandendo ogni volta tutti gli m archi porterebbe a ottenere un tempo di esecuzione O ( n m ) , peggiore quindi di quello ottenuto dall'algoritmo di Kruskal. Una soluzione più efficiente è fornita dall'utilizzo di una coda con priorità PQ all'interno della quale mantenere, a ogni istante, l'insieme dei nodi in V—T, utilizzando come peso di ogni nodo v e V — T il peso dell'arco più leggero che collega v a un qualche nodo in T. Se ad esempio consideriamo la situazione rappresentata nel primo grafo nella Figura 8.7, osserviamo che V — T = {v2, vg, V7, vg} e che i pesi associati a ognuno di tali nodi saranno, rispettivamente, 8, +00, 7, 14, dove il peso +00 per il nodo v WHILE (!PQ.Empty( )) {

elemento = PQ.Dequeue( ); v = elemento.dato; incluso[v] = TRUE; mst.InserisciFondoC ); FOR (X = listaAdiacenza[v].inizio; x != null; x = x.succ) { u = x.dato; IF (!incluso[u] && x.peso < peso[u]) { pred[u] = v; peso[u] = x.peso; PQ.DecreaseKeyC u, peso[u] );

> > Codice 8.11

Algoritmo di Jarnik-Prim per la ricerca del minimo albero di ricoprimento.

Utilizzando una coda con priorità di questo tipo, la selezione dell'arco viene effettuata mediante una D e q u e u e , ma, al tempo stesso, è necessario prevedere l'aggiornamento, quando necessario, dei pesi associati ai nodi in PQ. Tale aggiornamento può derivare dal passaggio di un nodo v da V —T a T, passaggio che fa sì che modifichiamo l'insieme degli archi tra T e V — T considerati per la selezione. Ad esempio, nel passaggio dalla situazione considerata sopra alla situazione successiva, descritta dal secondo grafo nella figura, abbiamo che, per effetto del passaggio del nodo v j da V — T a T, il nodo vg risulta ora adiacente a un nodo in T (V7 stesso), e quindi il peso associato a vg in PQ viene decrementato da +00 a 17 = Wfv^, V7). Se consideriamo la situazione successiva (descritta dal terzo grafo nella figura) possiamo vedere come, per effetto del passaggio in T di V2 adiacente anch'esso a vg, l'arco più leggero che collega tale nodo a un qualche altro nodo in T sia (v2,vg), e quindi, a questo punto, il peso associato a Vg in PQ è pari a W(v2, vg) = 8. Da quanto osservato, possiamo concludere che l'utilizzo di PQ richiede che, in corrispondenza a ogni passaggio di un nodo v da T a V —T, vengano esaminati tutti gli i archi

incidenti v. Per ogni arco (u, v) di questo tipo, se u e V — T allora il peso di u in PQ viene posto pari a W ( u , v), se tale valore è minore del peso attuale di u . In tal modo, se esiste ora un modo "più economico" per collegare un nodo in T a V — T, ciò viene riportato nella coda con priorità. Quindi, le operazioni che vanno effettuate su PQ sono quindi E n q u e u e per costruire la coda, D e q u e u e per estrarre man mano i nodi da inserire in T e D e c r e a s e K e y per aggiornare il peso dei nodi ancora in T (notiamo che l'aggiornamento può essere soltanto un decremento). Il Codice 8.11 realizza tale strategia impiegando un array booleano i n c l u s o per marcare i vertici in T, mentre quelli in V — T sono nella coda con priorità PQ (l'inizializzazione è alle righe 2 - 8 ) . Nel ciclo principale (righe 9 22), l'algoritmo estrae il nodo v da includere in T e decrementa il peso dei suoi vertici adiacenti u in V — T, quando questi hanno peso superiore a quello dell'arco (v, u) che li collega a v. L'algoritmo esegue in particolare n operazioni E n q u e u e e D e q u e u e , e al più 2 m operazioni D e c r e a s e K e y , e il suo costo, come nei casi precedenti degli algoritmi di Dijkstra e di Kruskal, dipende dal costo di tali operazioni sull'implementazione della coda con priorità adottata. Ad esempio, utilizzando uno heap abbiamo, come già visto, che le tre operazioni in questione hanno ciascuna costo O(logn), per cui il costo complessivo dell'algoritmo risulta 0 ( ( n + m) logn), e quindi O ( m l o g n ) in quanto consideriamo il grafo connesso: l'utilizzo di uno heap di grado d > 2 ha come conseguenza, così come per l'algoritmo di Dijkstra, che il costo risulta 0 ( m l o g T n y n n ) . Infine, l'utilizzo di heap di Fibonacci (che, ricordiamo, hanno costi ammortizzati per le operazioni Enqueue, Dequeue e D e c r e a s e K e y pari a 0 ( 1 ) , O(logn) e 0 ( 1 ) , rispettivamente) fasi che il costo complessivo dell'algoritmo risulti 0 ( n l o g n - | - m), migliore quindi di quanto ottenuto per mezzo dall'algoritmo di Kruskal. ALVIE:

algoritmo di Jarnik-Prim

Osserva, sperimenta e verifica Jarnik-Prim

RIEPILOGO In questo capitolo abbiamo esaminato le code con priorità, mostrandone e discutendone l'implementazione più diffusa, lo heap. Abbiamo illustrato l'applicazione delle code con priorità a vari importanti problemi, come l'ordinamento, per il quale abbiamo considerato l'algoritmo di heapsort, la ricerca di cammini minimi e la derivazione del minimo albero ricoprente in grafi pesati. Per tali problemi, che rivestono grande rilevanza applicativa, abbiamo mostrato diverse soluzioni algoritmiche, sia facenti uso di code con priorità che operanti senza utilizzare tali strutture.

ESERCIZI 1. Mostrate come ottenere una coda con priorità con la stessa complessità in tempo dello heap, utilizzando un albero AVL (descrivete anche come realizzare la funzione D e c r e a s e K e y ) . Discutete complessità in spazio. 2. Un min-max heap è una struttura di dati che implementa una coda con priorità sia rispetto al minimo che rispetto al massimo. In uno heap di questo tipo, nella radice è contenuto il minimo e, in ognuno dei suoi due figli, il massimo tra gli elementi presenti nel relativo sottoalbero. In generale, i nodi nell'i-esimo livello dall'alto (dove assumiamo che la radice è a livello 1) contengono gli elementi minimi nei relativi sottoalberi, se i è dispari, e gli elementi massimi, se i è pari. Implementate tale struttura con le operazioni E n q u e u e , E x t r a c t M i n , E x t r a c t M a x , che inseriscono un elemento ed estraggono il minimo e il massimo, rispettivamente. 3. Nella trattazione dell'algoritmo di Dijkstra abbiamo implicitamente supposto che tra ogni coppia di nodi esista un solo cammino minimo. Dimostrate che la correttezza e l'efficienza dell'algoritmo valgono anche nel caso generale in cui possono esistere più cammini minimi tra due nodi. Mostrate anche che i cammini selezionati dall'algoritmo tramite l'array p r e d formano un albero dei cammini minimi con radice nel nodo di partenza s. 4. Implementate uno heap di grado predefinito d > 2: a tal fine, prevedete una funzione C r e a ( d ) che crea uno heap del grado specificato. 5. Mostrate come utilizzare l'algoritmo di Dijkstra per determinare, dato un nodo v, il ciclo semplice di lunghezza minima che include tale nodo. Implementate quindi un algoritmo per ottenere il girovita (girth) di un grafo, vale a dire la lunghezza del ciclo semplice di lunghezza minima nel grafo stesso. 6. Modificate il codice dell'algoritmo di Bellman-Ford per verificare la presenza di cicli negativi, cosi come illustrato nel testo. 7. Implementate un semplice algoritmo di clustering basato sul minimo albero di ricoprimento. L'algoritmo, dato un insieme S di punti in uno spazio a d = 3 dimensioni, suddivide tale insieme in k cluster, dove k è un parametro passato all'algoritmo stesso. 8. Fornite un controesempio per mostrare che gli algoritmi di Kruskal e Jamik-Prim non calcolano sempre correttamente l'albero minimo di ricoprimento per un grafo orientato.

Capitolo 9

NP-completezza

SOMMARIO In questo capitolo finale riprendiamo in esame le classi di complessità introdotte nel primo capitolo, dandone una definizione formale basata sul concetto di problema decisionale e su quello di verifica di una soluzione. Introduciamo inoltre il concetto di riduzione polinomiale e quello di problema NP-completo, dimostrando la NP-completezza di alcuni problemi computazionali esaminati nel resto del libro e fornendo alcuni suggerimenti su come dimostrare un nuovo risultato di NP-completezza. Infine, mostriamo come affrontare la difficoltà computazionale intrinseca di un problema facendo uso di algoritmi polinomiali di approssimazione: forniamo un tale algoritmo per il problema del ricoprimento tramite vertici e per quello del commesso viaggiatore ristretto a istanze metriche e mostriamo come, in generale, quest'ultimo problema non ammetta algoritmi di approssimazione ma possa essere risolto, in pratica, mediante euristiche basate sul paradigma della ricerca locale. DIFFICOLTÀ 1,5 CFU

9.1

Problemi NP-completi

Abbiamo più volte incontrato nei capitoli precedenti problemi per i quali non conosciamo algoritmi di risoluzione polinomiali, ma per i quali non siamo neanche in grado di dimostrare che tali algoritmi non esistano. In particolare, nel primo capitolo abbiamo introdotto il problema del gioco del Sudoku, per poi considerare nel capitolo successivo i problemi della partizione di numeri interi e della bisaccia e, nel quinto capitolo, i problemi della colorazione di grafi e della ricerca del massimo insieme indipendente. In tutti questi casi, abbiamo detto che i problemi erano NP-completi, intendendo con questo termine problemi per i quali, nonostante gli sforzi ripetuti di molti ricercatori, non

conosciamo algoritmi di risoluzione polinomiale e tali che se uno solo di essi è risolvibile in tempo polinomiale, allora ogni problema NP-completo è risolvibile in tempo polinomiale. Per questo motivo, è opinione diffusa che un problema NP-completo non può essere risolto in tempo polinomiale, anche se non conosciamo ancora una dimostrazione di tale affermazione. Il concetto di problema NP-completo consente di affrontare la risoluzione di un problema computazionale da due punti di vista complementari. Da un lato, il nostro obiettivo è chiaramente quello di progettare un algoritmo di risoluzione efficiente per il problema in esame: le strutture di dati e i paradigmi algoritmici discussi in questo libro sono gli strumenti più adatti per tentare di raggiungere quest'obiettivo. Dall'altro lato, avendo a disposizione la nozione di problema NP-completo, possiamo tentare di dimostrare che quello che stiamo analizzando è, probabilmente, intrinsecamente difficile e che la progettazione di un algoritmo polinomiale per esso sarebbe un risultato con conseguenze molto più significative (ovvero, migliaia di altri problemi risulterebbero improvvisamente risolvibili in tempo polinomiale). Sebbene mostrare l'intrinseca difficoltà di un problema computazionale possa sembrare meno interessante che progettare per esso un algoritmo polinomiale di risoluzione, un risultato di NP-completezza ha l'indubbio vantaggio di far rivolgere i nostri sforzi verso obiettivi meno ambiziosi: come vedremo al termine di questo capitolo, esistono diversi modi per affrontare la difficoltà di un problema NP-completo, tra cui uno dei più esplorati consiste nella progettazione di algoritmi di approssimazione, ovvero algoritmi efficienti le cui prestazioni, in termini di qualità della soluzione calcolata (non necessariamente ottima), siano in qualche modo garantite.

9.1.1

Classi P e NP

Nel primo capitolo abbiamo introdotto in modo informale le classi di complessità P e NP, evidenziando che un problema contenuto nella prima classe ammette un algoritmo di risoluzione efficiente, ovvero polinomiale, mentre un problema contenuto nella seconda classe ammette un algoritmo efficiente di verifica di una soluzione. 1 Per formalizzare queste definizioni, restringiamo la nostra attenzione (per ora) ai soli problemi di decisione, ossia ai problemi la cui soluzione è una risposta binaria — sì o no. Utilizzando i meccanismi di codifica binaria (Paragrafo 1.2.1) delle istanze di un problema decisionale, identifichiamo un problema decisionale con il corrispettivo insieme (potenzialmente infinito) di istanze la cui risposta è "sì": risolvere tale problema consiste quindi nel decidere l'appartenenza di una sequenza binaria all'insieme (osserviamo che non è restrittivo restringersi alle sole sequenze binarie, in quanto il calcolatore stesso ope1

L'acronimo NP sta per non-deterministico polinomiale, motivato storicamente dalla definizione della classe NP usando la macchina di Turing non-deterministica.

ra solo su tale tipo di sequenze). Pertanto, un problema di decisione TT non è altro che un sottoinsieme dell'insieme di tutte le possibili sequenze binarie, in particolare di quelle che soddisfano una determinata proprietà. Ad esempio, il problema di decidere se un dato numero intero è primo consiste di tutte le sequenze binarie che codificano numeri interi primi, per cui la sequenza 1101 (13 in decimale) appartiene a tale problema mentre la stringa 1100 (12 in decimale) non vi appartiene. Il problema di decidere se un grafo può essere colorato con tre colori consiste di tutte le sequenze binarie che codificano grafi che possono essere colorati con tre colori. Nel seguito, per motivi di chiarezza, continueremo a definire un problema decisionale facendo uso di descrizioni formulate in linguaggio naturale, anche se implicitamente intenderemo definirlo come uno specifico insieme di sequenze binarie, facendo riferimento a opportuni meccanismi di codifica (Paragrafo 1.2.1). Un problema decisionale TI appartiene alla classe P se esiste un algoritmo polinomiale A che, presa in ingresso una sequenza binaria x, determina se x appartiene a l l o meno. Ad esempio, sappiamo che il problema di decidere se, dato un grafo (orientato) G e due suoi nodi s e t , esiste un cammino semplice da s a t appartiene a P, in quanto abbiamo visto nel Paragrafo 7.4.1 come visitare tutti i nodi raggiungibili da s operando una visita in ampiezza del grafo: se t è incluso tra questi vertici, allora la risposta al problema è affermativa, altrimenti è negativa. Non sappiamo se il problema di decidere se un grafo può essere colorato con tre colori appartiene a P, ma possiamo mostrare che lo stesso problema ristretto a due colori vi appartiene: a tale scopo, mostriamo come utilizzare il fatto che se un vertice è colorato con il primo colore, allora tutti i suoi vicini devono essere colorati con il secondo colore. L'idea dell'algoritmo consiste nel colorare un vertice i con uno dei due colori e dedurre tutte le colorazioni degli altri vertici che ne conseguono: se riusciamo a colorare tutti i vertici, possiamo concludere che il grafo è colorabile con due colori. Altrimenti, se arriviamo a una contraddizione (ovvero, siamo costretti a colorare un vertice con due colori diversi), possiamo dedurre che il grafo non è colorabile con due colori (in realtà, questo algoritmo deve essere applicato a ogni componente connessa del grafo). Il Codice 9.1 realizza l'algoritmo appena descritto ipotizzando che il grafo in ingresso sia connesso: dopo aver cancellato i colori di tutti i vertici (righe 2 e 3), il codice prova ad assegnare al primo vertice il colore 1 e aggiorna il numero di vertici colorati, memorizzato nella variabile f a t t i (riga 4). Il ciclo w h i l e successivo determina le eventuali conseguenze della colorazione attuale: ogni vertice i viene esaminato all'interno del ciclo f o r alle righe 6 - 2 1 , per vedere se non è già stato colorato (riga 7). In tal caso, il codice controlla se i vicini di i hanno usato entrambi i colori (righe 8-13): se è così, allora abbiamo trovato una contraddizione (riga 15). Se invece i vicini di i hanno usato uno solo dei due colori, l'altro viene assegnato a i stesso (righe 17 e 18): ovviamente, vi

DueColorazione( A ): (pre: A matrice di adiacenza di un grafo connesso di n nodi) FOR (i = 0; i < N; i = i+1) colore [i] c o l o r e [0] WHILE

=

=

( f a t t i

FOR ( i IF

=

-1;

f a t t i <

0;

=

i

N; i

<

(colore[i] usato[0]

=

FOR ( j

0;

IF

=

1;

n)

<

=

0)

i+1)

u s a t o t i ] j

(A[j] [i]

<

{

{

i;

= j

FALSE; =

1 kk

==

u s a t o [ c o l o r e [ j ] ]

j

1) {

+

c o l o r e [ j ] =

>=

0)

{

TRUE;

}

> IF

(usato[0]

kk

u s a t o t i ] )

{

RETURN FALSE; > ELSE { IF

(usato[0])

{

c o l o r e [ i ]

=

1;

f a t t i

=

f a t t i

+

1;

}

IF

( u s a t o t i ] )

{

c o l o r e [ i ]

=

0;

f a t t i

=

f a t t i

+

1;

}

> >

> RETURN

Codice 9.1

TRUE;

Algoritmo per decidere se un grafo connesso è colorabile con due colori.

è anche la possibilità che nessun colore sia stato usato dai vicini di i, nel qual caso non possiamo ancora concludere nulla sulla sua colorazione. Al termine del ciclo w h i l e , se non troviamo una contraddizione, concludiamo che il grafo è colorabile con due colori (in quanto tutti i nodi sono stati colorati): altrimenti deduciamo che non lo è (riga 22). ALVIE:

colorazione di un grafo con due colori

Osserva, sperimenta e verifica TwoColoring

Poiché a ogni iterazione del ciclo w h i l e coloriamo un nuovo nodo oppure incontriamo una contraddizione, abbiamo che il numero di iterazioni eseguite dal ciclo è al più n . Ciascuna iterazione richiede 0 ( n 2 ) tempo, per cui la complessità temporale dell'algoritmo è 0 ( n 3 ) . Facendo riferimento alla rappresentazione mediante liste di adia-

cenza e utilizzando una variante della procedura di visita in ampiezza di un grafo vista nel Paragrafo 7.4.1, possiamo mostrare che tale algoritmo può essere implementato più , efficientemente in tempo 0 ( n + m), dove m indica il numero degli archi del grafo. Migliaia di problemi decisionali appartengono alla classe P e in questo libro ne abbiamo incontrati diversi. Da questo punto di vista, uno dei risultati recenti più interessanti è stato ottenuto dagli indiani Manindra Agrawal, Neeraj Kayal e Nitin Saxena e consiste nella dimostrazione che appartiene a P il problema di decidere se un dato numero intero ( è primo: tale problema aveva resistito all'attacco di centinaia di valenti ricercatori matematici e informatici, senza che nessuno fosse stato in grado di progettare un algoritmo di risoluzione polinomiale o di dimostrare che un tale algoritmo non poteva esistere. La classe P, tuttavia, non esaurisce l'intera gamma dei problemi decisionali: come abbiamo visto nel primo capitolo, esistono molti problemi per i quali non conosciamo un algoritmo di risoluzione polinomiale e ve ne sono diversi per i quali siamo sicuri che tale algoritmo non esiste (come il problema delle torri di Hanoi). Introduciamo ora la classe NP che include, oltre a tutti i problemi in P, molti altri problemi computazionali. Intuitivamente, un problema FI in NP non necessariamente ammette un algoritmo di risoluzione polinomiale, ma è tale che se una sequenza x appartiene a Fi allora deve esistere una dimostrazione breve di questo fatto, la quale può essere verificata in tempo polinomiale. Formalmente, la classe NP include tutti i problemi di decisione Fi per i quali esiste un algoritmo polinomiale V e un polinomio p che, per ogni sequenza binaria x, soddisfano le seguenti due condizioni: completezza: se x appartiene a Fi, allora esiste una sequenza y di lunghezza p(|x|) tale che V con x e y in ingresso termina restituendo il valore TRUE; consistenza: se x non appartiene a FI, allora, per ogni sequenza y, V con x e y in ingresso termina restituendo il valore FALSE. Osserviamo che P è contenuta in NP. Dato un problema FI in P, sia A un algoritmo di risoluzione polinomiale per IT. Possiamo allora definire V nel modo seguente: per ogni x e y, l'algoritmo V con x e y in ingresso restituisce il valore TRUE se A con x in ingresso risponde in modo affermativo, altrimenti restituisce il valore FALSE. Chiaramente, V (assieme a un qualunque polinomio e senza aver bisogno di usare y) soddisfa le condizioni di completezza e consistenza sopra descritte: quindi, Fi appartiene a NP. Non sappiamo, invece, se NP è contenuta in P. Data l'enorme quantità di problemi in NP per i quali non conosciamo un algoritmo polinomiale di risoluzione, la congettura più accreditata è che P sia diversa da NP. Non avendo una dimostrazione di quest'affermazione, possiamo solamente individuare all'interno della classe NP i problemi decisionali che maggiormente si prestano a fungere da problemi "separatori" delle due classi: tali problemi sono i problemi NP-completi, che costituiscono i problemi più difficili all'interno della classe NP.

9.1.2

Riducibilità polinomiale

Per poter definire il concetto di problema NP-completo, abbiamo bisogno della nozione di riducibilità polinomiale. Intuitivamente, un problema di decisione FI è riducibile polinomialmente a un altro problema di decisione FI' se l'esistenza di un algoritmo di risoluzione polinomiale per IV implica l'esistenza di un tale algoritmo anche per IT. Abbiamo già visto come il concetto di riducibilità può essere applicato per dimostrare che un dato problema computazionale è risolvibile in modo efficiente (pensiamo ad esempio al problema del minimo antenato comune analizzato nel Paragrafo 4.2.1). Forniamo ora un ulteriore esempio di tale applicazione considerando il problema della soddisfacibilità ristretto a clausole con due letterali. Sia X = { x o . x i , . . . , x n _ i ) un insieme di n variabili booleane. Una formula booleana in forma normale congiuntiva su X è un insieme C = {co, C i , . . . , c m _ i ) di m clausole, dove ciascuna clausola Ct, per 0 ^ i < m, è a sua volta un insieme di letterali, ovvero un insieme di variabili in X e/o di loro negazioni (indicate con x^). Un'assegnazione di valori per X è una funzione T : X —> {TRUE, FALSE} che assegna a ogni variabile un valore di verità. Un letterale L è soddisfatto da T se L = Xj e X(xj) = TRUE oppure se L = Xj" e x(XJ ) = FALSE, per qualche 0 ^ j < n . Una clausola è soddisfatta da T se almeno un suo letterale lo è e una formula è soddisfatta da T se tutte le sue clausole lo sono. Il problema della soddisfacibilità (indicato con SAT) consiste nel decidere se una formula booleana in forma normale congiuntiva è soddisfacibile. In particolare, il problema 2-SAT è la restrizione di SAT al caso in cui le clausole contengano esattamente due letterali. Per mostrare che 2-SAT è risolvibile in tempo polinomiale, definiamo una riduzione da 2-SAT al problema di decidere se, dato un grafo orientato G e due nodi s e t, esiste un cammino in G da s a t: poiché quest'ultimo problema ammette un algoritmo di risoluzione polinomiale, abbiamo che anche 2-SAT ammette un tale algoritmo. Data un formula booleana cp in forma normale congiuntiva formata da m clausole c o , C i , . . . , c m _ i su n variabili x o , x i , . . . , x n _ i , costruiamo un grafo orientato G = (V, E) contenente 2 n vertici (due per ogni variabile booleana) e 2 m archi (due per ogni clausola). 2 In particolare, per ogni variabile xt, G include due vertici v?°s e v" ee corrispondenti, rispettivamente, ai letterali x\ e x[. Inoltre, per ogni clausola {li, I2} della formula, G include un arco tra il vertice corrispondente a li e quello corrispondente a I2 e un arco tra il vertice corrispondente a I2 e quello corrispondente a lj (Figura 9.1): intuitivamente, questi due archi modellano il fatto che se uno dei due letterali della clausola non è soddisfatto, allora lo deve essere l'altro letterale. Notiamo che il grafo G soddisfa la seguente proprietà: se esiste un cammino tra il vertice corrispondente a un letterale l e il vertice corrispondente a un letterale l ' , allora 2 Senza perdita di generalità, possiamo supporre che nessuna clausola è formata da una variabile e dalla sua negazione: in effetti, una tale clausola è soddisfatta da qualunque assegnazione di valori e può, quindi, essere eliminata dalla formula.

Figura 9.1

Un esempio di riduzione da 2-SAT al problema della ricerca di cammini in un grafo: le clausole sono (x 0 ,x7}, {xo.xj), { x i , x 2 } e {xf,x5}.

esiste un cammino tra il vertice corrispondente a l ' e il vertice corrispondente a T. Ad esempio, nella Figura 9.1, esiste il cammino da VQCg a v ^ (che passa per v" eg ) ed esiste anche il cammino da v ^ 8 a VqOS (che passa per V|0S). Mostriamo ora che cp è soddisfacibile se e solo se, per ogni variabile Xi con 0 ^ i < n, abbiamo che v" eg non è raggiungibile da vP°s e v?°s non è raggiungibile da v" eg (ovvero, se non vi sono contraddizioni in cp). Sia T un'assegnazione di verità che soddisfa cp e supponiamo, per assurdo, che esista una variabile Xi tale che v" eg è raggiungibile da v?os e che v?os è raggiungibile da v" eg : supponiamo che t ( x ì . ) = T R U E (possiamo gestire il caso opposto in modo simile). Poiché esiste un cammino da v?os a v" eg e poiché T soddisfa x^ ma non soddisfa x^, deve esistere un arco (p, q) di tale cammino tale che il letterale l p a cui corrisponde p è soddisfatto mentre il letterale l q a cui corrisponde q non lo è: per definizione di G, cp include la clausola {l p , l q } la quale non è soddisfatta da t , contraddicendo l'ipotesi. Viceversa, supponiamo che, per ogni variabile x^ con 0 ^ i < n , v" eg non è raggiungibile da v?os oppure v?°s non è raggiungibile da v" eg , e mostriamo come costruire un'assegnazione di valori T che soddisfa cp ripetendo il seguente procedimento fino a quando a tutte le variabili abbiamo assegnato un valore (notiamo la similitudine tra questo procedimento e l'algoritmo per decidere se un grafo è colorabile con due colori). Sia l un letterale alla cui variabile corrispondente non abbiamo assegnato un valore e tale che il vertice p corrispondente a I non è raggiungibile dal vertice q corrispondente a l (per ipotesi, tale letterale deve esistere se vi sono ancora variabili a cui non abbiamo assegnato un valore). Estendiamo T in modo da soddisfare tutti i letterali a cui corrispondono vertici raggiungibili da q e in modo da non soddisfare tutti i letterali a cui corrispondono vertici raggiungibili da p. Tale estensione dell'assegnazione non crea contraddizioni, in quanto se i vertici corrispondenti a un letterale e alla sua negazione fossero entrambi raggiungibili da q, allora

(per simmetria) p sarebbe da essi raggiungibile e, quindi, esisterebbe un cammino da q a p. Inoltre, se un letterale l ' corrispondente a un vertice raggiungibile da q fosse già stato non soddisfatto in un passo precedente, allora p sarebbe raggiungibile dal vertice corrispondente alla negazione di L' e, quindi, T avrebbe già assegnato un valore alla variabile corrispondente a l. Poiché a ogni passo, estendiamo T soddisfacendo l e tutti i letterali che corrispondono a vertici raggiungibili da q, abbiamo che al termine del procedimento non può esistere un arco (r, s) tale che il letterale a cui corrisponde r è soddisfatto mentre quello a cui corrisponde s non lo è. Quindi, T soddisfa tutte le clausole. Nel caso del grafo mostrato nella Figura 9.1, possiamo scegliere l = Vq0S, in quanto 66 VQ non è raggiungibile da VgOS. Quindi, estendiamo l'assegnazione T (attualmente vuota) in modo da soddisfare tutti i letterali a cui corrispondono vertici raggiungibili da VqOS, che sono v" eg e v^ 05 . Pertanto, T(X 0 ) = T R U E , T(XI ) = T R U E e T(X 2 ) = FALSE. Poiché a ogni variabile abbiamo assegnato un valore, il procedimento ha termine: in effetti, T soddisfa le quattro clausole {xo, xì~}, {xo, X2}, {xj, x 2 } e {xì~, X2}.

9.1.3

Problemi NP-completi

In questo capitolo, siamo principalmente interessati a utilizzare il concetto di riducibilità per ottenere risultati negativi piuttosto che positivi, ovvero per dimostrare che un problema non è risolvibile facendo uso di risorse temporali limitate. Un semplice esempio di tale applicazione consiste nel dimostrare che il problema geometrico del minimo insieme convesso ha, in generale, una complessità temporale n ( n l o g n ) . Tale problema consiste nel trovare, dato un insieme di punti sul piano, il più piccolo (rispetto all'inclusione insiemistica) insieme convesso S che li contiene tutti: 3 nella Figura 9.2 mostriamo due esempi di insiemi convessi di cardinalità minima. Un punto p e S è un estremo di S, se esiste un semipiano passante per p tale che p è l'unico punto che giace sulla retta che delimita il semipiano: il problema del minimo insieme convesso consiste nel calcolare gli estremi di S come una lista (ciclicamente) ordinata di punti (ad esempio, la soluzione nella parte sinistra della Figura 9.2 è data da Po. Pi - P2 e P3). Notiamo che se i punti dell'istanza del problema giacciono su una parabola (come nella parte destra della Figura 9.2), allora la soluzione al problema del minimo insieme convesso consiste nella lista dei punti ordinata in base alle loro ascisse. Questa osservazione ci permette di ridurre il problema dell'ordinamento di n numeri interi Qo, d i , . . . , a n _ i a quello del calcolo del minimo insieme convesso nel modo seguente: per ogni i con 0 ^ i ^ n — 1, definiamo un punto di coordinate (ai, a?). Chiaramente, gli n punti cosi costruiti giacciono sulla parabola di equazione y = x 2 e quindi il loro 'Ricordiamo che un un insieme S è detto convesso se, per ogni coppia di punti in S, il segmento che li unisce è interamente contenuto in S.

Figura 9.2

Due esempi di minimo insieme convesso.

minimo insieme convesso consiste nel loro elenco ordinato in base alle loro ascisse: tale elenco è dunque l'ordinamento degli n numeri interi. Poiché la costruzione suddetta può essere eseguita in tempo O(n), se il problema del minimo insieme convesso è risolvibile in tempo o ( n l o g n ) , allora anche il problema dell'ordinamento di n numeri interi è risolvibile in tempo o ( n l o g n ) : nel Paragrafo 2.5.3 abbiamo visto che ciò non è in generale possibile, per cui abbiamo appena dimostrato un limite inferiore alla complessità temporale del problema del minimo insieme convesso. In generale, se un problema TI è riducibile polinomialmente a un problema f i ' e se sappiamo che IT non ammette un algoritmo di risoluzione polinomiale, allora possiamo concludere che neanche Fi' ammette un tale algoritmo. In altre parole, IT' è almeno tanto difficile quanto IT (notiamo che l'uso "positivo" del concetto di riducibilità consiste nell'affermare che FI è almeno tanto facile quanto IT'): quindi, le cattive notizie, ovvero, la non trattabilità di un problema, si propagano da sinistra verso destra (mentre le buone notizie lo fanno da destra verso sinistra). Per definire la nozione di NP-completezza, introduciamo una restrizione del concetto di riducibilità in cui ogni istanza del problema di partenza viene trasformata in un'istanza del problema di arrivo, in modo che le due istanze siano entrambe positive oppure entrambe negative. Formalmente, un problema IT è polinomialmente trasformabile in un problema Fi' se esiste un algoritmo polinomiale T tale che, per ogni sequenza binaria x, vale x e Fi se e solo se T con x in ingresso restituisce una sequenza binaria in Fi'. Chiaramente, se IT è polinomialmente trasformabile in TI', allora Fi è polinomialmente

riducibile a II': infatti, se esiste un algoritmo polinomiale A di risoluzione per Fi', allora la composizione di T con A fornisce un algoritmo polinomiale di risoluzione per TI. Un problema di decisione FI è NP-completo se FI appartiene a NP e se ogni altro problema in NP è polinomialmente trasformabile in FI. Quindi, FI è almeno tanto difficile quanto ogni altro problema in NP: in altre parole, se dimostriamo che Fi è in P, allora abbiamo che l'intera classe NP è contenuta in P (e quindi le due classi coincidono). E naturale a questo punto chiedersi se esistono problemi NP-completi (anche se il lettore avrà già intuito la risposta a tale domanda). Inoltre, se P / NP, possono esistere problemi che né appartengono a P e né sono NP-completi (un possibile candidato di questo tipo è il problema aperto dell'isomorfismo tra grafi, discusso nel Capitolo 6, del quale non conosciamo un algoritmo polinomiale di risoluzione e nemmeno una dimostrazione di NP-completezza). Prima di discutere l'esistenza di un problema NP-completo, però, osserviamo che una volta dimostratane l'esistenza, possiamo sfruttare la proprietà di transitività della trasformabilità polinomiale per estendere l'insieme dei problemi siffatti. La definizione di trasformabilità soddisfa infatti la seguente proprietà: se FIQ è polinomialmente trasformabile in F[[ e Fii è polinomialmente trasformabile in IT2, allora Fio è polinomialmente trasformabile in FI2. A questo punto, volendo dimostrare che un certo problema computazionale FI è NP-completo, possiamo procedere in tre passi: prima dimostriamo che FI appartiene a NP mostrando l'esistenza del suo certificato polinomiale; poi individuiamo un altro problema Fi', che già sappiamo essere NP-completo; infine, trasformiamo polinomialmente Fi' in FI.

9.1.4

Teorema di Cook-Levin

Per applicare la strategia sopra esposta dobbiamo necessariamente trovare un primo problema NP-completo. Il teorema di Cook-Levin afferma che SAT è NP-completo. Osserviamo che SAT appartiene a NP, in quanto ogni formula soddisfacibile ammette una dimostrazione breve e facile da verificare che consiste nella specifica di un'assegnazione di valori che soddisfa la formula. La parte difficile del teorema di Cook-Levin consiste, quindi, nel mostrare che ogni problema in NP è polinomialmente trasformabile in SAT. Non diamo la dimostrazione del suddetto teorema, ma ci limitiamo a fornire una breve descrizione dell'approccio utilizzato. Dato un problema Fi e NP, sappiamo che esiste un algoritmo polinomiale V e un polinomio p tali che, per ogni sequenza binaria x, se x Fi, allora esiste una sequenza y di lunghezza p(|x|) tale che V con x e y in ingresso termina restituendo il valore TRUE, mentre se x £ FI, allora, per ogni sequenza y, V con x e y in ingresso termina restituendo il valore FALSE. L'idea della dimostrazione consiste nel costruire, per ogni x, in tempo polinomiale una formula booleana cpx le cui uniche variabili libere sono p(|x|) variabili yo,yi>• • • >y p (|x|)-i> intendendo con ciò che la soddisfacibilità della formula dipende solo dai valori assegnati a tali variabili:

intuitivamente, la variabile y i corrisponde al valore del bit in posizione i all'interno della sequenza y. La formula li>yo}>{lo>t-i>yo} (chiaramente D c è soddisfacibile se e solo se lo oppure li è soddisfatto); 3. k = 3: in questo caso, D c è formato dalla sola clausola c; 4. k > 3: in questo caso, che è il più difficile, l'insieme D c contiene un insieme di k — 2 clausole collegate tra di loro attraverso nuove variabili booleane e tali che la loro soddisfacibilità sia equivalente a quella di c. Formalmente, D c è l'insieme {{io.ii,yo}.{yo'l2-yi}.{yi.l3.y2}.---'{yk-5.lic-3.yk-4}'{yk-4'lk-2.W-i}

Dalla definizione di D c abbiamo che Z = XU

U c6CA|c| = l

{y 0 c ,yi}u

U c€CA|c|=2

{yS)u

U

{y§,...,yfcl_4}

ceCA|c|>3

e che la costruzione dell'istanza di 3-SAT può essere eseguita in tempo polinomiale. Supponiamo che esista un'assegnazione T di verità alle variabili di X che soddisfa C. Quindi, T soddisfa c per ogni clausola c e C: mostriamo che tale assegnazione può essere estesa alle nuove variabili di tipo y c introdotte nel definire D c , in modo che tutte le clausole in esso contenute siano soddisfatte (da quanto detto sopra, possiamo supporre che |c| > 3). Poiché c è soddisfatta da T, deve esistere H tale che T soddisfa IH con 0 ^ h ^ |c| — 1: estendiamo T assegnando il valore TRUE a tutte le variabili y? con 0 ^ i ^ h — 2 e il valore FALSE alle rimanenti variabili. In questo modo, siamo sicuri che la clausola di D c contenente Ih è soddisfatta (da Ih stesso), le clausole che la precedono sono soddisfatte grazie al loro terzo letterale e quelle che la seguono lo sono grazie al loro primo letterale. Viceversa, supponiamo che esista un'assegnazione T di verità alle variabili di Z che soddisfi tutte le clausole in D e, per assurdo, che tale assegnazione ristretta alle sole variabili di X non soddisfi almeno una clausola c e C, ovvero che tutti i letterali contenuti in c non siano soddisfatti (di nuovo, ipotizziamo che |c| > 3). Ciò implica che tutte le variabili di tipo y c devono essere vere, perché altrimenti una delle prime |c| — 3 clausole in D c non è soddisfatta, contraddicendo l'ipotesi che T soddisfa tutte le clausole in D. Quindi, x(yf c |_ 4 ) = TRUE, ovvero T(yf c |_ 4 ) = FALSE: poiché abbiamo supposto che anche l| c |-2 e t|c|-i n o n sono soddisfatti, l'ultima clausola in D c non è soddisfatta, contraddicendo nuovamente l'ipotesi che T soddisfa tutte le clausole in D. In conclusione, abbiamo dimostrato che C è soddisfacibile se e solo se D lo è e, quindi, che il problema SAT è trasformabile in tempo polinomiale nel problema 3-SAT: quindi, quest'ultimo è NP-completo. Notiamo che, in modo simile a quanto fatto per 3-SAT, possiamo mostrare la NP-completezza del problema della soddisfacibilità nel caso in cui le clausole contengono esattamente k letterali, per ogni k ^ 3: tale affermazione non si estende però al caso in cui k = 2, in quanto, come abbiamo visto nel Paragrafo 9.1.2, in questo caso il problema diviene risolvibile in tempo polinomiale e, quindi, difficilmente esso è anche NP-completo. La NP-completezza di 3-SAT ha un duplice valore: da un lato esso mostra che la difficoltà computazionale del problema della soddisfacibilità non dipende dalla lunghezza delle clausole (fintanto che queste contengono almeno tre letterali), dall'altro ci consente nel seguito di usare 3-SAT come problema di partenza, il quale, avendo istanze più regolari, è più facile da utilizzare per sviluppare trasformazioni volte a dimostrare risultati di N P-completezza.

Figura 9.3

9.2.2

Un esempio di minimo ricoprimento tramite vertici.

Tecnica di progettazione di componenti

Per dimostrare la NP-completezza del problema del massimo insieme indipendente in un grafo (o meglio della sua versione decisionale), mostriamo prima che il seguente problema, detto minimo ricoprimento tramite vertici è NP-completo: dato un grafo G = (V, E) e un intero k > 0, esiste un sottoinsieme V' di V con |V'| < k, tale che ogni arco del grafo è coperto da V' ovvero, per ogni arco (u, v) € E, u € V' oppure v S V'? Nella Figura 9.3 mostriamo un esempio di ricoprimento tramite 3 vertici del grafo delle conoscenze discusso nel Paragrafo 6.1.1. Notiamo che, in questo caso, un qualunque sottoinsieme di vertici di cardinalità minore di 3, non può essere un ricoprimento: in effetti, due vertici sono necessari per coprire il triangolo formato da V], V2 e V4 e un ulteriore vertice è necessario per coprire l'arco tra V3 e V5. Il problema del minimo ricoprimento tramite vertici ammette dimostrazioni brevi e verificabili in tempo polinomiale: tali dimostrazioni sono i sottoinsiemi dell'insieme dei vertici del grafo che costituiscono un ricoprimento degli archi di cardinalità al più k. Mostriamo ora che 3-SAT è trasformabile in tempo polinomiale nel problema del minimo ricoprimento tramite vertici: a tale scopo, faremo uso di una tecnica più sofisticata di quella vista nel paragrafo precedente, che viene generalmente indicata con il nome di progettazione di componenti. In particolare, la trasformazione opera definendo, per ogni variabile, una componente (gadget) del grafo il cui scopo è quello di modellare l'assegnazione di verità alla variabile e, per ogni clausola, una componente il cui scopo è quello di modellare la soddisfacibilità della clausola. I due insiemi di componenti sono poi collegati tra di loro per garantire che l'assegnazione alle variabili soddisfi tutte le clausole.

Più precisamente, sia C = { c o , . . . , c m _ i } un insieme di m clausole costruite a partire dall'insieme X di variabili booleane { x o , . . . , x n _ i } , tali che |c-L| = 3 per 0 ^ i < invogliamo definire un grafo G e un intero k tale che C è soddisfacibile se e solo se G include un ricoprimento di esattamente k vertici. Per ogni variabile con 0 ^ i < n , G include due vertici v? ero e v[also collegati tra di loro mediante un arco. Queste sono le componenti di verità del grafo, in quanto ogni ricoprimento di G deve necessariamente includere almeno un vertice tra vVero e v-also per 0 ^ i < n : il valore di k sarà scelto in modo tale che ne includa esattamente uno, ovvero quello corrispondente al valore di verità della variabile corrispondente. Per ogni clausola Cj con 0 ^ j < m, G include una cricca di tre vertici v?, v- e v 2 . Queste sono le componenti corrispondenti alla soddisfacibilità delle clausole, in quanto ogni ricoprimento di G deve necessariamente includere almeno due vertici tra v9, v' e v? per 0 ^ j < m: il valore di k sarà scelto in modo tale che ne includa esattamente due, in modo che quello non selezionato corrisponda a un letterale certamente soddisfatto all'interno della clausola corrispondente. Le componenti di verità e quelle di soddisfacibilità sono collegate tra di loro aggiungendo un arco tra i vertici contenuti nelle prime componenti con i corrispondenti vertici contenuti nelle seconde componenti. Più precisamente, per ogni t, j e k con 0 < i < n , 0 1. Utilizzando la proprietà induttiva su i = h. = O(logp a), ovvero che ( y ' ) h f ( r i ) = a H f ( n / | 3 h ) , possiamo derivare che T(n) = 0 ( a h f ( n / ( 3 h ) ) = 0 ( a H f ( l ) ) = O ( c x l o ^ n ) = o ( 2 l o 6 a l o s n / l o & e ) = O(n l o B0 a ), concludendo la dimostrazione del teorema.

Indice analitico 1-bilanciato, 165-168 2-3-albero, 177 2-opt, 3 4 5 - 3 4 8 3-opt, 3 4 6 - 3 4 9 abbinamento, 9 0 - 9 4 , 206 accesso diretto, 19, 23, 24, 26, 80, 83, 91,92 accesso sequenziale, 23, 25, 80, 83 accoppiamento perfetto, 206 aciclico, 264, 270, 271, 273, 280 adiacenti, 201 aggregazione, 108, 109, 161, 227, 2 2 9 232,234 al-Khwarizmi, Muhammad, 1 alberi, 2 alberi cardinali, 143, 144, 191 alberi di Fibonacci, 165, 166, 197 alberi ordinali, 144-148, 264 albero AVL, 165, 167, 169, 174, 187 albero BFS, 2 6 4 - 2 6 6 , 270, 280 albero binario, 113-124, 127, 133-138, 142, 143, 145, 148, 149, 162, 165, 283 albero binario di ricerca, 162, 165 albero dei cammini minimi, 316 albero dei suffissi, 195, 196 albero DFS, 267-271, 275 albero di ricoprimento, 264, 266, 307, 308,310-312,314 albero euclideo, 307

algoritmo algoritmo algoritmo algoritmo algoritmo algoritmo algoritmo

di r-approssimazione, 337 di Bellman-Ford, 295, 302 di Dijkstra, 295, 297 di Floyd-Warshall, 305 di Strassen, 60, 68, 215 esponenziale, 11 goloso, 199, 224, 225, 250, 251,338 algoritmo polinomiale, 11, 16—18, 66, 218, 2 2 2 , 3 1 8 , 3 1 9 , 3 2 1 , 3 2 5 , 326,335-337, 340-342,344, 348, 349 all pair shortest path, 296 all'indietro, 265, 269 altezza, 95, 117 ALVI E, 3, 4, 7, 12, 13, 17, 28, 31, 32, 36, 39, 46, 49, 5 1 , 6 2 , 6 9 , 7 4 , 76, 79, 94, 98, 102, 107, 110, 118, 120, 122, 123, 131, 135, 141, 148, 158, 161, 165, 170, 1 7 7 , 1 7 9 , 1 8 2 , 188, 189, 194, 2 2 1 , 2 2 4 , 2 2 8 , 2 3 3 , 237, 243, 248, 2 5 5 - 2 5 9 , 267, 270, 273, 279, 288, 2 9 1 , 2 9 3 , 2 9 9 , 303, 3 0 6 , 3 1 2 , 3 1 5 , 320, 338, 344, 347 analisi ammortizzata, 99, 100, 102, 107, 108, 110-112 antenato, 114 approssimazione, 59, 225, 229, 317, 318, 3 3 7 , 3 3 8 , 340-344, 3 4 7 - 3 4 9

approssimazione di Stirling, 290 archi, 199 arco, 113, ITI arco back, 269 arco cross, 269 arco forward, 269 array associativo, 156 array bidimensionale, 56, 66, 68, 79, 91, 130, 140, 149, 208 ASCII, 10 Ask, 241 assegnazione delle lunghezze d'onda, 216, 217 assegnazione di valori, 307, 322, 323, 326,328 A T & T Bell Labs, 2 A T & T call graph, 226 authority, 246 auto-organizzazione, 99, 102, 103, 108, 111 autorità, 246 awersariale, 94 B-albero, 171-177, 180, 197 B-tree, 171 backtrack, 15, 22 backup, 75 basi di dati, 170 BFS, 262 bilanciato, 121 bit o binary digit, 2, 10 bisaccia, 77, 79, 80, 82, 317, 336 bitmap, 178 blocchi, 171 Breadth-First Search (BFS), 262 bucket, 219 cammino (orientato o meno), 201, 205, 213 cammino hamiltoniano, 206, 207, 342

cammino minimo (pesato o meno), 202203,205,295,297-301,303306, 342 caso medio, 19 caso pessimo, 19 casuale, 52-53 Central Processing Unit, 28 certificati polinomiali, 17 chaining, 157 chiave, 37 chiave di ricerca, 151 chiusura transitiva, 213, 214 ciclico, 270 ciclo, 201, 302 ciclo euleriano, 127, 128, 149, 208, 274 ciclo hamiltoniano, 207, 208, 340, 341 ciclo orientato, 205 cima della pila, 254, 258, 259 classe NP, 17, 208, 321 classe P, 319 clausole, 322-324, 329-334, 336, 348 dient, 156 dipping, 57 clique, 204 cluster, 161, 227, 307, 308 cluster analysis, 307 coda con priorità, 281 coefficiente di aggregazione, 227 collisione, 156, 157, 161 complessità computazionale, 36 complessità in spazio, 19 complessità in tempo, 19 completamente bilanciato, 121 completo, 121 completo a sinistra, 133-135, 283-285, 292 componente connessa, 203, 204, 214, 230, 253, 273-274, 302, 307, 310,319

componente fortemente connessa, 205, 274,275 computer graphics, 56 concentratore, 246, 248 congettura di Goldbach, 5 connesso, 203 contrazione, 211 convesso, 324, 325 Cook, Stephen, 18 corrispondenza biunivoca, 13, 136, 145, 146, 148, 150, 191, 218, 271 costo ammortizzato, 28, 100, 102, 108— 110, 197, 288, 301 costo medio, 52, 98, 99, 160 costo minimo, 63, 64, 68-70, 82, 295, 344 costo ottimo, 69 costo uniforme, 19 crawler, 241, 261, 262 credito, 109 cricca, 204, 216, 218, 221, 227, 230, 237,332 Crick, Francis, 2 cut, 308 cut-off, 235 Data Base Management System, 170 data mining, 306 database, 170 dati satellite, 151, 162 decomponibili, 119, 121 denso, 200 Depth-First Search (DFS), 267 di arrivo, 205 di destinazione, 205 di partenza, 205 diametro, 226, 227, 230, 231, 265 digital fingerprint, 156 dimensione, 10, 116, 151, 200 dinamico, 27, 152

Directed Acyclic Graph (DAG), 271, 272, 275,278, 280 diretto, 205 discendente, 114 disco, 171 distance vector, 295 distanza (pesata o meno), 202-203, 296 distribuzione del grado, 226, 234 distribuzione di Bernoulli, 228, 229 distribuzione di Poisson, 229 disuguaglianza triangolare, 341-344 divide et impera, 23, 40, 41, 43-45, 47, 49, 60, 64, 65, 69, 70, 80, 82, 119 divisione, 175 dizionario, 151 embedding planare, 2 1 1 , 2 1 2 entrante, 205 entropia, 2 equazioni di ricorrenza, 21, 23, 42, 54, 80, 82 Erickson, Jeff, 353 esponenziale, 7 espressioni regolari, 189 estremo, 201 etichettato, 201 euristiche, 317, 345, 348 ExpertRank, 241 Extensible Markup Language (XML), 131 fabbriche di link, 245 fattore di caricamento, 158 Fibonacci, 21, 165, 166, 301, 315 figlio (sinistro, destro), 113-114, 115, 124, 133, 134, 136, 138, 144, 162-164, 285 file system, 262 finale, 205 finger search, 182

First Come First Served (FCFS), 29 First In First Out (FIFO), 259 foglie, 114 foresta di ricoprimento, 307 forma normale congiuntiva, 322, 327, 348 forma normale disgiuntiva, 328 formula booleana, 322, 326, 327, 348 formula di Stirling, 229 frame buffer, 56, 57 fratello, 114 fuori linea, 104, 107, 111 fusione, 46-49, 51, 53, 55, 69, 177, 180, 181,257, 293, 295 Godei, Kurt, 4 gadget, 331, 336 gap di complessità, 60, 340 girovita (girth), 316 Google, 241, 245 GoogleMaps, 295 grado, 144, 201, 205, 227, 229 grado dell'albero, 144 grado in ingresso, 205 grado in uscita, 205 grafo a intervalli, 216—219, 221-223, 251 grafo bipartito, 206, 2 1 1 , 2 1 2 grafo completo, 204, 211, 307, 340, 342, 349 grafo etichettato, 201 grafo fortemente connesso, 205 grafo planare, 211, 216 grafo regolare, 230 grafo orientato, 205, 225, 232, 240, 241, 2 5 1 , 2 6 1 , 2 6 8 , 270, 271,274, 275, 277, 280, 294, 296, 322 grafo pesato, 201, 208, 209, 295, 296 greedy o goloso, 224

Harel, David, 3 hash, 151, 154-161, 171, 178, 179, 183, 187, 189, 190, 197, 266 hash doppio, 161 heap, 283, 284 heap di Fibonacci, 301, 315 Heapsort, 292 heaptree, 283-285 host, 262 hub, 246 Hypertext Induced Topic Selection (HITS), 241, 246-249 HyperText Markup Language (HTML), 262 in avanti, 269 in linea, 103, 235, 239, 241, 244, 268, 337,347 in loco, 36, 47, 51, 289, 291, 292 incidente, 201 indecidibile, 6 independent set, 221 indice, 170 information retrieval, 177, 183 iniziale, 205 inorder, 120 insertion sort, 31 insieme indipendente, 199, 221-225, 230, 250, 251,317, 331,334, 335 Internet Protocol (IP), 262, 293 intervallo, 217 intrinsecamente difficile, 318 intrusion detection, 71 invariante di scala, 235, 239 inversione, 105-107 isolato, 201 isomorfi, 212 iterativa, 155 k-ari, 143

kernel, 161, 162 knapsack, 77 Landau, Gad, 19 Last In First Out (LIFO), 253 lati, 200 Least Recently Used (LRU), 103 legge di potenza, 235 letterali, 322 Levin, Leonid, 18 limite inferiore, 36, 40, 46, 47, 60, 146, 172, 189, 197, 325, 342 limite superiore, 36, 40, 60, 98, 100102,235, 347 lineare, 161 link, 225 link analysis, 241 lista, 2, 3, 25 lista circolare, 88 lista circolare doppia, 89 lista di adiacenza, 209, 210, 212, 263, 264,267, 272, 279 lista doppia, 86, 87, 111, 123, 152154, 178,311 liste a salti, 83, 94 liste invertite, 151, 177-181, 183, 196, 197,239 livelli di memoria, 21, 171 longest common subsequence (LCS), 72 Lucas, Edouard, 6 lunghezza, 201 macchina di Turing, 5, 318 macro-vertici, 274, 275, 278, 280 MapQuest, 295 massimale, 203 master theorem, 42 match, 90 matrice, 56

matrice di adiacenza, 208, 210, 2 1 1 213, 241,242, 247, 327 memoization, 77 memoria principale, 49, 53, 161, 162, 171, 1 7 3 , 1 7 5 , 1 8 0 , 1 9 7 memoria secondaria, 49, 53, 161, 162, 171, 172, 175-177, 180 memoria virtuale, 161, 162 memorizzazione binarizzata, 144, 145, 280 mergesort, 47, 180, 189 Message-Digest Algorithm (MD), 155 microarray, 307 minimo albero di ricoprimento (MST), 307 minimo antenato comune (LCA), 113, 125-127, 129, 149, 150, 196, 322,342 minimo insieme convesso, 324 minimo insieme di campionamento, 334, 335 minimo ricoprimento tramite vertici, 331 modello matematico, 226 moltiplicazione veloce, 43, 60, 62 motori di ricerca, 178, 180, 239, 241, 250 Move-To-Front (MTF), 103 MSNMaps, 295 multigrafo, 207 navigatore casuale, 274 nodo, 113, 199 nodo critico, 167-169, 197 nodo interno, 114, 121, 143, 191,291, 304 non orientato, 204 non determinismo, 318 NP-completo, 18, 80, 216, 221, 317, 318,322,326, 3 2 8 - 3 3 1 , 3 3 4 337, 340-342, 348, 349

numeri di Fibonacci, 62, 166 numero cromatico, 216, 218, 219 numero di Catalan, 63, 142, 146 numero di Fibonacci, 21 numero di Ramsey, 230 numero di trasferimenti, 171, 197 Odifreddi, Piergiorgio, 19 offline, 104 online, 103 open addressing, 158 Open Directory Project (ODP), 262 Open Shortest Path First (OSPF), 295 opus libri (opere algoritmiche), 2, 28, 55,89, 99, 1 1 3 , 1 2 5 , 1 5 4 , 1 6 1 , 170, 1 7 7 , 2 1 5 , 2 3 9 , 256, 261, 2 9 3 , 3 0 6 , 338 ordinamento topologico, 253, 270-272,

280 ordinata, 144 ordinato, 152 ordine, 200 orientato, 205 ottimo, 36 output sensitive, 182 pacchetti, 2 9 3 - 2 9 5 packet switching, 293 padre, 114 page fault, 162 PageRank, 2 4 1 - 2 4 6 , 2 4 8 , 2 4 9 p a r a d i g m a della ricerca locale,

344

parentesi bilanciate, 143, 145-148, 150 partizione, 75 passo elementare, 18 pattern matching, 196 peer-to-peer, 1 5 4 , 1 5 6 peggiore, 19 perfect match, 90, 206 perfetto, 90, 157

permutazione, 105, 159, 160, 206, 207 pesato, 201 peso, 281 peso di un cammino, 203 piccolo mondo, 230—234 pila, 253 pivot, 50-53, 94 pixel, 56, 57 polinomiale, 8 polinomialmente trasformabile, 325, 326, 334 Portable Document Format (PDF), 257 posizionale, 143 posting, 178 postorder, 120 Postscript, 256, 257 potenziale, 47, 110, 195, 245 power law, 235 predecessore, 95 prefisso, 183 preorder, 120 primaria, 170 principio di località temporale, 103 probing, 159 problem solving, 2 problema aperto, 326 problema dei matrimoni stabili, 83, 89, 90, 92, 93, 111 problema del commesso viaggiatore, 338, 3 4 0 - 3 4 2 , 3 4 4 - 3 4 6 , 349 problema della fermata, 4—6, 10 problema della soddisfacibilità, 322 problema di decisione, 319, 322, 326, 3 2 8 , 3 4 0 , 348 problema di ottimizzazione, 77, 328, 337, 3 4 0 , 3 4 4 , 348 problemi computazionali, 1 - 3 , 6, 11, 21, 32, 205, 317, 321, 329, 335, 337, 348

problemi di ottimizzazione, 69 problemi intrattabili, 11 problemi NP-completi, 18 problemi trattabili, 11, 14 prodotto, 57 prodotto scalare, 57 profondità, 117 progettazione di componenti, 331 programmazione dinamica, 23, 62, 6 9 7 2 , 7 5 - 8 0 , 1 3 0 , 2 2 5 , 3 0 4 , 305 proprietà fondamentale delle occorrenze, 195

quadratica, 161

reti biologiche, 226 reti complesse, 225, 227, 230, 231, 2 3 4 236,250 reti di informazioni, 226 reti sociali, 225, 226, 231 reti tecnologiche, 226 ricerca, 37 ricerca binaria, 37, 3 9 ^ 3 , 96, 157, 163, 172, 174-176, 187, 328, 348 ricerca locale, 317, 344-346, 348 ricerca sequenziale, 37 ricoprimento, 334 rotazioni, 168-170, 174, 187, 197 round robin, 89, 111 router, 190, 293-295 Routing Information Protocol, 295

query, 125, 127, 129, 131, 148, 149, 239 quicksort, 49, 94, 99 raddoppio, 28, 130 radice, 113 radiosità, 59 radixsort, 189 raggruppamenti di nodi, 227 random, 53, 83, 94, 97-99, 102, 111, 153,228,233,237 Random Access Machine (RAM), 19 rango, 42, 90, 239, 270 rank, 270 rapporto aureo, 22 rappresentazione implicita, 133, 135, 136, 218,283, 284 rappresentazione succinta, 133, 135, 136, 138,142,146-148 rastering, 57 record, 170, 172-175 recupero dei documenti, 177 red-black tree, 177 rendering, 57-59 restrizione, 334

scheduling, 28, 29, 215, 222 search spam, 240, 248 secondarie, 170 Secure Hash Algorithm (SHA), 155 secure sockets layer, 18 segmento, 33 segmento di somma massima, 32—36, 81 selection sort, 30 selezione, 54 self-adjusting, 103 self-organizing, 103 semplice, 202 sequenza, 3 sequenza di scansione, 159 sequenza lineare, 23-25, 27, 29, 37, 55, 83,84,217 sequenza random, 97 server, 156 Shannon, Claude, 2, 10 Shortest Job First (SJF), 29 shortest path, 295-297 signature file, 178

pseudo-polinomiale, 80

similitudine, 334 single source shortest path (SSSP), 297 sistema lineare ottico, 217 sistemi di recupero dei documenti, 177 sistemi distribuiti, 156 sistemi distribuiti di condivisione dei file, 156 Six Degrees of Kevin Bacon, 225 skip list, 94, 177 small world, 231 snippet, 240 software, 3, 4 soglia, 230 somma telescopica, 106, 110 sostituzione locale, 329 sotto-problema, 41, 43, 63, 69-71, 7 5 79,225 sotto-sequenza, 33, 50, 72-74, 80, 328 sotto-sequenza comune, 72-74, 80, 328 sotto-sequenza comune più lunga (LCS), 72 sottoalbero, 114 sottografo, 203 sottografo indotto, 203, 204, 221, 227, 230, 2 4 1 , 3 3 8 spanning tree, 264, 307 sparso, 200 spazio, 19 spider, 241 split, 175 stabile, 81, 90 statico, 152 Structured Query Language (SQL), 170 Sudoku, 14-18, 317 suffix tree, 195 surfer, 242 sweeping line, 219 tabella di routing, 294, 295 tabelle hash, 156

taglio, 308 task, 28 tempo, 19 teorema di Cook-Levin, 326-328 teorema di Kuratowski-Pontryagin-Wagner, 211 teorema di Perron-Frobenius, 249, 250 teorema fondamentale delle ricorrenze, 42-44, 46, 48, 5 4 , 5 5 , 6 1 , 8 2 teoria di Ramsey, 230 terminali, 201 terminazione, 4 termine, 178 testo, 178 topic drift, 249 Torri di Hanoi, 6—10, 16 tour, 340-347 trasformata veloce di Fourier, 46 trasversale, 269 trie, 184 trie compatto, 190 trovare, 17 Turing, Alan, 4 unario, 190, 194 Unicode, 10 Uniform Resource Locator (URL), 261— 262 union-find, 100 universo, 151, 152, 154, 155 Unix, 2 uscente, 205 verificare, 17 vertici, 199 vettore di rango, 242, 249 visita, 119, 262 visita anticipata, 120 visita in ampiezza, 133, 262 visita in profondità, 267

visita per livelli, 133 visita posticipata, 120 visita simmetrica, 120 von Neumann, John, 19 Watson, James, 2 World Wide Web (WWW), 225 YahooMaps, 295 zaino, 77 zone, 52

View more...

Comments

Copyright ©2017 KUPDF Inc.
SUPPORT KUPDF